From 8e30fd7e0ddd58bf08e010cc283117aec25b22cc Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 1 Sep 2026 11:13:57 +0800 Subject: [PATCH 01/52] stock ledger fix --- .../20260831_stock_ledger_fix_timeline.xlsx | Bin 0 -> 12849 bytes scripts/export_stock_ledger_fix_timeline.py | 418 +++ .../fpsms/modules/common/SettingNames.java | 3 + .../scheduler/service/SchedulerService.kt | 83 +- .../scheduler/web/SchedulerController.kt | 11 + .../master/service/StockLedgerFixService.kt | 3078 +++++++++++++++++ .../modules/master/web/ItemsController.kt | 14 +- .../master/web/StockLedgerFixController.kt | 126 + .../master/web/models/StockLedgerFixModels.kt | 138 + .../modules/report/service/ReportService.kt | 44 +- .../service/StockLotBalanceReportService.kt | 372 ++ .../service/StockLotLedgerReportService.kt | 492 +++ .../modules/report/web/ReportController.kt | 56 +- .../web/StockLotBalanceReportController.kt | 330 ++ .../web/StockLotLedgerReportController.kt | 326 ++ .../stock/service/StockLotDayCloseService.kt | 260 ++ src/main/resources/application-prod.yml | 3 + src/main/resources/application.yml | 4 + .../01_ledger_lot_date_index.sql | 7 + .../01_create_stock_lot_day.sql | 30 + .../01_insert_schedule_setting.sql | 10 + .../01_add_sl_date_deleted_item_uom_index.sql | 8 + .../db/scripts/20260814_inventory_1_0.sql | 87 + 23 files changed, 5887 insertions(+), 13 deletions(-) create mode 100644 docs/deploy/20260831_stock_ledger_fix_timeline.xlsx create mode 100644 scripts/export_stock_ledger_fix_timeline.py create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt create mode 100644 src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql create mode 100644 src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql create mode 100644 src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql create mode 100644 src/main/resources/db/scripts/20260814_inventory_1_0.sql diff --git a/docs/deploy/20260831_stock_ledger_fix_timeline.xlsx b/docs/deploy/20260831_stock_ledger_fix_timeline.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a54f84c9b91141065cc8b99732ae5c7796cb1c0e GIT binary patch literal 12849 zcmZ{L1yCH@*7ab)-QC?axVt-q!QI{6-Q5WuEI@Dw?oM!bcX#=7@B3ff@8$d6RCP~v z)vVR$?CN#)?tRuM%7B5R0RR9fz>*WTj`*OQWXkWW@!tp1@59*6NYT;G-igt`-k!nT z##(Mt4z`C0?$c(cvTIXDBta2^C|_jm&q)TCkVa~o;OC1oZ~X> zLH(}hzlTl>3IHJg&nXz$Ihy=6hw;R2={_c;P(3k6J4%}+lKiZwZ7Cspp(~8Ur8WJT zP~)Ptrq3~LI--PKMyBuHH}JBh?Ndfw2TY!D#?0$u-w6w*d`DwNbpfJ2qNRozC?b~U zgPUl@W}N;S&m#CpmJ>jG$q_2$8USo3O2%n42KUzj=Ikr+YWEN9XE;`z8i_D4v2PDm;}Gpla)5OhpEo7-+6J z=v7~n2I<>meQnOq-4JExf7-(O0%x=E?ZkTfLpvG23%q{sv1N0$`>4GVQ*^yNVc7j6 zI2qd6q)49tfNV+t0QGlp+^rd%%}s1f{&i;lD?DdfK&NF+G~bKrDR;Ab8`uGkfW~@M zCkxk=`i$T+dBPB6%@}JB1UU0gjmkBY6gF{Y_n{x)k!_e^L zYj^!6#+CWDCdTzm4HhzFAtvn7>dP*eVp-h=Eek*|r_V4dO7Ci56qJlR+UUIYCJ={_ zbpE)0WlcTU!a5&I!dTx&m(E;pN>xeAPLHU5kC62deN;8Zq!0vELx`d!y#D&hy8m@~ zo!?~gh#Q|Jz?KPDlgMwvu5?#kD|}0}r?1P>OhDag@Yl4Yf8elBGDNG?ec=o`6bn2L zcaG+StM`51Szh$WvNM|v&gRaQJ7?qQbd)_F_@+`@Zcomwb!U+fbbX=4l=0&I^7n+2 z@)#9`nFpVZlPvRWm>8hKjFsEkOl-tJ)s#DY$CW}@@Ars-iYez43ku4&?^Z=e80Ny_ zJ13qf=nZ>Svc^oF)9WH6iUw&2z2@{K>!$DDWw z1i~rs+VS&~)D4O_P?7cJ(NPz}nl(symj@)==!rqhj_X)-1ERz>cFemc7 zW`ydgH9C`%ySdShq-|^Wnm+dm61JoZWnFb73(4YSVtGUKt-Hva4N~wdr~*(qq3M>2 zc%Vhl%&GaIkM#qU$eXA>O&w4}8!_Pz6?U0?8u^-1%fXLG;yBycPWw*3tY09D#edza z{ah_f(?kbJ!`fggXQ%6*cTefMs>k?)@&FlFN@s!ODgO`Ad4{C;6Cr%t}(RN8tO%|J3 z)q_kc%x{Rzf#cYK5zy#R7Oby@GVse?e!3p{^-Cpt62Jb?>3D+6#C!q*a-#0%-@l!R zibw7w2<4Q{JS%X}1b@rb?Um|23>-CmYxWJDW3{G>eUO;Nl-@<8C>1zjVS_Bp`~uw` ztZtcC9>&K1jerFP;;My!f6vn$=5y-6PsQ(nULYU8mOuAYJO*}g`7!uR7GJd7^5suA zeJ1rW>z;`Mu8Z3O`(C~?FE6k$P#I}Bjt>p0Rwq#}a^-cMzM1pqUY$KJkp)+0exWDD zh?fbd6C!JVjT*V6>4QEr29iUMs?L~vjzq~7#QHLX~q%m z0JCY$S>@);O;2cnnNg0n>wP-vcgNZ0S^{tJN!PGBSp4W%!40-b#{sQWpBiVU7;7U+8rIL9{XqwLm1Y#zMnq7SmNu?3 zPZMUdlIsn6IA}Ux_!pFIIi0h)gRJcDo+Pf%X>Q&6*KhX25{P>|2YR{5HTQ#uE4Cmpmc>|Yk%DE9HmWjhU7gfm>17jyNC%7lDG7E2h56^~| z0HMpA(a2G_D-XlF`hEdl)6*Bbbr#hL@}JrDBccE31Qr0e;{*V({*^%7>>RC}%uP(3 zof!ZA`a6p>;9A(NaNbu#F({6GP1ht7!*@j?im?I)aU|I+ue~+e8up;kE^e?5@%Xlk>SLYP_{*M;9)o&T-MWZc73ACd{yi~pf`hW38wj%d zohq#k<~{Y{ZM=2N)G4A~`?l;N>}`)u?`}e2ukPgfwcfoF_I zc5lK2T@duR9xDTi8QD8ykZ5i`l2cl#&74V zvHNoMrl{f5d)4wTaFr}23r)Q+R9lOfly)}N^MCDU=@sKx;51{*aPazcsWU4j|a9_Mj24GtEa_bMs>fVD@jGm@HEM8LGe z?I!MlQ-dvAfMFpw8B7`y5ZEFt{W#S|TX$-9|BR081lJzAbUEzqMW6c!_W&hKG>;&> zN-MX^SIndejwb8Rp`KWdnmO#zW;#_UC}ClMSku?^N`gSV8A~D z9T20Z9VH})gb=KVYH3iZAhFbSukFSVD#$1-IPz5^{Q>5ShLlO`Qi4a(`^i8TvsU9py z8Th}#N?&Z+aL&S&yyD_ED(Q)tsM!^ah?&Pwnn|jBQ-mPh9itdd1yir;%eY`Vmt;-Z zJ;igA9+wI{!hwexiTi=6PYCMwZP0B=msiYOKM9@XhwuPY1VZsJP%f#Wli!0;5VOXC zc@YECrE)fX^W7+DNRzk>`Ug1+$TJAW@|z&hf#j)k64_$i&a`0uyA0o4sLm1hmOhx4 zv**mK{6QOs{f1`1ICKV6UnMq}Yd>O9d*2?r)h!2!38WRF5cUNds=Fhye6}A! z12D$09^B?-lN(YqoX}EHCClU3(6~2bf}{lDkJ6E>T*xvOL;MlNh2;DieX0ob%CSmt zNUTK~pD*}*S!zS-()33~m42ZiFA0?)*B8U)!+a^z!4%FeBBffsj-beQl&?MG_nUdY zaqBfsJAXrpd<9K_k$b^zDOJ3}IfXPN>_szT7qjP8qnM!16-p`d5@0AVI;ZCT_Jdq4 zA+nwaB_9g2uEh%bHB(e-D+GhrFcAwfwjRt*)!zmM6OunPLSIo_<8645_BMam9;8hD z-X{mPFjyg2@sU~L1*yUwJ{K&p>8ZBWW(jekIZ}hvzN*acertQ9b58t>EbrrcLN%G> ze&k2DwvwP6i5J;0r9`~M_GRBF zEEX8RNJzhMuE@`?ECqb$9)8U)W3bo&Th9@~x_qieBuMSVN-Yq58f%=nszuAgQ@A=} zrPkE|lf6D#59RW^HcnkgX?BTV;Sx-a5cySAAd3eQM=jmzY`|Axu8(Fh2)a_#2L&mL zOUtgE9DmcUjRhv|715K;chO&i#bChw+Ry{47~F4eGGSnEUT?Q!w8X|0DL131pVUrZ zFbn%2tdDJ8h&TjWT}CXDJCK-~fea%IClzc;V6-|&79{p-N=M3s5r2-rjKciWG%yK9=lFDzaHR9X#;^a<~btb#rcafFCDSnph*BMl#0ZXK9+A8Y#Pi1q z`a#mTDrza~cb^HJR-5J6l$gh|gjH~_Ye|{fH`~Ybk$D{& zG`z3E0a<1VN+E%^Khnu|r$fkES1}6(8mJfG?F_#1Jgbr})B|H2GuH))zEX+NjT^x3~NO!b9 z%BO=uKtQxreq(&n1PlFHLMdGG?Lf6(N0LL}po$O^_M$P}Y#rAceXvr;pR8M#x2$Nj zmdI6TnVdB89&S&gflhGmyXpnb3m!82!e@4N9x^s28FP14;j=DJA+Lni`5t4TC3e*}xu zLEr=1aq71*f)nZ4dn2y~iwo%xdEsKM`Iv?0>798t@rHH0wt%Z%o8cm3dM@%`@9^S(9;lTvZ!a-2$8DE@R^%uAnwo5`5u(_1Nv ze1qMr$OT+y^sD!B)tozx97T%}jL2>&t7L{a`PY?jeC!e_Menk-(gt}JrG`O1FV>!} zdd$JtF|ekBR}C>-irk$8Zj!dtAtjgR0FpXEA;nw7G*XHJahLYVO@n7pF|BHpD^j`n z9i%5h15xfSm?QqK<}zh1`ms_%T~bAfrkx!zzVoU?J5|X`_6O}QW^ZL<6{l-%zoy!s zS{H9};5Tf>F;>>Z+{qeO7+7}L(bdkw6cwYYHc)8Wz3Efi4AOnmgd+GZVMV+Ng2!v4 zHHIV3I_``A%3bSALPk91hLCb+UuU6&*-x5ukG`1hwR;m=gRS2?s71)wRFxj1f996l zt5D~e9JG#S>Ai{4XM&NPT+%nj5kUA<=D>?x)OVXbf3bR(1-AL(MQFO`)3NNY(h}7B zNQvCMf>H(fUKZKFL`KkoN$JOLT<4)42?FS(jg{8;bPX_Z_U23 zIx+uc64-0zk@PXPD~9p3u5VO*>GzhIvgq(uvh(x-_|F#vhLo>(4bT9S&29kkDqvPQFo*pl>K6^yO8EfrceCT zTg-k~n1k7rxLoF)xTwKBSNu|lg-a>k4_tXVCJK8De3z3g8oXx@Qtz(4ZcC%i!4h*( zj7Pmj)Vk4T8iTt58&}s&q_-O-dp`Hrx`&Mp^iE0VPx+E{L1{N<&dxE6X=G?R#&H`e z@5)#oUj0V**G{>%z9BAMjZ;IYz_(134bdA*Qe7dxj-3-_D7nppjL9ckO5Y>d3@v$JHh(wfN=8Me!#~`=H{u}H;)sT;+V48#ef{5`z z!isj8e)>d~44u8DsI<{_VWFaV(JGdj8LiuR(**o28&4#tHd-fhIU#*6Ab##s{@8WX z+^2oog}C(>H*nG(ic6U{K(`$h5=CARC(NGL2-WK}(|v@yIFn%G4ii#m$3@gTXKbNn zOk9Mgsb(~+e()jY;4xpmk?4WI{Gt}a@Pvi&7l**sZrrsN-IPx_u<7y-=A&~J#^&+<; z!mz~F*E)UC`H-(e*1P!)v{y`S>II|=pCqXwL^&;!$&$ql*;;DlM162LP^oyz&}*m? zJxi(5-5)_Sl3silK_OuB>mIJ2ecwlqD%V%DF~E3dZ;!E`h%Fd*-gv~Ba=SX4wcuOA z%Ucr1KL9V@G$5G77o1 zPR~z+rt9qtX=t8;PV=l7;v0dhOVZh31(I05A`JQqeW@&6I-YEMloT<|l{cGIEtD=Hqf~{{o_BRavw&jUrAd`IA@2vGmfHUQ9 z&PZ+qQqaMcrQLBv&=uv6C&4UN8JdwNSHayhfCUb%i3~;>sfW^UNuKVn)ShKI z(f9^$N7iac_8Iw!nT40Qcp3;T(bU=w^liBakUR{PJoJaE6(V!a5O(M~8so zNDI$yjwYi#BhBz)N2)ak&jsUrbZqn5BRy*?><*~ycA+PV%3X>JItojs?-QFJRf7PY-fzt>yfg zzDmx<1v{fep8W$&$^{;$M;USmOIBs=$10plmuD!8vg|59WRh>GlhIz$+_ZkygS zs*nf}lQI?O_=arGF5>FUFw&qI-PFt9hQhy+%wJB)=BJnC2)1GL$vltbt0}M;O zKqKbZ1R1oa9Ir6a+H|UZyN}p84+A22J&l)#*L^bN!NFxko~@m z$<-5VS;Cy<7dHrIiCzlI~|9Z-cr<-tm7GR;t_LN3058Xd|+ZBsW@G!7Ul7` z-%X;}T46@{`g2=k-UK^oH_Er)Usxb-6;mmTJ?4`1@n#w8>UyF{x1Z-wBD%9{$ zwRn>VsSrmD11aPj0I`wx?&*TB3N}Pq2QU=t<^s!m*cV)_>#jWYDF1+C5l4i6C@sjM+?LgtW}P%RE+~| z<3>2+&WU_;6$hi?O>^ljAaX#bYiyJoX~}<1^2Y*;6F|HWXOFH>6wZ-`24PEfw`@w zIH(X5a%4%+E-FM=HpQCQ zJ~(x*jsmHJ7cp=mFLYff5wxYl$*H;;uHi=jUq0FGiex`%oG2tkcJ|5piKFyd&n9M0 z#RGO}u1#ssg91`z$fOb$NTq+D@6dI|;-JmAK-L#2dB9$`haej?rXub0 zqpkaH-Em8+K~Y>Z0!jXDW9P9_(a~C14ndO05p0f}+1^0wrjw$49`_yfTqWPs+8e3) z;3?=8rS5WmX(k_SL45%D_D0#FJz|Hau(5NgoUCq5f2h0)x}^2B`#v{ZYL{-uROvWq zE>7w_o&d&fu&GAS85tzp3BvwP(NyB8YI~s){IcQ#*W<@l%OT)wg^so&Y@c+ov@mUt z8t+ZiJB1tnEU~vllTw2JI`;B&r_9IZ46?DywaC)GWZA)UuT1Us^UV6v#~;05&6hE~ z?~nih1m1rw^;rKZ^-{ikaa!R(dZ)!QpoNi6(jXAro6_KeK7e_%) zj68dPW~kXDh9ybCRBCp6SV86V7>TmmD`ZCZ&}C5HJ36c_pC8+zCFohs} z@TirgHOD~Qanwn7KF6TPG?FjFVVtpM=jL(n(db{2>L|ll8qr2CTBS>~xVoO8yDptU z#+}30s|y<(agCmopFav};g~za+21gmlk$7=WN36&r4H$1vHsv^Cmt7WiapV4AYV7BY#*${rQ6hI{yW z9%oTj1Kaj-a>LKL1-Npvuqb)jzOdHi%ZP7CG{-sIwN#FQPt<6ok)n`3Q*%jA5i`5KDthnG4%${@7=IFH!y2!mu^~Yf=F1Fo~^ea)E@iL*8Md zDMbt&s>GPD5`q{E_=0$*B-Jex^Tk-xaa^vTge@Vu)oS$#t4-f^<`F$-5hc_oN?mmj85zX5p-PyuP`6ss7wIEd7;6a{p983FL}8D@kwYY_9)l+-sY=VTO==8%u{! zmSUqKjXN|&P0MCP3pHmio*RrLTKPWeMoidJHhI&^=<5wsOrqAt64v^Jx0iRHf8&#B zi|wlC?=HeCz}{VelNSo%B8?I6_m1LZ&lsUBa-W$#-w-+VB zosA#v4NqND^`W;=3ymo${6PlA(0bM(_iazxYOPD-^e5o~)~OJNl%3*z(V^lRCeE@c zwe6Qka?v^DvvQemaRAac*l*6NYw7p_nb zjI`G(q-KaSFJO({S^MyZALYl7MppRQ62Yb zl^Z5rBu}*|+pmPsoVN!O5Z&ERd-V62NQ$_N*g!lc-c-n3Tj~4?ie2BkNh94j2ML)m zP~<#=+$$TRxq0`TLX?Y@-0Q zP;o-t>Gg!F0F&HM@tXrIx57&$u`RJk5`w;Z=A+LWD25WtFzq0H9z>)Pe-vakjEiA{ z*-=}zPDtA)Nf&G10doe&f7j+MOR9hSNAW{wklB}LQPvGVb z9+JZCFx^5#Ab=k-At50jwypim^XYo%|SW6Au&Fx zyqO_-(~;)5wd&I{U+U@}`XM)eO}eu! z3=hb_#m&!h+%w!IQ@LvrFdO-^!nq?Tn0XH_nFUjD`${>7oHw)680?P128`gd{k=iu z)t|39Z;1cd&N)kuSsDcc05}N%0Qi6H_c}RySerQgmDu;b*u*V!{!Z*lzFi?dsx+KATJ0ANP*gO=b>*1uNd2Yc_ zCyF&czAW+(9}D1@E}cpyaQLaRQx{WIyC=Xb?Ey>6xTNYnMZ@g7CrKTmQPZF8{anSA zBF3>&Ig_cap=_+nK2Ted5(dKVPSq35%Zy;SX0x>JtH#K|j>Kx62I#BQMgoPEiD6eD zq;AwIbk@L9>E(q+BZH=X5v*U<*)nrudbg!|Q%1Bhyn-(XDZ{a-Y7nxpC``8_T=V)~ z`@$4<%xaJ_QyCqQwB=Tjk9y?52)Snr#j+^(XwX#H2oPF**{4^C`~JD1n!}t%oYlT1 z=EcG>Y4b?vpw;X-=xYRIlfje))6Hv{!4zk@xJ?yYV5mZpnm%`Fo%`L^FE;Z;cL!^% z+p8;!6y9`GoXXVfm)VEUlt8ZIYOfj7NplAap7!yv@P-nXt*_Sh7u^(HM9=9+q;%S z#}-1F?y&_scGq^~h4CF_E`0nXVX3EcY9l1ZQ$?5_xz&RL-4e=ke8d24aXp`Ze_K@$xNwX^Whxp2oGN z!lfo!ZPbOrbe>6j9h`a2z!Pl);|hB%kA~yI9SUE_e`y*HuRRQVg%iDSJM6}v2qQMD z!_HVY5D(3UHzqrr>ecfOfqTk`dEdf}^BtNLyq(EfSbw!lU;k;i&jmU3{o`Zb+L~bO zoV>K@z`An(SNrbmeRj;;YI|1y2dZQi>EXJJx|aa|#nqkle5(7^>HvSpB!C4r zCJr?4^(}QbHRsg|bI6LZH2pRX*YHc-C{uGSJ{OSz_E+%Nt31kaolJ2-d#7Pe&v&BVq}U9&lKPn`{57X z$Il#98{bfV?WPZ7Gi3zTUT-j%*;s*-pGp?7GcnZUe2C>N-*=(VS z@p2kLWpXv{gH+-6lMG=n1=&yfPFo=z8+VSeiSWEVA;TW`Aecv872m2;J7y`! zrqp_0Cnm**yZC_0D&YcMR3Ee1Cj2u`%q{1P&@gT(nl42vECL*xg7c|=&ST9HWY*~# zD(IQT(8w`|%AqSb#W$`Oeo}%-rs(ZqlDNhrnTI~-*Ua|D`*?jfdOABiy|v-OO@L4G zl$QYo0%!P&0tqNmW>=EflUXelpY_wDkIp!OczJaEe&!Ld)kv7@-<$UqX7(x`I%7BJ zI)3tay_J!dJB56cH?iF@2c?#l%thUNIz$|F*BkP7DN1fY4*y3bVypO(!M_^=n?ef8#o!n|z1 z=|EECotF1d&FpRP>K18TRUzh}dJyJU;k7uoxtYX%{q&z2XtO>(ZThIRAPy z{w0I|`GEW_iT)%mgdip)vFbo?Kq$EsGKd<^ayYCe6P;o0UIvx@3ldeAawJ>$$g|PY zn?`gYrE4NCyW*O;<3I;46$X1<2biL+5#z8LPS{h4YDpTegi1iF2Q6)I*Nv_IJzRpQ zS4ol_&IfV%4{Jy7Z?AJZT>@Q~>LtZE2_Z>RD#U}J1Gc!RbYT}0##=>Vbh28Hpc5D{ zbora!`BNw`h-f6a+(qN2vkG?4VS7&d;3 z;V*MAP{hvG*~HdaPu0WT#7XBbL5wF)%KujXfL`&jizQ2FA4pXjKw~ig6Dp=nd|aS^ z{cxS2X^kR4yq}G%na$GLPhc=Faqom5)HJ7#VDu+(mB8ljRds!?M;$F88`Ps&@uuO}ZJPk>Mnb6M7+g3Q6o}&Vn9Q ze*jUl<52g2Xtske!z9Y{(1X9o+9-lD3(k)Q>Dx*?v*8KpqIL+h85A>5I2Rk5sr+Sb z24pN$su*|x7p?fOCAJLr9wuMA@B4@>DuNT*rAP_*GWJ~O=2Ch(q8NdqrqAJ&Br None: + for col, w in widths.items(): + ws.column_dimensions[col].width = w + + +def paint(cell, value, fill=None, font=None, align=WRAP, merge=None) -> None: + cell.value = value + cell.alignment = align + cell.border = THIN + if fill: + cell.fill = fill + if font: + cell.font = font + + +def write_table(ws: Worksheet, start_row: int, start_col: int, headers: list[str], rows: list[list[str]], header_fill: PatternFill) -> int: + for i, h in enumerate(headers): + c = ws.cell(start_row, start_col + i) + paint(c, h, header_fill, BOLD, WRAP_CENTER) + r = start_row + 1 + for row in rows: + row_fill = None + row_font = NORMAL + cells: list[str] = [] + for val in row: + if val.startswith("GATE:"): + row_fill, row_font = FILL_GATE, BOLD + cells.append(val[5:].strip()) + elif val.startswith("WAIT:"): + row_fill = FILL_WAIT + cells.append(val[5:].strip()) + elif val.startswith("STOP:"): + row_fill, row_font = FILL_STOP, BOLD + cells.append(val[5:].strip()) + else: + cells.append(val) + for i, text in enumerate(cells): + c = ws.cell(r, start_col + i) + font = row_font if i == 0 or row_fill in (FILL_GATE, FILL_STOP) else NORMAL + paint(c, text, row_fill, font) + if i == 0: + c.alignment = WRAP_CENTER + r += 1 + return r - 1 + + +def sheet_main(wb: Workbook) -> None: + ws = wb.active + ws.title = "31-8夜_主流程" + ws.sheet_view.showGridLines = False + ws.freeze_panes = "A8" + ws.row_dimensions[1].height = 24 + ws.page_setup.orientation = "landscape" + ws.page_setup.fitToPage = True + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 1 + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + ws.print_title_rows = "1:7" + ws.page_setup.horizontalCentered = True + ws.sheet_properties.pageSetUpPr.fitToPage = True + + ws.merge_cells("A1:I1") + paint(ws["A1"], "31/8 夜 stock-ledger-fix:兩條流程(InventoryId match / not match)", FILL_TITLE, WHITE, WRAP_CENTER) + for col in range(2, 10): + ws.cell(1, col).fill = FILL_TITLE + ws.cell(1, col).border = THIN + + ws.merge_cells("A2:I2") + paint( + ws["A2"], + "前提:白天鏈已備好 0315–0830 SQL;dump 31/8 22:30 起 freeze。" + " 程式已支援修/匯出「今天」與 2.7 adjDate=今天(UI 選今天或 API 傳 adjDate)。" + " 23:05 Preview 決定走左欄或右欄,不要兩條一起做。" + " 時間由預計分鐘累加(樂觀);逾時整串延後。", + FILL_NOTE, + ITALIC, + ) + ws.row_dimensions[2].height = 48 + for col in range(2, 10): + ws.cell(2, col).fill = FILL_NOTE + ws.cell(2, col).border = THIN + + ws.merge_cells("A3:D3") + paint( + ws["A3"], + "流程 A MATCH(31/8 dump 套檔前 inventory 頂 = 30/8 修帳前的頂)", + FILL_MATCH, + WHITE11, + WRAP_CENTER, + ) + for col in range(2, 5): + ws.cell(3, col).fill = FILL_MATCH + ws.cell(3, col).border = THIN + + ws.merge_cells("F3:I3") + paint( + ws["F3"], + "流程 B NOT MATCH(31/8 dump 套檔前 inventory 頂 ≠ 30/8 修帳前)", + FILL_NOMATCH, + WHITE11, + WRAP_CENTER, + ) + for col in range(7, 10): + ws.cell(3, col).fill = FILL_NOMATCH + ws.cell(3, col).border = THIN + + ws.merge_cells("A4:D4") + paint( + ws["A4"], + "套 0315–0830 完整 SQL(含 inventory + inventoryId)。不要長 2.3。約 23:30 套完正式庫。", + FILL_HEAD_M, + NORMAL, + ) + ws.row_dimensions[4].height = 36 + for col in range(2, 5): + ws.cell(4, col).fill = FILL_HEAD_M + ws.cell(4, col).border = THIN + + ws.merge_cells("F4:I4") + paint( + ws["F4"], + "套 0315–0830 不完整 SQL(無 inventory/inventoryId)→ 本 dump 1.0 → 長 2.3。" + " 禁止套舊 1.0 INSERT。約 23:53 套完正式庫。", + FILL_HEAD_N, + NORMAL, + ) + for col in range(7, 10): + ws.cell(4, col).fill = FILL_HEAD_N + ws.cell(4, col).border = THIN + + match_rows = [ + ["22:30", "1", "dump + freeze", "停 DN/GRN、lot expiry、日結、GRN sync。記下 I0=MAX(inventory.id)、N=MAX(stock_ledger.id)"], + ["22:31", "5", "zip/下載完", "逾 15 分當異常"], + ["22:36", "25", "import 完", "最易爆;爆了後面全停"], + ["GATE:23:05", "—", "Preview:InventoryId MATCH", "I0 = 30/8 記住的頂 → 走本欄。新 UOM 仍可 MATCH"], + ["23:05", "10", "套 0315–0830 完整 SQL", "inventory(寫死 id)→ ledger(含 inventoryId)→ 日結。舊 qty 非最終"], + ["23:15", "<10s", "再跑 1.0", "新 UOM 才 INSERT(例 1051);qty 刷成 31/8 dump 當下"], + ["23:15", "2", "短區間 2.1–2.6:0830–0831", "可修今天。不要重跑 0315–0829,不要長 2.3"], + ["23:17", "3", "2.7 ADJ(adjDate=31/8)", "UI 選「今天」或 API adjDate=今天。勿用預設昨天(會 ADJ 到 30/8)"], + ["23:20", "10", "匯出完整檔 0315–0831", "inventory → ledger.inventoryId → 日結;2.7 新列(id>N)要 INSERT"], + ["WAIT:23:25", "5", "正式庫 pull code + 開後端", "freeze 仍在;I0/N 未變"], + ["23:30", "5", "正式庫套 0315–0831 完整 SQL", "先 inventory、再 ledger、再日結。套完再開 expiry/日結"], + ] + nomatch_rows = [ + ["22:30", "1", "dump + freeze", "同左。記下實際 I0(已不是舊頂)、N"], + ["22:31", "5", "zip/下載完", "同左"], + ["22:36", "25", "import 完", "同左"], + ["GATE:23:05", "—", "Preview:NOT MATCH", "禁止套 0830 inventory/inventoryId"], + ["23:05", "10", "套 0315–0830 不完整 SQL", "只套 ledger 計算欄+日結(若有)。不要套舊 1.0 INSERT"], + ["23:15", "1", "再跑 1.0", "本 dump 新 id 從 I0+1 起;與 0830 檔不是同一套"], + ["23:16", "15", "長區間 2.3:0315–0830", "本夜最長段。逾 23:50 則本夜不套正式庫"], + ["23:31", "2", "短區間 2.1–2.6:0830–0831", "同左:可修今天"], + ["23:33", "3", "2.7 ADJ(adjDate=31/8)", "同左:選今天"], + ["23:43", "10", "匯出完整檔 0315–0831", "id 同源於本 dump 1.0,不是 0830 檔"], + ["WAIT:23:48", "5", "正式庫 pull code + 開後端", "1:00 master 維持停到套完"], + ["23:53", "5", "正式庫套完整 SQL", "先 inventory、再 ledger、再日結"], + ["STOP:(風險)", "—", "長 2.3 拖過", "優先本夜 freeze 修完再上,或取消套檔改白天"], + ] + + write_table(ws, 6, 1, ["時間", "預計(分)", "動作", "備註"], match_rows, FILL_HEAD_M) + write_table(ws, 6, 6, ["時間", "預計(分)", "動作", "備註"], nomatch_rows, FILL_HEAD_N) + + ws.column_dimensions["E"].width = 2.5 + note_row = 21 + ws.merge_cells(start_row=note_row, start_column=1, end_row=note_row, end_column=9) + paint( + ws.cell(note_row, 1), + "顏色:黃=Preview;藍=等待;橙=風險。" + " MATCH 不要跑長 2.3。" + " NOT MATCH 不要套 0830 inventory/inventoryId。" + " 2.7 必須 adjDate=dump 日(今天)。" + " writeExportSql 完整檔(含 inventory/inventoryId/2.7 INSERT)若尚未改完,匯出前先確認。", + FILL_NOTE, + ITALIC, + ) + ws.row_dimensions[note_row].height = 48 + for col in range(2, 10): + ws.cell(note_row, col).fill = FILL_NOTE + ws.cell(note_row, col).border = THIN + + set_col_widths( + ws, + { + "A": 12, + "B": 10, + "C": 28, + "D": 38, + "E": 2.5, + "F": 12, + "G": 10, + "H": 30, + "I": 38, + }, + ) + for r in range(7, 20): + ws.row_dimensions[r].height = 44 + ws.row_dimensions[6].height = 22 + ws.print_area = "A1:I21" + + +def sheet_gate(wb: Workbook) -> None: + ws = wb.create_sheet("InventoryId_match判定") + ws.sheet_view.showGridLines = False + ws.page_setup.orientation = "landscape" + ws.page_setup.fitToPage = True + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 1 + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + + ws.merge_cells("A1:E1") + paint(ws["A1"], "InventoryId match 判定(數字過了才往下,不要用「時間靠近」)", FILL_TITLE, WHITE, WRAP_CENTER) + for col in range(2, 6): + ws.cell(1, col).fill = FILL_TITLE + ws.cell(1, col).border = THIN + + ws.merge_cells("A2:E2") + paint( + ws["A2"], + "A 在套任何 0830 SQL 之前(dump 原樣)。B 在套完 0830 SQL+再跑 1.0 之後(僅 MATCH)。" + " C 在同夜修完 0830–0831+2.7(adjDate=今天)之後,才能完整匯出。", + FILL_NOTE, + ITALIC, + ) + ws.row_dimensions[2].height = 36 + for col in range(2, 6): + ws.cell(2, col).fill = FILL_NOTE + ws.cell(2, col).border = THIN + + a_rows = [ + ["A1", "inventory 頂 I0", "套檔前 MAX(inventory.id)", "= 0830 修帳前記住的頂(例 1000)", "≠ → 流程 B(NOT MATCH)。禁止套舊 1.0 INSERT"], + ["A2", "新 UOM pair", "lot 有 (itemId,uomId) 但 inventory 還沒列", "0 或 >0 都可以", ">0 不是失敗:MATCH 時套完舊檔再 1.0;NOT MATCH 時本 dump 1.0 一併補"], + ["A3", "ledger 頂 N", "套檔前 MAX(stock_ledger.id)", "記下,給 2.7 INSERT 用", "—"], + ["A4", "0830 SQL inventory 段", "要不要套", "僅 A1 通過才套", "A1 失敗:inventory / inventoryId 都不要套"], + ] + write_table(ws, 4, 1, ["#", "檢查", "怎麼算", "MATCH 通過", "NOT MATCH/失敗"], a_rows, FILL_HEAD) + + ws.merge_cells("A10:E10") + paint(ws["A10"], "B 僅流程 A(MATCH)套完 0830 SQL、再跑 1.0 之後", FILL_MATCH, WHITE11, WRAP_CENTER) + for col in range(2, 6): + ws.cell(10, col).fill = FILL_MATCH + ws.cell(10, col).border = THIN + + b_rows = [ + ["B1", "needInventoryInsert", "1.0 後還缺的 (item,uom)", "= 0", ">0 → 1.0 沒補完,不要匯出"], + ["B2", "inventory 頂", "1.0 後 MAX(id)", "≥ I0;有新 UOM 會 > 舊頂(例 1051)", "仍 = dump 的 I0 且 A2>0 → 1.0 沒跑或沒 INSERT"], + ["B3", "0830 區間 inventoryId", "miss / wrong vs MIN(inventory.id) per (item,uom)", "= 0", "≠0 → 再跑 0830 的 2.3(短區間)"], + ] + write_table(ws, 11, 1, ["#", "檢查", "怎麼算", "通過", "失敗"], b_rows, FILL_HEAD_M) + + ws.merge_cells("A16:E16") + paint(ws["A16"], "C 兩條流程都要:同夜修完 0831+2.7(adjDate=今天)之後(完整匯出前)", FILL_TITLE, WHITE11, WRAP_CENTER) + for col in range(2, 6): + ws.cell(16, col).fill = FILL_TITLE + ws.cell(16, col).border = THIN + + c_rows = [ + ["C1", "0831 inventoryId", "miss / wrong", "= 0", "再跑 0831 的 2.3"], + ["C2", "2.7 新列", "id > N 且 type=ADJ、createdBy=stock-ledger-fix", "匯出必須 INSERT 這些 id", "現況 writeExportSql 只 UPDATE JOIN id → 正式庫會丟 ADJ"], + ["C3", "正式庫套檔前", "freeze 仍在", "MATCH:I0 仍為記住的頂、ledger 頂仍 N。NOT MATCH:I0 仍為本 dump 的頂", "任一變了 → 不要套完整檔"], + ] + write_table(ws, 17, 1, ["#", "檢查", "怎麼算", "通過", "失敗"], c_rows, FILL_HEAD) + + ws.merge_cells("A22:E22") + paint(ws["A22"], "分支速查(給 23:05)", FILL_GATE, BOLD, WRAP_CENTER) + for col in range(2, 6): + ws.cell(22, col).fill = FILL_GATE + ws.cell(22, col).border = THIN + + branch = [ + ["MATCH", "無新 UOM", "流程 A", "套完整 SQL → 1.0 刷 qty → 修 0830–0831 → 2.7 adjDate=今天 → 匯出 → 套正式庫", "正式庫不必再跑 1.0/2.3"], + ["MATCH", "有新 UOM", "流程 A(同一條)", "套完整 SQL → 1.0 INSERT 新 id → 修 0830–0831 → 2.7 → 完整檔必含 inventory", "不要因 max 仍舊頂就跳過第二次 1.0"], + ["NOT MATCH", "—", "流程 B", "不完整 SQL → 本 dump 1.0 → 長 2.3 → 修 0830–0831 → 2.7 adjDate=今天", "2.3 逾時則本夜不套正式庫"], + ] + write_table(ws, 23, 1, ["A1", "A2 新 UOM", "走哪條", "本夜做什麼", "結果"], branch, FILL_HEAD) + + set_col_widths(ws, {"A": 14, "B": 22, "C": 36, "D": 42, "E": 48}) + for r in list(range(5, 9)) + list(range(12, 15)) + list(range(18, 21)) + list(range(24, 27)): + ws.row_dimensions[r].height = 42 + ws.row_dimensions[1].height = 24 + ws.print_area = "A1:E26" + + +def sheet_notes(wb: Workbook) -> None: + ws = wb.create_sheet("注意與對照") + ws.sheet_view.showGridLines = False + ws.page_setup.orientation = "landscape" + ws.page_setup.fitToPage = True + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 1 + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + + ws.merge_cells("A1:C1") + paint(ws["A1"], "和舊圖的對照、套檔順序、程式缺口", FILL_TITLE, WHITE, WRAP_CENTER) + ws["B1"].fill = FILL_TITLE + ws["C1"].fill = FILL_TITLE + ws["B1"].border = THIN + ws["C1"].border = THIN + + vs_rows = [ + ["舊圖等 0:00 才能修 0831/2.7", "已改:可修今天;2.7 傳 adjDate=今天", "同左"], + ["舊圖 23:11–23:40 1.0+長 2.3", "MATCH:刪長 2.3;套檔後 1.0 很快", "NOT MATCH:長 2.3 約 15 分(23:16)"], + ["舊圖 0:40 才套正式庫", "MATCH:約 23:30", "NOT MATCH:約 23:53"], + ] + write_table(ws, 3, 1, ["舊圖", "流程 A MATCH", "流程 B NOT MATCH"], vs_rows, FILL_HEAD) + + ws.merge_cells("A9:C9") + paint(ws["A9"], "套檔順序(完整檔,兩條流程相同)", FILL_MATCH, WHITE11, WRAP_CENTER) + ws["B9"].fill = FILL_MATCH + ws["C9"].fill = FILL_MATCH + ws["B9"].border = THIN + ws["C9"].border = THIN + + order_rows = [ + ["1", "inventory", "INSERT 寫死新 id + UPDATE 既有列 qty/status(MATCH 的 1001+ 來自 0830 檔;NOT MATCH 來自本 dump 1.0)"], + ["2", "stock_ledger", "id ≤ N → UPDATE(含 inventoryId、lotQty、balance…);id > N(2.7 ADJ)→ INSERT 寫死 id"], + ["3", "stock_lot_day", "刪區間再 INSERT/ON DUPLICATE。在 ledger 之後"], + ["—", "正式庫不要再跑", "Preview C 通過則不必 1.0、不必 2.3。套完再開 expiry/日結"], + ] + write_table(ws, 10, 1, ["#", "對象", "做法"], order_rows, FILL_HEAD_M) + + ws.merge_cells("A16:C16") + paint(ws["A16"], "現有程式缺口(上線前)", FILL_NOMATCH, WHITE11, WRAP_CENTER) + ws["B16"].fill = FILL_NOMATCH + ws["C16"].fill = FILL_NOMATCH + ws["B16"].border = THIN + ws["C16"].border = THIN + + gap_rows = [ + ["fixDay / fixRange / upsertDayForFix", "已允許修/日結「今天」(拒未來)", "freeze 夜可修 0831"], + ["2.7 ADJ", "可傳 adjDate(預設昨天;freeze 夜傳今天)", "UI 選「今天」或 API ?adjDate= / body.adjDate"], + ["writeExportSql", "可匯出到今天;仍可能缺 inventory/inventoryId/2.7 INSERT", "完整檔選項若未做完需手動補"], + ["排程 closeDay", "仍拒今天", "正式庫開站後日結仍只關昨天"], + ] + write_table(ws, 17, 1, ["位置", "現況", "要補"], gap_rows, FILL_HEAD_N) + + ws.merge_cells("A24:C24") + paint( + ws["A24"], + "記住:inventory 頂沒變 ≠ 1.0 做完。新 UOM lot line 時常仍是 max=1000,但一定要再跑 1.0。" + " MATCH 時順序必須是:先套 0830 SQL,再 1.0(否則新 pair 會把 1001+ 編錯)。", + FILL_NOTE, + ITALIC, + ) + ws.row_dimensions[24].height = 42 + ws["B24"].fill = FILL_NOTE + ws["C24"].fill = FILL_NOTE + ws["B24"].border = THIN + ws["C24"].border = THIN + + set_col_widths(ws, {"A": 36, "B": 48, "C": 56}) + for r in list(range(4, 8)) + list(range(11, 15)) + list(range(18, 23)): + ws.row_dimensions[r].height = 36 + ws.row_dimensions[1].height = 24 + ws.print_area = "A1:C24" + + +def main() -> None: + OUT.parent.mkdir(parents=True, exist_ok=True) + wb = Workbook() + sheet_main(wb) + sheet_gate(wb) + sheet_notes(wb) + wb.save(OUT) + print(f"Wrote {OUT}") + + +if __name__ == "__main__": + main() diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 3adac3bb..81710219 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -59,6 +59,9 @@ public abstract class SettingNames { /** Mark expired inventory lot lines as unavailable (default 00:05 daily) */ public static final String SCHEDULE_INVENTORY_LOT_EXPIRY = "SCHEDULE.inventoryLot.expiry"; + /** Daily stock_lot_day close for yesterday (default 00:15; moves + carry) */ + public static final String SCHEDULE_STOCK_LOT_DAY_CLOSE = "SCHEDULE.stockLotDay.close"; + public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index 875ce4b0..efc1aff3 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -13,8 +13,9 @@ import com.ffii.fpsms.modules.common.alert.SchedulerSyncAlertService import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService import com.ffii.fpsms.modules.master.service.ProductionScheduleService -import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService import com.ffii.fpsms.modules.stock.service.InventoryLotLineService +import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService +import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService import com.ffii.fpsms.modules.settings.entity.Settings import com.ffii.fpsms.modules.settings.service.SettingsService import jakarta.annotation.PostConstruct @@ -43,6 +44,8 @@ open class SchedulerService( @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, @Value("\${scheduler.m18Units.incrementalLookbackDays:7}") val m18UnitsIncrementalLookbackDays: Int, @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, + @Value("\${scheduler.stockLotDayClose.enabled:true}") val stockLotDayCloseEnabled: Boolean, + @Value("\${scheduler.stockLotDayClose.catchUpDays:7}") val stockLotDayCloseCatchUpDays: Int, /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, @@ -71,6 +74,7 @@ open class SchedulerService( val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, private val schedulerSyncAlertService: SchedulerSyncAlertService, + private val stockLotDayCloseService: StockLotDayCloseService, @Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String, @Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean, ) { @@ -82,6 +86,8 @@ open class SchedulerService( const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" + /** Daily 00:15 — stock_lot_day close for yesterday (moves + carry). */ + const val STOCK_LOT_DAY_CLOSE_DEFAULT_CRON: String = "0 15 0 * * *" } /** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */ @@ -111,6 +117,8 @@ open class SchedulerService( var scheduledGrnCodeSync: ScheduledFuture<*>? = null var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null + var scheduledStockLotDayClose: ScheduledFuture<*>? = null + var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null var scheduledDo1CatchUp: ScheduledFuture<*>? = null @@ -208,6 +216,7 @@ open class SchedulerService( schedulePostCompletedDnGrn(); scheduleGrnCodeSync(); scheduleInventoryLotExpiry(); + scheduleStockLotDayClose(); scheduleJobOrderPlanStartAuto(); scheduleDo1CatchUpOnce(); scheduleSyncAlertWatchdog(); @@ -581,6 +590,78 @@ open class SchedulerService( } } + /** + * Daily stock_lot_day close at 00:15 (yesterday + catch-up). + * Set scheduler.stockLotDayClose.enabled=false to disable. + */ + fun scheduleStockLotDayClose() { + if (!stockLotDayCloseEnabled) { + scheduledStockLotDayClose?.cancel(false) + scheduledStockLotDayClose = null + logger.info("stock_lot_day close scheduler disabled (scheduler.stockLotDayClose.enabled=false)") + return + } + scheduledStockLotDayClose = commonSchedule( + scheduledStockLotDayClose, + SettingNames.SCHEDULE_STOCK_LOT_DAY_CLOSE, + STOCK_LOT_DAY_CLOSE_DEFAULT_CRON, + { runStockLotDayClose() }, + ) + logger.info( + "Scheduled stock_lot_day close (default cron={}, catchUpDays={})", + STOCK_LOT_DAY_CLOSE_DEFAULT_CRON, + stockLotDayCloseCatchUpDays, + ) + } + + /** + * Close through [date] (default yesterday). Catch-up from last closed day, capped by catchUpDays. + * Rejects date >= today. + */ + open fun runStockLotDayClose(date: LocalDate? = null): String { + val currentTime = LocalDateTime.now() + return try { + val result = stockLotDayCloseService.closeThrough( + target = date, + catchUpDays = stockLotDayCloseCatchUpDays, + ) + if (result.skipped) { + val msg = "stock_lot_day close skipped: ${result.skipReason}" + logger.warn("Scheduler - {}", msg) + saveSyncLog( + type = "STOCK_LOT_DAY", + status = "SKIPPED", + result = SyncResult(0, 0, 0, msg), + start = currentTime, + ) + return msg + } + val query = "from=${result.dateFrom} to=${result.dateTo} days=${result.days.size} catchUpDays=$stockLotDayCloseCatchUpDays" + saveSyncLog( + type = "STOCK_LOT_DAY", + status = "SUCCESS", + result = SyncResult( + totalProcessed = result.days.size, + totalSuccess = result.totalRows, + totalFail = 0, + query = query, + ), + start = currentTime, + ) + logger.info("Scheduler - stock_lot_day close done {}", query) + "stock_lot_day close ok $query rows=${result.totalRows}" + } catch (e: Exception) { + logger.error("Scheduler - stock_lot_day close failed: ${e.message}", e) + saveSyncLog( + type = "STOCK_LOT_DAY", + status = "FAILED", + error = e.message, + start = currentTime, + ) + throw e + } + } + // Function for schedule // --------------------------- FP-MTMS --------------------------- // diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt index 23e5568e..073beda6 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt @@ -96,6 +96,17 @@ class SchedulerController( return "Inventory lot expiry status update triggered" } + /** + * Manual stock_lot_day close (moves + carry). Omit [date] → through yesterday (with catch-up). + * [date] must be before today. Example: GET /scheduler/trigger/stock-lot-day-close?date=2026-08-20 + */ + @GetMapping("/trigger/stock-lot-day-close") + fun triggerStockLotDayClose( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) date: LocalDate? = null, + ): String { + return schedulerService.runStockLotDayClose(date) + } + @GetMapping("/trigger/post-completed-dn-grn") fun triggerPostCompletedDnGrn( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt new file mode 100644 index 00000000..7ef5a34a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt @@ -0,0 +1,3078 @@ +package com.ffii.fpsms.modules.master.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjPreview +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRow +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCheckPart +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayStatus +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit +import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.RowCallbackHandler +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.io.BufferedWriter +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.math.BigDecimal +import java.nio.charset.StandardCharsets +import java.sql.ResultSet +import java.time.LocalDate +import java.time.format.DateTimeParseException +import java.util.concurrent.ConcurrentHashMap +import javax.sql.DataSource + +/** + * Inventory 1.0 (A) then per-day stock_ledger backfill (2.1–2.6) and [stock_lot_day]. + * 2.4 opening looks back to last lotQtyAfter (not yesterday day-table or 0). + * 2.7 ADJ aligns lot-line in/out to ledger sums (yesterday, new ids; reverse when ledger over). + * + * SIL/SOL have no inventoryId; lot is inventoryLotLineId / inventoryLotId only. + */ +@Service +open class StockLedgerFixService( + private val jdbcDao: JdbcDao, + private val transactionManager: PlatformTransactionManager, + private val dataSource: DataSource, + private val stockLotDayCloseService: StockLotDayCloseService, +) { + private val log = LoggerFactory.getLogger(StockLedgerFixService::class.java) + private val inFlight = ConcurrentHashMap.newKeySet() + private val inventoryLock = "inventory-1.0" + private val runLock = "ledger-fix-run" + + open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse { + if (to.isBefore(from)) { + throw IllegalArgumentException("to must be on or after from") + } + if (from.plusDays(62).isBefore(to)) { + throw IllegalArgumentException("date range must be at most 62 days") + } + val args = mapOf("from" to from, "toNext" to to.plusDays(1)) + val rows = jdbcDao.queryForList( + """ + SELECT + DATE_FORMAT(sl.date, '%Y-%m-%d') AS d, + COUNT(*) AS cnt, + SUM(sl.inventoryLotLineId IS NULL) AS missLot, + SUM(sl.uomId IS NULL) AS missUom, + SUM(sl.inventoryId IS NULL) AS missInventoryId, + SUM(sl.lotQtyAfter IS NULL) AS missLotQty + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :from + AND sl.date < :toNext + GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') + ORDER BY d + """.trimIndent(), + args, + ) + val dayLots = jdbcDao.queryForList( + """ + SELECT DATE_FORMAT(date, '%Y-%m-%d') AS d, COUNT(*) AS lots + FROM stock_lot_day + WHERE deleted = 0 + AND date >= :from + AND date < :toNext + GROUP BY DATE_FORMAT(date, '%Y-%m-%d') + """.trimIndent(), + args, + ).associate { row -> + row["d"].toString() to toInt(row["lots"]) + } + val days = rows.map { row -> + val d = row["d"].toString() + StockLedgerFixDayStatus( + date = d, + cnt = toInt(row["cnt"]), + missLot = toInt(row["missLot"]), + missUom = toInt(row["missUom"]), + missInventoryId = toInt(row["missInventoryId"]), + missLotQty = toInt(row["missLotQty"]), + dayTableLots = dayLots[d] ?: 0, + ) + } + return StockLedgerFixCalendarResponse( + from = from.toString(), + to = to.toString(), + days = days, + ) + } + + /** + * A: keep existing inventory ids; INSERT missing (itemId, uomId) from lot lines; + * recalc onHand/onHold/unavailable from Σ lot qty by item+UOM (0 if no lots). + * Does not UPDATE inventory_lot_line. + */ + open fun inventoryPreview(): StockLedgerFixInventoryPreview { + val inventoryRows = toInt( + jdbcDao.queryForList( + "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", + ).firstOrNull()?.get("c"), + ) + val lotUomPairs = toInt( + jdbcDao.queryForList(LOT_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + val missingUomPairs = toInt( + jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + return StockLedgerFixInventoryPreview( + inventoryRows = inventoryRows, + lotUomPairs = lotUomPairs, + missingUomPairs = missingUomPairs, + ) + } + + @Transactional(timeout = 300) + open fun fixInventory10(): StockLedgerFixInventoryResponse { + if (!inFlight.add(inventoryLock)) { + throw IllegalStateException("inventory 1.0 already running") + } + try { + val inserted = jdbcDao.executeUpdate(INSERT_MISSING_INVENTORY_SQL) + val updated = jdbcDao.executeUpdate(UPDATE_INVENTORY_QTY_SQL) + val missingAfter = toInt( + jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + log.info( + "stock-ledger-fix inventory-1.0 inserted={} updated={} missingAfter={}", + inserted, + updated, + missingAfter, + ) + return StockLedgerFixInventoryResponse( + inserted = inserted, + updated = updated, + missingUomPairsAfter = missingAfter, + ) + } finally { + inFlight.remove(inventoryLock) + } + } + + open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail { + val args = dayArgs(day) + val ledger = jdbcDao.queryForList( + """ + SELECT + COUNT(*) AS cnt, + SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + AND sl.inventoryLotLineId <> COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + ) AS lotIncorrect, + SUM(sl.uomId IS NULL) AS uomMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND sl.uomId IS NOT NULL + AND iu.uomId IS NOT NULL + AND sl.uomId <> iu.uomId + ) AS uomIncorrect, + SUM(sl.inventoryId IS NULL) AS invMiss, + SUM( + sl.inventoryId IS NOT NULL + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM inventory inv + WHERE inv.id = sl.inventoryId + AND IFNULL(inv.deleted, 0) = 0 + AND inv.itemId = sl.itemId + AND inv.uomId = sl.uomId + ) + ) AS invIncorrect, + SUM(sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) AS lotQtyMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) + ) AS lotQtyMissWithLot, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND ( + sl.lotQtyAfter - sl.lotQtyBefore + <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + ) + ) AS lotQtyIncorrect, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) + ) AS overIssue, + SUM(sl.balance IS NULL) AS balanceMiss, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + ) AS fixable21a, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) = 1 + ) AS fixable21b, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) > 1 + ) AS cannotMultiLine, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) + ) AS cannotNoSource + FROM stock_ledger sl + LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId + LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId + LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId + LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId + LEFT JOIN ( + SELECT inventoryLotId, COUNT(*) AS lineCnt + FROM inventory_lot_line + WHERE IFNULL(deleted, 0) = 0 + GROUP BY inventoryLotId + ) lc ON lc.inventoryLotId = sil.inventoryLotId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + + val cnt = toInt(ledger["cnt"]) + val lotMiss = toInt(ledger["lotMiss"]) + val lotIncorrect = toInt(ledger["lotIncorrect"]) + val uomMiss = toInt(ledger["uomMiss"]) + val uomIncorrect = toInt(ledger["uomIncorrect"]) + val invMiss = toInt(ledger["invMiss"]) + val invIncorrect = toInt(ledger["invIncorrect"]) + val lotQtyMissWithLot = toInt(ledger["lotQtyMissWithLot"]) + val lotQtyIncorrect = toInt(ledger["lotQtyIncorrect"]) + val overIssue = toInt(ledger["overIssue"]) + val balanceMiss = toInt(ledger["balanceMiss"]) + + val dayTable = jdbcDao.queryForList( + """ + SELECT + COUNT(*) AS expected, + SUM(d.id IS NULL) AS miss, + SUM( + d.id IS NOT NULL AND ( + (x.kind = 'move' AND ( + d.closing <> lastSl.lotQtyAfter + OR d.opening <> firstSl.lotQtyBefore + OR d.inQty <> x.inQty + OR d.outQty <> x.outQty + )) + OR (x.kind = 'carry' AND ( + d.inQty <> 0 + OR d.outQty <> 0 + OR d.opening <> x.prevClosing + OR d.closing <> x.prevClosing + )) + ) + ) AS incorrect + FROM ( + SELECT + sl.inventoryLotLineId, + 'move' AS kind, + MIN(sl.id) AS firstId, + MAX(sl.id) AS lastId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty, + CAST(NULL AS DECIMAL(14,2)) AS prevClosing + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + GROUP BY sl.inventoryLotLineId + + UNION ALL + + SELECT + prev.inventoryLotLineId, + 'carry' AS kind, + NULL AS firstId, + NULL AS lastId, + CAST(0 AS DECIMAL(14,2)) AS inQty, + CAST(0 AS DECIMAL(14,2)) AS outQty, + prev.closing AS prevClosing + FROM stock_lot_day prev + WHERE prev.date = :prevDay + AND IFNULL(prev.deleted, 0) = 0 + AND prev.closing > 0 + AND NOT EXISTS ( + SELECT 1 FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId = prev.inventoryLotLineId + ) + ) x + LEFT JOIN stock_ledger firstSl ON firstSl.id = x.firstId + LEFT JOIN stock_ledger lastSl ON lastSl.id = x.lastId + LEFT JOIN stock_lot_day d + ON d.inventoryLotLineId = x.inventoryLotLineId + AND d.date = :day + AND IFNULL(d.deleted, 0) = 0 + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + + val dayLots = toInt(dayTable["expected"]) + val dayMiss = toInt(dayTable["miss"]) + val dayIncorrect = toInt(dayTable["incorrect"]) + // No lot ids yet → SQL finds 0 lots, 0/0/0 would look "correct". Ledger exists but day table cannot be built. + val dayTablePending = cnt > 0 && dayLots == 0 + val dayTableMiss = if (dayTablePending) 1 else dayMiss + val dayTableTotal = if (dayTablePending) 1 else dayLots + + fun part(key: String, label: String, miss: Int, incorrect: Int, total: Int) = + StockLedgerFixCheckPart( + key = key, + label = label, + ok = (total - miss - incorrect).coerceAtLeast(0), + miss = miss, + incorrect = incorrect, + ) + + return StockLedgerFixDayDetail( + date = day.toString(), + cnt = cnt, + parts = fieldAndReasonParts( + cnt = cnt, + lotMiss = lotMiss, + lotIncorrect = lotIncorrect, + uomMiss = uomMiss, + uomIncorrect = uomIncorrect, + invMiss = invMiss, + invIncorrect = invIncorrect, + lotQtyMissWithLot = lotQtyMissWithLot, + lotQtyIncorrect = lotQtyIncorrect, + overIssue = overIssue, + balanceMiss = balanceMiss, + fixable21a = toInt(ledger["fixable21a"]), + fixable21b = toInt(ledger["fixable21b"]), + cannotMultiLine = toInt(ledger["cannotMultiLine"]), + cannotNoSource = toInt(ledger["cannotNoSource"]), + earlyTkeOver = 0, + otherOver = 0, + extra = listOf( + part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal), + ), + ), + ) + } + + open fun searchInventory(qRaw: String): List { + val q = qRaw.trim() + if (q.isEmpty()) throw IllegalArgumentException("q is required") + val args = mapOf("q" to q, "like" to "%$q%") + return jdbcDao.queryForList( + """ + SELECT + i.id AS inventoryId, + i.itemId AS itemId, + it.code AS itemCode, + i.uomId AS uomId, + ( + SELECT COUNT(*) FROM stock_ledger sl + WHERE sl.deleted = 0 AND sl.inventoryId = i.id + ) AS ledgerCnt + FROM inventory i + LEFT JOIN items it ON it.id = i.itemId + WHERE IFNULL(i.deleted, 0) = 0 + AND ( + CAST(i.id AS CHAR) = :q + OR CAST(i.itemId AS CHAR) = :q + OR it.code LIKE :like + ) + ORDER BY ledgerCnt DESC, i.id + LIMIT 20 + """.trimIndent(), + args, + ).map { row -> + StockLedgerFixSearchInventoryHit( + inventoryId = toLong(row["inventoryId"]), + itemId = toLongOrNull(row["itemId"]), + itemCode = row["itemCode"]?.toString(), + uomId = toLongOrNull(row["uomId"]), + ledgerCnt = toInt(row["ledgerCnt"]), + ) + } + } + + open fun searchLot(qRaw: String): List { + val q = qRaw.trim() + if (q.isEmpty()) throw IllegalArgumentException("q is required") + val args = mapOf("q" to q, "like" to "%$q%") + return jdbcDao.queryForList( + """ + SELECT + ill.id AS inventoryLotLineId, + il.lotNo AS lotNo, + it.code AS itemCode, + ( + SELECT sl.inventoryId FROM stock_ledger sl + WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id + AND sl.inventoryId IS NOT NULL + ORDER BY sl.id DESC LIMIT 1 + ) AS inventoryId, + ( + SELECT COUNT(*) FROM stock_ledger sl + WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id + ) AS ledgerCnt + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN items it ON it.id = il.itemId + WHERE IFNULL(ill.deleted, 0) = 0 + AND ( + CAST(ill.id AS CHAR) = :q + OR il.lotNo LIKE :like + ) + ORDER BY ledgerCnt DESC, ill.id + LIMIT 20 + """.trimIndent(), + args, + ).map { row -> + StockLedgerFixSearchLotHit( + inventoryLotLineId = toLong(row["inventoryLotLineId"]), + lotNo = row["lotNo"]?.toString(), + itemCode = row["itemCode"]?.toString(), + inventoryId = toLongOrNull(row["inventoryId"]), + ledgerCnt = toInt(row["ledgerCnt"]), + ) + } + } + + open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail { + val today = LocalDate.now() + val args = mapOf("inventoryId" to inventoryId, "today" to today) + val head = jdbcDao.queryForList( + """ + SELECT + i.id AS inventoryId, + i.itemId AS itemId, + it.code AS itemCode, + i.uomId AS uomId + FROM inventory i + LEFT JOIN items it ON it.id = i.itemId + WHERE i.id = :inventoryId + """.trimIndent(), + args, + ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") + val stats = ledgerScopeStats( + "sl.inventoryId = :inventoryId", + args, + itemId = toLongOrNull(head["itemId"]), + ) + return StockLedgerFixScopeDetail( + kind = "inventory", + id = inventoryId, + itemCode = head["itemCode"]?.toString(), + lotNo = null, + inventoryId = inventoryId, + uomId = toLongOrNull(head["uomId"]), + firstDate = stats.firstDate, + lastDate = stats.lastDate, + cnt = stats.cnt, + lastBalance = stats.lastBalance, + lastLotQtyAfter = stats.lastLotQtyAfter, + parts = stats.parts, + ) + } + + open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail { + val today = LocalDate.now() + val args = mapOf("lotId" to lotLineId, "today" to today) + val head = jdbcDao.queryForList( + """ + SELECT + ill.id AS inventoryLotLineId, + il.lotNo AS lotNo, + il.itemId AS itemId, + it.code AS itemCode + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN items it ON it.id = il.itemId + WHERE ill.id = :lotId + """.trimIndent(), + args, + ).firstOrNull() ?: throw IllegalArgumentException("lot line not found: $lotLineId") + val stats = ledgerScopeStats( + "sl.inventoryLotLineId = :lotId", + args, + itemId = toLongOrNull(head["itemId"]), + ) + return StockLedgerFixScopeDetail( + kind = "lot", + id = lotLineId, + itemCode = head["itemCode"]?.toString(), + lotNo = head["lotNo"]?.toString(), + inventoryId = toLongOrNull(stats.lastInventoryId), + uomId = null, + firstDate = stats.firstDate, + lastDate = stats.lastDate, + cnt = stats.cnt, + lastBalance = stats.lastBalance, + lastLotQtyAfter = stats.lastLotQtyAfter, + parts = stats.parts, + ) + } + + open fun fixInventoryScope(inventoryId: Long): StockLedgerFixRunResponse { + if (inventoryId <= 0) throw IllegalArgumentException("inventoryId is required") + if (!inFlight.add(runLock)) throw IllegalStateException("fix already running") + val today = LocalDate.now() + val t0 = System.nanoTime() + try { + val inv = jdbcDao.queryForList( + """ + SELECT id, itemId, uomId FROM inventory WHERE id = :inventoryId + """.trimIndent(), + mapOf("inventoryId" to inventoryId), + ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") + val args = mapOf( + "inventoryId" to inventoryId, + "itemId" to (inv["itemId"] ?: 0), + "uomId" to (inv["uomId"] ?: 0), + "today" to today, + ) + val filledLotLineId = timed("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } + val filledUomId = timed("inv 2.2") { fillUomIdScope(args, byInventory = true) } + val filledInventoryId = timed("inv 2.3") { fillInventoryIdScope(args, byInventory = true) } + val filledLotQty = timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } + val filledBalance = timed("inv 2.5") { fillBalanceHistory(args) } + val lotIds = lotIdsForInventory(inventoryId, today) + val range = dateRangeForFilter("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to today)) + val dayRows = if (range != null) { + timed("inv 2.6") { upsertStockLotDayRange(lotIds, range.first, range.second) } + } else 0 + val still = stillMissScope("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to today)) + log.info( + "stock-ledger-fix inventory {} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", + inventoryId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, filledBalance, dayRows, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = range?.first?.toString() ?: today.toString(), + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRows, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix inventory {} FAILED after {}ms: {}", inventoryId, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + inFlight.remove(runLock) + } + } + + open fun fixLotScope(lotLineId: Long): StockLedgerFixRunResponse { + if (lotLineId <= 0) throw IllegalArgumentException("lotLineId is required") + if (!inFlight.add(runLock)) throw IllegalStateException("fix already running") + val today = LocalDate.now() + val t0 = System.nanoTime() + try { + val exists = toInt( + jdbcDao.queryForList( + "SELECT COUNT(*) AS c FROM inventory_lot_line WHERE id = :lotId", + mapOf("lotId" to lotLineId), + ).firstOrNull()?.get("c"), + ) + if (exists == 0) throw IllegalArgumentException("lot line not found: $lotLineId") + val args = mapOf("lotId" to lotLineId, "today" to today) + val filledLotLineId = timed("lot 2.1") { fillLotLineIdScope(args, byInventory = false) } + val filledUomId = timed("lot 2.2") { fillUomIdScope(args, byInventory = false) } + val filledInventoryId = timed("lot 2.3") { fillInventoryIdScope(args, byInventory = false) } + val filledLotQty = timed("lot 2.4") { fillLotQtyHistory(args, byInventory = false) } + val range = dateRangeForFilter("inventoryLotLineId = :lotId", args) + val dayRows = if (range != null) { + timed("lot 2.6") { upsertStockLotDayRange(listOf(lotLineId), range.first, range.second) } + } else 0 + val still = stillMissScope("inventoryLotLineId = :lotId", args) + log.info( + "stock-ledger-fix lot {} lotLine={} uom={} inv={} lotQty={} dayRows={} total={}ms", + lotLineId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, dayRows, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = range?.first?.toString() ?: today.toString(), + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = 0, + dayRowsWritten = dayRows, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix lot {} FAILED after {}ms: {}", lotLineId, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + inFlight.remove(runLock) + } + } + + /** + * Line in/out vs Σ ledger in/out. Any non-zero gap becomes ADJ on [adjDate] + * (default yesterday; pass today on freeze-night dump). + * Positive miss → ADJ in/out; negative miss → reverse ADJ out/in. + * Goal: last lotQtyAfter = line remain (negative allowed for true over-issue). + * Ledger gap sum uses `date < adjDate+1`. + */ + open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview { + val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) + val t0 = System.nanoTime() + val gaps = loadAdjGaps(asOfExclusive) + val auto = gaps.filter { it.missIn.compareTo(BigDecimal.ZERO) != 0 || it.missOut.compareTo(BigDecimal.ZERO) != 0 } + val sumIn = auto.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } + val sumOut = auto.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } + val limit = rowLimit.coerceIn(0, 50) + log.info( + "stock-ledger-fix adj preview lots={} in={} out={} revIn={} revOut={} {}ms", + auto.size, + auto.count { it.missIn > BigDecimal.ZERO }, + auto.count { it.missOut > BigDecimal.ZERO }, + auto.count { it.missIn < BigDecimal.ZERO }, + auto.count { it.missOut < BigDecimal.ZERO }, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixAdjPreview( + adjDate = adjDate.toString(), + lotCount = auto.size, + adjInCount = auto.count { it.missIn > BigDecimal.ZERO }, + adjOutCount = auto.count { it.missOut > BigDecimal.ZERO }, + skippedNegCount = auto.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO }, + sumMissIn = sumIn.stripTrailingZeros().toPlainString(), + sumMissOut = sumOut.stripTrailingZeros().toPlainString(), + skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(), + rows = auto.take(limit).map { it.toRow() }, + ) + } + + open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse { + if (!inFlight.add(runLock)) { + throw IllegalStateException("fix already running") + } + val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) + val t0 = System.nanoTime() + try { + val gaps = loadAdjGaps(asOfExclusive) + val auto = gaps.filter { it.missIn.compareTo(BigDecimal.ZERO) != 0 || it.missOut.compareTo(BigDecimal.ZERO) != 0 } + var insertedIn = 0 + var insertedOut = 0 + if (auto.isNotEmpty()) { + val insTx = TransactionTemplate(transactionManager) + insTx.timeout = 300 + insTx.execute { + auto.forEach { g -> + if (g.missIn.compareTo(BigDecimal.ZERO) > 0) { + insertAdjLedger(g, adjDate, inQty = g.missIn, outQty = null) + insertedIn++ + } else if (g.missIn.compareTo(BigDecimal.ZERO) < 0) { + insertAdjLedger(g, adjDate, inQty = null, outQty = g.missIn.negate()) + insertedOut++ + } + if (g.missOut.compareTo(BigDecimal.ZERO) > 0) { + insertAdjLedger(g, adjDate, inQty = null, outQty = g.missOut) + insertedOut++ + } else if (g.missOut.compareTo(BigDecimal.ZERO) < 0) { + insertAdjLedger(g, adjDate, inQty = g.missOut.negate(), outQty = null) + insertedIn++ + } + } + } + } + val lotIds = linkedSetOf() + fun addLot(lotId: Long, invId: Long?) { + if (lotId > 0) lotIds.add(lotId) + } + auto.forEach { addLot(it.lotLineId, it.inventoryId) } + jdbcDao.queryForList( + """ + SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :adjDate AND date < :asOf + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + """.trimIndent(), + mapOf("adjDate" to adjDate, "asOf" to asOfExclusive), + ).forEach { addLot(toLong(it["lotId"]), toLongOrNull(it["invId"])) } + val filledLotQty = timed("adj 2.4") { fillLotQtyForLotIds(lotIds.toList(), asOfExclusive) } + val filledBalance = timed("adj 2.5") { fillBalanceOnNewAdjRows(adjDate, asOfExclusive) } + val range = if (lotIds.isEmpty()) null else dateRangeForFilter( + "inventoryLotLineId IN (:lotIds)", + mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()), + ) + val dayRows = if (range != null && lotIds.isNotEmpty()) { + timed("adj 2.6") { upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } + } else 0 + log.info( + "stock-ledger-fix adj {} in={} out={} lotQty={} bal={} day={} total={}ms", + adjDate, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixAdjResponse( + adjDate = adjDate.toString(), + insertedIn = insertedIn, + insertedOut = insertedOut, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRows, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + inFlight.remove(runLock) + } + } + + /** + * No wrapping transaction: each step auto-commits so a later timeout + * (typically 2.5) does not roll back 2.1–2.4 lot fills. + * [stepsRaw] empty / all = 2.1–2.6; otherwise only those keys (e.g. 2.3). + */ + open fun fixDay(dateRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { + val day = parseDay(dateRaw) + // Allow today (freeze-night dump fix); reject future only. + if (day.isAfter(LocalDate.now())) { + throw IllegalArgumentException("cannot fix future dates") + } + val key = day.toString() + val steps = parseDaySteps(stepsRaw) + if (!inFlight.add(runLock)) { + throw IllegalStateException("fix already running") + } + val t0 = System.nanoTime() + try { + val args = dayArgs(day) + val filledLotLineId = if ("2.1" in steps) timed("2.1 fillLotLineId") { fillLotLineId(args) } else 0 + val filledUomId = if ("2.2" in steps) timed("2.2 fillUomId") { fillUomId(args) } else 0 + val filledInventoryId = if ("2.3" in steps) timed("2.3 fillInventoryId") { fillInventoryId(args) } else 0 + val filledLotQty = if ("2.4" in steps) timed("2.4 fillLotQty") { fillLotQty(args) } else 0 + val filledBalance = if ("2.5" in steps) timed("2.5 fillBalance") { fillBalance(args) } else 0 + val dayRowsWritten = if ("2.6" in steps) { + timed("2.6 upsertStockLotDay") { + stockLotDayCloseService.upsertDayForFix(day).rowsWritten + } + } else { + 0 + } + val still = stillMiss(args) + log.info( + "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", + key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, + filledLotQty, filledBalance, dayRowsWritten, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = key, + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRowsWritten, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn( + "stock-ledger-fix {} FAILED after {}ms: {}", + key, (System.nanoTime() - t0) / 1_000_000, e.message, + ) + throw e + } finally { + inFlight.remove(runLock) + } + } + + /** + * One HTTP call for [from]..[to] inclusive (before today). + * 2.1–2.5 use a single date window (same SQL as [fixDay]); 2.6 still walks days + * because stock_lot_day carry depends on the previous close. + */ + open fun fixRange(fromRaw: String, toRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { + val from = parseDay(fromRaw) + val to = parseDay(toRaw) + if (to.isBefore(from)) { + throw IllegalArgumentException("to must be on or after from") + } + if (from.plusDays(400).isBefore(to)) { + throw IllegalArgumentException("date range must be at most 400 days") + } + if (to.isAfter(LocalDate.now())) { + throw IllegalArgumentException("cannot fix future dates") + } + val key = "$from..$to" + val steps = parseDaySteps(stepsRaw) + if (!inFlight.add(runLock)) { + throw IllegalStateException("fix already running") + } + val t0 = System.nanoTime() + try { + val args = rangeArgs(from, to) + val filledLotLineId = if ("2.1" in steps) timed("range 2.1 fillLotLineId") { fillLotLineId(args) } else 0 + val filledUomId = if ("2.2" in steps) timed("range 2.2 fillUomId") { fillUomId(args) } else 0 + val filledInventoryId = if ("2.3" in steps) timed("range 2.3 fillInventoryId") { fillInventoryId(args) } else 0 + val filledLotQty = if ("2.4" in steps) timed("range 2.4 fillLotQty") { fillLotQty(args) } else 0 + val filledBalance = if ("2.5" in steps) timed("range 2.5 fillBalance") { fillBalance(args) } else 0 + val dayRowsWritten = if ("2.6" in steps) { + timed("range 2.6 upsertStockLotDay") { + var n = 0 + var d = from + while (!d.isAfter(to)) { + n += stockLotDayCloseService.upsertDayForFix(d).rowsWritten + d = d.plusDays(1) + } + n + } + } else { + 0 + } + val still = stillMiss(args) + log.info( + "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", + key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, + filledLotQty, filledBalance, dayRowsWritten, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = key, + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRowsWritten, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn( + "stock-ledger-fix {} FAILED after {}ms: {}", + key, (System.nanoTime() - t0) / 1_000_000, e.message, + ) + throw e + } finally { + inFlight.remove(runLock) + } + } + + /** 2.1 SOL / SIL lot line; one-line fallback when SIL has lot header only. */ + private fun fillLotLineId(args: Map): Int { + val fromSolSil = timed("2.1a solSil") { + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + """.trimIndent(), + args, + ) + } + val fromOneLine = timed("2.1b oneLine") { + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN ( + SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId + FROM inventory_lot_line ill + INNER JOIN ( + SELECT DISTINCT sil2.inventoryLotId + FROM stock_ledger slx + INNER JOIN stock_in_line sil2 + ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 + WHERE slx.deleted = 0 + AND slx.date >= :day AND slx.date < :dayNext + AND slx.inventoryLotLineId IS NULL + AND sil2.inventoryLotLineId IS NULL + AND sil2.inventoryLotId IS NOT NULL + ) hdr ON hdr.inventoryLotId = ill.inventoryLotId + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY ill.inventoryLotId + HAVING COUNT(*) = 1 + ) one ON one.inventoryLotId = sil.inventoryLotId + SET sl.inventoryLotLineId = one.onlyLineId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + """.trimIndent(), + args, + ) + } + return fromSolSil + fromOneLine + } + + /** 2.2 lot line stock UOM → uom_conversion id. Include deleted item_uom (historical lots still point there). */ + private fun fillUomId(args: Map): Int { + return jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN inventory_lot_line ill + ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + SET sl.uomId = iu.uomId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + } + + /** 2.3 inventory by (itemId, uomId) from 1.0 MySQL step. */ + private fun fillInventoryId(args: Map): Int { + return jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId + FROM inventory i + INNER JOIN ( + SELECT DISTINCT itemId, uomId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + AND itemId IS NOT NULL + AND uomId IS NOT NULL + ) t ON t.itemId = i.itemId AND t.uomId = i.uomId + WHERE IFNULL(i.deleted, 0) = 0 + GROUP BY i.itemId, i.uomId + ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + """.trimIndent(), + args, + ) + } + + /** + * 2.4 Opening = last lotQtyAfter before the window start (yesterday first, then lookback). + * Idle days must not reset to 0. Window is :day .. :dayNext (one day or a range). + */ + private fun fillLotQty(args: Map): Int { + return withTempTx { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_today ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_miss ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_prev ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, + prevAfter DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_qty ( + id INT NOT NULL PRIMARY KEY, + lotAfter DECIMAL(14,2) NOT NULL, + delta DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_today (inventoryLotLineId) + SELECT DISTINCT inventoryLotLineId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + AND inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + timed("2.4a1 yesterday") { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) + SELECT slp.inventoryLotLineId, slp.lotQtyAfter + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId + FROM stock_ledger sl + INNER JOIN tmp_sl_fix_lot_today t + ON t.inventoryLotLineId = sl.inventoryLotLineId + WHERE sl.deleted = 0 + AND sl.date >= :prevDay AND sl.date < :day + AND sl.lotQtyAfter IS NOT NULL + GROUP BY sl.inventoryLotLineId + ) m ON m.maxId = slp.id + """.trimIndent(), + args, + ) + } + timed("2.4a2 lookback") { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_miss (inventoryLotLineId) + SELECT t.inventoryLotLineId + FROM tmp_sl_fix_lot_today t + LEFT JOIN tmp_sl_fix_lot_prev p + ON p.inventoryLotLineId = t.inventoryLotLineId + WHERE p.inventoryLotLineId IS NULL + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) + SELECT slp.inventoryLotLineId, slp.lotQtyAfter + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :prevDay + AND sl.lotQtyAfter IS NOT NULL + AND sl.inventoryLotLineId IN ( + SELECT inventoryLotLineId FROM tmp_sl_fix_lot_miss + ) + GROUP BY sl.inventoryLotLineId + ) x ON x.maxId = slp.id + """.trimIndent(), + args, + ) + } + timed("2.4b window") { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_qty (id, lotAfter, delta) + SELECT + sl2.id, + CAST( + COALESCE(p.prevAfter, 0) + + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryLotLineId + ORDER BY sl2.date, sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS lotAfter, + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta + FROM stock_ledger sl2 + LEFT JOIN tmp_sl_fix_lot_prev p + ON p.inventoryLotLineId = sl2.inventoryLotLineId + WHERE sl2.deleted = 0 + AND sl2.date >= :day AND sl2.date < :dayNext + AND sl2.inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + } + timed("2.4c apply") { + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_qty t ON t.id = sl.id + SET sl.lotQtyAfter = t.lotAfter, + sl.lotQtyBefore = t.lotAfter - t.delta + """.trimIndent(), + ) + } + } finally { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") + } + } + } + + /** + * 2.5 Running balance per inventoryId. + * Opening = last balance before the window. Fast path: day before :day. Lookback only for + * inventoryIds that had no row that day (idle days must not reset to 0). + * Window is :day .. :dayNext (one day or a range). + */ + private fun fillBalance(args: Map): Int { + val tx = TransactionTemplate(transactionManager) + return tx.execute { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_today ( + inventoryId INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_miss ( + inventoryId INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_prev ( + inventoryId INT NOT NULL PRIMARY KEY, + prevBalance DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_bal ( + id INT NOT NULL PRIMARY KEY, + newBalance DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_today (inventoryId) + SELECT DISTINCT inventoryId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + AND inventoryId IS NOT NULL + """.trimIndent(), + args, + ) + timed("2.5a1 yesterday") { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) + SELECT slp.inventoryId, slp.balance + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryId, MAX(sl.id) AS maxId + FROM stock_ledger sl + INNER JOIN tmp_stock_ledger_fix_today t + ON t.inventoryId = sl.inventoryId + WHERE sl.deleted = 0 + AND sl.date >= :prevDay AND sl.date < :day + AND sl.balance IS NOT NULL + GROUP BY sl.inventoryId + ) m ON m.maxId = slp.id + """.trimIndent(), + args, + ) + } + timed("2.5a2 lookback") { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_miss (inventoryId) + SELECT t.inventoryId + FROM tmp_stock_ledger_fix_today t + LEFT JOIN tmp_stock_ledger_fix_prev p + ON p.inventoryId = t.inventoryId + WHERE p.inventoryId IS NULL + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) + SELECT slp.inventoryId, slp.balance + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryId, MAX(sl.id) AS maxId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :prevDay + AND sl.balance IS NOT NULL + AND sl.inventoryId IN ( + SELECT inventoryId FROM tmp_stock_ledger_fix_miss + ) + GROUP BY sl.inventoryId + ) x ON x.maxId = slp.id + """.trimIndent(), + args, + ) + } + timed("2.5b window") { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_bal (id, newBalance) + SELECT + sl2.id, + CAST( + COALESCE(p.prevBalance, 0) + + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryId + ORDER BY sl2.date, sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS newBalance + FROM stock_ledger sl2 + LEFT JOIN tmp_stock_ledger_fix_prev p + ON p.inventoryId = sl2.inventoryId + WHERE sl2.deleted = 0 + AND sl2.date >= :day AND sl2.date < :dayNext + AND sl2.inventoryId IS NOT NULL + """.trimIndent(), + args, + ) + } + timed("2.5c apply") { + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_stock_ledger_fix_bal t ON t.id = sl.id + SET sl.balance = t.newBalance + """.trimIndent(), + ) + } + } finally { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") + } + } ?: 0 + } + + private fun fillLotLineIdScope(args: Map, byInventory: Boolean): Int { + if (byInventory) return fillLotLineIdScopeByInventory(args) + val lotFilter = """ + AND ( + COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = :lotId + OR sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + ) + """.trimIndent() + val fromSolSil = jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + $lotFilter + """.trimIndent(), + args, + ) + val fromOneLine = timed("lot 2.1b oneLine") { + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN ( + SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId + FROM inventory_lot_line ill + INNER JOIN ( + SELECT DISTINCT sil2.inventoryLotId + FROM stock_ledger slx + INNER JOIN stock_in_line sil2 + ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 + WHERE slx.deleted = 0 + AND slx.date < :today + AND slx.inventoryLotLineId IS NULL + AND sil2.inventoryLotLineId IS NULL + AND sil2.inventoryLotId IS NOT NULL + AND sil2.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + ) hdr ON hdr.inventoryLotId = ill.inventoryLotId + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY ill.inventoryLotId + HAVING COUNT(*) = 1 + ) one ON one.inventoryLotId = sil.inventoryLotId + SET sl.inventoryLotLineId = one.onlyLineId + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + AND sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + """.trimIndent(), + args, + ) + } + return fromSolSil + fromOneLine + } + + /** Collect candidate ledger ids first — do not UPDATE with date < today OR …. */ + private fun fillLotLineIdScopeByInventory(args: Map): Int { + return withTempTx { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_ids ( + id INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_sil_lots ( + inventoryLotId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_one_line ( + inventoryLotId BIGINT NOT NULL PRIMARY KEY, + onlyLineId BIGINT NOT NULL + ) + """.trimIndent(), + ) + try { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND sl.inventoryId = :inventoryId + """.trimIndent(), + args, + ) + jdbcDao.executeUpdate( + """ + INSERT IGNORE INTO tmp_sl_fix_lot_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND sl.itemId = :itemId + AND (sl.uomId = :uomId OR sl.uomId IS NULL) + """.trimIndent(), + args, + ) + val fromSolSil = jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + WHERE sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_sil_lots (inventoryLotId) + SELECT DISTINCT sil.inventoryLotId + FROM tmp_sl_fix_lot_ids t + INNER JOIN stock_ledger sl ON sl.id = t.id + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + WHERE sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_one_line (inventoryLotId, onlyLineId) + SELECT ill.inventoryLotId, MIN(ill.id) + FROM inventory_lot_line ill + INNER JOIN tmp_sl_fix_sil_lots hdr ON hdr.inventoryLotId = ill.inventoryLotId + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY ill.inventoryLotId + HAVING COUNT(*) = 1 + """.trimIndent(), + ) + val fromOneLine = timed("inv 2.1b oneLine") { + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN tmp_sl_fix_one_line one ON one.inventoryLotId = sil.inventoryLotId + SET sl.inventoryLotLineId = one.onlyLineId + WHERE sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + """.trimIndent(), + ) + } + fromSolSil + fromOneLine + } finally { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") + } + } + } + + private fun fillUomIdScope(args: Map, byInventory: Boolean): Int { + if (!byInventory) { + return jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN inventory_lot_line ill + ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + SET sl.uomId = iu.uomId + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId = :lotId + """.trimIndent(), + args, + ) + } + return withTempTx { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_uom_lots ( + id BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_uom_ids ( + id INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + try { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_uom_lots (id) + SELECT DISTINCT inventoryLotLineId + FROM stock_ledger + WHERE deleted = 0 + AND date < :today + AND inventoryId = :inventoryId + AND inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_uom_ids (id) + SELECT sl.id + FROM stock_ledger sl + INNER JOIN tmp_sl_fix_uom_lots t ON t.id = sl.inventoryLotLineId + WHERE sl.deleted = 0 + AND sl.date < :today + """.trimIndent(), + args, + ) + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_uom_ids t ON t.id = sl.id + INNER JOIN inventory_lot_line ill + ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + SET sl.uomId = iu.uomId + """.trimIndent(), + ) + } finally { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") + } + } + } + + private fun fillInventoryIdScope(args: Map, byInventory: Boolean): Int { + if (!byInventory) { + return jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId + FROM inventory i + WHERE IFNULL(i.deleted, 0) = 0 + GROUP BY i.itemId, i.uomId + ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND sl.inventoryLotLineId = :lotId + """.trimIndent(), + args, + ) + } + return withTempTx { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_inv_ids ( + id INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + try { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_inv_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryId = :inventoryId + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + """.trimIndent(), + args, + ) + jdbcDao.executeUpdate( + """ + INSERT IGNORE INTO tmp_sl_fix_inv_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.itemId = :itemId + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + """.trimIndent(), + args, + ) + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_inv_ids t ON t.id = sl.id + INNER JOIN ( + SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId + FROM inventory i + WHERE IFNULL(i.deleted, 0) = 0 + GROUP BY i.itemId, i.uomId + ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + """.trimIndent(), + ) + } finally { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") + } + } + } + + private fun fillLotQtyHistory(args: Map, byInventory: Boolean): Int { + val extra = if (byInventory) "AND sl2.inventoryId = :inventoryId" else "AND sl2.inventoryLotLineId = :lotId" + val extraUpd = if (byInventory) "AND sl.inventoryId = :inventoryId" else "AND sl.inventoryLotLineId = :lotId" + return jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT + sl2.id, + CAST( + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryLotLineId + ORDER BY sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS lotAfter, + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta + FROM stock_ledger sl2 + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryLotLineId IS NOT NULL + $extra + ) calc ON calc.id = sl.id + SET sl.lotQtyAfter = calc.lotAfter, + sl.lotQtyBefore = calc.lotAfter - calc.delta + WHERE sl.deleted = 0 + AND sl.date < :today + $extraUpd + """.trimIndent(), + args, + ) + } + + private fun fillBalanceHistory(args: Map): Int { + return jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT + sl2.id, + CAST( + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryId + ORDER BY sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS newBalance + FROM stock_ledger sl2 + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryId = :inventoryId + ) calc ON calc.id = sl.id + SET sl.balance = calc.newBalance + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryId = :inventoryId + """.trimIndent(), + args, + ) + } + + private fun lotIdsForInventory(inventoryId: Long, today: LocalDate): List { + return jdbcDao.queryForList( + """ + SELECT DISTINCT inventoryLotLineId AS id + FROM stock_ledger + WHERE deleted = 0 + AND inventoryId = :inventoryId + AND inventoryLotLineId IS NOT NULL + AND date < :today + """.trimIndent(), + mapOf("inventoryId" to inventoryId, "today" to today), + ).map { toLong(it["id"]) }.filter { it > 0 } + } + + private fun dateRangeForFilter(filter: String, args: Map): Pair? { + val row = jdbcDao.queryForList( + """ + SELECT MIN(date) AS firstDate, MAX(date) AS lastDate + FROM stock_ledger + WHERE deleted = 0 + AND date < :today + AND $filter + """.trimIndent(), + args, + ).firstOrNull() ?: return null + val first = toLocalDate(row["firstDate"]) ?: return null + val last = toLocalDate(row["lastDate"]) ?: return null + val today = args["today"] as LocalDate + val toExclusive = last.plusDays(1).let { if (it.isAfter(today)) today else it } + return first to toExclusive + } + + private fun upsertStockLotDayRange(lotIds: List, from: LocalDate, toExclusive: LocalDate): Int { + if (lotIds.isEmpty()) return 0 + return jdbcDao.executeUpdate( + """ + INSERT INTO stock_lot_day ( + created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + ) + SELECT + NOW(), 'ledger-fix-api', 0, NOW(), 'ledger-fix-api', 0, + x.inventoryLotLineId, + x.itemId, + x.itemCode, + x.uomId, + il.lotNo, + x.d, + firstSl.lotQtyBefore, + x.inQty, + x.outQty, + lastSl.lotQtyAfter + FROM ( + SELECT + sl.inventoryLotLineId, + CAST(sl.date AS DATE) AS d, + MIN(sl.itemId) AS itemId, + MIN(sl.itemCode) AS itemCode, + MIN(sl.uomId) AS uomId, + MIN(sl.id) AS firstId, + MAX(sl.id) AS lastId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.inventoryLotLineId IN (:lotIds) + AND sl.lotQtyAfter IS NOT NULL + GROUP BY sl.inventoryLotLineId, CAST(sl.date AS DATE) + ) x + INNER JOIN stock_ledger firstSl ON firstSl.id = x.firstId + INNER JOIN stock_ledger lastSl ON lastSl.id = x.lastId + LEFT JOIN inventory_lot_line ill ON ill.id = x.inventoryLotLineId + LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId + ON DUPLICATE KEY UPDATE + itemId = VALUES(itemId), + itemCode = VALUES(itemCode), + uomId = VALUES(uomId), + lotNo = VALUES(lotNo), + opening = VALUES(opening), + inQty = VALUES(inQty), + outQty = VALUES(outQty), + closing = VALUES(closing), + modified = NOW(), + modifiedBy = 'ledger-fix-api', + version = stock_lot_day.version + 1, + deleted = 0 + """.trimIndent(), + mapOf( + "lotIds" to lotIds, + "from" to from, + "toExclusive" to toExclusive, + ), + ) + } + + private data class ScopeStats( + val cnt: Int, + val firstDate: String?, + val lastDate: String?, + val lastBalance: String?, + val lastLotQtyAfter: String?, + val lastInventoryId: Any?, + val parts: List, + ) + + private fun ledgerScopeStats( + filter: String, + args: Map, + itemId: Long? = null, + ): ScopeStats { + val ledger = jdbcDao.queryForList( + """ + SELECT + COUNT(*) AS cnt, + DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate, + DATE_FORMAT(MAX(sl.date), '%Y-%m-%d') AS lastDate, + SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, + SUM(sl.uomId IS NULL) AS uomMiss, + SUM(sl.inventoryId IS NULL) AS invMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) + ) AS lotQtyMissWithLot, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + ) AS lotQtyIncorrect, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) + ) AS overIssue, + SUM(sl.balance IS NULL) AS balanceMiss, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + ) AS fixable21a, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) = 1 + ) AS fixable21b, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) > 1 + ) AS cannotMultiLine, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) + ) AS cannotNoSource + FROM stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + LEFT JOIN ( + SELECT inventoryLotId, COUNT(*) AS lineCnt + FROM inventory_lot_line + WHERE IFNULL(deleted, 0) = 0 + GROUP BY inventoryLotId + ) lc ON lc.inventoryLotId = sil.inventoryLotId + WHERE sl.deleted = 0 + AND sl.date < :today + AND $filter + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + val last = jdbcDao.queryForList( + """ + SELECT sl.balance, sl.lotQtyAfter, sl.inventoryId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND $filter + ORDER BY sl.id DESC + LIMIT 1 + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + val cnt = toInt(ledger["cnt"]) + val overIssue = toInt(ledger["overIssue"]) + val earlyTke = if (overIssue > 0) overIssueEarlyTkeRows(filter, args, itemId) else 0 + return ScopeStats( + cnt = cnt, + firstDate = ledger["firstDate"]?.toString(), + lastDate = ledger["lastDate"]?.toString(), + lastBalance = last["balance"]?.toString(), + lastLotQtyAfter = last["lotQtyAfter"]?.toString(), + lastInventoryId = last["inventoryId"], + parts = fieldAndReasonParts( + cnt = cnt, + lotMiss = toInt(ledger["lotMiss"]), + lotIncorrect = 0, + uomMiss = toInt(ledger["uomMiss"]), + uomIncorrect = 0, + invMiss = toInt(ledger["invMiss"]), + invIncorrect = 0, + lotQtyMissWithLot = toInt(ledger["lotQtyMissWithLot"]), + lotQtyIncorrect = toInt(ledger["lotQtyIncorrect"]), + overIssue = overIssue, + balanceMiss = toInt(ledger["balanceMiss"]), + fixable21a = toInt(ledger["fixable21a"]), + fixable21b = toInt(ledger["fixable21b"]), + cannotMultiLine = toInt(ledger["cannotMultiLine"]), + cannotNoSource = toInt(ledger["cannotNoSource"]), + earlyTkeOver = earlyTke, + otherOver = (overIssue - earlyTke).coerceAtLeast(0), + ), + ) + } + + private fun stillMissScope(filter: String, args: Map): MissCounts { + val row = jdbcDao.queryForList( + """ + SELECT + SUM(inventoryLotLineId IS NULL) AS missLot, + SUM(uomId IS NULL) AS missUom, + SUM(inventoryId IS NULL) AS missInventoryId, + SUM(lotQtyAfter IS NULL) AS missLotQty + FROM stock_ledger + WHERE deleted = 0 + AND date < :today + AND $filter + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + return MissCounts( + missLot = toInt(row["missLot"]), + missUom = toInt(row["missUom"]), + missInventoryId = toInt(row["missInventoryId"]), + missLotQty = toInt(row["missLotQty"]), + ) + } + + private fun stillMiss(args: Map): MissCounts { + val row = jdbcDao.queryForList( + """ + SELECT + SUM(inventoryLotLineId IS NULL) AS missLot, + SUM(uomId IS NULL) AS missUom, + SUM(inventoryId IS NULL) AS missInventoryId, + SUM(lotQtyAfter IS NULL) AS missLotQty + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + return MissCounts( + missLot = toInt(row["missLot"]), + missUom = toInt(row["missUom"]), + missInventoryId = toInt(row["missInventoryId"]), + missLotQty = toInt(row["missLotQty"]), + ) + } + + private fun fieldAndReasonParts( + cnt: Int, + lotMiss: Int, + lotIncorrect: Int, + uomMiss: Int, + uomIncorrect: Int, + invMiss: Int, + invIncorrect: Int, + lotQtyMissWithLot: Int, + lotQtyIncorrect: Int, + overIssue: Int, + balanceMiss: Int, + fixable21a: Int, + fixable21b: Int, + cannotMultiLine: Int, + cannotNoSource: Int, + earlyTkeOver: Int, + otherOver: Int, + extra: List = emptyList(), + ): List { + fun field(key: String, label: String, miss: Int, incorrect: Int) = + StockLedgerFixCheckPart( + key = key, + label = label, + ok = (cnt - miss - incorrect).coerceAtLeast(0), + miss = miss, + incorrect = incorrect, + group = "field", + ) + fun canFix(key: String, label: String, n: Int) = + StockLedgerFixCheckPart(key, label, ok = 0, miss = n, incorrect = 0, group = "canFix") + fun cannotFix(key: String, label: String, n: Int) = + StockLedgerFixCheckPart(key, label, ok = 0, miss = 0, incorrect = n, group = "cannotFix") + return buildList { + add(field("lot", "inventoryLotLineId", lotMiss, lotIncorrect)) + add(field("uom", "uomId", uomMiss, uomIncorrect)) + add(field("inventory", "inventoryId", invMiss, invIncorrect)) + add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect)) + add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue)) + add(field("balance", "balance", balanceMiss, 0)) + addAll(extra) + add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a)) + add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b)) + add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot)) + add(cannotFix("cannotNoSource", "不能修:沒有 SIL/SOL lot 來源", cannotNoSource)) + add(cannotFix("cannotMultiLine", "不能修:同一 lot 有多條 warehouse line", cannotMultiLine)) + add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) + add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) + } + } + + private fun overIssueEarlyTkeRows(filter: String, args: Map, itemId: Long?): Int { + if (itemId == null || itemId <= 0) return 0 + val q = args.toMutableMap() + q["itemId"] = itemId + return toInt( + jdbcDao.queryForList( + """ + SELECT SUM(ok) AS c FROM ( + SELECT EXISTS ( + SELECT 1 + FROM inventory_lot_line ill2 + INNER JOIN inventory_lot il2 + ON il2.id = ill2.inventoryLotId AND il2.itemId = :itemId + INNER JOIN ( + SELECT sl3.inventoryLotLineId + FROM stock_ledger sl3 + INNER JOIN inventory_lot_line ill3 ON ill3.id = sl3.inventoryLotLineId + INNER JOIN inventory_lot il3 ON il3.id = ill3.inventoryLotId AND il3.itemId = :itemId + WHERE sl3.deleted = 0 + AND sl3.date < :today + GROUP BY sl3.inventoryLotLineId + HAVING SUM(COALESCE(sl3.inQty, 0)) > 0 + AND SUM(CASE WHEN sl3.type = 'TKE' AND sl3.inQty > 0 THEN sl3.inQty ELSE 0 END) + >= SUM(COALESCE(sl3.inQty, 0)) * 0.99 + ) tke ON tke.inventoryLotLineId = ill2.id + WHERE IFNULL(ill2.deleted, 0) = 0 + AND ill2.warehouseId = ill.warehouseId + AND ill2.id <> sl.inventoryLotLineId + ) AS ok + FROM stock_ledger sl + INNER JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId + WHERE sl.deleted = 0 + AND sl.date < :today + AND $filter + AND sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) + ) x + """.trimIndent(), + q, + ).firstOrNull()?.get("c"), + ) + } + + /** + * Export SQL patch for target DB (same dump lineage / freeze). + * [partsRaw] omit / empty / all = ledger + 2.6 (legacy). Else subset: + * 1.0 inventory, 2.3 inventoryId, ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 ADJ INSERT. + * Apply order in file: 1.0 → ledger(+2.3) → 2.7 → 2.6. + */ + open fun writeExportSql( + from: LocalDate, + to: LocalDate, + out: OutputStream, + partsRaw: List? = null, + ) { + if (to.isBefore(from)) throw IllegalArgumentException("to must be on or after from") + if (from.plusDays(400).isBefore(to)) { + throw IllegalArgumentException("date range must be at most 400 days") + } + val today = LocalDate.now() + if (to.isAfter(today)) { + throw IllegalArgumentException("cannot export future dates") + } + val toExclusive = to.plusDays(1) + if (!from.isBefore(toExclusive)) { + throw IllegalArgumentException("from must be before toExclusive") + } + val parts = parseExportParts(partsRaw) + val want10 = "1.0" in parts + val want23 = "2.3" in parts + val wantLedger = "ledger" in parts + val want26 = "2.6" in parts + val want27 = "2.7" in parts + if (!want10 && !want23 && !wantLedger && !want26 && !want27) { + throw IllegalArgumentException("parts must include at least one of: 1.0, 2.3, ledger, 2.6, 2.7") + } + val args = mapOf("from" to from, "toExclusive" to toExclusive) + val ledgerCnt = if (wantLedger || want23 || want27) { + toInt( + jdbcDao.queryForList( + """ + SELECT COUNT(*) AS c FROM stock_ledger + WHERE deleted = 0 AND date >= :from AND date < :toExclusive + """.trimIndent(), + args, + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val dayCnt = if (want26) { + toInt( + jdbcDao.queryForList( + """ + SELECT COUNT(*) AS c FROM stock_lot_day + WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive + """.trimIndent(), + args, + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val adjCnt = if (want27) { + toInt( + jdbcDao.queryForList( + """ + SELECT COUNT(*) AS c FROM stock_ledger + WHERE deleted = 0 + AND date >= :from AND date < :toExclusive + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + """.trimIndent(), + args, + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val invCnt = if (want10) { + toInt( + jdbcDao.queryForList( + "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8), 65536) + val jdbc = NamedParameterJdbcTemplate(dataSource) + jdbc.jdbcTemplate.fetchSize = 2000 + try { + writer.write("-- stock-ledger-fix patch\n") + writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") + writer.write("-- parts=${parts.joinToString(",")}\n") + writer.write("-- apply order: 1.0 inventory → ledger(+2.3) UPDATE → 2.7 ADJ INSERT → 2.6 stock_lot_day\n") + writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") + if (want23 && !want10) { + writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n") + } + writer.write("-- please run in the target DB/schema, e.g. USE fpsmsdb;\n") + writer.write( + "-- expected inventoryRows=$invCnt ledgerRows=$ledgerCnt adjRows=$adjCnt stock_lot_dayRows=$dayCnt\n", + ) + writer.write("-- SET NAMES utf8mb4;\n\n") + + if (want10) { + writeExportInventory10(writer, jdbc) + } + if (wantLedger || want23) { + writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) + } + if (want27) { + writeExportAdjInsert(writer, jdbc, args) + } + if (want26) { + writeExportStockLotDay(writer, jdbc, args, from, toExclusive) + } + + writer.write("\n-- done\n") + writer.flush() + log.info( + "stock-ledger-fix export {}..{} parts={} inv={} ledger={} adj={} day={}", + from, toExclusive.minusDays(1), parts.joinToString(","), invCnt, ledgerCnt, adjCnt, dayCnt, + ) + } finally { + writer.flush() + } + } + + private fun writeExportInventory10(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) { + writer.write("-- === 1.0 inventory (INSERT with fixed id + UPDATE qty/status) ===\n") + var batch = 0 + jdbc.query( + """ + SELECT id, created, createdBy, version, modified, modifiedBy, deleted, + itemId, uomId, onHandQty, onHoldQty, unavailableQty, + price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + ORDER BY id + """.trimIndent(), + emptyMap(), + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write( + "INSERT INTO inventory (\n" + + " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + + " itemId, uomId, onHandQty, onHoldQty, unavailableQty,\n" + + " price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status\n" + + ") VALUES\n", + ) + } else { + writer.write(",\n") + } + writer.write( + "(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + + "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + + "${rsSql(rs, "itemId")}, ${rsSql(rs, "uomId")}, ${rsSql(rs, "onHandQty")}, " + + "${rsSql(rs, "onHoldQty")}, ${rsSql(rs, "unavailableQty")}, ${rsSql(rs, "price")}, " + + "${rsSql(rs, "currencyId")}, ${rsSql(rs, "cpu")}, ${rsSql(rs, "cpuUnit")}, " + + "${rsSql(rs, "cpm")}, ${rsSql(rs, "cpmUnit")}, ${rsSql(rs, "status")})", + ) + batch++ + if (batch >= 200) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + + "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + + "price=VALUES(price), currencyId=VALUES(currencyId), " + + "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=inventory.version+1, deleted=0;\n\n", + ) + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + + "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + + "price=VALUES(price), currencyId=VALUES(currencyId), " + + "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=inventory.version+1, deleted=0;\n\n", + ) + } else { + writer.write("-- (no inventory rows)\n\n") + } + } + + private fun writeExportLedgerPatch( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + wantLedger: Boolean, + want23: Boolean, + ) { + writer.write("-- === ledger UPDATE (JOIN id)") + if (wantLedger) writer.write(" lot/uom/lotQty/balance") + if (want23) writer.write(" inventoryId") + writer.write(" ===\n") + val cols = mutableListOf("id") + if (wantLedger) { + cols += listOf("inventoryLotLineId", "uomId", "lotQtyBefore", "lotQtyAfter", "balance") + } + if (want23) cols += "inventoryId" + writer.write("CREATE TEMPORARY TABLE tmp_sl_fix_patch (\n") + writer.write(" id INT NOT NULL PRIMARY KEY") + if (wantLedger) { + writer.write(",\n inventoryLotLineId BIGINT NULL,\n uomId INT NULL,\n") + writer.write(" lotQtyBefore DECIMAL(14,2) NULL,\n lotQtyAfter DECIMAL(14,2) NULL,\n") + writer.write(" balance DECIMAL(14,2) NULL") + } + if (want23) writer.write(",\n inventoryId INT NULL") + writer.write("\n);\n\n") + var batch = 0 + val selectCols = cols.joinToString(", ") + jdbc.query( + """ + SELECT $selectCols + FROM stock_ledger + WHERE deleted = 0 AND date >= :from AND date < :toExclusive + """.trimIndent(), + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write("INSERT INTO tmp_sl_fix_patch (${cols.joinToString(", ")}) VALUES\n") + } else { + writer.write(",\n") + } + writer.write("(" + cols.joinToString(", ") { rsSql(rs, it) } + ")") + batch++ + if (batch >= 500) { + writer.write(";\n\n") + batch = 0 + } + }, + ) + if (batch > 0) writer.write(";\n\n") + val setParts = mutableListOf() + if (wantLedger) { + setParts += listOf( + "sl.inventoryLotLineId = t.inventoryLotLineId", + "sl.uomId = t.uomId", + "sl.lotQtyBefore = t.lotQtyBefore", + "sl.lotQtyAfter = t.lotQtyAfter", + "sl.balance = t.balance", + ) + } + if (want23) setParts += "sl.inventoryId = t.inventoryId" + writer.write( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_patch t ON t.id = sl.id + SET + ${setParts.joinToString(",\n ")} + WHERE sl.deleted = 0; + + DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_patch; + + """.trimIndent(), + ) + writer.write("\n") + } + + private fun writeExportAdjInsert( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + ) { + writer.write("-- === 2.7 ADJ INSERT (fixed id; rows missing on target are created) ===\n") + var batch = 0 + jdbc.query( + """ + SELECT id, created, createdBy, version, modified, modifiedBy, deleted, + date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, + itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, + uomId, type + FROM stock_ledger + WHERE deleted = 0 + AND date >= :from AND date < :toExclusive + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + ORDER BY id + """.trimIndent(), + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write( + "INSERT INTO stock_ledger (\n" + + " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + + " date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,\n" + + " itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,\n" + + " uomId, type\n) VALUES\n", + ) + } else { + writer.write(",\n") + } + writer.write( + "(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + + "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + + "${rsSql(rs, "date")}, ${rsSql(rs, "stockInLineId")}, ${rsSql(rs, "stockOutLineId")}, " + + "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "inventoryId")}, " + + "${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + + "${rsSql(rs, "balance")}, ${rsSql(rs, "lotQtyBefore")}, ${rsSql(rs, "lotQtyAfter")}, " + + "${rsSql(rs, "uomId")}, ${rsSql(rs, "type")})", + ) + batch++ + if (batch >= 200) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + + "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + + "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + + "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_ledger.version+1, deleted=0;\n\n", + ) + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + + "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + + "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + + "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_ledger.version+1, deleted=0;\n\n", + ) + } else { + writer.write("-- (no ADJ rows from stock-ledger-fix in range)\n\n") + } + } + + private fun writeExportStockLotDay( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + from: LocalDate, + toExclusive: LocalDate, + ) { + writer.write("-- === 2.6 stock_lot_day ===\n") + writer.write( + """ + DELETE FROM stock_lot_day + WHERE `date` >= '$from' AND `date` < '$toExclusive'; + + """.trimIndent(), + ) + writer.write("\n") + var batch = 0 + jdbc.query( + """ + SELECT created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + FROM stock_lot_day + WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive + ORDER BY date, inventoryLotLineId + """.trimIndent(), + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write( + "INSERT INTO stock_lot_day (\n" + + " created, createdBy, version, modified, modifiedBy, deleted,\n" + + " inventoryLotLineId, itemId, itemCode, uomId, lotNo, `date`,\n" + + " opening, inQty, outQty, closing\n) VALUES\n", + ) + } else { + writer.write(",\n") + } + writer.write( + "(${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + + "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + + "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, " + + "${rsSql(rs, "uomId")}, ${rsSql(rs, "lotNo")}, ${rsSql(rs, "date")}, " + + "${rsSql(rs, "opening")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + + "${rsSql(rs, "closing")})", + ) + batch++ + if (batch >= 500) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " + + "lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " + + "outQty=VALUES(outQty), closing=VALUES(closing), " + + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_lot_day.version+1, deleted=0;\n\n", + ) + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " + + "lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " + + "outQty=VALUES(outQty), closing=VALUES(closing), " + + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_lot_day.version+1, deleted=0;\n", + ) + } + } + + private fun parseExportParts(raw: List?): Set { + if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { + return EXPORT_PARTS_DEFAULT + } + val flat = raw.flatMap { it.split(",") }.map { it.trim() }.filter { it.isNotEmpty() } + if (flat.isEmpty() || flat.any { it.equals("all", ignoreCase = true) }) { + return EXPORT_PARTS_DEFAULT + } + val out = linkedSetOf() + flat.forEach { token -> + val t = token.lowercase() + val key = when (t) { + "1.0", "10", "inventory" -> "1.0" + "2.3", "23", "inventoryid" -> "2.3" + "ledger", "2.1", "2.2", "2.4", "2.5" -> "ledger" + "2.6", "26", "day", "stock_lot_day" -> "2.6" + "2.7", "27", "adj" -> "2.7" + else -> throw IllegalArgumentException( + "unknown export part '$token' (use 1.0, 2.3, ledger, 2.6, 2.7, or all)", + ) + } + out.add(key) + } + if (out.isEmpty()) throw IllegalArgumentException("parts must not be empty") + return out + } + + private fun rsSql(rs: ResultSet, col: String): String { + val v = rs.getObject(col) + if (v == null || rs.wasNull()) return "NULL" + return when (v) { + is BigDecimal -> v.stripTrailingZeros().toPlainString() + is Number -> v.toString() + is java.sql.Date -> "'${v.toLocalDate()}'" + is java.sql.Timestamp -> "'${v.toLocalDateTime().toString().replace('T', ' ')}'" + is Boolean -> if (v) "1" else "0" + else -> "'" + v.toString().replace("\\", "\\\\").replace("'", "''") + "'" + } + } + + private fun dayArgs(day: LocalDate): Map = + mapOf( + "day" to day, + "dayNext" to day.plusDays(1), + "prevDay" to day.minusDays(1), + ) + + /** Inclusive [from]..[to]; same keys as [dayArgs] so 2.1–2.5 SQL can reuse the day window. */ + private fun rangeArgs(from: LocalDate, to: LocalDate): Map = + mapOf( + "day" to from, + "dayNext" to to.plusDays(1), + "prevDay" to from.minusDays(1), + ) + + /** Same JDBC connection so TEMPORARY TABLEs survive across statements. */ + private fun withTempTx(block: () -> Int): Int { + return TransactionTemplate(transactionManager).execute { block() } ?: 0 + } + + private fun timed(name: String, block: () -> Int): Int { + val t0 = System.nanoTime() + return try { + val n = block() + log.info("stock-ledger-fix step {} rows={} {}ms", name, n, (System.nanoTime() - t0) / 1_000_000) + n + } catch (e: Exception) { + log.warn("stock-ledger-fix step {} FAILED {}ms: {}", name, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } + } + + private fun parseDay(raw: String): LocalDate { + val trimmed = raw.trim() + if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)") + return try { + LocalDate.parse(trimmed) + } catch (_: DateTimeParseException) { + throw IllegalArgumentException("date must be yyyy-MM-dd") + } + } + + /** + * ADJ window: ledger date = [adjDate], gap sums use `date < adjDate+1`. + * Default adjDate = yesterday. Pass today for freeze-night dump (e.g. 31/8). + */ + private fun resolveAdjWindow(adjDateRaw: String?): Pair { + val today = LocalDate.now() + val adjDate = if (adjDateRaw.isNullOrBlank()) { + today.minusDays(1) + } else { + parseDay(adjDateRaw) + } + if (adjDate.isAfter(today)) { + throw IllegalArgumentException("adjDate cannot be in the future") + } + return adjDate to adjDate.plusDays(1) + } + + private fun parseDaySteps(raw: List?): Set { + if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { + return DAY_STEPS + } + val out = linkedSetOf() + raw.forEach { token -> + val t = token.trim().lowercase() + val key = when (t) { + "2.1", "21" -> "2.1" + "2.2", "22" -> "2.2" + "2.3", "23" -> "2.3" + "2.4", "24" -> "2.4" + "2.5", "25" -> "2.5" + "2.6", "26" -> "2.6" + else -> throw IllegalArgumentException("unknown step '$token' (use 2.1–2.6 or all)") + } + out.add(key) + } + if (out.isEmpty()) throw IllegalArgumentException("steps must not be empty") + return out + } + + private fun toInt(value: Any?): Int = when (value) { + null -> 0 + is Number -> value.toInt() + else -> value.toString().toIntOrNull() ?: 0 + } + + private fun toLong(value: Any?): Long = toLongOrNull(value) ?: 0L + + private fun toLongOrNull(value: Any?): Long? = when (value) { + null -> null + is Number -> value.toLong() + else -> value.toString().toLongOrNull() + } + + private fun loadAdjGaps(today: LocalDate): List { + val tx = TransactionTemplate(transactionManager) + tx.timeout = 300 + val args = mapOf("today" to today) + return tx.execute { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_adj_line ( + lotLineId BIGINT NOT NULL PRIMARY KEY, + inventoryId INT NULL, + itemId INT NULL, + itemCode VARCHAR(64) NULL, + uomId INT NULL, + lineIn DECIMAL(14,2) NOT NULL, + lineOut DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_adj_led ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, + ledger_in DECIMAL(14,2) NOT NULL, + ledger_out DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + timed("adj line-all") { + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_adj_line ( + lotLineId, inventoryId, itemId, itemCode, uomId, lineIn, lineOut + ) + SELECT + ill.id, + inv.inventoryId, + il.itemId, + it.code, + iu.uomId, + CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)), + CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + LEFT JOIN items it ON it.id = il.itemId + LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId + LEFT JOIN ( + SELECT itemId, uomId, MIN(id) AS inventoryId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + GROUP BY itemId, uomId + ) inv ON inv.itemId = il.itemId AND inv.uomId = iu.uomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND ( + COALESCE(ill.inQty, 0) <> 0 + OR COALESCE(ill.outQty, 0) <> 0 + ) + """.trimIndent(), + ) + } + val lineIds = jdbcDao.queryForList( + "SELECT lotLineId FROM tmp_adj_line", + ).map { toLong(it["lotLineId"]) }.filter { it > 0 } + log.info("stock-ledger-fix adj line rows={}", lineIds.size) + if (lineIds.isEmpty()) return@execute emptyList() + timed("adj ledger-by-line") { + lineIds.chunked(40).sumOf { chunk -> + jdbcDao.executeUpdate( + """ + INSERT INTO tmp_adj_led (inventoryLotLineId, ledger_in, ledger_out) + SELECT + sl.inventoryLotLineId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)), + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) + FROM stock_ledger sl FORCE INDEX (idx_ledger_lot_date_id) + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IN (:ids) + GROUP BY sl.inventoryLotLineId + """.trimIndent(), + mapOf("today" to today, "ids" to chunk), + ) + } + } + jdbcDao.queryForList( + """ + SELECT + l.lotLineId, + l.inventoryId, + l.itemId, + l.itemCode, + l.uomId, + l.lineIn, + l.lineOut, + CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) AS ledgerIn, + CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) AS ledgerOut + FROM tmp_adj_line l + LEFT JOIN tmp_adj_led agg ON agg.inventoryLotLineId = l.lotLineId + WHERE l.lineIn <> CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) + OR l.lineOut <> CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) + """.trimIndent(), + ).map { row -> + val lineIn = toDecimal(row["lineIn"]) + val lineOut = toDecimal(row["lineOut"]) + val ledgerIn = toDecimal(row["ledgerIn"]) + val ledgerOut = toDecimal(row["ledgerOut"]) + AdjGap( + lotLineId = toLong(row["lotLineId"]), + inventoryId = toLongOrNull(row["inventoryId"]), + itemId = toLongOrNull(row["itemId"]), + itemCode = row["itemCode"]?.toString(), + uomId = toLongOrNull(row["uomId"]), + lineIn = lineIn, + lineOut = lineOut, + ledgerIn = ledgerIn, + ledgerOut = ledgerOut, + missIn = lineIn.subtract(ledgerIn), + missOut = lineOut.subtract(ledgerOut), + ) + } + } finally { + try { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") + } catch (e: Exception) { + log.warn("stock-ledger-fix drop tmp_adj skipped: {}", e.message) + } + } + } ?: emptyList() + } + + private fun insertAdjLedger(g: AdjGap, adjDate: LocalDate, inQty: BigDecimal?, outQty: BigDecimal?) { + val args = hashMapOf( + "adjDate" to adjDate, + "lotLineId" to g.lotLineId, + "inventoryId" to g.inventoryId, + "itemId" to g.itemId, + "itemCode" to g.itemCode, + "inQty" to inQty, + "outQty" to outQty, + "uomId" to g.uomId, + ) + jdbcDao.executeUpdate( + """ + INSERT INTO stock_ledger ( + created, createdBy, version, modified, modifiedBy, deleted, + date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, + itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, + uomId, type + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :adjDate, NULL, NULL, :lotLineId, :inventoryId, + :itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL, + :uomId, 'ADJ' + ) + """.trimIndent(), + args, + ) + } + + private fun fillLotQtyForLotIds(lotIds: List, today: LocalDate): Int { + if (lotIds.isEmpty()) return 0 + return withTempTx { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") + jdbcDao.executeUpdate( + "CREATE TEMPORARY TABLE tmp_sl_fix_adj_lots (id BIGINT NOT NULL PRIMARY KEY)", + ) + try { + lotIds.chunked(200).forEach { chunk -> + val values = chunk.joinToString(",") { "($it)" } + jdbcDao.executeUpdate("INSERT INTO tmp_sl_fix_adj_lots (id) VALUES $values") + } + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT + sl2.id, + CAST( + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryLotLineId + ORDER BY sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS lotAfter, + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta + FROM stock_ledger sl2 + INNER JOIN tmp_sl_fix_adj_lots t ON t.id = sl2.inventoryLotLineId + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryLotLineId IS NOT NULL + ) calc ON calc.id = sl.id + SET sl.lotQtyAfter = calc.lotAfter, + sl.lotQtyBefore = calc.lotAfter - calc.delta + WHERE sl.deleted = 0 + AND sl.date < :today + """.trimIndent(), + mapOf("today" to today), + ) + } finally { + try { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") + } catch (e: Exception) { + log.warn("stock-ledger-fix drop tmp_sl_fix_adj_lots skipped: {}", e.message) + } + } + } + } + + /** Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. */ + private fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { + val rows = jdbcDao.queryForList( + """ + SELECT id, inventoryId, + CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) AS inQty, + CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) AS outQty + FROM stock_ledger + WHERE deleted = 0 + AND date >= :adjDate AND date < :today + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + AND inventoryId IS NOT NULL + ORDER BY id + """.trimIndent(), + mapOf("adjDate" to adjDate, "today" to today), + ) + if (rows.isEmpty()) return 0 + val prevByInv = HashMap() + val updates = ArrayList>() + rows.forEach { row -> + val id = toLong(row["id"]) + val invId = toLong(row["inventoryId"]) + if (prevByInv[invId] == null) { + prevByInv[invId] = toDecimal( + jdbcDao.queryForList( + """ + SELECT balance + FROM stock_ledger + WHERE deleted = 0 + AND inventoryId = :invId + AND date < :today + AND id < :adjId + ORDER BY id DESC + LIMIT 1 + """.trimIndent(), + mapOf("invId" to invId, "today" to today, "adjId" to id), + ).firstOrNull()?.get("balance"), + ) + } + val delta = toDecimal(row["inQty"]).subtract(toDecimal(row["outQty"])) + val bal = prevByInv.getValue(invId).add(delta) + prevByInv[invId] = bal + updates.add(id to bal) + } + return withTempTx { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_adj_bal ( + id BIGINT NOT NULL PRIMARY KEY, + newBalance DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + updates.chunked(200).forEach { chunk -> + val values = chunk.joinToString(",") { (id, bal) -> + "($id, ${bal.toPlainString()})" + } + jdbcDao.executeUpdate( + "INSERT INTO tmp_sl_fix_adj_bal (id, newBalance) VALUES $values", + ) + } + jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_adj_bal t ON t.id = sl.id + SET sl.balance = t.newBalance + WHERE sl.deleted = 0 + """.trimIndent(), + ) + } finally { + try { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") + } catch (e: Exception) { + log.warn("stock-ledger-fix drop tmp_sl_fix_adj_bal skipped: {}", e.message) + } + } + } + } + + private fun toDecimal(value: Any?): BigDecimal = when (value) { + null -> BigDecimal.ZERO + is BigDecimal -> value + is Number -> BigDecimal(value.toString()) + else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } + + private data class AdjGap( + val lotLineId: Long, + val inventoryId: Long?, + val itemId: Long?, + val itemCode: String?, + val uomId: Long?, + val lineIn: BigDecimal, + val lineOut: BigDecimal, + val ledgerIn: BigDecimal, + val ledgerOut: BigDecimal, + val missIn: BigDecimal, + val missOut: BigDecimal, + ) { + fun toRow() = StockLedgerFixAdjRow( + lotLineId = lotLineId, + inventoryId = inventoryId, + itemCode = itemCode, + lineIn = lineIn.stripTrailingZeros().toPlainString(), + lineOut = lineOut.stripTrailingZeros().toPlainString(), + ledgerIn = ledgerIn.stripTrailingZeros().toPlainString(), + ledgerOut = ledgerOut.stripTrailingZeros().toPlainString(), + missIn = missIn.stripTrailingZeros().toPlainString(), + missOut = missOut.stripTrailingZeros().toPlainString(), + ) + } + + private fun toLocalDate(value: Any?): LocalDate? = when (value) { + null -> null + is LocalDate -> value + is java.sql.Date -> value.toLocalDate() + is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() + else -> try { + LocalDate.parse(value.toString().take(10)) + } catch (_: Exception) { + null + } + } + + private data class MissCounts( + val missLot: Int, + val missUom: Int, + val missInventoryId: Int, + val missLotQty: Int, + ) + + companion object { + private val DAY_STEPS = linkedSetOf("2.1", "2.2", "2.3", "2.4", "2.5", "2.6") + /** Legacy default export: ledger calc fields + stock_lot_day (no inventory / inventoryId / ADJ). */ + private val EXPORT_PARTS_DEFAULT = linkedSetOf("ledger", "2.6") + + private const val LOT_UOM_PAIR_COUNT_SQL = """ + SELECT COUNT(*) AS c FROM ( + SELECT il.itemId, iu.uomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId, iu.uomId + ) x + """ + + private const val MISSING_UOM_PAIR_COUNT_SQL = """ + SELECT COUNT(*) AS c FROM ( + SELECT il.itemId, iu.uomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + LEFT JOIN inventory inv + ON inv.itemId = il.itemId + AND inv.uomId = iu.uomId + AND IFNULL(inv.deleted, 0) = 0 + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + AND inv.id IS NULL + GROUP BY il.itemId, iu.uomId + ) x + """ + + private const val INSERT_MISSING_INVENTORY_SQL = """ + INSERT INTO inventory ( + itemId, uomId, onHandQty, onHoldQty, unavailableQty, + price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status, + created, createdBy, modified, modifiedBy, version, deleted + ) + SELECT + src.itemId, + src.uomId, + 0, 0, 0, + COALESCE(tpl.price, 0), + tpl.currencyId, + COALESCE(tpl.cpu, 0), + COALESCE(tpl.cpuUnit, 'HKD'), + COALESCE(tpl.cpm, 0), + COALESCE(tpl.cpmUnit, 'HKD'), + 'unavailable', + NOW(), 'stock-ledger-fix', NOW(), 'stock-ledger-fix', 0, 0 + FROM ( + SELECT il.itemId, iu.uomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId, iu.uomId + ) src + LEFT JOIN inventory existing + ON existing.itemId = src.itemId + AND existing.uomId = src.uomId + AND IFNULL(existing.deleted, 0) = 0 + LEFT JOIN inventory tpl + ON tpl.id = ( + SELECT MIN(i.id) + FROM inventory i + WHERE i.itemId = src.itemId + AND IFNULL(i.deleted, 0) = 0 + ) + WHERE existing.id IS NULL + """ + + private const val UPDATE_INVENTORY_QTY_SQL = """ + UPDATE inventory i + LEFT JOIN ( + SELECT + il.itemId, + iu.uomId, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand, + SUM(COALESCE(ill.holdQty, 0)) AS onHold, + SUM( + CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) + ELSE 0 + END + ) AS unavailable + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId, iu.uomId + ) agg ON agg.itemId = i.itemId AND agg.uomId = i.uomId + SET + i.onHandQty = COALESCE(agg.onHand, 0), + i.onHoldQty = COALESCE(agg.onHold, 0), + i.unavailableQty = COALESCE(agg.unavailable, 0), + i.status = IF( + COALESCE(agg.onHand, 0) - COALESCE(agg.onHold, 0) - COALESCE(agg.unavailable, 0) > 0, + 'available', + 'unavailable' + ), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + """ + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt index 5b83ade6..ea5d4dc7 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt @@ -89,8 +89,8 @@ class ItemsController( @GetMapping("/pickOrderItems") fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes> { try { - println("=== Debug: getPickOrderItemsByPage ===") - println("Request parameters: ${request.parameterMap}") + //println("=== Debug: getPickOrderItemsByPage ===") + //println("Request parameters: ${request.parameterMap}") val criteriaArgs = CriteriaArgsBuilder.withRequest(request) .addStringLike("itemCode") @@ -100,18 +100,18 @@ class ItemsController( .addString("targetDateTo") .build() - println("Criteria args: $criteriaArgs") - + //println("Criteria args: $criteriaArgs") + val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10 val pageNum = request.getParameter("pageNum")?.toIntOrNull() ?: 1 - println("Page size: $pageSize, Page num: $pageNum") + //println("Page size: $pageSize, Page num: $pageNum") val fullList = itemsService.getPickOrderItemsByPage(criteriaArgs) - println("Full list size: ${fullList.size}") + //println("Full list size: ${fullList.size}") val paginatedList = PagingUtils.getPaginatedList(fullList, pageSize, pageNum) - println("Paginated list size: ${paginatedList.size}") + //println("Paginated list size: ${paginatedList.size}") return RecordsRes(paginatedList, fullList.size) } catch (e: Exception) { diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt new file mode 100644 index 00000000..b255f47a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt @@ -0,0 +1,126 @@ +package com.ffii.fpsms.modules.master.web + +import com.ffii.fpsms.modules.master.service.StockLedgerFixService +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRequest +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunRequest +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.HttpHeaders +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import jakarta.servlet.http.HttpServletResponse +import java.time.LocalDate + +/** + * Admin data-fix: inventory 1.0 (A) then ledger days (2.1–2.6), including date-range mode. + */ +@RestController +@RequestMapping("/stock-ledger-fix") +open class StockLedgerFixController( + private val stockLedgerFixService: StockLedgerFixService, +) { + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/calendar") + open fun calendar( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) from: LocalDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) to: LocalDate, + ): StockLedgerFixCalendarResponse = stockLedgerFixService.calendar(from, to) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/day") + open fun day( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) date: LocalDate, + ): StockLedgerFixDayDetail = stockLedgerFixService.dayDetail(date) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/inventory") + open fun inventoryPreview(): StockLedgerFixInventoryPreview = + stockLedgerFixService.inventoryPreview() + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PostMapping("/inventory") + open fun inventory(): StockLedgerFixInventoryResponse = + stockLedgerFixService.fixInventory10() + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/lookup/inventory") + open fun lookupInventory(@RequestParam q: String): List = + stockLedgerFixService.searchInventory(q) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/lookup/lot") + open fun lookupLot(@RequestParam q: String): List = + stockLedgerFixService.searchLot(q) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/scope/inventory") + open fun inventoryScope(@RequestParam id: Long): StockLedgerFixScopeDetail = + stockLedgerFixService.inventoryScopeDetail(id) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/scope/lot") + open fun lotScope(@RequestParam id: Long): StockLedgerFixScopeDetail = + stockLedgerFixService.lotScopeDetail(id) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/export") + open fun export( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) from: LocalDate, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) to: LocalDate, + @RequestParam(required = false) parts: List?, + response: HttpServletResponse, + ) { + val name = "stock_ledger_fix_${from.toString().replace("-", "")}_${to.toString().replace("-", "")}.sql" + response.contentType = "application/sql;charset=UTF-8" + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$name\"") + stockLedgerFixService.writeExportSql(from, to, response.outputStream, parts) + } + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @GetMapping("/adj") + open fun adjPreview( + @RequestParam(defaultValue = "20") rowLimit: Int, + @RequestParam(required = false) adjDate: String?, + ) = stockLedgerFixService.adjPreview(rowLimit, adjDate) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PostMapping("/adj") + open fun adjApply(@RequestBody(required = false) body: StockLedgerFixAdjRequest?) = + stockLedgerFixService.applyAdjAlign(body?.adjDate) + + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PostMapping("/run") + open fun run(@RequestBody request: StockLedgerFixRunRequest): StockLedgerFixRunResponse { + val mode = request.mode?.trim()?.lowercase().orEmpty().ifEmpty { "day" } + return when (mode) { + "day" -> stockLedgerFixService.fixDay( + request.date ?: throw IllegalArgumentException("date is required"), + request.steps, + ) + "range" -> stockLedgerFixService.fixRange( + request.from ?: throw IllegalArgumentException("from is required"), + request.to ?: throw IllegalArgumentException("to is required"), + request.steps, + ) + "inventory" -> stockLedgerFixService.fixInventoryScope( + request.inventoryId ?: throw IllegalArgumentException("inventoryId is required"), + ) + "lot" -> stockLedgerFixService.fixLotScope( + request.lotLineId ?: throw IllegalArgumentException("lotLineId is required"), + ) + else -> throw IllegalArgumentException("mode must be day, range, inventory, or lot") + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt new file mode 100644 index 00000000..eb9f9593 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt @@ -0,0 +1,138 @@ +package com.ffii.fpsms.modules.master.web.models + +data class StockLedgerFixRunRequest( + val date: String? = null, + val from: String? = null, + val to: String? = null, + val mode: String? = null, + val inventoryId: Long? = null, + val lotLineId: Long? = null, + /** Day / range. Omit / empty / ["all"] = 2.1–2.6. Else subset e.g. ["2.3"]. */ + val steps: List? = null, +) + +data class StockLedgerFixDayStatus( + val date: String, + val cnt: Int, + val missLot: Int, + val missUom: Int, + val missInventoryId: Int, + val missLotQty: Int, + val dayTableLots: Int, +) + +data class StockLedgerFixCalendarResponse( + val from: String, + val to: String, + val days: List, +) + +data class StockLedgerFixRunResponse( + val date: String, + val filledLotLineId: Int, + val filledUomId: Int, + val filledInventoryId: Int, + val filledLotQty: Int, + val filledBalance: Int, + val dayRowsWritten: Int, + val stillMissLot: Int, + val stillMissUom: Int, + val stillMissInventoryId: Int, + val stillMissLotQty: Int, +) + +data class StockLedgerFixCheckPart( + val key: String, + val label: String, + val ok: Int, + val miss: Int, + val incorrect: Int, + val group: String = "field", +) + +data class StockLedgerFixDayDetail( + val date: String, + val cnt: Int, + val parts: List, +) + +data class StockLedgerFixInventoryPreview( + val inventoryRows: Int, + val lotUomPairs: Int, + val missingUomPairs: Int, +) + +data class StockLedgerFixInventoryResponse( + val inserted: Int, + val updated: Int, + val missingUomPairsAfter: Int, +) + +data class StockLedgerFixSearchInventoryHit( + val inventoryId: Long, + val itemId: Long?, + val itemCode: String?, + val uomId: Long?, + val ledgerCnt: Int, +) + +data class StockLedgerFixSearchLotHit( + val inventoryLotLineId: Long, + val lotNo: String?, + val itemCode: String?, + val inventoryId: Long?, + val ledgerCnt: Int, +) + +data class StockLedgerFixAdjRequest( + /** Optional ADJ ledger date (yyyy-MM-dd). Default = yesterday. Use today on freeze-night dump. */ + val adjDate: String? = null, +) + +data class StockLedgerFixAdjRow( + val lotLineId: Long, + val inventoryId: Long?, + val itemCode: String?, + val lineIn: String, + val lineOut: String, + val ledgerIn: String, + val ledgerOut: String, + val missIn: String, + val missOut: String, +) + +data class StockLedgerFixAdjPreview( + val adjDate: String, + val lotCount: Int, + val adjInCount: Int, + val adjOutCount: Int, + val skippedNegCount: Int, + val sumMissIn: String, + val sumMissOut: String, + val skuNet: String, + val rows: List, +) + +data class StockLedgerFixAdjResponse( + val adjDate: String, + val insertedIn: Int, + val insertedOut: Int, + val filledLotQty: Int, + val filledBalance: Int, + val dayRowsWritten: Int, +) + +data class StockLedgerFixScopeDetail( + val kind: String, + val id: Long, + val itemCode: String?, + val lotNo: String?, + val inventoryId: Long?, + val uomId: Long?, + val firstDate: String?, + val lastDate: String?, + val cnt: Int, + val lastBalance: String?, + val lastLotQtyAfter: String?, + val parts: List, +) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 845af94e..392733c2 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -641,12 +641,19 @@ return result * Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables. * Supports comma-separated values for stockCategory (items.type) and itemCode. * Date range [lastInDateStart, lastInDateEnd] filters on stock_in_line.productionDate (完成生產日期), same basis as 成品/半成品生產分析報告. + * Always limited to NOR stock-in (excludes TRF). PP/PF uses this line's PO code prefix, not lot-origin TRF walk. */ fun searchStockInTraceabilityReport( stockCategory: String?, itemCode: String?, lastInDateStart: String?, - lastInDateEnd: String? + lastInDateEnd: String?, + storeId: String? = null, + warehouse: String? = null, + area: String? = null, + slot: String? = null, + lotNo: String? = null, + poPrefix: String? = null, ): List> { val args = mutableMapOf() @@ -671,6 +678,34 @@ return result "AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)" } else "" + val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") { + args["storeId"] = storeId.trim() + "AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')" + } else { + "" + } + val warehouseSql = if (!warehouse.isNullOrBlank() && warehouse.trim() != "All") { + buildMultiValueLikeClause(warehouse, "wh.warehouse", "warehousePart", args) + } else { + "" + } + val areaSql = if (!area.isNullOrBlank() && area.trim() != "All") { + buildMultiValueLikeClause(area, "wh.area", "areaPart", args) + } else { + "" + } + val slotSql = if (!slot.isNullOrBlank() && slot.trim() != "All") { + buildMultiValueLikeClause(slot, "wh.slot", "slotPart", args) + } else { + "" + } + val lotNoSql = buildMultiValueLikeClause(lotNo, "COALESCE(sil.lotNo, il.lotNo)", "lotNo", args) + val poPrefixSql = when (poPrefix?.trim()?.uppercase()) { + "PP" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PP'" + "PF" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PF'" + else -> "" + } + val sql = """ SELECT COALESCE(it.code, '') as itemNo, @@ -728,10 +763,17 @@ return result GROUP BY qr.stockInLineId ) qr_agg ON qr_agg.stockInLineId = sil.id WHERE sil.deleted = false + AND UPPER(TRIM(COALESCE(sil.type, ''))) = 'NOR' $stockCategorySql $itemCodeSql $lastInDateStartSql $lastInDateEndSql + $storeIdSql + $warehouseSql + $areaSql + $slotSql + $lotNoSql + $poPrefixSql ORDER BY it.code, sil.lotNo """.trimIndent() diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt new file mode 100644 index 00000000..9bab7f8a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt @@ -0,0 +1,372 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * 庫存批次結餘:永遠今天。現存讀 [inventory_lot_line](inQty-outQty), + * 當天入/出/MISS/BAD/過期/盤盈虧讀當天 [stock_ledger]。 + */ +@Service +open class StockLotBalanceReportService( + private val jdbcDao: JdbcDao, +) { + private val log = LoggerFactory.getLogger(StockLotBalanceReportService::class.java) + private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + data class TimedResult( + val rows: List>, + val timings: List, + val stockDate: String, + ) + + fun searchStockLotBalanceReport( + stockDate: String?, + itemCode: String?, + warehouseCode: String?, + storeId: String?, + lotNo: String?, + ): List> = searchStockLotBalanceReportTimed( + stockDate, itemCode, warehouseCode, storeId, lotNo, + ).rows + + fun searchStockLotBalanceReportTimed( + stockDate: String?, + itemCode: String?, + warehouseCode: String?, + storeId: String?, + lotNo: String?, + ): TimedResult { + val asOfDate = LocalDate.now() + val asOfDateStr = asOfDate.format(dateFmt) + if (!stockDate.isNullOrBlank() && stockDate.replace("/", "-") != asOfDateStr) { + log.info("stock-lot-balance ignore stockDate={} use today={}", stockDate, asOfDateStr) + } + + val timings = mutableListOf() + val tAll = System.nanoTime() + fun timed(name: String, extra: (T) -> String, block: () -> T): T { + val t0 = System.nanoTime() + return try { + val r = block() + val ms = (System.nanoTime() - t0) / 1_000_000 + val line = "$name ${extra(r)} ${ms}ms" + timings.add(line) + log.info("stock-lot-balance {}", line) + r + } catch (e: Exception) { + val ms = (System.nanoTime() - t0) / 1_000_000 + log.warn("stock-lot-balance {} FAILED {}ms: {}", name, ms, e.message) + timings.add("$name FAILED ${ms}ms") + throw e + } + } + + val args = mutableMapOf( + "d0Start" to asOfDate.atStartOfDay(), + "d0EndExclusive" to asOfDate.plusDays(1).atStartOfDay(), + ) + val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args) + val forceLedgerIndex = if (itemCode.isNullOrBlank()) { + "FORCE INDEX (idx_sl_deleted_date)" + } else { + "" + } + + val masterArgs = HashMap() + val itemCodeSqlIt = buildMultiValueLikeClause(itemCode, "it.code", "itemCodeIt", masterArgs) + val lotNoSql = buildMultiValueLikeClause(lotNo, "il.lotNo", "lotNo", masterArgs) + val warehouseCodeSql = if (!warehouseCode.isNullOrBlank() && warehouseCode.trim() != "All") { + buildMultiValueLikeClause(warehouseCode, "wh.code", "warehouseCode", masterArgs) + } else { + "" + } + val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") { + masterArgs["storeId"] = storeId.trim() + "AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')" + } else { + "" + } + val masterSelectSql = """ + SELECT + ill.id AS inventoryLotLineId, + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + COALESCE(wh.code, '') AS warehouseCode, + COALESCE(wh.store_id, '') AS storeId, + (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS currentQty, + COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN items it + ON it.id = il.itemId AND it.deleted = 0 + INNER JOIN warehouse wh + ON wh.id = ill.warehouseId AND wh.deleted = 0 + LEFT JOIN item_uom iu + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId + WHERE ill.deleted = 0 + AND it.code IS NOT NULL AND it.code <> '' + $itemCodeSqlIt + $warehouseCodeSql + $storeIdSql + $lotNoSql + """.trimIndent() + + val liveLots = timed("1.live_lots", { rows: List> -> "rows=${rows.size}" }) { + jdbcDao.queryForList( + """ + $masterSelectSql + AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) <> 0 + """.trimIndent(), + masterArgs, + ) + } + val masterByLot = HashMap>(liveLots.size * 2) + for (r in liveLots) { + masterByLot[toLong(r["inventoryLotLineId"])] = r + } + + val ledgerRows = timed("2.day_ledger", { rows: List> -> "rows=${rows.size}" }) { + jdbcDao.queryForList( + """ + SELECT + sl.inventoryLotLineId, + sl.id AS slId, + sl.type, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + sl.lotQtyBefore, + sl.lotQtyAfter + FROM stock_ledger sl $forceLedgerIndex + WHERE sl.deleted = 0 + AND sl.date >= :d0Start + AND sl.date < :d0EndExclusive + AND sl.inventoryLotLineId IS NOT NULL + $itemCodeSqlSl + """.trimIndent(), + args, + ) + } + + val movesByLot = timed("3.agg_types", { map: Map -> "lots=${map.size}" }) { + aggregateDayMoves(ledgerRows) + } + + val missingMoveIds = movesByLot.keys.filter { it !in masterByLot } + timed("4.zero_lots", { rows: List> -> "rows=${rows.size} missing=${missingMoveIds.size}" }) { + if (missingMoveIds.isEmpty()) emptyList() + else queryLotIdChunks(missingMoveIds, masterArgs) { chunkArgs -> + jdbcDao.queryForList( + """ + $masterSelectSql + AND ill.id IN (:lotIds) + """.trimIndent(), + chunkArgs, + ) + } + }.forEach { r -> + masterByLot[toLong(r["inventoryLotLineId"])] = r + } + + val rows = timed("5.assemble", { list: List> -> "rows=${list.size}" }) { + assembleRows(movesByLot, masterByLot) + } + + val totalMs = (System.nanoTime() - tAll) / 1_000_000 + timings.add("search.total ${totalMs}ms") + log.info( + "stock-lot-balance DONE stockDate={} itemCode={} {}", + asOfDateStr, + itemCode ?: "", + timings.joinToString(" | "), + ) + return TimedResult(rows = rows, timings = timings, stockDate = asOfDateStr) + } + + private data class LotDayAgg( + var cumStockIn: Double = 0.0, + var cumStockOut: Double = 0.0, + var misInputAndLost: Double = 0.0, + var variance: Double = 0.0, + var defectiveGoods: Double = 0.0, + var expiredDisposed: Double = 0.0, + var firstSlId: Long = Long.MAX_VALUE, + var lastSlId: Long = Long.MIN_VALUE, + var firstQtyBefore: Double? = null, + var lastQtyAfter: Double? = null, + ) { + fun hasMovement(): Boolean = + cumStockIn != 0.0 || + cumStockOut != 0.0 || + misInputAndLost != 0.0 || + variance != 0.0 || + defectiveGoods != 0.0 || + expiredDisposed != 0.0 + } + + private fun aggregateDayMoves(ledgerRows: List>): Map { + val byLot = HashMap() + for (r in ledgerRows) { + val lotId = toLong(r["inventoryLotLineId"]) + if (lotId <= 0) continue + val slId = toLong(r["slId"]) + val type = r["type"]?.toString()?.trim()?.uppercase().orEmpty() + val inQty = toDouble(r["inQty"]) + val outQty = toDouble(r["outQty"]) + val agg = byLot.getOrPut(lotId) { LotDayAgg() } + when (type) { + "NOR", "ADJ", "TRF", "OPEN" -> + if (inQty > 0) agg.cumStockIn += inQty + } + when (type) { + "NOR", "ADJ", "TRF" -> + if (outQty > 0) agg.cumStockOut += outQty + } + if (type == "MISS" && outQty > 0) agg.misInputAndLost += outQty + if (type == "BAD" && outQty > 0) agg.defectiveGoods += outQty + if (type == "EXPIRY" && outQty > 0) agg.expiredDisposed += outQty + if (type == "TKE") agg.variance += inQty - outQty + if (slId > 0 && slId < agg.firstSlId) { + agg.firstSlId = slId + agg.firstQtyBefore = r["lotQtyBefore"]?.let { toDouble(it) } + } + if (slId > 0 && slId > agg.lastSlId) { + agg.lastSlId = slId + agg.lastQtyAfter = r["lotQtyAfter"]?.let { toDouble(it) } + } + } + return byLot + } + + private fun assembleRows( + movesByLot: Map, + masterByLot: Map>, + ): List> { + val lotIds = LinkedHashSet(masterByLot.size) + lotIds.addAll(masterByLot.keys) + val out = ArrayList>(lotIds.size) + for (lotId in lotIds) { + val master = masterByLot[lotId] ?: continue + val move = movesByLot[lotId] + val current = toDouble(master["currentQty"]) + val opening = move?.firstQtyBefore ?: current + val keep = current != 0.0 || (move?.hasMovement() == true) + if (!keep) continue + out.add(buildRow(lotId, master, opening, current, move)) + } + out.sortWith( + compareBy> { it["itemNo"]?.toString().orEmpty() } + .thenBy { it["lotNo"]?.toString().orEmpty() } + .thenBy { it["warehouseCode"]?.toString().orEmpty() } + .thenBy { it["inventoryLotLineId"]?.toString().orEmpty() }, + ) + return out + } + + private fun buildRow( + lotId: Long, + master: Map, + opening: Double, + current: Double, + move: LotDayAgg?, + ): Map { + val cumIn = move?.cumStockIn ?: 0.0 + val cumOut = move?.cumStockOut ?: 0.0 + val miss = move?.misInputAndLost ?: 0.0 + val variance = move?.variance ?: 0.0 + val defective = move?.defectiveGoods ?: 0.0 + val expired = move?.expiredDisposed ?: 0.0 + val avg = toDouble(master["avgUnitPriceRaw"]) + val stockValue = avg * current + val row = HashMap(24) + row["inventoryLotLineId"] = lotId.toString() + row["itemNo"] = master["itemNo"] ?: "" + row["itemName"] = master["itemName"] ?: "" + row["unitOfMeasure"] = master["unitOfMeasure"] ?: "" + row["lotNo"] = master["lotNo"] ?: "" + row["expiryDate"] = master["expiryDate"] ?: "" + row["warehouseCode"] = master["warehouseCode"] ?: "" + row["storeId"] = master["storeId"] ?: "" + row["storeLocation"] = master["warehouseCode"] ?: "" + row["openingBalanceRaw"] = opening + row["cumStockInRaw"] = cumIn + row["cumStockOutRaw"] = cumOut + row["misInputAndLostRaw"] = miss + row["varianceRaw"] = variance + row["defectiveGoodsRaw"] = defective + row["expiredDisposedRaw"] = expired + row["currentBalanceRaw"] = current + row["avgUnitPriceRaw"] = avg + row["totalOpeningBalance"] = formatQty(opening) + row["totalCumStockIn"] = formatQty(cumIn) + row["totalCumStockOut"] = formatQty(cumOut) + row["totalMisInputAndLost"] = formatQty(miss) + row["totalVariance"] = formatQty(variance) + row["totalDefectiveGoods"] = formatQty(defective) + row["totalExpiredDisposed"] = formatQty(expired) + row["totalCurrentBalance"] = formatQty(current) + row["avgUnitPrice"] = formatQty(avg) + row["totalStockBalance"] = formatQty(stockValue) + return row + } + + private fun queryLotIdChunks( + lotIds: Collection, + baseArgs: Map, + query: (Map) -> List>, + ): List> { + val ids = lotIds.filter { it > 0 }.distinct() + if (ids.isEmpty()) return emptyList() + val out = ArrayList>() + for (chunk in ids.chunked(800)) { + val chunkArgs = HashMap(baseArgs) + chunkArgs["lotIds"] = chunk + out.addAll(query(chunkArgs)) + } + return out + } + + private fun formatQty(n: Double): String { + val body = String.format(Locale.US, "%,.2f", kotlin.math.abs(n)) + return if (n < 0) "($body)" else body + } + + private fun toDouble(v: Any?): Double { + if (v == null) return 0.0 + if (v is Number) return v.toDouble() + return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + } + + private fun toLong(v: Any?): Long { + if (v == null) return 0L + if (v is Number) return v.toLong() + return v.toString().toLongOrNull() ?: 0L + } + + private fun buildMultiValueLikeClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap, + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val conditions = values.mapIndexed { index, value -> + val paramName = "${paramPrefix}_$index" + args[paramName] = "%$value%" + "$columnName LIKE :$paramName" + } + return "AND (${conditions.joinToString(" OR ")})" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt new file mode 100644 index 00000000..16547fce --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt @@ -0,0 +1,492 @@ +package com.ffii.fpsms.modules.report.service + +import com.ffii.core.support.JdbcDao +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * 庫存流水帳:期初讀 as-of 當天 [stock_lot_day](2.6 carry),期間明細讀 stock_ledger。 + */ +@Service +open class StockLotLedgerReportService( + private val jdbcDao: JdbcDao, +) { + private val log = LoggerFactory.getLogger(StockLotLedgerReportService::class.java) + + data class LedgerPeriod( + val start: LocalDate, + val end: LocalDate, + ) { + val startStr: String get() = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val endStr: String get() = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val asOfStr: String get() = start.minusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val endExclusiveStr: String get() = end.plusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + val isValid: Boolean get() = !end.isBefore(start) + } + + data class TimedResult( + val rows: List>, + val timings: List, + val period: LedgerPeriod, + ) + + fun resolvePeriod(startRaw: String?, endRaw: String?): LedgerPeriod { + val end = if (!endRaw.isNullOrBlank()) { + LocalDate.parse(endRaw.replace("/", "-")) + } else { + LocalDate.now() + } + val startParsed = if (!startRaw.isNullOrBlank()) { + LocalDate.parse(startRaw.replace("/", "-")) + } else { + end.withDayOfMonth(1) + } + return LedgerPeriod(start = startParsed.withDayOfMonth(1), end = end) + } + + fun searchStockLotLedgerReport( + itemCode: String?, + reportPeriodStart: String?, + reportPeriodEnd: String?, + ): List> = searchStockLotLedgerReportTimed( + itemCode, reportPeriodStart, reportPeriodEnd, + ).rows + + fun searchStockLotLedgerReportTimed( + itemCode: String?, + reportPeriodStart: String?, + reportPeriodEnd: String?, + ): TimedResult { + val period = resolvePeriod(reportPeriodStart, reportPeriodEnd) + if (!period.isValid) { + return TimedResult(emptyList(), listOf("invalid-period 0ms"), period) + } + + val timings = mutableListOf() + val tAll = System.nanoTime() + fun timed(name: String, extra: (T) -> String, block: () -> T): T { + val t0 = System.nanoTime() + return try { + val r = block() + val ms = (System.nanoTime() - t0) / 1_000_000 + val note = extra(r) + val line = "$name $note ${ms}ms" + timings.add(line) + log.info("stock-lot-ledger {}", line) + r + } catch (e: Exception) { + val ms = (System.nanoTime() - t0) / 1_000_000 + log.warn("stock-lot-ledger {} FAILED {}ms: {}", name, ms, e.message) + timings.add("$name FAILED ${ms}ms") + throw e + } + } + + val args = mutableMapOf( + "reportPeriodStart" to period.start.atStartOfDay(), + "reportPeriodEndExclusive" to period.end.plusDays(1).atStartOfDay(), + "asOfDate" to period.asOfStr, + ) + val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args) + val itemCodeSqlDay = buildMultiValueLikeClause(itemCode, "d.itemCode", "itemCodeDay", args) + + val forceLedgerIndex = if (itemCode.isNullOrBlank()) { + "FORCE INDEX (idx_sl_deleted_date)" + } else { + "" + } + + val dayOpen = timed("1.day_open", { rows: List> -> "rows=${rows.size}" }) { + jdbcDao.queryForList( + """ + SELECT d.inventoryLotLineId, d.closing AS openingQty + FROM stock_lot_day d + WHERE IFNULL(d.deleted, 0) = 0 + AND d.date = :asOfDate + AND d.closing <> 0 + $itemCodeSqlDay + """.trimIndent(), + args, + ) + } + val dayOpenByLot = HashMap(dayOpen.size * 2) + for (r in dayOpen) { + dayOpenByLot[toLong(r["inventoryLotLineId"])] = toDouble(r["openingQty"]) + } + + val ledgerRows = timed("2a.period_ledger", { rows: List> -> "rows=${rows.size}" }) { + jdbcDao.queryForList( + """ + SELECT + sl.inventoryLotLineId, + sl.id AS slId, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + sl.lotQtyAfter AS lotQtyAfter, + sl.lotQtyBefore AS lotQtyBefore, + sl.stockInLineId, + sl.stockOutLineId + FROM stock_ledger sl $forceLedgerIndex + WHERE sl.deleted = 0 + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + AND sl.inventoryLotLineId IS NOT NULL + $itemCodeSqlSl + """.trimIndent(), + args, + ) + } + + val refBySlId = timed("2b.ref_docs", { map: Map -> "refs=${map.size}" }) { + loadRefDocs(ledgerRows) + } + + val moves = timed("2c.attach_refs", { rows: List> -> "rows=${rows.size}" }) { + ledgerRows.map { r -> + val out = HashMap(r) + val slId = toLong(r["slId"]) + out["orderRefNo"] = refBySlId[slId] ?: "" + out + } + } + + val periodLotIds = LinkedHashSet() + val firstMoveByLot = HashMap() + val firstMoveKey = HashMap>() + for (r in moves) { + val lotId = toLong(r["inventoryLotLineId"]) + if (lotId <= 0) continue + periodLotIds.add(lotId) + if (lotId in dayOpenByLot) continue + val date = r["trnDate"]?.toString().orEmpty() + val slId = toLong(r["slId"]) + val prev = firstMoveKey[lotId] + if (prev == null || date < prev.first || (date == prev.first && slId < prev.second)) { + firstMoveKey[lotId] = date to slId + firstMoveByLot[lotId] = toDouble(r["lotQtyBefore"]) + } + } + timings.add("3.first_move derived lots=${firstMoveByLot.size} 0ms") + log.info("stock-lot-ledger 3.first_move derived lots={} 0ms", firstMoveByLot.size) + + val eligibleLotIds = LinkedHashSet(dayOpenByLot.size + periodLotIds.size) + eligibleLotIds.addAll(dayOpenByLot.keys) + eligibleLotIds.addAll(periodLotIds) + + val masterByLot = HashMap>(eligibleLotIds.size * 2) + timed("4.master", { rows: List> -> "rows=${rows.size} lots=${eligibleLotIds.size}" }) { + if (eligibleLotIds.isEmpty()) emptyList() + else queryLotIdChunks(eligibleLotIds.toList(), emptyMap()) { chunkArgs -> + jdbcDao.queryForList( + """ + SELECT + ill.id AS inventoryLotLineId, + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + iu.uomId AS lotUomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN items it + ON it.id = il.itemId AND it.deleted = 0 + LEFT JOIN item_uom iu + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId + WHERE ill.deleted = 0 + AND ill.id IN (:lotIds) + AND it.code IS NOT NULL AND it.code <> '' + """.trimIndent(), + chunkArgs, + ) + } + }.forEach { r -> + masterByLot[toLong(r["inventoryLotLineId"])] = r + } + + val assembled = timed("5.assemble", { rows: List> -> "rows=${rows.size}" }) { + assembleRows( + asOfStr = period.asOfStr, + dayOpenByLot = dayOpenByLot, + firstMoveByLot = firstMoveByLot, + moves = moves, + masterByLot = masterByLot, + ) + } + + val rows = timed("6.totals", { list: List> -> "rows=${list.size}" }) { + attachItemUomTotals(assembled) + } + + val totalMs = (System.nanoTime() - tAll) / 1_000_000 + timings.add("search.total ${totalMs}ms") + log.info( + "stock-lot-ledger DONE period={}..{} asOf={} itemCode={} {}", + period.startStr, + period.endStr, + period.asOfStr, + itemCode ?: "", + timings.joinToString(" | "), + ) + return TimedResult(rows = rows, timings = timings, period = period) + } + + private fun assembleRows( + asOfStr: String, + dayOpenByLot: Map, + firstMoveByLot: Map, + moves: List>, + masterByLot: Map>, + ): List> { + val movesByLot = LinkedHashMap>>() + for (m in moves) { + val lotId = toLong(m["inventoryLotLineId"]) + if (lotId !in masterByLot) continue + movesByLot.getOrPut(lotId) { mutableListOf() }.add(m) + } + + val openingLots = LinkedHashSet() + openingLots.addAll(dayOpenByLot.keys) + openingLots.addAll(firstMoveByLot.keys) + openingLots.addAll(movesByLot.keys) + + val out = ArrayList>(openingLots.size + moves.size) + for (lotId in openingLots) { + val master = masterByLot[lotId] ?: continue + val opening = dayOpenByLot[lotId] ?: firstMoveByLot[lotId] ?: 0.0 + out.add(openingRow(lotId, asOfStr, opening, master)) + val lotMoves = (movesByLot[lotId] ?: continue).sortedWith( + compareBy( + { it["trnDate"]?.toString().orEmpty() }, + { toLong(it["slId"]) }, + ), + ) + var run = opening + for (m in lotMoves) { + val inQty = toDouble(m["inQty"]) + val outQty = toDouble(m["outQty"]) + val after = m["lotQtyAfter"] + run = if (after != null) toDouble(after) else run + inQty - outQty + out.add(moveRow(lotId, m, inQty, outQty, run, master)) + } + } + + out.sortWith( + compareBy> { it["itemNo"]?.toString().orEmpty() } + .thenBy { it["unitOfMeasure"]?.toString().orEmpty() } + .thenBy { it["lotNo"]?.toString().orEmpty() } + .thenBy { it["inventoryLotLineId"]?.toString().orEmpty() } + .thenByDescending { toInt(it["isOpening"]) } + .thenBy { it["trnDate"]?.toString().orEmpty() } + .thenBy { toLong(it["slId"]) }, + ) + return out + } + + private fun loadRefDocs(ledgerRows: List>): Map { + if (ledgerRows.isEmpty()) return emptyMap() + val slByIn = HashMap>() + val slByOut = HashMap>() + for (r in ledgerRows) { + val slId = toLong(r["slId"]) + if (slId <= 0) continue + val inId = toLong(r["stockInLineId"]) + val outId = toLong(r["stockOutLineId"]) + if (inId > 0) slByIn.getOrPut(inId) { mutableListOf() }.add(slId) + if (outId > 0) slByOut.getOrPut(outId) { mutableListOf() }.add(slId) + } + val refs = HashMap() + if (slByOut.isNotEmpty()) { + for (chunk in slByOut.keys.toList().chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sol.id AS lineId, + COALESCE( + NULLIF(TRIM(do.code), ''), + NULLIF(TRIM(po_out.code), ''), + NULLIF(TRIM(jo.code), ''), + '' + ) AS orderRefNo + FROM stock_out_line sol + LEFT JOIN pick_order_line pol + ON pol.id = sol.pickOrderLineId AND pol.deleted = 0 + LEFT JOIN pick_order po_out + ON po_out.id = pol.poId AND po_out.deleted = 0 + LEFT JOIN delivery_order do + ON do.id = po_out.doId AND do.deleted = 0 + LEFT JOIN job_order jo + ON jo.id = po_out.joId AND jo.deleted = 0 + WHERE sol.id IN (:ids) + """.trimIndent(), + mapOf("ids" to chunk), + ) + for (r in rows) { + val ref = r["orderRefNo"]?.toString()?.trim().orEmpty() + if (ref.isEmpty()) continue + slByOut[toLong(r["lineId"])]?.forEach { slId -> refs[slId] = ref } + } + } + } + if (slByIn.isNotEmpty()) { + for (chunk in slByIn.keys.toList().chunked(800)) { + val rows = jdbcDao.queryForList( + """ + SELECT + sil.id AS lineId, + COALESCE( + NULLIF(TRIM(jo.code), ''), + NULLIF(TRIM(po.code), ''), + NULLIF(TRIM(si.code), ''), + '' + ) AS orderRefNo + FROM stock_in_line sil + LEFT JOIN stock_in si + ON si.id = sil.stockInId AND si.deleted = 0 + LEFT JOIN job_order jo + ON jo.id = sil.jobOrderId AND jo.deleted = 0 + LEFT JOIN purchase_order po + ON po.id = sil.purchaseOrderId AND po.deleted = 0 + WHERE sil.id IN (:ids) + """.trimIndent(), + mapOf("ids" to chunk), + ) + for (r in rows) { + val ref = r["orderRefNo"]?.toString()?.trim().orEmpty() + if (ref.isEmpty()) continue + slByIn[toLong(r["lineId"])]?.forEach { slId -> + if (refs[slId].isNullOrEmpty()) refs[slId] = ref + } + } + } + } + return refs + } + + private fun openingRow( + lotId: Long, + asOfStr: String, + opening: Double, + master: Map, + ): Map { + val row = HashMap(16) + row["inventoryLotLineId"] = lotId.toString() + row["slId"] = 0L + row["itemNo"] = master["itemNo"] ?: "" + row["itemName"] = master["itemName"] ?: "" + row["unitOfMeasure"] = master["unitOfMeasure"] ?: "" + row["lotNo"] = master["lotNo"] ?: "" + row["expiryDate"] = master["expiryDate"] ?: "" + row["trnDate"] = asOfStr + row["orderRefNo"] = "As of $asOfStr" + row["isOpening"] = 1 + row["openingQtyRaw"] = opening + row["inQtyRaw"] = 0.0 + row["outQtyRaw"] = 0.0 + row["balQtyRaw"] = opening + return row + } + + private fun moveRow( + lotId: Long, + m: Map, + inQty: Double, + outQty: Double, + bal: Double, + master: Map, + ): Map { + val row = HashMap(16) + row["inventoryLotLineId"] = lotId.toString() + row["slId"] = toLong(m["slId"]) + row["itemNo"] = master["itemNo"] ?: "" + row["itemName"] = master["itemName"] ?: "" + row["unitOfMeasure"] = master["unitOfMeasure"] ?: "" + row["lotNo"] = master["lotNo"] ?: "" + row["expiryDate"] = master["expiryDate"] ?: "" + row["trnDate"] = m["trnDate"] ?: "" + row["orderRefNo"] = m["orderRefNo"] ?: "" + row["isOpening"] = 0 + row["openingQtyRaw"] = 0.0 + row["inQtyRaw"] = inQty + row["outQtyRaw"] = outQty + row["balQtyRaw"] = bal + return row + } + + private fun queryLotIdChunks( + lotIds: Collection, + baseArgs: Map, + query: (Map) -> List>, + ): List> { + val ids = lotIds.filter { it > 0 }.distinct() + if (ids.isEmpty()) return emptyList() + val out = ArrayList>() + for (chunk in ids.chunked(800)) { + val chunkArgs = HashMap(baseArgs) + chunkArgs["lotIds"] = chunk + out.addAll(query(chunkArgs)) + } + return out + } + + private fun attachItemUomTotals(rows: List>): List> { + if (rows.isEmpty()) return rows + val lotEnd = LinkedHashMap>() + for (r in rows) { + val lotId = r["inventoryLotLineId"]?.toString().orEmpty() + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + lotEnd[lotId] = key to toDouble(r["balQtyRaw"]) + } + val tot = HashMap() + for ((_, v) in lotEnd) { + tot[v.first] = (tot[v.first] ?: 0.0) + v.second + } + return rows.map { r -> + val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" + val out = HashMap(r) + out["totalCumBalance"] = tot[key] ?: 0.0 + out + } + } + + private fun toDouble(v: Any?): Double { + if (v == null) return 0.0 + if (v is Number) return v.toDouble() + return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + } + + private fun toLong(v: Any?): Long { + if (v == null) return 0L + if (v is Number) return v.toLong() + return v.toString().toLongOrNull() ?: 0L + } + + private fun toInt(v: Any?): Int { + if (v == null) return 0 + if (v is Number) return v.toInt() + return v.toString().toIntOrNull() ?: 0 + } + + private fun buildMultiValueLikeClause( + paramValue: String?, + columnName: String, + paramPrefix: String, + args: MutableMap, + ): String { + if (paramValue.isNullOrBlank()) return "" + val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val conditions = values.mapIndexed { index, value -> + val paramName = "${paramPrefix}_$index" + args[paramName] = "%$value%" + "$columnName LIKE :$paramName" + } + return "AND (${conditions.joinToString(" OR ")})" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index fc243621..b1cf56b9 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -121,7 +121,13 @@ class ReportController( @RequestParam(required = false) stockCategory: String?, @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) lastInDateStart: String?, - @RequestParam(required = false) lastInDateEnd: String? + @RequestParam(required = false) lastInDateEnd: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) warehouse: String?, + @RequestParam(required = false) area: String?, + @RequestParam(required = false) slot: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) poPrefix: String?, ): ResponseEntity { val parameters = mutableMapOf() @@ -137,7 +143,13 @@ class ReportController( stockCategory, itemCode, lastInDateStart, - lastInDateEnd + lastInDateEnd, + storeId, + warehouse, + area, + slot, + lotNo, + poPrefix, ) val pdfBytes = reportService.createPdfResponse( @@ -161,18 +173,37 @@ class ReportController( @RequestParam(required = false) itemCode: String?, @RequestParam(required = false) lastInDateStart: String?, @RequestParam(required = false) lastInDateEnd: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) warehouse: String?, + @RequestParam(required = false) area: String?, + @RequestParam(required = false) slot: String?, + @RequestParam(required = false) lotNo: String?, + @RequestParam(required = false) poPrefix: String?, ): ResponseEntity { val dbData = reportService.searchStockInTraceabilityReport( stockCategory, itemCode, lastInDateStart, lastInDateEnd, + storeId, + warehouse, + area, + slot, + lotNo, + poPrefix, ) val excelBytes = createStockInTraceabilityExcel( dbData = dbData, lastInDateStart = lastInDateStart ?: "", lastInDateEnd = lastInDateEnd ?: "", + itemCode = itemCode, + storeId = storeId, + warehouse = warehouse, + area = area, + slot = slot, + lotNo = lotNo, + poPrefix = poPrefix, ) val headers = HttpHeaders().apply { @@ -563,6 +594,13 @@ class ReportController( dbData: List>, lastInDateStart: String, lastInDateEnd: String, + itemCode: String? = null, + storeId: String? = null, + warehouse: String? = null, + area: String? = null, + slot: String? = null, + lotNo: String? = null, + poPrefix: String? = null, ): ByteArray { val workbook = XSSFWorkbook() val styles = createCommonWorkbookStyles(workbook) @@ -598,8 +636,18 @@ class ReportController( } sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5)) subtitleRow.createCell(6).apply { - val cap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" }) - setCellValue("最後入倉日期:${cap.trim()}") + val dateCap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" }) + val filterBits = listOfNotNull( + itemCode?.takeIf { it.isNotBlank() }?.let { "貨品:$it" }, + storeId?.takeIf { it.isNotBlank() && it != "All" }?.let { "樓層:$it" }, + warehouse?.takeIf { it.isNotBlank() && it != "All" }?.let { "倉庫:$it" }, + area?.takeIf { it.isNotBlank() && it != "All" }?.let { "區域:$it" }, + slot?.takeIf { it.isNotBlank() && it != "All" }?.let { "儲位:$it" }, + lotNo?.takeIf { it.isNotBlank() }?.let { "批號:$it" }, + poPrefix?.takeIf { it.isNotBlank() && it != "All" }?.let { "PP/PF:$it" }, + ).joinToString(" ") + val extra = if (filterBits.isNotBlank()) " $filterBits" else "" + setCellValue("最後入倉日期:${dateCap.trim()}$extra") cellStyle = styles.subtitle } sheet.addMergedRegion(CellRangeAddress(1, 1, 6, totalColumns - 1)) diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt new file mode 100644 index 00000000..818647d0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt @@ -0,0 +1,330 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.StockLotBalanceReportService +import org.slf4j.LoggerFactory +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.DataFormat +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * FP-MTMS | 庫存批次結餘報告 (Excel only, always today) + * Excel: /report/print-stock-lot-balance-excel + */ +@RestController +@RequestMapping("/report") +class StockLotBalanceReportController( + private val stockLotBalanceReportService: StockLotBalanceReportService, +) { + private val log = LoggerFactory.getLogger(StockLotBalanceReportController::class.java) + private data class ExcelStyles( + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val number: CellStyle, + val dash: CellStyle, + ) + + @GetMapping("/print-stock-lot-balance-excel") + fun exportStockLotBalanceReportExcel( + @RequestParam(required = false) stockDate: String?, + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) warehouseCode: String?, + @RequestParam(required = false) storeId: String?, + @RequestParam(required = false) lotNo: String?, + ): ResponseEntity { + val tAll = System.nanoTime() + val result = stockLotBalanceReportService.searchStockLotBalanceReportTimed( + stockDate = stockDate, + itemCode = itemCode, + warehouseCode = warehouseCode, + storeId = storeId, + lotNo = lotNo, + ) + val reportDate = result.stockDate + val tExcel = System.nanoTime() + val excelBytes = createStockLotBalanceExcel( + dbData = result.rows, + reportDate = reportDate, + itemCode = itemCode, + warehouseCode = warehouseCode, + storeId = storeId, + lotNo = lotNo, + timings = result.timings, + ) + val excelMs = (System.nanoTime() - tExcel) / 1_000_000 + val totalMs = (System.nanoTime() - tAll) / 1_000_000 + val excelLine = "6.excel rows=${result.rows.size} ${excelMs}ms" + val requestLine = "request.total ${totalMs}ms" + log.info("stock-lot-balance {} | {}", excelLine, requestLine) + log.info( + "stock-lot-balance REQUEST {} | {} | {}", + requestLine, + result.timings.joinToString(" | "), + excelLine, + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "StockLotBalanceReport.xlsx") + set("filename", "StockLotBalanceReport.xlsx") + set("X-Report-Timings", (result.timings + excelLine + requestLine).joinToString(" | ")) + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createStyles(workbook: Workbook): ExcelStyles { + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 14 + } + setFont(font) + } + val subtitleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 11 + } + setFont(font) + } + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val font = workbook.createFont().apply { bold = true } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + val centerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val df: DataFormat = workbook.createDataFormat() + dataFormat = df.getFormat("#,##0.##") + } + val dashStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + return ExcelStyles( + title = titleStyle, + subtitle = subtitleStyle, + header = headerStyle, + text = textStyle, + center = centerStyle, + number = numberStyle, + dash = dashStyle, + ) + } + + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { + row.createCell(col).apply { + setCellValue(value?.toString() ?: "") + cellStyle = style + } + } + + private fun parseSignedNumber(value: Any?): Double? { + val raw = value?.toString()?.trim().orEmpty() + if (raw.isBlank() || raw == "-" || raw.equals("null", ignoreCase = true)) return null + val negative = raw.startsWith("(") && raw.endsWith(")") + val cleaned = raw.removePrefix("(").removeSuffix(")").replace(",", "").trim() + val n = cleaned.toDoubleOrNull() ?: return null + return if (negative) -n else n + } + + private fun setNumberCellFromFormatted( + row: Row, + col: Int, + value: Any?, + numberStyle: CellStyle, + dashStyle: CellStyle, + ) { + val cell = row.createCell(col) + val parsed = parseSignedNumber(value) + if (parsed == null) { + cell.setCellValue("") + cell.cellStyle = dashStyle + return + } + if (parsed < 0) { + cell.setCellValue("(${"%,.2f".format(kotlin.math.abs(parsed))})") + cell.cellStyle = dashStyle + } else { + cell.setCellValue(parsed) + cell.cellStyle = numberStyle + } + } + + private fun createStockLotBalanceExcel( + dbData: List>, + reportDate: String, + itemCode: String?, + warehouseCode: String?, + storeId: String?, + lotNo: String?, + timings: List, + ): ByteArray { + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存批次結餘報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) + + val headers = listOf( + "貨品編號", "貨品名稱", "單位", + "批號", "到期日", "倉位", "樓層", + "期初存量", "纍計存入量", "纍計存出量", + "錯誤輸入或遺失", "盤盈虧", "不良品棄置", "過期棄置", + "現存存貨", "單位均價", "庫存總價值", + ) + val totalColumns = headers.size + var rowIndex = 0 + + val titleRow = sheet.createRow(rowIndex++) + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title + } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + val reportDateTime = + reportDate + + "(" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val filterBits = listOfNotNull( + itemCode?.takeIf { it.isNotBlank() }?.let { "貨品:$it" }, + warehouseCode?.takeIf { it.isNotBlank() && it != "All" }?.let { "倉位:$it" }, + storeId?.takeIf { it.isNotBlank() && it != "All" }?.let { "樓層:$it" }, + lotNo?.takeIf { it.isNotBlank() }?.let { "批號:$it" }, + ).joinToString(" ") + val subtitleRow = sheet.createRow(rowIndex++) + subtitleRow.createCell(0).apply { + setCellValue( + "報告日期(現況):$reportDateTime" + + if (filterBits.isNotBlank()) " $filterBits" else "", + ) + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) + if (timings.isNotEmpty()) { + val timingRow = sheet.createRow(rowIndex++) + timingRow.createCell(0).apply { + setCellValue("耗時:${timings.joinToString(" | ")}") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1)) + } else { + sheet.createRow(rowIndex++) + } + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } + + if (dbData.isEmpty()) { + val r = sheet.createRow(rowIndex++) + for (c in 0 until totalColumns) { + r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } + } + } else { + dbData.forEach { m -> + val r = sheet.createRow(rowIndex++) + setTextCell(r, 0, m["itemNo"], styles.text) + setTextCell(r, 1, m["itemName"], styles.text) + setTextCell(r, 2, m["unitOfMeasure"], styles.center) + setTextCell(r, 3, m["lotNo"], styles.text) + setTextCell(r, 4, m["expiryDate"], styles.center) + setTextCell(r, 5, m["warehouseCode"], styles.text) + setTextCell(r, 6, m["storeId"], styles.center) + setNumberCellFromFormatted(r, 7, m["totalOpeningBalance"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 8, m["totalCumStockIn"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 9, m["totalCumStockOut"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 10, m["totalMisInputAndLost"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 11, m["totalVariance"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 12, m["totalDefectiveGoods"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 13, m["totalExpiredDisposed"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 14, m["totalCurrentBalance"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 15, m["avgUnitPrice"], styles.number, styles.dash) + setNumberCellFromFormatted(r, 16, m["totalStockBalance"], styles.number, styles.dash) + } + } + + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + } + val widths = intArrayOf(14, 22, 12, 18, 12, 18, 8, 12, 12, 12, 14, 12, 12, 12, 12, 12, 14) + widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt new file mode 100644 index 00000000..083d5586 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt @@ -0,0 +1,326 @@ +package com.ffii.fpsms.modules.report.web + +import com.ffii.fpsms.modules.report.service.StockLotLedgerReportService +import org.slf4j.LoggerFactory +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.CellStyle +import org.apache.poi.ss.usermodel.DataFormat +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.usermodel.Workbook +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.ss.util.WorkbookUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.io.ByteArrayOutputStream +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * 庫存流水帳報告 (Excel) + * Excel: /report/print-stock-lot-ledger-excel + */ +@RestController +@RequestMapping("/report") +class StockLotLedgerReportController( + private val stockLotLedgerReportService: StockLotLedgerReportService, +) { + private val log = LoggerFactory.getLogger(StockLotLedgerReportController::class.java) + private data class ExcelStyles( + val title: CellStyle, + val subtitle: CellStyle, + val header: CellStyle, + val text: CellStyle, + val center: CellStyle, + val number: CellStyle, + val dash: CellStyle, + ) + + @GetMapping("/print-stock-lot-ledger-excel") + fun exportStockLotLedgerReportExcel( + @RequestParam(required = false) itemCode: String?, + @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, + @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, + ): ResponseEntity { + val tAll = System.nanoTime() + val result = stockLotLedgerReportService.searchStockLotLedgerReportTimed( + itemCode = itemCode, + reportPeriodStart = reportPeriodStart, + reportPeriodEnd = reportPeriodEnd, + ) + val period = result.period + val tExcel = System.nanoTime() + val excelBytes = createExcel( + dbData = result.rows, + reportPeriodStart = period.startStr, + reportPeriodEnd = period.endStr, + timings = result.timings, + ) + val excelMs = (System.nanoTime() - tExcel) / 1_000_000 + val totalMs = (System.nanoTime() - tAll) / 1_000_000 + val excelLine = "7.excel rows=${result.rows.size} ${excelMs}ms" + val requestLine = "request.total ${totalMs}ms" + log.info("stock-lot-ledger {} | {}", excelLine, requestLine) + log.info( + "stock-lot-ledger REQUEST {} | {} | {}", + requestLine, + result.timings.joinToString(" | "), + excelLine, + ) + + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + setContentDispositionFormData("attachment", "StockLotLedgerReport.xlsx") + set("filename", "StockLotLedgerReport.xlsx") + set("X-Report-Timings", (result.timings + excelLine + requestLine).joinToString(" | ")) + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createStyles(workbook: Workbook): ExcelStyles { + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 14 + } + setFont(font) + } + val subtitleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont().apply { + bold = true + fontHeightInPoints = 11 + } + setFont(font) + } + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val font = workbook.createFont().apply { bold = true } + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + val centerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + val df: DataFormat = workbook.createDataFormat() + dataFormat = df.getFormat("#,##0.##") + } + val dashStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + return ExcelStyles( + title = titleStyle, + subtitle = subtitleStyle, + header = headerStyle, + text = textStyle, + center = centerStyle, + number = numberStyle, + dash = dashStyle, + ) + } + + private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) { + row.createCell(col).apply { + setCellValue(value?.toString() ?: "") + cellStyle = style + } + } + + private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle, dashStyle: CellStyle) { + val cell = row.createCell(col) + val n = when (value) { + null -> null + is Number -> value.toDouble() + else -> { + val raw = value.toString().trim() + if (raw.isEmpty() || raw == "-") null else raw.replace(",", "").toDoubleOrNull() + } + } + if (n == null) { + cell.setCellValue("") + cell.cellStyle = dashStyle + return + } + if (n < 0) { + cell.setCellValue("(${"%,.2f".format(kotlin.math.abs(n))})") + cell.cellStyle = dashStyle + } else { + cell.setCellValue(n) + cell.cellStyle = numberStyle + } + } + + private fun isOpeningRow(m: Map): Boolean { + val v = m["isOpening"] + if (v is Number) return v.toInt() != 0 + return v?.toString() == "1" + } + + private fun createExcel( + dbData: List>, + reportPeriodStart: String, + reportPeriodEnd: String, + timings: List, + ): ByteArray { + val workbook = SXSSFWorkbook(100) + workbook.setCompressTempFiles(true) + try { + val styles = createStyles(workbook) + val reportTitle = "庫存流水帳報告" + val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle)) + val headers = listOf( + "貨品編號", "貨品名稱", "單位", "貨品單位結餘", + "批號", "到期日", "參考日期", "參考單據", + "批號期初", "入庫", "出庫", "結餘", + ) + val totalColumns = headers.size + var rowIndex = 0 + + val titleRow = sheet.createRow(rowIndex++) + titleRow.createCell(0).apply { + setCellValue(reportTitle) + cellStyle = styles.title + } + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + + val reportDateTime = + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + + "(" + + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + + ")" + val subtitleRow = sheet.createRow(rowIndex++) + subtitleRow.createCell(0).apply { + setCellValue("報告日期:$reportDateTime") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4)) + subtitleRow.createCell(5).apply { + setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1)) + if (timings.isNotEmpty()) { + val timingRow = sheet.createRow(rowIndex++) + timingRow.createCell(0).apply { + setCellValue("耗時:${timings.joinToString(" | ")}") + cellStyle = styles.subtitle + } + sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1)) + } else { + sheet.createRow(rowIndex++) + } + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headers.forEachIndexed { i, h -> + headerRow.createCell(i).apply { + setCellValue(h) + cellStyle = styles.header + } + } + + if (dbData.isEmpty()) { + val r = sheet.createRow(rowIndex++) + for (c in 0 until totalColumns) { + r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text } + } + } else { + var prevItemUom = "" + var prevLot = "" + dbData.forEach { m -> + val itemNo = m["itemNo"]?.toString().orEmpty() + val uom = m["unitOfMeasure"]?.toString().orEmpty() + val lotId = m["inventoryLotLineId"]?.toString().orEmpty() + val itemUomKey = "$itemNo|$uom" + val lotKey = "$itemUomKey|$lotId" + val showItem = itemUomKey != prevItemUom + val showLot = lotKey != prevLot + val r = sheet.createRow(rowIndex++) + setTextCell(r, 0, if (showItem) itemNo else "", styles.text) + setTextCell(r, 1, if (showItem) m["itemName"] else "", styles.text) + setTextCell(r, 2, if (showItem) uom else "", styles.center) + if (showItem) { + setNumberCell(r, 3, m["totalCumBalance"], styles.number, styles.dash) + } else { + setTextCell(r, 3, "", styles.dash) + } + setTextCell(r, 4, if (showLot) m["lotNo"] else "", styles.text) + setTextCell(r, 5, if (showLot) m["expiryDate"] else "", styles.center) + setTextCell(r, 6, m["trnDate"], styles.center) + setTextCell(r, 7, m["orderRefNo"], styles.text) + if (isOpeningRow(m)) { + setNumberCell(r, 8, m["openingQtyRaw"], styles.number, styles.dash) + } else { + setTextCell(r, 8, "", styles.dash) + } + setNumberCell(r, 9, m["inQtyRaw"], styles.number, styles.dash) + setNumberCell(r, 10, m["outQtyRaw"], styles.number, styles.dash) + setNumberCell(r, 11, m["balQtyRaw"], styles.number, styles.dash) + prevItemUom = itemUomKey + prevLot = lotKey + } + } + + val lastRowIndex = rowIndex - 1 + if (lastRowIndex >= headerRowIndex) { + sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0)) + } + intArrayOf(14, 26, 10, 14, 16, 12, 12, 22, 12, 10, 10, 12) + .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + + val out = ByteArrayOutputStream() + workbook.write(out) + return out.toByteArray() + } finally { + workbook.dispose() + workbook.close() + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt new file mode 100644 index 00000000..d2031afc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt @@ -0,0 +1,260 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.support.JdbcDao +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.util.concurrent.ConcurrentHashMap + +/** + * Daily close for [stock_lot_day] (fix step 2.6): + * - moves: aggregate day's ledger by lot (opening / in / out / closing) + * - carry: previous closing > 0 with no ledger today → opening = closing = prev.closing + * + * Never closes today or future dates. Idempotent via ON DUPLICATE KEY UPDATE. + */ +@Service +open class StockLotDayCloseService( + private val jdbcDao: JdbcDao, +) { + private val log = LoggerFactory.getLogger(StockLotDayCloseService::class.java) + private val inFlight = ConcurrentHashMap.newKeySet() + + data class CloseDayResult( + val date: LocalDate, + val moveRows: Int, + val carryRows: Int, + ) { + val rowsWritten: Int get() = moveRows + carryRows + } + + data class CloseRunResult( + val days: List, + val skipped: Boolean = false, + val skipReason: String? = null, + ) { + val totalRows: Int get() = days.sumOf { it.rowsWritten } + val dateFrom: LocalDate? get() = days.minOfOrNull { it.date } + val dateTo: LocalDate? get() = days.maxOfOrNull { it.date } + } + + /** + * Close [day] only. [actor] is written to createdBy/modifiedBy. + */ + open fun closeDay(day: LocalDate, actor: String = ACTOR_SCHEDULER): CloseDayResult { + require(day.isBefore(LocalDate.now())) { + "only dates before today can be closed (got $day)" + } + if (!inFlight.add(LOCK)) { + throw IllegalStateException("stock_lot_day close already running") + } + try { + return doCloseDay(day, actor) + } finally { + inFlight.remove(LOCK) + } + } + + /** + * Same as [doCloseDay] without the scheduler lock — for [StockLedgerFixService] which already + * holds its own run lock around day steps 2.1–2.6. + * Fix UI may close **today** on a freeze-night dump (e.g. 31/8 dump → fix 0831 same evening). + * Scheduler [closeDay] / [closeThrough] still reject today. + */ + open fun upsertDayForFix(day: LocalDate): CloseDayResult { + require(!day.isAfter(LocalDate.now())) { + "only dates on or before today can be closed for fix (got $day)" + } + return doCloseDay(day, ACTOR_FIX) + } + + /** + * Close yesterday, optionally catching up from last closed day (capped by [catchUpDays]). + */ + open fun closeYesterday(catchUpDays: Int = 7, actor: String = ACTOR_SCHEDULER): CloseRunResult { + val yesterday = LocalDate.now().minusDays(1) + return closeThrough(yesterday, catchUpDays, actor) + } + + /** + * Close through [target] (inclusive). Catch-up from last closed + 1, capped by [catchUpDays]. + * If [target] is null, uses yesterday. + */ + open fun closeThrough( + target: LocalDate? = null, + catchUpDays: Int = 7, + actor: String = ACTOR_SCHEDULER, + ): CloseRunResult { + val end = target ?: LocalDate.now().minusDays(1) + require(end.isBefore(LocalDate.now())) { + "only dates before today can be closed (got $end)" + } + val cap = catchUpDays.coerceAtLeast(0) + if (!inFlight.add(LOCK)) { + log.warn("stock_lot_day close skipped: already running") + return CloseRunResult(emptyList(), skipped = true, skipReason = "already running") + } + try { + val lastClosed = maxClosedDate() + val from = when { + lastClosed == null -> end + lastClosed.isBefore(end) -> { + val gapStart = lastClosed.plusDays(1) + val earliest = end.minusDays(cap.toLong()) + if (gapStart.isBefore(earliest)) earliest else gapStart + } + else -> end // already closed through end → re-close end (idempotent) + } + val days = mutableListOf() + var d = from + while (!d.isAfter(end)) { + days += doCloseDay(d, actor) + d = d.plusDays(1) + } + log.info( + "stock_lot_day close done from={} to={} days={} rows={} actor={}", + from, end, days.size, days.sumOf { it.rowsWritten }, actor, + ) + return CloseRunResult(days) + } finally { + inFlight.remove(LOCK) + } + } + + private fun doCloseDay(day: LocalDate, actor: String): CloseDayResult { + val args = mapOf( + "day" to day, + "dayNext" to day.plusDays(1), + "prevDay" to day.minusDays(1), + "actor" to actor, + ) + val t0 = System.nanoTime() + val moveRows = jdbcDao.executeUpdate(MOVES_SQL, args) + val carryRows = jdbcDao.executeUpdate(CARRY_SQL, args) + log.info( + "stock_lot_day close {} moves={} carry={} {}ms actor={}", + day, moveRows, carryRows, (System.nanoTime() - t0) / 1_000_000, actor, + ) + return CloseDayResult(date = day, moveRows = moveRows, carryRows = carryRows) + } + + private fun maxClosedDate(): LocalDate? { + val row = jdbcDao.queryForList( + """ + SELECT MAX(date) AS d + FROM stock_lot_day + WHERE IFNULL(deleted, 0) = 0 + """.trimIndent(), + ).firstOrNull() ?: return null + val v = row["d"] ?: return null + return when (v) { + is LocalDate -> v + is java.sql.Date -> v.toLocalDate() + is java.sql.Timestamp -> v.toLocalDateTime().toLocalDate() + else -> LocalDate.parse(v.toString().take(10)) + } + } + + companion object { + const val ACTOR_SCHEDULER = "stock-lot-day-close" + const val ACTOR_FIX = "ledger-fix-api" + private const val LOCK = "stock-lot-day-close" + + private val MOVES_SQL = """ + INSERT INTO stock_lot_day ( + created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + ) + SELECT + NOW(), :actor, 0, NOW(), :actor, 0, + x.inventoryLotLineId, + x.itemId, + x.itemCode, + x.uomId, + il.lotNo, + :day, + firstSl.lotQtyBefore, + x.inQty, + x.outQty, + lastSl.lotQtyAfter + FROM ( + SELECT + sl.inventoryLotLineId, + MIN(sl.itemId) AS itemId, + MIN(sl.itemCode) AS itemCode, + MIN(sl.uomId) AS uomId, + MIN(sl.id) AS firstId, + MAX(sl.id) AS lastId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + AND sl.lotQtyAfter IS NOT NULL + GROUP BY sl.inventoryLotLineId + ) x + INNER JOIN stock_ledger firstSl ON firstSl.id = x.firstId + INNER JOIN stock_ledger lastSl ON lastSl.id = x.lastId + LEFT JOIN inventory_lot_line ill ON ill.id = x.inventoryLotLineId + LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId + ON DUPLICATE KEY UPDATE + itemId = VALUES(itemId), + itemCode = VALUES(itemCode), + uomId = VALUES(uomId), + lotNo = VALUES(lotNo), + opening = VALUES(opening), + inQty = VALUES(inQty), + outQty = VALUES(outQty), + closing = VALUES(closing), + modified = NOW(), + modifiedBy = VALUES(modifiedBy), + version = stock_lot_day.version + 1, + deleted = 0 + """.trimIndent() + + private val CARRY_SQL = """ + INSERT INTO stock_lot_day ( + created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + ) + SELECT + NOW(), :actor, 0, NOW(), :actor, 0, + prev.inventoryLotLineId, + prev.itemId, + prev.itemCode, + prev.uomId, + prev.lotNo, + :day, + prev.closing, + 0, + 0, + prev.closing + FROM stock_lot_day prev + WHERE prev.date = :prevDay + AND IFNULL(prev.deleted, 0) = 0 + AND prev.closing > 0 + AND NOT EXISTS ( + SELECT 1 FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId = prev.inventoryLotLineId + ) + ON DUPLICATE KEY UPDATE + itemId = VALUES(itemId), + itemCode = VALUES(itemCode), + uomId = VALUES(uomId), + lotNo = VALUES(lotNo), + opening = VALUES(opening), + inQty = VALUES(inQty), + outQty = VALUES(outQty), + closing = VALUES(closing), + modified = NOW(), + modifiedBy = VALUES(modifiedBy), + version = stock_lot_day.version + 1, + deleted = 0 + """.trimIndent() + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index dcd2d6e5..3629408f 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -40,6 +40,9 @@ scheduler: syncOffsetDays: 10 # from (today − 10) 00:00 to now, rows missing grn_code inventoryLotExpiry: enabled: true + stockLotDayClose: + enabled: true + catchUpDays: 7 # One-time DO1 catch-ups (only registered when scheduler.m18Sync.enabled=true — production profile). # skipExistingDo defaults true for catch-up: already-synced/picked DOs are not overwritten. do1CatchUp: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74ce2bc6..d85c24af 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,6 +33,10 @@ scheduler: syncOffsetDays: 0 inventoryLotExpiry: enabled: true + # Daily stock_lot_day close (yesterday) at 00:15 — moves + carry; catchUpDays caps missed days. + stockLotDayClose: + enabled: true + catchUpDays: 7 # Job order: at 00:00:15 daily, process JOs whose planStart was yesterday (hide or reschedule). jo: planStart: diff --git a/src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql b/src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql new file mode 100644 index 00000000..dd215481 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260812_stock_lot_balance_perf/01_ledger_lot_date_index.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql + +--changeset fpsms:stock_lot_balance_ledger_lot_date_index +--comment: Speed stock lot balance report — as-of last row by inventoryLotLineId + date + id + +CREATE INDEX `idx_ledger_lot_date_id` + ON `stock_ledger` (`inventoryLotLineId`, `date`, `id`); diff --git a/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql b/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql new file mode 100644 index 00000000..414eea05 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql @@ -0,0 +1,30 @@ +--liquibase formatted sql + +--changeset fpsms:create_stock_lot_day +--comment: Daily lot stock card (主錨). Month-end report aggregates this table. +--precondition onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'stock_lot_day' + +CREATE TABLE `stock_lot_day` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `inventoryLotLineId` BIGINT NOT NULL, + `itemId` INT NULL, + `itemCode` VARCHAR(255) NULL, + `uomId` INT NULL, + `lotNo` VARCHAR(512) NULL, + `date` DATE NOT NULL, + `opening` DECIMAL(14,2) NOT NULL DEFAULT 0.00, + `inQty` DECIMAL(14,2) NOT NULL DEFAULT 0.00, + `outQty` DECIMAL(14,2) NOT NULL DEFAULT 0.00, + `closing` DECIMAL(14,2) NOT NULL DEFAULT 0.00, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_stock_lot_day_lot_date` (`inventoryLotLineId`, `date`), + KEY `idx_stock_lot_day_date` (`date`), + KEY `idx_stock_lot_day_item_date` (`itemId`, `date`) +); diff --git a/src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql b/src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql new file mode 100644 index 00000000..1ea65b05 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260821_stock_lot_day_close/01_insert_schedule_setting.sql @@ -0,0 +1,10 @@ +--liquibase formatted sql +--changeset fpsms:20260821_stock_lot_day_close_schedule +--comment: Daily stock_lot_day close cron (default 00:15) + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT 'SCHEDULE.stockLotDay.close', '0 15 0 * * *', 'SCHEDULE', 'string' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` WHERE `name` = 'SCHEDULE.stockLotDay.close' +); diff --git a/src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql b/src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql new file mode 100644 index 00000000..bffada7d --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260824_stock_ledger_fix_perf/01_add_sl_date_deleted_item_uom_index.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql + +--changeset fpsms:stock_ledger_fix_add_sl_date_deleted_item_uom_index +--comment: Speed stock-ledger-fix step 2.3 fillInventoryId (DISTINCT itemId,uomId over date range) + +CREATE INDEX idx_sl_date_deleted_item_uom + ON stock_ledger (`date`, `deleted`, `itemId`, `uomId`); + diff --git a/src/main/resources/db/scripts/20260814_inventory_1_0.sql b/src/main/resources/db/scripts/20260814_inventory_1_0.sql new file mode 100644 index 00000000..355f899d --- /dev/null +++ b/src/main/resources/db/scripts/20260814_inventory_1_0.sql @@ -0,0 +1,87 @@ +-- Manual (not Liquibase). Inventory 1.0 option A. +-- Keep existing inventory.id. INSERT missing (itemId, uomId) from lot lines +-- (including item_uom.deleted=1 — historical lots still point there). +-- Recalc onHand / onHold / unavailable from Σ lot (in-out / hold / unavailable). +-- UOM with no lots → qty 0. Do NOT UPDATE inventory_lot_line (old trigger would fire). +-- Run once on the dump before walking ledger days. Idempotent if lots unchanged. + +-- 1) INSERT missing (itemId, uomId); copy price/currency from any existing row of that item. +INSERT INTO inventory ( + itemId, uomId, onHandQty, onHoldQty, unavailableQty, + price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status, + created, createdBy, modified, modifiedBy, version, deleted +) +SELECT + src.itemId, + src.uomId, + 0, 0, 0, + COALESCE(tpl.price, 0), + tpl.currencyId, + COALESCE(tpl.cpu, 0), + COALESCE(tpl.cpuUnit, 'HKD'), + COALESCE(tpl.cpm, 0), + COALESCE(tpl.cpmUnit, 'HKD'), + 'unavailable', + NOW(), 'stock-ledger-fix', NOW(), 'stock-ledger-fix', 0, 0 +FROM ( + SELECT il.itemId, iu.uomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId, iu.uomId +) src +LEFT JOIN inventory existing + ON existing.itemId = src.itemId + AND existing.uomId = src.uomId + AND IFNULL(existing.deleted, 0) = 0 +LEFT JOIN inventory tpl + ON tpl.id = ( + SELECT MIN(i.id) + FROM inventory i + WHERE i.itemId = src.itemId + AND IFNULL(i.deleted, 0) = 0 + ) +WHERE existing.id IS NULL; + +-- 2) Recalc every inventory row from lot lines grouped by item+UOM. No lots → 0. +UPDATE inventory i +LEFT JOIN ( + SELECT + il.itemId, + iu.uomId, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand, + SUM(COALESCE(ill.holdQty, 0)) AS onHold, + SUM( + CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) + ELSE 0 + END + ) AS unavailable + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId, iu.uomId +) agg ON agg.itemId = i.itemId AND agg.uomId = i.uomId +SET + i.onHandQty = COALESCE(agg.onHand, 0), + i.onHoldQty = COALESCE(agg.onHold, 0), + i.unavailableQty = COALESCE(agg.unavailable, 0), + i.status = IF( + COALESCE(agg.onHand, 0) - COALESCE(agg.onHold, 0) - COALESCE(agg.unavailable, 0) > 0, + 'available', + 'unavailable' + ), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' +WHERE IFNULL(i.deleted, 0) = 0; From 02a58538e697ef146f724d8d44ed8c57df119e6b Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 1 Sep 2026 11:28:17 +0800 Subject: [PATCH 02/52] update --- .../modules/master/service/ItemUomService.kt | 50 +++++++- .../stock/service/StockInLineService.kt | 27 +++++ .../stock/service/StockQtyRoundSupport.kt | 109 ++++++++++++++++++ .../stock/web/model/SaveStockInRequest.kt | 8 +- .../stock/service/StockQtyRoundSupportTest.kt | 82 +++++++++++++ 5 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 275b80c5..b005670b 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -104,6 +104,9 @@ open class ItemUomService( open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty; val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; + if (sameUomId(purchaseUnit, stockUnit)) { + return purchaseQty.setScale(0, RoundingMode.UP) + } val one = BigDecimal.ONE; // IMPORTANT: @@ -129,6 +132,9 @@ open class ItemUomService( open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty + if (sameUomId(purchaseUnit, stockUnit)) { + return purchaseQty.setScale(0, RoundingMode.DOWN) + } val one = BigDecimal.ONE // Use high precision for intermediate steps, and round down only at the end. @@ -149,6 +155,9 @@ open class ItemUomService( open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty + if (sameUomId(purchaseUnit, stockUnit)) { + return stockQty.setScale(0, RoundingMode.UP) + } val one = BigDecimal.ONE val calcScale = 10 @@ -168,8 +177,11 @@ open class ItemUomService( * Returns source qty when source/purchase unit mapping is not found. */ open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { - val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty + if (sameUomId(uomId, purchaseUnit)) { + return sourceQty.setScale(0, RoundingMode.UP) + } + val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty val one = BigDecimal.ONE val calcScale = 10 @@ -185,9 +197,12 @@ open class ItemUomService( } open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { - val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty; - - val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO; + val stockUnit = findStockUnitByItemId(itemId) + if (stockUnit != null && sameUomId(uomId, stockUnit)) { + return sourceQty.setScale(0, RoundingMode.UP) + } + val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty + if (stockUnit == null) return BigDecimal.ZERO val one = BigDecimal.ONE; val calcScale = 10 @@ -208,8 +223,12 @@ open class ItemUomService( * Same as convertQtyToStockQty but rounds down the final stock qty to integer. */ open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { + val stockUnit = findStockUnitByItemId(itemId) + if (stockUnit != null && sameUomId(uomId, stockUnit)) { + return sourceQty.setScale(0, RoundingMode.DOWN) + } val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty - val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO + if (stockUnit == null) return BigDecimal.ZERO val one = BigDecimal.ONE val calcScale = 10 @@ -224,6 +243,18 @@ open class ItemUomService( return stockQty.setScale(0, RoundingMode.DOWN) } + /** True when both item UOMs point at the same `uom_conversion` row. */ + private fun sameUomId(left: ItemUom?, right: ItemUom?): Boolean { + val leftId = left?.uom?.id ?: return false + val rightId = right?.uom?.id ?: return false + return leftId == rightId + } + + private fun sameUomId(uomId: Long, unit: ItemUom?): Boolean { + val unitUomId = unit?.uom?.id ?: return false + return unitUomId == uomId + } + /** * Stock UOM code [PCS/CTN]* denotes pieces-per-carton; displayed stock qty should be whole cartons (integer). */ @@ -247,6 +278,9 @@ open class ItemUomService( open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty + if (sameUomId(purchaseUnit, stockUnit)) { + return finalizePreciseStockQty(stockUnit, purchaseQty) + } val one = BigDecimal.ONE val calcScale = 10 @@ -266,8 +300,12 @@ open class ItemUomService( * Used for PO (M18 UOM) conversion where round-down is not allowed. */ open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { + val stockUnit = findStockUnitByItemId(itemId) + if (stockUnit != null && sameUomId(uomId, stockUnit)) { + return finalizePreciseStockQty(stockUnit, sourceQty) + } val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty - val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO + if (stockUnit == null) return BigDecimal.ZERO val one = BigDecimal.ONE val calcScale = 10 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index fa4c1abc..8101446b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -331,6 +331,31 @@ open class StockInLineService( latestCode = latestCode, ) } + + private fun applyRequestedStockQtyRound( + stockInLine: StockInLine, + request: SaveStockInLineRequest, + isCreate: Boolean, + ) { + if (request.stockQtyRoundMode.isNullOrBlank()) return + if (stockInLine.purchaseOrderLine == null) return + if (!isCreate) { + val status = stockInLine.status?.lowercase()?.trim() + if (status != StockInLineStatus.PENDING.status && status != StockInLineStatus.ESCALATED.status) { + return + } + } + val currentQty = stockInLine.acceptedQty ?: return + val (roundedQty, remarks) = StockQtyRoundSupport.applyIfRequested( + currentQty = currentQty, + currentRemarks = stockInLine.remarks ?: request.remarks, + request = request, + defaultSource = if (isCreate) "CREATE" else "QC", + ) + stockInLine.acceptedQty = roundedQty + stockInLine.remarks = remarks + } + @Throws(IOException::class) @Transactional open fun create(request: SaveStockInLineRequest): MessageResponse { @@ -449,6 +474,7 @@ open class StockInLineService( this.type = "NOR" status = StockInLineStatus.PENDING.status } + applyRequestedStockQtyRound(stockInLine, request, isCreate = true) if (jo != null) { val planStartDate = jo.planStart?.toLocalDate() stockInLine.lotNo = assignLotNoForJo(planStartDate) @@ -1030,6 +1056,7 @@ open class StockInLineService( this.expiryDate = stockInLine.expiryDate ?: request.expiryDate this.remarks = stockInLine.remarks ?: request.remarks } + applyRequestedStockQtyRound(stockInLine, request, isCreate = false) _logStep("apply_fields", _tApplyFields) if (request.status == StockInLineStatus.RECEIVED.status) { val _tPutAwayTotal = System.nanoTime() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt new file mode 100644 index 00000000..33093520 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt @@ -0,0 +1,109 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.exception.BadRequestException +import com.ffii.fpsms.modules.common.SecurityUtils +import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +object StockQtyRoundSupport { + private val TAG_REGEX = Regex("""\[QTY_ROUND[^\]]*\]""") + private val AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm") + private val REMARKS_MAX = 500 + + enum class Mode { + HALF_UP, + CEILING, + FLOOR, + CUSTOM, + } + + fun isNotInteger(qty: BigDecimal): Boolean { + return qty.stripTrailingZeros().scale() > 0 + } + + fun applyIfRequested( + currentQty: BigDecimal, + currentRemarks: String?, + request: SaveStockInLineRequest, + defaultSource: String, + ): Pair { + val mode = parseMode(request.stockQtyRoundMode) ?: return currentQty to currentRemarks + if (!isNotInteger(currentQty)) { + return currentQty to currentRemarks + } + val after = roundQty(currentQty, mode, request.stockQtyCustomQty) + val source = request.stockQtyRoundSource?.trim()?.ifBlank { null } ?: defaultSource + val tag = buildTag( + mode = mode, + before = currentQty, + after = after, + source = source, + ) + val withReason = if (mode == Mode.CUSTOM) { + val reason = request.stockQtyCustomReason?.trim().orEmpty() + if (reason.isEmpty()) tag else "$tag 原因:$reason" + } else { + tag + } + return after to mergeRemark(currentRemarks, withReason) + } + + fun parseMode(raw: String?): Mode? { + val value = raw?.trim()?.uppercase() ?: return null + if (value.isEmpty()) return null + return try { + Mode.valueOf(value) + } catch (_: IllegalArgumentException) { + throw BadRequestException("Invalid stockQtyRoundMode: $raw") + } + } + + fun roundQty(before: BigDecimal, mode: Mode, customQty: BigDecimal?): BigDecimal { + return when (mode) { + Mode.HALF_UP -> before.setScale(0, RoundingMode.HALF_UP) + Mode.CEILING -> before.setScale(0, RoundingMode.CEILING) + Mode.FLOOR -> before.setScale(0, RoundingMode.FLOOR) + Mode.CUSTOM -> { + val custom = customQty ?: throw BadRequestException("stockQtyCustomQty is required for CUSTOM") + if (custom <= BigDecimal.ZERO) { + throw BadRequestException("stockQtyCustomQty must be greater than 0") + } + if (isNotInteger(custom)) { + throw BadRequestException("stockQtyCustomQty must be an integer") + } + custom.setScale(0, RoundingMode.DOWN) + } + } + } + + fun buildTag( + mode: Mode, + before: BigDecimal, + after: BigDecimal, + source: String, + at: LocalDateTime = LocalDateTime.now(), + by: String = currentUsername(), + ): String { + return "[QTY_ROUND mode=${mode.name} before=${plain(before)} after=${plain(after)} source=$source by=${sanitize(by)} at=${at.format(AT_FORMAT)}]" + } + + fun mergeRemark(existing: String?, tag: String): String { + val stripped = existing?.replace(TAG_REGEX, "")?.trim().orEmpty() + val merged = if (stripped.isEmpty()) tag else "$tag $stripped" + return if (merged.length <= REMARKS_MAX) merged else merged.take(REMARKS_MAX) + } + + private fun currentUsername(): String { + val user = SecurityUtils.getUser().orElse(null) + return user?.username ?: user?.name ?: "unknown" + } + + private fun plain(qty: BigDecimal): String = qty.stripTrailingZeros().toPlainString() + + private fun sanitize(value: String): String { + return value.replace(Regex("""[\s\[\]]+"""), "_").ifBlank { "unknown" } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt index ff261531..e605552d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt @@ -63,7 +63,13 @@ data class SaveStockInLineRequest( var escalationLog: SaveEscalationLogRequest? = null, var warehouseId: Long? = null, var rejectQty: BigDecimal? = null, - var inventoryLotLines: List? = null + var inventoryLotLines: List? = null, + /** HALF_UP / CEILING / FLOOR / CUSTOM. Null keeps current decimal stock qty. */ + var stockQtyRoundMode: String? = null, + var stockQtyCustomQty: BigDecimal? = null, + /** CREATE (PO submit) or QC (old pending line). */ + var stockQtyRoundSource: String? = null, + var stockQtyCustomReason: String? = null, ) data class SaveInventoryLotLineForSil ( diff --git a/src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt new file mode 100644 index 00000000..944affc2 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt @@ -0,0 +1,82 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.LocalDateTime + +class StockQtyRoundSupportTest { + @Test + fun isNotInteger_detects_decimals() { + assertTrue(StockQtyRoundSupport.isNotInteger(BigDecimal("10.50"))) + assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10.00"))) + assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10"))) + } + + @Test + fun roundQty_halfUp_ceiling_floor() { + val before = BigDecimal("10.50") + assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.HALF_UP, null).compareTo(BigDecimal("11"))) + assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CEILING, null).compareTo(BigDecimal("11"))) + assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.FLOOR, null).compareTo(BigDecimal("10"))) + assertEquals( + 0, + StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CUSTOM, BigDecimal("12")).compareTo(BigDecimal("12")), + ) + } + + @Test + fun mergeRemark_replaces_previous_tag() { + val first = StockQtyRoundSupport.buildTag( + mode = StockQtyRoundSupport.Mode.FLOOR, + before = BigDecimal("10.50"), + after = BigDecimal("10"), + source = "CREATE", + at = LocalDateTime.of(2026, 8, 20, 17, 40), + by = "kw093", + ) + val mergedOnce = StockQtyRoundSupport.mergeRemark("舊備註", first) + val second = StockQtyRoundSupport.buildTag( + mode = StockQtyRoundSupport.Mode.CUSTOM, + before = BigDecimal("10.50"), + after = BigDecimal("12"), + source = "QC", + at = LocalDateTime.of(2026, 8, 20, 17, 45), + by = "kw093", + ) + val mergedTwice = StockQtyRoundSupport.mergeRemark(mergedOnce, second) + assertTrue(mergedTwice.startsWith("[QTY_ROUND mode=CUSTOM")) + assertTrue(mergedTwice.contains("舊備註")) + assertFalse(mergedTwice.contains("mode=FLOOR")) + } + + @Test + fun applyIfRequested_writes_custom_tag() { + val request = SaveStockInLineRequest( + itemId = 1, + acceptedQty = BigDecimal("10.50"), + acceptQty = null, + status = null, + expiryDate = null, + stockQtyRoundMode = "CUSTOM", + stockQtyCustomQty = BigDecimal("12"), + stockQtyRoundSource = "QC", + stockQtyCustomReason = "實收12箱", + ) + val (qty, remarks) = StockQtyRoundSupport.applyIfRequested( + currentQty = BigDecimal("10.50"), + currentRemarks = null, + request = request, + defaultSource = "QC", + ) + assertEquals(0, qty.compareTo(BigDecimal("12"))) + assertTrue(remarks!!.contains("mode=CUSTOM")) + assertTrue(remarks.contains("before=10.5")) + assertTrue(remarks.contains("after=12")) + assertTrue(remarks.contains("source=QC")) + assertTrue(remarks.contains("原因:實收12箱")) + } +} From 1541c5d1b47560939b0ff44638e0d5efa13859ed Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Sat, 12 Sep 2026 00:24:48 +0800 Subject: [PATCH 03/52] Add inventory stock-UOM bucket foundation (schema, triggers, resolver, API). Co-authored-by: Cursor --- .../service/DoWorkbenchMainService.kt | 8 +- .../modules/master/service/ItemUomService.kt | 28 ++- .../fpsms/modules/stock/entity/Inventory.kt | 9 + .../stock/entity/InventoryRepository.kt | 91 ++++---- .../stock/entity/projection/InventoryInfo.kt | 33 +-- .../stock/service/InventoryBucketResolver.kt | 56 +++++ .../modules/stock/service/InventoryService.kt | 40 +--- .../stock/service/StockInLineService.kt | 5 +- .../stock/service/StockOutLineService.kt | 33 +-- .../service/StockOutLineWorkbenchService.kt | 7 +- .../modules/stock/web/InventoryController.kt | 10 +- .../01_add_inventory_stock_uom_id.sql | 17 ++ .../02_add_stock_uom_indexes.sql | 16 ++ .../03_drop_inventory_lot_line_triggers.sql | 10 + ...rite_inventory_lot_line_trigger_insert.sql | 161 +++++++++++++ ...rite_inventory_lot_line_trigger_update.sql | 217 ++++++++++++++++++ 16 files changed, 606 insertions(+), 135 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index e1563c53..dba9441e 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -46,6 +46,7 @@ import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot +import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus import org.springframework.stereotype.Service @@ -115,6 +116,7 @@ open class DoWorkbenchMainService( private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val stockLedgerRepository: StockLedgerRepository, private val itemUomService: ItemUomService, + private val inventoryBucketResolver: InventoryBucketResolver, private val itemUomRespository: ItemUomRespository, private val bagRepository: BagRepository, private val bagService: BagService, @@ -2595,15 +2597,15 @@ return MessageResponse( private fun createWorkbenchPickLedger(sol: StockOutLine, deltaQty: BigDecimal) { if (deltaQty <= BigDecimal.ZERO) return val solItem = sol.item ?: return - val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return + val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } + ?: sol.inventoryLotLine + val inventory = inventoryBucketResolver.findInventoryBucket(solItem.id!!, ill) ?: return // Fast path (batch-submit style): avoid querying latest ledger. // At this point inventory.onHandQty has already been updated by DB triggers after lot outQty change. // So previousBalance can be reconstructed as (onHandAfter + deltaQty). val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val previousBalance = onHandAfter + deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble() - val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } - ?: sol.inventoryLotLine val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = sol diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 275b80c5..dd414bac 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -70,19 +70,29 @@ open class ItemUomService( } /** - * 同一 [itemId] 可能對應多筆 [Inventory](不同 uom)。 - * 優先取 `inventory.uomId` 與 `item_uom` 中 `baseUnit = true` 之列的 `uomId` 一致的那一筆; - * 若無 base 設定或無匹配列,則回退為 `findFirstByItemIdAndDeletedIsFalseOrderByIdAsc`。 + * @deprecated ChangeList #4 — do not use. Multi stock-UOM buckets cannot be keyed by base UOM. + * Use [com.ffii.fpsms.modules.stock.service.InventoryBucketResolver.findInventoryBucket] with + * lot-line stockItemUomId / stockUomId instead. + * + * Temporary fallback: looks up by the item's current stockUnit uomId (not base). */ + @Deprecated( + message = "Use InventoryBucketResolver.findInventoryBucket(itemId, lotLine)", + replaceWith = ReplaceWith( + "inventoryBucketResolver.findInventoryBucket(itemId, lotLine)", + "com.ffii.fpsms.modules.stock.service.InventoryBucketResolver", + ), + ) open fun findInventoryForItemBaseUom(itemId: Long): Inventory? { - val base = findBaseUnitByItemId(itemId) - val uomId = base?.uom?.id - if (uomId != null) { - val match = inventoryRepository.findFirstByItemIdAndUomIdAndDeletedIsFalseOrderByIdAsc(itemId, uomId) - if (match != null) { - return match + val stockUnitUomId = findStockUnitByItemId(itemId)?.uom?.id + if (stockUnitUomId != null) { + inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(itemId, stockUnitUomId)?.let { + return it } + inventoryRepository.findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId, stockUnitUomId) + ?.let { return it } } + // Last resort only while stockUomId is still null on old rows (pre data patch). return inventoryRepository.findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(itemId) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt index 503d4789..2a67e762 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt @@ -54,10 +54,19 @@ open class Inventory: BaseEntity(){ open var cpmUnit: String? = null // @NotNull + /** Base UOM snapshot only (set when the inventory row is created; not the bucket key). */ @ManyToOne @JoinColumn(name = "uomId") open var uom: UomConversion? = null + /** + * Stock-UOM bucket key = uom_conversion.id. + * Unique with itemId: uk_inventory_item_stock_uom. Nullable until data patch. + */ + @ManyToOne + @JoinColumn(name = "stockUomId") + open var stockUom: UomConversion? = null + // @NotNull @Column(name = "status") open var status: String? = null diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt index 89d77547..21b8fc02 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt @@ -14,43 +14,49 @@ import java.util.Optional interface InventoryRepository: AbstractRepository { fun findInventoryInfoByDeletedIsFalse(): List + /** + * All non-deleted inventory rows matching filters (one row per stock-UOM bucket). + * Does not require baseUnit match — that hid non-base buckets. + */ @Query( - """ - SELECT i FROM Inventory i - WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) - AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) - AND (:type IS NULL OR :type = '' OR i.item.type = :type) - AND i.deleted = false - AND EXISTS ( - SELECT 1 FROM ItemUom iu - WHERE iu.item.id = i.item.id - AND iu.deleted = false - AND iu.baseUnit = true - AND iu.uom.id = i.uom.id - ) - """ -) - fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page + """ + SELECT i FROM Inventory i + WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) + AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) + AND (:type IS NULL OR :type = '' OR i.item.type = :type) + AND i.deleted = false + """ + ) + fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( + code: String, + name: String, + type: String, + pageable: Pageable, + ): Page - @Query("SELECT i FROM Inventory i " + - "WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) " + - "AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) " + - "AND (:type IS NULL OR :type = '' OR i.item.type = :type) " + - "AND i.item.id IN :itemIds " + - "AND i.deleted = false") + @Query( + """ + SELECT i FROM Inventory i + WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) + AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) + AND (:type IS NULL OR :type = '' OR i.item.type = :type) + AND i.item.id IN :itemIds + AND i.deleted = false + """ + ) fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( code: String, name: String, type: String, itemIds: List, - pageable: Pageable + pageable: Pageable, ): Page /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 - * Inventory search page only: no baseUnit/uomId match required; - * one row per item = latest inventory id (no larger id for same item). + * Formerly "latest id per item" — that hid older stock-UOM buckets. + * Now returns all matching rows (same as non-latest search). Prefer the non-Latest methods. */ + @Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse — returns all stock-UOM buckets") @Query( """ SELECT i FROM Inventory i @@ -58,12 +64,6 @@ interface InventoryRepository: AbstractRepository { AND (:name IS NULL OR :name = '' OR LOWER(i.item.name) LIKE LOWER(CONCAT('%', :name, '%'))) AND (:type IS NULL OR :type = '' OR i.item.type = :type) AND i.deleted = false - AND NOT EXISTS ( - SELECT 1 FROM Inventory i2 - WHERE i2.item.id = i.item.id - AND i2.deleted = false - AND i2.id > i.id - ) """ ) fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( @@ -73,7 +73,7 @@ interface InventoryRepository: AbstractRepository { pageable: Pageable, ): Page - /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 */ + @Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse") @Query( """ SELECT i FROM Inventory i @@ -82,12 +82,6 @@ interface InventoryRepository: AbstractRepository { AND (:type IS NULL OR :type = '' OR i.item.type = :type) AND i.item.id IN :itemIds AND i.deleted = false - AND NOT EXISTS ( - SELECT 1 FROM Inventory i2 - WHERE i2.item.id = i.item.id - AND i2.deleted = false - AND i2.id > i.id - ) """ ) fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( @@ -102,7 +96,13 @@ interface InventoryRepository: AbstractRepository { fun findInventoryInfoByItemInAndDeletedIsFalse(items: List): List + /** + * Unsafe when an item has multiple stock-UOM buckets (NonUniqueResultException). + * Prefer [findByItemIdAndStockUomIdAndDeletedIsFalse] or [findAllByItemIdAndDeletedIsFalse]. + */ + @Deprecated("Multiple stock-UOM buckets per item — use findByItemIdAndStockUomIdAndDeletedIsFalse") fun findByItemId(itemId: Long): Optional + fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection): List @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") @@ -110,6 +110,15 @@ interface InventoryRepository: AbstractRepository { fun findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(itemId: Long): Inventory? - /** 與 item_uom 中 baseUnit=true 之 uomId 對齊時使用,避免同一 item 多筆 inventory 時 NonUniqueResult */ + /** + * Legacy base-UOM lookup. Prefer [findByItemIdAndStockUomIdAndDeletedIsFalse]. + * Shared base uomId across buckets can still collide until callers migrate. + */ + @Deprecated("Prefer findByItemIdAndStockUomIdAndDeletedIsFalse") fun findFirstByItemIdAndUomIdAndDeletedIsFalseOrderByIdAsc(itemId: Long, uomId: Long): Inventory? -} \ No newline at end of file + + /** Resolve one inventory bucket by (itemId, stockUomId). */ + fun findByItemIdAndStockUomIdAndDeletedIsFalse(itemId: Long, stockUomId: Long): Inventory? + + fun findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId: Long, stockUomId: Long): Inventory? +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt index be8d777c..f6239339 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt @@ -14,41 +14,28 @@ interface InventoryInfo{ val itemName: String? @get:Value("#{target.item.type}") val itemType: String? - // @get:Value("#{target.qty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") -// @get:Value("#{target.onHandQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") @get:Value("#{target.onHandQty}") val onHandQty: BigDecimal? - // @get:Value("#{target.onHoldQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") @get:Value("#{target.onHoldQty}") val onHoldQty: BigDecimal? - // @get:Value("#{target.unavailableQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") @get:Value("#{target.unavailableQty}") val unavailableQty: BigDecimal? - // @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty) / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") - @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty)}") + /** Available = onHand - unavailable (onHold is not maintained by stock-UOM triggers). */ + @get:Value("#{(target.onHandQty - target.unavailableQty)}") val availableQty: BigDecimal? - @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom.code}") + @get:Value("#{target.stockUom?.id}") + val stockUomId: Long? + @get:Value("#{target.stockUom?.code}") val uomCode: String? - @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfudesc}") + @get:Value("#{target.stockUom?.udfudesc}") val uomUdfudesc: String? - @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfShortDesc}") + @get:Value("#{target.stockUom?.udfShortDesc}") val uomShortDesc: String? - // @get:Value("#{target.qty * target.uom.gramPerSmallestUnit}") -// val germPerSmallestUnit: BigDecimal? - @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty)}") + @get:Value("#{(target.onHandQty - target.unavailableQty)}") val qtyPerSmallestUnit: BigDecimal? - @get:Value("#{target.item.itemUoms.^[baseUnit == true && deleted == false]?.uom?.udfudesc}") + /** Base UOM snapshot from inventory.uomId (not current item baseUnit master). */ + @get:Value("#{target.uom?.udfudesc}") val baseUom: String? - // @get:Value("#{target.qty * (target.uom.unit4 != '' ? target.uom.unit4Qty " + -// ": target.uom.unit3 != '' ? target.uom.unit3Qty " + -// ": target.uom.unit2 != '' ? target.uom.unit2Qty " + -// ": target.uom.unit1Qty)}") -// val qtyPerSmallestUnit: BigDecimal? -// @get:Value("#{target.uom.unit4 != '' ? target.uom.unit4 " + -// ": target.uom.unit3 != '' ? target.uom.unit3 " + -// ": target.uom.unit2 != '' ? target.uom.unit2 " + -// ": target.uom.unit1}") -// val smallestUnit: String? val price: BigDecimal? @get:Value("#{target.currency?.name}") val currencyName: String? diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt new file mode 100644 index 00000000..c3d529c5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt @@ -0,0 +1,56 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.master.entity.ItemUom +import com.ffii.fpsms.modules.master.entity.ItemUomRespository +import com.ffii.fpsms.modules.stock.entity.Inventory +import com.ffii.fpsms.modules.stock.entity.InventoryLotLine +import com.ffii.fpsms.modules.stock.entity.InventoryRepository +import org.springframework.stereotype.Service + +/** + * ChangeList #34 — single door for inventory bucket lookup by (itemId, stockUomId). + * + * stockUomId = item_uom.uomId for the lot-line's stockItemUomId (same as DB trigger). + * Modules must call this instead of findByItemId / findInventoryForItemBaseUom. + */ +@Service +open class InventoryBucketResolver( + private val itemUomRepository: ItemUomRespository, + private val inventoryRepository: InventoryRepository, +) { + /** item_uom.id → uom_conversion.id used as inventory.stockUomId. */ + open fun resolveStockUomId(stockItemUomId: Long): Long? { + val itemUom = itemUomRepository.findByIdAndDeletedIsFalse(stockItemUomId) ?: return null + return resolveStockUomId(itemUom) + } + + open fun resolveStockUomId(itemUom: ItemUom?): Long? = itemUom?.uom?.id + + open fun resolveStockUomId(lotLine: InventoryLotLine?): Long? = + resolveStockUomId(lotLine?.stockUom) ?: lotLine?.stockUom?.id?.let { resolveStockUomId(it) } + + open fun findInventoryBucket(itemId: Long, stockUomId: Long): Inventory? = + inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(itemId, stockUomId) + ?: inventoryRepository.findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId, stockUomId) + + /** + * Preferred path: resolve stockUomId from the lot line, then find the bucket. + * If the lot line has no stock UOM, falls back to the item's current stockUnit uomId + * (transition only — callers with a lot line should always set stockItemUomId). + */ + open fun findInventoryBucket(itemId: Long, lotLine: InventoryLotLine?): Inventory? { + val fromLot = resolveStockUomId(lotLine) + if (fromLot != null) { + return findInventoryBucket(itemId, fromLot) + } + val stockUnitUomId = + itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id + ?: return null + return findInventoryBucket(itemId, stockUnitUomId) + } + + open fun findInventoryBucketByStockItemUomId(itemId: Long, stockItemUomId: Long): Inventory? { + val stockUomId = resolveStockUomId(stockItemUomId) ?: return null + return findInventoryBucket(itemId, stockUomId) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index d909d742..ae0e4ccd 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -89,41 +89,13 @@ open class InventoryService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 - * Inventory search page only: no baseUnit/uomId filter; - * one inventory row per item (latest id). Shared getRecordByPage is unchanged. + * ChangeList #8 — deprecated alias of [allInventoriesByPage] (all stock-UOM buckets). */ + @Deprecated("Prefer allInventoriesByPage — both return all stock-UOM rows") open fun searchInventoriesByLatestInventory( request: SearchInventoryRequest, ): RecordsRes { - val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10) - val code = request.code?.trim().orEmpty() - val name = request.name?.trim().orEmpty() - val type = request.type?.trim().orEmpty() - val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } - - val response = if (lotNo != null) { - val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo) - if (itemIds.isEmpty()) { - return RecordsRes(emptyList(), 0) - } - inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( - code = code, - name = name, - type = type, - itemIds = itemIds, - pageable = pageable, - ) - } else { - inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( - code = code, - name = name, - type = type, - pageable = pageable, - ) - } - - return RecordsRes(response.content, response.totalElements.toInt()) + return allInventoriesByPage(request) } open fun allInventoriesByItems(items: List): List{ @@ -366,17 +338,13 @@ open class InventoryService( val warehouse = warehouseRepository.findAll().find { it.code == request.warehouseCode }!! // val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!) val stockUnit = itemUomService.findStockUnitByItemId(itemId = inventoryLot.item!!.id!!) -// val zero = BigDecimal.ZERO -// val one = BigDecimal.ONE -// val ratio = (salesUnit?.ratioN ?: zero).divide(salesUnit?.ratioD ?: one) - + // ChangeList #8: lot-line stockItemUomId must be set so trigger writes inventory.stockUomId. InventoryLotLine().apply { this.inventoryLot = inventoryLot this.warehouse = warehouse this.inQty = request.qty this.status = InventoryLotLineStatus.AVAILABLE this.stockUom = stockUnit -// this.stockUom = salesUnit } } val savedInventoryLotLine = inventoryLotLineRepository.saveAllAndFlush(inventoryLotLineEntries) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index fa4c1abc..ebae2331 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -103,6 +103,7 @@ open class StockInLineService( private val deliveryOrderRepository: DeliveryOrderRepository, private val stockLedgerRepository: StockLedgerRepository, private val inventoryRepository: InventoryRepository, + private val inventoryBucketResolver: InventoryBucketResolver, private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, /** Lazy to avoid circular dependency: M18PurchaseOrderService → … → StockInLineService. */ @@ -1389,8 +1390,8 @@ open class StockInLineService( val item = stockInLine.item ?: return val _tInv = System.nanoTime() - val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return - _logStep("inventoryRepository.findInventoryForItemBaseUom", _tInv) + val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockInLine.inventoryLotLine) ?: return + _logStep("inventoryBucketResolver.findInventoryBucket", _tInv) // ✅ 修复:查询最新的 stock_ledger 记录,基于前一笔 balance 计算 val _tLatest = System.nanoTime() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index e32492dc..759abbb5 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -81,6 +81,7 @@ private val inventoryLotLineService: InventoryLotLineService, private val inventoryRepository: InventoryRepository, private val pickExecutionIssueRepository: PickExecutionIssueRepository, private val itemUomService: ItemUomService, + private val inventoryBucketResolver: InventoryBucketResolver, @Lazy private val doReplenishmentService: DoReplenishmentService, ): AbstractBaseEntityService(jdbcDao, stockOutLineRepository) { @@ -1359,7 +1360,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } val item = savedSol.item val inventoryBeforeUpdate = item?.id?.let { itemId -> - itemUomService.findInventoryForItemBaseUom(itemId) + inventoryBucketResolver.findInventoryBucket(itemId, ill) } val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() @@ -1374,7 +1375,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { val item = savedSol.item val inventoryBeforeUpdate = item?.id?.let { itemId -> - itemUomService.findInventoryForItemBaseUom(itemId) + inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine) } val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() @@ -1512,7 +1513,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { val item = stockOutLine.item ?: return - val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return + val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) ?: return val outQty = stockOutLine.qty?.toDouble() ?: 0.0 // Use latest ledger balance (same pattern as createStockLedgerForStockIn) so balance is correct when multiple actions run in one transaction @@ -1782,10 +1783,10 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { return } - val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) + val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) if (inventory == null) { if (flushAfterSave) { - println("${tracePrefix}Skip ledger creation: inventory not found by itemId=${item.id}") + println("${tracePrefix}Skip ledger creation: inventory bucket not found by itemId=${item.id}") } return } @@ -2198,7 +2199,7 @@ fun applyStockOutLineDelta( val itemId = savedSol.item?.id if (itemId != null) { - val inv = itemUomService.findInventoryForItemBaseUom(itemId) + val inv = inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine) if (inv != null) { val zero = BigDecimal.ZERO inv.onHandQty = (inv.onHandQty ?: zero).minus(deltaQty) @@ -2213,7 +2214,7 @@ fun applyStockOutLineDelta( // 3) stock_ledger (same ledger shape as createStockLedgerForStockOut / completeStockOutAfterLotLineSave) if (!skipLedgerWrite) { val item = savedSol.item ?: return savedSol - val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return savedSol + val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, savedSol.inventoryLotLine) ?: return savedSol val previousBalance = resolvePreviousBalance( itemId = item.id!!, @@ -2349,18 +2350,22 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) // 5) 批量组装 ledger(避免每笔 createStockLedgerForStockOut) - // Keep per-item running balance so multiple lines in the same batch + // Keep per-bucket running balance so multiple lines in the same batch // chain from the previous ledger balance instead of reusing inventory.onHandQty. - val runningLedgerBalanceByItemId = mutableMapOf() - val inventoryByItemId = mutableMapOf() + val runningLedgerBalanceByBucket = mutableMapOf, Double>() + val inventoryByBucket = mutableMapOf, Inventory>() savedLines.sortedBy { it.id ?: Long.MAX_VALUE }.forEach { sol -> val item = sol.item ?: return@forEach val itemId = item.id ?: return@forEach - val inv = inventoryByItemId[itemId] - ?: itemUomService.findInventoryForItemBaseUom(itemId)?.also { inventoryByItemId[itemId] = it } + val stockUomId = inventoryBucketResolver.resolveStockUomId(sol.inventoryLotLine) + ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id + ?: return@forEach + val bucketKey = itemId to stockUomId + val inv = inventoryByBucket[bucketKey] + ?: inventoryBucketResolver.findInventoryBucket(itemId, stockUomId)?.also { inventoryByBucket[bucketKey] = it } ?: return@forEach val delta = BigDecimal.valueOf(sol.qty ?: 0.0) - val prevBalance = runningLedgerBalanceByItemId[itemId] + val prevBalance = runningLedgerBalanceByBucket[bucketKey] ?: run { val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) latestLedger?.balance ?: (inv.onHandQty ?: BigDecimal.ZERO).toDouble() @@ -2392,7 +2397,7 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult signedDelta = delta.negate(), ) ledgersToInsert += ledger - runningLedgerBalanceByItemId[itemId] = newBalance + runningLedgerBalanceByBucket[bucketKey] = newBalance } stockLedgerRepository.saveAll(ledgersToInsert) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt index efcf4c3c..6744f734 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt @@ -62,6 +62,7 @@ open class StockOutLineWorkbenchService( private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, private val stockLedgerRepository: StockLedgerRepository, private val itemUomService: ItemUomService, + private val inventoryBucketResolver: InventoryBucketResolver, private val itemUomRespository: ItemUomRespository, private val bagRepository: BagRepository, private val bagService: BagService, @@ -512,13 +513,13 @@ if (updated == 0) { if (deltaQty <= BigDecimal.ZERO) return val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return val solItem = sol.item ?: return - val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return + val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } + ?: sol.inventoryLotLine + val inventory = inventoryBucketResolver.findInventoryBucket(solItem.id!!, ill) ?: return // Fast path: use inventory onHand after update + delta to reconstruct previous balance. val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val previousBalance = onHandAfter + deltaQty.toDouble() val newBalance = previousBalance - deltaQty.toDouble() - val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } - ?: sol.inventoryLotLine val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } val ledger = StockLedger().apply { this.stockOutLine = sol diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt index 20cf216a..1dfd2ba9 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt @@ -16,20 +16,22 @@ class InventoryController( return inventoryService.allInventories() } + /** Full inventory search: one row per (itemId, stockUomId) bucket. */ @GetMapping("/getRecordByPage") fun allInventoriesByPage(@ModelAttribute request: SearchInventoryRequest): RecordsRes { - return inventoryService.allInventoriesByPage(request); + return inventoryService.allInventoriesByPage(request) } /** - * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 - * Inventory search page only: latest inventory row per item, no baseUnit/uomId filter. + * ChangeList #8 — deprecated. Formerly returned only max(id) per item (hid older stock UOMs). + * Now aliases [allInventoriesByPage] (all buckets). Prefer GET /inventory/getRecordByPage. */ + @Deprecated("Use GET /inventory/getRecordByPage — returns all stock-UOM buckets") @GetMapping("/searchLatest/getRecordByPage") fun searchInventoriesByLatestInventory( @ModelAttribute request: SearchInventoryRequest, ): RecordsRes { - return inventoryService.searchInventoriesByLatestInventory(request) + return inventoryService.allInventoriesByPage(request) } @PostMapping("/import-bom") diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql new file mode 100644 index 00000000..9152bfee --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/01_add_inventory_stock_uom_id.sql @@ -0,0 +1,17 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-add-inventory-stock-uom-id +-- comment: Add inventory.stockUomId as the stock-UOM bucket key. Nullable until the post-go-live data patch backfills existing rows. + +ALTER TABLE `inventory` + ADD COLUMN `stockUomId` INT NULL + COMMENT 'Stock-UOM bucket key: uom_conversion.id. Resolved from inventory_lot_line.stockItemUomId -> item_uom.uomId. Distinct from uomId (base-UOM snapshot only).' + AFTER `uomId`; + +ALTER TABLE `inventory` + ADD CONSTRAINT `FK_INVENTORY_STOCK_UOM` + FOREIGN KEY (`stockUomId`) REFERENCES `uom_conversion` (`id`); + +-- One inventory row per (itemId, stockUomId). MySQL UNIQUE allows multiple NULLs, so pre-patch rows may keep stockUomId NULL. +ALTER TABLE `inventory` + ADD UNIQUE KEY `uk_inventory_item_stock_uom` (`itemId`, `stockUomId`); diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql new file mode 100644 index 00000000..735e3e75 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/02_add_stock_uom_indexes.sql @@ -0,0 +1,16 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-add-stock-uom-indexes +-- comment: Supporting indexes for AFTER INSERT/UPDATE triggers that resolve and full-SUM one (itemId, stockUomId) bucket. + +-- Join path: inventory_lot_line.stockItemUomId -> item_uom (bucket resolve + SUM filter). +CREATE INDEX `idx_ill_stock_item_uom` + ON `inventory_lot_line` (`stockItemUomId`); + +-- Join path: inventory_lot_line -> inventory_lot; includes deleted for the trigger filter IFNULL(deleted,0)=0. +CREATE INDEX `idx_ill_lot_deleted` + ON `inventory_lot_line` (`inventoryLotId`, `deleted`); + +-- Join path: inventory_lot by itemId for SUM WHERE il.itemId = :itemId (complements existing lotNo+itemId index). +CREATE INDEX `idx_il_item_deleted` + ON `inventory_lot` (`itemId`, `deleted`); diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql new file mode 100644 index 00000000..b55e74d2 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/03_drop_inventory_lot_line_triggers.sql @@ -0,0 +1,10 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-drop-ill-triggers +-- comment: Drop legacy inventory_lot_line triggers (baseUnit lookup + delta qty). Replaced by stockUomId full-SUM triggers in 04/05. + +-- Drop both case variants; MySQL trigger name casing can differ by platform history. +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_insert`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_INSERT`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_update`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_UPDATE`; diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql new file mode 100644 index 00000000..00d4d7a8 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/04_rewrite_inventory_lot_line_trigger_insert.sql @@ -0,0 +1,161 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-ill-after-insert-stock-uom splitStatements:false +-- comment: AFTER INSERT on inventory_lot_line. Resolve (itemId, stockUomId) bucket, ensure inventory row, full-SUM overwrite onHand/unavailable/status. SIGNAL rolls back the lot-line write. Does not write onHoldQty. Sets uomId (base snapshot) only when creating a new inventory row. + +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_AFTER_insert` + AFTER INSERT + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + DECLARE v_itemId INT; + DECLARE v_stockUomId INT; -- bucket key = item_uom.uomId for NEW.stockItemUomId + DECLARE v_baseUomId INT; -- baseUnit uomId; written to inventory.uomId only on CREATE + DECLARE v_inventoryId INT DEFAULT NULL; + DECLARE v_onHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_unavailable DECIMAL(14, 2) DEFAULT 0; + DECLARE v_currencyId INT DEFAULT NULL; + DECLARE v_currencyName VARCHAR(30) DEFAULT 'HKD'; + DECLARE v_price DECIMAL(14, 2) DEFAULT 0; + + -- Step 1: stockItemUomId is required to know which stock-UOM bucket this line belongs to. + IF NEW.stockItemUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'inventory_lot_line.stockItemUomId is required'; + END IF; + + -- Step 2: Resolve bucket (itemId, stockUomId) from lot + item_uom. + -- stockItemUomId -> item_uom.id; stockUomId := item_uom.uomId (must belong to the same item). + SELECT il.itemId, iu.uomId + INTO v_itemId, v_stockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = NEW.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = NEW.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_itemId IS NULL OR v_stockUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Cannot resolve stockUomId from stockItemUomId / inventoryLotId'; + END IF; + + -- Step 3: Resolve base UOM for inventory.uomId snapshot (used only if we INSERT a new inventory row). + SELECT iu.uomId + INTO v_baseUomId + FROM `item_uom` iu + WHERE iu.itemId = v_itemId + AND iu.baseUnit = TRUE + AND IFNULL(iu.deleted, 0) = 0 + LIMIT 1; + + IF v_baseUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'item has no baseUnit item_uom for inventory.uomId snapshot'; + END IF; + + -- Step 4: Find existing inventory row for this stock-UOM bucket. + SELECT i.id + INTO v_inventoryId + FROM `inventory` i + WHERE i.itemId = v_itemId + AND i.stockUomId = v_stockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + -- Step 5: Create inventory row if missing (qty starts at 0; Step 6 overwrites from lot lines). + -- uomId = base snapshot once; stockUomId = bucket key. onHoldQty left at 0 and never maintained by this trigger. + IF v_inventoryId IS NULL THEN + -- Prefer currency/price from the lot's stock-in / PO / quotation path; fall back to HKD / first currency. + SELECT c.id, COALESCE(c.name, 'HKD'), COALESCE(pql.price, 0) + INTO v_currencyId, v_currencyName, v_price + FROM `inventory_lot` il + LEFT JOIN `stock_in_line` sil ON sil.id = il.stockInLineId + LEFT JOIN `purchase_order_line` pol ON pol.id = sil.purchaseOrderLineId + LEFT JOIN `purchase_quotation_line` pql + ON pql.itemId = il.itemId AND pql.uomId = pol.uomId + LEFT JOIN `purchase_quotation` pq ON pq.id = pql.purchaseQuotationId + LEFT JOIN `currency` c ON c.id = pq.currencyId + WHERE il.id = NEW.inventoryLotId + LIMIT 1; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE c.code = 'HKD' + AND IFNULL(c.deleted, 0) = 0 + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE IFNULL(c.deleted, 0) = 0 + ORDER BY c.id + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Cannot resolve currencyId for new inventory row'; + END IF; + + INSERT INTO `inventory` (`itemId`, + `onHandQty`, + `onHoldQty`, + `unavailableQty`, + `price`, + `currencyId`, + `cpu`, + `cpuUnit`, + `cpm`, + `cpmUnit`, + `uomId`, + `stockUomId`, + `status`) + VALUES (v_itemId, + 0, + 0, + 0, + COALESCE(v_price, 0), + v_currencyId, + 0, + COALESCE(v_currencyName, 'HKD'), + 0, + COALESCE(v_currencyName, 'HKD'), + v_baseUomId, + v_stockUomId, + 'unavailable'); + + SET v_inventoryId = LAST_INSERT_ID(); + END IF; + + -- Step 6: Full recompute for THIS bucket only (not a delta on existing inventory qty). + -- onHand = SUM(inQty - outQty) for all statuses + -- unavailable = SUM(inQty - outQty) for status = unavailable only (holdQty ignored) + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_onHand, v_unavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_itemId + AND iu.uomId = v_stockUomId + AND IFNULL(ill.deleted, 0) = 0; + + -- Step 7: Overwrite inventory qty/status from the SUM (source of truth = lot lines). + -- Do not touch onHoldQty, uomId, or stockUomId on an existing row. + UPDATE `inventory` + SET `onHandQty` = v_onHand, + `unavailableQty` = v_unavailable, + `status` = IF(v_onHand - v_unavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_inventoryId; +END; diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql new file mode 100644 index 00000000..149d7a53 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/05_rewrite_inventory_lot_line_trigger_update.sql @@ -0,0 +1,217 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260910-ill-after-update-stock-uom splitStatements:false +-- comment: AFTER UPDATE on inventory_lot_line. Full-SUM the NEW (itemId, stockUomId) bucket. If the line moved to a different bucket, also full-SUM the OLD bucket. SIGNAL rolls back on resolve failure. Does not write onHoldQty. + +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_AFTER_update` + AFTER UPDATE + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + DECLARE v_itemId INT; + DECLARE v_stockUomId INT; -- NEW bucket key + DECLARE v_baseUomId INT; -- baseUnit uomId; used only when creating inventory + DECLARE v_inventoryId INT DEFAULT NULL; + DECLARE v_onHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_unavailable DECIMAL(14, 2) DEFAULT 0; + DECLARE v_currencyId INT DEFAULT NULL; + DECLARE v_currencyName VARCHAR(30) DEFAULT 'HKD'; + DECLARE v_price DECIMAL(14, 2) DEFAULT 0; + + DECLARE v_oldItemId INT DEFAULT NULL; + DECLARE v_oldStockUomId INT DEFAULT NULL; + DECLARE v_oldInventoryId INT DEFAULT NULL; + DECLARE v_oldOnHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_oldUnavailable DECIMAL(14, 2) DEFAULT 0; + + -- Step 1: NEW.stockItemUomId is required. + IF NEW.stockItemUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'inventory_lot_line.stockItemUomId is required'; + END IF; + + -- Step 2: Resolve NEW bucket (itemId, stockUomId). + SELECT il.itemId, iu.uomId + INTO v_itemId, v_stockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = NEW.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = NEW.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_itemId IS NULL OR v_stockUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Cannot resolve stockUomId from stockItemUomId / inventoryLotId'; + END IF; + + -- Step 3: If stock UOM or lot changed, recompute the OLD bucket so qty does not stick on the wrong row. + -- After this UPDATE, the line already belongs to NEW; OLD-bucket SUM naturally excludes it. + IF OLD.stockItemUomId IS NOT NULL + AND (OLD.stockItemUomId <> NEW.stockItemUomId + OR OLD.inventoryLotId <> NEW.inventoryLotId) THEN + + SELECT il.itemId, iu.uomId + INTO v_oldItemId, v_oldStockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = OLD.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = OLD.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_oldItemId IS NOT NULL + AND v_oldStockUomId IS NOT NULL + AND (v_oldItemId <> v_itemId OR v_oldStockUomId <> v_stockUomId) THEN + + SELECT i.id + INTO v_oldInventoryId + FROM `inventory` i + WHERE i.itemId = v_oldItemId + AND i.stockUomId = v_oldStockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + IF v_oldInventoryId IS NOT NULL THEN + -- Full SUM for OLD bucket (same formulas as NEW). + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_oldOnHand, v_oldUnavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu + ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_oldItemId + AND iu.uomId = v_oldStockUomId + AND IFNULL(ill.deleted, 0) = 0; + + UPDATE `inventory` + SET `onHandQty` = v_oldOnHand, + `unavailableQty` = v_oldUnavailable, + `status` = IF(v_oldOnHand - v_oldUnavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_oldInventoryId; + END IF; + END IF; + END IF; + + -- Step 4: Resolve base UOM snapshot (only needed when creating a new inventory row). + SELECT iu.uomId + INTO v_baseUomId + FROM `item_uom` iu + WHERE iu.itemId = v_itemId + AND iu.baseUnit = TRUE + AND IFNULL(iu.deleted, 0) = 0 + LIMIT 1; + + IF v_baseUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'item has no baseUnit item_uom for inventory.uomId snapshot'; + END IF; + + -- Step 5: Find or create inventory row for the NEW bucket. + SELECT i.id + INTO v_inventoryId + FROM `inventory` i + WHERE i.itemId = v_itemId + AND i.stockUomId = v_stockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + IF v_inventoryId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD'), COALESCE(pql.price, 0) + INTO v_currencyId, v_currencyName, v_price + FROM `inventory_lot` il + LEFT JOIN `stock_in_line` sil ON sil.id = il.stockInLineId + LEFT JOIN `purchase_order_line` pol ON pol.id = sil.purchaseOrderLineId + LEFT JOIN `purchase_quotation_line` pql + ON pql.itemId = il.itemId AND pql.uomId = pol.uomId + LEFT JOIN `purchase_quotation` pq ON pq.id = pql.purchaseQuotationId + LEFT JOIN `currency` c ON c.id = pq.currencyId + WHERE il.id = NEW.inventoryLotId + LIMIT 1; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE c.code = 'HKD' + AND IFNULL(c.deleted, 0) = 0 + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE IFNULL(c.deleted, 0) = 0 + ORDER BY c.id + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'Cannot resolve currencyId for new inventory row'; + END IF; + + INSERT INTO `inventory` (`itemId`, + `onHandQty`, + `onHoldQty`, + `unavailableQty`, + `price`, + `currencyId`, + `cpu`, + `cpuUnit`, + `cpm`, + `cpmUnit`, + `uomId`, + `stockUomId`, + `status`) + VALUES (v_itemId, + 0, + 0, + 0, + COALESCE(v_price, 0), + v_currencyId, + 0, + COALESCE(v_currencyName, 'HKD'), + 0, + COALESCE(v_currencyName, 'HKD'), + v_baseUomId, + v_stockUomId, + 'unavailable'); + + SET v_inventoryId = LAST_INSERT_ID(); + END IF; + + -- Step 6: Full SUM for NEW bucket + overwrite. + -- onHand = all statuses; unavailable = unavailable lines only; holdQty ignored. + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_onHand, v_unavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_itemId + AND iu.uomId = v_stockUomId + AND IFNULL(ill.deleted, 0) = 0; + + -- Do not touch onHoldQty, uomId, or stockUomId on an existing row. + UPDATE `inventory` + SET `onHandQty` = v_onHand, + `unavailableQty` = v_unavailable, + `status` = IF(v_onHand - v_unavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_inventoryId; +END; From 193a8cbab5aa249126c08afe45fb3408f82bd3e0 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 14 Sep 2026 15:01:47 +0800 Subject: [PATCH 04/52] =?UTF-8?q?Liquibase=2020260910=EF=BC=9A#1=20stockUo?= =?UTF-8?q?mId=E3=80=81#2=E2=80=933=20Option=20A=20trigger=E3=80=81#35=20i?= =?UTF-8?q?ndex=20=E4=B8=8A=E7=B7=9A=20app=EF=BC=9A#5=20entity=E3=80=81#7?= =?UTF-8?q?=20available=20=3D=20onHand=20=E2=88=92=20unavailable=E3=80=81#?= =?UTF-8?q?8=20=E6=90=9C=E5=B0=8B=E5=85=A8=20bucket=E3=80=81#34=20Resolver?= =?UTF-8?q?=EF=BC=88#10=EF=BC=8F#11=20=E5=B7=B2=E6=8E=A5=EF=BC=89=20Admin?= =?UTF-8?q?=201.0=EF=BC=9A=E8=A3=9C=20stockUomId=E3=80=81uomId=3Dbase?= =?UTF-8?q?=EF=BC=88=E9=80=99=E5=B0=B1=E6=98=AF=20#1=20=E7=9A=84=20data=20?= =?UTF-8?q?patch=EF=BC=89=202.3=20=E6=8A=8A=20ledger=20=E7=B6=81=E5=88=B0?= =?UTF-8?q?=E8=A9=B2=20bucket=202.7=EF=BC=9A=E5=85=88=E8=B6=85=E7=99=BC=20?= =?UTF-8?q?remain=E2=86=920=EF=BC=88=E6=94=B9=20lot=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=86=8D=20miss=EF=BC=9B=E5=85=B1=E7=94=A8=E4=B8=80=E5=BC=B5?= =?UTF-8?q?=E5=85=A5=E5=80=89=E5=96=AE=E3=80=81=E4=B8=80=E5=BC=B5=E5=87=BA?= =?UTF-8?q?=E5=80=89=E5=96=AE=202.6=20=E6=97=A5=E7=B5=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../master/service/StockLedgerFixService.kt | 759 +++++++++++++++--- .../master/web/models/StockLedgerFixModels.kt | 7 + .../stock/service/StockLotDayCloseService.kt | 124 ++- .../01_create_stock_lot_day.sql | 2 +- 4 files changed, 741 insertions(+), 151 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt index 7ef5a34a..d780d4d7 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt @@ -17,7 +17,9 @@ import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService import org.slf4j.LoggerFactory import org.springframework.jdbc.core.RowCallbackHandler +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.jdbc.support.GeneratedKeyHolder import org.springframework.stereotype.Service import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.annotation.Transactional @@ -36,7 +38,10 @@ import javax.sql.DataSource /** * Inventory 1.0 (A) then per-day stock_ledger backfill (2.1–2.6) and [stock_lot_day]. * 2.4 opening looks back to last lotQtyAfter (not yesterday day-table or 0). - * 2.7 ADJ aligns lot-line in/out to ledger sums (yesterday, new ids; reverse when ledger over). + * 2.7: (1) over-issue `lineIn < lineOut` → set lot inQty = outQty (trigger → inventory 0) + * plus SIL+ledger ADJ in; (2) miss ADJ: line vs Σ ledger, SIL/SOL on existing lot, no lot qty write. + * One Apply shares a single stock_in header and a single stock_out header (reused by adjDate); + * each lot is a line on that document. Ledger FKs the line ids. * * SIL/SOL have no inventoryId; lot is inventoryLotLineId / inventoryLotId only. */ @@ -48,6 +53,7 @@ open class StockLedgerFixService( private val stockLotDayCloseService: StockLotDayCloseService, ) { private val log = LoggerFactory.getLogger(StockLedgerFixService::class.java) + private val namedJdbc = NamedParameterJdbcTemplate(dataSource) private val inFlight = ConcurrentHashMap.newKeySet() private val inventoryLock = "inventory-1.0" private val runLock = "ledger-fix-run" @@ -111,9 +117,11 @@ open class StockLedgerFixService( } /** - * A: keep existing inventory ids; INSERT missing (itemId, uomId) from lot lines; - * recalc onHand/onHold/unavailable from Σ lot qty by item+UOM (0 if no lots). - * Does not UPDATE inventory_lot_line. + * A: keep existing inventory ids; + * backfill stockUomId from lot stock UOM (and set uomId to base); + * INSERT missing (itemId, stockUomId); + * recalc onHand/unavailable from Σ lot qty by item+stock UOM (0 if no lots). + * Does not write inventory.onHoldQty (hold stays on lot line). Does not UPDATE inventory_lot_line. */ open fun inventoryPreview(): StockLedgerFixInventoryPreview { val inventoryRows = toInt( @@ -127,10 +135,14 @@ open class StockLedgerFixService( val missingUomPairs = toInt( jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), ) + val nullStockUomId = toInt( + jdbcDao.queryForList(NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), + ) return StockLedgerFixInventoryPreview( inventoryRows = inventoryRows, lotUomPairs = lotUomPairs, missingUomPairs = missingUomPairs, + nullStockUomId = nullStockUomId, ) } @@ -140,21 +152,31 @@ open class StockLedgerFixService( throw IllegalStateException("inventory 1.0 already running") } try { + val patchedFromUom = jdbcDao.executeUpdate(BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL) + val patchedSingleLot = jdbcDao.executeUpdate(BACKFILL_STOCK_UOM_SINGLE_LOT_SQL) + val patchedStockUomId = patchedFromUom + patchedSingleLot val inserted = jdbcDao.executeUpdate(INSERT_MISSING_INVENTORY_SQL) val updated = jdbcDao.executeUpdate(UPDATE_INVENTORY_QTY_SQL) val missingAfter = toInt( jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), ) + val nullStockUomIdAfter = toInt( + jdbcDao.queryForList(NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), + ) log.info( - "stock-ledger-fix inventory-1.0 inserted={} updated={} missingAfter={}", + "stock-ledger-fix inventory-1.0 patchedStockUomId={} inserted={} updated={} missingAfter={} nullStockUomIdAfter={}", + patchedStockUomId, inserted, updated, missingAfter, + nullStockUomIdAfter, ) return StockLedgerFixInventoryResponse( inserted = inserted, updated = updated, missingUomPairsAfter = missingAfter, + patchedStockUomId = patchedStockUomId, + nullStockUomIdAfter = nullStockUomIdAfter, ) } finally { inFlight.remove(inventoryLock) @@ -190,7 +212,7 @@ open class StockLedgerFixService( WHERE inv.id = sl.inventoryId AND IFNULL(inv.deleted, 0) = 0 AND inv.itemId = sl.itemId - AND inv.uomId = sl.uomId + AND COALESCE(inv.stockUomId, inv.uomId) = sl.uomId ) ) AS invIncorrect, SUM(sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) AS lotQtyMiss, @@ -641,37 +663,46 @@ open class StockLedgerFixService( /** * Line in/out vs Σ ledger in/out. Any non-zero gap becomes ADJ on [adjDate] * (default yesterday; pass today on freeze-night dump). + * Step 1: over-issue (`lineIn < lineOut`) sets lot inQty = outQty (trigger updates inventory) + * and writes SIL + ledger ADJ in. Step 2: miss ADJ (no further lot qty write). * Positive miss → ADJ in/out; negative miss → reverse ADJ out/in. - * Goal: last lotQtyAfter = line remain (negative allowed for true over-issue). + * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). * Ledger gap sum uses `date < adjDate+1`. */ open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview { val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() val gaps = loadAdjGaps(asOfExclusive) - val auto = gaps.filter { it.missIn.compareTo(BigDecimal.ZERO) != 0 || it.missOut.compareTo(BigDecimal.ZERO) != 0 } - val sumIn = auto.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } - val sumOut = auto.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } + val work = gaps.filter { it.hasWork() } + val over = work.filter { it.hasOverIssue() } + val miss = work.filter { it.hasMiss() } + val sumIn = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } + val sumOut = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } + val sumOver = over.fold(BigDecimal.ZERO) { a, g -> a + g.overIssue } val limit = rowLimit.coerceIn(0, 50) log.info( - "stock-ledger-fix adj preview lots={} in={} out={} revIn={} revOut={} {}ms", - auto.size, - auto.count { it.missIn > BigDecimal.ZERO }, - auto.count { it.missOut > BigDecimal.ZERO }, - auto.count { it.missIn < BigDecimal.ZERO }, - auto.count { it.missOut < BigDecimal.ZERO }, + "stock-ledger-fix adj preview lots={} over={} in={} out={} revIn={} revOut={} {}ms", + work.size, + over.size, + miss.count { it.missIn > BigDecimal.ZERO }, + miss.count { it.missOut > BigDecimal.ZERO }, + miss.count { it.missIn < BigDecimal.ZERO }, + miss.count { it.missOut < BigDecimal.ZERO }, (System.nanoTime() - t0) / 1_000_000, ) + val rows = (over + miss.filter { !it.hasOverIssue() }).distinctBy { it.lotLineId }.take(limit) return StockLedgerFixAdjPreview( adjDate = adjDate.toString(), - lotCount = auto.size, - adjInCount = auto.count { it.missIn > BigDecimal.ZERO }, - adjOutCount = auto.count { it.missOut > BigDecimal.ZERO }, - skippedNegCount = auto.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO }, + lotCount = work.size, + adjInCount = miss.count { it.missIn > BigDecimal.ZERO }, + adjOutCount = miss.count { it.missOut > BigDecimal.ZERO }, + skippedNegCount = miss.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO }, sumMissIn = sumIn.stripTrailingZeros().toPlainString(), sumMissOut = sumOut.stripTrailingZeros().toPlainString(), skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(), - rows = auto.take(limit).map { it.toRow() }, + overIssueCount = over.size, + sumOverIssue = sumOver.stripTrailingZeros().toPlainString(), + rows = rows.map { it.toRow() }, ) } @@ -683,26 +714,49 @@ open class StockLedgerFixService( val t0 = System.nanoTime() try { val gaps = loadAdjGaps(asOfExclusive) - val auto = gaps.filter { it.missIn.compareTo(BigDecimal.ZERO) != 0 || it.missOut.compareTo(BigDecimal.ZERO) != 0 } + val work = gaps.filter { it.hasWork() } + val over = work.filter { it.hasOverIssue() } + val miss = work.filter { it.hasMiss() } var insertedIn = 0 var insertedOut = 0 - if (auto.isNotEmpty()) { + var overIssuePatched = 0 + if (work.isNotEmpty()) { val insTx = TransactionTemplate(transactionManager) insTx.timeout = 300 insTx.execute { - auto.forEach { g -> + var stockInId: Long? = null + var stockOutId: Long? = null + fun inHeader(): Long = + stockInId ?: ensureAdjStockInHeader(adjDate).also { stockInId = it } + fun outHeader(): Long = + stockOutId ?: ensureAdjStockOutHeader(adjDate).also { stockOutId = it } + over.forEach { g -> + if (patchLotRemainToZero(g.lotLineId)) { + insertAdjLedger( + g, adjDate, inQty = g.overIssue, outQty = null, + remarks = OVER_ISSUE_REMARKS, stockInId = inHeader(), + ) + insertedIn++ + overIssuePatched++ + } + } + miss.forEach { g -> if (g.missIn.compareTo(BigDecimal.ZERO) > 0) { - insertAdjLedger(g, adjDate, inQty = g.missIn, outQty = null) + insertAdjLedger(g, adjDate, inQty = g.missIn, outQty = null, stockInId = inHeader()) insertedIn++ } else if (g.missIn.compareTo(BigDecimal.ZERO) < 0) { - insertAdjLedger(g, adjDate, inQty = null, outQty = g.missIn.negate()) + insertAdjLedger( + g, adjDate, inQty = null, outQty = g.missIn.negate(), stockOutId = outHeader(), + ) insertedOut++ } if (g.missOut.compareTo(BigDecimal.ZERO) > 0) { - insertAdjLedger(g, adjDate, inQty = null, outQty = g.missOut) + insertAdjLedger(g, adjDate, inQty = null, outQty = g.missOut, stockOutId = outHeader()) insertedOut++ } else if (g.missOut.compareTo(BigDecimal.ZERO) < 0) { - insertAdjLedger(g, adjDate, inQty = g.missOut.negate(), outQty = null) + insertAdjLedger( + g, adjDate, inQty = g.missOut.negate(), outQty = null, stockInId = inHeader(), + ) insertedIn++ } } @@ -712,7 +766,7 @@ open class StockLedgerFixService( fun addLot(lotId: Long, invId: Long?) { if (lotId > 0) lotIds.add(lotId) } - auto.forEach { addLot(it.lotLineId, it.inventoryId) } + work.forEach { addLot(it.lotLineId, it.inventoryId) } jdbcDao.queryForList( """ SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId @@ -734,14 +788,15 @@ open class StockLedgerFixService( timed("adj 2.6") { upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } } else 0 log.info( - "stock-ledger-fix adj {} in={} out={} lotQty={} bal={} day={} total={}ms", - adjDate, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, + "stock-ledger-fix adj {} over={} in={} out={} lotQty={} bal={} day={} total={}ms", + adjDate, overIssuePatched, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, (System.nanoTime() - t0) / 1_000_000, ) return StockLedgerFixAdjResponse( adjDate = adjDate.toString(), insertedIn = insertedIn, insertedOut = insertedOut, + overIssuePatched = overIssuePatched, filledLotQty = filledLotQty, filledBalance = filledBalance, dayRowsWritten = dayRows, @@ -817,9 +872,8 @@ open class StockLedgerFixService( } /** - * One HTTP call for [from]..[to] inclusive (before today). - * 2.1–2.5 use a single date window (same SQL as [fixDay]); 2.6 still walks days - * because stock_lot_day carry depends on the previous close. + * One HTTP call for [from]..[to] inclusive. + * Walks days like [fixDay] (each day commits) so 2.3–2.5 do not lock the whole range. */ open fun fixRange(fromRaw: String, toRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { val from = parseDay(fromRaw) @@ -840,26 +894,28 @@ open class StockLedgerFixService( } val t0 = System.nanoTime() try { - val args = rangeArgs(from, to) - val filledLotLineId = if ("2.1" in steps) timed("range 2.1 fillLotLineId") { fillLotLineId(args) } else 0 - val filledUomId = if ("2.2" in steps) timed("range 2.2 fillUomId") { fillUomId(args) } else 0 - val filledInventoryId = if ("2.3" in steps) timed("range 2.3 fillInventoryId") { fillInventoryId(args) } else 0 - val filledLotQty = if ("2.4" in steps) timed("range 2.4 fillLotQty") { fillLotQty(args) } else 0 - val filledBalance = if ("2.5" in steps) timed("range 2.5 fillBalance") { fillBalance(args) } else 0 - val dayRowsWritten = if ("2.6" in steps) { - timed("range 2.6 upsertStockLotDay") { - var n = 0 - var d = from - while (!d.isAfter(to)) { - n += stockLotDayCloseService.upsertDayForFix(d).rowsWritten - d = d.plusDays(1) + var filledLotLineId = 0 + var filledUomId = 0 + var filledInventoryId = 0 + var filledLotQty = 0 + var filledBalance = 0 + var dayRowsWritten = 0 + var d = from + while (!d.isAfter(to)) { + val args = dayArgs(d) + if ("2.1" in steps) filledLotLineId += timed("range $d 2.1") { fillLotLineId(args) } + if ("2.2" in steps) filledUomId += timed("range $d 2.2") { fillUomId(args) } + if ("2.3" in steps) filledInventoryId += timed("range $d 2.3") { fillInventoryId(args) } + if ("2.4" in steps) filledLotQty += timed("range $d 2.4") { fillLotQty(args) } + if ("2.5" in steps) filledBalance += timed("range $d 2.5") { fillBalance(args) } + if ("2.6" in steps) { + dayRowsWritten += timed("range $d 2.6") { + stockLotDayCloseService.upsertDayForFix(d).rowsWritten } - n } - } else { - 0 + d = d.plusDays(1) } - val still = stillMiss(args) + val still = stillMiss(rangeArgs(from, to)) log.info( "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, @@ -964,33 +1020,65 @@ open class StockLedgerFixService( ) } - /** 2.3 inventory by (itemId, uomId) from 1.0 MySQL step. */ + /** + * 2.3: ledger stock UOM (`sl.uomId` from 2.2) → inventory bucket. + * A joins `inventory.stockUomId` (indexable). B is leftover rows with null stockUomId. + */ private fun fillInventoryId(args: Map): Int { - return jdbcDao.executeUpdate( + return applyInventoryIdUpdates( + args, + extraJoin = "", + extraWhere = "sl.date >= :day AND sl.date < :dayNext", + ) + } + + private fun applyInventoryIdUpdates( + args: Map, + extraJoin: String, + extraWhere: String, + ): Int { + val byStockUom = jdbcDao.executeUpdate( """ UPDATE stock_ledger sl + $extraJoin INNER JOIN ( - SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId - FROM inventory i - INNER JOIN ( - SELECT DISTINCT itemId, uomId - FROM stock_ledger - WHERE deleted = 0 - AND date >= :day AND date < :dayNext - AND itemId IS NOT NULL - AND uomId IS NOT NULL - ) t ON t.itemId = i.itemId AND t.uomId = i.uomId - WHERE IFNULL(i.deleted, 0) = 0 - GROUP BY i.itemId, i.uomId + SELECT itemId, stockUomId, MIN(id) AS inventoryId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NOT NULL + GROUP BY itemId, stockUomId + ) inv ON inv.itemId = sl.itemId AND inv.stockUomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + WHERE sl.deleted = 0 + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND (sl.inventoryId IS NULL OR sl.inventoryId <> inv.inventoryId) + AND ($extraWhere) + """.trimIndent(), + args, + ) + val byLegacyUom = jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + $extraJoin + INNER JOIN ( + SELECT itemId, uomId, MIN(id) AS inventoryId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + AND uomId IS NOT NULL + GROUP BY itemId, uomId ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId SET sl.inventoryId = inv.inventoryId WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext AND sl.itemId IS NOT NULL AND sl.uomId IS NOT NULL + AND sl.inventoryId IS NULL + AND ($extraWhere) """.trimIndent(), args, ) + return byStockUom + byLegacyUom } /** @@ -1552,23 +1640,10 @@ open class StockLedgerFixService( private fun fillInventoryIdScope(args: Map, byInventory: Boolean): Int { if (!byInventory) { - return jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN ( - SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId - FROM inventory i - WHERE IFNULL(i.deleted, 0) = 0 - GROUP BY i.itemId, i.uomId - ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId - SET sl.inventoryId = inv.inventoryId - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - AND sl.inventoryLotLineId = :lotId - """.trimIndent(), + return applyInventoryIdUpdates( args, + extraJoin = "", + extraWhere = "sl.date < :today AND sl.inventoryLotLineId = :lotId", ) } return withTempTx { @@ -1607,18 +1682,10 @@ open class StockLedgerFixService( """.trimIndent(), args, ) - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_inv_ids t ON t.id = sl.id - INNER JOIN ( - SELECT i.itemId, i.uomId, MIN(i.id) AS inventoryId - FROM inventory i - WHERE IFNULL(i.deleted, 0) = 0 - GROUP BY i.itemId, i.uomId - ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId - SET sl.inventoryId = inv.inventoryId - """.trimIndent(), + applyInventoryIdUpdates( + args, + extraJoin = "INNER JOIN tmp_sl_fix_inv_ids t ON t.id = sl.id", + extraWhere = "1=1", ) } finally { jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") @@ -2063,8 +2130,8 @@ open class StockLedgerFixService( /** * Export SQL patch for target DB (same dump lineage / freeze). * [partsRaw] omit / empty / all = ledger + 2.6 (legacy). Else subset: - * 1.0 inventory, 2.3 inventoryId, ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 ADJ INSERT. - * Apply order in file: 1.0 → ledger(+2.3) → 2.7 → 2.6. + * 1.0 inventory, 2.3 inventoryId, ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 SIL/SOL + ADJ INSERT. + * Apply order in file: 1.0 → ledger(+2.3) → 2.7 docs → 2.7 ADJ → 2.6. */ open fun writeExportSql( from: LocalDate, @@ -2152,7 +2219,7 @@ open class StockLedgerFixService( writer.write("-- stock-ledger-fix patch\n") writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") writer.write("-- parts=${parts.joinToString(",")}\n") - writer.write("-- apply order: 1.0 inventory → ledger(+2.3) UPDATE → 2.7 ADJ INSERT → 2.6 stock_lot_day\n") + writer.write("-- apply order: 1.0 inventory → ledger(+2.3) UPDATE → 2.7 SIL/SOL then ADJ INSERT → 2.6 stock_lot_day\n") writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") if (want23 && !want10) { writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n") @@ -2170,6 +2237,7 @@ open class StockLedgerFixService( writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) } if (want27) { + writeExportAdjDocs(writer, jdbc, args) writeExportAdjInsert(writer, jdbc, args) } if (want26) { @@ -2193,7 +2261,7 @@ open class StockLedgerFixService( jdbc.query( """ SELECT id, created, createdBy, version, modified, modifiedBy, deleted, - itemId, uomId, onHandQty, onHoldQty, unavailableQty, + itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status FROM inventory WHERE IFNULL(deleted, 0) = 0 @@ -2205,7 +2273,7 @@ open class StockLedgerFixService( writer.write( "INSERT INTO inventory (\n" + " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + - " itemId, uomId, onHandQty, onHoldQty, unavailableQty,\n" + + " itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,\n" + " price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status\n" + ") VALUES\n", ) @@ -2215,7 +2283,7 @@ open class StockLedgerFixService( writer.write( "(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + - "${rsSql(rs, "itemId")}, ${rsSql(rs, "uomId")}, ${rsSql(rs, "onHandQty")}, " + + "${rsSql(rs, "itemId")}, ${rsSql(rs, "uomId")}, ${rsSql(rs, "stockUomId")}, ${rsSql(rs, "onHandQty")}, " + "${rsSql(rs, "onHoldQty")}, ${rsSql(rs, "unavailableQty")}, ${rsSql(rs, "price")}, " + "${rsSql(rs, "currencyId")}, ${rsSql(rs, "cpu")}, ${rsSql(rs, "cpuUnit")}, " + "${rsSql(rs, "cpm")}, ${rsSql(rs, "cpmUnit")}, ${rsSql(rs, "status")})", @@ -2226,6 +2294,7 @@ open class StockLedgerFixService( "\nON DUPLICATE KEY UPDATE " + "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + + "uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " + "price=VALUES(price), currencyId=VALUES(currencyId), " + "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + @@ -2240,6 +2309,7 @@ open class StockLedgerFixService( "\nON DUPLICATE KEY UPDATE " + "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + + "uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " + "price=VALUES(price), currencyId=VALUES(currencyId), " + "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + @@ -2325,6 +2395,119 @@ open class StockLedgerFixService( writer.write("\n") } + private fun writeExportAdjDocs( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + ) { + writer.write("-- === 2.7 ADJ stock_in / stock_out (1 header + many lines; apply before ledger ADJ FKs) ===\n") + writeExportRows( + writer, + jdbc, + """ + SELECT DISTINCT si.id, si.created, si.createdBy, si.version, si.modified, si.modifiedBy, si.deleted, + si.code, si.status, si.orderDate, si.completeDate + FROM stock_in si + INNER JOIN stock_in_line sil ON sil.stockInId = si.id AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY si.id + """.trimIndent(), + args, + table = "stock_in", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "code", "status", "orderDate", "completeDate", + ), + onDup = "code=VALUES(code), status=VALUES(status), orderDate=VALUES(orderDate), " + + "completeDate=VALUES(completeDate), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_in.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_in headers in range)", + ) + writeExportRows( + writer, + jdbc, + """ + SELECT sil.id, sil.created, sil.createdBy, sil.version, sil.modified, sil.modifiedBy, sil.deleted, + sil.itemId, sil.itemNo, sil.stockInId, sil.acceptedQty, sil.receiptDate, sil.status, + sil.inventoryLotId, sil.inventoryLotLineId, sil.lotNo, sil.remarks, sil.type + FROM stock_in_line sil + INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY sil.id + """.trimIndent(), + args, + table = "stock_in_line", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "itemId", "itemNo", "stockInId", "acceptedQty", "receiptDate", "status", + "inventoryLotId", "inventoryLotLineId", "lotNo", "remarks", "type", + ), + onDup = "acceptedQty=VALUES(acceptedQty), inventoryLotId=VALUES(inventoryLotId), " + + "inventoryLotLineId=VALUES(inventoryLotLineId), lotNo=VALUES(lotNo), status=VALUES(status), " + + "type=VALUES(type), remarks=VALUES(remarks), modified=VALUES(modified), " + + "modifiedBy=VALUES(modifiedBy), version=stock_in_line.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_in_line in range)", + ) + writeExportRows( + writer, + jdbc, + """ + SELECT DISTINCT so.id, so.created, so.createdBy, so.version, so.modified, so.modifiedBy, so.deleted, + so.type, so.completeDate, so.status, so.remarks + FROM stock_out so + INNER JOIN stock_out_line sol ON sol.stockOutId = so.id AND IFNULL(sol.deleted, 0) = 0 + INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY so.id + """.trimIndent(), + args, + table = "stock_out", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "type", "completeDate", "status", "remarks", + ), + onDup = "type=VALUES(type), completeDate=VALUES(completeDate), status=VALUES(status), " + + "remarks=VALUES(remarks), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_out.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_out headers in range)", + ) + writeExportRows( + writer, + jdbc, + """ + SELECT sol.id, sol.created, sol.createdBy, sol.version, sol.modified, sol.modifiedBy, sol.deleted, + sol.itemId, sol.qty, sol.stockOutId, sol.inventoryLotLineId, sol.status, sol.pickTime, sol.type + FROM stock_out_line sol + INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY sol.id + """.trimIndent(), + args, + table = "stock_out_line", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "itemId", "qty", "stockOutId", "inventoryLotLineId", "status", "pickTime", "type", + ), + onDup = "qty=VALUES(qty), inventoryLotLineId=VALUES(inventoryLotLineId), status=VALUES(status), " + + "pickTime=VALUES(pickTime), type=VALUES(type), modified=VALUES(modified), " + + "modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_out_line in range)", + ) + } + private fun writeExportAdjInsert( writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate, @@ -2372,6 +2555,7 @@ open class StockLedgerFixService( writer.write( "\nON DUPLICATE KEY UPDATE " + "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + + "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + @@ -2385,6 +2569,7 @@ open class StockLedgerFixService( writer.write( "\nON DUPLICATE KEY UPDATE " + "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + + "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + @@ -2494,6 +2679,41 @@ open class StockLedgerFixService( return out } + private fun writeExportRows( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + sql: String, + args: Map, + table: String, + columns: List, + onDup: String, + emptyComment: String, + ) { + var batch = 0 + jdbc.query( + sql, + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write("INSERT INTO $table (\n ${columns.joinToString(", ")}\n) VALUES\n") + } else { + writer.write(",\n") + } + writer.write("(" + columns.joinToString(", ") { rsSql(rs, it) } + ")") + batch++ + if (batch >= 200) { + writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n") + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n") + } else { + writer.write("$emptyComment\n\n") + } + } + private fun rsSql(rs: ResultSet, col: String): String { val v = rs.getObject(col) if (v == null || rs.wasNull()) return "NULL" @@ -2514,7 +2734,7 @@ open class StockLedgerFixService( "prevDay" to day.minusDays(1), ) - /** Inclusive [from]..[to]; same keys as [dayArgs] so 2.1–2.5 SQL can reuse the day window. */ + /** Inclusive [from]..[to]; same keys as [dayArgs] (used by stillMiss over the whole range). */ private fun rangeArgs(from: LocalDate, to: LocalDate): Map = mapOf( "day" to from, @@ -2652,11 +2872,11 @@ open class StockLedgerFixService( LEFT JOIN items it ON it.id = il.itemId LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId LEFT JOIN ( - SELECT itemId, uomId, MIN(id) AS inventoryId + SELECT itemId, COALESCE(stockUomId, uomId) AS bucketUomId, MIN(id) AS inventoryId FROM inventory WHERE IFNULL(deleted, 0) = 0 - GROUP BY itemId, uomId - ) inv ON inv.itemId = il.itemId AND inv.uomId = iu.uomId + GROUP BY itemId, COALESCE(stockUomId, uomId) + ) inv ON inv.itemId = il.itemId AND inv.bucketUomId = iu.uomId WHERE IFNULL(ill.deleted, 0) = 0 AND ( COALESCE(ill.inQty, 0) <> 0 @@ -2705,6 +2925,7 @@ open class StockLedgerFixService( LEFT JOIN tmp_adj_led agg ON agg.inventoryLotLineId = l.lotLineId WHERE l.lineIn <> CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) OR l.lineOut <> CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) + OR l.lineIn < l.lineOut """.trimIndent(), ).map { row -> val lineIn = toDecimal(row["lineIn"]) @@ -2736,9 +2957,29 @@ open class StockLedgerFixService( } ?: emptyList() } - private fun insertAdjLedger(g: AdjGap, adjDate: LocalDate, inQty: BigDecimal?, outQty: BigDecimal?) { + private fun insertAdjLedger( + g: AdjGap, + adjDate: LocalDate, + inQty: BigDecimal?, + outQty: BigDecimal?, + remarks: String = ADJ_DOC_REMARKS, + stockInId: Long? = null, + stockOutId: Long? = null, + ) { + val silId = if (inQty != null) { + insertAdjStockInLine(g, adjDate, inQty, remarks, stockInId ?: error("stockInId required for ADJ in")) + } else { + null + } + val solId = if (outQty != null) { + insertAdjStockOutLine(g, adjDate, outQty, remarks, stockOutId ?: error("stockOutId required for ADJ out")) + } else { + null + } val args = hashMapOf( "adjDate" to adjDate, + "silId" to silId, + "solId" to solId, "lotLineId" to g.lotLineId, "inventoryId" to g.inventoryId, "itemId" to g.itemId, @@ -2756,7 +2997,7 @@ open class StockLedgerFixService( uomId, type ) VALUES ( NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, - :adjDate, NULL, NULL, :lotLineId, :inventoryId, + :adjDate, :silId, :solId, :lotLineId, :inventoryId, :itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL, :uomId, 'ADJ' ) @@ -2765,6 +3006,206 @@ open class StockLedgerFixService( ) } + /** Sets lot inQty = outQty so remain = 0; trigger recomputes inventory. */ + private fun patchLotRemainToZero(lotLineId: Long): Boolean { + val n = jdbcDao.executeUpdate( + """ + UPDATE inventory_lot_line + SET inQty = CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)), + status = 'unavailable', + modified = NOW(), + modifiedBy = 'stock-ledger-fix', + version = IFNULL(version, 0) + 1 + WHERE id = :lotLineId + AND IFNULL(deleted, 0) = 0 + AND CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) + < CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) + """.trimIndent(), + mapOf("lotLineId" to lotLineId), + ) + if (n == 0) { + log.warn("stock-ledger-fix over-issue skip lot update: lotLineId={}", lotLineId) + } + return n > 0 + } + + private fun adjStockInCode(adjDate: LocalDate): String = "SLF-I-$adjDate" + + private fun adjStockOutRemarks(adjDate: LocalDate): String = "$ADJ_HEADER_REMARKS $adjDate" + + /** One stock_in per ADJ date (reused if Apply runs again). */ + private fun ensureAdjStockInHeader(adjDate: LocalDate): Long { + val code = adjStockInCode(adjDate) + val existing = jdbcDao.queryForMap( + """ + SELECT id FROM stock_in + WHERE code = :code AND IFNULL(deleted, 0) = 0 + LIMIT 1 + """.trimIndent(), + mapOf("code" to code), + ).orElse(null) + if (existing != null) return toLong(existing["id"]) + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_in ( + created, createdBy, version, modified, modifiedBy, deleted, + code, status, orderDate, completeDate + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :code, 'completed', :at, :at + ) + """.trimIndent(), + mapOf("code" to code, "at" to at), + ) + } + + /** One stock_out per ADJ date (reused if Apply runs again). */ + private fun ensureAdjStockOutHeader(adjDate: LocalDate): Long { + val remarks = adjStockOutRemarks(adjDate) + val existing = jdbcDao.queryForMap( + """ + SELECT id FROM stock_out + WHERE createdBy = 'stock-ledger-fix' + AND type = 'ADJ' + AND remarks = :remarks + AND IFNULL(deleted, 0) = 0 + ORDER BY id + LIMIT 1 + """.trimIndent(), + mapOf("remarks" to remarks), + ).orElse(null) + if (existing != null) return toLong(existing["id"]) + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_out ( + created, createdBy, version, modified, modifiedBy, deleted, + type, completeDate, status, remarks + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + 'ADJ', :at, 'completed', :remarks + ) + """.trimIndent(), + mapOf("at" to at, "remarks" to remarks), + ) + } + + /** Line on shared stock_in; does not UPDATE inventory_lot_line or inventory. */ + private fun insertAdjStockInLine( + g: AdjGap, + adjDate: LocalDate, + qty: BigDecimal, + remarks: String, + stockInId: Long, + ): Long? { + val itemId = g.itemId + if (itemId == null) { + log.warn("stock-ledger-fix adj skip SIL: lotLineId={} has no itemId", g.lotLineId) + return null + } + val lot = loadAdjLotLink(g.lotLineId) ?: return null + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_in_line ( + created, createdBy, version, modified, modifiedBy, deleted, + itemId, itemNo, stockInId, acceptedQty, receiptDate, status, + inventoryLotId, inventoryLotLineId, lotNo, remarks, type + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :itemId, :itemNo, :stockInId, :qty, :at, 'completed', + :inventoryLotId, :lotLineId, :lotNo, :remarks, 'ADJ' + ) + """.trimIndent(), + mapOf( + "itemId" to itemId, + "itemNo" to adjItemNo(g.itemCode), + "stockInId" to stockInId, + "qty" to qty, + "at" to at, + "inventoryLotId" to lot.inventoryLotId, + "lotLineId" to g.lotLineId, + "lotNo" to lot.lotNo, + "remarks" to remarks, + ), + ) + } + + /** Line on shared stock_out; does not UPDATE inventory_lot_line or inventory. */ + private fun insertAdjStockOutLine( + g: AdjGap, + adjDate: LocalDate, + qty: BigDecimal, + remarks: String, + stockOutId: Long, + ): Long? { + val itemId = g.itemId + if (itemId == null) { + log.warn("stock-ledger-fix adj skip SOL: lotLineId={} has no itemId", g.lotLineId) + return null + } + if (loadAdjLotLink(g.lotLineId) == null) return null + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_out_line ( + created, createdBy, version, modified, modifiedBy, deleted, + itemId, qty, stockOutId, inventoryLotLineId, status, pickTime, type + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :itemId, :qty, :stockOutId, :lotLineId, 'completed', :at, 'ADJ' + ) + """.trimIndent(), + mapOf( + "itemId" to itemId, + "qty" to qty, + "stockOutId" to stockOutId, + "lotLineId" to g.lotLineId, + "at" to at, + ), + ) + } + + private fun loadAdjLotLink(lotLineId: Long): AdjLotLink? { + val row = jdbcDao.queryForMap( + """ + SELECT ill.inventoryLotId AS inventoryLotId, il.lotNo AS lotNo + FROM inventory_lot_line ill + LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId + WHERE ill.id = :lotLineId + AND IFNULL(ill.deleted, 0) = 0 + """.trimIndent(), + mapOf("lotLineId" to lotLineId), + ).orElse(null) + if (row == null) { + log.warn("stock-ledger-fix adj skip SIL/SOL: lotLineId={} not found", lotLineId) + return null + } + return AdjLotLink( + inventoryLotId = toLongOrNull(row["inventoryLotId"]), + lotNo = row["lotNo"]?.toString(), + ) + } + + private fun insertReturningId(sql: String, args: Map): Long { + val keyHolder = GeneratedKeyHolder() + namedJdbc.update(sql, MapSqlParameterSource(args), keyHolder, arrayOf("id")) + val key = keyHolder.key?.toLong() + ?: keyHolder.keyList.firstOrNull()?.values?.firstOrNull()?.let { (it as Number).toLong() } + return key ?: error("stock-ledger-fix: insert did not return id") + } + + private fun adjItemNo(itemCode: String?): String { + val raw = itemCode?.trim().orEmpty() + return if (raw.isEmpty()) "SLF" else raw.take(20) + } + + private data class AdjLotLink( + val inventoryLotId: Long?, + val lotNo: String?, + ) + private fun fillLotQtyForLotIds(lotIds: List, today: LocalDate): Int { if (lotIds.isEmpty()) return 0 return withTempTx { @@ -2920,6 +3361,16 @@ open class StockLedgerFixService( val missIn: BigDecimal, val missOut: BigDecimal, ) { + val overIssue: BigDecimal = + if (lineOut.compareTo(lineIn) > 0) lineOut.subtract(lineIn) else BigDecimal.ZERO + + fun hasOverIssue(): Boolean = overIssue.compareTo(BigDecimal.ZERO) > 0 + + fun hasMiss(): Boolean = + missIn.compareTo(BigDecimal.ZERO) != 0 || missOut.compareTo(BigDecimal.ZERO) != 0 + + fun hasWork(): Boolean = hasOverIssue() || hasMiss() + fun toRow() = StockLedgerFixAdjRow( lotLineId = lotLineId, inventoryId = inventoryId, @@ -2930,6 +3381,7 @@ open class StockLedgerFixService( ledgerOut = ledgerOut.stripTrailingZeros().toPlainString(), missIn = missIn.stripTrailingZeros().toPlainString(), missOut = missOut.stripTrailingZeros().toPlainString(), + overIssue = overIssue.stripTrailingZeros().toPlainString(), ) } @@ -2956,6 +3408,9 @@ open class StockLedgerFixService( private val DAY_STEPS = linkedSetOf("2.1", "2.2", "2.3", "2.4", "2.5", "2.6") /** Legacy default export: ledger calc fields + stock_lot_day (no inventory / inventoryId / ADJ). */ private val EXPORT_PARTS_DEFAULT = linkedSetOf("ledger", "2.6") + private const val ADJ_DOC_REMARKS = "stock-ledger-fix miss ADJ (no lot qty change)" + private const val OVER_ISSUE_REMARKS = "stock-ledger-fix over-issue remain→0 (lot inQty patched)" + private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ" private const val LOT_UOM_PAIR_COUNT_SQL = """ SELECT COUNT(*) AS c FROM ( @@ -2982,7 +3437,7 @@ open class StockLedgerFixService( ON iu.id = ill.stockItemUomId LEFT JOIN inventory inv ON inv.itemId = il.itemId - AND inv.uomId = iu.uomId + AND COALESCE(inv.stockUomId, inv.uomId) = iu.uomId AND IFNULL(inv.deleted, 0) = 0 WHERE IFNULL(ill.deleted, 0) = 0 AND il.itemId IS NOT NULL @@ -2992,14 +3447,90 @@ open class StockLedgerFixService( ) x """ + private const val NULL_STOCK_UOM_COUNT_SQL = """ + SELECT COUNT(*) AS c + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + """ + + /** + * Old row: uomId already equals a lot stock UOM (stock, or base when base=stock). + * Fill stockUomId from that lot stock; move uomId to item base. + */ + private const val BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL = """ + UPDATE inventory i + INNER JOIN ( + SELECT DISTINCT il.itemId, iu.uomId AS stockUomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + ) b ON b.itemId = i.itemId AND b.stockUomId = i.uomId + LEFT JOIN item_uom baseIu + ON baseIu.itemId = i.itemId + AND baseIu.baseUnit = 1 + AND IFNULL(baseIu.deleted, 0) = 0 + SET + i.stockUomId = b.stockUomId, + i.uomId = COALESCE(baseIu.uomId, i.uomId), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + AND i.stockUomId IS NULL + """ + + /** + * Remaining NULL stockUomId: item has exactly one lot stock UOM + * (uomId already base, stock may differ). Skip if that bucket row already exists. + */ + private const val BACKFILL_STOCK_UOM_SINGLE_LOT_SQL = """ + UPDATE inventory i + INNER JOIN ( + SELECT il.itemId, MIN(iu.uomId) AS stockUomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId + HAVING COUNT(DISTINCT iu.uomId) = 1 + ) b ON b.itemId = i.itemId + LEFT JOIN item_uom baseIu + ON baseIu.itemId = i.itemId + AND baseIu.baseUnit = 1 + AND IFNULL(baseIu.deleted, 0) = 0 + LEFT JOIN inventory taken + ON taken.itemId = i.itemId + AND taken.stockUomId = b.stockUomId + AND IFNULL(taken.deleted, 0) = 0 + AND taken.id <> i.id + SET + i.stockUomId = b.stockUomId, + i.uomId = COALESCE(baseIu.uomId, i.uomId), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + AND i.stockUomId IS NULL + AND taken.id IS NULL + """ + private const val INSERT_MISSING_INVENTORY_SQL = """ INSERT INTO inventory ( - itemId, uomId, onHandQty, onHoldQty, unavailableQty, + itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status, created, createdBy, modified, modifiedBy, version, deleted ) SELECT src.itemId, + COALESCE(baseIu.uomId, src.uomId), src.uomId, 0, 0, 0, COALESCE(tpl.price, 0), @@ -3022,9 +3553,13 @@ open class StockLedgerFixService( AND iu.uomId IS NOT NULL GROUP BY il.itemId, iu.uomId ) src + LEFT JOIN item_uom baseIu + ON baseIu.itemId = src.itemId + AND baseIu.baseUnit = 1 + AND IFNULL(baseIu.deleted, 0) = 0 LEFT JOIN inventory existing ON existing.itemId = src.itemId - AND existing.uomId = src.uomId + AND COALESCE(existing.stockUomId, existing.uomId) = src.uomId AND IFNULL(existing.deleted, 0) = 0 LEFT JOIN inventory tpl ON tpl.id = ( @@ -3043,7 +3578,6 @@ open class StockLedgerFixService( il.itemId, iu.uomId, SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand, - SUM(COALESCE(ill.holdQty, 0)) AS onHold, SUM( CASE WHEN LOWER(ill.status) = 'unavailable' @@ -3060,13 +3594,12 @@ open class StockLedgerFixService( AND il.itemId IS NOT NULL AND iu.uomId IS NOT NULL GROUP BY il.itemId, iu.uomId - ) agg ON agg.itemId = i.itemId AND agg.uomId = i.uomId + ) agg ON agg.itemId = i.itemId AND agg.uomId = COALESCE(i.stockUomId, i.uomId) SET i.onHandQty = COALESCE(agg.onHand, 0), - i.onHoldQty = COALESCE(agg.onHold, 0), i.unavailableQty = COALESCE(agg.unavailable, 0), i.status = IF( - COALESCE(agg.onHand, 0) - COALESCE(agg.onHold, 0) - COALESCE(agg.unavailable, 0) > 0, + COALESCE(agg.onHand, 0) - COALESCE(agg.unavailable, 0) > 0, 'available', 'unavailable' ), diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt index eb9f9593..4086266a 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt @@ -60,12 +60,15 @@ data class StockLedgerFixInventoryPreview( val inventoryRows: Int, val lotUomPairs: Int, val missingUomPairs: Int, + val nullStockUomId: Int, ) data class StockLedgerFixInventoryResponse( val inserted: Int, val updated: Int, val missingUomPairsAfter: Int, + val patchedStockUomId: Int, + val nullStockUomIdAfter: Int, ) data class StockLedgerFixSearchInventoryHit( @@ -99,6 +102,7 @@ data class StockLedgerFixAdjRow( val ledgerOut: String, val missIn: String, val missOut: String, + val overIssue: String, ) data class StockLedgerFixAdjPreview( @@ -110,6 +114,8 @@ data class StockLedgerFixAdjPreview( val sumMissIn: String, val sumMissOut: String, val skuNet: String, + val overIssueCount: Int, + val sumOverIssue: String, val rows: List, ) @@ -117,6 +123,7 @@ data class StockLedgerFixAdjResponse( val adjDate: String, val insertedIn: Int, val insertedOut: Int, + val overIssuePatched: Int, val filledLotQty: Int, val filledBalance: Int, val dayRowsWritten: Int, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt index d2031afc..13ab82cd 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt @@ -3,6 +3,8 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.support.JdbcDao import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDate import java.util.concurrent.ConcurrentHashMap @@ -12,10 +14,12 @@ import java.util.concurrent.ConcurrentHashMap * - carry: previous closing > 0 with no ledger today → opening = closing = prev.closing * * Never closes today or future dates. Idempotent via ON DUPLICATE KEY UPDATE. + * Unchanged rows are skipped so a second close of the same day does not rewrite. */ @Service open class StockLotDayCloseService( private val jdbcDao: JdbcDao, + private val transactionManager: PlatformTransactionManager, ) { private val log = LoggerFactory.getLogger(StockLotDayCloseService::class.java) private val inFlight = ConcurrentHashMap.newKeySet() @@ -129,13 +133,29 @@ open class StockLotDayCloseService( "actor" to actor, ) val t0 = System.nanoTime() - val moveRows = jdbcDao.executeUpdate(MOVES_SQL, args) - val carryRows = jdbcDao.executeUpdate(CARRY_SQL, args) + val result = TransactionTemplate(transactionManager).execute { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sld_lots_today") + jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sld_lots_today ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + try { + jdbcDao.executeUpdate(FILL_LOTS_TODAY_SQL, args) + val moveRows = jdbcDao.executeUpdate(MOVES_SQL, args) + val carryRows = jdbcDao.executeUpdate(CARRY_SQL, args) + CloseDayResult(date = day, moveRows = moveRows, carryRows = carryRows) + } finally { + jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sld_lots_today") + } + } ?: CloseDayResult(date = day, moveRows = 0, carryRows = 0) log.info( "stock_lot_day close {} moves={} carry={} {}ms actor={}", - day, moveRows, carryRows, (System.nanoTime() - t0) / 1_000_000, actor, + day, result.moveRows, result.carryRows, (System.nanoTime() - t0) / 1_000_000, actor, ) - return CloseDayResult(date = day, moveRows = moveRows, carryRows = carryRows) + return result } private fun maxClosedDate(): LocalDate? { @@ -160,6 +180,31 @@ open class StockLotDayCloseService( const val ACTOR_FIX = "ledger-fix-api" private const val LOCK = "stock-lot-day-close" + private const val ON_DUP = """ + ON DUPLICATE KEY UPDATE + itemId = VALUES(itemId), + itemCode = VALUES(itemCode), + uomId = VALUES(uomId), + lotNo = VALUES(lotNo), + opening = VALUES(opening), + inQty = VALUES(inQty), + outQty = VALUES(outQty), + closing = VALUES(closing), + modified = NOW(), + modifiedBy = VALUES(modifiedBy), + version = stock_lot_day.version + 1, + deleted = 0 + """ + + private val FILL_LOTS_TODAY_SQL = """ + INSERT INTO tmp_sld_lots_today (inventoryLotLineId) + SELECT DISTINCT sl.inventoryLotLineId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + """ + private val MOVES_SQL = """ INSERT INTO stock_lot_day ( created, createdBy, version, modified, modifiedBy, deleted, @@ -199,20 +244,23 @@ open class StockLotDayCloseService( INNER JOIN stock_ledger lastSl ON lastSl.id = x.lastId LEFT JOIN inventory_lot_line ill ON ill.id = x.inventoryLotLineId LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId - ON DUPLICATE KEY UPDATE - itemId = VALUES(itemId), - itemCode = VALUES(itemCode), - uomId = VALUES(uomId), - lotNo = VALUES(lotNo), - opening = VALUES(opening), - inQty = VALUES(inQty), - outQty = VALUES(outQty), - closing = VALUES(closing), - modified = NOW(), - modifiedBy = VALUES(modifiedBy), - version = stock_lot_day.version + 1, - deleted = 0 - """.trimIndent() + LEFT JOIN stock_lot_day cur + ON cur.inventoryLotLineId = x.inventoryLotLineId + AND cur.date = :day + WHERE cur.inventoryLotLineId IS NULL + OR IFNULL(cur.deleted, 0) <> 0 + OR NOT ( + cur.opening <=> firstSl.lotQtyBefore + AND cur.inQty <=> x.inQty + AND cur.outQty <=> x.outQty + AND cur.closing <=> lastSl.lotQtyAfter + AND cur.itemId <=> x.itemId + AND cur.uomId <=> x.uomId + AND IFNULL(cur.itemCode, '') <=> IFNULL(x.itemCode, '') + AND IFNULL(cur.lotNo, '') <=> IFNULL(il.lotNo, '') + ) + $ON_DUP + """ private val CARRY_SQL = """ INSERT INTO stock_lot_day ( @@ -233,28 +281,30 @@ open class StockLotDayCloseService( 0, prev.closing FROM stock_lot_day prev + LEFT JOIN tmp_sld_lots_today t + ON t.inventoryLotLineId = prev.inventoryLotLineId + LEFT JOIN stock_lot_day cur + ON cur.inventoryLotLineId = prev.inventoryLotLineId + AND cur.date = :day WHERE prev.date = :prevDay AND IFNULL(prev.deleted, 0) = 0 AND prev.closing > 0 - AND NOT EXISTS ( - SELECT 1 FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId = prev.inventoryLotLineId + AND t.inventoryLotLineId IS NULL + AND ( + cur.inventoryLotLineId IS NULL + OR IFNULL(cur.deleted, 0) <> 0 + OR NOT ( + cur.opening <=> prev.closing + AND cur.inQty <=> 0 + AND cur.outQty <=> 0 + AND cur.closing <=> prev.closing + AND cur.itemId <=> prev.itemId + AND cur.uomId <=> prev.uomId + AND IFNULL(cur.itemCode, '') <=> IFNULL(prev.itemCode, '') + AND IFNULL(cur.lotNo, '') <=> IFNULL(prev.lotNo, '') + ) ) - ON DUPLICATE KEY UPDATE - itemId = VALUES(itemId), - itemCode = VALUES(itemCode), - uomId = VALUES(uomId), - lotNo = VALUES(lotNo), - opening = VALUES(opening), - inQty = VALUES(inQty), - outQty = VALUES(outQty), - closing = VALUES(closing), - modified = NOW(), - modifiedBy = VALUES(modifiedBy), - version = stock_lot_day.version + 1, - deleted = 0 - """.trimIndent() + $ON_DUP + """ } } diff --git a/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql b/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql index 414eea05..2dcd7622 100644 --- a/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql +++ b/src/main/resources/db/changelog/changes/20260814_stock_lot_day/01_create_stock_lot_day.sql @@ -2,7 +2,7 @@ --changeset fpsms:create_stock_lot_day --comment: Daily lot stock card (主錨). Month-end report aggregates this table. ---precondition onFail:MARK_RAN +--preconditions onFail:MARK_RAN --precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'stock_lot_day' CREATE TABLE `stock_lot_day` ( From fbe1fae555826472988c2ae1fa486e7420ddf680 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 14 Sep 2026 19:23:27 +0800 Subject: [PATCH 05/52] 14,13,9,15,19,16,17,20-22 --- .../jobOrder/service/JobOrderService.kt | 62 ++++---- .../modules/jobOrder/service/PSService.kt | 24 +-- .../ProductionScheduleLineRepository.kt | 15 +- .../service/ProductionScheduleService.kt | 74 +++++++-- .../service/PickExecutionIssueService.kt | 66 -------- .../pickOrder/service/PickOrderService.kt | 146 +++++++----------- .../stock/service/InventoryLotLineService.kt | 52 +------ .../modules/stock/service/InventoryService.kt | 3 + .../stock/service/StockInLineService.kt | 3 + .../stock/service/StockOutLineService.kt | 81 +--------- .../stock/service/StockTakeRecordService.kt | 73 ++++++--- .../stock/service/SuggestedPickLotService.kt | 88 +---------- 12 files changed, 239 insertions(+), 448 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt index e8d5d968..e9e90355 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt @@ -166,7 +166,12 @@ open class JobOrderService( val inventoriesMap = if (allItemIds.isNotEmpty()) { inventoryRepository.findInventoryInfoByItemIdInAndDeletedIsFalse(allItemIds) - .associateBy { it.itemId } + .mapNotNull { info -> + val itemId = info.itemId ?: return@mapNotNull null + val stockUomId = info.stockUomId ?: return@mapNotNull null + (itemId to stockUomId) to info + } + .toMap() } else { emptyMap() } @@ -228,7 +233,7 @@ open class JobOrderService( private fun calculateStockCounts( jobOrder: JobOrder, - inventoriesMap: Map + inventoriesMap: Map, com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo> ): Pair { // 过滤掉 consumables 和 CMB 类型的物料 val nonConsumablesJobms = jobOrder.jobms.filter { jobm -> @@ -243,36 +248,22 @@ open class JobOrderService( var sufficientCount = 0 var insufficientCount = 0 - //println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===") - nonConsumablesJobms.forEach { jobm -> val itemId = jobm.item?.id - val itemCode = jobm.item?.code ?: "N/A" - val itemName = jobm.item?.name ?: "N/A" - if (itemId != null) { - val inventory = inventoriesMap[itemId] - val availableQty = if (inventory != null) { - inventory.availableQty ?: ( - (inventory.onHandQty ?: BigDecimal.ZERO) - - (inventory.onHoldQty ?: BigDecimal.ZERO) - - (inventory.unavailableQty ?: BigDecimal.ZERO) - ) - } else { - BigDecimal.ZERO - } + val jobmUomId = jobm.uom?.id + val inventory = resolveInventoryForJobm(itemId, jobmUomId, inventoriesMap) + val availableQty = inventory?.availableQty + ?: (inventory?.let { + (it.onHandQty ?: BigDecimal.ZERO) - (it.unavailableQty ?: BigDecimal.ZERO) + } ?: BigDecimal.ZERO) - // ✅ 获取 reqQty 和 availableQty 的单位信息 val reqQty = jobm.reqQty ?: BigDecimal.ZERO val reqUomId = jobm.uom?.id ?: 0L - val reqUomName = jobm.uom?.udfudesc ?: "N/A" - // ✅ 修复:使用 itemUomService 获取 stockUomId(与 ProductProcessService 保持一致) val stockUnitItemUom = itemUomService.findStockUnitByItemId(itemId) - val stockUomId = stockUnitItemUom?.uom?.id ?: 0L - val stockUomName = stockUnitItemUom?.uom?.udfudesc ?: "N/A" + val availableUomId = inventory?.stockUomId ?: stockUnitItemUom?.uom?.id ?: 0L - // ✅ 转换为 base unit 进行比较(与 ProductProcessService 保持一致) val baseReqQtyResult = if (reqUomId > 0 && reqQty > BigDecimal.ZERO) { try { itemUomService.convertUomByItem( @@ -291,13 +282,13 @@ open class JobOrderService( null } - val baseAvailableQtyResult = if (stockUomId > 0 && availableQty > BigDecimal.ZERO) { + val baseAvailableQtyResult = if (availableUomId > 0 && availableQty > BigDecimal.ZERO) { try { itemUomService.convertUomByItem( ConvertUomByItemRequest( itemId = itemId, qty = availableQty, - uomId = stockUomId, + uomId = availableUomId, targetUnit = "baseUnit" ) ) @@ -311,27 +302,32 @@ open class JobOrderService( val baseReqQty = baseReqQtyResult?.newQty ?: BigDecimal.ZERO val baseAvailableQty = baseAvailableQtyResult?.newQty ?: BigDecimal.ZERO - val baseUomName = baseReqQtyResult?.udfudesc ?: baseAvailableQtyResult?.udfudesc ?: "N/A" - // ✅ 使用 base unit 进行比较 if (baseAvailableQty >= baseReqQty) { sufficientCount++ - //println("✅ SUFFICIENT - Item: $itemCode ($itemName) - reqQty: $reqQty ($reqUomName) = $baseReqQty ($baseUomName), availableQty: $availableQty ($stockUomName) = $baseAvailableQty ($baseUomName)") } else { insufficientCount++ - //println("❌ INSUFFICIENT - Item: $itemCode ($itemName) - reqQty: $reqQty ($reqUomName) = $baseReqQty ($baseUomName), availableQty: $availableQty ($stockUomName) = $baseAvailableQty ($baseUomName)") } } else { - // 如果没有 itemId,视为不足 insufficientCount++ - //println("❌ INSUFFICIENT - Item: $itemCode ($itemName) - No itemId") } } - //println("=== Result: sufficient=$sufficientCount, insufficient=$insufficientCount ===") - return Pair(sufficientCount, insufficientCount) } + + private fun resolveInventoryForJobm( + itemId: Long, + jobmUomId: Long?, + inventoriesMap: Map, com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo>, + ): com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo? { + if (jobmUomId != null) { + inventoriesMap[itemId to jobmUomId]?.let { return it } + } + val stockUnitUomId = itemUomService.findStockUnitByItemId(itemId)?.uom?.id ?: return null + return inventoriesMap[itemId to stockUnitUomId] + } + open fun jobOrderDetail(id: Long): JobOrderDetail { val sqlResult = jobOrderRepository.findJobOrderDetailById(id) ?: throw NoSuchElementException(); diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt index 120ab5a9..f4cba6b7 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt @@ -168,8 +168,8 @@ open class PSService( /** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */ /** - * Recalculate [inventory.onHandQty] / hold / unavailable from [inventory_lot_line] for FG BOM items. - * Same aggregation as pick-issue manual inventory sync. + * Recalculate [inventory.onHandQty] / unavailable from [inventory_lot_line] for FG BOM items. + * Per (itemId, stockUomId) bucket. Does not write onHoldQty. */ fun refreshInventoryOnHandForFgBomItems(): Int { val sql = """ @@ -182,24 +182,28 @@ open class PSService( LEFT JOIN ( SELECT il.itemId, + iu.uomId AS stockUomId, SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS totalOnHandQty, - SUM(COALESCE(ill.holdQty, 0)) AS totalOnHoldQty, SUM(CASE - WHEN ill.status = 'unavailable' - THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) + WHEN LOWER(ill.status) = 'unavailable' + THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) ELSE 0 END) AS totalUnavailableQty FROM inventory_lot_line ill - INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 - WHERE ill.deleted = 0 - GROUP BY il.itemId + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY il.itemId, iu.uomId ) calc ON calc.itemId = i.itemId + AND ( + i.stockUomId = calc.stockUomId + OR (i.stockUomId IS NULL AND i.uomId = calc.stockUomId) + ) SET i.onHandQty = COALESCE(calc.totalOnHandQty, 0), - i.onHoldQty = COALESCE(calc.totalOnHoldQty, 0), i.unavailableQty = COALESCE(calc.totalUnavailableQty, 0), i.status = IF( - COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalOnHoldQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0, + COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0, 'available', 'unavailable' ), diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleLineRepository.kt index 2c2f9326..43919b43 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleLineRepository.kt @@ -27,14 +27,25 @@ interface ProductionScheduleLineRepository : AbstractRepository= DATE_ADD(:fromDate, INTERVAL 1 DAY) AND DATE(ps.produceAt) < DATE_ADD(:fromDate, INTERVAL 8 DAY) AND ps.id = ( @@ -1968,5 +2005,16 @@ open class ProductionScheduleService( // Optional: log the action (if you have logging setup) // logger.info("Cleared all production schedules with produceAt >= today") } - + + private fun availableQtyWithoutHold( + inventories: List?, + stockUomId: Long?, + zero: BigDecimal, + ): BigDecimal { + if (inventories.isNullOrEmpty() || stockUomId == null) return zero + val bucket = inventories.firstOrNull { inv -> + inv.stockUom?.id == stockUomId || (inv.stockUom == null && inv.uom?.id == stockUomId) + } ?: return zero + return (bucket.onHandQty ?: zero) - (bucket.unavailableQty ?: zero) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index 5fd8fcbe..73218938 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -2313,71 +2313,6 @@ private fun updateLotLineAfterIssue(lotLineId: Long, qty: BigDecimal, isMissItem lotLine.modifiedBy = "system" // 修复:使用 saveAndFlush 确保立即提交到数据库,触发触发器 inventoryLotLineRepository.saveAndFlush(lotLine) - updateInventoryAfterLotLineChange(lotLine) - } -} -private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) { - try { - val item = lotLine.inventoryLot?.item ?: return - val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return - - // 使用 SQL 查询计算所有相关 lot lines 的总和 - val sql = """ - SELECT - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) as totalOnHandQty, - SUM(COALESCE(ill.holdQty, 0)) as totalOnHoldQty, - SUM(CASE - WHEN ill.status = 'unavailable' - THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) - ELSE 0 - END) as totalUnavailableQty - FROM inventory_lot_line ill - INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId - WHERE il.itemId = :itemId - AND ill.deleted = 0 - """.trimIndent() - - val result = jdbcDao.queryForMap(sql, mapOf("itemId" to item.id!!)).orElse(null) - - if (result != null) { - val totalOnHandQty = (result["totalOnHandQty"] as? Number)?.let { - BigDecimal(it.toString()) - } ?: BigDecimal.ZERO - - val totalOnHoldQty = (result["totalOnHoldQty"] as? Number)?.let { - BigDecimal(it.toString()) - } ?: BigDecimal.ZERO - - val totalUnavailableQty = (result["totalUnavailableQty"] as? Number)?.let { - BigDecimal(it.toString()) - } ?: BigDecimal.ZERO - - // 更新 inventory - inventory.onHandQty = totalOnHandQty - inventory.onHoldQty = totalOnHoldQty - inventory.unavailableQty = totalUnavailableQty - inventory.status = if (totalOnHandQty.subtract(totalOnHoldQty).subtract(totalUnavailableQty) > BigDecimal.ZERO) { - "available" - } else { - "unavailable" - } - inventory.modified = LocalDateTime.now() - inventory.modifiedBy = "system" - - inventoryRepository.saveAndFlush(inventory) - - println("=== MANUALLY UPDATED INVENTORY ===") - println("Item ID: ${item.id}") - println("Total OnHandQty: ${totalOnHandQty}") - println("Total OnHoldQty: ${totalOnHoldQty}") - println("Total UnavailableQty: ${totalUnavailableQty}") - println("Status: ${inventory.status}") - println("==================================") - } - - } catch (e: Exception) { - println("Error updating inventory manually: ${e.message}") - e.printStackTrace() } } /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ @@ -2768,7 +2703,6 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse lotLine.modified = LocalDateTime.now() lotLine.modifiedBy = "system" inventoryLotLineRepository.saveAndFlush(lotLine) - updateInventoryAfterLotLineChange(lotLine) println("✅ Reset lot ${request.lotId}: issueQty=0, status=AVAILABLE") } val rejectedLines = stockOutLineRepository diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt index 18560561..a6cf73f2 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt @@ -31,6 +31,7 @@ import com.ffii.fpsms.modules.stock.entity.StockOutRepository import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo import com.ffii.fpsms.modules.stock.service.InventoryLotLineService +import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo import com.ffii.fpsms.modules.stock.service.InventoryService import com.ffii.fpsms.modules.stock.service.StockOutLineService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotService @@ -215,28 +216,17 @@ open class PickOrderService( val today = LocalDate.now() val zero = BigDecimal.ZERO - val inventories = if (itemIds.isNotEmpty()) { - inventoryLotLineService - .allInventoryLotLinesByItemIdIn(itemIds) - .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } - .filter { (it.inQty ?: zero).minus(it.outQty ?: zero).minus(it.holdQty ?: zero) > zero } - .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) } - .groupBy { it.item?.id } - } else { - emptyMap() - } + val inventories = pickableLotLinesByItemAndStockUom(itemIds, today, zero) // Build response (removed suggestions) val pickOrderInfos = fullPickOrders.map { po -> val pickOrderLineInfos = po.pickOrderLines.map { pol -> - val inventory = pol.item?.id?.let { inventories[it] } - GetPickOrderLineInfo( id = pol.id, itemId = pol.item?.id, itemCode = pol.item?.code, itemName = pol.item?.name, - availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) }, + availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero), requiredQty = pol.qty, uomCode = pol.uom?.code, uomDesc = pol.uom?.udfudesc, @@ -660,7 +650,7 @@ open class PickOrderService( // Get Inventory Data val requiredItems = pos .flatMap { it.pickOrderLines } - .groupBy { it.item?.id } + .groupBy { it.item?.id to it.uom?.id } .map { (key, value) -> key to object : CurrentInventoryItemInfo { override val id: Long? = value[0].item?.id @@ -671,42 +661,25 @@ open class PickOrderService( override val requiredQty: BigDecimal = value.sumOf { it.qty ?: zero } } - } // itemId - requiredQty + } // itemId+uom - requiredQty - val itemIds = requiredItems.mapNotNull { it.first } -// val inventories = inventoryLotLineRepository.findCurrentInventoryByItems(itemIds) -// val inventories = inventoryService.allInventoriesByItemIds(itemIds) - val inventories = inventoryLotLineService - .allInventoryLotLinesByItemIdIn(itemIds) - .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } - .filter { (it.inQty ?: zero).minus(it.outQty ?: zero).minus(it.holdQty ?: zero) > zero } - .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) } - .sortedBy { it.expiryDate } - .groupBy { it.item?.id } + val itemIds = requiredItems.mapNotNull { it.first.first } + val inventories = pickableLotLinesByItemAndStockUom(itemIds, today, zero) // Pick Orders val releasePickOrderInfos = pos .map { po -> val releasePickOrderLineInfos = po.pickOrderLines.map { pol -> -// if (pol.item?.id != null && pol.item!!.id!! > 0) { - val inventory = pol.item?.id?.let { inventories[it] } - val itemUom = pol.item?.id?.let { itemUomService.findSalesUnitByItemId(it) } -// val inventory = inventories.find { it.itemId == pol.item?.id } - - // Return ReleasePickOrderLineInfo( id = pol.id, itemId = pol.item?.id, itemCode = pol.item?.code, itemName = pol.item?.name, -// availableQty = inventory?.availableQty, - availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) }, -// availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) * (itemUom?.ratioN ?: one) * (itemUom?.ratioD ?: one) }, + availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero), requiredQty = pol.qty, uomCode = pol.uom?.code, uomDesc = pol.uom?.udfudesc, ) -// } } // Return @@ -725,16 +698,12 @@ open class PickOrderService( // val inventory = inventories // .find { it.itemId == item.first } - val inventory = item.first?.let { inventories[it] } - val itemUom = item.first?.let { itemUomService.findSalesUnitByItemId(it) } + val inventory = item.first.let { (itemId, uomId) -> + if (itemId != null && uomId != null) inventories[itemId to uomId] else null + } item.second.let { -// it.availableQty = inventory?.availableQty - - it.availableQty = inventory?.sumOf { i -> i.availableQty ?: zero } -// it.availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) * (itemUom?.ratioN ?: one) * (itemUom?.ratioD ?: one) } - - // return + it.availableQty = inventory?.sumOf { i -> (i.inQty ?: zero) - (i.outQty ?: zero) } it } } @@ -759,7 +728,7 @@ open class PickOrderService( // Get Inventory Data val requiredItems = pos .flatMap { it.pickOrderLines } - .groupBy { it.item?.id } + .groupBy { it.item?.id to it.uom?.id } .map { (key, value) -> key to object : CurrentInventoryItemInfo { override val id: Long? = value[0].item?.id @@ -769,16 +738,10 @@ open class PickOrderService( override var availableQty: BigDecimal? = zero override val requiredQty: BigDecimal = value.sumOf { it.qty ?: zero } } - } // itemId - requiredQty + } - val itemIds = requiredItems.mapNotNull { it.first } - val inventories = inventoryLotLineService - .allInventoryLotLinesByItemIdIn(itemIds) - .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } - .filter { (it.inQty ?: zero).minus(it.outQty ?: zero).minus(it.holdQty ?: zero) > zero } - .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) } - .sortedBy { it.expiryDate } - .groupBy { it.item?.id } + val itemIds = requiredItems.mapNotNull { it.first.first } + val inventories = pickableLotLinesByItemAndStockUom(itemIds, today, zero) val suggestions = suggestedPickLotService.suggestionForPickOrders(SuggestedPickLotForPoRequest(pickOrders = pos)) @@ -793,24 +756,10 @@ open class PickOrderService( emptyMap() } - // Pre-calculate available quantities for each item ONCE - val itemAvailableQtyMap = mutableMapOf() - itemIds.forEach { itemId -> - val inventory = inventories[itemId] - val totalAvailableQty = inventory?.sumOf { i -> - val inQty = i.inQty ?: zero - val outQty = i.outQty ?: zero - val holdQty = i.holdQty ?: zero - inQty.minus(outQty).minus(holdQty) - } ?: zero - itemAvailableQtyMap[itemId] = totalAvailableQty - } - val releasePickOrderLineInfos = pos .map { po -> val releasePickOrderLineInfos = po.pickOrderLines.map { pol -> - val itemId = pol.item?.id - val availableQty = itemId?.let { itemAvailableQtyMap[it] } ?: zero + val availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero) // 取得這一行的所有 stock_out_line val stockOutLines = stockOutLinesByPickOrderLineId[pol.id] ?: emptyList() @@ -871,16 +820,11 @@ open class PickOrderService( } // Items val currentInventoryInfos = requiredItems.map { item -> - val inventory = item.first?.let { inventories[it] } - val itemUom = item.first?.let { itemUomService.findSalesUnitByItemId(it) } - + val inventory = item.first.let { (itemId, uomId) -> + if (itemId != null && uomId != null) inventories[itemId to uomId] else null + } item.second.let { - val convertedAvailableQty = inventory?.sumOf { i -> - val baseQty = (i.availableQty ?: zero) - baseQty - } - - it.availableQty = convertedAvailableQty + it.availableQty = inventory?.sumOf { i -> (i.inQty ?: zero) - (i.outQty ?: zero) } it } } @@ -2344,19 +2288,16 @@ open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageRespons -- Calculate available quantity from inventory COALESCE(( - SELECT SUM( - COALESCE(inv.onHandQty, 0) - - COALESCE(inv.onHoldQty, 0) - - COALESCE(inv.unavailableQty, 0) - ) + SELECT COALESCE(inv.onHandQty, 0) - COALESCE(inv.unavailableQty, 0) FROM fpsmsdb.inventory inv - JOIN fpsmsdb.item_uom iu - ON iu.itemId = inv.itemId - AND iu.uomId = inv.uomId - AND iu.baseUnit = 1 - AND iu.deleted = false WHERE inv.itemId = i.id AND inv.deleted = false + AND ( + inv.stockUomId = pol.uomId + OR (inv.stockUomId IS NULL AND inv.uomId = pol.uomId) + ) + ORDER BY CASE WHEN inv.stockUomId IS NULL THEN 1 ELSE 0 END, inv.id + LIMIT 1 ), 0) as availableQty, -- Check if all stock out lines for this pick order line are completed @@ -5201,5 +5142,36 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map, + today: LocalDate, + zero: BigDecimal, + ): Map, List> { + if (itemIds.isEmpty()) return emptyMap() + return inventoryLotLineService + .allInventoryLotLinesByItemIdIn(itemIds) + .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } + .filter { ((it.inQty ?: zero) - (it.outQty ?: zero)) > zero } + .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) } + .mapNotNull { line -> + val itemId = line.item?.id ?: return@mapNotNull null + val stockUomId = line.uomId ?: return@mapNotNull null + (itemId to stockUomId) to line + } + .groupBy({ it.first }, { it.second }) + } + + private fun pickLineAvailableQty( + itemId: Long?, + polUomId: Long?, + lotsByBucket: Map, List>, + zero: BigDecimal, + ): BigDecimal { + if (itemId == null || polUomId == null) return zero + return lotsByBucket[itemId to polUomId] + ?.sumOf { (it.inQty ?: zero) - (it.outQty ?: zero) } + ?: zero + } + } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 78494b98..f4903d88 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -35,7 +35,6 @@ import java.util.Optional import kotlin.jvm.optionals.getOrNull import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineQuantitiesRequest -import com.ffii.fpsms.modules.stock.entity.InventoryRepository import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo @@ -58,7 +57,6 @@ open class InventoryLotLineService( private val warehouseRepository: WarehouseRepository, private val itemUomRespository: ItemUomRespository, private val stockInLineRepository: StockInLineRepository, - private val inventoryRepository: InventoryRepository, private val printerService: PrinterService, @Lazy private val jobOrderService: JobOrderService @@ -150,11 +148,15 @@ open class InventoryLotLineService( } } open fun saveInventoryLotLine(request: SaveInventoryLotLineRequest): InventoryLotLine { - val inventoryLotLine = - request.id?.let { inventoryLotLineRepository.findById(it).getOrNull() } ?: InventoryLotLine() + val existing = request.id?.let { inventoryLotLineRepository.findById(it).getOrNull() } + val inventoryLotLine = existing ?: InventoryLotLine() val inventoryLot = request.inventoryLotId?.let { inventoryLotRepository.findById(it).getOrNull() } val warehouse = request.warehouseId?.let { warehouseRepository.findById(it).getOrNull() } val stockUom = request.stockUomId?.let { itemUomRespository.findById(it).getOrNull() } + ?: existing?.stockUom + if (stockUom == null) { + throw IllegalArgumentException("stockItemUomId is required for inventory_lot_line") + } val status = request.status?.let { _status -> InventoryLotLineStatus.entries.find { it.value == _status } } inventoryLotLine.apply { @@ -191,9 +193,6 @@ open class InventoryLotLineService( val updatedLotLine = saveInventoryLotLine(updateRequest) - // ADD THIS: Update inventory table after lot line status change - updateInventoryTable(updatedLotLine) - return MessageResponse( id = updatedLotLine.id, name = updatedLotLine.id.toString(), @@ -204,45 +203,6 @@ open class InventoryLotLineService( ) } - // ADD THIS: New method to update inventory table - private fun updateInventoryTable(inventoryLotLine: InventoryLotLine) { - try { - // Get the item ID from the inventory lot - val itemId = inventoryLotLine.inventoryLot?.item?.id - if (itemId == null) { - println("Cannot update inventory table: itemId is null for lot line ${inventoryLotLine.id}") - return - } - - // Calculate onHoldQty (sum of holdQty from available lots only) - val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) - .sumOf { it.holdQty ?: BigDecimal.ZERO } - - // Calculate unavailableQty (sum of inQty from unavailable lots only) - val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE) - .sumOf { - val inQty = it.inQty ?: BigDecimal.ZERO - val outQty = it.outQty ?: BigDecimal.ZERO - val remainingQty = inQty.minus(outQty) - remainingQty - } - - // Update the inventory table - val inventory = inventoryRepository.findByItemId(itemId).orElse(null) - if (inventory != null) { - inventory.onHoldQty = onHoldQty - inventory.unavailableQty = unavailableQty - inventoryRepository.save(inventory) - - println("Updated inventory for item $itemId: onHoldQty=$onHoldQty, unavailableQty=$unavailableQty") - } else { - println("Inventory not found for item $itemId") - } - } catch (e: Exception) { - println("Error updating inventory table for lot line ${inventoryLotLine.id}: ${e.message}") - e.printStackTrace() - } - } @Throws(IOException::class) @Transactional open fun exportStockInLineQrcode(request: LotLineToQrcode): Map { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index ae0e4ccd..411380bd 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -338,6 +338,9 @@ open class InventoryService( val warehouse = warehouseRepository.findAll().find { it.code == request.warehouseCode }!! // val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!) val stockUnit = itemUomService.findStockUnitByItemId(itemId = inventoryLot.item!!.id!!) + ?: throw IllegalArgumentException( + "stockItemUomId is required: no stockUnit item_uom for itemId=${inventoryLot.item!!.id}", + ) // ChangeList #8: lot-line stockItemUomId must be set so trigger writes inventory.stockUomId. InventoryLotLine().apply { this.inventoryLot = inventoryLot diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index ebae2331..b143d786 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -538,6 +538,9 @@ open class StockInLineService( this.inQty = convertedBaseQty this.status = InventoryLotLineStatus.AVAILABLE this.stockUom = stockItemUom + ?: throw IllegalArgumentException( + "stockItemUomId is required: no stockUnit item_uom for itemId=${request.itemId}", + ) } saveLines.add(inventoryLotLine) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 759abbb5..73237b50 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -43,7 +43,6 @@ import com.ffii.fpsms.modules.pickOrder.service.PickOrderService import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.common.SecurityUtils import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository -import com.ffii.fpsms.modules.stock.entity.InventoryRepository import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository import java.time.LocalTime import com.ffii.fpsms.modules.stock.entity.StockInLineRepository @@ -78,7 +77,6 @@ private val inventoryLotLineService: InventoryLotLineService, private val bagService: BagService, private val pickOrderService: PickOrderService, private val stockLedgerRepository: StockLedgerRepository, - private val inventoryRepository: InventoryRepository, private val pickExecutionIssueRepository: PickExecutionIssueRepository, private val itemUomService: ItemUomService, private val inventoryBucketResolver: InventoryBucketResolver, @@ -266,13 +264,6 @@ fun handleQc(stockOutLine: StockOutLine, request: UpdateStockOutLineRequest): Li this.holdQty = this.holdQty!!.minus((request.qty.div(ratio)).toBigDecimal()) } inventoryLotLineRepository.save(targetLotLineEntry) - // update inventory - val inventory = inventoryRepository.findByItemId(request.itemId).orElseThrow() - val inventoryEntry = inventory.apply { - this.onHandQty = this.onHandQty!!.minus((request.qty.div(ratio)).toBigDecimal()) - this.onHoldQty = this.onHoldQty!!.minus((request.qty.div(ratio)).toBigDecimal()) - } - inventoryRepository.save(inventoryEntry) return listOf(stockOutLine, newStockOutLine) } @Transactional @@ -817,10 +808,6 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { val savedLotLine = inventoryLotLineRepository.save(inventoryLotLine) println("Saved lot line: ${savedLotLine.id}, status: ${savedLotLine.status}, holdQty: ${savedLotLine.holdQty}") - // Step 3: Update inventory table - println("Updating inventory table...") - updateInventoryTableAfterLotRejection(inventoryLotLine) - // Step 4: Trigger resuggest val pickOrderLine = stockOutLine.pickOrderLine if (pickOrderLine?.pickOrder?.id != null) { @@ -839,51 +826,6 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { } } -// ADD THIS: Update inventory table after lot rejection -private fun updateInventoryTableAfterLotRejection(inventoryLotLine: InventoryLotLine) { - try { - println("=== UPDATING INVENTORY TABLE ===") - val itemId = inventoryLotLine.inventoryLot?.item?.id - println("Item ID: $itemId") - - if (itemId != null) { - // Calculate onHoldQty (sum of holdQty from available lots only) - val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) - .sumOf { it.holdQty ?: BigDecimal.ZERO } - - // Calculate unavailableQty (sum of inQty from unavailable lots only) - val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId,InventoryLotLineStatus.UNAVAILABLE) - .sumOf { - val inQty = it.inQty ?: BigDecimal.ZERO - val outQty = it.outQty ?: BigDecimal.ZERO - val remainingQty = inQty.minus(outQty) - remainingQty - } - - println("Calculated onHoldQty: $onHoldQty") - println("Calculated unavailableQty: $unavailableQty") - - // Update the inventory table - val inventory = inventoryRepository.findByItemId(itemId).orElse(null) - if (inventory != null) { - println("Found inventory record: ${inventory.id}") - println("Current inventory - onHoldQty: ${inventory.onHoldQty}, unavailableQty: ${inventory.unavailableQty}") - - inventory.onHoldQty = onHoldQty - inventory.unavailableQty = unavailableQty - val savedInventory = inventoryRepository.save(inventory) - - println("Updated inventory - onHoldQty: ${savedInventory.onHoldQty}, unavailableQty: ${savedInventory.unavailableQty}") - } else { - println("No inventory record found for item $itemId") - } - } - } catch (e: Exception) { - println("Error updating inventory table after lot rejection: ${e.message}") - e.printStackTrace() - } -} - @Transactional(rollbackFor = [Exception::class]) open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { val startTime = System.currentTimeMillis() @@ -955,14 +897,6 @@ open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { this.holdQty = (this.holdQty ?: zero) - baseQty } inventoryLotLineRepository.save(lotLine) - - // 更新 inventory - val inv = inventoryRepository.findByItemId(lotLine.inventoryLot?.item?.id!!).orElseThrow() - inv.apply { - this.onHandQty = (this.onHandQty ?: zero) - baseQty - this.onHoldQty = (this.onHoldQty ?: zero) - baseQty - } - inventoryRepository.save(inv) } processedIds += line.stockOutLineId @@ -2156,7 +2090,7 @@ fun applyStockOutLineDelta( val postingType = (typeOverride ?: savedSol.type ?: "").trim().lowercase() val isIssuePosting = postingType == "miss" || postingType == "bad" || postingType == "expiry" - // 2) inventory_lot_line + inventory + // 2) inventory_lot_line; inventory onHand/unavailable is owned by the lot-line trigger if (!skipInventoryWrite) { val lotLine = savedSol.inventoryLotLine if (lotLine != null) { @@ -2196,19 +2130,6 @@ fun applyStockOutLineDelta( } } } - - val itemId = savedSol.item?.id - if (itemId != null) { - val inv = inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine) - if (inv != null) { - val zero = BigDecimal.ZERO - inv.onHandQty = (inv.onHandQty ?: zero).minus(deltaQty) - if (!isIssuePosting) { - inv.onHoldQty = (inv.onHoldQty ?: zero).minus(deltaQty) - } - inventoryRepository.save(inv) - } - } } // 3) stock_ledger (same ledger shape as createStockLedgerForStockOut / completeStockOutAfterLotLineSave) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 33327100..86e8570f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -65,7 +65,8 @@ open class StockTakeRecordService( val stockLedgerRepository: StockLedgerRepository, val inventoryRepository: InventoryRepository, val uomConversionRepository: UomConversionRepository, - val itemsRepository: ItemsRepository + val itemsRepository: ItemsRepository, + val inventoryBucketResolver: InventoryBucketResolver, ) { private val logger: Logger = LoggerFactory.getLogger(StockTakeRecordService::class.java) @@ -2125,7 +2126,7 @@ open fun batchSaveApproverStockTakeRecordsByIds( "batchSaveApproverStockTakeRecordsByIds cache build completed: lotLinePairs={}, lots={}, inventories={}, stockTakeLines={}, cacheBuildMs={}", adjustmentCache.inventoryLotLineByWarehouseLot.size, adjustmentCache.inventoryLotById.size, - adjustmentCache.inventoryByItemId.size, + adjustmentCache.inventoryByItemAndStockUom.size, adjustmentCache.stockTakeLineByRecordId.size, elapsedMs(cacheBuildStartNs) ) @@ -2213,7 +2214,7 @@ open fun batchSaveApproverStockTakeRecordsByIds( val runtimeCache = BatchAdjustmentRuntimeCache( stockOutByStockTakeId = mutableMapOf(), stockInByStockTakeId = mutableMapOf(), - runningLedgerBalanceByItemId = mutableMapOf() + runningLedgerBalanceByBucket = mutableMapOf() ) val adjustmentContext = StockTakeAdjustmentBatchContext() var varianceCount = 0 @@ -2278,7 +2279,7 @@ open fun batchSaveApproverStockTakeRecordsByIds( "batchSaveApproverStockTakeRecordsByIds runtime cache stats: stockOutHeads={}, stockInHeads={}, ledgerItems={}, batchedStockTakeLines={}, batchedOutLines={}, batchedInLines={}, batchedInventoryLotLines={}, batchedLedgers={}", runtimeCache.stockOutByStockTakeId.size, runtimeCache.stockInByStockTakeId.size, - runtimeCache.runningLedgerBalanceByItemId.size, + runtimeCache.runningLedgerBalanceByBucket.size, adjustmentContext.stockTakeLineByRecordId.size, adjustmentContext.stockOutLines.size, adjustmentContext.stockInLines.size, @@ -2398,9 +2399,7 @@ private fun applyVarianceAdjustment( ?: throw IllegalArgumentException("Inventory lot not found") val itemId = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found") - val inventory = cache?.inventoryByItemId?.get(itemId) - ?: inventoryRepository.findByItemId(itemId).orElse(null) - ?: throw IllegalArgumentException("Inventory not found for item") + val inventory = resolveInventoryForStockTake(itemId, inventoryLotLine, cache) // 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建 val stockTakeLine = stockTakeRecord.id?.let { rid -> @@ -2481,11 +2480,9 @@ private fun applyVarianceAdjustment( // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 val itemIdForLedger = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found for stock take ledger") - val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger) - ?: run { - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) - latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() - } + val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory) + val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey) + ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val newBalance = previousBalance - qtyToRemove.toDouble() // FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 @@ -2519,7 +2516,7 @@ private fun applyVarianceAdjustment( } else { stockLedgerRepository.save(stockLedger) } - runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance) + runtimeCache?.runningLedgerBalanceByBucket?.put(ledgerKey, newBalance) val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) latestLine.outQty = newOutQty @@ -2619,11 +2616,9 @@ private fun applyVarianceAdjustment( val itemIdForLedger = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") - val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger) - ?: run { - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) - latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() - } + val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory) + val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey) + ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() val newBalance = previousBalance + plusQty.toDouble() // latestLine.inQty already includes plusQty above — snapshot live lot line after move. @@ -2656,21 +2651,46 @@ private fun applyVarianceAdjustment( } else { stockLedgerRepository.save(stockLedger) } - runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance) + runtimeCache?.runningLedgerBalanceByBucket?.put(ledgerKey, newBalance) } } +private fun resolveInventoryForStockTake( + itemId: Long, + lotLine: InventoryLotLine, + cache: BatchAdjustmentCache?, +): com.ffii.fpsms.modules.stock.entity.Inventory { + val stockUomId = inventoryBucketResolver.resolveStockUomId(lotLine) + if (stockUomId != null) { + cache?.inventoryByItemAndStockUom?.get(itemId to stockUomId)?.let { return it } + inventoryBucketResolver.findInventoryBucket(itemId, stockUomId)?.let { return it } + } + inventoryBucketResolver.findInventoryBucket(itemId, lotLine)?.let { return it } + throw IllegalArgumentException("Inventory not found for item $itemId stock UOM $stockUomId") +} + +private fun stockTakeLedgerBucketKey( + itemId: Long, + lotLine: InventoryLotLine, + inventory: com.ffii.fpsms.modules.stock.entity.Inventory, +): Pair { + val stockUomId = inventoryBucketResolver.resolveStockUomId(lotLine) + ?: inventory.stockUom?.id + ?: 0L + return itemId to stockUomId +} + private data class BatchAdjustmentCache( val inventoryLotLineByWarehouseLot: Map, InventoryLotLine>, val inventoryLotById: Map, - val inventoryByItemId: Map, + val inventoryByItemAndStockUom: Map, com.ffii.fpsms.modules.stock.entity.Inventory>, val stockTakeLineByRecordId: Map ) private data class BatchAdjustmentRuntimeCache( val stockOutByStockTakeId: MutableMap, val stockInByStockTakeId: MutableMap, - val runningLedgerBalanceByItemId: MutableMap + val runningLedgerBalanceByBucket: MutableMap, Double> ) private data class StockTakeAdjustmentBatchContext( @@ -2725,10 +2745,15 @@ private fun buildBatchAdjustmentCache(records: List): BatchAdju emptyMap() } val itemIds = inventoryLotById.values.mapNotNull { it.item?.id }.distinct() - val inventoryByItemId = + val inventoryByItemAndStockUom = if (itemIds.isNotEmpty()) { inventoryRepository.findAllByItemIdInAndDeletedIsFalse(itemIds) - .groupBy { it.item?.id ?: 0L } + .mapNotNull { inv -> + val itemId = inv.item?.id ?: return@mapNotNull null + val stockUomId = inv.stockUom?.id ?: inv.uom?.id ?: return@mapNotNull null + (itemId to stockUomId) to inv + } + .groupBy({ it.first }, { it.second }) .mapValues { (_, list) -> list.minByOrNull { it.id ?: Long.MAX_VALUE }!! } } else { emptyMap() @@ -2745,7 +2770,7 @@ private fun buildBatchAdjustmentCache(records: List): BatchAdju return BatchAdjustmentCache( inventoryLotLineByWarehouseLot = inventoryLotLineByWarehouseLot, inventoryLotById = inventoryLotById, - inventoryByItemId = inventoryByItemId, + inventoryByItemAndStockUom = inventoryByItemAndStockUom, stockTakeLineByRecordId = stockTakeLineByRecordId ) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt index 507b100a..2f0b535f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt @@ -28,8 +28,7 @@ import kotlin.jvm.optionals.getOrNull import org.springframework.transaction.annotation.Transactional import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository -import java.math.RoundingMode -import com.ffii.fpsms.modules.stock.entity.InventoryRepository +import java.math.RoundingMode import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus import com.ffii.fpsms.modules.stock.web.model.PickAnotherLotRequest import com.ffii.fpsms.modules.stock.service.InventoryLotLineService @@ -54,7 +53,6 @@ open class SuggestedPickLotService( val itemUomService: ItemUomService, val pickExecutionIssueRepository: PickExecutionIssueRepository, // 添加逗号 val pickOrderRepository: PickOrderRepository, - val inventoryRepository: InventoryRepository, val failInventoryLotLineRepository: FailInventoryLotLineRepository, val stockOutRepository: StockOutRepository, val itemRepository: ItemsRepository, @@ -1073,38 +1071,6 @@ val basePickOrders = (listOf(pickOrder) + allCompetingPickOrders).distinctBy { i } } - // ✅ 优化:批量更新 inventory table(收集所有 item IDs,只更新一次) - val allItemIdsToUpdate = allPickOrdersToResuggest - .flatMap { it.pickOrderLines } - .mapNotNull { it.item?.id } - .distinct() - - if (allItemIdsToUpdate.isNotEmpty()) { - println("=== Batch updating inventory table for ${allItemIdsToUpdate.size} items ===") - allItemIdsToUpdate.forEach { itemId -> - // Calculate onHoldQty for ALL pick orders that use this item - // ✅ FIX: Use enum directly, not .value - val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) - .sumOf { it.holdQty ?: BigDecimal.ZERO } - - // ✅ FIX: Use enum directly, not .value - val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE) - .sumOf { - val inQty = it.inQty ?: BigDecimal.ZERO - val outQty = it.outQty ?: BigDecimal.ZERO - val remainingQty = inQty.minus(outQty) - remainingQty - } - - val inventory = inventoryRepository.findByItemId(itemId).orElse(null) - if (inventory != null) { - inventory.onHoldQty = onHoldQty - inventory.unavailableQty = unavailableQty - inventoryRepository.save(inventory) - } - } - println("✅ Batch updated inventory table for ${allItemIdsToUpdate.size} items") - } println("=== RESUGGEST DEBUG END ===") return MessageResponse( @@ -1345,40 +1311,6 @@ private fun generateOptimalSuggestionsForAllPickOrders( return suggestions } -private fun updateInventoryTableAfterResuggest(pickOrder: PickOrder) { - try { - // Get all item IDs from the pick order - val itemIds = pickOrder.pickOrderLines.mapNotNull { it.item?.id }.distinct() - - itemIds.forEach { itemId -> - // FIX: Calculate onHoldQty for ALL pick orders that use this item, not just the current one - val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) - .sumOf { it.holdQty ?: BigDecimal.ZERO } - - // FIX: Use enum method instead of string method - val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE) - .sumOf { - val inQty = it.inQty ?: BigDecimal.ZERO - val outQty = it.outQty ?: BigDecimal.ZERO - val remainingQty = inQty.minus(outQty) - remainingQty - } - - // Update the inventory table - val inventory = inventoryRepository.findByItemId(itemId).orElse(null) - if (inventory != null) { - inventory.onHoldQty = onHoldQty - inventory.unavailableQty = unavailableQty - inventoryRepository.save(inventory) - - println("Updated inventory for item $itemId: onHoldQty=$onHoldQty, unavailableQty=$unavailableQty") - } - } - } catch (e: Exception) { - println("Error updating inventory table after resuggest: ${e.message}") - e.printStackTrace() - } -} private fun findAllSuggestionsForPickOrder(pickOrderId: Long): List { val pickOrderLines = pickOrderRepository.findById(pickOrderId) .orElseThrow() @@ -1802,27 +1734,9 @@ private fun handleMissingItem(inventoryLotLine: InventoryLotLine, failQty: BigDe // 处理损坏物品的情况 private fun handleBrokenItem(inventoryLotLine: InventoryLotLine, failQty: BigDecimal) { try { - // 1. 将库存批次行标记为不可用 inventoryLotLine.status = InventoryLotLineStatus.UNAVAILABLE - - // 2. 更新库存表的不可用数量 - val itemId = inventoryLotLine.inventoryLot?.item?.id - if (itemId != null) { - val inventory = inventoryRepository.findByItemId(itemId).orElse(null) - if (inventory != null) { - val currentUnavailableQty = inventory.unavailableQty ?: BigDecimal.ZERO - inventory.unavailableQty = currentUnavailableQty.plus(failQty) - inventoryRepository.save(inventory) - - println("Broken item handled: Updated unavailableQty by $failQty for item $itemId") - } - } - - // 3. 保存库存批次行的状态变更 inventoryLotLineRepository.save(inventoryLotLine) - println("Broken item handled: Marked lot line ${inventoryLotLine.id} as unavailable") - } catch (e: Exception) { println("Error handling broken item: ${e.message}") throw e From dbfbf8534a8a0a0809b6b5358b757682f5a55908 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 15 Sep 2026 12:31:08 +0800 Subject: [PATCH 06/52] fix stock leger balance part --- .../service/DoWorkbenchMainService.kt | 5 +- .../master/service/StockLedgerFixService.kt | 22 ++++--- .../service/PickExecutionIssueService.kt | 60 ++++++++++--------- .../stock/entity/StockLedgerRepository.kt | 1 + .../stock/service/StockInLineService.kt | 13 ++-- .../stock/service/StockOutLineService.kt | 20 ++----- .../service/StockOutLineWorkbenchService.kt | 5 +- .../stock/service/StockTakeRecordService.kt | 13 +++- 8 files changed, 78 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index dba9441e..97d870f8 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -2665,12 +2665,13 @@ return MessageResponse( } private fun resolveWorkbenchPreviousBalance( - itemId: Long, inventory: Inventory, onHandQtyBeforeUpdate: Double?, ): Double { if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) + val latestLedger = inventory.id?.let { + stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) + } return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt index d780d4d7..bea9364f 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt @@ -482,8 +482,7 @@ open class StockLedgerFixService( } open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail { - val today = LocalDate.now() - val args = mapOf("inventoryId" to inventoryId, "today" to today) + val args = mapOf("inventoryId" to inventoryId, "today" to inventoryScopeToExclusive()) val head = jdbcDao.queryForList( """ SELECT @@ -556,10 +555,14 @@ open class StockLedgerFixService( ) } + /** + * Rebuild 2.1–2.6 for one inventory bucket through calendar today + * (`date < tomorrow`). Calendar day-fix still excludes today unless Allow today. + */ open fun fixInventoryScope(inventoryId: Long): StockLedgerFixRunResponse { if (inventoryId <= 0) throw IllegalArgumentException("inventoryId is required") if (!inFlight.add(runLock)) throw IllegalStateException("fix already running") - val today = LocalDate.now() + val toExclusive = inventoryScopeToExclusive() val t0 = System.nanoTime() try { val inv = jdbcDao.queryForList( @@ -572,26 +575,26 @@ open class StockLedgerFixService( "inventoryId" to inventoryId, "itemId" to (inv["itemId"] ?: 0), "uomId" to (inv["uomId"] ?: 0), - "today" to today, + "today" to toExclusive, ) val filledLotLineId = timed("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } val filledUomId = timed("inv 2.2") { fillUomIdScope(args, byInventory = true) } val filledInventoryId = timed("inv 2.3") { fillInventoryIdScope(args, byInventory = true) } val filledLotQty = timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } val filledBalance = timed("inv 2.5") { fillBalanceHistory(args) } - val lotIds = lotIdsForInventory(inventoryId, today) - val range = dateRangeForFilter("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to today)) + val lotIds = lotIdsForInventory(inventoryId, toExclusive) + val range = dateRangeForFilter("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to toExclusive)) val dayRows = if (range != null) { timed("inv 2.6") { upsertStockLotDayRange(lotIds, range.first, range.second) } } else 0 - val still = stillMissScope("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to today)) + val still = stillMissScope("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to toExclusive)) log.info( "stock-ledger-fix inventory {} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", inventoryId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, filledBalance, dayRows, (System.nanoTime() - t0) / 1_000_000, ) return StockLedgerFixRunResponse( - date = range?.first?.toString() ?: today.toString(), + date = range?.first?.toString() ?: LocalDate.now().toString(), filledLotLineId = filledLotLineId, filledUomId = filledUomId, filledInventoryId = filledInventoryId, @@ -1730,6 +1733,9 @@ open class StockLedgerFixService( ) } + /** Exclusive bound for inventory-scope inspect/fix: include calendar today. */ + private fun inventoryScopeToExclusive(): LocalDate = LocalDate.now().plusDays(1) + private fun fillBalanceHistory(args: Map): Int { return jdbcDao.executeUpdate( """ diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index 73218938..a392e3a8 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -10,6 +10,7 @@ import com.ffii.fpsms.modules.pickOrder.entity.HandleStatus import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository import com.ffii.fpsms.modules.stock.entity.InventoryLotLine import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository +import com.ffii.fpsms.modules.stock.entity.Inventory import com.ffii.fpsms.modules.stock.entity.InventoryRepository import com.ffii.fpsms.modules.stock.service.StockOutLineService @@ -46,6 +47,7 @@ import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult import com.ffii.fpsms.modules.stock.entity.StockLedger import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot +import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository @@ -72,7 +74,8 @@ open class PickExecutionIssueService( private val stockLedgerRepository: StockLedgerRepository, private val deliveryOrderRepository: DeliveryOrderRepository, private val suggestedPickLotRepository: SuggestPickLotRepository, - private val doPickOrderRepository: DoPickOrderRepository + private val doPickOrderRepository: DoPickOrderRepository, + private val inventoryBucketResolver: InventoryBucketResolver, ) { // 配置属性:是否在批次拒绝时自动重新建议批次(默认为 false,不自动重新建议) @@ -858,13 +861,8 @@ private fun handleMissItemWithPartialPick(request: PickExecutionIssueRequest, ac println("Miss Qty: ${missQty}") val lotId = request.lotId ?: return - val itemId = request.itemId ?: return val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) - // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty - val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) - val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() - if (inventoryLotLine != null) { // ✅ 修改:更新 outQty 为 actualPickQty val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO @@ -927,9 +925,7 @@ private fun handleMissItemOnly(request: PickExecutionIssueRequest, missQty: BigD val itemId = request.itemId ?: return val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) - // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty - val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) - val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() + val onHandQtyBeforeUpdate = bucketOnHandQty(itemId, inventoryLotLine) if (inventoryLotLine != null) { // ✅ 修改:释放 holdQty(减少 holdQty) @@ -1128,10 +1124,9 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty val lotId = request.lotId ?: return val itemId = request.itemId ?: return + val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) - // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty - val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) - val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() + val onHandQtyBeforeUpdate = bucketOnHandQty(itemId, inventoryLotLine) // ✅ 修复:更新 stock_out_line,但不要累积 qty val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( @@ -1163,7 +1158,6 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty } // ✅ 修复:更新 inventory_lot_line 的 outQty - val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) if (inventoryLotLine != null) { // ✅ 修复:计算新的 outQty,考虑之前的 outQty val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO @@ -2324,23 +2318,19 @@ private fun createStockLedgerForStockOut( val item = stockOutLine.item ?: return val outQty = stockOutLine.qty?.toDouble() ?: 0.0 - - // ✅ 修复:如果传入了 balance,直接使用;否则重新查询 inventory + val inventory = resolveLedgerInventory(item.id!!, stockOutLine.inventoryLotLine) ?: return + val finalBalance = if (balance != null) { if (balance < 0) 0.0 else balance } else { - // 重新查询 inventory 以获取触发器更新后的最新 onHandQty - val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return - val currentOnHandQty = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() - val newBalance = currentOnHandQty - outQty + val previous = previousBalanceForInventory(inventory) + val newBalance = previous - outQty if (newBalance < 0) 0.0 else newBalance } // 使用传入的 ledgerType,如果没有则使用 stockOutLine.type val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" - val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return - val ill = stockOutLine.inventoryLotLine val outQtyBd = BigDecimal.valueOf(outQty) val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } @@ -2736,8 +2726,11 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse ) } - // ✅ 修复:在创建和保存 stockOutLine 之前获取 inventory 的当前 onHandQty - val inventoryBeforeUpdate = inventoryRepository.findByItemId(request.itemId).orElse(null) + val lotLine = request.lotId.let { + inventoryLotLineRepository.findById(it).orElse(null) + } + val inventoryBeforeUpdate = inventoryBucketResolver.findInventoryBucket(request.itemId, lotLine) + ?: inventoryRepository.findByItemId(request.itemId).orElse(null) val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() println("=== submitIssueWithQty: Before update ===") @@ -2746,9 +2739,6 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse println("OnHandQty Before Update: ${onHandQtyBeforeUpdate}") println("==========================================") - val lotLine = request.lotId.let { - inventoryLotLineRepository.findById(it).orElse(null) - } println("Lot line: id=${lotLine?.id}, lotNo=${lotLine?.inventoryLot?.lotNo}") val item = itemsRepository.findById(request.itemId).orElse(null) @@ -2835,7 +2825,7 @@ private fun createStockLedgerForStockOutWithBalance( // Use传入的 ledgerType,如果没有则使用 stockOutLine.type val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" - val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return + val inventory = resolveLedgerInventory(item.id!!, stockOutLine.inventoryLotLine) ?: return val ill = stockOutLine.inventoryLotLine val outQtyBd = BigDecimal.valueOf(outQty) @@ -2947,4 +2937,20 @@ private fun resolveLotUomInfo(lotId: Long?): Pair { return Pair(uomCode, uomDesc) } + +private fun bucketOnHandQty(itemId: Long, lotLine: InventoryLotLine?): Double { + val inventory = resolveLedgerInventory(itemId, lotLine) + return (inventory?.onHandQty ?: BigDecimal.ZERO).toDouble() +} + +private fun resolveLedgerInventory(itemId: Long, lotLine: InventoryLotLine?): Inventory? = + inventoryBucketResolver.findInventoryBucket(itemId, lotLine) + ?: inventoryRepository.findByItemId(itemId).orElse(null) + +private fun previousBalanceForInventory(inventory: Inventory): Double { + val latest = inventory.id?.let { + stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) + } + return latest?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() +} } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt index f5b3027c..9335de6a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt @@ -60,6 +60,7 @@ interface StockLedgerRepository: AbstractRepository { """) fun findLatestByItemId(@Param("itemId") itemId: Long): List fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLedger? +fun findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(inventoryId: Long): StockLedger? @Query(""" SELECT sl FROM StockLedger sl diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index b143d786..c46be317 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -1074,7 +1074,7 @@ open class StockInLineService( if (putAwayDeltaStockQty > BigDecimal.ZERO) { val _tLedger = System.nanoTime() createStockLedgerForStockIn(stockInLine, putAwayDeltaStockQty.toDouble()) - _logStep("createStockLedgerForStockIn(includes findLatestByItemId)", _tLedger) + _logStep("createStockLedgerForStockIn(includes findLatestByInventoryId)", _tLedger) } val _tQtyCalc = System.nanoTime() @@ -1396,13 +1396,14 @@ open class StockInLineService( val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockInLine.inventoryLotLine) ?: return _logStep("inventoryBucketResolver.findInventoryBucket", _tInv) - // ✅ 修复:查询最新的 stock_ledger 记录,基于前一笔 balance 计算 + // Chain running balance on this inventory bucket only (not the whole item). val _tLatest = System.nanoTime() - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(item.id!!) - _logStep("stockLedgerRepository.findLatestByItemId", _tLatest) + val latestLedger = inventory.id?.let { + stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) + } + _logStep("stockLedgerRepository.findLatestByInventoryId", _tLatest) - // ✅ 修复:如果 latestLedger 为 null(第一次 stock in),previousBalance 应该是 0 - // 因为 inventory.onHandQty 已经被触发器更新为包含本次 stock in 的数量 + // First ledger on this bucket starts at 0: inventory.onHandQty already includes this stock-in. val previousBalance = latestLedger?.balance ?: 0.0 val newBalance = previousBalance + inQty diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 73237b50..1901a760 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -1450,9 +1450,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) ?: return val outQty = stockOutLine.qty?.toDouble() ?: 0.0 - // Use latest ledger balance (same pattern as createStockLedgerForStockIn) so balance is correct when multiple actions run in one transaction - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(item.id!!) - val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + val previousBalance = resolvePreviousBalance(inventory = inventory) val newBalance = previousBalance - outQty val ill = stockOutLine.inventoryLotLine @@ -1726,7 +1724,6 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } val previousBalance = resolvePreviousBalance( - itemId = item.id!!, inventory = inventory, onHandQtyBeforeUpdate = onHandQtyBeforeUpdate ) @@ -1774,14 +1771,15 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { } private fun resolvePreviousBalance( - itemId: Long, inventory: Inventory, onHandQtyBeforeUpdate: Double? = null ): Double { if (onHandQtyBeforeUpdate != null) { return onHandQtyBeforeUpdate } - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) + val latestLedger = inventory.id?.let { + stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) + } return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() } @@ -2137,10 +2135,7 @@ fun applyStockOutLineDelta( val item = savedSol.item ?: return savedSol val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, savedSol.inventoryLotLine) ?: return savedSol - val previousBalance = resolvePreviousBalance( - itemId = item.id!!, - inventory = inventory - ) + val previousBalance = resolvePreviousBalance(inventory = inventory) val outQty = deltaQty.toDouble() val newBalance = previousBalance - outQty @@ -2287,10 +2282,7 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult ?: return@forEach val delta = BigDecimal.valueOf(sol.qty ?: 0.0) val prevBalance = runningLedgerBalanceByBucket[bucketKey] - ?: run { - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) - latestLedger?.balance ?: (inv.onHandQty ?: BigDecimal.ZERO).toDouble() - } + ?: resolvePreviousBalance(inventory = inv) val newBalance = prevBalance - delta.toDouble() val ill = sol.inventoryLotLine diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt index 6744f734..b2474f11 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt @@ -547,12 +547,13 @@ if (updated == 0) { } private fun resolveWorkbenchPreviousBalance( - itemId: Long, inventory: Inventory, onHandQtyBeforeUpdate: Double?, ): Double { if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) + val latestLedger = inventory.id?.let { + stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) + } return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 86e8570f..6c2cbb3b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -2482,7 +2482,7 @@ private fun applyVarianceAdjustment( ?: throw IllegalArgumentException("Item ID not found for stock take ledger") val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory) val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey) - ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + ?: previousBalanceForInventoryBucket(inventory) val newBalance = previousBalance - qtyToRemove.toDouble() // FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 @@ -2618,7 +2618,7 @@ private fun applyVarianceAdjustment( ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory) val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey) - ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + ?: previousBalanceForInventoryBucket(inventory) val newBalance = previousBalance + plusQty.toDouble() // latestLine.inQty already includes plusQty above — snapshot live lot line after move. @@ -2680,6 +2680,15 @@ private fun stockTakeLedgerBucketKey( return itemId to stockUomId } +private fun previousBalanceForInventoryBucket( + inventory: com.ffii.fpsms.modules.stock.entity.Inventory, +): Double { + val latest = inventory.id?.let { + stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) + } + return latest?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() +} + private data class BatchAdjustmentCache( val inventoryLotLineByWarehouseLot: Map, InventoryLotLine>, val inventoryLotById: Map, From 1122a860bd1488a30a52eedcffd806230bb6e089 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 15 Sep 2026 14:12:43 +0800 Subject: [PATCH 07/52] 1.0 fix --- .../master/service/StockLedgerFixService.kt | 62 +++++++++++++++++-- .../master/web/models/StockLedgerFixModels.kt | 1 + 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt index bea9364f..1051ec2d 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt @@ -118,8 +118,10 @@ open class StockLedgerFixService( /** * A: keep existing inventory ids; - * backfill stockUomId from lot stock UOM (and set uomId to base); + * backfill stockUomId from lot stock UOM (and set uomId to base) — one row per + * (itemId, stockUomId) so uk_inventory_item_stock_uom is not violated; * INSERT missing (itemId, stockUomId); + * soft-delete leftover NULL stockUomId rows on items that already have a bucket; * recalc onHand/unavailable from Σ lot qty by item+stock UOM (0 if no lots). * Does not write inventory.onHoldQty (hold stays on lot line). Does not UPDATE inventory_lot_line. */ @@ -156,6 +158,7 @@ open class StockLedgerFixService( val patchedSingleLot = jdbcDao.executeUpdate(BACKFILL_STOCK_UOM_SINGLE_LOT_SQL) val patchedStockUomId = patchedFromUom + patchedSingleLot val inserted = jdbcDao.executeUpdate(INSERT_MISSING_INVENTORY_SQL) + val orphansDeleted = jdbcDao.executeUpdate(SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL) val updated = jdbcDao.executeUpdate(UPDATE_INVENTORY_QTY_SQL) val missingAfter = toInt( jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), @@ -164,9 +167,10 @@ open class StockLedgerFixService( jdbcDao.queryForList(NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), ) log.info( - "stock-ledger-fix inventory-1.0 patchedStockUomId={} inserted={} updated={} missingAfter={} nullStockUomIdAfter={}", + "stock-ledger-fix inventory-1.0 patchedStockUomId={} inserted={} orphansDeleted={} updated={} missingAfter={} nullStockUomIdAfter={}", patchedStockUomId, inserted, + orphansDeleted, updated, missingAfter, nullStockUomIdAfter, @@ -177,6 +181,7 @@ open class StockLedgerFixService( missingUomPairsAfter = missingAfter, patchedStockUomId = patchedStockUomId, nullStockUomIdAfter = nullStockUomIdAfter, + orphansDeleted = orphansDeleted, ) } finally { inFlight.remove(inventoryLock) @@ -3463,6 +3468,8 @@ open class StockLedgerFixService( /** * Old row: uomId already equals a lot stock UOM (stock, or base when base=stock). * Fill stockUomId from that lot stock; move uomId to item base. + * One keeper per (itemId, uomId); skip if that stockUomId is already taken + * (including soft-deleted rows that still occupy the unique key). */ private const val BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL = """ UPDATE inventory i @@ -3477,10 +3484,21 @@ open class StockLedgerFixService( AND il.itemId IS NOT NULL AND iu.uomId IS NOT NULL ) b ON b.itemId = i.itemId AND b.stockUomId = i.uomId + INNER JOIN ( + SELECT itemId, uomId, MIN(id) AS keepId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + GROUP BY itemId, uomId + ) keep ON keep.keepId = i.id LEFT JOIN item_uom baseIu ON baseIu.itemId = i.itemId AND baseIu.baseUnit = 1 AND IFNULL(baseIu.deleted, 0) = 0 + LEFT JOIN inventory taken + ON taken.itemId = i.itemId + AND taken.stockUomId = b.stockUomId + AND taken.id <> i.id SET i.stockUomId = b.stockUomId, i.uomId = COALESCE(baseIu.uomId, i.uomId), @@ -3488,11 +3506,12 @@ open class StockLedgerFixService( i.modifiedBy = 'stock-ledger-fix' WHERE IFNULL(i.deleted, 0) = 0 AND i.stockUomId IS NULL + AND taken.id IS NULL """ /** * Remaining NULL stockUomId: item has exactly one lot stock UOM - * (uomId already base, stock may differ). Skip if that bucket row already exists. + * (uomId already base, stock may differ). Keep MIN(id) only. */ private const val BACKFILL_STOCK_UOM_SINGLE_LOT_SQL = """ UPDATE inventory i @@ -3509,6 +3528,13 @@ open class StockLedgerFixService( GROUP BY il.itemId HAVING COUNT(DISTINCT iu.uomId) = 1 ) b ON b.itemId = i.itemId + INNER JOIN ( + SELECT itemId, MIN(id) AS keepId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + GROUP BY itemId + ) keep ON keep.keepId = i.id LEFT JOIN item_uom baseIu ON baseIu.itemId = i.itemId AND baseIu.baseUnit = 1 @@ -3516,7 +3542,6 @@ open class StockLedgerFixService( LEFT JOIN inventory taken ON taken.itemId = i.itemId AND taken.stockUomId = b.stockUomId - AND IFNULL(taken.deleted, 0) = 0 AND taken.id <> i.id SET i.stockUomId = b.stockUomId, @@ -3565,8 +3590,14 @@ open class StockLedgerFixService( AND IFNULL(baseIu.deleted, 0) = 0 LEFT JOIN inventory existing ON existing.itemId = src.itemId - AND COALESCE(existing.stockUomId, existing.uomId) = src.uomId AND IFNULL(existing.deleted, 0) = 0 + AND ( + existing.stockUomId = src.uomId + OR (existing.stockUomId IS NULL AND existing.uomId = src.uomId) + ) + LEFT JOIN inventory occupied + ON occupied.itemId = src.itemId + AND occupied.stockUomId = src.uomId LEFT JOIN inventory tpl ON tpl.id = ( SELECT MIN(i.id) @@ -3575,6 +3606,27 @@ open class StockLedgerFixService( AND IFNULL(i.deleted, 0) = 0 ) WHERE existing.id IS NULL + AND occupied.id IS NULL + """ + + /** + * Extra old UOM rows left NULL after a keeper bucket exists for the item + * (e.g. 18918 id 5261). Soft-delete so search / COALESCE fallbacks ignore them. + */ + private const val SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL = """ + UPDATE inventory i + INNER JOIN ( + SELECT DISTINCT itemId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NOT NULL + ) keeper ON keeper.itemId = i.itemId + SET + i.deleted = 1, + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + AND i.stockUomId IS NULL """ private const val UPDATE_INVENTORY_QTY_SQL = """ diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt index 4086266a..aecdac08 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt @@ -69,6 +69,7 @@ data class StockLedgerFixInventoryResponse( val missingUomPairsAfter: Int, val patchedStockUomId: Int, val nullStockUomIdAfter: Int, + val orphansDeleted: Int = 0, ) data class StockLedgerFixSearchInventoryHit( From bffa3eb34cdaf702c384db45cf4b352ed0a4a0c3 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 15 Sep 2026 14:57:28 +0800 Subject: [PATCH 08/52] =?UTF-8?q?#24=20/=20#25=20/=20#26=EF=BC=89#27=20#22?= =?UTF-8?q?=20#4/#6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jobOrder/entity/JobOrderRepository.kt | 12 ++++++++++++ .../jobOrder/entity/projections/JobOrderInfo.kt | 2 ++ .../entity/ProductionScheduleRepository.kt | 8 +++++++- .../modules/master/service/ItemUomService.kt | 16 ++++------------ .../entity/projections/ProductProcessInfo.kt | 1 + .../service/ProductProcessService.kt | 1 + 6 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt index b13670bf..c98092dd 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt @@ -54,8 +54,11 @@ interface JobOrderRepository : AbstractRepository { json_arrayagg( json_object( 'id', jobm.id, + 'itemId', jobm.itemId, + 'uomId', jobm.uomId, 'code', i.code, 'name', i.name, + 'type', i.type, 'pickedLotNo', pln.pickedLotNo, 'reqQty', jobm.reqQty, 'uom', uc.udfudesc, @@ -93,8 +96,11 @@ interface JobOrderRepository : AbstractRepository { json_arrayagg( json_object( 'id', jobm.id, + 'itemId', jobm.itemId, + 'uomId', jobm.uomId, 'code', i.code, 'name', i.name, + 'type', i.type, 'lotNo', il.lotNo, 'reqQty', jobm.reqQty, 'uom', uc.udfudesc, @@ -272,8 +278,11 @@ interface JobOrderRepository : AbstractRepository { json_arrayagg( json_object( 'id', jobm.id, + 'itemId', jobm.itemId, + 'uomId', jobm.uomId, 'code', i.code, 'name', i.name, + 'type', i.type, 'lotNo', il.lotNo, 'reqQty', jobm.reqQty, 'uom', uc.udfudesc, @@ -312,8 +321,11 @@ interface JobOrderRepository : AbstractRepository { json_arrayagg( json_object( 'id', jobm.id, + 'itemId', jobm.itemId, + 'uomId', jobm.uomId, 'code', i.code, 'name', i.name, + 'type', i.type, 'lotNo', il.lotNo, 'reqQty', jobm.reqQty, 'uom', uc.udfudesc, diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt index 4e7a77cb..68ef322a 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt @@ -126,6 +126,8 @@ data class JobOrderDetail( data class JobOrderDetailPickLine( val id: Long?, + val itemId: Long?, + val uomId: Long?, val code: String?, val name: String?, val pickedLotNo: List?, diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleRepository.kt index bf2e2c7d..1ce9ee21 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleRepository.kt @@ -183,7 +183,7 @@ interface ProductionScheduleRepository : AbstractRepository Date: Wed, 16 Sep 2026 11:40:38 +0800 Subject: [PATCH 09/52] inventory search fix --- .../modules/master/service/ItemsService.kt | 5 + .../entity/InventoryLotLineRepository.kt | 5 +- .../entity/projection/InventoryInfoDto.kt | 28 ++ .../projection/InventoryStockUomSearchRow.kt | 3 +- .../InventoryStockUomSearchRowDto.kt | 28 ++ .../modules/stock/service/InventoryService.kt | 353 +++++++++++++++--- .../stock/service/StockAdjustmentService.kt | 5 +- .../stock/service/StockInLineService.kt | 22 +- .../modules/stock/web/InventoryController.kt | 6 +- .../stock/web/model/StockAdjustmentRequest.kt | 1 + .../modules/stock/web/model/StockInRequest.kt | 3 +- 11 files changed, 403 insertions(+), 56 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfoDto.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRowDto.kt diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt index 452bd0ae..ceac69d5 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt @@ -410,8 +410,13 @@ open class ItemsService( "i.AverageUnitPrice as averageUnitPrice, " + "i.LatestMarketUnitPrice as latestMarketUnitPrice, " + "DATE_FORMAT(i.latestMupUpdatedDate, '%Y-%m-%d %H:%i') as latestMupUpdatedDate, " + + "uc_s.id as uomId, " + + "uc_s.code as uom, " + + "uc_s.udfudesc as uomDesc, " + "uc_p.udfudesc as purchaseUnit " + "FROM items i " + + "LEFT JOIN item_uom iu_s ON iu_s.itemId = i.id AND iu_s.deleted = false AND iu_s.stockUnit = true " + + "LEFT JOIN uom_conversion uc_s ON uc_s.id = iu_s.uomId " + "LEFT JOIN item_uom iu_p ON iu_p.itemId = i.id AND iu_p.deleted = false AND iu_p.purchaseUnit = true " + "LEFT JOIN uom_conversion uc_p ON uc_p.id = iu_p.uomId " + "WHERE i.deleted = false" diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index c854c08e..75b60c7d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -156,7 +156,10 @@ WHERE ill.id = :id AND (:lotNo IS NULL OR :lotNo = '' OR il.lotNo = :lotNo) """, ) - /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 */ + /** + * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 + * Lot UOMs only. Location Search uses [com.ffii.fpsms.modules.stock.service.InventoryService.searchInventoriesByLatestInventory]. + */ fun searchInventoryGroupedByStockUom( @Param("code") code: String, @Param("name") name: String, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfoDto.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfoDto.kt new file mode 100644 index 00000000..781e0cc8 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfoDto.kt @@ -0,0 +1,28 @@ +package com.ffii.fpsms.modules.stock.entity.projection + +import java.math.BigDecimal +import java.time.LocalDateTime + +/** JDBC row for Item Search: inventory buckets plus current master stock UOM when missing. */ +data class InventoryInfoDto( + override val id: Long? = null, + override val itemId: Long? = null, + override val itemCode: String? = null, + override val itemName: String? = null, + override val itemType: String? = null, + override val onHandQty: BigDecimal? = null, + override val onHoldQty: BigDecimal? = null, + override val unavailableQty: BigDecimal? = null, + override val availableQty: BigDecimal? = null, + override val stockUomId: Long? = null, + override val uomCode: String? = null, + override val uomUdfudesc: String? = null, + override val uomShortDesc: String? = null, + override val qtyPerSmallestUnit: BigDecimal? = null, + override val baseUom: String? = null, + override val price: BigDecimal? = null, + override val currencyName: String? = null, + override val status: String? = null, + override val latestMarketUnitPrice: Double? = null, + override val latestMupUpdatedDate: LocalDateTime? = null, +) : InventoryInfo diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt index e48f4af7..93460031 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt @@ -4,7 +4,8 @@ import java.math.BigDecimal import java.time.LocalDateTime /** - * Inventory search page: one row per item + lot-line stock UoM. + * Inventory search page: one row per item + UoM + * (lot-line / bucket UOMs, plus current master stockUnit when it has no rows). */ interface InventoryStockUomSearchRow { val id: Long? diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRowDto.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRowDto.kt new file mode 100644 index 00000000..7218b545 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRowDto.kt @@ -0,0 +1,28 @@ +package com.ffii.fpsms.modules.stock.entity.projection + +import java.math.BigDecimal +import java.time.LocalDateTime + +/** JDBC row for inventory search: lot UOMs plus current master stock UOM when missing. */ +data class InventoryStockUomSearchRowDto( + override val id: Long? = null, + override val itemId: Long? = null, + override val itemCode: String? = null, + override val itemName: String? = null, + override val itemType: String? = null, + override val onHandQty: BigDecimal? = null, + override val onHoldQty: BigDecimal? = null, + override val unavailableQty: BigDecimal? = null, + override val availableQty: BigDecimal? = null, + override val uomId: Long? = null, + override val uomCode: String? = null, + override val uomUdfudesc: String? = null, + override val uomShortDesc: String? = null, + override val qtyPerSmallestUnit: BigDecimal? = null, + override val baseUom: String? = null, + override val price: BigDecimal? = null, + override val currencyName: String? = null, + override val status: String? = null, + override val latestMarketUnitPrice: Double? = null, + override val latestMupUpdatedDate: LocalDateTime? = null, +) : InventoryStockUomSearchRow diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index dd0fea93..71744df4 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -17,15 +17,17 @@ import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType import com.ffii.fpsms.modules.stock.entity.* import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo +import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfoDto import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow +import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRowDto import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest import org.apache.poi.ss.usermodel.Sheet import org.apache.poi.ss.usermodel.Workbook import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.core.io.support.PathMatchingResourcePatternResolver -import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import java.math.BigDecimal +import java.sql.Timestamp import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId @@ -58,67 +60,80 @@ open class InventoryService( return inventoryRepository.findInventoryInfoByDeletedIsFalse(); } - open fun allInventoriesByPage(request: SearchInventoryRequest): RecordsRes{ - val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10) + /** + * Item Search: one row per (itemId, stockUomId) bucket, plus current master stockUnit + * as a 0-qty row when that UoM has no inventory bucket. + */ + open fun allInventoriesByPage(request: SearchInventoryRequest): RecordsRes { + val pageSize = (request.pageSize ?: 10).coerceAtLeast(1) + val pageNum = (request.pageNum ?: 0).coerceAtLeast(0) + val code = request.code?.trim().orEmpty() + val name = request.name?.trim().orEmpty() + val type = request.type?.trim().orEmpty() val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } - - val response = if (lotNo != null) { - val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo) - if (itemIds.isEmpty()) { - return RecordsRes(emptyList(), 0) - } - inventoryRepository.findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( - code = request.code, - name = request.name, - type = request.type, - itemIds = itemIds, - pageable = pageable - ) + val itemIds = if (lotNo != null) { + inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo) } else { - inventoryRepository.findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( - code = request.code, - name = request.name, - type = request.type, - pageable = pageable - ) + emptyList() + } + if (lotNo != null && itemIds.isEmpty()) { + return RecordsRes(emptyList(), 0) } - val records = response.content - val total = response.totalElements - - return RecordsRes(records, total.toInt()); + val args = mutableMapOf( + "code" to code, + "name" to name, + "type" to type, + "hasItemIdFilter" to if (lotNo != null) 1 else 0, + "itemIds" to itemIds.ifEmpty { listOf(-1L) }, + ) + val unionSql = buildItemSearchUnionSql() + val total = jdbcDao.queryForInt("SELECT COUNT(*) FROM ($unionSql) t", args) + args["limit"] = pageSize + args["offset"] = pageNum * pageSize + val rows = jdbcDao.queryForList( + "$unionSql ORDER BY itemCode ASC, uomUdfudesc ASC LIMIT :limit OFFSET :offset", + args, + ) + return RecordsRes(rows.map { mapItemSearchRow(it) }, total) } /** - * Location Search: one row per item + lot-line stock UoM, optionally filtered by warehouse. - * Item Search uses [allInventoriesByPage] (inventory buckets, no location). + * Location Search: one row per item + lot-line UoM, plus current master stockUnit + * as a 0-qty row when that UoM has no matching lots. */ open fun searchInventoriesByLatestInventory( request: SearchInventoryRequest, ): RecordsRes { - val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10) + val pageSize = (request.pageSize ?: 10).coerceAtLeast(1) + val pageNum = (request.pageNum ?: 0).coerceAtLeast(0) val code = request.code?.trim().orEmpty() val name = request.name?.trim().orEmpty() val type = request.type?.trim().orEmpty() - val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } + val lotNo = request.lotNo?.trim().orEmpty() val warehouseIds = resolveWarehouseIds(request.storeId, request.warehouse, request.area) if (warehouseIds != null && warehouseIds.isEmpty()) { return RecordsRes(emptyList(), 0) } - val hasWarehouseFilter = warehouseIds != null - val response = inventoryLotLineRepository.searchInventoryGroupedByStockUom( - code = code, - name = name, - type = type, - status = InventoryLotLineStatus.AVAILABLE, - hasWarehouseFilter = hasWarehouseFilter, - warehouseIds = warehouseIds ?: listOf(-1L), - lotNo = lotNo, - pageable = pageable, + val args = mutableMapOf( + "code" to code, + "name" to name, + "type" to type, + "lotNo" to lotNo, + "status" to InventoryLotLineStatus.AVAILABLE.value, + "hasWarehouseFilter" to if (warehouseIds != null) 1 else 0, + "warehouseIds" to (warehouseIds ?: listOf(-1L)), ) - - return RecordsRes(response.content, response.totalElements.toInt()) + val unionSql = buildLocationSearchUnionSql() + val total = jdbcDao.queryForInt("SELECT COUNT(*) FROM ($unionSql) t", args) + args["limit"] = pageSize + args["offset"] = pageNum * pageSize + val rows = jdbcDao.queryForList( + "$unionSql ORDER BY itemCode ASC, uomUdfudesc ASC LIMIT :limit OFFSET :offset", + args, + ) + return RecordsRes(rows.map { mapLocationSearchRow(it) }, total) } /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 */ @@ -140,6 +155,260 @@ open class InventoryService( ).mapNotNull { it.id } } + private fun buildItemSearchUnionSql(): String { + val itemFilter = """ + AND (:code = '' OR item.code LIKE CONCAT('%', :code, '%')) + AND (:name = '' OR item.name LIKE CONCAT('%', :name, '%')) + AND (:type = '' OR item.type = :type) + AND (:hasItemIdFilter = 0 OR item.id IN (:itemIds)) + """.trimIndent() + return """ + SELECT + inv.id AS id, + item.id AS itemId, + item.code AS itemCode, + item.name AS itemName, + item.type AS itemType, + COALESCE(inv.onHandQty, 0) AS onHandQty, + COALESCE(inv.onHoldQty, 0) AS onHoldQty, + COALESCE(inv.unavailableQty, 0) AS unavailableQty, + (COALESCE(inv.onHandQty, 0) - COALESCE(inv.unavailableQty, 0)) AS availableQty, + uc.id AS stockUomId, + uc.code AS uomCode, + uc.udfudesc AS uomUdfudesc, + uc.udfShortDesc AS uomShortDesc, + (COALESCE(inv.onHandQty, 0) - COALESCE(inv.unavailableQty, 0)) AS qtyPerSmallestUnit, + uc_base.udfudesc AS baseUom, + inv.price AS price, + cur.name AS currencyName, + inv.status AS status, + item.latestMarketUnitPrice AS latestMarketUnitPrice, + item.latestMupUpdatedDate AS latestMupUpdatedDate + FROM inventory inv + INNER JOIN items item ON item.id = inv.itemId AND item.deleted = 0 + LEFT JOIN uom_conversion uc ON uc.id = inv.stockUomId + LEFT JOIN uom_conversion uc_base ON uc_base.id = inv.uomId + LEFT JOIN currency cur ON cur.id = inv.currencyId + WHERE inv.deleted = 0 + $itemFilter + + UNION ALL + + SELECT + 0 AS id, + item.id AS itemId, + item.code AS itemCode, + item.name AS itemName, + item.type AS itemType, + 0 AS onHandQty, + 0 AS onHoldQty, + 0 AS unavailableQty, + 0 AS availableQty, + uc.id AS stockUomId, + uc.code AS uomCode, + uc.udfudesc AS uomUdfudesc, + uc.udfShortDesc AS uomShortDesc, + 0 AS qtyPerSmallestUnit, + uc.udfudesc AS baseUom, + NULL AS price, + NULL AS currencyName, + 'active' AS status, + item.latestMarketUnitPrice AS latestMarketUnitPrice, + item.latestMupUpdatedDate AS latestMupUpdatedDate + FROM items item + INNER JOIN item_uom iu + ON iu.itemId = item.id + AND iu.stockUnit = 1 + AND iu.deleted = 0 + AND iu.id = ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = item.id + AND iu2.stockUnit = 1 + AND iu2.deleted = 0 + ) + INNER JOIN uom_conversion uc ON uc.id = iu.uomId + WHERE item.deleted = 0 + $itemFilter + AND EXISTS ( + SELECT 1 + FROM inventory inv2 + WHERE inv2.itemId = item.id + AND inv2.deleted = 0 + ) + AND NOT EXISTS ( + SELECT 1 + FROM inventory inv2 + WHERE inv2.itemId = item.id + AND inv2.deleted = 0 + AND inv2.stockUomId = uc.id + ) + """.trimIndent() + } + + private fun buildLocationSearchUnionSql(): String { + val itemFilter = """ + AND (:code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%'))) + AND (:name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%'))) + AND (:type = '' OR item.type = :type) + """.trimIndent() + val lotFilter = """ + ill.deleted = 0 + AND il.deleted = 0 + AND item.deleted = 0 + AND ill.status = :status + AND (:hasWarehouseFilter = 0 OR ill.warehouseId IN (:warehouseIds)) + AND (:lotNo = '' OR il.lotNo = :lotNo) + """.trimIndent() + val lotExistsFilter = """ + ill2.deleted = 0 + AND il2.deleted = 0 + AND ill2.status = :status + AND (:hasWarehouseFilter = 0 OR ill2.warehouseId IN (:warehouseIds)) + AND (:lotNo = '' OR il2.lotNo = :lotNo) + """.trimIndent() + return """ + SELECT + MAX(ill.id) AS id, + item.id AS itemId, + item.code AS itemCode, + item.name AS itemName, + item.type AS itemType, + SUM(COALESCE(ill.inQty, 0)) AS onHandQty, + SUM(COALESCE(ill.holdQty, 0)) AS onHoldQty, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS availableQty, + uom.id AS uomId, + uom.code AS uomCode, + uom.udfudesc AS uomUdfudesc, + uom.udfShortDesc AS uomShortDesc, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS qtyPerSmallestUnit, + MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice, + MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId + INNER JOIN items item ON item.id = il.itemId + INNER JOIN item_uom su ON su.id = ill.stockItemUomId + INNER JOIN uom_conversion uom ON uom.id = su.uomId + WHERE $lotFilter + $itemFilter + GROUP BY item.id, item.code, item.name, item.type, uom.id, uom.code, uom.udfudesc, uom.udfShortDesc + + UNION ALL + + SELECT + 0 AS id, + item.id AS itemId, + item.code AS itemCode, + item.name AS itemName, + item.type AS itemType, + 0 AS onHandQty, + 0 AS onHoldQty, + 0 AS availableQty, + uom.id AS uomId, + uom.code AS uomCode, + uom.udfudesc AS uomUdfudesc, + uom.udfShortDesc AS uomShortDesc, + 0 AS qtyPerSmallestUnit, + item.latestMarketUnitPrice AS latestMarketUnitPrice, + item.latestMupUpdatedDate AS latestMupUpdatedDate + FROM items item + INNER JOIN item_uom iu + ON iu.itemId = item.id + AND iu.stockUnit = 1 + AND iu.deleted = 0 + AND iu.id = ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = item.id + AND iu2.stockUnit = 1 + AND iu2.deleted = 0 + ) + INNER JOIN uom_conversion uom ON uom.id = iu.uomId + WHERE item.deleted = 0 + $itemFilter + AND EXISTS ( + SELECT 1 + FROM inventory_lot_line ill2 + INNER JOIN inventory_lot il2 ON il2.id = ill2.inventoryLotId + WHERE il2.itemId = item.id + AND $lotExistsFilter + ) + AND NOT EXISTS ( + SELECT 1 + FROM inventory_lot_line ill2 + INNER JOIN inventory_lot il2 ON il2.id = ill2.inventoryLotId + INNER JOIN item_uom su2 ON su2.id = ill2.stockItemUomId + WHERE il2.itemId = item.id + AND su2.uomId = uom.id + AND $lotExistsFilter + ) + """.trimIndent() + } + + private fun mapItemSearchRow(row: Map): InventoryInfoDto { + val available = toBigDecimal(row["availableQty"]) + return InventoryInfoDto( + id = toLong(row["id"]), + itemId = toLong(row["itemId"]), + itemCode = row["itemCode"]?.toString(), + itemName = row["itemName"]?.toString(), + itemType = row["itemType"]?.toString(), + onHandQty = toBigDecimal(row["onHandQty"]), + onHoldQty = toBigDecimal(row["onHoldQty"]), + unavailableQty = toBigDecimal(row["unavailableQty"]), + availableQty = available, + stockUomId = toLong(row["stockUomId"]), + uomCode = row["uomCode"]?.toString(), + uomUdfudesc = row["uomUdfudesc"]?.toString(), + uomShortDesc = row["uomShortDesc"]?.toString(), + qtyPerSmallestUnit = available, + baseUom = row["baseUom"]?.toString(), + price = toBigDecimal(row["price"]), + currencyName = row["currencyName"]?.toString(), + status = row["status"]?.toString(), + latestMarketUnitPrice = (row["latestMarketUnitPrice"] as? Number)?.toDouble(), + latestMupUpdatedDate = toLocalDateTime(row["latestMupUpdatedDate"]), + ) + } + + private fun mapLocationSearchRow(row: Map): InventoryStockUomSearchRowDto { + val available = toBigDecimal(row["availableQty"]) + return InventoryStockUomSearchRowDto( + id = toLong(row["id"]), + itemId = toLong(row["itemId"]), + itemCode = row["itemCode"]?.toString(), + itemName = row["itemName"]?.toString(), + itemType = row["itemType"]?.toString(), + onHandQty = toBigDecimal(row["onHandQty"]), + onHoldQty = toBigDecimal(row["onHoldQty"]), + unavailableQty = BigDecimal.ZERO, + availableQty = available, + uomId = toLong(row["uomId"]), + uomCode = row["uomCode"]?.toString(), + uomUdfudesc = row["uomUdfudesc"]?.toString(), + uomShortDesc = row["uomShortDesc"]?.toString(), + qtyPerSmallestUnit = available, + latestMarketUnitPrice = (row["latestMarketUnitPrice"] as? Number)?.toDouble(), + latestMupUpdatedDate = toLocalDateTime(row["latestMupUpdatedDate"]), + ) + } + + private fun toLong(value: Any?): Long? = (value as? Number)?.toLong() + + private fun toBigDecimal(value: Any?): BigDecimal? = when (value) { + null -> null + is BigDecimal -> value + is Number -> BigDecimal(value.toString()) + else -> null + } + + private fun toLocalDateTime(value: Any?): LocalDateTime? = when (value) { + is LocalDateTime -> value + is Timestamp -> value.toLocalDateTime() + is java.sql.Date -> value.toLocalDate().atStartOfDay() + else -> null + } + open fun allInventoriesByItems(items: List): List{ return inventoryRepository.findInventoryInfoByItemInAndDeletedIsFalse(items); } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt index 6b192dc9..1e9ace15 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt @@ -25,7 +25,7 @@ open class StockAdjustmentService( private val stockAdjustmentRecordRepository: StockAdjustmentRecordRepository ) { - /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.8 | 2026-09-16 */ @Transactional open fun submit(request: StockAdjustmentRequest): MessageResponse { val originalById = request.originalLines.filter { it.id > 0 }.associateBy { it.id } @@ -55,7 +55,8 @@ open class StockAdjustmentService( productLotNo = current.productlotNo?.takeIf { it.isNotBlank() }, dnNo = current.dnNo?.takeIf { it.isNotBlank() }, type = stockType, - warehouseId = current.warehouseId + warehouseId = current.warehouseId, + uomId = current.uomId, ) ) saveAdjustmentRecordForStockIn(stockInLine, current.remarks) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index c46be317..7283ead0 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -28,6 +28,7 @@ import com.ffii.core.utils.PdfUtils; import com.ffii.core.utils.ZebraPrinterUtil import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus +import com.ffii.fpsms.modules.master.entity.ItemUom import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.master.service.ItemUomService @@ -1496,12 +1497,7 @@ open class StockInLineService( savedStockInLine.inventoryLot = savedInventoryLot // Step 4: Create a row in inventory_lot_line table - val stockItemUom = itemUomRepository.findFirstByItemIdAndStockUnitIsTrueAndDeletedIsFalseOrderByIdAsc( - request.itemId - ) ?: throw IllegalArgumentException( - "Stock UOM not found for item: id=${request.itemId}, itemNo=${request.itemNo}. " + - "Ensure at least one item_uom row has stockUnit=1, deleted=0 for this item." - ) + val stockItemUom = resolveItemUomForNewLot(request.itemId, request.itemNo, request.uomId) val warehouse = if (request.warehouseId != null) { warehouseRepository.findById(request.warehouseId).orElseThrow() @@ -1531,6 +1527,20 @@ open class StockInLineService( return savedStockInLine } + private fun resolveItemUomForNewLot(itemId: Long, itemNo: String, uomId: Long?): ItemUom { + if (uomId != null) { + return itemUomRepository.findFirstByItemIdAndUomIdAndDeletedIsFalse(itemId, uomId) + ?: throw IllegalArgumentException( + "Item UOM not found for item: id=$itemId, itemNo=$itemNo, uomId=$uomId." + ) + } + return itemUomRepository.findFirstByItemIdAndStockUnitIsTrueAndDeletedIsFalseOrderByIdAsc(itemId) + ?: throw IllegalArgumentException( + "Stock UOM not found for item: id=$itemId, itemNo=$itemNo. " + + "Ensure at least one item_uom row has stockUnit=1, deleted=0 for this item." + ) + } + @Transactional open fun createStockInForExistingInventoryLotLine( request: StockInRequest, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt index 9205a8f7..f63b22bf 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt @@ -17,15 +17,15 @@ class InventoryController( return inventoryService.allInventories() } - /** Full inventory search: one row per (itemId, stockUomId) bucket. */ + /** Full inventory search: one row per (itemId, stockUomId) bucket, plus current stockUnit when missing. */ @GetMapping("/getRecordByPage") fun allInventoriesByPage(@ModelAttribute request: SearchInventoryRequest): RecordsRes { return inventoryService.allInventoriesByPage(request) } /** - * Location Search: one row per item + lot-line stock UoM, with optional location filters. - * Item Search uses GET /inventory/getRecordByPage (inventory buckets). + * Location Search: one row per item + lot-line stock UoM, plus current master stockUnit when missing. + * Item Search uses GET /inventory/getRecordByPage (inventory buckets + current stockUnit). */ @GetMapping("/searchLatest/getRecordByPage") fun searchInventoriesByLatestInventory( diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt index 570985c5..b2b4dbe6 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockAdjustmentRequest.kt @@ -15,6 +15,7 @@ data class StockAdjustmentLineRequest( val expiryDate: String, val warehouseId: Long, val uom: String? = null, + val uomId: Long? = null, val remarks: String? = null ) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockInRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockInRequest.kt index 5a935dd6..2f617e0b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockInRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockInRequest.kt @@ -13,5 +13,6 @@ data class StockInRequest( val productLotNo: String? = null, val dnNo: String? = null, val type: String? = null, - val warehouseId: Long? = null + val warehouseId: Long? = null, + val uomId: Long? = null, ) \ No newline at end of file From 97ee03e82ef20a26ee8d7c49953cd9a772c669ec Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Wed, 16 Sep 2026 12:25:06 +0800 Subject: [PATCH 10/52] Stock balance report: download time improved --- .../modules/report/service/ReportService.kt | 281 ++++++++++-------- 1 file changed, 165 insertions(+), 116 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 392733c2..481024de 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -12,9 +12,11 @@ import com.ffii.fpsms.modules.master.enums.ShopType import com.ffii.fpsms.modules.master.service.ItemUomService import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService import java.math.BigDecimal +import java.time.LocalDate import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter import net.sf.jasperreports.export.SimpleExporterInput import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput +import org.slf4j.LoggerFactory @Service open class ReportService( @@ -23,6 +25,7 @@ open class ReportService( private val shopRepository: ShopRepository, private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { + private val log = LoggerFactory.getLogger(javaClass) /** * Queries the database for inventory data based on dates and optional item type. */ @@ -1495,13 +1498,16 @@ return result } /** - * Stock Balance Report (date-driven). + * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-16 + * Stock Balance Report (date-driven). One summarized row per item. * - * - 期初存量: stockDate - 1 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) - * - 現存存貨: stockDate 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) - * - 單位均價: 依 item 判斷是否 BOM - * - BOM: 用 delivery_order_line (prefer up, else price/qty, else price) 算加權平均 - * - 非 BOM: 用 purchase_order_line (prefer up, else price/qty, else price) 算加權平均 + * Qty comes from [stock_lot_day] (daily lot close) instead of scanning all stock_ledger: + * - 期初存量: historical = that day's opening; today = yesterday's closing + * - 現存存貨: historical = that day's closing; today = inventory.onHandQty + * (today is not closed yet). If the day row is missing, fall back to previous + * close + that day's ledger net. + * - 當日入/出/MISS/BAD/TKE: that calendar day's stock_ledger only + * - 單位均價: items.AverageUnitPrice * - 庫存總價值: 單位均價 * 現存存貨 */ fun searchStockBalanceReportByDate( @@ -1514,101 +1520,80 @@ return result ): List> { val args = mutableMapOf() val formattedStockDate = stockDate.replace("/", "-") - args["stockDate"] = formattedStockDate + val stockLocalDate = LocalDate.parse(formattedStockDate) + val useLiveInventory = !stockLocalDate.isBefore(LocalDate.now()) + val liveFlag = if (useLiveInventory) "1" else "0" + args["d0"] = stockLocalDate + args["d1"] = stockLocalDate.minusDays(1) + args["d0Start"] = stockLocalDate.atStartOfDay() + args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay() val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) + val itemCodeSqlDay = buildMultiValueLikeClause(itemCode, "d.itemCode", "itemCodeDay", args) + val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args) val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" - "AND COALESCE(store_location.storeLocation, '') LIKE :storeLocation" + """ + AND EXISTS ( + SELECT 1 + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 + INNER JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0 + WHERE ill.deleted = 0 + AND il.itemId = q.itemId + AND wh.code LIKE :storeLocation + ) + """.trimIndent() } else "" + val forceLedgerIndex = if (itemCode.isNullOrBlank()) { + "FORCE INDEX (idx_sl_deleted_date)" + } else { + "" + } + val inventoryScopeUnion = if (useLiveInventory) { + """ + UNION + SELECT itemId FROM inventory_per_item WHERE currentBalance <> 0 + """.trimIndent() + } else { + "" + } val baseSql = """ - WITH params AS ( + WITH day0 AS ( SELECT - DATE(:stockDate) AS d0, - DATE_SUB(DATE(:stockDate), INTERVAL 1 DAY) AS d1 + d.itemId, + SUM(COALESCE(d.opening, 0)) AS openingBalance, + SUM(COALESCE(d.closing, 0)) AS currentBalance + FROM stock_lot_day d + WHERE IFNULL(d.deleted, 0) = 0 + AND d.date = :d0 + AND d.itemId IS NOT NULL + $itemCodeSqlDay + GROUP BY d.itemId ), - ledger_item_ids AS ( - SELECT DISTINCT sl.itemId - FROM stock_ledger sl - INNER JOIN params p ON 1=1 - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND DATE(sl.date) <= p.d0 + day1 AS ( + SELECT + d.itemId, + SUM(COALESCE(d.closing, 0)) AS openingBalance + FROM stock_lot_day d + WHERE IFNULL(d.deleted, 0) = 0 + AND d.date = :d1 + AND d.itemId IS NOT NULL + $itemCodeSqlDay + GROUP BY d.itemId ), - item_scope AS ( + inventory_per_item AS ( SELECT - it.id AS itemId, - it.code AS itemNo, - it.name AS itemName, - it.type AS itemType, - COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw - FROM items it - INNER JOIN ledger_item_ids li ON li.itemId = it.id - WHERE it.deleted = 0 - AND it.code IS NOT NULL AND it.code <> '' + iv.itemId, + SUM(COALESCE(iv.onHandQty, 0)) AS currentBalance + FROM inventory iv + INNER JOIN items it ON it.id = iv.itemId AND it.deleted = 0 + WHERE iv.deleted = 0 $itemCodeSql $stockCategorySql - ), - store_location AS ( - SELECT - sl.itemId, - MAX(wh.code) AS storeLocation - FROM stock_ledger sl - LEFT JOIN stock_in_line sil ON sl.stockInLineId = sil.id AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in ON sil.inventoryLotId = il_in.id AND il_in.deleted = 0 - LEFT JOIN stock_out_line sol ON sl.stockOutLineId = sol.id AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out ON sol.inventoryLotLineId = ill_out.id AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out ON ill_out.inventoryLotId = il_out.id AND il_out.deleted = 0 - LEFT JOIN inventory_lot_line ill_any ON ill_any.inventoryLotId = COALESCE(il_in.id, il_out.id) AND ill_any.deleted = 0 - LEFT JOIN warehouse wh ON ill_any.warehouseId = wh.id AND wh.deleted = 0 - WHERE sl.deleted = 0 - GROUP BY sl.itemId - ), - opening_ranked AS ( - SELECT - sl.itemId, - COALESCE(sl.balance, 0) AS openingBalance, - ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn - FROM stock_ledger sl - INNER JOIN params p ON 1=1 - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND DATE(sl.date) <= p.d1 - ), - opening_per_item AS ( - SELECT itemId, openingBalance - FROM opening_ranked - WHERE rn = 1 - ), - current_ranked AS ( - SELECT - sl.itemId, - COALESCE(sl.balance, 0) AS currentBalance, - ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn - FROM stock_ledger sl - INNER JOIN params p ON 1=1 - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND DATE(sl.date) <= p.d0 - ), - current_per_item AS ( - SELECT itemId, currentBalance - FROM current_ranked - WHERE rn = 1 - ), - last_in_out AS ( - SELECT - sl.itemId, - MAX(CASE WHEN COALESCE(sl.inQty, 0) > 0 THEN sl.date END) AS lastInDate, - MAX(CASE WHEN COALESCE(sl.outQty, 0) > 0 THEN sl.date END) AS lastOutDate - FROM stock_ledger sl - INNER JOIN params p ON 1=1 - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND DATE(sl.date) <= p.d0 - GROUP BY sl.itemId + GROUP BY iv.itemId ), ledger_moves AS ( SELECT @@ -1651,14 +1636,74 @@ return result THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) ELSE 0 END - ) AS variance - FROM stock_ledger sl - INNER JOIN params p ON 1=1 + ) AS variance, + SUM(COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS dayNet + FROM stock_ledger sl $forceLedgerIndex WHERE sl.deleted = 0 AND sl.itemId IS NOT NULL - AND DATE(sl.date) > p.d1 - AND DATE(sl.date) <= p.d0 + AND sl.date >= :d0Start + AND sl.date < :d0EndExclusive + $itemCodeSqlSl GROUP BY sl.itemId + ), + item_ids AS ( + SELECT itemId FROM day0 + UNION + SELECT itemId FROM day1 + UNION + SELECT itemId FROM ledger_moves + $inventoryScopeUnion + ), + item_scope AS ( + SELECT + it.id AS itemId, + it.code AS itemNo, + it.name AS itemName, + COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw + FROM items it + INNER JOIN item_ids ids ON ids.itemId = it.id + WHERE it.deleted = 0 + AND it.code IS NOT NULL AND it.code <> '' + $itemCodeSql + $stockCategorySql + ), + stock_one AS ( + SELECT iu.* + FROM item_uom iu + INNER JOIN ( + SELECT itemId, MIN(id) AS id + FROM item_uom + WHERE deleted = 0 AND stockUnit = 1 + GROUP BY itemId + ) x ON x.id = iu.id + ), + qty_per_item AS ( + SELECT + ids.itemId, + COALESCE( + CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, + d1.openingBalance, + 0 + ) AS openingBalance, + COALESCE( + CASE WHEN $liveFlag = 1 THEN invc.currentBalance ELSE d0.currentBalance END, + COALESCE( + CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, + d1.openingBalance, + 0 + ) + COALESCE(lm.dayNet, 0), + 0 + ) AS currentBalance, + COALESCE(lm.cumStockIn, 0) AS cumStockIn, + COALESCE(lm.cumStockOut, 0) AS cumStockOut, + COALESCE(lm.misInputAndLost, 0) AS misInputAndLost, + COALESCE(lm.variance, 0) AS variance, + COALESCE(lm.defectiveGoods, 0) AS defectiveGoods + FROM item_ids ids + LEFT JOIN day0 d0 ON d0.itemId = ids.itemId + LEFT JOIN day1 d1 ON d1.itemId = ids.itemId + LEFT JOIN inventory_per_item invc ON invc.itemId = ids.itemId + LEFT JOIN ledger_moves lm ON lm.itemId = ids.itemId ) SELECT '' AS stockSubCategory, @@ -1672,31 +1717,27 @@ return result '' AS cumStockOut, '' AS currentBalance, '' AS reOrderQty, - COALESCE(store_location.storeLocation, '') AS storeLocation, - COALESCE(DATE_FORMAT(lio.lastInDate, '%Y-%m-%d'), '') AS lastInDate, - COALESCE(DATE_FORMAT(lio.lastOutDate, '%Y-%m-%d'), '') AS lastOutDate, - COALESCE(op.openingBalance, 0) AS openingBalanceRaw, - COALESCE(cp.currentBalance, 0) AS currentBalanceRaw, - CASE WHEN COALESCE(op.openingBalance, 0) < 0 THEN CONCAT('(', FORMAT(-op.openingBalance, 0), ')') ELSE FORMAT(COALESCE(op.openingBalance, 0), 0) END AS totalOpeningBalance, - CASE WHEN COALESCE(lm.cumStockIn, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockIn, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockIn, 0), 0) END AS totalCumStockIn, - CASE WHEN COALESCE(lm.cumStockOut, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockOut, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockOut, 0), 0) END AS totalCumStockOut, - CASE WHEN COALESCE(cp.currentBalance, 0) < 0 THEN CONCAT('(', FORMAT(-cp.currentBalance, 0), ')') ELSE FORMAT(COALESCE(cp.currentBalance, 0), 0) END AS totalCurrentBalance, + '' AS storeLocation, + '' AS lastInDate, + '' AS lastOutDate, + q.openingBalance AS openingBalanceRaw, + q.currentBalance AS currentBalanceRaw, + CASE WHEN q.openingBalance < 0 THEN CONCAT('(', FORMAT(-q.openingBalance, 0), ')') ELSE FORMAT(q.openingBalance, 0) END AS totalOpeningBalance, + CASE WHEN q.cumStockIn < 0 THEN CONCAT('(', FORMAT(-q.cumStockIn, 0), ')') ELSE FORMAT(q.cumStockIn, 0) END AS totalCumStockIn, + CASE WHEN q.cumStockOut < 0 THEN CONCAT('(', FORMAT(-q.cumStockOut, 0), ')') ELSE FORMAT(q.cumStockOut, 0) END AS totalCumStockOut, + CASE WHEN q.currentBalance < 0 THEN CONCAT('(', FORMAT(-q.currentBalance, 0), ')') ELSE FORMAT(q.currentBalance, 0) END AS totalCurrentBalance, '' AS misInputAndLost, '' AS defectiveGoods, '' AS variance, - CASE WHEN COALESCE(lm.misInputAndLost, 0) < 0 THEN CONCAT('(', FORMAT(-lm.misInputAndLost, 0), ')') ELSE FORMAT(COALESCE(lm.misInputAndLost, 0), 0) END AS totalMisInputAndLost, - CASE WHEN COALESCE(lm.variance, 0) < 0 THEN CONCAT('(', FORMAT(-lm.variance, 0), ')') ELSE FORMAT(COALESCE(lm.variance, 0), 0) END AS totalVariance, - CASE WHEN COALESCE(lm.defectiveGoods, 0) < 0 THEN CONCAT('(', FORMAT(-lm.defectiveGoods, 0), ')') ELSE FORMAT(COALESCE(lm.defectiveGoods, 0), 0) END AS totalDefectiveGoods, + CASE WHEN q.misInputAndLost < 0 THEN CONCAT('(', FORMAT(-q.misInputAndLost, 0), ')') ELSE FORMAT(q.misInputAndLost, 0) END AS totalMisInputAndLost, + CASE WHEN q.variance < 0 THEN CONCAT('(', FORMAT(-q.variance, 0), ')') ELSE FORMAT(q.variance, 0) END AS totalVariance, + CASE WHEN q.defectiveGoods < 0 THEN CONCAT('(', FORMAT(-q.defectiveGoods, 0), ')') ELSE FORMAT(q.defectiveGoods, 0) END AS totalDefectiveGoods, FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0), 2), 2) AS avgUnitPrice, - FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0) * COALESCE(cp.currentBalance, 0), 2), 2) AS totalStockBalance - FROM item_scope s - LEFT JOIN opening_per_item op ON op.itemId = s.itemId - LEFT JOIN current_per_item cp ON cp.itemId = s.itemId - LEFT JOIN last_in_out lio ON lio.itemId = s.itemId - LEFT JOIN ledger_moves lm ON lm.itemId = s.itemId - LEFT JOIN item_uom iu ON iu.itemId = s.itemId AND iu.stockUnit = 1 AND iu.deleted = 0 - LEFT JOIN uom_conversion uc ON iu.uomId = uc.id - LEFT JOIN store_location ON store_location.itemId = s.itemId + FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0) * COALESCE(q.currentBalance, 0), 2), 2) AS totalStockBalance + FROM qty_per_item q + INNER JOIN item_scope s ON s.itemId = q.itemId + LEFT JOIN stock_one so ON so.itemId = q.itemId + LEFT JOIN uom_conversion uc ON so.uomId = uc.id WHERE 1=1 $storeLocationSql """.trimIndent() @@ -1713,10 +1754,8 @@ return result val finalSql = if (filters.isEmpty()) { - // no numeric filter: just order by itemNo baseSql } else { - // wrap to filter on raw numeric current balance """ SELECT * FROM ( $baseSql @@ -1725,7 +1764,17 @@ return result """.trimIndent() } - return jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) + val t0 = System.nanoTime() + val rows = jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) + log.info( + "stock-balance-by-date date={} liveInventory={} itemCode={} rows={} {}ms", + stockLocalDate, + useLiveInventory, + itemCode ?: "", + rows.size, + (System.nanoTime() - t0) / 1_000_000, + ) + return rows } /** * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. From 926eebf86280d6c5e7c9fb6bf3596c3e3623b409 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 21 Sep 2026 15:03:44 +0800 Subject: [PATCH 11/52] remove useless py --- scripts/export_stock_ledger_fix_timeline.py | 418 -------------------- 1 file changed, 418 deletions(-) delete mode 100644 scripts/export_stock_ledger_fix_timeline.py diff --git a/scripts/export_stock_ledger_fix_timeline.py b/scripts/export_stock_ledger_fix_timeline.py deleted file mode 100644 index 7b7440d7..00000000 --- a/scripts/export_stock_ledger_fix_timeline.py +++ /dev/null @@ -1,418 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -31/8 night stock-ledger-fix timeline (InventoryId match vs not match). - -Usage (from repo root): - python scripts/export_stock_ledger_fix_timeline.py - -Output: - docs/deploy/20260831_stock_ledger_fix_timeline.xlsx -""" - -from __future__ import annotations - -from pathlib import Path - -from openpyxl import Workbook -from openpyxl.styles import Alignment, Border, Font, PatternFill, Side -from openpyxl.utils import get_column_letter -from openpyxl.worksheet.worksheet import Worksheet - -ROOT = Path(__file__).resolve().parents[1] -OUT = ROOT / "docs" / "deploy" / "20260831_stock_ledger_fix_timeline.xlsx" - -THIN = Border( - left=Side(style="thin", color="B0B0B0"), - right=Side(style="thin", color="B0B0B0"), - top=Side(style="thin", color="B0B0B0"), - bottom=Side(style="thin", color="B0B0B0"), -) -WRAP = Alignment(wrap_text=True, vertical="center") -WRAP_CENTER = Alignment(wrap_text=True, vertical="center", horizontal="center") - -FILL_TITLE = PatternFill("solid", fgColor="1F4E79") -FILL_MATCH = PatternFill("solid", fgColor="548235") -FILL_NOMATCH = PatternFill("solid", fgColor="C45911") -FILL_HEAD = PatternFill("solid", fgColor="D9E2F3") -FILL_HEAD_M = PatternFill("solid", fgColor="C6E0B4") -FILL_HEAD_N = PatternFill("solid", fgColor="F8CBAD") -FILL_GATE = PatternFill("solid", fgColor="FFF2CC") -FILL_WAIT = PatternFill("solid", fgColor="DDEBF7") -FILL_STOP = PatternFill("solid", fgColor="F4B183") -FILL_NOTE = PatternFill("solid", fgColor="F2F2F2") -WHITE = Font(bold=True, color="FFFFFF", name="Calibri", size=14) -WHITE11 = Font(bold=True, color="FFFFFF", name="Calibri", size=11) -BOLD = Font(bold=True, name="Calibri", size=11) -NORMAL = Font(name="Calibri", size=10) -ITALIC = Font(italic=True, color="666666", name="Calibri", size=9) - - -def set_col_widths(ws: Worksheet, widths: dict[str, float]) -> None: - for col, w in widths.items(): - ws.column_dimensions[col].width = w - - -def paint(cell, value, fill=None, font=None, align=WRAP, merge=None) -> None: - cell.value = value - cell.alignment = align - cell.border = THIN - if fill: - cell.fill = fill - if font: - cell.font = font - - -def write_table(ws: Worksheet, start_row: int, start_col: int, headers: list[str], rows: list[list[str]], header_fill: PatternFill) -> int: - for i, h in enumerate(headers): - c = ws.cell(start_row, start_col + i) - paint(c, h, header_fill, BOLD, WRAP_CENTER) - r = start_row + 1 - for row in rows: - row_fill = None - row_font = NORMAL - cells: list[str] = [] - for val in row: - if val.startswith("GATE:"): - row_fill, row_font = FILL_GATE, BOLD - cells.append(val[5:].strip()) - elif val.startswith("WAIT:"): - row_fill = FILL_WAIT - cells.append(val[5:].strip()) - elif val.startswith("STOP:"): - row_fill, row_font = FILL_STOP, BOLD - cells.append(val[5:].strip()) - else: - cells.append(val) - for i, text in enumerate(cells): - c = ws.cell(r, start_col + i) - font = row_font if i == 0 or row_fill in (FILL_GATE, FILL_STOP) else NORMAL - paint(c, text, row_fill, font) - if i == 0: - c.alignment = WRAP_CENTER - r += 1 - return r - 1 - - -def sheet_main(wb: Workbook) -> None: - ws = wb.active - ws.title = "31-8夜_主流程" - ws.sheet_view.showGridLines = False - ws.freeze_panes = "A8" - ws.row_dimensions[1].height = 24 - ws.page_setup.orientation = "landscape" - ws.page_setup.fitToPage = True - ws.page_setup.fitToWidth = 1 - ws.page_setup.fitToHeight = 1 - ws.page_setup.paperSize = ws.PAPERSIZE_A4 - ws.print_title_rows = "1:7" - ws.page_setup.horizontalCentered = True - ws.sheet_properties.pageSetUpPr.fitToPage = True - - ws.merge_cells("A1:I1") - paint(ws["A1"], "31/8 夜 stock-ledger-fix:兩條流程(InventoryId match / not match)", FILL_TITLE, WHITE, WRAP_CENTER) - for col in range(2, 10): - ws.cell(1, col).fill = FILL_TITLE - ws.cell(1, col).border = THIN - - ws.merge_cells("A2:I2") - paint( - ws["A2"], - "前提:白天鏈已備好 0315–0830 SQL;dump 31/8 22:30 起 freeze。" - " 程式已支援修/匯出「今天」與 2.7 adjDate=今天(UI 選今天或 API 傳 adjDate)。" - " 23:05 Preview 決定走左欄或右欄,不要兩條一起做。" - " 時間由預計分鐘累加(樂觀);逾時整串延後。", - FILL_NOTE, - ITALIC, - ) - ws.row_dimensions[2].height = 48 - for col in range(2, 10): - ws.cell(2, col).fill = FILL_NOTE - ws.cell(2, col).border = THIN - - ws.merge_cells("A3:D3") - paint( - ws["A3"], - "流程 A MATCH(31/8 dump 套檔前 inventory 頂 = 30/8 修帳前的頂)", - FILL_MATCH, - WHITE11, - WRAP_CENTER, - ) - for col in range(2, 5): - ws.cell(3, col).fill = FILL_MATCH - ws.cell(3, col).border = THIN - - ws.merge_cells("F3:I3") - paint( - ws["F3"], - "流程 B NOT MATCH(31/8 dump 套檔前 inventory 頂 ≠ 30/8 修帳前)", - FILL_NOMATCH, - WHITE11, - WRAP_CENTER, - ) - for col in range(7, 10): - ws.cell(3, col).fill = FILL_NOMATCH - ws.cell(3, col).border = THIN - - ws.merge_cells("A4:D4") - paint( - ws["A4"], - "套 0315–0830 完整 SQL(含 inventory + inventoryId)。不要長 2.3。約 23:30 套完正式庫。", - FILL_HEAD_M, - NORMAL, - ) - ws.row_dimensions[4].height = 36 - for col in range(2, 5): - ws.cell(4, col).fill = FILL_HEAD_M - ws.cell(4, col).border = THIN - - ws.merge_cells("F4:I4") - paint( - ws["F4"], - "套 0315–0830 不完整 SQL(無 inventory/inventoryId)→ 本 dump 1.0 → 長 2.3。" - " 禁止套舊 1.0 INSERT。約 23:53 套完正式庫。", - FILL_HEAD_N, - NORMAL, - ) - for col in range(7, 10): - ws.cell(4, col).fill = FILL_HEAD_N - ws.cell(4, col).border = THIN - - match_rows = [ - ["22:30", "1", "dump + freeze", "停 DN/GRN、lot expiry、日結、GRN sync。記下 I0=MAX(inventory.id)、N=MAX(stock_ledger.id)"], - ["22:31", "5", "zip/下載完", "逾 15 分當異常"], - ["22:36", "25", "import 完", "最易爆;爆了後面全停"], - ["GATE:23:05", "—", "Preview:InventoryId MATCH", "I0 = 30/8 記住的頂 → 走本欄。新 UOM 仍可 MATCH"], - ["23:05", "10", "套 0315–0830 完整 SQL", "inventory(寫死 id)→ ledger(含 inventoryId)→ 日結。舊 qty 非最終"], - ["23:15", "<10s", "再跑 1.0", "新 UOM 才 INSERT(例 1051);qty 刷成 31/8 dump 當下"], - ["23:15", "2", "短區間 2.1–2.6:0830–0831", "可修今天。不要重跑 0315–0829,不要長 2.3"], - ["23:17", "3", "2.7 ADJ(adjDate=31/8)", "UI 選「今天」或 API adjDate=今天。勿用預設昨天(會 ADJ 到 30/8)"], - ["23:20", "10", "匯出完整檔 0315–0831", "inventory → ledger.inventoryId → 日結;2.7 新列(id>N)要 INSERT"], - ["WAIT:23:25", "5", "正式庫 pull code + 開後端", "freeze 仍在;I0/N 未變"], - ["23:30", "5", "正式庫套 0315–0831 完整 SQL", "先 inventory、再 ledger、再日結。套完再開 expiry/日結"], - ] - nomatch_rows = [ - ["22:30", "1", "dump + freeze", "同左。記下實際 I0(已不是舊頂)、N"], - ["22:31", "5", "zip/下載完", "同左"], - ["22:36", "25", "import 完", "同左"], - ["GATE:23:05", "—", "Preview:NOT MATCH", "禁止套 0830 inventory/inventoryId"], - ["23:05", "10", "套 0315–0830 不完整 SQL", "只套 ledger 計算欄+日結(若有)。不要套舊 1.0 INSERT"], - ["23:15", "1", "再跑 1.0", "本 dump 新 id 從 I0+1 起;與 0830 檔不是同一套"], - ["23:16", "15", "長區間 2.3:0315–0830", "本夜最長段。逾 23:50 則本夜不套正式庫"], - ["23:31", "2", "短區間 2.1–2.6:0830–0831", "同左:可修今天"], - ["23:33", "3", "2.7 ADJ(adjDate=31/8)", "同左:選今天"], - ["23:43", "10", "匯出完整檔 0315–0831", "id 同源於本 dump 1.0,不是 0830 檔"], - ["WAIT:23:48", "5", "正式庫 pull code + 開後端", "1:00 master 維持停到套完"], - ["23:53", "5", "正式庫套完整 SQL", "先 inventory、再 ledger、再日結"], - ["STOP:(風險)", "—", "長 2.3 拖過", "優先本夜 freeze 修完再上,或取消套檔改白天"], - ] - - write_table(ws, 6, 1, ["時間", "預計(分)", "動作", "備註"], match_rows, FILL_HEAD_M) - write_table(ws, 6, 6, ["時間", "預計(分)", "動作", "備註"], nomatch_rows, FILL_HEAD_N) - - ws.column_dimensions["E"].width = 2.5 - note_row = 21 - ws.merge_cells(start_row=note_row, start_column=1, end_row=note_row, end_column=9) - paint( - ws.cell(note_row, 1), - "顏色:黃=Preview;藍=等待;橙=風險。" - " MATCH 不要跑長 2.3。" - " NOT MATCH 不要套 0830 inventory/inventoryId。" - " 2.7 必須 adjDate=dump 日(今天)。" - " writeExportSql 完整檔(含 inventory/inventoryId/2.7 INSERT)若尚未改完,匯出前先確認。", - FILL_NOTE, - ITALIC, - ) - ws.row_dimensions[note_row].height = 48 - for col in range(2, 10): - ws.cell(note_row, col).fill = FILL_NOTE - ws.cell(note_row, col).border = THIN - - set_col_widths( - ws, - { - "A": 12, - "B": 10, - "C": 28, - "D": 38, - "E": 2.5, - "F": 12, - "G": 10, - "H": 30, - "I": 38, - }, - ) - for r in range(7, 20): - ws.row_dimensions[r].height = 44 - ws.row_dimensions[6].height = 22 - ws.print_area = "A1:I21" - - -def sheet_gate(wb: Workbook) -> None: - ws = wb.create_sheet("InventoryId_match判定") - ws.sheet_view.showGridLines = False - ws.page_setup.orientation = "landscape" - ws.page_setup.fitToPage = True - ws.page_setup.fitToWidth = 1 - ws.page_setup.fitToHeight = 1 - ws.page_setup.paperSize = ws.PAPERSIZE_A4 - - ws.merge_cells("A1:E1") - paint(ws["A1"], "InventoryId match 判定(數字過了才往下,不要用「時間靠近」)", FILL_TITLE, WHITE, WRAP_CENTER) - for col in range(2, 6): - ws.cell(1, col).fill = FILL_TITLE - ws.cell(1, col).border = THIN - - ws.merge_cells("A2:E2") - paint( - ws["A2"], - "A 在套任何 0830 SQL 之前(dump 原樣)。B 在套完 0830 SQL+再跑 1.0 之後(僅 MATCH)。" - " C 在同夜修完 0830–0831+2.7(adjDate=今天)之後,才能完整匯出。", - FILL_NOTE, - ITALIC, - ) - ws.row_dimensions[2].height = 36 - for col in range(2, 6): - ws.cell(2, col).fill = FILL_NOTE - ws.cell(2, col).border = THIN - - a_rows = [ - ["A1", "inventory 頂 I0", "套檔前 MAX(inventory.id)", "= 0830 修帳前記住的頂(例 1000)", "≠ → 流程 B(NOT MATCH)。禁止套舊 1.0 INSERT"], - ["A2", "新 UOM pair", "lot 有 (itemId,uomId) 但 inventory 還沒列", "0 或 >0 都可以", ">0 不是失敗:MATCH 時套完舊檔再 1.0;NOT MATCH 時本 dump 1.0 一併補"], - ["A3", "ledger 頂 N", "套檔前 MAX(stock_ledger.id)", "記下,給 2.7 INSERT 用", "—"], - ["A4", "0830 SQL inventory 段", "要不要套", "僅 A1 通過才套", "A1 失敗:inventory / inventoryId 都不要套"], - ] - write_table(ws, 4, 1, ["#", "檢查", "怎麼算", "MATCH 通過", "NOT MATCH/失敗"], a_rows, FILL_HEAD) - - ws.merge_cells("A10:E10") - paint(ws["A10"], "B 僅流程 A(MATCH)套完 0830 SQL、再跑 1.0 之後", FILL_MATCH, WHITE11, WRAP_CENTER) - for col in range(2, 6): - ws.cell(10, col).fill = FILL_MATCH - ws.cell(10, col).border = THIN - - b_rows = [ - ["B1", "needInventoryInsert", "1.0 後還缺的 (item,uom)", "= 0", ">0 → 1.0 沒補完,不要匯出"], - ["B2", "inventory 頂", "1.0 後 MAX(id)", "≥ I0;有新 UOM 會 > 舊頂(例 1051)", "仍 = dump 的 I0 且 A2>0 → 1.0 沒跑或沒 INSERT"], - ["B3", "0830 區間 inventoryId", "miss / wrong vs MIN(inventory.id) per (item,uom)", "= 0", "≠0 → 再跑 0830 的 2.3(短區間)"], - ] - write_table(ws, 11, 1, ["#", "檢查", "怎麼算", "通過", "失敗"], b_rows, FILL_HEAD_M) - - ws.merge_cells("A16:E16") - paint(ws["A16"], "C 兩條流程都要:同夜修完 0831+2.7(adjDate=今天)之後(完整匯出前)", FILL_TITLE, WHITE11, WRAP_CENTER) - for col in range(2, 6): - ws.cell(16, col).fill = FILL_TITLE - ws.cell(16, col).border = THIN - - c_rows = [ - ["C1", "0831 inventoryId", "miss / wrong", "= 0", "再跑 0831 的 2.3"], - ["C2", "2.7 新列", "id > N 且 type=ADJ、createdBy=stock-ledger-fix", "匯出必須 INSERT 這些 id", "現況 writeExportSql 只 UPDATE JOIN id → 正式庫會丟 ADJ"], - ["C3", "正式庫套檔前", "freeze 仍在", "MATCH:I0 仍為記住的頂、ledger 頂仍 N。NOT MATCH:I0 仍為本 dump 的頂", "任一變了 → 不要套完整檔"], - ] - write_table(ws, 17, 1, ["#", "檢查", "怎麼算", "通過", "失敗"], c_rows, FILL_HEAD) - - ws.merge_cells("A22:E22") - paint(ws["A22"], "分支速查(給 23:05)", FILL_GATE, BOLD, WRAP_CENTER) - for col in range(2, 6): - ws.cell(22, col).fill = FILL_GATE - ws.cell(22, col).border = THIN - - branch = [ - ["MATCH", "無新 UOM", "流程 A", "套完整 SQL → 1.0 刷 qty → 修 0830–0831 → 2.7 adjDate=今天 → 匯出 → 套正式庫", "正式庫不必再跑 1.0/2.3"], - ["MATCH", "有新 UOM", "流程 A(同一條)", "套完整 SQL → 1.0 INSERT 新 id → 修 0830–0831 → 2.7 → 完整檔必含 inventory", "不要因 max 仍舊頂就跳過第二次 1.0"], - ["NOT MATCH", "—", "流程 B", "不完整 SQL → 本 dump 1.0 → 長 2.3 → 修 0830–0831 → 2.7 adjDate=今天", "2.3 逾時則本夜不套正式庫"], - ] - write_table(ws, 23, 1, ["A1", "A2 新 UOM", "走哪條", "本夜做什麼", "結果"], branch, FILL_HEAD) - - set_col_widths(ws, {"A": 14, "B": 22, "C": 36, "D": 42, "E": 48}) - for r in list(range(5, 9)) + list(range(12, 15)) + list(range(18, 21)) + list(range(24, 27)): - ws.row_dimensions[r].height = 42 - ws.row_dimensions[1].height = 24 - ws.print_area = "A1:E26" - - -def sheet_notes(wb: Workbook) -> None: - ws = wb.create_sheet("注意與對照") - ws.sheet_view.showGridLines = False - ws.page_setup.orientation = "landscape" - ws.page_setup.fitToPage = True - ws.page_setup.fitToWidth = 1 - ws.page_setup.fitToHeight = 1 - ws.page_setup.paperSize = ws.PAPERSIZE_A4 - - ws.merge_cells("A1:C1") - paint(ws["A1"], "和舊圖的對照、套檔順序、程式缺口", FILL_TITLE, WHITE, WRAP_CENTER) - ws["B1"].fill = FILL_TITLE - ws["C1"].fill = FILL_TITLE - ws["B1"].border = THIN - ws["C1"].border = THIN - - vs_rows = [ - ["舊圖等 0:00 才能修 0831/2.7", "已改:可修今天;2.7 傳 adjDate=今天", "同左"], - ["舊圖 23:11–23:40 1.0+長 2.3", "MATCH:刪長 2.3;套檔後 1.0 很快", "NOT MATCH:長 2.3 約 15 分(23:16)"], - ["舊圖 0:40 才套正式庫", "MATCH:約 23:30", "NOT MATCH:約 23:53"], - ] - write_table(ws, 3, 1, ["舊圖", "流程 A MATCH", "流程 B NOT MATCH"], vs_rows, FILL_HEAD) - - ws.merge_cells("A9:C9") - paint(ws["A9"], "套檔順序(完整檔,兩條流程相同)", FILL_MATCH, WHITE11, WRAP_CENTER) - ws["B9"].fill = FILL_MATCH - ws["C9"].fill = FILL_MATCH - ws["B9"].border = THIN - ws["C9"].border = THIN - - order_rows = [ - ["1", "inventory", "INSERT 寫死新 id + UPDATE 既有列 qty/status(MATCH 的 1001+ 來自 0830 檔;NOT MATCH 來自本 dump 1.0)"], - ["2", "stock_ledger", "id ≤ N → UPDATE(含 inventoryId、lotQty、balance…);id > N(2.7 ADJ)→ INSERT 寫死 id"], - ["3", "stock_lot_day", "刪區間再 INSERT/ON DUPLICATE。在 ledger 之後"], - ["—", "正式庫不要再跑", "Preview C 通過則不必 1.0、不必 2.3。套完再開 expiry/日結"], - ] - write_table(ws, 10, 1, ["#", "對象", "做法"], order_rows, FILL_HEAD_M) - - ws.merge_cells("A16:C16") - paint(ws["A16"], "現有程式缺口(上線前)", FILL_NOMATCH, WHITE11, WRAP_CENTER) - ws["B16"].fill = FILL_NOMATCH - ws["C16"].fill = FILL_NOMATCH - ws["B16"].border = THIN - ws["C16"].border = THIN - - gap_rows = [ - ["fixDay / fixRange / upsertDayForFix", "已允許修/日結「今天」(拒未來)", "freeze 夜可修 0831"], - ["2.7 ADJ", "可傳 adjDate(預設昨天;freeze 夜傳今天)", "UI 選「今天」或 API ?adjDate= / body.adjDate"], - ["writeExportSql", "可匯出到今天;仍可能缺 inventory/inventoryId/2.7 INSERT", "完整檔選項若未做完需手動補"], - ["排程 closeDay", "仍拒今天", "正式庫開站後日結仍只關昨天"], - ] - write_table(ws, 17, 1, ["位置", "現況", "要補"], gap_rows, FILL_HEAD_N) - - ws.merge_cells("A24:C24") - paint( - ws["A24"], - "記住:inventory 頂沒變 ≠ 1.0 做完。新 UOM lot line 時常仍是 max=1000,但一定要再跑 1.0。" - " MATCH 時順序必須是:先套 0830 SQL,再 1.0(否則新 pair 會把 1001+ 編錯)。", - FILL_NOTE, - ITALIC, - ) - ws.row_dimensions[24].height = 42 - ws["B24"].fill = FILL_NOTE - ws["C24"].fill = FILL_NOTE - ws["B24"].border = THIN - ws["C24"].border = THIN - - set_col_widths(ws, {"A": 36, "B": 48, "C": 56}) - for r in list(range(4, 8)) + list(range(11, 15)) + list(range(18, 23)): - ws.row_dimensions[r].height = 36 - ws.row_dimensions[1].height = 24 - ws.print_area = "A1:C24" - - -def main() -> None: - OUT.parent.mkdir(parents=True, exist_ok=True) - wb = Workbook() - sheet_main(wb) - sheet_gate(wb) - sheet_notes(wb) - wb.save(OUT) - print(f"Wrote {OUT}") - - -if __name__ == "__main__": - main() From c2445b72a86ae2eae4e874b63c277d24f8079788 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Mon, 21 Sep 2026 17:47:57 +0800 Subject: [PATCH 12/52] Lock PS stock to the current stock-unit bucket and return Chinese SIGNAL reasons. Co-authored-by: Cursor #22 no longer joins every inventory row on the PS list. #36 maps SQLSTATE 45000 to HTTP 400 with the trigger message. --- .../com/ffii/core/support/ErrorHandler.java | 35 ++ .../modules/jobOrder/service/PSService.kt | 27 +- .../06_signal_messages_zh.sql | 380 ++++++++++++++++++ 3 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/06_signal_messages_zh.sql diff --git a/src/main/java/com/ffii/core/support/ErrorHandler.java b/src/main/java/com/ffii/core/support/ErrorHandler.java index c8f5c109..de867565 100644 --- a/src/main/java/com/ffii/core/support/ErrorHandler.java +++ b/src/main/java/com/ffii/core/support/ErrorHandler.java @@ -1,9 +1,11 @@ package com.ffii.core.support; +import java.sql.SQLException; import java.util.UUID; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.dao.DataAccessException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; @@ -33,6 +35,39 @@ public class ErrorHandler extends ResponseEntityExceptionHandler { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } + /** MySQL SIGNAL SQLSTATE 45000 is a rejected business write, not an unknown crash. */ + @ExceptionHandler(DataAccessException.class) + public ResponseEntity sqlSignal(final DataAccessException ex) { + String signal = sqlSignalMessage(ex); + if (signal != null) { + logger.warn("SQLSTATE 45000: " + signal); + return new ResponseEntity<>(new FailureRes(signal), HttpStatus.BAD_REQUEST); + } + return error500(ex); + } + + private String sqlSignalMessage(Throwable ex) { + String shortest = null; + Throwable current = ex; + while (current != null) { + if (current instanceof SQLException sql && "45000".equals(sql.getSQLState())) { + String message = sql.getMessage(); + if (message != null && !message.isBlank()) { + String trimmed = message.trim(); + if (shortest == null || trimmed.length() < shortest.length()) { + shortest = trimmed; + } + } + } + Throwable next = current.getCause(); + if (next == current) { + break; + } + current = next; + } + return shortest; + } + @ExceptionHandler({ InternalServerErrorException.class, Exception.class }) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ResponseEntity error500(final Exception ex) { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt index f4cba6b7..27d606ca 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt @@ -77,10 +77,31 @@ open class PSService( FROM bom LEFT JOIN items ON bom.itemId = items.id - LEFT JOIN inventory ON items.id = inventory.itemId - LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode - LEFT JOIN item_uom iu ON iu.itemId = items.id AND iu.stockUnit = 1 + LEFT JOIN item_uom iu + ON iu.itemId = items.id + AND iu.stockUnit = 1 + AND IFNULL(iu.deleted, 0) = 0 + AND iu.id = ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = items.id + AND iu2.stockUnit = 1 + AND IFNULL(iu2.deleted, 0) = 0 + ) LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu.uomId + LEFT JOIN inventory ON inventory.id = ( + SELECT i2.id + FROM inventory i2 + WHERE i2.itemId = items.id + AND IFNULL(i2.deleted, 0) = 0 + AND ( + i2.stockUomId = iu.uomId + OR (i2.stockUomId IS NULL AND i2.uomId = iu.uomId) + ) + ORDER BY CASE WHEN i2.stockUomId IS NULL THEN 1 ELSE 0 END, i2.id + LIMIT 1 + ) + LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode WHERE bom.deleted = 0 and bom.description = 'FG' """.trimIndent() return jdbcDao.queryForList(sql, args) diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/06_signal_messages_zh.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/06_signal_messages_zh.sql new file mode 100644 index 00000000..faa525f2 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/06_signal_messages_zh.sql @@ -0,0 +1,380 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260921-drop-ill-triggers-for-zh +-- comment: #36 Drop stock-UOM triggers so the next changesets can recreate them with Chinese SIGNAL text. +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_insert`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_update`; + +-- changeset kelvin:20260921-ill-after-insert-signal-zh splitStatements:false +-- comment: #36 Recreate AFTER INSERT trigger. Same bucket logic as 20260910-ill-after-insert-stock-uom. SIGNAL text is Chinese for API/UI. +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_AFTER_insert` + AFTER INSERT + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + DECLARE v_itemId INT; + DECLARE v_stockUomId INT; -- bucket key = item_uom.uomId for NEW.stockItemUomId + DECLARE v_baseUomId INT; -- baseUnit uomId; written to inventory.uomId only on CREATE + DECLARE v_inventoryId INT DEFAULT NULL; + DECLARE v_onHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_unavailable DECIMAL(14, 2) DEFAULT 0; + DECLARE v_currencyId INT DEFAULT NULL; + DECLARE v_currencyName VARCHAR(30) DEFAULT 'HKD'; + DECLARE v_price DECIMAL(14, 2) DEFAULT 0; + + -- Step 1: stockItemUomId is required to know which stock-UOM bucket this line belongs to. + IF NEW.stockItemUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '入倉明細缺少庫存單位,無法入帳'; + END IF; + + -- Step 2: Resolve bucket (itemId, stockUomId) from lot + item_uom. + -- stockItemUomId -> item_uom.id; stockUomId := item_uom.uomId (must belong to the same item). + SELECT il.itemId, iu.uomId + INTO v_itemId, v_stockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = NEW.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = NEW.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_itemId IS NULL OR v_stockUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '無法對應庫存桶,請檢查貨品單位與批次'; + END IF; + + -- Step 3: Resolve base UOM for inventory.uomId snapshot (used only if we INSERT a new inventory row). + SELECT iu.uomId + INTO v_baseUomId + FROM `item_uom` iu + WHERE iu.itemId = v_itemId + AND iu.baseUnit = TRUE + AND IFNULL(iu.deleted, 0) = 0 + LIMIT 1; + + IF v_baseUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此貨品未設定基本單位,無法建立庫存'; + END IF; + + -- Step 4: Find existing inventory row for this stock-UOM bucket. + SELECT i.id + INTO v_inventoryId + FROM `inventory` i + WHERE i.itemId = v_itemId + AND i.stockUomId = v_stockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + -- Step 5: Create inventory row if missing (qty starts at 0; Step 6 overwrites from lot lines). + -- uomId = base snapshot once; stockUomId = bucket key. onHoldQty left at 0 and never maintained by this trigger. + IF v_inventoryId IS NULL THEN + -- Prefer currency/price from the lot's stock-in / PO / quotation path; fall back to HKD / first currency. + SELECT c.id, COALESCE(c.name, 'HKD'), COALESCE(pql.price, 0) + INTO v_currencyId, v_currencyName, v_price + FROM `inventory_lot` il + LEFT JOIN `stock_in_line` sil ON sil.id = il.stockInLineId + LEFT JOIN `purchase_order_line` pol ON pol.id = sil.purchaseOrderLineId + LEFT JOIN `purchase_quotation_line` pql + ON pql.itemId = il.itemId AND pql.uomId = pol.uomId + LEFT JOIN `purchase_quotation` pq ON pq.id = pql.purchaseQuotationId + LEFT JOIN `currency` c ON c.id = pq.currencyId + WHERE il.id = NEW.inventoryLotId + LIMIT 1; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE c.code = 'HKD' + AND IFNULL(c.deleted, 0) = 0 + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE IFNULL(c.deleted, 0) = 0 + ORDER BY c.id + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '無法取得幣別,無法建立庫存'; + END IF; + + INSERT INTO `inventory` (`itemId`, + `onHandQty`, + `onHoldQty`, + `unavailableQty`, + `price`, + `currencyId`, + `cpu`, + `cpuUnit`, + `cpm`, + `cpmUnit`, + `uomId`, + `stockUomId`, + `status`) + VALUES (v_itemId, + 0, + 0, + 0, + COALESCE(v_price, 0), + v_currencyId, + 0, + COALESCE(v_currencyName, 'HKD'), + 0, + COALESCE(v_currencyName, 'HKD'), + v_baseUomId, + v_stockUomId, + 'unavailable'); + + SET v_inventoryId = LAST_INSERT_ID(); + END IF; + + -- Step 6: Full recompute for THIS bucket only (not a delta on existing inventory qty). + -- onHand = SUM(inQty - outQty) for all statuses + -- unavailable = SUM(inQty - outQty) for status = unavailable only (holdQty ignored) + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_onHand, v_unavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_itemId + AND iu.uomId = v_stockUomId + AND IFNULL(ill.deleted, 0) = 0; + + -- Step 7: Overwrite inventory qty/status from the SUM (source of truth = lot lines). + -- Do not touch onHoldQty, uomId, or stockUomId on an existing row. + UPDATE `inventory` + SET `onHandQty` = v_onHand, + `unavailableQty` = v_unavailable, + `status` = IF(v_onHand - v_unavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_inventoryId; +END; + +-- changeset kelvin:20260921-ill-after-update-signal-zh splitStatements:false +-- comment: #36 Recreate AFTER UPDATE trigger. Same bucket logic as 20260910-ill-after-update-stock-uom. SIGNAL text is Chinese for API/UI. +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_AFTER_update` + AFTER UPDATE + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + DECLARE v_itemId INT; + DECLARE v_stockUomId INT; -- NEW bucket key + DECLARE v_baseUomId INT; -- baseUnit uomId; used only when creating inventory + DECLARE v_inventoryId INT DEFAULT NULL; + DECLARE v_onHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_unavailable DECIMAL(14, 2) DEFAULT 0; + DECLARE v_currencyId INT DEFAULT NULL; + DECLARE v_currencyName VARCHAR(30) DEFAULT 'HKD'; + DECLARE v_price DECIMAL(14, 2) DEFAULT 0; + + DECLARE v_oldItemId INT DEFAULT NULL; + DECLARE v_oldStockUomId INT DEFAULT NULL; + DECLARE v_oldInventoryId INT DEFAULT NULL; + DECLARE v_oldOnHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_oldUnavailable DECIMAL(14, 2) DEFAULT 0; + + -- Step 1: NEW.stockItemUomId is required. + IF NEW.stockItemUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '入倉明細缺少庫存單位,無法入帳'; + END IF; + + -- Step 2: Resolve NEW bucket (itemId, stockUomId). + SELECT il.itemId, iu.uomId + INTO v_itemId, v_stockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = NEW.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = NEW.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_itemId IS NULL OR v_stockUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '無法對應庫存桶,請檢查貨品單位與批次'; + END IF; + + -- Step 3: If stock UOM or lot changed, recompute the OLD bucket so qty does not stick on the wrong row. + -- After this UPDATE, the line already belongs to NEW; OLD-bucket SUM naturally excludes it. + IF OLD.stockItemUomId IS NOT NULL + AND (OLD.stockItemUomId <> NEW.stockItemUomId + OR OLD.inventoryLotId <> NEW.inventoryLotId) THEN + + SELECT il.itemId, iu.uomId + INTO v_oldItemId, v_oldStockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = OLD.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = OLD.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_oldItemId IS NOT NULL + AND v_oldStockUomId IS NOT NULL + AND (v_oldItemId <> v_itemId OR v_oldStockUomId <> v_stockUomId) THEN + + SELECT i.id + INTO v_oldInventoryId + FROM `inventory` i + WHERE i.itemId = v_oldItemId + AND i.stockUomId = v_oldStockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + IF v_oldInventoryId IS NOT NULL THEN + -- Full SUM for OLD bucket (same formulas as NEW). + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_oldOnHand, v_oldUnavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu + ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_oldItemId + AND iu.uomId = v_oldStockUomId + AND IFNULL(ill.deleted, 0) = 0; + + UPDATE `inventory` + SET `onHandQty` = v_oldOnHand, + `unavailableQty` = v_oldUnavailable, + `status` = IF(v_oldOnHand - v_oldUnavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_oldInventoryId; + END IF; + END IF; + END IF; + + -- Step 4: Resolve base UOM snapshot (only needed when creating a new inventory row). + SELECT iu.uomId + INTO v_baseUomId + FROM `item_uom` iu + WHERE iu.itemId = v_itemId + AND iu.baseUnit = TRUE + AND IFNULL(iu.deleted, 0) = 0 + LIMIT 1; + + IF v_baseUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此貨品未設定基本單位,無法建立庫存'; + END IF; + + -- Step 5: Find or create inventory row for the NEW bucket. + SELECT i.id + INTO v_inventoryId + FROM `inventory` i + WHERE i.itemId = v_itemId + AND i.stockUomId = v_stockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + IF v_inventoryId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD'), COALESCE(pql.price, 0) + INTO v_currencyId, v_currencyName, v_price + FROM `inventory_lot` il + LEFT JOIN `stock_in_line` sil ON sil.id = il.stockInLineId + LEFT JOIN `purchase_order_line` pol ON pol.id = sil.purchaseOrderLineId + LEFT JOIN `purchase_quotation_line` pql + ON pql.itemId = il.itemId AND pql.uomId = pol.uomId + LEFT JOIN `purchase_quotation` pq ON pq.id = pql.purchaseQuotationId + LEFT JOIN `currency` c ON c.id = pq.currencyId + WHERE il.id = NEW.inventoryLotId + LIMIT 1; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE c.code = 'HKD' + AND IFNULL(c.deleted, 0) = 0 + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE IFNULL(c.deleted, 0) = 0 + ORDER BY c.id + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '無法取得幣別,無法建立庫存'; + END IF; + + INSERT INTO `inventory` (`itemId`, + `onHandQty`, + `onHoldQty`, + `unavailableQty`, + `price`, + `currencyId`, + `cpu`, + `cpuUnit`, + `cpm`, + `cpmUnit`, + `uomId`, + `stockUomId`, + `status`) + VALUES (v_itemId, + 0, + 0, + 0, + COALESCE(v_price, 0), + v_currencyId, + 0, + COALESCE(v_currencyName, 'HKD'), + 0, + COALESCE(v_currencyName, 'HKD'), + v_baseUomId, + v_stockUomId, + 'unavailable'); + + SET v_inventoryId = LAST_INSERT_ID(); + END IF; + + -- Step 6: Full SUM for NEW bucket + overwrite. + -- onHand = all statuses; unavailable = unavailable lines only; holdQty ignored. + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_onHand, v_unavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_itemId + AND iu.uomId = v_stockUomId + AND IFNULL(ill.deleted, 0) = 0; + + -- Do not touch onHoldQty, uomId, or stockUomId on an existing row. + UPDATE `inventory` + SET `onHandQty` = v_onHand, + `unavailableQty` = v_unavailable, + `status` = IF(v_onHand - v_unavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_inventoryId; +END; From 4941bfcf39483c1e38958b858cc6fd4cfd4fc85e Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 22 Sep 2026 12:21:26 +0800 Subject: [PATCH 13/52] reopen stock lot ledger report and improve stock ledger report and if INT show as x.00 --- .../modules/report/service/ReportService.kt | 103 ++- .../service/StockLedgerReportService.kt | 755 ++++++++++++------ .../FGStockOutTraceabilityReportController.kt | 4 +- .../report/web/ItemQcFailReportController.kt | 4 +- ...ialStockOutTraceabilityReportController.kt | 4 +- .../modules/report/web/ReportController.kt | 18 +- ...emiFGProductionAnalysisReportController.kt | 2 +- ...ockItemConsumptionTrendReportController.kt | 2 +- .../report/web/StockLedgerReportController.kt | 30 +- .../web/StockLotBalanceReportController.kt | 2 +- .../web/StockLotLedgerReportController.kt | 2 +- .../web/StockTakeVarianceReportController.kt | 7 +- .../resources/jasper/StockBalanceReport.jrxml | 2 +- .../resources/jasper/StockLedgarReport.jrxml | 2 +- 14 files changed, 639 insertions(+), 298 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 481024de..a4ba7f0c 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -26,6 +26,7 @@ open class ReportService( private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { private val log = LoggerFactory.getLogger(javaClass) + private val compiledReports = java.util.concurrent.ConcurrentHashMap() /** * Queries the database for inventory data based on dates and optional item type. */ @@ -1498,8 +1499,8 @@ return result } /** - * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-16 - * Stock Balance Report (date-driven). One summarized row per item. + * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.2 | 2026-09-21 + * Stock Balance Report (date-driven). One summarized row per item + stock UOM. * * Qty comes from [stock_lot_day] (daily lot close) instead of scanning all stock_ledger: * - 期初存量: historical = that day's opening; today = yesterday's closing @@ -1507,8 +1508,8 @@ return result * (today is not closed yet). If the day row is missing, fall back to previous * close + that day's ledger net. * - 當日入/出/MISS/BAD/TKE: that calendar day's stock_ledger only - * - 單位均價: items.AverageUnitPrice - * - 庫存總價值: 單位均價 * 現存存貨 + * - 單位均價: items.AverageUnitPrice (HKD / stock UOM), converted by item_uom ratio + * - 庫存總價值: 該 UOM 均價 * 該 UOM 現存存貨 */ fun searchStockBalanceReportByDate( stockCategory: String?, @@ -1540,8 +1541,10 @@ return result FROM inventory_lot_line ill INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 INNER JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0 + LEFT JOIN item_uom iu_loc ON iu_loc.id = ill.stockItemUomId AND iu_loc.deleted = 0 WHERE ill.deleted = 0 AND il.itemId = q.itemId + AND iu_loc.uomId <=> q.uomId AND wh.code LIKE :storeLocation ) """.trimIndent() @@ -1554,7 +1557,7 @@ return result val inventoryScopeUnion = if (useLiveInventory) { """ UNION - SELECT itemId FROM inventory_per_item WHERE currentBalance <> 0 + SELECT itemId, uomId FROM inventory_per_item WHERE currentBalance <> 0 """.trimIndent() } else { "" @@ -1564,6 +1567,7 @@ return result WITH day0 AS ( SELECT d.itemId, + d.uomId, SUM(COALESCE(d.opening, 0)) AS openingBalance, SUM(COALESCE(d.closing, 0)) AS currentBalance FROM stock_lot_day d @@ -1571,33 +1575,36 @@ return result AND d.date = :d0 AND d.itemId IS NOT NULL $itemCodeSqlDay - GROUP BY d.itemId + GROUP BY d.itemId, d.uomId ), day1 AS ( SELECT d.itemId, + d.uomId, SUM(COALESCE(d.closing, 0)) AS openingBalance FROM stock_lot_day d WHERE IFNULL(d.deleted, 0) = 0 AND d.date = :d1 AND d.itemId IS NOT NULL $itemCodeSqlDay - GROUP BY d.itemId + GROUP BY d.itemId, d.uomId ), inventory_per_item AS ( SELECT iv.itemId, + iv.stockUomId AS uomId, SUM(COALESCE(iv.onHandQty, 0)) AS currentBalance FROM inventory iv INNER JOIN items it ON it.id = iv.itemId AND it.deleted = 0 WHERE iv.deleted = 0 $itemCodeSql $stockCategorySql - GROUP BY iv.itemId + GROUP BY iv.itemId, iv.stockUomId ), ledger_moves AS ( SELECT sl.itemId, + sl.uomId, SUM( CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') @@ -1644,14 +1651,14 @@ return result AND sl.date >= :d0Start AND sl.date < :d0EndExclusive $itemCodeSqlSl - GROUP BY sl.itemId + GROUP BY sl.itemId, sl.uomId ), item_ids AS ( - SELECT itemId FROM day0 + SELECT itemId, uomId FROM day0 UNION - SELECT itemId FROM day1 + SELECT itemId, uomId FROM day1 UNION - SELECT itemId FROM ledger_moves + SELECT itemId, uomId FROM ledger_moves $inventoryScopeUnion ), item_scope AS ( @@ -1661,9 +1668,9 @@ return result it.name AS itemName, COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw FROM items it - INNER JOIN item_ids ids ON ids.itemId = it.id WHERE it.deleted = 0 AND it.code IS NOT NULL AND it.code <> '' + AND it.id IN (SELECT DISTINCT itemId FROM item_ids) $itemCodeSql $stockCategorySql ), @@ -1680,6 +1687,7 @@ return result qty_per_item AS ( SELECT ids.itemId, + ids.uomId, COALESCE( CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, d1.openingBalance, @@ -1700,10 +1708,10 @@ return result COALESCE(lm.variance, 0) AS variance, COALESCE(lm.defectiveGoods, 0) AS defectiveGoods FROM item_ids ids - LEFT JOIN day0 d0 ON d0.itemId = ids.itemId - LEFT JOIN day1 d1 ON d1.itemId = ids.itemId - LEFT JOIN inventory_per_item invc ON invc.itemId = ids.itemId - LEFT JOIN ledger_moves lm ON lm.itemId = ids.itemId + LEFT JOIN day0 d0 ON d0.itemId = ids.itemId AND d0.uomId <=> ids.uomId + LEFT JOIN day1 d1 ON d1.itemId = ids.itemId AND d1.uomId <=> ids.uomId + LEFT JOIN inventory_per_item invc ON invc.itemId = ids.itemId AND invc.uomId <=> ids.uomId + LEFT JOIN ledger_moves lm ON lm.itemId = ids.itemId AND lm.uomId <=> ids.uomId ) SELECT '' AS stockSubCategory, @@ -1732,12 +1740,46 @@ return result CASE WHEN q.misInputAndLost < 0 THEN CONCAT('(', FORMAT(-q.misInputAndLost, 0), ')') ELSE FORMAT(q.misInputAndLost, 0) END AS totalMisInputAndLost, CASE WHEN q.variance < 0 THEN CONCAT('(', FORMAT(-q.variance, 0), ')') ELSE FORMAT(q.variance, 0) END AS totalVariance, CASE WHEN q.defectiveGoods < 0 THEN CONCAT('(', FORMAT(-q.defectiveGoods, 0), ')') ELSE FORMAT(q.defectiveGoods, 0) END AS totalDefectiveGoods, - FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0), 2), 2) AS avgUnitPrice, - FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0) * COALESCE(q.currentBalance, 0), 2), 2) AS totalStockBalance + FORMAT(ROUND( + CASE + WHEN iu.id IS NULL OR so.id IS NULL THEN COALESCE(s.avgUnitPriceRaw, 0) + WHEN iu.uomId <=> so.uomId THEN COALESCE(s.avgUnitPriceRaw, 0) + ELSE ROUND( + COALESCE(s.avgUnitPriceRaw, 0) + * (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1)) + * (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)), + 4 + ) + END + , 2), 2) AS avgUnitPrice, + FORMAT(ROUND( + CASE + WHEN iu.id IS NULL OR so.id IS NULL THEN COALESCE(s.avgUnitPriceRaw, 0) + WHEN iu.uomId <=> so.uomId THEN COALESCE(s.avgUnitPriceRaw, 0) + ELSE ROUND( + COALESCE(s.avgUnitPriceRaw, 0) + * (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1)) + * (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)), + 4 + ) + END + * COALESCE(q.currentBalance, 0) + , 2), 2) AS totalStockBalance FROM qty_per_item q INNER JOIN item_scope s ON s.itemId = q.itemId LEFT JOIN stock_one so ON so.itemId = q.itemId - LEFT JOIN uom_conversion uc ON so.uomId = uc.id + LEFT JOIN item_uom iu + ON iu.itemId = q.itemId + AND iu.deleted = 0 + AND iu.uomId <=> q.uomId + AND iu.id = ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = q.itemId + AND iu2.deleted = 0 + AND iu2.uomId <=> q.uomId + ) + LEFT JOIN uom_conversion uc ON uc.id = COALESCE(iu.uomId, q.uomId) WHERE 1=1 $storeLocationSql """.trimIndent() @@ -1765,7 +1807,7 @@ return result } val t0 = System.nanoTime() - val rows = jdbcDao.queryForList("$finalSql ORDER BY itemNo", args) + val rows = jdbcDao.queryForList("$finalSql ORDER BY itemNo, unitOfMeasure", args) log.info( "stock-balance-by-date date={} liveInventory={} itemCode={} rows={} {}ms", stockLocalDate, @@ -1780,9 +1822,7 @@ return result * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. */ fun createExcelResponse(templatePath: String, params: Map, dataList: List>): ByteArray { - val stream = this::class.java.getResourceAsStream(templatePath) - ?: throw RuntimeException("Report template not found: $templatePath") - val jasperReport = JasperCompileManager.compileReport(stream) + val jasperReport = compiledReport(templatePath) val dataSource = JRMapCollectionDataSource(dataList) val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) val out = ByteArrayOutputStream() @@ -1797,17 +1837,20 @@ return result * Compiles and fills a Jasper Report, returning the PDF as a ByteArray. */ fun createPdfResponse(templatePath: String, params: Map, dataList: List>): ByteArray { - val stream = this::class.java.getResourceAsStream(templatePath) - ?: throw RuntimeException("Report template not found: $templatePath") - - val jasperReport = JasperCompileManager.compileReport(stream) - + val jasperReport = compiledReport(templatePath) val dataSource = JRMapCollectionDataSource(dataList) - val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) return JasperExportManager.exportReportToPdf(jasperPrint) } + private fun compiledReport(templatePath: String): JasperReport { + return compiledReports.computeIfAbsent(templatePath) { + val stream = this::class.java.getResourceAsStream(templatePath) + ?: throw RuntimeException("Report template not found: $templatePath") + stream.use { JasperCompileManager.compileReport(it) } + } + } + fun mergePdfBytes(parts: List): ByteArray { if (parts.isEmpty()) return ByteArray(0) if (parts.size == 1) return parts[0] diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 55c546b7..139c71d5 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -1,7 +1,10 @@ package com.ffii.fpsms.modules.report.service import com.ffii.core.support.JdbcDao +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.RoundingMode import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -9,7 +12,7 @@ import java.time.format.DateTimeFormatter open class StockLedgerReportService( private val jdbcDao: JdbcDao, ) { - + private val log = LoggerFactory.getLogger(StockLedgerReportService::class.java) private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") /** @@ -18,11 +21,12 @@ open class StockLedgerReportService( * * - stockSubCategory = items.type * - trnDate = stock_ledger.date - * - trnRefNo = stock_ledger.type - * - cumBalance = stock_ledger.balance(異動後纍計存量) - * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) + * - trnRefNo = stock_ledger.type(ADJ 且關聯盤點單時改為盤點) + * - cumBalance = stock_ledger.balance(異動後貨品纍計存量) + * - cumOpeningBal = balance - inQty + outQty(異動前貨品纍計) * - * 只查 [start, end] 期間列,不掃起日以前全歷史。 + * 帳本先依日期區間撈窄欄,批號/單據再依這段出現的 id 分批補。 + * 餘額用帳本上的貨品纍計,排序為貨品 → 時間 → id。 */ fun searchStockLedgerReport( stockCategory: String?, @@ -31,264 +35,500 @@ open class StockLedgerReportService( reportPeriodStart: String?, reportPeriodEnd: String?, ): List> { + val tAll = System.nanoTime() + val timings = mutableListOf() + fun timed(name: String, extra: (T) -> String, block: () -> T): T { + val t0 = System.nanoTime() + val result = block() + val line = "$name ${extra(result)} ${(System.nanoTime() - t0) / 1_000_000}ms" + timings.add(line) + log.info("stock-ledger {}", line) + return result + } - val args = mutableMapOf() - - val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") + val endDate = (reportPeriodEnd?.replace("/", "-") ?: LocalDate.now().format(dateFmt)) - - val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { + val startDate = if (!reportPeriodStart.isNullOrBlank()) { reportPeriodStart.replace("/", "-") } else { - val minDateSql = """ + val minDateRow = jdbcDao.queryForList( + """ SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate FROM stock_ledger sl WHERE sl.deleted = 0 AND sl.itemCode IS NOT NULL AND sl.itemCode <> '' - """.trimIndent() - - val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap()).firstOrNull() - (minDateRow?.get("firstDate") as? String) - ?: reportPeriodEnd + """.trimIndent(), + emptyMap(), + ).firstOrNull() + (minDateRow?.get("firstDate") as? String) ?: endDate } + val endExclusive = LocalDate.parse(endDate).plusDays(1).format(dateFmt) - val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) - args["reportPeriodStart"] = reportPeriodStart - args["reportPeriodEndExclusive"] = endExclusive - - val stockCategorySql = buildMultiValueExactClause( - stockCategory, - "it.type", - "stockCategory", - args - ) - - val itemCodeSql = buildMultiValueLikeClause( - itemCode, - "sl.itemCode", - "itemCode", - args + val args = mutableMapOf( + "reportPeriodStart" to LocalDate.parse(startDate).atStartOfDay(), + "reportPeriodEndExclusive" to LocalDate.parse(endExclusive).atStartOfDay(), ) - - // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 - val storeLocationSql = if (!storeLocation.isNullOrBlank()) { - args["storeLocation"] = "%$storeLocation%" - "AND lot.storeLocation LIKE :storeLocation" + val itemCodeSql = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCode", args) + val categorySql = buildCategoryItemFilter(stockCategory, args) + val forceLedgerIndex = if (itemCode.isNullOrBlank()) { + "FORCE INDEX (idx_sl_deleted_date)" } else { "" } - val sql = """ -SELECT - stockSubCategory, - itemNo, - itemName, - unitOfMeasure, - lotNo, - expiryDate, - trnDate, - CASE trnRefNoRaw - WHEN 'OPEN' THEN '開倉' - WHEN 'NOR' THEN '出入倉' - WHEN 'TRF' THEN '轉倉' - WHEN 'ADJ' THEN '調整' - WHEN 'MISS' THEN '遺失' - WHEN 'BAD' THEN '不良品' - WHEN 'TKE' THEN '盤點' - ELSE trnRefNoRaw - END AS trnRefNo, - storeLocation, - orderRefNo, - jobOrderNo, - - openingBalance, - cumStockIn, - cumStockOut, - currentBalance, - lastInDate, - lastOutDate, - reOrderLevel, - reOrderQty, + val moves = timed("1.ledger", { rows: List -> "rows=${rows.size}" }) { + jdbcDao.queryForList( + """ + SELECT + sl.id AS slId, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, + TRIM(COALESCE(sl.type, '')) AS rawType, + sl.itemId AS itemId, + sl.itemCode AS itemCode, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + COALESCE(sl.balance, 0) AS bal, + sl.inventoryLotLineId AS inventoryLotLineId, + sl.stockInLineId AS stockInLineId, + sl.stockOutLineId AS stockOutLineId + FROM stock_ledger sl $forceLedgerIndex + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + $itemCodeSql + $categorySql + """.trimIndent(), + args, + ).map { r -> + LedgerMove( + slId = toLong(r["slId"]), + trnDate = r["trnDate"]?.toString().orEmpty(), + rawType = r["rawType"]?.toString().orEmpty(), + itemId = toLong(r["itemId"]), + itemCode = r["itemCode"]?.toString().orEmpty(), + inQty = toDouble(r["inQty"]), + outQty = toDouble(r["outQty"]), + bal = toDouble(r["bal"]), + lotLineId = toLong(r["inventoryLotLineId"]), + stockInLineId = toLong(r["stockInLineId"]), + stockOutLineId = toLong(r["stockOutLineId"]), + ) + } + } + if (moves.isEmpty()) { + log.info("stock-ledger DONE rows=0 {}", timings.joinToString(" | ")) + return emptyList() + } - CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, - CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, + val lineByIn = HashMap>() + val lineByOut = HashMap() + timed("2.lot_keys", { _: Unit -> + "in=${lineByIn.size} out=${lineByOut.size}" + }) { + val missingIn = moves.filter { it.lotLineId <= 0 && it.stockInLineId > 0 } + .map { it.stockInLineId } + val missingOut = moves.filter { it.lotLineId <= 0 && it.stockOutLineId > 0 } + .map { it.stockOutLineId } + for (r in queryChunks( + missingIn, + """ + SELECT id, inventoryLotLineId, inventoryLotId + FROM stock_in_line + WHERE deleted = 0 AND id IN (:ids) + """.trimIndent(), + )) { + lineByIn[toLong(r["id"])] = toLong(r["inventoryLotLineId"]) to toLong(r["inventoryLotId"]) + } + for (r in queryChunks( + missingOut, + """ + SELECT id, inventoryLotLineId + FROM stock_out_line + WHERE deleted = 0 AND id IN (:ids) + """.trimIndent(), + )) { + lineByOut[toLong(r["id"])] = toLong(r["inventoryLotLineId"]) + } + } - CASE WHEN cumOpeningBalRaw < 0 - THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') - ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, - CASE WHEN bal < 0 - THEN CONCAT('(', FORMAT(-bal, 0), ')') - ELSE FORMAT(bal, 0) END AS cumBalance, + data class LotKey(val lineId: Long, val lotId: Long) + val lotKeyBySl = HashMap(moves.size * 2) + for (m in moves) { + val fromIn = lineByIn[m.stockInLineId] + val inLine = fromIn?.first ?: 0L + val inLot = fromIn?.second ?: 0L + val outLine = lineByOut[m.stockOutLineId] ?: 0L + val lineId = when { + m.lotLineId > 0 -> m.lotLineId + inLine > 0 -> inLine + inLot > 0 -> 0L + else -> outLine + } + val lotId = if (lineId <= 0 && inLot > 0) inLot else 0L + if (lineId > 0 || lotId > 0) { + lotKeyBySl[m.slId] = LotKey(lineId, lotId) + } + } - CASE WHEN totalCumOpeningBalRaw < 0 - THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') - ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, - CASE WHEN totalStockInRaw < 0 - THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') - ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, - CASE WHEN totalStockOutRaw < 0 - THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') - ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, - CASE WHEN totalCumBalanceRaw < 0 - THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') - ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance + val lotByLine = HashMap() + val lotByHeader = HashMap() + timed("3.lots", { _: Unit -> "lines=${lotByLine.size} headers=${lotByHeader.size}" }) { + for (r in queryChunks( + lotKeyBySl.values.map { it.lineId }, + """ + SELECT + ill.id AS lineId, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + COALESCE(wh.code, '') AS storeLocation + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND il.deleted = 0 + LEFT JOIN warehouse wh + ON wh.id = ill.warehouseId AND wh.deleted = 0 + WHERE ill.deleted = 0 + AND ill.id IN (:ids) + """.trimIndent(), + )) { + lotByLine[toLong(r["lineId"])] = LotInfo( + lotNo = r["lotNo"]?.toString().orEmpty(), + expiryDate = r["expiryDate"]?.toString().orEmpty(), + storeLocation = r["storeLocation"]?.toString().orEmpty(), + ) + } + val headerIds = lotKeyBySl.values + .filter { it.lineId <= 0 && it.lotId > 0 } + .map { it.lotId } + for (r in queryChunks( + headerIds, + """ + SELECT + il.id AS lotId, + COALESCE(il.lotNo, '') AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, + COALESCE(MAX(wh.code), '') AS storeLocation + FROM inventory_lot il + LEFT JOIN inventory_lot_line ill + ON ill.inventoryLotId = il.id AND ill.deleted = 0 + LEFT JOIN warehouse wh + ON wh.id = ill.warehouseId AND wh.deleted = 0 + WHERE il.deleted = 0 + AND il.id IN (:ids) + GROUP BY il.id, il.lotNo, il.expiryDate + """.trimIndent(), + )) { + lotByHeader[toLong(r["lotId"])] = LotInfo( + lotNo = r["lotNo"]?.toString().orEmpty(), + expiryDate = r["expiryDate"]?.toString().orEmpty(), + storeLocation = r["storeLocation"]?.toString().orEmpty(), + ) + } + } -FROM ( - SELECT - x.*, - FIRST_VALUE(cumOpeningBalRaw) OVER ( - PARTITION BY itemCode ORDER BY trnDateRaw, slId - ) AS totalCumOpeningBalRaw, - SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, - SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, - FIRST_VALUE(bal) OVER ( - PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC - ) AS totalCumBalanceRaw - FROM ( - SELECT - sl.id AS slId, - DATE(sl.date) AS trnDateRaw, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' - AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') - END AS trnRefNoRaw, + val locationFilter = storeLocation?.trim().orEmpty() + val withLot = ArrayList(moves.size) + for (m in moves) { + val key = lotKeyBySl[m.slId] ?: continue + val lot = if (key.lineId > 0) lotByLine[key.lineId] else lotByHeader[key.lotId] + if (lot == null) continue + if (locationFilter.isNotEmpty() && + !lot.storeLocation.contains(locationFilter, ignoreCase = true) + ) { + continue + } + withLot.add(MoveWithLot(m, lot)) + } + timings.add("4.filter rows=${withLot.size}") + if (withLot.isEmpty()) { + log.info("stock-ledger DONE rows=0 {}", timings.joinToString(" | ")) + return emptyList() + } - sl.itemCode AS itemCode, - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - COALESCE(sl.balance, 0) AS bal, - COALESCE(sl.balance, 0) - - COALESCE(sl.inQty, 0) - + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + data class ItemInfo( + val type: String, + val code: String, + val name: String, + val uom: String, + ) + val items = HashMap() + timed("5.items", { _: Unit -> "items=${items.size}" }) { + for (r in queryChunks( + withLot.map { it.move.itemId }, + """ + SELECT + it.id AS itemId, + COALESCE(it.type, '') AS stockSubCategory, + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(uc.udfudesc, '') AS unitOfMeasure + FROM items it + LEFT JOIN ( + SELECT itemId, MIN(id) AS id + FROM item_uom + WHERE deleted = 0 AND stockUnit = 1 AND itemId IN (:ids) + GROUP BY itemId + ) one_uom ON one_uom.itemId = it.id + LEFT JOIN item_uom iu + ON iu.id = one_uom.id + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId + WHERE it.deleted = 0 + AND it.id IN (:ids) + """.trimIndent(), + )) { + items[toLong(r["itemId"])] = ItemInfo( + type = r["stockSubCategory"]?.toString().orEmpty(), + code = r["itemNo"]?.toString().orEmpty(), + name = r["itemName"]?.toString().orEmpty(), + uom = r["unitOfMeasure"]?.toString().orEmpty(), + ) + } + } - it.type AS stockSubCategory, - it.code AS itemNo, - it.name AS itemName, - uc.udfudesc AS unitOfMeasure, + data class InRef(val orderRefNo: String?, val jobOrderNo: String, val stockTake: Boolean) + data class OutRef(val orderRefNo: String?, val stockTake: Boolean) + val inRefs = HashMap() + val outRefs = HashMap() + timed("6.refs", { _: Unit -> "in=${inRefs.size} out=${outRefs.size}" }) { + for (r in queryChunks( + withLot.map { it.move.stockOutLineId }, + """ + SELECT + sol.id AS lineId, + COALESCE(TRIM(do.code), TRIM(jo_po.code)) AS orderRefNo, + CASE + WHEN so.stockTakeId IS NOT NULL OR so.type = 'stockTake' THEN 1 + ELSE 0 + END AS isStockTake + FROM stock_out_line sol + LEFT JOIN stock_out so + ON so.id = sol.stockOutId AND so.deleted = 0 + LEFT JOIN pick_order_line pol + ON pol.id = sol.pickOrderLineId AND pol.deleted = 0 + LEFT JOIN pick_order po_out + ON po_out.id = pol.poId AND po_out.deleted = 0 + LEFT JOIN job_order jo_po + ON jo_po.id = po_out.joId AND jo_po.deleted = 0 + LEFT JOIN delivery_order do + ON do.id = po_out.doId AND do.deleted = 0 + WHERE sol.deleted = 0 + AND sol.id IN (:ids) + """.trimIndent(), + )) { + outRefs[toLong(r["lineId"])] = OutRef( + orderRefNo = r["orderRefNo"]?.toString(), + stockTake = toLong(r["isStockTake"]) == 1L, + ) + } + for (r in queryChunks( + withLot.map { it.move.stockInLineId }, + """ + SELECT + sil.id AS lineId, + COALESCE(TRIM(jo.code), TRIM(po.code)) AS orderRefNo, + COALESCE(TRIM(jo.code), '') AS jobOrderNo, + CASE WHEN si.stockTakeId IS NOT NULL THEN 1 ELSE 0 END AS isStockTake + FROM stock_in_line sil + LEFT JOIN stock_in si + ON si.id = sil.stockInId AND si.deleted = 0 + LEFT JOIN job_order jo + ON jo.id = sil.jobOrderId AND jo.deleted = 0 + LEFT JOIN purchase_order po + ON po.id = sil.purchaseOrderId AND po.deleted = 0 + WHERE sil.deleted = 0 + AND sil.id IN (:ids) + """.trimIndent(), + )) { + inRefs[toLong(r["lineId"])] = InRef( + orderRefNo = r["orderRefNo"]?.toString(), + jobOrderNo = r["jobOrderNo"]?.toString().orEmpty(), + stockTake = toLong(r["isStockTake"]) == 1L, + ) + } + } - lot.lotNo AS lotNo, - COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, - lot.storeLocation AS storeLocation, + val totals = HashMap() + for (row in withLot) { + val m = row.move + val total = totals.getOrPut(m.itemCode) { ItemTotal() } + total.inSum += m.inQty + total.outSum += m.outQty + val opening = m.bal - m.inQty + m.outQty + if (total.isBeforeFirst(m.trnDate, m.slId)) { + total.firstDate = m.trnDate + total.firstId = m.slId + total.opening = opening + } + if (total.isAfterLast(m.trnDate, m.slId)) { + total.lastDate = m.trnDate + total.lastId = m.slId + total.closing = m.bal + } + } - COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, - COALESCE(TRIM(jo.code), '') AS jobOrderNo, + val rows = timed("7.assemble", { list: List> -> "rows=${list.size}" }) { + val sorted = withLot.sortedWith( + compareBy { items[it.move.itemId]?.code.orEmpty() } + .thenBy { it.move.trnDate } + .thenBy { it.move.slId } + .thenBy { it.lot.lotNo }, + ) + sorted.map { row -> + val m = row.move + val item = items[m.itemId] + val inRef = inRefs[m.stockInLineId] + val outRef = outRefs[m.stockOutLineId] + val norm = m.rawType.uppercase() + val refType = when { + norm == "TKE" -> "TKE" + norm == "ADJ" && (outRef?.stockTake == true || inRef?.stockTake == true) -> "TKE" + else -> m.rawType + } + val total = totals[m.itemCode] + reportRow( + stockSubCategory = item?.type.orEmpty(), + itemNo = item?.code.orEmpty(), + itemName = item?.name.orEmpty(), + unitOfMeasure = item?.uom.orEmpty(), + lotNo = row.lot.lotNo, + expiryDate = row.lot.expiryDate, + trnDate = m.trnDate, + trnRefNo = typeLabel(refType), + storeLocation = row.lot.storeLocation, + orderRefNo = outRef?.orderRefNo ?: inRef?.orderRefNo ?: "", + jobOrderNo = inRef?.jobOrderNo.orEmpty(), + inQty = m.inQty, + outQty = m.outQty, + opening = m.bal - m.inQty + m.outQty, + balance = m.bal, + totalOpening = total?.opening ?: 0.0, + totalIn = total?.inSum ?: 0.0, + totalOut = total?.outSum ?: 0.0, + totalBalance = total?.closing ?: 0.0, + ) + } + } - '' AS openingBalance, - '' AS cumStockIn, - '' AS cumStockOut, - '' AS currentBalance, - '' AS lastInDate, - '' AS lastOutDate, - '' AS reOrderLevel, - '' AS reOrderQty - FROM stock_ledger sl - LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id - AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in - ON sil.inventoryLotId = il_in.id - AND il_in.deleted = 0 + val totalMs = (System.nanoTime() - tAll) / 1_000_000 + timings.add("search.total ${totalMs}ms") + log.info("stock-ledger DONE {}", timings.joinToString(" | ")) + return rows + } - LEFT JOIN stock_out_line sol - ON sl.stockOutLineId = sol.id - AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out - ON sol.inventoryLotLineId = ill_out.id - AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out - ON ill_out.inventoryLotId = il_out.id - AND il_out.deleted = 0 + private fun reportRow( + stockSubCategory: String, + itemNo: String, + itemName: String, + unitOfMeasure: String, + lotNo: String, + expiryDate: String, + trnDate: String, + trnRefNo: String, + storeLocation: String, + orderRefNo: String, + jobOrderNo: String, + inQty: Double, + outQty: Double, + opening: Double, + balance: Double, + totalOpening: Double, + totalIn: Double, + totalOut: Double, + totalBalance: Double, + ): Map { + val row = HashMap(32) + row["stockSubCategory"] = stockSubCategory + row["itemNo"] = itemNo + row["itemName"] = itemName + row["unitOfMeasure"] = unitOfMeasure + row["lotNo"] = lotNo + row["expiryDate"] = expiryDate + row["trnDate"] = trnDate + row["trnRefNo"] = trnRefNo + row["storeLocation"] = storeLocation + row["orderRefNo"] = orderRefNo + row["jobOrderNo"] = jobOrderNo + row["openingBalance"] = "" + row["cumStockIn"] = "" + row["cumStockOut"] = "" + row["currentBalance"] = "" + row["lastInDate"] = "" + row["lastOutDate"] = "" + row["reOrderLevel"] = "" + row["reOrderQty"] = "" + row["stockIn"] = formatQty(inQty) + row["stockOut"] = formatQty(outQty) + row["cumOpeningBal"] = formatQty(opening) + row["cumBalance"] = formatQty(balance) + row["totalCumOpeningBal"] = formatQty(totalOpening) + row["totalStockIn"] = formatQty(totalIn) + row["totalStockOut"] = formatQty(totalOut) + row["totalCumBalance"] = formatQty(totalBalance) + return row + } - LEFT JOIN ( - SELECT - il.id AS lotId, - il.lotNo AS lotNo, - il.expiryDate AS expiryDate, - MAX(wh.code) AS storeLocation - FROM inventory_lot il - LEFT JOIN inventory_lot_line ill - ON ill.inventoryLotId = il.id - AND ill.deleted = 0 - LEFT JOIN warehouse wh - ON ill.warehouseId = wh.id - AND wh.deleted = 0 - WHERE il.deleted = 0 - GROUP BY - il.id, il.lotNo, il.expiryDate - ) lot - ON lot.lotId = COALESCE(il_in.id, il_out.id) + private fun typeLabel(raw: String): String = when (raw.uppercase()) { + "OPEN" -> "開倉" + "NOR" -> "出入倉" + "TRF" -> "轉倉" + "ADJ" -> "調整" + "MISS" -> "遺失" + "BAD" -> "不良品" + "TKE" -> "盤點" + else -> raw + } - LEFT JOIN items it - ON sl.itemId = it.id - AND it.deleted = 0 - LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 - AND iu.deleted = 0 - LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id + /** Same shape as MySQL FORMAT(n, 0), negatives in parentheses. */ + private fun formatQty(value: Double): String { + val rounded = BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP) + val negative = rounded.signum() < 0 + val digits = rounded.abs().toPlainString() + val grouped = digits.reversed().chunked(3).joinToString(",").reversed() + return if (negative) "($grouped)" else grouped + } - LEFT JOIN stock_out so - ON sol.stockOutId = so.id - AND so.deleted = 0 - LEFT JOIN pick_order_line pol - ON sol.pickOrderLineId = pol.id - AND pol.deleted = 0 - LEFT JOIN pick_order po_out - ON pol.poId = po_out.id - AND po_out.deleted = 0 - LEFT JOIN job_order jo_po - ON po_out.joId = jo_po.id - AND jo_po.deleted = 0 - LEFT JOIN delivery_order do - ON po_out.doId = do.id - AND do.deleted = 0 - LEFT JOIN stock_in si - ON sil.stockInId = si.id - AND si.deleted = 0 - LEFT JOIN job_order jo - ON sil.jobOrderId = jo.id - AND jo.deleted = 0 - LEFT JOIN purchase_order po - ON sil.purchaseOrderId = po.id - AND po.deleted = 0 - WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND sl.date >= :reportPeriodStart - AND sl.date < :reportPeriodEndExclusive - $stockCategorySql - $itemCodeSql - $storeLocationSql - AND lot.lotId IS NOT NULL - ) x -) y -ORDER BY - itemNo, - trnDateRaw, - slId, - lotNo -""".trimIndent() + private fun buildCategoryItemFilter( + stockCategory: String?, + args: MutableMap, + ): String { + if (stockCategory.isNullOrBlank()) return "" + val values = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (values.isEmpty()) return "" + val ors = values.mapIndexed { index, value -> + val name = "stockCategory_$index" + args[name] = value + "it.type = :$name" + } + return """ + AND sl.itemId IN ( + SELECT it.id FROM items it + WHERE it.deleted = 0 AND (${ors.joinToString(" OR ")}) + ) + """.trimIndent() + } - return jdbcDao.queryForList(sql, args) + private fun queryChunks(ids: Collection, sql: String): List> { + val distinct = ids.filter { it > 0 }.distinct() + if (distinct.isEmpty()) return emptyList() + val out = ArrayList>() + for (chunk in distinct.chunked(800)) { + out.addAll(jdbcDao.queryForList(sql, mapOf("ids" to chunk))) + } + return out } - /** LIKE 多值工具方法 */ private fun buildMultiValueLikeClause( paramValue: String?, columnName: String, paramPrefix: String, - args: MutableMap + args: MutableMap, ): String { if (paramValue.isNullOrBlank()) return "" val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } if (values.isEmpty()) return "" - val conditions = values.mapIndexed { index, value -> val paramName = "${paramPrefix}_$index" args[paramName] = "%$value%" @@ -297,22 +537,61 @@ ORDER BY return "AND (${conditions.joinToString(" OR ")})" } - /** = 多值工具方法 */ - private fun buildMultiValueExactClause( - paramValue: String?, - columnName: String, - paramPrefix: String, - args: MutableMap - ): String { - if (paramValue.isNullOrBlank()) return "" - val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } - if (values.isEmpty()) return "" + private fun toDouble(v: Any?): Double { + if (v == null) return 0.0 + if (v is Number) return v.toDouble() + return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + } - val conditions = values.mapIndexed { index, value -> - val paramName = "${paramPrefix}_$index" - args[paramName] = value - "$columnName = :$paramName" + private fun toLong(v: Any?): Long { + if (v == null) return 0L + if (v is Number) return v.toLong() + return v.toString().toLongOrNull() ?: 0L + } + + private data class LedgerMove( + val slId: Long, + val trnDate: String, + val rawType: String, + val itemId: Long, + val itemCode: String, + val inQty: Double, + val outQty: Double, + val bal: Double, + val lotLineId: Long, + val stockInLineId: Long, + val stockOutLineId: Long, + ) + + private data class MoveWithLot( + val move: LedgerMove, + val lot: LotInfo, + ) + + private data class LotInfo( + val lotNo: String, + val expiryDate: String, + val storeLocation: String, + ) + + private class ItemTotal { + var firstDate: String? = null + var firstId: Long = 0L + var lastDate: String? = null + var lastId: Long = 0L + var opening: Double = 0.0 + var closing: Double = 0.0 + var inSum: Double = 0.0 + var outSum: Double = 0.0 + + fun isBeforeFirst(date: String, id: Long): Boolean { + val current = firstDate ?: return true + return date < current || (date == current && id < firstId) + } + + fun isAfterLast(date: String, id: Long): Boolean { + val current = lastDate ?: return true + return date > current || (date == current && id > lastId) } - return "AND (${conditions.joinToString(" OR ")})" } } diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt index 5c2d666b..c6bdb0f8 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt @@ -159,7 +159,7 @@ class FGStockOutTraceabilityReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { @@ -216,7 +216,7 @@ class FGStockOutTraceabilityReportController( alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") borderTop = BorderStyle.THICK borderBottom = BorderStyle.THICK borderLeft = BorderStyle.THIN diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt index 73902a82..01b4697f 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt @@ -271,7 +271,7 @@ class ItemQcFailReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df = workbook.creationHelper.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val decimalNumberStyle = workbook.createCellStyle().apply { @@ -282,7 +282,7 @@ class ItemQcFailReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df = workbook.creationHelper.createDataFormat() - dataFormat = df.getFormat("#,##0.##") + dataFormat = df.getFormat("#,##0.00") } val headers = listOf( diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt index 17834aff..0000e717 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt @@ -184,7 +184,7 @@ class MaterialStockOutTraceabilityReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { @@ -222,7 +222,7 @@ class MaterialStockOutTraceabilityReportController( alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") borderTop = BorderStyle.THIN borderBottom = BorderStyle.THICK borderLeft = BorderStyle.THIN diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index 324af163..7875a416 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -482,7 +482,7 @@ class ReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { alignment = HorizontalAlignment.RIGHT @@ -874,7 +874,7 @@ class ReportController( setTextCell(r, 4, m["dnNo"], styles.text) setTextCell(r, 5, m["deliveryOrderNo"], styles.text) setTextCell(r, 6, m["customerName"], styles.text) - setNumberCellFromFormatted(r, 7, m["qty"], styles.int, styles.dash, preferInt = true) + setNumberCellFromFormatted(r, 7, m["qty"], styles.int, styles.dash, preferInt = false) setTextCell(r, 8, m["truckNo"], styles.center) itemTotalQty += parseSignedNumber(m["qty"]) ?: 0.0 @@ -956,13 +956,13 @@ class ReportController( setTextCell(r, 0, m["itemNo"], styles.text) setTextCell(r, 1, m["itemName"], styles.text) setTextCell(r, 2, m["unitOfMeasure"], styles.center) - setNumberCellFromFormatted(r, 3, m["totalOpeningBalance"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 4, m["totalCumStockIn"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 5, m["totalCumStockOut"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 6, m["totalMisInputAndLost"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.int, styles.dash, preferInt = true) - setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.int, styles.dash, preferInt = true) + setNumberCellFromFormatted(r, 3, m["totalOpeningBalance"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 4, m["totalCumStockIn"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 5, m["totalCumStockOut"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 6, m["totalMisInputAndLost"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.number, styles.dash, preferInt = false) + setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.number, styles.dash, preferInt = false) setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) } diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 2362797d..7bc10840 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -174,7 +174,7 @@ class SemiFGProductionAnalysisReportController( borderBottom = BorderStyle.THIN borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN - dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") + dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt index 97a5abdd..fae51b4b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt @@ -191,7 +191,7 @@ class StockItemConsumptionTrendReportController( borderBottom = BorderStyle.THIN borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN - dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") + dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index 53450321..01b5bf20 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -1,4 +1,5 @@ package com.ffii.fpsms.modules.report.web +import org.slf4j.LoggerFactory import org.springframework.web.bind.annotation.* import org.springframework.http.* import java.time.LocalDate @@ -26,6 +27,7 @@ class StockLedgerReportController( private val reportService: ReportService, private val stockLedgerReportService: StockLedgerReportService, ) { + private val log = LoggerFactory.getLogger(StockLedgerReportController::class.java) private data class ExcelStyles( val title: CellStyle, val subtitle: CellStyle, @@ -64,6 +66,7 @@ class StockLedgerReportController( parameters["reportPeriodStart"] = reportPeriodStart ?: "" parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" + val tSearch = System.nanoTime() val dbData = stockLedgerReportService.searchStockLedgerReport( stockCategory = stockCategory, itemCode = itemCode, @@ -71,12 +74,20 @@ class StockLedgerReportController( reportPeriodStart = reportPeriodStart, reportPeriodEnd = reportPeriodEnd, ) - + val searchMs = (System.nanoTime() - tSearch) / 1_000_000 + val tPdf = System.nanoTime() val pdfBytes = reportService.createPdfResponse( "/jasper/StockLedgarReport.jrxml", parameters, dbData, ) + log.info( + "stock-ledger pdf rows={} search={}ms pdf={}ms bytes={}", + dbData.size, + searchMs, + (System.nanoTime() - tPdf) / 1_000_000, + pdfBytes.size, + ) val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_PDF @@ -95,6 +106,7 @@ class StockLedgerReportController( @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, ): ResponseEntity { + val tSearch = System.nanoTime() val dbData = stockLedgerReportService.searchStockLedgerReport( stockCategory = stockCategory, itemCode = itemCode, @@ -102,12 +114,20 @@ class StockLedgerReportController( reportPeriodStart = reportPeriodStart, reportPeriodEnd = reportPeriodEnd, ) - + val searchMs = (System.nanoTime() - tSearch) / 1_000_000 + val tExcel = System.nanoTime() val excelBytes = createStockLedgerExcel( dbData = dbData, reportPeriodStart = reportPeriodStart ?: "", reportPeriodEnd = reportPeriodEnd ?: "", ) + log.info( + "stock-ledger excel rows={} search={}ms excel={}ms bytes={}", + dbData.size, + searchMs, + (System.nanoTime() - tExcel) / 1_000_000, + excelBytes.size, + ) val headers = HttpHeaders().apply { contentType = MediaType.parseMediaType( @@ -174,7 +194,7 @@ class StockLedgerReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT @@ -188,7 +208,7 @@ class StockLedgerReportController( alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") borderTop = BorderStyle.THICK borderBottom = BorderStyle.THICK borderLeft = BorderStyle.THIN @@ -270,7 +290,7 @@ class StockLedgerReportController( return } if (parsed < 0) { - val s = "%,d".format(kotlin.math.abs(parsed).toLong()) + val s = "%,.2f".format(kotlin.math.abs(parsed)) cell.setCellValue("($s)") cell.cellStyle = dashStyle } else { diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt index 818647d0..0f510912 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt @@ -151,7 +151,7 @@ class StockLotBalanceReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0.##") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt index 083d5586..d8ac95da 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt @@ -145,7 +145,7 @@ class StockLotLedgerReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0.##") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { alignment = HorizontalAlignment.RIGHT diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt index bfaca59f..b457e2c0 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt @@ -328,7 +328,7 @@ class StockTakeVarianceReportController( borderLeft = BorderStyle.THIN borderRight = BorderStyle.THIN val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") } val dashStyle = workbook.createCellStyle().apply { @@ -344,7 +344,7 @@ class StockTakeVarianceReportController( alignment = HorizontalAlignment.RIGHT verticalAlignment = VerticalAlignment.CENTER val df: DataFormat = workbook.createDataFormat() - dataFormat = df.getFormat("#,##0") + dataFormat = df.getFormat("#,##0.00") borderTop = BorderStyle.THICK borderBottom = BorderStyle.THICK borderLeft = BorderStyle.THIN @@ -578,8 +578,7 @@ class StockTakeVarianceReportController( } private fun formatAbsInt(v: Double): String { - val abs = kotlin.math.abs(v).toLong() - return "%,d".format(abs) + return "%,.2f".format(kotlin.math.abs(v)) } private data class Rep012ReportData( diff --git a/src/main/resources/jasper/StockBalanceReport.jrxml b/src/main/resources/jasper/StockBalanceReport.jrxml index 072560bc..b31e17a6 100644 --- a/src/main/resources/jasper/StockBalanceReport.jrxml +++ b/src/main/resources/jasper/StockBalanceReport.jrxml @@ -71,7 +71,7 @@ - + diff --git a/src/main/resources/jasper/StockLedgarReport.jrxml b/src/main/resources/jasper/StockLedgarReport.jrxml index a245642f..a85ee22a 100644 --- a/src/main/resources/jasper/StockLedgarReport.jrxml +++ b/src/main/resources/jasper/StockLedgarReport.jrxml @@ -73,7 +73,7 @@ - + From 3976d28c8851dfe37bae288ef59e75bd92a48ac7 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 22 Sep 2026 12:23:45 +0800 Subject: [PATCH 14/52] split stockledgerfixservice file --- .../master/service/StockLedgerFixService.kt | 3663 +---------------- .../ledgerfix/StockLedgerFixAdjService.kt | 613 +++ .../StockLedgerFixBackfillService.kt | 1403 +++++++ .../ledgerfix/StockLedgerFixExportService.kt | 625 +++ .../ledgerfix/StockLedgerFixInventorySql.kt | 246 ++ .../ledgerfix/StockLedgerFixQueryService.kt | 703 ++++ .../ledgerfix/StockLedgerFixSupport.kt | 137 + 7 files changed, 3762 insertions(+), 3628 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt index 1051ec2d..a55c197d 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt @@ -1,3669 +1,76 @@ package com.ffii.fpsms.modules.master.service -import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixAdjService +import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixBackfillService +import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixExportService +import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixQueryService import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjPreview import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjResponse -import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRow import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse -import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCheckPart import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail -import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayStatus import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryResponse import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunResponse import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit -import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService -import org.slf4j.LoggerFactory -import org.springframework.jdbc.core.RowCallbackHandler -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate -import org.springframework.jdbc.support.GeneratedKeyHolder import org.springframework.stereotype.Service -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.annotation.Transactional -import org.springframework.transaction.support.TransactionTemplate -import java.io.BufferedWriter import java.io.OutputStream -import java.io.OutputStreamWriter -import java.math.BigDecimal -import java.nio.charset.StandardCharsets -import java.sql.ResultSet import java.time.LocalDate -import java.time.format.DateTimeParseException -import java.util.concurrent.ConcurrentHashMap -import javax.sql.DataSource /** - * Inventory 1.0 (A) then per-day stock_ledger backfill (2.1–2.6) and [stock_lot_day]. - * 2.4 opening looks back to last lotQtyAfter (not yesterday day-table or 0). - * 2.7: (1) over-issue `lineIn < lineOut` → set lot inQty = outQty (trigger → inventory 0) - * plus SIL+ledger ADJ in; (2) miss ADJ: line vs Σ ledger, SIL/SOL on existing lot, no lot qty write. - * One Apply shares a single stock_in header and a single stock_out header (reused by adjDate); - * each lot is a line on that document. Ledger FKs the line ids. - * - * SIL/SOL have no inventoryId; lot is inventoryLotLineId / inventoryLotId only. + * Facade for stock-ledger-fix. Work is split across query, backfill, ADJ, and SQL export. + * Inventory 1.0 then per-day stock_ledger backfill (2.1–2.6) and stock_lot_day. */ @Service open class StockLedgerFixService( - private val jdbcDao: JdbcDao, - private val transactionManager: PlatformTransactionManager, - private val dataSource: DataSource, - private val stockLotDayCloseService: StockLotDayCloseService, + private val query: StockLedgerFixQueryService, + private val backfill: StockLedgerFixBackfillService, + private val adj: StockLedgerFixAdjService, + private val export: StockLedgerFixExportService, ) { - private val log = LoggerFactory.getLogger(StockLedgerFixService::class.java) - private val namedJdbc = NamedParameterJdbcTemplate(dataSource) - private val inFlight = ConcurrentHashMap.newKeySet() - private val inventoryLock = "inventory-1.0" - private val runLock = "ledger-fix-run" + open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse = + query.calendar(from, to) - open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse { - if (to.isBefore(from)) { - throw IllegalArgumentException("to must be on or after from") - } - if (from.plusDays(62).isBefore(to)) { - throw IllegalArgumentException("date range must be at most 62 days") - } - val args = mapOf("from" to from, "toNext" to to.plusDays(1)) - val rows = jdbcDao.queryForList( - """ - SELECT - DATE_FORMAT(sl.date, '%Y-%m-%d') AS d, - COUNT(*) AS cnt, - SUM(sl.inventoryLotLineId IS NULL) AS missLot, - SUM(sl.uomId IS NULL) AS missUom, - SUM(sl.inventoryId IS NULL) AS missInventoryId, - SUM(sl.lotQtyAfter IS NULL) AS missLotQty - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date >= :from - AND sl.date < :toNext - GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') - ORDER BY d - """.trimIndent(), - args, - ) - val dayLots = jdbcDao.queryForList( - """ - SELECT DATE_FORMAT(date, '%Y-%m-%d') AS d, COUNT(*) AS lots - FROM stock_lot_day - WHERE deleted = 0 - AND date >= :from - AND date < :toNext - GROUP BY DATE_FORMAT(date, '%Y-%m-%d') - """.trimIndent(), - args, - ).associate { row -> - row["d"].toString() to toInt(row["lots"]) - } - val days = rows.map { row -> - val d = row["d"].toString() - StockLedgerFixDayStatus( - date = d, - cnt = toInt(row["cnt"]), - missLot = toInt(row["missLot"]), - missUom = toInt(row["missUom"]), - missInventoryId = toInt(row["missInventoryId"]), - missLotQty = toInt(row["missLotQty"]), - dayTableLots = dayLots[d] ?: 0, - ) - } - return StockLedgerFixCalendarResponse( - from = from.toString(), - to = to.toString(), - days = days, - ) - } + open fun inventoryPreview(): StockLedgerFixInventoryPreview = query.inventoryPreview() - /** - * A: keep existing inventory ids; - * backfill stockUomId from lot stock UOM (and set uomId to base) — one row per - * (itemId, stockUomId) so uk_inventory_item_stock_uom is not violated; - * INSERT missing (itemId, stockUomId); - * soft-delete leftover NULL stockUomId rows on items that already have a bucket; - * recalc onHand/unavailable from Σ lot qty by item+stock UOM (0 if no lots). - * Does not write inventory.onHoldQty (hold stays on lot line). Does not UPDATE inventory_lot_line. - */ - open fun inventoryPreview(): StockLedgerFixInventoryPreview { - val inventoryRows = toInt( - jdbcDao.queryForList( - "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", - ).firstOrNull()?.get("c"), - ) - val lotUomPairs = toInt( - jdbcDao.queryForList(LOT_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), - ) - val missingUomPairs = toInt( - jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), - ) - val nullStockUomId = toInt( - jdbcDao.queryForList(NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), - ) - return StockLedgerFixInventoryPreview( - inventoryRows = inventoryRows, - lotUomPairs = lotUomPairs, - missingUomPairs = missingUomPairs, - nullStockUomId = nullStockUomId, - ) - } + open fun fixInventory10(): StockLedgerFixInventoryResponse = backfill.fixInventory10() - @Transactional(timeout = 300) - open fun fixInventory10(): StockLedgerFixInventoryResponse { - if (!inFlight.add(inventoryLock)) { - throw IllegalStateException("inventory 1.0 already running") - } - try { - val patchedFromUom = jdbcDao.executeUpdate(BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL) - val patchedSingleLot = jdbcDao.executeUpdate(BACKFILL_STOCK_UOM_SINGLE_LOT_SQL) - val patchedStockUomId = patchedFromUom + patchedSingleLot - val inserted = jdbcDao.executeUpdate(INSERT_MISSING_INVENTORY_SQL) - val orphansDeleted = jdbcDao.executeUpdate(SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL) - val updated = jdbcDao.executeUpdate(UPDATE_INVENTORY_QTY_SQL) - val missingAfter = toInt( - jdbcDao.queryForList(MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), - ) - val nullStockUomIdAfter = toInt( - jdbcDao.queryForList(NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), - ) - log.info( - "stock-ledger-fix inventory-1.0 patchedStockUomId={} inserted={} orphansDeleted={} updated={} missingAfter={} nullStockUomIdAfter={}", - patchedStockUomId, - inserted, - orphansDeleted, - updated, - missingAfter, - nullStockUomIdAfter, - ) - return StockLedgerFixInventoryResponse( - inserted = inserted, - updated = updated, - missingUomPairsAfter = missingAfter, - patchedStockUomId = patchedStockUomId, - nullStockUomIdAfter = nullStockUomIdAfter, - orphansDeleted = orphansDeleted, - ) - } finally { - inFlight.remove(inventoryLock) - } - } + open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail = query.dayDetail(day) - open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail { - val args = dayArgs(day) - val ledger = jdbcDao.queryForList( - """ - SELECT - COUNT(*) AS cnt, - SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, - SUM( - sl.inventoryLotLineId IS NOT NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - AND sl.inventoryLotLineId <> COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) - ) AS lotIncorrect, - SUM(sl.uomId IS NULL) AS uomMiss, - SUM( - sl.inventoryLotLineId IS NOT NULL - AND sl.uomId IS NOT NULL - AND iu.uomId IS NOT NULL - AND sl.uomId <> iu.uomId - ) AS uomIncorrect, - SUM(sl.inventoryId IS NULL) AS invMiss, - SUM( - sl.inventoryId IS NOT NULL - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM inventory inv - WHERE inv.id = sl.inventoryId - AND IFNULL(inv.deleted, 0) = 0 - AND inv.itemId = sl.itemId - AND COALESCE(inv.stockUomId, inv.uomId) = sl.uomId - ) - ) AS invIncorrect, - SUM(sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) AS lotQtyMiss, - SUM( - sl.inventoryLotLineId IS NOT NULL - AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) - ) AS lotQtyMissWithLot, - SUM( - sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND ( - sl.lotQtyAfter - sl.lotQtyBefore - <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - ) - ) AS lotQtyIncorrect, - SUM( - sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND sl.lotQtyAfter - sl.lotQtyBefore - = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) - ) AS overIssue, - SUM(sl.balance IS NULL) AS balanceMiss, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - ) AS fixable21a, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND sil.inventoryLotId IS NOT NULL - AND IFNULL(lc.lineCnt, 0) = 1 - ) AS fixable21b, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND sil.inventoryLotId IS NOT NULL - AND IFNULL(lc.lineCnt, 0) > 1 - ) AS cannotMultiLine, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) - ) AS cannotNoSource - FROM stock_ledger sl - LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId - LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId - LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId - LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId - LEFT JOIN ( - SELECT inventoryLotId, COUNT(*) AS lineCnt - FROM inventory_lot_line - WHERE IFNULL(deleted, 0) = 0 - GROUP BY inventoryLotId - ) lc ON lc.inventoryLotId = sil.inventoryLotId - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() + open fun searchInventory(qRaw: String): List = + query.searchInventory(qRaw) - val cnt = toInt(ledger["cnt"]) - val lotMiss = toInt(ledger["lotMiss"]) - val lotIncorrect = toInt(ledger["lotIncorrect"]) - val uomMiss = toInt(ledger["uomMiss"]) - val uomIncorrect = toInt(ledger["uomIncorrect"]) - val invMiss = toInt(ledger["invMiss"]) - val invIncorrect = toInt(ledger["invIncorrect"]) - val lotQtyMissWithLot = toInt(ledger["lotQtyMissWithLot"]) - val lotQtyIncorrect = toInt(ledger["lotQtyIncorrect"]) - val overIssue = toInt(ledger["overIssue"]) - val balanceMiss = toInt(ledger["balanceMiss"]) + open fun searchLot(qRaw: String): List = query.searchLot(qRaw) - val dayTable = jdbcDao.queryForList( - """ - SELECT - COUNT(*) AS expected, - SUM(d.id IS NULL) AS miss, - SUM( - d.id IS NOT NULL AND ( - (x.kind = 'move' AND ( - d.closing <> lastSl.lotQtyAfter - OR d.opening <> firstSl.lotQtyBefore - OR d.inQty <> x.inQty - OR d.outQty <> x.outQty - )) - OR (x.kind = 'carry' AND ( - d.inQty <> 0 - OR d.outQty <> 0 - OR d.opening <> x.prevClosing - OR d.closing <> x.prevClosing - )) - ) - ) AS incorrect - FROM ( - SELECT - sl.inventoryLotLineId, - 'move' AS kind, - MIN(sl.id) AS firstId, - MAX(sl.id) AS lastId, - CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, - CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty, - CAST(NULL AS DECIMAL(14,2)) AS prevClosing - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId IS NOT NULL - GROUP BY sl.inventoryLotLineId + open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail = + query.inventoryScopeDetail(inventoryId) - UNION ALL + open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail = + query.lotScopeDetail(lotLineId) - SELECT - prev.inventoryLotLineId, - 'carry' AS kind, - NULL AS firstId, - NULL AS lastId, - CAST(0 AS DECIMAL(14,2)) AS inQty, - CAST(0 AS DECIMAL(14,2)) AS outQty, - prev.closing AS prevClosing - FROM stock_lot_day prev - WHERE prev.date = :prevDay - AND IFNULL(prev.deleted, 0) = 0 - AND prev.closing > 0 - AND NOT EXISTS ( - SELECT 1 FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId = prev.inventoryLotLineId - ) - ) x - LEFT JOIN stock_ledger firstSl ON firstSl.id = x.firstId - LEFT JOIN stock_ledger lastSl ON lastSl.id = x.lastId - LEFT JOIN stock_lot_day d - ON d.inventoryLotLineId = x.inventoryLotLineId - AND d.date = :day - AND IFNULL(d.deleted, 0) = 0 - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() + open fun fixInventoryScope(inventoryId: Long): StockLedgerFixRunResponse = + backfill.fixInventoryScope(inventoryId) - val dayLots = toInt(dayTable["expected"]) - val dayMiss = toInt(dayTable["miss"]) - val dayIncorrect = toInt(dayTable["incorrect"]) - // No lot ids yet → SQL finds 0 lots, 0/0/0 would look "correct". Ledger exists but day table cannot be built. - val dayTablePending = cnt > 0 && dayLots == 0 - val dayTableMiss = if (dayTablePending) 1 else dayMiss - val dayTableTotal = if (dayTablePending) 1 else dayLots + open fun fixLotScope(lotLineId: Long): StockLedgerFixRunResponse = + backfill.fixLotScope(lotLineId) - fun part(key: String, label: String, miss: Int, incorrect: Int, total: Int) = - StockLedgerFixCheckPart( - key = key, - label = label, - ok = (total - miss - incorrect).coerceAtLeast(0), - miss = miss, - incorrect = incorrect, - ) + open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview = + adj.adjPreview(rowLimit, adjDateRaw) - return StockLedgerFixDayDetail( - date = day.toString(), - cnt = cnt, - parts = fieldAndReasonParts( - cnt = cnt, - lotMiss = lotMiss, - lotIncorrect = lotIncorrect, - uomMiss = uomMiss, - uomIncorrect = uomIncorrect, - invMiss = invMiss, - invIncorrect = invIncorrect, - lotQtyMissWithLot = lotQtyMissWithLot, - lotQtyIncorrect = lotQtyIncorrect, - overIssue = overIssue, - balanceMiss = balanceMiss, - fixable21a = toInt(ledger["fixable21a"]), - fixable21b = toInt(ledger["fixable21b"]), - cannotMultiLine = toInt(ledger["cannotMultiLine"]), - cannotNoSource = toInt(ledger["cannotNoSource"]), - earlyTkeOver = 0, - otherOver = 0, - extra = listOf( - part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal), - ), - ), - ) - } + open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse = + adj.applyAdjAlign(adjDateRaw) - open fun searchInventory(qRaw: String): List { - val q = qRaw.trim() - if (q.isEmpty()) throw IllegalArgumentException("q is required") - val args = mapOf("q" to q, "like" to "%$q%") - return jdbcDao.queryForList( - """ - SELECT - i.id AS inventoryId, - i.itemId AS itemId, - it.code AS itemCode, - i.uomId AS uomId, - ( - SELECT COUNT(*) FROM stock_ledger sl - WHERE sl.deleted = 0 AND sl.inventoryId = i.id - ) AS ledgerCnt - FROM inventory i - LEFT JOIN items it ON it.id = i.itemId - WHERE IFNULL(i.deleted, 0) = 0 - AND ( - CAST(i.id AS CHAR) = :q - OR CAST(i.itemId AS CHAR) = :q - OR it.code LIKE :like - ) - ORDER BY ledgerCnt DESC, i.id - LIMIT 20 - """.trimIndent(), - args, - ).map { row -> - StockLedgerFixSearchInventoryHit( - inventoryId = toLong(row["inventoryId"]), - itemId = toLongOrNull(row["itemId"]), - itemCode = row["itemCode"]?.toString(), - uomId = toLongOrNull(row["uomId"]), - ledgerCnt = toInt(row["ledgerCnt"]), - ) - } - } + open fun fixDay(dateRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse = + backfill.fixDay(dateRaw, stepsRaw) - open fun searchLot(qRaw: String): List { - val q = qRaw.trim() - if (q.isEmpty()) throw IllegalArgumentException("q is required") - val args = mapOf("q" to q, "like" to "%$q%") - return jdbcDao.queryForList( - """ - SELECT - ill.id AS inventoryLotLineId, - il.lotNo AS lotNo, - it.code AS itemCode, - ( - SELECT sl.inventoryId FROM stock_ledger sl - WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id - AND sl.inventoryId IS NOT NULL - ORDER BY sl.id DESC LIMIT 1 - ) AS inventoryId, - ( - SELECT COUNT(*) FROM stock_ledger sl - WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id - ) AS ledgerCnt - FROM inventory_lot_line ill - INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId - LEFT JOIN items it ON it.id = il.itemId - WHERE IFNULL(ill.deleted, 0) = 0 - AND ( - CAST(ill.id AS CHAR) = :q - OR il.lotNo LIKE :like - ) - ORDER BY ledgerCnt DESC, ill.id - LIMIT 20 - """.trimIndent(), - args, - ).map { row -> - StockLedgerFixSearchLotHit( - inventoryLotLineId = toLong(row["inventoryLotLineId"]), - lotNo = row["lotNo"]?.toString(), - itemCode = row["itemCode"]?.toString(), - inventoryId = toLongOrNull(row["inventoryId"]), - ledgerCnt = toInt(row["ledgerCnt"]), - ) - } - } + open fun fixRange(fromRaw: String, toRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse = + backfill.fixRange(fromRaw, toRaw, stepsRaw) - open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail { - val args = mapOf("inventoryId" to inventoryId, "today" to inventoryScopeToExclusive()) - val head = jdbcDao.queryForList( - """ - SELECT - i.id AS inventoryId, - i.itemId AS itemId, - it.code AS itemCode, - i.uomId AS uomId - FROM inventory i - LEFT JOIN items it ON it.id = i.itemId - WHERE i.id = :inventoryId - """.trimIndent(), - args, - ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") - val stats = ledgerScopeStats( - "sl.inventoryId = :inventoryId", - args, - itemId = toLongOrNull(head["itemId"]), - ) - return StockLedgerFixScopeDetail( - kind = "inventory", - id = inventoryId, - itemCode = head["itemCode"]?.toString(), - lotNo = null, - inventoryId = inventoryId, - uomId = toLongOrNull(head["uomId"]), - firstDate = stats.firstDate, - lastDate = stats.lastDate, - cnt = stats.cnt, - lastBalance = stats.lastBalance, - lastLotQtyAfter = stats.lastLotQtyAfter, - parts = stats.parts, - ) - } - - open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail { - val today = LocalDate.now() - val args = mapOf("lotId" to lotLineId, "today" to today) - val head = jdbcDao.queryForList( - """ - SELECT - ill.id AS inventoryLotLineId, - il.lotNo AS lotNo, - il.itemId AS itemId, - it.code AS itemCode - FROM inventory_lot_line ill - INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId - LEFT JOIN items it ON it.id = il.itemId - WHERE ill.id = :lotId - """.trimIndent(), - args, - ).firstOrNull() ?: throw IllegalArgumentException("lot line not found: $lotLineId") - val stats = ledgerScopeStats( - "sl.inventoryLotLineId = :lotId", - args, - itemId = toLongOrNull(head["itemId"]), - ) - return StockLedgerFixScopeDetail( - kind = "lot", - id = lotLineId, - itemCode = head["itemCode"]?.toString(), - lotNo = head["lotNo"]?.toString(), - inventoryId = toLongOrNull(stats.lastInventoryId), - uomId = null, - firstDate = stats.firstDate, - lastDate = stats.lastDate, - cnt = stats.cnt, - lastBalance = stats.lastBalance, - lastLotQtyAfter = stats.lastLotQtyAfter, - parts = stats.parts, - ) - } - - /** - * Rebuild 2.1–2.6 for one inventory bucket through calendar today - * (`date < tomorrow`). Calendar day-fix still excludes today unless Allow today. - */ - open fun fixInventoryScope(inventoryId: Long): StockLedgerFixRunResponse { - if (inventoryId <= 0) throw IllegalArgumentException("inventoryId is required") - if (!inFlight.add(runLock)) throw IllegalStateException("fix already running") - val toExclusive = inventoryScopeToExclusive() - val t0 = System.nanoTime() - try { - val inv = jdbcDao.queryForList( - """ - SELECT id, itemId, uomId FROM inventory WHERE id = :inventoryId - """.trimIndent(), - mapOf("inventoryId" to inventoryId), - ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") - val args = mapOf( - "inventoryId" to inventoryId, - "itemId" to (inv["itemId"] ?: 0), - "uomId" to (inv["uomId"] ?: 0), - "today" to toExclusive, - ) - val filledLotLineId = timed("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } - val filledUomId = timed("inv 2.2") { fillUomIdScope(args, byInventory = true) } - val filledInventoryId = timed("inv 2.3") { fillInventoryIdScope(args, byInventory = true) } - val filledLotQty = timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } - val filledBalance = timed("inv 2.5") { fillBalanceHistory(args) } - val lotIds = lotIdsForInventory(inventoryId, toExclusive) - val range = dateRangeForFilter("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to toExclusive)) - val dayRows = if (range != null) { - timed("inv 2.6") { upsertStockLotDayRange(lotIds, range.first, range.second) } - } else 0 - val still = stillMissScope("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to toExclusive)) - log.info( - "stock-ledger-fix inventory {} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", - inventoryId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, filledBalance, dayRows, - (System.nanoTime() - t0) / 1_000_000, - ) - return StockLedgerFixRunResponse( - date = range?.first?.toString() ?: LocalDate.now().toString(), - filledLotLineId = filledLotLineId, - filledUomId = filledUomId, - filledInventoryId = filledInventoryId, - filledLotQty = filledLotQty, - filledBalance = filledBalance, - dayRowsWritten = dayRows, - stillMissLot = still.missLot, - stillMissUom = still.missUom, - stillMissInventoryId = still.missInventoryId, - stillMissLotQty = still.missLotQty, - ) - } catch (e: Exception) { - log.warn("stock-ledger-fix inventory {} FAILED after {}ms: {}", inventoryId, (System.nanoTime() - t0) / 1_000_000, e.message) - throw e - } finally { - inFlight.remove(runLock) - } - } - - open fun fixLotScope(lotLineId: Long): StockLedgerFixRunResponse { - if (lotLineId <= 0) throw IllegalArgumentException("lotLineId is required") - if (!inFlight.add(runLock)) throw IllegalStateException("fix already running") - val today = LocalDate.now() - val t0 = System.nanoTime() - try { - val exists = toInt( - jdbcDao.queryForList( - "SELECT COUNT(*) AS c FROM inventory_lot_line WHERE id = :lotId", - mapOf("lotId" to lotLineId), - ).firstOrNull()?.get("c"), - ) - if (exists == 0) throw IllegalArgumentException("lot line not found: $lotLineId") - val args = mapOf("lotId" to lotLineId, "today" to today) - val filledLotLineId = timed("lot 2.1") { fillLotLineIdScope(args, byInventory = false) } - val filledUomId = timed("lot 2.2") { fillUomIdScope(args, byInventory = false) } - val filledInventoryId = timed("lot 2.3") { fillInventoryIdScope(args, byInventory = false) } - val filledLotQty = timed("lot 2.4") { fillLotQtyHistory(args, byInventory = false) } - val range = dateRangeForFilter("inventoryLotLineId = :lotId", args) - val dayRows = if (range != null) { - timed("lot 2.6") { upsertStockLotDayRange(listOf(lotLineId), range.first, range.second) } - } else 0 - val still = stillMissScope("inventoryLotLineId = :lotId", args) - log.info( - "stock-ledger-fix lot {} lotLine={} uom={} inv={} lotQty={} dayRows={} total={}ms", - lotLineId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, dayRows, - (System.nanoTime() - t0) / 1_000_000, - ) - return StockLedgerFixRunResponse( - date = range?.first?.toString() ?: today.toString(), - filledLotLineId = filledLotLineId, - filledUomId = filledUomId, - filledInventoryId = filledInventoryId, - filledLotQty = filledLotQty, - filledBalance = 0, - dayRowsWritten = dayRows, - stillMissLot = still.missLot, - stillMissUom = still.missUom, - stillMissInventoryId = still.missInventoryId, - stillMissLotQty = still.missLotQty, - ) - } catch (e: Exception) { - log.warn("stock-ledger-fix lot {} FAILED after {}ms: {}", lotLineId, (System.nanoTime() - t0) / 1_000_000, e.message) - throw e - } finally { - inFlight.remove(runLock) - } - } - - /** - * Line in/out vs Σ ledger in/out. Any non-zero gap becomes ADJ on [adjDate] - * (default yesterday; pass today on freeze-night dump). - * Step 1: over-issue (`lineIn < lineOut`) sets lot inQty = outQty (trigger updates inventory) - * and writes SIL + ledger ADJ in. Step 2: miss ADJ (no further lot qty write). - * Positive miss → ADJ in/out; negative miss → reverse ADJ out/in. - * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). - * Ledger gap sum uses `date < adjDate+1`. - */ - open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview { - val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) - val t0 = System.nanoTime() - val gaps = loadAdjGaps(asOfExclusive) - val work = gaps.filter { it.hasWork() } - val over = work.filter { it.hasOverIssue() } - val miss = work.filter { it.hasMiss() } - val sumIn = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } - val sumOut = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } - val sumOver = over.fold(BigDecimal.ZERO) { a, g -> a + g.overIssue } - val limit = rowLimit.coerceIn(0, 50) - log.info( - "stock-ledger-fix adj preview lots={} over={} in={} out={} revIn={} revOut={} {}ms", - work.size, - over.size, - miss.count { it.missIn > BigDecimal.ZERO }, - miss.count { it.missOut > BigDecimal.ZERO }, - miss.count { it.missIn < BigDecimal.ZERO }, - miss.count { it.missOut < BigDecimal.ZERO }, - (System.nanoTime() - t0) / 1_000_000, - ) - val rows = (over + miss.filter { !it.hasOverIssue() }).distinctBy { it.lotLineId }.take(limit) - return StockLedgerFixAdjPreview( - adjDate = adjDate.toString(), - lotCount = work.size, - adjInCount = miss.count { it.missIn > BigDecimal.ZERO }, - adjOutCount = miss.count { it.missOut > BigDecimal.ZERO }, - skippedNegCount = miss.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO }, - sumMissIn = sumIn.stripTrailingZeros().toPlainString(), - sumMissOut = sumOut.stripTrailingZeros().toPlainString(), - skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(), - overIssueCount = over.size, - sumOverIssue = sumOver.stripTrailingZeros().toPlainString(), - rows = rows.map { it.toRow() }, - ) - } - - open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse { - if (!inFlight.add(runLock)) { - throw IllegalStateException("fix already running") - } - val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) - val t0 = System.nanoTime() - try { - val gaps = loadAdjGaps(asOfExclusive) - val work = gaps.filter { it.hasWork() } - val over = work.filter { it.hasOverIssue() } - val miss = work.filter { it.hasMiss() } - var insertedIn = 0 - var insertedOut = 0 - var overIssuePatched = 0 - if (work.isNotEmpty()) { - val insTx = TransactionTemplate(transactionManager) - insTx.timeout = 300 - insTx.execute { - var stockInId: Long? = null - var stockOutId: Long? = null - fun inHeader(): Long = - stockInId ?: ensureAdjStockInHeader(adjDate).also { stockInId = it } - fun outHeader(): Long = - stockOutId ?: ensureAdjStockOutHeader(adjDate).also { stockOutId = it } - over.forEach { g -> - if (patchLotRemainToZero(g.lotLineId)) { - insertAdjLedger( - g, adjDate, inQty = g.overIssue, outQty = null, - remarks = OVER_ISSUE_REMARKS, stockInId = inHeader(), - ) - insertedIn++ - overIssuePatched++ - } - } - miss.forEach { g -> - if (g.missIn.compareTo(BigDecimal.ZERO) > 0) { - insertAdjLedger(g, adjDate, inQty = g.missIn, outQty = null, stockInId = inHeader()) - insertedIn++ - } else if (g.missIn.compareTo(BigDecimal.ZERO) < 0) { - insertAdjLedger( - g, adjDate, inQty = null, outQty = g.missIn.negate(), stockOutId = outHeader(), - ) - insertedOut++ - } - if (g.missOut.compareTo(BigDecimal.ZERO) > 0) { - insertAdjLedger(g, adjDate, inQty = null, outQty = g.missOut, stockOutId = outHeader()) - insertedOut++ - } else if (g.missOut.compareTo(BigDecimal.ZERO) < 0) { - insertAdjLedger( - g, adjDate, inQty = g.missOut.negate(), outQty = null, stockInId = inHeader(), - ) - insertedIn++ - } - } - } - } - val lotIds = linkedSetOf() - fun addLot(lotId: Long, invId: Long?) { - if (lotId > 0) lotIds.add(lotId) - } - work.forEach { addLot(it.lotLineId, it.inventoryId) } - jdbcDao.queryForList( - """ - SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId - FROM stock_ledger - WHERE deleted = 0 - AND date >= :adjDate AND date < :asOf - AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' - """.trimIndent(), - mapOf("adjDate" to adjDate, "asOf" to asOfExclusive), - ).forEach { addLot(toLong(it["lotId"]), toLongOrNull(it["invId"])) } - val filledLotQty = timed("adj 2.4") { fillLotQtyForLotIds(lotIds.toList(), asOfExclusive) } - val filledBalance = timed("adj 2.5") { fillBalanceOnNewAdjRows(adjDate, asOfExclusive) } - val range = if (lotIds.isEmpty()) null else dateRangeForFilter( - "inventoryLotLineId IN (:lotIds)", - mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()), - ) - val dayRows = if (range != null && lotIds.isNotEmpty()) { - timed("adj 2.6") { upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } - } else 0 - log.info( - "stock-ledger-fix adj {} over={} in={} out={} lotQty={} bal={} day={} total={}ms", - adjDate, overIssuePatched, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, - (System.nanoTime() - t0) / 1_000_000, - ) - return StockLedgerFixAdjResponse( - adjDate = adjDate.toString(), - insertedIn = insertedIn, - insertedOut = insertedOut, - overIssuePatched = overIssuePatched, - filledLotQty = filledLotQty, - filledBalance = filledBalance, - dayRowsWritten = dayRows, - ) - } catch (e: Exception) { - log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message) - throw e - } finally { - inFlight.remove(runLock) - } - } - - /** - * No wrapping transaction: each step auto-commits so a later timeout - * (typically 2.5) does not roll back 2.1–2.4 lot fills. - * [stepsRaw] empty / all = 2.1–2.6; otherwise only those keys (e.g. 2.3). - */ - open fun fixDay(dateRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { - val day = parseDay(dateRaw) - // Allow today (freeze-night dump fix); reject future only. - if (day.isAfter(LocalDate.now())) { - throw IllegalArgumentException("cannot fix future dates") - } - val key = day.toString() - val steps = parseDaySteps(stepsRaw) - if (!inFlight.add(runLock)) { - throw IllegalStateException("fix already running") - } - val t0 = System.nanoTime() - try { - val args = dayArgs(day) - val filledLotLineId = if ("2.1" in steps) timed("2.1 fillLotLineId") { fillLotLineId(args) } else 0 - val filledUomId = if ("2.2" in steps) timed("2.2 fillUomId") { fillUomId(args) } else 0 - val filledInventoryId = if ("2.3" in steps) timed("2.3 fillInventoryId") { fillInventoryId(args) } else 0 - val filledLotQty = if ("2.4" in steps) timed("2.4 fillLotQty") { fillLotQty(args) } else 0 - val filledBalance = if ("2.5" in steps) timed("2.5 fillBalance") { fillBalance(args) } else 0 - val dayRowsWritten = if ("2.6" in steps) { - timed("2.6 upsertStockLotDay") { - stockLotDayCloseService.upsertDayForFix(day).rowsWritten - } - } else { - 0 - } - val still = stillMiss(args) - log.info( - "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", - key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, - filledLotQty, filledBalance, dayRowsWritten, - (System.nanoTime() - t0) / 1_000_000, - ) - return StockLedgerFixRunResponse( - date = key, - filledLotLineId = filledLotLineId, - filledUomId = filledUomId, - filledInventoryId = filledInventoryId, - filledLotQty = filledLotQty, - filledBalance = filledBalance, - dayRowsWritten = dayRowsWritten, - stillMissLot = still.missLot, - stillMissUom = still.missUom, - stillMissInventoryId = still.missInventoryId, - stillMissLotQty = still.missLotQty, - ) - } catch (e: Exception) { - log.warn( - "stock-ledger-fix {} FAILED after {}ms: {}", - key, (System.nanoTime() - t0) / 1_000_000, e.message, - ) - throw e - } finally { - inFlight.remove(runLock) - } - } - - /** - * One HTTP call for [from]..[to] inclusive. - * Walks days like [fixDay] (each day commits) so 2.3–2.5 do not lock the whole range. - */ - open fun fixRange(fromRaw: String, toRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { - val from = parseDay(fromRaw) - val to = parseDay(toRaw) - if (to.isBefore(from)) { - throw IllegalArgumentException("to must be on or after from") - } - if (from.plusDays(400).isBefore(to)) { - throw IllegalArgumentException("date range must be at most 400 days") - } - if (to.isAfter(LocalDate.now())) { - throw IllegalArgumentException("cannot fix future dates") - } - val key = "$from..$to" - val steps = parseDaySteps(stepsRaw) - if (!inFlight.add(runLock)) { - throw IllegalStateException("fix already running") - } - val t0 = System.nanoTime() - try { - var filledLotLineId = 0 - var filledUomId = 0 - var filledInventoryId = 0 - var filledLotQty = 0 - var filledBalance = 0 - var dayRowsWritten = 0 - var d = from - while (!d.isAfter(to)) { - val args = dayArgs(d) - if ("2.1" in steps) filledLotLineId += timed("range $d 2.1") { fillLotLineId(args) } - if ("2.2" in steps) filledUomId += timed("range $d 2.2") { fillUomId(args) } - if ("2.3" in steps) filledInventoryId += timed("range $d 2.3") { fillInventoryId(args) } - if ("2.4" in steps) filledLotQty += timed("range $d 2.4") { fillLotQty(args) } - if ("2.5" in steps) filledBalance += timed("range $d 2.5") { fillBalance(args) } - if ("2.6" in steps) { - dayRowsWritten += timed("range $d 2.6") { - stockLotDayCloseService.upsertDayForFix(d).rowsWritten - } - } - d = d.plusDays(1) - } - val still = stillMiss(rangeArgs(from, to)) - log.info( - "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", - key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, - filledLotQty, filledBalance, dayRowsWritten, - (System.nanoTime() - t0) / 1_000_000, - ) - return StockLedgerFixRunResponse( - date = key, - filledLotLineId = filledLotLineId, - filledUomId = filledUomId, - filledInventoryId = filledInventoryId, - filledLotQty = filledLotQty, - filledBalance = filledBalance, - dayRowsWritten = dayRowsWritten, - stillMissLot = still.missLot, - stillMissUom = still.missUom, - stillMissInventoryId = still.missInventoryId, - stillMissLotQty = still.missLotQty, - ) - } catch (e: Exception) { - log.warn( - "stock-ledger-fix {} FAILED after {}ms: {}", - key, (System.nanoTime() - t0) / 1_000_000, e.message, - ) - throw e - } finally { - inFlight.remove(runLock) - } - } - - /** 2.1 SOL / SIL lot line; one-line fallback when SIL has lot header only. */ - private fun fillLotLineId(args: Map): Int { - val fromSolSil = timed("2.1a solSil") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - LEFT JOIN stock_out_line sol - ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 - LEFT JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - """.trimIndent(), - args, - ) - } - val fromOneLine = timed("2.1b oneLine") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - INNER JOIN ( - SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId - FROM inventory_lot_line ill - INNER JOIN ( - SELECT DISTINCT sil2.inventoryLotId - FROM stock_ledger slx - INNER JOIN stock_in_line sil2 - ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 - WHERE slx.deleted = 0 - AND slx.date >= :day AND slx.date < :dayNext - AND slx.inventoryLotLineId IS NULL - AND sil2.inventoryLotLineId IS NULL - AND sil2.inventoryLotId IS NOT NULL - ) hdr ON hdr.inventoryLotId = ill.inventoryLotId - WHERE IFNULL(ill.deleted, 0) = 0 - GROUP BY ill.inventoryLotId - HAVING COUNT(*) = 1 - ) one ON one.inventoryLotId = sil.inventoryLotId - SET sl.inventoryLotLineId = one.onlyLineId - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId IS NULL - AND sil.inventoryLotLineId IS NULL - AND sil.inventoryLotId IS NOT NULL - """.trimIndent(), - args, - ) - } - return fromSolSil + fromOneLine - } - - /** 2.2 lot line stock UOM → uom_conversion id. Include deleted item_uom (historical lots still point there). */ - private fun fillUomId(args: Map): Int { - return jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN inventory_lot_line ill - ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - SET sl.uomId = iu.uomId - WHERE sl.deleted = 0 - AND sl.date >= :day AND sl.date < :dayNext - AND sl.inventoryLotLineId IS NOT NULL - """.trimIndent(), - args, - ) - } - - /** - * 2.3: ledger stock UOM (`sl.uomId` from 2.2) → inventory bucket. - * A joins `inventory.stockUomId` (indexable). B is leftover rows with null stockUomId. - */ - private fun fillInventoryId(args: Map): Int { - return applyInventoryIdUpdates( - args, - extraJoin = "", - extraWhere = "sl.date >= :day AND sl.date < :dayNext", - ) - } - - private fun applyInventoryIdUpdates( - args: Map, - extraJoin: String, - extraWhere: String, - ): Int { - val byStockUom = jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - $extraJoin - INNER JOIN ( - SELECT itemId, stockUomId, MIN(id) AS inventoryId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - AND stockUomId IS NOT NULL - GROUP BY itemId, stockUomId - ) inv ON inv.itemId = sl.itemId AND inv.stockUomId = sl.uomId - SET sl.inventoryId = inv.inventoryId - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - AND (sl.inventoryId IS NULL OR sl.inventoryId <> inv.inventoryId) - AND ($extraWhere) - """.trimIndent(), - args, - ) - val byLegacyUom = jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - $extraJoin - INNER JOIN ( - SELECT itemId, uomId, MIN(id) AS inventoryId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - AND stockUomId IS NULL - AND uomId IS NOT NULL - GROUP BY itemId, uomId - ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId - SET sl.inventoryId = inv.inventoryId - WHERE sl.deleted = 0 - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - AND sl.inventoryId IS NULL - AND ($extraWhere) - """.trimIndent(), - args, - ) - return byStockUom + byLegacyUom - } - - /** - * 2.4 Opening = last lotQtyAfter before the window start (yesterday first, then lookback). - * Idle days must not reset to 0. Window is :day .. :dayNext (one day or a range). - */ - private fun fillLotQty(args: Map): Int { - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_today ( - inventoryLotLineId BIGINT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_miss ( - inventoryLotLineId BIGINT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_prev ( - inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, - prevAfter DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_qty ( - id INT NOT NULL PRIMARY KEY, - lotAfter DECIMAL(14,2) NOT NULL, - delta DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_today (inventoryLotLineId) - SELECT DISTINCT inventoryLotLineId - FROM stock_ledger - WHERE deleted = 0 - AND date >= :day AND date < :dayNext - AND inventoryLotLineId IS NOT NULL - """.trimIndent(), - args, - ) - timed("2.4a1 yesterday") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) - SELECT slp.inventoryLotLineId, slp.lotQtyAfter - FROM stock_ledger slp - INNER JOIN ( - SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId - FROM stock_ledger sl - INNER JOIN tmp_sl_fix_lot_today t - ON t.inventoryLotLineId = sl.inventoryLotLineId - WHERE sl.deleted = 0 - AND sl.date >= :prevDay AND sl.date < :day - AND sl.lotQtyAfter IS NOT NULL - GROUP BY sl.inventoryLotLineId - ) m ON m.maxId = slp.id - """.trimIndent(), - args, - ) - } - timed("2.4a2 lookback") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_miss (inventoryLotLineId) - SELECT t.inventoryLotLineId - FROM tmp_sl_fix_lot_today t - LEFT JOIN tmp_sl_fix_lot_prev p - ON p.inventoryLotLineId = t.inventoryLotLineId - WHERE p.inventoryLotLineId IS NULL - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) - SELECT slp.inventoryLotLineId, slp.lotQtyAfter - FROM stock_ledger slp - INNER JOIN ( - SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :prevDay - AND sl.lotQtyAfter IS NOT NULL - AND sl.inventoryLotLineId IN ( - SELECT inventoryLotLineId FROM tmp_sl_fix_lot_miss - ) - GROUP BY sl.inventoryLotLineId - ) x ON x.maxId = slp.id - """.trimIndent(), - args, - ) - } - timed("2.4b window") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_qty (id, lotAfter, delta) - SELECT - sl2.id, - CAST( - COALESCE(p.prevAfter, 0) - + SUM( - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) - ) OVER ( - PARTITION BY sl2.inventoryLotLineId - ORDER BY sl2.date, sl2.id - ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW - ) - AS DECIMAL(14,2)) AS lotAfter, - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta - FROM stock_ledger sl2 - LEFT JOIN tmp_sl_fix_lot_prev p - ON p.inventoryLotLineId = sl2.inventoryLotLineId - WHERE sl2.deleted = 0 - AND sl2.date >= :day AND sl2.date < :dayNext - AND sl2.inventoryLotLineId IS NOT NULL - """.trimIndent(), - args, - ) - } - timed("2.4c apply") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_lot_qty t ON t.id = sl.id - SET sl.lotQtyAfter = t.lotAfter, - sl.lotQtyBefore = t.lotAfter - t.delta - """.trimIndent(), - ) - } - } finally { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") - } - } - } - - /** - * 2.5 Running balance per inventoryId. - * Opening = last balance before the window. Fast path: day before :day. Lookback only for - * inventoryIds that had no row that day (idle days must not reset to 0). - * Window is :day .. :dayNext (one day or a range). - */ - private fun fillBalance(args: Map): Int { - val tx = TransactionTemplate(transactionManager) - return tx.execute { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_stock_ledger_fix_today ( - inventoryId INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_stock_ledger_fix_miss ( - inventoryId INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_stock_ledger_fix_prev ( - inventoryId INT NOT NULL PRIMARY KEY, - prevBalance DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_stock_ledger_fix_bal ( - id INT NOT NULL PRIMARY KEY, - newBalance DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_today (inventoryId) - SELECT DISTINCT inventoryId - FROM stock_ledger - WHERE deleted = 0 - AND date >= :day AND date < :dayNext - AND inventoryId IS NOT NULL - """.trimIndent(), - args, - ) - timed("2.5a1 yesterday") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) - SELECT slp.inventoryId, slp.balance - FROM stock_ledger slp - INNER JOIN ( - SELECT sl.inventoryId, MAX(sl.id) AS maxId - FROM stock_ledger sl - INNER JOIN tmp_stock_ledger_fix_today t - ON t.inventoryId = sl.inventoryId - WHERE sl.deleted = 0 - AND sl.date >= :prevDay AND sl.date < :day - AND sl.balance IS NOT NULL - GROUP BY sl.inventoryId - ) m ON m.maxId = slp.id - """.trimIndent(), - args, - ) - } - timed("2.5a2 lookback") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_miss (inventoryId) - SELECT t.inventoryId - FROM tmp_stock_ledger_fix_today t - LEFT JOIN tmp_stock_ledger_fix_prev p - ON p.inventoryId = t.inventoryId - WHERE p.inventoryId IS NULL - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) - SELECT slp.inventoryId, slp.balance - FROM stock_ledger slp - INNER JOIN ( - SELECT sl.inventoryId, MAX(sl.id) AS maxId - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :prevDay - AND sl.balance IS NOT NULL - AND sl.inventoryId IN ( - SELECT inventoryId FROM tmp_stock_ledger_fix_miss - ) - GROUP BY sl.inventoryId - ) x ON x.maxId = slp.id - """.trimIndent(), - args, - ) - } - timed("2.5b window") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_stock_ledger_fix_bal (id, newBalance) - SELECT - sl2.id, - CAST( - COALESCE(p.prevBalance, 0) - + SUM( - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) - ) OVER ( - PARTITION BY sl2.inventoryId - ORDER BY sl2.date, sl2.id - ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW - ) - AS DECIMAL(14,2)) AS newBalance - FROM stock_ledger sl2 - LEFT JOIN tmp_stock_ledger_fix_prev p - ON p.inventoryId = sl2.inventoryId - WHERE sl2.deleted = 0 - AND sl2.date >= :day AND sl2.date < :dayNext - AND sl2.inventoryId IS NOT NULL - """.trimIndent(), - args, - ) - } - timed("2.5c apply") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_stock_ledger_fix_bal t ON t.id = sl.id - SET sl.balance = t.newBalance - """.trimIndent(), - ) - } - } finally { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") - } - } ?: 0 - } - - private fun fillLotLineIdScope(args: Map, byInventory: Boolean): Int { - if (byInventory) return fillLotLineIdScopeByInventory(args) - val lotFilter = """ - AND ( - COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = :lotId - OR sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) - ) - """.trimIndent() - val fromSolSil = jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - LEFT JOIN stock_out_line sol - ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 - LEFT JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - $lotFilter - """.trimIndent(), - args, - ) - val fromOneLine = timed("lot 2.1b oneLine") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - INNER JOIN ( - SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId - FROM inventory_lot_line ill - INNER JOIN ( - SELECT DISTINCT sil2.inventoryLotId - FROM stock_ledger slx - INNER JOIN stock_in_line sil2 - ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 - WHERE slx.deleted = 0 - AND slx.date < :today - AND slx.inventoryLotLineId IS NULL - AND sil2.inventoryLotLineId IS NULL - AND sil2.inventoryLotId IS NOT NULL - AND sil2.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) - ) hdr ON hdr.inventoryLotId = ill.inventoryLotId - WHERE IFNULL(ill.deleted, 0) = 0 - GROUP BY ill.inventoryLotId - HAVING COUNT(*) = 1 - ) one ON one.inventoryLotId = sil.inventoryLotId - SET sl.inventoryLotLineId = one.onlyLineId - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IS NULL - AND sil.inventoryLotLineId IS NULL - AND sil.inventoryLotId IS NOT NULL - AND sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) - """.trimIndent(), - args, - ) - } - return fromSolSil + fromOneLine - } - - /** Collect candidate ledger ids first — do not UPDATE with date < today OR …. */ - private fun fillLotLineIdScopeByInventory(args: Map): Int { - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_lot_ids ( - id INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_sil_lots ( - inventoryLotId BIGINT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_one_line ( - inventoryLotId BIGINT NOT NULL PRIMARY KEY, - onlyLineId BIGINT NOT NULL - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_lot_ids (id) - SELECT sl.id - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IS NULL - AND sl.inventoryId = :inventoryId - """.trimIndent(), - args, - ) - jdbcDao.executeUpdate( - """ - INSERT IGNORE INTO tmp_sl_fix_lot_ids (id) - SELECT sl.id - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IS NULL - AND sl.itemId = :itemId - AND (sl.uomId = :uomId OR sl.uomId IS NULL) - """.trimIndent(), - args, - ) - val fromSolSil = jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id - LEFT JOIN stock_out_line sol - ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 - LEFT JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) - WHERE sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_sil_lots (inventoryLotId) - SELECT DISTINCT sil.inventoryLotId - FROM tmp_sl_fix_lot_ids t - INNER JOIN stock_ledger sl ON sl.id = t.id - INNER JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - WHERE sl.inventoryLotLineId IS NULL - AND sil.inventoryLotLineId IS NULL - AND sil.inventoryLotId IS NOT NULL - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_one_line (inventoryLotId, onlyLineId) - SELECT ill.inventoryLotId, MIN(ill.id) - FROM inventory_lot_line ill - INNER JOIN tmp_sl_fix_sil_lots hdr ON hdr.inventoryLotId = ill.inventoryLotId - WHERE IFNULL(ill.deleted, 0) = 0 - GROUP BY ill.inventoryLotId - HAVING COUNT(*) = 1 - """.trimIndent(), - ) - val fromOneLine = timed("inv 2.1b oneLine") { - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id - INNER JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - INNER JOIN tmp_sl_fix_one_line one ON one.inventoryLotId = sil.inventoryLotId - SET sl.inventoryLotLineId = one.onlyLineId - WHERE sl.inventoryLotLineId IS NULL - AND sil.inventoryLotLineId IS NULL - AND sil.inventoryLotId IS NOT NULL - """.trimIndent(), - ) - } - fromSolSil + fromOneLine - } finally { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") - } - } - } - - private fun fillUomIdScope(args: Map, byInventory: Boolean): Int { - if (!byInventory) { - return jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN inventory_lot_line ill - ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - SET sl.uomId = iu.uomId - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId = :lotId - """.trimIndent(), - args, - ) - } - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_uom_lots ( - id BIGINT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_uom_ids ( - id INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_uom_lots (id) - SELECT DISTINCT inventoryLotLineId - FROM stock_ledger - WHERE deleted = 0 - AND date < :today - AND inventoryId = :inventoryId - AND inventoryLotLineId IS NOT NULL - """.trimIndent(), - args, - ) - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_uom_ids (id) - SELECT sl.id - FROM stock_ledger sl - INNER JOIN tmp_sl_fix_uom_lots t ON t.id = sl.inventoryLotLineId - WHERE sl.deleted = 0 - AND sl.date < :today - """.trimIndent(), - args, - ) - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_uom_ids t ON t.id = sl.id - INNER JOIN inventory_lot_line ill - ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - SET sl.uomId = iu.uomId - """.trimIndent(), - ) - } finally { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") - } - } - } - - private fun fillInventoryIdScope(args: Map, byInventory: Boolean): Int { - if (!byInventory) { - return applyInventoryIdUpdates( - args, - extraJoin = "", - extraWhere = "sl.date < :today AND sl.inventoryLotLineId = :lotId", - ) - } - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_inv_ids ( - id INT NOT NULL PRIMARY KEY - ) - """.trimIndent(), - ) - try { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_sl_fix_inv_ids (id) - SELECT sl.id - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryId = :inventoryId - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - """.trimIndent(), - args, - ) - jdbcDao.executeUpdate( - """ - INSERT IGNORE INTO tmp_sl_fix_inv_ids (id) - SELECT sl.id - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.itemId = :itemId - AND sl.itemId IS NOT NULL - AND sl.uomId IS NOT NULL - """.trimIndent(), - args, - ) - applyInventoryIdUpdates( - args, - extraJoin = "INNER JOIN tmp_sl_fix_inv_ids t ON t.id = sl.id", - extraWhere = "1=1", - ) - } finally { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") - } - } - } - - private fun fillLotQtyHistory(args: Map, byInventory: Boolean): Int { - val extra = if (byInventory) "AND sl2.inventoryId = :inventoryId" else "AND sl2.inventoryLotLineId = :lotId" - val extraUpd = if (byInventory) "AND sl.inventoryId = :inventoryId" else "AND sl.inventoryLotLineId = :lotId" - return jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN ( - SELECT - sl2.id, - CAST( - SUM( - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) - ) OVER ( - PARTITION BY sl2.inventoryLotLineId - ORDER BY sl2.id - ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW - ) - AS DECIMAL(14,2)) AS lotAfter, - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta - FROM stock_ledger sl2 - WHERE sl2.deleted = 0 - AND sl2.date < :today - AND sl2.inventoryLotLineId IS NOT NULL - $extra - ) calc ON calc.id = sl.id - SET sl.lotQtyAfter = calc.lotAfter, - sl.lotQtyBefore = calc.lotAfter - calc.delta - WHERE sl.deleted = 0 - AND sl.date < :today - $extraUpd - """.trimIndent(), - args, - ) - } - - /** Exclusive bound for inventory-scope inspect/fix: include calendar today. */ - private fun inventoryScopeToExclusive(): LocalDate = LocalDate.now().plusDays(1) - - private fun fillBalanceHistory(args: Map): Int { - return jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN ( - SELECT - sl2.id, - CAST( - SUM( - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) - ) OVER ( - PARTITION BY sl2.inventoryId - ORDER BY sl2.id - ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW - ) - AS DECIMAL(14,2)) AS newBalance - FROM stock_ledger sl2 - WHERE sl2.deleted = 0 - AND sl2.date < :today - AND sl2.inventoryId = :inventoryId - ) calc ON calc.id = sl.id - SET sl.balance = calc.newBalance - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryId = :inventoryId - """.trimIndent(), - args, - ) - } - - private fun lotIdsForInventory(inventoryId: Long, today: LocalDate): List { - return jdbcDao.queryForList( - """ - SELECT DISTINCT inventoryLotLineId AS id - FROM stock_ledger - WHERE deleted = 0 - AND inventoryId = :inventoryId - AND inventoryLotLineId IS NOT NULL - AND date < :today - """.trimIndent(), - mapOf("inventoryId" to inventoryId, "today" to today), - ).map { toLong(it["id"]) }.filter { it > 0 } - } - - private fun dateRangeForFilter(filter: String, args: Map): Pair? { - val row = jdbcDao.queryForList( - """ - SELECT MIN(date) AS firstDate, MAX(date) AS lastDate - FROM stock_ledger - WHERE deleted = 0 - AND date < :today - AND $filter - """.trimIndent(), - args, - ).firstOrNull() ?: return null - val first = toLocalDate(row["firstDate"]) ?: return null - val last = toLocalDate(row["lastDate"]) ?: return null - val today = args["today"] as LocalDate - val toExclusive = last.plusDays(1).let { if (it.isAfter(today)) today else it } - return first to toExclusive - } - - private fun upsertStockLotDayRange(lotIds: List, from: LocalDate, toExclusive: LocalDate): Int { - if (lotIds.isEmpty()) return 0 - return jdbcDao.executeUpdate( - """ - INSERT INTO stock_lot_day ( - created, createdBy, version, modified, modifiedBy, deleted, - inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, - opening, inQty, outQty, closing - ) - SELECT - NOW(), 'ledger-fix-api', 0, NOW(), 'ledger-fix-api', 0, - x.inventoryLotLineId, - x.itemId, - x.itemCode, - x.uomId, - il.lotNo, - x.d, - firstSl.lotQtyBefore, - x.inQty, - x.outQty, - lastSl.lotQtyAfter - FROM ( - SELECT - sl.inventoryLotLineId, - CAST(sl.date AS DATE) AS d, - MIN(sl.itemId) AS itemId, - MIN(sl.itemCode) AS itemCode, - MIN(sl.uomId) AS uomId, - MIN(sl.id) AS firstId, - MAX(sl.id) AS lastId, - CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, - CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date >= :from AND sl.date < :toExclusive - AND sl.inventoryLotLineId IN (:lotIds) - AND sl.lotQtyAfter IS NOT NULL - GROUP BY sl.inventoryLotLineId, CAST(sl.date AS DATE) - ) x - INNER JOIN stock_ledger firstSl ON firstSl.id = x.firstId - INNER JOIN stock_ledger lastSl ON lastSl.id = x.lastId - LEFT JOIN inventory_lot_line ill ON ill.id = x.inventoryLotLineId - LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId - ON DUPLICATE KEY UPDATE - itemId = VALUES(itemId), - itemCode = VALUES(itemCode), - uomId = VALUES(uomId), - lotNo = VALUES(lotNo), - opening = VALUES(opening), - inQty = VALUES(inQty), - outQty = VALUES(outQty), - closing = VALUES(closing), - modified = NOW(), - modifiedBy = 'ledger-fix-api', - version = stock_lot_day.version + 1, - deleted = 0 - """.trimIndent(), - mapOf( - "lotIds" to lotIds, - "from" to from, - "toExclusive" to toExclusive, - ), - ) - } - - private data class ScopeStats( - val cnt: Int, - val firstDate: String?, - val lastDate: String?, - val lastBalance: String?, - val lastLotQtyAfter: String?, - val lastInventoryId: Any?, - val parts: List, - ) - - private fun ledgerScopeStats( - filter: String, - args: Map, - itemId: Long? = null, - ): ScopeStats { - val ledger = jdbcDao.queryForList( - """ - SELECT - COUNT(*) AS cnt, - DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate, - DATE_FORMAT(MAX(sl.date), '%Y-%m-%d') AS lastDate, - SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, - SUM(sl.uomId IS NULL) AS uomMiss, - SUM(sl.inventoryId IS NULL) AS invMiss, - SUM( - sl.inventoryLotLineId IS NOT NULL - AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) - ) AS lotQtyMissWithLot, - SUM( - sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND sl.lotQtyAfter - sl.lotQtyBefore - <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - ) AS lotQtyIncorrect, - SUM( - sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND sl.lotQtyAfter - sl.lotQtyBefore - = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) - ) AS overIssue, - SUM(sl.balance IS NULL) AS balanceMiss, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL - ) AS fixable21a, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND sil.inventoryLotId IS NOT NULL - AND IFNULL(lc.lineCnt, 0) = 1 - ) AS fixable21b, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND sil.inventoryLotId IS NOT NULL - AND IFNULL(lc.lineCnt, 0) > 1 - ) AS cannotMultiLine, - SUM( - sl.inventoryLotLineId IS NULL - AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL - AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) - ) AS cannotNoSource - FROM stock_ledger sl - LEFT JOIN stock_out_line sol - ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 - LEFT JOIN stock_in_line sil - ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 - LEFT JOIN ( - SELECT inventoryLotId, COUNT(*) AS lineCnt - FROM inventory_lot_line - WHERE IFNULL(deleted, 0) = 0 - GROUP BY inventoryLotId - ) lc ON lc.inventoryLotId = sil.inventoryLotId - WHERE sl.deleted = 0 - AND sl.date < :today - AND $filter - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() - val last = jdbcDao.queryForList( - """ - SELECT sl.balance, sl.lotQtyAfter, sl.inventoryId - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.date < :today - AND $filter - ORDER BY sl.id DESC - LIMIT 1 - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() - val cnt = toInt(ledger["cnt"]) - val overIssue = toInt(ledger["overIssue"]) - val earlyTke = if (overIssue > 0) overIssueEarlyTkeRows(filter, args, itemId) else 0 - return ScopeStats( - cnt = cnt, - firstDate = ledger["firstDate"]?.toString(), - lastDate = ledger["lastDate"]?.toString(), - lastBalance = last["balance"]?.toString(), - lastLotQtyAfter = last["lotQtyAfter"]?.toString(), - lastInventoryId = last["inventoryId"], - parts = fieldAndReasonParts( - cnt = cnt, - lotMiss = toInt(ledger["lotMiss"]), - lotIncorrect = 0, - uomMiss = toInt(ledger["uomMiss"]), - uomIncorrect = 0, - invMiss = toInt(ledger["invMiss"]), - invIncorrect = 0, - lotQtyMissWithLot = toInt(ledger["lotQtyMissWithLot"]), - lotQtyIncorrect = toInt(ledger["lotQtyIncorrect"]), - overIssue = overIssue, - balanceMiss = toInt(ledger["balanceMiss"]), - fixable21a = toInt(ledger["fixable21a"]), - fixable21b = toInt(ledger["fixable21b"]), - cannotMultiLine = toInt(ledger["cannotMultiLine"]), - cannotNoSource = toInt(ledger["cannotNoSource"]), - earlyTkeOver = earlyTke, - otherOver = (overIssue - earlyTke).coerceAtLeast(0), - ), - ) - } - - private fun stillMissScope(filter: String, args: Map): MissCounts { - val row = jdbcDao.queryForList( - """ - SELECT - SUM(inventoryLotLineId IS NULL) AS missLot, - SUM(uomId IS NULL) AS missUom, - SUM(inventoryId IS NULL) AS missInventoryId, - SUM(lotQtyAfter IS NULL) AS missLotQty - FROM stock_ledger - WHERE deleted = 0 - AND date < :today - AND $filter - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() - return MissCounts( - missLot = toInt(row["missLot"]), - missUom = toInt(row["missUom"]), - missInventoryId = toInt(row["missInventoryId"]), - missLotQty = toInt(row["missLotQty"]), - ) - } - - private fun stillMiss(args: Map): MissCounts { - val row = jdbcDao.queryForList( - """ - SELECT - SUM(inventoryLotLineId IS NULL) AS missLot, - SUM(uomId IS NULL) AS missUom, - SUM(inventoryId IS NULL) AS missInventoryId, - SUM(lotQtyAfter IS NULL) AS missLotQty - FROM stock_ledger - WHERE deleted = 0 - AND date >= :day AND date < :dayNext - """.trimIndent(), - args, - ).firstOrNull() ?: emptyMap() - return MissCounts( - missLot = toInt(row["missLot"]), - missUom = toInt(row["missUom"]), - missInventoryId = toInt(row["missInventoryId"]), - missLotQty = toInt(row["missLotQty"]), - ) - } - - private fun fieldAndReasonParts( - cnt: Int, - lotMiss: Int, - lotIncorrect: Int, - uomMiss: Int, - uomIncorrect: Int, - invMiss: Int, - invIncorrect: Int, - lotQtyMissWithLot: Int, - lotQtyIncorrect: Int, - overIssue: Int, - balanceMiss: Int, - fixable21a: Int, - fixable21b: Int, - cannotMultiLine: Int, - cannotNoSource: Int, - earlyTkeOver: Int, - otherOver: Int, - extra: List = emptyList(), - ): List { - fun field(key: String, label: String, miss: Int, incorrect: Int) = - StockLedgerFixCheckPart( - key = key, - label = label, - ok = (cnt - miss - incorrect).coerceAtLeast(0), - miss = miss, - incorrect = incorrect, - group = "field", - ) - fun canFix(key: String, label: String, n: Int) = - StockLedgerFixCheckPart(key, label, ok = 0, miss = n, incorrect = 0, group = "canFix") - fun cannotFix(key: String, label: String, n: Int) = - StockLedgerFixCheckPart(key, label, ok = 0, miss = 0, incorrect = n, group = "cannotFix") - return buildList { - add(field("lot", "inventoryLotLineId", lotMiss, lotIncorrect)) - add(field("uom", "uomId", uomMiss, uomIncorrect)) - add(field("inventory", "inventoryId", invMiss, invIncorrect)) - add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect)) - add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue)) - add(field("balance", "balance", balanceMiss, 0)) - addAll(extra) - add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a)) - add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b)) - add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot)) - add(cannotFix("cannotNoSource", "不能修:沒有 SIL/SOL lot 來源", cannotNoSource)) - add(cannotFix("cannotMultiLine", "不能修:同一 lot 有多條 warehouse line", cannotMultiLine)) - add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) - add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) - } - } - - private fun overIssueEarlyTkeRows(filter: String, args: Map, itemId: Long?): Int { - if (itemId == null || itemId <= 0) return 0 - val q = args.toMutableMap() - q["itemId"] = itemId - return toInt( - jdbcDao.queryForList( - """ - SELECT SUM(ok) AS c FROM ( - SELECT EXISTS ( - SELECT 1 - FROM inventory_lot_line ill2 - INNER JOIN inventory_lot il2 - ON il2.id = ill2.inventoryLotId AND il2.itemId = :itemId - INNER JOIN ( - SELECT sl3.inventoryLotLineId - FROM stock_ledger sl3 - INNER JOIN inventory_lot_line ill3 ON ill3.id = sl3.inventoryLotLineId - INNER JOIN inventory_lot il3 ON il3.id = ill3.inventoryLotId AND il3.itemId = :itemId - WHERE sl3.deleted = 0 - AND sl3.date < :today - GROUP BY sl3.inventoryLotLineId - HAVING SUM(COALESCE(sl3.inQty, 0)) > 0 - AND SUM(CASE WHEN sl3.type = 'TKE' AND sl3.inQty > 0 THEN sl3.inQty ELSE 0 END) - >= SUM(COALESCE(sl3.inQty, 0)) * 0.99 - ) tke ON tke.inventoryLotLineId = ill2.id - WHERE IFNULL(ill2.deleted, 0) = 0 - AND ill2.warehouseId = ill.warehouseId - AND ill2.id <> sl.inventoryLotLineId - ) AS ok - FROM stock_ledger sl - INNER JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId - WHERE sl.deleted = 0 - AND sl.date < :today - AND $filter - AND sl.lotQtyAfter IS NOT NULL - AND sl.lotQtyBefore IS NOT NULL - AND sl.lotQtyAfter - sl.lotQtyBefore - = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) - AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) - ) x - """.trimIndent(), - q, - ).firstOrNull()?.get("c"), - ) - } - - /** - * Export SQL patch for target DB (same dump lineage / freeze). - * [partsRaw] omit / empty / all = ledger + 2.6 (legacy). Else subset: - * 1.0 inventory, 2.3 inventoryId, ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 SIL/SOL + ADJ INSERT. - * Apply order in file: 1.0 → ledger(+2.3) → 2.7 docs → 2.7 ADJ → 2.6. - */ open fun writeExportSql( from: LocalDate, to: LocalDate, out: OutputStream, partsRaw: List? = null, - ) { - if (to.isBefore(from)) throw IllegalArgumentException("to must be on or after from") - if (from.plusDays(400).isBefore(to)) { - throw IllegalArgumentException("date range must be at most 400 days") - } - val today = LocalDate.now() - if (to.isAfter(today)) { - throw IllegalArgumentException("cannot export future dates") - } - val toExclusive = to.plusDays(1) - if (!from.isBefore(toExclusive)) { - throw IllegalArgumentException("from must be before toExclusive") - } - val parts = parseExportParts(partsRaw) - val want10 = "1.0" in parts - val want23 = "2.3" in parts - val wantLedger = "ledger" in parts - val want26 = "2.6" in parts - val want27 = "2.7" in parts - if (!want10 && !want23 && !wantLedger && !want26 && !want27) { - throw IllegalArgumentException("parts must include at least one of: 1.0, 2.3, ledger, 2.6, 2.7") - } - val args = mapOf("from" to from, "toExclusive" to toExclusive) - val ledgerCnt = if (wantLedger || want23 || want27) { - toInt( - jdbcDao.queryForList( - """ - SELECT COUNT(*) AS c FROM stock_ledger - WHERE deleted = 0 AND date >= :from AND date < :toExclusive - """.trimIndent(), - args, - ).firstOrNull()?.get("c"), - ) - } else { - 0 - } - val dayCnt = if (want26) { - toInt( - jdbcDao.queryForList( - """ - SELECT COUNT(*) AS c FROM stock_lot_day - WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive - """.trimIndent(), - args, - ).firstOrNull()?.get("c"), - ) - } else { - 0 - } - val adjCnt = if (want27) { - toInt( - jdbcDao.queryForList( - """ - SELECT COUNT(*) AS c FROM stock_ledger - WHERE deleted = 0 - AND date >= :from AND date < :toExclusive - AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' - """.trimIndent(), - args, - ).firstOrNull()?.get("c"), - ) - } else { - 0 - } - val invCnt = if (want10) { - toInt( - jdbcDao.queryForList( - "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", - ).firstOrNull()?.get("c"), - ) - } else { - 0 - } - val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8), 65536) - val jdbc = NamedParameterJdbcTemplate(dataSource) - jdbc.jdbcTemplate.fetchSize = 2000 - try { - writer.write("-- stock-ledger-fix patch\n") - writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") - writer.write("-- parts=${parts.joinToString(",")}\n") - writer.write("-- apply order: 1.0 inventory → ledger(+2.3) UPDATE → 2.7 SIL/SOL then ADJ INSERT → 2.6 stock_lot_day\n") - writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") - if (want23 && !want10) { - writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n") - } - writer.write("-- please run in the target DB/schema, e.g. USE fpsmsdb;\n") - writer.write( - "-- expected inventoryRows=$invCnt ledgerRows=$ledgerCnt adjRows=$adjCnt stock_lot_dayRows=$dayCnt\n", - ) - writer.write("-- SET NAMES utf8mb4;\n\n") - - if (want10) { - writeExportInventory10(writer, jdbc) - } - if (wantLedger || want23) { - writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) - } - if (want27) { - writeExportAdjDocs(writer, jdbc, args) - writeExportAdjInsert(writer, jdbc, args) - } - if (want26) { - writeExportStockLotDay(writer, jdbc, args, from, toExclusive) - } - - writer.write("\n-- done\n") - writer.flush() - log.info( - "stock-ledger-fix export {}..{} parts={} inv={} ledger={} adj={} day={}", - from, toExclusive.minusDays(1), parts.joinToString(","), invCnt, ledgerCnt, adjCnt, dayCnt, - ) - } finally { - writer.flush() - } - } - - private fun writeExportInventory10(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) { - writer.write("-- === 1.0 inventory (INSERT with fixed id + UPDATE qty/status) ===\n") - var batch = 0 - jdbc.query( - """ - SELECT id, created, createdBy, version, modified, modifiedBy, deleted, - itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, - price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - ORDER BY id - """.trimIndent(), - emptyMap(), - RowCallbackHandler { rs -> - if (batch == 0) { - writer.write( - "INSERT INTO inventory (\n" + - " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + - " itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,\n" + - " price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status\n" + - ") VALUES\n", - ) - } else { - writer.write(",\n") - } - writer.write( - "(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + - "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + - "${rsSql(rs, "itemId")}, ${rsSql(rs, "uomId")}, ${rsSql(rs, "stockUomId")}, ${rsSql(rs, "onHandQty")}, " + - "${rsSql(rs, "onHoldQty")}, ${rsSql(rs, "unavailableQty")}, ${rsSql(rs, "price")}, " + - "${rsSql(rs, "currencyId")}, ${rsSql(rs, "cpu")}, ${rsSql(rs, "cpuUnit")}, " + - "${rsSql(rs, "cpm")}, ${rsSql(rs, "cpmUnit")}, ${rsSql(rs, "status")})", - ) - batch++ - if (batch >= 200) { - writer.write( - "\nON DUPLICATE KEY UPDATE " + - "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + - "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + - "uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " + - "price=VALUES(price), currencyId=VALUES(currencyId), " + - "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + - "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=inventory.version+1, deleted=0;\n\n", - ) - batch = 0 - } - }, - ) - if (batch > 0) { - writer.write( - "\nON DUPLICATE KEY UPDATE " + - "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + - "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + - "uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " + - "price=VALUES(price), currencyId=VALUES(currencyId), " + - "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + - "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=inventory.version+1, deleted=0;\n\n", - ) - } else { - writer.write("-- (no inventory rows)\n\n") - } - } - - private fun writeExportLedgerPatch( - writer: BufferedWriter, - jdbc: NamedParameterJdbcTemplate, - args: Map, - wantLedger: Boolean, - want23: Boolean, - ) { - writer.write("-- === ledger UPDATE (JOIN id)") - if (wantLedger) writer.write(" lot/uom/lotQty/balance") - if (want23) writer.write(" inventoryId") - writer.write(" ===\n") - val cols = mutableListOf("id") - if (wantLedger) { - cols += listOf("inventoryLotLineId", "uomId", "lotQtyBefore", "lotQtyAfter", "balance") - } - if (want23) cols += "inventoryId" - writer.write("CREATE TEMPORARY TABLE tmp_sl_fix_patch (\n") - writer.write(" id INT NOT NULL PRIMARY KEY") - if (wantLedger) { - writer.write(",\n inventoryLotLineId BIGINT NULL,\n uomId INT NULL,\n") - writer.write(" lotQtyBefore DECIMAL(14,2) NULL,\n lotQtyAfter DECIMAL(14,2) NULL,\n") - writer.write(" balance DECIMAL(14,2) NULL") - } - if (want23) writer.write(",\n inventoryId INT NULL") - writer.write("\n);\n\n") - var batch = 0 - val selectCols = cols.joinToString(", ") - jdbc.query( - """ - SELECT $selectCols - FROM stock_ledger - WHERE deleted = 0 AND date >= :from AND date < :toExclusive - """.trimIndent(), - args, - RowCallbackHandler { rs -> - if (batch == 0) { - writer.write("INSERT INTO tmp_sl_fix_patch (${cols.joinToString(", ")}) VALUES\n") - } else { - writer.write(",\n") - } - writer.write("(" + cols.joinToString(", ") { rsSql(rs, it) } + ")") - batch++ - if (batch >= 500) { - writer.write(";\n\n") - batch = 0 - } - }, - ) - if (batch > 0) writer.write(";\n\n") - val setParts = mutableListOf() - if (wantLedger) { - setParts += listOf( - "sl.inventoryLotLineId = t.inventoryLotLineId", - "sl.uomId = t.uomId", - "sl.lotQtyBefore = t.lotQtyBefore", - "sl.lotQtyAfter = t.lotQtyAfter", - "sl.balance = t.balance", - ) - } - if (want23) setParts += "sl.inventoryId = t.inventoryId" - writer.write( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_patch t ON t.id = sl.id - SET - ${setParts.joinToString(",\n ")} - WHERE sl.deleted = 0; - - DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_patch; - - """.trimIndent(), - ) - writer.write("\n") - } - - private fun writeExportAdjDocs( - writer: BufferedWriter, - jdbc: NamedParameterJdbcTemplate, - args: Map, - ) { - writer.write("-- === 2.7 ADJ stock_in / stock_out (1 header + many lines; apply before ledger ADJ FKs) ===\n") - writeExportRows( - writer, - jdbc, - """ - SELECT DISTINCT si.id, si.created, si.createdBy, si.version, si.modified, si.modifiedBy, si.deleted, - si.code, si.status, si.orderDate, si.completeDate - FROM stock_in si - INNER JOIN stock_in_line sil ON sil.stockInId = si.id AND IFNULL(sil.deleted, 0) = 0 - INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id - WHERE sl.deleted = 0 - AND sl.date >= :from AND sl.date < :toExclusive - AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' - ORDER BY si.id - """.trimIndent(), - args, - table = "stock_in", - columns = listOf( - "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", - "code", "status", "orderDate", "completeDate", - ), - onDup = "code=VALUES(code), status=VALUES(status), orderDate=VALUES(orderDate), " + - "completeDate=VALUES(completeDate), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=stock_in.version+1, deleted=0", - emptyComment = "-- (no ADJ stock_in headers in range)", - ) - writeExportRows( - writer, - jdbc, - """ - SELECT sil.id, sil.created, sil.createdBy, sil.version, sil.modified, sil.modifiedBy, sil.deleted, - sil.itemId, sil.itemNo, sil.stockInId, sil.acceptedQty, sil.receiptDate, sil.status, - sil.inventoryLotId, sil.inventoryLotLineId, sil.lotNo, sil.remarks, sil.type - FROM stock_in_line sil - INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id - WHERE sl.deleted = 0 - AND sl.date >= :from AND sl.date < :toExclusive - AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' - ORDER BY sil.id - """.trimIndent(), - args, - table = "stock_in_line", - columns = listOf( - "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", - "itemId", "itemNo", "stockInId", "acceptedQty", "receiptDate", "status", - "inventoryLotId", "inventoryLotLineId", "lotNo", "remarks", "type", - ), - onDup = "acceptedQty=VALUES(acceptedQty), inventoryLotId=VALUES(inventoryLotId), " + - "inventoryLotLineId=VALUES(inventoryLotLineId), lotNo=VALUES(lotNo), status=VALUES(status), " + - "type=VALUES(type), remarks=VALUES(remarks), modified=VALUES(modified), " + - "modifiedBy=VALUES(modifiedBy), version=stock_in_line.version+1, deleted=0", - emptyComment = "-- (no ADJ stock_in_line in range)", - ) - writeExportRows( - writer, - jdbc, - """ - SELECT DISTINCT so.id, so.created, so.createdBy, so.version, so.modified, so.modifiedBy, so.deleted, - so.type, so.completeDate, so.status, so.remarks - FROM stock_out so - INNER JOIN stock_out_line sol ON sol.stockOutId = so.id AND IFNULL(sol.deleted, 0) = 0 - INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id - WHERE sl.deleted = 0 - AND sl.date >= :from AND sl.date < :toExclusive - AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' - ORDER BY so.id - """.trimIndent(), - args, - table = "stock_out", - columns = listOf( - "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", - "type", "completeDate", "status", "remarks", - ), - onDup = "type=VALUES(type), completeDate=VALUES(completeDate), status=VALUES(status), " + - "remarks=VALUES(remarks), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=stock_out.version+1, deleted=0", - emptyComment = "-- (no ADJ stock_out headers in range)", - ) - writeExportRows( - writer, - jdbc, - """ - SELECT sol.id, sol.created, sol.createdBy, sol.version, sol.modified, sol.modifiedBy, sol.deleted, - sol.itemId, sol.qty, sol.stockOutId, sol.inventoryLotLineId, sol.status, sol.pickTime, sol.type - FROM stock_out_line sol - INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id - WHERE sl.deleted = 0 - AND sl.date >= :from AND sl.date < :toExclusive - AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' - ORDER BY sol.id - """.trimIndent(), - args, - table = "stock_out_line", - columns = listOf( - "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", - "itemId", "qty", "stockOutId", "inventoryLotLineId", "status", "pickTime", "type", - ), - onDup = "qty=VALUES(qty), inventoryLotLineId=VALUES(inventoryLotLineId), status=VALUES(status), " + - "pickTime=VALUES(pickTime), type=VALUES(type), modified=VALUES(modified), " + - "modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0", - emptyComment = "-- (no ADJ stock_out_line in range)", - ) - } - - private fun writeExportAdjInsert( - writer: BufferedWriter, - jdbc: NamedParameterJdbcTemplate, - args: Map, - ) { - writer.write("-- === 2.7 ADJ INSERT (fixed id; rows missing on target are created) ===\n") - var batch = 0 - jdbc.query( - """ - SELECT id, created, createdBy, version, modified, modifiedBy, deleted, - date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, - itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, - uomId, type - FROM stock_ledger - WHERE deleted = 0 - AND date >= :from AND date < :toExclusive - AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' - ORDER BY id - """.trimIndent(), - args, - RowCallbackHandler { rs -> - if (batch == 0) { - writer.write( - "INSERT INTO stock_ledger (\n" + - " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + - " date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,\n" + - " itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,\n" + - " uomId, type\n) VALUES\n", - ) - } else { - writer.write(",\n") - } - writer.write( - "(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + - "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + - "${rsSql(rs, "date")}, ${rsSql(rs, "stockInLineId")}, ${rsSql(rs, "stockOutLineId")}, " + - "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "inventoryId")}, " + - "${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + - "${rsSql(rs, "balance")}, ${rsSql(rs, "lotQtyBefore")}, ${rsSql(rs, "lotQtyAfter")}, " + - "${rsSql(rs, "uomId")}, ${rsSql(rs, "type")})", - ) - batch++ - if (batch >= 200) { - writer.write( - "\nON DUPLICATE KEY UPDATE " + - "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + - "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + - "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + - "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + - "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=stock_ledger.version+1, deleted=0;\n\n", - ) - batch = 0 - } - }, - ) - if (batch > 0) { - writer.write( - "\nON DUPLICATE KEY UPDATE " + - "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + - "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + - "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + - "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + - "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=stock_ledger.version+1, deleted=0;\n\n", - ) - } else { - writer.write("-- (no ADJ rows from stock-ledger-fix in range)\n\n") - } - } - - private fun writeExportStockLotDay( - writer: BufferedWriter, - jdbc: NamedParameterJdbcTemplate, - args: Map, - from: LocalDate, - toExclusive: LocalDate, - ) { - writer.write("-- === 2.6 stock_lot_day ===\n") - writer.write( - """ - DELETE FROM stock_lot_day - WHERE `date` >= '$from' AND `date` < '$toExclusive'; - - """.trimIndent(), - ) - writer.write("\n") - var batch = 0 - jdbc.query( - """ - SELECT created, createdBy, version, modified, modifiedBy, deleted, - inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, - opening, inQty, outQty, closing - FROM stock_lot_day - WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive - ORDER BY date, inventoryLotLineId - """.trimIndent(), - args, - RowCallbackHandler { rs -> - if (batch == 0) { - writer.write( - "INSERT INTO stock_lot_day (\n" + - " created, createdBy, version, modified, modifiedBy, deleted,\n" + - " inventoryLotLineId, itemId, itemCode, uomId, lotNo, `date`,\n" + - " opening, inQty, outQty, closing\n) VALUES\n", - ) - } else { - writer.write(",\n") - } - writer.write( - "(${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + - "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + - "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, " + - "${rsSql(rs, "uomId")}, ${rsSql(rs, "lotNo")}, ${rsSql(rs, "date")}, " + - "${rsSql(rs, "opening")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + - "${rsSql(rs, "closing")})", - ) - batch++ - if (batch >= 500) { - writer.write( - "\nON DUPLICATE KEY UPDATE " + - "itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " + - "lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " + - "outQty=VALUES(outQty), closing=VALUES(closing), " + - "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=stock_lot_day.version+1, deleted=0;\n\n", - ) - batch = 0 - } - }, - ) - if (batch > 0) { - writer.write( - "\nON DUPLICATE KEY UPDATE " + - "itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " + - "lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " + - "outQty=VALUES(outQty), closing=VALUES(closing), " + - "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=stock_lot_day.version+1, deleted=0;\n", - ) - } - } - - private fun parseExportParts(raw: List?): Set { - if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { - return EXPORT_PARTS_DEFAULT - } - val flat = raw.flatMap { it.split(",") }.map { it.trim() }.filter { it.isNotEmpty() } - if (flat.isEmpty() || flat.any { it.equals("all", ignoreCase = true) }) { - return EXPORT_PARTS_DEFAULT - } - val out = linkedSetOf() - flat.forEach { token -> - val t = token.lowercase() - val key = when (t) { - "1.0", "10", "inventory" -> "1.0" - "2.3", "23", "inventoryid" -> "2.3" - "ledger", "2.1", "2.2", "2.4", "2.5" -> "ledger" - "2.6", "26", "day", "stock_lot_day" -> "2.6" - "2.7", "27", "adj" -> "2.7" - else -> throw IllegalArgumentException( - "unknown export part '$token' (use 1.0, 2.3, ledger, 2.6, 2.7, or all)", - ) - } - out.add(key) - } - if (out.isEmpty()) throw IllegalArgumentException("parts must not be empty") - return out - } - - private fun writeExportRows( - writer: BufferedWriter, - jdbc: NamedParameterJdbcTemplate, - sql: String, - args: Map, - table: String, - columns: List, - onDup: String, - emptyComment: String, - ) { - var batch = 0 - jdbc.query( - sql, - args, - RowCallbackHandler { rs -> - if (batch == 0) { - writer.write("INSERT INTO $table (\n ${columns.joinToString(", ")}\n) VALUES\n") - } else { - writer.write(",\n") - } - writer.write("(" + columns.joinToString(", ") { rsSql(rs, it) } + ")") - batch++ - if (batch >= 200) { - writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n") - batch = 0 - } - }, - ) - if (batch > 0) { - writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n") - } else { - writer.write("$emptyComment\n\n") - } - } - - private fun rsSql(rs: ResultSet, col: String): String { - val v = rs.getObject(col) - if (v == null || rs.wasNull()) return "NULL" - return when (v) { - is BigDecimal -> v.stripTrailingZeros().toPlainString() - is Number -> v.toString() - is java.sql.Date -> "'${v.toLocalDate()}'" - is java.sql.Timestamp -> "'${v.toLocalDateTime().toString().replace('T', ' ')}'" - is Boolean -> if (v) "1" else "0" - else -> "'" + v.toString().replace("\\", "\\\\").replace("'", "''") + "'" - } - } - - private fun dayArgs(day: LocalDate): Map = - mapOf( - "day" to day, - "dayNext" to day.plusDays(1), - "prevDay" to day.minusDays(1), - ) - - /** Inclusive [from]..[to]; same keys as [dayArgs] (used by stillMiss over the whole range). */ - private fun rangeArgs(from: LocalDate, to: LocalDate): Map = - mapOf( - "day" to from, - "dayNext" to to.plusDays(1), - "prevDay" to from.minusDays(1), - ) - - /** Same JDBC connection so TEMPORARY TABLEs survive across statements. */ - private fun withTempTx(block: () -> Int): Int { - return TransactionTemplate(transactionManager).execute { block() } ?: 0 - } - - private fun timed(name: String, block: () -> Int): Int { - val t0 = System.nanoTime() - return try { - val n = block() - log.info("stock-ledger-fix step {} rows={} {}ms", name, n, (System.nanoTime() - t0) / 1_000_000) - n - } catch (e: Exception) { - log.warn("stock-ledger-fix step {} FAILED {}ms: {}", name, (System.nanoTime() - t0) / 1_000_000, e.message) - throw e - } - } - - private fun parseDay(raw: String): LocalDate { - val trimmed = raw.trim() - if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)") - return try { - LocalDate.parse(trimmed) - } catch (_: DateTimeParseException) { - throw IllegalArgumentException("date must be yyyy-MM-dd") - } - } - - /** - * ADJ window: ledger date = [adjDate], gap sums use `date < adjDate+1`. - * Default adjDate = yesterday. Pass today for freeze-night dump (e.g. 31/8). - */ - private fun resolveAdjWindow(adjDateRaw: String?): Pair { - val today = LocalDate.now() - val adjDate = if (adjDateRaw.isNullOrBlank()) { - today.minusDays(1) - } else { - parseDay(adjDateRaw) - } - if (adjDate.isAfter(today)) { - throw IllegalArgumentException("adjDate cannot be in the future") - } - return adjDate to adjDate.plusDays(1) - } - - private fun parseDaySteps(raw: List?): Set { - if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { - return DAY_STEPS - } - val out = linkedSetOf() - raw.forEach { token -> - val t = token.trim().lowercase() - val key = when (t) { - "2.1", "21" -> "2.1" - "2.2", "22" -> "2.2" - "2.3", "23" -> "2.3" - "2.4", "24" -> "2.4" - "2.5", "25" -> "2.5" - "2.6", "26" -> "2.6" - else -> throw IllegalArgumentException("unknown step '$token' (use 2.1–2.6 or all)") - } - out.add(key) - } - if (out.isEmpty()) throw IllegalArgumentException("steps must not be empty") - return out - } - - private fun toInt(value: Any?): Int = when (value) { - null -> 0 - is Number -> value.toInt() - else -> value.toString().toIntOrNull() ?: 0 - } - - private fun toLong(value: Any?): Long = toLongOrNull(value) ?: 0L - - private fun toLongOrNull(value: Any?): Long? = when (value) { - null -> null - is Number -> value.toLong() - else -> value.toString().toLongOrNull() - } - - private fun loadAdjGaps(today: LocalDate): List { - val tx = TransactionTemplate(transactionManager) - tx.timeout = 300 - val args = mapOf("today" to today) - return tx.execute { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_adj_line ( - lotLineId BIGINT NOT NULL PRIMARY KEY, - inventoryId INT NULL, - itemId INT NULL, - itemCode VARCHAR(64) NULL, - uomId INT NULL, - lineIn DECIMAL(14,2) NOT NULL, - lineOut DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_adj_led ( - inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, - ledger_in DECIMAL(14,2) NOT NULL, - ledger_out DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - try { - timed("adj line-all") { - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_adj_line ( - lotLineId, inventoryId, itemId, itemCode, uomId, lineIn, lineOut - ) - SELECT - ill.id, - inv.inventoryId, - il.itemId, - it.code, - iu.uomId, - CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)), - CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) - FROM inventory_lot_line ill - INNER JOIN inventory_lot il - ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 - LEFT JOIN items it ON it.id = il.itemId - LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId - LEFT JOIN ( - SELECT itemId, COALESCE(stockUomId, uomId) AS bucketUomId, MIN(id) AS inventoryId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - GROUP BY itemId, COALESCE(stockUomId, uomId) - ) inv ON inv.itemId = il.itemId AND inv.bucketUomId = iu.uomId - WHERE IFNULL(ill.deleted, 0) = 0 - AND ( - COALESCE(ill.inQty, 0) <> 0 - OR COALESCE(ill.outQty, 0) <> 0 - ) - """.trimIndent(), - ) - } - val lineIds = jdbcDao.queryForList( - "SELECT lotLineId FROM tmp_adj_line", - ).map { toLong(it["lotLineId"]) }.filter { it > 0 } - log.info("stock-ledger-fix adj line rows={}", lineIds.size) - if (lineIds.isEmpty()) return@execute emptyList() - timed("adj ledger-by-line") { - lineIds.chunked(40).sumOf { chunk -> - jdbcDao.executeUpdate( - """ - INSERT INTO tmp_adj_led (inventoryLotLineId, ledger_in, ledger_out) - SELECT - sl.inventoryLotLineId, - CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)), - CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) - FROM stock_ledger sl FORCE INDEX (idx_ledger_lot_date_id) - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IN (:ids) - GROUP BY sl.inventoryLotLineId - """.trimIndent(), - mapOf("today" to today, "ids" to chunk), - ) - } - } - jdbcDao.queryForList( - """ - SELECT - l.lotLineId, - l.inventoryId, - l.itemId, - l.itemCode, - l.uomId, - l.lineIn, - l.lineOut, - CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) AS ledgerIn, - CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) AS ledgerOut - FROM tmp_adj_line l - LEFT JOIN tmp_adj_led agg ON agg.inventoryLotLineId = l.lotLineId - WHERE l.lineIn <> CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) - OR l.lineOut <> CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) - OR l.lineIn < l.lineOut - """.trimIndent(), - ).map { row -> - val lineIn = toDecimal(row["lineIn"]) - val lineOut = toDecimal(row["lineOut"]) - val ledgerIn = toDecimal(row["ledgerIn"]) - val ledgerOut = toDecimal(row["ledgerOut"]) - AdjGap( - lotLineId = toLong(row["lotLineId"]), - inventoryId = toLongOrNull(row["inventoryId"]), - itemId = toLongOrNull(row["itemId"]), - itemCode = row["itemCode"]?.toString(), - uomId = toLongOrNull(row["uomId"]), - lineIn = lineIn, - lineOut = lineOut, - ledgerIn = ledgerIn, - ledgerOut = ledgerOut, - missIn = lineIn.subtract(ledgerIn), - missOut = lineOut.subtract(ledgerOut), - ) - } - } finally { - try { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") - } catch (e: Exception) { - log.warn("stock-ledger-fix drop tmp_adj skipped: {}", e.message) - } - } - } ?: emptyList() - } - - private fun insertAdjLedger( - g: AdjGap, - adjDate: LocalDate, - inQty: BigDecimal?, - outQty: BigDecimal?, - remarks: String = ADJ_DOC_REMARKS, - stockInId: Long? = null, - stockOutId: Long? = null, - ) { - val silId = if (inQty != null) { - insertAdjStockInLine(g, adjDate, inQty, remarks, stockInId ?: error("stockInId required for ADJ in")) - } else { - null - } - val solId = if (outQty != null) { - insertAdjStockOutLine(g, adjDate, outQty, remarks, stockOutId ?: error("stockOutId required for ADJ out")) - } else { - null - } - val args = hashMapOf( - "adjDate" to adjDate, - "silId" to silId, - "solId" to solId, - "lotLineId" to g.lotLineId, - "inventoryId" to g.inventoryId, - "itemId" to g.itemId, - "itemCode" to g.itemCode, - "inQty" to inQty, - "outQty" to outQty, - "uomId" to g.uomId, - ) - jdbcDao.executeUpdate( - """ - INSERT INTO stock_ledger ( - created, createdBy, version, modified, modifiedBy, deleted, - date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, - itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, - uomId, type - ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, - :adjDate, :silId, :solId, :lotLineId, :inventoryId, - :itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL, - :uomId, 'ADJ' - ) - """.trimIndent(), - args, - ) - } - - /** Sets lot inQty = outQty so remain = 0; trigger recomputes inventory. */ - private fun patchLotRemainToZero(lotLineId: Long): Boolean { - val n = jdbcDao.executeUpdate( - """ - UPDATE inventory_lot_line - SET inQty = CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)), - status = 'unavailable', - modified = NOW(), - modifiedBy = 'stock-ledger-fix', - version = IFNULL(version, 0) + 1 - WHERE id = :lotLineId - AND IFNULL(deleted, 0) = 0 - AND CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) - < CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) - """.trimIndent(), - mapOf("lotLineId" to lotLineId), - ) - if (n == 0) { - log.warn("stock-ledger-fix over-issue skip lot update: lotLineId={}", lotLineId) - } - return n > 0 - } - - private fun adjStockInCode(adjDate: LocalDate): String = "SLF-I-$adjDate" - - private fun adjStockOutRemarks(adjDate: LocalDate): String = "$ADJ_HEADER_REMARKS $adjDate" - - /** One stock_in per ADJ date (reused if Apply runs again). */ - private fun ensureAdjStockInHeader(adjDate: LocalDate): Long { - val code = adjStockInCode(adjDate) - val existing = jdbcDao.queryForMap( - """ - SELECT id FROM stock_in - WHERE code = :code AND IFNULL(deleted, 0) = 0 - LIMIT 1 - """.trimIndent(), - mapOf("code" to code), - ).orElse(null) - if (existing != null) return toLong(existing["id"]) - val at = adjDate.atTime(12, 0) - return insertReturningId( - """ - INSERT INTO stock_in ( - created, createdBy, version, modified, modifiedBy, deleted, - code, status, orderDate, completeDate - ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, - :code, 'completed', :at, :at - ) - """.trimIndent(), - mapOf("code" to code, "at" to at), - ) - } - - /** One stock_out per ADJ date (reused if Apply runs again). */ - private fun ensureAdjStockOutHeader(adjDate: LocalDate): Long { - val remarks = adjStockOutRemarks(adjDate) - val existing = jdbcDao.queryForMap( - """ - SELECT id FROM stock_out - WHERE createdBy = 'stock-ledger-fix' - AND type = 'ADJ' - AND remarks = :remarks - AND IFNULL(deleted, 0) = 0 - ORDER BY id - LIMIT 1 - """.trimIndent(), - mapOf("remarks" to remarks), - ).orElse(null) - if (existing != null) return toLong(existing["id"]) - val at = adjDate.atTime(12, 0) - return insertReturningId( - """ - INSERT INTO stock_out ( - created, createdBy, version, modified, modifiedBy, deleted, - type, completeDate, status, remarks - ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, - 'ADJ', :at, 'completed', :remarks - ) - """.trimIndent(), - mapOf("at" to at, "remarks" to remarks), - ) - } - - /** Line on shared stock_in; does not UPDATE inventory_lot_line or inventory. */ - private fun insertAdjStockInLine( - g: AdjGap, - adjDate: LocalDate, - qty: BigDecimal, - remarks: String, - stockInId: Long, - ): Long? { - val itemId = g.itemId - if (itemId == null) { - log.warn("stock-ledger-fix adj skip SIL: lotLineId={} has no itemId", g.lotLineId) - return null - } - val lot = loadAdjLotLink(g.lotLineId) ?: return null - val at = adjDate.atTime(12, 0) - return insertReturningId( - """ - INSERT INTO stock_in_line ( - created, createdBy, version, modified, modifiedBy, deleted, - itemId, itemNo, stockInId, acceptedQty, receiptDate, status, - inventoryLotId, inventoryLotLineId, lotNo, remarks, type - ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, - :itemId, :itemNo, :stockInId, :qty, :at, 'completed', - :inventoryLotId, :lotLineId, :lotNo, :remarks, 'ADJ' - ) - """.trimIndent(), - mapOf( - "itemId" to itemId, - "itemNo" to adjItemNo(g.itemCode), - "stockInId" to stockInId, - "qty" to qty, - "at" to at, - "inventoryLotId" to lot.inventoryLotId, - "lotLineId" to g.lotLineId, - "lotNo" to lot.lotNo, - "remarks" to remarks, - ), - ) - } - - /** Line on shared stock_out; does not UPDATE inventory_lot_line or inventory. */ - private fun insertAdjStockOutLine( - g: AdjGap, - adjDate: LocalDate, - qty: BigDecimal, - remarks: String, - stockOutId: Long, - ): Long? { - val itemId = g.itemId - if (itemId == null) { - log.warn("stock-ledger-fix adj skip SOL: lotLineId={} has no itemId", g.lotLineId) - return null - } - if (loadAdjLotLink(g.lotLineId) == null) return null - val at = adjDate.atTime(12, 0) - return insertReturningId( - """ - INSERT INTO stock_out_line ( - created, createdBy, version, modified, modifiedBy, deleted, - itemId, qty, stockOutId, inventoryLotLineId, status, pickTime, type - ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, - :itemId, :qty, :stockOutId, :lotLineId, 'completed', :at, 'ADJ' - ) - """.trimIndent(), - mapOf( - "itemId" to itemId, - "qty" to qty, - "stockOutId" to stockOutId, - "lotLineId" to g.lotLineId, - "at" to at, - ), - ) - } - - private fun loadAdjLotLink(lotLineId: Long): AdjLotLink? { - val row = jdbcDao.queryForMap( - """ - SELECT ill.inventoryLotId AS inventoryLotId, il.lotNo AS lotNo - FROM inventory_lot_line ill - LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId - WHERE ill.id = :lotLineId - AND IFNULL(ill.deleted, 0) = 0 - """.trimIndent(), - mapOf("lotLineId" to lotLineId), - ).orElse(null) - if (row == null) { - log.warn("stock-ledger-fix adj skip SIL/SOL: lotLineId={} not found", lotLineId) - return null - } - return AdjLotLink( - inventoryLotId = toLongOrNull(row["inventoryLotId"]), - lotNo = row["lotNo"]?.toString(), - ) - } - - private fun insertReturningId(sql: String, args: Map): Long { - val keyHolder = GeneratedKeyHolder() - namedJdbc.update(sql, MapSqlParameterSource(args), keyHolder, arrayOf("id")) - val key = keyHolder.key?.toLong() - ?: keyHolder.keyList.firstOrNull()?.values?.firstOrNull()?.let { (it as Number).toLong() } - return key ?: error("stock-ledger-fix: insert did not return id") - } - - private fun adjItemNo(itemCode: String?): String { - val raw = itemCode?.trim().orEmpty() - return if (raw.isEmpty()) "SLF" else raw.take(20) - } - - private data class AdjLotLink( - val inventoryLotId: Long?, - val lotNo: String?, - ) - - private fun fillLotQtyForLotIds(lotIds: List, today: LocalDate): Int { - if (lotIds.isEmpty()) return 0 - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") - jdbcDao.executeUpdate( - "CREATE TEMPORARY TABLE tmp_sl_fix_adj_lots (id BIGINT NOT NULL PRIMARY KEY)", - ) - try { - lotIds.chunked(200).forEach { chunk -> - val values = chunk.joinToString(",") { "($it)" } - jdbcDao.executeUpdate("INSERT INTO tmp_sl_fix_adj_lots (id) VALUES $values") - } - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN ( - SELECT - sl2.id, - CAST( - SUM( - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) - ) OVER ( - PARTITION BY sl2.inventoryLotLineId - ORDER BY sl2.id - ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW - ) - AS DECIMAL(14,2)) AS lotAfter, - CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) - - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta - FROM stock_ledger sl2 - INNER JOIN tmp_sl_fix_adj_lots t ON t.id = sl2.inventoryLotLineId - WHERE sl2.deleted = 0 - AND sl2.date < :today - AND sl2.inventoryLotLineId IS NOT NULL - ) calc ON calc.id = sl.id - SET sl.lotQtyAfter = calc.lotAfter, - sl.lotQtyBefore = calc.lotAfter - calc.delta - WHERE sl.deleted = 0 - AND sl.date < :today - """.trimIndent(), - mapOf("today" to today), - ) - } finally { - try { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") - } catch (e: Exception) { - log.warn("stock-ledger-fix drop tmp_sl_fix_adj_lots skipped: {}", e.message) - } - } - } - } - - /** Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. */ - private fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { - val rows = jdbcDao.queryForList( - """ - SELECT id, inventoryId, - CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) AS inQty, - CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) AS outQty - FROM stock_ledger - WHERE deleted = 0 - AND date >= :adjDate AND date < :today - AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' - AND inventoryId IS NOT NULL - ORDER BY id - """.trimIndent(), - mapOf("adjDate" to adjDate, "today" to today), - ) - if (rows.isEmpty()) return 0 - val prevByInv = HashMap() - val updates = ArrayList>() - rows.forEach { row -> - val id = toLong(row["id"]) - val invId = toLong(row["inventoryId"]) - if (prevByInv[invId] == null) { - prevByInv[invId] = toDecimal( - jdbcDao.queryForList( - """ - SELECT balance - FROM stock_ledger - WHERE deleted = 0 - AND inventoryId = :invId - AND date < :today - AND id < :adjId - ORDER BY id DESC - LIMIT 1 - """.trimIndent(), - mapOf("invId" to invId, "today" to today, "adjId" to id), - ).firstOrNull()?.get("balance"), - ) - } - val delta = toDecimal(row["inQty"]).subtract(toDecimal(row["outQty"])) - val bal = prevByInv.getValue(invId).add(delta) - prevByInv[invId] = bal - updates.add(id to bal) - } - return withTempTx { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") - jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_sl_fix_adj_bal ( - id BIGINT NOT NULL PRIMARY KEY, - newBalance DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), - ) - try { - updates.chunked(200).forEach { chunk -> - val values = chunk.joinToString(",") { (id, bal) -> - "($id, ${bal.toPlainString()})" - } - jdbcDao.executeUpdate( - "INSERT INTO tmp_sl_fix_adj_bal (id, newBalance) VALUES $values", - ) - } - jdbcDao.executeUpdate( - """ - UPDATE stock_ledger sl - INNER JOIN tmp_sl_fix_adj_bal t ON t.id = sl.id - SET sl.balance = t.newBalance - WHERE sl.deleted = 0 - """.trimIndent(), - ) - } finally { - try { - jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") - } catch (e: Exception) { - log.warn("stock-ledger-fix drop tmp_sl_fix_adj_bal skipped: {}", e.message) - } - } - } - } - - private fun toDecimal(value: Any?): BigDecimal = when (value) { - null -> BigDecimal.ZERO - is BigDecimal -> value - is Number -> BigDecimal(value.toString()) - else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO - } - - private data class AdjGap( - val lotLineId: Long, - val inventoryId: Long?, - val itemId: Long?, - val itemCode: String?, - val uomId: Long?, - val lineIn: BigDecimal, - val lineOut: BigDecimal, - val ledgerIn: BigDecimal, - val ledgerOut: BigDecimal, - val missIn: BigDecimal, - val missOut: BigDecimal, - ) { - val overIssue: BigDecimal = - if (lineOut.compareTo(lineIn) > 0) lineOut.subtract(lineIn) else BigDecimal.ZERO - - fun hasOverIssue(): Boolean = overIssue.compareTo(BigDecimal.ZERO) > 0 - - fun hasMiss(): Boolean = - missIn.compareTo(BigDecimal.ZERO) != 0 || missOut.compareTo(BigDecimal.ZERO) != 0 - - fun hasWork(): Boolean = hasOverIssue() || hasMiss() - - fun toRow() = StockLedgerFixAdjRow( - lotLineId = lotLineId, - inventoryId = inventoryId, - itemCode = itemCode, - lineIn = lineIn.stripTrailingZeros().toPlainString(), - lineOut = lineOut.stripTrailingZeros().toPlainString(), - ledgerIn = ledgerIn.stripTrailingZeros().toPlainString(), - ledgerOut = ledgerOut.stripTrailingZeros().toPlainString(), - missIn = missIn.stripTrailingZeros().toPlainString(), - missOut = missOut.stripTrailingZeros().toPlainString(), - overIssue = overIssue.stripTrailingZeros().toPlainString(), - ) - } - - private fun toLocalDate(value: Any?): LocalDate? = when (value) { - null -> null - is LocalDate -> value - is java.sql.Date -> value.toLocalDate() - is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() - else -> try { - LocalDate.parse(value.toString().take(10)) - } catch (_: Exception) { - null - } - } - - private data class MissCounts( - val missLot: Int, - val missUom: Int, - val missInventoryId: Int, - val missLotQty: Int, - ) - - companion object { - private val DAY_STEPS = linkedSetOf("2.1", "2.2", "2.3", "2.4", "2.5", "2.6") - /** Legacy default export: ledger calc fields + stock_lot_day (no inventory / inventoryId / ADJ). */ - private val EXPORT_PARTS_DEFAULT = linkedSetOf("ledger", "2.6") - private const val ADJ_DOC_REMARKS = "stock-ledger-fix miss ADJ (no lot qty change)" - private const val OVER_ISSUE_REMARKS = "stock-ledger-fix over-issue remain→0 (lot inQty patched)" - private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ" - - private const val LOT_UOM_PAIR_COUNT_SQL = """ - SELECT COUNT(*) AS c FROM ( - SELECT il.itemId, iu.uomId - FROM inventory_lot_line ill - INNER JOIN inventory_lot il - ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - WHERE IFNULL(ill.deleted, 0) = 0 - AND il.itemId IS NOT NULL - AND iu.uomId IS NOT NULL - GROUP BY il.itemId, iu.uomId - ) x - """ - - private const val MISSING_UOM_PAIR_COUNT_SQL = """ - SELECT COUNT(*) AS c FROM ( - SELECT il.itemId, iu.uomId - FROM inventory_lot_line ill - INNER JOIN inventory_lot il - ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - LEFT JOIN inventory inv - ON inv.itemId = il.itemId - AND COALESCE(inv.stockUomId, inv.uomId) = iu.uomId - AND IFNULL(inv.deleted, 0) = 0 - WHERE IFNULL(ill.deleted, 0) = 0 - AND il.itemId IS NOT NULL - AND iu.uomId IS NOT NULL - AND inv.id IS NULL - GROUP BY il.itemId, iu.uomId - ) x - """ - - private const val NULL_STOCK_UOM_COUNT_SQL = """ - SELECT COUNT(*) AS c - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - AND stockUomId IS NULL - """ - - /** - * Old row: uomId already equals a lot stock UOM (stock, or base when base=stock). - * Fill stockUomId from that lot stock; move uomId to item base. - * One keeper per (itemId, uomId); skip if that stockUomId is already taken - * (including soft-deleted rows that still occupy the unique key). - */ - private const val BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL = """ - UPDATE inventory i - INNER JOIN ( - SELECT DISTINCT il.itemId, iu.uomId AS stockUomId - FROM inventory_lot_line ill - INNER JOIN inventory_lot il - ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - WHERE IFNULL(ill.deleted, 0) = 0 - AND il.itemId IS NOT NULL - AND iu.uomId IS NOT NULL - ) b ON b.itemId = i.itemId AND b.stockUomId = i.uomId - INNER JOIN ( - SELECT itemId, uomId, MIN(id) AS keepId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - AND stockUomId IS NULL - GROUP BY itemId, uomId - ) keep ON keep.keepId = i.id - LEFT JOIN item_uom baseIu - ON baseIu.itemId = i.itemId - AND baseIu.baseUnit = 1 - AND IFNULL(baseIu.deleted, 0) = 0 - LEFT JOIN inventory taken - ON taken.itemId = i.itemId - AND taken.stockUomId = b.stockUomId - AND taken.id <> i.id - SET - i.stockUomId = b.stockUomId, - i.uomId = COALESCE(baseIu.uomId, i.uomId), - i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' - WHERE IFNULL(i.deleted, 0) = 0 - AND i.stockUomId IS NULL - AND taken.id IS NULL - """ - - /** - * Remaining NULL stockUomId: item has exactly one lot stock UOM - * (uomId already base, stock may differ). Keep MIN(id) only. - */ - private const val BACKFILL_STOCK_UOM_SINGLE_LOT_SQL = """ - UPDATE inventory i - INNER JOIN ( - SELECT il.itemId, MIN(iu.uomId) AS stockUomId - FROM inventory_lot_line ill - INNER JOIN inventory_lot il - ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - WHERE IFNULL(ill.deleted, 0) = 0 - AND il.itemId IS NOT NULL - AND iu.uomId IS NOT NULL - GROUP BY il.itemId - HAVING COUNT(DISTINCT iu.uomId) = 1 - ) b ON b.itemId = i.itemId - INNER JOIN ( - SELECT itemId, MIN(id) AS keepId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - AND stockUomId IS NULL - GROUP BY itemId - ) keep ON keep.keepId = i.id - LEFT JOIN item_uom baseIu - ON baseIu.itemId = i.itemId - AND baseIu.baseUnit = 1 - AND IFNULL(baseIu.deleted, 0) = 0 - LEFT JOIN inventory taken - ON taken.itemId = i.itemId - AND taken.stockUomId = b.stockUomId - AND taken.id <> i.id - SET - i.stockUomId = b.stockUomId, - i.uomId = COALESCE(baseIu.uomId, i.uomId), - i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' - WHERE IFNULL(i.deleted, 0) = 0 - AND i.stockUomId IS NULL - AND taken.id IS NULL - """ - - private const val INSERT_MISSING_INVENTORY_SQL = """ - INSERT INTO inventory ( - itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, - price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status, - created, createdBy, modified, modifiedBy, version, deleted - ) - SELECT - src.itemId, - COALESCE(baseIu.uomId, src.uomId), - src.uomId, - 0, 0, 0, - COALESCE(tpl.price, 0), - tpl.currencyId, - COALESCE(tpl.cpu, 0), - COALESCE(tpl.cpuUnit, 'HKD'), - COALESCE(tpl.cpm, 0), - COALESCE(tpl.cpmUnit, 'HKD'), - 'unavailable', - NOW(), 'stock-ledger-fix', NOW(), 'stock-ledger-fix', 0, 0 - FROM ( - SELECT il.itemId, iu.uomId - FROM inventory_lot_line ill - INNER JOIN inventory_lot il - ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - WHERE IFNULL(ill.deleted, 0) = 0 - AND il.itemId IS NOT NULL - AND iu.uomId IS NOT NULL - GROUP BY il.itemId, iu.uomId - ) src - LEFT JOIN item_uom baseIu - ON baseIu.itemId = src.itemId - AND baseIu.baseUnit = 1 - AND IFNULL(baseIu.deleted, 0) = 0 - LEFT JOIN inventory existing - ON existing.itemId = src.itemId - AND IFNULL(existing.deleted, 0) = 0 - AND ( - existing.stockUomId = src.uomId - OR (existing.stockUomId IS NULL AND existing.uomId = src.uomId) - ) - LEFT JOIN inventory occupied - ON occupied.itemId = src.itemId - AND occupied.stockUomId = src.uomId - LEFT JOIN inventory tpl - ON tpl.id = ( - SELECT MIN(i.id) - FROM inventory i - WHERE i.itemId = src.itemId - AND IFNULL(i.deleted, 0) = 0 - ) - WHERE existing.id IS NULL - AND occupied.id IS NULL - """ - - /** - * Extra old UOM rows left NULL after a keeper bucket exists for the item - * (e.g. 18918 id 5261). Soft-delete so search / COALESCE fallbacks ignore them. - */ - private const val SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL = """ - UPDATE inventory i - INNER JOIN ( - SELECT DISTINCT itemId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - AND stockUomId IS NOT NULL - ) keeper ON keeper.itemId = i.itemId - SET - i.deleted = 1, - i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' - WHERE IFNULL(i.deleted, 0) = 0 - AND i.stockUomId IS NULL - """ - - private const val UPDATE_INVENTORY_QTY_SQL = """ - UPDATE inventory i - LEFT JOIN ( - SELECT - il.itemId, - iu.uomId, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand, - SUM( - CASE - WHEN LOWER(ill.status) = 'unavailable' - THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) - ELSE 0 - END - ) AS unavailable - FROM inventory_lot_line ill - INNER JOIN inventory_lot il - ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 - INNER JOIN item_uom iu - ON iu.id = ill.stockItemUomId - WHERE IFNULL(ill.deleted, 0) = 0 - AND il.itemId IS NOT NULL - AND iu.uomId IS NOT NULL - GROUP BY il.itemId, iu.uomId - ) agg ON agg.itemId = i.itemId AND agg.uomId = COALESCE(i.stockUomId, i.uomId) - SET - i.onHandQty = COALESCE(agg.onHand, 0), - i.unavailableQty = COALESCE(agg.unavailable, 0), - i.status = IF( - COALESCE(agg.onHand, 0) - COALESCE(agg.unavailable, 0) > 0, - 'available', - 'unavailable' - ), - i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' - WHERE IFNULL(i.deleted, 0) = 0 - """ - } + ) = export.writeExportSql(from, to, out, partsRaw) } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt new file mode 100644 index 00000000..6b97b641 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -0,0 +1,613 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjPreview +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRow +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.support.GeneratedKeyHolder +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.math.BigDecimal +import java.time.LocalDate + +@Service +open class StockLedgerFixAdjService( + private val support: StockLedgerFixSupport, + private val backfill: StockLedgerFixBackfillService, +) { + private val log = LoggerFactory.getLogger(StockLedgerFixAdjService::class.java) + + /** + * Line in/out vs Σ ledger in/out. Any non-zero gap becomes ADJ on [adjDate] + * (default yesterday; pass today on freeze-night dump). + * Step 1: over-issue (`lineIn < lineOut`) sets lot inQty = outQty (trigger updates inventory) + * and writes SIL + ledger ADJ in. Step 2: miss ADJ (no further lot qty write). + * Positive miss → ADJ in/out; negative miss → reverse ADJ out/in. + * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). + * Ledger gap sum uses `date < adjDate+1`. + */ + open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview { + val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) + val t0 = System.nanoTime() + val gaps = loadAdjGaps(asOfExclusive) + val work = gaps.filter { it.hasWork() } + val over = work.filter { it.hasOverIssue() } + val miss = work.filter { it.hasMiss() } + val sumIn = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } + val sumOut = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } + val sumOver = over.fold(BigDecimal.ZERO) { a, g -> a + g.overIssue } + val limit = rowLimit.coerceIn(0, 50) + log.info( + "stock-ledger-fix adj preview lots={} over={} in={} out={} revIn={} revOut={} {}ms", + work.size, + over.size, + miss.count { it.missIn > BigDecimal.ZERO }, + miss.count { it.missOut > BigDecimal.ZERO }, + miss.count { it.missIn < BigDecimal.ZERO }, + miss.count { it.missOut < BigDecimal.ZERO }, + (System.nanoTime() - t0) / 1_000_000, + ) + val rows = (over + miss.filter { !it.hasOverIssue() }).distinctBy { it.lotLineId }.take(limit) + return StockLedgerFixAdjPreview( + adjDate = adjDate.toString(), + lotCount = work.size, + adjInCount = miss.count { it.missIn > BigDecimal.ZERO }, + adjOutCount = miss.count { it.missOut > BigDecimal.ZERO }, + skippedNegCount = miss.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO }, + sumMissIn = sumIn.stripTrailingZeros().toPlainString(), + sumMissOut = sumOut.stripTrailingZeros().toPlainString(), + skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(), + overIssueCount = over.size, + sumOverIssue = sumOver.stripTrailingZeros().toPlainString(), + rows = rows.map { it.toRow() }, + ) + } + + open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse { + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) { + throw IllegalStateException("fix already running") + } + val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) + val t0 = System.nanoTime() + try { + val gaps = loadAdjGaps(asOfExclusive) + val work = gaps.filter { it.hasWork() } + val over = work.filter { it.hasOverIssue() } + val miss = work.filter { it.hasMiss() } + var insertedIn = 0 + var insertedOut = 0 + var overIssuePatched = 0 + if (work.isNotEmpty()) { + val insTx = TransactionTemplate(support.transactionManager) + insTx.timeout = 300 + insTx.execute { + var stockInId: Long? = null + var stockOutId: Long? = null + fun inHeader(): Long = + stockInId ?: ensureAdjStockInHeader(adjDate).also { stockInId = it } + fun outHeader(): Long = + stockOutId ?: ensureAdjStockOutHeader(adjDate).also { stockOutId = it } + over.forEach { g -> + if (patchLotRemainToZero(g.lotLineId)) { + insertAdjLedger( + g, adjDate, inQty = g.overIssue, outQty = null, + remarks = OVER_ISSUE_REMARKS, stockInId = inHeader(), + ) + insertedIn++ + overIssuePatched++ + } + } + miss.forEach { g -> + if (g.missIn.compareTo(BigDecimal.ZERO) > 0) { + insertAdjLedger(g, adjDate, inQty = g.missIn, outQty = null, stockInId = inHeader()) + insertedIn++ + } else if (g.missIn.compareTo(BigDecimal.ZERO) < 0) { + insertAdjLedger( + g, adjDate, inQty = null, outQty = g.missIn.negate(), stockOutId = outHeader(), + ) + insertedOut++ + } + if (g.missOut.compareTo(BigDecimal.ZERO) > 0) { + insertAdjLedger(g, adjDate, inQty = null, outQty = g.missOut, stockOutId = outHeader()) + insertedOut++ + } else if (g.missOut.compareTo(BigDecimal.ZERO) < 0) { + insertAdjLedger( + g, adjDate, inQty = g.missOut.negate(), outQty = null, stockInId = inHeader(), + ) + insertedIn++ + } + } + } + } + val lotIds = linkedSetOf() + fun addLot(lotId: Long, invId: Long?) { + if (lotId > 0) lotIds.add(lotId) + } + work.forEach { addLot(it.lotLineId, it.inventoryId) } + support.jdbcDao.queryForList( + """ + SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :adjDate AND date < :asOf + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + """.trimIndent(), + mapOf("adjDate" to adjDate, "asOf" to asOfExclusive), + ).forEach { addLot(support.toLong(it["lotId"]), support.toLongOrNull(it["invId"])) } + val filledLotQty = support.timed("adj 2.4") { backfill.fillLotQtyForLotIds(lotIds.toList(), asOfExclusive) } + val filledBalance = support.timed("adj 2.5") { backfill.fillBalanceOnNewAdjRows(adjDate, asOfExclusive) } + val range = if (lotIds.isEmpty()) null else backfill.dateRangeForFilter( + "inventoryLotLineId IN (:lotIds)", + mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()), + ) + val dayRows = if (range != null && lotIds.isNotEmpty()) { + support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } + } else 0 + log.info( + "stock-ledger-fix adj {} over={} in={} out={} lotQty={} bal={} day={} total={}ms", + adjDate, overIssuePatched, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixAdjResponse( + adjDate = adjDate.toString(), + insertedIn = insertedIn, + insertedOut = insertedOut, + overIssuePatched = overIssuePatched, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRows, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + /** + * ADJ window: ledger date = [adjDate], gap sums use `date < adjDate+1`. + * Default adjDate = yesterday. Pass today for freeze-night dump (e.g. 31/8). + */ + private fun resolveAdjWindow(adjDateRaw: String?): Pair { + val today = LocalDate.now() + val adjDate = if (adjDateRaw.isNullOrBlank()) { + today.minusDays(1) + } else { + support.parseDay(adjDateRaw) + } + if (adjDate.isAfter(today)) { + throw IllegalArgumentException("adjDate cannot be in the future") + } + return adjDate to adjDate.plusDays(1) + } + + private fun loadAdjGaps(today: LocalDate): List { + val tx = TransactionTemplate(support.transactionManager) + tx.timeout = 300 + val args = mapOf("today" to today) + return tx.execute { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_adj_line ( + lotLineId BIGINT NOT NULL PRIMARY KEY, + inventoryId INT NULL, + itemId INT NULL, + itemCode VARCHAR(64) NULL, + uomId INT NULL, + lineIn DECIMAL(14,2) NOT NULL, + lineOut DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_adj_led ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, + ledger_in DECIMAL(14,2) NOT NULL, + ledger_out DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + support.timed("adj line-all") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_adj_line ( + lotLineId, inventoryId, itemId, itemCode, uomId, lineIn, lineOut + ) + SELECT + ill.id, + inv.inventoryId, + il.itemId, + it.code, + iu.uomId, + CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)), + CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + LEFT JOIN items it ON it.id = il.itemId + LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId + LEFT JOIN ( + SELECT itemId, COALESCE(stockUomId, uomId) AS bucketUomId, MIN(id) AS inventoryId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + GROUP BY itemId, COALESCE(stockUomId, uomId) + ) inv ON inv.itemId = il.itemId AND inv.bucketUomId = iu.uomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND ( + COALESCE(ill.inQty, 0) <> 0 + OR COALESCE(ill.outQty, 0) <> 0 + ) + """.trimIndent(), + ) + } + val lineIds = support.jdbcDao.queryForList( + "SELECT lotLineId FROM tmp_adj_line", + ).map { support.toLong(it["lotLineId"]) }.filter { it > 0 } + log.info("stock-ledger-fix adj line rows={}", lineIds.size) + if (lineIds.isEmpty()) return@execute emptyList() + support.timed("adj ledger-by-line") { + lineIds.chunked(40).sumOf { chunk -> + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_adj_led (inventoryLotLineId, ledger_in, ledger_out) + SELECT + sl.inventoryLotLineId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)), + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) + FROM stock_ledger sl FORCE INDEX (idx_ledger_lot_date_id) + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IN (:ids) + GROUP BY sl.inventoryLotLineId + """.trimIndent(), + mapOf("today" to today, "ids" to chunk), + ) + } + } + support.jdbcDao.queryForList( + """ + SELECT + l.lotLineId, + l.inventoryId, + l.itemId, + l.itemCode, + l.uomId, + l.lineIn, + l.lineOut, + CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) AS ledgerIn, + CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) AS ledgerOut + FROM tmp_adj_line l + LEFT JOIN tmp_adj_led agg ON agg.inventoryLotLineId = l.lotLineId + WHERE l.lineIn <> CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) + OR l.lineOut <> CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) + OR l.lineIn < l.lineOut + """.trimIndent(), + ).map { row -> + val lineIn = support.toDecimal(row["lineIn"]) + val lineOut = support.toDecimal(row["lineOut"]) + val ledgerIn = support.toDecimal(row["ledgerIn"]) + val ledgerOut = support.toDecimal(row["ledgerOut"]) + AdjGap( + lotLineId = support.toLong(row["lotLineId"]), + inventoryId = support.toLongOrNull(row["inventoryId"]), + itemId = support.toLongOrNull(row["itemId"]), + itemCode = row["itemCode"]?.toString(), + uomId = support.toLongOrNull(row["uomId"]), + lineIn = lineIn, + lineOut = lineOut, + ledgerIn = ledgerIn, + ledgerOut = ledgerOut, + missIn = lineIn.subtract(ledgerIn), + missOut = lineOut.subtract(ledgerOut), + ) + } + } finally { + try { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") + } catch (e: Exception) { + log.warn("stock-ledger-fix drop tmp_adj skipped: {}", e.message) + } + } + } ?: emptyList() + } + + private fun insertAdjLedger( + g: AdjGap, + adjDate: LocalDate, + inQty: BigDecimal?, + outQty: BigDecimal?, + remarks: String = ADJ_DOC_REMARKS, + stockInId: Long? = null, + stockOutId: Long? = null, + ) { + val silId = if (inQty != null) { + insertAdjStockInLine(g, adjDate, inQty, remarks, stockInId ?: error("stockInId required for ADJ in")) + } else { + null + } + val solId = if (outQty != null) { + insertAdjStockOutLine(g, adjDate, outQty, remarks, stockOutId ?: error("stockOutId required for ADJ out")) + } else { + null + } + val args = hashMapOf( + "adjDate" to adjDate, + "silId" to silId, + "solId" to solId, + "lotLineId" to g.lotLineId, + "inventoryId" to g.inventoryId, + "itemId" to g.itemId, + "itemCode" to g.itemCode, + "inQty" to inQty, + "outQty" to outQty, + "uomId" to g.uomId, + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO stock_ledger ( + created, createdBy, version, modified, modifiedBy, deleted, + date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, + itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, + uomId, type + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :adjDate, :silId, :solId, :lotLineId, :inventoryId, + :itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL, + :uomId, 'ADJ' + ) + """.trimIndent(), + args, + ) + } + + /** Sets lot inQty = outQty so remain = 0; trigger recomputes inventory. */ + private fun patchLotRemainToZero(lotLineId: Long): Boolean { + val n = support.jdbcDao.executeUpdate( + """ + UPDATE inventory_lot_line + SET inQty = CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)), + status = 'unavailable', + modified = NOW(), + modifiedBy = 'stock-ledger-fix', + version = IFNULL(version, 0) + 1 + WHERE id = :lotLineId + AND IFNULL(deleted, 0) = 0 + AND CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) + < CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) + """.trimIndent(), + mapOf("lotLineId" to lotLineId), + ) + if (n == 0) { + log.warn("stock-ledger-fix over-issue skip lot update: lotLineId={}", lotLineId) + } + return n > 0 + } + + private fun adjStockInCode(adjDate: LocalDate): String = "SLF-I-$adjDate" + + private fun adjStockOutRemarks(adjDate: LocalDate): String = "$ADJ_HEADER_REMARKS $adjDate" + + /** One stock_in per ADJ date (reused if Apply runs again). */ + private fun ensureAdjStockInHeader(adjDate: LocalDate): Long { + val code = adjStockInCode(adjDate) + val existing = support.jdbcDao.queryForMap( + """ + SELECT id FROM stock_in + WHERE code = :code AND IFNULL(deleted, 0) = 0 + LIMIT 1 + """.trimIndent(), + mapOf("code" to code), + ).orElse(null) + if (existing != null) return support.toLong(existing["id"]) + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_in ( + created, createdBy, version, modified, modifiedBy, deleted, + code, status, orderDate, completeDate + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :code, 'completed', :at, :at + ) + """.trimIndent(), + mapOf("code" to code, "at" to at), + ) + } + + /** One stock_out per ADJ date (reused if Apply runs again). */ + private fun ensureAdjStockOutHeader(adjDate: LocalDate): Long { + val remarks = adjStockOutRemarks(adjDate) + val existing = support.jdbcDao.queryForMap( + """ + SELECT id FROM stock_out + WHERE createdBy = 'stock-ledger-fix' + AND type = 'ADJ' + AND remarks = :remarks + AND IFNULL(deleted, 0) = 0 + ORDER BY id + LIMIT 1 + """.trimIndent(), + mapOf("remarks" to remarks), + ).orElse(null) + if (existing != null) return support.toLong(existing["id"]) + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_out ( + created, createdBy, version, modified, modifiedBy, deleted, + type, completeDate, status, remarks + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + 'ADJ', :at, 'completed', :remarks + ) + """.trimIndent(), + mapOf("at" to at, "remarks" to remarks), + ) + } + + /** Line on shared stock_in; does not UPDATE inventory_lot_line or inventory. */ + private fun insertAdjStockInLine( + g: AdjGap, + adjDate: LocalDate, + qty: BigDecimal, + remarks: String, + stockInId: Long, + ): Long? { + val itemId = g.itemId + if (itemId == null) { + log.warn("stock-ledger-fix adj skip SIL: lotLineId={} has no itemId", g.lotLineId) + return null + } + val lot = loadAdjLotLink(g.lotLineId) ?: return null + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_in_line ( + created, createdBy, version, modified, modifiedBy, deleted, + itemId, itemNo, stockInId, acceptedQty, receiptDate, status, + inventoryLotId, inventoryLotLineId, lotNo, remarks, type + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :itemId, :itemNo, :stockInId, :qty, :at, 'completed', + :inventoryLotId, :lotLineId, :lotNo, :remarks, 'ADJ' + ) + """.trimIndent(), + mapOf( + "itemId" to itemId, + "itemNo" to adjItemNo(g.itemCode), + "stockInId" to stockInId, + "qty" to qty, + "at" to at, + "inventoryLotId" to lot.inventoryLotId, + "lotLineId" to g.lotLineId, + "lotNo" to lot.lotNo, + "remarks" to remarks, + ), + ) + } + + /** Line on shared stock_out; does not UPDATE inventory_lot_line or inventory. */ + private fun insertAdjStockOutLine( + g: AdjGap, + adjDate: LocalDate, + qty: BigDecimal, + remarks: String, + stockOutId: Long, + ): Long? { + val itemId = g.itemId + if (itemId == null) { + log.warn("stock-ledger-fix adj skip SOL: lotLineId={} has no itemId", g.lotLineId) + return null + } + if (loadAdjLotLink(g.lotLineId) == null) return null + val at = adjDate.atTime(12, 0) + return insertReturningId( + """ + INSERT INTO stock_out_line ( + created, createdBy, version, modified, modifiedBy, deleted, + itemId, qty, stockOutId, inventoryLotLineId, status, pickTime, type + ) VALUES ( + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + :itemId, :qty, :stockOutId, :lotLineId, 'completed', :at, 'ADJ' + ) + """.trimIndent(), + mapOf( + "itemId" to itemId, + "qty" to qty, + "stockOutId" to stockOutId, + "lotLineId" to g.lotLineId, + "at" to at, + ), + ) + } + + private fun loadAdjLotLink(lotLineId: Long): AdjLotLink? { + val row = support.jdbcDao.queryForMap( + """ + SELECT ill.inventoryLotId AS inventoryLotId, il.lotNo AS lotNo + FROM inventory_lot_line ill + LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId + WHERE ill.id = :lotLineId + AND IFNULL(ill.deleted, 0) = 0 + """.trimIndent(), + mapOf("lotLineId" to lotLineId), + ).orElse(null) + if (row == null) { + log.warn("stock-ledger-fix adj skip SIL/SOL: lotLineId={} not found", lotLineId) + return null + } + return AdjLotLink( + inventoryLotId = support.toLongOrNull(row["inventoryLotId"]), + lotNo = row["lotNo"]?.toString(), + ) + } + + private fun insertReturningId(sql: String, args: Map): Long { + val keyHolder = GeneratedKeyHolder() + support.namedJdbc.update(sql, MapSqlParameterSource(args), keyHolder, arrayOf("id")) + val key = keyHolder.key?.toLong() + ?: keyHolder.keyList.firstOrNull()?.values?.firstOrNull()?.let { (it as Number).toLong() } + return key ?: error("stock-ledger-fix: insert did not return id") + } + + private fun adjItemNo(itemCode: String?): String { + val raw = itemCode?.trim().orEmpty() + return if (raw.isEmpty()) "SLF" else raw.take(20) + } + + private data class AdjLotLink( + val inventoryLotId: Long?, + val lotNo: String?, + ) + + private data class AdjGap( + val lotLineId: Long, + val inventoryId: Long?, + val itemId: Long?, + val itemCode: String?, + val uomId: Long?, + val lineIn: BigDecimal, + val lineOut: BigDecimal, + val ledgerIn: BigDecimal, + val ledgerOut: BigDecimal, + val missIn: BigDecimal, + val missOut: BigDecimal, + ) { + val overIssue: BigDecimal = + if (lineOut.compareTo(lineIn) > 0) lineOut.subtract(lineIn) else BigDecimal.ZERO + + fun hasOverIssue(): Boolean = overIssue.compareTo(BigDecimal.ZERO) > 0 + + fun hasMiss(): Boolean = + missIn.compareTo(BigDecimal.ZERO) != 0 || missOut.compareTo(BigDecimal.ZERO) != 0 + + fun hasWork(): Boolean = hasOverIssue() || hasMiss() + + fun toRow() = StockLedgerFixAdjRow( + lotLineId = lotLineId, + inventoryId = inventoryId, + itemCode = itemCode, + lineIn = lineIn.stripTrailingZeros().toPlainString(), + lineOut = lineOut.stripTrailingZeros().toPlainString(), + ledgerIn = ledgerIn.stripTrailingZeros().toPlainString(), + ledgerOut = ledgerOut.stripTrailingZeros().toPlainString(), + missIn = missIn.stripTrailingZeros().toPlainString(), + missOut = missOut.stripTrailingZeros().toPlainString(), + overIssue = overIssue.stripTrailingZeros().toPlainString(), + ) + } + + companion object { + private const val ADJ_DOC_REMARKS = "stock-ledger-fix miss ADJ (no lot qty change)" + private const val OVER_ISSUE_REMARKS = "stock-ledger-fix over-issue remain→0 (lot inQty patched)" + private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt new file mode 100644 index 00000000..e6ffce3b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -0,0 +1,1403 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunResponse +import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import java.math.BigDecimal +import java.time.LocalDate + +@Service +open class StockLedgerFixBackfillService( + private val support: StockLedgerFixSupport, + private val stockLotDayCloseService: StockLotDayCloseService, +) { + private val log = LoggerFactory.getLogger(StockLedgerFixBackfillService::class.java) + + @Transactional(timeout = 300) + open fun fixInventory10(): StockLedgerFixInventoryResponse { + if (!support.tryLock(StockLedgerFixSupport.INVENTORY_LOCK)) { + throw IllegalStateException("inventory 1.0 already running") + } + try { + val patchedFromUom = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL) + val patchedSingleLot = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.BACKFILL_STOCK_UOM_SINGLE_LOT_SQL) + val patchedStockUomId = patchedFromUom + patchedSingleLot + val inserted = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.INSERT_MISSING_INVENTORY_SQL) + val orphansDeleted = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL) + val updated = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.UPDATE_INVENTORY_QTY_SQL) + val missingAfter = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + val nullStockUomIdAfter = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), + ) + log.info( + "stock-ledger-fix inventory-1.0 patchedStockUomId={} inserted={} orphansDeleted={} updated={} missingAfter={} nullStockUomIdAfter={}", + patchedStockUomId, + inserted, + orphansDeleted, + updated, + missingAfter, + nullStockUomIdAfter, + ) + return StockLedgerFixInventoryResponse( + inserted = inserted, + updated = updated, + missingUomPairsAfter = missingAfter, + patchedStockUomId = patchedStockUomId, + nullStockUomIdAfter = nullStockUomIdAfter, + orphansDeleted = orphansDeleted, + ) + } finally { + support.unlock(StockLedgerFixSupport.INVENTORY_LOCK) + } + } + + /** + * Rebuild 2.1–2.6 for one inventory bucket through calendar today + * (`date < tomorrow`). Calendar day-fix still excludes today unless Allow today. + */ + open fun fixInventoryScope(inventoryId: Long): StockLedgerFixRunResponse { + if (inventoryId <= 0) throw IllegalArgumentException("inventoryId is required") + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) throw IllegalStateException("fix already running") + val toExclusive = support.inventoryScopeToExclusive() + val t0 = System.nanoTime() + try { + val inv = support.jdbcDao.queryForList( + """ + SELECT id, itemId, uomId FROM inventory WHERE id = :inventoryId + """.trimIndent(), + mapOf("inventoryId" to inventoryId), + ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") + val args = mapOf( + "inventoryId" to inventoryId, + "itemId" to (inv["itemId"] ?: 0), + "uomId" to (inv["uomId"] ?: 0), + "today" to toExclusive, + ) + val filledLotLineId = support.timed("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } + val filledUomId = support.timed("inv 2.2") { fillUomIdScope(args, byInventory = true) } + val filledInventoryId = support.timed("inv 2.3") { fillInventoryIdScope(args, byInventory = true) } + val filledLotQty = support.timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } + val filledBalance = support.timed("inv 2.5") { fillBalanceHistory(args) } + val lotIds = lotIdsForInventory(inventoryId, toExclusive) + val range = dateRangeForFilter("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to toExclusive)) + val dayRows = if (range != null) { + support.timed("inv 2.6") { upsertStockLotDayRange(lotIds, range.first, range.second) } + } else 0 + val still = stillMissScope("inventoryId = :inventoryId", mapOf("inventoryId" to inventoryId, "today" to toExclusive)) + log.info( + "stock-ledger-fix inventory {} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", + inventoryId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, filledBalance, dayRows, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = range?.first?.toString() ?: LocalDate.now().toString(), + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRows, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix inventory {} FAILED after {}ms: {}", inventoryId, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + open fun fixLotScope(lotLineId: Long): StockLedgerFixRunResponse { + if (lotLineId <= 0) throw IllegalArgumentException("lotLineId is required") + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) throw IllegalStateException("fix already running") + val today = LocalDate.now() + val t0 = System.nanoTime() + try { + val exists = support.toInt( + support.jdbcDao.queryForList( + "SELECT COUNT(*) AS c FROM inventory_lot_line WHERE id = :lotId", + mapOf("lotId" to lotLineId), + ).firstOrNull()?.get("c"), + ) + if (exists == 0) throw IllegalArgumentException("lot line not found: $lotLineId") + val args = mapOf("lotId" to lotLineId, "today" to today) + val filledLotLineId = support.timed("lot 2.1") { fillLotLineIdScope(args, byInventory = false) } + val filledUomId = support.timed("lot 2.2") { fillUomIdScope(args, byInventory = false) } + val filledInventoryId = support.timed("lot 2.3") { fillInventoryIdScope(args, byInventory = false) } + val filledLotQty = support.timed("lot 2.4") { fillLotQtyHistory(args, byInventory = false) } + val range = dateRangeForFilter("inventoryLotLineId = :lotId", args) + val dayRows = if (range != null) { + support.timed("lot 2.6") { upsertStockLotDayRange(listOf(lotLineId), range.first, range.second) } + } else 0 + val still = stillMissScope("inventoryLotLineId = :lotId", args) + log.info( + "stock-ledger-fix lot {} lotLine={} uom={} inv={} lotQty={} dayRows={} total={}ms", + lotLineId, filledLotLineId, filledUomId, filledInventoryId, filledLotQty, dayRows, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = range?.first?.toString() ?: today.toString(), + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = 0, + dayRowsWritten = dayRows, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix lot {} FAILED after {}ms: {}", lotLineId, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + /** + * No wrapping transaction: each step auto-commits so a later timeout + * (typically 2.5) does not roll back 2.1–2.4 lot fills. + * [stepsRaw] empty / all = 2.1–2.6; otherwise only those keys (e.g. 2.3). + */ + open fun fixDay(dateRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { + val day = support.parseDay(dateRaw) + // Allow today (freeze-night dump fix); reject future only. + if (day.isAfter(LocalDate.now())) { + throw IllegalArgumentException("cannot fix future dates") + } + val key = day.toString() + val steps = support.parseDaySteps(stepsRaw) + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) { + throw IllegalStateException("fix already running") + } + val t0 = System.nanoTime() + try { + val args = support.dayArgs(day) + val filledLotLineId = if ("2.1" in steps) support.timed("2.1 fillLotLineId") { fillLotLineId(args) } else 0 + val filledUomId = if ("2.2" in steps) support.timed("2.2 fillUomId") { fillUomId(args) } else 0 + val filledInventoryId = if ("2.3" in steps) support.timed("2.3 fillInventoryId") { fillInventoryId(args) } else 0 + val filledLotQty = if ("2.4" in steps) support.timed("2.4 fillLotQty") { fillLotQty(args) } else 0 + val filledBalance = if ("2.5" in steps) support.timed("2.5 fillBalance") { fillBalance(args) } else 0 + val dayRowsWritten = if ("2.6" in steps) { + support.timed("2.6 upsertStockLotDay") { + stockLotDayCloseService.upsertDayForFix(day).rowsWritten + } + } else { + 0 + } + val still = stillMiss(args) + log.info( + "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", + key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, + filledLotQty, filledBalance, dayRowsWritten, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = key, + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRowsWritten, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn( + "stock-ledger-fix {} FAILED after {}ms: {}", + key, (System.nanoTime() - t0) / 1_000_000, e.message, + ) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + /** + * One HTTP call for [from]..[to] inclusive. + * Walks days like [fixDay] (each day commits) so 2.3–2.5 do not lock the whole range. + */ + open fun fixRange(fromRaw: String, toRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse { + val from = support.parseDay(fromRaw) + val to = support.parseDay(toRaw) + if (to.isBefore(from)) { + throw IllegalArgumentException("to must be on or after from") + } + if (from.plusDays(400).isBefore(to)) { + throw IllegalArgumentException("date range must be at most 400 days") + } + if (to.isAfter(LocalDate.now())) { + throw IllegalArgumentException("cannot fix future dates") + } + val key = "$from..$to" + val steps = support.parseDaySteps(stepsRaw) + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) { + throw IllegalStateException("fix already running") + } + val t0 = System.nanoTime() + try { + var filledLotLineId = 0 + var filledUomId = 0 + var filledInventoryId = 0 + var filledLotQty = 0 + var filledBalance = 0 + var dayRowsWritten = 0 + var d = from + while (!d.isAfter(to)) { + val args = support.dayArgs(d) + if ("2.1" in steps) filledLotLineId += support.timed("range $d 2.1") { fillLotLineId(args) } + if ("2.2" in steps) filledUomId += support.timed("range $d 2.2") { fillUomId(args) } + if ("2.3" in steps) filledInventoryId += support.timed("range $d 2.3") { fillInventoryId(args) } + if ("2.4" in steps) filledLotQty += support.timed("range $d 2.4") { fillLotQty(args) } + if ("2.5" in steps) filledBalance += support.timed("range $d 2.5") { fillBalance(args) } + if ("2.6" in steps) { + dayRowsWritten += support.timed("range $d 2.6") { + stockLotDayCloseService.upsertDayForFix(d).rowsWritten + } + } + d = d.plusDays(1) + } + val still = stillMiss(support.rangeArgs(from, to)) + log.info( + "stock-ledger-fix {} steps={} lotLine={} uom={} inv={} lotQty={} bal={} dayRows={} total={}ms", + key, steps.joinToString(","), filledLotLineId, filledUomId, filledInventoryId, + filledLotQty, filledBalance, dayRowsWritten, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixRunResponse( + date = key, + filledLotLineId = filledLotLineId, + filledUomId = filledUomId, + filledInventoryId = filledInventoryId, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRowsWritten, + stillMissLot = still.missLot, + stillMissUom = still.missUom, + stillMissInventoryId = still.missInventoryId, + stillMissLotQty = still.missLotQty, + ) + } catch (e: Exception) { + log.warn( + "stock-ledger-fix {} FAILED after {}ms: {}", + key, (System.nanoTime() - t0) / 1_000_000, e.message, + ) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + /** 2.1 SOL / SIL lot line; one-line fallback when SIL has lot header only. */ + private fun fillLotLineId(args: Map): Int { + val fromSolSil = support.timed("2.1a solSil") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + """.trimIndent(), + args, + ) + } + val fromOneLine = support.timed("2.1b oneLine") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN ( + SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId + FROM inventory_lot_line ill + INNER JOIN ( + SELECT DISTINCT sil2.inventoryLotId + FROM stock_ledger slx + INNER JOIN stock_in_line sil2 + ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 + WHERE slx.deleted = 0 + AND slx.date >= :day AND slx.date < :dayNext + AND slx.inventoryLotLineId IS NULL + AND sil2.inventoryLotLineId IS NULL + AND sil2.inventoryLotId IS NOT NULL + ) hdr ON hdr.inventoryLotId = ill.inventoryLotId + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY ill.inventoryLotId + HAVING COUNT(*) = 1 + ) one ON one.inventoryLotId = sil.inventoryLotId + SET sl.inventoryLotLineId = one.onlyLineId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + """.trimIndent(), + args, + ) + } + return fromSolSil + fromOneLine + } + + /** 2.2 lot line stock UOM → uom_conversion id. Include deleted item_uom (historical lots still point there). */ + private fun fillUomId(args: Map): Int { + return support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN inventory_lot_line ill + ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + SET sl.uomId = iu.uomId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + } + + /** + * 2.3: ledger stock UOM (`sl.uomId` from 2.2) → inventory bucket. + * A joins `inventory.stockUomId` (indexable). B is leftover rows with null stockUomId. + */ + private fun fillInventoryId(args: Map): Int { + return applyInventoryIdUpdates( + args, + extraJoin = "", + extraWhere = "sl.date >= :day AND sl.date < :dayNext", + ) + } + + private fun applyInventoryIdUpdates( + args: Map, + extraJoin: String, + extraWhere: String, + ): Int { + val byStockUom = support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + $extraJoin + INNER JOIN ( + SELECT itemId, stockUomId, MIN(id) AS inventoryId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NOT NULL + GROUP BY itemId, stockUomId + ) inv ON inv.itemId = sl.itemId AND inv.stockUomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + WHERE sl.deleted = 0 + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND (sl.inventoryId IS NULL OR sl.inventoryId <> inv.inventoryId) + AND ($extraWhere) + """.trimIndent(), + args, + ) + val byLegacyUom = support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + $extraJoin + INNER JOIN ( + SELECT itemId, uomId, MIN(id) AS inventoryId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + AND uomId IS NOT NULL + GROUP BY itemId, uomId + ) inv ON inv.itemId = sl.itemId AND inv.uomId = sl.uomId + SET sl.inventoryId = inv.inventoryId + WHERE sl.deleted = 0 + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND sl.inventoryId IS NULL + AND ($extraWhere) + """.trimIndent(), + args, + ) + return byStockUom + byLegacyUom + } + + /** + * 2.4 Opening = last lotQtyAfter before the window start (yesterday first, then lookback). + * Idle days must not reset to 0. Window is :day .. :dayNext (one day or a range). + */ + private fun fillLotQty(args: Map): Int { + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_today ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_miss ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_prev ( + inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, + prevAfter DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_qty ( + id INT NOT NULL PRIMARY KEY, + lotAfter DECIMAL(14,2) NOT NULL, + delta DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_today (inventoryLotLineId) + SELECT DISTINCT inventoryLotLineId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + AND inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + support.timed("2.4a1 yesterday") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) + SELECT slp.inventoryLotLineId, slp.lotQtyAfter + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId + FROM stock_ledger sl + INNER JOIN tmp_sl_fix_lot_today t + ON t.inventoryLotLineId = sl.inventoryLotLineId + WHERE sl.deleted = 0 + AND sl.date >= :prevDay AND sl.date < :day + AND sl.lotQtyAfter IS NOT NULL + GROUP BY sl.inventoryLotLineId + ) m ON m.maxId = slp.id + """.trimIndent(), + args, + ) + } + support.timed("2.4a2 lookback") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_miss (inventoryLotLineId) + SELECT t.inventoryLotLineId + FROM tmp_sl_fix_lot_today t + LEFT JOIN tmp_sl_fix_lot_prev p + ON p.inventoryLotLineId = t.inventoryLotLineId + WHERE p.inventoryLotLineId IS NULL + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_prev (inventoryLotLineId, prevAfter) + SELECT slp.inventoryLotLineId, slp.lotQtyAfter + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :prevDay + AND sl.lotQtyAfter IS NOT NULL + AND sl.inventoryLotLineId IN ( + SELECT inventoryLotLineId FROM tmp_sl_fix_lot_miss + ) + GROUP BY sl.inventoryLotLineId + ) x ON x.maxId = slp.id + """.trimIndent(), + args, + ) + } + support.timed("2.4b window") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_qty (id, lotAfter, delta) + SELECT + sl2.id, + CAST( + COALESCE(p.prevAfter, 0) + + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryLotLineId + ORDER BY sl2.date, sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS lotAfter, + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta + FROM stock_ledger sl2 + LEFT JOIN tmp_sl_fix_lot_prev p + ON p.inventoryLotLineId = sl2.inventoryLotLineId + WHERE sl2.deleted = 0 + AND sl2.date >= :day AND sl2.date < :dayNext + AND sl2.inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + } + support.timed("2.4c apply") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_qty t ON t.id = sl.id + SET sl.lotQtyAfter = t.lotAfter, + sl.lotQtyBefore = t.lotAfter - t.delta + """.trimIndent(), + ) + } + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_qty") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_prev") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_today") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_miss") + } + } + } + + /** + * 2.5 Running balance per inventoryId. + * Opening = last balance before the window. Fast path: day before :day. Lookback only for + * inventoryIds that had no row that day (idle days must not reset to 0). + * Window is :day .. :dayNext (one day or a range). + */ + private fun fillBalance(args: Map): Int { + val tx = TransactionTemplate(support.transactionManager) + return tx.execute { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_today ( + inventoryId INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_miss ( + inventoryId INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_prev ( + inventoryId INT NOT NULL PRIMARY KEY, + prevBalance DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_stock_ledger_fix_bal ( + id INT NOT NULL PRIMARY KEY, + newBalance DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_today (inventoryId) + SELECT DISTINCT inventoryId + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + AND inventoryId IS NOT NULL + """.trimIndent(), + args, + ) + support.timed("2.5a1 yesterday") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) + SELECT slp.inventoryId, slp.balance + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryId, MAX(sl.id) AS maxId + FROM stock_ledger sl + INNER JOIN tmp_stock_ledger_fix_today t + ON t.inventoryId = sl.inventoryId + WHERE sl.deleted = 0 + AND sl.date >= :prevDay AND sl.date < :day + AND sl.balance IS NOT NULL + GROUP BY sl.inventoryId + ) m ON m.maxId = slp.id + """.trimIndent(), + args, + ) + } + support.timed("2.5a2 lookback") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_miss (inventoryId) + SELECT t.inventoryId + FROM tmp_stock_ledger_fix_today t + LEFT JOIN tmp_stock_ledger_fix_prev p + ON p.inventoryId = t.inventoryId + WHERE p.inventoryId IS NULL + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_prev (inventoryId, prevBalance) + SELECT slp.inventoryId, slp.balance + FROM stock_ledger slp + INNER JOIN ( + SELECT sl.inventoryId, MAX(sl.id) AS maxId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :prevDay + AND sl.balance IS NOT NULL + AND sl.inventoryId IN ( + SELECT inventoryId FROM tmp_stock_ledger_fix_miss + ) + GROUP BY sl.inventoryId + ) x ON x.maxId = slp.id + """.trimIndent(), + args, + ) + } + support.timed("2.5b window") { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_stock_ledger_fix_bal (id, newBalance) + SELECT + sl2.id, + CAST( + COALESCE(p.prevBalance, 0) + + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryId + ORDER BY sl2.date, sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS newBalance + FROM stock_ledger sl2 + LEFT JOIN tmp_stock_ledger_fix_prev p + ON p.inventoryId = sl2.inventoryId + WHERE sl2.deleted = 0 + AND sl2.date >= :day AND sl2.date < :dayNext + AND sl2.inventoryId IS NOT NULL + """.trimIndent(), + args, + ) + } + support.timed("2.5c apply") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_stock_ledger_fix_bal t ON t.id = sl.id + SET sl.balance = t.newBalance + """.trimIndent(), + ) + } + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_bal") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_prev") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_today") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_stock_ledger_fix_miss") + } + } ?: 0 + } + + private fun fillLotLineIdScope(args: Map, byInventory: Boolean): Int { + if (byInventory) return fillLotLineIdScopeByInventory(args) + val lotFilter = """ + AND ( + COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = :lotId + OR sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + ) + """.trimIndent() + val fromSolSil = support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + $lotFilter + """.trimIndent(), + args, + ) + val fromOneLine = support.timed("lot 2.1b oneLine") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN ( + SELECT ill.inventoryLotId, MIN(ill.id) AS onlyLineId + FROM inventory_lot_line ill + INNER JOIN ( + SELECT DISTINCT sil2.inventoryLotId + FROM stock_ledger slx + INNER JOIN stock_in_line sil2 + ON sil2.id = slx.stockInLineId AND IFNULL(sil2.deleted, 0) = 0 + WHERE slx.deleted = 0 + AND slx.date < :today + AND slx.inventoryLotLineId IS NULL + AND sil2.inventoryLotLineId IS NULL + AND sil2.inventoryLotId IS NOT NULL + AND sil2.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + ) hdr ON hdr.inventoryLotId = ill.inventoryLotId + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY ill.inventoryLotId + HAVING COUNT(*) = 1 + ) one ON one.inventoryLotId = sil.inventoryLotId + SET sl.inventoryLotLineId = one.onlyLineId + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + AND sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + """.trimIndent(), + args, + ) + } + return fromSolSil + fromOneLine + } + + /** Collect candidate ledger ids first — do not UPDATE with date < today OR …. */ + private fun fillLotLineIdScopeByInventory(args: Map): Int { + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_lot_ids ( + id INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_sil_lots ( + inventoryLotId BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_one_line ( + inventoryLotId BIGINT NOT NULL PRIMARY KEY, + onlyLineId BIGINT NOT NULL + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_lot_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND sl.inventoryId = :inventoryId + """.trimIndent(), + args, + ) + support.jdbcDao.executeUpdate( + """ + INSERT IGNORE INTO tmp_sl_fix_lot_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IS NULL + AND sl.itemId = :itemId + AND (sl.uomId = :uomId OR sl.uomId IS NULL) + """.trimIndent(), + args, + ) + val fromSolSil = support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET sl.inventoryLotLineId = COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + WHERE sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_sil_lots (inventoryLotId) + SELECT DISTINCT sil.inventoryLotId + FROM tmp_sl_fix_lot_ids t + INNER JOIN stock_ledger sl ON sl.id = t.id + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + WHERE sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_one_line (inventoryLotId, onlyLineId) + SELECT ill.inventoryLotId, MIN(ill.id) + FROM inventory_lot_line ill + INNER JOIN tmp_sl_fix_sil_lots hdr ON hdr.inventoryLotId = ill.inventoryLotId + WHERE IFNULL(ill.deleted, 0) = 0 + GROUP BY ill.inventoryLotId + HAVING COUNT(*) = 1 + """.trimIndent(), + ) + val fromOneLine = support.timed("inv 2.1b oneLine") { + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id + INNER JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN tmp_sl_fix_one_line one ON one.inventoryLotId = sil.inventoryLotId + SET sl.inventoryLotLineId = one.onlyLineId + WHERE sl.inventoryLotLineId IS NULL + AND sil.inventoryLotLineId IS NULL + AND sil.inventoryLotId IS NOT NULL + """.trimIndent(), + ) + } + fromSolSil + fromOneLine + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") + } + } + } + + private fun fillUomIdScope(args: Map, byInventory: Boolean): Int { + if (!byInventory) { + return support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN inventory_lot_line ill + ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + SET sl.uomId = iu.uomId + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId = :lotId + """.trimIndent(), + args, + ) + } + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_uom_lots ( + id BIGINT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_uom_ids ( + id INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_uom_lots (id) + SELECT DISTINCT inventoryLotLineId + FROM stock_ledger + WHERE deleted = 0 + AND date < :today + AND inventoryId = :inventoryId + AND inventoryLotLineId IS NOT NULL + """.trimIndent(), + args, + ) + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_uom_ids (id) + SELECT sl.id + FROM stock_ledger sl + INNER JOIN tmp_sl_fix_uom_lots t ON t.id = sl.inventoryLotLineId + WHERE sl.deleted = 0 + AND sl.date < :today + """.trimIndent(), + args, + ) + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_uom_ids t ON t.id = sl.id + INNER JOIN inventory_lot_line ill + ON ill.id = sl.inventoryLotLineId AND IFNULL(ill.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + SET sl.uomId = iu.uomId + """.trimIndent(), + ) + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_ids") + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_uom_lots") + } + } + } + + private fun fillInventoryIdScope(args: Map, byInventory: Boolean): Int { + if (!byInventory) { + return applyInventoryIdUpdates( + args, + extraJoin = "", + extraWhere = "sl.date < :today AND sl.inventoryLotLineId = :lotId", + ) + } + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_inv_ids ( + id INT NOT NULL PRIMARY KEY + ) + """.trimIndent(), + ) + try { + support.jdbcDao.executeUpdate( + """ + INSERT INTO tmp_sl_fix_inv_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryId = :inventoryId + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + """.trimIndent(), + args, + ) + support.jdbcDao.executeUpdate( + """ + INSERT IGNORE INTO tmp_sl_fix_inv_ids (id) + SELECT sl.id + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.itemId = :itemId + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + """.trimIndent(), + args, + ) + applyInventoryIdUpdates( + args, + extraJoin = "INNER JOIN tmp_sl_fix_inv_ids t ON t.id = sl.id", + extraWhere = "1=1", + ) + } finally { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_inv_ids") + } + } + } + + private fun fillLotQtyHistory(args: Map, byInventory: Boolean): Int { + val extra = if (byInventory) "AND sl2.inventoryId = :inventoryId" else "AND sl2.inventoryLotLineId = :lotId" + val extraUpd = if (byInventory) "AND sl.inventoryId = :inventoryId" else "AND sl.inventoryLotLineId = :lotId" + return support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT + sl2.id, + CAST( + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryLotLineId + ORDER BY sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS lotAfter, + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta + FROM stock_ledger sl2 + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryLotLineId IS NOT NULL + $extra + ) calc ON calc.id = sl.id + SET sl.lotQtyAfter = calc.lotAfter, + sl.lotQtyBefore = calc.lotAfter - calc.delta + WHERE sl.deleted = 0 + AND sl.date < :today + $extraUpd + """.trimIndent(), + args, + ) + } + + private fun fillBalanceHistory(args: Map): Int { + return support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT + sl2.id, + CAST( + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryId + ORDER BY sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS newBalance + FROM stock_ledger sl2 + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryId = :inventoryId + ) calc ON calc.id = sl.id + SET sl.balance = calc.newBalance + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryId = :inventoryId + """.trimIndent(), + args, + ) + } + + private fun lotIdsForInventory(inventoryId: Long, today: LocalDate): List { + return support.jdbcDao.queryForList( + """ + SELECT DISTINCT inventoryLotLineId AS id + FROM stock_ledger + WHERE deleted = 0 + AND inventoryId = :inventoryId + AND inventoryLotLineId IS NOT NULL + AND date < :today + """.trimIndent(), + mapOf("inventoryId" to inventoryId, "today" to today), + ).map { support.toLong(it["id"]) }.filter { it > 0 } + } + + internal fun dateRangeForFilter(filter: String, args: Map): Pair? { + val row = support.jdbcDao.queryForList( + """ + SELECT MIN(date) AS firstDate, MAX(date) AS lastDate + FROM stock_ledger + WHERE deleted = 0 + AND date < :today + AND $filter + """.trimIndent(), + args, + ).firstOrNull() ?: return null + val first = support.toLocalDate(row["firstDate"]) ?: return null + val last = support.toLocalDate(row["lastDate"]) ?: return null + val today = args["today"] as LocalDate + val toExclusive = last.plusDays(1).let { if (it.isAfter(today)) today else it } + return first to toExclusive + } + + internal fun upsertStockLotDayRange(lotIds: List, from: LocalDate, toExclusive: LocalDate): Int { + if (lotIds.isEmpty()) return 0 + return support.jdbcDao.executeUpdate( + """ + INSERT INTO stock_lot_day ( + created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + ) + SELECT + NOW(), 'ledger-fix-api', 0, NOW(), 'ledger-fix-api', 0, + x.inventoryLotLineId, + x.itemId, + x.itemCode, + x.uomId, + il.lotNo, + x.d, + firstSl.lotQtyBefore, + x.inQty, + x.outQty, + lastSl.lotQtyAfter + FROM ( + SELECT + sl.inventoryLotLineId, + CAST(sl.date AS DATE) AS d, + MIN(sl.itemId) AS itemId, + MIN(sl.itemCode) AS itemCode, + MIN(sl.uomId) AS uomId, + MIN(sl.id) AS firstId, + MAX(sl.id) AS lastId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.inventoryLotLineId IN (:lotIds) + AND sl.lotQtyAfter IS NOT NULL + GROUP BY sl.inventoryLotLineId, CAST(sl.date AS DATE) + ) x + INNER JOIN stock_ledger firstSl ON firstSl.id = x.firstId + INNER JOIN stock_ledger lastSl ON lastSl.id = x.lastId + LEFT JOIN inventory_lot_line ill ON ill.id = x.inventoryLotLineId + LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId + ON DUPLICATE KEY UPDATE + itemId = VALUES(itemId), + itemCode = VALUES(itemCode), + uomId = VALUES(uomId), + lotNo = VALUES(lotNo), + opening = VALUES(opening), + inQty = VALUES(inQty), + outQty = VALUES(outQty), + closing = VALUES(closing), + modified = NOW(), + modifiedBy = 'ledger-fix-api', + version = stock_lot_day.version + 1, + deleted = 0 + """.trimIndent(), + mapOf( + "lotIds" to lotIds, + "from" to from, + "toExclusive" to toExclusive, + ), + ) + } + + private fun stillMissScope(filter: String, args: Map): MissCounts { + val row = support.jdbcDao.queryForList( + """ + SELECT + SUM(inventoryLotLineId IS NULL) AS missLot, + SUM(uomId IS NULL) AS missUom, + SUM(inventoryId IS NULL) AS missInventoryId, + SUM(lotQtyAfter IS NULL) AS missLotQty + FROM stock_ledger + WHERE deleted = 0 + AND date < :today + AND $filter + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + return MissCounts( + missLot = support.toInt(row["missLot"]), + missUom = support.toInt(row["missUom"]), + missInventoryId = support.toInt(row["missInventoryId"]), + missLotQty = support.toInt(row["missLotQty"]), + ) + } + + private fun stillMiss(args: Map): MissCounts { + val row = support.jdbcDao.queryForList( + """ + SELECT + SUM(inventoryLotLineId IS NULL) AS missLot, + SUM(uomId IS NULL) AS missUom, + SUM(inventoryId IS NULL) AS missInventoryId, + SUM(lotQtyAfter IS NULL) AS missLotQty + FROM stock_ledger + WHERE deleted = 0 + AND date >= :day AND date < :dayNext + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + return MissCounts( + missLot = support.toInt(row["missLot"]), + missUom = support.toInt(row["missUom"]), + missInventoryId = support.toInt(row["missInventoryId"]), + missLotQty = support.toInt(row["missLotQty"]), + ) + } + + internal fun fillLotQtyForLotIds(lotIds: List, today: LocalDate): Int { + if (lotIds.isEmpty()) return 0 + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") + support.jdbcDao.executeUpdate( + "CREATE TEMPORARY TABLE tmp_sl_fix_adj_lots (id BIGINT NOT NULL PRIMARY KEY)", + ) + try { + lotIds.chunked(200).forEach { chunk -> + val values = chunk.joinToString(",") { "($it)" } + support.jdbcDao.executeUpdate("INSERT INTO tmp_sl_fix_adj_lots (id) VALUES $values") + } + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT + sl2.id, + CAST( + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryLotLineId + ORDER BY sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS lotAfter, + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) AS delta + FROM stock_ledger sl2 + INNER JOIN tmp_sl_fix_adj_lots t ON t.id = sl2.inventoryLotLineId + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryLotLineId IS NOT NULL + ) calc ON calc.id = sl.id + SET sl.lotQtyAfter = calc.lotAfter, + sl.lotQtyBefore = calc.lotAfter - calc.delta + WHERE sl.deleted = 0 + AND sl.date < :today + """.trimIndent(), + mapOf("today" to today), + ) + } finally { + try { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") + } catch (e: Exception) { + log.warn("stock-ledger-fix drop tmp_sl_fix_adj_lots skipped: {}", e.message) + } + } + } + } + + /** Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. */ + internal fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { + val rows = support.jdbcDao.queryForList( + """ + SELECT id, inventoryId, + CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) AS inQty, + CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) AS outQty + FROM stock_ledger + WHERE deleted = 0 + AND date >= :adjDate AND date < :today + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + AND inventoryId IS NOT NULL + ORDER BY id + """.trimIndent(), + mapOf("adjDate" to adjDate, "today" to today), + ) + if (rows.isEmpty()) return 0 + val prevByInv = HashMap() + val updates = ArrayList>() + rows.forEach { row -> + val id = support.toLong(row["id"]) + val invId = support.toLong(row["inventoryId"]) + if (prevByInv[invId] == null) { + prevByInv[invId] = support.toDecimal( + support.jdbcDao.queryForList( + """ + SELECT balance + FROM stock_ledger + WHERE deleted = 0 + AND inventoryId = :invId + AND date < :today + AND id < :adjId + ORDER BY id DESC + LIMIT 1 + """.trimIndent(), + mapOf("invId" to invId, "today" to today, "adjId" to id), + ).firstOrNull()?.get("balance"), + ) + } + val delta = support.toDecimal(row["inQty"]).subtract(support.toDecimal(row["outQty"])) + val bal = prevByInv.getValue(invId).add(delta) + prevByInv[invId] = bal + updates.add(id to bal) + } + return support.withTempTx { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") + support.jdbcDao.executeUpdate( + """ + CREATE TEMPORARY TABLE tmp_sl_fix_adj_bal ( + id BIGINT NOT NULL PRIMARY KEY, + newBalance DECIMAL(14,2) NOT NULL + ) + """.trimIndent(), + ) + try { + updates.chunked(200).forEach { chunk -> + val values = chunk.joinToString(",") { (id, bal) -> + "($id, ${bal.toPlainString()})" + } + support.jdbcDao.executeUpdate( + "INSERT INTO tmp_sl_fix_adj_bal (id, newBalance) VALUES $values", + ) + } + support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_adj_bal t ON t.id = sl.id + SET sl.balance = t.newBalance + WHERE sl.deleted = 0 + """.trimIndent(), + ) + } finally { + try { + support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_bal") + } catch (e: Exception) { + log.warn("stock-ledger-fix drop tmp_sl_fix_adj_bal skipped: {}", e.message) + } + } + } + } + + private data class MissCounts( + val missLot: Int, + val missUom: Int, + val missInventoryId: Int, + val missLotQty: Int, + ) +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt new file mode 100644 index 00000000..1c1dea20 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt @@ -0,0 +1,625 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.RowCallbackHandler +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Service +import java.io.BufferedWriter +import java.io.OutputStream +import java.io.OutputStreamWriter +import java.math.BigDecimal +import java.nio.charset.StandardCharsets +import java.sql.ResultSet +import java.time.LocalDate + +@Service +open class StockLedgerFixExportService( + private val support: StockLedgerFixSupport, +) { + private val log = LoggerFactory.getLogger(StockLedgerFixExportService::class.java) + + /** + * Export SQL patch for target DB (same dump lineage / freeze). + * [partsRaw] omit / empty / all = ledger + 2.6 (legacy). Else subset: + * 1.0 inventory, 2.3 inventoryId, ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 SIL/SOL + ADJ INSERT. + * Apply order in file: 1.0 → ledger(+2.3) → 2.7 docs → 2.7 ADJ → 2.6. + */ + open fun writeExportSql( + from: LocalDate, + to: LocalDate, + out: OutputStream, + partsRaw: List? = null, + ) { + if (to.isBefore(from)) throw IllegalArgumentException("to must be on or after from") + if (from.plusDays(400).isBefore(to)) { + throw IllegalArgumentException("date range must be at most 400 days") + } + val today = LocalDate.now() + if (to.isAfter(today)) { + throw IllegalArgumentException("cannot export future dates") + } + val toExclusive = to.plusDays(1) + if (!from.isBefore(toExclusive)) { + throw IllegalArgumentException("from must be before toExclusive") + } + val parts = parseExportParts(partsRaw) + val want10 = "1.0" in parts + val want23 = "2.3" in parts + val wantLedger = "ledger" in parts + val want26 = "2.6" in parts + val want27 = "2.7" in parts + if (!want10 && !want23 && !wantLedger && !want26 && !want27) { + throw IllegalArgumentException("parts must include at least one of: 1.0, 2.3, ledger, 2.6, 2.7") + } + val args = mapOf("from" to from, "toExclusive" to toExclusive) + val ledgerCnt = if (wantLedger || want23 || want27) { + support.toInt( + support.jdbcDao.queryForList( + """ + SELECT COUNT(*) AS c FROM stock_ledger + WHERE deleted = 0 AND date >= :from AND date < :toExclusive + """.trimIndent(), + args, + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val dayCnt = if (want26) { + support.toInt( + support.jdbcDao.queryForList( + """ + SELECT COUNT(*) AS c FROM stock_lot_day + WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive + """.trimIndent(), + args, + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val adjCnt = if (want27) { + support.toInt( + support.jdbcDao.queryForList( + """ + SELECT COUNT(*) AS c FROM stock_ledger + WHERE deleted = 0 + AND date >= :from AND date < :toExclusive + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + """.trimIndent(), + args, + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val invCnt = if (want10) { + support.toInt( + support.jdbcDao.queryForList( + "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", + ).firstOrNull()?.get("c"), + ) + } else { + 0 + } + val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8), 65536) + val jdbc = NamedParameterJdbcTemplate(support.dataSource) + jdbc.jdbcTemplate.fetchSize = 2000 + try { + writer.write("-- stock-ledger-fix patch\n") + writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") + writer.write("-- parts=${parts.joinToString(",")}\n") + writer.write("-- apply order: 1.0 inventory → ledger(+2.3) UPDATE → 2.7 SIL/SOL then ADJ INSERT → 2.6 stock_lot_day\n") + writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") + if (want23 && !want10) { + writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n") + } + writer.write("-- please run in the target DB/schema, e.g. USE fpsmsdb;\n") + writer.write( + "-- expected inventoryRows=$invCnt ledgerRows=$ledgerCnt adjRows=$adjCnt stock_lot_dayRows=$dayCnt\n", + ) + writer.write("-- SET NAMES utf8mb4;\n\n") + + if (want10) { + writeExportInventory10(writer, jdbc) + } + if (wantLedger || want23) { + writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) + } + if (want27) { + writeExportAdjDocs(writer, jdbc, args) + writeExportAdjInsert(writer, jdbc, args) + } + if (want26) { + writeExportStockLotDay(writer, jdbc, args, from, toExclusive) + } + + writer.write("\n-- done\n") + writer.flush() + log.info( + "stock-ledger-fix export {}..{} parts={} inv={} ledger={} adj={} day={}", + from, toExclusive.minusDays(1), parts.joinToString(","), invCnt, ledgerCnt, adjCnt, dayCnt, + ) + } finally { + writer.flush() + } + } + + private fun writeExportInventory10(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) { + writer.write("-- === 1.0 inventory (INSERT with fixed id + UPDATE qty/status) ===\n") + var batch = 0 + jdbc.query( + """ + SELECT id, created, createdBy, version, modified, modifiedBy, deleted, + itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, + price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + ORDER BY id + """.trimIndent(), + emptyMap(), + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write( + "INSERT INTO inventory (\n" + + " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + + " itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,\n" + + " price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status\n" + + ") VALUES\n", + ) + } else { + writer.write(",\n") + } + writer.write( + "(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + + "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + + "${rsSql(rs, "itemId")}, ${rsSql(rs, "uomId")}, ${rsSql(rs, "stockUomId")}, ${rsSql(rs, "onHandQty")}, " + + "${rsSql(rs, "onHoldQty")}, ${rsSql(rs, "unavailableQty")}, ${rsSql(rs, "price")}, " + + "${rsSql(rs, "currencyId")}, ${rsSql(rs, "cpu")}, ${rsSql(rs, "cpuUnit")}, " + + "${rsSql(rs, "cpm")}, ${rsSql(rs, "cpmUnit")}, ${rsSql(rs, "status")})", + ) + batch++ + if (batch >= 200) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + + "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + + "uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " + + "price=VALUES(price), currencyId=VALUES(currencyId), " + + "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=inventory.version+1, deleted=0;\n\n", + ) + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " + + "unavailableQty=VALUES(unavailableQty), status=VALUES(status), " + + "uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " + + "price=VALUES(price), currencyId=VALUES(currencyId), " + + "cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " + + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=inventory.version+1, deleted=0;\n\n", + ) + } else { + writer.write("-- (no inventory rows)\n\n") + } + } + + private fun writeExportLedgerPatch( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + wantLedger: Boolean, + want23: Boolean, + ) { + writer.write("-- === ledger UPDATE (JOIN id)") + if (wantLedger) writer.write(" lot/uom/lotQty/balance") + if (want23) writer.write(" inventoryId") + writer.write(" ===\n") + val cols = mutableListOf("id") + if (wantLedger) { + cols += listOf("inventoryLotLineId", "uomId", "lotQtyBefore", "lotQtyAfter", "balance") + } + if (want23) cols += "inventoryId" + writer.write("CREATE TEMPORARY TABLE tmp_sl_fix_patch (\n") + writer.write(" id INT NOT NULL PRIMARY KEY") + if (wantLedger) { + writer.write(",\n inventoryLotLineId BIGINT NULL,\n uomId INT NULL,\n") + writer.write(" lotQtyBefore DECIMAL(14,2) NULL,\n lotQtyAfter DECIMAL(14,2) NULL,\n") + writer.write(" balance DECIMAL(14,2) NULL") + } + if (want23) writer.write(",\n inventoryId INT NULL") + writer.write("\n);\n\n") + var batch = 0 + val selectCols = cols.joinToString(", ") + jdbc.query( + """ + SELECT $selectCols + FROM stock_ledger + WHERE deleted = 0 AND date >= :from AND date < :toExclusive + """.trimIndent(), + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write("INSERT INTO tmp_sl_fix_patch (${cols.joinToString(", ")}) VALUES\n") + } else { + writer.write(",\n") + } + writer.write("(" + cols.joinToString(", ") { rsSql(rs, it) } + ")") + batch++ + if (batch >= 500) { + writer.write(";\n\n") + batch = 0 + } + }, + ) + if (batch > 0) writer.write(";\n\n") + val setParts = mutableListOf() + if (wantLedger) { + setParts += listOf( + "sl.inventoryLotLineId = t.inventoryLotLineId", + "sl.uomId = t.uomId", + "sl.lotQtyBefore = t.lotQtyBefore", + "sl.lotQtyAfter = t.lotQtyAfter", + "sl.balance = t.balance", + ) + } + if (want23) setParts += "sl.inventoryId = t.inventoryId" + writer.write( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_patch t ON t.id = sl.id + SET + ${setParts.joinToString(",\n ")} + WHERE sl.deleted = 0; + + DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_patch; + + """.trimIndent(), + ) + writer.write("\n") + } + + private fun writeExportAdjDocs( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + ) { + writer.write("-- === 2.7 ADJ stock_in / stock_out (1 header + many lines; apply before ledger ADJ FKs) ===\n") + writeExportRows( + writer, + jdbc, + """ + SELECT DISTINCT si.id, si.created, si.createdBy, si.version, si.modified, si.modifiedBy, si.deleted, + si.code, si.status, si.orderDate, si.completeDate + FROM stock_in si + INNER JOIN stock_in_line sil ON sil.stockInId = si.id AND IFNULL(sil.deleted, 0) = 0 + INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY si.id + """.trimIndent(), + args, + table = "stock_in", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "code", "status", "orderDate", "completeDate", + ), + onDup = "code=VALUES(code), status=VALUES(status), orderDate=VALUES(orderDate), " + + "completeDate=VALUES(completeDate), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_in.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_in headers in range)", + ) + writeExportRows( + writer, + jdbc, + """ + SELECT sil.id, sil.created, sil.createdBy, sil.version, sil.modified, sil.modifiedBy, sil.deleted, + sil.itemId, sil.itemNo, sil.stockInId, sil.acceptedQty, sil.receiptDate, sil.status, + sil.inventoryLotId, sil.inventoryLotLineId, sil.lotNo, sil.remarks, sil.type + FROM stock_in_line sil + INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY sil.id + """.trimIndent(), + args, + table = "stock_in_line", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "itemId", "itemNo", "stockInId", "acceptedQty", "receiptDate", "status", + "inventoryLotId", "inventoryLotLineId", "lotNo", "remarks", "type", + ), + onDup = "acceptedQty=VALUES(acceptedQty), inventoryLotId=VALUES(inventoryLotId), " + + "inventoryLotLineId=VALUES(inventoryLotLineId), lotNo=VALUES(lotNo), status=VALUES(status), " + + "type=VALUES(type), remarks=VALUES(remarks), modified=VALUES(modified), " + + "modifiedBy=VALUES(modifiedBy), version=stock_in_line.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_in_line in range)", + ) + writeExportRows( + writer, + jdbc, + """ + SELECT DISTINCT so.id, so.created, so.createdBy, so.version, so.modified, so.modifiedBy, so.deleted, + so.type, so.completeDate, so.status, so.remarks + FROM stock_out so + INNER JOIN stock_out_line sol ON sol.stockOutId = so.id AND IFNULL(sol.deleted, 0) = 0 + INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY so.id + """.trimIndent(), + args, + table = "stock_out", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "type", "completeDate", "status", "remarks", + ), + onDup = "type=VALUES(type), completeDate=VALUES(completeDate), status=VALUES(status), " + + "remarks=VALUES(remarks), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_out.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_out headers in range)", + ) + writeExportRows( + writer, + jdbc, + """ + SELECT sol.id, sol.created, sol.createdBy, sol.version, sol.modified, sol.modifiedBy, sol.deleted, + sol.itemId, sol.qty, sol.stockOutId, sol.inventoryLotLineId, sol.status, sol.pickTime, sol.type + FROM stock_out_line sol + INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id + WHERE sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY sol.id + """.trimIndent(), + args, + table = "stock_out_line", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "itemId", "qty", "stockOutId", "inventoryLotLineId", "status", "pickTime", "type", + ), + onDup = "qty=VALUES(qty), inventoryLotLineId=VALUES(inventoryLotLineId), status=VALUES(status), " + + "pickTime=VALUES(pickTime), type=VALUES(type), modified=VALUES(modified), " + + "modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_out_line in range)", + ) + } + + private fun writeExportAdjInsert( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + ) { + writer.write("-- === 2.7 ADJ INSERT (fixed id; rows missing on target are created) ===\n") + var batch = 0 + jdbc.query( + """ + SELECT id, created, createdBy, version, modified, modifiedBy, deleted, + date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId, + itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, + uomId, type + FROM stock_ledger + WHERE deleted = 0 + AND date >= :from AND date < :toExclusive + AND type = 'ADJ' + AND createdBy = 'stock-ledger-fix' + ORDER BY id + """.trimIndent(), + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write( + "INSERT INTO stock_ledger (\n" + + " id, created, createdBy, version, modified, modifiedBy, deleted,\n" + + " date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,\n" + + " itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,\n" + + " uomId, type\n) VALUES\n", + ) + } else { + writer.write(",\n") + } + writer.write( + "(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + + "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + + "${rsSql(rs, "date")}, ${rsSql(rs, "stockInLineId")}, ${rsSql(rs, "stockOutLineId")}, " + + "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "inventoryId")}, " + + "${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + + "${rsSql(rs, "balance")}, ${rsSql(rs, "lotQtyBefore")}, ${rsSql(rs, "lotQtyAfter")}, " + + "${rsSql(rs, "uomId")}, ${rsSql(rs, "type")})", + ) + batch++ + if (batch >= 200) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + + "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + + "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + + "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + + "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_ledger.version+1, deleted=0;\n\n", + ) + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " + + "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + + "inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " + + "lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " + + "uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_ledger.version+1, deleted=0;\n\n", + ) + } else { + writer.write("-- (no ADJ rows from stock-ledger-fix in range)\n\n") + } + } + + private fun writeExportStockLotDay( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + from: LocalDate, + toExclusive: LocalDate, + ) { + writer.write("-- === 2.6 stock_lot_day ===\n") + writer.write( + """ + DELETE FROM stock_lot_day + WHERE `date` >= '$from' AND `date` < '$toExclusive'; + + """.trimIndent(), + ) + writer.write("\n") + var batch = 0 + jdbc.query( + """ + SELECT created, createdBy, version, modified, modifiedBy, deleted, + inventoryLotLineId, itemId, itemCode, uomId, lotNo, date, + opening, inQty, outQty, closing + FROM stock_lot_day + WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive + ORDER BY date, inventoryLotLineId + """.trimIndent(), + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write( + "INSERT INTO stock_lot_day (\n" + + " created, createdBy, version, modified, modifiedBy, deleted,\n" + + " inventoryLotLineId, itemId, itemCode, uomId, lotNo, `date`,\n" + + " opening, inQty, outQty, closing\n) VALUES\n", + ) + } else { + writer.write(",\n") + } + writer.write( + "(${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " + + "${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " + + "${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, " + + "${rsSql(rs, "uomId")}, ${rsSql(rs, "lotNo")}, ${rsSql(rs, "date")}, " + + "${rsSql(rs, "opening")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " + + "${rsSql(rs, "closing")})", + ) + batch++ + if (batch >= 500) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " + + "lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " + + "outQty=VALUES(outQty), closing=VALUES(closing), " + + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_lot_day.version+1, deleted=0;\n\n", + ) + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write( + "\nON DUPLICATE KEY UPDATE " + + "itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " + + "lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " + + "outQty=VALUES(outQty), closing=VALUES(closing), " + + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_lot_day.version+1, deleted=0;\n", + ) + } + } + + private fun parseExportParts(raw: List?): Set { + if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { + return EXPORT_PARTS_DEFAULT + } + val flat = raw.flatMap { it.split(",") }.map { it.trim() }.filter { it.isNotEmpty() } + if (flat.isEmpty() || flat.any { it.equals("all", ignoreCase = true) }) { + return EXPORT_PARTS_DEFAULT + } + val out = linkedSetOf() + flat.forEach { token -> + val t = token.lowercase() + val key = when (t) { + "1.0", "10", "inventory" -> "1.0" + "2.3", "23", "inventoryid" -> "2.3" + "ledger", "2.1", "2.2", "2.4", "2.5" -> "ledger" + "2.6", "26", "day", "stock_lot_day" -> "2.6" + "2.7", "27", "adj" -> "2.7" + else -> throw IllegalArgumentException( + "unknown export part '$token' (use 1.0, 2.3, ledger, 2.6, 2.7, or all)", + ) + } + out.add(key) + } + if (out.isEmpty()) throw IllegalArgumentException("parts must not be empty") + return out + } + + private fun writeExportRows( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + sql: String, + args: Map, + table: String, + columns: List, + onDup: String, + emptyComment: String, + ) { + var batch = 0 + jdbc.query( + sql, + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write("INSERT INTO $table (\n ${columns.joinToString(", ")}\n) VALUES\n") + } else { + writer.write(",\n") + } + writer.write("(" + columns.joinToString(", ") { rsSql(rs, it) } + ")") + batch++ + if (batch >= 200) { + writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n") + batch = 0 + } + }, + ) + if (batch > 0) { + writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n") + } else { + writer.write("$emptyComment\n\n") + } + } + + private fun rsSql(rs: ResultSet, col: String): String { + val v = rs.getObject(col) + if (v == null || rs.wasNull()) return "NULL" + return when (v) { + is BigDecimal -> v.stripTrailingZeros().toPlainString() + is Number -> v.toString() + is java.sql.Date -> "'${v.toLocalDate()}'" + is java.sql.Timestamp -> "'${v.toLocalDateTime().toString().replace('T', ' ')}'" + is Boolean -> if (v) "1" else "0" + else -> "'" + v.toString().replace("\\", "\\\\").replace("'", "''") + "'" + } + } + + companion object { + /** Legacy default export: ledger calc fields + stock_lot_day (no inventory / inventoryId / ADJ). */ + private val EXPORT_PARTS_DEFAULT = linkedSetOf("ledger", "2.6") + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt new file mode 100644 index 00000000..cef015d3 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt @@ -0,0 +1,246 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +internal object StockLedgerFixInventorySql { + internal const val LOT_UOM_PAIR_COUNT_SQL = """ + SELECT COUNT(*) AS c FROM ( + SELECT il.itemId, iu.uomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId, iu.uomId + ) x + """ + + internal const val MISSING_UOM_PAIR_COUNT_SQL = """ + SELECT COUNT(*) AS c FROM ( + SELECT il.itemId, iu.uomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + LEFT JOIN inventory inv + ON inv.itemId = il.itemId + AND COALESCE(inv.stockUomId, inv.uomId) = iu.uomId + AND IFNULL(inv.deleted, 0) = 0 + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + AND inv.id IS NULL + GROUP BY il.itemId, iu.uomId + ) x + """ + + internal const val NULL_STOCK_UOM_COUNT_SQL = """ + SELECT COUNT(*) AS c + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + """ + + /** + * Old row: uomId already equals a lot stock UOM (stock, or base when base=stock). + * Fill stockUomId from that lot stock; move uomId to item base. + * One keeper per (itemId, uomId); skip if that stockUomId is already taken + * (including soft-deleted rows that still occupy the unique key). + */ + internal const val BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL = """ + UPDATE inventory i + INNER JOIN ( + SELECT DISTINCT il.itemId, iu.uomId AS stockUomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + ) b ON b.itemId = i.itemId AND b.stockUomId = i.uomId + INNER JOIN ( + SELECT itemId, uomId, MIN(id) AS keepId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + GROUP BY itemId, uomId + ) keep ON keep.keepId = i.id + LEFT JOIN item_uom baseIu + ON baseIu.itemId = i.itemId + AND baseIu.baseUnit = 1 + AND IFNULL(baseIu.deleted, 0) = 0 + LEFT JOIN inventory taken + ON taken.itemId = i.itemId + AND taken.stockUomId = b.stockUomId + AND taken.id <> i.id + SET + i.stockUomId = b.stockUomId, + i.uomId = COALESCE(baseIu.uomId, i.uomId), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + AND i.stockUomId IS NULL + AND taken.id IS NULL + """ + + /** + * Remaining NULL stockUomId: item has exactly one lot stock UOM + * (uomId already base, stock may differ). Keep MIN(id) only. + */ + internal const val BACKFILL_STOCK_UOM_SINGLE_LOT_SQL = """ + UPDATE inventory i + INNER JOIN ( + SELECT il.itemId, MIN(iu.uomId) AS stockUomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId + HAVING COUNT(DISTINCT iu.uomId) = 1 + ) b ON b.itemId = i.itemId + INNER JOIN ( + SELECT itemId, MIN(id) AS keepId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NULL + GROUP BY itemId + ) keep ON keep.keepId = i.id + LEFT JOIN item_uom baseIu + ON baseIu.itemId = i.itemId + AND baseIu.baseUnit = 1 + AND IFNULL(baseIu.deleted, 0) = 0 + LEFT JOIN inventory taken + ON taken.itemId = i.itemId + AND taken.stockUomId = b.stockUomId + AND taken.id <> i.id + SET + i.stockUomId = b.stockUomId, + i.uomId = COALESCE(baseIu.uomId, i.uomId), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + AND i.stockUomId IS NULL + AND taken.id IS NULL + """ + + internal const val INSERT_MISSING_INVENTORY_SQL = """ + INSERT INTO inventory ( + itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty, + price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status, + created, createdBy, modified, modifiedBy, version, deleted + ) + SELECT + src.itemId, + COALESCE(baseIu.uomId, src.uomId), + src.uomId, + 0, 0, 0, + COALESCE(tpl.price, 0), + tpl.currencyId, + COALESCE(tpl.cpu, 0), + COALESCE(tpl.cpuUnit, 'HKD'), + COALESCE(tpl.cpm, 0), + COALESCE(tpl.cpmUnit, 'HKD'), + 'unavailable', + NOW(), 'stock-ledger-fix', NOW(), 'stock-ledger-fix', 0, 0 + FROM ( + SELECT il.itemId, iu.uomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId, iu.uomId + ) src + LEFT JOIN item_uom baseIu + ON baseIu.itemId = src.itemId + AND baseIu.baseUnit = 1 + AND IFNULL(baseIu.deleted, 0) = 0 + LEFT JOIN inventory existing + ON existing.itemId = src.itemId + AND IFNULL(existing.deleted, 0) = 0 + AND ( + existing.stockUomId = src.uomId + OR (existing.stockUomId IS NULL AND existing.uomId = src.uomId) + ) + LEFT JOIN inventory occupied + ON occupied.itemId = src.itemId + AND occupied.stockUomId = src.uomId + LEFT JOIN inventory tpl + ON tpl.id = ( + SELECT MIN(i.id) + FROM inventory i + WHERE i.itemId = src.itemId + AND IFNULL(i.deleted, 0) = 0 + ) + WHERE existing.id IS NULL + AND occupied.id IS NULL + """ + + /** + * Extra old UOM rows left NULL after a keeper bucket exists for the item + * (e.g. 18918 id 5261). Soft-delete so search / COALESCE fallbacks ignore them. + */ + internal const val SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL = """ + UPDATE inventory i + INNER JOIN ( + SELECT DISTINCT itemId + FROM inventory + WHERE IFNULL(deleted, 0) = 0 + AND stockUomId IS NOT NULL + ) keeper ON keeper.itemId = i.itemId + SET + i.deleted = 1, + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + AND i.stockUomId IS NULL + """ + + internal const val UPDATE_INVENTORY_QTY_SQL = """ + UPDATE inventory i + LEFT JOIN ( + SELECT + il.itemId, + iu.uomId, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand, + SUM( + CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) + ELSE 0 + END + ) AS unavailable + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN item_uom iu + ON iu.id = ill.stockItemUomId + WHERE IFNULL(ill.deleted, 0) = 0 + AND il.itemId IS NOT NULL + AND iu.uomId IS NOT NULL + GROUP BY il.itemId, iu.uomId + ) agg ON agg.itemId = i.itemId AND agg.uomId = COALESCE(i.stockUomId, i.uomId) + SET + i.onHandQty = COALESCE(agg.onHand, 0), + i.unavailableQty = COALESCE(agg.unavailable, 0), + i.status = IF( + COALESCE(agg.onHand, 0) - COALESCE(agg.unavailable, 0) > 0, + 'available', + 'unavailable' + ), + i.modified = NOW(), + i.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(i.deleted, 0) = 0 + """ +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt new file mode 100644 index 00000000..af138c99 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt @@ -0,0 +1,703 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCheckPart +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayStatus +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit +import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit +import org.springframework.stereotype.Service +import java.time.LocalDate + +@Service +open class StockLedgerFixQueryService( + private val support: StockLedgerFixSupport, +) { + open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse { + if (to.isBefore(from)) { + throw IllegalArgumentException("to must be on or after from") + } + if (from.plusDays(62).isBefore(to)) { + throw IllegalArgumentException("date range must be at most 62 days") + } + val args = mapOf("from" to from, "toNext" to to.plusDays(1)) + val rows = support.jdbcDao.queryForList( + """ + SELECT + DATE_FORMAT(sl.date, '%Y-%m-%d') AS d, + COUNT(*) AS cnt, + SUM(sl.inventoryLotLineId IS NULL) AS missLot, + SUM(sl.uomId IS NULL) AS missUom, + SUM(sl.inventoryId IS NULL) AS missInventoryId, + SUM(sl.lotQtyAfter IS NULL) AS missLotQty + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :from + AND sl.date < :toNext + GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') + ORDER BY d + """.trimIndent(), + args, + ) + val dayLots = support.jdbcDao.queryForList( + """ + SELECT DATE_FORMAT(date, '%Y-%m-%d') AS d, COUNT(*) AS lots + FROM stock_lot_day + WHERE deleted = 0 + AND date >= :from + AND date < :toNext + GROUP BY DATE_FORMAT(date, '%Y-%m-%d') + """.trimIndent(), + args, + ).associate { row -> + row["d"].toString() to support.toInt(row["lots"]) + } + val days = rows.map { row -> + val d = row["d"].toString() + StockLedgerFixDayStatus( + date = d, + cnt = support.toInt(row["cnt"]), + missLot = support.toInt(row["missLot"]), + missUom = support.toInt(row["missUom"]), + missInventoryId = support.toInt(row["missInventoryId"]), + missLotQty = support.toInt(row["missLotQty"]), + dayTableLots = dayLots[d] ?: 0, + ) + } + return StockLedgerFixCalendarResponse( + from = from.toString(), + to = to.toString(), + days = days, + ) + } + + /** + * A: keep existing inventory ids; + * backfill stockUomId from lot stock UOM (and set uomId to base) — one row per + * (itemId, stockUomId) so uk_inventory_item_stock_uom is not violated; + * INSERT missing (itemId, stockUomId); + * soft-delete leftover NULL stockUomId rows on items that already have a bucket; + * recalc onHand/unavailable from Σ lot qty by item+stock UOM (0 if no lots). + * Does not write inventory.onHoldQty (hold stays on lot line). Does not UPDATE inventory_lot_line. + */ + open fun inventoryPreview(): StockLedgerFixInventoryPreview { + val inventoryRows = support.toInt( + support.jdbcDao.queryForList( + "SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0", + ).firstOrNull()?.get("c"), + ) + val lotUomPairs = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.LOT_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + val missingUomPairs = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), + ) + val nullStockUomId = support.toInt( + support.jdbcDao.queryForList(StockLedgerFixInventorySql.NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), + ) + return StockLedgerFixInventoryPreview( + inventoryRows = inventoryRows, + lotUomPairs = lotUomPairs, + missingUomPairs = missingUomPairs, + nullStockUomId = nullStockUomId, + ) + } + + open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail { + val args = support.dayArgs(day) + val ledger = support.jdbcDao.queryForList( + """ + SELECT + COUNT(*) AS cnt, + SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + AND sl.inventoryLotLineId <> COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) + ) AS lotIncorrect, + SUM(sl.uomId IS NULL) AS uomMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND sl.uomId IS NOT NULL + AND iu.uomId IS NOT NULL + AND sl.uomId <> iu.uomId + ) AS uomIncorrect, + SUM(sl.inventoryId IS NULL) AS invMiss, + SUM( + sl.inventoryId IS NOT NULL + AND sl.itemId IS NOT NULL + AND sl.uomId IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM inventory inv + WHERE inv.id = sl.inventoryId + AND IFNULL(inv.deleted, 0) = 0 + AND inv.itemId = sl.itemId + AND COALESCE(inv.stockUomId, inv.uomId) = sl.uomId + ) + ) AS invIncorrect, + SUM(sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) AS lotQtyMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) + ) AS lotQtyMissWithLot, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND ( + sl.lotQtyAfter - sl.lotQtyBefore + <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + ) + ) AS lotQtyIncorrect, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) + ) AS overIssue, + SUM(sl.balance IS NULL) AS balanceMiss, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + ) AS fixable21a, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) = 1 + ) AS fixable21b, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) > 1 + ) AS cannotMultiLine, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) + ) AS cannotNoSource + FROM stock_ledger sl + LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId + LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId + LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId + LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId + LEFT JOIN ( + SELECT inventoryLotId, COUNT(*) AS lineCnt + FROM inventory_lot_line + WHERE IFNULL(deleted, 0) = 0 + GROUP BY inventoryLotId + ) lc ON lc.inventoryLotId = sil.inventoryLotId + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + + val cnt = support.toInt(ledger["cnt"]) + val lotMiss = support.toInt(ledger["lotMiss"]) + val lotIncorrect = support.toInt(ledger["lotIncorrect"]) + val uomMiss = support.toInt(ledger["uomMiss"]) + val uomIncorrect = support.toInt(ledger["uomIncorrect"]) + val invMiss = support.toInt(ledger["invMiss"]) + val invIncorrect = support.toInt(ledger["invIncorrect"]) + val lotQtyMissWithLot = support.toInt(ledger["lotQtyMissWithLot"]) + val lotQtyIncorrect = support.toInt(ledger["lotQtyIncorrect"]) + val overIssue = support.toInt(ledger["overIssue"]) + val balanceMiss = support.toInt(ledger["balanceMiss"]) + + val dayTable = support.jdbcDao.queryForList( + """ + SELECT + COUNT(*) AS expected, + SUM(d.id IS NULL) AS miss, + SUM( + d.id IS NOT NULL AND ( + (x.kind = 'move' AND ( + d.closing <> lastSl.lotQtyAfter + OR d.opening <> firstSl.lotQtyBefore + OR d.inQty <> x.inQty + OR d.outQty <> x.outQty + )) + OR (x.kind = 'carry' AND ( + d.inQty <> 0 + OR d.outQty <> 0 + OR d.opening <> x.prevClosing + OR d.closing <> x.prevClosing + )) + ) + ) AS incorrect + FROM ( + SELECT + sl.inventoryLotLineId, + 'move' AS kind, + MIN(sl.id) AS firstId, + MAX(sl.id) AS lastId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty, + CAST(NULL AS DECIMAL(14,2)) AS prevClosing + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId IS NOT NULL + GROUP BY sl.inventoryLotLineId + + UNION ALL + + SELECT + prev.inventoryLotLineId, + 'carry' AS kind, + NULL AS firstId, + NULL AS lastId, + CAST(0 AS DECIMAL(14,2)) AS inQty, + CAST(0 AS DECIMAL(14,2)) AS outQty, + prev.closing AS prevClosing + FROM stock_lot_day prev + WHERE prev.date = :prevDay + AND IFNULL(prev.deleted, 0) = 0 + AND prev.closing > 0 + AND NOT EXISTS ( + SELECT 1 FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date >= :day AND sl.date < :dayNext + AND sl.inventoryLotLineId = prev.inventoryLotLineId + ) + ) x + LEFT JOIN stock_ledger firstSl ON firstSl.id = x.firstId + LEFT JOIN stock_ledger lastSl ON lastSl.id = x.lastId + LEFT JOIN stock_lot_day d + ON d.inventoryLotLineId = x.inventoryLotLineId + AND d.date = :day + AND IFNULL(d.deleted, 0) = 0 + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + + val dayLots = support.toInt(dayTable["expected"]) + val dayMiss = support.toInt(dayTable["miss"]) + val dayIncorrect = support.toInt(dayTable["incorrect"]) + // No lot ids yet → SQL finds 0 lots, 0/0/0 would look "correct". Ledger exists but day table cannot be built. + val dayTablePending = cnt > 0 && dayLots == 0 + val dayTableMiss = if (dayTablePending) 1 else dayMiss + val dayTableTotal = if (dayTablePending) 1 else dayLots + + fun part(key: String, label: String, miss: Int, incorrect: Int, total: Int) = + StockLedgerFixCheckPart( + key = key, + label = label, + ok = (total - miss - incorrect).coerceAtLeast(0), + miss = miss, + incorrect = incorrect, + ) + + return StockLedgerFixDayDetail( + date = day.toString(), + cnt = cnt, + parts = fieldAndReasonParts( + cnt = cnt, + lotMiss = lotMiss, + lotIncorrect = lotIncorrect, + uomMiss = uomMiss, + uomIncorrect = uomIncorrect, + invMiss = invMiss, + invIncorrect = invIncorrect, + lotQtyMissWithLot = lotQtyMissWithLot, + lotQtyIncorrect = lotQtyIncorrect, + overIssue = overIssue, + balanceMiss = balanceMiss, + fixable21a = support.toInt(ledger["fixable21a"]), + fixable21b = support.toInt(ledger["fixable21b"]), + cannotMultiLine = support.toInt(ledger["cannotMultiLine"]), + cannotNoSource = support.toInt(ledger["cannotNoSource"]), + earlyTkeOver = 0, + otherOver = 0, + extra = listOf( + part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal), + ), + ), + ) + } + + open fun searchInventory(qRaw: String): List { + val q = qRaw.trim() + if (q.isEmpty()) throw IllegalArgumentException("q is required") + val args = mapOf("q" to q, "like" to "%$q%") + return support.jdbcDao.queryForList( + """ + SELECT + i.id AS inventoryId, + i.itemId AS itemId, + it.code AS itemCode, + i.uomId AS uomId, + ( + SELECT COUNT(*) FROM stock_ledger sl + WHERE sl.deleted = 0 AND sl.inventoryId = i.id + ) AS ledgerCnt + FROM inventory i + LEFT JOIN items it ON it.id = i.itemId + WHERE IFNULL(i.deleted, 0) = 0 + AND ( + CAST(i.id AS CHAR) = :q + OR CAST(i.itemId AS CHAR) = :q + OR it.code LIKE :like + ) + ORDER BY ledgerCnt DESC, i.id + LIMIT 20 + """.trimIndent(), + args, + ).map { row -> + StockLedgerFixSearchInventoryHit( + inventoryId = support.toLong(row["inventoryId"]), + itemId = support.toLongOrNull(row["itemId"]), + itemCode = row["itemCode"]?.toString(), + uomId = support.toLongOrNull(row["uomId"]), + ledgerCnt = support.toInt(row["ledgerCnt"]), + ) + } + } + + open fun searchLot(qRaw: String): List { + val q = qRaw.trim() + if (q.isEmpty()) throw IllegalArgumentException("q is required") + val args = mapOf("q" to q, "like" to "%$q%") + return support.jdbcDao.queryForList( + """ + SELECT + ill.id AS inventoryLotLineId, + il.lotNo AS lotNo, + it.code AS itemCode, + ( + SELECT sl.inventoryId FROM stock_ledger sl + WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id + AND sl.inventoryId IS NOT NULL + ORDER BY sl.id DESC LIMIT 1 + ) AS inventoryId, + ( + SELECT COUNT(*) FROM stock_ledger sl + WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id + ) AS ledgerCnt + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN items it ON it.id = il.itemId + WHERE IFNULL(ill.deleted, 0) = 0 + AND ( + CAST(ill.id AS CHAR) = :q + OR il.lotNo LIKE :like + ) + ORDER BY ledgerCnt DESC, ill.id + LIMIT 20 + """.trimIndent(), + args, + ).map { row -> + StockLedgerFixSearchLotHit( + inventoryLotLineId = support.toLong(row["inventoryLotLineId"]), + lotNo = row["lotNo"]?.toString(), + itemCode = row["itemCode"]?.toString(), + inventoryId = support.toLongOrNull(row["inventoryId"]), + ledgerCnt = support.toInt(row["ledgerCnt"]), + ) + } + } + + open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail { + val args = mapOf("inventoryId" to inventoryId, "today" to support.inventoryScopeToExclusive()) + val head = support.jdbcDao.queryForList( + """ + SELECT + i.id AS inventoryId, + i.itemId AS itemId, + it.code AS itemCode, + i.uomId AS uomId + FROM inventory i + LEFT JOIN items it ON it.id = i.itemId + WHERE i.id = :inventoryId + """.trimIndent(), + args, + ).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId") + val stats = ledgerScopeStats( + "sl.inventoryId = :inventoryId", + args, + itemId = support.toLongOrNull(head["itemId"]), + ) + return StockLedgerFixScopeDetail( + kind = "inventory", + id = inventoryId, + itemCode = head["itemCode"]?.toString(), + lotNo = null, + inventoryId = inventoryId, + uomId = support.toLongOrNull(head["uomId"]), + firstDate = stats.firstDate, + lastDate = stats.lastDate, + cnt = stats.cnt, + lastBalance = stats.lastBalance, + lastLotQtyAfter = stats.lastLotQtyAfter, + parts = stats.parts, + ) + } + + open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail { + val today = LocalDate.now() + val args = mapOf("lotId" to lotLineId, "today" to today) + val head = support.jdbcDao.queryForList( + """ + SELECT + ill.id AS inventoryLotLineId, + il.lotNo AS lotNo, + il.itemId AS itemId, + it.code AS itemCode + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN items it ON it.id = il.itemId + WHERE ill.id = :lotId + """.trimIndent(), + args, + ).firstOrNull() ?: throw IllegalArgumentException("lot line not found: $lotLineId") + val stats = ledgerScopeStats( + "sl.inventoryLotLineId = :lotId", + args, + itemId = support.toLongOrNull(head["itemId"]), + ) + return StockLedgerFixScopeDetail( + kind = "lot", + id = lotLineId, + itemCode = head["itemCode"]?.toString(), + lotNo = head["lotNo"]?.toString(), + inventoryId = support.toLongOrNull(stats.lastInventoryId), + uomId = null, + firstDate = stats.firstDate, + lastDate = stats.lastDate, + cnt = stats.cnt, + lastBalance = stats.lastBalance, + lastLotQtyAfter = stats.lastLotQtyAfter, + parts = stats.parts, + ) + } + + private data class ScopeStats( + val cnt: Int, + val firstDate: String?, + val lastDate: String?, + val lastBalance: String?, + val lastLotQtyAfter: String?, + val lastInventoryId: Any?, + val parts: List, + ) + + private fun ledgerScopeStats( + filter: String, + args: Map, + itemId: Long? = null, + ): ScopeStats { + val ledger = support.jdbcDao.queryForList( + """ + SELECT + COUNT(*) AS cnt, + DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate, + DATE_FORMAT(MAX(sl.date), '%Y-%m-%d') AS lastDate, + SUM(sl.inventoryLotLineId IS NULL) AS lotMiss, + SUM(sl.uomId IS NULL) AS uomMiss, + SUM(sl.inventoryId IS NULL) AS invMiss, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) + ) AS lotQtyMissWithLot, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + <> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + ) AS lotQtyIncorrect, + SUM( + sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) + ) AS overIssue, + SUM(sl.balance IS NULL) AS balanceMiss, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL + ) AS fixable21a, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) = 1 + ) AS fixable21b, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND sil.inventoryLotId IS NOT NULL + AND IFNULL(lc.lineCnt, 0) > 1 + ) AS cannotMultiLine, + SUM( + sl.inventoryLotLineId IS NULL + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) + ) AS cannotNoSource + FROM stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + LEFT JOIN ( + SELECT inventoryLotId, COUNT(*) AS lineCnt + FROM inventory_lot_line + WHERE IFNULL(deleted, 0) = 0 + GROUP BY inventoryLotId + ) lc ON lc.inventoryLotId = sil.inventoryLotId + WHERE sl.deleted = 0 + AND sl.date < :today + AND $filter + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + val last = support.jdbcDao.queryForList( + """ + SELECT sl.balance, sl.lotQtyAfter, sl.inventoryId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND $filter + ORDER BY sl.id DESC + LIMIT 1 + """.trimIndent(), + args, + ).firstOrNull() ?: emptyMap() + val cnt = support.toInt(ledger["cnt"]) + val overIssue = support.toInt(ledger["overIssue"]) + val earlyTke = if (overIssue > 0) overIssueEarlyTkeRows(filter, args, itemId) else 0 + return ScopeStats( + cnt = cnt, + firstDate = ledger["firstDate"]?.toString(), + lastDate = ledger["lastDate"]?.toString(), + lastBalance = last["balance"]?.toString(), + lastLotQtyAfter = last["lotQtyAfter"]?.toString(), + lastInventoryId = last["inventoryId"], + parts = fieldAndReasonParts( + cnt = cnt, + lotMiss = support.toInt(ledger["lotMiss"]), + lotIncorrect = 0, + uomMiss = support.toInt(ledger["uomMiss"]), + uomIncorrect = 0, + invMiss = support.toInt(ledger["invMiss"]), + invIncorrect = 0, + lotQtyMissWithLot = support.toInt(ledger["lotQtyMissWithLot"]), + lotQtyIncorrect = support.toInt(ledger["lotQtyIncorrect"]), + overIssue = overIssue, + balanceMiss = support.toInt(ledger["balanceMiss"]), + fixable21a = support.toInt(ledger["fixable21a"]), + fixable21b = support.toInt(ledger["fixable21b"]), + cannotMultiLine = support.toInt(ledger["cannotMultiLine"]), + cannotNoSource = support.toInt(ledger["cannotNoSource"]), + earlyTkeOver = earlyTke, + otherOver = (overIssue - earlyTke).coerceAtLeast(0), + ), + ) + } + + private fun fieldAndReasonParts( + cnt: Int, + lotMiss: Int, + lotIncorrect: Int, + uomMiss: Int, + uomIncorrect: Int, + invMiss: Int, + invIncorrect: Int, + lotQtyMissWithLot: Int, + lotQtyIncorrect: Int, + overIssue: Int, + balanceMiss: Int, + fixable21a: Int, + fixable21b: Int, + cannotMultiLine: Int, + cannotNoSource: Int, + earlyTkeOver: Int, + otherOver: Int, + extra: List = emptyList(), + ): List { + fun field(key: String, label: String, miss: Int, incorrect: Int) = + StockLedgerFixCheckPart( + key = key, + label = label, + ok = (cnt - miss - incorrect).coerceAtLeast(0), + miss = miss, + incorrect = incorrect, + group = "field", + ) + fun canFix(key: String, label: String, n: Int) = + StockLedgerFixCheckPart(key, label, ok = 0, miss = n, incorrect = 0, group = "canFix") + fun cannotFix(key: String, label: String, n: Int) = + StockLedgerFixCheckPart(key, label, ok = 0, miss = 0, incorrect = n, group = "cannotFix") + return buildList { + add(field("lot", "inventoryLotLineId", lotMiss, lotIncorrect)) + add(field("uom", "uomId", uomMiss, uomIncorrect)) + add(field("inventory", "inventoryId", invMiss, invIncorrect)) + add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect)) + add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue)) + add(field("balance", "balance", balanceMiss, 0)) + addAll(extra) + add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a)) + add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b)) + add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot)) + add(cannotFix("cannotNoSource", "不能修:沒有 SIL/SOL lot 來源", cannotNoSource)) + add(cannotFix("cannotMultiLine", "不能修:同一 lot 有多條 warehouse line", cannotMultiLine)) + add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) + add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) + } + } + + private fun overIssueEarlyTkeRows(filter: String, args: Map, itemId: Long?): Int { + if (itemId == null || itemId <= 0) return 0 + val q = args.toMutableMap() + q["itemId"] = itemId + return support.toInt( + support.jdbcDao.queryForList( + """ + SELECT SUM(ok) AS c FROM ( + SELECT EXISTS ( + SELECT 1 + FROM inventory_lot_line ill2 + INNER JOIN inventory_lot il2 + ON il2.id = ill2.inventoryLotId AND il2.itemId = :itemId + INNER JOIN ( + SELECT sl3.inventoryLotLineId + FROM stock_ledger sl3 + INNER JOIN inventory_lot_line ill3 ON ill3.id = sl3.inventoryLotLineId + INNER JOIN inventory_lot il3 ON il3.id = ill3.inventoryLotId AND il3.itemId = :itemId + WHERE sl3.deleted = 0 + AND sl3.date < :today + GROUP BY sl3.inventoryLotLineId + HAVING SUM(COALESCE(sl3.inQty, 0)) > 0 + AND SUM(CASE WHEN sl3.type = 'TKE' AND sl3.inQty > 0 THEN sl3.inQty ELSE 0 END) + >= SUM(COALESCE(sl3.inQty, 0)) * 0.99 + ) tke ON tke.inventoryLotLineId = ill2.id + WHERE IFNULL(ill2.deleted, 0) = 0 + AND ill2.warehouseId = ill.warehouseId + AND ill2.id <> sl.inventoryLotLineId + ) AS ok + FROM stock_ledger sl + INNER JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId + WHERE sl.deleted = 0 + AND sl.date < :today + AND $filter + AND sl.lotQtyAfter IS NOT NULL + AND sl.lotQtyBefore IS NOT NULL + AND sl.lotQtyAfter - sl.lotQtyBefore + = CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2)) + AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0) + ) x + """.trimIndent(), + q, + ).firstOrNull()?.get("c"), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt new file mode 100644 index 00000000..cf5a3b6b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt @@ -0,0 +1,137 @@ +package com.ffii.fpsms.modules.master.service.ledgerfix + +import com.ffii.core.support.JdbcDao +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Component +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.math.BigDecimal +import java.time.LocalDate +import java.time.format.DateTimeParseException +import java.util.concurrent.ConcurrentHashMap +import javax.sql.DataSource + +/** Shared JDBC, conversions, and the single in-flight lock for stock-ledger-fix. */ +@Component +open class StockLedgerFixSupport( + val jdbcDao: JdbcDao, + val transactionManager: PlatformTransactionManager, + val dataSource: DataSource, +) { + private val log = LoggerFactory.getLogger(StockLedgerFixSupport::class.java) + val namedJdbc = NamedParameterJdbcTemplate(dataSource) + private val inFlight = ConcurrentHashMap.newKeySet() + + fun tryLock(key: String): Boolean = inFlight.add(key) + + fun unlock(key: String) { + inFlight.remove(key) + } + + /** Exclusive bound for inventory-scope inspect/fix: include calendar today. */ + fun inventoryScopeToExclusive(): LocalDate = LocalDate.now().plusDays(1) + + fun dayArgs(day: LocalDate): Map = + mapOf( + "day" to day, + "dayNext" to day.plusDays(1), + "prevDay" to day.minusDays(1), + ) + + /** Inclusive [from]..[to]; same keys as [dayArgs] (used by stillMiss over the whole range). */ + fun rangeArgs(from: LocalDate, to: LocalDate): Map = + mapOf( + "day" to from, + "dayNext" to to.plusDays(1), + "prevDay" to from.minusDays(1), + ) + + /** Same JDBC connection so TEMPORARY TABLEs survive across statements. */ + fun withTempTx(block: () -> Int): Int { + return TransactionTemplate(transactionManager).execute { block() } ?: 0 + } + + fun timed(name: String, block: () -> Int): Int { + val t0 = System.nanoTime() + return try { + val n = block() + log.info("stock-ledger-fix step {} rows={} {}ms", name, n, (System.nanoTime() - t0) / 1_000_000) + n + } catch (e: Exception) { + log.warn("stock-ledger-fix step {} FAILED {}ms: {}", name, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } + } + + fun parseDay(raw: String): LocalDate { + val trimmed = raw.trim() + if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)") + return try { + LocalDate.parse(trimmed) + } catch (_: DateTimeParseException) { + throw IllegalArgumentException("date must be yyyy-MM-dd") + } + } + + + fun parseDaySteps(raw: List?): Set { + if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) { + return DAY_STEPS + } + val out = linkedSetOf() + raw.forEach { token -> + val t = token.trim().lowercase() + val key = when (t) { + "2.1", "21" -> "2.1" + "2.2", "22" -> "2.2" + "2.3", "23" -> "2.3" + "2.4", "24" -> "2.4" + "2.5", "25" -> "2.5" + "2.6", "26" -> "2.6" + else -> throw IllegalArgumentException("unknown step '$token' (use 2.1–2.6 or all)") + } + out.add(key) + } + if (out.isEmpty()) throw IllegalArgumentException("steps must not be empty") + return out + } + + fun toInt(value: Any?): Int = when (value) { + null -> 0 + is Number -> value.toInt() + else -> value.toString().toIntOrNull() ?: 0 + } + + fun toLong(value: Any?): Long = toLongOrNull(value) ?: 0L + + fun toLongOrNull(value: Any?): Long? = when (value) { + null -> null + is Number -> value.toLong() + else -> value.toString().toLongOrNull() + } + + fun toDecimal(value: Any?): BigDecimal = when (value) { + null -> BigDecimal.ZERO + is BigDecimal -> value + is Number -> BigDecimal(value.toString()) + else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } + + fun toLocalDate(value: Any?): LocalDate? = when (value) { + null -> null + is LocalDate -> value + is java.sql.Date -> value.toLocalDate() + is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() + else -> try { + LocalDate.parse(value.toString().take(10)) + } catch (_: Exception) { + null + } + } + companion object { + const val INVENTORY_LOCK = "inventory-1.0" + const val RUN_LOCK = "ledger-fix-run" + private val DAY_STEPS = linkedSetOf("2.1", "2.2", "2.3", "2.4", "2.5", "2.6") + } +} From 017e2d21b9e24e1b07555b9cc371127c4d18da28 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Tue, 22 Sep 2026 19:02:57 +0800 Subject: [PATCH 15/52] Return Chinese HTTP 400 for missing stock UOM and block mixed-UOM lots. App and Liquibase BEFORE triggers reject one lot spanning two stock-UOM buckets so API/UI match M-X-01 and M-E-01. --- .../stock/service/InventoryLotLineService.kt | 18 +++++- .../modules/stock/service/InventoryService.kt | 7 ++- .../stock/service/StockInLineService.kt | 11 ++-- .../07_one_lot_one_stock_uom.sql | 56 +++++++++++++++++++ 4 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/07_one_lot_one_stock_uom.sql diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 0fdab334..0082e854 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -25,8 +25,10 @@ import net.sf.jasperreports.engine.JasperCompileManager import org.springframework.core.io.ClassPathResource import org.springframework.data.domain.PageRequest import java.time.LocalDate +import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException import java.io.FileNotFoundException import java.io.IOException import java.math.BigDecimal @@ -190,7 +192,21 @@ open class InventoryLotLineService( val stockUom = request.stockUomId?.let { itemUomRespository.findById(it).getOrNull() } ?: existing?.stockUom if (stockUom == null) { - throw IllegalArgumentException("stockItemUomId is required for inventory_lot_line") + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "入倉明細缺少庫存單位,無法入帳") + } + // M-E-01: one inventory lot must not mix two stock-UOM buckets (item_uom.uomId). + val lotId = inventoryLot?.id ?: existing?.inventoryLot?.id + val newBucketUomId = stockUom.uom?.id + if (lotId != null && newBucketUomId != null) { + val conflict = inventoryLotLineRepository.findAllByInventoryLotId(lotId).any { sibling -> + sibling.deleted != true && + sibling.id != existing?.id && + sibling.stockUom?.uom?.id != null && + sibling.stockUom!!.uom!!.id != newBucketUomId + } + if (conflict) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "同一批次不可混用不同庫存單位") + } } val status = request.status?.let { _status -> InventoryLotLineStatus.entries.find { it.value == _status } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index 71744df4..6570d0cb 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -25,7 +25,9 @@ import org.apache.poi.ss.usermodel.Sheet import org.apache.poi.ss.usermodel.Workbook import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import org.springframework.http.HttpStatus import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException import java.math.BigDecimal import java.sql.Timestamp import java.time.LocalDate @@ -649,8 +651,9 @@ open class InventoryService( val warehouse = warehouseRepository.findAll().find { it.code == request.warehouseCode }!! // val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!) val stockUnit = itemUomService.findStockUnitByItemId(itemId = inventoryLot.item!!.id!!) - ?: throw IllegalArgumentException( - "stockItemUomId is required: no stockUnit item_uom for itemId=${inventoryLot.item!!.id}", + ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "入倉明細缺少庫存單位,無法入帳", ) // ChangeList #8: lot-line stockItemUomId must be set so trigger writes inventory.stockUomId. InventoryLotLine().apply { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 7283ead0..abcb493d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -539,8 +539,9 @@ open class StockInLineService( this.inQty = convertedBaseQty this.status = InventoryLotLineStatus.AVAILABLE this.stockUom = stockItemUom - ?: throw IllegalArgumentException( - "stockItemUomId is required: no stockUnit item_uom for itemId=${request.itemId}", + ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "入倉明細缺少庫存單位,無法入帳", ) } saveLines.add(inventoryLotLine) @@ -1535,9 +1536,9 @@ open class StockInLineService( ) } return itemUomRepository.findFirstByItemIdAndStockUnitIsTrueAndDeletedIsFalseOrderByIdAsc(itemId) - ?: throw IllegalArgumentException( - "Stock UOM not found for item: id=$itemId, itemNo=$itemNo. " + - "Ensure at least one item_uom row has stockUnit=1, deleted=0 for this item." + ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "入倉明細缺少庫存單位,無法入帳", ) } diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/07_one_lot_one_stock_uom.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/07_one_lot_one_stock_uom.sql new file mode 100644 index 00000000..37845e1f --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/07_one_lot_one_stock_uom.sql @@ -0,0 +1,56 @@ +-- liquibase formatted sql + +-- changeset kelvin:20260922-ill-before-insert-one-lot-one-stock-uom splitStatements:false +-- comment: M-E-01 One inventory_lot must not mix two stock UOM buckets. +DROP TRIGGER IF EXISTS `inventory_lot_line_BEFORE_insert_one_stock_uom`; +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_BEFORE_insert_one_stock_uom` + BEFORE INSERT + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + IF NEW.stockItemUomId IS NOT NULL + AND NEW.inventoryLotId IS NOT NULL + AND IFNULL(NEW.deleted, 0) = 0 + AND EXISTS ( + SELECT 1 + FROM `inventory_lot_line` ill + INNER JOIN `item_uom` iu_old + ON iu_old.id = ill.stockItemUomId AND IFNULL(iu_old.deleted, 0) = 0 + INNER JOIN `item_uom` iu_new + ON iu_new.id = NEW.stockItemUomId AND IFNULL(iu_new.deleted, 0) = 0 + WHERE ill.inventoryLotId = NEW.inventoryLotId + AND IFNULL(ill.deleted, 0) = 0 + AND iu_old.uomId <> iu_new.uomId + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '同一批次不可混用不同庫存單位'; + END IF; +END; + +-- changeset kelvin:20260922-ill-before-update-one-lot-one-stock-uom splitStatements:false +-- comment: M-E-01 Also block UPDATE that would mix stock UOMs on the same lot. +DROP TRIGGER IF EXISTS `inventory_lot_line_BEFORE_update_one_stock_uom`; +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_BEFORE_update_one_stock_uom` + BEFORE UPDATE + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + IF NEW.stockItemUomId IS NOT NULL + AND NEW.inventoryLotId IS NOT NULL + AND IFNULL(NEW.deleted, 0) = 0 + AND EXISTS ( + SELECT 1 + FROM `inventory_lot_line` ill + INNER JOIN `item_uom` iu_old + ON iu_old.id = ill.stockItemUomId AND IFNULL(iu_old.deleted, 0) = 0 + INNER JOIN `item_uom` iu_new + ON iu_new.id = NEW.stockItemUomId AND IFNULL(iu_new.deleted, 0) = 0 + WHERE ill.inventoryLotId = NEW.inventoryLotId + AND IFNULL(ill.deleted, 0) = 0 + AND ill.id <> NEW.id + AND iu_old.uomId <> iu_new.uomId + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '同一批次不可混用不同庫存單位'; + END IF; +END; From 586bb098054c5f7c1e3ebc5a6c23b672bc81af18 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Thu, 24 Sep 2026 00:23:36 +0800 Subject: [PATCH 16/52] added unit showing refine in stock take, allow user to set a warning percent to remind stock take difference when inputing the stock qty, try to quicken the transaction inventory report for PDF and excel --- .../fpsms/modules/common/SettingNames.java | 6 + .../modules/report/service/ReportService.kt | 22 +- .../modules/report/service/StockLedgerPdf.kt | 257 +++++++++++ .../service/StockLedgerReportService.kt | 412 ++++++++++-------- .../report/web/StockLedgerReportController.kt | 34 +- .../settings/web/SettingsController.java | 13 + .../stock/service/StockTakeRecordService.kt | 6 + .../stock/web/model/StockTakeRecordReponse.kt | 2 + .../01_setting.sql | 9 + .../resources/jasper/StockLedgarReport.jrxml | 2 +- .../report/service/StockLedgerPdfSpeedTest.kt | 40 ++ 11 files changed, 578 insertions(+), 225 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt create mode 100644 src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 4f747a09..f5e4bbae 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -77,6 +77,12 @@ public abstract class SettingNames { * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. */ public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; + + /** + * Stock-take count vs on-hand gap (percent) that shows a non-blocking warning. + * Either direction. Default 50. + */ + public static final String STOCK_TAKE_QTY_GAP_WARN_PERCENT = "STOCK_TAKE.qtyGapWarnPercent"; /* * Mail settings */ diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 377b2f23..0f9a34b6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -4,7 +4,7 @@ import org.springframework.stereotype.Service import net.sf.jasperreports.engine.* import net.sf.jasperreports.engine.data.JRMapCollectionDataSource import java.io.ByteArrayOutputStream -import java.io.InputStream +import java.util.concurrent.ConcurrentHashMap import com.ffii.core.support.JdbcDao import com.ffii.fpsms.m18.M18GrnRules import com.ffii.fpsms.modules.master.entity.ShopRepository @@ -28,6 +28,15 @@ open class ReportService( private val shopRepository: ShopRepository, private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, ) { + private val compiledReports = ConcurrentHashMap() + + private fun compiledReport(templatePath: String): JasperReport = + compiledReports.computeIfAbsent(templatePath) { path -> + val stream = this::class.java.getResourceAsStream(path) + ?: throw RuntimeException("Report template not found: $path") + stream.use { JasperCompileManager.compileReport(it) } + } + /** * Queries the database for inventory data based on dates and optional item type. */ @@ -1731,9 +1740,7 @@ return result * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. */ fun createExcelResponse(templatePath: String, params: Map, dataList: List>): ByteArray { - val stream = this::class.java.getResourceAsStream(templatePath) - ?: throw RuntimeException("Report template not found: $templatePath") - val jasperReport = JasperCompileManager.compileReport(stream) + val jasperReport = compiledReport(templatePath) val dataSource = JRMapCollectionDataSource(dataList) val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) val out = ByteArrayOutputStream() @@ -1748,13 +1755,8 @@ return result * Compiles and fills a Jasper Report, returning the PDF as a ByteArray. */ fun createPdfResponse(templatePath: String, params: Map, dataList: List>): ByteArray { - val stream = this::class.java.getResourceAsStream(templatePath) - ?: throw RuntimeException("Report template not found: $templatePath") - - val jasperReport = JasperCompileManager.compileReport(stream) - + val jasperReport = compiledReport(templatePath) val dataSource = JRMapCollectionDataSource(dataList) - val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) return JasperExportManager.exportReportToPdf(jasperPrint) } diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt new file mode 100644 index 00000000..381e5711 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt @@ -0,0 +1,257 @@ +package com.ffii.fpsms.modules.report.service + +import org.apache.pdfbox.pdmodel.PDDocument +import org.apache.pdfbox.pdmodel.PDPage +import org.apache.pdfbox.pdmodel.PDPageContentStream +import org.apache.pdfbox.pdmodel.common.PDRectangle +import org.apache.pdfbox.pdmodel.font.PDFont +import org.apache.pdfbox.pdmodel.font.PDType0Font +import org.apache.pdfbox.util.Matrix +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +/** + * Draws 庫存明細報告 directly. Jasper measured each cell and kept whole item groups + * together, which is what made large PDFs take tens of seconds. + */ +object StockLedgerPdf { + private const val PAGE_W = 842f + private const val PAGE_H = 595f + private const val ROW_H = 13f + private const val BOTTOM = 22f + private const val BODY_SIZE = 8f + private const val HEADER_SIZE = 8f + + private val fontBytes: ByteArray by lazy { + StockLedgerPdf::class.java.getResourceAsStream("/fonts/msjh_0.ttf")?.readBytes() + ?: error("Font not found: /fonts/msjh_0.ttf") + } + + private data class Col(val x: Float, val w: Float, val title: String, val alignRight: Boolean = false) + + private val columns = listOf( + Col(11f, 78f, "出入賬日期"), + Col(91f, 58f, "類型"), + Col(151f, 120f, "批號"), + Col(273f, 72f, "到期日"), + Col(347f, 58f, "纍計期初", alignRight = true), + Col(407f, 48f, "入庫", alignRight = true), + Col(457f, 48f, "出庫", alignRight = true), + Col(507f, 62f, "纍計存量", alignRight = true), + Col(571f, 140f, "參考編號"), + Col(713f, 112f, "存貨位置"), + ) + + fun write( + rows: List>, + reportDate: String, + reportTime: String, + periodStart: String, + periodEnd: String, + ): ByteArray { + PDDocument().use { doc -> + val font = PDType0Font.load(doc, ByteArrayInputStream(fontBytes), true) + val canvas = Canvas(doc, font, reportDate, reportTime, periodStart, periodEnd) + if (rows.isEmpty()) { + canvas.ensureRow() + canvas.text(columns[0].x, canvas.y, "-", BODY_SIZE) + } else { + var itemNo: String? = null + var count = 0 + var totalIn = "" + var totalOut = "" + var totalBal = "" + fun closeGroup() { + if (count > 1) { + canvas.ensureRow() + canvas.text(250f, canvas.y, "貨品總量:", BODY_SIZE, 95f, alignRight = true) + canvas.text(columns[5].x, canvas.y, totalIn, BODY_SIZE, columns[5].w, alignRight = true) + canvas.text(columns[6].x, canvas.y, totalOut, BODY_SIZE, columns[6].w, alignRight = true) + canvas.text(columns[7].x, canvas.y, totalBal, BODY_SIZE, columns[7].w, alignRight = true) + canvas.advance() + } else if (count == 1) { + canvas.advance(4f) + } + } + for (row in rows) { + val nextItem = row["itemNo"]?.toString().orEmpty() + if (nextItem != itemNo) { + closeGroup() + itemNo = nextItem + count = 0 + val name = row["itemName"]?.toString().orEmpty() + val uom = row["unitOfMeasure"]?.toString().orEmpty() + val itemTitle = listOf(nextItem, name, uom).filter { it.isNotBlank() }.joinToString(" ") + canvas.ensureRow() + canvas.text(columns[0].x, canvas.y, itemTitle, BODY_SIZE + 1f) + canvas.advance() + } + canvas.ensureRow() + canvas.cell(0, row["trnDate"]) + canvas.cell(1, row["trnRefNo"]) + canvas.cell(2, row["lotNo"]) + canvas.cell(3, row["expiryDate"]) + canvas.cell(4, row["cumOpeningBal"]) + canvas.cell(5, row["stockIn"]) + canvas.cell(6, row["stockOut"]) + canvas.cell(7, row["cumBalance"]) + canvas.cell(8, row["orderRefNo"]) + canvas.cell(9, row["storeLocation"]) + canvas.advance() + count++ + totalIn = row["totalStockIn"]?.toString().orEmpty() + totalOut = row["totalStockOut"]?.toString().orEmpty() + totalBal = row["totalCumBalance"]?.toString().orEmpty() + } + closeGroup() + } + canvas.closePage() + canvas.stampPageNumbers() + val out = ByteArrayOutputStream() + doc.save(out) + return out.toByteArray() + } + } + + private class Canvas( + private val doc: PDDocument, + private val font: PDFont, + private val reportDate: String, + private val reportTime: String, + private val periodStart: String, + private val periodEnd: String, + ) { + private val pages = ArrayList() + private var stream: PDPageContentStream? = null + private var textOpen = false + var y = 0f + private set + + init { + newPage() + } + + fun ensureRow() { + if (y < BOTTOM + ROW_H) newPage() + } + + fun advance(dy: Float = ROW_H) { + y -= dy + } + + fun cell(index: Int, value: Any?) { + val col = columns[index] + text(col.x, y, value?.toString().orEmpty(), BODY_SIZE, col.w, col.alignRight) + } + + fun text(x: Float, baseline: Float, raw: String, size: Float, width: Float = 700f, alignRight: Boolean = false) { + val value = fit(raw, size, width - 2f) + if (value.isEmpty()) return + openText() + val drawX = if (alignRight) { + x + width - 2f - widthOf(value, size) + } else { + x + } + stream!!.setFont(font, size) + stream!!.setTextMatrix(Matrix(1f, 0f, 0f, 1f, drawX, baseline)) + stream!!.showText(value) + } + + fun closePage() { + endText() + stream?.close() + stream = null + } + + fun stampPageNumbers() { + val total = pages.size + pages.forEachIndexed { index, page -> + PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> + val label = "頁數 ${index + 1} / $total" + cs.beginText() + cs.setFont(font, 9f) + val labelX = PAGE_W - 18f - widthOf(label, 9f) + cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, labelX, PAGE_H - 28f)) + cs.showText(label) + cs.endText() + } + } + } + + private fun newPage() { + closePage() + val page = PDPage(PDRectangle(PAGE_W, PAGE_H)) + doc.addPage(page) + pages.add(page) + stream = PDPageContentStream(doc, page) + y = PAGE_H - 36f + openText() + text(300f, y, "庫存明細報告", 14f) + y -= 18f + text(18f, y, "報告日期:$reportDate 報告時間:$reportTime", 9f) + val period = if (periodStart.isNotBlank() || periodEnd.isNotBlank()) { + "報告期間:${periodStart.ifBlank { "—" }} 至 ${periodEnd.ifBlank { "—" }}" + } else { + "" + } + if (period.isNotEmpty()) { + text(420f, y, period, 9f) + } + y -= 16f + endText() + val lineY = y + 4f + stream!!.moveTo(11f, lineY) + stream!!.lineTo(PAGE_W - 16f, lineY) + stream!!.stroke() + y -= 12f + openText() + for (col in columns) { + text(col.x, y, col.title, HEADER_SIZE, col.w, col.alignRight) + } + y -= 4f + endText() + val lineY2 = y + stream!!.moveTo(11f, lineY2) + stream!!.lineTo(PAGE_W - 16f, lineY2) + stream!!.stroke() + y -= 14f + } + + private fun openText() { + if (!textOpen) { + stream!!.beginText() + textOpen = true + } + } + + private fun endText() { + if (textOpen) { + stream!!.endText() + textOpen = false + } + } + + private fun fit(raw: String, size: Float, maxWidth: Float): String { + val cleaned = raw.replace('\n', ' ').replace('\r', ' ') + if (cleaned.isEmpty() || fits(cleaned, size, maxWidth)) return cleaned + var end = cleaned.length + while (end > 0 && widthOf(cleaned.substring(0, end), size) > maxWidth) { + end-- + } + return if (end == cleaned.length) cleaned else cleaned.substring(0, end) + } + + private fun fits(text: String, size: Float, maxWidth: Float): Boolean { + var units = 0f + for (ch in text) { + units += if (ch.code < 128) 0.56f else 1f + if (units * size > maxWidth) return false + } + return true + } + + private fun widthOf(text: String, size: Float): Float = + font.getStringWidth(text) / 1000f * size + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index ea39625c..7e57c09c 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -2,8 +2,13 @@ package com.ffii.fpsms.modules.report.service import com.ffii.core.support.JdbcDao import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols import java.time.LocalDate import java.time.format.DateTimeFormatter +import java.util.Locale @Service open class StockLedgerReportService( @@ -23,6 +28,8 @@ open class StockLedgerReportService( * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) * * 只查 [start, end] 期間列,不掃起日以前全歷史。 + * 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。 + * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 */ fun searchStockLedgerReport( stockCategory: String?, @@ -71,213 +78,244 @@ open class StockLedgerReportService( args ) - // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" - "AND lot.storeLocation LIKE :storeLocation" + "AND wh.code LIKE :storeLocation" } else { "" } + // Wide reports are a date range over the whole ledger. Pin that index so MySQL + // does not start from another stock_ledger index and scan extra rows. + val ledgerIndex = if (itemCode.isNullOrBlank()) "FORCE INDEX (idx_sl_deleted_date)" else "" + val sql = """ SELECT - stockSubCategory, + sl.id AS slId, + sl.itemCode AS itemCode, + DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, + CASE + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' + WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' + AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' + ELSE COALESCE(sl.type, '') + END AS trnRefNoRaw, + COALESCE(sl.inQty, 0) AS inQty, + COALESCE(sl.outQty, 0) AS outQty, + COALESCE(sl.balance, 0) AS bal, + COALESCE(sl.balance, 0) - COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + it.type AS stockSubCategory, + it.code AS itemNo, + it.name AS itemName, + uom.unitOfMeasure AS unitOfMeasure, + COALESCE(il_in.lotNo, il_out.lotNo) AS lotNo, + COALESCE( + DATE_FORMAT(il_in.expiryDate, '%Y-%m-%d'), + DATE_FORMAT(il_out.expiryDate, '%Y-%m-%d'), + '' + ) AS expiryDate, + wh.code AS storeLocation, + COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, + COALESCE(TRIM(jo.code), '') AS jobOrderNo +FROM stock_ledger sl $ledgerIndex + LEFT JOIN stock_in_line sil + ON sl.stockInLineId = sil.id + AND sil.deleted = 0 + LEFT JOIN inventory_lot il_in + ON sil.inventoryLotId = il_in.id + AND il_in.deleted = 0 + LEFT JOIN stock_out_line sol + ON sl.stockOutLineId = sol.id + AND sol.deleted = 0 + LEFT JOIN inventory_lot_line ill_out + ON sol.inventoryLotLineId = ill_out.id + AND ill_out.deleted = 0 + LEFT JOIN inventory_lot il_out + ON ill_out.inventoryLotId = il_out.id + AND il_out.deleted = 0 + LEFT JOIN inventory_lot_line ill_in_loc + ON ill_in_loc.inventoryLotId = il_in.id + AND ill_in_loc.deleted = 0 + LEFT JOIN warehouse wh + ON wh.id = COALESCE(ill_in_loc.warehouseId, ill_out.warehouseId) + AND wh.deleted = 0 + LEFT JOIN items it + ON sl.itemId = it.id + AND it.deleted = 0 + LEFT JOIN ( + SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure + FROM item_uom iu + JOIN uom_conversion uc ON uc.id = iu.uomId + WHERE iu.stockUnit = 1 + AND iu.deleted = 0 + GROUP BY iu.itemId + ) uom ON uom.itemId = it.id + LEFT JOIN stock_out so + ON sol.stockOutId = so.id + AND so.deleted = 0 + LEFT JOIN pick_order_line pol + ON sol.pickOrderLineId = pol.id + AND pol.deleted = 0 + LEFT JOIN pick_order po_out + ON pol.poId = po_out.id + AND po_out.deleted = 0 + LEFT JOIN job_order jo_po + ON po_out.joId = jo_po.id + AND jo_po.deleted = 0 + LEFT JOIN delivery_order do + ON po_out.doId = do.id + AND do.deleted = 0 + LEFT JOIN stock_in si + ON sil.stockInId = si.id + AND si.deleted = 0 + LEFT JOIN job_order jo + ON sil.jobOrderId = jo.id + AND jo.deleted = 0 + LEFT JOIN purchase_order po + ON sil.purchaseOrderId = po.id + AND po.deleted = 0 +WHERE + sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + $stockCategorySql + $itemCodeSql + $storeLocationSql + AND COALESCE(il_in.id, il_out.id) IS NOT NULL +ORDER BY itemNo, - itemName, - unitOfMeasure, - lotNo, - expiryDate, trnDate, - CASE trnRefNoRaw - WHEN 'OPEN' THEN '開倉' - WHEN 'NOR' THEN '出入倉' - WHEN 'TRF' THEN '轉倉' - WHEN 'ADJ' THEN '調整' - WHEN 'MISS' THEN '遺失' - WHEN 'BAD' THEN '不良品' - WHEN 'TKE' THEN '盤點' - ELSE trnRefNoRaw - END AS trnRefNo, - storeLocation, - orderRefNo, - jobOrderNo, - - openingBalance, - cumStockIn, - cumStockOut, - currentBalance, - lastInDate, - lastOutDate, - reOrderLevel, - reOrderQty, - - CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, - CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, - - CASE WHEN cumOpeningBalRaw < 0 - THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') - ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, - CASE WHEN bal < 0 - THEN CONCAT('(', FORMAT(-bal, 0), ')') - ELSE FORMAT(bal, 0) END AS cumBalance, - - CASE WHEN totalCumOpeningBalRaw < 0 - THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') - ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, - CASE WHEN totalStockInRaw < 0 - THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') - ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, - CASE WHEN totalStockOutRaw < 0 - THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') - ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, - CASE WHEN totalCumBalanceRaw < 0 - THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') - ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance - -FROM ( - SELECT - x.*, - FIRST_VALUE(cumOpeningBalRaw) OVER ( - PARTITION BY itemCode ORDER BY trnDateRaw, slId - ) AS totalCumOpeningBalRaw, - SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, - SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, - FIRST_VALUE(bal) OVER ( - PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC - ) AS totalCumBalanceRaw - FROM ( - SELECT - sl.id AS slId, - DATE(sl.date) AS trnDateRaw, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, - CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' - AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') - END AS trnRefNoRaw, - - sl.itemCode AS itemCode, - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - COALESCE(sl.balance, 0) AS bal, - COALESCE(sl.balance, 0) - - COALESCE(sl.inQty, 0) - + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, - - it.type AS stockSubCategory, - it.code AS itemNo, - it.name AS itemName, - uc.udfudesc AS unitOfMeasure, + slId, + lotNo +""".trimIndent() - lot.lotNo AS lotNo, - COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, - lot.storeLocation AS storeLocation, + return attachItemTotals(jdbcDao.queryForList(sql, args)) + } - COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, - COALESCE(TRIM(jo.code), '') AS jobOrderNo, + /** + * Item totals match the old window: first opening, sum in/out, last balance per itemCode. + * Rows stay ordered by item, date, id. + */ + private fun attachItemTotals(rows: List>): List> { + if (rows.isEmpty()) return rows + val totals = HashMap(rows.size.coerceAtMost(4096)) + for (row in rows) { + val key = row["itemCode"]?.toString().orEmpty() + val bucket = totals.getOrPut(key) { LedgerItemTotals() } + val inQty = toQty(row["inQty"]) + val outQty = toQty(row["outQty"]) + val opening = toQty(row["cumOpeningBalRaw"]) + val closing = toQty(row["bal"]) + val date = row["trnDate"]?.toString().orEmpty() + val id = toLongId(row["slId"]) + bucket.inSum = bucket.inSum.add(inQty) + bucket.outSum = bucket.outSum.add(outQty) + if (!bucket.seen || earlier(date, id, bucket.firstDate, bucket.firstId)) { + bucket.opening = opening + bucket.firstDate = date + bucket.firstId = id + } + if (!bucket.seen || later(date, id, bucket.lastDate, bucket.lastId)) { + bucket.closing = closing + bucket.lastDate = date + bucket.lastId = id + } + bucket.seen = true + } - '' AS openingBalance, - '' AS cumStockIn, - '' AS cumStockOut, - '' AS currentBalance, - '' AS lastInDate, - '' AS lastOutDate, - '' AS reOrderLevel, - '' AS reOrderQty - FROM stock_ledger sl - LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id - AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in - ON sil.inventoryLotId = il_in.id - AND il_in.deleted = 0 + val fmt = DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US)) + val out = ArrayList>(rows.size) + for (row in rows) { + val bucket = totals.getValue(row["itemCode"]?.toString().orEmpty()) + val trnRefNoRaw = row["trnRefNoRaw"]?.toString().orEmpty() + out.add( + linkedMapOf( + "stockSubCategory" to text(row["stockSubCategory"]), + "itemNo" to text(row["itemNo"]), + "itemName" to text(row["itemName"]), + "unitOfMeasure" to text(row["unitOfMeasure"]), + "lotNo" to text(row["lotNo"]), + "expiryDate" to text(row["expiryDate"]), + "trnDate" to text(row["trnDate"]), + "trnRefNo" to ledgerTypeLabel(trnRefNoRaw), + "storeLocation" to text(row["storeLocation"]), + "orderRefNo" to text(row["orderRefNo"]), + "jobOrderNo" to text(row["jobOrderNo"]), + "openingBalance" to "", + "cumStockIn" to "", + "cumStockOut" to "", + "currentBalance" to "", + "lastInDate" to "", + "lastOutDate" to "", + "reOrderLevel" to "", + "reOrderQty" to "", + "stockIn" to formatLedgerQty(toQty(row["inQty"]), fmt), + "stockOut" to formatLedgerQty(toQty(row["outQty"]), fmt), + "cumOpeningBal" to formatLedgerQty(toQty(row["cumOpeningBalRaw"]), fmt), + "cumBalance" to formatLedgerQty(toQty(row["bal"]), fmt), + "totalCumOpeningBal" to formatLedgerQty(bucket.opening, fmt), + "totalStockIn" to formatLedgerQty(bucket.inSum, fmt), + "totalStockOut" to formatLedgerQty(bucket.outSum, fmt), + "totalCumBalance" to formatLedgerQty(bucket.closing, fmt), + ) + ) + } + return out + } - LEFT JOIN stock_out_line sol - ON sl.stockOutLineId = sol.id - AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out - ON sol.inventoryLotLineId = ill_out.id - AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out - ON ill_out.inventoryLotId = il_out.id - AND il_out.deleted = 0 + private class LedgerItemTotals { + var inSum: BigDecimal = BigDecimal.ZERO + var outSum: BigDecimal = BigDecimal.ZERO + var opening: BigDecimal = BigDecimal.ZERO + var closing: BigDecimal = BigDecimal.ZERO + var firstDate: String = "" + var firstId: Long = 0 + var lastDate: String = "" + var lastId: Long = 0 + var seen: Boolean = false + } - LEFT JOIN ( - SELECT - il.id AS lotId, - il.lotNo AS lotNo, - il.expiryDate AS expiryDate, - MAX(wh.code) AS storeLocation - FROM inventory_lot il - LEFT JOIN inventory_lot_line ill - ON ill.inventoryLotId = il.id - AND ill.deleted = 0 - LEFT JOIN warehouse wh - ON ill.warehouseId = wh.id - AND wh.deleted = 0 - WHERE il.deleted = 0 - GROUP BY - il.id, il.lotNo, il.expiryDate - ) lot - ON lot.lotId = COALESCE(il_in.id, il_out.id) + private fun earlier(date: String, id: Long, otherDate: String, otherId: Long): Boolean = + date < otherDate || (date == otherDate && id < otherId) + + private fun later(date: String, id: Long, otherDate: String, otherId: Long): Boolean = + date > otherDate || (date == otherDate && id > otherId) + + private fun ledgerTypeLabel(raw: String): String = when (raw) { + "OPEN" -> "開倉" + "NOR" -> "出入倉" + "TRF" -> "轉倉" + "ADJ" -> "調整" + "MISS" -> "遺失" + "BAD" -> "不良品" + "TKE" -> "盤點" + else -> raw + } - LEFT JOIN items it - ON sl.itemId = it.id - AND it.deleted = 0 - LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 - AND iu.deleted = 0 - LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id + private fun formatLedgerQty(value: BigDecimal, fmt: DecimalFormat): String { + val rounded = value.setScale(0, RoundingMode.HALF_UP) + val text = fmt.format(rounded.abs()) + return if (rounded.signum() < 0) "($text)" else text + } - LEFT JOIN stock_out so - ON sol.stockOutId = so.id - AND so.deleted = 0 - LEFT JOIN pick_order_line pol - ON sol.pickOrderLineId = pol.id - AND pol.deleted = 0 - LEFT JOIN pick_order po_out - ON pol.poId = po_out.id - AND po_out.deleted = 0 - LEFT JOIN job_order jo_po - ON po_out.joId = jo_po.id - AND jo_po.deleted = 0 - LEFT JOIN delivery_order do - ON po_out.doId = do.id - AND do.deleted = 0 - LEFT JOIN stock_in si - ON sil.stockInId = si.id - AND si.deleted = 0 - LEFT JOIN job_order jo - ON sil.jobOrderId = jo.id - AND jo.deleted = 0 - LEFT JOIN purchase_order po - ON sil.purchaseOrderId = po.id - AND po.deleted = 0 - WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND sl.date >= :reportPeriodStart - AND sl.date < :reportPeriodEndExclusive - $stockCategorySql - $itemCodeSql - $storeLocationSql - AND lot.lotId IS NOT NULL - ) x -) y -ORDER BY - itemNo, - trnDateRaw, - slId, - lotNo -""".trimIndent() + private fun toQty(value: Any?): BigDecimal = when (value) { + null -> BigDecimal.ZERO + is BigDecimal -> value + is Long -> BigDecimal.valueOf(value) + is Int -> BigDecimal.valueOf(value.toLong()) + is Number -> BigDecimal(value.toString()) + else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } - return jdbcDao.queryForList(sql, args) + private fun toLongId(value: Any?): Long = when (value) { + is Number -> value.toLong() + else -> value?.toString()?.toLongOrNull() ?: 0L } + private fun text(value: Any?): String = value?.toString().orEmpty() + /** Full codes use equality; short tokens use prefix LIKE. */ private fun buildItemCodeFilterClause( paramValue: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index 7fa4fb43..48a88789 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -5,8 +5,8 @@ import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens +import com.ffii.fpsms.modules.report.service.StockLedgerPdf import com.ffii.fpsms.modules.report.service.StockLedgerReportService -import com.ffii.fpsms.modules.report.service.ReportService import org.apache.poi.ss.usermodel.BorderStyle import org.apache.poi.ss.usermodel.CellStyle import org.apache.poi.ss.usermodel.DataFormat @@ -24,7 +24,6 @@ import java.io.ByteArrayOutputStream @RestController @RequestMapping("/report") class StockLedgerReportController( - private val reportService: ReportService, private val stockLedgerReportService: StockLedgerReportService, ) { private data class ExcelStyles( @@ -98,27 +97,6 @@ class StockLedgerReportController( reportPeriodStart: String?, reportPeriodEnd: String?, ): ResponseEntity { - val tokenCount = ReportMultiValueTokens.split(itemCode).size - val parameters = mutableMapOf() - - parameters["stockCategory"] = stockCategory ?: "All" - parameters["stockSubCategory"] = stockCategory ?: "All" - parameters["itemNo"] = - when { - itemCode.isNullOrBlank() -> "All" - tokenCount > 30 -> "$tokenCount items" - else -> itemCode - } - parameters["year"] = LocalDate.now().year.toString() - parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) - parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) - - parameters["storeLocation"] = storeLocation ?: "" - parameters["balanceFilterStart"] = "" - parameters["balanceFilterEnd"] = "" - parameters["reportPeriodStart"] = reportPeriodStart ?: "" - parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" - val dbData = stockLedgerReportService.searchStockLedgerReport( stockCategory = stockCategory, itemCode = itemCode, @@ -127,10 +105,12 @@ class StockLedgerReportController( reportPeriodEnd = reportPeriodEnd, ) - val pdfBytes = reportService.createPdfResponse( - "/jasper/StockLedgarReport.jrxml", - parameters, - dbData, + val pdfBytes = StockLedgerPdf.write( + rows = dbData, + reportDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), + reportTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), + periodStart = reportPeriodStart?.trim().orEmpty(), + periodEnd = reportPeriodEnd?.trim().orEmpty(), ) val headers = HttpHeaders().apply { diff --git a/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java b/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java index acf0d792..9bced40b 100644 --- a/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java +++ b/src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java @@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.settings.web; import java.util.List; import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController; import com.ffii.core.exception.BadRequestException; import com.ffii.core.exception.NotFoundException; +import com.ffii.fpsms.modules.common.SettingNames; import com.ffii.fpsms.modules.settings.entity.Settings; import com.ffii.fpsms.modules.settings.service.SettingsService; @@ -53,6 +57,9 @@ public class SettingsController{ } private void applyUpdate(String name, UpdateReq body) { + if (SettingNames.STOCK_TAKE_QTY_GAP_WARN_PERCENT.equals(name) && !isAdmin()) { + throw new AccessDeniedException("ADMIN only"); + } Settings entity = this.settingsService.findByName(name) .orElseThrow(NotFoundException::new); if (!this.settingsService.validateType(entity.getType(), body.getValue())) { @@ -63,6 +70,12 @@ public class SettingsController{ this.settingsService.save(entity); } + private static boolean isAdmin() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) return false; + return auth.getAuthorities().stream().anyMatch(a -> "ADMIN".equals(a.getAuthority())); + } + public static class UpdateReq { @NotBlank private String value; diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 33327100..2fdbbb0c 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -805,6 +805,7 @@ open class StockTakeRecordService( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseName = warehouse?.name, status = ill.status?.name, @@ -1065,6 +1066,7 @@ open class StockTakeRecordService( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseName = warehouse?.name, status = ill.status?.name, @@ -1175,6 +1177,7 @@ open class StockTakeRecordService( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseArea = warehouse?.area, warehouseName = warehouse?.name, @@ -1270,6 +1273,7 @@ open class StockTakeRecordService( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseArea = warehouse?.area, warehouseName = warehouse?.name, @@ -2854,6 +2858,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseName = warehouse?.name, status = ill.status?.name, @@ -2954,6 +2959,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( holdQty = ill.holdQty, availableQty = availableQty, uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, warehouseCode = warehouse?.code, warehouseName = warehouse?.name, status = ill.status?.name, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt index 9fa6f33c..d96bf61f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt @@ -78,6 +78,8 @@ data class InventoryLotDetailResponse( @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") val approverTime: LocalDateTime? = null, val lastSelect: Int? = null, + /** Stock UoM short label (e.g. 包), shown beside the count input. */ + val uomShortDesc: String? = null, ) data class InventoryLotLineListRequest( val warehouseCode: String diff --git a/src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql b/src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql new file mode 100644 index 00000000..d1793c45 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql @@ -0,0 +1,9 @@ +--liquibase formatted sql +--changeset fpsms:20260923_stock_take_qty_gap_warn_percent + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT 'STOCK_TAKE.qtyGapWarnPercent', '50', 'STOCK_TAKE', 'integer' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` WHERE `name` = 'STOCK_TAKE.qtyGapWarnPercent' +); diff --git a/src/main/resources/jasper/StockLedgarReport.jrxml b/src/main/resources/jasper/StockLedgarReport.jrxml index a245642f..a85ee22a 100644 --- a/src/main/resources/jasper/StockLedgarReport.jrxml +++ b/src/main/resources/jasper/StockLedgarReport.jrxml @@ -73,7 +73,7 @@ - + diff --git a/src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt new file mode 100644 index 00000000..03a714b2 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt @@ -0,0 +1,40 @@ +package com.ffii.fpsms.modules.report.service + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StockLedgerPdfSpeedTest { + @Test + fun writesLargeLedgerWithoutJasperLayout() { + val rows = ArrayList>(40_000) + repeat(40_000) { i -> + val item = "PP%04d".format(i / 40) + rows.add( + linkedMapOf( + "itemNo" to item, + "itemName" to "測試貨品$item", + "unitOfMeasure" to "包", + "trnDate" to "2026-09-01", + "trnRefNo" to "出入倉", + "lotNo" to "LT-$i", + "expiryDate" to "2026-12-31", + "cumOpeningBal" to "1,200", + "stockIn" to "10", + "stockOut" to "0", + "cumBalance" to "1,210", + "orderRefNo" to "DO-20260901-${i % 1000}", + "storeLocation" to "W201", + "totalStockIn" to "400", + "totalStockOut" to "0", + "totalCumBalance" to "1,210", + ) + ) + } + val started = System.nanoTime() + val pdf = StockLedgerPdf.write(rows, "2026-09-23", "23:40:00", "2026-09-01", "2026-09-07") + val seconds = (System.nanoTime() - started) / 1_000_000_000.0 + assertTrue(pdf.size > 1000, "pdf bytes=${pdf.size}") + assertTrue(pdf[0] == '%'.code.toByte()) + assertTrue(seconds < 15.0, "pdf took ${"%.2f".format(seconds)}s for 40000 rows") + } +} From db0c21003b5f1de0fb0ea3ff697af432f24bf292 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Mon, 28 Sep 2026 13:14:03 +0800 Subject: [PATCH 17/52] Fix Liquibase 07 so DROP and CREATE TRIGGER are separate changesets. BOM and same-changeset DROP+CREATE broke MySQL startup; match 06 style with splitStatements:false on CREATE only. --- .../07_one_lot_one_stock_uom.sql | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/07_one_lot_one_stock_uom.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/07_one_lot_one_stock_uom.sql index 37845e1f..c2bd2f70 100644 --- a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/07_one_lot_one_stock_uom.sql +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/07_one_lot_one_stock_uom.sql @@ -1,8 +1,12 @@ --- liquibase formatted sql +-- liquibase formatted sql + +-- changeset kelvin:20260922-drop-ill-before-one-lot-one-stock-uom +-- comment: M-E-01 Drop BEFORE mix-UOM triggers so recreate can run as one statement. +DROP TRIGGER IF EXISTS `inventory_lot_line_BEFORE_insert_one_stock_uom`; +DROP TRIGGER IF EXISTS `inventory_lot_line_BEFORE_update_one_stock_uom`; -- changeset kelvin:20260922-ill-before-insert-one-lot-one-stock-uom splitStatements:false -- comment: M-E-01 One inventory_lot must not mix two stock UOM buckets. -DROP TRIGGER IF EXISTS `inventory_lot_line_BEFORE_insert_one_stock_uom`; CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_BEFORE_insert_one_stock_uom` BEFORE INSERT ON `inventory_lot_line` @@ -29,7 +33,6 @@ END; -- changeset kelvin:20260922-ill-before-update-one-lot-one-stock-uom splitStatements:false -- comment: M-E-01 Also block UPDATE that would mix stock UOMs on the same lot. -DROP TRIGGER IF EXISTS `inventory_lot_line_BEFORE_update_one_stock_uom`; CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_BEFORE_update_one_stock_uom` BEFORE UPDATE ON `inventory_lot_line` From 5254069467f1d22ba7ff6b5fbab24a2bd1ef6550 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Mon, 28 Sep 2026 15:16:55 +0800 Subject: [PATCH 18/52] [Fix] Price columns now hidden for some report downloads --- .../com/ffii/fpsms/modules/report/web/ReportController.kt | 4 ++++ .../modules/report/web/StockLotOnhandReportController.kt | 5 ++++- src/main/resources/jasper/StockBalanceReport.jrxml | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index 816098bd..185d807b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -1063,6 +1063,7 @@ class ReportController( return out.toByteArray() } + /** FP-MTMS Version Checklist | Functions Ref. No. 87 | v1.0.0 | 2026-09-28 */ private fun createStockBalanceExcel( dbData: List>, reportDate: String, @@ -1139,6 +1140,9 @@ class ReportController( } val widths = intArrayOf(14, 26, 10, 12, 12, 12, 14, 12, 12, 12, 12, 14) widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + // Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now. + sheet.setColumnHidden(10, true) + sheet.setColumnHidden(11, true) val out = ByteArrayOutputStream() workbook.use { it.write(out) } diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt index 041b6cab..6718b7fb 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt @@ -191,7 +191,7 @@ class StockLotOnhandReportController( return v } - /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.2 | 2026-09-28 */ private fun createExcel( dbData: List>, reportDate: String, @@ -310,6 +310,9 @@ class StockLotOnhandReportController( sheet.createFreezePane(0, headerRowIndex + 1) intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } + // Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now. + sheet.setColumnHidden(13, true) + sheet.setColumnHidden(14, true) val out = ByteArrayOutputStream() workbook.write(out) diff --git a/src/main/resources/jasper/StockBalanceReport.jrxml b/src/main/resources/jasper/StockBalanceReport.jrxml index 072560bc..2feb7101 100644 --- a/src/main/resources/jasper/StockBalanceReport.jrxml +++ b/src/main/resources/jasper/StockBalanceReport.jrxml @@ -1,5 +1,6 @@ + @@ -77,6 +78,7 @@ + @@ -163,6 +165,7 @@ + @@ -405,6 +408,7 @@ + @@ -416,6 +420,7 @@ + From fdba96acea2e86ea69f57a4cb53bad81eabe25de Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 28 Sep 2026 15:18:09 +0800 Subject: [PATCH 19/52] report lot uom fix fix UPDATE_INVENTORY_QTY_SQL unavavable qty don't use onhold qty --- .../ledgerfix/StockLedgerFixInventorySql.kt | 2 +- .../FGStockOutTraceabilityReportService.kt | 24 +++++-- .../report/service/ItemQcFailReportService.kt | 18 ++++- ...terialStockOutTraceabilityReportService.kt | 22 +++++-- .../modules/report/service/ReportService.kt | 50 ++++++++++---- .../service/StockLedgerReportService.kt | 66 +++++++++++++------ .../service/StockTakeVarianceReportService.kt | 51 +++++++------- .../FGStockOutTraceabilityReportController.kt | 5 +- ...ialStockOutTraceabilityReportController.kt | 5 +- .../modules/report/web/ReportController.kt | 2 +- .../report/web/StockLedgerReportController.kt | 2 +- .../web/StockTakeVarianceReportController.kt | 2 +- .../jasper/FGStockOutTraceabilityReport.jrxml | 2 +- src/main/resources/jasper/ItemQCReport.jrxml | 2 +- .../jasper/MaterialStockOutTraceability.jrxml | 2 +- .../jasper/StockInTraceabilityReport.jrxml | 2 +- .../resources/jasper/StockLedgarReport.jrxml | 2 +- .../jasper/StockTakeVarianceReport.jrxml | 2 +- 18 files changed, 173 insertions(+), 88 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt index cef015d3..b7e5a129 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt @@ -217,7 +217,7 @@ internal object StockLedgerFixInventorySql { SUM( CASE WHEN LOWER(ill.status) = 'unavailable' - THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) + THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) ELSE 0 END ) AS unavailable diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt index 7023b2fb..3eec75d2 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt @@ -105,7 +105,7 @@ class FGStockOutTraceabilityReportService( CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, IFNULL(sp.name, '') AS customerName, FORMAT( - ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 + ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 ) AS qtyNumeric, FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty, '' AS truckNo, @@ -131,7 +131,7 @@ class FGStockOutTraceabilityReportService( GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, '' AS pickRemark, FORMAT( - ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 + ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 ) AS totalStockOutQty, 0 AS stockSubCategory FROM delivery_order_pick_order dopo @@ -154,11 +154,6 @@ class FGStockOutTraceabilityReportService( ON dol.deliveryOrderId = do.id AND dol.itemId = pol.itemId AND dol.deleted = 0 - LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 - LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id LEFT JOIN stock_out_line sol ON pol.id = sol.pickOrderLineId AND sol.itemId = it.id @@ -170,6 +165,20 @@ class FGStockOutTraceabilityReportService( LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN item_uom iu + ON iu.deleted = 0 + AND iu.id = COALESCE( + ill.stockItemUomId, + ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = it.id + AND iu2.deleted = 0 + AND iu2.stockUnit = 1 + ) + ) + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 @@ -213,6 +222,7 @@ class FGStockOutTraceabilityReportService( il.expiryDate ORDER BY it.code, + uc.udfudesc, deliveryDate, il.lotNo """.trimIndent() diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt index fc2d1b65..0ec4c22d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt @@ -109,12 +109,23 @@ open class ItemQcFailReportService( LEFT JOIN qc_result_measurement qrm ON qrm.qcResultId = qr.id AND qrm.deleted = 0 + LEFT JOIN inventory_lot_line ill_uom + ON ill_uom.deleted = 0 + AND ill_uom.id = COALESCE( + sil.inventoryLotLineId, + ( + SELECT MIN(illx.id) + FROM inventory_lot_line illx + WHERE illx.inventoryLotId = sil.inventoryLotId + AND illx.deleted = 0 + AND illx.stockItemUomId IS NOT NULL + ) + ) LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 + ON iu.id = ill_uom.stockItemUomId AND iu.deleted = 0 LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id + ON uc.id = iu.uomId LEFT JOIN item_uom iu_purchase ON it.id = iu_purchase.itemId AND iu_purchase.purchaseUnit = 1 @@ -164,6 +175,7 @@ open class ItemQcFailReportService( qr.qcPassed DESC, it.type, it.code, + COALESCE(uc.udfudesc, uc.code, ''), COALESCE(sil.lotNo, il.lotNo, ''), CASE WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' ELSE 'IQC' END, COALESCE(qic.`order`, 9999), diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/MaterialStockOutTraceabilityReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/MaterialStockOutTraceabilityReportService.kt index 0f61589e..54ab65a4 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/MaterialStockOutTraceabilityReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/MaterialStockOutTraceabilityReportService.kt @@ -105,7 +105,7 @@ class MaterialStockOutTraceabilityReportService( COALESCE(wh.code, '') AS storeLocation, '' AS pickRemark, FORMAT( - ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 + ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 ) AS totalStockOutQty FROM stock_out_line sol INNER JOIN stock_out so @@ -126,14 +126,23 @@ class MaterialStockOutTraceabilityReportService( INNER JOIN items it ON sol.itemId = it.id AND it.deleted = 0 - LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 - LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN item_uom iu + ON iu.deleted = 0 + AND iu.id = COALESCE( + ill.stockItemUomId, + ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = it.id + AND iu2.deleted = 0 + AND iu2.stockUnit = 1 + ) + ) + LEFT JOIN uom_conversion uc + ON iu.uomId = uc.id LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 @@ -169,6 +178,7 @@ class MaterialStockOutTraceabilityReportService( $handlerSql ORDER BY it.code, + uc.udfudesc, il.lotNo, sol.endTime """.trimIndent() diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index a4ba7f0c..3e0854ea 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -372,7 +372,7 @@ return result CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, IFNULL(sp.name, '') AS customerName, FORMAT( - ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 + ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 ) AS qtyNumeric, FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty, '' AS truckNo, @@ -396,7 +396,7 @@ return result GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, '' AS pickRemark, FORMAT( - ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 + ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 ) AS totalStockOutQty, 0 AS stockSubCategory FROM do_pick_order_line_record dpolr @@ -416,11 +416,6 @@ return result LEFT JOIN items it ON dol.itemId = it.id AND it.deleted = 0 - LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 - LEFT JOIN uom_conversion uc - ON iu.uomId = uc.id LEFT JOIN pick_order_line pol ON dpolr.pick_order_id = pol.poId AND pol.itemId = it.id @@ -439,6 +434,20 @@ return result LEFT JOIN inventory_lot_line ill ON sol.inventoryLotLineId = ill.id AND ill.deleted = 0 + LEFT JOIN item_uom iu + ON iu.deleted = 0 + AND iu.id = COALESCE( + ill.stockItemUomId, + ( + SELECT MIN(iu2.id) + FROM item_uom iu2 + WHERE iu2.itemId = it.id + AND iu2.deleted = 0 + AND iu2.stockUnit = 1 + ) + ) + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId LEFT JOIN inventory_lot il ON ill.inventoryLotId = il.id AND il.deleted = 0 @@ -479,6 +488,7 @@ return result il.expiryDate ORDER BY it.code, + uc.udfudesc, deliveryDate, il.lotNo """.trimIndent() @@ -730,19 +740,33 @@ return result END, 2))) as iqcDefectPercentage, CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN '不合格' ELSE '已接受' END as iqcResult, COALESCE(qr_agg.remarksConcat, '') as iqcRemarks, - TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(qr_agg.failQtySum, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcDefectQty, + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(qr_agg.failQtySum, 0)) OVER (PARTITION BY it.id, iu.id), 2))) as totalIqcDefectQty, COALESCE(wh.code, '') as storeLocation, COALESCE(sp_si.code, sp_po.code, '') as supplierID, COALESCE(sp_si.name, sp_po.name, '') as supplierName, - TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(pa_sil.putAwayQtySum, 0)) OVER (PARTITION BY it.id), 2))) as totalStockInQty, - TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcSampleQty + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(pa_sil.putAwayQtySum, 0)) OVER (PARTITION BY it.id, iu.id), 2))) as totalStockInQty, + TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id, iu.id), 2))) as totalIqcSampleQty FROM stock_in_line sil LEFT JOIN stock_in si ON sil.stockInId = si.id LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id LEFT JOIN items it ON sil.itemId = it.id - LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true - LEFT JOIN uom_conversion uc ON iu.uomId = uc.id LEFT JOIN inventory_lot il ON sil.inventoryLotId = il.id + LEFT JOIN inventory_lot_line ill_uom + ON ill_uom.deleted = 0 + AND ill_uom.id = COALESCE( + sil.inventoryLotLineId, + ( + SELECT MIN(illx.id) + FROM inventory_lot_line illx + WHERE illx.inventoryLotId = il.id + AND illx.deleted = 0 + AND illx.stockItemUomId IS NOT NULL + ) + ) + LEFT JOIN item_uom iu + ON iu.id = ill_uom.stockItemUomId + AND iu.deleted = 0 + LEFT JOIN uom_conversion uc ON uc.id = iu.uomId LEFT JOIN ( SELECT inventoryLotId, SUM(COALESCE(inQty, 0)) AS putAwayQtySum FROM inventory_lot_line @@ -778,7 +802,7 @@ return result $slotSql $lotNoSql $poPrefixSql - ORDER BY it.code, sil.lotNo + ORDER BY it.code, uc.udfudesc, sil.lotNo """.trimIndent() return jdbcDao.queryForList(sql, args) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 139c71d5..5d3a83e2 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -89,6 +89,7 @@ open class StockLedgerReportService( COALESCE(sl.inQty, 0) AS inQty, COALESCE(sl.outQty, 0) AS outQty, COALESCE(sl.balance, 0) AS bal, + sl.uomId AS ledgerUomId, sl.inventoryLotLineId AS inventoryLotLineId, sl.stockInLineId AS stockInLineId, sl.stockOutLineId AS stockOutLineId @@ -112,6 +113,7 @@ open class StockLedgerReportService( inQty = toDouble(r["inQty"]), outQty = toDouble(r["outQty"]), bal = toDouble(r["bal"]), + ledgerUomId = toLong(r["ledgerUomId"]), lotLineId = toLong(r["inventoryLotLineId"]), stockInLineId = toLong(r["stockInLineId"]), stockOutLineId = toLong(r["stockOutLineId"]), @@ -183,12 +185,17 @@ open class StockLedgerReportService( ill.id AS lineId, COALESCE(il.lotNo, '') AS lotNo, COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, - COALESCE(wh.code, '') AS storeLocation + COALESCE(wh.code, '') AS storeLocation, + COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure FROM inventory_lot_line ill INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 LEFT JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0 + LEFT JOIN item_uom iu + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId WHERE ill.deleted = 0 AND ill.id IN (:ids) """.trimIndent(), @@ -197,6 +204,7 @@ open class StockLedgerReportService( lotNo = r["lotNo"]?.toString().orEmpty(), expiryDate = r["expiryDate"]?.toString().orEmpty(), storeLocation = r["storeLocation"]?.toString().orEmpty(), + unitOfMeasure = r["unitOfMeasure"]?.toString().orEmpty(), ) } val headerIds = lotKeyBySl.values @@ -209,12 +217,17 @@ open class StockLedgerReportService( il.id AS lotId, COALESCE(il.lotNo, '') AS lotNo, COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, - COALESCE(MAX(wh.code), '') AS storeLocation + COALESCE(MAX(wh.code), '') AS storeLocation, + COALESCE(MAX(uc.udfudesc), MAX(uc.code), '') AS unitOfMeasure FROM inventory_lot il LEFT JOIN inventory_lot_line ill ON ill.inventoryLotId = il.id AND ill.deleted = 0 LEFT JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0 + LEFT JOIN item_uom iu + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 + LEFT JOIN uom_conversion uc + ON uc.id = iu.uomId WHERE il.deleted = 0 AND il.id IN (:ids) GROUP BY il.id, il.lotNo, il.expiryDate @@ -224,10 +237,31 @@ open class StockLedgerReportService( lotNo = r["lotNo"]?.toString().orEmpty(), expiryDate = r["expiryDate"]?.toString().orEmpty(), storeLocation = r["storeLocation"]?.toString().orEmpty(), + unitOfMeasure = r["unitOfMeasure"]?.toString().orEmpty(), ) } } + val uomDescById = HashMap() + val missingUomIds = ArrayList() + for (m in moves) { + val key = lotKeyBySl[m.slId] ?: continue + val lot = if (key.lineId > 0) lotByLine[key.lineId] else lotByHeader[key.lotId] + if (lot != null && lot.unitOfMeasure.isBlank() && m.ledgerUomId > 0) { + missingUomIds.add(m.ledgerUomId) + } + } + for (r in queryChunks( + missingUomIds, + """ + SELECT id AS uomId, COALESCE(udfudesc, code, '') AS unitOfMeasure + FROM uom_conversion + WHERE id IN (:ids) + """.trimIndent(), + )) { + uomDescById[toLong(r["uomId"])] = r["unitOfMeasure"]?.toString().orEmpty() + } + val locationFilter = storeLocation?.trim().orEmpty() val withLot = ArrayList(moves.size) for (m in moves) { @@ -239,7 +273,8 @@ open class StockLedgerReportService( ) { continue } - withLot.add(MoveWithLot(m, lot)) + val uom = lot.unitOfMeasure.ifBlank { uomDescById[m.ledgerUomId].orEmpty() } + withLot.add(MoveWithLot(m, lot, uom)) } timings.add("4.filter rows=${withLot.size}") if (withLot.isEmpty()) { @@ -251,7 +286,6 @@ open class StockLedgerReportService( val type: String, val code: String, val name: String, - val uom: String, ) val items = HashMap() timed("5.items", { _: Unit -> "items=${items.size}" }) { @@ -262,19 +296,8 @@ open class StockLedgerReportService( it.id AS itemId, COALESCE(it.type, '') AS stockSubCategory, COALESCE(it.code, '') AS itemNo, - COALESCE(it.name, '') AS itemName, - COALESCE(uc.udfudesc, '') AS unitOfMeasure + COALESCE(it.name, '') AS itemName FROM items it - LEFT JOIN ( - SELECT itemId, MIN(id) AS id - FROM item_uom - WHERE deleted = 0 AND stockUnit = 1 AND itemId IN (:ids) - GROUP BY itemId - ) one_uom ON one_uom.itemId = it.id - LEFT JOIN item_uom iu - ON iu.id = one_uom.id - LEFT JOIN uom_conversion uc - ON uc.id = iu.uomId WHERE it.deleted = 0 AND it.id IN (:ids) """.trimIndent(), @@ -283,7 +306,6 @@ open class StockLedgerReportService( type = r["stockSubCategory"]?.toString().orEmpty(), code = r["itemNo"]?.toString().orEmpty(), name = r["itemName"]?.toString().orEmpty(), - uom = r["unitOfMeasure"]?.toString().orEmpty(), ) } } @@ -353,7 +375,7 @@ open class StockLedgerReportService( val totals = HashMap() for (row in withLot) { val m = row.move - val total = totals.getOrPut(m.itemCode) { ItemTotal() } + val total = totals.getOrPut("${m.itemCode}|${row.unitOfMeasure}") { ItemTotal() } total.inSum += m.inQty total.outSum += m.outQty val opening = m.bal - m.inQty + m.outQty @@ -372,6 +394,7 @@ open class StockLedgerReportService( val rows = timed("7.assemble", { list: List> -> "rows=${list.size}" }) { val sorted = withLot.sortedWith( compareBy { items[it.move.itemId]?.code.orEmpty() } + .thenBy { it.unitOfMeasure } .thenBy { it.move.trnDate } .thenBy { it.move.slId } .thenBy { it.lot.lotNo }, @@ -387,12 +410,12 @@ open class StockLedgerReportService( norm == "ADJ" && (outRef?.stockTake == true || inRef?.stockTake == true) -> "TKE" else -> m.rawType } - val total = totals[m.itemCode] + val total = totals["${m.itemCode}|${row.unitOfMeasure}"] reportRow( stockSubCategory = item?.type.orEmpty(), itemNo = item?.code.orEmpty(), itemName = item?.name.orEmpty(), - unitOfMeasure = item?.uom.orEmpty(), + unitOfMeasure = row.unitOfMeasure, lotNo = row.lot.lotNo, expiryDate = row.lot.expiryDate, trnDate = m.trnDate, @@ -558,6 +581,7 @@ open class StockLedgerReportService( val inQty: Double, val outQty: Double, val bal: Double, + val ledgerUomId: Long, val lotLineId: Long, val stockInLineId: Long, val stockOutLineId: Long, @@ -566,12 +590,14 @@ open class StockLedgerReportService( private data class MoveWithLot( val move: LedgerMove, val lot: LotInfo, + val unitOfMeasure: String, ) private data class LotInfo( val lotNo: String, val expiryDate: String, val storeLocation: String, + val unitOfMeasure: String, ) private class ItemTotal { diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt index 9b190257..6b030bcf 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt @@ -245,8 +245,7 @@ in_agg AS ( AND iu_purchase.purchaseUnit = 1 AND iu_purchase.deleted = 0 LEFT JOIN item_uom iu_stock - ON it.id = iu_stock.itemId - AND iu_stock.stockUnit = 1 + ON iu_stock.id = ill.stockItemUomId AND iu_stock.deleted = 0 WHERE ill.deleted = 0 GROUP BY ill.id @@ -316,8 +315,7 @@ data AS ( $ROOT_STOCK_IN_JOIN_SQL LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 LEFT JOIN uom_conversion uc ON iu.uomId = uc.id @@ -377,16 +375,17 @@ SELECT END AS variancePercentage, /* item totals(footer 用);負數括號顯示,無小數 */ - CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance, - CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn, - CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut, - CASE WHEN SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance, - CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty, + CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalOpeningBalance, + CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockIn, + CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockOut, + CASE WHEN SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCurrentBalance, + CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalStockTakeQty, lotTypeRaw AS type FROM data ORDER BY itemNo, + unitOfMeasure, lotNo, storeLocation """.trimIndent() @@ -521,8 +520,7 @@ in_agg AS ( AND iu_purchase.purchaseUnit = 1 AND iu_purchase.deleted = 0 LEFT JOIN item_uom iu_stock - ON it.id = iu_stock.itemId - AND iu_stock.stockUnit = 1 + ON iu_stock.id = ill.stockItemUomId AND iu_stock.deleted = 0 WHERE ill.deleted = 0 GROUP BY ill.id @@ -598,8 +596,7 @@ data AS ( $ROOT_STOCK_IN_JOIN_SQL LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 LEFT JOIN uom_conversion uc ON iu.uomId = uc.id @@ -655,16 +652,17 @@ SELECT ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%') END AS variancePercentage, - CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance, - CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn, - CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut, - CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance, - CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty, + CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalOpeningBalance, + CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockIn, + CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockOut, + CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCurrentBalance, + CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalStockTakeQty, lotTypeRaw AS type FROM data ORDER BY itemNo, + unitOfMeasure, lotNo, storeLocation """.trimIndent() @@ -820,8 +818,7 @@ in_agg AS ( AND iu_purchase.purchaseUnit = 1 AND iu_purchase.deleted = 0 LEFT JOIN item_uom iu_stock - ON it.id = iu_stock.itemId - AND iu_stock.stockUnit = 1 + ON iu_stock.id = ill.stockItemUomId AND iu_stock.deleted = 0 WHERE ill.deleted = 0 GROUP BY lb.inventoryLotLineId @@ -888,8 +885,7 @@ data AS ( $ROOT_STOCK_IN_JOIN_SQL LEFT JOIN item_uom iu - ON it.id = iu.itemId - AND iu.stockUnit = 1 + ON iu.id = ill.stockItemUomId AND iu.deleted = 0 LEFT JOIN uom_conversion uc ON iu.uomId = uc.id @@ -945,16 +941,17 @@ SELECT ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%') END AS variancePercentage, - CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance, - CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn, - CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut, - CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance, - CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty, + CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalOpeningBalance, + CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockIn, + CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockOut, + CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCurrentBalance, + CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalStockTakeQty, lotTypeRaw AS type FROM data ORDER BY itemNo, + unitOfMeasure, lotNo, storeLocation """.trimIndent() diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt index c6bdb0f8..e476cb46 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt @@ -306,11 +306,13 @@ class FGStockOutTraceabilityReportController( } else { var currentItemNo: String? = null var currentItemName = "" + var currentUom = "" var currentItemTotalQty = 0.0 dbData.forEach { rowMap -> val itemNo = rowMap["itemNo"]?.toString() ?: "" - if (currentItemNo != null && itemNo != currentItemNo) { + val uom = rowMap["unitOfMeasure"]?.toString().orEmpty() + if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName) addBlankSeparatorRow() currentItemTotalQty = 0.0 @@ -331,6 +333,7 @@ class FGStockOutTraceabilityReportController( currentItemNo = itemNo currentItemName = rowMap["itemName"]?.toString() ?: "" + currentUom = uom currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0 } diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt index 0000e717..91daa642 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt @@ -298,11 +298,13 @@ class MaterialStockOutTraceabilityReportController( } else { var currentItemNo: String? = null var currentItemName: String = "" + var currentUom = "" var currentItemTotalQty: Double = 0.0 dbData.forEach { rowMap -> val itemNo = rowMap["itemNo"]?.toString() ?: "" - if (currentItemNo != null && itemNo != currentItemNo) { + val uom = rowMap["unitOfMeasure"]?.toString().orEmpty() + if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName) addBlankSeparatorRow() currentItemTotalQty = 0.0 @@ -323,6 +325,7 @@ class MaterialStockOutTraceabilityReportController( currentItemNo = itemNo currentItemName = rowMap["itemName"]?.toString() ?: "" + currentUom = uom currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0 } diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index 7875a416..eaab880c 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -704,7 +704,7 @@ class ReportController( val itemName = m["itemName"]?.toString().orEmpty() val uom = m["unitOfMeasure"]?.toString().orEmpty() - if (currentItemNo != null && itemNo != currentItemNo) { + if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { addItemSummaryRow( currentItemNo!!, currentItemName, diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt index 01b5bf20..7e7560c6 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt @@ -394,7 +394,7 @@ class StockLedgerReportController( val itemName = m["itemName"]?.toString().orEmpty() val uom = m["unitOfMeasure"]?.toString().orEmpty() - if (currentItemNo != null && itemNo != currentItemNo) { + if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { addItemSummaryRow( currentItemNo!!, currentItemName, diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt index b457e2c0..ebcd3c03 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt @@ -490,7 +490,7 @@ class StockTakeVarianceReportController( val itemName = rowMap["itemName"]?.toString().orEmpty() val uom = rowMap["unitOfMeasure"]?.toString().orEmpty() - if (currentItemNo != null && itemNo != currentItemNo) { + if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { addItemSummaryRow(currentItemTotalQty, currentItemTotalStockTakeQty, currentItemNo!!, currentItemName) addBlankSeparatorRow() currentItemTotalQty = 0.0 diff --git a/src/main/resources/jasper/FGStockOutTraceabilityReport.jrxml b/src/main/resources/jasper/FGStockOutTraceabilityReport.jrxml index 6959ca05..3a0565cb 100644 --- a/src/main/resources/jasper/FGStockOutTraceabilityReport.jrxml +++ b/src/main/resources/jasper/FGStockOutTraceabilityReport.jrxml @@ -59,7 +59,7 @@ - + diff --git a/src/main/resources/jasper/ItemQCReport.jrxml b/src/main/resources/jasper/ItemQCReport.jrxml index a6c47ca3..7cfb8fde 100644 --- a/src/main/resources/jasper/ItemQCReport.jrxml +++ b/src/main/resources/jasper/ItemQCReport.jrxml @@ -59,7 +59,7 @@ - + diff --git a/src/main/resources/jasper/MaterialStockOutTraceability.jrxml b/src/main/resources/jasper/MaterialStockOutTraceability.jrxml index 6bfa86e6..fd639a4e 100644 --- a/src/main/resources/jasper/MaterialStockOutTraceability.jrxml +++ b/src/main/resources/jasper/MaterialStockOutTraceability.jrxml @@ -54,7 +54,7 @@ - + diff --git a/src/main/resources/jasper/StockInTraceabilityReport.jrxml b/src/main/resources/jasper/StockInTraceabilityReport.jrxml index 6ee11df2..552afed5 100644 --- a/src/main/resources/jasper/StockInTraceabilityReport.jrxml +++ b/src/main/resources/jasper/StockInTraceabilityReport.jrxml @@ -62,7 +62,7 @@ - + diff --git a/src/main/resources/jasper/StockLedgarReport.jrxml b/src/main/resources/jasper/StockLedgarReport.jrxml index a85ee22a..86229971 100644 --- a/src/main/resources/jasper/StockLedgarReport.jrxml +++ b/src/main/resources/jasper/StockLedgarReport.jrxml @@ -74,7 +74,7 @@ - + diff --git a/src/main/resources/jasper/StockTakeVarianceReport.jrxml b/src/main/resources/jasper/StockTakeVarianceReport.jrxml index 04020a3a..6d5b0685 100644 --- a/src/main/resources/jasper/StockTakeVarianceReport.jrxml +++ b/src/main/resources/jasper/StockTakeVarianceReport.jrxml @@ -67,7 +67,7 @@ - + From e48b4f70e10ff58998b9b90cd077e094fc83a7b0 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Mon, 28 Sep 2026 16:52:19 +0800 Subject: [PATCH 20/52] Cap Miss/Bad stock-out to lot physical remain and drop findByItemId fallback. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent negative onHand via createStockOut/issue paths; resolve ledger inventory by stock-UOM bucket only, with unit coverage for the guard and ErrorHandler 45000→400. Co-authored-by: Cursor --- .../service/PickExecutionIssueService.kt | 10 ++- .../stock/service/InventoryLotLineQtyGuard.kt | 76 +++++++++++++++++ .../stock/service/StockOutLineService.kt | 19 ++++- .../support/ErrorHandlerSqlSignalTest.java | 41 ++++++++++ .../service/InventoryBucketResolverTest.kt | 82 +++++++++++++++++++ .../service/InventoryLotLineQtyGuardTest.kt | 77 +++++++++++++++++ 6 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt create mode 100644 src/test/java/com/ffii/core/support/ErrorHandlerSqlSignalTest.java create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryBucketResolverTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuardTest.kt diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index a392e3a8..a5b3437b 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -52,6 +52,8 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest @Service @@ -2730,8 +2732,13 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse inventoryLotLineRepository.findById(it).orElse(null) } val inventoryBeforeUpdate = inventoryBucketResolver.findInventoryBucket(request.itemId, lotLine) - ?: inventoryRepository.findByItemId(request.itemId).orElse(null) val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() + if (lotLine != null && inventoryBeforeUpdate == null) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "找不到對應庫存單位的庫存桶,無法提交不良品/短少。", + ) + } println("=== submitIssueWithQty: Before update ===") println("Item ID: ${request.itemId}") @@ -2945,7 +2952,6 @@ private fun bucketOnHandQty(itemId: Long, lotLine: InventoryLotLine?): Double { private fun resolveLedgerInventory(itemId: Long, lotLine: InventoryLotLine?): Inventory? = inventoryBucketResolver.findInventoryBucket(itemId, lotLine) - ?: inventoryRepository.findByItemId(itemId).orElse(null) private fun previousBalanceForInventory(inventory: Inventory): Double { val latest = inventory.id?.let { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt new file mode 100644 index 00000000..522215b4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt @@ -0,0 +1,76 @@ +package com.ffii.fpsms.modules.stock.service + +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import java.math.BigDecimal + +/** + * Shared lot-line remain rules. + * + * Option A triggers set inventory.onHand = Σ(in−out). Hold is lot metadata only. + * Pick / Miss / Bad / Expiry via [StockOutLineService.createStockOut] do + * `out += qty` and `hold = max(0, hold−qty)` — cap is **physical remain** `in−out`. + * Adjustment / TRF out-only paths keep hold unchanged — cap is `in−out−hold`. + */ +object InventoryLotLineQtyGuard { + fun physicalRemain(inQty: BigDecimal?, outQty: BigDecimal?): BigDecimal { + val i = inQty ?: BigDecimal.ZERO + val o = outQty ?: BigDecimal.ZERO + return i.subtract(o) + } + + fun availableQty( + inQty: BigDecimal?, + outQty: BigDecimal?, + holdQty: BigDecimal?, + ): BigDecimal { + val h = holdQty ?: BigDecimal.ZERO + return physicalRemain(inQty, outQty).subtract(h) + } + + fun isIssueStockOutType(type: String?): Boolean { + val t = type?.trim()?.lowercase() ?: return false + return t == "miss" || t == "bad" || t == "expiry" + } + + /** Cap for createStockOut / issue posting: qty ≤ in − out. */ + fun requireQtyWithinPhysicalRemain( + inQty: BigDecimal?, + outQty: BigDecimal?, + qty: BigDecimal, + label: String = "數量", + ) { + if (qty <= BigDecimal.ZERO) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "${label}必須大於 0") + } + val remain = physicalRemain(inQty, outQty) + if (qty > remain) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "${label}超過實體剩餘(remain=$remain, requested=$qty)。" + + "規則:requested ≤ inQty − outQty。", + ) + } + } + + /** Cap for adjustment / stock-transfer out-only: qty ≤ in − out − hold. */ + fun requireQtyWithinAvailable( + inQty: BigDecimal?, + outQty: BigDecimal?, + holdQty: BigDecimal?, + qty: BigDecimal, + label: String = "數量", + ) { + if (qty <= BigDecimal.ZERO) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "${label}必須大於 0") + } + val available = availableQty(inQty, outQty, holdQty) + if (qty > available) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "${label}超過可用量(availableQty=$available, requested=$qty)。" + + "規則:requested ≤ inQty − outQty − holdQty。", + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 1901a760..47a0f371 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -1580,9 +1580,20 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { open fun createStockOut(request: StockOutRequest): StockOutLine { val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() val qtyBd = BigDecimal.valueOf(request.qty) + val label = when { + InventoryLotLineQtyGuard.isIssueStockOutType(request.type) -> + request.type!!.trim().replaceFirstChar { it.uppercase() } + " 數量" + else -> "出倉數量" + } + InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain( + inventoryLotLine.inQty, + inventoryLotLine.outQty, + qtyBd, + label, + ) val oldHold = inventoryLotLine.holdQty ?: BigDecimal.ZERO - // Increase outQty and release hold (pick / QR); inventory aggregates via trigger inventory_lot_line_AFTER_UPDATE + // Increase outQty and release hold (pick / QR / Miss / Bad); inventory aggregates via trigger val updatedInventoryLotLine = inventoryLotLine.apply { val currentOutQty = this.outQty ?: BigDecimal.ZERO this.outQty = currentOutQty + qtyBd @@ -2095,6 +2106,12 @@ fun applyStockOutLineDelta( if (isIssuePosting) { val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null) if (latestLotLine != null) { + InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain( + latestLotLine.inQty, + latestLotLine.outQty, + deltaQty, + postingType.replaceFirstChar { it.uppercase() } + " 數量", + ) val prevStatus = latestLotLine.status val currentHoldQty = latestLotLine.holdQty ?: BigDecimal.ZERO val currentOutQty = latestLotLine.outQty ?: BigDecimal.ZERO diff --git a/src/test/java/com/ffii/core/support/ErrorHandlerSqlSignalTest.java b/src/test/java/com/ffii/core/support/ErrorHandlerSqlSignalTest.java new file mode 100644 index 00000000..004f8fe4 --- /dev/null +++ b/src/test/java/com/ffii/core/support/ErrorHandlerSqlSignalTest.java @@ -0,0 +1,41 @@ +package com.ffii.core.support; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.SQLException; + +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.ffii.core.response.FailureRes; + +class ErrorHandlerSqlSignalTest { + + private final ErrorHandler handler = new ErrorHandler(); + + @Test + void sqlSignal_45000_returns_http_400_with_chinese_message() { + SQLException signal = new SQLException("同一批次不可混用不同庫存單位", "45000"); + DataIntegrityViolationException wrapped = + new DataIntegrityViolationException("nested", new SQLException("wrapper", "HY000", signal)); + + ResponseEntity response = handler.sqlSignal(wrapped); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody() instanceof FailureRes); + assertEquals("同一批次不可混用不同庫存單位", ((FailureRes) response.getBody()).getError()); + } + + @Test + void sqlSignal_non_45000_falls_through_to_500() { + DataIntegrityViolationException plain = + new DataIntegrityViolationException("db down", new SQLException("gone", "08001")); + + ResponseEntity response = handler.sqlSignal(plain); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryBucketResolverTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryBucketResolverTest.kt new file mode 100644 index 00000000..6aef1298 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryBucketResolverTest.kt @@ -0,0 +1,82 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.master.entity.ItemUom +import com.ffii.fpsms.modules.master.entity.ItemUomRespository +import com.ffii.fpsms.modules.master.entity.UomConversion +import com.ffii.fpsms.modules.stock.entity.Inventory +import com.ffii.fpsms.modules.stock.entity.InventoryLotLine +import com.ffii.fpsms.modules.stock.entity.InventoryRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +class InventoryBucketResolverTest { + + private val itemUomRepository = mock(ItemUomRespository::class.java) + private val inventoryRepository = mock(InventoryRepository::class.java) + private val resolver = InventoryBucketResolver(itemUomRepository, inventoryRepository) + + @Test + fun resolveStockUomId_from_item_uom_id_uses_uom_conversion_id() { + val uom = UomConversion().apply { id = 207L } + val itemUom = ItemUom().apply { + id = 99L + this.uom = uom + } + `when`(itemUomRepository.findByIdAndDeletedIsFalse(99L)).thenReturn(itemUom) + + assertEquals(207L, resolver.resolveStockUomId(99L)) + } + + @Test + fun findInventoryBucket_by_item_and_stock_uom_returns_exact_bucket() { + val bucket = Inventory().apply { id = 10L } + `when`(inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(1L, 207L)) + .thenReturn(bucket) + + assertSame(bucket, resolver.findInventoryBucket(1L, 207L)) + verify(inventoryRepository, times(1)) + .findByItemIdAndStockUomIdAndDeletedIsFalse(1L, 207L) + } + + @Test + fun findInventoryBucket_from_lot_line_uses_lot_stock_uom_not_stock_unit_fallback() { + val stockUomConv = UomConversion().apply { id = 2149L } + val itemUom = ItemUom().apply { + id = 55L + uom = stockUomConv + } + val lotLine = InventoryLotLine().apply { stockUom = itemUom } + val bucket = Inventory().apply { id = 22L } + `when`(inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(7L, 2149L)) + .thenReturn(bucket) + + assertSame(bucket, resolver.findInventoryBucket(7L, lotLine)) + verify(inventoryRepository, times(1)) + .findByItemIdAndStockUomIdAndDeletedIsFalse(7L, 2149L) + } + + @Test + fun findInventoryBucket_without_lot_stock_uom_falls_back_to_item_stock_unit() { + val stockUnitUom = UomConversion().apply { id = 100L } + val stockUnit = ItemUom().apply { uom = stockUnitUom } + val bucket = Inventory().apply { id = 3L } + `when`(itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(5L)) + .thenReturn(stockUnit) + `when`(inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(5L, 100L)) + .thenReturn(bucket) + + assertSame(bucket, resolver.findInventoryBucket(5L, null as InventoryLotLine?)) + } + + @Test + fun findInventoryBucket_returns_null_when_stock_uom_unresolvable() { + `when`(itemUomRepository.findByIdAndDeletedIsFalse(1L)).thenReturn(null) + assertNull(resolver.findInventoryBucketByStockItemUomId(9L, 1L)) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuardTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuardTest.kt new file mode 100644 index 00000000..816472de --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuardTest.kt @@ -0,0 +1,77 @@ +package com.ffii.fpsms.modules.stock.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.web.server.ResponseStatusException +import java.math.BigDecimal + +class InventoryLotLineQtyGuardTest { + + @Test + fun physical_remain_is_in_minus_out() { + assertEquals( + BigDecimal("7"), + InventoryLotLineQtyGuard.physicalRemain(BigDecimal("10"), BigDecimal("3")), + ) + } + + @Test + fun available_subtracts_hold() { + assertEquals( + BigDecimal("4"), + InventoryLotLineQtyGuard.availableQty(BigDecimal("10"), BigDecimal("3"), BigDecimal("3")), + ) + } + + @Test + fun issue_types_recognized() { + assertTrue(InventoryLotLineQtyGuard.isIssueStockOutType("Bad")) + assertTrue(InventoryLotLineQtyGuard.isIssueStockOutType("MISS")) + assertTrue(InventoryLotLineQtyGuard.isIssueStockOutType("expiry")) + assertFalse(InventoryLotLineQtyGuard.isIssueStockOutType("pick")) + assertFalse(InventoryLotLineQtyGuard.isIssueStockOutType(null)) + } + + @Test + fun require_physical_remain_allows_pick_from_hold() { + // hold=10, avail(in-out-hold)=0, but physical remain=10 — pick/issue OK + InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain( + BigDecimal("10"), + BigDecimal.ZERO, + BigDecimal("10"), + "Bad 數量", + ) + } + + @Test + fun require_physical_remain_rejects_overshoot() { + val ex = assertThrows { + InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain( + BigDecimal("10"), + BigDecimal("8"), + BigDecimal("3"), + "Bad 數量", + ) + } + assertEquals(400, ex.statusCode.value()) + assertTrue(ex.reason!!.contains("超過實體剩餘")) + } + + @Test + fun require_available_rejects_when_held() { + val ex = assertThrows { + InventoryLotLineQtyGuard.requireQtyWithinAvailable( + BigDecimal("10"), + BigDecimal.ZERO, + BigDecimal("10"), + BigDecimal("1"), + "調整數量", + ) + } + assertEquals(400, ex.statusCode.value()) + assertTrue(ex.reason!!.contains("超過可用量")) + } +} From 90fd8b37eed8a508cf8ac7a33eb42cc16b00156a Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 29 Sep 2026 16:10:12 +0800 Subject: [PATCH 21/52] 2.4,2.5 fix --- .../ledgerfix/StockLedgerFixAdjService.kt | 9 ++-- .../StockLedgerFixBackfillService.kt | 42 +++++++++++++------ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt index 6b97b641..6cd69bdb 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -1,5 +1,6 @@ package com.ffii.fpsms.modules.master.service.ledgerfix +import com.ffii.fpsms.modules.common.SecurityUtils import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjPreview import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjResponse import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRow @@ -439,17 +440,19 @@ open class StockLedgerFixAdjService( ).orElse(null) if (existing != null) return support.toLong(existing["id"]) val at = adjDate.atTime(12, 0) + val handlerId = SecurityUtils.getUser().orElse(null)?.id + ?: throw IllegalStateException("stock-ledger-fix: login user required for stock_out.handlerId") return insertReturningId( """ INSERT INTO stock_out ( created, createdBy, version, modified, modifiedBy, deleted, - type, completeDate, status, remarks + type, completeDate, status, remarks, handlerId ) VALUES ( NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, - 'ADJ', :at, 'completed', :remarks + 'ADJ', :at, 'completed', :remarks, :handlerId ) """.trimIndent(), - mapOf("at" to at, "remarks" to remarks), + mapOf("at" to at, "remarks" to remarks, "handlerId" to handlerId), ) } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt index e6ffce3b..2dbf3f02 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -439,6 +439,8 @@ open class StockLedgerFixBackfillService( /** * 2.4 Opening = last lotQtyAfter before the window start (yesterday first, then lookback). + * Lookback: latest date before yesterday, then MAX(id) on that date. MAX(id) over all + * older rows picks a backdated insert (higher id, earlier date) and skips later receipts. * Idle days must not reset to 0. Window is :day .. :dayNext (one day or a range). */ private fun fillLotQty(args: Map): Int { @@ -529,12 +531,19 @@ open class StockLedgerFixBackfillService( INNER JOIN ( SELECT sl.inventoryLotLineId, MAX(sl.id) AS maxId FROM stock_ledger sl + INNER JOIN ( + SELECT s.inventoryLotLineId, MAX(s.date) AS maxDate + FROM stock_ledger s + INNER JOIN tmp_sl_fix_lot_miss m + ON m.inventoryLotLineId = s.inventoryLotLineId + WHERE s.deleted = 0 + AND s.date < :prevDay + AND s.lotQtyAfter IS NOT NULL + GROUP BY s.inventoryLotLineId + ) d ON d.inventoryLotLineId = sl.inventoryLotLineId + AND d.maxDate = sl.date WHERE sl.deleted = 0 - AND sl.date < :prevDay AND sl.lotQtyAfter IS NOT NULL - AND sl.inventoryLotLineId IN ( - SELECT inventoryLotLineId FROM tmp_sl_fix_lot_miss - ) GROUP BY sl.inventoryLotLineId ) x ON x.maxId = slp.id """.trimIndent(), @@ -593,6 +602,8 @@ open class StockLedgerFixBackfillService( * 2.5 Running balance per inventoryId. * Opening = last balance before the window. Fast path: day before :day. Lookback only for * inventoryIds that had no row that day (idle days must not reset to 0). + * Lookback: latest date before yesterday, then MAX(id) on that date. MAX(id) over all + * older rows picks a backdated insert and skips receipts dated after that row. * Window is :day .. :dayNext (one day or a range). */ private fun fillBalance(args: Map): Int { @@ -683,12 +694,19 @@ open class StockLedgerFixBackfillService( INNER JOIN ( SELECT sl.inventoryId, MAX(sl.id) AS maxId FROM stock_ledger sl + INNER JOIN ( + SELECT s.inventoryId, MAX(s.date) AS maxDate + FROM stock_ledger s + INNER JOIN tmp_stock_ledger_fix_miss m + ON m.inventoryId = s.inventoryId + WHERE s.deleted = 0 + AND s.date < :prevDay + AND s.balance IS NOT NULL + GROUP BY s.inventoryId + ) d ON d.inventoryId = sl.inventoryId + AND d.maxDate = sl.date WHERE sl.deleted = 0 - AND sl.date < :prevDay AND sl.balance IS NOT NULL - AND sl.inventoryId IN ( - SELECT inventoryId FROM tmp_stock_ledger_fix_miss - ) GROUP BY sl.inventoryId ) x ON x.maxId = slp.id """.trimIndent(), @@ -1131,7 +1149,7 @@ open class StockLedgerFixBackfillService( ).map { support.toLong(it["id"]) }.filter { it > 0 } } - internal fun dateRangeForFilter(filter: String, args: Map): Pair? { + open internal fun dateRangeForFilter(filter: String, args: Map): Pair? { val row = support.jdbcDao.queryForList( """ SELECT MIN(date) AS firstDate, MAX(date) AS lastDate @@ -1149,7 +1167,7 @@ open class StockLedgerFixBackfillService( return first to toExclusive } - internal fun upsertStockLotDayRange(lotIds: List, from: LocalDate, toExclusive: LocalDate): Int { + open internal fun upsertStockLotDayRange(lotIds: List, from: LocalDate, toExclusive: LocalDate): Int { if (lotIds.isEmpty()) return 0 return support.jdbcDao.executeUpdate( """ @@ -1259,7 +1277,7 @@ open class StockLedgerFixBackfillService( ) } - internal fun fillLotQtyForLotIds(lotIds: List, today: LocalDate): Int { + open internal fun fillLotQtyForLotIds(lotIds: List, today: LocalDate): Int { if (lotIds.isEmpty()) return 0 return support.withTempTx { support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_adj_lots") @@ -1313,7 +1331,7 @@ open class StockLedgerFixBackfillService( } /** Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. */ - internal fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { + open internal fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { val rows = support.jdbcDao.queryForList( """ SELECT id, inventoryId, From 12eac49634b657bf0da8a09b72c7796c56ae8476 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Tue, 29 Sep 2026 22:24:18 +0800 Subject: [PATCH 22/52] added access right LANE for car line --- .../ffii/fpsms/config/security/SecurityConfig.java | 8 ++++++++ .../pickOrder/web/TruckLaneScheduleController.kt | 14 +++++++------- .../01_add_truck_lane_authority.sql | 8 ++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index b18b8477..16550992 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -79,6 +79,7 @@ public class SecurityConfig { * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) + * Route board truck APIs → ADMIN / TRUCK_LANE / TESTING */ @Bean @Order(1) @@ -140,6 +141,13 @@ public class SecurityConfig { .hasAnyAuthority("ADMIN", "M18_SYNC") .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") .hasAnyAuthority("ADMIN", "M18_SYNC") + /* 車線看板 (/settings/shop/board):ADMIN / TRUCK_LANE / TESTING */ + .requestMatchers("/truck/**") + .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") + .requestMatchers("/truckLaneVersion/**") + .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") + .requestMatchers("/truckLaneSchedule/**") + .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt index 23f9bc7a..a1c20a8d 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt @@ -22,13 +22,13 @@ import java.time.LocalDateTime open class TruckLaneScheduleController @Autowired constructor( private val truckLaneScheduleService: TruckLaneScheduleService, ) { - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { return truckLaneScheduleService.createManual(request) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/planFromRouteExcel") @Throws(ServletRequestBindingException::class) open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity { @@ -66,19 +66,19 @@ open class TruckLaneScheduleController @Autowired constructor( return truckLaneScheduleService.getById(id) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/cancel") open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse { return truckLaneScheduleService.cancel(id) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/applyNow") open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse { return truckLaneScheduleService.applyNow(id) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/retry-failed") open fun retryFailed( @PathVariable id: Long, @@ -87,7 +87,7 @@ open class TruckLaneScheduleController @Autowired constructor( return truckLaneScheduleService.retryFailed(id, request?.executeAt) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/reactivate") open fun reactivateCancelled( @PathVariable id: Long, @@ -124,7 +124,7 @@ open class TruckLaneScheduleController @Autowired constructor( } } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/importExcel") @Throws(ServletRequestBindingException::class) open fun importExcel( diff --git a/src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql b/src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql new file mode 100644 index 00000000..b01af9e0 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset fpsms:add_truck_lane_authority +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'TRUCK_LANE' +--comment: Route board (/settings/shop/board): ADMIN or TRUCK_LANE (車線) +INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) +VALUES ('TRUCK_LANE', '車線', 'SHOP', 'Allow maintaining shop route board / truck lanes'); From 5f65764892a16a7d258d7d9c6bf7fab5eebdf8bf Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 1 Oct 2026 00:33:46 +0800 Subject: [PATCH 23/52] remove the preview of 2.7 and correct 2,7 to fix data correct --- .../ledgerfix/StockLedgerFixAdjService.kt | 451 +++++++++--------- .../StockLedgerFixBackfillService.kt | 10 +- .../01_ledger_inv_date_index.sql | 19 + 3 files changed, 261 insertions(+), 219 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260930_ledger_inv_date_index/01_ledger_inv_date_index.sql diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt index 6cd69bdb..f30b6606 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -20,48 +20,40 @@ open class StockLedgerFixAdjService( private val log = LoggerFactory.getLogger(StockLedgerFixAdjService::class.java) /** - * Line in/out vs Σ ledger in/out. Any non-zero gap becomes ADJ on [adjDate] - * (default yesterday; pass today on freeze-night dump). - * Step 1: over-issue (`lineIn < lineOut`) sets lot inQty = outQty (trigger updates inventory) - * and writes SIL + ledger ADJ in. Step 2: miss ADJ (no further lot qty write). - * Positive miss → ADJ in/out; negative miss → reverse ADJ out/in. + * One ADJ per inventory bucket on [adjDate] (default yesterday; pass today on freeze-night dump). + * Quantity is onHandQty minus the bucket ledger net, including rows with no lot. + * The row is attached to the lot line whose own net is furthest from its ledger. + * Lot lines and inventory are not updated, so the lot-line trigger does not run. + * A bucket whose ledger net already equals onHandQty is skipped. A second run inserts nothing. * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). - * Ledger gap sum uses `date < adjDate+1`. + * Ledger net uses `date < adjDate+1`. */ open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview { val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() - val gaps = loadAdjGaps(asOfExclusive) - val work = gaps.filter { it.hasWork() } - val over = work.filter { it.hasOverIssue() } - val miss = work.filter { it.hasMiss() } - val sumIn = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } - val sumOut = miss.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } - val sumOver = over.fold(BigDecimal.ZERO) { a, g -> a + g.overIssue } + val plans = planBucketAdjs(asOfExclusive, resolveHost = false) + val sumIn = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } + val sumOut = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } val limit = rowLimit.coerceIn(0, 50) log.info( - "stock-ledger-fix adj preview lots={} over={} in={} out={} revIn={} revOut={} {}ms", - work.size, - over.size, - miss.count { it.missIn > BigDecimal.ZERO }, - miss.count { it.missOut > BigDecimal.ZERO }, - miss.count { it.missIn < BigDecimal.ZERO }, - miss.count { it.missOut < BigDecimal.ZERO }, + "stock-ledger-fix adj preview buckets={} in={} out={} {}ms", + plans.size, + plans.count { it.missIn > BigDecimal.ZERO }, + plans.count { it.missOut > BigDecimal.ZERO }, (System.nanoTime() - t0) / 1_000_000, ) - val rows = (over + miss.filter { !it.hasOverIssue() }).distinctBy { it.lotLineId }.take(limit) return StockLedgerFixAdjPreview( adjDate = adjDate.toString(), - lotCount = work.size, - adjInCount = miss.count { it.missIn > BigDecimal.ZERO }, - adjOutCount = miss.count { it.missOut > BigDecimal.ZERO }, - skippedNegCount = miss.count { it.missIn < BigDecimal.ZERO || it.missOut < BigDecimal.ZERO }, + lotCount = plans.size, + adjInCount = plans.count { it.missIn > BigDecimal.ZERO }, + adjOutCount = plans.count { it.missOut > BigDecimal.ZERO }, + skippedNegCount = 0, sumMissIn = sumIn.stripTrailingZeros().toPlainString(), sumMissOut = sumOut.stripTrailingZeros().toPlainString(), skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(), - overIssueCount = over.size, - sumOverIssue = sumOver.stripTrailingZeros().toPlainString(), - rows = rows.map { it.toRow() }, + overIssueCount = 0, + sumOverIssue = "0", + rows = plans.take(limit).map { it.toRow() }, ) } @@ -72,14 +64,11 @@ open class StockLedgerFixAdjService( val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() try { - val gaps = loadAdjGaps(asOfExclusive) - val work = gaps.filter { it.hasWork() } - val over = work.filter { it.hasOverIssue() } - val miss = work.filter { it.hasMiss() } + val plans = planBucketAdjs(asOfExclusive, resolveHost = true) var insertedIn = 0 var insertedOut = 0 - var overIssuePatched = 0 - if (work.isNotEmpty()) { + val overIssuePatched = 0 + if (plans.isNotEmpty()) { val insTx = TransactionTemplate(support.transactionManager) insTx.timeout = 300 insTx.execute { @@ -89,34 +78,20 @@ open class StockLedgerFixAdjService( stockInId ?: ensureAdjStockInHeader(adjDate).also { stockInId = it } fun outHeader(): Long = stockOutId ?: ensureAdjStockOutHeader(adjDate).also { stockOutId = it } - over.forEach { g -> - if (patchLotRemainToZero(g.lotLineId)) { - insertAdjLedger( - g, adjDate, inQty = g.overIssue, outQty = null, - remarks = OVER_ISSUE_REMARKS, stockInId = inHeader(), - ) - insertedIn++ - overIssuePatched++ - } - } - miss.forEach { g -> - if (g.missIn.compareTo(BigDecimal.ZERO) > 0) { - insertAdjLedger(g, adjDate, inQty = g.missIn, outQty = null, stockInId = inHeader()) - insertedIn++ - } else if (g.missIn.compareTo(BigDecimal.ZERO) < 0) { - insertAdjLedger( - g, adjDate, inQty = null, outQty = g.missIn.negate(), stockOutId = outHeader(), - ) - insertedOut++ - } - if (g.missOut.compareTo(BigDecimal.ZERO) > 0) { - insertAdjLedger(g, adjDate, inQty = null, outQty = g.missOut, stockOutId = outHeader()) - insertedOut++ - } else if (g.missOut.compareTo(BigDecimal.ZERO) < 0) { - insertAdjLedger( - g, adjDate, inQty = g.missOut.negate(), outQty = null, stockInId = inHeader(), - ) - insertedIn++ + plans.forEach { g -> + when { + g.missIn > BigDecimal.ZERO -> { + insertAdjLedger( + g, adjDate, inQty = g.missIn, outQty = null, stockInId = inHeader(), + ) + insertedIn++ + } + g.missOut > BigDecimal.ZERO -> { + insertAdjLedger( + g, adjDate, inQty = null, outQty = g.missOut, stockOutId = outHeader(), + ) + insertedOut++ + } } } } @@ -125,7 +100,7 @@ open class StockLedgerFixAdjService( fun addLot(lotId: Long, invId: Long?) { if (lotId > 0) lotIds.add(lotId) } - work.forEach { addLot(it.lotLineId, it.inventoryId) } + plans.forEach { addLot(it.lotLineId, it.inventoryId) } support.jdbcDao.queryForList( """ SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId @@ -147,8 +122,9 @@ open class StockLedgerFixAdjService( support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } } else 0 log.info( - "stock-ledger-fix adj {} over={} in={} out={} lotQty={} bal={} day={} total={}ms", - adjDate, overIssuePatched, insertedIn, insertedOut, filledLotQty, filledBalance, dayRows, + "stock-ledger-fix adj {} buckets={} in={} out={} lotQty={} bal={} day={} total={}ms", + adjDate, plans.size, insertedIn, insertedOut, + filledLotQty, filledBalance, dayRows, (System.nanoTime() - t0) / 1_000_000, ) return StockLedgerFixAdjResponse( @@ -185,141 +161,206 @@ open class StockLedgerFixAdjService( return adjDate to adjDate.plusDays(1) } - private fun loadAdjGaps(today: LocalDate): List { - val tx = TransactionTemplate(support.transactionManager) - tx.timeout = 300 - val args = mapOf("today" to today) - return tx.execute { - support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") - support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") - support.jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_adj_line ( - lotLineId BIGINT NOT NULL PRIMARY KEY, - inventoryId INT NULL, - itemId INT NULL, - itemCode VARCHAR(64) NULL, - uomId INT NULL, - lineIn DECIMAL(14,2) NOT NULL, - lineOut DECIMAL(14,2) NOT NULL + /** + * One ADJ per bucket whose latest balance differs from onHandQty. + * After 2.5 that balance equals the bucket ledger net, so the ADJ qty is onHandQty − balance. + * Preview does not scan lot history. Apply resolves which lot line the ADJ is attached to. + */ + private fun planBucketAdjs(today: LocalDate, resolveHost: Boolean): List { + val buckets = loadMismatchedByLastBalance(today) + if (buckets.isEmpty()) return emptyList() + if (!resolveHost) { + val lines = loadLotLines(buckets.mapNotNull { it.lotLineId }.distinct()) + return buckets.map { bucket -> previewGap(bucket, lines[bucket.lotLineId]) } + } + val lotsByInv = loadLotsOnBuckets(today, buckets.map { it.inventoryId }) + return buckets.mapNotNull { bucket -> + val host = chooseAdjHost(lotsByInv[bucket.inventoryId].orEmpty(), bucket.delta) + if (host == null) { + log.warn( + "stock-ledger-fix adj skip inventoryId={} delta={}: no lot line to attach", + bucket.inventoryId, bucket.delta, ) - """.trimIndent(), + return@mapNotNull null + } + val needIn = bucket.delta > BigDecimal.ZERO + AdjGap( + lotLineId = host.lotLineId, + inventoryId = bucket.inventoryId, + itemId = host.itemId, + itemCode = host.itemCode, + uomId = host.uomId, + lineIn = host.lineIn, + lineOut = host.lineOut, + ledgerIn = host.ledgerIn, + ledgerOut = host.ledgerOut, + missIn = if (needIn) bucket.delta else BigDecimal.ZERO, + missOut = if (needIn) BigDecimal.ZERO else bucket.delta.negate(), ) - support.jdbcDao.executeUpdate( - """ - CREATE TEMPORARY TABLE tmp_adj_led ( - inventoryLotLineId BIGINT NOT NULL PRIMARY KEY, - ledger_in DECIMAL(14,2) NOT NULL, - ledger_out DECIMAL(14,2) NOT NULL - ) - """.trimIndent(), + } + } + + private data class BucketDelta( + val inventoryId: Long, + val delta: BigDecimal, + val lotLineId: Long?, + val itemId: Long?, + val itemCode: String?, + ) + + /** Preview row. Ledger in/out stay 0; the bucket delta is missIn / missOut. */ + private fun previewGap(bucket: BucketDelta, line: Map?) = AdjGap( + lotLineId = bucket.lotLineId ?: 0L, + inventoryId = bucket.inventoryId, + itemId = support.toLongOrNull(line?.get("itemId") ?: bucket.itemId), + itemCode = (line?.get("itemCode") ?: bucket.itemCode)?.toString(), + uomId = support.toLongOrNull(line?.get("uomId")), + lineIn = support.toDecimal(line?.get("lineIn")), + lineOut = support.toDecimal(line?.get("lineOut")), + ledgerIn = BigDecimal.ZERO, + ledgerOut = BigDecimal.ZERO, + missIn = if (bucket.delta > BigDecimal.ZERO) bucket.delta else BigDecimal.ZERO, + missOut = if (bucket.delta > BigDecimal.ZERO) BigDecimal.ZERO else bucket.delta.negate(), + ) + + private data class LotOnBucket( + val lotLineId: Long, + val itemId: Long?, + val itemCode: String?, + val uomId: Long?, + val lineIn: BigDecimal, + val lineOut: BigDecimal, + val ledgerIn: BigDecimal, + val ledgerOut: BigDecimal, + ) { + val lotGap: BigDecimal + get() = lineIn.subtract(lineOut).subtract(ledgerIn.subtract(ledgerOut)) + } + + /** + * Latest balance per bucket (date, then id). After 2.5 this equals the ledger net, + * so onHand − balance is the ADJ qty. One index seek per inventory, not a full sum. + */ + private fun loadMismatchedByLastBalance(today: LocalDate): List { + return support.jdbcDao.queryForList( + """ + SELECT i.id AS inventoryId, + i.itemId AS itemId, + it.code AS itemCode, + CAST(COALESCE(i.onHandQty, 0) AS DECIMAL(14,2)) AS onHand, + x.balance AS balance, + x.lotLineId AS lotLineId + FROM inventory i + LEFT JOIN items it ON it.id = i.itemId + INNER JOIN LATERAL ( + SELECT s.balance AS balance, + s.inventoryLotLineId AS lotLineId + FROM stock_ledger s FORCE INDEX (idx_ledger_inv_last) + WHERE s.inventoryId = i.id + AND s.deleted = 0 + AND s.date < :today + AND s.balance IS NOT NULL + ORDER BY s.date DESC, s.id DESC + LIMIT 1 + ) x ON TRUE + WHERE IFNULL(i.deleted, 0) = 0 + AND ABS(CAST(x.balance AS DECIMAL(14,2)) + - CAST(COALESCE(i.onHandQty, 0) AS DECIMAL(14,2))) > 0.01 + """.trimIndent(), + mapOf("today" to today), + ).mapNotNull { row -> + val delta = support.toDecimal(row["onHand"]).subtract(support.toDecimal(row["balance"])) + if (delta.abs().compareTo(ADJ_TOLERANCE) <= 0) null + else BucketDelta( + inventoryId = support.toLong(row["inventoryId"]), + delta = delta, + lotLineId = support.toLongOrNull(row["lotLineId"]), + itemId = support.toLongOrNull(row["itemId"]), + itemCode = row["itemCode"]?.toString(), ) - try { - support.timed("adj line-all") { - support.jdbcDao.executeUpdate( - """ - INSERT INTO tmp_adj_line ( - lotLineId, inventoryId, itemId, itemCode, uomId, lineIn, lineOut - ) - SELECT - ill.id, - inv.inventoryId, - il.itemId, - it.code, - iu.uomId, - CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)), - CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) - FROM inventory_lot_line ill - INNER JOIN inventory_lot il - ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 - LEFT JOIN items it ON it.id = il.itemId - LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId - LEFT JOIN ( - SELECT itemId, COALESCE(stockUomId, uomId) AS bucketUomId, MIN(id) AS inventoryId - FROM inventory - WHERE IFNULL(deleted, 0) = 0 - GROUP BY itemId, COALESCE(stockUomId, uomId) - ) inv ON inv.itemId = il.itemId AND inv.bucketUomId = iu.uomId - WHERE IFNULL(ill.deleted, 0) = 0 - AND ( - COALESCE(ill.inQty, 0) <> 0 - OR COALESCE(ill.outQty, 0) <> 0 - ) - """.trimIndent(), - ) - } - val lineIds = support.jdbcDao.queryForList( - "SELECT lotLineId FROM tmp_adj_line", - ).map { support.toLong(it["lotLineId"]) }.filter { it > 0 } - log.info("stock-ledger-fix adj line rows={}", lineIds.size) - if (lineIds.isEmpty()) return@execute emptyList() - support.timed("adj ledger-by-line") { - lineIds.chunked(40).sumOf { chunk -> - support.jdbcDao.executeUpdate( - """ - INSERT INTO tmp_adj_led (inventoryLotLineId, ledger_in, ledger_out) - SELECT - sl.inventoryLotLineId, - CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)), - CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) - FROM stock_ledger sl FORCE INDEX (idx_ledger_lot_date_id) - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryLotLineId IN (:ids) - GROUP BY sl.inventoryLotLineId - """.trimIndent(), - mapOf("today" to today, "ids" to chunk), - ) - } - } + } + } + + /** Lot nets only for buckets that need an ADJ, so the host lot can be chosen. */ + private fun loadLotsOnBuckets(today: LocalDate, inventoryIds: List): Map> { + val ledgerRows = mutableListOf>() + inventoryIds.chunked(50).forEach { chunk -> + ledgerRows.addAll( support.jdbcDao.queryForList( """ - SELECT - l.lotLineId, - l.inventoryId, - l.itemId, - l.itemCode, - l.uomId, - l.lineIn, - l.lineOut, - CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) AS ledgerIn, - CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) AS ledgerOut - FROM tmp_adj_line l - LEFT JOIN tmp_adj_led agg ON agg.inventoryLotLineId = l.lotLineId - WHERE l.lineIn <> CAST(COALESCE(agg.ledger_in, 0) AS DECIMAL(14,2)) - OR l.lineOut <> CAST(COALESCE(agg.ledger_out, 0) AS DECIMAL(14,2)) - OR l.lineIn < l.lineOut + SELECT sl.inventoryId, + sl.inventoryLotLineId AS lotLineId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS ledgerIn, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS ledgerOut, + MIN(sl.itemId) AS itemId, + MIN(sl.itemCode) AS itemCode, + MIN(sl.uomId) AS uomId + FROM stock_ledger sl FORCE INDEX (idx_ledger_inv_date) + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryId IN (:ids) + AND sl.inventoryLotLineId IS NOT NULL + GROUP BY sl.inventoryId, sl.inventoryLotLineId """.trimIndent(), - ).map { row -> - val lineIn = support.toDecimal(row["lineIn"]) - val lineOut = support.toDecimal(row["lineOut"]) - val ledgerIn = support.toDecimal(row["ledgerIn"]) - val ledgerOut = support.toDecimal(row["ledgerOut"]) - AdjGap( - lotLineId = support.toLong(row["lotLineId"]), - inventoryId = support.toLongOrNull(row["inventoryId"]), - itemId = support.toLongOrNull(row["itemId"]), - itemCode = row["itemCode"]?.toString(), - uomId = support.toLongOrNull(row["uomId"]), - lineIn = lineIn, - lineOut = lineOut, - ledgerIn = ledgerIn, - ledgerOut = ledgerOut, - missIn = lineIn.subtract(ledgerIn), - missOut = lineOut.subtract(ledgerOut), - ) - } - } finally { - try { - support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_led") - support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_adj_line") - } catch (e: Exception) { - log.warn("stock-ledger-fix drop tmp_adj skipped: {}", e.message) - } - } - } ?: emptyList() + mapOf("today" to today, "ids" to chunk), + ), + ) + } + if (ledgerRows.isEmpty()) return emptyMap() + val lines = loadLotLines(ledgerRows.map { support.toLong(it["lotLineId"]) }.distinct()) + return ledgerRows.map { row -> + val lotLineId = support.toLong(row["lotLineId"]) + val line = lines[lotLineId] + LotOnBucket( + lotLineId = lotLineId, + itemId = support.toLongOrNull(line?.get("itemId") ?: row["itemId"]), + itemCode = (line?.get("itemCode") ?: row["itemCode"])?.toString(), + uomId = support.toLongOrNull(row["uomId"] ?: line?.get("uomId")), + lineIn = support.toDecimal(line?.get("lineIn")), + lineOut = support.toDecimal(line?.get("lineOut")), + ledgerIn = support.toDecimal(row["ledgerIn"]), + ledgerOut = support.toDecimal(row["ledgerOut"]), + ) to support.toLong(row["inventoryId"]) + }.groupBy({ it.second }, { it.first }) + } + + private fun loadLotLines(lotIds: List): Map> { + if (lotIds.isEmpty()) return emptyMap() + val lines = HashMap>() + lotIds.chunked(200).forEach { chunk -> + support.jdbcDao.queryForList( + """ + SELECT ill.id AS lotLineId, + CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS lineIn, + CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) AS lineOut, + il.itemId AS itemId, + it.code AS itemCode, + iu.uomId AS uomId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN items it ON it.id = il.itemId + LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId + WHERE ill.id IN (:ids) + """.trimIndent(), + mapOf("ids" to chunk), + ).forEach { lines[support.toLong(it["lotLineId"])] = it } + } + return lines + } + + /** Prefer the lot whose own gap has the same sign as the bucket delta, largest first. */ + private fun chooseAdjHost(lots: List, delta: BigDecimal): LotOnBucket? { + if (lots.isEmpty()) return null + val sameSign = lots.filter { + it.lotGap.abs().compareTo(ADJ_TOLERANCE) > 0 && it.lotGap.signum() == delta.signum() + } + if (sameSign.isNotEmpty()) return sameSign.maxBy { it.lotGap.abs() } + val anyGap = lots.filter { it.lotGap.abs().compareTo(ADJ_TOLERANCE) > 0 } + if (anyGap.isNotEmpty()) return anyGap.maxBy { it.lotGap.abs() } + return lots.maxBy { it.ledgerIn.add(it.ledgerOut) } } + private fun insertAdjLedger( g: AdjGap, adjDate: LocalDate, @@ -369,28 +410,6 @@ open class StockLedgerFixAdjService( ) } - /** Sets lot inQty = outQty so remain = 0; trigger recomputes inventory. */ - private fun patchLotRemainToZero(lotLineId: Long): Boolean { - val n = support.jdbcDao.executeUpdate( - """ - UPDATE inventory_lot_line - SET inQty = CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)), - status = 'unavailable', - modified = NOW(), - modifiedBy = 'stock-ledger-fix', - version = IFNULL(version, 0) + 1 - WHERE id = :lotLineId - AND IFNULL(deleted, 0) = 0 - AND CAST(COALESCE(inQty, 0) AS DECIMAL(14,2)) - < CAST(COALESCE(outQty, 0) AS DECIMAL(14,2)) - """.trimIndent(), - mapOf("lotLineId" to lotLineId), - ) - if (n == 0) { - log.warn("stock-ledger-fix over-issue skip lot update: lotLineId={}", lotLineId) - } - return n > 0 - } private fun adjStockInCode(adjDate: LocalDate): String = "SLF-I-$adjDate" @@ -609,8 +628,8 @@ open class StockLedgerFixAdjService( } companion object { - private const val ADJ_DOC_REMARKS = "stock-ledger-fix miss ADJ (no lot qty change)" - private const val OVER_ISSUE_REMARKS = "stock-ledger-fix over-issue remain→0 (lot inQty patched)" + private val ADJ_TOLERANCE = BigDecimal("0.01") + private const val ADJ_DOC_REMARKS = "stock-ledger-fix bucket ADJ (lot line unchanged)" private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ" } } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt index 2dbf3f02..38588957 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -1301,7 +1301,7 @@ open class StockLedgerFixBackfillService( - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) ) OVER ( PARTITION BY sl2.inventoryLotLineId - ORDER BY sl2.id + ORDER BY sl2.date, sl2.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS DECIMAL(14,2)) AS lotAfter, @@ -1330,7 +1330,10 @@ open class StockLedgerFixBackfillService( } } - /** Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. */ + /** + * Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. + * Opening balance is the latest date, then the highest id on that date (same rule as 2.5 lookback). + */ open internal fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { val rows = support.jdbcDao.queryForList( """ @@ -1363,7 +1366,8 @@ open class StockLedgerFixBackfillService( AND inventoryId = :invId AND date < :today AND id < :adjId - ORDER BY id DESC + AND balance IS NOT NULL + ORDER BY date DESC, id DESC LIMIT 1 """.trimIndent(), mapOf("invId" to invId, "today" to today, "adjId" to id), diff --git a/src/main/resources/db/changelog/changes/20260930_ledger_inv_date_index/01_ledger_inv_date_index.sql b/src/main/resources/db/changelog/changes/20260930_ledger_inv_date_index/01_ledger_inv_date_index.sql new file mode 100644 index 00000000..239655a8 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260930_ledger_inv_date_index/01_ledger_inv_date_index.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +--changeset fpsms:ledger_inv_date_covering_index +--comment: Cover 2.7 bucket net by inventoryId + date without reading the full stock_ledger row +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'stock_ledger' AND INDEX_NAME = 'idx_ledger_inv_date' + +ALTER TABLE `stock_ledger` + ADD INDEX `idx_ledger_inv_date` (`inventoryId`, `date`, `inventoryLotLineId`, `inQty`, `outQty`, `deleted`), + ALGORITHM=INPLACE, LOCK=NONE; + +--changeset fpsms:ledger_inv_last_balance_index +--comment: Last ledger balance per inventory for 2.7 preview (date desc, id desc, limit 1) +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'stock_ledger' AND INDEX_NAME = 'idx_ledger_inv_last' + +ALTER TABLE `stock_ledger` + ADD INDEX `idx_ledger_inv_last` (`inventoryId`, `deleted`, `date`, `id`, `balance`), + ALGORITHM=INPLACE, LOCK=NONE; From 2c6648ba14abfb7b3e61ca9fd10d8aa914d8b957 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Thu, 1 Oct 2026 20:15:40 +0800 Subject: [PATCH 24/52] fixing the around 16:30 logout problem; Handle user put DO (m18 PO) sync in m18 sync page PO tab; make the fg/semi fg report count by date rather than by month, created a button to make qc accept faster, added item code/name in search criteria of PO, make 2 new version of PO Item label printout (can set 1 to print the old version in setting) --- .../java/com/ffii/core/utils/QrCodeUtil.kt | 17 +- .../m18/service/M18DeliveryOrderService.kt | 68 +++-- .../m18/service/M18PurchaseOrderService.kt | 54 ++-- .../fpsms/modules/common/SettingNames.java | 3 + .../service/PurchaseOrderService.kt | 15 +- .../web/PurchaseOrderController.kt | 2 + .../SemiFGProductionAnalysisReportService.kt | 117 ++++++++ ...emiFGProductionAnalysisReportController.kt | 249 ++++++++++++++++++ .../stock/service/InventoryLotLineService.kt | 15 +- .../stock/service/PoItemLabelTemplate.kt | 63 +++++ .../stock/service/StockInLineService.kt | 15 +- src/main/resources/application-prod.yml | 5 +- src/main/resources/application.yml | 3 +- .../01_setting.sql | 9 + .../02_allow_version_3.sql | 7 + .../resources/qrCodeLabel/poItemPDF_v2.jrxml | 172 ++++++++++++ .../resources/qrCodeLabel/poItemPDF_v3.jrxml | 172 ++++++++++++ 17 files changed, 932 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt create mode 100644 src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql create mode 100644 src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml create mode 100644 src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml diff --git a/src/main/java/com/ffii/core/utils/QrCodeUtil.kt b/src/main/java/com/ffii/core/utils/QrCodeUtil.kt index 11f05544..e7b052f5 100644 --- a/src/main/java/com/ffii/core/utils/QrCodeUtil.kt +++ b/src/main/java/com/ffii/core/utils/QrCodeUtil.kt @@ -1,18 +1,21 @@ package com.ffii.core.utils import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType import java.awt.image.BufferedImage -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.client.j2se.MatrixToImageWriter +import com.google.zxing.qrcode.QRCodeWriter open class QrCodeUtil { companion object { fun generateQRCodeImage(barcodeText: String?): BufferedImage { - val barcodeWriter = QRCodeWriter() - val bitMatrix: BitMatrix = barcodeWriter.encode(barcodeText, BarcodeFormat.QR_CODE, 250, 250) - val img: BufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix) - return img + return generateQRCodeImage(barcodeText, 250, 4) + } + + fun generateQRCodeImage(barcodeText: String?, size: Int, margin: Int): BufferedImage { + val hints = hashMapOf(EncodeHintType.MARGIN to margin) + val bitMatrix = QRCodeWriter().encode(barcodeText, BarcodeFormat.QR_CODE, size, size, hints) + return MatrixToImageWriter.toBufferedImage(bitMatrix) } } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 56f6539e..6bd1d094 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -89,14 +89,8 @@ open class M18DeliveryOrderService( "" } - // Shop PO - val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") - val shopPoSupplier = commonUtils.listToString( - shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), - "venId=equal=", - "=or=" - ) - var shopPoConds = "(${shopPoBuyers})=and=(${shopPoSupplier})" + // Shop PO — same BE/supplier scope as [buildShopPoScopeConds] + var shopPoConds = buildShopPoScopeConds() if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { shopPoConds += "=and=(${lastDateConds})" @@ -160,9 +154,24 @@ open class M18DeliveryOrderService( ) } + /** + * Shop PO (MTMS DO) BE/supplier scope used by scheduler and by-code sync: + * beId = TOA and venId in [M18Config.SHOP_PO_SUPPLIER] (e.g. P06/P07). + */ + private fun buildShopPoScopeConds(): String { + val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") + val shopPoSupplier = commonUtils.listToString( + shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), + "venId=equal=", + "=or=" + ) + return "(${shopPoBuyers})=and=(${shopPoSupplier})" + } + /** * Sync a single M18 shop PO / delivery order by document [code], same search pattern as - * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. + * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode], including + * the scheduler shop-PO BE/supplier filters (rejects material POs). * * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). * No M18-side "加單" filtering is used. @@ -173,15 +182,16 @@ open class M18DeliveryOrderService( isExtraSync: Boolean = false, newOnly: Boolean = false, ): SyncResult { - if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { + val trimmed = code.trim() + if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(trimmed)) { return SyncResult( totalProcessed = 1, totalSuccess = 0, totalFail = 0, - query = "skipped (newOnly=true): delivery_order.code already exists: $code", + query = "skipped (newOnly=true): delivery_order.code already exists: $trimmed", ) } - val conds = "(code=equal=$code)" + val conds = "(code=equal=$trimmed)=and=${buildShopPoScopeConds()}" val searchRequest = M18PurchaseOrderListRequest( stSearch = "po", params = null, @@ -228,6 +238,7 @@ open class M18DeliveryOrderService( val successList = mutableListOf() val skippedList = mutableListOf() + val skippedNotPendingList = mutableListOf() val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() @@ -291,6 +302,23 @@ open class M18DeliveryOrderService( // logger.info("${doRefType}: Finding For Latest M18 Data Log...") val latestDeliveryOrderLog = m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) + val existingDeliveryOrderForSync = + latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } + if (existingDeliveryOrderForSync != null && + existingDeliveryOrderForSync.deleted != true && + existingDeliveryOrderForSync.status != DeliveryOrderStatus.PENDING + ) { + val statusValue = existingDeliveryOrderForSync.status?.value ?: "unknown" + logger.info( + "${doRefType}: Skipping M18 sync — local DO id=${existingDeliveryOrderForSync.id} " + + "code=${existingDeliveryOrderForSync.code} status=$statusValue " + + "(only pending may be overwritten; receiving=已放單). M18 ID: ${deliveryOrder.id}" + ) + skippedNotPendingList.add( + "code=${existingDeliveryOrderForSync.code} status=$statusValue" + ) + return@forEach + } //logger.info(latestDeliveryOrderLog.toString()) // Save to m18_data_log table // logger.info("${doRefType}: Saving for M18 Data Log...") @@ -312,10 +340,9 @@ open class M18DeliveryOrderService( // logger.info("${doRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLog.id}") try { - // Find the delivery_order if exist + // Find the delivery_order if exist (re-use lookup from pending guard above) // logger.info("${doRefType}: Finding exising delivery order...") - val existingDeliveryOrder = - latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } + val existingDeliveryOrder = existingDeliveryOrderForSync // logger.info("${doRefType}: Exising delivery order ID: ${existingDeliveryOrder?.id}") // Save to delivery_order table @@ -621,12 +648,17 @@ open class M18DeliveryOrderService( logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") - val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" + val skippedExistingSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" + val skippedNotPendingSuffix = if (skippedNotPendingList.isNotEmpty()) { + " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" + } else { + "" + } return SyncResult( - totalProcessed = successList.size + failList.size + skippedList.size, + totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size, totalSuccess = successList.size, totalFail = failList.size, - query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, + query = (deliveryOrdersWithType?.query ?: "") + skippedExistingSuffix + skippedNotPendingSuffix, ) } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index dd488c96..8dffa5ab 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -97,15 +97,8 @@ open class M18PurchaseOrderService( "" } - // Material PO - val materialPoBuyers = - commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") - val materialPoSupplierNot = commonUtils.listToString( - shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), - "venId=unequal=", - "=or=" - ) - var materialPoConds = "(${materialPoBuyers})=and=(${materialPoSupplierNot}))" + // Material PO — same BE/supplier scope as [buildMaterialPoScopeConds] + var materialPoConds = buildMaterialPoScopeConds() if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { materialPoConds += "=and=(${lastDateConds})" @@ -233,11 +226,28 @@ open class M18PurchaseOrderService( return if (beId?.toString() == m18Config.BEID_TOA) PurchaseOrderType.SHOP else PurchaseOrderType.MATERIAL } + /** + * Material PO BE/supplier scope used by scheduler and by-code sync: + * beId in (PP, PF) and venId not in [M18Config.MATERIAL_PO_SUPPLIER_NOT] (e.g. P06/P07 shop suppliers). + */ + private fun buildMaterialPoScopeConds(): String { + val materialPoBuyers = + commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") + val materialPoSupplierNot = commonUtils.listToString( + shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), + "venId=unequal=", + "=or=" + ) + return "(${materialPoBuyers})=and=(${materialPoSupplierNot})" + } + open fun savePurchaseOrderByCode(code: String): SyncResult { - // Follow the scheduler's original approach: - // 1) use /search/search to find the PO id by code - // 2) then sync by PO id (this reuses the same create/update logic) - val conds = "(code=equal=$code)" + // Same approach as scheduler list sync, restricted to one document code: + // 1) /search/search with material-PO BE/supplier scope + code + // 2) sync by PO id (reuses create/update logic) + // Rejects shop POs (TOA / P06|P07) that belong to DO sync instead. + val trimmed = code.trim() + val conds = "(code=equal=$trimmed)=and=${buildMaterialPoScopeConds()}" return saveFromPoListSearch(conds, searchPurchaseOrdersByConds(conds)) } @@ -357,6 +367,7 @@ open class M18PurchaseOrderService( val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() + val skippedNotPendingList = mutableListOf() val affectedItemIds = mutableSetOf() val uomByM18IdCache = mutableMapOf() @@ -398,11 +409,15 @@ open class M18PurchaseOrderService( if (existingPurchaseOrderForSync != null && existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING ) { + val statusValue = existingPurchaseOrderForSync.status?.value ?: "unknown" logger.info( "${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " + - "code=${existingPurchaseOrderForSync.code} status=${existingPurchaseOrderForSync.status?.value} " + + "code=${existingPurchaseOrderForSync.code} status=$statusValue " + "(only pending may be overwritten). M18 ID: $m18PurchaseOrderId" ) + skippedNotPendingList.add( + "code=${existingPurchaseOrderForSync.code} status=$statusValue" + ) return@forEach } @@ -729,11 +744,18 @@ open class M18PurchaseOrderService( logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") + val baseQuery = purchaseOrdersWithType?.query ?: "" + val skippedSuffix = if (skippedNotPendingList.isNotEmpty()) { + " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" + } else { + "" + } + return SyncResult( - totalProcessed = successList.size + failList.size, + totalProcessed = successList.size + failList.size + skippedNotPendingList.size, totalSuccess = successList.size, totalFail = failList.size, - query = purchaseOrdersWithType?.query ?: "" + query = baseQuery + skippedSuffix ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index f5e4bbae..7aad93fc 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -135,4 +135,7 @@ public abstract class SettingNames { /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess"; + /** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR with a larger item name. */ + public static final String LABEL_PO_ITEM_VERSION = "LABEL.poItem.version"; + } diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt index 80a3e833..d90a7daa 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt @@ -211,8 +211,19 @@ open fun getPoSummariesByIds(ids: List): List { sql.append(" group by po.id"); sql.append(" order by po.orderDate desc") sql.append(" ) r") - if (args.containsKey("itemDetail")){ - sql.append(" where r.itemDetail like :itemDetail "); + val outerFilters = mutableListOf() + if (args.containsKey("itemDetail")) { + outerFilters.add("r.itemDetail like :itemDetail") + } + if (args.containsKey("itemCode")) { + outerFilters.add("r.itemCode like :itemCode") + } + if (args.containsKey("itemName")) { + outerFilters.add("r.itemName like :itemName") + } + if (outerFilters.isNotEmpty()) { + sql.append(" where ") + sql.append(outerFilters.joinToString(" AND ")) } return sql.toString() } diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt index 6e475304..24671530 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt @@ -47,6 +47,8 @@ class PurchaseOrderController( val criteriaArgs = CriteriaArgsBuilder.withRequest(request) .addStringLike("supplier") .addStringLike("code") + .addStringLike("itemCode") + .addStringLike("itemName") .addString("status") .addBoolean("escalated") .addDate("orderDate") diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt index bdcd3603..3d11430d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.report.service import org.springframework.stereotype.Service import com.ffii.core.support.JdbcDao +import java.time.LocalDate @Service class SemiFGProductionAnalysisReportService( @@ -199,6 +200,122 @@ class SemiFGProductionAnalysisReportService( return jdbcDao.queryForList(sql, args) } + /** + * Same filters as [searchSemiFGProductionAnalysisReport], one row per item and production date. + * Quantity is not pivoted; the Excel builder places each date in the requested range as its own column. + */ + fun searchSemiFGProductionAnalysisDaily( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + rangeStart: LocalDate, + rangeEnd: LocalDate, + ): List> { + val args = mutableMapOf() + + val stockCategorySql = if (!itemCode.isNullOrBlank()) { + "" + } else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { + buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args) + } else { + "" + } + val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args) + val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args) + + val yearSql = if (!year.isNullOrBlank() && year != "All") { + args["year"] = year + "AND YEAR(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) = :year" + } else { + "" + } + + args["lastOutDateStart"] = rangeStart.toString() + args["lastOutDateEnd"] = rangeEnd.toString() + val lastOutDateStartSql = + "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) >= DATE(:lastOutDateStart)" + val lastOutDateEndSql = + "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) <= DATE(:lastOutDateEnd)" + + val sql = """ + WITH qr_agg AS ( + SELECT + qr.stockInLineId, + MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed + FROM qc_result qr + WHERE qr.deleted = 0 + GROUP BY qr.stockInLineId + ), + base AS ( + SELECT + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(ic.sub, '') AS stockSubCategory, + COALESCE(uc.udfudesc, '') AS unitOfMeasure, + DATE( + CASE + WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate + ELSE si.productionDate + END + ) AS prodDate, + si.id AS stockInLineId, + CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0 + ELSE COALESCE(si.acceptedQty, 0) + END AS linePutAwayQty + FROM stock_in_line si + INNER JOIN items it ON si.itemId = it.id + INNER JOIN bom b ON b.code = it.code AND b.deleted = false + LEFT JOIN inventory_lot il ON il.id = si.inventoryLotId AND il.deleted = false + LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id + LEFT JOIN item_category ic ON it.categoryId = ic.id + LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + WHERE si.deleted = false + AND si.productLotNo IS NOT NULL + AND TRIM(si.productLotNo) <> '' + AND ( + CASE + WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate + ELSE si.productionDate + END + ) IS NOT NULL + AND si.jobOrderId IS NOT NULL + AND (si.status IS NULL OR si.status <> 'Pending') + $stockCategorySql + $stockSubCategorySql + $itemCodeSql + $yearSql + $lastOutDateStartSql + $lastOutDateEndSql + ), + dedup AS ( + SELECT + itemNo, + itemName, + stockSubCategory, + unitOfMeasure, + prodDate, + stockInLineId, + MAX(linePutAwayQty) AS linePutAwayQty + FROM base + GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, prodDate, stockInLineId + ) + SELECT + d.itemNo AS itemNo, + MAX(d.itemName) AS itemName, + MAX(d.unitOfMeasure) AS unitOfMeasure, + d.prodDate AS prodDate, + CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS DECIMAL(18,2)) AS qty + FROM dedup d + GROUP BY d.itemNo, d.prodDate + HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0 + ORDER BY d.itemNo, d.prodDate + """.trimIndent() + + return jdbcDao.queryForList(sql, args) + } + /** * Gets list of item codes (bom.code) with names based on stockCategory filter. * Supports multiple categories separated by comma (e.g., "FG,WIP"). diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 4182292e..7089ebd8 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import com.ffii.fpsms.modules.report.service.SemiFGProductionAnalysisReportService import com.ffii.fpsms.modules.report.service.ReportService import org.apache.poi.ss.usermodel.BorderStyle @@ -173,6 +174,243 @@ class SemiFGProductionAnalysisReportController( return ResponseEntity(excelBytes, headers, HttpStatus.OK) } + @PostMapping("/print-semi-fg-production-analysis-excel-daily") + fun exportSemiFGProductionAnalysisReportExcelDaily( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + val start = parseReportDate(r.lastOutDateStart) + val end = parseReportDate(r.lastOutDateEnd) + if (start == null || end == null) { + return jsonError(HttpStatus.BAD_REQUEST, "請先輸入完成生產日期(由、至)") + } + if (end.isBefore(start)) { + return jsonError(HttpStatus.BAD_REQUEST, "完成生產日期「由」不可晚於「至」") + } + val dayCount = ChronoUnit.DAYS.between(start, end) + 1 + if (dayCount > MAX_DAILY_COLUMNS) { + return jsonError(HttpStatus.BAD_REQUEST, "日期範圍不可超過 $MAX_DAILY_COLUMNS 日") + } + + val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisDaily( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + start, + end, + ) + val dates = generateSequence(start) { previous -> + val next = previous.plusDays(1) + if (next.isAfter(end)) null else next + }.toList() + val excelBytes = createSemiFGProductionAnalysisDailyExcel( + dbData = dbData, + reportTitle = "成品/半成品生產分析報告", + dates = dates, + ) + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport_Daily.xlsx") + set("filename", "SemiFGProductionAnalysisReport_Daily.xlsx") + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createSemiFGProductionAnalysisDailyExcel( + dbData: List>, + reportTitle: String, + dates: List, + ): ByteArray { + val workbook = XSSFWorkbook() + val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle) + val sheet = workbook.createSheet(safeSheetName) + val dateFormat = DateTimeFormatter.ISO_LOCAL_DATE + val columnDateFormat = DateTimeFormatter.ofPattern( + if (dates.first().year == dates.last().year) "d/M" else "d/M/yy", + ) + val fixedColumns = 3 + val totalColumns = fixedColumns + dates.size + 1 + var rowIndex = 0 + + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + } + val titleFont = workbook.createFont().apply { + bold = true + fontHeightInPoints = 16 + } + titleStyle.setFont(titleFont) + + val infoStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + } + + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + wrapText = true + } + val headerFont = workbook.createFont().apply { bold = true } + headerStyle.setFont(headerFont) + + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") + } + + val dashStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val titleRow = sheet.createRow(rowIndex++) + titleRow.heightInPoints = 28f + val titleCell = titleRow.createCell(0) + titleCell.setCellValue(reportTitle) + titleCell.cellStyle = titleStyle + if (totalColumns > 1) { + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + } + + val rangeLabel = "${dates.first().format(dateFormat)} 至 ${dates.last().format(dateFormat)}" + val infoRow = sheet.createRow(rowIndex++) + val infoCell = infoRow.createCell(0) + infoCell.setCellValue( + "報告日期:${LocalDate.now().format(dateFormat)} " + + "報告時間:${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))} " + + "完成生產日期:$rangeLabel" + ) + infoCell.cellStyle = infoStyle + if (totalColumns > 1) { + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) + } + + rowIndex++ + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headerRow.heightInPoints = 32f + val headers = buildList { + add("貨品編號") + add("貨品名稱") + add("單位") + dates.forEach { add(it.format(columnDateFormat)) } + add("上架總計") + } + headers.forEachIndexed { index, name -> + val cell = headerRow.createCell(index) + cell.setCellValue(name) + cell.cellStyle = headerStyle + } + + val items = pivotDailyRows(dbData) + if (items.isEmpty()) { + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, "-", textStyle) + setTextCell(dataRow, 1, "-", textStyle) + setTextCell(dataRow, 2, "-", textStyle) + for (col in fixedColumns until totalColumns) { + setDashCell(dataRow, col, dashStyle) + } + } else { + items.forEach { item -> + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, item.itemNo.ifBlank { "-" }, textStyle) + setTextCell(dataRow, 1, item.itemName.ifBlank { "-" }, textStyle) + setTextCell(dataRow, 2, item.unitOfMeasure.ifBlank { "-" }, textStyle) + var total = 0.0 + dates.forEachIndexed { index, date -> + val qty = item.qtyByDate[date] ?: 0.0 + total += qty + setNumberCell(dataRow, fixedColumns + index, qty, numberStyle, dashStyle) + } + setNumberCell(dataRow, totalColumns - 1, total, numberStyle, dashStyle) + } + } + + sheet.createFreezePane(fixedColumns, headerRowIndex + 1) + sheet.setColumnWidth(0, 18 * 256) + sheet.setColumnWidth(1, 24 * 256) + sheet.setColumnWidth(2, 12 * 256) + for (col in fixedColumns until totalColumns) { + sheet.setColumnWidth(col, 12 * 256) + } + + val output = ByteArrayOutputStream() + workbook.use { it.write(output) } + return output.toByteArray() + } + + private fun pivotDailyRows(dbData: List>): List { + val byItem = linkedMapOf() + dbData.forEach { row -> + val itemNo = row["itemNo"]?.toString()?.trim().orEmpty() + val prodDate = toLocalDate(row["prodDate"]) ?: return@forEach + val qty = parseNumericForExcelChart(row["qty"]) + val item = byItem.getOrPut(itemNo) { + DailyItemQty( + itemNo = itemNo, + itemName = row["itemName"]?.toString().orEmpty(), + unitOfMeasure = row["unitOfMeasure"]?.toString().orEmpty(), + qtyByDate = linkedMapOf(), + ) + } + item.qtyByDate[prodDate] = (item.qtyByDate[prodDate] ?: 0.0) + qty + } + return byItem.values.toList() + } + + private fun parseReportDate(raw: String?): LocalDate? { + if (raw.isNullOrBlank()) return null + val normalized = raw.trim().replace("/", "-") + return runCatching { LocalDate.parse(normalized) }.getOrNull() + ?: runCatching { LocalDate.parse(normalized, DateTimeFormatter.ofPattern("dd-MM-yyyy")) }.getOrNull() + } + + private fun toLocalDate(value: Any?): LocalDate? = when (value) { + is LocalDate -> value + is java.sql.Date -> value.toLocalDate() + is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() + is java.time.LocalDateTime -> value.toLocalDate() + is java.util.Date -> value.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate() + is String -> parseReportDate(value.take(10)) + else -> null + } + + private fun jsonError(status: HttpStatus, message: String): ResponseEntity { + val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"") + return ResponseEntity.status(status) + .contentType(MediaType.APPLICATION_JSON) + .body("""{"message":"$escaped"}""".toByteArray(Charsets.UTF_8)) + } + private fun createSemiFGProductionAnalysisExcel( dbData: List>, reportTitle: String, @@ -517,4 +755,15 @@ class SemiFGProductionAnalysisReportController( val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) return ResponseEntity(itemCodesWithCategory, HttpStatus.OK) } + + private data class DailyItemQty( + val itemNo: String, + val itemName: String, + val unitOfMeasure: String, + val qtyByDate: MutableMap, + ) + + companion object { + private const val MAX_DAILY_COLUMNS = 366 + } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 377e8b48..ec914f52 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -2,7 +2,6 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.response.RecordsRes import com.ffii.core.utils.PdfUtils -import com.ffii.core.utils.QrCodeUtil import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.stock.entity.InventoryLotLine @@ -44,6 +43,7 @@ import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse import com.ffii.fpsms.modules.jobOrder.service.JobOrderService import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest import com.ffii.fpsms.modules.master.service.PrinterService +import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest import com.ffii.core.utils.ZebraPrinterUtil @@ -60,6 +60,7 @@ open class InventoryLotLineService( private val stockInLineRepository: StockInLineRepository, private val inventoryRepository: InventoryRepository, private val printerService: PrinterService, + private val settingsService: SettingsService, @Lazy private val jobOrderService: JobOrderService ) { @@ -247,7 +248,8 @@ open class InventoryLotLineService( @Throws(IOException::class) @Transactional open fun exportStockInLineQrcode(request: LotLineToQrcode): Map { - val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" + val labelVersion = PoItemLabelTemplate.version(settingsService) + val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) val resource = ClassPathResource(QRCODE_PDF) if (!resource.exists()) { println("here") @@ -287,13 +289,18 @@ open class InventoryLotLineService( field["acceptedQty"] = "%.2f".format(info.acceptedQty) val stockItemUom = itemUomRespository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(info.itemId) - field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc + field["uom"] = fullUom + val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) + field["qtyLabel"] = qtyLabel + field["qtyDetail"] = qtyDetail field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo!! field["supplier"] = info.supplier ?: "N/A" - val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) + val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) field["qrCode"] = image fields.add(field) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt new file mode 100644 index 00000000..d0228bf0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt @@ -0,0 +1,63 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.utils.QrCodeUtil +import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.settings.service.SettingsService +import java.awt.image.BufferedImage + +object PoItemLabelTemplate { + const val V1 = "qrCodeLabel/poItemPDF.jrxml" + const val V2 = "qrCodeLabel/poItemPDF_v2.jrxml" + const val V3 = "qrCodeLabel/poItemPDF_v3.jrxml" + + fun version(settingsService: SettingsService): Int { + val raw = settingsService.findByName(SettingNames.LABEL_PO_ITEM_VERSION) + .map { it.value } + .orElse("1") + ?.trim() + return when (raw) { + "2" -> 2 + "3" -> 3 + else -> 1 + } + } + + fun path(version: Int): String = when (version) { + 2 -> V2 + 3 -> V3 + else -> V1 + } + + fun qrImage(content: String, version: Int): BufferedImage = + if (version == 1) QrCodeUtil.generateQRCodeImage(content) + else QrCodeUtil.generateQRCodeImage(content, 500, 1) + + /** First line is qty + short unit. Second line is the full unit only when it differs. */ + fun qtyLines(qtyRaw: String?, fullRaw: String?, shortRaw: String?): Pair { + val qty = stripTrailingZeros(qtyRaw) + val full = blankUnit(fullRaw) + val short = blankUnit(shortRaw) + val same = short.isEmpty() || full.isEmpty() || short.equals(full, ignoreCase = true) + if (same) { + val unit = short.ifEmpty { full } + val line = when { + unit.isEmpty() -> qty + unit.first().isDigit() -> listOf(qty, unit).filter { it.isNotEmpty() }.joinToString(" ") + else -> qty + unit + } + return line to "" + } + return (qty + short) to full + } + + private fun blankUnit(raw: String?): String { + val text = raw?.trim().orEmpty() + return if (text.isEmpty() || text.equals("N/A", ignoreCase = true)) "" else text + } + + private fun stripTrailingZeros(raw: String?): String { + val text = raw?.trim().orEmpty() + if (!text.contains('.')) return text + return text.replace(Regex("0+$"), "").removeSuffix(".") + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index fa4c1abc..800dacfd 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.exception.BadRequestException import com.ffii.core.support.AbstractBaseEntityService import com.ffii.core.support.JdbcDao -import com.ffii.core.utils.QrCodeUtil import com.ffii.fpsms.modules.common.CodeGenerator import com.ffii.fpsms.modules.master.entity.ItemsRepository import com.ffii.fpsms.modules.master.entity.QcItemRepository @@ -33,6 +32,7 @@ import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.master.service.ItemUomService //import com.ffii.fpsms.modules.master.service.BomOutputQtyService import com.ffii.fpsms.modules.master.service.PrinterService +import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository @@ -98,6 +98,7 @@ open class StockInLineService( private val itemUomRepository: ItemUomRespository, private val itemUomService: ItemUomService, private val printerService: PrinterService, + private val settingsService: SettingsService, private val stockTakeLineRepository: StockTakeLineRepository, private val inventoryLotLineService: InventoryLotLineService, private val deliveryOrderRepository: DeliveryOrderRepository, @@ -1208,7 +1209,8 @@ open class StockInLineService( @Throws(IOException::class) @Transactional open fun exportStockInLineQrcode(request: ExportQrCodeRequest): Map { - val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" + val labelVersion = PoItemLabelTemplate.version(settingsService) + val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) val resource = ClassPathResource(QRCODE_PDF) if (!resource.exists()) { println("here") @@ -1242,12 +1244,17 @@ open class StockInLineService( field["poCode"] = info.poCode ?: "N/A" field["itemType"] = info.itemType ?: "N/A" field["acceptedQty"] = "%.2f".format(acceptedQty) - field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc + field["uom"] = fullUom + val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) + field["qtyLabel"] = qtyLabel + field["qtyDetail"] = qtyDetail field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo ?: "N/A" field["supplier"] = info.supplier ?: "N/A" - val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) + val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) field["qrCode"] = image fields.add(field) } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index dcd2d6e5..956ed653 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -3,9 +3,10 @@ fpsms: monitoring: enabled: true -# Session length in production; frontend can call /refresh-token before expiry to stay logged in. +# Session length in production; frontend refreshes via /refresh-token before expiry. +# Floor staff often work ~08:00–22:00 (~14h); 16h access covers one shift without mid-day kickout. jwt: - expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK + expiration-minutes: 960 # 16 hours access token (was 480 / 8h — users logged out ~16:30 after morning login) refresh-expiration-days: 7 # Required for stable JWT across restarts; set JWT_SECRET in the deployment environment (long random string). secret: ${JWT_SECRET:fpsms-dev-jwt-signing-secret-change-for-production-use-long-random-JWT_SECRET} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74ce2bc6..ed67e727 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -113,8 +113,9 @@ spring: dialect: storage_engine: innodb -# JWT: access token expiry and refresh token expiry. Frontend should call /refresh-token before access token expires. +# JWT: access token expiry and refresh token expiry. Frontend calls /refresh-token before access token expires. # Signing key must be STABLE across server restarts (do not use a random key per boot). Override with env JWT_SECRET in production. +# Prod overrides to 16h access (floor shifts ~08:00–22:00); see application-prod.yml. jwt: expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session refresh-expiration-days: 30 # refresh token validity (days) diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql new file mode 100644 index 00000000..8af0d109 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql @@ -0,0 +1,9 @@ +--liquibase formatted sql +--changeset fpsms:20261001_po_item_label_version + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT 'LABEL.poItem.version', '1', 'LABEL', '1/2' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` WHERE `name` = 'LABEL.poItem.version' +); diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql new file mode 100644 index 00000000..c704aa81 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +--changeset fpsms:20261001_po_item_label_version_allow_3 + +UPDATE `settings` +SET `type` = '1/2/3' +WHERE `name` = 'LABEL.poItem.version' + AND `type` = '1/2'; diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml new file mode 100644 index 00000000..d4a57f3d --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml new file mode 100644 index 00000000..47c4aec3 --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 41d5df10a5dba895389409998a22850b5c637fd2 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Fri, 2 Oct 2026 00:41:04 +0800 Subject: [PATCH 25/52] =?UTF-8?q?make=20the=20stock=20take=20qty=20can=20b?= =?UTF-8?q?e=20edit=20before=20=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/stock/entity/StockTakeRecord.kt | 4 ++ .../stock/service/StockTakeRecordService.kt | 52 ++++++++++++++++++- .../stock/web/model/StockTakeRecordReponse.kt | 6 ++- .../01_add_picker_revise_count.sql | 5 ++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt index 72a03b0b..4e4bc56f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt @@ -81,6 +81,10 @@ open class StockTakeRecord : BaseEntity() { @Column(name = "lastSelect") open var lastSelect: Int? = null + /** How many times the picker has changed an already saved first count. Max 2. */ + @Column(name = "pickerReviseCount", nullable = false) + open var pickerReviseCount: Int? = 0 + @Column(name = "approverTime") open var approverTime: LocalDateTime? = null @Column(name = "date", nullable = false) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 2fdbbb0c..d4d3c308 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -1200,6 +1200,7 @@ open class StockTakeRecordService( stockTakerName = rec.stockTakerName, stockTakeEndTime = rec.stockTakeEndTime, approverTime = rec.approverTime, + pickerReviseCount = rec.pickerReviseCount ?: 0, ) } @@ -1296,6 +1297,7 @@ open class StockTakeRecordService( stockTakerName = stockTakeRecord?.stockTakerName, stockTakeEndTime = stockTakeRecord?.stockTakeEndTime, approverTime = stockTakeRecord?.approverTime, + pickerReviseCount = stockTakeRecord?.pickerReviseCount ?: 0, ) } @@ -1342,12 +1344,59 @@ open class StockTakeRecordService( .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) + if (request.reviseFirstQty && request.stockTakeRecordId == null) { + throw IllegalArgumentException("Stock take record ID is required") + } + // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId) ?: throw IllegalArgumentException("Stock take record not found: ${request.stockTakeRecordId}") + val headerStatus = stockTake.status + val headerStillOpen = headerStatus == StockTakeStatus.PENDING || + headerStatus == StockTakeStatus.STOCKTAKING || + headerStatus == StockTakeStatus.APPROVING + val revisingOpenPass = existingRecord.pickerFirstStockTakeQty != null && + existingRecord.pickerSecondStockTakeQty == null && + existingRecord.approverStockTakeQty == null && + existingRecord.status.equals("pass", ignoreCase = true) && + headerStillOpen when { + // 已盤點的列在區域「已完成」前可改第一次數量。列狀態已完成、或區域已完成,不能改。 + request.reviseFirstQty || revisingOpenPass -> { + if (!headerStillOpen) { + throw IllegalArgumentException("Cannot change counted qty after the section is completed") + } + if (!existingRecord.status.equals("pass", ignoreCase = true)) { + throw IllegalArgumentException("Only counted records can be changed") + } + if (existingRecord.pickerFirstStockTakeQty == null) { + throw IllegalArgumentException("No first count to change") + } + if (existingRecord.pickerSecondStockTakeQty != null || existingRecord.approverStockTakeQty != null) { + throw IllegalArgumentException("Cannot change the first count after a later count exists") + } + val reviseCount = existingRecord.pickerReviseCount ?: 0 + if (reviseCount >= 2) { + throw IllegalArgumentException("This count can only be changed 2 times") + } + val varianceQty = availableQty - request.qty - request.badQty + existingRecord.apply { + this.pickerFirstStockTakeQty = request.qty + this.pickerFirstBadQty = request.badQty + this.bookQty = availableQty + this.varianceQty = varianceQty + this.stockTakerId = stockTakerId + this.stockTakerName = user?.name + this.status = "pass" + this.pickerSecondStockTakeQty = null + this.pickerSecondBadQty = null + this.stockTakeEndTime = null + this.pickerReviseCount = reviseCount + 1 + } + existingRecord + } existingRecord.pickerFirstStockTakeQty == null -> { val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty val varianceQty = availableQty - request.qty - request.badQty @@ -1367,7 +1416,8 @@ open class StockTakeRecordService( } existingRecord } - existingRecord.pickerSecondStockTakeQty == null -> { + existingRecord.pickerSecondStockTakeQty == null && + existingRecord.status.equals("notMatch", ignoreCase = true) -> { val varianceQty = availableQty - request.qty - request.badQty existingRecord.apply { this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt index d96bf61f..791c52ba 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt @@ -80,6 +80,8 @@ data class InventoryLotDetailResponse( val lastSelect: Int? = null, /** Stock UoM short label (e.g. 包), shown beside the count input. */ val uomShortDesc: String? = null, + /** Times the picker has changed this saved first count. */ + val pickerReviseCount: Int? = 0, ) data class InventoryLotLineListRequest( val warehouseCode: String @@ -90,7 +92,9 @@ data class SaveStockTakeRecordRequest( val qty: BigDecimal, // QTY(第一次或第二次,后端判断) val badQty: BigDecimal, // val stockTakerName: String, - val remark: String? = null + val remark: String? = null, + /** true = 區域仍在盤點中時,覆寫已盤點的第一次數量;不寫入第二次盤點 */ + val reviseFirstQty: Boolean = false, ) /** diff --git a/src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql b/src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql new file mode 100644 index 00000000..864f978c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql @@ -0,0 +1,5 @@ +-- liquibase formatted sql +-- changeset fpsms:20261001-picker-revise-count + +ALTER TABLE `fpsmsdb`.`stocktakerecord` +ADD COLUMN `pickerReviseCount` INT NOT NULL DEFAULT 0 AFTER `lastSelect`; From 8a7d0167fddacbccd77fdc28027beae84ceac9a2 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sat, 3 Oct 2026 00:52:15 +0800 Subject: [PATCH 26/52] fixing the schedule_sync_log.query too long error added version 4 of PO item label --- .../com/ffii/fpsms/m18/model/SyncResult.kt | 16 +- .../m18/service/M18DeliveryOrderService.kt | 6 +- .../m18/service/M18PurchaseOrderService.kt | 6 +- .../fpsms/modules/common/SettingNames.java | 2 +- .../scheduler/service/SchedulerService.kt | 13 +- .../service/PurchaseOrderService.kt | 2 +- .../stock/service/PoItemLabelTemplate.kt | 3 + .../stock/service/StockInLineService.kt | 14 +- .../03_allow_version_4.sql | 7 + .../01_widen_query_and_error_message.sql | 8 + .../resources/qrCodeLabel/poItemPDF_v4.jrxml | 172 ++++++++++++++++++ 11 files changed, 226 insertions(+), 23 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql create mode 100644 src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql create mode 100644 src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml diff --git a/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt b/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt index 4c4d3340..d0df7074 100644 --- a/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt +++ b/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt @@ -1,10 +1,20 @@ package com.ffii.fpsms.m18.model -import java.time.LocalDateTime - data class SyncResult( val totalProcessed: Int, val totalSuccess: Int, val totalFail: Int, val query: String? = null -) \ No newline at end of file +) + +/** + * Bounded suffix for [scheduler_sync_log.query]. Listing every skipped document + * overflows VARCHAR(2000) on a multi-day PO window. + */ +fun skippedNotPendingQuerySuffix(entries: List, previewLimit: Int = 20): String { + if (entries.isEmpty()) return "" + val shown = entries.take(previewLimit) + val hidden = entries.size - shown.size + val more = if (hidden > 0) "; ... +$hidden more" else "" + return " | skipped (not pending, only pending may be overwritten) count=${entries.size}: ${shown.joinToString("; ")}$more" +} \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 6bd1d094..34c7f81f 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -649,11 +649,7 @@ open class M18DeliveryOrderService( logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") val skippedExistingSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" - val skippedNotPendingSuffix = if (skippedNotPendingList.isNotEmpty()) { - " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" - } else { - "" - } + val skippedNotPendingSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) return SyncResult( totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size, totalSuccess = successList.size, diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index 8dffa5ab..e93bbcd4 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -745,11 +745,7 @@ open class M18PurchaseOrderService( logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") val baseQuery = purchaseOrdersWithType?.query ?: "" - val skippedSuffix = if (skippedNotPendingList.isNotEmpty()) { - " | skipped (not pending, only pending may be overwritten): ${skippedNotPendingList.joinToString("; ")}" - } else { - "" - } + val skippedSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) return SyncResult( totalProcessed = successList.size + failList.size + skippedNotPendingList.size, diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 7aad93fc..88b8dfee 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -135,7 +135,7 @@ public abstract class SettingNames { /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess"; - /** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR with a larger item name. */ + /** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR, 4 = version 3 with the version 1 QR size. */ public static final String LABEL_PO_ITEM_VERSION = "LABEL.poItem.version"; } diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index de198a67..7322508c 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -87,6 +87,8 @@ open class SchedulerService( const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" + /** MySQL TEXT is 65535 bytes; 16000 chars stays under that for utf8mb4. */ + const val SYNC_LOG_TEXT_MAX_CHARS: Int = 16_000 } /** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */ @@ -730,14 +732,21 @@ open class SchedulerService( recordsProcessed = result?.totalProcessed ?: 0, recordsSaved = result?.totalSuccess ?: 0, recordsFailed = result?.totalFail ?: 0, - errorMessage = error, - query = result?.query ?: "", + errorMessage = fitSyncLogText(error), + query = fitSyncLogText(result?.query ?: ""), startTime = start, endTime = LocalDateTime.now() ) schedulerSyncLogRepository.save(log) } + private fun fitSyncLogText(value: String?): String? { + if (value == null || value.length <= SYNC_LOG_TEXT_MAX_CHARS) return value + val marker = "...(truncated, length=${value.length})" + logger.warn("scheduler_sync_log text truncated from {} to {} characters", value.length, SYNC_LOG_TEXT_MAX_CHARS) + return value.take((SYNC_LOG_TEXT_MAX_CHARS - marker.length).coerceAtLeast(0)) + marker + } + open fun getM18Dos2() { logger.info("DO Scheduler 2 - DO") val currentTime = LocalDateTime.now() diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt index d90a7daa..039f0228 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt @@ -103,7 +103,7 @@ open fun getPoSummariesByIds(ids: List): List { orderDate = it["orderDate"] as LocalDateTime?, estimatedArrivalDate = it["estimatedArrivalDate"] as LocalDateTime?, supplierName = it["supplierName"] as String?, - escalated = (it["escalated"] as Boolean?) ?: false, + escalated = it["escalated"] == 1L, ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt index d0228bf0..f4dcf00e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt @@ -9,6 +9,7 @@ object PoItemLabelTemplate { const val V1 = "qrCodeLabel/poItemPDF.jrxml" const val V2 = "qrCodeLabel/poItemPDF_v2.jrxml" const val V3 = "qrCodeLabel/poItemPDF_v3.jrxml" + const val V4 = "qrCodeLabel/poItemPDF_v4.jrxml" fun version(settingsService: SettingsService): Int { val raw = settingsService.findByName(SettingNames.LABEL_PO_ITEM_VERSION) @@ -18,6 +19,7 @@ object PoItemLabelTemplate { return when (raw) { "2" -> 2 "3" -> 3 + "4" -> 4 else -> 1 } } @@ -25,6 +27,7 @@ object PoItemLabelTemplate { fun path(version: Int): String = when (version) { 2 -> V2 3 -> V3 + 4 -> V4 else -> V1 } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 800dacfd..446fa208 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -650,13 +650,15 @@ open class StockInLineService( open fun updatePurchaseOrderStatus(po : PurchaseOrder) : PurchaseOrder { val pols = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(po.id!!) - var newStatus = PurchaseOrderStatus.PENDING - if (pols.isNotEmpty()) { - newStatus = if (pols.any { pol-> pol.status != PurchaseOrderLineStatus.COMPLETED }) { - PurchaseOrderStatus.RECEIVING - } else { + // A line that was never received, or whose stock-in rows were all removed before putaway, is pending. + // Receiving only while some line is still receiving or already completed. + val newStatus = when { + pols.isEmpty() || pols.all { it.status == null || it.status == PurchaseOrderLineStatus.PENDING } -> + PurchaseOrderStatus.PENDING + pols.all { it.status == PurchaseOrderLineStatus.COMPLETED } -> PurchaseOrderStatus.COMPLETED - } + else -> + PurchaseOrderStatus.RECEIVING } po.apply { status = newStatus diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql new file mode 100644 index 00000000..30cafc5c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +--changeset fpsms:20261002_po_item_label_version_allow_4 + +UPDATE `settings` +SET `type` = '1/2/3/4' +WHERE `name` = 'LABEL.poItem.version' + AND `type` IN ('1/2', '1/2/3'); diff --git a/src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql b/src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql new file mode 100644 index 00000000..6b682cf6 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql +-- changeset fpsms:20261002-scheduler-sync-log-text + +-- PO sync stores the M18 conds plus skipped PO codes. VARCHAR(2000) / VARCHAR(1024) +-- reject that insert (MySQL 1406) and the scheduled task fails after the sync. +ALTER TABLE `fpsmsdb`.`scheduler_sync_log` + MODIFY COLUMN `query` TEXT NULL, + MODIFY COLUMN `errorMessage` TEXT NULL; diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml new file mode 100644 index 00000000..7125b95a --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0b064e11fbd70c4d562e55fdc5fe75aa3f438b5b Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 4 Oct 2026 13:59:01 +0800 Subject: [PATCH 27/52] make the enter box num and print delivery note faster --- .../com/ffii/core/utils/BrotherPrinterUtil.kt | 79 ++++- .../com/ffii/core/utils/CanonPrinterUtil.kt | 8 +- .../service/DoWorkbenchMainService.kt | 300 +++++++++++++----- .../entity/PickOrderLineRepository.kt | 12 + .../service/StockLedgerReportService.kt | 228 +++++++++---- .../entity/InventoryLotLineRepository.kt | 13 + .../stock/service/StockInLineService.kt | 4 +- 7 files changed, 493 insertions(+), 151 deletions(-) diff --git a/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt b/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt index 70af932e..6683cbe6 100644 --- a/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt +++ b/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt @@ -3,8 +3,11 @@ package com.ffii.core.utils import org.apache.pdfbox.pdmodel.PDDocument import org.apache.pdfbox.rendering.PDFRenderer import java.awt.image.BufferedImage +import java.awt.image.DataBufferByte +import java.awt.image.MultiPixelPackedSampleModel import java.io.ByteArrayOutputStream import java.io.File +import java.net.InetSocketAddress import java.net.Socket import org.slf4j.LoggerFactory @@ -12,6 +15,7 @@ class BrotherPrinterUtil { companion object { private const val SOCKET_TIMEOUT_MS = 120_000 // 2 min for large jobs; Brother may be slow to accept + private const val CONNECT_TIMEOUT_MS = 5_000 private const val BROTHER_DPI = 200 // Lower DPI = smaller job; some Brother models buffer better at 200 private val log = LoggerFactory.getLogger(BrotherPrinterUtil::class.java) @@ -25,11 +29,14 @@ class BrotherPrinterUtil { log.info("Brother print: file=${pdfFile.name} size=${pdfFile.length()} ip=$printerIp port=$printerPort copies=$printQty") + PrinterLockManager.withLock(printerIp, printerPort) { PDDocument.load(pdfFile).use { document -> val renderer = PDFRenderer(document) val totalPages = document.numberOfPages - Socket(printerIp, printerPort).use { socket -> + Socket().use { socket -> + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(printerIp, printerPort), CONNECT_TIMEOUT_MS) socket.soTimeout = SOCKET_TIMEOUT_MS val os = socket.getOutputStream() @@ -52,6 +59,7 @@ class BrotherPrinterUtil { log.info("Brother print job sent successfully") } } + } } private fun getPjlHeader(qty: Int): ByteArray { @@ -67,6 +75,8 @@ class BrotherPrinterUtil { * Uses given DPI (e.g. 200) to keep job size smaller for printers with limited buffer. */ private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { + convertPackedBinaryToPcl(image, dpi)?.let { return it } + val out = ByteArrayOutputStream() val width = image.width val height = image.height @@ -75,21 +85,21 @@ class BrotherPrinterUtil { out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset // PCL: Set resolution and start raster out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) - out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) for (y in 0 until height) { val rowBytes = (width + 7) / 8 val data = ByteArray(rowBytes) - + for (x in 0 until width) { // Check if pixel is dark (threshold) val color = image.getRGB(x, y) - val isBlack = (color and 0xFF) < 128 + val isBlack = (color and 0xFF) < 128 if (isBlack) { data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() } } - + // PCL: Transfer Raster Data (Esc * b # W [data]) out.write("\u001B*b${rowBytes}W".toByteArray()) out.write(data) @@ -99,5 +109,64 @@ class BrotherPrinterUtil { out.write("\u001B*rB".toByteArray()) return out.toByteArray() } + + /** + * TYPE_BYTE_BINARY from PDFBox: sample 0 is black, sample 1 is white, MSB first. + * PCL raster uses the opposite polarity (bit 1 = black). getRGB() per pixel is too slow + * for a multi-page A4 note; pack the row from the data buffer instead. + * Returns null when the raster layout does not match, so the caller keeps the slow path. + */ + private fun convertPackedBinaryToPcl(image: BufferedImage, dpi: Int): ByteArray? { + if (image.type != BufferedImage.TYPE_BYTE_BINARY) return null + val raster = image.raster + val sampleModel = raster.sampleModel as? MultiPixelPackedSampleModel ?: return null + if (sampleModel.pixelBitStride != 1 || sampleModel.dataBitOffset != 0) return null + val dataBuffer = raster.dataBuffer as? DataBufferByte ?: return null + if (dataBuffer.numBanks != 1) return null + + val bank = dataBuffer.data + val bankOffset = dataBuffer.offsets[0] + val width = image.width + val height = image.height + if (width <= 0 || height <= 0) return null + val stride = sampleModel.scanlineStride + val rowBytes = (width + 7) / 8 + if (stride < rowBytes) return null + if (bankOffset < 0 || bankOffset + (height - 1) * stride + rowBytes > bank.size) return null + + val check = minOf(8, width) + for (x in 0 until check) { + val srcBit = (bank[bankOffset].toInt() ushr (7 - x)) and 1 + val rgbBlack = (image.getRGB(x, 0) and 0xFF) < 128 + if ((srcBit == 0) != rgbBlack) return null + } + + val out = ByteArrayOutputStream(height * (rowBytes + 12) + 32) + out.write("\u001BE".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) + + val fullBytes = width / 8 + val remainder = width % 8 + val remainderMask = if (remainder == 0) 0 else (0xFF shl (8 - remainder)) and 0xFF + val row = ByteArray(rowBytes) + + for (y in 0 until height) { + val rowStart = bankOffset + y * stride + var i = 0 + while (i < fullBytes) { + row[i] = (bank[rowStart + i].toInt().inv() and 0xFF).toByte() + i++ + } + if (remainder != 0) { + val src = bank[rowStart + fullBytes].toInt() and 0xFF + row[fullBytes] = (src.inv() and remainderMask).toByte() + } + out.write("\u001B*b${rowBytes}W".toByteArray(Charsets.US_ASCII)) + out.write(row) + } + out.write("\u001B*rB".toByteArray(Charsets.US_ASCII)) + return out.toByteArray() + } } } \ No newline at end of file diff --git a/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt b/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt index 939d58ec..448bb00e 100644 --- a/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt +++ b/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt @@ -4,6 +4,7 @@ import org.apache.pdfbox.pdmodel.PDDocument import org.slf4j.LoggerFactory import java.io.File import java.io.OutputStream +import java.net.InetSocketAddress import java.net.Socket /** @@ -111,7 +112,11 @@ open class CanonPrinterUtil { val copies = printQty ?: 1 repeat(copies) { copyIndex -> val tSend = System.nanoTime() - Socket(printerIp, printerPort).use { socket -> + PrinterLockManager.withLock(printerIp, printerPort) { + Socket().use { socket -> + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(printerIp, printerPort), 5_000) + socket.soTimeout = 120_000 val os: OutputStream = socket.getOutputStream() // Send PJL commands @@ -126,6 +131,7 @@ open class CanonPrinterUtil { os.flush() } + } val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 log.info( "Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)", diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index e1563c53..50b2ec58 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -71,6 +71,7 @@ import jakarta.persistence.EntityManager import jakarta.persistence.PersistenceContext import org.springframework.orm.ObjectOptimisticLockingFailureException import com.ffii.core.utils.PdfUtils +import com.ffii.core.utils.PrinterLockManager import com.ffii.core.utils.ZebraPrinterUtil import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry import com.ffii.fpsms.modules.master.service.PrinterService @@ -85,11 +86,11 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsReprintReque import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors import org.springframework.core.io.ClassPathResource import net.sf.jasperreports.engine.JasperCompileManager import net.sf.jasperreports.engine.JasperExportManager import net.sf.jasperreports.engine.JasperPrint +import net.sf.jasperreports.engine.JasperReport import java.io.File import java.io.FileNotFoundException import java.util.Locale @@ -142,6 +143,20 @@ open class DoWorkbenchMainService( companion object { private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java) + + private const val DELIVERY_NOTE_JRXML = "DeliveryNote/DeliveryNotePDF.jrxml" + private const val CARTON_LABEL_JRXML = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" + + private val deliveryNoteJasperTemplate: JasperReport by lazy { compileJasper(DELIVERY_NOTE_JRXML) } + private val cartonLabelJasperTemplate: JasperReport by lazy { compileJasper(CARTON_LABEL_JRXML) } + + private fun compileJasper(path: String): JasperReport { + val resource = ClassPathResource(path) + if (!resource.exists()) { + throw FileNotFoundException("Report file not found: $path") + } + return resource.inputStream.use { JasperCompileManager.compileReport(it) } + } } private sealed interface WorkbenchScanIllResolve { @@ -2931,6 +2946,7 @@ return MessageResponse( } @Transactional open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { + val started = System.nanoTime() val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") @@ -2944,17 +2960,38 @@ return MessageResponse( val jasperPrint = pdf["report"] as JasperPrint val tempPdfFile = File.createTempFile("print_job_", ".pdf") + var handedToPrinter = false try { JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty - printer.ip?.let { ip -> - val port = printer.port ?: 9100 - val driver = A4PrintDriverRegistry.getDriver(printer.brand) - driver.print(tempPdfFile, ip, port, printQty) + val ip = printer.ip?.trim().orEmpty() + if (ip.isEmpty()) { + tempPdfFile.delete() + throw IllegalStateException("Printer ${printer.id} has no IP address") } - updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) - } finally { - //tempPdfFile.delete() + val port = printer.port ?: 9100 + val brand = printer.brand + val doPickOrderId = request.doPickOrderId + val cartonQty = request.numOfCarton + sendToPrinter( + tempPdfFile, + ip, + port, + afterPrint = { saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) }, + ) { + A4PrintDriverRegistry.getDriver(brand).print(tempPdfFile, ip, port, printQty) + } + handedToPrinter = true + log.info( + "Workbench print-DN sent buildMs={} doPickOrderId={} printerId={} printerIp={}", + (System.nanoTime() - started) / 1_000_000, + request.doPickOrderId, + request.printerId, + printer.ip ?: "null", + ) + } catch (e: Exception) { + if (!handedToPrinter) tempPdfFile.delete() + throw e } } @@ -3021,14 +3058,7 @@ return MessageResponse( } open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map { - val DELIVERYNOTE_PDF = "DeliveryNote/DeliveryNotePDF.jrxml" - val resource = ClassPathResource(DELIVERYNOTE_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Report file not found: $DELIVERYNOTE_PDF") - } - - val inputStream = resource.inputStream - val deliveryNote = JasperCompileManager.compileReport(inputStream) + val deliveryNote = deliveryNoteJasperTemplate val fields = mutableListOf>() val params = mutableMapOf() @@ -3042,18 +3072,9 @@ return MessageResponse( val exportLines = deliveryOrderService.deliveryNoteExportLines(deliveryNoteInfo) - val pickOrderLines = ctx.pickOrderIds.flatMap { pid -> - pickOrderLineRepository.findAllByPickOrderId(pid) - } + val pickOrderLines = loadPickOrderLinesForPrint(ctx.pickOrderIds) val allPickOrderLineIds = pickOrderLines.mapNotNull { it.id } - val stockOutLinesByPickOrderLineId: Map> = - if (allPickOrderLineIds.isNotEmpty()) { - allPickOrderLineIds.associateWith { polId -> - stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) - } - } else { - emptyMap() - } + val stockOutLinesByPickOrderLineId = loadStockOutLinesForPrint(allPickOrderLineIds) val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT) ?.replace("/", "") @@ -3079,11 +3100,7 @@ return MessageResponse( val allIllIds = stockOutLinesByPickOrderLineId.values .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } .distinct() - val illById: Map = if (allIllIds.isNotEmpty()) { - inventoryLotLineRepository.findAllById(allIllIds).associateBy { it.id!! } - } else { - emptyMap() - } + val illById = loadInventoryLotLinesWithWarehouse(allIllIds) val deliveryOrders = deliveryOrderRepository.findAllById(ctx.deliveryOrderIds) val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } @@ -3157,6 +3174,7 @@ return MessageResponse( } @Transactional open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { + val started = System.nanoTime() val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") @@ -3170,26 +3188,48 @@ return MessageResponse( val jasperPrint = pdf["report"] as JasperPrint val tempPdfFile = File.createTempFile("print_job_", ".pdf") + var handedToPrinter = false try { JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty - printer.ip?.let { ip -> - printer.port?.let { port -> - ZebraPrinterUtil.printPdfToZebra( - tempPdfFile, - ip, - port, - printQty, - ZebraPrinterUtil.PrintDirection.ROTATED, - printer.dpi, - ) - } + val ip = printer.ip?.trim().orEmpty() + val port = printer.port + if (ip.isEmpty() || port == null) { + tempPdfFile.delete() + throw IllegalStateException("Label printer ${printer.id} has no IP or port") } - if (request.blankCartonNumber != true) { - updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) + val dpi = printer.dpi + val saveCarton = request.blankCartonNumber != true + val doPickOrderId = request.doPickOrderId + val cartonQty = request.numOfCarton + sendToPrinter( + tempPdfFile, + ip, + port, + afterPrint = { + if (saveCarton) saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) + }, + ) { + ZebraPrinterUtil.printPdfToZebra( + tempPdfFile, + ip, + port, + printQty, + ZebraPrinterUtil.PrintDirection.ROTATED, + dpi, + ) } - } finally { - //tempPdfFile.delete() + handedToPrinter = true + log.info( + "Workbench print-DNLabels sent buildMs={} doPickOrderId={} cartons={} printerIp={}", + (System.nanoTime() - started) / 1_000_000, + request.doPickOrderId, + request.numOfCarton, + printer.ip ?: "null", + ) + } catch (e: Exception) { + if (!handedToPrinter) tempPdfFile.delete() + throw e } } @@ -3210,15 +3250,8 @@ return MessageResponse( } private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map { - val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" - val resource = ClassPathResource(DNLABELS_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Label file not found: $DNLABELS_PDF") - } - val ctx = getWorkbenchPrintContext(request.doPickOrderId) - val inputStream = resource.inputStream - val cartonLabel = JasperCompileManager.compileReport(inputStream) + val cartonLabel = cartonLabelJasperTemplate val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) }.toMutableList() @@ -3279,15 +3312,8 @@ return MessageResponse( totalCartonsOnShipment = request.totalCartonsOnShipment, ) - val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" - val resource = ClassPathResource(DNLABELS_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Label file not found: $DNLABELS_PDF") - } - val ctx = getWorkbenchPrintContext(request.doPickOrderId) - val inputStream = resource.inputStream - val cartonLabel = JasperCompileManager.compileReport(inputStream) + val cartonLabel = cartonLabelJasperTemplate val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) }.toMutableList() @@ -3327,23 +3353,147 @@ return MessageResponse( val pdf = exportDNLabelsReprintWorkbench(request) val jasperPrint = pdf["report"] as JasperPrint val tempPdfFile = File.createTempFile("print_job_", ".pdf") + var handedToPrinter = false try { JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty - printer.ip?.let { ip -> - printer.port?.let { port -> - ZebraPrinterUtil.printPdfToZebra( - tempPdfFile, - ip, - port, - printQty, - ZebraPrinterUtil.PrintDirection.ROTATED, - printer.dpi, - ) - } + val ip = printer.ip?.trim().orEmpty() + val port = printer.port + if (ip.isEmpty() || port == null) { + tempPdfFile.delete() + throw IllegalStateException("Label printer ${printer.id} has no IP or port") + } + val dpi = printer.dpi + sendToPrinter(tempPdfFile, ip, port) { + ZebraPrinterUtil.printPdfToZebra( + tempPdfFile, + ip, + port, + printQty, + ZebraPrinterUtil.PrintDirection.ROTATED, + dpi, + ) } + handedToPrinter = true + } catch (e: Exception) { + if (!handedToPrinter) tempPdfFile.delete() + throw e + } + } + + /** + * Send on this request so a printer timeout is returned to the tablet. + * Carton qty is saved only after the printer accepts the job. + */ + private fun sendToPrinter( + tempPdfFile: File, + ip: String, + port: Int, + afterPrint: (() -> Unit)? = null, + action: () -> Unit, + ) { + try { + PrinterLockManager.withLock(ip, port) { + action() + } + log.info("Workbench print sent printer={}:{} file={}", ip, port, tempPdfFile.name) + afterPrint?.invoke() + } catch (e: Exception) { + log.error("Workbench print failed printer={}:{} file={}", ip, port, tempPdfFile.name, e) + throw e } finally { - // tempPdfFile.delete() + tempPdfFile.delete() } } + + /** Same column as before (`cartonQty` only). Runs only after the printer accepts the job. */ + private fun saveCartonQtyAfterSuccessfulPrint(deliveryOrderPickOrderId: Long, cartonQty: Int) { + if (cartonQty <= 0) return + val tx = TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + tx.executeWithoutResult { + updateWorkbenchCartonQty(deliveryOrderPickOrderId, cartonQty) + } + } + + private fun loadPickOrderLinesForPrint(pickOrderIds: List): List { + if (pickOrderIds.isEmpty()) return emptyList() + return pickOrderIds.distinct().chunked(400).flatMap { chunk -> + pickOrderLineRepository.findAllForPrintByPickOrderIdIn(chunk) + } + } + + private fun loadStockOutLinesForPrint(pickOrderLineIds: List): Map> { + if (pickOrderLineIds.isEmpty()) return emptyMap() + val lines = mutableListOf() + pickOrderLineIds.distinct().chunked(400).forEach { chunk -> + val rows = jdbcDao.queryForList( + """ + SELECT + sol.id AS id, + sol.pickOrderLineId AS pickOrderLineId, + sol.qty AS qty, + sol.inventoryLotLineId AS inventoryLotLineId, + sol.status AS status, + il.lotNo AS lotNo + FROM fpsmsdb.stock_out_line sol + LEFT JOIN fpsmsdb.inventory_lot_line ill + ON ill.id = sol.inventoryLotLineId + LEFT JOIN fpsmsdb.inventory_lot il + ON il.id = ill.inventoryLotId + WHERE sol.deleted = 0 + AND sol.pickOrderLineId IN (:ids) + """.trimIndent(), + mapOf("ids" to chunk), + ) + rows.forEach { row -> + val polId = (rowValue(row, "pickOrderLineId") as? Number)?.toLong() ?: return@forEach + val qtyRaw = rowValue(row, "qty") + lines.add( + PrintStockOutLineInfo( + id = (rowValue(row, "id") as? Number)?.toLong() ?: 0L, + pickOrderLineId = polId, + qty = when (qtyRaw) { + is BigDecimal -> qtyRaw + is Number -> BigDecimal(qtyRaw.toString()) + else -> BigDecimal.ZERO + }, + inventoryLotLineId = (rowValue(row, "inventoryLotLineId") as? Number)?.toLong(), + lotNo = rowValue(row, "lotNo")?.toString()?.takeIf { it.isNotBlank() }, + status = rowValue(row, "status")?.toString() ?: "", + ), + ) + } + } + return lines.groupBy { it.pickOrderLineId!! } + } + + private fun loadInventoryLotLinesWithWarehouse(ids: Collection): Map { + if (ids.isEmpty()) return emptyMap() + val loaded = ids.distinct().chunked(400).flatMap { chunk -> + inventoryLotLineRepository.findAllByIdInWithWarehouse(chunk) + } + return loaded.mapNotNull { line -> line.id?.let { id -> id to line } }.toMap() + } + + private fun rowValue(row: Map, key: String): Any? { + val matched = row.keys.find { it.equals(key, ignoreCase = true) } ?: return null + return row[matched] + } + + private data class PrintStockOutLineInfo( + override val id: Long, + override val pickOrderLineId: Long?, + override val qty: BigDecimal, + override val inventoryLotLineId: Long?, + override val lotNo: String?, + override val status: String, + ) : StockOutLineInfo { + override val itemId: Long = 0L + override val itemName: String? = null + override val itemNo: String = "" + override val stockOutId: Long = 0L + override val pickTime: java.time.LocalDateTime? = null + } } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt index bdf8da28..ba35afb9 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.pickOrder.entity import com.ffii.core.support.AbstractRepository import org.springframework.stereotype.Repository +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -33,6 +34,17 @@ fun findByPickOrderId(pickOrderId: Long): List ) fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List): List + /** Delivery-note print: one query, associations initialized (avoids per-line lazy loads). */ + @EntityGraph(attributePaths = ["item", "pickOrder", "uom"]) + @Query( + """ + SELECT pol FROM PickOrderLine pol + WHERE pol.pickOrder.id IN :pickOrderIds + AND pol.deleted = false + """ + ) + fun findAllForPrintByPickOrderIdIn(@Param("pickOrderIds") pickOrderIds: List): List + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 7e57c09c..470d720b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -27,8 +27,8 @@ open class StockLedgerReportService( * - cumBalance = stock_ledger.balance(異動後纍計存量) * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) * - * 只查 [start, end] 期間列,不掃起日以前全歷史。 - * 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。 + * 先用貨品/日期索引切出流水帳,再一對一接批次行。 + * 不依 inventoryLotId 展開該批次的全部儲位,否則一筆入庫會變成多列。 * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 */ fun searchStockLedgerReport( @@ -40,6 +40,7 @@ open class StockLedgerReportService( ): List> { val args = mutableMapOf() + val itemPredicate = ledgerItemPredicate(itemCode, args) val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") ?: LocalDate.now().format(dateFmt)) @@ -47,17 +48,7 @@ open class StockLedgerReportService( val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { reportPeriodStart.replace("/", "-") } else { - val minDateSql = """ - SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - """.trimIndent() - - val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap()).firstOrNull() - (minDateRow?.get("firstDate") as? String) - ?: reportPeriodEnd + earliestLedgerDate(itemPredicate, args) ?: reportPeriodEnd } val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) @@ -71,13 +62,6 @@ open class StockLedgerReportService( args ) - val itemCodeSql = buildItemCodeFilterClause( - itemCode, - "sl.itemCode", - "itemCode", - args - ) - val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" "AND wh.code LIKE :storeLocation" @@ -85,62 +69,96 @@ open class StockLedgerReportService( "" } - // Wide reports are a date range over the whole ledger. Pin that index so MySQL - // does not start from another stock_ledger index and scan extra rows. - val ledgerIndex = if (itemCode.isNullOrBlank()) "FORCE INDEX (idx_sl_deleted_date)" else "" + val uomItemSql = if (itemPredicate.itemIdParamNames.isEmpty()) { + "" + } else { + "AND iu.itemId IN (${itemPredicate.itemIdParamNames.joinToString(", ") { ":$it" }})" + } + // One item is already ordered by the (itemId, deleted, date, id) index. + val orderBy = if (itemPredicate.singleItem) { + "ledger.dt, ledger.slId" + } else { + "it.code, ledger.dt, ledger.slId" + } + + // NO_MERGE keeps the date/item slice materialized before the lot joins. val sql = """ -SELECT - sl.id AS slId, - sl.itemCode AS itemCode, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, +SELECT /*+ NO_MERGE(ledger) */ + ledger.slId AS slId, + ledger.itemCode AS itemCode, + DATE_FORMAT(ledger.dt, '%Y-%m-%d') AS trnDate, CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' + WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'TKE' THEN 'TKE' + WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'ADJ' AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') + ELSE COALESCE(ledger.ledgerType, '') END AS trnRefNoRaw, - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - COALESCE(sl.balance, 0) AS bal, - COALESCE(sl.balance, 0) - COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + COALESCE(ledger.inQty, 0) AS inQty, + COALESCE(ledger.outQty, 0) AS outQty, + COALESCE(ledger.bal, 0) AS bal, + COALESCE(ledger.bal, 0) - COALESCE(ledger.inQty, 0) + COALESCE(ledger.outQty, 0) AS cumOpeningBalRaw, it.type AS stockSubCategory, it.code AS itemNo, it.name AS itemName, uom.unitOfMeasure AS unitOfMeasure, - COALESCE(il_in.lotNo, il_out.lotNo) AS lotNo, - COALESCE( - DATE_FORMAT(il_in.expiryDate, '%Y-%m-%d'), - DATE_FORMAT(il_out.expiryDate, '%Y-%m-%d'), - '' - ) AS expiryDate, + il.lotNo AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, wh.code AS storeLocation, COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, COALESCE(TRIM(jo.code), '') AS jobOrderNo -FROM stock_ledger sl $ledgerIndex +FROM ( + SELECT + sl.id AS slId, + sl.itemCode AS itemCode, + sl.date AS dt, + sl.type AS ledgerType, + sl.inQty AS inQty, + sl.outQty AS outQty, + sl.balance AS bal, + sl.itemId AS itemId, + sl.stockInLineId AS stockInLineId, + sl.stockOutLineId AS stockOutLineId, + sl.inventoryLotLineId AS inventoryLotLineId + FROM stock_ledger sl ${itemPredicate.indexHint} + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + ${itemPredicate.sql} +) ledger LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id + ON ledger.stockInLineId = sil.id AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in - ON sil.inventoryLotId = il_in.id - AND il_in.deleted = 0 LEFT JOIN stock_out_line sol - ON sl.stockOutLineId = sol.id + ON ledger.stockOutLineId = sol.id AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out - ON sol.inventoryLotLineId = ill_out.id - AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out - ON ill_out.inventoryLotId = il_out.id - AND il_out.deleted = 0 - LEFT JOIN inventory_lot_line ill_in_loc - ON ill_in_loc.inventoryLotId = il_in.id - AND ill_in_loc.deleted = 0 + LEFT JOIN inventory_lot_line ill + ON ill.id = ledger.inventoryLotLineId + AND ill.deleted = 0 + LEFT JOIN inventory_lot_line ill_sil + ON ill.id IS NULL + AND ill_sil.id = sil.inventoryLotLineId + AND ill_sil.deleted = 0 + LEFT JOIN inventory_lot_line ill_sol + ON ill.id IS NULL + AND ill_sil.id IS NULL + AND ill_sol.id = sol.inventoryLotLineId + AND ill_sol.deleted = 0 + LEFT JOIN inventory_lot il + ON il.id = COALESCE( + ill.inventoryLotId, + ill_sil.inventoryLotId, + ill_sol.inventoryLotId, + sil.inventoryLotId + ) + AND il.deleted = 0 LEFT JOIN warehouse wh - ON wh.id = COALESCE(ill_in_loc.warehouseId, ill_out.warehouseId) + ON wh.id = COALESCE(ill.warehouseId, ill_sil.warehouseId, ill_sol.warehouseId) AND wh.deleted = 0 LEFT JOIN items it - ON sl.itemId = it.id + ON ledger.itemId = it.id AND it.deleted = 0 LEFT JOIN ( SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure @@ -148,6 +166,7 @@ FROM stock_ledger sl $ledgerIndex JOIN uom_conversion uc ON uc.id = iu.uomId WHERE iu.stockUnit = 1 AND iu.deleted = 0 + $uomItemSql GROUP BY iu.itemId ) uom ON uom.itemId = it.id LEFT JOIN stock_out so @@ -174,26 +193,97 @@ FROM stock_ledger sl $ledgerIndex LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 -WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND sl.date >= :reportPeriodStart - AND sl.date < :reportPeriodEndExclusive +WHERE il.id IS NOT NULL $stockCategorySql - $itemCodeSql $storeLocationSql - AND COALESCE(il_in.id, il_out.id) IS NOT NULL ORDER BY - itemNo, - trnDate, - slId, - lotNo + $orderBy """.trimIndent() return attachItemTotals(jdbcDao.queryForList(sql, args)) } + /** + * Exact item codes become itemId so MySQL can use + * idx_ledger_item_deleted_date_id (itemId, deleted, date, id). + */ + private fun ledgerItemPredicate(itemCode: String?, args: MutableMap): LedgerItemPredicate { + val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(itemCode) + if (exact.isEmpty() && fuzzy.isEmpty()) { + return LedgerItemPredicate( + sql = "", + indexHint = "FORCE INDEX (idx_sl_deleted_date)", + singleItem = false, + itemIdParamNames = emptyList(), + ) + } + if (fuzzy.isEmpty()) { + val ids = lookupItemIds(exact) + if (ids.isNotEmpty()) { + val names = ids.mapIndexed { index, id -> + val name = "itemId_$index" + args[name] = id + name + } + return LedgerItemPredicate( + sql = "AND sl.itemId IN (${names.joinToString(", ") { ":$it" }})", + indexHint = "FORCE INDEX (idx_ledger_item_deleted_date_id)", + singleItem = ids.size == 1, + itemIdParamNames = names, + ) + } + } + val oneExactCode = fuzzy.isEmpty() && exact.size == 1 + return LedgerItemPredicate( + sql = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCode", args), + indexHint = if (exact.size + fuzzy.size == 1) "FORCE INDEX (idx_sl_deleted_itemcode_created)" else "", + singleItem = oneExactCode, + itemIdParamNames = emptyList(), + ) + } + + private fun lookupItemIds(codes: List): List { + if (codes.isEmpty()) return emptyList() + val args = HashMap(codes.size) + val names = codes.mapIndexed { index, code -> + val name = "lookupCode_$index" + args[name] = code + ":$name" + } + val sql = """ + SELECT id AS itemId + FROM items + WHERE code IN (${names.joinToString(", ")}) + """.trimIndent() + return jdbcDao.queryForList(sql, args).mapNotNull { row -> + when (val id = row["itemId"]) { + is Number -> id.toLong() + else -> id?.toString()?.toLongOrNull() + } + }.distinct() + } + + /** Blank start date: earliest movement for this filter, not MIN() of the whole ledger. */ + private fun earliestLedgerDate(predicate: LedgerItemPredicate, args: Map): String? { + val sql = """ + SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate + FROM stock_ledger sl ${predicate.indexHint} + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + ${predicate.sql} + """.trimIndent() + val row = jdbcDao.queryForList(sql, args).firstOrNull() + return row?.get("firstDate") as? String + } + + private data class LedgerItemPredicate( + val sql: String, + val indexHint: String, + val singleItem: Boolean, + val itemIdParamNames: List, + ) + /** * Item totals match the old window: first opening, sum in/out, last balance per itemCode. * Rows stay ordered by item, date, id. diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 68911e1c..bc738342 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -326,4 +326,17 @@ WHERE ill.id = :id fun findAllByIdInAndDeletedFalseWithRefs( @Param("ids") ids: Collection ): List + + /** Delivery-note route column: warehouse code without a lazy load per lot line. */ + @EntityGraph( + type = EntityGraph.EntityGraphType.FETCH, + attributePaths = ["warehouse"] + ) + @Query( + """ + SELECT ill FROM InventoryLotLine ill + WHERE ill.id IN :ids + """ + ) + fun findAllByIdInWithWarehouse(@Param("ids") ids: Collection): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 446fa208..b86bf0a1 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -446,6 +446,8 @@ open class StockInLineService( } dnNo = request.dnNo receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() + // The item label prints this field under 收貨日期. + productionDate = request.productionDate?.atStartOfDay() ?: receiptDate productLotNo = request.productLotNo this.type = "NOR" status = StockInLineStatus.PENDING.status @@ -1252,7 +1254,7 @@ open class StockInLineService( val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) field["qtyLabel"] = qtyLabel field["qtyDetail"] = qtyDetail - field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" + field["productionDate"] = (info.productionDate ?: info.receiptDate)?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo ?: "N/A" field["supplier"] = info.supplier ?: "N/A" From d024862c1d4287b4229069c762a1eb6e90c06cf2 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 4 Oct 2026 14:34:27 +0800 Subject: [PATCH 28/52] =?UTF-8?q?=E7=99=BC=E8=B2=A8=E8=88=87=E9=85=8D?= =?UTF-8?q?=E9=80=81>=E5=93=A1=E5=B7=A5=E7=99=BC=E8=B2=A8=E7=B8=BE?= =?UTF-8?q?=E6=95=88=20added=20a=20new=20access=20right=20=E7=B8=BE?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fpsms/config/security/SecurityConfig.java | 5 ++++ .../01_add_performance_authority.sql | 19 ++++++++++++ .../resources/qrCodeLabel/poItemPDF_v4.jrxml | 30 +++++++++---------- 3 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 16550992..6d9c1478 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -148,6 +148,11 @@ public class SecurityConfig { .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") .requestMatchers("/truckLaneSchedule/**") .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") + /* 員工發貨績效 (/chart/delivery): PERFORMANCE (績效) */ + .requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance") + .hasAuthority("PERFORMANCE") + .requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance-handlers") + .hasAuthority("PERFORMANCE") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql b/src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql new file mode 100644 index 00000000..0f803056 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +--changeset fpsms:add_performance_authority +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'PERFORMANCE' +--comment: Staff delivery performance on /chart/delivery requires PERFORMANCE (績效) +INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) +VALUES ('PERFORMANCE', '績效', 'CHART', 'Allow viewing staff delivery performance on /chart/delivery'); + +--changeset fpsms:grant_performance_authority_to_2fi +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM user_authority ua JOIN `user` u ON u.id = ua.userId JOIN authority a ON a.id = ua.authId WHERE u.username = '2fi' AND a.authority = 'PERFORMANCE' +--comment: Default grant 績效 to user 2fi +INSERT IGNORE INTO `user_authority` (`userId`, `authId`) +SELECT u.id, a.id +FROM `user` u +JOIN `authority` a ON a.authority = 'PERFORMANCE' +WHERE u.username = '2fi' + AND u.deleted = 0; diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml index 7125b95a..31e29cd3 100644 --- a/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml +++ b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml @@ -66,28 +66,28 @@ - + - + - + - + - + @@ -96,14 +96,14 @@ - + - + @@ -114,42 +114,42 @@ - + - + - + - + - + - + - + - + From b36c68666aa96e1076bb471e1d6bb40e8ec78809 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Mon, 5 Oct 2026 12:50:08 +0800 Subject: [PATCH 29/52] Fix DO stockQty and Location Search to use per-stock-UOM buckets. Stop mixing sibling UOMs on DO detail and keep Location available as onHand minus unavailable so hold is not subtracted. Co-authored-by: Cursor --- .../service/DeliveryOrderService.kt | 44 ++++++++++++------- .../entity/InventoryLotLineRepository.kt | 6 +-- .../service/InventoryLocationSearchQty.kt | 26 +++++++++++ .../modules/stock/service/InventoryService.kt | 14 +++--- .../service/DoLineStockQtyBucketTest.kt | 25 +++++++++++ .../service/InventoryLocationSearchQtyTest.kt | 31 +++++++++++++ 6 files changed, 121 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index cee583ca..bf7d0677 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -43,6 +43,7 @@ import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository import java.time.LocalTime import com.ffii.fpsms.modules.pickOrder.entity.Truck import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository +import com.ffii.fpsms.modules.stock.entity.InventoryRepository import com.ffii.fpsms.modules.stock.entity.StockOutRepository import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository import com.ffii.fpsms.modules.stock.entity.StockOut @@ -91,7 +92,6 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderLineInfo import com.ffii.fpsms.modules.master.entity.Items import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine import com.ffii.fpsms.modules.stock.entity.InventoryLotLine -import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import java.util.Locale import org.slf4j.Logger @@ -110,6 +110,7 @@ open class DeliveryOrderService( private val pickOrderRepository: PickOrderRepository, private val suggestedPickLotService: SuggestedPickLotService, private val inventoryLotLineRepository: InventoryLotLineRepository, + private val inventoryRepository: InventoryRepository, private val stockOutRepository: StockOutRepository, private val stockOutLineRepository: StockOutLIneRepository, private val pickOrderLineRepository: PickOrderLineRepository, @@ -481,14 +482,18 @@ open class DeliveryOrderService( return deliveryOrderRepository.findByIdAndDeletedIsFalse(id); } */ - private fun computeAvailableStockQtyForItem(itemId: Long): BigDecimal { - val availableLots = inventoryLotLineRepository - .findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) - return availableLots.sumOf { lot -> - (lot.inQty ?: BigDecimal.ZERO) - .minus(lot.outQty ?: BigDecimal.ZERO) - .minus(lot.holdQty ?: BigDecimal.ZERO) - } + /** + * DO line stock figure: one inventory bucket `(itemId, stockUomId)` where + * [stockUomId] is the line's UOM (`uom_conversion.id`). + * `available = onHand − unavailable` (hold not subtracted), same as Item Search. + * Must not sum lot lines across all stock UOMs of the item. + */ + private fun computeAvailableStockQtyForItem(itemId: Long, stockUomId: Long?): BigDecimal { + if (stockUomId == null) return BigDecimal.ZERO + val bucket = inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(itemId, stockUomId) + ?: return BigDecimal.ZERO + return (bucket.onHandQty ?: BigDecimal.ZERO) + .minus(bucket.unavailableQty ?: BigDecimal.ZERO) } /** @@ -603,11 +608,13 @@ open class DeliveryOrderService( null } - val itemIds = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.mapNotNull { it.item?.id }.distinct() - val stockQtyByItemId = itemIds.associateWith { itemId -> - computeAvailableStockQtyForItem(itemId) - } - val fallbackQtyByItemId = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.mapNotNull { line -> + val activeLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true } + val stockQtyByBucket = activeLines.mapNotNull { line -> + val itemId = line.item?.id ?: return@mapNotNull null + val stockUomId = line.uom?.id ?: return@mapNotNull null + (itemId to stockUomId) to computeAvailableStockQtyForItem(itemId, stockUomId) + }.toMap() + val fallbackQtyByItemId = activeLines.mapNotNull { line -> val itemId = line.item?.id ?: return@mapNotNull null itemId to (line.qty ?: BigDecimal.ZERO) }.toMap() @@ -632,9 +639,14 @@ open class DeliveryOrderService( isExtra = deliveryOrder.isExtra, handlerName = handlerName, truckLaneCode = truckLaneCode, - deliveryOrderLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.map { line -> + deliveryOrderLines = activeLines.map { line -> val itemId = line.item?.id - val stockQty = itemId?.let { stockQtyByItemId[it] } ?: BigDecimal.ZERO + val stockUomId = line.uom?.id + val stockQty = if (itemId != null && stockUomId != null) { + stockQtyByBucket[itemId to stockUomId] ?: BigDecimal.ZERO + } else { + BigDecimal.ZERO + } val requiredQty = line.qty ?: BigDecimal.ZERO val availableStatus = if (stockQty >= requiredQty) "available" else "insufficient" diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 75b60c7d..baf034fa 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -111,14 +111,14 @@ WHERE ill.id = :id item.code AS itemCode, item.name AS itemName, item.type AS itemType, - SUM(COALESCE(ill.inQty, 0)) AS onHandQty, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHandQty, SUM(COALESCE(ill.holdQty, 0)) AS onHoldQty, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS availableQty, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS availableQty, CAST(uom.id AS long) AS uomId, uom.code AS uomCode, uom.udfudesc AS uomUdfudesc, uom.udfShortDesc AS uomShortDesc, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS qtyPerSmallestUnit, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS qtyPerSmallestUnit, MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice, MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate FROM InventoryLotLine ill diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt new file mode 100644 index 00000000..5037017c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt @@ -0,0 +1,26 @@ +package com.ffii.fpsms.modules.stock.service + +import java.math.BigDecimal + +/** + * Location Search qty rules aligned with inventory **bucket** display + * (`available = onHand − unavailable`; hold is separate, not subtracted from available). + * + * Location Search only aggregates `status = available` lot lines at the filtered location, + * so unavailable at that slice is 0 and available collapses to physical remain `in − out`. + */ +object InventoryLocationSearchQty { + fun onHand(inQty: BigDecimal?, outQty: BigDecimal?): BigDecimal { + val inn = inQty ?: BigDecimal.ZERO + val out = outQty ?: BigDecimal.ZERO + return inn.subtract(out) + } + + /** Bucket-aligned available for available-status lines: physical remain (hold not subtracted). */ + fun available(inQty: BigDecimal?, outQty: BigDecimal?): BigDecimal = onHand(inQty, outQty) + + /** SQL fragments used by [InventoryService.buildLocationSearchUnionSql]. */ + const val SQL_ON_HAND = "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))" + const val SQL_AVAILABLE = "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))" + const val SQL_ON_HOLD = "SUM(COALESCE(ill.holdQty, 0))" +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt index 6570d0cb..5af3b38f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt @@ -101,8 +101,10 @@ open class InventoryService( } /** - * Location Search: one row per item + lot-line UoM, plus current master stockUnit - * as a 0-qty row when that UoM has no matching lots. + * Location Search: one row per item + lot-line stock UoM (bucket key), plus current + * master stockUnit as a 0-qty row when that UoM has no matching lots. + * Qty formula matches inventory bucket display: available = onHand − unavailable + * (hold shown separately; not subtracted). See [InventoryLocationSearchQty]. */ open fun searchInventoriesByLatestInventory( request: SearchInventoryRequest, @@ -276,14 +278,14 @@ open class InventoryService( item.code AS itemCode, item.name AS itemName, item.type AS itemType, - SUM(COALESCE(ill.inQty, 0)) AS onHandQty, - SUM(COALESCE(ill.holdQty, 0)) AS onHoldQty, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS availableQty, + ${InventoryLocationSearchQty.SQL_ON_HAND} AS onHandQty, + ${InventoryLocationSearchQty.SQL_ON_HOLD} AS onHoldQty, + ${InventoryLocationSearchQty.SQL_AVAILABLE} AS availableQty, uom.id AS uomId, uom.code AS uomCode, uom.udfudesc AS uomUdfudesc, uom.udfShortDesc AS uomShortDesc, - SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)) AS qtyPerSmallestUnit, + ${InventoryLocationSearchQty.SQL_AVAILABLE} AS qtyPerSmallestUnit, MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice, MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate FROM inventory_lot_line ill diff --git a/src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt new file mode 100644 index 00000000..eb5a2393 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/deliveryOrder/service/DoLineStockQtyBucketTest.kt @@ -0,0 +1,25 @@ +package com.ffii.fpsms.modules.deliveryOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Documents DO detail stockQty rule after bucket fix: + * available = onHand − unavailable for one (itemId, stockUomId); never sum sibling UOMs. + */ +class DoLineStockQtyBucketTest { + + private fun bucketAvailable(onHand: BigDecimal?, unavailable: BigDecimal?): BigDecimal = + (onHand ?: BigDecimal.ZERO).minus(unavailable ?: BigDecimal.ZERO) + + @Test + fun two_buckets_must_not_be_added() { + // MA0879-style: 箱 avail 12 + 個 avail 760 — DO line in 箱 must show 12, not 772 + val box = bucketAvailable(BigDecimal("15"), BigDecimal("3")) + val each = bucketAvailable(BigDecimal("880"), BigDecimal("120")) + assertEquals(BigDecimal("12"), box) + assertEquals(BigDecimal("760"), each) + assertEquals(BigDecimal("772"), box.add(each)) // old bug total — must not be returned for either line + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt new file mode 100644 index 00000000..d9e6c7f6 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQtyTest.kt @@ -0,0 +1,31 @@ +package com.ffii.fpsms.modules.stock.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class InventoryLocationSearchQtyTest { + + @Test + fun onHand_and_available_are_physical_remain_not_minus_hold() { + // in=10, out=3 → remain 7; hold must not shrink Location Search available + assertEquals(BigDecimal("7"), InventoryLocationSearchQty.onHand(BigDecimal("10"), BigDecimal("3"))) + assertEquals(BigDecimal("7"), InventoryLocationSearchQty.available(BigDecimal("10"), BigDecimal("3"))) + } + + @Test + fun sql_fragments_match_bucket_not_pickable_formula() { + assertEquals( + "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))", + InventoryLocationSearchQty.SQL_ON_HAND, + ) + assertEquals( + "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))", + InventoryLocationSearchQty.SQL_AVAILABLE, + ) + assertFalse(InventoryLocationSearchQty.SQL_AVAILABLE.contains("holdQty")) + assertTrue(InventoryLocationSearchQty.SQL_ON_HOLD.contains("holdQty")) + } +} From 04783a2e74105c5678890ae0d34b269aa24ef9d3 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 11:20:59 +0800 Subject: [PATCH 30/52] 2.1 fix and 2.7 fix improve report update --- .../ledgerfix/StockLedgerFixAdjService.kt | 56 +++- .../StockLedgerFixBackfillService.kt | 243 +++++++++++++++++- .../ledgerfix/StockLedgerFixExportService.kt | 196 +++++++++++++- .../ledgerfix/StockLedgerFixQueryService.kt | 86 ++++++- .../ledgerfix/StockLedgerFixSupport.kt | 12 + .../master/web/models/StockLedgerFixModels.kt | 12 + .../service/StockLedgerReportService.kt | 5 +- 7 files changed, 583 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt index f30b6606..b5a2c61e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -24,7 +24,8 @@ open class StockLedgerFixAdjService( * Quantity is onHandQty minus the bucket ledger net, including rows with no lot. * The row is attached to the lot line whose own net is furthest from its ledger. * Lot lines and inventory are not updated, so the lot-line trigger does not run. - * A bucket whose ledger net already equals onHandQty is skipped. A second run inserts nothing. + * Each ADJ line also gets one stock_adjustment_record. A later run fills any ADJ line that still has none. + * A bucket whose ledger net already equals onHandQty is skipped. A second run inserts no new ADJ. * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). * Ledger net uses `date < adjDate+1`. */ @@ -96,6 +97,10 @@ open class StockLedgerFixAdjService( } } } + val adjustmentRecords = TransactionTemplate(support.transactionManager).let { tx -> + tx.timeout = 300 + tx.execute { insertMissingAdjustmentRecords() } ?: 0 + } val lotIds = linkedSetOf() fun addLot(lotId: Long, invId: Long?) { if (lotId > 0) lotIds.add(lotId) @@ -122,8 +127,8 @@ open class StockLedgerFixAdjService( support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } } else 0 log.info( - "stock-ledger-fix adj {} buckets={} in={} out={} lotQty={} bal={} day={} total={}ms", - adjDate, plans.size, insertedIn, insertedOut, + "stock-ledger-fix adj {} buckets={} in={} out={} adjRecord={} lotQty={} bal={} day={} total={}ms", + adjDate, plans.size, insertedIn, insertedOut, adjustmentRecords, filledLotQty, filledBalance, dayRows, (System.nanoTime() - t0) / 1_000_000, ) @@ -135,6 +140,7 @@ open class StockLedgerFixAdjService( filledLotQty = filledLotQty, filledBalance = filledBalance, dayRowsWritten = dayRows, + adjustmentRecords = adjustmentRecords, ) } catch (e: Exception) { log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message) @@ -475,6 +481,50 @@ open class StockLedgerFixAdjService( ) } + /** One remarks row per stock-ledger-fix ADJ line that does not already have one. */ + private fun insertMissingAdjustmentRecords(): Int { + return support.jdbcDao.executeUpdate( + """ + INSERT INTO stock_adjustment_record ( + created, createdBy, version, modified, modifiedBy, deleted, + itemId, itemCode, itemName, lotNo, inQty, outQty, remarks, + stockInLineId, stockOutLineId + ) + SELECT + NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + sl.itemId, + LEFT(COALESCE(sl.itemCode, it.code), 50), + LEFT(it.name, 200), + LEFT(il.lotNo, 512), + CASE WHEN sl.stockInLineId IS NOT NULL THEN sl.inQty ELSE NULL END, + CASE WHEN sl.stockOutLineId IS NOT NULL THEN sl.outQty ELSE NULL END, + LEFT(COALESCE(sil.remarks, so.remarks, 'stock-ledger-fix 2.7 ADJ'), 500), + sl.stockInLineId, + sl.stockOutLineId + FROM stock_ledger sl + INNER JOIN items it ON it.id = sl.itemId + LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId + LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId + LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId + LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId + LEFT JOIN stock_out so ON so.id = sol.stockOutId + WHERE sl.deleted = 0 + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + AND NOT EXISTS ( + SELECT 1 + FROM stock_adjustment_record r + WHERE IFNULL(r.deleted, 0) = 0 + AND ( + (sl.stockInLineId IS NOT NULL AND r.stockInLineId = sl.stockInLineId) + OR (sl.stockOutLineId IS NOT NULL AND r.stockOutLineId = sl.stockOutLineId) + ) + ) + """.trimIndent(), + ) + } + /** Line on shared stock_in; does not UPDATE inventory_lot_line or inventory. */ private fun insertAdjStockInLine( g: AdjGap, diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt index 38588957..bd586ccb 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -79,7 +79,8 @@ open class StockLedgerFixBackfillService( "uomId" to (inv["uomId"] ?: 0), "today" to toExclusive, ) - val filledLotLineId = support.timed("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } + val lotStep = support.timedValue("inv 2.1") { fillLotLineIdScope(args, byInventory = true) } + val filledLotLineId = lotStep.filled val filledUomId = support.timed("inv 2.2") { fillUomIdScope(args, byInventory = true) } val filledInventoryId = support.timed("inv 2.3") { fillInventoryIdScope(args, byInventory = true) } val filledLotQty = support.timed("inv 2.4") { fillLotQtyHistory(args, byInventory = true) } @@ -107,6 +108,11 @@ open class StockLedgerFixBackfillService( stillMissUom = still.missUom, stillMissInventoryId = still.missInventoryId, stillMissLotQty = still.missLotQty, + deletedUnlinkedLot = lotStep.deletedUnlinked, + existingLotMatch = lotStep.existingMatch, + existingLotMismatch = lotStep.existingMismatch, + deletedBlankOutLine = lotStep.deletedBlankOutLine, + alignedOutLine = lotStep.alignedOutLine, ) } catch (e: Exception) { log.warn("stock-ledger-fix inventory {} FAILED after {}ms: {}", inventoryId, (System.nanoTime() - t0) / 1_000_000, e.message) @@ -130,7 +136,8 @@ open class StockLedgerFixBackfillService( ) if (exists == 0) throw IllegalArgumentException("lot line not found: $lotLineId") val args = mapOf("lotId" to lotLineId, "today" to today) - val filledLotLineId = support.timed("lot 2.1") { fillLotLineIdScope(args, byInventory = false) } + val lotStep = support.timedValue("lot 2.1") { fillLotLineIdScope(args, byInventory = false) } + val filledLotLineId = lotStep.filled val filledUomId = support.timed("lot 2.2") { fillUomIdScope(args, byInventory = false) } val filledInventoryId = support.timed("lot 2.3") { fillInventoryIdScope(args, byInventory = false) } val filledLotQty = support.timed("lot 2.4") { fillLotQtyHistory(args, byInventory = false) } @@ -156,6 +163,11 @@ open class StockLedgerFixBackfillService( stillMissUom = still.missUom, stillMissInventoryId = still.missInventoryId, stillMissLotQty = still.missLotQty, + deletedUnlinkedLot = lotStep.deletedUnlinked, + existingLotMatch = lotStep.existingMatch, + existingLotMismatch = lotStep.existingMismatch, + deletedBlankOutLine = lotStep.deletedBlankOutLine, + alignedOutLine = lotStep.alignedOutLine, ) } catch (e: Exception) { log.warn("stock-ledger-fix lot {} FAILED after {}ms: {}", lotLineId, (System.nanoTime() - t0) / 1_000_000, e.message) @@ -184,7 +196,8 @@ open class StockLedgerFixBackfillService( val t0 = System.nanoTime() try { val args = support.dayArgs(day) - val filledLotLineId = if ("2.1" in steps) support.timed("2.1 fillLotLineId") { fillLotLineId(args) } else 0 + val lotStep = if ("2.1" in steps) support.timedValue("2.1 fillLotLineId") { fillLotLineId(args) } else LotLineStep(0, 0, 0, 0) + val filledLotLineId = lotStep.filled val filledUomId = if ("2.2" in steps) support.timed("2.2 fillUomId") { fillUomId(args) } else 0 val filledInventoryId = if ("2.3" in steps) support.timed("2.3 fillInventoryId") { fillInventoryId(args) } else 0 val filledLotQty = if ("2.4" in steps) support.timed("2.4 fillLotQty") { fillLotQty(args) } else 0 @@ -215,6 +228,11 @@ open class StockLedgerFixBackfillService( stillMissUom = still.missUom, stillMissInventoryId = still.missInventoryId, stillMissLotQty = still.missLotQty, + deletedUnlinkedLot = lotStep.deletedUnlinked, + existingLotMatch = lotStep.existingMatch, + existingLotMismatch = lotStep.existingMismatch, + deletedBlankOutLine = lotStep.deletedBlankOutLine, + alignedOutLine = lotStep.alignedOutLine, ) } catch (e: Exception) { log.warn( @@ -251,6 +269,11 @@ open class StockLedgerFixBackfillService( val t0 = System.nanoTime() try { var filledLotLineId = 0 + var deletedUnlinkedLot = 0 + var existingLotMatch = 0 + var existingLotMismatch = 0 + var deletedBlankOutLine = 0 + var alignedOutLine = 0 var filledUomId = 0 var filledInventoryId = 0 var filledLotQty = 0 @@ -259,7 +282,15 @@ open class StockLedgerFixBackfillService( var d = from while (!d.isAfter(to)) { val args = support.dayArgs(d) - if ("2.1" in steps) filledLotLineId += support.timed("range $d 2.1") { fillLotLineId(args) } + if ("2.1" in steps) { + val lotStep = support.timedValue("range $d 2.1") { fillLotLineId(args) } + filledLotLineId += lotStep.filled + deletedUnlinkedLot += lotStep.deletedUnlinked + existingLotMatch += lotStep.existingMatch + existingLotMismatch += lotStep.existingMismatch + deletedBlankOutLine += lotStep.deletedBlankOutLine + alignedOutLine += lotStep.alignedOutLine + } if ("2.2" in steps) filledUomId += support.timed("range $d 2.2") { fillUomId(args) } if ("2.3" in steps) filledInventoryId += support.timed("range $d 2.3") { fillInventoryId(args) } if ("2.4" in steps) filledLotQty += support.timed("range $d 2.4") { fillLotQty(args) } @@ -290,6 +321,11 @@ open class StockLedgerFixBackfillService( stillMissUom = still.missUom, stillMissInventoryId = still.missInventoryId, stillMissLotQty = still.missLotQty, + deletedUnlinkedLot = deletedUnlinkedLot, + existingLotMatch = existingLotMatch, + existingLotMismatch = existingLotMismatch, + deletedBlankOutLine = deletedBlankOutLine, + alignedOutLine = alignedOutLine, ) } catch (e: Exception) { log.warn( @@ -302,8 +338,22 @@ open class StockLedgerFixBackfillService( } } + /** 2.1 fill, then soft-delete rows whose in/out line still cannot point at a lot line. */ + private fun fillLotLineId(args: Map): LotLineStep { + val filled = fillLotLineIdOnly(args) + val where = "sl.date >= :day AND sl.date < :dayNext" + val deleted = softDeleteUnlinkedLot(where, args) + val outLine = alignExistingOutLine(where, args) + val check = countExistingLotLineMatch(where, args) + return LotLineStep( + filled, deleted, check.first, check.second, + deletedBlankOutLine = outLine.first, + alignedOutLine = outLine.second, + ) + } + /** 2.1 SOL / SIL lot line; one-line fallback when SIL has lot header only. */ - private fun fillLotLineId(args: Map): Int { + private fun fillLotLineIdOnly(args: Map): Int { val fromSolSil = support.timed("2.1a solSil") { support.jdbcDao.executeUpdate( """ @@ -358,6 +408,136 @@ open class StockLedgerFixBackfillService( return fromSolSil + fromOneLine } + /** + * Rows that already have a lot line stay. Soft-delete only when a related + * stock in/out line exists and 2.1a/2.1b still could not set inventoryLotLineId. + */ + private fun softDeleteUnlinkedLot( + dateWhere: String, + args: Map, + extraAnd: String = "1 = 1", + ): Int { + return support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + SET + sl.deleted = 1, + sl.modified = NOW(), + sl.modifiedBy = 'stock-ledger-fix' + WHERE sl.deleted = 0 + AND ($dateWhere) + AND sl.inventoryLotLineId IS NULL + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + AND ($extraAnd) + """.trimIndent(), + args, + ) + } + + /** Ledger lot line vs live SIL/SOL lot line. Does not update those rows. */ + private fun countExistingLotLineMatch(dateWhere: String, args: Map): Pair { + val row = support.jdbcDao.queryForList( + """ + SELECT + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = sl.inventoryLotLineId + ) AS matched, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + AND ( + COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + OR COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) <> sl.inventoryLotLineId + ) + ) AS mismatched + FROM stock_ledger sl + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 + WHERE sl.deleted = 0 + AND ($dateWhere) + """.trimIndent(), + args, + ).firstOrNull() + return support.toInt(row?.get("matched")) to support.toInt(row?.get("mismatched")) + } + + private data class LotLineStep( + val filled: Int, + val deletedUnlinked: Int, + val existingMatch: Int, + val existingMismatch: Int, + val deletedBlankOutLine: Int = 0, + val alignedOutLine: Int = 0, + ) + + /** + * Ledger already has a lot line. + * Stock-out line lot is null: clear the ledger lot and soft-delete the ledger. + * Stock-out line lot differs: set the line to the ledger lot. Skip a line whose live ledgers disagree. + */ + private fun alignExistingOutLine( + dateWhere: String, + args: Map, + extraAnd: String = "1 = 1", + ): Pair { + val aligned = support.jdbcDao.executeUpdate( + """ + UPDATE stock_out_line sol + INNER JOIN ( + SELECT sl.stockOutLineId AS lineId, MIN(sl.inventoryLotLineId) AS lotLineId + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND ($dateWhere) + AND sl.stockOutLineId IS NOT NULL + AND sl.inventoryLotLineId IS NOT NULL + AND ($extraAnd) + AND NOT EXISTS ( + SELECT 1 + FROM stock_ledger sl2 + WHERE sl2.deleted = 0 + AND sl2.stockOutLineId = sl.stockOutLineId + AND sl2.inventoryLotLineId IS NOT NULL + AND sl2.inventoryLotLineId <> sl.inventoryLotLineId + ) + GROUP BY sl.stockOutLineId + ) src ON src.lineId = sol.id + SET + sol.inventoryLotLineId = src.lotLineId, + sol.modified = NOW(), + sol.modifiedBy = 'stock-ledger-fix' + WHERE IFNULL(sol.deleted, 0) = 0 + AND sol.inventoryLotLineId IS NOT NULL + AND sol.inventoryLotLineId <> src.lotLineId + """.trimIndent(), + args, + ) + val deletedBlank = support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + SET + sl.inventoryLotLineId = NULL, + sl.deleted = 1, + sl.modified = NOW(), + sl.modifiedBy = 'stock-ledger-fix' + WHERE sl.deleted = 0 + AND ($dateWhere) + AND sl.inventoryLotLineId IS NOT NULL + AND sol.inventoryLotLineId IS NULL + AND ($extraAnd) + """.trimIndent(), + args, + ) + return deletedBlank to aligned + } + /** 2.2 lot line stock UOM → uom_conversion id. Include deleted item_uom (historical lots still point there). */ private fun fillUomId(args: Map): Int { return support.jdbcDao.executeUpdate( @@ -758,7 +938,7 @@ open class StockLedgerFixBackfillService( } ?: 0 } - private fun fillLotLineIdScope(args: Map, byInventory: Boolean): Int { + private fun fillLotLineIdScope(args: Map, byInventory: Boolean): LotLineStep { if (byInventory) return fillLotLineIdScopeByInventory(args) val lotFilter = """ AND ( @@ -818,12 +998,34 @@ open class StockLedgerFixBackfillService( args, ) } - return fromSolSil + fromOneLine + val filled = fromSolSil + fromOneLine + val deleted = softDeleteUnlinkedLot( + "sl.date < :today", + args, + "sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId)", + ) + val outLine = alignExistingOutLine("sl.date < :today", args, "sl.inventoryLotLineId = :lotId") + val check = countExistingLotLineMatch( + """ + sl.date < :today + AND ( + sl.inventoryLotLineId = :lotId + OR sil.inventoryLotId IN (SELECT inventoryLotId FROM inventory_lot_line WHERE id = :lotId) + ) + """.trimIndent(), + args, + ) + return LotLineStep( + filled, deleted, check.first, check.second, + deletedBlankOutLine = outLine.first, + alignedOutLine = outLine.second, + ) } /** Collect candidate ledger ids first — do not UPDATE with date < today OR …. */ - private fun fillLotLineIdScopeByInventory(args: Map): Int { - return support.withTempTx { + private fun fillLotLineIdScopeByInventory(args: Map): LotLineStep { + var step = LotLineStep(0, 0, 0, 0) + support.withTempTx { support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") @@ -927,13 +1129,34 @@ open class StockLedgerFixBackfillService( """.trimIndent(), ) } - fromSolSil + fromOneLine + val deleted = support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_lot_ids t ON t.id = sl.id + SET + sl.deleted = 1, + sl.modified = NOW(), + sl.modifiedBy = 'stock-ledger-fix' + WHERE sl.deleted = 0 + AND sl.inventoryLotLineId IS NULL + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + """.trimIndent(), + ) + val outLine = alignExistingOutLine("sl.date < :today", args, "sl.inventoryId = :inventoryId") + val check = countExistingLotLineMatch("sl.inventoryId = :inventoryId", args) + step = LotLineStep( + fromSolSil + fromOneLine, deleted, check.first, check.second, + deletedBlankOutLine = outLine.first, + alignedOutLine = outLine.second, + ) + 0 } finally { support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_one_line") support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_sil_lots") support.jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_lot_ids") } } + return step } private fun fillUomIdScope(args: Map, byInventory: Boolean): Int { diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt index 1c1dea20..10cf52f8 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt @@ -21,8 +21,9 @@ open class StockLedgerFixExportService( /** * Export SQL patch for target DB (same dump lineage / freeze). * [partsRaw] omit / empty / all = ledger + 2.6 (legacy). Else subset: - * 1.0 inventory, 2.3 inventoryId, ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 SIL/SOL + ADJ INSERT. - * Apply order in file: 1.0 → ledger(+2.3) → 2.7 docs → 2.7 ADJ → 2.6. + * 1.0 inventory, 2.1 soft-deleted ledger + stock-out lot, 2.3 inventoryId, + * ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 SIL/SOL + adjustment record + ADJ INSERT. + * Apply order in file: 1.0 → 2.1 → ledger(+2.3) → 2.7 docs → 2.7 ADJ → 2.6. */ open fun writeExportSql( from: LocalDate, @@ -44,12 +45,13 @@ open class StockLedgerFixExportService( } val parts = parseExportParts(partsRaw) val want10 = "1.0" in parts + val want21 = "2.1" in parts val want23 = "2.3" in parts val wantLedger = "ledger" in parts val want26 = "2.6" in parts val want27 = "2.7" in parts - if (!want10 && !want23 && !wantLedger && !want26 && !want27) { - throw IllegalArgumentException("parts must include at least one of: 1.0, 2.3, ledger, 2.6, 2.7") + if (!want10 && !want21 && !want23 && !wantLedger && !want26 && !want27) { + throw IllegalArgumentException("parts must include at least one of: 1.0, 2.1, 2.3, ledger, 2.6, 2.7") } val args = mapOf("from" to from, "toExclusive" to toExclusive) val ledgerCnt = if (wantLedger || want23 || want27) { @@ -110,7 +112,7 @@ open class StockLedgerFixExportService( writer.write("-- stock-ledger-fix patch\n") writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") writer.write("-- parts=${parts.joinToString(",")}\n") - writer.write("-- apply order: 1.0 inventory → ledger(+2.3) UPDATE → 2.7 SIL/SOL then ADJ INSERT → 2.6 stock_lot_day\n") + writer.write("-- apply order: 1.0 inventory → 2.1 delete ledger + stock_out lot → ledger(+2.3) UPDATE → 2.7 SIL/SOL/adjustment record then ADJ INSERT → 2.6 stock_lot_day\n") writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") if (want23 && !want10) { writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n") @@ -124,6 +126,9 @@ open class StockLedgerFixExportService( if (want10) { writeExportInventory10(writer, jdbc) } + if (want21) { + writeExport21(writer, jdbc, args) + } if (wantLedger || want23) { writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23) } @@ -209,6 +214,146 @@ open class StockLedgerFixExportService( } else { writer.write("-- (no inventory rows)\n\n") } + writeTempUpdate( + writer, + jdbc, + emptyMap(), + title = "-- === 1.0 soft-delete orphan inventory (stockUomId still null) ===\n", + selectSql = """ + SELECT id, modified, modifiedBy + FROM inventory + WHERE deleted = 1 + AND modifiedBy = 'stock-ledger-fix' + ORDER BY id + """.trimIndent(), + tempName = "tmp_sl_fix_inv_del", + columnDefs = "modified DATETIME NULL, modifiedBy VARCHAR(255) NULL", + columns = listOf("id", "modified", "modifiedBy"), + updateSql = """ + UPDATE inventory i + INNER JOIN tmp_sl_fix_inv_del t ON t.id = i.id + SET + i.deleted = 1, + i.modified = t.modified, + i.modifiedBy = t.modifiedBy; + """.trimIndent(), + emptyComment = "-- (no stock-ledger-fix deleted inventory rows)", + ) + } + + /** + * 2.1 results the live-row ledger patch cannot carry: + * soft-deleted ledger rows (including lot cleared when the out line has no lot), + * and stock_out_line.inventoryLotLineId set to the ledger lot. + */ + private fun writeExport21( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + ) { + writeTempUpdate( + writer, + jdbc, + args, + title = "-- === 2.1 soft-delete stock_ledger (id missing on target is skipped) ===\n", + selectSql = """ + SELECT id, inventoryLotLineId, modified, modifiedBy + FROM stock_ledger + WHERE deleted = 1 + AND modifiedBy = 'stock-ledger-fix' + AND date >= :from AND date < :toExclusive + ORDER BY id + """.trimIndent(), + tempName = "tmp_sl_fix_ledger_del", + columnDefs = "inventoryLotLineId BIGINT NULL, modified DATETIME NULL, modifiedBy VARCHAR(255) NULL", + columns = listOf("id", "inventoryLotLineId", "modified", "modifiedBy"), + updateSql = """ + UPDATE stock_ledger sl + INNER JOIN tmp_sl_fix_ledger_del t ON t.id = sl.id + SET + sl.deleted = 1, + sl.inventoryLotLineId = t.inventoryLotLineId, + sl.modified = t.modified, + sl.modifiedBy = t.modifiedBy; + """.trimIndent(), + emptyComment = "-- (no stock-ledger-fix deleted ledger rows in range)", + ) + writeTempUpdate( + writer, + jdbc, + args, + title = "-- === 2.1 stock_out_line lot = ledger lot (existing lines only) ===\n", + selectSql = """ + SELECT sol.id, sol.inventoryLotLineId, sol.modified, sol.modifiedBy + FROM stock_out_line sol + WHERE sol.modifiedBy = 'stock-ledger-fix' + AND IFNULL(sol.createdBy, '') <> 'stock-ledger-fix' + AND EXISTS ( + SELECT 1 + FROM stock_ledger sl + WHERE sl.stockOutLineId = sol.id + AND sl.date >= :from AND sl.date < :toExclusive + ) + ORDER BY sol.id + """.trimIndent(), + tempName = "tmp_sl_fix_sol_lot", + columnDefs = "inventoryLotLineId BIGINT NULL, modified DATETIME NULL, modifiedBy VARCHAR(255) NULL", + columns = listOf("id", "inventoryLotLineId", "modified", "modifiedBy"), + updateSql = """ + UPDATE stock_out_line sol + INNER JOIN tmp_sl_fix_sol_lot t ON t.id = sol.id + SET + sol.inventoryLotLineId = t.inventoryLotLineId, + sol.modified = t.modified, + sol.modifiedBy = t.modifiedBy; + """.trimIndent(), + emptyComment = "-- (no stock-ledger-fix stock_out_line lot updates in range)", + ) + } + + private fun writeTempUpdate( + writer: BufferedWriter, + jdbc: NamedParameterJdbcTemplate, + args: Map, + title: String, + selectSql: String, + tempName: String, + columnDefs: String, + columns: List, + updateSql: String, + emptyComment: String, + ) { + writer.write(title) + writer.write("DROP TEMPORARY TABLE IF EXISTS $tempName;\n") + writer.write("CREATE TEMPORARY TABLE $tempName (\n") + writer.write(" id INT NOT NULL PRIMARY KEY,\n $columnDefs\n);\n\n") + var batch = 0 + var any = false + jdbc.query( + selectSql, + args, + RowCallbackHandler { rs -> + if (batch == 0) { + writer.write("INSERT INTO $tempName (${columns.joinToString(", ")}) VALUES\n") + } else { + writer.write(",\n") + } + writer.write("(" + columns.joinToString(", ") { rsSql(rs, it) } + ")") + any = true + batch++ + if (batch >= 500) { + writer.write(";\n\n") + batch = 0 + } + }, + ) + if (batch > 0) writer.write(";\n\n") + if (any) { + writer.write("$updateSql\n\n") + } else { + writer.write("$emptyComment\n\n") + } + writer.write("DROP TEMPORARY TABLE IF EXISTS $tempName;\n\n") } private fun writeExportLedgerPatch( @@ -397,6 +542,42 @@ open class StockLedgerFixExportService( "modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0", emptyComment = "-- (no ADJ stock_out_line in range)", ) + writer.write("-- === 2.7 stock_adjustment_record (after SIL/SOL; FK to those lines) ===\n") + writeExportRows( + writer, + jdbc, + """ + SELECT DISTINCT r.id, r.created, r.createdBy, r.version, r.modified, r.modifiedBy, r.deleted, + r.itemId, r.itemCode, r.itemName, r.lotNo, r.inQty, r.outQty, r.remarks, + r.stockInLineId, r.stockOutLineId + FROM stock_adjustment_record r + INNER JOIN stock_ledger sl + ON ( + (r.stockInLineId IS NOT NULL AND sl.stockInLineId = r.stockInLineId) + OR (r.stockOutLineId IS NOT NULL AND sl.stockOutLineId = r.stockOutLineId) + ) + WHERE IFNULL(r.deleted, 0) = 0 + AND r.createdBy = 'stock-ledger-fix' + AND sl.deleted = 0 + AND sl.date >= :from AND sl.date < :toExclusive + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + ORDER BY r.id + """.trimIndent(), + args, + table = "stock_adjustment_record", + columns = listOf( + "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", + "itemId", "itemCode", "itemName", "lotNo", "inQty", "outQty", "remarks", + "stockInLineId", "stockOutLineId", + ), + onDup = "itemCode=VALUES(itemCode), itemName=VALUES(itemName), lotNo=VALUES(lotNo), " + + "inQty=VALUES(inQty), outQty=VALUES(outQty), remarks=VALUES(remarks), " + + "stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " + + "modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + + "version=stock_adjustment_record.version+1, deleted=0", + emptyComment = "-- (no ADJ stock_adjustment_record in range)", + ) } private fun writeExportAdjInsert( @@ -557,11 +738,12 @@ open class StockLedgerFixExportService( val key = when (t) { "1.0", "10", "inventory" -> "1.0" "2.3", "23", "inventoryid" -> "2.3" - "ledger", "2.1", "2.2", "2.4", "2.5" -> "ledger" + "2.1", "21" -> "2.1" + "ledger", "2.2", "2.4", "2.5" -> "ledger" "2.6", "26", "day", "stock_lot_day" -> "2.6" "2.7", "27", "adj" -> "2.7" else -> throw IllegalArgumentException( - "unknown export part '$token' (use 1.0, 2.3, ledger, 2.6, 2.7, or all)", + "unknown export part '$token' (use 1.0, 2.1, 2.3, ledger, 2.6, 2.7, or all)", ) } out.add(key) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt index af138c99..38659152 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt @@ -180,10 +180,36 @@ open class StockLedgerFixQueryService( sl.inventoryLotLineId IS NULL AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) - ) AS cannotNoSource + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + ) AS cannotNoSource, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = sl.inventoryLotLineId + ) AS existingLotMatch, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + AND ( + COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + OR COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) <> sl.inventoryLotLineId + ) + ) AS existingLotMismatch, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND sol.id IS NOT NULL + AND sol.inventoryLotLineId IS NULL + ) AS outLineBlank, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND sol.inventoryLotLineId IS NOT NULL + AND sol.inventoryLotLineId <> sl.inventoryLotLineId + ) AS outLineDiffers FROM stock_ledger sl - LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId - LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId + LEFT JOIN stock_out_line sol + ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 + LEFT JOIN stock_in_line sil + ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0 LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId LEFT JOIN ( @@ -315,6 +341,10 @@ open class StockLedgerFixQueryService( cannotNoSource = support.toInt(ledger["cannotNoSource"]), earlyTkeOver = 0, otherOver = 0, + existingLotMatch = support.toInt(ledger["existingLotMatch"]), + existingLotMismatch = support.toInt(ledger["existingLotMismatch"]), + outLineBlank = support.toInt(ledger["outLineBlank"]), + outLineDiffers = support.toInt(ledger["outLineDiffers"]), extra = listOf( part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal), ), @@ -541,7 +571,31 @@ open class StockLedgerFixQueryService( sl.inventoryLotLineId IS NULL AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) - ) AS cannotNoSource + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + ) AS cannotNoSource, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = sl.inventoryLotLineId + ) AS existingLotMatch, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) + AND ( + COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL + OR COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) <> sl.inventoryLotLineId + ) + ) AS existingLotMismatch, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND sol.id IS NOT NULL + AND sol.inventoryLotLineId IS NULL + ) AS outLineBlank, + SUM( + sl.inventoryLotLineId IS NOT NULL + AND sol.inventoryLotLineId IS NOT NULL + AND sol.inventoryLotLineId <> sl.inventoryLotLineId + ) AS outLineDiffers FROM stock_ledger sl LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0 @@ -599,6 +653,10 @@ open class StockLedgerFixQueryService( cannotNoSource = support.toInt(ledger["cannotNoSource"]), earlyTkeOver = earlyTke, otherOver = (overIssue - earlyTke).coerceAtLeast(0), + existingLotMatch = support.toInt(ledger["existingLotMatch"]), + existingLotMismatch = support.toInt(ledger["existingLotMismatch"]), + outLineBlank = support.toInt(ledger["outLineBlank"]), + outLineDiffers = support.toInt(ledger["outLineDiffers"]), ), ) } @@ -621,6 +679,10 @@ open class StockLedgerFixQueryService( cannotNoSource: Int, earlyTkeOver: Int, otherOver: Int, + existingLotMatch: Int = 0, + existingLotMismatch: Int = 0, + outLineBlank: Int = 0, + outLineDiffers: Int = 0, extra: List = emptyList(), ): List { fun field(key: String, label: String, miss: Int, incorrect: Int) = @@ -643,12 +705,24 @@ open class StockLedgerFixQueryService( add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect)) add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue)) add(field("balance", "balance", balanceMiss, 0)) + add( + StockLedgerFixCheckPart( + key = "existingLotLine", + label = "已有 lot line:入出倉列是否同一條", + ok = existingLotMatch, + miss = 0, + incorrect = existingLotMismatch, + group = "field", + ), + ) addAll(extra) + add(canFix("outLineBlank", "2.1:出倉列批號是空,流水帳改空並軟刪", outLineBlank)) + add(canFix("outLineDiffers", "2.1:出倉列批號不同,改出倉列跟流水帳", outLineDiffers)) add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a)) add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b)) add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot)) - add(cannotFix("cannotNoSource", "不能修:沒有 SIL/SOL lot 來源", cannotNoSource)) - add(cannotFix("cannotMultiLine", "不能修:同一 lot 有多條 warehouse line", cannotMultiLine)) + add(cannotFix("cannotNoSource", "2.1 軟刪:入出倉列對不到批號列", cannotNoSource)) + add(cannotFix("cannotMultiLine", "2.1 軟刪:同一 lot 有多條 warehouse line", cannotMultiLine)) add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt index cf5a3b6b..8d38a460 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt @@ -64,6 +64,18 @@ open class StockLedgerFixSupport( } } + fun timedValue(name: String, block: () -> T): T { + val t0 = System.nanoTime() + return try { + val value = block() + log.info("stock-ledger-fix step {} {}ms", name, (System.nanoTime() - t0) / 1_000_000) + value + } catch (e: Exception) { + log.warn("stock-ledger-fix step {} FAILED {}ms: {}", name, (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } + } + fun parseDay(raw: String): LocalDate { val trimmed = raw.trim() if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)") diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt index aecdac08..fd2a8a46 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt @@ -39,6 +39,16 @@ data class StockLedgerFixRunResponse( val stillMissUom: Int, val stillMissInventoryId: Int, val stillMissLotQty: Int, + /** 2.1: related SIL/SOL cannot point at a lot line, so the ledger row is soft-deleted. */ + val deletedUnlinkedLot: Int = 0, + /** Ledger already had inventoryLotLineId and the live SIL/SOL lot line is the same. */ + val existingLotMatch: Int = 0, + /** Ledger already had inventoryLotLineId; the live SIL/SOL lot line is missing or different. Not deleted. */ + val existingLotMismatch: Int = 0, + /** Ledger had a lot line but the live stock-out line lot was null: lot cleared and ledger soft-deleted. */ + val deletedBlankOutLine: Int = 0, + /** Live stock-out line lot differed; the line was set to the ledger lot. Ledger lot was not changed. */ + val alignedOutLine: Int = 0, ) data class StockLedgerFixCheckPart( @@ -128,6 +138,8 @@ data class StockLedgerFixAdjResponse( val filledLotQty: Int, val filledBalance: Int, val dayRowsWritten: Int, + /** Rows inserted into stock_adjustment_record for stock-ledger-fix ADJ lines, including ones already on the ledger. */ + val adjustmentRecords: Int = 0, ) data class StockLedgerFixScopeDetail( diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 5d3a83e2..085677ef 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -320,7 +320,10 @@ open class StockLedgerReportService( """ SELECT sol.id AS lineId, - COALESCE(TRIM(do.code), TRIM(jo_po.code)) AS orderRefNo, + CASE + WHEN po_out.type = 'consumable' THEN TRIM(po_out.code) + ELSE COALESCE(TRIM(do.code), TRIM(jo_po.code)) + END AS orderRefNo, CASE WHEN so.stockTakeId IS NOT NULL OR so.type = 'stockTake' THEN 1 ELSE 0 From d50648d7bae6fd119b81eb10782c53dbd584d4ac Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 13:21:35 +0800 Subject: [PATCH 31/52] export sql fix --- .../service/ledgerfix/StockLedgerFixExportService.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt index 10cf52f8..baa95d32 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt @@ -496,7 +496,7 @@ open class StockLedgerFixExportService( jdbc, """ SELECT DISTINCT so.id, so.created, so.createdBy, so.version, so.modified, so.modifiedBy, so.deleted, - so.type, so.completeDate, so.status, so.remarks + so.type, so.completeDate, so.status, so.remarks, so.handlerId FROM stock_out so INNER JOIN stock_out_line sol ON sol.stockOutId = so.id AND IFNULL(sol.deleted, 0) = 0 INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id @@ -510,11 +510,11 @@ open class StockLedgerFixExportService( table = "stock_out", columns = listOf( "id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted", - "type", "completeDate", "status", "remarks", + "type", "completeDate", "status", "remarks", "handlerId", ), onDup = "type=VALUES(type), completeDate=VALUES(completeDate), status=VALUES(status), " + - "remarks=VALUES(remarks), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " + - "version=stock_out.version+1, deleted=0", + "remarks=VALUES(remarks), handlerId=VALUES(handlerId), modified=VALUES(modified), " + + "modifiedBy=VALUES(modifiedBy), version=stock_out.version+1, deleted=0", emptyComment = "-- (no ADJ stock_out headers in range)", ) writeExportRows( From 69fef376518defe5748839d08a8445e3a757ba65 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 14:53:31 +0800 Subject: [PATCH 32/52] 2.1 deleted fix --- .../service/ledgerfix/StockLedgerFixBackfillService.kt | 7 +++---- .../master/service/ledgerfix/StockLedgerFixQueryService.kt | 4 +--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt index bd586ccb..47213821 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -409,8 +409,9 @@ open class StockLedgerFixBackfillService( } /** - * Rows that already have a lot line stay. Soft-delete only when a related - * stock in/out line exists and 2.1a/2.1b still could not set inventoryLotLineId. + * Rows that already have a lot line stay. Soft-delete when 2.1a/2.1b still + * left inventoryLotLineId null: a related in/out line that cannot point at a lot, + * or neither stockInLineId nor stockOutLineId is set. */ private fun softDeleteUnlinkedLot( dateWhere: String, @@ -429,7 +430,6 @@ open class StockLedgerFixBackfillService( WHERE sl.deleted = 0 AND ($dateWhere) AND sl.inventoryLotLineId IS NULL - AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) AND ($extraAnd) """.trimIndent(), args, @@ -1139,7 +1139,6 @@ open class StockLedgerFixBackfillService( sl.modifiedBy = 'stock-ledger-fix' WHERE sl.deleted = 0 AND sl.inventoryLotLineId IS NULL - AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) """.trimIndent(), ) val outLine = alignExistingOutLine("sl.date < :today", args, "sl.inventoryId = :inventoryId") diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt index 38659152..e576ab57 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt @@ -180,7 +180,6 @@ open class StockLedgerFixQueryService( sl.inventoryLotLineId IS NULL AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) - AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) ) AS cannotNoSource, SUM( sl.inventoryLotLineId IS NOT NULL @@ -571,7 +570,6 @@ open class StockLedgerFixQueryService( sl.inventoryLotLineId IS NULL AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0) - AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) ) AS cannotNoSource, SUM( sl.inventoryLotLineId IS NOT NULL @@ -721,7 +719,7 @@ open class StockLedgerFixQueryService( add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a)) add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b)) add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot)) - add(cannotFix("cannotNoSource", "2.1 軟刪:入出倉列對不到批號列", cannotNoSource)) + add(cannotFix("cannotNoSource", "2.1 軟刪:對不到批號列(含沒有入出倉列)", cannotNoSource)) add(cannotFix("cannotMultiLine", "2.1 軟刪:同一 lot 有多條 warehouse line", cannotMultiLine)) add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver)) add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver)) From b9c6c4f27d96e9cd1cf20c5d76114ad55c283119 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 15:26:02 +0800 Subject: [PATCH 33/52] 2.7 fix be 2.7 a 2.7 b --- .../master/service/StockLedgerFixService.kt | 3 + .../ledgerfix/StockLedgerFixAdjService.kt | 130 +++++++++++++++++- .../ledgerfix/StockLedgerFixExportService.kt | 35 +++++ .../master/web/StockLedgerFixController.kt | 5 +- .../master/web/models/StockLedgerFixModels.kt | 2 + 5 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt index a55c197d..3d132841 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt @@ -61,6 +61,9 @@ open class StockLedgerFixService( open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse = adj.applyAdjAlign(adjDateRaw) + open fun applyAdjLotLineIn(adjDateRaw: String? = null): StockLedgerFixAdjResponse = + adj.applyAdjLotLineIn(adjDateRaw) + open fun fixDay(dateRaw: String, stepsRaw: List? = null): StockLedgerFixRunResponse = backfill.fixDay(dateRaw, stepsRaw) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt index b5a2c61e..daede986 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -20,7 +20,7 @@ open class StockLedgerFixAdjService( private val log = LoggerFactory.getLogger(StockLedgerFixAdjService::class.java) /** - * One ADJ per inventory bucket on [adjDate] (default yesterday; pass today on freeze-night dump). + * 2.7b. One ADJ per inventory bucket on [adjDate] (default yesterday; pass today on freeze-night dump). * Quantity is onHandQty minus the bucket ledger net, including rows with no lot. * The row is attached to the lot line whose own net is furthest from its ledger. * Lot lines and inventory are not updated, so the lot-line trigger does not run. @@ -150,6 +150,133 @@ open class StockLedgerFixAdjService( } } + /** + * 2.7a. Lot lines with outQty greater than inQty get inQty raised to outQty, + * and one inbound ADJ on that same lot for the gap. A second run finds no gap. + * The lot-line update fires the inventory trigger, so the ledger in must be the same qty. + */ + open fun applyAdjLotLineIn(adjDateRaw: String? = null): StockLedgerFixAdjResponse { + if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) { + throw IllegalStateException("fix already running") + } + val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) + val t0 = System.nanoTime() + try { + val gaps = loadLotLineInGaps() + var insertedIn = 0 + val lotIds = linkedSetOf() + if (gaps.isNotEmpty()) { + val insTx = TransactionTemplate(support.transactionManager) + insTx.timeout = 300 + insTx.execute { + var stockInId: Long? = null + fun inHeader(): Long = + stockInId ?: ensureAdjStockInHeader(adjDate).also { stockInId = it } + gaps.forEach { g -> + val raised = support.jdbcDao.executeUpdate( + """ + UPDATE inventory_lot_line + SET inQty = outQty, + modified = NOW(), + modifiedBy = 'stock-ledger-fix' + WHERE id = :id + AND IFNULL(deleted, 0) = 0 + AND COALESCE(outQty, 0) > COALESCE(inQty, 0) + """.trimIndent(), + mapOf("id" to g.lotLineId), + ) + if (raised == 0) return@forEach + insertAdjLedger( + g, adjDate, inQty = g.missIn, outQty = null, + remarks = ADJ_LOT_LINE_REMARKS, stockInId = inHeader(), + ) + insertedIn++ + lotIds.add(g.lotLineId) + } + } + } + val adjustmentRecords = TransactionTemplate(support.transactionManager).let { tx -> + tx.timeout = 300 + tx.execute { insertMissingAdjustmentRecords() } ?: 0 + } + val filledLotQty = support.timed("adj 2.7a 2.4") { + backfill.fillLotQtyForLotIds(lotIds.toList(), asOfExclusive) + } + val filledBalance = support.timed("adj 2.7a 2.5") { + backfill.fillBalanceOnNewAdjRows(adjDate, asOfExclusive) + } + val range = if (lotIds.isEmpty()) null else backfill.dateRangeForFilter( + "inventoryLotLineId IN (:lotIds)", + mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()), + ) + val dayRows = if (range != null && lotIds.isNotEmpty()) { + support.timed("adj 2.7a 2.6") { + backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) + } + } else 0 + log.info( + "stock-ledger-fix 2.7a {} lines={} adjRecord={} lotQty={} bal={} day={} total={}ms", + adjDate, insertedIn, adjustmentRecords, filledLotQty, filledBalance, dayRows, + (System.nanoTime() - t0) / 1_000_000, + ) + return StockLedgerFixAdjResponse( + adjDate = adjDate.toString(), + insertedIn = insertedIn, + insertedOut = 0, + overIssuePatched = insertedIn, + filledLotQty = filledLotQty, + filledBalance = filledBalance, + dayRowsWritten = dayRows, + adjustmentRecords = adjustmentRecords, + ) + } catch (e: Exception) { + log.warn("stock-ledger-fix 2.7a FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message) + throw e + } finally { + support.unlock(StockLedgerFixSupport.RUN_LOCK) + } + } + + private fun loadLotLineInGaps(): List { + return support.jdbcDao.queryForList( + """ + SELECT ill.id AS lotLineId, + il.itemId AS itemId, + it.code AS itemCode, + iu.uomId AS uomId, + i.id AS inventoryId, + CAST(COALESCE(ill.outQty, 0) - COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS gap + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN items it ON it.id = il.itemId + INNER JOIN item_uom iu ON iu.id = ill.stockItemUomId + LEFT JOIN inventory i + ON i.itemId = il.itemId + AND i.stockUomId = iu.uomId + AND IFNULL(i.deleted, 0) = 0 + WHERE IFNULL(ill.deleted, 0) = 0 + AND COALESCE(ill.outQty, 0) > COALESCE(ill.inQty, 0) + """.trimIndent(), + ).mapNotNull { row -> + val gap = support.toDecimal(row["gap"]) + if (gap.compareTo(ADJ_TOLERANCE) <= 0) return@mapNotNull null + AdjGap( + lotLineId = support.toLong(row["lotLineId"]), + inventoryId = support.toLongOrNull(row["inventoryId"]), + itemId = support.toLongOrNull(row["itemId"]), + itemCode = row["itemCode"]?.toString(), + uomId = support.toLongOrNull(row["uomId"]), + lineIn = BigDecimal.ZERO, + lineOut = BigDecimal.ZERO, + ledgerIn = BigDecimal.ZERO, + ledgerOut = BigDecimal.ZERO, + missIn = gap, + missOut = BigDecimal.ZERO, + ) + } + } + /** * ADJ window: ledger date = [adjDate], gap sums use `date < adjDate+1`. * Default adjDate = yesterday. Pass today for freeze-night dump (e.g. 31/8). @@ -680,6 +807,7 @@ open class StockLedgerFixAdjService( companion object { private val ADJ_TOLERANCE = BigDecimal("0.01") private const val ADJ_DOC_REMARKS = "stock-ledger-fix bucket ADJ (lot line unchanged)" + private const val ADJ_LOT_LINE_REMARKS = "stock-ledger-fix 2.7a ADJ" private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ" } } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt index baa95d32..2903c475 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt @@ -436,6 +436,41 @@ open class StockLedgerFixExportService( jdbc: NamedParameterJdbcTemplate, args: Map, ) { + writeTempUpdate( + writer, + jdbc, + args, + title = "-- === 2.7a lot line inQty (out was greater; trigger refreshes onHand) ===\n", + selectSql = """ + SELECT ill.id, ill.inQty, ill.modified, ill.modifiedBy + FROM inventory_lot_line ill + WHERE ill.modifiedBy = 'stock-ledger-fix' + AND IFNULL(ill.deleted, 0) = 0 + AND EXISTS ( + SELECT 1 + FROM stock_ledger sl + INNER JOIN stock_in_line sil ON sil.id = sl.stockInLineId + WHERE sl.inventoryLotLineId = ill.id + AND sl.deleted = 0 + AND sl.type = 'ADJ' + AND sl.createdBy = 'stock-ledger-fix' + AND sl.date >= :from AND sl.date < :toExclusive + AND sil.remarks = 'stock-ledger-fix 2.7a ADJ' + ) + ORDER BY ill.id + """.trimIndent(), + tempName = "tmp_slf_ill_in", + columnDefs = "inQty DECIMAL(14,2) NULL, modified DATETIME NULL, modifiedBy VARCHAR(50) NULL", + columns = listOf("id", "inQty", "modified", "modifiedBy"), + updateSql = """ + UPDATE inventory_lot_line ill + INNER JOIN tmp_slf_ill_in t ON t.id = ill.id + SET ill.inQty = t.inQty, + ill.modified = t.modified, + ill.modifiedBy = t.modifiedBy + """.trimIndent(), + emptyComment = "-- (no 2.7a lot-line inQty in range)", + ) writer.write("-- === 2.7 ADJ stock_in / stock_out (1 header + many lines; apply before ledger ADJ FKs) ===\n") writeExportRows( writer, diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt index b255f47a..a5540f1a 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt @@ -98,7 +98,10 @@ open class StockLedgerFixController( @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") @PostMapping("/adj") open fun adjApply(@RequestBody(required = false) body: StockLedgerFixAdjRequest?) = - stockLedgerFixService.applyAdjAlign(body?.adjDate) + when (body?.step?.trim()?.lowercase()) { + "2.7a", "27a" -> stockLedgerFixService.applyAdjLotLineIn(body?.adjDate) + else -> stockLedgerFixService.applyAdjAlign(body?.adjDate) + } @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") @PostMapping("/run") diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt index fd2a8a46..45f53287 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt @@ -101,6 +101,8 @@ data class StockLedgerFixSearchLotHit( data class StockLedgerFixAdjRequest( /** Optional ADJ ledger date (yyyy-MM-dd). Default = yesterday. Use today on freeze-night dump. */ val adjDate: String? = null, + /** `2.7a` raises lot-line inQty where outQty is greater. Empty = 2.7b bucket align. */ + val step: String? = null, ) data class StockLedgerFixAdjRow( From e78b88c9d399b086e9ad2d23848cdd7245c4ec79 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 16:06:53 +0800 Subject: [PATCH 34/52] 2.7 b fix --- .../ledgerfix/StockLedgerFixAdjService.kt | 283 +++++------------- .../StockLedgerFixBackfillService.kt | 39 +++ 2 files changed, 121 insertions(+), 201 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt index daede986..70d1ad0e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -20,24 +20,22 @@ open class StockLedgerFixAdjService( private val log = LoggerFactory.getLogger(StockLedgerFixAdjService::class.java) /** - * 2.7b. One ADJ per inventory bucket on [adjDate] (default yesterday; pass today on freeze-night dump). - * Quantity is onHandQty minus the bucket ledger net, including rows with no lot. - * The row is attached to the lot line whose own net is furthest from its ledger. - * Lot lines and inventory are not updated, so the lot-line trigger does not run. - * Each ADJ line also gets one stock_adjustment_record. A later run fills any ADJ line that still has none. - * A bucket whose ledger net already equals onHandQty is skipped. A second run inserts no new ADJ. - * All inbound lines share one stock_in; all outbound lines share one stock_out (per adjDate). + * 2.7b. One ADJ per lot line whose remain (in − out) differs from its ledger net. + * Quantity is that lot's own gap. Out greater than in is left to 2.7a. + * Lot lines and inventory are not updated. After the ADJ, the bucket balance is + * rebuilt from live in/out so a stale balance is not copied forward. + * A lot that already matches is skipped. A second run inserts no new ADJ. * Ledger net uses `date < adjDate+1`. */ open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview { val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() - val plans = planBucketAdjs(asOfExclusive, resolveHost = false) + val plans = planLotAdjs(asOfExclusive) val sumIn = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missIn } val sumOut = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missOut } val limit = rowLimit.coerceIn(0, 50) log.info( - "stock-ledger-fix adj preview buckets={} in={} out={} {}ms", + "stock-ledger-fix 2.7b preview lots={} in={} out={} {}ms", plans.size, plans.count { it.missIn > BigDecimal.ZERO }, plans.count { it.missOut > BigDecimal.ZERO }, @@ -65,7 +63,7 @@ open class StockLedgerFixAdjService( val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() try { - val plans = planBucketAdjs(asOfExclusive, resolveHost = true) + val plans = planLotAdjs(asOfExclusive) var insertedIn = 0 var insertedOut = 0 val overIssuePatched = 0 @@ -118,7 +116,12 @@ open class StockLedgerFixAdjService( mapOf("adjDate" to adjDate, "asOf" to asOfExclusive), ).forEach { addLot(support.toLong(it["lotId"]), support.toLongOrNull(it["invId"])) } val filledLotQty = support.timed("adj 2.4") { backfill.fillLotQtyForLotIds(lotIds.toList(), asOfExclusive) } - val filledBalance = support.timed("adj 2.5") { backfill.fillBalanceOnNewAdjRows(adjDate, asOfExclusive) } + val filledBalance = support.timed("adj 2.5") { + backfill.fillBalanceForInventoryIds( + plans.mapNotNull { it.inventoryId }.distinct(), + asOfExclusive, + ) + } val range = if (lotIds.isEmpty()) null else backfill.dateRangeForFilter( "inventoryLotLineId IN (:lotIds)", mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()), @@ -127,7 +130,7 @@ open class StockLedgerFixAdjService( support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) } } else 0 log.info( - "stock-ledger-fix adj {} buckets={} in={} out={} adjRecord={} lotQty={} bal={} day={} total={}ms", + "stock-ledger-fix 2.7b {} lots={} in={} out={} adjRecord={} lotQty={} bal={} day={} total={}ms", adjDate, plans.size, insertedIn, insertedOut, adjustmentRecords, filledLotQty, filledBalance, dayRows, (System.nanoTime() - t0) / 1_000_000, @@ -295,202 +298,80 @@ open class StockLedgerFixAdjService( } /** - * One ADJ per bucket whose latest balance differs from onHandQty. - * After 2.5 that balance equals the bucket ledger net, so the ADJ qty is onHandQty − balance. - * Preview does not scan lot history. Apply resolves which lot line the ADJ is attached to. - */ - private fun planBucketAdjs(today: LocalDate, resolveHost: Boolean): List { - val buckets = loadMismatchedByLastBalance(today) - if (buckets.isEmpty()) return emptyList() - if (!resolveHost) { - val lines = loadLotLines(buckets.mapNotNull { it.lotLineId }.distinct()) - return buckets.map { bucket -> previewGap(bucket, lines[bucket.lotLineId]) } - } - val lotsByInv = loadLotsOnBuckets(today, buckets.map { it.inventoryId }) - return buckets.mapNotNull { bucket -> - val host = chooseAdjHost(lotsByInv[bucket.inventoryId].orEmpty(), bucket.delta) - if (host == null) { - log.warn( - "stock-ledger-fix adj skip inventoryId={} delta={}: no lot line to attach", - bucket.inventoryId, bucket.delta, - ) - return@mapNotNull null - } - val needIn = bucket.delta > BigDecimal.ZERO - AdjGap( - lotLineId = host.lotLineId, - inventoryId = bucket.inventoryId, - itemId = host.itemId, - itemCode = host.itemCode, - uomId = host.uomId, - lineIn = host.lineIn, - lineOut = host.lineOut, - ledgerIn = host.ledgerIn, - ledgerOut = host.ledgerOut, - missIn = if (needIn) bucket.delta else BigDecimal.ZERO, - missOut = if (needIn) BigDecimal.ZERO else bucket.delta.negate(), - ) - } - } - - private data class BucketDelta( - val inventoryId: Long, - val delta: BigDecimal, - val lotLineId: Long?, - val itemId: Long?, - val itemCode: String?, - ) - - /** Preview row. Ledger in/out stay 0; the bucket delta is missIn / missOut. */ - private fun previewGap(bucket: BucketDelta, line: Map?) = AdjGap( - lotLineId = bucket.lotLineId ?: 0L, - inventoryId = bucket.inventoryId, - itemId = support.toLongOrNull(line?.get("itemId") ?: bucket.itemId), - itemCode = (line?.get("itemCode") ?: bucket.itemCode)?.toString(), - uomId = support.toLongOrNull(line?.get("uomId")), - lineIn = support.toDecimal(line?.get("lineIn")), - lineOut = support.toDecimal(line?.get("lineOut")), - ledgerIn = BigDecimal.ZERO, - ledgerOut = BigDecimal.ZERO, - missIn = if (bucket.delta > BigDecimal.ZERO) bucket.delta else BigDecimal.ZERO, - missOut = if (bucket.delta > BigDecimal.ZERO) BigDecimal.ZERO else bucket.delta.negate(), - ) - - private data class LotOnBucket( - val lotLineId: Long, - val itemId: Long?, - val itemCode: String?, - val uomId: Long?, - val lineIn: BigDecimal, - val lineOut: BigDecimal, - val ledgerIn: BigDecimal, - val ledgerOut: BigDecimal, - ) { - val lotGap: BigDecimal - get() = lineIn.subtract(lineOut).subtract(ledgerIn.subtract(ledgerOut)) - } - - /** - * Latest balance per bucket (date, then id). After 2.5 this equals the ledger net, - * so onHand − balance is the ADJ qty. One index seek per inventory, not a full sum. + * One ADJ per lot whose remain differs from its own ledger net. + * Gap = (line in − line out) − (ledger in − ledger out). Positive gap is an inbound ADJ. + * Lots with out greater than in are skipped; 2.7a owns those. */ - private fun loadMismatchedByLastBalance(today: LocalDate): List { - return support.jdbcDao.queryForList( + private fun planLotAdjs(today: LocalDate): List { + val lines = support.jdbcDao.queryForList( """ - SELECT i.id AS inventoryId, - i.itemId AS itemId, + SELECT ill.id AS lotLineId, + CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS lineIn, + CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) AS lineOut, + il.itemId AS itemId, it.code AS itemCode, - CAST(COALESCE(i.onHandQty, 0) AS DECIMAL(14,2)) AS onHand, - x.balance AS balance, - x.lotLineId AS lotLineId - FROM inventory i - LEFT JOIN items it ON it.id = i.itemId - INNER JOIN LATERAL ( - SELECT s.balance AS balance, - s.inventoryLotLineId AS lotLineId - FROM stock_ledger s FORCE INDEX (idx_ledger_inv_last) - WHERE s.inventoryId = i.id - AND s.deleted = 0 - AND s.date < :today - AND s.balance IS NOT NULL - ORDER BY s.date DESC, s.id DESC - LIMIT 1 - ) x ON TRUE - WHERE IFNULL(i.deleted, 0) = 0 - AND ABS(CAST(x.balance AS DECIMAL(14,2)) - - CAST(COALESCE(i.onHandQty, 0) AS DECIMAL(14,2))) > 0.01 + iu.uomId AS uomId, + i.id AS inventoryId + FROM inventory_lot_line ill + INNER JOIN inventory_lot il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN items it ON it.id = il.itemId + INNER JOIN item_uom iu ON iu.id = ill.stockItemUomId + LEFT JOIN inventory i + ON i.itemId = il.itemId + AND i.stockUomId = iu.uomId + AND IFNULL(i.deleted, 0) = 0 + WHERE IFNULL(ill.deleted, 0) = 0 + AND COALESCE(ill.outQty, 0) <= COALESCE(ill.inQty, 0) """.trimIndent(), - mapOf("today" to today), - ).mapNotNull { row -> - val delta = support.toDecimal(row["onHand"]).subtract(support.toDecimal(row["balance"])) - if (delta.abs().compareTo(ADJ_TOLERANCE) <= 0) null - else BucketDelta( - inventoryId = support.toLong(row["inventoryId"]), - delta = delta, - lotLineId = support.toLongOrNull(row["lotLineId"]), - itemId = support.toLongOrNull(row["itemId"]), - itemCode = row["itemCode"]?.toString(), - ) - } - } - - /** Lot nets only for buckets that need an ADJ, so the host lot can be chosen. */ - private fun loadLotsOnBuckets(today: LocalDate, inventoryIds: List): Map> { - val ledgerRows = mutableListOf>() - inventoryIds.chunked(50).forEach { chunk -> - ledgerRows.addAll( - support.jdbcDao.queryForList( - """ - SELECT sl.inventoryId, - sl.inventoryLotLineId AS lotLineId, - CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS ledgerIn, - CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS ledgerOut, - MIN(sl.itemId) AS itemId, - MIN(sl.itemCode) AS itemCode, - MIN(sl.uomId) AS uomId - FROM stock_ledger sl FORCE INDEX (idx_ledger_inv_date) - WHERE sl.deleted = 0 - AND sl.date < :today - AND sl.inventoryId IN (:ids) - AND sl.inventoryLotLineId IS NOT NULL - GROUP BY sl.inventoryId, sl.inventoryLotLineId - """.trimIndent(), - mapOf("today" to today, "ids" to chunk), - ), - ) - } - if (ledgerRows.isEmpty()) return emptyMap() - val lines = loadLotLines(ledgerRows.map { support.toLong(it["lotLineId"]) }.distinct()) - return ledgerRows.map { row -> - val lotLineId = support.toLong(row["lotLineId"]) - val line = lines[lotLineId] - LotOnBucket( - lotLineId = lotLineId, - itemId = support.toLongOrNull(line?.get("itemId") ?: row["itemId"]), - itemCode = (line?.get("itemCode") ?: row["itemCode"])?.toString(), - uomId = support.toLongOrNull(row["uomId"] ?: line?.get("uomId")), - lineIn = support.toDecimal(line?.get("lineIn")), - lineOut = support.toDecimal(line?.get("lineOut")), - ledgerIn = support.toDecimal(row["ledgerIn"]), - ledgerOut = support.toDecimal(row["ledgerOut"]), - ) to support.toLong(row["inventoryId"]) - }.groupBy({ it.second }, { it.first }) - } - - private fun loadLotLines(lotIds: List): Map> { - if (lotIds.isEmpty()) return emptyMap() - val lines = HashMap>() - lotIds.chunked(200).forEach { chunk -> + ) + if (lines.isEmpty()) return emptyList() + val byId = HashMap>(lines.size) + lines.forEach { byId[support.toLong(it["lotLineId"])] = it } + val gaps = ArrayList() + byId.keys.chunked(400).forEach { chunk -> + val netByLot = HashMap>(chunk.size) support.jdbcDao.queryForList( """ - SELECT ill.id AS lotLineId, - CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS lineIn, - CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) AS lineOut, - il.itemId AS itemId, - it.code AS itemCode, - iu.uomId AS uomId - FROM inventory_lot_line ill - INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId - LEFT JOIN items it ON it.id = il.itemId - LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId - WHERE ill.id IN (:ids) + SELECT sl.inventoryLotLineId AS lotLineId, + CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS ledgerIn, + CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS ledgerOut + FROM stock_ledger sl + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryLotLineId IN (:ids) + GROUP BY sl.inventoryLotLineId """.trimIndent(), - mapOf("ids" to chunk), - ).forEach { lines[support.toLong(it["lotLineId"])] = it } - } - return lines - } - - /** Prefer the lot whose own gap has the same sign as the bucket delta, largest first. */ - private fun chooseAdjHost(lots: List, delta: BigDecimal): LotOnBucket? { - if (lots.isEmpty()) return null - val sameSign = lots.filter { - it.lotGap.abs().compareTo(ADJ_TOLERANCE) > 0 && it.lotGap.signum() == delta.signum() + mapOf("today" to today, "ids" to chunk), + ).forEach { netByLot[support.toLong(it["lotLineId"])] = it } + chunk.forEach lotLoop@{ lotId -> + val line = byId.getValue(lotId) + val lineIn = support.toDecimal(line["lineIn"]) + val lineOut = support.toDecimal(line["lineOut"]) + val net = netByLot[lotId] + val ledgerIn = support.toDecimal(net?.get("ledgerIn")) + val ledgerOut = support.toDecimal(net?.get("ledgerOut")) + val gap = lineIn.subtract(lineOut).subtract(ledgerIn.subtract(ledgerOut)) + if (gap.abs().compareTo(ADJ_TOLERANCE) <= 0) return@lotLoop + val needIn = gap.signum() > 0 + gaps.add( + AdjGap( + lotLineId = lotId, + inventoryId = support.toLongOrNull(line["inventoryId"]), + itemId = support.toLongOrNull(line["itemId"]), + itemCode = line["itemCode"]?.toString(), + uomId = support.toLongOrNull(line["uomId"]), + lineIn = lineIn, + lineOut = lineOut, + ledgerIn = ledgerIn, + ledgerOut = ledgerOut, + missIn = if (needIn) gap else BigDecimal.ZERO, + missOut = if (needIn) BigDecimal.ZERO else gap.negate(), + ), + ) + } } - if (sameSign.isNotEmpty()) return sameSign.maxBy { it.lotGap.abs() } - val anyGap = lots.filter { it.lotGap.abs().compareTo(ADJ_TOLERANCE) > 0 } - if (anyGap.isNotEmpty()) return anyGap.maxBy { it.lotGap.abs() } - return lots.maxBy { it.ledgerIn.add(it.ledgerOut) } + return gaps } @@ -806,7 +687,7 @@ open class StockLedgerFixAdjService( companion object { private val ADJ_TOLERANCE = BigDecimal("0.01") - private const val ADJ_DOC_REMARKS = "stock-ledger-fix bucket ADJ (lot line unchanged)" + private const val ADJ_DOC_REMARKS = "stock-ledger-fix 2.7b ADJ" private const val ADJ_LOT_LINE_REMARKS = "stock-ledger-fix 2.7a ADJ" private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ" } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt index 47213821..0940d4f3 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -1556,6 +1556,45 @@ open class StockLedgerFixBackfillService( * Chain balance on new ADJ rows only; older rows for the same SKU are unchanged. * Opening balance is the latest date, then the highest id on that date (same rule as 2.5 lookback). */ + /** + * Rebuild bucket balance from live in/out (date, then id), so a stale stored balance is not kept. + */ + open internal fun fillBalanceForInventoryIds(inventoryIds: List, today: LocalDate): Int { + if (inventoryIds.isEmpty()) return 0 + var rows = 0 + inventoryIds.distinct().forEach { invId -> + rows += support.jdbcDao.executeUpdate( + """ + UPDATE stock_ledger sl + INNER JOIN ( + SELECT + sl2.id, + CAST( + SUM( + CAST(COALESCE(sl2.inQty, 0) AS DECIMAL(14,2)) + - CAST(COALESCE(sl2.outQty, 0) AS DECIMAL(14,2)) + ) OVER ( + PARTITION BY sl2.inventoryId + ORDER BY sl2.date, sl2.id + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(14,2)) AS newBalance + FROM stock_ledger sl2 + WHERE sl2.deleted = 0 + AND sl2.date < :today + AND sl2.inventoryId = :inventoryId + ) calc ON calc.id = sl.id + SET sl.balance = calc.newBalance + WHERE sl.deleted = 0 + AND sl.date < :today + AND sl.inventoryId = :inventoryId + """.trimIndent(), + mapOf("today" to today, "inventoryId" to invId), + ) + } + return rows + } + open internal fun fillBalanceOnNewAdjRows(adjDate: LocalDate, today: LocalDate): Int { val rows = support.jdbcDao.queryForList( """ From a3710e2b1ec175f0bb68c4904e82382844aa00e5 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sat, 10 Oct 2026 16:45:50 +0800 Subject: [PATCH 35/52] change the job order combo unit, from sales unit to stock unit --- .../ffii/fpsms/modules/master/entity/projections/BomCombo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt index 8f4bb249..efd9964d 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt @@ -7,7 +7,7 @@ interface BomCombo { val id: Long; @get:Value("#{target.id}") val value: Long; - @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") + @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[stockUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") val label: String; val outputQty: BigDecimal; val outputQtyUom: String?; From 4e10d6436558e32548705a4281ad20cc4cbf7688 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 12 Oct 2026 10:57:58 +0800 Subject: [PATCH 36/52] all fix reference from stock ledger fix cahnge to DNP --- .../ledgerfix/StockLedgerFixAdjService.kt | 24 ++++++----- .../StockLedgerFixBackfillService.kt | 15 +++---- .../ledgerfix/StockLedgerFixExportService.kt | 29 ++++++------- .../ledgerfix/StockLedgerFixInventorySql.kt | 10 ++--- .../ledgerfix/StockLedgerFixSupport.kt | 41 +++++++++++++++++++ 5 files changed, 82 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt index 70d1ad0e..94551d38 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt @@ -63,6 +63,7 @@ open class StockLedgerFixAdjService( val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() try { + support.stampFixActor() val plans = planLotAdjs(asOfExclusive) var insertedIn = 0 var insertedOut = 0 @@ -111,7 +112,7 @@ open class StockLedgerFixAdjService( WHERE deleted = 0 AND date >= :adjDate AND date < :asOf AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' + AND createdBy IN ('DNP', 'stock-ledger-fix') """.trimIndent(), mapOf("adjDate" to adjDate, "asOf" to asOfExclusive), ).forEach { addLot(support.toLong(it["lotId"]), support.toLongOrNull(it["invId"])) } @@ -165,6 +166,7 @@ open class StockLedgerFixAdjService( val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw) val t0 = System.nanoTime() try { + support.stampFixActor() val gaps = loadLotLineInGaps() var insertedIn = 0 val lotIds = linkedSetOf() @@ -181,7 +183,7 @@ open class StockLedgerFixAdjService( UPDATE inventory_lot_line SET inQty = outQty, modified = NOW(), - modifiedBy = 'stock-ledger-fix' + modifiedBy = 'DNP' WHERE id = :id AND IFNULL(deleted, 0) = 0 AND COALESCE(outQty, 0) > COALESCE(inQty, 0) @@ -414,7 +416,7 @@ open class StockLedgerFixAdjService( itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter, uomId, type ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + NOW(), 'DNP', 0, NOW(), 'DNP', 0, :adjDate, :silId, :solId, :lotLineId, :inventoryId, :itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL, :uomId, 'ADJ' @@ -448,7 +450,7 @@ open class StockLedgerFixAdjService( created, createdBy, version, modified, modifiedBy, deleted, code, status, orderDate, completeDate ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + NOW(), 'DNP', 0, NOW(), 'DNP', 0, :code, 'completed', :at, :at ) """.trimIndent(), @@ -462,7 +464,7 @@ open class StockLedgerFixAdjService( val existing = support.jdbcDao.queryForMap( """ SELECT id FROM stock_out - WHERE createdBy = 'stock-ledger-fix' + WHERE createdBy IN ('DNP', 'stock-ledger-fix') AND type = 'ADJ' AND remarks = :remarks AND IFNULL(deleted, 0) = 0 @@ -481,7 +483,7 @@ open class StockLedgerFixAdjService( created, createdBy, version, modified, modifiedBy, deleted, type, completeDate, status, remarks, handlerId ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + NOW(), 'DNP', 0, NOW(), 'DNP', 0, 'ADJ', :at, 'completed', :remarks, :handlerId ) """.trimIndent(), @@ -499,14 +501,14 @@ open class StockLedgerFixAdjService( stockInLineId, stockOutLineId ) SELECT - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + NOW(), 'DNP', 0, NOW(), 'DNP', 0, sl.itemId, LEFT(COALESCE(sl.itemCode, it.code), 50), LEFT(it.name, 200), LEFT(il.lotNo, 512), CASE WHEN sl.stockInLineId IS NOT NULL THEN sl.inQty ELSE NULL END, CASE WHEN sl.stockOutLineId IS NOT NULL THEN sl.outQty ELSE NULL END, - LEFT(COALESCE(sil.remarks, so.remarks, 'stock-ledger-fix 2.7 ADJ'), 500), + 'DNP', sl.stockInLineId, sl.stockOutLineId FROM stock_ledger sl @@ -518,7 +520,7 @@ open class StockLedgerFixAdjService( LEFT JOIN stock_out so ON so.id = sol.stockOutId WHERE sl.deleted = 0 AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' + AND sl.createdBy IN ('DNP', 'stock-ledger-fix') AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL) AND NOT EXISTS ( SELECT 1 @@ -555,7 +557,7 @@ open class StockLedgerFixAdjService( itemId, itemNo, stockInId, acceptedQty, receiptDate, status, inventoryLotId, inventoryLotLineId, lotNo, remarks, type ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + NOW(), 'DNP', 0, NOW(), 'DNP', 0, :itemId, :itemNo, :stockInId, :qty, :at, 'completed', :inventoryLotId, :lotLineId, :lotNo, :remarks, 'ADJ' ) @@ -595,7 +597,7 @@ open class StockLedgerFixAdjService( created, createdBy, version, modified, modifiedBy, deleted, itemId, qty, stockOutId, inventoryLotLineId, status, pickTime, type ) VALUES ( - NOW(), 'stock-ledger-fix', 0, NOW(), 'stock-ledger-fix', 0, + NOW(), 'DNP', 0, NOW(), 'DNP', 0, :itemId, :qty, :stockOutId, :lotLineId, 'completed', :at, 'ADJ' ) """.trimIndent(), diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt index 0940d4f3..d3613ed5 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -23,6 +23,7 @@ open class StockLedgerFixBackfillService( throw IllegalStateException("inventory 1.0 already running") } try { + support.stampFixActor() val patchedFromUom = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL) val patchedSingleLot = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.BACKFILL_STOCK_UOM_SINGLE_LOT_SQL) val patchedStockUomId = patchedFromUom + patchedSingleLot @@ -426,7 +427,7 @@ open class StockLedgerFixBackfillService( SET sl.deleted = 1, sl.modified = NOW(), - sl.modifiedBy = 'stock-ledger-fix' + sl.modifiedBy = 'DNP' WHERE sl.deleted = 0 AND ($dateWhere) AND sl.inventoryLotLineId IS NULL @@ -510,7 +511,7 @@ open class StockLedgerFixBackfillService( SET sol.inventoryLotLineId = src.lotLineId, sol.modified = NOW(), - sol.modifiedBy = 'stock-ledger-fix' + sol.modifiedBy = 'DNP' WHERE IFNULL(sol.deleted, 0) = 0 AND sol.inventoryLotLineId IS NOT NULL AND sol.inventoryLotLineId <> src.lotLineId @@ -526,7 +527,7 @@ open class StockLedgerFixBackfillService( sl.inventoryLotLineId = NULL, sl.deleted = 1, sl.modified = NOW(), - sl.modifiedBy = 'stock-ledger-fix' + sl.modifiedBy = 'DNP' WHERE sl.deleted = 0 AND ($dateWhere) AND sl.inventoryLotLineId IS NOT NULL @@ -1136,7 +1137,7 @@ open class StockLedgerFixBackfillService( SET sl.deleted = 1, sl.modified = NOW(), - sl.modifiedBy = 'stock-ledger-fix' + sl.modifiedBy = 'DNP' WHERE sl.deleted = 0 AND sl.inventoryLotLineId IS NULL """.trimIndent(), @@ -1399,7 +1400,7 @@ open class StockLedgerFixBackfillService( opening, inQty, outQty, closing ) SELECT - NOW(), 'ledger-fix-api', 0, NOW(), 'ledger-fix-api', 0, + NOW(), 'DNP', 0, NOW(), 'DNP', 0, x.inventoryLotLineId, x.itemId, x.itemCode, @@ -1442,7 +1443,7 @@ open class StockLedgerFixBackfillService( outQty = VALUES(outQty), closing = VALUES(closing), modified = NOW(), - modifiedBy = 'ledger-fix-api', + modifiedBy = 'DNP', version = stock_lot_day.version + 1, deleted = 0 """.trimIndent(), @@ -1605,7 +1606,7 @@ open class StockLedgerFixBackfillService( WHERE deleted = 0 AND date >= :adjDate AND date < :today AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' + AND createdBy IN ('DNP', 'stock-ledger-fix') AND inventoryId IS NOT NULL ORDER BY id """.trimIndent(), diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt index 2903c475..f6713c0b 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt @@ -43,6 +43,7 @@ open class StockLedgerFixExportService( if (!from.isBefore(toExclusive)) { throw IllegalArgumentException("from must be before toExclusive") } + support.stampFixActor() val parts = parseExportParts(partsRaw) val want10 = "1.0" in parts val want21 = "2.1" in parts @@ -88,7 +89,7 @@ open class StockLedgerFixExportService( WHERE deleted = 0 AND date >= :from AND date < :toExclusive AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' + AND createdBy IN ('DNP', 'stock-ledger-fix') """.trimIndent(), args, ).firstOrNull()?.get("c"), @@ -223,7 +224,7 @@ open class StockLedgerFixExportService( SELECT id, modified, modifiedBy FROM inventory WHERE deleted = 1 - AND modifiedBy = 'stock-ledger-fix' + AND modifiedBy IN ('DNP', 'stock-ledger-fix') ORDER BY id """.trimIndent(), tempName = "tmp_sl_fix_inv_del", @@ -260,7 +261,7 @@ open class StockLedgerFixExportService( SELECT id, inventoryLotLineId, modified, modifiedBy FROM stock_ledger WHERE deleted = 1 - AND modifiedBy = 'stock-ledger-fix' + AND modifiedBy IN ('DNP', 'stock-ledger-fix') AND date >= :from AND date < :toExclusive ORDER BY id """.trimIndent(), @@ -286,8 +287,8 @@ open class StockLedgerFixExportService( selectSql = """ SELECT sol.id, sol.inventoryLotLineId, sol.modified, sol.modifiedBy FROM stock_out_line sol - WHERE sol.modifiedBy = 'stock-ledger-fix' - AND IFNULL(sol.createdBy, '') <> 'stock-ledger-fix' + WHERE sol.modifiedBy IN ('DNP', 'stock-ledger-fix') + AND IFNULL(sol.createdBy, '') NOT IN ('DNP', 'stock-ledger-fix') AND EXISTS ( SELECT 1 FROM stock_ledger sl @@ -444,7 +445,7 @@ open class StockLedgerFixExportService( selectSql = """ SELECT ill.id, ill.inQty, ill.modified, ill.modifiedBy FROM inventory_lot_line ill - WHERE ill.modifiedBy = 'stock-ledger-fix' + WHERE ill.modifiedBy IN ('DNP', 'stock-ledger-fix') AND IFNULL(ill.deleted, 0) = 0 AND EXISTS ( SELECT 1 @@ -453,7 +454,7 @@ open class StockLedgerFixExportService( WHERE sl.inventoryLotLineId = ill.id AND sl.deleted = 0 AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' + AND sl.createdBy IN ('DNP', 'stock-ledger-fix') AND sl.date >= :from AND sl.date < :toExclusive AND sil.remarks = 'stock-ledger-fix 2.7a ADJ' ) @@ -484,7 +485,7 @@ open class StockLedgerFixExportService( WHERE sl.deleted = 0 AND sl.date >= :from AND sl.date < :toExclusive AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' + AND sl.createdBy IN ('DNP', 'stock-ledger-fix') ORDER BY si.id """.trimIndent(), args, @@ -510,7 +511,7 @@ open class StockLedgerFixExportService( WHERE sl.deleted = 0 AND sl.date >= :from AND sl.date < :toExclusive AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' + AND sl.createdBy IN ('DNP', 'stock-ledger-fix') ORDER BY sil.id """.trimIndent(), args, @@ -538,7 +539,7 @@ open class StockLedgerFixExportService( WHERE sl.deleted = 0 AND sl.date >= :from AND sl.date < :toExclusive AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' + AND sl.createdBy IN ('DNP', 'stock-ledger-fix') ORDER BY so.id """.trimIndent(), args, @@ -563,7 +564,7 @@ open class StockLedgerFixExportService( WHERE sl.deleted = 0 AND sl.date >= :from AND sl.date < :toExclusive AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' + AND sl.createdBy IN ('DNP', 'stock-ledger-fix') ORDER BY sol.id """.trimIndent(), args, @@ -592,11 +593,11 @@ open class StockLedgerFixExportService( OR (r.stockOutLineId IS NOT NULL AND sl.stockOutLineId = r.stockOutLineId) ) WHERE IFNULL(r.deleted, 0) = 0 - AND r.createdBy = 'stock-ledger-fix' + AND r.createdBy IN ('DNP', 'stock-ledger-fix') AND sl.deleted = 0 AND sl.date >= :from AND sl.date < :toExclusive AND sl.type = 'ADJ' - AND sl.createdBy = 'stock-ledger-fix' + AND sl.createdBy IN ('DNP', 'stock-ledger-fix') ORDER BY r.id """.trimIndent(), args, @@ -632,7 +633,7 @@ open class StockLedgerFixExportService( WHERE deleted = 0 AND date >= :from AND date < :toExclusive AND type = 'ADJ' - AND createdBy = 'stock-ledger-fix' + AND createdBy IN ('DNP', 'stock-ledger-fix') ORDER BY id """.trimIndent(), args, diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt index b7e5a129..f8719ab5 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt @@ -81,7 +81,7 @@ internal object StockLedgerFixInventorySql { i.stockUomId = b.stockUomId, i.uomId = COALESCE(baseIu.uomId, i.uomId), i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' + i.modifiedBy = 'DNP' WHERE IFNULL(i.deleted, 0) = 0 AND i.stockUomId IS NULL AND taken.id IS NULL @@ -125,7 +125,7 @@ internal object StockLedgerFixInventorySql { i.stockUomId = b.stockUomId, i.uomId = COALESCE(baseIu.uomId, i.uomId), i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' + i.modifiedBy = 'DNP' WHERE IFNULL(i.deleted, 0) = 0 AND i.stockUomId IS NULL AND taken.id IS NULL @@ -149,7 +149,7 @@ internal object StockLedgerFixInventorySql { COALESCE(tpl.cpm, 0), COALESCE(tpl.cpmUnit, 'HKD'), 'unavailable', - NOW(), 'stock-ledger-fix', NOW(), 'stock-ledger-fix', 0, 0 + NOW(), 'DNP', NOW(), 'DNP', 0, 0 FROM ( SELECT il.itemId, iu.uomId FROM inventory_lot_line ill @@ -202,7 +202,7 @@ internal object StockLedgerFixInventorySql { SET i.deleted = 1, i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' + i.modifiedBy = 'DNP' WHERE IFNULL(i.deleted, 0) = 0 AND i.stockUomId IS NULL """ @@ -240,7 +240,7 @@ internal object StockLedgerFixInventorySql { 'unavailable' ), i.modified = NOW(), - i.modifiedBy = 'stock-ledger-fix' + i.modifiedBy = 'DNP' WHERE IFNULL(i.deleted, 0) = 0 """ } diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt index 8d38a460..895fa1ff 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt @@ -109,6 +109,36 @@ open class StockLedgerFixSupport( return out } + /** + * Rename rows already stamped by this fix to [FIX_ACTOR]. + * Adjustment-record remarks written by the fix become [FIX_ACTOR] as well. + */ + fun stampFixActor() { + jdbcDao.executeUpdate( + """ + UPDATE stock_adjustment_record + SET remarks = '$FIX_ACTOR', + modified = NOW(), + modifiedBy = '$FIX_ACTOR' + WHERE IFNULL(remarks, '') LIKE 'stock-ledger-fix%' + """.trimIndent(), + ) + FIX_ACTOR_TABLES.forEach { table -> + jdbcDao.executeUpdate( + "UPDATE $table SET createdBy = '$FIX_ACTOR' WHERE createdBy = 'stock-ledger-fix'", + ) + jdbcDao.executeUpdate( + "UPDATE $table SET modifiedBy = '$FIX_ACTOR' WHERE modifiedBy = 'stock-ledger-fix'", + ) + } + jdbcDao.executeUpdate( + "UPDATE stock_lot_day SET createdBy = '$FIX_ACTOR' WHERE createdBy = 'ledger-fix-api'", + ) + jdbcDao.executeUpdate( + "UPDATE stock_lot_day SET modifiedBy = '$FIX_ACTOR' WHERE modifiedBy = 'ledger-fix-api'", + ) + } + fun toInt(value: Any?): Int = when (value) { null -> 0 is Number -> value.toInt() @@ -144,6 +174,17 @@ open class StockLedgerFixSupport( companion object { const val INVENTORY_LOCK = "inventory-1.0" const val RUN_LOCK = "ledger-fix-run" + const val FIX_ACTOR = "DNP" private val DAY_STEPS = linkedSetOf("2.1", "2.2", "2.3", "2.4", "2.5", "2.6") + private val FIX_ACTOR_TABLES = listOf( + "stock_ledger", + "inventory", + "inventory_lot_line", + "stock_in", + "stock_in_line", + "stock_out", + "stock_out_line", + "stock_adjustment_record", + ) } } From 576f7fadd825c18dbde0a89070c811bc0b3c8d58 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 12 Oct 2026 13:50:46 +0800 Subject: [PATCH 37/52] export sql fix --- .../master/service/ledgerfix/StockLedgerFixExportService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt index f6713c0b..2950e8fd 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt @@ -350,7 +350,8 @@ open class StockLedgerFixExportService( ) if (batch > 0) writer.write(";\n\n") if (any) { - writer.write("$updateSql\n\n") + val sql = updateSql.trim().let { if (it.endsWith(";")) it else "$it;" } + writer.write("$sql\n\n") } else { writer.write("$emptyComment\n\n") } @@ -468,7 +469,7 @@ open class StockLedgerFixExportService( INNER JOIN tmp_slf_ill_in t ON t.id = ill.id SET ill.inQty = t.inQty, ill.modified = t.modified, - ill.modifiedBy = t.modifiedBy + ill.modifiedBy = t.modifiedBy; """.trimIndent(), emptyComment = "-- (no 2.7a lot-line inQty in range)", ) From 0ffbbd4476e586b38a3de200760c8f8f4120c25a Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 13 Oct 2026 11:32:10 +0800 Subject: [PATCH 38/52] fix lot line outqty =null to 0 when create lot line outqty defualt 0 --- .../fpsms/modules/stock/service/StockInLineService.kt | 1 + .../01_update_lot_line_outQty_null.sql | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 src/main/resources/db/changelog/changes/20261013_update_lot_line_outQty_null/01_update_lot_line_outQty_null.sql diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 6ac0166e..37e9aa15 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -540,6 +540,7 @@ open class StockInLineService( this.inventoryLot = stockInLine.inventoryLot this.warehouse = warehouse this.inQty = convertedBaseQty + this.outQty = BigDecimal.ZERO this.status = InventoryLotLineStatus.AVAILABLE this.stockUom = stockItemUom ?: throw ResponseStatusException( diff --git a/src/main/resources/db/changelog/changes/20261013_update_lot_line_outQty_null/01_update_lot_line_outQty_null.sql b/src/main/resources/db/changelog/changes/20261013_update_lot_line_outQty_null/01_update_lot_line_outQty_null.sql new file mode 100644 index 00000000..e0507b7b --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261013_update_lot_line_outQty_null/01_update_lot_line_outQty_null.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset fpsms:update_lot_line_outQty_null_v2 +--comment: Update inventory lot line outQty to 0 if it is null +UPDATE inventory_lot_line +SET outQty = 0 +WHERE outQty IS NULL + AND deleted = 0; From db4f81de85600a3393503b616f5fc162d35c9360 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 13 Oct 2026 12:01:21 +0800 Subject: [PATCH 39/52] 1.0 fix also fix lot line out qty --- .../ledgerfix/StockLedgerFixBackfillService.kt | 5 ++++- .../service/ledgerfix/StockLedgerFixInventorySql.kt | 12 ++++++++++++ .../master/web/models/StockLedgerFixModels.kt | 1 + .../01_update_lot_line_outQty_null.sql | 9 +++------ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt index d3613ed5..faae1256 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt @@ -29,6 +29,7 @@ open class StockLedgerFixBackfillService( val patchedStockUomId = patchedFromUom + patchedSingleLot val inserted = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.INSERT_MISSING_INVENTORY_SQL) val orphansDeleted = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL) + val outQtyNulledToZero = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.ZERO_NULL_OUT_QTY_SQL) val updated = support.jdbcDao.executeUpdate(StockLedgerFixInventorySql.UPDATE_INVENTORY_QTY_SQL) val missingAfter = support.toInt( support.jdbcDao.queryForList(StockLedgerFixInventorySql.MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"), @@ -37,10 +38,11 @@ open class StockLedgerFixBackfillService( support.jdbcDao.queryForList(StockLedgerFixInventorySql.NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"), ) log.info( - "stock-ledger-fix inventory-1.0 patchedStockUomId={} inserted={} orphansDeleted={} updated={} missingAfter={} nullStockUomIdAfter={}", + "stock-ledger-fix inventory-1.0 patchedStockUomId={} inserted={} orphansDeleted={} outQtyNulledToZero={} updated={} missingAfter={} nullStockUomIdAfter={}", patchedStockUomId, inserted, orphansDeleted, + outQtyNulledToZero, updated, missingAfter, nullStockUomIdAfter, @@ -52,6 +54,7 @@ open class StockLedgerFixBackfillService( patchedStockUomId = patchedStockUomId, nullStockUomIdAfter = nullStockUomIdAfter, orphansDeleted = orphansDeleted, + outQtyNulledToZero = outQtyNulledToZero, ) } finally { support.unlock(StockLedgerFixSupport.INVENTORY_LOCK) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt index f8719ab5..51aa5672 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt @@ -207,6 +207,18 @@ internal object StockLedgerFixInventorySql { AND i.stockUomId IS NULL """ + /** + * Putaway used to insert outQty as NULL. The lot-line trigger sums + * `inQty - outQty`, and NULL drops the row. Zero those lines before + * [UPDATE_INVENTORY_QTY_SQL] rewrites the buckets. + */ + internal const val ZERO_NULL_OUT_QTY_SQL = """ + UPDATE inventory_lot_line + SET outQty = 0 + WHERE outQty IS NULL + AND IFNULL(deleted, 0) = 0 + """ + internal const val UPDATE_INVENTORY_QTY_SQL = """ UPDATE inventory i LEFT JOIN ( diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt index 45f53287..723ed87c 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt @@ -80,6 +80,7 @@ data class StockLedgerFixInventoryResponse( val patchedStockUomId: Int, val nullStockUomIdAfter: Int, val orphansDeleted: Int = 0, + val outQtyNulledToZero: Int = 0, ) data class StockLedgerFixSearchInventoryHit( diff --git a/src/main/resources/db/changelog/changes/20261013_update_lot_line_outQty_null/01_update_lot_line_outQty_null.sql b/src/main/resources/db/changelog/changes/20261013_update_lot_line_outQty_null/01_update_lot_line_outQty_null.sql index e0507b7b..f3cb84ba 100644 --- a/src/main/resources/db/changelog/changes/20261013_update_lot_line_outQty_null/01_update_lot_line_outQty_null.sql +++ b/src/main/resources/db/changelog/changes/20261013_update_lot_line_outQty_null/01_update_lot_line_outQty_null.sql @@ -1,8 +1,5 @@ --liquibase formatted sql ---changeset fpsms:update_lot_line_outQty_null_v2 ---comment: Update inventory lot line outQty to 0 if it is null -UPDATE inventory_lot_line -SET outQty = 0 -WHERE outQty IS NULL - AND deleted = 0; +-- Data fix moved to POST /stock-ledger-fix/inventory (inventory 1.0). +-- Do not UPDATE inventory_lot_line from this changelog. +-- The previous changeset fpsms:update_lot_line_outQty_null is already MARK_RAN and did not change rows. From 70695549d5aeacb422b9889dbcd9bea80db08737 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Tue, 13 Oct 2026 13:02:58 +0800 Subject: [PATCH 40/52] Replace inventory-bucket errors with wording that names the lot and stock unit. Co-authored-by: Cursor --- .../service/PickExecutionIssueService.kt | 2 +- .../08_signal_stock_account_wording.sql | 380 ++++++++++++++++++ 2 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/08_signal_stock_account_wording.sql diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index a5b3437b..a242b0e5 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -2736,7 +2736,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse if (lotLine != null && inventoryBeforeUpdate == null) { throw ResponseStatusException( HttpStatus.BAD_REQUEST, - "找不到對應庫存單位的庫存桶,無法提交不良品/短少。", + "此批次的庫存單位與貨品不符,無法提交不良品/短少。", ) } diff --git a/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/08_signal_stock_account_wording.sql b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/08_signal_stock_account_wording.sql new file mode 100644 index 00000000..4b2dcd1c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260910_inventory_stock_uom/08_signal_stock_account_wording.sql @@ -0,0 +1,380 @@ +-- liquibase formatted sql + +-- changeset kelvin:20261013-drop-ill-triggers-stock-account +-- comment: Recreate lot-line triggers so the customer-facing SIGNAL names the lot and stock unit, without 庫存桶. +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_insert`; +DROP TRIGGER IF EXISTS `inventory_lot_line_AFTER_update`; + +-- changeset kelvin:20261013-ill-after-insert-stock-account splitStatements:false +-- comment: AFTER INSERT trigger. Same logic as 20260921-ill-after-insert-signal-zh. SIGNAL names the lot and stock unit. +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_AFTER_insert` + AFTER INSERT + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + DECLARE v_itemId INT; + DECLARE v_stockUomId INT; -- bucket key = item_uom.uomId for NEW.stockItemUomId + DECLARE v_baseUomId INT; -- baseUnit uomId; written to inventory.uomId only on CREATE + DECLARE v_inventoryId INT DEFAULT NULL; + DECLARE v_onHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_unavailable DECIMAL(14, 2) DEFAULT 0; + DECLARE v_currencyId INT DEFAULT NULL; + DECLARE v_currencyName VARCHAR(30) DEFAULT 'HKD'; + DECLARE v_price DECIMAL(14, 2) DEFAULT 0; + + -- Step 1: stockItemUomId is required to know which stock-UOM bucket this line belongs to. + IF NEW.stockItemUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '入倉明細缺少庫存單位,無法入帳'; + END IF; + + -- Step 2: Resolve bucket (itemId, stockUomId) from lot + item_uom. + -- stockItemUomId -> item_uom.id; stockUomId := item_uom.uomId (must belong to the same item). + SELECT il.itemId, iu.uomId + INTO v_itemId, v_stockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = NEW.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = NEW.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_itemId IS NULL OR v_stockUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此批次的庫存單位與貨品不符,請檢查貨品單位與批次'; + END IF; + + -- Step 3: Resolve base UOM for inventory.uomId snapshot (used only if we INSERT a new inventory row). + SELECT iu.uomId + INTO v_baseUomId + FROM `item_uom` iu + WHERE iu.itemId = v_itemId + AND iu.baseUnit = TRUE + AND IFNULL(iu.deleted, 0) = 0 + LIMIT 1; + + IF v_baseUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此貨品未設定基本單位,無法建立庫存'; + END IF; + + -- Step 4: Find existing inventory row for this stock-UOM bucket. + SELECT i.id + INTO v_inventoryId + FROM `inventory` i + WHERE i.itemId = v_itemId + AND i.stockUomId = v_stockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + -- Step 5: Create inventory row if missing (qty starts at 0; Step 6 overwrites from lot lines). + -- uomId = base snapshot once; stockUomId = bucket key. onHoldQty left at 0 and never maintained by this trigger. + IF v_inventoryId IS NULL THEN + -- Prefer currency/price from the lot's stock-in / PO / quotation path; fall back to HKD / first currency. + SELECT c.id, COALESCE(c.name, 'HKD'), COALESCE(pql.price, 0) + INTO v_currencyId, v_currencyName, v_price + FROM `inventory_lot` il + LEFT JOIN `stock_in_line` sil ON sil.id = il.stockInLineId + LEFT JOIN `purchase_order_line` pol ON pol.id = sil.purchaseOrderLineId + LEFT JOIN `purchase_quotation_line` pql + ON pql.itemId = il.itemId AND pql.uomId = pol.uomId + LEFT JOIN `purchase_quotation` pq ON pq.id = pql.purchaseQuotationId + LEFT JOIN `currency` c ON c.id = pq.currencyId + WHERE il.id = NEW.inventoryLotId + LIMIT 1; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE c.code = 'HKD' + AND IFNULL(c.deleted, 0) = 0 + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE IFNULL(c.deleted, 0) = 0 + ORDER BY c.id + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '無法取得幣別,無法建立庫存'; + END IF; + + INSERT INTO `inventory` (`itemId`, + `onHandQty`, + `onHoldQty`, + `unavailableQty`, + `price`, + `currencyId`, + `cpu`, + `cpuUnit`, + `cpm`, + `cpmUnit`, + `uomId`, + `stockUomId`, + `status`) + VALUES (v_itemId, + 0, + 0, + 0, + COALESCE(v_price, 0), + v_currencyId, + 0, + COALESCE(v_currencyName, 'HKD'), + 0, + COALESCE(v_currencyName, 'HKD'), + v_baseUomId, + v_stockUomId, + 'unavailable'); + + SET v_inventoryId = LAST_INSERT_ID(); + END IF; + + -- Step 6: Full recompute for THIS bucket only (not a delta on existing inventory qty). + -- onHand = SUM(inQty - outQty) for all statuses + -- unavailable = SUM(inQty - outQty) for status = unavailable only (holdQty ignored) + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_onHand, v_unavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_itemId + AND iu.uomId = v_stockUomId + AND IFNULL(ill.deleted, 0) = 0; + + -- Step 7: Overwrite inventory qty/status from the SUM (source of truth = lot lines). + -- Do not touch onHoldQty, uomId, or stockUomId on an existing row. + UPDATE `inventory` + SET `onHandQty` = v_onHand, + `unavailableQty` = v_unavailable, + `status` = IF(v_onHand - v_unavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_inventoryId; +END; + +-- changeset kelvin:20261013-ill-after-update-stock-account splitStatements:false +-- comment: AFTER UPDATE trigger. Same logic as 20260921-ill-after-update-signal-zh. SIGNAL names the lot and stock unit. +CREATE DEFINER = CURRENT_USER TRIGGER `inventory_lot_line_AFTER_update` + AFTER UPDATE + ON `inventory_lot_line` + FOR EACH ROW +BEGIN + DECLARE v_itemId INT; + DECLARE v_stockUomId INT; -- NEW bucket key + DECLARE v_baseUomId INT; -- baseUnit uomId; used only when creating inventory + DECLARE v_inventoryId INT DEFAULT NULL; + DECLARE v_onHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_unavailable DECIMAL(14, 2) DEFAULT 0; + DECLARE v_currencyId INT DEFAULT NULL; + DECLARE v_currencyName VARCHAR(30) DEFAULT 'HKD'; + DECLARE v_price DECIMAL(14, 2) DEFAULT 0; + + DECLARE v_oldItemId INT DEFAULT NULL; + DECLARE v_oldStockUomId INT DEFAULT NULL; + DECLARE v_oldInventoryId INT DEFAULT NULL; + DECLARE v_oldOnHand DECIMAL(14, 2) DEFAULT 0; + DECLARE v_oldUnavailable DECIMAL(14, 2) DEFAULT 0; + + -- Step 1: NEW.stockItemUomId is required. + IF NEW.stockItemUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '入倉明細缺少庫存單位,無法入帳'; + END IF; + + -- Step 2: Resolve NEW bucket (itemId, stockUomId). + SELECT il.itemId, iu.uomId + INTO v_itemId, v_stockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = NEW.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = NEW.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_itemId IS NULL OR v_stockUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此批次的庫存單位與貨品不符,請檢查貨品單位與批次'; + END IF; + + -- Step 3: If stock UOM or lot changed, recompute the OLD bucket so qty does not stick on the wrong row. + -- After this UPDATE, the line already belongs to NEW; OLD-bucket SUM naturally excludes it. + IF OLD.stockItemUomId IS NOT NULL + AND (OLD.stockItemUomId <> NEW.stockItemUomId + OR OLD.inventoryLotId <> NEW.inventoryLotId) THEN + + SELECT il.itemId, iu.uomId + INTO v_oldItemId, v_oldStockUomId + FROM `inventory_lot` il + INNER JOIN `item_uom` iu + ON iu.id = OLD.stockItemUomId + AND iu.itemId = il.itemId + AND IFNULL(iu.deleted, 0) = 0 + WHERE il.id = OLD.inventoryLotId + AND IFNULL(il.deleted, 0) = 0 + LIMIT 1; + + IF v_oldItemId IS NOT NULL + AND v_oldStockUomId IS NOT NULL + AND (v_oldItemId <> v_itemId OR v_oldStockUomId <> v_stockUomId) THEN + + SELECT i.id + INTO v_oldInventoryId + FROM `inventory` i + WHERE i.itemId = v_oldItemId + AND i.stockUomId = v_oldStockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + IF v_oldInventoryId IS NOT NULL THEN + -- Full SUM for OLD bucket (same formulas as NEW). + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_oldOnHand, v_oldUnavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il + ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu + ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_oldItemId + AND iu.uomId = v_oldStockUomId + AND IFNULL(ill.deleted, 0) = 0; + + UPDATE `inventory` + SET `onHandQty` = v_oldOnHand, + `unavailableQty` = v_oldUnavailable, + `status` = IF(v_oldOnHand - v_oldUnavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_oldInventoryId; + END IF; + END IF; + END IF; + + -- Step 4: Resolve base UOM snapshot (only needed when creating a new inventory row). + SELECT iu.uomId + INTO v_baseUomId + FROM `item_uom` iu + WHERE iu.itemId = v_itemId + AND iu.baseUnit = TRUE + AND IFNULL(iu.deleted, 0) = 0 + LIMIT 1; + + IF v_baseUomId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '此貨品未設定基本單位,無法建立庫存'; + END IF; + + -- Step 5: Find or create inventory row for the NEW bucket. + SELECT i.id + INTO v_inventoryId + FROM `inventory` i + WHERE i.itemId = v_itemId + AND i.stockUomId = v_stockUomId + AND IFNULL(i.deleted, 0) = 0 + LIMIT 1; + + IF v_inventoryId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD'), COALESCE(pql.price, 0) + INTO v_currencyId, v_currencyName, v_price + FROM `inventory_lot` il + LEFT JOIN `stock_in_line` sil ON sil.id = il.stockInLineId + LEFT JOIN `purchase_order_line` pol ON pol.id = sil.purchaseOrderLineId + LEFT JOIN `purchase_quotation_line` pql + ON pql.itemId = il.itemId AND pql.uomId = pol.uomId + LEFT JOIN `purchase_quotation` pq ON pq.id = pql.purchaseQuotationId + LEFT JOIN `currency` c ON c.id = pq.currencyId + WHERE il.id = NEW.inventoryLotId + LIMIT 1; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE c.code = 'HKD' + AND IFNULL(c.deleted, 0) = 0 + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SELECT c.id, COALESCE(c.name, 'HKD') + INTO v_currencyId, v_currencyName + FROM `currency` c + WHERE IFNULL(c.deleted, 0) = 0 + ORDER BY c.id + LIMIT 1; + END IF; + + IF v_currencyId IS NULL THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '無法取得幣別,無法建立庫存'; + END IF; + + INSERT INTO `inventory` (`itemId`, + `onHandQty`, + `onHoldQty`, + `unavailableQty`, + `price`, + `currencyId`, + `cpu`, + `cpuUnit`, + `cpm`, + `cpmUnit`, + `uomId`, + `stockUomId`, + `status`) + VALUES (v_itemId, + 0, + 0, + 0, + COALESCE(v_price, 0), + v_currencyId, + 0, + COALESCE(v_currencyName, 'HKD'), + 0, + COALESCE(v_currencyName, 'HKD'), + v_baseUomId, + v_stockUomId, + 'unavailable'); + + SET v_inventoryId = LAST_INSERT_ID(); + END IF; + + -- Step 6: Full SUM for NEW bucket + overwrite. + -- onHand = all statuses; unavailable = unavailable lines only; holdQty ignored. + SELECT COALESCE(SUM(ill.inQty - ill.outQty), 0), + COALESCE(SUM(CASE + WHEN LOWER(ill.status) = 'unavailable' + THEN ill.inQty - ill.outQty + ELSE 0 END), 0) + INTO v_onHand, v_unavailable + FROM `inventory_lot_line` ill + INNER JOIN `inventory_lot` il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 + INNER JOIN `item_uom` iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 + WHERE il.itemId = v_itemId + AND iu.uomId = v_stockUomId + AND IFNULL(ill.deleted, 0) = 0; + + -- Do not touch onHoldQty, uomId, or stockUomId on an existing row. + UPDATE `inventory` + SET `onHandQty` = v_onHand, + `unavailableQty` = v_unavailable, + `status` = IF(v_onHand - v_unavailable > 0, 'available', 'unavailable'), + `modified` = CURRENT_TIMESTAMP + WHERE `id` = v_inventoryId; +END; From b7d29cef12e6e524c03931b8c9d6da0c92273918 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Tue, 13 Oct 2026 15:02:17 +0800 Subject: [PATCH 41/52] no message --- .../modules/pickOrder/service/PickExecutionIssueService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index a242b0e5..e0e9b6e6 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -2736,7 +2736,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse if (lotLine != null && inventoryBeforeUpdate == null) { throw ResponseStatusException( HttpStatus.BAD_REQUEST, - "此批次的庫存單位與貨品不符,無法提交不良品/短少。", + "此批次的庫存單位與貨品不符,無法提交貨品遺失或不良貨品。", ) } From 9230a9e742b8e1f4d172e692d397d2926ea0e520 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 13 Oct 2026 15:43:38 +0800 Subject: [PATCH 42/52] lot line also need export --- .../master/service/ledgerfix/StockLedgerFixExportService.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt index 2950e8fd..370b4776 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt @@ -113,7 +113,7 @@ open class StockLedgerFixExportService( writer.write("-- stock-ledger-fix patch\n") writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") writer.write("-- parts=${parts.joinToString(",")}\n") - writer.write("-- apply order: 1.0 inventory → 2.1 delete ledger + stock_out lot → ledger(+2.3) UPDATE → 2.7 SIL/SOL/adjustment record then ADJ INSERT → 2.6 stock_lot_day\n") + writer.write("-- apply order: 1.0 lot outQty NULL→0 then inventory → 2.1 delete ledger + stock_out lot → ledger(+2.3) UPDATE → 2.7a lot inQty + SIL/SOL/adjustment record then ADJ INSERT → 2.6 stock_lot_day\n") writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") if (want23 && !want10) { writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n") @@ -153,6 +153,9 @@ open class StockLedgerFixExportService( } private fun writeExportInventory10(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) { + writer.write("-- === 1.0 lot line outQty NULL → 0 (before inventory; trigger may run, next block overwrites buckets) ===\n") + writer.write(StockLedgerFixInventorySql.ZERO_NULL_OUT_QTY_SQL.trim()) + writer.write(";\n\n") writer.write("-- === 1.0 inventory (INSERT with fixed id + UPDATE qty/status) ===\n") var batch = 0 jdbc.query( From 131e747ff31196f2c2faa4fc6aa34e1f4011cdbb Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 14 Oct 2026 13:39:03 +0800 Subject: [PATCH 43/52] exprot sql fix --- .../ledgerfix/StockLedgerFixExportService.kt | 62 ++++++++++--------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt index 370b4776..59c939b7 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt @@ -113,7 +113,7 @@ open class StockLedgerFixExportService( writer.write("-- stock-ledger-fix patch\n") writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n") writer.write("-- parts=${parts.joinToString(",")}\n") - writer.write("-- apply order: 1.0 lot outQty NULL→0 then inventory → 2.1 delete ledger + stock_out lot → ledger(+2.3) UPDATE → 2.7a lot inQty + SIL/SOL/adjustment record then ADJ INSERT → 2.6 stock_lot_day\n") + writer.write("-- apply order: 1.0 inventory (stockUomId) then lot outQty NULL→0 → 2.1 delete ledger + stock_out lot → ledger(+2.3) UPDATE → 2.7a lot inQty + SIL/SOL/adjustment record then ADJ INSERT → 2.6 stock_lot_day\n") writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n") if (want23 && !want10) { writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n") @@ -153,10 +153,41 @@ open class StockLedgerFixExportService( } private fun writeExportInventory10(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) { - writer.write("-- === 1.0 lot line outQty NULL → 0 (before inventory; trigger may run, next block overwrites buckets) ===\n") + writer.write("-- === 1.0 inventory (stockUomId first, so the later outQty trigger updates this row) ===\n") + writeInventoryUpsert(writer, jdbc) + writeTempUpdate( + writer, + jdbc, + emptyMap(), + title = "-- === 1.0 soft-delete orphan inventory (stockUomId still null) ===\n", + selectSql = """ + SELECT id, modified, modifiedBy + FROM inventory + WHERE deleted = 1 + AND modifiedBy IN ('DNP', 'stock-ledger-fix') + ORDER BY id + """.trimIndent(), + tempName = "tmp_sl_fix_inv_del", + columnDefs = "modified DATETIME NULL, modifiedBy VARCHAR(255) NULL", + columns = listOf("id", "modified", "modifiedBy"), + updateSql = """ + UPDATE inventory i + INNER JOIN tmp_sl_fix_inv_del t ON t.id = i.id + SET + i.deleted = 1, + i.modified = t.modified, + i.modifiedBy = t.modifiedBy; + """.trimIndent(), + emptyComment = "-- (no stock-ledger-fix deleted inventory rows)", + ) + writer.write("-- === 1.0 lot line outQty NULL → 0 (after stockUomId; trigger updates the existing bucket) ===\n") writer.write(StockLedgerFixInventorySql.ZERO_NULL_OUT_QTY_SQL.trim()) writer.write(";\n\n") - writer.write("-- === 1.0 inventory (INSERT with fixed id + UPDATE qty/status) ===\n") + writer.write("-- === 1.0 inventory qty again (trigger above replaced onHandQty) ===\n") + writeInventoryUpsert(writer, jdbc) + } + + private fun writeInventoryUpsert(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) { var batch = 0 jdbc.query( """ @@ -218,31 +249,6 @@ open class StockLedgerFixExportService( } else { writer.write("-- (no inventory rows)\n\n") } - writeTempUpdate( - writer, - jdbc, - emptyMap(), - title = "-- === 1.0 soft-delete orphan inventory (stockUomId still null) ===\n", - selectSql = """ - SELECT id, modified, modifiedBy - FROM inventory - WHERE deleted = 1 - AND modifiedBy IN ('DNP', 'stock-ledger-fix') - ORDER BY id - """.trimIndent(), - tempName = "tmp_sl_fix_inv_del", - columnDefs = "modified DATETIME NULL, modifiedBy VARCHAR(255) NULL", - columns = listOf("id", "modified", "modifiedBy"), - updateSql = """ - UPDATE inventory i - INNER JOIN tmp_sl_fix_inv_del t ON t.id = i.id - SET - i.deleted = 1, - i.modified = t.modified, - i.modifiedBy = t.modifiedBy; - """.trimIndent(), - emptyComment = "-- (no stock-ledger-fix deleted inventory rows)", - ) } /** From f00a77980ae3461fe9d918794dda6aeca8e1bbb3 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 14 Oct 2026 17:52:26 +0800 Subject: [PATCH 44/52] remark of merge round up/down --- .../ffii/fpsms/modules/stock/service/StockInLineService.kt | 3 +++ .../ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt | 5 +++++ .../ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt | 1 + 3 files changed, 9 insertions(+) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index c8f72b7f..c3e0704e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -333,6 +333,7 @@ open class StockInLineService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ private fun applyRequestedStockQtyRound( stockInLine: StockInLine, request: SaveStockInLineRequest, @@ -357,6 +358,7 @@ open class StockInLineService( stockInLine.remarks = remarks } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ @Throws(IOException::class) @Transactional open fun create(request: SaveStockInLineRequest): MessageResponse { @@ -975,6 +977,7 @@ open class StockInLineService( } } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ @Throws(IOException::class) @Transactional open fun update(request: SaveStockInLineRequest): MessageResponse { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt index 33093520..d7955c7e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt @@ -24,6 +24,7 @@ object StockQtyRoundSupport { return qty.stripTrailingZeros().scale() > 0 } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun applyIfRequested( currentQty: BigDecimal, currentRemarks: String?, @@ -51,6 +52,7 @@ object StockQtyRoundSupport { return after to mergeRemark(currentRemarks, withReason) } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun parseMode(raw: String?): Mode? { val value = raw?.trim()?.uppercase() ?: return null if (value.isEmpty()) return null @@ -61,6 +63,7 @@ object StockQtyRoundSupport { } } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun roundQty(before: BigDecimal, mode: Mode, customQty: BigDecimal?): BigDecimal { return when (mode) { Mode.HALF_UP -> before.setScale(0, RoundingMode.HALF_UP) @@ -79,6 +82,7 @@ object StockQtyRoundSupport { } } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun buildTag( mode: Mode, before: BigDecimal, @@ -90,6 +94,7 @@ object StockQtyRoundSupport { return "[QTY_ROUND mode=${mode.name} before=${plain(before)} after=${plain(after)} source=$source by=${sanitize(by)} at=${at.format(AT_FORMAT)}]" } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ fun mergeRemark(existing: String?, tag: String): String { val stripped = existing?.replace(TAG_REGEX, "")?.trim().orEmpty() val merged = if (stripped.isEmpty()) tag else "$tag $stripped" diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt index e605552d..d853ee5e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt @@ -37,6 +37,7 @@ data class SaveStockInRequest( // val m18 ) +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ data class SaveStockInLineRequest( var id: Long? = null, var stockInId: Long? = null, From 40dea3406fa9e61c0a53488544d5a489422e9848 Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 26 Oct 2026 17:24:32 +0800 Subject: [PATCH 45/52] trucklanecode rename --- .../pickOrder/entity/TruckLaneRenameLog.kt | 32 ++ .../entity/TruckLaneRenameLogRepository.kt | 9 + .../pickOrder/service/TruckLaneRenameRules.kt | 61 ++++ .../service/TruckLaneRenameService.kt | 336 ++++++++++++++++++ .../service/TruckLaneVersionService.kt | 26 ++ .../modules/pickOrder/web/TruckController.kt | 15 + .../web/models/TruckLaneRenameModels.kt | 43 +++ .../01_create_truck_lane_rename_log.sql | 23 ++ 8 files changed, 545 insertions(+) create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt create mode 100644 src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt new file mode 100644 index 00000000..40acce88 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt @@ -0,0 +1,32 @@ +package com.ffii.fpsms.modules.pickOrder.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table + +@Entity +@Table(name = "truck_lane_rename_log") +open class TruckLaneRenameLog : BaseEntity() { + @Column(name = "oldCode", nullable = false, length = 50) + open var oldCode: String = "" + + @Column(name = "newCode", nullable = false, length = 50) + open var newCode: String = "" + + @Column(name = "storeId", nullable = false, length = 10) + open var storeId: String = "" + + @Column(name = "remark", length = 255) + open var remark: String? = null + + /** A clock time, or `mixed` when rows in the bucket did not share one time. */ + @Column(name = "oldDepartureTime", length = 20) + open var oldDepartureTime: String? = null + + @Column(name = "newDepartureTime", length = 20) + open var newDepartureTime: String? = null + + @Column(name = "ticketCount", nullable = false) + open var ticketCount: Int = 0 +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt new file mode 100644 index 00000000..773915ea --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt @@ -0,0 +1,9 @@ +package com.ffii.fpsms.modules.pickOrder.entity + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TruckLaneRenameLogRepository : JpaRepository { + fun findTop50ByDeletedFalseOrderByIdDesc(): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt new file mode 100644 index 00000000..6db99169 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt @@ -0,0 +1,61 @@ +package com.ffii.fpsms.modules.pickOrder.service + +/** + * Bucket identity shared by the route board and the rename transaction. + * Remark is part of the key only on 4F. Comparison trims, and does not collapse inner spaces. + */ +object TruckLaneRenameRules { + const val RESERVED = "車線-X" + const val MAX_LEN = 50 + + data class BucketRef( + val code: String, + val floor: String, + val remarkKey: String, + ) + + fun floorOf(storeId: String?): String { + val compact = storeId?.trim()?.uppercase()?.replace(" ", "")?.replace("/", "") ?: "" + return if (compact == "4F") "4F" else "2F" + } + + /** Blank, null, and whitespace-only remarks share the empty key. 2F ignores remark. */ + fun remarkKey(floor: String, remark: String?): String { + if (floor != "4F") return "" + return remark?.trim().orEmpty() + } + + /** Null when the name is acceptable. Otherwise a stable error code. */ + fun validateNames(oldName: String, newName: String): String? { + val oldTrim = oldName.trim() + val newTrim = newName.trim() + if (oldTrim.isEmpty() || newTrim.isEmpty()) return "LANE_NAME_EMPTY" + if (newTrim.length > MAX_LEN) return "LANE_NAME_TOO_LONG" + if (oldTrim == RESERVED || newTrim == RESERVED) return "LANE_NAME_RESERVED" + return null + } + + /** + * Same-remark collision ignores case and excludes the source bucket. + * A different remark refuses only when the trimmed new name is exactly the same, + * because tickets have no remark and could not tell the buckets apart. + */ + fun collisionCode(source: BucketRef, newCode: String, live: List): String? { + val next = newCode.trim() + for (other in live) { + if (other.floor != source.floor) continue + val sameSource = other.remarkKey == source.remarkKey && + other.code.trim().equals(source.code.trim(), ignoreCase = true) + if (sameSource) continue + if (other.remarkKey == source.remarkKey && + other.code.trim().equals(next, ignoreCase = true) + ) { + return "LANE_NAME_TAKEN" + } + if (other.remarkKey != source.remarkKey && other.code.trim() == next) { + return "LANE_NAME_REMARK_AMBIGUOUS" + } + } + return null + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt new file mode 100644 index 00000000..74e8e800 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt @@ -0,0 +1,336 @@ +package com.ffii.fpsms.modules.pickOrder.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.pickOrder.entity.Truck +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneRenameLog +import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneRenameLogRepository +import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository +import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameRequest +import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameResponse +import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneRequest +import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneResponse +import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneRenameLogResponse +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.time.LocalTime +import java.time.format.DateTimeParseException + +@Service +open class TruckLaneRenameService( + private val jdbcDao: JdbcDao, + private val truckRepository: TruckRepository, + private val renameLogRepository: TruckLaneRenameLogRepository, +) { + @Transactional(readOnly = true) + open fun preview(req: PreviewTruckLaneRenameRequest): PreviewTruckLaneRenameResponse { + val code = req.truckLanceCode.trim() + if (code.isEmpty()) throw badRequest("LANE_NAME_EMPTY") + val floor = TruckLaneRenameRules.floorOf(req.storeId) + val remarkKey = TruckLaneRenameRules.remarkKey(floor, req.remark) + val params = ticketParams(code, floor, remarkKey) + return PreviewTruckLaneRenameResponse( + unfinishedCount = count(UNFINISHED_COUNT_SQL, params), + completedCount = count(COMPLETED_COUNT_SQL, params), + handlerNames = jdbcDao.queryForStrings(HANDLER_SQL, params) + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct(), + openScheduleCount = count(OPEN_SCHEDULE_COUNT_SQL, params), + ) + } + + @Transactional + open fun rename(req: RenameTruckLaneRequest): RenameTruckLaneResponse { + val oldCode = req.truckLanceCode.trim() + val newCode = req.replacementTruckLanceCode.trim() + val nameError = TruckLaneRenameRules.validateNames(oldCode, newCode) + if (nameError != null) throw badRequest(nameError) + val floor = TruckLaneRenameRules.floorOf(req.storeId) + val remarkKey = TruckLaneRenameRules.remarkKey(floor, req.remark) + val newTime = if (req.departureTimeChanged) parseTime(req.newDepartureTime) else null + if (req.departureTimeChanged && newTime == null) throw badRequest("LANE_TIME_INVALID") + + jdbcDao.queryForList( + "SELECT id FROM truck WHERE deleted = 0 FOR UPDATE", + emptyMap(), + ) + + val live = truckRepository.findAllForRouteBoard().filter { it.deleted != true } + val sourceRows = live.filter { inBucket(it, oldCode, floor, remarkKey) } + if (sourceRows.isEmpty()) throw badRequest("LANE_NOT_FOUND") + + val refs = liveBuckets(live) + val collision = TruckLaneRenameRules.collisionCode( + TruckLaneRenameRules.BucketRef(oldCode, floor, remarkKey), + newCode, + refs, + ) + if (collision != null) throw conflict(collision) + + val oldTimeLabel = describeTimes(sourceRows.map { it.departureTime }) + val casings = sourceRows.map { it.truckLanceCode?.trim().orEmpty() }.filter { it.isNotEmpty() }.distinct() + var scheduleCount = 0 + var ticketCount = 0 + for (casing in casings) { + scheduleCount += applyOpenScheduleRename(casing, newCode, floor, remarkKey) + val params = ticketParams(casing, floor, remarkKey).toMutableMap() + params["newCode"] = newCode + ticketCount += jdbcDao.executeUpdate( + updateTicketSql(DOPO_UPDATE_SQL, req.departureTimeChanged), + withTime(params, newTime), + ) + jdbcDao.executeUpdate( + updateTicketSql(PICK_UPDATE_SQL, req.departureTimeChanged), + withTime(params, newTime), + ) + } + for (row in sourceRows) { + row.truckLanceCode = newCode + if (req.departureTimeChanged) row.departureTime = newTime + } + truckRepository.saveAll(sourceRows) + + val log = TruckLaneRenameLog() + log.oldCode = oldCode + log.newCode = newCode + log.storeId = floor + log.remark = remarkKey.ifEmpty { null } + log.oldDepartureTime = oldTimeLabel + log.newDepartureTime = if (req.departureTimeChanged) newTime.toString() else oldTimeLabel + log.ticketCount = ticketCount + renameLogRepository.save(log) + + return RenameTruckLaneResponse( + truckLanceCode = newCode, + ticketCount = ticketCount, + scheduleLineCount = scheduleCount, + ) + } + + /** + * Restore writes master rows back to the snapshot name and does not move unfinished tickets. + * Open schedule from/to that still point at the current name are moved back here. + */ + @Transactional + open fun rewriteOpenScheduleNames( + oldCode: String, + newCode: String, + storeId: String?, + remark: String?, + ): Int { + val floor = TruckLaneRenameRules.floorOf(storeId) + val remarkKey = TruckLaneRenameRules.remarkKey(floor, remark) + return applyOpenScheduleRename(oldCode.trim(), newCode.trim(), floor, remarkKey) + } + + @Transactional(readOnly = true) + open fun recentLogs(): List = + renameLogRepository.findTop50ByDeletedFalseOrderByIdDesc().map { row -> + TruckLaneRenameLogResponse( + id = row.id ?: 0, + created = row.created?.toString(), + createdBy = row.createdBy, + oldCode = row.oldCode, + newCode = row.newCode, + storeId = row.storeId, + remark = row.remark, + oldDepartureTime = row.oldDepartureTime, + newDepartureTime = row.newDepartureTime, + ticketCount = row.ticketCount, + ) + } + + private fun applyOpenScheduleRename( + oldCode: String, + newCode: String, + floor: String, + remarkKey: String, + ): Int { + if (oldCode.isEmpty() || newCode.isEmpty() || oldCode == newCode) return 0 + val params = ticketParams(oldCode, floor, remarkKey).toMutableMap() + params["newCode"] = newCode + val matched = count(OPEN_SCHEDULE_COUNT_SQL, params) + jdbcDao.executeUpdate(SCHEDULE_FROM_SQL, params) + jdbcDao.executeUpdate(SCHEDULE_TO_SQL, params) + return matched + } + + private fun liveBuckets(live: List): List { + val grouped = linkedMapOf, String>() + for (row in live) { + val code = row.truckLanceCode?.trim().orEmpty() + if (code.isEmpty()) continue + val floor = TruckLaneRenameRules.floorOf(row.storeId) + val remarkKey = TruckLaneRenameRules.remarkKey(floor, row.remark) + val key = Triple(code.lowercase(), floor, remarkKey) + grouped.putIfAbsent(key, code) + } + return grouped.map { (key, code) -> + TruckLaneRenameRules.BucketRef(code, key.second, key.third) + } + } + + private fun inBucket(row: Truck, code: String, floor: String, remarkKey: String): Boolean { + val rowFloor = TruckLaneRenameRules.floorOf(row.storeId) + val rowRemark = TruckLaneRenameRules.remarkKey(rowFloor, row.remark) + return rowFloor == floor && + rowRemark == remarkKey && + row.truckLanceCode?.trim().orEmpty().equals(code, ignoreCase = true) + } + + private fun describeTimes(times: List): String { + val labels = times.map { it?.withNano(0)?.toString() ?: "" }.distinct() + return if (labels.size <= 1) labels.firstOrNull().orEmpty() else "mixed" + } + + private fun parseTime(raw: String?): LocalTime? { + val text = raw?.trim().orEmpty() + if (text.isEmpty()) return null + val normalized = if (text.length == 5) "$text:00" else text + return try { + LocalTime.parse(normalized) + } catch (_: DateTimeParseException) { + null + } + } + + private fun withTime(params: MutableMap, time: LocalTime?): Map { + if (time != null) params["newTime"] = time.toString() + return params + } + + private fun updateTicketSql(base: String, withTime: Boolean): String { + if (!withTime) return base + return base.replace("SET colCode = :newCode", "SET colCode = :newCode, colTime = :newTime") + .replace("SET TruckLanceCode = :newCode", "SET TruckLanceCode = :newCode, truck_departure_time = :newTime") + .replace("SET truckLanceCode = :newCode", "SET truckLanceCode = :newCode, truckDepartureTime = :newTime") + } + + private fun ticketParams(code: String, floor: String, remarkKey: String): Map = mapOf( + "oldCode" to code, + "floor" to floor, + "remark" to remarkKey, + "blankRemark" to if (remarkKey.isEmpty()) 1 else 0, + ) + + private fun count(sql: String, params: Map): Int { + val row = jdbcDao.queryForList(sql, params).firstOrNull() ?: return 0 + val value = row.values.firstOrNull() + return (value as? Number)?.toInt() ?: 0 + } + + private fun badRequest(code: String) = ResponseStatusException(HttpStatus.BAD_REQUEST, code) + + private fun conflict(code: String) = ResponseStatusException(HttpStatus.CONFLICT, code) + + companion object { + private const val FLOOR_MATCH = """ + ( + (:floor = '4F' AND UPPER(REPLACE(TRIM(storeCol), ' ', '')) IN ('4F', '4/F')) + OR (:floor <> '4F' AND UPPER(REPLACE(TRIM(storeCol), ' ', '')) IN ('2F', '2/F')) + ) + """ + + private const val REMARK_MATCH_4F = """ + ( + :floor <> '4F' + OR ( + (:blankRemark = 1 AND (remarkCol IS NULL OR TRIM(remarkCol) = '')) + OR (:blankRemark = 0 AND TRIM(remarkCol) = :remark) + ) + ) + """ + + private val UNFINISHED_COUNT_SQL = """ + SELECT COUNT(*) AS cnt + FROM delivery_order_pick_order + WHERE deleted = 0 + AND ticketStatus IN ('pending', 'released') + AND TRIM(truckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "storeId")} + """.trimIndent() + + private val COMPLETED_COUNT_SQL = """ + SELECT COUNT(*) AS cnt + FROM delivery_order_pick_order + WHERE deleted = 0 + AND ticketStatus = 'completed' + AND TRIM(truckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "storeId")} + """.trimIndent() + + private val HANDLER_SQL = """ + SELECT DISTINCT TRIM(handlerName) + FROM delivery_order_pick_order + WHERE deleted = 0 + AND ticketStatus IN ('pending', 'released') + AND handledBy IS NOT NULL + AND handlerName IS NOT NULL + AND TRIM(handlerName) <> '' + AND TRIM(truckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "storeId")} + """.trimIndent() + + private val DOPO_UPDATE_SQL = """ + UPDATE delivery_order_pick_order + SET truckLanceCode = :newCode, version = version + 1 + WHERE deleted = 0 + AND ticketStatus IN ('pending', 'released') + AND TRIM(truckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "storeId")} + """.trimIndent() + + private val PICK_UPDATE_SQL = """ + UPDATE do_pick_order + SET TruckLanceCode = :newCode, version = version + 1 + WHERE deleted = 0 + AND ticket_status IN ('pending', 'released') + AND TRIM(TruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "store_id")} + """.trimIndent() + + private val OPEN_SCHEDULE_COUNT_SQL = """ + SELECT COUNT(*) AS cnt + FROM truck_lane_schedule_line l + JOIN truck_lane_schedule s ON s.id = l.scheduleId + WHERE l.deleted = 0 AND s.deleted = 0 + AND s.status NOT IN ('APPLIED', 'CANCELLED') + AND ( + ( + TRIM(l.fromTruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "l.fromStoreId")} + AND ${REMARK_MATCH_4F.replace("remarkCol", "l.fromRemark")} + ) + OR ( + TRIM(l.toTruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "l.toStoreId")} + AND ${REMARK_MATCH_4F.replace("remarkCol", "l.toRemark")} + ) + ) + """.trimIndent() + + private val SCHEDULE_FROM_SQL = """ + UPDATE truck_lane_schedule_line l + JOIN truck_lane_schedule s ON s.id = l.scheduleId + SET l.fromTruckLanceCode = :newCode, l.version = l.version + 1 + WHERE l.deleted = 0 AND s.deleted = 0 + AND s.status NOT IN ('APPLIED', 'CANCELLED') + AND TRIM(l.fromTruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "l.fromStoreId")} + AND ${REMARK_MATCH_4F.replace("remarkCol", "l.fromRemark")} + """.trimIndent() + + private val SCHEDULE_TO_SQL = """ + UPDATE truck_lane_schedule_line l + JOIN truck_lane_schedule s ON s.id = l.scheduleId + SET l.toTruckLanceCode = :newCode, l.version = l.version + 1 + WHERE l.deleted = 0 AND s.deleted = 0 + AND s.status NOT IN ('APPLIED', 'CANCELLED') + AND TRIM(l.toTruckLanceCode) = :oldCode + AND ${FLOOR_MATCH.replace("storeCol", "l.toStoreId")} + AND ${REMARK_MATCH_4F.replace("remarkCol", "l.toRemark")} + """.trimIndent() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt index c5623d2a..f120a522 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt @@ -16,6 +16,7 @@ open class TruckLaneVersionService( private val truckLaneVersionRepository: TruckLaneVersionRepository, private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, private val logisticRepository: LogisticRepository, + private val truckLaneRenameService: TruckLaneRenameService, ) { private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse = TruckLaneVersionResponse( @@ -271,6 +272,7 @@ open class TruckLaneVersionService( } } + rewriteOpenSchedulesBackToSnapshot(version, lines) restoreRowsFromLines(version, lines) if (!skipPostSnapshot) { @@ -316,6 +318,7 @@ open class TruckLaneVersionService( return "No snapshot lines for affected lanes (versionId=$versionId)" } + rewriteOpenSchedulesBackToSnapshot(version, scopedLines) val fullSnapshotIds = allLines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() for (lane in codes) { val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) @@ -340,6 +343,29 @@ open class TruckLaneVersionService( return "Restored ${scopedLines.size} rows in ${codes.size} lane(s) from versionId=$versionId" } + /** + * Open schedule from/to that still use the live name are pointed back at the snapshot name. + * Unfinished delivery tickets are left on the current name. + */ + private fun rewriteOpenSchedulesBackToSnapshot( + version: TruckLaneVersion, + lines: List, + ) { + val ids = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.distinct() + if (ids.isEmpty()) return + val trucks = truckRepository.findAllById(ids).associateBy { it.id } + val seen = HashSet() + for (line in lines) { + val truck = trucks[line.truckRowId] ?: continue + val before = truck.truckLanceCode?.trim().orEmpty() + val after = (line.truckLanceCode ?: version.truckLanceCode)?.trim().orEmpty() + if (before.isEmpty() || after.isEmpty() || before == after) continue + val key = "$before|$after|${truck.storeId}|${truck.remark}" + if (!seen.add(key)) continue + truckLaneRenameService.rewriteOpenScheduleNames(before, after, truck.storeId, truck.remark) + } + } + private fun restoreRowsFromLines( version: TruckLaneVersion, lines: List, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt index c4b4edb2..59022d4a 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt @@ -18,8 +18,11 @@ import org.springframework.web.multipart.MultipartHttpServletRequest import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest +import com.ffii.fpsms.modules.pickOrder.service.TruckLaneRenameService import com.ffii.fpsms.modules.pickOrder.service.TruckService import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository +import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameRequest +import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneRequest import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest @@ -37,6 +40,7 @@ import jakarta.validation.Valid open class TruckController( private val truckService: TruckService, private val truckRepository: TruckRepository, + private val truckLaneRenameService: TruckLaneRenameService, ) { @@ -469,4 +473,15 @@ open class TruckController( } } + @PostMapping("/previewTruckLaneRename") + open fun previewTruckLaneRename(@RequestBody req: PreviewTruckLaneRenameRequest) = + truckLaneRenameService.preview(req) + + @PostMapping("/renameTruckLane") + open fun renameTruckLane(@RequestBody req: RenameTruckLaneRequest) = + truckLaneRenameService.rename(req) + + @GetMapping("/truckLaneRenameLog") + open fun truckLaneRenameLog() = truckLaneRenameService.recentLogs() + } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt new file mode 100644 index 00000000..b4e9449d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt @@ -0,0 +1,43 @@ +package com.ffii.fpsms.modules.pickOrder.web.models + +data class PreviewTruckLaneRenameRequest( + val truckLanceCode: String = "", + val storeId: String = "2F", + val remark: String? = null, +) + +data class PreviewTruckLaneRenameResponse( + val unfinishedCount: Int = 0, + val completedCount: Int = 0, + val handlerNames: List = emptyList(), + val openScheduleCount: Int = 0, +) + +data class RenameTruckLaneRequest( + val truckLanceCode: String = "", + val storeId: String = "2F", + val remark: String? = null, + @param:com.fasterxml.jackson.annotation.JsonProperty("newTruckLanceCode") + val replacementTruckLanceCode: String = "", + val departureTimeChanged: Boolean = false, + val newDepartureTime: String? = null, +) + +data class RenameTruckLaneResponse( + val truckLanceCode: String = "", + val ticketCount: Int = 0, + val scheduleLineCount: Int = 0, +) + +data class TruckLaneRenameLogResponse( + val id: Long = 0, + val created: String? = null, + val createdBy: String? = null, + val oldCode: String = "", + val newCode: String = "", + val storeId: String = "", + val remark: String? = null, + val oldDepartureTime: String? = null, + val newDepartureTime: String? = null, + val ticketCount: Int = 0, +) diff --git a/src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql b/src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql new file mode 100644 index 00000000..f7f36449 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261026_truck_lane_rename/01_create_truck_lane_rename_log.sql @@ -0,0 +1,23 @@ +-- liquibase formatted sql +-- changeset fpsms:20261026_01_create_truck_lane_rename_log +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'truck_lane_rename_log' + +CREATE TABLE IF NOT EXISTS `truck_lane_rename_log` +( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `oldCode` VARCHAR(50) NOT NULL, + `newCode` VARCHAR(50) NOT NULL, + `storeId` VARCHAR(10) NOT NULL, + `remark` VARCHAR(255) NULL DEFAULT NULL, + `oldDepartureTime` VARCHAR(20) NULL DEFAULT NULL, + `newDepartureTime` VARCHAR(20) NULL DEFAULT NULL, + `ticketCount` INT NOT NULL DEFAULT '0', + CONSTRAINT pk_truck_lane_rename_log PRIMARY KEY (`id`) +); From e36db529380ef7d0167ba2dc8f0a7bed081ca686 Mon Sep 17 00:00:00 2001 From: tommy Date: Tue, 27 Oct 2026 16:25:21 +0800 Subject: [PATCH 46/52] rename trucklancecode --- .../service/TruckLaneRenameService.kt | 47 ++----------------- 1 file changed, 4 insertions(+), 43 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt index 74e8e800..7c217a2e 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt @@ -71,20 +71,11 @@ open class TruckLaneRenameService( val oldTimeLabel = describeTimes(sourceRows.map { it.departureTime }) val casings = sourceRows.map { it.truckLanceCode?.trim().orEmpty() }.filter { it.isNotEmpty() }.distinct() + // Existing delivery tickets (pending / released / assigned / completed) keep the old name. + // Later releases copy the new master name at insert time. var scheduleCount = 0 - var ticketCount = 0 for (casing in casings) { scheduleCount += applyOpenScheduleRename(casing, newCode, floor, remarkKey) - val params = ticketParams(casing, floor, remarkKey).toMutableMap() - params["newCode"] = newCode - ticketCount += jdbcDao.executeUpdate( - updateTicketSql(DOPO_UPDATE_SQL, req.departureTimeChanged), - withTime(params, newTime), - ) - jdbcDao.executeUpdate( - updateTicketSql(PICK_UPDATE_SQL, req.departureTimeChanged), - withTime(params, newTime), - ) } for (row in sourceRows) { row.truckLanceCode = newCode @@ -99,12 +90,12 @@ open class TruckLaneRenameService( log.remark = remarkKey.ifEmpty { null } log.oldDepartureTime = oldTimeLabel log.newDepartureTime = if (req.departureTimeChanged) newTime.toString() else oldTimeLabel - log.ticketCount = ticketCount + log.ticketCount = 0 renameLogRepository.save(log) return RenameTruckLaneResponse( truckLanceCode = newCode, - ticketCount = ticketCount, + ticketCount = 0, scheduleLineCount = scheduleCount, ) } @@ -196,18 +187,6 @@ open class TruckLaneRenameService( } } - private fun withTime(params: MutableMap, time: LocalTime?): Map { - if (time != null) params["newTime"] = time.toString() - return params - } - - private fun updateTicketSql(base: String, withTime: Boolean): String { - if (!withTime) return base - return base.replace("SET colCode = :newCode", "SET colCode = :newCode, colTime = :newTime") - .replace("SET TruckLanceCode = :newCode", "SET TruckLanceCode = :newCode, truck_departure_time = :newTime") - .replace("SET truckLanceCode = :newCode", "SET truckLanceCode = :newCode, truckDepartureTime = :newTime") - } - private fun ticketParams(code: String, floor: String, remarkKey: String): Map = mapOf( "oldCode" to code, "floor" to floor, @@ -273,24 +252,6 @@ open class TruckLaneRenameService( AND ${FLOOR_MATCH.replace("storeCol", "storeId")} """.trimIndent() - private val DOPO_UPDATE_SQL = """ - UPDATE delivery_order_pick_order - SET truckLanceCode = :newCode, version = version + 1 - WHERE deleted = 0 - AND ticketStatus IN ('pending', 'released') - AND TRIM(truckLanceCode) = :oldCode - AND ${FLOOR_MATCH.replace("storeCol", "storeId")} - """.trimIndent() - - private val PICK_UPDATE_SQL = """ - UPDATE do_pick_order - SET TruckLanceCode = :newCode, version = version + 1 - WHERE deleted = 0 - AND ticket_status IN ('pending', 'released') - AND TRIM(TruckLanceCode) = :oldCode - AND ${FLOOR_MATCH.replace("storeCol", "store_id")} - """.trimIndent() - private val OPEN_SCHEDULE_COUNT_SQL = """ SELECT COUNT(*) AS cnt FROM truck_lane_schedule_line l From b200778250c0a223624ab53f9c63dff2d3274643 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 28 Oct 2026 16:00:49 +0800 Subject: [PATCH 47/52] =?UTF-8?q?=E5=B7=B2=E6=94=BE=E5=96=AE=E7=9A=84?= =?UTF-8?q?=E9=80=81=E8=B2=A8=E5=96=AE=E6=90=9C=E5=B0=8B=E6=94=B9=E8=AE=80?= =?UTF-8?q?=E7=A5=A8=E6=93=9A=E8=BB=8A=E7=B7=9A=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E6=94=BE=E5=96=AE=E5=BE=8C=E5=86=8D=E4=BE=9D=E4=BE=9B=E6=87=89?= =?UTF-8?q?=E5=95=86=E9=87=8D=E6=9F=A5=E8=BB=8A=E8=BC=9B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../service/DeliveryOrderService.kt | 248 +++++++++++++----- 1 file changed, 189 insertions(+), 59 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index cee583ca..73a0eb9d 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -159,6 +159,7 @@ open class DeliveryOrderService( } } + /** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */ open fun searchDoLiteByPage( code: String?, shopName: String?, @@ -203,10 +204,14 @@ open class DeliveryOrderService( val preFilteredContent = allResult.content val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val ticketLaneByDoId = loadTicketTruckLanesByDoId( + deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, + ) - // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) + // 未放單才依供應商樓層查 truck;已放單用票據車線 val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> val deliveryOrder = deliveryOrdersMap[info.id] + if (isReleasedDeliveryOrder(deliveryOrder)) return@mapNotNull null val shopId = deliveryOrder?.shop?.id val estimatedArrivalDate = info.estimatedArrivalDate if (shopId != null && estimatedArrivalDate != null) { @@ -234,23 +239,16 @@ open class DeliveryOrderService( // 处理预过滤后的记录:计算 truckLanceCode 并过滤 val processedRecords = preFilteredContent.map { info -> val deliveryOrder = deliveryOrdersMap[info.id] - val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) - val shop = deliveryOrder?.shop - val shopId = shop?.id val estimatedArrivalDate = info.estimatedArrivalDate - val calculatedTruckLanceCode = - if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { - val targetDate = estimatedArrivalDate.toLocalDate() - val dayAbbr = getDayOfWeekAbbr(targetDate) - - // 从缓存中获取 Truck - val matchedTruck = truckCache[Triple(shopId, preferredFloor, dayAbbr)] - matchedTruck?.truckLanceCode - } else { - null - } + val calculatedTruckLanceCode = displayedTruckLanceCode( + deliveryOrder = deliveryOrder, + estimatedArrivalDate = estimatedArrivalDate, + floorSuppliers2F = floorSuppliers2F, + floorSuppliers4F = floorSuppliers4F, + truckCache = truckCache, + ticketLaneByDoId = ticketLaneByDoId, + ) DeliveryOrderInfoLiteDto( id = info.id, @@ -299,22 +297,22 @@ open class DeliveryOrderService( ) val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val loadedOrders = result.content.associate { info -> + info.id to deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) + } + val ticketLaneByDoId = loadTicketTruckLanesByDoId( + loadedOrders.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it?.id }, + ) val records = result.content.map { info -> - val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) - val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) - val shop = deliveryOrder?.shop - val shopId = shop?.id - val estimatedArrivalDate = info.estimatedArrivalDate - - val calculatedTruckLanceCode = - if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { - val targetDate = estimatedArrivalDate.toLocalDate() - val dayAbbr = getDayOfWeekAbbr(targetDate) - resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode - } else { - null - } + val deliveryOrder = loadedOrders[info.id] + val calculatedTruckLanceCode = displayedTruckLanceCode( + deliveryOrder = deliveryOrder, + estimatedArrivalDate = info.estimatedArrivalDate, + floorSuppliers2F = floorSuppliers2F, + floorSuppliers4F = floorSuppliers4F, + truckCache = null, + ticketLaneByDoId = ticketLaneByDoId, + ) DeliveryOrderInfoLiteDto( id = info.id, @@ -391,8 +389,9 @@ open class DeliveryOrderService( } /** - * 僅回傳依店鋪/ETA 從 truck 排程**推算後** `truckLanceCode` 為 null 或空白的送貨單(畫面上對應「車線-X」)。 - * 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。 + * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 + * 僅回傳顯示車線為 null 或空白的送貨單(畫面上對應「車線-X」)。 + * 已放單(receiving/completed)用票據車線;未放單才依店鋪/ETA 從 truck 排程推算。 */ open fun searchDoLiteUnassignedTruckByPage( code: String?, @@ -425,23 +424,20 @@ open class DeliveryOrderService( val deliveryOrderIds = allResult.content.mapNotNull { it.id } val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val ticketLaneByDoId = loadTicketTruckLanesByDoId( + deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, + ) val processedRecords = allResult.content.map { info -> val deliveryOrder = deliveryOrdersMap[info.id] - val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) - val shop = deliveryOrder?.shop - val shopId = shop?.id - val infoEta = info.estimatedArrivalDate - - val calculatedTruckLanceCode = - if (deliveryOrder != null && shopId != null && infoEta != null) { - val targetDate = infoEta.toLocalDate() - val dayAbbr = getDayOfWeekAbbr(targetDate) - resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode - } else { - null - } + val calculatedTruckLanceCode = displayedTruckLanceCode( + deliveryOrder = deliveryOrder, + estimatedArrivalDate = info.estimatedArrivalDate, + floorSuppliers2F = floorSuppliers2F, + floorSuppliers4F = floorSuppliers4F, + truckCache = null, + ticketLaneByDoId = ticketLaneByDoId, + ) DeliveryOrderInfoLiteDto( id = info.id, @@ -2502,6 +2498,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } return out } + /** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */ private fun buildResolvedTruckDtosForLiteRows( rows: List, ): List { @@ -2509,9 +2506,13 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } if (ids.isEmpty()) return emptyList() val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() + val ticketLaneByDoId = loadTicketTruckLanesByDoId( + deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, + ) val shopIdAndDatePairs = rows.mapNotNull { info -> val d = deliveryOrdersMap[info.id] + if (isReleasedDeliveryOrder(d)) return@mapNotNull null val shopId = d?.shop?.id val eta = info.estimatedArrivalDate if (shopId != null && eta != null) { @@ -2533,18 +2534,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } return rows.map { info -> val deliveryOrder = deliveryOrdersMap[info.id] - val supplierCode = deliveryOrder?.supplier?.code - val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) - val shopId = deliveryOrder?.shop?.id - val infoEta = info.estimatedArrivalDate - val calculatedTruckLanceCode = - if (deliveryOrder != null && shopId != null && infoEta != null) { - val targetDate = infoEta.toLocalDate() - val dayAbbr = getDayOfWeekAbbr(targetDate) - truckCache[Triple(shopId, preferredFloor, dayAbbr)]?.truckLanceCode - } else { - null - } + val calculatedTruckLanceCode = displayedTruckLanceCode( + deliveryOrder = deliveryOrder, + estimatedArrivalDate = info.estimatedArrivalDate, + floorSuppliers2F = floorSuppliers2F, + floorSuppliers4F = floorSuppliers4F, + truckCache = truckCache, + ticketLaneByDoId = ticketLaneByDoId, + ) DeliveryOrderInfoLiteDto( id = info.id, code = info.code, @@ -2560,6 +2557,139 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } } } + private class TicketLaneHit( + val lane: String?, + val ticketId: Long, + val srcRank: Int, + ) + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 + * 放單後狀態:receiving 表示已發布,completed 表示已完成。 + */ + private fun isReleasedDeliveryOrder(deliveryOrder: DeliveryOrder?): Boolean = + deliveryOrder?.status == DeliveryOrderStatus.RECEIVING || + deliveryOrder?.status == DeliveryOrderStatus.COMPLETED + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 + * 已放單讀票據車線(工作台 `delivery_order_pick_order`,否則 `do_pick_order`/record)。 + * 未放單才依供應商樓層 + 店鋪 + 星期從 truck 推算。 + * [truckCache] 為 null 時,未放單即時查 truck。 + */ + private fun displayedTruckLanceCode( + deliveryOrder: DeliveryOrder?, + estimatedArrivalDate: LocalDateTime?, + floorSuppliers2F: List, + floorSuppliers4F: List, + truckCache: Map, Truck?>?, + ticketLaneByDoId: Map, + ): String? { + if (isReleasedDeliveryOrder(deliveryOrder)) { + return deliveryOrder?.id?.let { ticketLaneByDoId[it] } + } + val shopId = deliveryOrder?.shop?.id ?: return null + val eta = estimatedArrivalDate ?: return null + val preferredFloor = preferredStoreFloorForSupplier( + deliveryOrder.supplier?.code, + floorSuppliers2F, + floorSuppliers4F, + ) + val dayAbbr = getDayOfWeekAbbr(eta.toLocalDate()) + val key = Triple(shopId, preferredFloor, dayAbbr) + if (truckCache != null) { + return truckCache[key]?.truckLanceCode + } + return resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode + } + + /** + * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 + * 一批送貨單對應的票據車線。同一張單有多張票時,工作台票優先,同來源取 id 最大的一張。 + * 票上的車線為空白時仍回傳 null,呼叫端不再改查 truck。 + */ + private fun loadTicketTruckLanesByDoId(doIds: Collection): Map { + val ids = doIds.distinct() + if (ids.isEmpty()) return emptyMap() + val best = HashMap() + val sql = """ + SELECT do_id, truck_lance_code, ticket_id, src_rank + FROM ( + SELECT po.doId AS do_id, + NULLIF(TRIM(dop.truckLanceCode), '') AS truck_lance_code, + dop.id AS ticket_id, + 1 AS src_rank + FROM fpsmsdb.delivery_order_pick_order dop + INNER JOIN fpsmsdb.pick_order po + ON po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + WHERE dop.deleted = 0 + AND po.doId IN (:doIds) + + UNION ALL + + SELECT dpo.do_order_id AS do_id, + NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code, + dpo.id AS ticket_id, + 2 AS src_rank + FROM fpsmsdb.do_pick_order dpo + WHERE dpo.deleted = 0 + AND dpo.do_order_id IN (:doIds) + + UNION ALL + + SELECT dpol.do_order_id AS do_id, + NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code, + dpo.id AS ticket_id, + 2 AS src_rank + FROM fpsmsdb.do_pick_order dpo + INNER JOIN fpsmsdb.do_pick_order_line dpol + ON dpol.do_pick_order_id = dpo.id AND dpol.deleted = 0 + WHERE dpo.deleted = 0 + AND dpol.do_order_id IN (:doIds) + + UNION ALL + + SELECT rec.do_order_id AS do_id, + NULLIF(TRIM(rec.TruckLanceCode), '') AS truck_lance_code, + rec.id AS ticket_id, + 3 AS src_rank + FROM fpsmsdb.do_pick_order_record rec + WHERE rec.deleted = 0 + AND rec.do_order_id IN (:doIds) + ) lanes + """.trimIndent() + + ids.chunked(400).forEach { chunk -> + val rows = jdbcDao.queryForList(sql, mapOf("doIds" to chunk)) + for (row in rows) { + val doId = jdbcNumber(row, "do_id")?.toLong() ?: continue + val ticketId = jdbcNumber(row, "ticket_id")?.toLong() ?: 0L + val srcRank = jdbcNumber(row, "src_rank")?.toInt() ?: 9 + val lane = jdbcText(row, "truck_lance_code") + val hit = TicketLaneHit(lane, ticketId, srcRank) + val current = best[doId] + if ( + current == null || + hit.srcRank < current.srcRank || + (hit.srcRank == current.srcRank && hit.ticketId > current.ticketId) + ) { + best[doId] = hit + } + } + } + return best.mapValues { it.value.lane } + } + + private fun jdbcNumber(row: Map<*, *>, name: String): Number? { + val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null + return value as? Number + } + + private fun jdbcText(row: Map<*, *>, name: String): String? { + val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null + return value.toString().trim().takeIf { it.isNotEmpty() } + } + /** * 依店鋪 + 揀貨樓層解析當日應顯示之車線。 * - **2F**(P07/P06D/P06Y):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 From d50642a7a11a6e44fe4977f4f65d7b403d099d62 Mon Sep 17 00:00:00 2001 From: tommy Date: Wed, 28 Oct 2026 17:53:33 +0800 Subject: [PATCH 48/52] truck rename --- .../ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt | 4 ++++ .../fpsms/modules/pickOrder/service/TruckLaneRenameService.kt | 2 ++ .../modules/pickOrder/web/models/TruckLaneRenameModels.kt | 1 + 3 files changed, 7 insertions(+) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt index 40acce88..977bfb0a 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt @@ -29,4 +29,8 @@ open class TruckLaneRenameLog : BaseEntity() { @Column(name = "ticketCount", nullable = false) open var ticketCount: Int = 0 + + /** `truck.id` of each shop row in the renamed lane, comma-separated, ascending. */ + @Column(name = "truckIds", columnDefinition = "TEXT") + open var truckIds: String? = null } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt index 7c217a2e..fa4dc2c6 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt @@ -91,6 +91,7 @@ open class TruckLaneRenameService( log.oldDepartureTime = oldTimeLabel log.newDepartureTime = if (req.departureTimeChanged) newTime.toString() else oldTimeLabel log.ticketCount = 0 + log.truckIds = sourceRows.mapNotNull { it.id }.distinct().sorted().joinToString(",") renameLogRepository.save(log) return RenameTruckLaneResponse( @@ -130,6 +131,7 @@ open class TruckLaneRenameService( oldDepartureTime = row.oldDepartureTime, newDepartureTime = row.newDepartureTime, ticketCount = row.ticketCount, + truckIds = row.truckIds, ) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt index b4e9449d..78b659e4 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt @@ -40,4 +40,5 @@ data class TruckLaneRenameLogResponse( val oldDepartureTime: String? = null, val newDepartureTime: String? = null, val ticketCount: Int = 0, + val truckIds: String? = null, ) From fdaef672b7eb81089951df80863d247c20ef0eae Mon Sep 17 00:00:00 2001 From: tommy Date: Wed, 28 Oct 2026 17:55:23 +0800 Subject: [PATCH 49/52] no message --- .../01_add_truck_ids.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql diff --git a/src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql b/src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql new file mode 100644 index 00000000..f45dba7e --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261028_truck_lane_rename_log_truck_ids/01_add_truck_ids.sql @@ -0,0 +1,9 @@ +-- liquibase formatted sql +-- changeset fpsms:20261028_01_truck_lane_rename_log_truck_ids +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_rename_log' AND column_name = 'truckIds' + +-- truck.id values of the shops in the renamed lane, comma-separated, ascending. +-- Nullable so rows written before this column existed stay valid. +ALTER TABLE `truck_lane_rename_log` + ADD COLUMN `truckIds` TEXT NULL DEFAULT NULL AFTER `ticketCount`; From 1bae0e38a038d1ec6a20b3883896e1cac17e2428 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 28 Oct 2026 18:01:34 +0800 Subject: [PATCH 50/52] =?UTF-8?q?=E6=AD=A3=E5=BC=8F=E9=80=81=E8=B2=A8?= =?UTF-8?q?=E5=96=AE=E5=90=8C=E4=B8=80=E8=B2=A8=E5=93=81=E6=9C=89=E5=A4=9A?= =?UTF-8?q?=E7=AD=86=E6=8F=90=E6=96=99=E8=A1=8C=E6=99=82=EF=BC=8C=E5=B7=B2?= =?UTF-8?q?=E6=8F=90=E6=95=B8=E9=87=8F=E6=94=B9=E6=8C=89=E6=95=B8=E9=87=8F?= =?UTF-8?q?=E9=85=8D=E5=B0=8D=EF=BC=8C=E9=81=BF=E5=85=8D=E9=83=BD=E5=8D=B0?= =?UTF-8?q?=E6=88=90=E7=AC=AC=E4=B8=80=E7=AD=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../entity/models/DeliveryOrderLineInfo.kt | 1 + .../service/DeliveryOrderService.kt | 120 +++++++++++++++--- .../service/DoReplenishmentService.kt | 3 + .../service/DoWorkbenchMainService.kt | 10 +- 4 files changed, 112 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt index 1fd8a3e1..98145832 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value import java.math.BigDecimal interface DeliveryOrderLineInfo { + val id: Long @get:Value("#{target.item?.name}") val itemName: String? val itemNo: String diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index 73a0eb9d..dd4ccdf1 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -1383,6 +1383,7 @@ open class DeliveryOrderService( ) } + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ private fun exportDeliveryNoteFromRecord( request: ExportDeliveryNoteRequest, deliveryNote: net.sf.jasperreports.engine.JasperReport, @@ -1476,15 +1477,19 @@ open class DeliveryOrderService( val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds) - + val pickOrderLineIdByDeliveryLine = matchDeliveryNotePickOrderLines( + exportLines = exportLines, + pickOrderIdByDoId = pickOrderIdByDoId, + pickOrderLines = pickOrderLines, + replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(), + ) + sortedLines.forEach { row -> fields.add( buildDeliveryNotePdfLineField( deliveryOrderId = row.deliveryOrderId, line = row.line, sequenceNumber = fields.size + 1, - pickOrderIdByDoId = pickOrderIdByDoId, - pickOrderLines = pickOrderLines, stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, illById = illById, itemsById = itemsById, @@ -1493,6 +1498,7 @@ open class DeliveryOrderService( headerTicketNo = null, headerIsMerge = false, replenishPdfIndex = replenishPdfIndex, + pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine, ), ) } @@ -1553,17 +1559,47 @@ open class DeliveryOrderService( info.deliveryOrderLines.filter { it.deleted != true }.map { line -> DeliveryNoteExportLine(info.id, line) } } - fun resolvePickOrderLineIdForDeliveryNoteLine( - deliveryOrderId: Long, - itemId: Long?, + /** + * FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 + * Pair each delivery-order line with its own pick line. + * Same item on one pick order is matched by qty first (4↔4, 1↔1). + * Equal qty is paired in id order. Replenishment pick lines are left out; + * those rows still print from do_replenishment.pickOrderLineId. + */ + fun matchDeliveryNotePickOrderLines( + exportLines: List, pickOrderIdByDoId: Map, pickOrderLines: List, - ): Long? { - if (itemId == null) return null - val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: return null - return pickOrderLines - .firstOrNull { pol -> pol.pickOrder?.id == pickOrderId && pol.item?.id == itemId } - ?.id + replenishmentPickOrderLineIds: Set = emptySet(), + ): Map, Long> { + val keys = exportLines.mapNotNull { row -> + val itemId = row.line.itemId ?: return@mapNotNull null + if (row.line.deleted == true) return@mapNotNull null + DeliveryNoteLineMatchKey( + deliveryOrderId = row.deliveryOrderId, + lineId = row.line.id, + itemId = itemId, + qty = row.line.qty, + ) + } + val candidates = pickOrderLines.mapNotNull { pol -> + val polId = pol.id ?: return@mapNotNull null + val pickOrderId = pol.pickOrder?.id ?: return@mapNotNull null + val itemId = pol.item?.id ?: return@mapNotNull null + val qty = pol.qty ?: return@mapNotNull null + DeliveryNotePickLineCandidate( + id = polId, + pickOrderId = pickOrderId, + itemId = itemId, + qty = qty, + ) + } + return DeliveryNotePickLineMatcher.match( + lines = keys, + pickOrderIdByDoId = pickOrderIdByDoId, + pickLines = candidates, + replenishmentPickOrderLineIds = replenishmentPickOrderLineIds, + ) } fun parseTicketTypeLetter(ticketNo: String?): String? { @@ -1675,12 +1711,11 @@ open class DeliveryOrderService( return field } + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ fun buildDeliveryNotePdfLineField( deliveryOrderId: Long, line: DeliveryOrderLineInfo, sequenceNumber: Int, - pickOrderIdByDoId: Map, - pickOrderLines: List, stockOutLinesByPickOrderLineId: Map>, illById: Map, itemsById: Map, @@ -1689,6 +1724,7 @@ open class DeliveryOrderService( headerTicketNo: String? = null, headerIsMerge: Boolean = false, replenishPdfIndex: DoReplenishmentService.ReplenishPdfIndex = DoReplenishmentService.ReplenishPdfIndex.EMPTY, + pickOrderLineIdByDeliveryLine: Map, Long> = emptyMap(), ): MutableMap { val field = mutableMapOf() val isExtra = isExtraDeliveryTicket( @@ -1697,12 +1733,7 @@ open class DeliveryOrderService( headerIsMerge = headerIsMerge, ) - val polId = resolvePickOrderLineIdForDeliveryNoteLine( - deliveryOrderId = deliveryOrderId, - itemId = line.itemId, - pickOrderIdByDoId = pickOrderIdByDoId, - pickOrderLines = pickOrderLines, - ) + val polId = pickOrderLineIdByDeliveryLine[deliveryOrderId to line.id] val isReplenish = replenishPdfIndex.matches(deliveryOrderId, line.itemId, polId) field["sequenceNumber"] = formatSequenceNumber(sequenceNumber, isExtra, isReplenish) @@ -2806,3 +2837,52 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } } } } + +internal data class DeliveryNoteLineMatchKey( + val deliveryOrderId: Long, + val lineId: Long, + val itemId: Long, + val qty: BigDecimal, +) + +internal data class DeliveryNotePickLineCandidate( + val id: Long, + val pickOrderId: Long, + val itemId: Long, + val qty: BigDecimal, +) + +internal object DeliveryNotePickLineMatcher { + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ + fun match( + lines: List, + pickOrderIdByDoId: Map, + pickLines: List, + replenishmentPickOrderLineIds: Set = emptySet(), + ): Map, Long> { + val result = mutableMapOf, Long>() + val rowsByDoAndItem = lines.groupBy { it.deliveryOrderId to it.itemId } + for ((key, rows) in rowsByDoAndItem) { + val (deliveryOrderId, itemId) = key + val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: continue + val available = pickLines + .filter { pol -> + pol.pickOrderId == pickOrderId && + pol.itemId == itemId && + pol.id !in replenishmentPickOrderLineIds + } + .sortedBy { it.id } + .toMutableList() + for (row in rows.sortedBy { it.lineId }) { + val qtyIndex = available.indexOfFirst { it.qty.compareTo(row.qty) == 0 } + val chosen = when { + qtyIndex >= 0 -> available.removeAt(qtyIndex) + available.isNotEmpty() -> available.removeAt(0) + else -> null + } ?: continue + result[row.deliveryOrderId to row.lineId] = chosen.id + } + } + return result + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt index 81d0010a..50e8b0e2 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt @@ -162,6 +162,9 @@ open class DoReplenishmentService( return deliveryOrderId to resolvedItemId in targetDoItemKeys } + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ + fun replenishmentPickOrderLineIds(): Set = pickOrderLineIds + companion object { val EMPTY = ReplenishPdfIndex(emptySet(), emptySet()) } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 50b2ec58..74d9e7f7 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -3057,6 +3057,7 @@ return MessageResponse( return deliveryOrderService.formatDoCodesPlain(codes) } + /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map { val deliveryNote = deliveryNoteJasperTemplate val fields = mutableListOf>() @@ -3108,6 +3109,12 @@ return MessageResponse( val headerIsMerge = ctx.header.ticketNo?.startsWith("TI-M-") == true val headerTicketNo = ctx.header.ticketNo val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(ctx.deliveryOrderIds) + val pickOrderLineIdByDeliveryLine = deliveryOrderService.matchDeliveryNotePickOrderLines( + exportLines = exportLines, + pickOrderIdByDoId = ctx.pickOrderIdByDoId, + pickOrderLines = pickOrderLines, + replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(), + ) sortedLines.forEach { row -> fields.add( @@ -3115,8 +3122,6 @@ return MessageResponse( deliveryOrderId = row.deliveryOrderId, line = row.line, sequenceNumber = fields.size + 1, - pickOrderIdByDoId = ctx.pickOrderIdByDoId, - pickOrderLines = pickOrderLines, stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, illById = illById, itemsById = itemsById, @@ -3125,6 +3130,7 @@ return MessageResponse( headerTicketNo = headerTicketNo, headerIsMerge = headerIsMerge, replenishPdfIndex = replenishPdfIndex, + pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine, ), ) } From cd9fc251a94c7f30d42ecef587564eee851d6e9c Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 2 Nov 2026 14:51:52 +0800 Subject: [PATCH 51/52] =?UTF-8?q?=E5=B7=A5=E5=96=AE=E5=BB=BA=E8=AD=B0?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E5=85=A8=E9=83=A8=204F=EF=BC=8C=E5=90=8C?= =?UTF-8?q?=E5=88=B0=E6=9C=9F=E6=97=A5=E5=85=88=203F=20=E5=86=8D=202F?= =?UTF-8?q?=EF=BC=9B=E7=9B=A4=E9=BB=9E=E6=A0=B8=E5=87=86=E5=B7=AE=E7=95=B0?= =?UTF-8?q?=E6=94=B9=E8=B7=9F=E7=95=AB=E9=9D=A2=E9=81=B8=E6=93=87=E6=95=B8?= =?UTF-8?q?=E9=87=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../service/DoWorkbenchMainService.kt | 4 +- .../service/JoWorkbenchMainService.kt | 5 +- .../service/JoWorkbenchPickConstants.kt | 29 ++------- .../ConsumableWorkbenchPickConstants.kt | 4 +- .../stock/service/StockTakeRecordService.kt | 33 +++++++++- .../SuggestedPickLotWorkbenchService.kt | 62 +++++++++++++++---- 6 files changed, 93 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 74d9e7f7..cc3cabc0 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -259,9 +259,9 @@ open class DoWorkbenchMainService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 + * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.3 | 2026-11-02 * Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split). - * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES]). + * - JO: same list as assign ([JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES], all `4F-` bins). * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit). * - DO / other: pass through request (`null` → service default excludes). */ diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt index a2ef1724..2a41fc58 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchMainService.kt @@ -23,12 +23,12 @@ import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService +import com.ffii.fpsms.modules.stock.service.isWorkbenchWarehouseCodeExcluded import com.ffii.fpsms.modules.user.service.UserService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.time.LocalDate -import java.util.Locale /** * Job Order Workbench–only flows (assign prime, hierarchical payload for scan-pick UI). @@ -79,8 +79,7 @@ open class JoWorkbenchMainService( val availableStatus = notDeleted.filter { it.status == InventoryLotLineStatus.AVAILABLE } val notExpired = availableStatus.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } val notExcluded = notExpired.filter { lot -> - val code = lot.warehouse?.code?.trim()?.uppercase(Locale.ROOT) - code.isNullOrEmpty() || code !in workbenchDefaultExcludeWarehouseCodes + !isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, workbenchDefaultExcludeWarehouseCodes) } val positiveQty = notExcluded.filter { lot -> val inQ = lot.inQty ?: BigDecimal.ZERO diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt index 727c54a0..e5aaaff1 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt @@ -1,37 +1,18 @@ package com.ffii.fpsms.modules.jobOrder.service /** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * JO workbench pick constants. * * [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService]) * and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]). * JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution. + * + * Each entry matches a warehouse code exactly, or any code with that prefix plus `-`. + * `"4F"` therefore excludes every 4F bin (`4F-W402-…`, `4F-W401-…`, and the rest). */ object JoWorkbenchPickConstants { val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set = setOf( - "4F-W402-01-00", - "4F-W402-02-00", - "4F-W402-03-00", - "4F-W402-04-00", - "4F-W402-05-00", - "4F-W402-#A-00", - "4F-W402-#B-00", - "4F-W402-#C-00", - "4F-W402-#D-00", - "4F-W402-#E-00", - "4F-W402-#F-00", - "4F-W402-#G-00", - "4F-W402-#H-00", - "4F-W402-#I-00", - "4F-W402-#J-00", - "4F-W402-#K-00", - "4F-W402-#L-00", - "4F-W402-#M-00", - "4F-W402-#N-00", - "4F-W402-#O-00", - "4F-W402-#P-00", - "4F-W402-#Q-00", - "4F-W402-#R-00", - "4F-W402-#S-00", + "4F", ) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt index caaadd87..9372a772 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/ConsumableWorkbenchPickConstants.kt @@ -5,7 +5,7 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants /** * Temporary consumable workbench exclude list until per-user DB config (Scheme A) lands. * - * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants]). + * - [HARDCODED_EXCLUDE_USER_ID] → same warehouses as JO ([JoWorkbenchPickConstants], all `4F-` bins). * - All other users → empty list (no warehouse limit). Do not return null: * [SuggestedPickLotWorkbenchService] treats null as DO default 2F excludes. * Consumable re-suggest keeps storeId null (same as first prime / JO); it does not use DO floor store resolution. @@ -13,7 +13,7 @@ import com.ffii.fpsms.modules.jobOrder.service.JoWorkbenchPickConstants object ConsumableWorkbenchPickConstants { const val HARDCODED_EXCLUDE_USER_ID: Long = 246L - /** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.0 | 2026-08-13 */ + /** FP-MTMS Version Checklist | Functions Ref. No. 66 | v1.0.1 | 2026-11-02 */ fun resolveExcludeWarehouseCodes(userId: Long): List { if (userId != HARDCODED_EXCLUDE_USER_ID) return emptyList() return JoWorkbenchPickConstants.DEFAULT_EXCLUDE_WAREHOUSE_CODES.toList() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index d4d3c308..a7f51df3 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -316,8 +316,31 @@ open class StockTakeRecordService( } } - /** 與 Approver 列表前端一致:優先第二次盤點數,否則第一次 */ + /** + * 與核准列表「差異百分比」同一個選擇數量。 + * lastSelect:1 第一次、2 第二次、3 審核員(盤點數 − 不良數)。 + * 沒有 lastSelect 時用 finalQty 對應;再沒有才退回第二次,否則第一次。 + */ private fun resolvePickerQtyForVarianceFilter(response: InventoryLotDetailResponse): BigDecimal { + when (response.lastSelect) { + 1 -> return response.firstStockTakeQty ?: BigDecimal.ZERO + 2 -> return response.secondStockTakeQty ?: BigDecimal.ZERO + 3 -> return approverGoodQty(response) + } + val finalQty = response.finalQty + if (finalQty != null) { + val approverGood = approverGoodQty(response) + if (response.approverQty != null && sameStockTakeQty(approverGood, finalQty)) { + return approverGood + } + val second = response.secondStockTakeQty + if (second != null && sameStockTakeQty(second, finalQty)) return second + val first = response.firstStockTakeQty + if (first != null && sameStockTakeQty(first, finalQty)) return first + if (response.approverQty != null && sameStockTakeQty(response.approverQty, finalQty)) { + return approverGood + } + } val second = response.secondStockTakeQty val first = response.firstStockTakeQty return when { @@ -327,6 +350,13 @@ open class StockTakeRecordService( } } + /** 與前端選擇數量一致:審核員盤點數減不良數。 */ + private fun approverGoodQty(response: InventoryLotDetailResponse): BigDecimal = + (response.approverQty ?: BigDecimal.ZERO).subtract(response.approverBadQty ?: BigDecimal.ZERO) + + private fun sameStockTakeQty(a: BigDecimal?, b: BigDecimal?): Boolean = + a != null && b != null && a.compareTo(b) == 0 + private fun computeInventoryLotDetailVariancePercentage(response: InventoryLotDetailResponse): BigDecimal { val selectedQty = resolvePickerQtyForVarianceFilter(response) val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO @@ -646,6 +676,7 @@ open class StockTakeRecordService( return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey) } + /** FP-MTMS Version Checklist | Functions Ref. No. 90 | v1.0.0 | 2026-11-02 */ open fun getApproverInventoryLotDetailsAll( stockTakeId: Long? = null, pageNum: Int = 0, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt index 50f02a6c..e8dd5f0d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt @@ -23,6 +23,21 @@ data class WorkbenchSuggestionSummary( val skippedExisting: Int, ) +/** + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 + * Case-insensitive match of [warehouseCode] against [excludeCodes]. + * An entry matches the code exactly, or any code that starts with `{entry}-`. + * `"4F"` therefore excludes every 4F bin. + */ +fun isWorkbenchWarehouseCodeExcluded(warehouseCode: String?, excludeCodes: Collection): Boolean { + val code = warehouseCode?.trim()?.uppercase() ?: return false + if (code.isEmpty() || excludeCodes.isEmpty()) return false + return excludeCodes.any { raw -> + val entry = raw.trim().uppercase() + entry.isNotEmpty() && (code == entry || code.startsWith("$entry-")) + } +} + @Service open class SuggestedPickLotWorkbenchService( private val pickOrderLineRepository: PickOrderLineRepository, @@ -127,7 +142,7 @@ open class SuggestedPickLotWorkbenchService( /** * Backend-default avoid warehouse list for workbench suggestions. - * - Case-insensitive match against `warehouse.code`. + * - Case-insensitive match against `warehouse.code` (exact, or `{entry}-` prefix). * - When request.excludeWarehouseCodes is provided, it overrides this default. * * Keep it empty if you don't need any default excludes. @@ -143,9 +158,32 @@ open class SuggestedPickLotWorkbenchService( } private fun isWarehouseExcluded(lot: InventoryLotLine, excludeWarehouseCodes: Set): Boolean { - if (excludeWarehouseCodes.isEmpty()) return false - val code = lot.warehouse?.code?.trim()?.uppercase() - return !code.isNullOrEmpty() && code in excludeWarehouseCodes + return isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, excludeWarehouseCodes) + } + + /** 3F before 2F when expiry is the same. Other floors stay after those two. */ + private fun suggestionFloorRank(lot: InventoryLotLine): Int { + val store = normalizeStoreId(lot.warehouse?.store_id) + ?: floorTokenFromWarehouseCode(lot.warehouse?.code) + return when (store) { + "3F" -> 0 + "2F" -> 1 + else -> 2 + } + } + + private fun floorTokenFromWarehouseCode(code: String?): String? { + val head = code?.trim()?.substringBefore("-") ?: return null + return normalizeStoreId(head) + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 + * Closest expiry first; same day prefers 3F, then 2F. + */ + private fun compareSuggestedLots(a: InventoryLotLine, b: InventoryLotLine): Int { + val byExpiry = compareValues(a.inventoryLot?.expiryDate, b.inventoryLot?.expiryDate) + if (byExpiry != 0) return byExpiry + return suggestionFloorRank(a).compareTo(suggestionFloorRank(b)) } private fun effectiveExcludeWarehouseCodes(raw: List?): Set { @@ -168,7 +206,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * Build suggestions with no-hold rule: * available = inQty - outQty (ignore holdQty). * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). @@ -204,7 +242,7 @@ open class SuggestedPickLotWorkbenchService( .filter { it.status == InventoryLotLineStatus.AVAILABLE } .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } .filter { !isWarehouseExcluded(it, excludeSet) } - .sortedBy { it.inventoryLot?.expiryDate } + .sortedWith(this::compareSuggestedLots) .groupBy { it.inventoryLot?.item?.id } var created = 0 @@ -301,7 +339,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId]. * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. */ @@ -365,7 +403,7 @@ open class SuggestedPickLotWorkbenchService( if (storeKey == null) true else normalizeStoreId(lot.warehouse?.store_id) == storeKey } - .sortedBy { it.inventoryLot?.expiryDate } + .sortedWith(this::compareSuggestedLots) val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId) val created = syncSuggestedPickLotsForLine(lineFresh, desired) @@ -393,7 +431,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * Workbench explicit-qty remainder support (single-next-lot): * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). @@ -441,7 +479,7 @@ open class SuggestedPickLotWorkbenchService( if (storeKey == null) true else normalizeStoreId(lot.warehouse?.store_id) == storeKey } - .sortedBy { it.inventoryLot?.expiryDate } + .sortedWith(this::compareSuggestedLots) .firstOrNull() val desired = listOf(nextLot to targetQty) @@ -454,7 +492,7 @@ open class SuggestedPickLotWorkbenchService( } /** - * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 + * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 * Workbench explicit-qty split support: * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). * - If [targetQty] <= 0: soft-delete all active suggestions for the line. @@ -491,7 +529,7 @@ open class SuggestedPickLotWorkbenchService( .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } .filter { !isWarehouseExcluded(it, excludeSet) } .filter { matchesPolUom(it, line) } - .sortedBy { it.inventoryLot?.expiryDate } + .sortedWith(this::compareSuggestedLots) val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) val created = syncSuggestedPickLotsForLine(line, desired) From 57c0bda9e1df4ad59b2826255eac0f567958c1ec Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Wed, 4 Nov 2026 13:29:03 +0800 Subject: [PATCH 52/52] Stock Lot Ledger UI fix --- .../service/StockLotLedgerReportService.kt | 16 ++++++++++++++++ .../report/web/StockLotLedgerReportController.kt | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt index 16547fce..1166a127 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt @@ -46,6 +46,22 @@ open class StockLotLedgerReportService( return LedgerPeriod(start = startParsed.withDayOfMonth(1), end = end) } + /** First day of the earliest month that has a stock_ledger row. */ + fun earliestLedgerMonth(): LocalDate { + val row = jdbcDao.queryForList( + """ + SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate + FROM stock_ledger sl + WHERE sl.deleted = 0 + """.trimIndent(), + emptyMap(), + ).firstOrNull() + val raw = row?.get("firstDate")?.toString()?.trim().orEmpty() + val parsed = raw.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) } + ?.let { runCatching { LocalDate.parse(it.take(10)) }.getOrNull() } + return (parsed ?: LocalDate.now()).withDayOfMonth(1) + } + fun searchStockLotLedgerReport( itemCode: String?, reportPeriodStart: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt index d8ac95da..cea09dc4 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt @@ -47,6 +47,12 @@ class StockLotLedgerReportController( val dash: CellStyle, ) + @GetMapping("/stock-lot-ledger-earliest") + fun earliestLedgerMonth(): Map { + val earliest = stockLotLedgerReportService.earliestLedgerMonth() + return mapOf("earliest" to earliest.format(DateTimeFormatter.ISO_LOCAL_DATE)) + } + @GetMapping("/print-stock-lot-ledger-excel") fun exportStockLotLedgerReportExcel( @RequestParam(required = false) itemCode: String?,