From 1d6470c7701117eed56245d9a7ff7061ddc338bd Mon Sep 17 00:00:00 2001 From: b17301 Date: Tue, 28 Apr 2026 12:22:20 +0900 Subject: [PATCH] Refine process cost metrics and project search dropdown behavior --- data.db | Bin 236142592 -> 236146688 bytes main.py | 232 ++++++++++++++++++++++++-------- templates/process_cost.html | 260 +++++++++++++++++++++++++++++++++--- templates/projects.html | 10 +- 4 files changed, 428 insertions(+), 74 deletions(-) diff --git a/data.db b/data.db index 4c3c4c8c61855c12f64478deb636668bf37647ff..8e18fb9586c39e0c9c187d58af619581d54d3bc6 100644 GIT binary patch delta 50675 zcmaLg34Bav+xT&rL?jU-iP*Q;_lSrsG-9dRcWMb@i;$Gm))dtcMN#`!)l4i!t+A(; z3>vDo#!^+3SZgbas%onL>t{Ugf9~s@_jx{_``bJ}=iKManVB<-%t%T)nUIuvGGT6U z@A0{EU8|ET*W$_Z$~SZyzcu<|4L6Sk4TD!&b~aydHMo#x&zt4F-P+rxma1Cc`4bU51B@+%i06dR;#BV0y98I5GTB_l#cV;PY$n#gD>qnV86 zGTxTaLdH8XTFPi8qqU4SGTO>$C!@WL4l+8*=p>`Fj4m>|%IGGeyNq{b^pMe0MlTt? zWkkv7Bcrd3elntE^p`O}#y}Z^WDJ%OBV&k+SQ$fQ43iNjBVNXPGKR|-AtOP?`!Yt# z7$sx0j4?9C%J@J=qKpq^jFT~5#snE3$(ShPV;M;@CdrsABU#228K20QDr1_A=`uc* zF+;{o8M9=3CS$gYIWp$Tm?vYtj0G|l%2*_0v5X}$mdf~C#xfaS$XG7pOBpFLY%)@1 ztdOx%#wr=BWqc)Ljf}N2*2(x<#(EhWWNehNNycUwTV$lk*eYY2jO{XZ$k-`kmyF#q z(q()jV~>ozGWN;XFC#;SUB&?!2W1?R@vV$Z8HZ&Yk@203qcXmiaZJW>87E|%lyOSN z4>Gc3oR)D$##tHXWc(=Oyo{e@T#)gzjEgcZ$+#@z7a3P%T$Pb606{Maw;%!JM&M5@8BqW569p*oPd*X3VwhrI1OjuES!TM;XM2V7vN{O z2$$e8`~p|tDrCboxDGem>~e~8>(JAueo#ngc%53|q4n&er{8p&?&VxO=iPK$mfN|k zUwqT8wa=T zZcw3z4l{Jvr^7=Xf;QfC>*c)J=q+)U;d7oy7H+|9_!aKJUAPDL;Q{;x58)9!hTq-n zPc}YrE4s?9oZYwMQ@0D&k4q;X@Rw)r(~|A&bLCoeGTH7f&*wvvcU6~|n4D2x{$Kgz zt;6KcP9*RDK>m}JyuWU)Tov~w?;9h3Rwj90nEWR!dAqD$v2gO!D!d3RM= zw082Yq4J-a$!QJp|L|sw#fy@6wv}C5n7mVVd(rpFJG#q%GLv@{kR^kYw@bWMBY9gp z`A@CnZFyzMDal)F%m1sBoF=>WW|nIq$y*{NR!{caBA2(~2K$|UPu-HOZewln!=JjX zExN9NN7170Jwxk+gx2jD8Ws{7lI!Oxvfw^j^%4Ae+Uq~>C4XM8ZZG-sklML^x{)ha zg%JA#`SbYv<*x<#x-&uTkOp;g{rtVGy5AAPLTcB|b+L^s8)}>I@l&_4ZoBPI<-hLE zceIpAPyes;O5G)$hDw0&QsgRN$Bf*0c_VlF&D7~PF10Wgbe2$woX&KGg5-{W^Ai zb`HV+cD6K{Jy(v(+-Ghrua-OV<`z_|cD-B|o69YE%$r+Kt&q@MzsSwE;zx7hM*QI> zJcmEw1^fjs;cv)+f8Z7T>ozlPghhUE0}Hr=2jm7%$OB&B4L*<;d?6oLAwT$mKLkJl z2!w)A2ns_HCpfXf}s!$E8Lk$Rqnh*lDAQWmt z7}SBfP!H-u0|vg!SgEd}F8mYrb z9aigbUI(xD)TH4$#OsivLlz;>)h%teU)z-GZnpX()U7O9f^l}7uS2E^bDm2O+CvA6 z{rQNFmM&-9>e*U1?PO`}RWGPkoq9p?I&q%--}}C9VRJs}(8*%|s6%JVv=A?A{@pyG zoo4e*>TD_Imf!9lGSq90yFDp;sO4eqH~(1@lHI}2<66~8#qIm&bhb2$-q^+BXAek7 z&mI21mdv_;Z*z$J@#Y{QBVm|jy>VOK@%}LL;4~I zj;hBv6&mRR80;kgCH49sE94bF|bUNr!YoF;}OmelcUljr(wRWEacq$gVQF z$>=WQT^T)O^pw#{MsFEWGWy8qE2E!`XiG+9e@jpqr;BWJ9Ephj{Vl_t^DYU-?bWed z@PPi77S78}lwjNv(j*vX0>_HI2D+T@jAR#AjCGL9+}d@X>N+2i9O}HUuOzs*Ok)T4 zw^VdqZjFS&&cSgJs>JlSl>Fa$kmQxl*>6aH^ZwLQha?@+B^Y~FBDTMI-*@c73&}1H zSJ+T>NfRWve*4g8xh+Jlp(?hYWRc>|yPd;2&LMc13eg0YZ%XDJYwO>APM%a-M4Z~e z7zxG`ne!6nJ8x!WyxPoa9nS0E^`2TKT!(laQgq1D!7^M;8m2>x3ihQEV#{Rsb+ODc zUV$$z(8YZD5E0nLJhllEj4S+{Ucyt}k2UT`%OyBpKlW1+${HuDmxa_ia|@g6*beFN zOoxg^)G9r6n4!Zy3C6B=E-G`lyrKA%xB0JZO!~k!%yX!v^y^)y{`%5ZDbuHVP}zYa#ta`_HasZwBNvyz zK0wx4*)y+4{uR0M-_3tI|2OiiJnd7DxyJE)n44$i6>Vv=+gW`5oDP6TVfWf@wu5_y zS_;g`8Zp#j%NjAv60-Tg5Q|?*$9-Id#&LNrp^@{6qfv}nB~`-zI!}z0YkiQ{jNRE&w~TYM z^@vrwlO(}7rxhBiw%byIv1c1~@EaEEZM+fd6Q|~Aq{DC>Qgt|^gGaoYw628e&Icr3 zg7Fx9S%UG5Kk&U+?-b`%_DL{~@M&A=)jFKl!E2M6G+c*x9a412(!sJ>O&X>{j1Eh6IHp664#8X0R-$#7 zuR|um#pxt|@a8NzLD>U>CRl!SJf--bx8m%nD|WhdaLb66Cm??NJ>o2{`fagfv$Q2a z{!i(Y)Bvl8z0l+Ko1KLxQ(x#xTm^Ia8Z zo(UX}tUV+fr$a|xD7m8Z>X~}=9331hwp{IUzA2K8`F2P!PD~GUaLiZ#E0^<)mTauP zQHPs4lvtxy>8gWci>Z=b9FfbCjo0(j*Q)sr=rcFCF%^ z%SjSk=Z&uO9oM=1dY5xYyUxp9=L@cL;0Bj-cXXX+xz0yjXOE5QMiwT)cqnrm(sao# zuHk2rjlb>-mKS)&>b&AJp8bVwR^gZq(ekS_vs|A)qT}Qp__;zJA9+>Q*0R8sCo9fU z$s#`l%MZOQmV@@`@kme1kOgm`!lhJ*Zt z`=c`n@IH)$Q7{_Dz*zVI65&G_2jgJ^d;}BWV@QHYFd33z3VZ@nVH!+_Phkekgjw(z z%!WBI7v{lySO5!Q5iEu!uoOOrW$*)~oU?r@A)$kRpfwiy>zJ~R%0XD)W z*bG}B4YtBI*bX~jC+vdVkPhF#9@q=}U_WGl9S*=jI0WB9CLD$%@Esh5@8K96hZArT zPQee51*hQ*oP~4nBb-fm#p>wIK}ZKwYQ@^`QZTLqli;Z$Sh!hDc}vO`#byhqs{xyaO$v6|{yn z&=%T3d*}cip%Zk5F3=UaL3el;dO%O;1-&5(`aoak2hq?U2EafV1cM<4hCnO~g<%i} z@$eoDhY^qf@54wK1*2gMjD-&%5k7=*FdinrM=%jSh9sB-lOY+Vz$Y*jronXh6lTCo zm<6A~Y?uRcVIItf1+Wkn!D3hfOW|`^24BE(_!3gU2C1+DR>CS+4PU_;SPSdmYgi8( zU?XgT&9DX1U@L5c?XUxO!YF^EgfxWN~_Cp5P;Q$n0<@Bt*khcFJt!vy#UCc?*%1e0JgB*PT=1g63?m=2%9 z444VC;4_#Fb6_sagZZ!k7Q!M}3`<}sd=AUt3s??cLJHU*6;{AXSOu%$D_8?-VI6!8 z>tO?IgiWv+wm=$eg>A4McEC>91-l^~zJWck7xuw^$N)PWfP-)dzJ*LU3`gKQI11mx zF*pt<;3S-aA0P`(!x=aW=io;;4?n>L_!%z3CAbW~z!kU(*>DZ6!wt9zx8OGX3U}Zx z+=Kh@0Dgmq@CY8m@9+ek!ZY{-p2MH;0{()R@HgbZKky3v^^ntFZu!9tEZ`0vkQ+Q9 z4|st$_&{Fpg?wOz{NM-v5C8=r5DG#eC=5lQC=`R@Py$LqDJTs=PzK6EIVcYmpdwU) z%1{NWLN%xkH6R#jLI~7?P^b-IPzUNlJ*W>2ARHP(BX|oUpfN;36KD#}pgFt^E#Mt! z39X@G&I8B$y1zFa2F=06KsYpkOo^}8*GOiuoHH{Zb*l3U=QqteXt)gzzzrCARK~kArlV65%>;{ z!uN0tj>8E!38&x($b!>w2F}7c_z}*-PjCT#hKq0sF2gTy1+GFiT!ZUy18%}CxDCI; z9k>hk;66Nn-{2uUg2(VXJb|b14E})U@F%>0zu+bO4LR@+yn=so%jwTkesBW|xPu4e z22aQXUf>NrkQaO*A6Owj_<=tJKmiDZf=~zwLlGzn#h^HpfRa!ON<$EofwE8z%0mUH z2$i5RRDr5c4XQ&82!@&v0<|C%YC{;*fx1u+>O%tvhlbDy-hv2d43W?TnnE*Z4sSyX zcn4ZSD`*XEpe?k6_Rs-3LMP}9U7#y;gYNJy^njkw3wlEo^nt$652B$z41j?!2nIt8 z41rh}3d0}{;^93Q4kI7|-iMJe3P!^i7z-ajB76wrU_4BKk6x4N_qRtb|pt8oq)x zuol+A*RUQoz(&{vn_&y2!B*G?+hGUngk7*3(%~D}1AAc~?1v1n!vQ!5hu~Ytgu`$I zzJsIiJsgAMZ~{)kDfj`h;53|pvv3Z6g!AwdT!5e9B3y#Y@C#gltB?)X;5yuZn{W$m z!>@1$?!rB|4-eoscnFW+G5iis;3+(VKj1n12`}I;cnN<)4*UbJ;9pNU{pFD#+`t0v z-~qY86Y_u;c!Lk*1z*SqR>%*2;12;%00N;P6oSG~1d2j2C=Ml{B$R^E5CmnQER=)t zPys4JC8!Klpej^@>QDoMp(cbtEeM6$5C(OiF4Tki&;Y`rAvA)wAOadgBs77h&F z!4LyOAQpzgFo=VAcn^ld2uOhUVI+)#(J%(a!UvEDAHp~o4-?=cm~>XtcMM-5jMeQ*aB&=6}G{4*a16X7wm>~_y+dCUf2iwAp`7i01m<-_!ctZ zFdTvJ;3#|#$KW`efRk_vet;}E4QJpioP!_XJp2R~;Agl9m*6t|0$1QFWWzPM4maQ? z+=AQiE8Ky*a1ZXo1NaRd!XtPLzrz!F3eVsVcn*KU3-}9O!rzbs|G+EwH;@`E4vLjV+jKqv@>pfD7HqEHNqLkTDerJytfK^Z6u z<)A!NfQnEFDnk{h3e})G)PP{92_aAmLZLQ?I^`Jg9fN*FCjo>YafW{CBO`s_> zgXZuyw19V@CA5Op&<5H$ALO19R??Mmg3B8~%jDfN60VKkQFb>AU1o#Lh!pD#VlVCC= z!xZ=grouFs4xhpdmIx5_Wdyd>z@?z&*cp$+%7Q0mFsKaC( zcIa?hha$Vxq^)(Bpu=VzvI#CfQtBO)Yl(lzf*))T)76D8^o_bs#z`|he~I6^PfZ$0(6_kP-?No(tmFG|n(tQ^E?$ChgUgcOdJg$4 zwd2!H5%NJG<5tTjgEE|VHQ289ELuWp#^cVGFyo_T5l=gtzYjT9f^lfqNicqQE#g^c z^Y;x`N-$PAC&Ad8<$tI!NIp$vO!`uS@u`c*Kh=&UN-%z@?v7p}=tXDq_wag2Fz%T1 zbvPy=#re36{7bE}UIqJg2`!D^vJLyYvt^s}B#$K++pV0VLbMJ`B^Z~^>mPMc!zCD> z{s?=emWa_|sSd|<$kBlhh8efU`4WsPH~4O2yiXfP>9T(x z^FC`0P$5!>kvgo_;k*Rnpkx)0NnPFnJy!nb&J);2-FYM!7jdZs;~?bdC4vj~G0y~< z5{x;X>rlCnTB4T@vvlBOU&wY&vhkUym#&rzv_%$H`*dE1_#*0P#S~Thv{Zs|&y6mo zCVeg;%<0B%4=dis+hSbPk@9_l#^GBn;f&Kdwxp7>U6&66)=>Fgf#^g_L)%MPrj*l4 z_Ohk=crP_Jlv!G)ad}hB=osbgVR2g9X6>v_vm+%K*Vt+u&g+}1*Zu}Y!@ZA>abpdaj>jWYK}-9M(VIyhx0mk^-+_C z>kzL)iVF5D3Fb$bgZrsjqjlhcB*K;@xkLdM-#Z~+MKj&AtNIkn68RL%>3w~?)rVNz z)JbZG4!3nEVo|HK)?tDUn{~+6A;4Wt+FXaRI;_)y=S~r}A|C3D9wEWF!6oRhQU&`t z3HyvQ+_F4sk`p?-(jml4tujD|g*qJ3;e`%Wz15^qI?UGLfDTV|n0}EjcpKoO4%~DJ z#(~?T!vhJ%y{uziS;h5ZPmp{+*nyMozq{Fz@|h1lwsjJW4PDg1@yaa7+Q7 z7{_D24##w`oL8%a>o8J>^*UVF!EyTu{>k6!{5M&V{9Xj0Bp-4S7X=@!O=&)Ib zYzfApY+heYI#!2uI$V@syy#omK+WMeDtB})7VdJnXvxMtIaa?Qxsh>D8>-biN^r5_ zQN7$t9UL2oY~=E)pWr&DyUx#C=a9G5z7EjAu@CDc8>h{%2(@C24oh`7rbCVn!Hw0V z(K^i6A(PbHByLh~K?0V_g$?Ao|G94VVW=l4n#gs@^ zE4J6cvEo9>gPdj zYLH}O#mj0ryLFnpkzrgpC8n!a^4laB8@nmN`TlI*_-T~)Tw@Z)3*RX-)eGPC5{%1d zoh2J{{ZPBk@qYH$&(v{EkzmYWnJxS0at`acH*d)$$Rfs-vy^q5kLWQ8&a>Ks=R|p* z37Q?!$9r~2Um5*mM9b(eV}Oi-G6u;QEF;EybI4F{`PR}tLdXyyTF3|ru&P&@Wdh7+ z2|IMSEuo|Henpm*Rb0NBa4#W$osn>}zb#u9F)p0rx-U^KzMN@~Tv5{wn+O88$Z z7P9$N4lth#hDb1096)e+ zJlBCox<=_cQ#y$6Zb#oD$R}B@yB^d9ChXt!8VkBfZ-}HiOs>9E> z4-8*C*UQ81ku=0RXU^+y9w{f^HsB@y9z-qqhb+#@_X=;3ulgS?KcA6tT>k$a8N2=c z`hDfYI_+^vrdl9W2gh}l zDA~B~9eIcA{MdD_emKtJ;z?q*WaH%BhN5L%HOL=d+9JshYTGa>rnQXnzXA9Q*}t!;hqHJt@(||<196tZZU7p zVSh?)>YSU&s~_X}1J{x9OyoBS&UZ>%>60pSlJK8@wdwW0zqCWbe&?->KNV-0;~WBi zP+tPmR>A=1CDK{qzkFqHMr5qFCCFw839uU9N`FFv@$Q00EfpH+Fj|L=D%fvIF#k1z zmbKMMWt2N}a zS2~2$Q(GCJ!$KX7=hL(REJ{{#yKC;_KgFqOaIHMX>&rX_koPaA>NIQNA(s>hL}(46D1fA zgqI~4@4FmoDyz7Bdgs3I&1;8{W<$)^4m~6o=j#~~jLkgMO9VClzc%A>=k@nnSGG-+ zMM^jygpI7@9Ii-c?;PsBJ;eOW;NcQNoR?Uqm&lf2{2EBd7HW>^5{zF*i+xA!$1(}V z1K4>9#^n!hImG0%s0{*4!7 zPb1sP5-zWQ-|RQn|7Kal`6-_Lyo4zp&WCwWCv|D6cUBwjtHWG^%hz?e+sf;@)QNj- zGrE`?wr!JOT%4Oa6zZy$XsN?E9X9H4MF+ob|JSa1#sAM=XhpuOb~aXmv9rr`IHAKU z9YT7@94@aNudFxMPKqqzeC^ndNHBi$E2^iu&JRd%J*&_BH?s!!QnPlKU|gE%5{z5Y z1HDA4-fBm7NHFHOtwWJ0wM1(jCg`wPhin}J`lv~p>o8V_bt>2|N~mI);V=Rc;C&bgqhK_QfwAxbB*KR<4#vX-_y{J#$B+b*U@|1b6!-+D!Zer;pTZ27 z3A5lcm<@AaF3f}ZumBdqB3KMdU@3eK%is%G4qrkF*dP^Fz)DyJtKlnH18ZR&d=2Yi z18js%uo<>M8f=AaupM^5PS^#zAsxPfJ+K${!G6d9I~;(6a0tGIOgIcj;5#@9-@`FD z4kzFwoPr-93r@osI1A_CM>r2Z!3FpkF2W_a48On?xC+^D4X(otxCyu5Hv9^A;4a*Q z`|tpMgNN`49>eeO1fIe(_yeB9pYQ_yf|u|&E6*tesBW|xPu4e22aQX zUf>NrkQaO*A6Owj_<=tJKmiDZf=~zwLlGzn#h^HpfRa!ON<$EofwE8z%0mUH2$i5R zRDr5c4XQ&82!@&v0<|C%YC{;*fx1u+>O%tvhlbDy-hv2d43W?TnnE*Z4sSyXcn4ZS zD`*XEpe?k6_Rs-dUmuzfvGSJ zro*Q&17^Z3_zY&l9GDC9U_LB>g|G+~!xC5upTjcv0+z#5lc3f90{ zSO;Ijde{IPVH0eIEszFVVH<3R9k3I2!EQ*0Z(tAXg?+FeGQbW8;2<1=Zy^&7!x8un zj>7kF435JII0>iV2grica0br8IrtII!%uJleuj&12`N;2Ju9og7!Ptyr z*0Xv!N6!1MT0KO9@itkk4yig^(7~^Vy0X%Bi0-L&GE0K<^=nV;b>F9j@iO^nZ*$L5 zqSPD-5{zA6r06{Op;I0O8OO8o^r-0gWLNnm|)%2F>AZXaVm)OK1hHp$)W!cF-O=Ku72VouLbKg>KLt z-i02}6M8{!h=M-Q7hYer(frgO2EafV1cM<4hCnO~g<%i}@$eoDhY^qf@54wK1*2gM zjD-&%5k7=*FdinrM=%jSh9sB-lOY+Vz$Y*jronXh6lTCom<6A~Y?uRcVIItf1+Wkn z!D3hfOW|`^24BE(_!3gU2C1+DR>CS+4PU_;SPSdmYgi8(U?XgT&9DX1U@L5c?XUxO z!YF^EgfxWN~_Cp5P;Q$ z0D({t3PE8g0!5)16o(Q}5=ud72!b+D7Ro_+r~nnA5>$pNP!*~{b*KTsP!mF+7KB1= z2!lFM7wSQMXaM2R5E{W-5CM%L5}H6$Xa>#UZD;}SKuc%^t)UIHg?7*$IzUJ01f8J^ zbcJrv9o~f=&=Yz=Z-|0E&=+1`wbA_49|pic7zBeM28KW^425A32l4P842Kbr0Pn*{ z7zLwY42*>jAQ3)b98(<@Bg3Yi6(qJoW zgYB>bcET>$4e9U=?18*ighTKxWWr%M0^h+=_#TeIaX0}d;S~G;S#TQ8 zz*#s4Kf-zV2`<3Ta1k!SW%vcIz*WeGYj7QIz)iRXx8YZ~19#yb+=mD78$5(Z@ECrF zC-4-W!5{D({)89s7rcbOAqW0}SMaZ|oc{934{l%qckqDR;0bxa3%tPx@`5kq11sbQ zKk$bDC;)*_5DGzIC;~;H7!-#RP!dW(X$XQcP!`HTd8hytp%PSvDo_=wL3OAB!B7)I zpcaHeZ3u%pP#5Y!eP{sT&=4BITMz+_ArhKEQ)mXw;caLE??6jv1+AeCw1sxi9y&lr z=medi3v`8U&>h}|9?%ndL2rnHKF}9lU$xQv)E@@GKo|srAqIv(EDVKV5C`$_9t?*O zkO1$)NEij9VGN9g4>%!dWA5Ej8=SOQDob65snz;gH!QoshOumV=XDp(C)!5UZ#>)>lx4;x@3Y=X_O z1=3(EY=iBv19rkL*bV9M4eWuvun+b_2H4>M9E3ygEo8!BI0E0nQTQH?!ErbNC*c(Q z09kMv&cInX2S37j_z5n+&u|eg!DaXbuE15uhHG#gZoo~r1-Ic>xC3|L9^8iq@EbgY zNAMVahbQn9p1~jR9R7qC@E5#Fqi%aGj64&hCG?oV%gxJj!+6;5y%Mor}M8xydd%CrWUB%gugAg7NFA zDLLj6Z{GJzmTX+x>m(R&gLV8z&18#xWiIlcw^098Q#r2I-mdck*ZDa4zx)DA%Hsg* zMdx?RZb~q2oh5!(p{owFbjZ{pM~AQ{YSMTeR_kzChrp+5j8f37Bt(qW+vCv@=mQ_ayxhtWE0)ZwNMC0?jWyXr7Y zhfE!EbO`%PO&YJmY8@`?5cpEf(N>2kI_%TonGV(eR+A3WVYv?Hbnwjyuo{0A@<4*~ z*~S+2j|wMrX!I(;IIY-jN-*Z=nm@q0F(YA!x0~O7w*ppo<2`Y!rGWA4_qIq0&cCc; zKQE!I>(8F%9%i5P?k%^fZnpZl3z#1`h?ZddGD@Zd=Xq_tJk^#RzbT%o^BEl+%N5C^ zHsHu}B^$rda!3cqa$a5qtSy{RcReH+E6$K$T!M#s3CB!f-fG1d9URMTlx+N3kR#u5 zoy+^UTsqoyUhX=Z_lLUP>@7dtYC*;#>*F63_G!74xZ8dgw4i zf-%QKy+n{zEipxcafxPv9mjBT9`MGC3Y!vP7#!}<%o zL~3ER#04Gvil`-8>X4*Ex(<(Zs9aP{8m+@p9kO)rDyHU$)FDxaG#&2f5L8@E+CzuA zIviCYrG%QIz77dGtkWS|hax4_q#bpbuEPNxUg!`~N=+K8L#hrJbnq*!=4h!yk`C#F z|Mu)Oq=5Cb^PTjP1mk90pq2`4bV$}=uMSUjs1~Xw9jL<>I-J!ZZ*4V4Gabh3uw94y zI+P1jllInOfey!YaId50XsE*|9X9B2Lx7HZ!c=`dV} zR2|Of;PH-{ln3&{wsw+@*VQBSa;tSXuY*@h7gGn?+Djhpe8i9Fs^|)ZvH@FLbEdUQHUMgX5@vCD}Nt$MkYJIs|u6Q%CDC zUx!SBeiHWj%ZW201FRmA9^E|e=d!hb9FV)pmsgLt``8+%Rq@PgYmpRSjdjnH$J6eg z^t1a#$470W-Rl?ku++BXxf>c1TDL|>SdCEm&b3cmsU;b>lt?{$~YjJyo zm|1zhCBT|wyoET+O?_0jt1MzX>6|LTc+%P4U45`U!b5$!I3ci$d<$B(Wam%R*gbR0 zm{ zn0Jula}6nZ)#t%0OE3<0FA2sA$Hy$;@*^0&hius^Z@LY#&G%KuIbFhh(W z?UrnReeGXUUptuqm?W$!1};k!X7E1k#W17`cU1rGIV&ZL+~SWR$H_V^L5B1=ri@}FZIqy z3$U8sa(rp4dAGN<*``i@TO=6g%MZ4zZ^zg#!T30N&<^#v&rUi_mS7ys9eRmddWph2 z1FZX;?lAUWBpfkLzngZev)nBS#@V)8x?18h3C0rts3q)mz6r3Jzwr9W9<}qkB^Wn_ z;(OH+?IajWY-I_TcQW5!9e1*w`_#$x^_QgCitZ1vmUKD{wnz!ahiu>1VWkddb@0eg z`&(CsSRIy0FgAN!FY%8KHSKCE{UsQ4%-2g~5?tSu{bIdW8+WeF0qRSpvULb3pq6N^ z!&nK8oR984y~INaEu5E#2#oda>Kv9!Fdjh8Nic5go&{B?ufs4Mmg{gzg_J_E-mRT? zqpJjCH)csNb|XWA@zD1~uTr&etoiN7u@a0qmgyxf=p}rLsQqXv!T3t$Bnif(+awsD z2!5!S2r8;p>8!&P9d_w(M~7m?)TC{7n5aXV4%c-EEUqSPp+lk$>vg!SgSAAg_bKNq z`?dt*$}Uw>h3*oJgFIb=aWx##OT5sbYN=TBv#_JnHYbq&9O~g7Xsg&6fkLM~yRb(p7a>(+U6Oox+UBq29f`ov*t= zt%jO6?BzO~lVBV(&(TpPh zXF61DrzY*8!wenv>F`j8p!RCg&N@udVV4efbSTzAP1;t6i4tP}+wJFod;^+sOTTXdKyZ2A_sFp|dFNNJ^0rJ_#CS#cKu=OC zQ_b2@g0bD{IvmieJdt2Llm;JGlSb<>Ux!Q`p6gKgh?=yQ4zqN~kl_04m}l;r*RFlP zQ%5L$NPl@BCR4KW`!Ke95{zrG#8Ej(I-fshTU-^@Cyl$v#^1mkVV*Z?Nn*`y$#o|HnRh)k;ZeI*4NcLo7obKi`)E$x{HpL zU@UP_FX4Mrtr8)@_zI$8x723SB)C40dQN_G9_@Hr-CYwU7&~%Vg7G^fQ-4)Ek}knm zrNkW-l61H!!MH&bx~oD<3C5%wb@00o%!bd~Vj`JuCGCAGvX9Wo_2A4&Uj3IE40oq7MyoBUM@ z7!Tt1bP4IkQ@3%|)Ls9Ige>QMORrwQd{I`pMgjBp>9Qm^@0-mMtU{O$F*+>O;g}9N YIz*S|FO1@AnwQZwUxKkWuO9^eAFgJFZvX%Q delta 49860 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 diff --git a/main.py b/main.py index 4c3e7af..27757e9 100644 --- a/main.py +++ b/main.py @@ -5288,6 +5288,49 @@ def _safe_ratio(numerator: Any, denominator: Any) -> float: return (normalize_amount(numerator) / denom) * 100.0 +def _build_process_cost_related_clusters() -> dict[str, list[str]]: + related_map = get_project_related_links_map() + adjacency: dict[str, set[str]] = {} + for base_code, related_codes in related_map.items(): + normalized_base = normalize_text(base_code) + if not normalized_base: + continue + adjacency.setdefault(normalized_base, set()) + for related_code in related_codes: + normalized_related = normalize_text(related_code) + if not normalized_related: + continue + adjacency.setdefault(normalized_base, set()).add(normalized_related) + adjacency.setdefault(normalized_related, set()).add(normalized_base) + + cluster_map: dict[str, list[str]] = {} + visited: set[str] = set() + for code in sorted(adjacency): + if code in visited: + continue + stack = [code] + component: set[str] = set() + while stack: + current = stack.pop() + if current in component: + continue + component.add(current) + visited.add(current) + stack.extend(adjacency.get(current, set()) - component) + members = sorted(component) + for member in members: + cluster_map[member] = members + return cluster_map + + +def get_process_cost_related_codes(support_dept_code: str | None) -> list[str]: + code = normalize_text(support_dept_code) + if not code: + return [] + related_map = get_project_related_links_map() + return list(related_map.get(code, [])) + + def get_process_cost_available_years(source: str) -> list[int]: normalized_source = normalize_text(source).lower() if normalized_source == "wehago": @@ -5324,13 +5367,32 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]: rows = conn.execute( text( """ + WITH code_universe AS ( + SELECT DISTINCT support_dept_code + FROM project_contract_info + WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code + FROM project_status + WHERE COALESCE(support_dept_code, '') <> '' + UNION + SELECT DISTINCT support_dept_code + FROM project_basic_info + WHERE COALESCE(support_dept_code, '') <> '' + ) SELECT - COALESCE(support_dept_code, '') AS support_dept_code, - COALESCE(support_dept_name, '') AS support_dept_name, - COALESCE(hanmac_contract_amount, 0) AS hanmac_contract_amount, - COALESCE(client_name, '') AS client_name - FROM project_contract_info - WHERE COALESCE(support_dept_code, '') <> '' + COALESCE(u.support_dept_code, '') AS support_dept_code, + COALESCE(c.support_dept_name, p.support_dept_name, b.support_dept_name, '') AS support_dept_name, + COALESCE(c.hanmac_contract_amount, 0) AS hanmac_contract_amount, + COALESCE(c.client_name, '') AS client_name, + COALESCE(p.expected_as_cost, b.expected_as_cost, 0) AS expected_as_cost + FROM code_universe AS u + LEFT JOIN project_contract_info AS c + ON c.support_dept_code = u.support_dept_code + LEFT JOIN project_status AS p + ON p.support_dept_code = u.support_dept_code + LEFT JOIN project_basic_info AS b + ON b.support_dept_code = u.support_dept_code """ ) ).mappings().all() @@ -5343,9 +5405,53 @@ def _get_project_contract_meta() -> dict[str, dict[str, Any]]: return result -def get_process_cost_project_options(source: str, selected_year: int | None) -> list[dict[str, Any]]: +def _get_hanmac_process_cost_tx_by_code(selected_year: int | None) -> dict[str, dict[str, Any]]: + year_clause = "" + params: dict[str, Any] = {} + if selected_year: + year_clause = "AND year = :selected_year" + params["selected_year"] = selected_year + with engine.begin() as conn: + rows = conn.execute( + text( + f""" + SELECT + COALESCE(support_dept_code, '') AS support_dept_code, + MAX(COALESCE(support_dept_name, '')) AS support_dept_name, + SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount, + SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount, + COUNT(DISTINCT CASE WHEN COALESCE(voucher_number, '') <> '' THEN voucher_number END) AS voucher_count, + MAX(COALESCE(posting_date, '')) AS last_posting_date + FROM transactions + WHERE COALESCE(support_dept_code, '') <> '' + AND support_dept_code NOT IN ('ZZZZZZ') + AND COALESCE(support_dept_name, '') <> '' + AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') + {year_clause} + GROUP BY support_dept_code + """ + ), + params, + ).mappings().all() + result: dict[str, dict[str, Any]] = {} + for row in rows: + code = normalize_text(row.get("support_dept_code")) + if not code: + continue + result[code] = { + "support_dept_name": normalize_text(row.get("support_dept_name")), + "revenue_amount": normalize_amount(row.get("revenue_amount")), + "expense_amount": normalize_amount(row.get("expense_amount")), + "voucher_count": int(row.get("voucher_count") or 0), + "last_posting_date": normalize_text(row.get("last_posting_date")), + } + return result + + +def get_process_cost_project_options(source: str, selected_year: int | None, include_related: bool = False) -> list[dict[str, Any]]: normalized_source = normalize_text(source).lower() contract_meta = _get_project_contract_meta() + billing_summary_map = get_project_billing_summary_map() if normalized_source != "wehago" else {} if normalized_source == "wehago": init_wehago_compare_db(engine) @@ -5391,34 +5497,26 @@ def get_process_cost_project_options(source: str, selected_year: int | None) -> params, ).mappings().all() else: - year_clause = "" - params = {} - if selected_year: - year_clause = "AND year = :selected_year" - params["selected_year"] = selected_year - with engine.begin() as conn: - rows = conn.execute( - text( - f""" - SELECT - COALESCE(support_dept_code, '') AS support_dept_code, - MAX(COALESCE(support_dept_name, '')) AS support_dept_name, - SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount, - SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount, - COUNT(DISTINCT COALESCE(voucher_number, '')) AS voucher_count, - MAX(COALESCE(posting_date, '')) AS last_posting_date - FROM transactions - WHERE COALESCE(support_dept_code, '') <> '' - AND support_dept_code NOT IN ('ZZZZZZ') - AND COALESCE(support_dept_name, '') <> '' - AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실') - {year_clause} - GROUP BY support_dept_code - ORDER BY expense_amount DESC, support_dept_code - """ - ), - params, - ).mappings().all() + tx_by_code = _get_hanmac_process_cost_tx_by_code(selected_year) + universe_codes = sorted(set(contract_meta) | set(billing_summary_map) | set(tx_by_code)) + rows = [] + for code in universe_codes: + current_tx = tx_by_code.get(code, {}) + expense_amount = normalize_amount(current_tx.get("expense_amount")) + revenue_amount = normalize_amount(current_tx.get("revenue_amount")) + voucher_count = int(current_tx.get("voucher_count", 0) or 0) + last_posting_date = normalize_text(current_tx.get("last_posting_date")) + tx_name = normalize_text(current_tx.get("support_dept_name")) + rows.append( + { + "support_dept_code": code, + "support_dept_name": tx_name, + "revenue_amount": revenue_amount, + "expense_amount": expense_amount, + "voucher_count": voucher_count, + "last_posting_date": last_posting_date, + } + ) result: list[dict[str, Any]] = [] for row in rows: @@ -5426,6 +5524,7 @@ def get_process_cost_project_options(source: str, selected_year: int | None) -> if not code: continue contract_row = contract_meta.get(code, {}) + billing_row = billing_summary_map.get(code, {}) contract_amount = normalize_amount(contract_row.get("hanmac_contract_amount")) revenue_amount = normalize_amount(row.get("revenue_amount")) expense_amount = normalize_amount(row.get("expense_amount")) @@ -5433,8 +5532,8 @@ def get_process_cost_project_options(source: str, selected_year: int | None) -> result.append( { "support_dept_code": code, - "support_dept_name": normalize_text(contract_row.get("support_dept_name")) or normalize_text(row.get("support_dept_name")) or code, - "client_name": normalize_text(contract_row.get("client_name")), + "support_dept_name": normalize_text(contract_row.get("support_dept_name")) or normalize_text(billing_row.get("support_dept_name")) or normalize_text(row.get("support_dept_name")) or code, + "client_name": normalize_text(contract_row.get("client_name")) or normalize_text(billing_row.get("client_name")), "contract_amount": contract_amount, "revenue_amount": revenue_amount, "expense_amount": expense_amount, @@ -5444,11 +5543,16 @@ def get_process_cost_project_options(source: str, selected_year: int | None) -> "last_posting_date": normalize_text(row.get("last_posting_date")), } ) - result.sort(key=lambda item: (-normalize_amount(item.get("expense_amount")), item.get("support_dept_code", ""))) + result.sort(key=lambda item: (-normalize_amount(item.get("expense_amount")), -normalize_amount(item.get("revenue_amount")), item.get("support_dept_code", ""))) return result -def get_process_cost_project_detail(source: str, selected_year: int | None, support_dept_code: str) -> dict[str, Any]: +def get_process_cost_project_detail( + source: str, + selected_year: int | None, + support_dept_code: str, + include_related: bool = False, +) -> dict[str, Any]: code = normalize_text(support_dept_code) if not code: return { @@ -5460,11 +5564,15 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp } normalized_source = normalize_text(source).lower() - contract_meta = _get_project_contract_meta().get(code, {}) + contract_meta_map = _get_project_contract_meta() + contract_meta = contract_meta_map.get(code, {}) + related_clusters = _build_process_cost_related_clusters() if include_related else {} + cluster_codes = related_clusters.get(code, [code]) if include_related else [code] if normalized_source == "wehago": init_wehago_compare_db(engine) + in_clause, code_params = build_in_clause("process_cost_wehago_code", cluster_codes) year_clause = "" - params: dict[str, Any] = {"support_dept_code": code} + params: dict[str, Any] = dict(code_params) if selected_year: year_clause = "AND fiscal_year = :selected_year" params["selected_year"] = selected_year @@ -5490,7 +5598,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp COUNT(DISTINCT CASE WHEN COALESCE(confirmed_no, '') <> '' THEN confirmed_no ELSE draft_no END) AS voucher_count, MAX(COALESCE(proof_date, '')) AS last_posting_date FROM wehago_voucher_rows - WHERE support_dept_code = :support_dept_code + WHERE support_dept_code IN ({in_clause}) {year_clause} """ ), @@ -5506,7 +5614,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp COUNT(*) AS row_count, MAX(COALESCE(proof_date, '')) AS last_posting_date FROM wehago_voucher_rows - WHERE support_dept_code = :support_dept_code + WHERE support_dept_code IN ({in_clause}) AND (account_code LIKE '5%' OR account_code LIKE '6%') {year_clause} GROUP BY account_code, account_name @@ -5524,7 +5632,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp SUM(CASE WHEN account_code LIKE '401101%' OR account_code LIKE '401102%' THEN {amount_expr} ELSE 0 END) AS revenue_amount, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN {amount_expr} ELSE 0 END) AS expense_amount FROM wehago_voucher_rows - WHERE support_dept_code = :support_dept_code + WHERE support_dept_code IN ({in_clause}) {year_clause} AND LENGTH(COALESCE(proof_date, '')) >= 7 GROUP BY month_label @@ -5535,8 +5643,9 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp params, ).mappings().all() else: + in_clause, code_params = build_in_clause("process_cost_code", cluster_codes) year_clause = "" - params = {"support_dept_code": code} + params = dict(code_params) if selected_year: year_clause = "AND year = :selected_year" params["selected_year"] = selected_year @@ -5555,7 +5664,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp COUNT(DISTINCT COALESCE(voucher_number, '')) AS voucher_count, MAX(COALESCE(posting_date, '')) AS last_posting_date FROM transactions - WHERE support_dept_code = :support_dept_code + WHERE support_dept_code IN ({in_clause}) {year_clause} """ ), @@ -5571,7 +5680,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp COUNT(*) AS row_count, MAX(COALESCE(posting_date, '')) AS last_posting_date FROM transactions - WHERE support_dept_code = :support_dept_code + WHERE support_dept_code IN ({in_clause}) AND (account_code LIKE '5%' OR account_code LIKE '6%') {year_clause} GROUP BY account_code, account_name @@ -5589,7 +5698,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp SUM(CASE WHEN {REVENUE_SQL} THEN amount ELSE 0 END) AS revenue_amount, SUM(CASE WHEN account_code LIKE '5%' OR account_code LIKE '6%' THEN amount ELSE 0 END) AS expense_amount FROM transactions - WHERE support_dept_code = :support_dept_code + WHERE support_dept_code IN ({in_clause}) {year_clause} AND year IS NOT NULL AND month IS NOT NULL @@ -5602,7 +5711,12 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp ).mappings().all() summary_row = dict(summary) if summary else {} - contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount")) + if include_related: + contract_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("hanmac_contract_amount")) for member in cluster_codes) + as_cost_amount = sum(normalize_amount(contract_meta_map.get(member, {}).get("expected_as_cost")) for member in cluster_codes) + else: + contract_amount = normalize_amount(contract_meta.get("hanmac_contract_amount")) + as_cost_amount = normalize_amount(contract_meta.get("expected_as_cost")) revenue_amount = normalize_amount(summary_row.get("revenue_amount")) expense_amount = normalize_amount(summary_row.get("expense_amount")) labor_amount = normalize_amount(summary_row.get("labor_amount")) @@ -5615,7 +5729,8 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp phase_rows = [ {"phase": "직접인건비", "target_amount": target_base * 0.30, "actual_amount": labor_amount}, {"phase": "외주비", "target_amount": target_base * 0.32, "actual_amount": outsourcing_amount}, - {"phase": "설계경비", "target_amount": target_base * 0.23, "actual_amount": design_cost_amount}, + {"phase": "제경비", "target_amount": target_base * 0.23, "actual_amount": design_cost_amount}, + {"phase": "A/S비", "target_amount": as_cost_amount, "actual_amount": as_cost_amount}, {"phase": "판관비", "target_amount": target_base * 0.15, "actual_amount": sga_amount}, ] for row in phase_rows: @@ -5665,6 +5780,7 @@ def get_process_cost_project_detail(source: str, selected_year: int | None, supp "last_posting_date": normalize_text(summary_row.get("last_posting_date")), "voucher_count": int(summary_row.get("voucher_count") or 0), "row_count": int(summary_row.get("row_count") or 0), + "included_codes": cluster_codes if include_related else [code], }, "phase_rows": phase_rows, "account_rows": normalized_accounts, @@ -5684,6 +5800,7 @@ def render_process_cost_page( source: str | None = None, year: int | None = None, code: str | None = None, + include_related: bool = False, message: str = "", ) -> HTMLResponse: init_db() @@ -5693,23 +5810,30 @@ def render_process_cost_page( normalized_source = "hanmac" years = get_process_cost_available_years(normalized_source) - selected_year = year if year in years else (max(years) if years else None) - project_options = get_process_cost_project_options(normalized_source, selected_year) + selected_year = year if year in years else None + project_options = get_process_cost_project_options(normalized_source, selected_year, include_related=include_related) selected_code = normalize_text(code) if selected_code and not any(item["support_dept_code"] == selected_code for item in project_options): selected_code = "" if not selected_code and project_options: selected_code = project_options[0]["support_dept_code"] - detail = get_process_cost_project_detail(normalized_source, selected_year, selected_code) + detail = get_process_cost_project_detail( + normalized_source, + selected_year, + selected_code, + include_related=include_related, + ) context = { **base_context(request, message), "process_cost_source": normalized_source, "process_cost_years": years, "process_cost_selected_year": selected_year, "process_cost_selected_code": selected_code, + "process_cost_include_related": include_related, "process_cost_projects": project_options, "process_cost_detail": detail, + "process_cost_related_codes": get_process_cost_related_codes(selected_code), } return templates.TemplateResponse(request, "process_cost.html", context) @@ -7092,6 +7216,7 @@ async def process_cost( source: str | None = None, year: str | None = None, code: str | None = None, + include_related: str | None = None, ): try: return render_process_cost_page( @@ -7099,6 +7224,7 @@ async def process_cost( source=source, year=parse_optional_year(year), code=code, + include_related=normalize_text(include_related) in {"1", "true", "y", "yes", "on"}, ) except Exception as exc: logger.exception("프로세스 원가 페이지 에러: %s", exc) diff --git a/templates/process_cost.html b/templates/process_cost.html index 39b8713..457684a 100644 --- a/templates/process_cost.html +++ b/templates/process_cost.html @@ -48,6 +48,13 @@ color: #fff; } + .pc-tab-row { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .pc-filter { display: inline-flex; align-items: center; @@ -144,6 +151,83 @@ line-height: 1.6; } + .pc-related-box { + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; + padding: 12px; + display: grid; + gap: 10px; + } + + .pc-related-box h3 { + font-size: 14px; + } + + .pc-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .pc-chip { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line); + border-radius: 999px; + background: #f8fafc; + padding: 6px 10px; + font-size: 12px; + min-height: 32px; + } + + .pc-chip button { + border: none; + background: transparent; + color: var(--muted); + padding: 0; + min-width: 16px; + min-height: 16px; + line-height: 1; + cursor: pointer; + } + + .pc-related-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + } + + .pc-related-list { + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + max-height: 200px; + overflow: auto; + display: none; + } + + .pc-related-list.active { + display: block; + } + + .pc-related-item { + width: 100%; + border: none; + background: transparent; + text-align: left; + padding: 9px 10px; + cursor: pointer; + font-size: 12px; + color: var(--ink); + } + + .pc-related-item:hover { + background: #f5f7fa; + } + .pc-block { border: 1px solid var(--line); border-radius: 14px; @@ -309,23 +393,28 @@ {% set overview = detail.overview or {} %} {% set diagnostics = detail.diagnostics or {} %}
-
-

프로세스 원가 관리

-

토목설계회사 기준 초안 (직접인건비/외주비/설계경비/판관비 중심)

-
-
-
- Hanmac ERP - WEHAGO +
+ {% if process_cost_include_related %} + + {% endif %} + + +
+ +

설계 프로세스별 원가 현황

@@ -474,7 +578,7 @@
-
설계경비 비중{{ "{:.1f}".format(diagnostics.design_cost_ratio|default(0)) }}%
+
제경비 비중{{ "{:.1f}".format(diagnostics.design_cost_ratio|default(0)) }}%
@@ -486,10 +590,11 @@

데이터 현황

선택 소스{% if process_cost_source == 'wehago' %}WEHAGO{% else %}Hanmac ERP{% endif %}
-
연도{{ process_cost_selected_year or '-' }}
+
연도{{ process_cost_selected_year or '전체 기간' }}
전표 건수{{ "{:,}".format(overview.voucher_count|default(0)) }}
행 건수{{ "{:,}".format(overview.row_count|default(0)) }}
실행률{{ "{:.1f}".format(overview.execution_rate|default(0)) }}%
+
연계 반영{% if process_cost_include_related %}반영{% else %}미반영{% endif %}
@@ -504,6 +609,9 @@ {% block script %}