From 0570680d58fa8ca313b6380aaf601261f1e20153 Mon Sep 17 00:00:00 2001 From: mtpc4s9 Date: Sun, 14 Dec 2025 13:33:10 +0700 Subject: [PATCH] =?UTF-8?q?Th=C3=AAm=20PO=20Th=C3=AAm=20wizard=20gom=20nhi?= =?UTF-8?q?=E1=BB=81u=20RFQs=20=C4=91=E1=BB=83=20t=E1=BA=A1o=20POs=20(nh?= =?UTF-8?q?=E1=BB=AFng=20RFQs=20c=C3=B9ng=20vendor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/epr/__manifest__.py | 12 +- ...ence_data.xml => epr_pr_sequence_data.xml} | 0 addons/epr/data/epr_rfq_sequence_data.xml | 39 ++ addons/epr/models/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 185 -> 282 bytes .../__pycache__/epr_approval.cpython-312.pyc | Bin 0 -> 6708 bytes .../models/__pycache__/epr_po.cpython-312.pyc | Bin 0 -> 2585 bytes .../epr_purchase_request.cpython-312.pyc | Bin 16045 -> 17262 bytes .../__pycache__/epr_rfq.cpython-312.pyc | Bin 0 -> 15557 bytes addons/epr/models/epr_approval.py | 220 ++++++++++ addons/epr/models/epr_po.py | 68 +++ addons/epr/models/epr_purchase_request.py | 54 ++- addons/epr/models/epr_rfq.py | 409 ++++++++++++++++++ addons/epr/security/ir.model.access.csv | 27 +- addons/epr/views/epr_approval_views.xml | 138 ++++++ addons/epr/views/epr_menus.xml | 91 +++- addons/epr/views/epr_po_views.xml | 63 +++ .../epr/views/epr_purchase_request_views.xml | 15 + addons/epr/views/epr_rfq_views.xml | 221 ++++++++++ addons/epr/wizards/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 183 -> 220 bytes .../__pycache__/epr_create_po.cpython-312.pyc | Bin 0 -> 7350 bytes addons/epr/wizards/epr_create_po.py | 181 ++++++++ addons/epr/wizards/epr_create_po_views.xml | 53 +++ 24 files changed, 1565 insertions(+), 30 deletions(-) rename addons/epr/data/{epr_sequence_data.xml => epr_pr_sequence_data.xml} (100%) create mode 100644 addons/epr/data/epr_rfq_sequence_data.xml create mode 100644 addons/epr/models/__pycache__/epr_approval.cpython-312.pyc create mode 100644 addons/epr/models/__pycache__/epr_po.cpython-312.pyc create mode 100644 addons/epr/models/__pycache__/epr_rfq.cpython-312.pyc create mode 100644 addons/epr/models/epr_approval.py create mode 100644 addons/epr/models/epr_po.py create mode 100644 addons/epr/models/epr_rfq.py create mode 100644 addons/epr/views/epr_approval_views.xml create mode 100644 addons/epr/views/epr_po_views.xml create mode 100644 addons/epr/views/epr_rfq_views.xml create mode 100644 addons/epr/wizards/__pycache__/epr_create_po.cpython-312.pyc create mode 100644 addons/epr/wizards/epr_create_po.py create mode 100644 addons/epr/wizards/epr_create_po_views.xml diff --git a/addons/epr/__manifest__.py b/addons/epr/__manifest__.py index 46ba70e..ea7d2bd 100644 --- a/addons/epr/__manifest__.py +++ b/addons/epr/__manifest__.py @@ -26,15 +26,15 @@ 'security/epr_security.xml', 'security/ir.model.access.csv', 'security/epr_record_rules.xml', - 'data/epr_sequence_data.xml', - # 'data/epr_approval_default_data.xml', - # 'data/mail_template_data.xml', + 'data/epr_pr_sequence_data.xml', + 'data/epr_rfq_sequence_data.xml', 'views/epr_purchase_request_views.xml', - # 'views/epr_request_line_views.xml', - # 'views/epr_approval_matrix_views.xml', - # 'views/res_config_settings_views.xml', + 'views/epr_rfq_views.xml', + 'views/epr_approval_views.xml', + 'views/epr_po_views.xml', 'views/epr_menus.xml', 'wizards/epr_reject_wizard_views.xml', + 'wizards/epr_create_po_views.xml', ], 'assets': { # 'web.assets_backend': [ diff --git a/addons/epr/data/epr_sequence_data.xml b/addons/epr/data/epr_pr_sequence_data.xml similarity index 100% rename from addons/epr/data/epr_sequence_data.xml rename to addons/epr/data/epr_pr_sequence_data.xml diff --git a/addons/epr/data/epr_rfq_sequence_data.xml b/addons/epr/data/epr_rfq_sequence_data.xml new file mode 100644 index 0000000..2e0a695 --- /dev/null +++ b/addons/epr/data/epr_rfq_sequence_data.xml @@ -0,0 +1,39 @@ + + + + + + + ePR Request for Quotation + + epr.rfq + + + RFQ/%(year)s/ + + + 5 + + + 1 + 1 + + + + + + standard + + + + \ No newline at end of file diff --git a/addons/epr/models/__init__.py b/addons/epr/models/__init__.py index 4c375e4..e0b799a 100644 --- a/addons/epr/models/__init__.py +++ b/addons/epr/models/__init__.py @@ -1 +1,4 @@ from . import epr_purchase_request +from . import epr_rfq +from . import epr_approval +from . import epr_po \ No newline at end of file diff --git a/addons/epr/models/__pycache__/__init__.cpython-312.pyc b/addons/epr/models/__pycache__/__init__.cpython-312.pyc index c2ec60e0c06831cfcb8ec270d2308dbb57b873bc..ba68796e562102ce12f1ed081114d47c3d2d45dc 100644 GIT binary patch delta 182 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 OG`l$a4-V^0%6|iC^DS5a literal 0 HcmV?d00001 diff --git a/addons/epr/models/__pycache__/epr_po.cpython-312.pyc b/addons/epr/models/__pycache__/epr_po.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee7ee5ff9ad889759ca53ae9a9f6f8cb81260510 GIT binary patch literal 2585 zcmb7GO>7fK6rQ!$>z~+h;`{-HviT`t3piC3A^HQNkfxN9gaVhER;$G`Nj9=~&Fn5< zD@Z{?YAPW$Q4ayN5c&|QSXL>Tj|)eQLWxv>AvNgh%JWaK}P(XymO|Ki&ekx4}plY+&hVVxAP#ll)i zXM&pdt@<(+4Ay@HWd;$H6EMn!u#gLf5D|BC3x{wRi#UQM9G#ENNxlW{B;e}f-Uq2TLa0$TGjqTY5m!1>+S=?|J%>{s53OC+Gx%zTO zX=1614$Y5h78#e-VO$u4nHQh2pdz-< zW1&Wc+oAE$xS$5)LCB8QaO0sG2G#=-B6RP#h{{L_4OUuT@R1V(TvZ}0v2_rrC#_T> znAO$LIxngMF<7cLllLMKW}-z3!z@%XnFyvVi~B9h^7BJIMZ@kPw`{5%(=epl>Vd62 zexY(zEt%O;nKlBx(z%1d*`&s$(5$=rKax1 z!>5;<-kI!k542C7xi|Rh!H(J9FUyl>eo5|sRtFo_VLRH979Kb+Efr9BTE?~~jlmbZ0?D}Spubpx9>DfDMxYC*T`qr)aGvXGA9 zXD}%bHD{B4s7cYQMbR#o2#ZrM_zOvYM_*p92 zkXS>}c!C}PV}nYX#Z<2asVYmT{E$w8fSy!U=m-TK5E#W&-54cQw<%8|iw$T-`E@uj z96sng5X`tYTZAP)1kv{)L?eV!-nuEj4p|hF5;3slQJcOBQ{N)b5idHF-$S0@U~3Y@ zUF4<@PMlkdp-lT!>&(IFgKo>qZo1uVY;jW=xA7%6_3=h3nvo{XueGA4&Z$EynU1AQ zhlkqlHgvnGYa8)MlQi+cT0Ls%oa&z$oE~)3`(SA!5^j+u2G$ZtYMi+I>D4<|7aNZ) zN3zdCQbbt~BdKT2(=v&OA1%+dJb1PFY+jtFVd{TgoFbOyqgxkeIi|!};|?NRfL>W? z+IeMVc>}E^A?&<8`J?pKr6BV?SNfgv;7?!mJLuiJHvf^;h3WhYcN6yDySIL6tM$_) zn5HQZECPi^Zh2S0pc18=qVy;i$GFIH!RbPKK(KU;DZvdup~G*_{c%3z;spFGZj0x> z%gcS+p{d~+bJ}!UI^DD!7}&tq@<1#pV;Zp!P{;s;s_3xe<`>Rv|O8`5h+0$6lm`oabRbz@ja3etV) NVe&!p9|WrB%D>xBs2KnN literal 0 HcmV?d00001 diff --git a/addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc b/addons/epr/models/__pycache__/epr_purchase_request.cpython-312.pyc index c596ccf7d25bb10af9913b591118a8bcf894e079..a3d9732122405ac6f54c1df0092078a81ec1e8a7 100644 GIT binary patch delta 2575 zcmZ9Ne@t7~702(j@eho#4F(5;!6pTa+bkpo93Tz(MNQKr5FDCO687ZzJ%W*WkKAXc zfh2{rC?h}ACB4&6a?{rP{$r)?Ud; z)Q`pX3G>vFv-fGH+sD_jPkep?FZnd5b^F1*FRpL_`(EQ)~w~4?}4U#=M>JC z6nCvqtb0;r=k8@i#|p)Iv2z0T2E{#$^|2vGOxBYALz;&f0yBi+6p0N)RY@Syp_HU1*?&SaLL@F~IgxOe z8ZZ(sdtA-TBGF`Aku_6HNu-A4^^c7paWVlZ234UDQ-+39Nmh{6h$$&KIhxDL__ix4 zo16^jNHu67fu7DggCl8|bsigg*!8HJ-my5T&t}9o(|%h7<2&h5`!6uQN9zi%;=AdY z0yDNJ;4bF4Dkv|@61hpgD0~jTPru^~H*2QsW2>>H?PW@Ypq=1Fe-&o9fR3Ry)aOCy zI8H9B=y0Q}?{gEz) z(Q3YuGG}hXvhpJku4WiVpSemG5YDZi3_tR&z8bpTdaZTV8=MUP(dwDkGk;okdUaT) zuNM#EKc~Mb{*A8q2$UWGm;o?b-W!xBlU z5+UWFe+6Iy@bnX4!S0aP022U(x;$ZAmG1KdG5(gGE8mH`>7(-cJYJZmDYUdAf+{%?2ZVKyGk^+!4B!jt2I~a$}zWR{hTlhYjK{{7w2F&r7tftZ1xX ze-OjWA47u>{lDKq%;UMD<+4u9yKH1rV@(B_X!qyuUGa%gdBCSWxE34m#Tb3i%Zd1d zcVyq8R8;o;`uykb-j{uQB0l-S)sazGBs)gFeuenDQc4mgW=vMe>#QDmfnhW^5m%FI zSbT^m>{`>sWpW&%&_&$=o#qs!{%9%zqnI7ZxTumI7G#sim;MH*Dd0x{0q|pnfFZk& z{5?UVl2-wWA82Dxq2;rEsyU+BS9Ulqy$rLejy}bkw!>o@TlSq|32HA(Vohtd{?LBP z{*l@J*P`MX*V@^lx=hhC8TaapyDC%SxhP(3yT0q%u1~5O9#%EXtZJNbg+2>BePiqG zoV%twrW+$OwaqjBmP~2Iyg^@Lo6om6EOW?`Yaxa3bbQ2GG_xSc(INV3%^ucwvo-a2 zn40{X#(10M?vBbM!663TKUz+=bR@fkKQ38OMrMBzS+iGhtuA%Wjlh=xztIyS3 zs5@VmF}pH$_ot5LOhNHtuEA*>-#KTazY3IMEB#ZTe6s`Dy_5Z)*eV{{Dl)|tnS#2E z!;^88WbDO|k#Dja$J^(MP=WWH@q+EVE#s)l*h?1;`U2zljyXsAn}7qi%~c^|!T8=E z>_4&poO{+#Hm}ne>c7$>qh}7R1xKO5y%2O7@)izYJ7ZOz!Ls0SF)LVZ$oskn=Nl~P z+Gj#JI6{TGV~mgLju?4YMriLkm7VdwU$+jI|CX&ZOg@B#gBj2hKH=NIq0q8mGyamk z82mfS-(0UTmNpD9zSc0wXl=Z;>LCdK0Ej_)4zYtR(V4~yJVXE0xIyR9rE5bUW8=pV z=%zVMC$XQNX==xr^lZ~GuH^m<1mPIS9)mDZ?5ng?OKjPPH_-Q5;bM0Gb4nS4_LbPJ4{!92J-OSb5H^GpN;FCMD&B?s<)!dm zBfkXS3@@a}?3*mJM%Ccb5>sR~NuqIfj64ESAApYy|KfZAmw>lnTK~Z?f#!AkbkVb4 zV*J4yt6ikkjSO>ojPV!9Ka2c-L7_$C9wW9~bY1aW@+=~-7iDWy Uhaq_gbl~5JU+_Es7bi=dO8@`> delta 1471 zcmYk6UrbY19LMkP(n3c;u>6O%-~h3J2&Md;2!d2r>LiG=Aj`G(UZGvNm2=ziN0>7q z#?3i{XKWb5bPpy=3US-jmqoTL8#9aX(LHoqvIk$7$rgz7dFOXPpbww??(g^g{?6~* zb8i2+3`^6y9;c=z3+y-fpf+&aJG*NQJm12+2#4q;$jGRblVp?ZTC9s1P$UQgMRe3q zkXO(14U3I2<7R%3C@4w64ObJdHt%q~&RuWt{J|Zr;%!$}3sx5o#S%6%8nx6cS$nhLIY8#SFBG1ycb z*`e4kcg_d{RkEA$fP9p(TJB=3k>6x=${xnrE5bMoxK0QTul}cT+6aDn+PqBfrd$P& zUX!{8MLn$ZWM1ca(i0{AVV|b-Dx*1tuEnK$OPAzD_{3nltw(Z^ygVWxOGQG$v;5(ujj+p-o`$tblKm%udb zC_WBOofdzT(0i3B-3|jm$HpYrFwz*_a+nG4&uiNQ;y%dx2s`2&f^Wo&QG|xe`kM61 z5wi%&5KomPf5@k4ekBx=BnOZa^iriwd?xC5D)$1o=%f9OkbI-c1}W5Dl@H0ZiN%@v zTva+0u9sVkdF##$qj`N0(ilt3#^iNtw$c33X)~Jj)dTeg@X#XH6yt(xJh30w_0WPk zjosDjb=6?I&oXZE9p*7~BoO|N+c7aj|EO<*6`I|!3Z5H{an|Jb#$m=IO;;G7G%e+P zkGdZar_pU7X&AplXPfP?Ouue!5MP46+WZ?N{)7cDT{(CefO=bwvcCV?^Yo>be%Qln zD=65$%6Os<@m0Ul)>E*XPPGR1eTuR<#AgigBuSFPen}#qV`?73FYp&MwJksW7D`Of z@Q9@PMijPuLtEj+?bUw4ve6w1`yzzz2PoYhr@_wCy>-J^EvNS0b03q-h86Nv+%{qxw~yF)&cwaIiRO1X(IW9J$?sNPD74umX+jvZrB5}obGAu>JxMB`YgcUyEGbzrm#3gyJ zEXQQUb1WDMi@`)V7G(*cLi-%&)LtI`Cv@dK3{B!jc##`1iTsFJG>uq9vuGJLjaWtN zh|R=F_Hx4zZK7Rt;NST!x8S;^O%@Fb^mWC}sn4Q)q2d;=K95u|s>an+qnzmeE-zN1 zUaGwcr98LnTB%A_s&#cK(JNMqH4C-I)Or}DP=>NjtQQ*=))|!5jB;Zc%0{sXwKj__ zVk>&lX7r+#mFOu`qFwA@{kAU%#(dPTN!=-~2lWQb-o{%NwYHIlHL1JAO`z_^X0b2y z7`3ls^jpjH#fMcWlRmbs46S`(voRmdtVXaxX%KtGKD6C$P_{D4a;xFTyltVGSlDXx zv~5l5$HZ;o_M)|GXOz22+vhm(aq)?T9k)zs&WHfI&auHVbWiI0OnmBH?v_R{vH{dD zuBM*mPV(Z@?>Y>+t~Kj@#;Et%qIx%pJKyC-y2a-Z_lUa?`^4uFZx(kW?iIg;xKG@J zxL+JV>=y?SZxQz*-a5^l<3}DF=6w4U+ujNJ$jQ@R*rf^CFP}V}YTA3~h;T$Yos{AU z;bcq}4ku#?#?q;Z@nAUOPn?papqQ#L^ccW6{1cO3ICe&Y<8O+=T`7+`O23{^gNouLgQvLn z4_`@s=lB%&5O18%s@UQQIUF5T95Uu0EK8zd3B@KR={<9$Z1Ga5l6`;8#qHjKqd#^omJiCuAGo+PYEEGm;Ul-D6rtl_9A zos%0VfqJYsFm<7~Fv>4#XxY_H1S5X*R-ROBn7%+*OjW5J3PyzCgftQNnH6^^#%4JX z4US8SI~+YD$#IFjvF4Qe04OGs2`La9k0qmtKmvOwqF9fzpNddYradu<+EJTbqg9Jm z8JX;aN!3x$CX#aKR4^|2X)&-Nl~!$e1A0zC%fW9%ap^DUy=ORv!jis~qYg%~jRw?*73@JfoUw-=5A)o4ZVbPrVN4T7 z#p%P~k|AsnvlKm}&MexHJs?rJMy|%AO#6NnGaMu$f(%pKm7~{=Up<~{>(8|HPjPZB z5`C7^@z54ZxoPP7k|~Y>ZDq?3W)DjIO9nHholGcZgkes81}{Fd{49RtofOd|{qx3n z43u$VfR$s+wLP6_ds-jqs+FUWsw?L@D2tLT3}XAB2aL4}neIIhdxH=Uk#JN>Zb6}; z(Z%bN!l|WCK8OmTQ;R=I2E=Hdr2VKlt>fmWqrIVD9VSc6}< zY5S8LqdFDGAvq={Ly5S)yLzc-tQ(B0X?J~5rSpV8Mx9k$IkLlS zOCbRqn{pgFC=Ae(&m_}O)NSuJsKR1=#Uj&Eu^qb)546aG+#5KaFYpD?ex5J4V|8(V zo(~xN`BJ-uo75LtF-N4Rycg9f#?aJG-ZaUyUllVZC{Be1LEYbjcuuwNWk;HSjS*e| zO?)GwDem*ybq_e+`Q-G#XPq0b59hj`$aFoC?c6avaL?U--;5l_0wJS+C`GW4jrTJ= ze4EFJd5ritMr=B78Z(YFWNJa=6GYE~XwpZm1B!BaZvp1T`Afavewy8qAeu?mk7@9< zI8(IfwV&hT6+Bn2?(=}##5GyT{7pHU?d)hgdxi< z^r$!kflwqEj|T#A8mu4)Q|ekCqE;)lwNxwlI+EgHM8Cn>)wMm~OwQN%>A}3G_VT%z zb60F1@z=XPbYwlgg~KOdi$BtabK&E5hmoH^IhVFEX+&wa! zKKg3*$jGc^_Shf0m#tLSBOB+fpE*18_LXqf)01}eu=P(>6BFXliu>cqH`$T4f&~bp zuq`3tL1O_h=L&&veB{u(0_DscPmU`Ny_3+@aIyg# zxQh>A`6aZlf?4{IkZLVumf|wspdus$3|1NB!7Cl>uW$eU&JT9xHtfi3*zs{HyJ1hZ zV}O{Zm6&FbSGlH`7igetie~Y^j)LM`q*2hpFi+=RG0&;l(1hQ5k_1x1n6`w$1x1{t z)%-OcwdhKxRs;Qzni)3sInPa6eYRvXGG1UpSt4939NBjmf;*;(ZHBIrJ3upVYU%cj z5L*0+kigT|H9=0)Z=DJYOSi8H37Mq+DDoFS2n!4k`oRkXJmePCa1>~n~)Y7X~h&y44; zqbXnEx%|w`GgsE#sSrM^X?g!k>GosU)>pDM$J6fPd62ngT(d7+IhL;7c-PSdj#|Bj z#x{Q&b!*%FDca_c??@>P#%dXxx3F$ySBozz(@IwB(lcNHTCpLtH*hLBiB*V}OnN+s z`PYFqTbL-0-5*AUPv`#XGmyQXkdUXPZE-R)hd5FxA;Ny zlprpB^1Y;hN|I;~YboB;Mso8no?N=AO4J{1aw3Mv8{05R&F{x6g zE$?_xehU~^RU=X@E7yKS=AWXT`16P~rnNZxrw8($n#+?jlUMfL@vQ&bhK`T;^!oEz z;X=0I?X>spyxVj6rJ0vz&t7+>8+z}#`|dkY=s)@f*nlAtJI*DDTl>JS+Gb$OZ>UW& zO?UDDqGQRh5Lx^$Sf-VvD>-)M@+M^3G2aQXbw0QFK}cJ8Z7Bs*J0yP<@#>YU(N<2= z)mh&~9mT6=cKQpe_T@XCO`oZ&b~-D_*@k%98!xO{MY^Hyp1c3P9ff|& zs-@9{#+XGJmPIJZzJ<7PIlYfDGkC^`Ifl^-~OD+Yr_XLoADCJzxKY*yhIw zUz7yVBJ#yz`%*{~1Y})#aq0H=Cz;SD4tx`Qnh@Svx(zA4LK;UGug8QQq=Df{UW2bc zBq4yZNc&dUC$C3kE6#QjUfc2i5R#7D&+%*q-+&&qrr6`o@PES3^E`K{4n5?5Xg2dw zS;wHfneBn8j7E)%9gJ2M4MOX}Q3tJy7gddjIG`jzXjq_? z5xOf0<=O*fYY7=%p-VLYk(RHe+)S9p+E>?t@STxsP5*tA$^#LXF^IibkGmu zs7o`|L6cKla8m(*lc7W&K#htmCWlAEQN^X%=Rm~lONxsTL1hJ4p8%z7LIma419tGy(g+9A|_WAWg@!RP+!<1Zs8F-#!rwFqPlJY+AM`dWr*(IsmO-wcsdT9U3vK z0rFGpN=`yO1Jl0T;e;;j@~~k2=#EU(K!`$=1Acu-zMOG)=G;9Qch8N^8;A4l>#xPG#&YdjGwoaR zjjg%Ho=juUoyOfa27h$$=D}Rw?o8iqvQS*OdO@S5w?DZ02iM-d`u1nSrtf<`@Z^N; z8DV=?c;bGg{jsWL&c506YtCNjT{fdt^r5C9IZYw23wUB_n3L49Ao$(L_vWut8B$TxJ{udsPt%bd;WT79G$3g3B5wT2v_fUk*s z_%l3E9vy-#6yrqw;L?8?$}{kwUShR!e(7lq<@JGP01ElUsl}TU0=C_x5CQ(sG;ATd z^vN%hLTKre??)jFZvQX{lWGf!$Q=}sj7{!mHLO(M!8H<)Vp-Lld^deU!7n46iY6}G=NxEn8-Gq zPJ2%mGyr?Lq3534M;gH9)$2%Tnn9BVh^~JxXd;@};X-{W31pBFtvFT;pi&?x2U$*< z=@tO!y#+e`>7;qoEIJmPx_OoMYP=4#wK_%Nmky%hBWuzPx^f_ftm`G=miEqbqYQ!o zj7Z=NGmiN(Fa-6MfnGQs%cqusE1)d{O^{j!YTzD26>@}qdf&I} zya|Xf${<1+fGJk9RqQIM1h3}}(>62Tm7njL;`zp`lz|$BnVod|YLhg+p~X)UOsN|e zhLdlOhgHAymzcXW7)g>^Q2+$YFf#!aABLSUq(b7ifdhGQf$TbDICwANPt{7r-cY8v zkHS%DqciD@^acOiQT0^3gccRocsQ!LIa8*c&!ybDbzBI>Lq1-ys^Eeh?qq`)`UjGy z3_akKg>&036OPiHy`u(2=8wX)l8z! zS66h}7ql!okU=a3;Z9YDaDhs^N)a8=Fcs@fv1*f19MNydQ%1j z%1(KuB@j9#h2GM<+2}92Jqp*jmrdeSXhGc}LpYM#o}Je7BM<|~`7Y`<<% zPu^`AVO!e0ozfn^UYG0Kmg%JTWxM4W7f#a}VcE&mKK+oh)OJqW@4Gqgy3gI-%U_=P z@)i5_=JdM0yYBwHy9P};D|3#fjH4;%5Hb$ou46sYnp&nS^K~r`IH%J?=%Fd+7BX%@ zhazpA*Iv2$3Q)r}=T&FERk(KG>VbS`SFUq=rgM9~n*heqAH8w&ja>h(O#iO*rsvaL zyYtN*04M9Ke$6>5JFTbGV^hx2pKN|X6XsCH#NB)H~g zP@2!1TN+@Yhu~1J+t>-=<3`9$Uj8=daOH+SSLA?DZw@@J z@!xM@cU-a2%~RUtn`TU9octM6+|R0i(fISmU%r}odT2_$T`=^o>a{$JYUK|p`Uug# zO@BL!`rG-7o}c&pQp!BDU+XUugys88XDjmum%Xzwqs_H?iGgSJz{CDM6jJPTl0fRK zwm{AONtT7w?=|5)GyuXzzrl z&){#%P`3Htv}dWJ`Ew?aTf1{DTQd0DxNGm`}TY8$CsY-V|4SL}@xKJEG_$egAa2^ObDJ@wB7qp5xVj z{|P6AcN~f*JWPMVWcZs)xBp0WErwthp9`z=P3V~!y`alpB(N zf_Rl{SO-h8G=`ghYl5Yc?5Aj&H={n4eN;oiE9u&j3=cFmg>Y9IH4FActiuxR=MS3o?xC zVCm#)kD!u*SxbJKUN2HKPPJRe2BuUp^PH$%=2ER@dnw!gQZylr!dmzul^}kmi=ZUx z;_@K9)u>k^)tf4Taa<4$t2Qv&%<>6}f)s@)8l&hvie@OfMiGfV#vk&Z<4LL1F2>-( zXCx4S<7gPE?1ib#gWzltOOH}7(Jcm34fe64@#<-E5o-SK$Z^TgDP`O1c= zec&edy6GL4pPhL&?`h6g*XJv15dX^C^{B$;woMH#d%3FC>0_5q%$&GBc(>9wweNFx z>-4^yyEWr(ePFWJx@!NRM@)?&yU(XmA0w9%j>zi4YNDupPzd^U+>G;x8*$@ zkL;E@+tkaDjYgKYJMU@8uiL0+dAZsRvzu}?>oYa$^Yy*?>MiirKf7su z%iNZH{f4}!iRC_x7-MJk7Zvxk2^o;gp>yCLJZ#%PUVG=Y+4ihexNkC9pL}TMY@N&WwCsY_E@y4YSX&;N zELI=ov@X+AQNeWe#;moAN)GZwu!)Ke@(-$P*188?o3;Oe*J|}YsB&3*A2e>Vx*iNes17~gK~d9a(eSV#E(=Qdl9s`q->mZME<1ky7pJlumlxca*e+-$d&v+byM zAJMj8H@-x$uSx5GL&1tKKC}aI#K9=bxL?GI1-FjgM_i1mjJpXo602B+uRQen+-tUs zuRIoNbbHbWeGOtAD_3K!ShrAb&{Z)yI)+Pv*dVSG=_`-sg%;yG1uvr}1X&XB6-XP# z-EQ==X3c(f;QJ2-{eE*^Mk-h@a^%M3DNZlBj`%8kjQLk0(Dxz;( zvmcwpZp>}Zg3nmhrZs6d82qKyl??qal~-{Jt8Hflt**X__bffen@rVz+7BXsA90-=Ya4f zyP)DYthqZz@rJPcymoU}#@$KvZhguQABdkEJ?5{(4gldB)uvoufip-rM1G-ErPi+c z3Nlu|O@s?75l>YFL+moHKM_2qG)U?f4a@@-KngrPblKfbUKE^gP#4I6g6bOSF7Qs6VpL;`>ZHlEK07`5%>#6=aVG;n z%nP<+zz}-sfIq>*TDrD;5NICxrJ>yAne$xXM)Vq`=*TY#nxDFSD~=LkyrLC?Uo1=- zlWSGId~PuuhmUen{kG#B5GhV_?!koz|K8W}z6yud;W{He)`$}PH$0=Mjshq9%MrXo zE3g~&VFhqS@%e8&PcE>FS1;x|p3QV%?(e!=4SyUdVRxDje4O~{#UEeHJ$WGWJBTK;bok1oAtbwErcg(T-O?d;p7Z@_(m@HabZ;b^7Hcdi9o$MzONRyQvypRBiiz zQfZ3z&@vNV2Lm&Ke0_V~(+)6`uWkmSsJ!t@zj=gD{&8m#u^y-kE0Jq>_!8rm}87!Vw=SXE7-H3JHIH|o7}6rfh~ zmDjV@%>Y*z51SKHHbIcHB3atwRh^3Crvep4i6j*)#JO=Pu?Q zKz|!)wkm5dTaC7~r4G}D$=rAO;LO3i7t_)~5E?V)v{u^Q*{cc`T8ANDg<^?`v6xIE zRNXafor&QU59Ro!b0LYj-{a~%D&lR$dVs#qW!Q`@sQmX-zLuhDMEDj?)zo&$|A<#C zG#96HVcE>{{NHiiS+4v4a62B^k{13bkF|Jx=JiJ$dwF!u`3le5)OGkZN6)O 'to_approve'. +# Đây là snapshot của Config tại thời điểm submit. +# ============================================================================== +class EprApprovalEntry(models.Model): + _name = 'epr.approval.entry' + _description = 'RFQ Approval Entry' + _order = 'sequence, id' + + # Link về RFQ + rfq_id = fields.Many2one( + comodel_name='epr.rfq', + string='RFQ Reference', + required=True, + ondelete='cascade' + ) + + config_id = fields.Many2one( + comodel_name='epr.approval.config', + string='Source Rule', + readonly=True, + ondelete='set null' # Giữ entry dù config bị xóa + ) + + # Thông tin snapshot từ Config + name = fields.Char( + string='Summary', + required=True + ) + + sequence = fields.Integer( + string='Sequence', + required=True + ) + + approval_type = fields.Selection( + selection=[ + ('any', 'Any'), + ('all', 'All') + ], + required=True + ) + + # Trạng thái dòng duyệt này + status = fields.Selection( + selection=[ + ('new', 'To Process'), # Chưa đến lượt (do sequence cao hơn) + ('pending', 'Pending'), # Đang chờ duyệt + ('approved', 'Approved'), # Đã xong + ('rejected', 'Rejected') # Bị từ chối + ], + string='Status', + default='new', + index=True, + readonly=True + ) + + # Danh sách người CẦN duyệt + required_user_ids = fields.Many2many( + comodel_name='res.users', + relation='epr_approval_req_users_rel', + string='Required Approvers', + readonly=True + ) + + # Danh sách người ĐÃ duyệt (quan trọng cho logic 'All') + actual_user_ids = fields.Many2many( + comodel_name='res.users', + relation='epr_approval_act_users_rel', + string='Approved By', + readonly=True + ) + + approval_date = fields.Datetime( + string='Last Action Date', + readonly=True + ) + + # Field hỗ trợ UI: User hiện tại có được nút Approve không? + can_approve = fields.Boolean(compute='_compute_can_approve') + + @api.depends('status', 'required_user_ids', 'actual_user_ids', 'approval_type') + @api.depends_context('uid') + def _compute_can_approve(self): + current_user = self.env.user + for record in self: + # 1. Phải đang ở trạng thái Pending + if record.status != 'pending': + record.can_approve = False + continue + + # 2. User phải nằm trong danh sách được phép + if current_user not in record.required_user_ids: + record.can_approve = False + continue + + # 3. Nếu là 'All', user này chưa được duyệt trước đó + if record.approval_type == 'all' and current_user in record.actual_user_ids: + record.can_approve = False + else: + record.can_approve = True + + # ------------------------------------------------------------------------- + # ACTION METHODS + # ------------------------------------------------------------------------- + def action_approve_line(self): + """User bấm nút Approve trên dòng này""" + self.ensure_one() + if not self.can_approve: + raise UserError(_("You either don't have permission to approve or you've already approved it.")) + + # Ghi nhận người duyệt + self.write({ + 'actual_user_ids': [(4, self.env.user.id)], + 'approval_date': fields.Datetime.now() + }) + + # Kiểm tra điều kiện hoàn thành dòng này + is_done = False + if self.approval_type == 'any': + # Chỉ cần 1 người -> Done + is_done = True + elif self.approval_type == 'all': + # Phải đủ tất cả required users + # Dùng set để so sánh ID cho nhanh + required_set = set(self.required_user_ids.ids) + actual_set = set(self.actual_user_ids.ids) + if required_set.issubset(actual_set): + is_done = True + + if is_done: + self.status = 'approved' + # Gọi về RFQ cha để check xem có mở tiếp tầng sau (Sequence kế) không? + self.rfq_id._check_approval_progression() + + def action_refuse_line(self): + """User từ chối -> Từ chối TOÀN BỘ RFQ""" + self.ensure_one() + if not self.can_approve: + raise UserError(_("You don't have permission to reject this RFQ.")) + + self.write({ + 'status': 'rejected', + 'approval_date': fields.Datetime.now() + }) + + # Đẩy RFQ về trạng thái 'rejected' hoặc 'cancel' + # Gọi hàm xử lý từ chối bên RFQ + self.rfq_id.action_reject_approval() diff --git a/addons/epr/models/epr_po.py b/addons/epr/models/epr_po.py new file mode 100644 index 0000000..1251839 --- /dev/null +++ b/addons/epr/models/epr_po.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ + + +class PurchaseOrder(models.Model): + _inherit = 'purchase.order' + + # === FIELD LIÊN KẾT 1-1 TỚI EPR RFQ === + # Dùng cho action_create_po() trong epr.rfq + epr_rfq_id = fields.Many2one( + comodel_name='epr.rfq', + string='Original ePR RFQ', + readonly=True, + copy=False, + ondelete='set null', + help="Tham chiếu đến phiếu báo giá nội bộ (ePR) đã tạo ra PO này." + ) + + # === COMPUTED FIELDS CHO SMART BUTTON (Line-Level Linking) === + # Tìm tất cả các RFQ gốc dựa trên các dòng PO lines + epr_rfq_ids = fields.Many2many( + comodel_name='epr.rfq', + string='Source RFQs', + compute='_compute_epr_rfq_data', + help="Các phiếu yêu cầu báo giá (EPR RFQ) liên quan đến đơn mua hàng này." + ) + + epr_rfq_count = fields.Integer( + string='RFQ Count', + compute='_compute_epr_rfq_data' + ) + + @api.depends('order_line.epr_rfq_line_id') + def _compute_epr_rfq_data(self): + for po in self: + # Logic: Quét qua tất cả line của PO -> lấy epr_rfq_line -> lấy rfq_id -> unique + # Mapped tự động loại bỏ các giá trị trùng lặp (set) + rfqs = po.order_line.mapped('epr_rfq_line_id.rfq_id') + po.epr_rfq_ids = rfqs + po.epr_rfq_count = len(rfqs) + + # === ACTION SMART BUTTON === + def action_view_epr_rfqs(self): + """Mở danh sách các EPR RFQ nguồn""" + self.ensure_one() + return { + 'name': _('Source RFQs'), + 'type': 'ir.actions.act_window', + 'res_model': 'epr.rfq', + 'view_mode': 'list,form', + 'domain': [('id', 'in', self.epr_rfq_ids.ids)], + 'context': {'create': False}, # Không cho tạo mới RFQ từ đây để tránh mất quy trình + } + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + # === FIELD LIÊN KẾT QUAN TRỌNG NHẤT === + # Field này sẽ được Wizard 'epr.create.po.wizard' ghi dữ liệu vào + epr_rfq_line_id = fields.Many2one( + comodel_name='epr.rfq.line', + string='Source RFQ Line', + readonly=True, + copy=False, + index=True, + help="Dòng yêu cầu báo giá gốc đã tạo ra dòng đơn mua hàng này." + ) diff --git a/addons/epr/models/epr_purchase_request.py b/addons/epr/models/epr_purchase_request.py index 3d93f25..3bcca60 100644 --- a/addons/epr/models/epr_purchase_request.py +++ b/addons/epr/models/epr_purchase_request.py @@ -43,7 +43,7 @@ class EprPurchaseRequest(models.Model): compute='_compute_is_owner', store=False ) - + date_required = fields.Date( string='Date Required', required=True, @@ -74,13 +74,6 @@ class EprPurchaseRequest(models.Model): group_expand='_expand_groups' ) - # approver_ids = fields.Many2many( - # 'res.users', - # string='Approvers', - # compute='_compute_approvers', - # store=True - # ) - # Approvers: Nên là field thường (không compute) để lưu cố định người duyệt lúc Submit approver_ids = fields.Many2many( comodel_name='res.users', @@ -93,17 +86,20 @@ class EprPurchaseRequest(models.Model): readonly=True, tracking=True ) + currency_id = fields.Many2one( 'res.currency', string='Currency', default=lambda self: self.env.company.currency_id, required=True ) + line_ids = fields.One2many( 'epr.purchase.request.line', 'request_id', string='Products' ) + estimated_total = fields.Monetary( string='Estimated Total', compute='_compute_estimated_total', @@ -111,6 +107,22 @@ class EprPurchaseRequest(models.Model): currency_field='currency_id' ) + # Link sang RFQs + rfq_ids = fields.Many2many( + comodel_name='epr.rfq', + relation='epr_rfq_purchase_request_rel', # Tên bảng trung gian + column1='request_id', + column2='rfq_id', + string='RFQs', + readonly=True + ) + + # Số lượng RFQ + rfq_count = fields.Integer( + compute='_compute_rfq_count', + string='RFQ Count' + ) + # ========================================================================== # LOG FIELDS # ========================================================================== @@ -196,6 +208,12 @@ class EprPurchaseRequest(models.Model): # else: # request.approver_ids = False + # Compute RFQ count + @api.depends('rfq_ids') + def _compute_rfq_count(self): + for record in self: + record.rfq_count = len(record.rfq_ids) + # ========================================================================== # HELPER METHODS (Tách logic tìm người duyệt ra riêng) # ========================================================================== @@ -371,6 +389,26 @@ class EprPurchaseRequest(models.Model): # NOTE: Commented out for testing without mail server # self.activity_ids.unlink() + # === ACTION SMART BUTTON === + def action_view_rfqs(self): + """Mở danh sách các RFQ liên quan đến PR này""" + self.ensure_one() + return { + 'name': _('Request for Quotations'), + 'type': 'ir.actions.act_window', + 'res_model': 'epr.rfq', + 'view_mode': 'list,form', # Odoo 18 ưu tiên dùng 'list' thay vì 'tree' + 'domain': [('id', 'in', self.rfq_ids.ids)], + 'context': { + 'default_request_ids': [(6, 0, [self.id])], # Tự động link ngược lại PR này nếu tạo mới RFQ + 'create': True, + }, + } + +# ============================================================================== +# CLASS CON: epr.purchase.request.line (Chi tiết hàng hóa trong PR) +# ============================================================================== + class EprPurchaseRequestLine(models.Model): """ diff --git a/addons/epr/models/epr_rfq.py b/addons/epr/models/epr_rfq.py new file mode 100644 index 0000000..ddf3436 --- /dev/null +++ b/addons/epr/models/epr_rfq.py @@ -0,0 +1,409 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError + + +class EprRfq(models.Model): + _name = 'epr.rfq' + _description = 'EPR Request for Quotation' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'id desc' + + # === 1. IDENTIFICATION === + name = fields.Char( + string='Reference', + required=True, + copy=False, + readonly=True, + default=lambda self: _('New') + ) + + active = fields.Boolean(default=True) + + state = fields.Selection( + selection=[ + ('draft', 'Draft'), + ('sent', 'Sent'), + ('received', 'Received'), # Nhà cung cấp đã báo giá + ('to_approve', 'To Approve'), # Trình sếp duyệt giá + ('confirmed', 'Confirmed'), # Đã duyệt -> Sẵn sàng tạo PO + ('cancel', 'Cancelled') + ], + string='Status', + readonly=True, + index=True, + copy=False, + default='draft', + tracking=True + ) + + approval_ids = fields.One2many( + comodel_name='epr.approval.entry', + inverse_name='rfq_id', + string='Approval Steps' + ) + + # Tính tổng tiền trên RFQ để so sánh trong approval process + amount_total = fields.Monetary( + compute='_compute_amount_total', + string='Total', + currency_field='currency_id' + ) + + # === 2. RELATIONS === + # Link ngược lại PR gốc (01 RFQ có thể gom nhiều PR) + request_ids = fields.Many2many( + comodel_name='epr.purchase.request', + relation='epr_rfq_purchase_request_rel', # Tên bảng trung gian + column1='rfq_id', + column2='request_id', + string='Source Requests', + # Chỉ lấy các PR đã duyệt để tạo RFQ + domain="[('state', '=', 'approved')]", + readonly=True + ) + + partner_id = fields.Many2one( + comodel_name='res.partner', + string='Vendor', + required=True, + tracking=True, + # domain="[('supplier_rank', '>', 0)]", # Chỉ chọn đã từng được chọn qua ít nhất 01 lần + readonly=True + ) + + company_id = fields.Many2one( + comodel_name='res.company', + string='Company', + required=True, + default=lambda self: self.env.company + ) + + currency_id = fields.Many2one( + comodel_name='res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + readonly=True + ) + + # === 3. DATES === + date_order = fields.Datetime( + string='Order Date', + default=fields.Datetime.now, + readonly=True + ) + + date_deadline = fields.Date( + string='Bid Deadline', + help="Ngày hạn chót Vendor phải gửi báo giá", + readonly=True + ) + + # === 4. LINES & PURCHASES === + line_ids = fields.One2many( + comodel_name='epr.rfq.line', + inverse_name='rfq_id', + string='Products', + copy=True, + readonly=True + ) + + # Link sang Purchase Order gốc của Odoo + purchase_ids = fields.One2many( + comodel_name='purchase.order', + inverse_name='epr_rfq_id', + string='Purchase Orders' + ) + + purchase_count = fields.Integer( + compute='_compute_purchase_count', + string='PO Count' + ) + + # === 5. COMPUTE METHODS === + @api.depends('purchase_ids') + def _compute_purchase_count(self): + for rfq in self: + rfq.purchase_count = len(rfq.purchase_ids) + + # === 6. CRUD OVERRIDES === + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('epr.rfq') or _('New') + return super().create(vals_list) + + @api.depends('line_ids.subtotal') + def _compute_amount_total(self): + for rfq in self: + rfq.amount_total = sum(rfq.line_ids.mapped('subtotal')) + + # ------------------------------------------------------------------------- + # 7. ACTIONS + # ------------------------------------------------------------------------- + def action_send_email(self): + """Gửi email RFQ cho Vendor""" + self.ensure_one() + if self.state != 'draft': + raise UserError(_("Chỉ có thể gửi RFQ khi ở trạng thái Draft.")) + self.write({'state': 'sent'}) + return True + + def action_mark_received(self): + """Chuyển trạng thái sang Received khi nhận được phản hồi từ NCC""" + for rfq in self: + if rfq.state != 'sent': + # Tư vấn: Nên chặn nếu nhảy cóc trạng thái để đảm bảo quy trình + raise UserError(_("Chỉ có thể đánh dấu 'Đã nhận' khi RFQ đang ở trạng thái 'Đã gửi'.")) + rfq.write({'state': 'received'}) + + def action_confirm_rfq(self): + """Chốt RFQ, chuyển sang Confirmed""" + for rfq in self: + if rfq.state != 'received': + raise UserError(_("Vui lòng chuyển sang trạng thái 'Đã nhận' trước khi xác nhận.")) + rfq.write({'state': 'confirmed'}) + + def action_cancel_rfq(self): + """Hủy RFQ ở bất kỳ trạng thái nào (trừ khi đã hủy rồi)""" + for rfq in self: + if rfq.state == 'cancel': + continue + + # Nếu đã có PO liên kết, nên cảnh báo hoặc hủy luôn PO con + if rfq.purchase_ids and any(po.state not in ['cancel'] for po in rfq.purchase_ids): + raise UserError(_("Không thể hủy RFQ này vì đã có Đơn mua hàng (PO) được tạo. Vui lòng hủy PO trước.")) + + rfq.write({'state': 'cancel'}) + + def action_create_po(self): + """Chuyển đổi RFQ này thành Purchase Order""" + self.ensure_one() + if not self.line_ids: + raise ValidationError(_("Vui lòng thêm sản phẩm trước khi tạo PO.")) + + # Logic tạo PO + po_vals = { + 'partner_id': self.partner_id.id, + 'date_order': fields.Datetime.now(), + 'epr_rfq_id': self.id, # Link ngược lại RFQ này + 'origin': self.name, + 'company_id': self.company_id.id, + 'currency_id': self.currency_id.id, + 'order_line': [] + } + + for line in self.line_ids: + po_vals['order_line'].append((0, 0, { + 'product_id': line.product_id.id, + 'name': line.description or line.product_id.name, + 'product_qty': line.quantity, + 'price_unit': line.price_unit, + 'product_uom': line.uom_id.id, + 'date_planned': fields.Datetime.now(), + })) + + new_po = self.env['purchase.order'].create(po_vals) + + self.write({'state': 'confirmed'}) + + # Mở view PO vừa tạo + return { + 'type': 'ir.actions.act_window', + 'res_model': 'purchase.order', + 'res_id': new_po.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_reset_draft(self): + """Cho phép quay lại Draft nếu cần sửa""" + for rfq in self: + if rfq.state not in ['sent', 'to_approve', 'cancel']: + raise UserError(_("Chỉ có thể reset khi ở trạng thái Sent, To Approve hoặc Cancel.")) + rfq.write({'state': 'draft'}) + + # ------------------------------------------------------------------------- + # RFQ APPROVAL PROCESS + # ------------------------------------------------------------------------- + def action_submit_approval(self): + """Nút bấm Submit for Approval""" + self.ensure_one() + if not self.line_ids: + raise UserError(_("Vui lòng nhập chi tiết sản phẩm trước khi trình duyệt.")) + + # 1. Tìm các Rules phù hợp + configs = self.env['epr.approval.config'].search([ + ('active', '=', True), + ('company_id', '=', self.company_id.id), + ('min_amount', '<=', self.amount_total) + ], order='sequence asc') + + if not configs: + # Nếu không có rule nào -> Auto Approve + self.write({'state': 'received'}) # Hoặc trạng thái tiếp theo bạn muốn + return + + # 2. Xóa dữ liệu duyệt cũ (nếu submit lại) + self.approval_ids.unlink() + + # 3. Tạo Approval Entries (Snapshot) + approval_vals = [] + for conf in configs: + approval_vals.append({ + 'rfq_id': self.id, + 'config_id': conf.id, # Nếu bạn muốn link lại + 'name': conf.name, + 'sequence': conf.sequence, + 'approval_type': conf.approval_type, + 'required_user_ids': [(6, 0, conf.user_ids.ids)], + 'status': 'new', # Mặc định là new + }) + + self.env['epr.approval.entry'].create(approval_vals) + + # 4. Chuyển trạng thái và Kích hoạt tầng đầu tiên + self.write({'state': 'to_approve'}) + self._check_approval_progression() + + # ------------------------------------------------------------------------- + # APPROVAL LOGIC: LINEARIZATION + # ------------------------------------------------------------------------- + def _check_approval_progression(self): + """Hàm này được gọi mỗi khi có 1 dòng được Approved hoặc khi mới Submit""" + self.ensure_one() + + # Lấy tất cả entries + all_entries = self.approval_ids + + # 1. Tìm các Sequence đang 'pending' (Đang chạy) + current_pending = all_entries.filtered(lambda x: x.status == 'pending') + + if current_pending: + # Nếu còn dòng đang pending -> Chưa làm gì cả, đợi user khác duyệt tiếp + return + + # 2. Nếu không còn pending, tìm Sequence nhỏ nhất đang là 'new' (Chưa chạy) + next_new_entries = all_entries.filtered(lambda x: x.status == 'new') + + if next_new_entries: + # Tìm số sequence nhỏ nhất tiếp theo + min_seq = min(next_new_entries.mapped('sequence')) + + # Kích hoạt TOÀN BỘ các rule có cùng sequence đó (Parallel Approval) + to_activate = next_new_entries.filtered(lambda x: x.sequence == min_seq) + to_activate.write({'status': 'pending'}) + + # Gửi thông báo/Email cho những người vừa được kích hoạt (Optional) + # self._notify_approvers(to_activate) + else: + # 3. Không còn 'new', cũng không còn 'pending' -> Tất cả đã Approved + # Chuyển RFQ sang bước tiếp theo + self.action_mark_approved() + + def action_mark_approved(self): + """Hoàn tất quy trình duyệt""" + # Chuyển sang trạng thái 'Confirmed' + self.write({'state': 'confirmed'}) + self.message_post(body=_("Tất cả các cấp phê duyệt đã hoàn tất.")) + + def action_reject_approval(self): + """Xử lý khi bị từ chối""" + self.write({'state': 'draft'}) # Quay về nháp để sửa + self.message_post(body=_("Yêu cầu phê duyệt đã bị từ chối.")) + +# ============================================================================== +# CLASS CON: epr.rfq.line (Chi tiết hàng hóa trong RFQ) +# ============================================================================== + + +class EprRfqLine(models.Model): + _name = 'epr.rfq.line' + _description = 'EPR RFQ Line' + + rfq_id = fields.Many2one( + comodel_name='epr.rfq', + string='RFQ Reference', + required=True, + ondelete='cascade' + ) + + # Sản phẩm (Odoo Product) + product_id = fields.Many2one( + comodel_name='product.product', + string='Product', + required=True + ) + + description = fields.Text( + string='Description' + ) + + # Số lượng & Đơn giá (Có thể khác với PR ban đầu do đàm phán) + quantity = fields.Float( + string='Quantity', + required=True, + default=1.0 + ) + + uom_id = fields.Many2one( + comodel_name='uom.uom', + string='UoM' + ) + + price_unit = fields.Float( + string='Unit Price', + digits='Product Price' + ) + + taxes_id = fields.Many2many( + comodel_name='account.tax', + relation='epr_rfq_line_taxes_rel', + column1='line_id', + column2='tax_id', + string='Taxes' + ) + + # Tổng tiền trên RFQ line + subtotal = fields.Monetary( + compute='_compute_subtotal', + string='Subtotal', + store=True + ) + + currency_id = fields.Many2one( + related='rfq_id.currency_id' + ) + + @api.depends('quantity', 'price_unit') + def _compute_subtotal(self): + for line in self: + line.subtotal = line.quantity * line.price_unit + + @api.onchange('product_id') + def _onchange_product_id(self): + if self.product_id: + self.description = self.product_id.display_name + self.uom_id = self.product_id.uom_po_id or self.product_id.uom_id + + # ============================================================================== + # LINE-LEVEL LINKING LOGIC (From RFQs to POs) + # ============================================================================== + + # Link tới dòng của Purchase Order chuẩn Odoo + purchase_line_id = fields.Many2one( + 'purchase.order.line', + string='Purchase Order Line', + readonly=True, + copy=False + ) + + # Tiện ích để xem nhanh trạng thái + po_id = fields.Many2one( + related='purchase_line_id.order_id', + string='Purchase Order', + store=True, + readonly=True + ) diff --git a/addons/epr/security/ir.model.access.csv b/addons/epr/security/ir.model.access.csv index 1cea9e9..ee07cf3 100644 --- a/addons/epr/security/ir.model.access.csv +++ b/addons/epr/security/ir.model.access.csv @@ -1,12 +1,27 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_epr_purchase_request_user,ePR Request User,model_epr_purchase_request,group_epr_user,1,1,1,0 -access_epr_purchase_request_line_user,ePR Line User,model_epr_purchase_request_line,group_epr_user,1,1,1,0 -access_epr_reject_wizard_user,ePR Reject Wizard User,model_epr_reject_wizard,group_epr_user,1,0,0,0 +access_epr_purchase_request_line_user,ePR Request Line User,model_epr_purchase_request_line,group_epr_user,1,1,1,0 access_epr_purchase_request_manager,ePR Request Manager,model_epr_purchase_request,group_epr_manager,1,1,1,0 -access_epr_purchase_request_line_manager,ePR Line Manager,model_epr_purchase_request_line,group_epr_manager,1,1,1,0 -access_epr_reject_wizard_manager,ePR Reject Wizard Manager,model_epr_reject_wizard,group_epr_manager,1,1,1,1 +access_epr_purchase_request_line_manager,ePR Request Line Manager,model_epr_purchase_request_line,group_epr_manager,1,1,1,0 access_epr_purchase_request_officer,ePR Request Officer,model_epr_purchase_request,group_epr_purchasing_officer,1,1,1,0 -access_epr_purchase_request_line_officer,ePR Line Officer,model_epr_purchase_request_line,group_epr_purchasing_officer,1,1,1,0 +access_epr_purchase_request_line_officer,ePR Request Line Officer,model_epr_purchase_request_line,group_epr_purchasing_officer,1,1,1,0 access_epr_purchase_request_admin,ePR Request Admin,model_epr_purchase_request,group_epr_admin,1,1,1,1 -access_epr_purchase_request_line_admin,ePR Line Admin,model_epr_purchase_request_line,group_epr_admin,1,1,1,1 +access_epr_purchase_request_line_admin,ePR Request Line Admin,model_epr_purchase_request_line,group_epr_admin,1,1,1,1 +access_epr_rfq_officer,ePR RFQ Officer,model_epr_rfq,group_epr_purchasing_officer,1,1,1,0 +access_epr_rfq_line_officer,ePR RFQ Line Officer,model_epr_rfq_line,group_epr_purchasing_officer,1,1,1,0 +access_epr_rfq_admin,ePR RFQ Admin,model_epr_rfq,group_epr_admin,1,1,1,1 +access_epr_rfq_line_admin,ePR RFQ Line Admin,model_epr_rfq_line,group_epr_admin,1,1,1,1 +access_epr_approval_config_officer,ePR Approval Config Officer (read only),model_epr_approval_config,group_epr_purchasing_officer,1,0,0,0 +access_epr_approval_config_admin,ePR Approval Config Admin,model_epr_approval_config,group_epr_admin,1,1,1,1 +access_epr_approval_entry_user,ePR Approval Entry User (read only),model_epr_approval_entry,group_epr_user,1,0,0,0 +access_epr_approval_entry_manager,ePR Approval Entry Manager,model_epr_approval_entry,group_epr_manager,1,1,0,0 +access_epr_approval_entry_officer,ePR Approval Entry Officer,model_epr_approval_entry,group_epr_purchasing_officer,1,1,0,0 +access_epr_approval_entry_admin,ePR Approval Entry Admin,model_epr_approval_entry,group_epr_admin,1,1,1,1 +access_epr_reject_wizard_user,ePR Reject Wizard User (read only),model_epr_reject_wizard,group_epr_user,1,0,0,0 +access_epr_reject_wizard_manager,ePR Reject Wizard Manager,model_epr_reject_wizard,group_epr_manager,1,1,1,1 +access_epr_reject_wizard_officer,ePR Reject Wizard Officer,model_epr_reject_wizard,group_epr_purchasing_officer,1,1,1,1 access_epr_reject_wizard_admin,ePR Reject Wizard Admin,model_epr_reject_wizard,group_epr_admin,1,1,1,1 +access_epr_create_po_wizard_officer,ePR Create PO Wizard Officer,model_epr_create_po_wizard,group_epr_purchasing_officer,1,1,1,1 +access_epr_create_po_wizard_admin,ePR Create PO Wizard Admin,model_epr_create_po_wizard,group_epr_admin,1,1,1,1 +access_epr_create_po_line_wizard_officer,ePR Create PO Line Wizard Officer,model_epr_create_po_line_wizard,group_epr_purchasing_officer,1,1,1,1 +access_epr_create_po_line_wizard_admin,ePR Create PO Line Wizard Admin,model_epr_create_po_line_wizard,group_epr_admin,1,1,1,1 diff --git a/addons/epr/views/epr_approval_views.xml b/addons/epr/views/epr_approval_views.xml new file mode 100644 index 0000000..81b6b4e --- /dev/null +++ b/addons/epr/views/epr_approval_views.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + epr.approval.config.list + epr.approval.config + + + + + + + + + + + + + + + + + epr.approval.config.form + epr.approval.config + +
+ +
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + epr.approval.config.search + epr.approval.config + + + + + + + + + + + + Approval Rules + epr.approval.config + list,form + +

+ Define your Purchase Request approval rules here. +

+
+
+ + + + + + + + epr.approval.entry.list + epr.approval.entry + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + purchase.order.search.inherit.epr + purchase.order + + + + + + + + + + + + My Purchase Orders + purchase.order + list,form + [('epr_rfq_count', '>', 0)] + {'create': False} + +

+ No Purchase Orders created from EPR RFQs yet. +

+
+
+ +
+
\ No newline at end of file diff --git a/addons/epr/views/epr_purchase_request_views.xml b/addons/epr/views/epr_purchase_request_views.xml index 67f3a4a..2966bb4 100644 --- a/addons/epr/views/epr_purchase_request_views.xml +++ b/addons/epr/views/epr_purchase_request_views.xml @@ -173,6 +173,7 @@ epr.purchase.request
+
+ + diff --git a/addons/epr/views/epr_rfq_views.xml b/addons/epr/views/epr_rfq_views.xml new file mode 100644 index 0000000..b82f9ba --- /dev/null +++ b/addons/epr/views/epr_rfq_views.xml @@ -0,0 +1,221 @@ + + + + + + + + + epr.rfq.list + epr.rfq + + + + + + + + + + + + + + + + + + + + + epr.rfq.form + epr.rfq + + + +
+ +
+ + + + + + +
+ Request for Quotation +

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + epr.rfq.search + epr.rfq + + + + + + + + + + + + + + + + + + + + + + + + + + + Requests for Quotation + epr.rfq + list,form + + + +

+ Create your first Request for Quotation (RFQ) +

+

+ Manage your vendor negotiations and create Purchase Orders directly from RFQs. +

+
+
+ + \ No newline at end of file diff --git a/addons/epr/wizards/__init__.py b/addons/epr/wizards/__init__.py index a798f2d..0f3b969 100644 --- a/addons/epr/wizards/__init__.py +++ b/addons/epr/wizards/__init__.py @@ -1 +1,2 @@ from . import epr_reject_wizard +from . import epr_create_po diff --git a/addons/epr/wizards/__pycache__/__init__.cpython-312.pyc b/addons/epr/wizards/__pycache__/__init__.cpython-312.pyc index b86063fac7683a5b165bae1da9ce20dde809b1c6..e198c2d00a0ea5231eebc1db2cc42e170dfe6a4b 100644 GIT binary patch delta 122 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 Z*OR|84>Pu_hx2X2a@+792s~(F{|o1;mGJ-o literal 0 HcmV?d00001 diff --git a/addons/epr/wizards/epr_create_po.py b/addons/epr/wizards/epr_create_po.py new file mode 100644 index 0000000..4144070 --- /dev/null +++ b/addons/epr/wizards/epr_create_po.py @@ -0,0 +1,181 @@ +from odoo import models, fields, api, Command, _ +from odoo.exceptions import UserError + + +class EprCreatePoWizard(models.TransientModel): + _name = 'epr.create.po.wizard' + _description = 'Merge RFQs to Purchase Order' + + # Hiển thị Vendor chung để user confirm + partner_id = fields.Many2one( + comodel_name='res.partner', + string='Vendor', + required=True, + readonly=True + ) + + currency_id = fields.Many2one( + comodel_name='res.currency', + string='Currency', + required=True, + readonly=True + ) + + # Danh sách các dòng sẽ được đẩy vào PO (Cho phép user bỏ tick để xé nhỏ RFQ) + line_ids = fields.One2many( + comodel_name='epr.create.po.line.wizard', + inverse_name='wizard_id', + string='Products to Order' + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_ids = self.env.context.get('active_ids', []) + if not active_ids: + return res + + # 1. Lấy danh sách RFQ được chọn + rfqs = self.env['epr.rfq'].browse(active_ids) + + # 2. Validate: Cùng Vendor và Currency + first_partner = rfqs[0].partner_id + first_currency = rfqs[0].currency_id + + if any(r.partner_id != first_partner for r in rfqs): + raise UserError(_("Tất cả các RFQ được chọn phải cùng một Nhà cung cấp.")) + + if any(r.currency_id != first_currency for r in rfqs): + raise UserError(_("Tất cả các RFQ được chọn phải cùng loại Tiền tệ.")) + + if any(r.state != 'confirmed' for r in rfqs): # Giả sử trạng thái 'confirmed' là đã chốt + raise UserError(_("Chỉ có thể tạo PO từ các RFQ đã xác nhận (Confirmed).")) + + # 3. Loop qua từng dòng RFQ để prepare dữ liệu cho Wizard + lines_list = [] + for rfq in rfqs: + for line in rfq.line_ids: + # Chỉ load những dòng chưa tạo PO + if not line.purchase_line_id: + lines_list.append(Command.create({ + 'rfq_line_id': line.id, + 'product_id': line.product_id.id, + 'description': line.description, + 'quantity': line.quantity, # User có thể sửa số lượng tại wizard nếu muốn partial + 'price_unit': line.price_unit, + 'uom_id': line.uom_id.id, + 'taxes_id': [Command.set(line.taxes_id.ids)], + })) + + if not lines_list: + raise UserError(_("Không tìm thấy dòng sản phẩm nào khả dụng để tạo PO (có thể đã được tạo trước đó).")) + + res.update({ + 'partner_id': first_partner.id, + 'currency_id': first_currency.id, + 'line_ids': lines_list + }) + return res + + def action_create_po(self): + self.ensure_one() + if not self.line_ids: + raise UserError(_("Vui lòng chọn ít nhất một dòng sản phẩm.")) + + # 1. Prepare Header PO (Chuẩn Odoo) + po_vals = { + 'partner_id': self.partner_id.id, + 'currency_id': self.currency_id.id, + 'date_order': fields.Datetime.now(), + 'origin': ', '.join(self.line_ids.mapped('rfq_line_id.rfq_id.name')), # Source Document + 'order_line': [], + } + + # 2. Prepare PO Lines + for w_line in self.line_ids: + po_line_vals = { + 'product_id': w_line.product_id.id, + 'name': w_line.description or w_line.product_id.name, + 'product_qty': w_line.quantity, + 'price_unit': w_line.price_unit, + 'product_uom': w_line.uom_id.id, + 'taxes_id': [Command.set(w_line.taxes_id.ids)], + # inherit purchase.order.line để link 2 chiều chặt chẽ + 'epr_rfq_line_id': w_line.rfq_line_id.id + } + po_vals['order_line'].append(Command.create(po_line_vals)) + + # 3. Tạo PO + purchase_order = self.env['purchase.order'].create(po_vals) + + # 4. Update ngược lại RFQ Line (Line-Level Linking) + # Vì PO Line được tạo qua Command.create, ta cần map lại ID + # Cách đơn giản nhất: Loop lại PO Lines vừa tạo + + # Lưu ý: Logic này giả định thứ tự tạo không đổi, để chính xác tuyệt đối + # nên thêm field 'epr_rfq_line_id' vào 'purchase.order.line' + for i, po_line in enumerate(purchase_order.order_line): + # Lấy dòng wizard tương ứng theo index (nếu thứ tự được bảo toàn) + # Hoặc tốt hơn: Thêm field epr_rfq_line_id vào purchase.order.line (Xem mục 3) + wizard_line = self.line_ids[i] + wizard_line.rfq_line_id.write({'purchase_line_id': po_line.id}) + + return { + 'type': 'ir.actions.act_window', + 'res_model': 'purchase.order', + 'res_id': purchase_order.id, + 'view_mode': 'form', + 'target': 'current', + } + + +class EprCreatePoLineWizard(models.TransientModel): + _name = 'epr.create.po.line.wizard' + _description = 'Line details for PO creation' + + wizard_id = fields.Many2one( + comodel_name='epr.create.po.wizard', + string='Wizard', + required=True, + readonly=True + ) + + rfq_line_id = fields.Many2one( + comodel_name='epr.rfq.line', + string='RFQ Line', + required=True, + readonly=True + ) + + product_id = fields.Many2one( + comodel_name='product.product', + string='Product', + readonly=True + ) + + description = fields.Text( + string='Description', + readonly=True + ) + + quantity = fields.Float( + string='Quantity', + required=True + ) + + uom_id = fields.Many2one( + comodel_name='uom.uom', + string='UoM', + readonly=True + ) + + price_unit = fields.Float( + string='Price', + readonly=True + ) + + taxes_id = fields.Many2many( + comodel_name='account.tax', + string='Taxes', + readonly=True + ) diff --git a/addons/epr/wizards/epr_create_po_views.xml b/addons/epr/wizards/epr_create_po_views.xml new file mode 100644 index 0000000..020ca55 --- /dev/null +++ b/addons/epr/wizards/epr_create_po_views.xml @@ -0,0 +1,53 @@ + + + + epr.create.po.wizard.form + epr.create.po.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + + Create Purchase Order + epr.create.po.wizard + form + new + + list + + +
\ No newline at end of file