From c80f6c75a45614cc426c7fd5d070890c48fd5881 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Tue, 2 Jun 2026 08:23:38 -0600 Subject: [PATCH] feature /bom y descagras % kgs --- app/Manual_Reintegro.docx | Bin 0 -> 42288 bytes app/app.ipynb | 1653 +++++++++++++++++++++++++++++++++++-- 2 files changed, 1598 insertions(+), 55 deletions(-) create mode 100644 app/Manual_Reintegro.docx diff --git a/app/Manual_Reintegro.docx b/app/Manual_Reintegro.docx new file mode 100644 index 0000000000000000000000000000000000000000..65d3f8cb17113f70211f58842f3c601a272a482e GIT binary patch literal 42288 zcmagEbDU*OlPz4fZQHidW!tuG+v>7ytIM`++g4Xq*R6hL-aGT&`OWwJv(G-68L`%i z%zZL;#*vo-20;M;0Du5UDDKm)RxD0P1_S^Y2Lk{={%+M0wzG9Mv31r{_OLf`(xG#= zv2IS5k=qbJ2)+44O`+!_@Dhect=PAxu%}7D5~90H-pI}RFNTR6v_-$`t*tav_3en4%0G%oM@ zIqBy{^d{^tn)A6zN2Z!>l?l}zz>yZAf2;B%RKF>4(5c>ain`I!OvRyLqpujweHhvf zVgAsjm(FcMjrkm`p*;FgNpl;?GUV{e&MKI!Zs8O-NA1g&JMUscmD;>hQzKT{$KpIl z9Lw`X7%uCgH;|INkx=I+SO!yPT(x)b%fQPEWpNw{-`i@q?#tqWjLoEq|0sMYs|tU; zVuxT{$`~^z`_G&I>swZK+TKzM_0*_ z%SK>rI=yF0u6$ zFIn&IQV`KmN9)5oxj8U0UCu1TUZpOv4pKAJSPINv=~LgKyld*v+k{Ag>;eh-oEO9auTM^%uh1UMNL*87g z@{aB_NLWru*PY}MY^oD!ki)Err*!Vuf!HxE3GtMg=d`KIc94Rf{S4kELw)LvW^>%C z55(A<7Te9g2PixgTFK&jna_O(2=O~W#&$;Xj&}A=^hS1$CVv-scH)HW00V;Pi?`^a zjB3OK0)(gp72tWKq&P~zYTGjltBmzT4%^huL6MDS9M34b1Se>NMZYoimw9WS%Q7X>r5B}{MjH{!-?76zI9^X2i*f-&=_1WKu3 z7KlW<_&iPbRKaPZ-9AGzD*xHCI8McqeIhZERt@+#?&wfCO=FcA>Fbgh3KyiJyG zUfoVpV33Qf1pcfwukvqx{ z#1h)e`KiJ93^C8lMhl&1fmq-IkH-Ff0p@iO>JZ(iXji~@_UVUxW%f121^{|w(?uhJ zQbV~~_OcvZaAt-y>qKdcdYb{IBQ6a_)FIsb@Hl-`qm0 zA7~;}>%!ogxbaCX>BxTd<8}|1>>Myj)%U>afRXoy@5kA7$Ug-vaMs!pNqnZ~_(ba` z>MAAo>8c2MYlg{5QgGi5@v*6mD6iE{PJ~x9b!4?-P;*(a5M4?4LVVi*kL<`ne_HWk z$cwpZ?&H6hV|^mf-Ptgn?7q41LZ7>Varpjfgv3j|g0%h|i=My>_+`D5n#?z)wnM@l zmW8h3Wi6MGI=CUhUo0oq)tO2EL63I}nfdmB{k8OTiU02l!zsoCju!&}pfdhH9#}U! zM`QZ$r`E;hdvp4`GOlzk95=*k{`ln-NU4||EOIo^bq_I*{Un;eTUt#~O>~;R%vFxE zo_LhEhRt*?=-gDjO2P%$nu@B5s)pt@GgG3JDi|b#$P0is&wE>#*%{{ZLdm~3zTJ~) z*7g_>*%e=b{(--}2}ie`-EBa%A28vA84hn(BGmQvcHsB<_%xX;%Y!C3s2?nl?UT)c zFZrUt!pY4cKi!4lq=M%qOOf0eH6DwDKia`Pg9xr?a*DS|wFK3b89a|D*>{w|<9=9S z9Ev@062o>J>!VevEW6i;PGZDUEG{boptn6{KS}(G5xX0G`{I5NH=K1litX8rF5^&j zV4OArU(5kBJ&AFH#C4m+Ot5?DaJhj$VbZe{oPqB3;}wVKiSi`$NMu`(!ka)H?0h@3 z$Xm;e<@J6WuHiA1nf={Gk#|%2^n~yWivG-hhHz(zJjO@n?H;i**-RWHOnllM4)V=P zQ?g>hA>D={w~`9M^ZBUSqO4T09J!&o?P*V=H3h)n%8(U^AonJ@JbmEgI1^LS zQ`huG#PM-?YO6>vcTh#lKoUW8uhFLDqqjGp(AyLp4NW3t9@V`Y8>%pQuJvvle<6Z-TNQTs-3B3KX|nA9{-fB;kOy(cLw(TX!esfU(b!Lb%^`1PkzYnT zC!*RPb(8Jd5~?OQ>il^9hjuF`MrVI17EWE9K4cWlG%xc5ZNQ6ls1 zMrYN5v9xx`m_$7NwsXm^K>WH1SkJ?Rtd5`$ z3`Z6oBy2bp z!u+HVv8hC5v4-^{(I8mU!65*Dow<}`>SY&K)P@kQ(ATH;DIX-m_VXJyuD3W%1LHU$0Emzi#83gV1pPkwB>tJVV7926E2Yz{NuK92 z@;@tC-JQm5%~-)VF(61jrLVN|Akov*z zEQ&~k+dG7V5J~d;KGx>u__=-K6joEOro&F~^A^38{pwh?{m5UZn)4*xt~0^$@76jZ;S#`9NzCnkZ~#qSsctjwWuPh{pp#ZY;=gg1)kYw zsQz3pStua=`3z`e?7b=ee()^sI6QUbb`>A0RV2PUKmdO|K=dHPZgW;Y3vHH+pbcGN z_%vqzcL=;B`~{arG_w`H&;5d@@+D1X#+Lcg7m&Q~WEN`^K*W`EUG^3f zUh#${C{uL=r!N(lT`bIqW(M{?@)fT~?Tk;rD1<9%h*bZ}PD1@oAA16j-dEu6SLq-5 zS#k0O`F6UKPoaM%H}SW`Qu9x`tvJ%GtIpd1_}_C$w(HlwmgMF1^d#Q6r^4k$Wg299 z+Y))_lk}#<>OjX{P`&)SOXLDRj*w5K@6Q_lh?H9T;FkL>OCQR(q9d9hGcA~cIr*?kvh9)*yLlLhvd@Fus`b3YRNk((i`@zk#){1z^q|`F zc68kG)Rxbj?s1K2%wj@}7|w8KI`mMLGe&crW(FG*-*7g;bRkXQV`@l`Odv+4`+Rpb zyMl>!i|pkCol(0}W4a``&*gfl=9XifE9G?6#zI~h?2%C^0U{*dJ3@n@4=D9ilD3;= z#Bq`LPM=MVnR3WP>aW#r=@;FX?{vdEKIuI^2#rz4D=>OI!RoM3(pgbYoi*?PO9}?& zH<%+3Z^Y^)X#SQO$n>gDB&j~5 z!|aauwDeLM2CB3(-=g2qJzq8hw393mY%R4JRfA%b7DWc%6_U3KVYO^5fOk)jTcEKI zsz=rhx~T28!^+3HqjH?Wb{W0FHPU4CV+YK1E-VHVw8E=L_xVNR1wY2em37sO$Z>o^ zepvMtI+Z4|p3SV)Xjzws`KUuiHfNElv^h!Agp~VapkHRe0==}Fl)y38!YW_FTbHXE z+U)F-7z}oA0p+rwY2BuGI0sA0bP7FluzyIK6h4GlD3I}2349)?{xWUN_^NYPUQx=$ z7GO<&)f-4vtaC9?-9?*cfD#3WnIfsUjh>myh~i1m0LfR00#Y##Fcj9E5z0)TOl|P( zxwQe8yAsJJdN)CeqiTDe2)&@Sd~P`}fEu02{8*myulWxa@E9p7s_CN?nRP`-Tw@T6 z_yBhO8CSv3^-6!mbo)Op7(QjpI1^0Z8!)^zDL-_JwUxW4fhd1nMu*XyqQFP6o$BpxK zFg3OsYM9gvY_s{~y4;)ou}mZG>J6w(r9HHZNgSq#CeKjv3Z?XPIIHkk0K#Ez^er`% z?-t+Z8I{UGJJjya^v&q*A4*MoxBt=PJhbo8mWV>gaLAn%CnkKGJQ-10+zC2Dm>R2d(5bB{cU z5rJZDqgaL1CRM^PUdzk+57~Wxv%0q#Z9s?Q^4K3O3yB=(NEz5#NMWdc6w%=I6#f^6 zze?r}RdkAsDthe^aqB|ygZv0`%`VhO7*)~w>GyVX+$jok3me4Y=KQHTcj_D8>5Ne!`@Vt3Z4I-K^fsHHV`#Y zG&2*1x|vWExZ?qUUwEol0LLq9xza_yN}0u8aKeF~u}nunvd^5b zZ!t)pg_|G^)?s?3YqYMMxq4#I_3=Jvho4}y8%{A!w9&6UOToPr%;ru2!3)NM*h}> zO1kIUb7B5Vam0@mToX|#t7G-p#SS35#0(12{&mGZjZMZf@RCg;zmO~4 z^aG!156g#+gb&Px^gSF`t9Yop(0SPO$U8e}ad>_6wmv@See9l$t?BA@9E zD;wgjd*OUJ2d;S5v>mispCp4r7_vN*N#+6J54x~4)h(ta&jF*n8v|t2I&pLueQ*<6 z%TUnSYM~-cz{Ek|Io4>j{6wLl*w>7j@bCF5rqKD9u+2B0IZrX>JJ874#2^i~G=6|C z`w}1sUB`{g%*&+*9%46cGcA;Liy$c2 z+K!SzhJIG}ObXeDBxVVdb)z9e0KLG@QN%MHcpmf;0;_Xh?W=g@=|Tk^NjMssc8etH zrKd=XozIVtvaccl(;$*FS4A0U=xsfcc3+-^zVHfW$aZzcbK6WBy$_MBF9{Kp&5?Vk zPn7hJ+Y*B<>PO5O>E)Q4-KJaHEL$LW79%h`)V|)rp8;Z`%yr#HV9SyE`m`qc)#_ky2mn`UA*TGzxDN<54H-`B z#oZ{6?#c_LrqJwsbqA6r{BcN-@oM13A2@wUgHF1f<4r#)LXzjkFXA=`B1Sb4;THo)qwv+EOYoII?vt|a~r zjMAaCHU#z3F-Qb89CYaNI@nULfal5YrHLyQn(d6A#NO-Q@06X!l`2WLfh*=>xJvRHWYPFxO2oi4&;X&SL8&bJ z`8E{G`s5fR_a_ONm4vdBe4p{ceuD*WvV3?H^6mk+oK0Cjv2uI3m**Cwr%v9!U;-2#JnnpSq1g zjLM1^k}g>3m=bTRR@!)t>Wt|Y6FCo6SCf`S8@js`HsIngACHhv)!+hbI@26groBf5 zrYegfa9ta!gi7h1p`WGI=!7Xft}<5SygEhUd=a3uwP$n$t5j(F#LC|5tBCG}krvcp zXNV!{Pnh6i#}}_nULGk|u5v94`JY!XS1*Zd2YF^#x8&R)t5gOpVcW^T`jkbb`B6`U z_kg3N;B&o~kv_;ZYuS;<*XKzqkkN?R6}_LtE&YIChBuyCCqgpvivv)jZsW1s;)CKO!CL+ zf^A|2YvqzLfsHW%=J**wR-2b37Y%jq!-YA7^6WfEB>^4or#@8Ci=%}dJl;``aRO9L zd((j}B9o_Nm^ru@udH(tquZ%+8mzf&cX zZpyfSqu%cUH<>xTn0qQbx_N7s_o(uDuhIS9fM4~JY7tbAGJ&pHkie6x^eMLplEzaQ z@w7-HP-!#H38_du=tWxqmXA7|MsqvY>&s2OT9o77R0y+zn8ry`LZsw#J7LebiWXGW z=028nnpR3qQpI;vPp7qX#Osi^b!LXm<31L*mFx8mO0kvmoTm-3+6(acK3FWvhilE& zT8?gFmZc3*-Z)(FZvhFF09M!+p%x=+PHcgIN*2=|duUsbZ7{?%*3yOEi?fQZVwy>_ zExO>sC@;;FCE?wS7M6eJqH@xKkb(i4zL44peB@-vBKYM4l_GlU0#2#kg>vxcGoB$E;9}OI`71Z%Fv}-LB=K)^I&cb& zK81z5n4bKB8fS**-uf;9=dRJVktIUtE>`x_EBSUMPvBB{q=SPmz<;kb*!p85F?|=I zCccYMINwF6e`*c?Q9}Li6^H*Qr~VX_3u3?sz4-&xACiPpVjG2dSwPl7^`o}fJLJcD z;LWsNx5|0^t>P2s(xQd`v zH31}7T_0Q5DvyxN+*N(DdDV&JPni#>`Zm(R1}Ku|d|QDw)S8&0(;rHSp$+VsKpG-* ziD?F5xjD|B9Yl9iHNWn=s;qYTw=$gic4Zq?1N#v{>Q|Tab%_4jX1W}jY-ar{2P zb)ig~mT-hijsiYpv}2I;KvZ9{fCrdg^)1E^OqY!SMBI9WQ-zs|>#~&gfbUD0qXaof z8#0uFtwJ!TtT-$m=Y9@)%0fDOfdla$@I{y6>~rF(TPaMaiCysa-=-l25RluTIjxte z9_w#PajZYi)q-%o1BGF4{H;r|ETKdB<=rb@agF( zDAfBnw22P}7@4*ONOZ+9@aoE`zhRbPUbvi{MK_PT((@xF6u?(eR8f_zNNnKl&q0%? zJW|U}Qa1Vg%=PQEzVX$=kInqN-eMo0>Eow|PLu=D-I@aBFyzINBV&Pmz$ zwEeu!LD;X5b_&^=NNmH@$z#)+y_6rns;{?8E|fDoy5Z~5qEXZ9eQ9ID z(=C$!%U<1X0P&^c;Nkem9rLPk)Lic+taN3-x0w%bz|40oXT(dx)^)`oRMsFbb?Us1 z|C&Gh4;r7equ8DN-bh%`s~-Kv`!^}K9lz}>_=%CL-&;N$v!DDzQ@mGd+uxOD8!xUO zet5ooBz|OmLtoI(9Se8Y7j6>=mW^*1lCa?JYr#fcxmU57@3+ZM_wE_E>=TbszK*n> zpPZh=4@s2=lrA3K9_yR@B0_O~A@qN`H@iDpt0t0%82xNIHhsFawQjmUpM-y#DyHHM zIP7fE!|(W}iQ;>U=Ufqf4c5N(81~gpERm%&emHA}`66bJVmWs}cHM4cb)@q9dPFIA zcynJN6V^t=Ki0v67KdFUam|D^!iUw?@PF3n`DpO_aD>ozcEp*4p!TKawuKpw}`+6W#58b z;^bV4Um{23PeMXHyk9N+wtsQ>9Fcf_;J>=z%ToB!th2OuJ8=B)aCSZ5l*K|F z(uPy47V|Z5>|TMF^+2H#1idzU^h)Y?i@ze5*_s+<2{IdQMIBjJA<7|um;fk(XHdk{ z-TT?mdq<2NwfUuP;vMjwTNZ7n7Du0L(HY+w8M30z<%<4(_2>4?hB^yxcplLkmGBv2 zUeFs?k3u54n3yAp@5Z!JWpq>ILzys6Qd~j23`y}Pb7e5i5ehY-6zz&6M51haPnF*e z4*jd@c;^Vpa#bf|W+9-0^<8f3XE|1yW+cs@_brPq?wXsyqP566M&=!fRdt*FB!nV1 zU`YtYOej*2z7r7#!t|}QPD%o3o9(LX zLU9O1Vrry7`@~j%f~-Rw?zjYmqMXjkkX=$cKn;#DEdd1Cdo+aLq)yOLTA_Qi_`vv1 z(4xG*o#x-2X#eU2*(0$76#UQeHO*M0bRhH|(4+kl+d%*FeZ`XaZA$Pxj`AN<&2MiZ z-+uqi^gp0L%E@mm3Elr6fExwk?-rj~zPJMkcKP&_wgn zX{CJJ3~jwXxwPrn4tWWF46IDL=9{m&KWM!UFXYzlx!0a8@yVSW9gh|4y~gbD>$E?(=b`trrQZwY%X|<53qyaZ@McH2u~VN=Bn_1MGe~ z6YVxgU%h0qsQY>9Q;B{LKV#?F!dpW$d$t&nUE+Po&-r^Yj-H}(8Nu=RCoiSM`LM}L z=WSj2BgKvafBk9V55}@5t9R4h zBJKl7!Nm1~Y_Fg~HP zeoc`!T!ZPyGMt&;Ga~Td${DygSA~ya+B9|~Y1w!$;*tXWWaGd)*8fN%)tiK&QOS2A z`K+nNxiSlJ;utt)e|?uleIrRXkgN@&*3edCY}B4;Q%0?^Wlt(f?(j=?U!N<2c$b8I zzodF*wf*dg4dXdCv5ZuyG`5I zDRKNB$}!d{uer1fQ6p)3EVqFuZQ{;ROpzWBdajW6UvIM`GK)**v3o+uHPA8)I_C4R z=vWsALQq*&y}R1LAbJ^&J{FN@QVX`P^CI|qLegUDu_=`0j@|nP%OVeSqh~(`9dh&2 z-fwT-QCxN@+fIH7%bmF?x!q}1SpHgq^Yz>$8~Xl@f9V+Q=^=Z%e#qwgtncjUbg199 z_0!JT>GC`3aq0Nu%+ ziow25QSrW+C#_HWc9@NHF!g>hobKtgXV{-z(*_Y zJF(#lS~VouRTNOI2&^Rz*3xdeJ6v&;c-$m4WkH<2aGlMWkMQ2vPi`0YpaNr!8>b9b z6$ae9`M~AJWo$3_v0QfN)S!J<3jw~9OBI!${`Vy`k*9gOCXIan>6qw6HT{B+;=2Z@LeY}mo`(C zp(zjFeXq;07-ETPL|$rkv)b-lL1&m>GO3%71{Itj%;oqpYNYkO%jql=3zUCbuH2;= zPyn>zfT`NsJr0aU_4reef!N?X;)cwd6gaGEI^ri%ZnyP%|6_{vqEZr6)M^cmpp4z& zjhISTWa18dNvY>jc;-dUZBftCHvyQJ^t>VZq^adi+Nyjm}z=8JXmWL^{KKzWYh?% z3^=$Js>^njJEad^axLUBE3Q+6#b`W^IS1eR?bEuF8r~-m^L;W=0(L4iOLXUcC}^}TuwvF zh&_yUU0>}QI4{%Bb5hUZ(ZU*h0m2Fx8wNH2F{tosuI&wOEb^+Tm(ac2`z5d!+=Gb~ z!cI5!=iYMnk>Gl0&Ih;+OqI=Y_EMip8J7m&8}CjjU5k@Sl{2wJs|NR?28`w+Hd%5gnm1WP zgs-~PsDk#btFg3orKgskkgt-=LJH};;T}3V%MTpl7alma_GAf3I#T5)YPNAFUu+F+ z^7D~}iqx_3RUGGms24jKq!!$*i>`EuSDSRJHD@xei77a#nvT)NV-6#<5j}G<^U+C) z%(C&59A|-;8*A2N7Bd#*yrtsLS+0FKShlZ*$VH-#$3EK?m4{qENf^=4V$zDG3+*SiA6*4z3cdtvqut=feU8488Z&8wFsh@57LZsN59C{JrndOgS3^(1 zpRtJL2^J9?sxY7OQ^$5Rhc!yURXmEil5f@Jiqc;G1*MMtSrhq(kE`W~kLQ6;>`70B zuoir%Mx91^W|?dWW?0oNT=}k@a&~&iOlUOK5-7Yv&8Xsk_n*Y~?3o;YVi}r!O`TbC@X_u)=_Wxk(crQ#z;04pcq8 zp_$(i+f+N@Q1nz>>Ej@gD>)>@}1~-Cj&h3{5^Sg_<{cjmgAV#yrZpo9d?Tll!1xPEcfNp=6|$s;>Zy?*k;zb6KZelY69>b zqk<)6cXbU=aWqk>BrhuU-!j$8^g2}hqoPcgdQ$V1((xyLZe&1I3f@B4ak5<}QKiFd zi9>y$qrJ8+pe&34_6eipbI!WFHScLv} zmnLsGy{XyQ>3Z9jq$t0fSy^JswI{FLfB{W61#y z{VuGzQ>Q zzb$DSTwd!~uZt<#jbI;Sr=z-%9oO_7(6&F@2=pk?EJo&gl8WvL*2_&(0thVyW|H311&#wK{Y@1<@Q=0` zK=&S&L-V56nT?h$$pX1^w4j~xy~yLmoFee473f!qSMk=Rfu0^+rt@X!+vcGWp6Cw& z7hFD~U5TGWkG5>U_nwvp3!;mE%|V5873lvlKmSkjr+=CAdj2)f3zM(4%!TdBeDv?k z(fo2od^)bmu+f<_Ta*H%N4X95w1Ar%C_WeMGUg`PD9_W?wJT&f4`F8`TC7~p6}dV7 zK~P(oBB3MF#aU^p<@<>Fq}U4$%9F2~A^d1x?x{e(1~=wPRhGJfOcmp~2b&M^RPpRG z*FlM5tpnejaZYePrNO#ERXkIc3ZzT13;8sUQxGP#)LD~i9cILhx;SxBocyEf_J?M$ zr^@@@>6SF~;nQM4WmFlv*^+fsI$xnKlzYBE`J$9lI3Aq}{W<9d;hHSg)1QkvkNv!@ zS5k`6t}1)7cqv#(k>Saw4`JLV_C*EA@Cm?i;kupd*g1lcCDR8+#j}q0)FZ!_u$RVJ zo;ivO1f$8=Oq1WD{B3Ame{i}I-o}>hezx=M@Yo=UQWdB(6ML39i9f8nw`?U>U}~Be zqsZ@cb%Ov#FfK_GYKRXY_>Su8k|wzw`OwjEJD~lLr+LnyvJ?5vVXQ(!c*e@&{$}L}@|T*xVE?&^gk! zrAizZcY+dP{IqVK{pd7&JK9_sgm~YIF~ck*@n0x{1Mr-y`VKSGZyXSkf8n44j{OT~ z!F^s-;MIah8H8pvtNjl8TM`h|x1{3lzi^@QO2^vTBFd-Us#ba~dN4qv0-{Fwa}5KB z0HXr3c7FLv|HuH%Z4nh{$S%4vhGMF@@hZF+T?-;!P-CS9O0;W|d}4WS`yJ_j=#=rQ zjVmg!y%+qg>cd`$3JA?wHVhf^ztGZ%{vR}1-2X2$5-29!U73$Dm%~fW^|Q(hG{7-- z&0FRc2JcD6QVJs^N15171MUk>sr=IWsAxxq;o& z;}?)cl1|jjyd`&QdQ?CaFCa?{coaBgkj2|S?~IrhXx{7Ju3Zybk|;;j|Im!O8g{^D zbyrv*#(Nj4RFu}wEUhBPd&|P-Ru)2>LZJZChv6$BBIHAei>durD22pqlo0Cv<>kByH-rnVMY$rG{t@)pm#~Dqu8+TDUwJ{NPa-`%Q zHdGieL%%~>OPvMnz2+rz)T1@#hVXe@oz08&kp*-N_^XAE7FgLbH3g`=Qwlnu8viGX zFe-l=lvu_MiDVB3MY!L6g`aj!w>RJAju3{6-i;7sl@C5+ zK;@>9zOc0Y#4pP$UCCB<;U)~H@+v9_djPsH1$w3crM0ZWW7v|KGCYk8q*Vh+dqpG~ zIr1{HbWteb#RLsM@4VU6?cpm2GdKto36#J^;PmhP!vX72Jp>2U+WXZgm^LCN+X)1& z)psug=Dstc!&5L0F2=Os8Q+zd0dv>=t`wRQ_gk*Gq24$>ax|sU&?tQ#d?&0cO>-p( z5vbE5W`CT~IWqvpI^xY@P{gP+sHpr=YjUOwYGc!B*2x_AH8V8fSX=)>%Q3@9@{xOWn2!&2L&gwmj#l(2$Wo=e7`70BB7qWwaGYKgAVgq8#{0nVA?J`G zdE*x3be91C%|7Wh;*Q^whNE+^_RfZkzjIz!QKIH9CIx9Vr0-6-@uC9o{l#cX;$chs zjM+{WopG8dL5M(|cS!``4nZXSF%Y5>&_!rUsA)op8nZ{Mpg|60Q$}+*=l>hW(TWn) zpJy)SiGOvw!hh?QyCpXi8QcY`O5GIoD=IGld8g1-AYGI1*fa z0T}9!V2DE#p)H`I2`8+{8ZUzaId<_j>gfG9k8H4ZQ~_s8Et=G5N^O<+-RV1L^cH(L zs?Kdby9Q$ArQ~(iDC%gp;Vdk_$UsTi$7E*Pq=m1nA8ZpHq8B?X`{QugeCR5sOX$H+ z@tCZrDr%uFvc}>In`yNeujWyQBym@Ya>+U+9<>rt=W9g(oN;}?vV%@tdpcY|Qh&;J zp22j+D{Zb+?yEC%2zIoL6`KQFOLYOV)yo)W4zhKmBpRb>rLP31y^^KeMc84PF+IAI z4r@eNMmQmzT(XT4Oqb_gCv%!Tp=X|7jsI;VnO2Zs zSCJpepM(5wyN;9uQc`ok)oO&3A!aT|sRNmCoZ5a?b6LuF|Fmro%zoDIGu?D2RlH68 za{Nt!tfar~HN_<9GSbJaGvw0|Cf@s5!|5=*x)Dyx1)Bdbfn^GJIxqDynbz=_3@!GZ z1pB`Xjm^V6#BdQ#M411BA@2WV$aC^x;B?4d8KbLgF3)Wls@;G?wGzPNQ05GM-$ZRO zEbW1(awlIWTvx%mB#4%Y@r7{dR%&$U_RkLO`N!>re&XvI{>IDzTdatuj?66fZnhvn;TH#!`aEcOq%nZsu;g;5`ysBzrvqcH# ztiU?Ttq=1|^#&sRML@D*!R}F}T&bgOuLdV2FlbLeWV~l9oWN#OI%7X5&{ZgiH1iC! zW2VaL0!&)gS^*~YtLtZD9muwLx;`Ww%^0iEgz4~A1 zfyBSib>GkjeMo<^Z>O48mmt!#f1q*K8or@pWB!3|b{-on0RCT~JLw(1)9sT~cD-u_ zdi6lhO3S+7jV5wvsa;$zh!lca+Og~3GlTmOc>Bo%NHmN%DG(aJHtLF|R?nDe80Bx= z>;B2IErze~SpNm?gZmeJG2kC?8D$8dT}9UjnT_<&wKpW1^Zse(Fs-HRqden-P6;yfJkX*k zcED#dkB@YXU3xs#Q?fIw^!&)K<|LU&uXthcS5@|+T~HF++PZyKvAG#Yts--l;Vrk7 zquPYnD33E~RWV><)P*+I7wSTwdD4Xt8RHTd17TyHnDMl^iU$kG_I-(D2+tVH*l}M~ zCg)6w_9`&LnqvmsNUwMshRk?WUQ$S9&njr%NfmKQRP6~2F9|T$96~oe?=lE4aq%yK zWO0kneP2Jv00iRwyv{a4kV#_gxtlGQ%^<^k5m{g2BdV^%s!q@ru9{Sumcm<8iJ?Dv=oG zonwhH&+xyJUh+O*k@2iJFPq4eUC_MID&>}`)DaX}5MZh9bjvH==8iYl1UJtRc%Xl4 ztF4YTB$!>e*^UI8W6^lPhR;4PvPg80W04`wAk*hoV%_18Ny@3HHXnKBkx0GEt zS3{U%L6D)oDYh(sQ6$Y+2FxIvg+Z*_?J0(fs>{afx@NWdm&WBT3&;QN>|>i{?H`d` zGFDx+_17%buKy`=dp^a}&f-5s8XWx{q#gGEk4S9Uh4q_<8cvx?y>F4h7VAB5 zndG)%Bd4Mv!?6dZ>gf_VBi|>=*88q)?ym110CX=B697I6q_m)lpebmH69!L_Z`Hbt zp$)uiJeBh=YH8YhIk{NQ%CWuj_Npv{k7!QQX#D_z{djEOUIOvhZ0~k)2eCng0xC*o z%*6AyAJgju33o>x*7W;q2qQw^zO#;303vXI1Oh_=3_v<@B+^mnb4XcvE8aDOB7g^s zekf`B%$Sn^@@o^D0?z9@>q3oAfEzwZ_x#lf8jP8dESY;W=1W1`1%U5uk`K_4 zVCoUTP{94KH~PQcbmcDsmJK@pdV|jD5^TJiM15{xy8}9}uGj;$#qOlh^>6JX?v#gk zZ|hQ*=ICIxFps z45>?PjzE{UQ-P(oe`hqg1M0*_-U~5A?$&3@bTSVdfbo*A=tsVD1LSqXD-=VJhhk(yN3CcLWJCkW(=6ZO0HcSQvG04$4g%a^c={u z?=|=meq&(-vhPLVdUyOMyseR#!J3)W@2G*YqmAP~36w>oqBgQ|O0$xd5nvKDyK zju8VEM>bef{+OJBqAWr3sV=^oa;dO?y(m1EdIPoCax;ga2Q4G7srMr=;^r;qRtf2W z8DJCX;i3RB{B3V_=>B+Ho2R;U^kdyFzG(VGSpmvj+ub71^)Jr2mb}I}ZCUu6i~pO< z$-`TCW9n8Y6Mul9AT3=*Scr2|86N6#uaL9$nlGx#!E?wP>&|oq>@ho38-7Rsn5U$n zFPXoVp)W2A5F?^SENl9_J>1t<@lH=_NOCVTcUdy~@1c88un^SyFsHDTi9@KLW}v{`n77Ye@6rM*lxh zPRETy{U&0k5aS;pV4znIm3U|kGqs+|t9<-(4kj=Ehp=~mj-*-qhGW}yHpa%bH%>OT zZQI<~=Ek;d8ykCLXXAX6=XvgZ@BP2{&YUynOn22!HB(bvUETfkJ!%c}aM>$z)#c%- zwO9F(D*Oo>$R_;Bc@}EmRsL*u(u==laNZ;1=9@S$Ux7h*X@KW6UsSnLQYR!zW@3}pZIOCTs*iD6%${z$3=rWZ81Un!l3ji9aHxMB8vP!`K=3DEVq?TRD1andTDMoVCS2nS&!kc+h1ln z3ob}n=^=Te;Nl474Ty!1Q>N7#CnS!n@a8Ge;YHVs`sc}T@}M*(lK_AHx=%M8n|hQd zCX%M77Z1;L+AqhCAydUZtxYX}0*s(s34rUlG1MB$eIaeSR6T?1Z#sX})$O z!0e>VLdsPzyT z8c#d6>q$^flrM;i*Y!q(4WeYCxcW~9)4~1FQ|XMUMIItA%hnfkVLOsKbjyo z|In#6|D_IQhpo?yQZHgbl=_46u@#(rubl2ByYbj*)U8tQ^g;lk2g1TKY{_=0g%$N< z8rK`;pDSE<(j0Ae6MIPNN9UPlfOp}0`^JDA8kH-@(rs~oG$lYJSk>%mSFk)=J6?gk zTo;)zJ|5t_=&wj$?)7+_@!3A?OSg+_|J9iP$80vtx>D^iBI;T|(O$Qe68|Hi0@8I@FbUl4r97+X7|9xUIYv`h-JZzoxK@ zr+ehz2z&?6AjAW*6ll%!aB@;g0-|29{Vs&$dh_-i8`HtCRjDhKLBt_w_k-C1ItUf& zk_4(5kK1lF5)LY)8Mxot6-|0|tph5o8HosfrGQ_)gUa{;z#qLsX=%lyLC6u zJIHAEpA$Mg_FBf&J=X^1`+(Zbl3)I&=ZyNB{`zEc zHHQy>=OAb(bgthJL-m&SCeYw6rqLV-#x!s|nek6#$#0Zer7xZ~)kZhUrIYc-$64%V z!DV9#G;TBTr35@C=FG~8LtB|9B~pg`Xs`JRT9d8?x?e;kbh+7W@MUERY^lGl+5EY# zh{-ue(k#6Wgz2_KIJ zh1pzSob6W1-b4mr0s=bjTsQ$&*3%0X0$#)DuMi%3Jkj6L1GDm?86J*|dP<2}k1=I1 zaj_t%C7l#`keHEyLZ5PKYzU8)`j`q>$Zu$LJ8G~Kxo-KAX~mWena$u;J}|bgu3lS9 z6_eEal7T|Q?4R`ce`#DtiDs>AV2o2=r?>0LR}E|PwH}p*RAmWRg^fTHHnI~UWR*Wo zEltE@l8PNuEoJ<^t_}iDx?+Y_1XR3$uVw7$;3 zSCfDyu(_37C1hgEp|hL`H5Sy^xb?juP0(89L=+dJXADe?T&0Ma1~<6lhqmoF8`>Ej z4QiOSfib?ZF{v+4-Rco2)0!CyO@c%<=fPo7(D;Fex;iMt7nPy}))}?5qvr>$!hV~V z3acIWP3c#aarHE=K&mFN!>?Fwrg{d9BXD9s-Q&zyz-Hk3jCEn~0GEth%vhhJ0Bh>@ z{X9oFvE*6Qz+;3!dyI$m1813Gf8{r0#(z;^O9QAN3IJ4C69%w=u2W?%(Ux+Zr#vHP;m;_8vL60HYUIaR(OwGWJOjg3{*a^u$1)J;G%42+S@S<1GyQtddj9`dT)}dY-P@qWF{?f;)>c6i>kArTD1haE{Vo5-xn}Y; z%m<*NSWh*XO52P0?02&OnknUtv zr{@?d7gzr3@SPGX0`OlfpI2CJfFqd z%>-y_B;^@!>Xxp{s36Dh8S=e3;KDJJJ*mwa6v~vxrHLUhS0Peh=+D6D>9+Cn>?Mp3 z#jxV}quS^4#IC~FCGx-fCFK2c1HQ=j!gfHL7UKa#z@Uri^sLJDb}Vw`2}u^juqgYv zJ`t77z?RGi9ffoW2KV5sfj&@?PoUp43J0&lC)w^2l?ccSVwUKM8fkN=HnyqgAHMk0 z`II{g1;=k3nU?@#)elPkTScGn-ztc&yh7(K5|hd` zw9B`jlw?6w4=eM9gMIPYNc2Gj!JiC3SgZvDZfNw`J=JF{IAIVjd`#dFgRS9%D(9#fS@9r_w$xxYg`)zFQIV+<&w`~%!5?)bgkXZuPDEfV+5s3a?`C{yF~OJZs1kYa zrb|#%(tva0t2JS*&WmP+Gd=yg__+8JL(C5#mdOAhw7-F@{sL0@H&DhW&^@7JHKAh3 zM;faTjG?Q=Dhc8m_}>WACxE_X9d=Cw1~Bgzh9>1AR1^o|ML|XkE(Fs^0{*j8=$ttC zIVNEVk?JoW!3x{b!)F_m69kxIq`?pb7?&eGzvRT1Z;4z*3+3yOnWE6PafzX+kQ7T$ zsYfJgrIe?yeFWZ;h#Hz1O9q3iQeXoVChiUf;b^i#7MVog!A9a_EpWD%tTF0j18UsUcv9Jol+oHcmDE(-gq)7mz@km zJUZ?@g#t39Gro77_VCD8UcR86UKM{LMcoWo#4nKJhcXf)F%op{lS(Ck9ox$6IE)bX z`0o4e=HB(<-MUf9cKT-v?t|^|(5BUAg{NvMXUq5YQg_dmVr%{UA@Y6?a4Eb_*T>=X z!9~W$)8)tW`>AXDTh8?OhW5?N{Y86qHR9!E&I4d&|7PbJFAtqAMtgT>1wm8B#^Ly#b`$7}cU`JsO68u`#+IdH<>!%bGA79^( zCT(9=i@mjR@51gU`d0r-A76Jq-Hy_>2e*Ra56%yRamR$=oS=-*=9B{u-|eQ|YlF-CzE$uy>+gHl;tbQn+|@Sr9NSoN z4dLSqZZ^cWw_X(LSKdjd=~hQRDyM7Rw(Pdm%S$R2m2WNY4(2rK5F)?0q7)uxT`bsd zcg(NTrE^n4FMVD+HmxuTa}v0vJYMNV_uqU!mN&HPczA6H`VNW**KU1M2srW9-s^br zxe2%kc|M$9xi>5Rw6|Ap(YYX&FIJDfkymzCpa}C%EVVCsJD9YtoSU{Ul6W$4;eVUv z|B1=u`&6@LiU3(Yy!h6hdB34M`n{VWduxk(YO?gH>Tdtm;O)uFq4oV?i?Hrb8t2{D zuTSqjwQmpoXTSRXTrzFgJ(Dggg!`+iY;|Hh+x%&BQ^U70x;F2YUTQLWR4Sh?M`M{< z;s3ajbl$Epn^NuY5g-w^03o_NsdX&|JLCCSm*UxBPZL@&95eh z@cuHa+f+WuroO(lC0AS=NSMq}PN)(SGk*W_vh&n;KYwEX^pTx>>U)_Sv%L3#OL+KE zIv6^+=Hcs=`wf!!lAwT}Ai4TDKU1qtr?CIk@wxoyj-vfXtuU@_`6(SfBAPTI!uv=4 z#!hR&m^3dP=5dubdqpD~dBY(iE~f1;xy3N4g{jCwwA{i}YDpHf0=Lqj%R@#esbal# zi&A#ZhCuYCDLd5O%PM^CqmN4G5Cp))KXu*$-*$x)^JNN9Viu$Mgr_>_sQ(Pgf82)2{Qv0U7T#TQ-Uhnug={P*$Q0 z)pC&=7OJ*U1{^*z>YE!|&E>-yo(1u5l|y|D(~+SE^M_CNt(`BtgxgS~NH zDSgpOw!=!b2?#LL{XY_;?0CYf4ao4lvMI1>aCh9|!|RZnH^bJc(dnCX9}jocY?`Lo zr!&rrn@umi8`H8MF}OSuh}rzo{b5&0XV}V_qZxE*f`}L{JO*mKxTw~Xcv45J7h&R! z!6;EL4~s>nRtsJ+O1JT$@!s_-3%dES<&vqR`2k}i9s${a>EoqobveN(RH*IIzEii# zb)1_ofgap*^1KivP_E`Dj2h{RCU^z1RUOC@nyaSIH<7g}Kf!=E)gGK)F?Eua`QpqS-9#d>UQV7D1X8$WS2Ksey$K5_oDz<+qI5o-X(sK&;jJ*wcty~PuDzt4?28Gho&+;O|6%_ z3xB6q|Ku?IN77F-0Pm}3dCYYAWowL<+?C$VcODCA} zT}zKPyohnXxF(!?+kU03;*ThX1S9;#eTIH{i5V)h>%p;sHP4BC^uvfRCksv1h%Y5LwNN`M z*5w7vyx2Cj$iG+>UQUjbhnLt*N7CGs==2x->h}vo=53y`kXs4TOmXkfdQ-kk#~LRd zcD{&tyfZhvd630wh_Y(r5}z%9E+)c~bxX@~A_CPww+iHey{^EOOQf()!RKnIUGKRl zRj7|Psap~70jO+cQn!5M-h?hvH!Hq?#o!RZ9T71ztQjG94fumnF-o}?goE%W`K2~2 zOVKxBkg7ppCIocF<^J+3TwUAf+_y#${3+7WnXhgGkq*Vh)8fzKkfU4ZIa__dA``>5s0N# z?jKEZ9;vKWW>4QvVK#!let|vFZ;<1Z?;Iy==9FhXt?AjlxoY}m=NyKNDb9#_W8^Z0 zcocT3t_6%)t}i!M2h$%W}%b$QeoriCLD{1kYiDgLg>!1zAuqsO28YeiV5Z zgm@83!D&3YTInm~y_-}Zzl&MR5;m^M)~(W7u5REk&wRUlegEY=-fgk#*gN3Lg7&@S z?5;#dtSQ6p+o@$&cTZZMZ3H*$K4`Slg<5w_o*cKRYa1>1-c2X1XA#+rCAZ_2 z4iX=SxMob+&9lsu9FCu`ha-_AKPn^fPh3K3mgII&JBhB9@rGwSY z*Dyk}S3$AwM%PgZ9wCTd)qY%~m5+Op@cX7yg`MTjts?ID@oGh{JGnm5!69En@^^2# z@t2G1gSO(qXGe{Z`zu#aY4Y==_{u|Nk-{B%M|}QlgqAj|vxX&Oa?7K%2Pp>u6QS3dIKmmFd5V30SQ| zmhVc|G-BSp+>C$b3(H}uEoK!Yx7S;Y5muY|jBzV%WhJ9#r4!t1Ud)zJ`JT)U6?bUF zL~EYTy3gHNys4mob2=R#pwzcDj5!<4-G{i%x3-rZzCGdWnRLk)vlUL6a5q|<=yEMK zj4~{gLXK?-RKFWu*K8z4ZZaGnqd@Q8DB%Yv$mm{R61uiJvvAtI+yC-EX6~|a8ExX9 zvQkP?MF?HBbG-k#XEoAxqd1K3){!DxC#>UMdK%?MIR4E|$w#yGAxlD;eUkj4NgE%$ zR=DBZgRB}+TMNXy`biba!Cje2(7Ppw1IRv0JlLqKJ|q> zLb$&XHnt$%oSGkL{d%`|hyNjzsjbKNJnBXvx8KWN-txJVdn0pPzVGb#*0Yu1$+K1G z9wdQt{26H=eVK{x@STyQK8%P%sOlo%>Zl9%YqzF+DcC{l>AF_Oqo0|B;@gOT)$Kb&U*UJd@m({Aij zdQ@`gyd+6+#&z9g2?VA`or{*SaxG#fwpxV)Q=)2|{*(Bi@L)_t^%lrkb4FeDwcL@B zDs%{QPLyIvI3+oL^$KnZ4A8`TOD6dlauNf1>m9-%_^vWTu23qsD%#AK8DrZ#m10ND z=evdZ$73pP<5*^@m^bMS;oxXtm-s#1vGTN?_)&O?lUk`^e}{x1+$^-ytgpfeFD+;8 z>YcF|ekmcq*3rdsk*C1)rUMck;fN zyMLlzwaP&~(cR3u?KRs@ax!~QIBD+lk$|~M-|V6@5n0*z)k^&&uk1r#ZJIgPCX4Pa z!DO5RB|c=EF*tr#l3G^|};z{LKfA&f}J_pxwB>OY;WdN~#` zT1MO!2|(LQ?zHc+aPw#)WFiJ1J|ME|Ey65kRJwM0ba7V0&wzI%ZVKpA&~@)O$A3fU zp>{dH-ezz=%bHOE?>Pd$f_!ZZ?FO8-#D0Ks~*Mlb=EpzL9J)Z zNe9CHDI(d?V_(mx<#%4^^kld5Jlm8GB*Kyze-_>7+_56OfM3UT7_lfwHgMI$ZVd7S zg=>luY+-*pT=YQ;CjCR}z55dT12z;hZkDtxQuuKA(S*9i=un}@uQ@so&C}W3@G%+9G0Vpn z<(1?f%Yqv;c}tB*HX+0|Vun;h{@yYx6fSN+P?|u*=N{Ka6|&U@(yH*fw4UmFqlJSM z*~Fz~#uNlh`u!A^a}+e&bUB@D_Z!b_O%s$+10tlXStU)5=d@CR3^lYlxwGP0 zd24rmI#y)2P6^R{Y^Rd-xDx#;dAbtDwi4P_hA*@^8g2vr2&h=cX{S>CxKbYhiwpAC z!xGSsWdFRA%I`(u(;k*+t+P9o#tfD`j;)V(%w!C0XA8o=oZT#CJS@RG4wMG_G%foy z6XYbTNO#=&sU}M?wcRXLJSo;*k%Usafi>jf)BDM*1}^@7f!AHgD2w0TOpAM@`k1VJ8ZQfz)}zvipS`@VxO%4+@Di1DpKEP zDCvuoTdl|f<3YXC$?BuY_rrzUAs!Pt^$ax@DiH$#2{SVepv}*~?@_j`j4fAA5}eC2 z0zoYZIpQ2Js`zzG_5cak@Q*l$LQ#KP_N_ypfE-=~tGGqBuS1~fo_U49hJv6TBRj_@?BbS;51S=k zQ0g@c-Aj@DTY!591LuK+z@U&<@}TXeu{LN_&eOl+kRUPVIj+u8Gu~8NLm@jE!Q2Kz zou&1q_R@W3KN| z2$A~4YixlqAYu@17T^o)0?)xR?fNlGWT>>6Rx5LKtfytRASf=TU`|V9HRj;2ftsnk zP{?YpBe|dtRpePP$Us=>{=_BOT(h)O6h__YV}NhNe>Tmc1{hH5J<&2g$(6e>7@rW> zmk_Ak>xDm3GY6QjVAt66b8Qo^80NYLE|Dw@=p;z&L@jQ1IR8_RlrWgV7bkj64REpd zMtI_YXleZ}B!6BdEasMA>>o(}gE4SidY2k=rAWYjdDvJ6C+2u!Ivg#Z4d(NIZZKJV zlE>j2`}>C*z`Sqj`YT{mz@g*l+V=Z`#GeKXvMvb@2`~4_M=$oBX4j(ol~l>+ttA=U zzE?js3}Os70S107BOx4hOf%u-L12ua59lx$n40IZ-@o54rx%&xO@z_e!r&Ya9y;Mg7bgtvnpa(RtW&I-IV4?JZ}YD<_1k z@9ijb_zBUiGnzS}Qf(w}P_9}>aj-G25#)>b(S`h><0Hk=HPTQs!O}g0CA`_;#spUt z6AK^LJ8wHQKfHc6xgIpU)izAnrjI8|4y*G(>E(0lfvn^Et3Nt*Ip<9RQK$Q>&Ib$* z95<`#Ed^7Ht7py*pNYxM2^qJyO3yciOt%Wz(SQ_QnCh(#ouK8MhZ>s0dsAK)x3?j; zH-GLNIjNIxElIeE3q{pNvL@rIOq8`dG^+_W&&G6pr=iPRVap8sB1Yc(3?|P`!m! z?TaI4A{b74{@q4iF{3M3sY=LF({H=y<<2;)CLS zfM6|K&n;y0CU;aMyf4K{d@7V1%wBhWi^tfx2jC5yrsSYRF)-25GEXwF49zqP#r1hP zOS`=RQcKO~Ov|`>UiOU>w=`9zWMz+@Vib5o z!%kT$qdS_Z?2UjP$aKJ^d^U;>oZRq#@pFQ30ISpc-+pX;vA>Zv!JnRzY5cywHNGy8 z*i5)4Pp*I&8%dA0X$MLO^LnTSN!8;oWjWSn21|PM|?pR)>B}gl|6V3s9 zpJ~MSO>1WmOD{$WT8Ob8vj}N|D0Q{<)fq@JE7tBVnni@!aezF`|=w=yM!da+qmU|9qasoMp zD18wW&Il(TTV5~GY)DmiHLs!?ZkE;)TZH)&wk?nvS627v>iY4ek>)f;!GC%rcFfo*Wv>EYQwL4NdG{oX>uSOjL~s+Evmi zH1r#5ZuOg&bDXuFYLz}R?RRf)dX^bDMHjN|MKV*mJZsm@HCv^GF z9@6@IPwW>Xa!TM$25{+w^%WgzLu%RaLn}<}a5e+cJy**Vl+ko=)`W#GI0XdYKqLt@ zh|1lctGAo+38_5KmKQxf-Z!HrT}2vsV?lBbsKI^r8xx(k1?^xl)5YvyojK&Z?Ucfl zJbHJn2+1(Xnvrw;52=~(E_|A-$ON5%gT#K@{0^~4`wFVlgrXA_Qz+|R4%>Q2#UP5V zD!?Mw?+VlRja=DfOel+PW32y?bio5>0nv^^J9_R+dfT@O!)&S2hlhEwF`! ztp1z(Hg=U%pp}AA&j6C4H4flLzaO|v)0W~<1S)S>jv?Z!BeAElm!al5T>B%lzH6sV z`yylt5?`Tyt{zi;M>t1yr?7w71&UW3M{~k@pGZS6K9q)HMlzA8bBU8XJ#Z9-^ zzzBzA$w9ObuLf8<31>WI8peubDRCnc>4+~}r#>EUNj4s$_K8|6MoU+X-cWaH@QGUC zul5h>AWl-@$zQ0oA#uHP={$E?7?v7X5Miu%NEVV6J0&XH@+vfbYM`|tg^B?j%U=r- z0%=>t(jdcFGok3vEeBA>GzDqe>6gObu?KL%ST+0>V-(mmD-@_814?zcej{08QTwx^ zE4R@tvGD&}tAgopUPV14?eCba^^5g4UTry1fU#;)$#q6gLD!E}reU;}u_APp2t4(b8UMD$&vC4ma! zQdx@Y(Pr$%OQTU%sZ+rUz|(r%r*QH}bW~=>sv=npgdqUrQ5bsnh6|Gzmda9$24yJ- zTmv{EQhG9rV>XejlZc=LI0#UUWJ`7nY|fOH8A5>2gcVi4XP#myHhxdmDxjntz>y6~ zj$k!7ps37E(#EtL2!S(zAHbpOITHT`6LR*p z6x7LA#*6sWDDJ2|*jGkh$>vRax9&Bpl9cEKtzOV35=H!U7QgBy>h)?8QfXfh&xi15 zKOJt)H#5OCQ%#Fo57UB2&gz!X>3NNCtM_Q^hcoJdcUM8Us z2dDhi6I%wq+70i=HwQwl#mHP`Z8&sC#iTA}xUobFg<1+xbZA-_MN#$0+=%CLNk3|n zD`7Eo^h2zY{5a*WKTP-1rg?jjp55udl9j}E2kRfMu;g6x?_dZbN&IV z_ooK*1kPn@Sy8-l=u~yJfz&1p1ymfEpCh&Iv+oQ6ir`wx6Ou`VN?$3Jlq3fK8?_Mn zzfp-0LdXdhYQChxEB_OLRt+G6I9QC&2qxPj5}t_eRwB1cJdhJTR22p@7uqAJA&fV_ zE7wx|smgB?opnVg|4Z*4_$Ts4d&G?+obs$h83%4~LD*Xeq-DUW06HsQJlFS%DUp=o z2(+}{0_gQAv-`Fg5P|liB)?Swk^ytU6OzVm0LcmY;{QW33CMp-=1843vC*QV0Ngb9hr&Mi>^`BXEqw9GzF-tD`%3U`;hKV`fDMKXnSx4BT7INH#te?c`nqo6 zRZB1?5*quEW!3vGm0z*<)db?lwF9s!{9gO`SjO(aiSr}6I)K~n>x{UZhk*vAm z-lim0zx>EbeXE5C!i8c7EP{h#7ZNzFy@cO^*p914!xt%tXP>Z6vN;>&m!jUZOR#r; z3dy-F2dxiQIExDEzm0%59#WIwh_xHLO+^p`tu|7yhxs8$vREl_s+eXC{wqb&h-Rik zYg0oXG~e9f2~MEzSVTnwl|0i8C0Jo(LpRorXh$n#({30K1tAs$y7+S2zK;m*@yQPy zC7Rd0SCSmhhKZhG=jzrQ#JcwBFV48pjl79{1ZYSUS@*dSz<~xi)!jOXVeAME^-P;G zj>4zY5Ey_{GG%~MS$RItSUavbx=jgaQ#3iLO$~#4OfozPj+8w1^{=5ppoIWQoNjZp zF~r;=z5AdT5*!Z;r$kA1Eu$?RLH*Nzpu0{jeb78fckGna>?vs~1p54gluq{KAi7c{ z!ILzVJ3tJPQtU)DQsV4Hex=0PiKM5*+YP`&NpWOC5bZeSM?}VdL^4F0a1-;XGKXoQn_#E%iawN0{@mOqzP=n3NCbL!A+X5 z`V$-q0$Jvb5!L5J?rP*%Ne>p8!wysfeG;sAu6LRi2gv03K_4<3Q95ksG!R*)tgX?vxYNwm~H{$C{$717PK%N)uCUYLRv!P8U9BdM(f8sMmN zfF(GxN<1v`gHXk5RZ)H#{qH$*yY-pj)Cr?W^Xd5q8Om*#D?*26{KE^BIvHbdM2;7Y z$AMOATa-3`PRX`0&w2BkbCTzamr)~vAgrQCXf8bVr0`78x+0CSh*RwWJIGrB4Qq?4 zv6lOwWC6HB!NvhaTCohRd1GYyUR>I2XO*M}CYOquGl`lVTs`v=SZSprb28GAwVXN^ zO=@k(gc{83+!EE=fKwR|*G`{az|Buj-D4^VaHH?mS0`a?<*0e_Bvvs`zDJ{AynYmc zcZ9|D*0~6X%DB-MohER_tG`TAg#32yqJPJd&e-{g-#yonkRXI{5bgSf2{)4<P7$4Ab7+>nw?u9+XXo1%q#R2SV$N@qm-&~e}$3WogP)dZ2*f(PQ@)Dk1 z#BI|s{s!r)!@$`d!X2sR&>9 zSDrE{(!NZ9e8;71&cFEBw$VPbkRGPoMa)I}MT|Gl%ZO(O$m~C?korINL;anYz-wOl zH6%s);8UX8Q83rXcd)+`!xF6O&|}M~=cmWNzAd4Sf^x*ThMZ9MJf!^;w&JJsX^9cP z0AO43cl8G&j5soP(9nN-@$!ERjQO{d*+*f(q?cqa$MhAzFu(9?FO&m%%;zCox3>?$ z_n*l6pRK`ZIsIwp8WPtI&>FjlMTP)7@-S~m2#59Ya&Bri?7N6PthrmKC2T z^TxIMK!oGf_I~pNj()dT&JEd2V!7mfGu3D(Y>ak4x6BOVz<%Fg^(Ni-+G+SAlcW~% z_$e;k#xywA^Djm5cOF(Ht#RNzW<*Ruq+cNLEb;3jzD^UoTs639&BbaYUm1_wRpX0V zo7E%200X08{ZfRrfZt(!a8Sq<2;c)R_WustU!q&n!)Sperr!N{r?^=&#Lw3q>*`Fm zP1&!|G@$UXJIbi1S`ktXr;1%wM{8HH5sE(@<^AB9Vwa4)w_s%!-D5&nm;oNMU`dFG~t-^UJ3UezQ$`d z67`#3XDD_*Y3(|_4g{8Ubj>;hz!ZPN6#osQEnEDgIFA2WPc!q-90{y|%5k%Gbteb_ zjVS0JDr9+Q-eT-vrBJ3 zWoh;Y=a|&Sk>HqofGaGS_fmu8@5GlThoxFG<~Q7En$eecpr!tS792$LH? z-LJ9oc1JjWIYq#|1cN6_yfDKc_E6*3hsG1|(+grwf;Inp-9eC%wcB{XAceYMefXc|CBjzoBRRsPwXaRY4PtI~g+&wGXQ6!BURz3+7dl zgt_l>s8sq*Msog*lAL5xnC3W51!YKfLY4xeG00X$n9g6rAV*_&qVp*=YZlWZ+#G>+ z3qc)gu8+@`Z#~~&4&pF3ZA7ptOKwU7D9mP&EOp`td762(AY%g1NsFz=fE^6L9TXOJ zXr(y_DHtP+N$&JzQ&Esqj#E;6Qc?gYIge8uP~`F%smo2>+M$Iw{T)CKMBr>qs44bp znO#>d;h8Oo1vy(Y*}ij9!I|rKauV7oaGHZU%FFH(Ess6SK?pd}P|Q-Mf}Meh(1sWX zI-m^IU;lV>r#y|z%}mY9OkBXCRg$HYf*0bX(lX0*P^4szC56|qaZgc@g-QBwsB8~j zGEPK|dmsnA9p+`;NpoTkCWPVhjVurPJCIal+I>C>L6ed~TR~QA)@>hStGS9Y4E$i< z;g|A3A!A{%_q$h%=s_X;gdk^JpcXec+fnm$nKyj1q|n#~*MK2Jm(}1LaYK%%Ol+AAIxLo;U*)#0qjD zI!b^78I?wN>a8V2qFkEEvfMP*BDAXO3L>uy%*j&DC%+1Y&qM=xijOSgz7?V^@ zL^VB|9y)`E2_4St-I6b`YL6b0h2K(pDi{fPZxSqrsgQTLnf^0w9?Kk$)%SfC8C^S>u>j zhm{OJCW$v;%G~78=RrnEVNFHBU+MCK)4FdL-X;<+lL-?Zq(b69ZXsE!WVj-d)XwDu zcP&s9$L+Y9!OQHu>}8Yfs_qtochDu7uj&PK-eqzuf4dadB*?gJPwg)HMX@ z@U+5{>?7b5No!V%GC7U253A2(%pZTPcSuG3yv$&@`EOL^SOLix^k8)7hTl1nl>UBc ziwR1zB7L61j8BYfQk-WD6T6Q6h)X9gc5ckC+fU4wM;kHU5sIDX!?*zE1pn)`XpFY6 z49Xd`8?OSLvYOc*0P0RVSWDwXvqXlWdA*K;oKvqc9edQCCCd{;R8E?#IM{R~Za!m9n5dYjSV~M#);Z{Y&rUI3GPE&IO;A=` zUn^e3?@3}Ze!fk)UEM({&J=UM!H#&uobWa~ATy#B+t8KKFq-Uk-1IL|Ww(fanE{#9 z*x;Otg0fl4X8W7+Rz$@+v=Ig=e+RM(_o*idfbdr!MaiH5J(9~ALCh{ZQE4+PA`TjW zcH-=mq(N|iaVRqaq$GYb%6KCsl~v1zhC5<@BQYNz;4~p_;g|XD-R@CA{4@01LTd>u zeR_9nY-HV+^o#W=3OJH;hqsQDiP>2o=Hxu?S3(ASKwoPWAsx0rtH_g5Rx!q3rR`M9HikTv*Hi z_J?40DTq)kqvCqBYfz%*3qwy&2G) z_V$v%AB$M3@2})=(aY)ji#E-vwyE#)!1ZH)Xw~|Q0;WMgFw|3~DaT2uZm|7D0pI4x zGx*5FNql|o$js@Zz(a+EmSP z5CWt0Iu1(BNP9PrHnLEH2Mj~K1&OwffmV@gH1M33>8ihmECcV~f(p4a-IN~MNhh>` zyiMY)?~)$=kcjV48l<@C{f9g3fKPYANdL>76&%1F2UMru+X?!=!Ho8w9pjve{|2(b zz=phC{UOu(FHuaiH#fcsS+5^J|9!z#0l%1THz*L$5i}4G^1m;*GInw@w=wE{sfkdcisM&63my4}l^;sB`!y*`|^!T{^u)W*f&-eIP z_2?|pfIX1D92|JHG^+ee2&Zc932%*s0SmA!<*a=yWk;30bF3WtxgVjXAuo2Wj{+{p zbhbY7RI@~KuJwbH4)B<6>nus?WlIgAKYMg{oc5XtKWQxy`q+>uaIg-Ef>#xdJJ{cK zJBeHFWnC`OYxgEj&-=H3IQ⪪RSiJc3J1CdUfI97vOkjo`C;4J=q*!Nh8(skIM2 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 G-~RwAG 0:\n", + " log(f' [INFO] Descargas previas detectadas en {fact_con_prev} factura(s); se descontaran del calculo (incremental).')\n", + " else:\n", + " prev_dict = {}\n", + " else:\n", + " prev_dict = {}\n", " b_all, s_all, pp_all = _state['df_boms_all'], _state['df_sustitutos_all'], _state['df_partepais_all']\n", " partidas_por_factura = p_use.groupby('FACTURAEXPO')\n", " boms_por_pt = b_all.groupby('PT')\n", @@ -305,12 +368,10 @@ " cant_req_total = (float(c['CANTIDAD']) / 100.0) * peso_neto\n", " else:\n", " cant_req_total = float(p['CANTEXPO']) * float(c['CANTIDAD'])\n", - " cant_prev = 0.0\n", - " if prev_dict is not None:\n", - " cant_prev = float(prev_dict.get((factura, linea, comp), 0.0))\n", + " cant_prev = float(prev_dict.get((factura, linea, comp), 0.0))\n", " cant_pend = cant_req_total - cant_prev\n", - " if prev_dict is not None and cant_pend <= 1e-9: continue\n", - " cant_req = cant_pend if prev_dict is not None else cant_req_total\n", + " if cant_pend <= 1e-9: continue\n", + " cant_req = cant_pend\n", " base_row = {'FACTURAEXPO': factura, 'FECHA_FACTURAEXPO': fecha_export, 'PEDIMENTOEXPO': pedimento_ex,\n", " 'LINEA': linea, 'PT': p['PT'], 'CANT_PT': float(p['CANTEXPO']),\n", " 'PESONETO_PARTIDA': peso_neto, 'PCT_BOM': float(c['CANTIDAD']),\n", @@ -362,52 +423,94 @@ " cob_fact['pct_promedio'] = cob_fact['pct_promedio'].round(2)\n", " return cob_fact\n", "\n", - "def calcular_pronostico_paso8(progress=None):\n", - " df = _build_descarga_df(modo_calc='STANDARD', progress=progress)\n", + "def calcular_pronostico_paso8(progress=None, log=print):\n", + " df = _build_descarga_df(modo_calc='STANDARD', progress=progress, log=log)\n", " cob = _build_cobertura(df)\n", " _state['df_descarga_all'] = df\n", " _state['cobertura_factura'] = cob\n", " return df, cob\n", "\n", - "def calcular_pronostico_paso12_kg(progress=None):\n", - " df = _build_descarga_df(modo_calc='KG_PCT', progress=progress)\n", + "def calcular_pronostico_paso12_kg(progress=None, log=print):\n", + " df = _build_descarga_df(modo_calc='KG_PCT', progress=progress, log=log)\n", " cob = _build_cobertura(df)\n", " _state['df_descarga_kg'] = df\n", " _state['cobertura_factura_kg']= cob\n", " return df, cob\n", "\n", - "def _do_inserts(df_ins, dry_run, log, do_update_status=True, progress=None):\n", + "def _do_inserts(df_ins, dry_run, log, do_update_status=True, progress=None, insert_sdm=False, aplica_desc_manual=False, paso_label=\"Descarga\", modo=\"NATURAL\"):\n", + " \"\"\"Inserta en SDescargaT y actualiza SSaldoTem por cada fila de df_ins.\n", + " Si insert_sdm=True, ademas inserta una fila espejo en SDescargaM\n", + " (formato como CTM): CONSECUTIVO secuencial, CONSECUTIVOEXPO = SFacExp.CONSECUTIVO,\n", + " PROCEDENCIA='TEM', TIPODESPERDICIO='N', FACTURA = FACTURAIMPO.\"\"\"\n", " prog = _Progress(progress)\n", " if df_ins.empty:\n", " log('Nada que insertar.'); prog.done('Nada que insertar'); return 0, 0, 0, []\n", " with scaii_conn.cursor() as cur:\n", " cur.execute(\"SELECT ISNULL(MAX(CONSECUTIVO),0) FROM SDescargaT\")\n", " next_consec = int(cur.fetchone()[0]) + 1\n", - " log(f'Proximo CONSECUTIVO: {next_consec}')\n", + " log(f'Proximo CONSECUTIVO SDescargaT: {next_consec}')\n", + " next_consec_m = 0\n", + " if insert_sdm and not dry_run:\n", + " with scaii_conn.cursor() as cur:\n", + " cur.execute(\"SELECT ISNULL(MAX(CONSECUTIVO),0) FROM SDescargaM\")\n", + " next_consec_m = int(cur.fetchone()[0]) + 1\n", + " log(f'Proximo CONSECUTIVO SDescargaM: {next_consec_m}')\n", + " INSERT_M_SQL = (\"INSERT INTO SDescargaM (CONSECUTIVO, CONSECUTIVOEXPO, FACTURAEXPO, LINEA, \"\n", + " \"NUMPARTE, CLASE, CANTIDAD, UNIMED, FACTURAIMPO, NUMPARTEMP, \"\n", + " \"VALORIMPOMN, VALORIMPOME, PAIS, PESONETO, PESOBRUTO, \"\n", + " \"TIPOFRACCION, SECTOR, FACTURADEF, PROCEDENCIA, FACTURA, \"\n", + " \"TIPODESPERDICIO, ORDENVENTA, TOMARSALDOBASEALPT) \"\n", + " \"VALUES (\" + ','.join(['?']*23) + \")\")\n", " grupos = list(df_ins.groupby('FACTURAEXPO'))\n", " prog.setup(len(grupos), 'Insertando facturas')\n", - " inserted, updated, saldos_upd, errores = 0, 0, 0, []\n", + " inserted, updated, saldos_upd, ins_m, errores = 0, 0, 0, 0, []\n", " for factura, df_f in grupos:\n", - " cb, ib, sb = next_consec, inserted, saldos_upd\n", + " cb, ib, sb, mb = next_consec, inserted, saldos_upd, ins_m\n", + " consec_fac = 0\n", + " if insert_sdm and not dry_run:\n", + " with scaii_conn.cursor() as cur:\n", + " cur.execute(\"SELECT CONSECUTIVO FROM SFacExp WHERE FACTURAEXPO=?\", (factura,))\n", + " _rf = cur.fetchone()\n", + " consec_fac = int(_rf[0]) if _rf and _rf[0] is not None else 0\n", " ok = True\n", " try:\n", " with scaii_conn.cursor() as cur:\n", " for _, r in df_f.iterrows():\n", " if not dry_run:\n", + " # Helpers locales para evitar NULLs: '' para strings, 0/0.0 para numericos.\n", + " def _ss(v):\n", + " r = _s(v); return '' if r is None else r\n", + " def _ff(v):\n", + " r = _f(v); return 0.0 if r is None else r\n", + " def _aa(v):\n", + " r = _adv(v); return 0 if r is None else r\n", + " _pt_val = r['PT'] if 'PT' in r and pd.notna(r.get('PT')) else ''\n", + " _np_val = r['NUMPARTE_USADO'] if pd.notna(r.get('NUMPARTE_USADO')) else ''\n", + " _comp_val = r['COMPONENTE_MP'] if pd.notna(r.get('COMPONENTE_MP')) else ''\n", + " _um_val = (r['UNIMED_SALDO'] if pd.notna(r.get('UNIMED_SALDO'))\n", + " else (r['UNIMED_BOM'] if pd.notna(r.get('UNIMED_BOM')) else ''))\n", + " _fec_val = int(r['FECHADESC_CLARION']) if pd.notna(r.get('FECHADESC_CLARION')) else 0\n", " cur.execute(INSERT_DESC_SQL, (\n", " next_consec, r['FACTURAEXPO'], r['FACTURAEXPO'],\n", - " r['FACTURAIMPO_SALDO'], _s(r['PEDIMENTOIMPO']),\n", - " _s(r['PEDIMENTOEXPO']), _s(r['CLASE']),\n", - " _f(r['VALORMN']), _f(r['VALORME']),\n", - " _f(r['PESONETO_DESC']), _f(r['PESONETO_DESC']),\n", - " _s(r['PAISMERCANCIA']),\n", - " int(r['FECHADESC_CLARION']) if pd.notna(r['FECHADESC_CLARION']) else None,\n", - " _s(r['TIPOFRACCION']), r['NUMPARTE_USADO'],\n", + " r['FACTURAIMPO_SALDO'], _ss(r['PEDIMENTOIMPO']),\n", + " _ss(r['PEDIMENTOEXPO']), _ss(r['CLASE']),\n", + " _ff(r['VALORMN']), _ff(r['VALORME']),\n", + " 0.0, 0.0, # VALORIVAMN, VALORIVAME\n", + " _ff(r['PESONETO_DESC']), _ff(r['PESONETO_DESC']),\n", + " _ss(r['PAISMERCANCIA']),\n", + " _fec_val, # FECHADESC\n", + " _ss(r['TIPOFRACCION']),\n", + " '', # SECTOR\n", + " _pt_val, # NUMPARTEEXPO = PT\n", + " _np_val,\n", " float(r['CANT_DESCARGADA']),\n", - " r['UNIMED_SALDO'] if pd.notna(r['UNIMED_SALDO']) else r['UNIMED_BOM'],\n", + " _um_val,\n", " int(r['LINEA']) if pd.notna(r['LINEA']) else 0,\n", - " r['COMPONENTE_MP'], 1, '',\n", - " _f(r['MONTOIGI']), _adv(r['ADVALOREMIMPO']), '0 PARTE'))\n", + " _comp_val, 1, '',\n", + " _ff(r['MONTOIGI']), _aa(r['ADVALOREMIMPO']), '0 PARTE',\n", + " '', '', '', '', # PAGOIMPUESTO, TIENECERT, ORDENVENTA, ACTUALREPARACION\n", + " 0, 0, '', # CONSECPARCIAL, LINEAEXPOREF, APARTADOCTM\n", + " '', 0.0)) # DESCARGASM (vacio), CANTIDADRETORNADASM\n", " cur.execute(UPDATE_SALDO_SQL, (\n", " float(r['CANT_DESCARGADA']),\n", " _f0(r['VALORMN']), _f0(r['VALORME']),\n", @@ -415,19 +518,59 @@ " r['FACTURAIMPO_SALDO'], r['NUMPARTE_USADO'],\n", " r['UNIMED_SALDO'] if pd.notna(r['UNIMED_SALDO']) else r['UNIMED_BOM']))\n", " saldos_upd += 1\n", + " if insert_sdm:\n", + " # Helpers locales para evitar NULLs (igual que en SDescargaT).\n", + " _ssm = lambda v: '' if _s(v) is None else _s(v)\n", + " _ffm = lambda v: 0.0 if _f(v) is None else _f(v)\n", + " _ptm = r['PT'] if 'PT' in r and pd.notna(r.get('PT')) else ''\n", + " _npm = r['NUMPARTE_USADO'] if pd.notna(r.get('NUMPARTE_USADO')) else ''\n", + " _compm = r['COMPONENTE_MP'] if pd.notna(r.get('COMPONENTE_MP')) else ''\n", + " _umm = (r['UNIMED_SALDO'] if pd.notna(r.get('UNIMED_SALDO'))\n", + " else (r['UNIMED_BOM'] if pd.notna(r.get('UNIMED_BOM')) else ''))\n", + " _factimpo_m = r['FACTURAIMPO_SALDO'] if pd.notna(r.get('FACTURAIMPO_SALDO')) else ''\n", + " cur.execute(INSERT_M_SQL, (\n", + " next_consec_m, consec_fac, r['FACTURAEXPO'],\n", + " int(r['LINEA']) if pd.notna(r['LINEA']) else 0,\n", + " _ptm, _ssm(r['CLASE']),\n", + " float(r['CANT_DESCARGADA']),\n", + " _umm,\n", + " _factimpo_m, _compm,\n", + " _ffm(r['VALORMN']), _ffm(r['VALORME']),\n", + " _ssm(r['PAISMERCANCIA']),\n", + " _ffm(r['PESONETO_DESC']), _ffm(r['PESONETO_DESC']),\n", + " _ssm(r['TIPOFRACCION']), '',\n", + " '', 'TEM', _factimpo_m, 'N', '', ''))\n", + " next_consec_m += 1\n", + " ins_m += 1\n", " next_consec += 1\n", " inserted += 1\n", - " if not dry_run and do_update_status:\n", - " cur.execute(UPDATE_FACEXP_SQL, (factura,))\n", + " if not dry_run:\n", + " # Siempre actualizamos SFacExp (NATURAL y DIRIGIDA) con los campos\n", + " # de auditoria. ESTATUS solo cambia cuando viene de NA (do_update_status=True).\n", + " _hoy_cl = to_clarion(_dt.date.today())\n", + " _hora_cl = _to_clarion_time()\n", + " _coment = f\"{paso_label} {modo} - Utilerias 2.0 {_dt.date.today().isoformat()}\"\n", + " _manual = 'S' if aplica_desc_manual else 'N'\n", + " sql_upd = _build_update_facexp_sql(\n", + " cambia_estatus=bool(do_update_status), manual=_manual)\n", + " if do_update_status:\n", + " params = (_manual, _hoy_cl, _hora_cl, _hoy_cl,\n", + " _coment, _coment, factura)\n", + " else:\n", + " params = (_manual, _hoy_cl, _hora_cl,\n", + " _coment, _coment, factura)\n", + " cur.execute(sql_upd, params)\n", " if not dry_run: scaii_conn.commit()\n", " except Exception as e:\n", " ok = False\n", " if not dry_run: scaii_conn.rollback()\n", - " next_consec, inserted, saldos_upd = cb, ib, sb\n", + " next_consec, inserted, saldos_upd, ins_m = cb, ib, sb, mb\n", " errores.append((factura, str(e)))\n", " if ok and do_update_status: updated += 1\n", " prog.step()\n", " prog.done(f'{inserted} insertados')\n", + " if insert_sdm:\n", + " log(f' SDescargaM insertadas (espejo modo DIRIGIDA): {ins_m}')\n", " return inserted, updated, saldos_upd, errores\n", "\n", "def _ejecutar_descarga_NA(modo, dry_run, fecha_desde, fecha_hasta, log, key_df, key_cob, paso_label, progress=None):\n", @@ -453,7 +596,11 @@ " mask = df_da['FACTURAEXPO'].isin(elig) & df_da['TIPO'].isin(['ORIGINAL','SUSTITUTO'])\n", " df_ins = df_da[mask].copy()\n", " log(f'Filas a insertar: {len(df_ins)}')\n", - " inserted, updated, saldos_upd, errores = _do_inserts(df_ins, dry_run, log, do_update_status=True, progress=progress)\n", + " inserted, updated, saldos_upd, errores = _do_inserts(\n", + " df_ins, dry_run, log, do_update_status=True, progress=progress,\n", + " insert_sdm=(modo == 'DIRIGIDA'),\n", + " aplica_desc_manual=(modo == 'DIRIGIDA'),\n", + " paso_label=paso_label, modo=modo)\n", " log(f'\\n=== RESUMEN {paso_label} ({modo}, DRY_RUN={dry_run}) ===')\n", " log(f' Insertados : {inserted}')\n", " log(f' Updates SSaldoTem : {saldos_upd}')\n", @@ -500,7 +647,7 @@ " df_desc_prev['LINEA'] = df_desc_prev['LINEA'].astype(int)\n", " prev_dict = df_desc_prev.set_index(['FACTURAEXPO','LINEA','COMPONENTE_MP'])['CANT_PREV'].to_dict()\n", " _load_catalogos()\n", - " df_comp = _build_descarga_df(modo_calc=modo_calc, facturas_df=df_fact_ac, prev_dict=prev_dict, progress=progress)\n", + " df_comp = _build_descarga_df(modo_calc=modo_calc, facturas_df=df_fact_ac, prev_dict=prev_dict, progress=progress, log=log)\n", " log(f'Filas calculadas: {len(df_comp)}')\n", " if df_comp.empty: return\n", " if modo_comp == 'NATURAL':\n", @@ -510,7 +657,11 @@ " else:\n", " df_to_ins = df_comp[df_comp['TIPO'].isin(['ORIGINAL','SUSTITUTO'])]\n", " log(f'Filas a insertar: {len(df_to_ins)}')\n", - " inserted, _, saldos_upd, errores = _do_inserts(df_to_ins, dry_run, log, do_update_status=False, progress=progress)\n", + " inserted, _, saldos_upd, errores = _do_inserts(\n", + " df_to_ins, dry_run, log, do_update_status=False, progress=progress,\n", + " insert_sdm=(modo_comp == 'DIRIGIDA'),\n", + " aplica_desc_manual=(modo_comp == 'DIRIGIDA'),\n", + " paso_label=paso_label, modo=modo_comp)\n", " log(f'\\n=== RESUMEN {paso_label} ({modo_comp}, DRY_RUN={dry_run}) ===')\n", " log(f' Insertados : {inserted}')\n", " log(f' Updates SSaldoTem : {saldos_upd}')\n", @@ -961,7 +1112,723 @@ " log(f' Insertados : {inserted}')\n", " log(f' Updates SSaldoTem : {saldos_upd}')\n", " log(f' Errores : {len(errores)}')\n", - " for f, e in errores[:5]: log(f' {f}: {e}')" + " for f, e in errores[:5]: log(f' {f}: {e}')\n", + "\n", + "\n", + "# =============================================================\n", + "# DESCARGAS UNO A UNO\n", + "# IMPO.NUMPARTE = EXPO.NUMPARTE (mismo SKU entra y sale)\n", + "# Match: NUMPARTE + FRACCION + UM.\n", + "# =============================================================\n", + "\n", + "import io as _io_u1\n", + "\n", + "def _listar_partidas_expo_uno_a_uno(fecha_ini, fecha_fin):\n", + " \"\"\"Trae partidas EXPO en el rango, excluyendo TIPOFACTURA='NODES'.\n", + " Incluye facturas NA y AC. Trae el CANT_PREV ya descargado por\n", + " (FACTURAEXPO, LINEA, NUMPARTE) para detectar las que no estan completas.\"\"\"\n", + " sql = \"\"\"\n", + " SELECT pe.FACTURAEXPO, pe.LINEA, pe.NUMPARTE,\n", + " pe.CANTEXPO, pe.UNIMED, pe.FRACCIONEXPO,\n", + " ISNULL(pe.PESONETO,0) AS PESONETO,\n", + " pe.MONTOIGIME AS MONTOIGI,\n", + " fe.FECHAFACTURA_ISO, fe.PEDIMENTOEXPO, fe.ESTATUS, fe.TIPOFACTURA\n", + " FROM SPartidasExpo pe\n", + " INNER JOIN SFacExp fe ON fe.FACTURAEXPO = pe.FACTURAEXPO\n", + " WHERE ISNULL(fe.TIPOFACTURA,'') <> 'NODES'\n", + " AND fe.FECHAFACTURA_ISO BETWEEN ? AND ?\n", + " ORDER BY fe.FECHAFACTURA_ISO, pe.FACTURAEXPO, pe.LINEA\n", + " \"\"\"\n", + " df_part = pd.read_sql(sql, scaii_conn, params=(fecha_ini, fecha_fin))\n", + " if df_part.empty:\n", + " return df_part\n", + " facturas = df_part['FACTURAEXPO'].unique().tolist()\n", + " prev_parts = []\n", + " for i in range(0, len(facturas), 500):\n", + " chunk = facturas[i:i+500]\n", + " ph = ','.join(['?']*len(chunk))\n", + " prev_parts.append(pd.read_sql(f\"\"\"\n", + " SELECT FACTEXPO AS FACTURAEXPO, LINEAEXPO AS LINEA, NUMPARTE,\n", + " SUM(ISNULL(CANTDESC,0)) AS CANT_PREV\n", + " FROM SDescargaT WHERE FACTEXPO IN ({ph})\n", + " GROUP BY FACTEXPO, LINEAEXPO, NUMPARTE\n", + " \"\"\", scaii_conn, params=chunk))\n", + " df_prev = pd.concat(prev_parts, ignore_index=True) if prev_parts else pd.DataFrame()\n", + " if not df_prev.empty:\n", + " df_prev['LINEA'] = df_prev['LINEA'].astype(int)\n", + " df_part['LINEA'] = df_part['LINEA'].astype(int)\n", + " if df_prev.empty:\n", + " df_part['CANT_PREV'] = 0.0\n", + " else:\n", + " df_part = df_part.merge(df_prev, on=['FACTURAEXPO','LINEA','NUMPARTE'], how='left')\n", + " df_part['CANT_PREV'] = df_part['CANT_PREV'].fillna(0.0)\n", + " df_part['CANT_FALTANTE'] = (df_part['CANTEXPO'].astype(float)\n", + " - df_part['CANT_PREV'].astype(float)).round(6)\n", + " return df_part\n", + "\n", + "\n", + "def listar_partidas_expo_uno_a_uno(fecha_ini, fecha_fin):\n", + " \"\"\"Vista para el usuario: lista partidas EXPO afectadas + resumen.\"\"\"\n", + " df = _listar_partidas_expo_uno_a_uno(fecha_ini, fecha_fin)\n", + " if df.empty:\n", + " return pd.DataFrame(), pd.DataFrame()\n", + " detalle = df[['FACTURAEXPO','LINEA','NUMPARTE','UNIMED','FRACCIONEXPO',\n", + " 'CANTEXPO','CANT_PREV','CANT_FALTANTE','FECHAFACTURA_ISO','ESTATUS']].copy()\n", + " resumen = (df.groupby(['ESTATUS'], as_index=False)\n", + " .agg(partidas=('LINEA','count'),\n", + " facturas=('FACTURAEXPO','nunique'),\n", + " cantexpo_total=('CANTEXPO','sum'),\n", + " faltante_total=('CANT_FALTANTE','sum')))\n", + " return detalle, resumen\n", + "\n", + "\n", + "def _cargar_saldos_vencidos_uno_a_uno(numpartes):\n", + " \"\"\"SSaldoTem vencidos (< hoy) y disponibles, filtrados a los NUMPARTES dados.\"\"\"\n", + " import datetime as _dt\n", + " hoy = _dt.date.today().isoformat()\n", + " nps = [str(n).strip() for n in numpartes if str(n).strip()]\n", + " if not nps:\n", + " return pd.DataFrame()\n", + " out = []\n", + " LOTE = 1000\n", + " for i in range(0, len(nps), LOTE):\n", + " chunk = nps[i:i+LOTE]\n", + " ph = ','.join(['?']*len(chunk))\n", + " out.append(pd.read_sql(f\"\"\"\n", + " SELECT NUMPARTE, FACTURAIMPO, PEDIMENTOIMPO, CLASE, PAISORIGEN, FRACCIONIMPO,\n", + " FECHAFACTURA_ISO AS FECHA_ENTRADA, FECHAVENC_ISO AS FECHA_EXPIRACION,\n", + " UMEXITENCIA AS UNIDAD_MEDIDA, CANTEXITENCIA AS CANT_LOTE_ORIG,\n", + " ISNULL(VALORIMPOMN,0) AS VALORIMPOMN,\n", + " ISNULL(VALORIMPOME,0) AS VALORIMPOME,\n", + " ISNULL(PESONETO,0) AS PESONETO_LOTE,\n", + " (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) AS SALDO_DISPONIBLE\n", + " FROM SSaldoTem\n", + " WHERE NUMPARTE IN ({ph})\n", + " AND FECHAVENC_ISO IS NOT NULL\n", + " AND FECHAVENC_ISO < ?\n", + " AND (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) > 0\n", + " \"\"\", scaii_conn, params=tuple(chunk) + (hoy,)))\n", + " df = pd.concat(out, ignore_index=True) if out else pd.DataFrame()\n", + " if df.empty: return df\n", + " for c in ('NUMPARTE','FRACCIONIMPO','UNIDAD_MEDIDA'):\n", + " df[c] = df[c].fillna('').astype(str).str.strip()\n", + " return df\n", + "\n", + "\n", + "def analizar_descargas_uno_a_uno(fecha_ini, fecha_fin, progress=None, log=print):\n", + " \"\"\"Paso A: arma df_comp con la misma estructura que _build_descarga_df.\n", + " Devuelve (df_comp, resumen, inconsistencias).\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(3, 'Listando partidas EXPO...')\n", + " df_part = _listar_partidas_expo_uno_a_uno(fecha_ini, fecha_fin)\n", + " log(f'Partidas EXPO en rango (sin NODES): {len(df_part):,}')\n", + " if df_part.empty:\n", + " prog.done('Sin partidas'); return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()\n", + " # Solo las que aun tienen faltante\n", + " pendientes = df_part[df_part['CANT_FALTANTE'] > 1e-9].copy()\n", + " log(f'Partidas con faltante: {len(pendientes):,}')\n", + " if pendientes.empty:\n", + " prog.done('Sin pendientes'); return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()\n", + " prog.step(desc='Cargando saldos vencidos...')\n", + " numpartes = pendientes['NUMPARTE'].astype(str).str.strip().unique().tolist()\n", + " saldos = _cargar_saldos_vencidos_uno_a_uno(numpartes)\n", + " log(f'Saldos vencidos disponibles para esos NUMPARTES: {len(saldos):,}')\n", + " if saldos.empty:\n", + " prog.done('Sin saldos'); return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()\n", + " # Indexar saldos por NUMPARTE para acceso rapido\n", + " saldos = saldos.sort_values(['NUMPARTE','FECHA_ENTRADA']).reset_index(drop=True)\n", + " saldos_por_np = {k: list(g.index) for k, g in saldos.groupby('NUMPARTE')}\n", + "\n", + " prog.setup(len(pendientes), 'Procesando partidas...')\n", + " all_rows = []\n", + " inconsistencias = []\n", + " for _, p in pendientes.iterrows():\n", + " factura = p['FACTURAEXPO']\n", + " linea = int(p['LINEA'])\n", + " numparte = str(p['NUMPARTE']).strip()\n", + " unimed_e = str(p.get('UNIMED','') or '').strip().upper()\n", + " fracc_e = str(p.get('FRACCIONEXPO','') or '').strip()\n", + " falta = float(p['CANT_FALTANTE'])\n", + " base_row = {'FACTURAEXPO': factura, 'FECHA_FACTURAEXPO': p['FECHAFACTURA_ISO'],\n", + " 'PEDIMENTOEXPO': p['PEDIMENTOEXPO'],\n", + " 'LINEA': linea, 'PT': numparte, 'CANT_PT': float(p['CANTEXPO']),\n", + " 'PESONETO_PARTIDA': float(p['PESONETO']),\n", + " 'PCT_BOM': 100.0, 'MONTOIGI': p.get('MONTOIGI'),\n", + " 'COMPONENTE_MP': numparte, 'UNIMED_BOM': unimed_e,\n", + " 'CANT_REQUERIDA': falta, 'CANT_PREV': float(p['CANT_PREV'])}\n", + " idxs = saldos_por_np.get(numparte, [])\n", + " # Filtrar saldos con misma fraccion y misma UM\n", + " idxs_ok = [i for i in idxs\n", + " if saldos.at[i,'FRACCIONIMPO'] == fracc_e\n", + " and saldos.at[i,'UNIDAD_MEDIDA'].upper() == unimed_e]\n", + " if not idxs_ok:\n", + " inconsistencias.append({**{k: base_row[k] for k in ('FACTURAEXPO','LINEA')},\n", + " 'NUMPARTE': numparte, 'UNIMED': unimed_e, 'FRACCIONEXPO': fracc_e,\n", + " 'CANT_FALTANTE': falta,\n", + " 'STATUS': 'SIN_SALDO_COMPATIBLE',\n", + " 'DETALLE': 'No hay saldos vencidos con misma fraccion+UM'})\n", + " # Generar fila FALTANTE para que aparezca en df_comp\n", + " all_rows.append({**base_row, 'NUMPARTE_USADO': None, 'TIPO': 'FALTANTE',\n", + " 'FACTURAIMPO_SALDO': None, 'PEDIMENTOIMPO': None, 'CLASE': None,\n", + " 'PAISMERCANCIA': None, 'FRACCION_SALDO': None, 'UNIMED_SALDO': None,\n", + " 'FECHA_ENTRADA': None, 'FECHA_EXPIRACION': None, 'CANT_LOTE_ORIG': None,\n", + " 'CANT_DESCARGADA': falta, 'VALORMN': None, 'VALORME': None,\n", + " 'PESONETO_DESC': None, 'STATUS': 'FALTANTE'})\n", + " prog.step(); continue\n", + " # Consumir FIFO (saldos ya estan ordenados por FECHA_ENTRADA)\n", + " faltante, rows = _consumir(saldos, idxs_ok, falta, base_row, 'ORIGINAL', numparte)\n", + " all_rows.extend(rows)\n", + " if faltante > 1e-9:\n", + " inconsistencias.append({'FACTURAEXPO': factura, 'LINEA': linea,\n", + " 'NUMPARTE': numparte, 'UNIMED': unimed_e, 'FRACCIONEXPO': fracc_e,\n", + " 'CANT_REQUERIDA': falta,\n", + " 'CANT_DESCARGADA': falta - faltante, 'CANT_FALTANTE': faltante,\n", + " 'STATUS': 'SALDO_INSUFICIENTE',\n", + " 'DETALLE': f'Faltan {faltante:.4f} {unimed_e} sin cubrir'})\n", + " all_rows.append({**base_row, 'NUMPARTE_USADO': None, 'TIPO': 'FALTANTE',\n", + " 'FACTURAIMPO_SALDO': None, 'PEDIMENTOIMPO': None, 'CLASE': None,\n", + " 'PAISMERCANCIA': None, 'FRACCION_SALDO': None, 'UNIMED_SALDO': None,\n", + " 'FECHA_ENTRADA': None, 'FECHA_EXPIRACION': None, 'CANT_LOTE_ORIG': None,\n", + " 'CANT_DESCARGADA': faltante, 'VALORMN': None, 'VALORME': None,\n", + " 'PESONETO_DESC': None, 'STATUS': 'FALTANTE'})\n", + " prog.step()\n", + " df_comp = pd.DataFrame(all_rows)\n", + " if not df_comp.empty:\n", + " try:\n", + " _load_catalogos()\n", + " pp = _state.get('df_partepais_all', pd.DataFrame())\n", + " if not pp.empty:\n", + " pp_d = pp.drop_duplicates(subset=['FRACCION','PAIS'], keep='last') .set_index(['FRACCION','PAIS'])[['TIPOFRACCION','TASAIM']].to_dict('index')\n", + " def _pp(row):\n", + " info = pp_d.get((row['FRACCION_SALDO'], row['PAISMERCANCIA']))\n", + " if info is None: return pd.Series([None, None])\n", + " return pd.Series([info.get('TIPOFRACCION'), info.get('TASAIM')])\n", + " df_comp[['TIPOFRACCION','ADVALOREMIMPO']] = df_comp.apply(_pp, axis=1)\n", + " except Exception as e:\n", + " log(f' WARN catalogos: {e}')\n", + " df_comp['FECHADESC_CLARION'] = df_comp['FECHA_FACTURAEXPO'].apply(to_clarion)\n", + " resumen = pd.DataFrame()\n", + " if not df_comp.empty:\n", + " resumen = df_comp.groupby('TIPO').size().reset_index(name='filas')\n", + " prog.done('Analisis listo')\n", + " log(f'Filas calculadas: {len(df_comp):,} | Inconsistencias: {len(inconsistencias):,}')\n", + " return df_comp, resumen, pd.DataFrame(inconsistencias)\n", + "\n", + "\n", + "def ejecutar_descargas_uno_a_uno(df_comp, modo_comp='DIRIGIDA', dry_run=True,\n", + " progress=None, log=print):\n", + " \"\"\"Paso B: usa _do_inserts. No cambia ESTATUS (do_update_status=False) porque\n", + " mezcla facturas NA y AC; el usuario puede correr Paso 9 estandar despues si\n", + " quiere cerrar las NA que queden 100% cubiertas.\"\"\"\n", + " assert isinstance(dry_run, bool), 'dry_run debe ser bool'\n", + " if df_comp is None or df_comp.empty:\n", + " log('ERROR: plan vacio.'); return\n", + " if modo_comp == 'NATURAL':\n", + " bad = set(df_comp[df_comp['TIPO']=='FALTANTE']['FACTURAEXPO'])\n", + " df_to_ins = df_comp[~df_comp['FACTURAEXPO'].isin(bad) & df_comp['TIPO'].isin(['ORIGINAL','SUSTITUTO'])]\n", + " log(f'Excluidas en NATURAL (con faltante): {len(bad)}')\n", + " else:\n", + " df_to_ins = df_comp[df_comp['TIPO'].isin(['ORIGINAL','SUSTITUTO'])]\n", + " log(f'Filas a insertar: {len(df_to_ins)}')\n", + " inserted, _, saldos_upd, errores = _do_inserts(\n", + " df_to_ins, dry_run, log, do_update_status=False, progress=progress,\n", + " insert_sdm=(modo_comp == 'DIRIGIDA'),\n", + " aplica_desc_manual=(modo_comp == 'DIRIGIDA'),\n", + " paso_label='descargas uno a uno', modo=modo_comp)\n", + " log(f'\\n=== RESUMEN Descargas Uno a Uno ({modo_comp}, DRY_RUN={dry_run}) ===')\n", + " log(f' Insertados : {inserted}')\n", + " log(f' Updates SSaldoTem : {saldos_upd}')\n", + " log(f' Errores : {len(errores)}')\n", + " for f, e in errores[:5]: log(f' {f}: {e}')\n", + "\n", + "\n", + "# =============================================================\n", + "# REINTEGRO DE DESCARGAS\n", + "# Reduce CANTDESC en SDescargaT y devuelve al saldo IMPO en SSaldoTem.\n", + "# =============================================================\n", + "\n", + "import io as _io_re\n", + "\n", + "def generar_plantilla_reintegro():\n", + " \"\"\"Plantilla inicial: NUMPARTE_MP, UNIMED, FRACCION.\"\"\"\n", + " df = pd.DataFrame([\n", + " {'NUMPARTE_MP': 'MP-ABC-001', 'UNIMED': 'KGS', 'FRACCION': '72082700'},\n", + " {'NUMPARTE_MP': 'MP-XYZ-002', 'UNIMED': 'PZA', 'FRACCION': '84149099'},\n", + " ])\n", + " buf = _io_re.BytesIO()\n", + " with pd.ExcelWriter(buf, engine='openpyxl') as w:\n", + " df.to_excel(w, sheet_name='Reintegro', index=False)\n", + " buf.seek(0)\n", + " return buf.read()\n", + "\n", + "\n", + "def cargar_excel_reintegro_inicial(path):\n", + " \"\"\"Lee Excel inicial: NUMPARTE_MP + UNIMED + FRACCION.\"\"\"\n", + " df = pd.read_excel(path, dtype=str)\n", + " norm = {c: c.strip().upper().replace(' ', '_') for c in df.columns}\n", + " df = df.rename(columns=norm)\n", + " aliases = {\n", + " 'NUMPARTE_MP': ['NUMPARTE_MP','NUMPARTE','NUM_PARTE','COMPONENTE'],\n", + " 'UNIMED': ['UNIMED','UM','UNIDAD','UNIDAD_DE_MEDIDA'],\n", + " 'FRACCION': ['FRACCION','FRACCION_ARANCELARIA','FRACC'],\n", + " }\n", + " out = {}\n", + " for std, opts in aliases.items():\n", + " for o in opts:\n", + " if o in df.columns:\n", + " out[std] = df[o]; break\n", + " if std not in out:\n", + " raise ValueError(f'Falta la columna {std} (acepta: {opts})')\n", + " df2 = pd.DataFrame(out)\n", + " for c in df2.columns:\n", + " df2[c] = df2[c].fillna('').astype(str).str.strip()\n", + " df2['UNIMED'] = df2['UNIMED'].str.upper()\n", + " return df2[df2['NUMPARTE_MP'] != ''].reset_index(drop=True)\n", + "\n", + "\n", + "def listar_partidas_para_reintegro(df_mp, fecha_ini, fecha_fin, progress=None, log=print):\n", + " \"\"\"Lista descargas existentes en SDescargaT cuyas PARTE_ORIGINAL coincide con\n", + " los MP del Excel, agrupado por (FACTURAEXPO, LINEA, NUMPARTE_MP) con el PT\n", + " correspondiente. Solo facturas en el rango de fechas.\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(3, 'Buscando descargas...')\n", + " if df_mp is None or df_mp.empty:\n", + " log('Excel vacio.'); return pd.DataFrame()\n", + " mps = df_mp['NUMPARTE_MP'].astype(str).str.strip().unique().tolist()\n", + " if not mps:\n", + " log('Sin MPs.'); return pd.DataFrame()\n", + " LOTE = 1000\n", + " detalle = []\n", + " for i in range(0, len(mps), LOTE):\n", + " chunk = mps[i:i+LOTE]\n", + " ph = ','.join(['?'] * len(chunk))\n", + " try:\n", + " detalle.append(pd.read_sql(f\"\"\"\n", + " SELECT d.FACTEXPO AS FACTURAEXPO,\n", + " d.LINEAEXPO AS LINEA,\n", + " d.PARTEORIGINAL AS NUMPARTE_MP,\n", + " d.NUMPARTE AS NUMPARTE_USADO,\n", + " d.UNIMED AS UNIMED,\n", + " d.TIPOFRACCION AS FRACCION,\n", + " d.FACTIMPO,\n", + " d.PAISMERCANCIA,\n", + " SUM(ISNULL(d.CANTDESC,0)) AS CANTDESC_ACTUAL,\n", + " SUM(ISNULL(d.VALORMN,0)) AS VALORMN_ACTUAL,\n", + " SUM(ISNULL(d.VALORME,0)) AS VALORME_ACTUAL,\n", + " SUM(ISNULL(d.PESONETO,0)) AS PESONETO_ACTUAL,\n", + " SUM(ISNULL(d.PESOBRUTO,0)) AS PESOBRUTO_ACTUAL,\n", + " SUM(ISNULL(d.MONTOIGI,0)) AS MONTOIGI_ACTUAL,\n", + " COUNT(*) AS DESCARGAS,\n", + " f.FECHAFACTURA_ISO,\n", + " f.ESTATUS\n", + " FROM SDescargaT d\n", + " INNER JOIN SFacExp f ON f.FACTURAEXPO = d.FACTEXPO\n", + " WHERE d.PARTEORIGINAL IN ({ph})\n", + " AND f.FECHAFACTURA_ISO BETWEEN ? AND ?\n", + " AND ISNULL(d.CANTDESC,0) > 0\n", + " GROUP BY d.FACTEXPO, d.LINEAEXPO, d.PARTEORIGINAL, d.NUMPARTE, d.UNIMED,\n", + " d.TIPOFRACCION, d.FACTIMPO, d.PAISMERCANCIA, f.FECHAFACTURA_ISO, f.ESTATUS\n", + " ORDER BY f.FECHAFACTURA_ISO, d.FACTEXPO, d.LINEAEXPO\n", + " \"\"\", scaii_conn, params=tuple(chunk) + (fecha_ini, fecha_fin)))\n", + " except Exception as e:\n", + " log(f'WARN chunk MP: {e}')\n", + " df = pd.concat(detalle, ignore_index=True) if detalle else pd.DataFrame()\n", + " prog.step(desc=f'Descargas matched: {len(df)}')\n", + " if df.empty:\n", + " prog.done('Sin resultados'); return df\n", + " # Filtrar por UNIMED y FRACCION del Excel (si vienen)\n", + " df['UNIMED'] = df['UNIMED'].fillna('').astype(str).str.strip().str.upper()\n", + " df['FRACCION'] = df['FRACCION'].fillna('').astype(str).str.strip()\n", + " # join con df_mp para validar match\n", + " df_mp2 = df_mp.copy()\n", + " df_mp2.columns = [c.upper() for c in df_mp2.columns]\n", + " df_mp2['UNIMED'] = df_mp2['UNIMED'].str.upper()\n", + " df_mp2['FRACCION'] = df_mp2['FRACCION'].astype(str).str.strip()\n", + " # Solo conservamos los registros cuyo NUMPARTE_MP + UNIMED + FRACCION coincide\n", + " df = df.merge(df_mp2, on='NUMPARTE_MP', how='inner', suffixes=('','_xls'))\n", + " df = df[(df['UNIMED'] == df['UNIMED_xls']) & (df['FRACCION'] == df['FRACCION_xls'])]\n", + " df = df.drop(columns=['UNIMED_xls','FRACCION_xls']).reset_index(drop=True)\n", + " # Agregar columna vacia CANTDESC_NUEVA para que el usuario la llene\n", + " df['CANTDESC_NUEVA'] = ''\n", + " prog.done(f'{len(df)} partidas/MP afectadas')\n", + " return df\n", + "\n", + "\n", + "def exportar_excel_listado_reintegro(df, ruta):\n", + " if df is None or df.empty: return None\n", + " with pd.ExcelWriter(ruta, engine='openpyxl') as w:\n", + " df.to_excel(w, sheet_name='Reintegro_a_llenar', index=False)\n", + " return ruta\n", + "\n", + "\n", + "def cargar_excel_reintegro_lleno(path):\n", + " \"\"\"Excel con CANTDESC_NUEVA llena. Valida que sea > 0 y < CANTDESC_ACTUAL.\"\"\"\n", + " df = pd.read_excel(path, dtype=str)\n", + " norm = {c: c.strip().upper().replace(' ', '_') for c in df.columns}\n", + " df = df.rename(columns=norm)\n", + " required = ['FACTURAEXPO','LINEA','NUMPARTE_MP','CANTDESC_ACTUAL','CANTDESC_NUEVA']\n", + " missing = [c for c in required if c not in df.columns]\n", + " if missing:\n", + " raise ValueError(f'Faltan columnas: {missing}')\n", + " for c in ('FACTURAEXPO','NUMPARTE_MP'):\n", + " df[c] = df[c].fillna('').astype(str).str.strip()\n", + " df['LINEA'] = pd.to_numeric(df['LINEA'], errors='coerce').fillna(0).astype(int)\n", + " df['CANTDESC_ACTUAL'] = pd.to_numeric(df['CANTDESC_ACTUAL'], errors='coerce').fillna(0)\n", + " df['CANTDESC_NUEVA'] = pd.to_numeric(df['CANTDESC_NUEVA'], errors='coerce')\n", + " df = df[df['CANTDESC_NUEVA'].notna()].reset_index(drop=True)\n", + " df = df[df['FACTURAEXPO'] != ''].reset_index(drop=True)\n", + " return df\n", + "\n", + "\n", + "def analizar_reintegro(df_xls, progress=None, log=print):\n", + " \"\"\"Para cada fila del Excel:\n", + " 1. Valida CANTDESC_NUEVA > 0 y < CANTDESC_ACTUAL.\n", + " 2. Toma todas las filas SDescargaT actuales para (FACTURAEXPO, LINEA, NUMPARTE_MP).\n", + " 3. Distribuye la reduccion proporcional al CANTDESC de cada fila.\n", + " 4. Calcula proporciones de VALORMN/VALORME/PESONETO/PESOBRUTO/MONTOIGI a devolver.\n", + " Devuelve (plan, inconsistencias).\n", + " \"\"\"\n", + " prog = _Progress(progress)\n", + " if df_xls is None or df_xls.empty:\n", + " log('Excel lleno vacio.'); return pd.DataFrame(), pd.DataFrame()\n", + " prog.setup(len(df_xls), 'Analizando filas...')\n", + " plan_rows = []\n", + " incons = []\n", + " for _, r in df_xls.iterrows():\n", + " factura = r['FACTURAEXPO']\n", + " linea = int(r['LINEA'])\n", + " mp = r['NUMPARTE_MP']\n", + " actual = float(r['CANTDESC_ACTUAL'])\n", + " nueva = float(r['CANTDESC_NUEVA'])\n", + " if nueva <= 0:\n", + " incons.append({**r.to_dict(), 'STATUS': 'NUEVA_INVALIDA',\n", + " 'DETALLE': 'CANTDESC_NUEVA debe ser mayor que 0'})\n", + " prog.step(); continue\n", + " if nueva >= actual:\n", + " incons.append({**r.to_dict(), 'STATUS': 'NUEVA_NO_REDUCE',\n", + " 'DETALLE': 'CANTDESC_NUEVA debe ser menor que CANTDESC_ACTUAL'})\n", + " prog.step(); continue\n", + " # Cargar las descargas individuales para esa combinacion\n", + " try:\n", + " df_d = pd.read_sql(\"\"\"\n", + " SELECT CONSECUTIVO, FACTIMPO, UNIMED, NUMPARTE,\n", + " ISNULL(CANTDESC,0) AS CANTDESC,\n", + " ISNULL(VALORMN,0) AS VALORMN,\n", + " ISNULL(VALORME,0) AS VALORME,\n", + " ISNULL(PESONETO,0) AS PESONETO,\n", + " ISNULL(PESOBRUTO,0) AS PESOBRUTO,\n", + " ISNULL(MONTOIGI,0) AS MONTOIGI\n", + " FROM SDescargaT\n", + " WHERE FACTEXPO=? AND LINEAEXPO=? AND PARTEORIGINAL=?\n", + " AND ISNULL(CANTDESC,0) > 0\n", + " ORDER BY CONSECUTIVO\n", + " \"\"\", scaii_conn, params=(factura, linea, mp))\n", + " except Exception as e:\n", + " incons.append({**r.to_dict(), 'STATUS': 'ERROR_QUERY',\n", + " 'DETALLE': str(e)})\n", + " prog.step(); continue\n", + " if df_d.empty:\n", + " incons.append({**r.to_dict(), 'STATUS': 'SIN_DESCARGAS',\n", + " 'DETALLE': 'No hay descargas vigentes con esa combinacion'})\n", + " prog.step(); continue\n", + " suma_actual = float(df_d['CANTDESC'].sum())\n", + " if abs(suma_actual - actual) > 0.01:\n", + " incons.append({**r.to_dict(), 'STATUS': 'DESACTUALIZADO',\n", + " 'DETALLE': f'Suma actual en BD={suma_actual} no coincide con CANTDESC_ACTUAL del Excel'})\n", + " prog.step(); continue\n", + " reduccion_total = actual - nueva\n", + " # Distribuir proporcional al CANTDESC de cada fila\n", + " for _, d in df_d.iterrows():\n", + " prop = float(d['CANTDESC']) / suma_actual if suma_actual > 1e-9 else 0\n", + " reduccion_fila = round(reduccion_total * prop, 6)\n", + " plan_rows.append({\n", + " 'FACTURAEXPO': factura, 'LINEA': linea, 'NUMPARTE_MP': mp,\n", + " 'CONSECUTIVO': int(d['CONSECUTIVO']),\n", + " 'FACTIMPO': d['FACTIMPO'], 'UNIMED': d['UNIMED'],\n", + " 'NUMPARTE_DESC': d['NUMPARTE'],\n", + " 'CANTDESC_ACTUAL': float(d['CANTDESC']),\n", + " 'CANTDESC_NUEVA': round(float(d['CANTDESC']) - reduccion_fila, 6),\n", + " 'REDUCCION_CANT': reduccion_fila,\n", + " 'DEVOLVER_VALORMN': round(float(d['VALORMN']) * prop * (reduccion_total/suma_actual), 6),\n", + " 'DEVOLVER_VALORME': round(float(d['VALORME']) * prop * (reduccion_total/suma_actual), 6),\n", + " 'DEVOLVER_PESONETO': round(float(d['PESONETO']) * prop * (reduccion_total/suma_actual), 6),\n", + " 'DEVOLVER_PESOBRUTO': round(float(d['PESOBRUTO'])* prop * (reduccion_total/suma_actual), 6),\n", + " 'DEVOLVER_MONTOIGI': round(float(d['MONTOIGI']) * prop * (reduccion_total/suma_actual), 6),\n", + " })\n", + " prog.step()\n", + " prog.done(f'Plan: {len(plan_rows)} filas | Inconsistencias: {len(incons)}')\n", + " return pd.DataFrame(plan_rows), pd.DataFrame(incons)\n", + "\n", + "\n", + "def ejecutar_reintegro(plan, dry_run=True, ajustar_sdm=False, progress=None, log=print):\n", + " \"\"\"Paso B: aplica los UPDATEs a SDescargaT y SSaldoTem. Opcionalmente\n", + " ajusta tambien SDescargaM (espejo).\"\"\"\n", + " assert isinstance(dry_run, bool), 'dry_run debe ser bool'\n", + " if plan is None or plan.empty:\n", + " log('ERROR: plan vacio.'); return\n", + " prog = _Progress(progress)\n", + " UPD_DESC = \"\"\"UPDATE SDescargaT\n", + " SET CANTDESC = ?,\n", + " VALORMN = ISNULL(VALORMN,0) - ?,\n", + " VALORME = ISNULL(VALORME,0) - ?,\n", + " PESONETO = ISNULL(PESONETO,0) - ?,\n", + " PESOBRUTO= ISNULL(PESOBRUTO,0)- ?,\n", + " MONTOIGI = ISNULL(MONTOIGI,0) - ?\n", + " WHERE CONSECUTIVO = ?\"\"\"\n", + " UPD_SAL = \"\"\"UPDATE SSaldoTem\n", + " SET CANTUSADA = ISNULL(CANTUSADA,0) - ?,\n", + " VALORUSADOMN = ISNULL(VALORUSADOMN,0) - ?,\n", + " VALORUSADOME = ISNULL(VALORUSADOME,0) - ?,\n", + " PESOUSADO = ISNULL(PESOUSADO,0) - ?,\n", + " PESOBRUTOUSADO = ISNULL(PESOBRUTOUSADO,0) - ?\n", + " WHERE FACTURAIMPO=? AND NUMPARTE=? AND UMEXITENCIA=?\"\"\"\n", + " UPD_SDM = \"\"\"UPDATE SDescargaM\n", + " SET CANTIDAD = ISNULL(CANTIDAD,0) - ?,\n", + " VALORIMPOMN = ISNULL(VALORIMPOMN,0) - ?,\n", + " VALORIMPOME = ISNULL(VALORIMPOME,0) - ?,\n", + " PESONETO = ISNULL(PESONETO,0) - ?,\n", + " PESOBRUTO = ISNULL(PESOBRUTO,0) - ?\n", + " WHERE FACTURAEXPO=? AND LINEA=? AND NUMPARTEMP=? AND FACTURAIMPO=?\"\"\"\n", + " prog.setup(len(plan), 'Aplicando reintegro')\n", + " upd_t = upd_s = upd_m = errores = 0\n", + " err_list = []\n", + " # Para agrupar updates a SSaldoTem (suma de devoluciones por FACTIMPO+NUMPARTE+UNIMED)\n", + " # Lo hacemos por fila para mantener consistencia y rollback fino.\n", + " for _, p in plan.iterrows():\n", + " try:\n", + " with scaii_conn.cursor() as cur:\n", + " if not dry_run:\n", + " # 1) SDescargaT: poner CANTDESC al nuevo valor y restar proporcionales\n", + " cur.execute(UPD_DESC,\n", + " float(p['CANTDESC_NUEVA']),\n", + " float(p['DEVOLVER_VALORMN']), float(p['DEVOLVER_VALORME']),\n", + " float(p['DEVOLVER_PESONETO']), float(p['DEVOLVER_PESOBRUTO']),\n", + " float(p['DEVOLVER_MONTOIGI']),\n", + " int(p['CONSECUTIVO']))\n", + " upd_t += 1\n", + " # 2) SSaldoTem: devolver al saldo IMPO\n", + " cur.execute(UPD_SAL,\n", + " float(p['REDUCCION_CANT']),\n", + " float(p['DEVOLVER_VALORMN']), float(p['DEVOLVER_VALORME']),\n", + " float(p['DEVOLVER_PESONETO']), float(p['DEVOLVER_PESOBRUTO']),\n", + " p['FACTIMPO'], p['NUMPARTE_DESC'], p['UNIMED'])\n", + " upd_s += 1\n", + " # 3) SDescargaM (opcional)\n", + " if ajustar_sdm:\n", + " cur.execute(UPD_SDM,\n", + " float(p['REDUCCION_CANT']),\n", + " float(p['DEVOLVER_VALORMN']), float(p['DEVOLVER_VALORME']),\n", + " float(p['DEVOLVER_PESONETO']), float(p['DEVOLVER_PESOBRUTO']),\n", + " p['FACTURAEXPO'], int(p['LINEA']), p['NUMPARTE_MP'], p['FACTIMPO'])\n", + " upd_m += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0\n", + " else:\n", + " upd_t += 1; upd_s += 1\n", + " if ajustar_sdm: upd_m += 1\n", + " if not dry_run: scaii_conn.commit()\n", + " except Exception as e:\n", + " if not dry_run: scaii_conn.rollback()\n", + " errores += 1\n", + " etq = f\"{p['FACTURAEXPO']}/{p['LINEA']}/{p['NUMPARTE_MP']}/CONSEC {p['CONSECUTIVO']}\"\n", + " err_list.append((etq, str(e)))\n", + " log(f' ERROR {etq}: {e}')\n", + " prog.step()\n", + " prog.done(f'{upd_t} desc / {upd_s} saldos / {upd_m} sdm')\n", + " log(f'\\n=== RESUMEN Reintegro (DRY_RUN={dry_run}, ajustar_sdm={ajustar_sdm}) ===')\n", + " log(f' SDescargaT actualizadas: {upd_t:,}')\n", + " log(f' SSaldoTem actualizadas: {upd_s:,}')\n", + " log(f' SDescargaM actualizadas: {upd_m:,}')\n", + " log(f' Errores : {errores:,}')\n", + " for f, e in err_list[:5]: log(f' {f}: {e}')\n", + "\n", + "\n", + "# =============================================================\n", + "# DIAGNOSTICO PASO 12 (% KGS)\n", + "# Lista facturas EXPO pendientes (NA) y explica por que el Paso 12\n", + "# no las pudo descargar (en este momento). Solo lectura.\n", + "# =============================================================\n", + "\n", + "def diagnosticar_paso12_pendientes(fecha_ini=None, fecha_fin=None,\n", + " progress=None, log=print):\n", + " \"\"\"Para cada factura EXPO en NA (opcionalmente filtrada por rango de\n", + " FECHAFACTURA_ISO) revisa por que no se pudo descargar con el flujo\n", + " KG_PCT del Paso 12. Devuelve dos DataFrames: bloqueos por factura y\n", + " detalle por componente faltante.\n", + " \"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(5, 'Cargando facturas NA...')\n", + " # 1) Facturas NA\n", + " sql_fact = (\"SELECT FACTURAEXPO, PEDIMENTOEXPO, FECHAFACTURA_ISO, \"\n", + " \"ISNULL(TIPOFACTURA,'') AS TIPOFACTURA \"\n", + " \"FROM SFacExp WHERE ESTATUS = 'NA'\")\n", + " params = []\n", + " if fecha_ini:\n", + " sql_fact += ' AND FECHAFACTURA_ISO >= ?'; params.append(fecha_ini)\n", + " if fecha_fin:\n", + " sql_fact += ' AND FECHAFACTURA_ISO <= ?'; params.append(fecha_fin)\n", + " sql_fact += ' ORDER BY FECHAFACTURA_ISO'\n", + " df_fact = pd.read_sql(sql_fact, scaii_conn, params=params)\n", + " log(f'Facturas NA en rango: {len(df_fact)}')\n", + " if df_fact.empty:\n", + " prog.done('Sin facturas NA'); return pd.DataFrame(), pd.DataFrame()\n", + "\n", + " # 2) Cargar catalogos (PT con BOM, partepais, etc.)\n", + " prog.step(desc='Cargando catalogos...')\n", + " _load_catalogos()\n", + " df_part = _state.get('df_partidas_all', pd.DataFrame())\n", + " df_bom = _state.get('df_boms_all', pd.DataFrame())\n", + " saldos = _load_saldos_snapshot()\n", + "\n", + " if df_part.empty:\n", + " log('Sin SPartidasExpo en estado.'); df_part = pd.DataFrame()\n", + " if df_bom.empty:\n", + " log('Sin SMatBOM en estado.'); df_bom = pd.DataFrame()\n", + "\n", + " pts_con_bom = set(df_bom['PT'].astype(str).str.strip().unique().tolist()) if not df_bom.empty else set()\n", + " partidas_por_factura = (df_part.groupby('FACTURAEXPO')\n", + " if not df_part.empty else None)\n", + "\n", + " # 3) Saldos disponibles agrupados por (NUMPARTE, UM_KEY)\n", + " saldos_disp = saldos.groupby(['NUMPARTE','UM_KEY'])['SALDO_DISPONIBLE'].sum().to_dict() if not saldos.empty else {}\n", + " # 3.1) Mapa de sustitutos {NUMPARTE: [(alt, [um1,um2]), ...]} para emular\n", + " # el flujo real del Paso 12 (original primero, luego sustitutos).\n", + " df_sub = _state.get('df_sustitutos_all', pd.DataFrame())\n", + " sust_map = {}\n", + " if not df_sub.empty:\n", + " for _, r in df_sub.iterrows():\n", + " k = str(r['NUMPARTE']).strip()\n", + " alt = str(r['COMPONENTE_ALTERNO']).strip()\n", + " um_keys = [str(u).strip().upper() for u in [r.get('UNIDADMEDIDA1'), r.get('UNIDADMEDIDA2')] if u and str(u).strip()]\n", + " if k and alt and um_keys:\n", + " sust_map.setdefault(k, []).append((alt, um_keys))\n", + "\n", + " bloqueos = [] # uno por factura con su motivo principal\n", + " componentes = [] # detalle de cada componente faltante / problematico\n", + "\n", + " prog.setup(len(df_fact), 'Analizando facturas...')\n", + " for _, f in df_fact.iterrows():\n", + " factura = f['FACTURAEXPO']\n", + " tipof = (f['TIPOFACTURA'] or '').strip().upper()\n", + " fecha = f['FECHAFACTURA_ISO']\n", + " ped = f['PEDIMENTOEXPO']\n", + " # 3a) TIPOFACTURA NODES\n", + " if tipof == 'NODES':\n", + " bloqueos.append({'FACTURAEXPO': factura, 'PEDIMENTOEXPO': ped,\n", + " 'FECHAFACTURA_ISO': fecha, 'TIPOFACTURA': tipof,\n", + " 'PARTIDAS': 0, 'PT_SIN_BOM': 0, 'PESONETO_CERO': 0,\n", + " 'COMPONENTES_SIN_SALDO': 0,\n", + " 'MOTIVO_PRINCIPAL': 'TIPOFACTURA_NODES',\n", + " 'DETALLE': 'Las facturas NODES estan excluidas del Paso 12.'})\n", + " prog.step(); continue\n", + " # 3b) Sin partidas\n", + " if partidas_por_factura is None or factura not in partidas_por_factura.groups:\n", + " bloqueos.append({'FACTURAEXPO': factura, 'PEDIMENTOEXPO': ped,\n", + " 'FECHAFACTURA_ISO': fecha, 'TIPOFACTURA': tipof,\n", + " 'PARTIDAS': 0, 'PT_SIN_BOM': 0, 'PESONETO_CERO': 0,\n", + " 'COMPONENTES_SIN_SALDO': 0,\n", + " 'MOTIVO_PRINCIPAL': 'SIN_PARTIDAS',\n", + " 'DETALLE': 'La factura no tiene partidas EXPO cargadas.'})\n", + " prog.step(); continue\n", + " df_p = partidas_por_factura.get_group(factura)\n", + " n_partidas = len(df_p)\n", + " n_pt_sin_bom = 0\n", + " n_peso_cero = 0\n", + " n_comp_sin_saldo = 0\n", + " problemas = []\n", + " for _, p in df_p.iterrows():\n", + " pt = str(p['PT']).strip()\n", + " linea = int(p['LINEA']) if pd.notna(p['LINEA']) else 0\n", + " peso = float(p.get('PESONETO', 0) or 0)\n", + " if pt not in pts_con_bom:\n", + " n_pt_sin_bom += 1\n", + " componentes.append({'FACTURAEXPO': factura, 'LINEA': linea, 'PT': pt,\n", + " 'COMPONENTE': None, 'UNIMED': None,\n", + " 'CANT_REQUERIDA': 0, 'SALDO_DISPONIBLE': 0,\n", + " 'MOTIVO': 'PT_SIN_BOM',\n", + " 'DETALLE': f'El producto terminado {pt} no tiene BOM en SMatBOM.'})\n", + " problemas.append('PT_SIN_BOM')\n", + " continue\n", + " if peso <= 1e-9:\n", + " n_peso_cero += 1\n", + " componentes.append({'FACTURAEXPO': factura, 'LINEA': linea, 'PT': pt,\n", + " 'COMPONENTE': None, 'UNIMED': None,\n", + " 'CANT_REQUERIDA': 0, 'SALDO_DISPONIBLE': 0,\n", + " 'MOTIVO': 'PESONETO_CERO',\n", + " 'DETALLE': 'PESONETO=0 en la partida. El modo KG_PCT calcula cant_req=0 y no descarga.'})\n", + " problemas.append('PESONETO_CERO')\n", + " continue\n", + " # 3c) Revisar cada componente del BOM\n", + " df_b = df_bom[df_bom['PT'] == pt]\n", + " for _, c in df_b.iterrows():\n", + " comp = str(c['COMPONENTE_MP']).strip()\n", + " unimed = (c['UNIMED'] or '').strip().upper()\n", + " pct = float(c['CANTIDAD'] or 0)\n", + " cant_req = (pct / 100.0) * peso\n", + " if cant_req <= 1e-9:\n", + " continue\n", + " # Saldo del ORIGINAL: cualquier UM (replica _consumir con UM del BOM,\n", + " # aqui agregamos por NUMPARTE sin filtrar UM para no perder casos validos).\n", + " disp_orig = sum(v for (np_, um), v in saldos_disp.items() if np_ == comp)\n", + " # Saldo de SUSTITUTOS: solo si UM en UNIDADMEDIDA1/2 del registro de sust.\n", + " disp_sust = 0.0\n", + " lista_alt = []\n", + " for alt, um_keys in sust_map.get(comp, []):\n", + " s_alt = sum(v for (np_, um), v in saldos_disp.items()\n", + " if np_ == alt and um in um_keys)\n", + " if s_alt > 0:\n", + " disp_sust += s_alt\n", + " lista_alt.append(f'{alt}({s_alt:.2f})')\n", + " disp = disp_orig + disp_sust\n", + " if disp < cant_req:\n", + " n_comp_sin_saldo += 1\n", + " extra = f' | orig={disp_orig:.2f} sust={disp_sust:.2f}'\n", + " if lista_alt: extra += ' [' + ', '.join(lista_alt[:3]) + (']' if len(lista_alt)<=3 else ' ...]')\n", + " componentes.append({'FACTURAEXPO': factura, 'LINEA': linea, 'PT': pt,\n", + " 'COMPONENTE': comp, 'UNIMED': unimed,\n", + " 'CANT_REQUERIDA': round(cant_req, 6),\n", + " 'SALDO_DISPONIBLE': round(disp, 6),\n", + " 'SALDO_ORIGINAL': round(disp_orig, 6),\n", + " 'SALDO_SUSTITUTOS': round(disp_sust, 6),\n", + " 'N_SUSTITUTOS_CON_SALDO': len(lista_alt),\n", + " 'MOTIVO': 'COMPONENTE_SIN_SALDO',\n", + " 'DETALLE': f'Faltan {cant_req - disp:.4f} {unimed} (incluyendo sustitutos).{extra}'})\n", + " problemas.append('COMPONENTE_SIN_SALDO')\n", + " # 3d) Motivo principal\n", + " if not problemas:\n", + " motivo = 'COBERTURA_POSIBLE'\n", + " detalle = 'La factura tiene cobertura completa segun el snapshot actual. Si el Paso 12 no la pudo procesar revisa el modo NATURAL/DIRIGIDA o si hay descargas previas que la dejaron en un estado intermedio.'\n", + " else:\n", + " from collections import Counter\n", + " counter = Counter(problemas)\n", + " motivo = counter.most_common(1)[0][0]\n", + " detalle = ', '.join(f'{k}:{v}' for k, v in counter.most_common())\n", + " bloqueos.append({'FACTURAEXPO': factura, 'PEDIMENTOEXPO': ped,\n", + " 'FECHAFACTURA_ISO': fecha, 'TIPOFACTURA': tipof,\n", + " 'PARTIDAS': n_partidas, 'PT_SIN_BOM': n_pt_sin_bom,\n", + " 'PESONETO_CERO': n_peso_cero,\n", + " 'COMPONENTES_SIN_SALDO': n_comp_sin_saldo,\n", + " 'MOTIVO_PRINCIPAL': motivo,\n", + " 'DETALLE': detalle})\n", + " prog.step()\n", + " prog.done(f'{len(bloqueos)} facturas analizadas')\n", + " df_bloq = pd.DataFrame(bloqueos)\n", + " df_comp = pd.DataFrame(componentes)\n", + " # Resumen por motivo\n", + " if not df_bloq.empty:\n", + " res_motivo = df_bloq['MOTIVO_PRINCIPAL'].value_counts().reset_index()\n", + " res_motivo.columns = ['MOTIVO', 'FACTURAS']\n", + " log('\\n=== Resumen por motivo principal ===')\n", + " for _, row in res_motivo.iterrows():\n", + " log(f' {row[\"MOTIVO\"]:<30} {row[\"FACTURAS\"]:>5,}')\n", + " return df_bloq, df_comp\n", + "\n", + "\n", + "def exportar_excel_diagnostico_paso12(df_bloq, df_comp, ruta):\n", + " with pd.ExcelWriter(ruta, engine='openpyxl') as w:\n", + " if df_bloq is not None and not df_bloq.empty:\n", + " df_bloq.to_excel(w, sheet_name='Bloqueos_por_factura', index=False)\n", + " if df_comp is not None and not df_comp.empty:\n", + " df_comp.to_excel(w, sheet_name='Detalle_componentes', index=False)\n", + " return ruta" ] }, { @@ -975,6 +1842,41 @@ }, "outputs": [], "source": [ + "def _fmt_an_df(df):\n", + " \"\"\"Formatea las tablas con separador de miles, decimales y moneda.\n", + " Solo aplica formato numerico a columnas numericas (evita ValueError en strings).\n", + " Devuelve un pandas Styler (visual) sin alterar el DataFrame original.\"\"\"\n", + " if df is None or getattr(df, 'empty', True):\n", + " return df\n", + " fmt = {}\n", + " for col in df.columns:\n", + " # Solo formatear columnas numericas\n", + " if not pd.api.types.is_numeric_dtype(df[col]):\n", + " continue\n", + " c = str(col).lower()\n", + " # Anios y lineas: enteros sin coma (decoracion)\n", + " if c in ('anio', 'anio_entrada', 'linea'):\n", + " fmt[col] = '{:.0f}'\n", + " continue\n", + " if c.startswith('ratio'):\n", + " fmt[col] = '{:,.4f}'\n", + " elif any(k in c for k in ('vmn', 'vme', 'valor', 'dif_valor')):\n", + " fmt[col] = '${:,.2f}'\n", + " elif any(k in c for k in ('partidas', 'partes', 'lotes', 'unicas', 'n_sustitutos', 'pts_', 'relaciones', 'componentes_distintos', 'n_sustitutos_con_saldo')):\n", + " fmt[col] = '{:,.0f}'\n", + " else:\n", + " fmt[col] = '{:,.2f}'\n", + " try:\n", + " sty = df.style.format(fmt, na_rep='-')\n", + " try:\n", + " sty = sty.hide(axis='index')\n", + " except Exception:\n", + " try: sty = sty.hide_index()\n", + " except Exception: pass\n", + " return sty\n", + " except Exception:\n", + " return df\n", + "\n", "def cargar_analisis_saldos(progress=None):\n", " prog = _Progress(progress)\n", " prog.setup(4, 'Cargando SSaldoTem...')\n", @@ -1864,13 +2766,16 @@ "\n", " # SOLO INSERT espejo en SDescargaM. SDescargaT no se toca.\n", " if not dry_run:\n", + " # Defaults: '' para strings, 0/0.0 para numericos.\n", + " _x = lambda v, d='': (d if (v is None or (isinstance(v, float) and pd.isna(v))) else v)\n", + " _xn = lambda v: 0.0 if (v is None or (isinstance(v, float) and pd.isna(v))) else float(v)\n", " cur.execute(INSERT_M,\n", " next_m, consec_factura_ctm, factura_ctm, linea_ctm,\n", - " numparte_pt_ctm, r.CLASE, m_cantidad, r.UNIMED,\n", - " r.FACTIMPO, r.PARTEORIGINAL,\n", - " m_vmn, m_vme, r.PAISMERCANCIA, m_pneto, m_pbruto,\n", - " r.TIPOFRACCION, r.SECTOR,\n", - " '', 'TEM', r.FACTIMPO, 'N', r.ORDENVENTA, '')\n", + " _x(numparte_pt_ctm), _x(r.CLASE), _xn(m_cantidad), _x(r.UNIMED),\n", + " _x(r.FACTIMPO), _x(r.PARTEORIGINAL),\n", + " _xn(m_vmn), _xn(m_vme), _x(r.PAISMERCANCIA), _xn(m_pneto), _xn(m_pbruto),\n", + " _x(r.TIPOFRACCION), _x(r.SECTOR),\n", + " '', 'TEM', _x(r.FACTIMPO), 'N', _x(r.ORDENVENTA), '')\n", " next_m += 1; ins_m += 1\n", "\n", " if not dry_run:\n", @@ -1891,9 +2796,7 @@ " log(f' SDescargaT NO se modifica (solo se usa como referencia)')\n", " log(f' Errores : {len(errores):,}')\n", " for f, e in errores[:5]:\n", - " log(f' {f}: {e}')\n", - "\n", - "\n" + " log(f' {f}: {e}')" ] }, { @@ -2447,6 +3350,223 @@ " return fig" ] }, + { + "cell_type": "code", + "id": "logic-bom", + "metadata": {}, + "outputs": [], + "execution_count": null, + "source": [ + "# ============================================================================\n", + "# Logic: Analisis BOM + Sustitutos + Saldos (vigentes/vencidos)\n", + "# Pestana general - muestra composicion de PT, sus sustitutos y disponibilidad\n", + "# ============================================================================\n", + "\n", + "def cargar_bom_completo(progress=None):\n", + " \"\"\"Trae todo SMatBOM con info del PT (tipo de material si esta en SPartes).\n", + " Una fila = un componente de un PT.\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(2, 'Cargando SMatBOM...')\n", + " df = pd.read_sql(\"\"\"\n", + " SELECT B.NUMPARTE AS PT,\n", + " B.NUMPARTEBOM AS COMPONENTE,\n", + " B.CANTIDAD AS CANTIDAD_BOM,\n", + " B.UNIMED AS UNIMED_BOM,\n", + " P.TIPOMAT AS TIPOMAT_PT,\n", + " P.DESCRIPCIONE AS DESC_PT\n", + " FROM SMatBOM B\n", + " LEFT JOIN SPartes P ON P.NUMPARTE = B.NUMPARTE\n", + " \"\"\", scaii_conn)\n", + " prog.step(desc='SMatBOM cargado')\n", + " for c in ('PT', 'COMPONENTE', 'UNIMED_BOM', 'TIPOMAT_PT', 'DESC_PT'):\n", + " df[c] = df[c].fillna('').astype(str).str.strip()\n", + " df['CANTIDAD_BOM'] = pd.to_numeric(df['CANTIDAD_BOM'], errors='coerce').fillna(0.0)\n", + " prog.done(f'BOM: {len(df):,} relaciones PT-componente')\n", + " return df\n", + "\n", + "def cargar_sustitutos_map(progress=None):\n", + " \"\"\"Devuelve dict {NUMPARTE: [sustitutos]} desde SPartesSustitutos.\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(1, 'Cargando SPartesSustitutos...')\n", + " df = pd.read_sql(\"SELECT NUMPARTE, NUMPARTESUSTITUTO FROM SPartesSustitutos\", scaii_conn)\n", + " d = {}\n", + " for _, r in df.iterrows():\n", + " k = str(r['NUMPARTE']).strip()\n", + " v = str(r['NUMPARTESUSTITUTO']).strip()\n", + " if k and v:\n", + " d.setdefault(k, []).append(v)\n", + " prog.done(f'Sustitutos: {len(d):,} partes con al menos un sustituto')\n", + " return d\n", + "\n", + "def cargar_saldos_agrupados_por_parte(progress=None):\n", + " \"\"\"Saldos disponibles por NUMPARTE con sub-totales vigente / vencido.\n", + " Vigente = FECHAVENC_ISO IS NULL o > hoy. Vencido = FECHAVENC_ISO <= hoy.\"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(1, 'Cargando saldos por parte...')\n", + " df = pd.read_sql(\"\"\"\n", + " SELECT NUMPARTE,\n", + " CASE WHEN FECHAVENC_ISO IS NULL OR FECHAVENC_ISO > CAST(GETDATE() AS DATE)\n", + " THEN 'VIG' ELSE 'VEN' END AS ESTADO,\n", + " SUM(CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) AS SALDO_CANT,\n", + " SUM(ISNULL(VALORIMPOMN,0) - ISNULL(VALORUSADOMN,0)) AS SALDO_VMN,\n", + " SUM(ISNULL(VALORIMPOME,0) - ISNULL(VALORUSADOME,0)) AS SALDO_VME,\n", + " SUM(ISNULL(PESONETO,0) - ISNULL(PESOUSADO,0)) AS SALDO_PESO,\n", + " COUNT(*) AS LOTES\n", + " FROM SSaldoTem\n", + " WHERE (CANTEXITENCIA - ISNULL(CANTUSADA,0) - ISNULL(CANTUSADADESP,0)) > 0\n", + " GROUP BY NUMPARTE,\n", + " CASE WHEN FECHAVENC_ISO IS NULL OR FECHAVENC_ISO > CAST(GETDATE() AS DATE)\n", + " THEN 'VIG' ELSE 'VEN' END\n", + " \"\"\", scaii_conn)\n", + " df['NUMPARTE'] = df['NUMPARTE'].fillna('').astype(str).str.strip()\n", + " # Pivot a una fila por NUMPARTE con columnas separadas vig/ven\n", + " piv = df.pivot_table(index='NUMPARTE', columns='ESTADO',\n", + " values=['SALDO_CANT','SALDO_VMN','SALDO_VME','SALDO_PESO','LOTES'],\n", + " aggfunc='sum', fill_value=0.0)\n", + " piv.columns = [f'{a}_{b}' for a,b in piv.columns]\n", + " piv = piv.reset_index()\n", + " # Garantizar columnas (puede faltar VIG o VEN si no hay)\n", + " for col in ('SALDO_CANT_VIG','SALDO_CANT_VEN','SALDO_VMN_VIG','SALDO_VMN_VEN',\n", + " 'SALDO_VME_VIG','SALDO_VME_VEN','SALDO_PESO_VIG','SALDO_PESO_VEN',\n", + " 'LOTES_VIG','LOTES_VEN'):\n", + " if col not in piv.columns: piv[col] = 0.0\n", + " prog.done(f'Saldos: {len(piv):,} partes con saldo > 0')\n", + " return piv\n", + "\n", + "def analizar_bom_completo(progress=None):\n", + " \"\"\"Arma la tabla plana BOM con columnas:\n", + " PT, DESC_PT, TIPOMAT_PT, COMPONENTE, UNIMED_BOM, CANTIDAD_BOM,\n", + " N_SUSTITUTOS, LISTA_SUSTITUTOS,\n", + " SALDO_VIG_ORIG, SALDO_VEN_ORIG,\n", + " SALDO_VIG_SUST, SALDO_VEN_SUST,\n", + " SALDO_VIG_TOTAL, SALDO_VEN_TOTAL, SALDO_TOTAL,\n", + " ESTATUS_COBERTURA, LOTES_VIG, LOTES_VEN\n", + " \"\"\"\n", + " prog = _Progress(progress)\n", + " prog.setup(5, 'Cargando BOM...')\n", + " bom = cargar_bom_completo(progress=None)\n", + " prog.step(desc='Cargando sustitutos...')\n", + " sust = cargar_sustitutos_map(progress=None)\n", + " prog.step(desc='Cargando saldos...')\n", + " sal = cargar_saldos_agrupados_por_parte(progress=None)\n", + " prog.step(desc='Cruzando datos...')\n", + "\n", + " # Indice de saldos por NUMPARTE\n", + " sal_map = sal.set_index('NUMPARTE').to_dict('index')\n", + "\n", + " def _saldo_de(parte):\n", + " r = sal_map.get(parte)\n", + " if not r:\n", + " return (0.0, 0.0, 0, 0)\n", + " return (float(r.get('SALDO_CANT_VIG',0) or 0),\n", + " float(r.get('SALDO_CANT_VEN',0) or 0),\n", + " int(r.get('LOTES_VIG',0) or 0),\n", + " int(r.get('LOTES_VEN',0) or 0))\n", + "\n", + " rows = []\n", + " for _, b in bom.iterrows():\n", + " comp = b['COMPONENTE']\n", + " if not comp:\n", + " continue\n", + " s_vig_o, s_ven_o, lt_vig_o, lt_ven_o = _saldo_de(comp)\n", + " lista_sust = sust.get(comp, [])\n", + " s_vig_s = s_ven_s = 0.0\n", + " lt_vig_s = lt_ven_s = 0\n", + " for x in lista_sust:\n", + " v, w, lv, lw = _saldo_de(x)\n", + " s_vig_s += v; s_ven_s += w\n", + " lt_vig_s += lv; lt_ven_s += lw\n", + " vig_total = s_vig_o + s_vig_s\n", + " ven_total = s_ven_o + s_ven_s\n", + " total = vig_total + ven_total\n", + " if vig_total > 0:\n", + " estatus = 'DISPONIBLE_VIG'\n", + " elif ven_total > 0:\n", + " estatus = 'SOLO_VENCIDO'\n", + " else:\n", + " estatus = 'SIN_SALDO'\n", + " rows.append({\n", + " 'PT': b['PT'],\n", + " 'DESC_PT': b['DESC_PT'][:60],\n", + " 'TIPOMAT_PT': b['TIPOMAT_PT'],\n", + " 'COMPONENTE': comp,\n", + " 'UNIMED_BOM': b['UNIMED_BOM'],\n", + " 'CANTIDAD_BOM': b['CANTIDAD_BOM'],\n", + " 'N_SUSTITUTOS': len(lista_sust),\n", + " 'LISTA_SUSTITUTOS': ', '.join(lista_sust[:5]) + (' ...' if len(lista_sust) > 5 else ''),\n", + " 'SALDO_VIG_ORIG': s_vig_o,\n", + " 'SALDO_VEN_ORIG': s_ven_o,\n", + " 'SALDO_VIG_SUST': s_vig_s,\n", + " 'SALDO_VEN_SUST': s_ven_s,\n", + " 'SALDO_VIG_TOTAL': vig_total,\n", + " 'SALDO_VEN_TOTAL': ven_total,\n", + " 'SALDO_TOTAL': total,\n", + " 'ESTATUS_COBERTURA': estatus,\n", + " 'LOTES_VIG': lt_vig_o + lt_vig_s,\n", + " 'LOTES_VEN': lt_ven_o + lt_ven_s,\n", + " })\n", + " prog.step(desc='Tabla armada')\n", + " df_out = pd.DataFrame(rows)\n", + " if df_out.empty:\n", + " df_out = pd.DataFrame(columns=['PT','DESC_PT','TIPOMAT_PT','COMPONENTE','UNIMED_BOM',\n", + " 'CANTIDAD_BOM','N_SUSTITUTOS','LISTA_SUSTITUTOS','SALDO_VIG_ORIG','SALDO_VEN_ORIG',\n", + " 'SALDO_VIG_SUST','SALDO_VEN_SUST','SALDO_VIG_TOTAL','SALDO_VEN_TOTAL','SALDO_TOTAL',\n", + " 'ESTATUS_COBERTURA','LOTES_VIG','LOTES_VEN'])\n", + " df_out = df_out.sort_values(['ESTATUS_COBERTURA','PT','COMPONENTE']).reset_index(drop=True)\n", + " prog.done(f'BOM analizado: {len(df_out):,} filas (PT-componente)')\n", + " return df_out\n", + "\n", + "def resumen_bom(df_bom):\n", + " \"\"\"Resumen agregado: cuantos PTs totales, cuantos con problema, etc.\"\"\"\n", + " if df_bom is None or df_bom.empty:\n", + " return pd.DataFrame()\n", + " tot_pt = df_bom['PT'].nunique()\n", + " tot_comp_unicos = df_bom['COMPONENTE'].nunique()\n", + " por_estatus = df_bom.groupby('ESTATUS_COBERTURA', as_index=False).agg(\n", + " relaciones=('PT','count'),\n", + " pts_distintos=('PT','nunique'),\n", + " componentes_distintos=('COMPONENTE','nunique'))\n", + " pts_con_problema = df_bom[df_bom['ESTATUS_COBERTURA'] != 'DISPONIBLE_VIG']['PT'].nunique()\n", + " print(f'Total PTs con BOM: {tot_pt:,}')\n", + " print(f'Total componentes distintos: {tot_comp_unicos:,}')\n", + " print(f'PTs con al menos 1 problema: {pts_con_problema:,}')\n", + " print()\n", + " return por_estatus\n", + "\n", + "def exportar_excel_bom(df_bom, ruta='Analisis_BOM.xlsx'):\n", + " \"\"\"Exporta a Excel: hoja 'BOM_completo' + hoja 'Componentes_sin_saldo'.\"\"\"\n", + " ruta = Path(ruta).resolve()\n", + " with pd.ExcelWriter(ruta, engine='openpyxl') as xw:\n", + " df_bom.to_excel(xw, sheet_name='BOM_completo', index=False)\n", + " problema = df_bom[df_bom['ESTATUS_COBERTURA'] != 'DISPONIBLE_VIG']\n", + " problema.to_excel(xw, sheet_name='Componentes_sin_saldo_vig', index=False)\n", + " # Resumen por componente\n", + " if not df_bom.empty:\n", + " por_comp = df_bom.groupby(['COMPONENTE','UNIMED_BOM'], as_index=False).agg(\n", + " pts_que_lo_usan=('PT','nunique'),\n", + " saldo_vig_total=('SALDO_VIG_TOTAL','max'),\n", + " saldo_ven_total=('SALDO_VEN_TOTAL','max'),\n", + " n_sustitutos=('N_SUSTITUTOS','max'),\n", + " estatus=('ESTATUS_COBERTURA','first'))\n", + " por_comp = por_comp.sort_values(['estatus','pts_que_lo_usan'], ascending=[True, False])\n", + " por_comp.to_excel(xw, sheet_name='Por_componente', index=False)\n", + " return str(ruta)\n", + "\n", + "def filtrar_bom(df, pt_filtro='', comp_filtro='', solo_problemas=False):\n", + " \"\"\"Filtros simples sobre la tabla del BOM.\"\"\"\n", + " if df is None or df.empty:\n", + " return df\n", + " out = df.copy()\n", + " if pt_filtro:\n", + " out = out[out['PT'].str.contains(pt_filtro, case=False, regex=False, na=False)]\n", + " if comp_filtro:\n", + " out = out[out['COMPONENTE'].str.contains(comp_filtro, case=False, regex=False, na=False)]\n", + " if solo_problemas:\n", + " out = out[out['ESTATUS_COBERTURA'] != 'DISPONIBLE_VIG']\n", + " return out.reset_index(drop=True)\n", + "" + ] + }, { "cell_type": "code", "execution_count": null, @@ -4032,31 +5152,31 @@ " with out_an_anio:\n", " clear_output()\n", " display(HTML('

Saldo disponible por a-o (de entrada del lote)

'))\n", - " display(_state['por_anio_saldos'])\n", + " display(_fmt_an_df(_state['por_anio_saldos']))\n", " if not _state['por_anio_saldos'].empty:\n", " fig = graficar_saldos_anio(_state['por_anio_saldos']); display(fig); plt.close(fig)\n", " with out_an_imp_exp:\n", " clear_output()\n", " display(HTML('

IMPO vs EXPO por a-o

'))\n", - " display(_state['cmp_impo_expo'])\n", + " display(_fmt_an_df(_state['cmp_impo_expo']))\n", " if not _state['cmp_impo_expo'].empty:\n", " fig = graficar_impo_expo(_state['cmp_impo_expo']); display(fig); plt.close(fig)\n", " with out_an_pesos:\n", " clear_output()\n", " display(HTML('

Peso por a-o - IMPO / EXPO / CONSUMIDO / DESCARGAS

'))\n", - " display(_state['cmp_pesos_anio'])\n", + " display(_fmt_an_df(_state['cmp_pesos_anio']))\n", " if not _state['cmp_pesos_anio'].empty:\n", " fig = graficar_pesos_anio(_state['cmp_pesos_anio']); display(fig); plt.close(fig)\n", " with out_an_cant:\n", " clear_output()\n", " display(HTML('

Cantidades por a-o - IMPO vs EXPO

'))\n", - " display(_state['cmp_cantidades_anio'])\n", + " display(_fmt_an_df(_state['cmp_cantidades_anio']))\n", " if not _state['cmp_cantidades_anio'].empty:\n", " fig = graficar_cantidades_anio(_state['cmp_cantidades_anio']); display(fig); plt.close(fig)\n", " with out_an_cant_um:\n", " clear_output()\n", " display(HTML('

Cantidades por a-o + UM (separado por unidad de medida)

'))\n", - " display(_state['cmp_cantidades_anio_um'])\n", + " display(_fmt_an_df(_state['cmp_cantidades_anio_um']))\n", " if not _state['cmp_cantidades_anio_um'].empty:\n", " fig = graficar_cantidades_anio_um(_state['cmp_cantidades_anio_um']); display(fig); plt.close(fig)\n", "btn_an.on_click(_on_an)\n", @@ -5769,17 +6889,440 @@ "])\n", "\n", "\n", - "tabs = W.Tab(children=[tab_conn, tab_desc, tab_an, tab_nlp, tab_kg, tab_ctm, tab_sv, tab_datastage, tab_estructuras, tab_valores])\n", + "\n", + "\n", + "# ===== Descargas Uno a Uno (en pestania Descargas) =====\n", + "import datetime as _dt_u1\n", + "w_u1_fi = W.DatePicker(description='Fecha inicio:', value=_dt_u1.date(2019,4,12), layout={'width':'260px'})\n", + "w_u1_ff = W.DatePicker(description='Fecha fin:', value=_dt_u1.date(2026,5,14), layout={'width':'260px'})\n", + "w_u1_modo = W.RadioButtons(options=[('NATURAL','NATURAL'),('DIRIGIDA','DIRIGIDA')],\n", + " value='DIRIGIDA', description='Modo:', layout={'width':'320px'})\n", + "btn_u1_listar = W.Button(description='Listar partidas EXPO', button_style='', icon='list', layout={'width':'220px'})\n", + "btn_u1_analizar = W.Button(description='Analizar', button_style='primary', icon='search', layout={'width':'180px'})\n", + "btn_u1_xlsx = W.Button(description='Exportar Excel', button_style='', icon='file-excel', layout={'width':'200px'})\n", + "dry_u1 = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n", + "btn_u1_ejecutar = W.Button(description='Ejecutar uno a uno (Paso B)', button_style='warning', icon='play', layout={'width':'280px'})\n", + "bar_u1 = _mkbar('Uno a uno')\n", + "out_u1_log = W.Output(layout=OUT_STYLE)\n", + "out_u1_tabla = W.Output(layout=OUT_STYLE)\n", + "\n", + "_html_u1 = ('
'\n", + " 'Descargas Uno a Uno'\n", + " '

'\n", + " 'Para empresas que importan y re-exportan el mismo producto sin transformacion. '\n", + " 'Cada partida EXPO descarga el saldo IMPO del MISMO NUMPARTE, con la MISMA fraccion '\n", + " '(SSaldoTem.FRACCIONIMPO = SPartidasExpo.FRACCIONEXPO) y la MISMA unidad de medida. '\n", + " 'Solo consume saldos vencidos (FECHAVENC_ISO < hoy y SALDO_DISPONIBLE > 0). '\n", + " 'Omite facturas con TIPOFACTURA = NODES y respeta CANTEXPO (no sobre-descarga). '\n", + " 'Incluye facturas NA y AC con lineas faltantes.

')\n", + "\n", + "def _on_u1_listar(_):\n", + " with out_u1_tabla:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " if not w_u1_fi.value or not w_u1_ff.value:\n", + " print('Define fecha inicio y fecha fin.'); return\n", + " try:\n", + " detalle, resumen = listar_partidas_expo_uno_a_uno(\n", + " str(w_u1_fi.value), str(w_u1_ff.value))\n", + " if detalle.empty:\n", + " print('No hay partidas EXPO en ese rango.'); return\n", + " print(f'Partidas EXPO (sin NODES): {len(detalle):,}')\n", + " if not resumen.empty:\n", + " display(HTML('

Resumen por ESTATUS

'))\n", + " display(resumen)\n", + " display(HTML('

Detalle (primeras 200 filas)

'))\n", + " display(detalle.head(200))\n", + " except Exception as e:\n", + " print(f'ERROR: {e}')\n", + "btn_u1_listar.on_click(_on_u1_listar)\n", + "\n", + "def _on_u1_analizar(_):\n", + " with out_u1_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " if not w_u1_fi.value or not w_u1_ff.value:\n", + " print('Define fecha inicio y fecha fin.'); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " df_comp, resumen, incons = analizar_descargas_uno_a_uno(\n", + " str(w_u1_fi.value), str(w_u1_ff.value),\n", + " progress=bar_u1, log=print)\n", + " _state['u1_plan'] = df_comp\n", + " _state['u1_resumen'] = resumen\n", + " _state['u1_incons'] = incons\n", + " with out_u1_tabla:\n", + " clear_output()\n", + " if 'u1_resumen' in _state and not _state['u1_resumen'].empty:\n", + " display(HTML('

Resumen por TIPO

'))\n", + " display(_state['u1_resumen'])\n", + " if 'u1_plan' in _state and not _state['u1_plan'].empty:\n", + " display(HTML('

Plan detalle (primeras 100)

'))\n", + " display(_state['u1_plan'].head(100))\n", + " if 'u1_incons' in _state and not _state['u1_incons'].empty:\n", + " display(HTML('

Inconsistencias

'))\n", + " display(_state['u1_incons'])\n", + "btn_u1_analizar.on_click(_on_u1_analizar)\n", + "\n", + "def _on_u1_xlsx(_):\n", + " with out_u1_log:\n", + " if 'u1_plan' not in _state:\n", + " display(HTML('
Corre Analizar primero.
')); return\n", + " path = os.path.join(os.getcwd(), 'plan_descargas_uno_a_uno.xlsx')\n", + " with pd.ExcelWriter(path, engine='openpyxl') as w:\n", + " if not _state['u1_plan'].empty:\n", + " _state['u1_plan'].to_excel(w, sheet_name='Plan_Detalle', index=False)\n", + " if 'u1_resumen' in _state and not _state['u1_resumen'].empty:\n", + " _state['u1_resumen'].to_excel(w, sheet_name='Resumen', index=False)\n", + " if 'u1_incons' in _state and not _state['u1_incons'].empty:\n", + " _state['u1_incons'].to_excel(w, sheet_name='Inconsistencias', index=False)\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{path}
'))\n", + "btn_u1_xlsx.on_click(_on_u1_xlsx)\n", + "\n", + "def _on_u1_ejecutar(_):\n", + " with out_u1_log:\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " if 'u1_plan' not in _state:\n", + " print('Corre Analizar primero.'); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " ejecutar_descargas_uno_a_uno(_state['u1_plan'], modo_comp=w_u1_modo.value,\n", + " dry_run=dry_u1.value, log=print, progress=bar_u1)\n", + "btn_u1_ejecutar.on_click(_on_u1_ejecutar)\n", + "\n", + "tab_desc.children = tuple(list(tab_desc.children) + [\n", + " W.HTML('

Descargas Uno a Uno

'),\n", + " W.HTML(_html_u1),\n", + " W.HBox([w_u1_fi, w_u1_ff]),\n", + " W.HBox([btn_u1_listar, btn_u1_analizar, btn_u1_xlsx]), bar_u1,\n", + " out_u1_log, out_u1_tabla,\n", + " W.HTML('

Paso B - Ejecutar uno a uno

'\n", + " '

NATURAL excluye facturas con faltantes; '\n", + " 'DIRIGIDA inserta lo que se pudo y registra en SDescargaM espejo + APLICADESCMANUAL=S. '\n", + " 'Corre primero con DRY_RUN activado.

'),\n", + " W.HBox([w_u1_modo]), W.HBox([dry_u1]),\n", + " btn_u1_ejecutar,\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# ===== Reintegro de descargas =====\n", + "upload_re_inicial = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir lista MP')\n", + "btn_re_plantilla = W.Button(description='Descargar plantilla', button_style='info', icon='download', layout={'width':'220px'})\n", + "w_re_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_re_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "btn_re_listar = W.Button(description='Listar partidas EXPO afectadas', button_style='primary', icon='search', layout={'width':'310px'})\n", + "btn_re_export_lista = W.Button(description='Exportar lista a Excel (a llenar)', button_style='', icon='file-excel', layout={'width':'280px'})\n", + "bar_re = _mkbar('Reintegro')\n", + "out_re_log = W.Output(layout=OUT_STYLE)\n", + "out_re_tabla = W.Output(layout=OUT_STYLE)\n", + "\n", + "upload_re_lleno = W.FileUpload(accept='.xlsx,.xls', multiple=False, description='Subir Excel lleno')\n", + "btn_re_analizar = W.Button(description='Analizar reintegro', button_style='primary', icon='check', layout={'width':'240px'})\n", + "btn_re_plan_xlsx = W.Button(description='Exportar plan a Excel', button_style='', icon='file-excel', layout={'width':'240px'})\n", + "chk_re_sdm = W.Checkbox(value=False, description='Ajustar tambien SDescargaM (espejo)', indent=False, layout={'width':'380px'})\n", + "dry_re = W.Checkbox(value=True, description='DRY_RUN (simular sin escribir)')\n", + "btn_re_ejecutar = W.Button(description='Ejecutar reintegro', button_style='warning', icon='play', layout={'width':'260px'})\n", + "out_re_plan = W.Output(layout=OUT_STYLE)\n", + "\n", + "_html_re = ('
'\n", + " 'Reintegro de descargas'\n", + " '

'\n", + " 'Reduce la CANTDESC de descargas existentes y devuelve el saldo al IMPO. '\n", + " 'Sube primero un Excel con los NUMPARTE_MP, UNIMED, FRACCION y define '\n", + " 'el rango de fechas (FECHAFACTURA de las facturas EXPO). La utileria lista las '\n", + " 'partidas EXPO afectadas con su CANTDESC actual y una columna vacia para que '\n", + " 'indiques la NUEVA cantidad que debe quedar. La nueva cantidad debe ser '\n", + " 'mayor que 0 y menor que la actual. La reduccion se distribuye '\n", + " 'proporcionalmente entre las descargas reales que cubren esa partida.'\n", + " '

')\n", + "\n", + "def _on_re_plantilla(_):\n", + " with out_re_log:\n", + " clear_output()\n", + " bts = generar_plantilla_reintegro()\n", + " path = os.path.join(os.getcwd(), 'plantilla_reintegro_mp.xlsx')\n", + " with open(path, 'wb') as f: f.write(bts)\n", + " display(HTML(f'
'\n", + " f'Plantilla generada
'\n", + " f'{path}
'))\n", + "btn_re_plantilla.on_click(_on_re_plantilla)\n", + "\n", + "def _on_re_listar(_):\n", + " with out_re_tabla:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " if len(upload_re_inicial.value) == 0:\n", + " print('Sube el Excel con los MPs primero.'); return\n", + " if not w_re_fi.value or not w_re_ff.value:\n", + " print('Define rango de fechas.'); return\n", + " try:\n", + " if isinstance(upload_re_inicial.value, dict):\n", + " fname = list(upload_re_inicial.value.keys())[0]\n", + " fb = upload_re_inicial.value[fname]['content']\n", + " else:\n", + " fb = upload_re_inicial.value[0]['content']\n", + " tmp = os.path.join(os.getcwd(), '_upload_re_inicial.xlsx')\n", + " with open(tmp, 'wb') as f: f.write(fb)\n", + " df_mp = cargar_excel_reintegro_inicial(tmp)\n", + " print(f'MPs en Excel: {len(df_mp):,}')\n", + " except Exception as e:\n", + " print(f'ERROR Excel: {e}'); return\n", + " df = listar_partidas_para_reintegro(df_mp, str(w_re_fi.value), str(w_re_ff.value),\n", + " progress=bar_re, log=print)\n", + " _state['re_listado'] = df\n", + " if df.empty:\n", + " print('No hay descargas que coincidan.'); return\n", + " print(f'Descargas afectadas (agrupadas por factura+linea+MP): {len(df):,}')\n", + " display(df.head(200))\n", + "btn_re_listar.on_click(_on_re_listar)\n", + "\n", + "def _on_re_export_lista(_):\n", + " with out_re_log:\n", + " if 're_listado' not in _state or _state['re_listado'].empty:\n", + " display(HTML('
Corre \"Listar\" primero.
')); return\n", + " path = os.path.join(os.getcwd(), 'lista_reintegro_para_llenar.xlsx')\n", + " exportar_excel_listado_reintegro(_state['re_listado'], path)\n", + " display(HTML(f'
'\n", + " f'Lista generada (llena la columna CANTDESC_NUEVA)
'\n", + " f'{path}
'))\n", + "btn_re_export_lista.on_click(_on_re_export_lista)\n", + "\n", + "def _on_re_analizar(_):\n", + " with out_re_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " if len(upload_re_lleno.value) == 0:\n", + " print('Sube el Excel lleno primero.'); return\n", + " try:\n", + " if isinstance(upload_re_lleno.value, dict):\n", + " fname = list(upload_re_lleno.value.keys())[0]\n", + " fb = upload_re_lleno.value[fname]['content']\n", + " else:\n", + " fb = upload_re_lleno.value[0]['content']\n", + " tmp = os.path.join(os.getcwd(), '_upload_re_lleno.xlsx')\n", + " with open(tmp, 'wb') as f: f.write(fb)\n", + " df_xls = cargar_excel_reintegro_lleno(tmp)\n", + " print(f'Filas con CANTDESC_NUEVA: {len(df_xls):,}')\n", + " except Exception as e:\n", + " print(f'ERROR Excel: {e}'); return\n", + " plan, incons = analizar_reintegro(df_xls, progress=bar_re, log=print)\n", + " _state['re_plan'] = plan\n", + " _state['re_incons'] = incons\n", + " with out_re_plan:\n", + " clear_output()\n", + " if 're_plan' in _state and not _state['re_plan'].empty:\n", + " display(HTML('

Plan de reintegro (primeras 100)

'))\n", + " display(_state['re_plan'].head(100))\n", + " if 're_incons' in _state and not _state['re_incons'].empty:\n", + " display(HTML('

Inconsistencias

'))\n", + " display(_state['re_incons'])\n", + "btn_re_analizar.on_click(_on_re_analizar)\n", + "\n", + "def _on_re_plan_xlsx(_):\n", + " with out_re_log:\n", + " if 're_plan' not in _state:\n", + " display(HTML('
Corre Analizar primero.
')); return\n", + " path = os.path.join(os.getcwd(), 'plan_reintegro.xlsx')\n", + " with pd.ExcelWriter(path, engine='openpyxl') as w:\n", + " if not _state['re_plan'].empty:\n", + " _state['re_plan'].to_excel(w, sheet_name='Plan', index=False)\n", + " if 're_incons' in _state and not _state['re_incons'].empty:\n", + " _state['re_incons'].to_excel(w, sheet_name='Inconsistencias', index=False)\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{path}
'))\n", + "btn_re_plan_xlsx.on_click(_on_re_plan_xlsx)\n", + "\n", + "def _on_re_ejecutar(_):\n", + " with out_re_log:\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " if 're_plan' not in _state:\n", + " print('Corre Analizar primero.'); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " ejecutar_reintegro(_state['re_plan'], dry_run=dry_re.value,\n", + " ajustar_sdm=chk_re_sdm.value, progress=bar_re, log=print)\n", + "btn_re_ejecutar.on_click(_on_re_ejecutar)\n", + "\n", + "tab_desc.children = tuple(list(tab_desc.children) + [\n", + " W.HTML('

Reintegro de descargas

'),\n", + " W.HTML(_html_re),\n", + " btn_re_plantilla,\n", + " W.HTML('

Paso 1 - Sube el Excel con NUMPARTE_MP, UNIMED, FRACCION y define el rango de fechas:

'),\n", + " W.HBox([upload_re_inicial]),\n", + " W.HBox([w_re_fi, w_re_ff]),\n", + " W.HBox([btn_re_listar, btn_re_export_lista]), bar_re,\n", + " out_re_tabla,\n", + " W.HTML('

Paso 2 - Sube el Excel con la columna CANTDESC_NUEVA llena:

'),\n", + " W.HBox([upload_re_lleno]),\n", + " W.HBox([btn_re_analizar, btn_re_plan_xlsx]),\n", + " out_re_plan,\n", + " W.HTML('

Paso 3 - Ejecutar reintegro

'),\n", + " W.HBox([chk_re_sdm]),\n", + " W.HBox([dry_re]),\n", + " btn_re_ejecutar,\n", + " out_re_log,\n", + "])\n", + "\n", + "\n", + "\n", + "\n", + "# ===== Diagnostico Paso 12 (KGS): por que no se descargo =====\n", + "w_d12_fi = W.DatePicker(description='Fecha inicio:', layout={'width':'260px'})\n", + "w_d12_ff = W.DatePicker(description='Fecha fin:', layout={'width':'260px'})\n", + "btn_d12_run = W.Button(description='Analizar facturas pendientes', button_style='primary',\n", + " icon='stethoscope', layout={'width':'320px'})\n", + "btn_d12_xlsx = W.Button(description='Exportar diagnostico a Excel', button_style='',\n", + " icon='file-excel', layout={'width':'280px'})\n", + "bar_d12 = _mkbar('Diagnostico')\n", + "out_d12_log = W.Output(layout=OUT_STYLE)\n", + "out_d12_tabla = W.Output(layout=OUT_STYLE)\n", + "\n", + "_html_d12 = ('
'\n", + " 'Diagnostico Paso 12 (% KGS)'\n", + " '

'\n", + " 'Revisa las facturas EXPO pendientes (estatus NA) y explica por que el Paso 12 '\n", + " 'no las pudo descargar. Motivos posibles: TIPOFACTURA NODES, SIN_PARTIDAS, '\n", + " 'PT_SIN_BOM (producto sin receta), PESONETO_CERO (la partida no tiene peso), '\n", + " 'COMPONENTE_SIN_SALDO (no hay saldo IMPO suficiente). Es solo lectura: '\n", + " 'no escribe en la base.

')\n", + "\n", + "def _on_d12_run(_):\n", + " with out_d12_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " fi = str(w_d12_fi.value) if w_d12_fi.value else None\n", + " ff = str(w_d12_ff.value) if w_d12_ff.value else None\n", + " df_bloq, df_comp = diagnosticar_paso12_pendientes(\n", + " fecha_ini=fi, fecha_fin=ff, progress=bar_d12, log=print)\n", + " _state['d12_bloq'] = df_bloq\n", + " _state['d12_comp'] = df_comp\n", + " with out_d12_tabla:\n", + " clear_output()\n", + " if 'd12_bloq' in _state and not _state['d12_bloq'].empty:\n", + " display(HTML('

Resumen por factura (motivo principal)

'))\n", + " display(_state['d12_bloq'].head(200))\n", + " if 'd12_comp' in _state and not _state['d12_comp'].empty:\n", + " display(HTML('

Detalle por componente faltante (primeras 200)

'))\n", + " display(_state['d12_comp'].head(200))\n", + "btn_d12_run.on_click(_on_d12_run)\n", + "\n", + "def _on_d12_xlsx(_):\n", + " with out_d12_log:\n", + " if 'd12_bloq' not in _state:\n", + " display(HTML('
Corre Analizar primero.
')); return\n", + " path = os.path.join(os.getcwd(), 'diagnostico_paso12.xlsx')\n", + " exportar_excel_diagnostico_paso12(_state.get('d12_bloq'), _state.get('d12_comp'), path)\n", + " display(HTML(f'
'\n", + " f'Excel generado
'\n", + " f'{path}
'))\n", + "btn_d12_xlsx.on_click(_on_d12_xlsx)\n", + "\n", + "tab_kg.children = tuple(list(tab_kg.children) + [\n", + " W.HTML('

Diagnostico de facturas pendientes

'),\n", + " W.HTML(_html_d12),\n", + " W.HBox([w_d12_fi, w_d12_ff]),\n", + " W.HBox([btn_d12_run, btn_d12_xlsx]), bar_d12,\n", + " out_d12_log, out_d12_tabla,\n", + "])\n", + "\n", + "\n", + "# ----- Tab BOM: Analisis de composicion + sustitutos + saldos -----\n", + "out_bom_log = W.Output(layout=OUT_STYLE)\n", + "out_bom_resumen= W.Output()\n", + "out_bom_tabla = W.Output()\n", + "bar_bom = _mkbar('BOM')\n", + "tx_bom_pt = W.Text(value='', placeholder='filtro NUMPARTE PT (contiene)', layout={'width':'260px'})\n", + "tx_bom_comp = W.Text(value='', placeholder='filtro COMPONENTE (contiene)', layout={'width':'260px'})\n", + "chk_bom_prob = W.Checkbox(value=False, description='Solo con problemas (sin saldo vigente)')\n", + "btn_bom = W.Button(description='Cargar analisis BOM', button_style='primary', icon='sitemap', layout={'width':'260px'})\n", + "btn_bom_filtrar= W.Button(description='Aplicar filtros', icon='filter', layout={'width':'200px'})\n", + "btn_bom_xlsx = W.Button(description='Exportar a Excel', button_style='success', icon='file-excel-o', layout={'width':'200px'})\n", + "\n", + "def _bom_render_tabla(df):\n", + " with out_bom_tabla:\n", + " clear_output()\n", + " if df is None or df.empty:\n", + " print('Sin resultados.'); return\n", + " display(HTML(f'

{len(df):,} filas (PT-componente)

'))\n", + " display(_fmt_an_df(df.head(500)))\n", + " if len(df) > 500:\n", + " display(HTML(f'

(mostrando primeras 500 filas de {len(df):,} -- exporta a Excel para ver todas)

'))\n", + "\n", + "def _on_bom(_):\n", + " with out_bom_log:\n", + " clear_output()\n", + " if not CONEXION_OK: print(CONEXION_MSG); return\n", + " print(f'(DB actual: {DB_ACTUAL})')\n", + " t0 = time.time()\n", + " df = analizar_bom_completo(progress=bar_bom)\n", + " _state['bom_full'] = df\n", + " print(f'Tiempo total: {time.time()-t0:.1f}s')\n", + " with out_bom_resumen:\n", + " clear_output()\n", + " display(HTML('

Resumen por estatus de cobertura

'))\n", + " res = resumen_bom(df)\n", + " if not res.empty: display(_fmt_an_df(res))\n", + " _bom_render_tabla(df)\n", + "\n", + "def _on_bom_filtrar(_):\n", + " with out_bom_log:\n", + " if 'bom_full' not in _state:\n", + " print('Corre primero \"Cargar analisis BOM\".'); return\n", + " df = filtrar_bom(_state['bom_full'],\n", + " pt_filtro=tx_bom_pt.value.strip(),\n", + " comp_filtro=tx_bom_comp.value.strip(),\n", + " solo_problemas=chk_bom_prob.value)\n", + " print(f'Filtro aplicado: {len(df):,} filas')\n", + " _bom_render_tabla(df)\n", + "\n", + "def _on_bom_xlsx(_):\n", + " with out_bom_log:\n", + " if 'bom_full' not in _state:\n", + " print('Corre primero \"Cargar analisis BOM\".'); return\n", + " df = filtrar_bom(_state['bom_full'],\n", + " pt_filtro=tx_bom_pt.value.strip(),\n", + " comp_filtro=tx_bom_comp.value.strip(),\n", + " solo_problemas=chk_bom_prob.value)\n", + " path = exportar_excel_bom(df, 'Analisis_BOM.xlsx')\n", + " print(f'Excel: {path}')\n", + "\n", + "btn_bom.on_click(_on_bom)\n", + "btn_bom_filtrar.on_click(_on_bom_filtrar)\n", + "btn_bom_xlsx.on_click(_on_bom_xlsx)\n", + "\n", + "tab_bom = W.VBox([\n", + " W.HTML('
'\n", + " '

Analisis de BOM (composicion + sustitutos + saldos)

'\n", + " '

Cruza SMatBOM con SPartesSustitutos y SSaldoTem '\n", + " 'para mostrar, por cada producto terminado, sus componentes, sustitutos disponibles y '\n", + " 'cobertura (vigente vs vencido). Util para entender por que una factura EXPO no descarga.

'\n", + " '
'),\n", + " W.HBox([btn_bom, bar_bom]),\n", + " W.HTML('
'),\n", + " W.HTML('Filtros:'),\n", + " W.HBox([tx_bom_pt, tx_bom_comp, chk_bom_prob]),\n", + " W.HBox([btn_bom_filtrar, btn_bom_xlsx]),\n", + " out_bom_log,\n", + " out_bom_resumen,\n", + " out_bom_tabla,\n", + "])\n", + "\n", + "tabs = W.Tab(children=[tab_conn, tab_desc, tab_an, tab_nlp, tab_kg, tab_bom, tab_ctm, tab_sv, tab_datastage, tab_estructuras, tab_valores])\n", "tabs.set_title(0, 'Conexion')\n", "tabs.set_title(1, 'Descargas')\n", "tabs.set_title(2, 'Analisis Saldos')\n", "tabs.set_title(3, 'Sustitutos NLP')\n", "tabs.set_title(4, 'Descarga % KGS')\n", - "tabs.set_title(5, 'CTM')\n", - "tabs.set_title(6, 'Saldos Vencidos')\n", - "tabs.set_title(7, 'DataStage')\n", - "tabs.set_title(8, 'Estructuras SCAII')\n", - "tabs.set_title(9, 'Valores')\n", + "tabs.set_title(5, 'BOM')\n", + "tabs.set_title(6, 'CTM')\n", + "tabs.set_title(7, 'Saldos Vencidos')\n", + "tabs.set_title(8, 'DataStage')\n", + "tabs.set_title(9, 'Estructuras SCAII')\n", + "tabs.set_title(10, 'Valores')\n", "display(header, tabs)" ] }