From d536f5c11be697be55885917d4055c76965d4121 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 21 Nov 2022 09:36:58 +0100 Subject: [PATCH 01/84] add shopfloor_reception shopfloor_reception: only return incoming pickings after scans shopfloor_reception: split first screen shopfloor_reception: introduce multiuser shopfloor_reception: update first screen domain shopfloor_reception: fix line splitting shopfloor_reception: reassign user to picking at any time shopfloor_reception: add auto_post_line menu option shopfloor_reception: auto post lines --- shopfloor_reception/README.rst | 1 + shopfloor_reception/__init__.py | 2 + shopfloor_reception/__manifest__.py | 21 + .../data/shopfloor_scenario_data.xml | 16 + .../demo/shopfloor_menu_demo.xml | 18 + .../demo/stock_picking_type_demo.xml | 23 + .../docs/reception_sequence_graph.mermaid | 61 + .../docs/reception_sequence_graph.png | Bin 0 -> 155103 bytes shopfloor_reception/models/__init__.py | 1 + shopfloor_reception/models/shopfloor_menu.py | 29 + shopfloor_reception/readme/CONTRIBUTORS.rst | 2 + shopfloor_reception/readme/DESCRIPTION.rst | 2 + shopfloor_reception/readme/ROADMAP.rst | 1 + shopfloor_reception/services/__init__.py | 1 + shopfloor_reception/services/reception.py | 1331 +++++++++++++++++ shopfloor_reception/tests/__init__.py | 11 + shopfloor_reception/tests/common.py | 138 ++ .../tests/test_manual_selection.py | 37 + .../tests/test_reception_done.py | 93 ++ .../tests/test_select_dest_package.py | 131 ++ .../tests/test_select_document.py | 217 +++ shopfloor_reception/tests/test_select_move.py | 274 ++++ .../tests/test_set_destination.py | 161 ++ shopfloor_reception/tests/test_set_lot.py | 158 ++ .../tests/test_set_lot_confirm.py | 65 + .../tests/test_set_quantity.py | 348 +++++ .../tests/test_set_quantity_action.py | 86 ++ shopfloor_reception/tests/test_start.py | 50 + shopfloor_reception/views/shopfloor_menu.xml | 18 + 29 files changed, 3296 insertions(+) create mode 100644 shopfloor_reception/README.rst create mode 100644 shopfloor_reception/__init__.py create mode 100644 shopfloor_reception/__manifest__.py create mode 100644 shopfloor_reception/data/shopfloor_scenario_data.xml create mode 100644 shopfloor_reception/demo/shopfloor_menu_demo.xml create mode 100644 shopfloor_reception/demo/stock_picking_type_demo.xml create mode 100644 shopfloor_reception/docs/reception_sequence_graph.mermaid create mode 100644 shopfloor_reception/docs/reception_sequence_graph.png create mode 100644 shopfloor_reception/models/__init__.py create mode 100644 shopfloor_reception/models/shopfloor_menu.py create mode 100644 shopfloor_reception/readme/CONTRIBUTORS.rst create mode 100644 shopfloor_reception/readme/DESCRIPTION.rst create mode 100644 shopfloor_reception/readme/ROADMAP.rst create mode 100644 shopfloor_reception/services/__init__.py create mode 100644 shopfloor_reception/services/reception.py create mode 100644 shopfloor_reception/tests/__init__.py create mode 100644 shopfloor_reception/tests/common.py create mode 100644 shopfloor_reception/tests/test_manual_selection.py create mode 100644 shopfloor_reception/tests/test_reception_done.py create mode 100644 shopfloor_reception/tests/test_select_dest_package.py create mode 100644 shopfloor_reception/tests/test_select_document.py create mode 100644 shopfloor_reception/tests/test_select_move.py create mode 100644 shopfloor_reception/tests/test_set_destination.py create mode 100644 shopfloor_reception/tests/test_set_lot.py create mode 100644 shopfloor_reception/tests/test_set_lot_confirm.py create mode 100644 shopfloor_reception/tests/test_set_quantity.py create mode 100644 shopfloor_reception/tests/test_set_quantity_action.py create mode 100644 shopfloor_reception/tests/test_start.py create mode 100644 shopfloor_reception/views/shopfloor_menu.xml diff --git a/shopfloor_reception/README.rst b/shopfloor_reception/README.rst new file mode 100644 index 0000000000..f130ce14c7 --- /dev/null +++ b/shopfloor_reception/README.rst @@ -0,0 +1 @@ +wait 4 da bot diff --git a/shopfloor_reception/__init__.py b/shopfloor_reception/__init__.py new file mode 100644 index 0000000000..71a02422d5 --- /dev/null +++ b/shopfloor_reception/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py new file mode 100644 index 0000000000..bd8bafd619 --- /dev/null +++ b/shopfloor_reception/__manifest__.py @@ -0,0 +1,21 @@ +{ + "name": "Shopfloor Reception", + "summary": "Reception scenario for shopfloor", + "version": "14.0.1.0.0", + "development_status": "Beta", + "category": "Inventory", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["mmequignon", "JuMiSanAr"], + "license": "AGPL-3", + "installable": True, + "depends": ["shopfloor"], + "data": [ + "data/shopfloor_scenario_data.xml", + "views/shopfloor_menu.xml", + ], + "demo": [ + "demo/stock_picking_type_demo.xml", + "demo/shopfloor_menu_demo.xml", + ], +} diff --git a/shopfloor_reception/data/shopfloor_scenario_data.xml b/shopfloor_reception/data/shopfloor_scenario_data.xml new file mode 100644 index 0000000000..ae8db3c78b --- /dev/null +++ b/shopfloor_reception/data/shopfloor_scenario_data.xml @@ -0,0 +1,16 @@ + + + + + + Reception + reception + +{ + "auto_post_line": true +} + + + + diff --git a/shopfloor_reception/demo/shopfloor_menu_demo.xml b/shopfloor_reception/demo/shopfloor_menu_demo.xml new file mode 100644 index 0000000000..17022444fb --- /dev/null +++ b/shopfloor_reception/demo/shopfloor_menu_demo.xml @@ -0,0 +1,18 @@ + + + + + + Reception + 45 + + + + + + + diff --git a/shopfloor_reception/demo/stock_picking_type_demo.xml b/shopfloor_reception/demo/stock_picking_type_demo.xml new file mode 100644 index 0000000000..0279e0806f --- /dev/null +++ b/shopfloor_reception/demo/stock_picking_type_demo.xml @@ -0,0 +1,23 @@ + + + + + + Reception + RCP + + + + + + + + + internal + + + + + + diff --git a/shopfloor_reception/docs/reception_sequence_graph.mermaid b/shopfloor_reception/docs/reception_sequence_graph.mermaid new file mode 100644 index 0000000000..ea0c509bb2 --- /dev/null +++ b/shopfloor_reception/docs/reception_sequence_graph.mermaid @@ -0,0 +1,61 @@ +%%{init: {'theme': 'neutral' } }%% +sequenceDiagram + participant select_document + participant select_line + participant set_lot + participant set_quantity + participant set_destination + participant select_dest_package + rect rgb(0, 250, 250) + note left of select_document: scan_document(barcode) + select_document ->> select_document: Error: barcode not found + select_document ->> select_document: Multiple picking matching the product / packaging barcode + select_document ->> select_line: Picking scanned, one has been found + select_document ->> set_lot: Packaging / Product has been scanned, single correspondance. Tracked product + select_document ->> set_quantity: Packaging / Product has been scanned, single correspondance. Not tracked product + end + rect rgb(100, 250, 170) + note left of select_line: scan_line(picking_id, barcode) + select_line ->> select_line: Error: barcode not found + select_line ->> set_lot: Packaging / Product has been scanned, single correspondance. Tracked product + select_line ->> set_quantity: Packaging / Product has been scanned, single correspondance. Not tracked product + end + rect rgb(250, 220, 200) + note left of set_lot: set_lot(picking_id, select_line_ids, lot_name=None, expiration_date=None) + set_lot ->> select_line: User clicked on back + set_lot ->> set_lot: Barcode not found. Ask user to create one from barcode + set_lot ->> set_lot: expiration_date has been set on the selected line + set_lot ->> set_lot: lot_it has been set on the selected line + set_lot ->> set_lot: Error: expiration_date is required + note right of set_lot: set_lot_confirm_action(picking_id, select_line_ids) + set_lot ->> set_quantity: User clicked on the confirm button + end + rect rgb(250, 150, 250) + note left of set_quantity: set_quantity(picking_id, select_line_ids, quantity=None, barcode=None, confirmation=None) + set_quantity ->> set_quantity: Quantity has been set + set_quantity ->> set_quantity: User scanned a product / packaging, quantity has been incremented + set_quantity ->> set_quantity: Error: User tried to scan a package with a non valid location + set_quantity ->> set_quantity: Error: User tried to scan a non valid location + set_quantity ->> set_quantity: Warning: User scanned an unknown barcode. Ask to create a package + set_quantity ->> select_line: User scanned a package with a valid location + set_quantity ->> select_line: User scanned a valid location + set_quantity ->> set_destination: User scanner a package with no location + note right of set_quantity: process_with_new_pack(picking_id, select_line_ids) + set_quantity ->> set_destination: User confirmed the creation of a new package + note right of set_quantity: process_with_existing_pack(picking_id, select_line_ids) + set_quantity ->> select_dest_package: User asked to use an existing package + note right of set_quantity: process_with_new_pack(picking_id, select_line_ids) + set_quantity ->> set_destination: User clicked on "process with new pack" + end + rect rgb(220, 220, 220) + note left of set_destination: set_destination(picking_id, selected_line_ids, location_id, confirmation=False) + set_destination ->> set_destination: Warning: User scanned a child location of the picking type. Ask for confirmation + set_destination ->> set_destination: Error: User tried to scan a non-valid location + set_destination ->> select_line: User scanned a child location of the move's dest location + end + rect rgb(250, 150, 150) + note left of select_dest_package: select_dest_package(picking_id, selected_line_ids, location_id, confirmation=False) + select_dest_package ->> select_line: User scanned a valid package + select_dest_package ->> select_dest_package: Warning: User scanned an unknown barcode. Confirm to create one. + select_dest_package ->> select_dest_package: Error: User scanned a non-empty package + end diff --git a/shopfloor_reception/docs/reception_sequence_graph.png b/shopfloor_reception/docs/reception_sequence_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..ba77d46fa5ed7715f243e3e419efd7386202cc65 GIT binary patch literal 155103 zcmeFZcTkjF)-MVOh#&%nMlcX0+kg@!H#vhKsfi*v3z7s$k|bvkkenscWQ0bLEE!2n z5CsWMlBhHdclSH<&YbzGX6`rNsk-N$s__r&spjb&)?VrNTWfdNV>NklQd&|xJUntm z1sP2|JmLsEJVHGZV(`g~;szHU9zC9-jHH&Q@#g%+cTCz%=jTP82g@@VGxIaI``}?p zjsBz@;R<7P@z=R@wFr|+J;{$baQ|E$-dCiOg~1-gRxH2cP5-{RxxJh*J%7F`G_Y&( z)WfoQ)zw3Ls>ry~XK>T?OUa7`^+Od8ABf#zgciNH(raBv1|y zpO6yvPyTy{XG(RE;GYeG8=cY=zay7~IxFYiKc5VbAV~K1g@3u6AR>|oM#EPj(f@SC z;184#=ga?MPk01W5PE3%^Pe=r|7yiBy-yGS)mlkp^!%&XI=ZaZ{?&?sV0!-5TK^-M ze<<-+%nhh=8CO{b419BoRu?!@gA0uVDrT2E~hW_Tu>= zA;gL-2@@~L2r@D)#@4;<$gmps#Eb%{JJ9nH8yXKu| z#vP<*%>9%h?u*c77DtWX_XJk;E7(Vm#Hl><{O_QSZmTj~&FQY5i8#7#A&D2zK{|~V z6cM`fgQ9mDEr3S)jD!*eJRrO5x6AbzalF;q5p?NV|2Hwyw=v8o#-8{Xl+c3*lqJUr zN2m43x}%TE5Rl~47lTAxviveO{2k2C4=ExEzmm0aJ5x#AW-Pz=i*qeLicAJWs6DMN za}5%uV{@0H)W6kcYkT_&mi27YV=$5ZNryi1NfgYQu%7{1Gvxq*%v`dC5OzST9Os2S zz6pIcjce_$Uu9%X=zPUAc3e%Or)Rzj>o{xkgqqzk?lI0nsTfTk}@*po4t;;O} zsDl%H+xkoTC^0TqZ}0j!&ELTo^Oi3dh7Z($)L$uqB(WOLRD*^(B_~)7yyD87*ZIGo z144c9*~m)H3yrG)G04?jipbTllA|#OqwY>dXWn~E3Z68J5$c5XP;iEE0WeQ6ZpV}R zUg3BIqqy(rw9+{E1JFWXcss`E9(3=G;uDhVF#oDzykfpE7!4UEB+1#CMt==_O-h$X z`1apn5-c0{HKV)2)Xca|Xr=!j+ITNBRKLZ&T=eYM_q}Zoq<4gj$HL2^+g@uqPF|aZ z>5lQ(<3$q|)wJbccRNgm%;kbq(~5yOrA#1}7G>@D|Qjp^x`hmg}#G6hJ5$U2>7G!-na7&xf!MmTdaFqf4yREt6At$04lwM@HC#N`mq_6>U?HRCn)|lc_3G@*aqIZ?)_}1$ zYH=(MVo=o9X8ivB`&SRgu7LFPtC4?IuLwIiV{BIYWJhjS1ogn;`I*Pd9+3rxhI#jN z5h1@%RPw$1!xB5jv92B-%4TMH`QtXnSOJv%y(uG)jCVdqGh=geo5Bd^?{ZC>H3Rv1 zd7Z%|$|+oKPB8Zw3+n3Kr{F!etvk=#@oQ|qa5U|-VdlPfqEKXMOIWB$+uYXh!l!An zvTN$18t$_;MN{NyRo3Vpm^d&#wv2~nAu8zts&`*5$4war-F2JLa9K&xZ8rP%)-2lD zEla|$O%$9>&6Kfs-^Yt|r~2}nU3GV){jg}0`4gQ^;1xYy+cegP()s>+OJUrR`|wHi zvtGhiL9$1p$mMwDAB<4J1S4Nr4~E0_|n_xfz{AEs*7@ZiQe-wjBg3< z1}nk2E`>CT^{%T18RmV;@Bvo8+2 zs7+k`rnZDE{fe?Yzg-gAtm^#KaStQn`yAGUTYoaEp}Gl z&^nl5AhebpxO{@H^c!DXB;)=tf{mK@Ii6ps?z#J7*a~w?^wrR(1uIlj9QFvMe)rX` zWcA3ei6`oA4u?la&HIe^S{qoPdw#b^83{;!QPV^BoRlI+mYJ*Ss(Ov>4POlMZ=3tK z!fFyOwrtg>leuMjZLl9pbsN7egCn|I6{}aGvYM+CVgv@vyTWfgGaY6X|9R>6pp)k1sn!WS}%jAYd_ajAESk&IFE$p@(mOCX$oGwwg%sKb8ZYQXA_5`(B->qoD z+V9W$?grA`-mg~HZ zBYZjRz3^R40EHIu_OYb9YwP4hEn@0Gw^yC|&3(^%SnSxf@&f108vkD7Rk-(&;DRwG z<9WxWyer{V)>@1@Bw_6erg2#EdWzQuJsHmn{!_iH#<08U{pl$@O2A4Ua2P`LkRF3Ot`z-2Ld-!tP4heB(p`Ht2{ zJX+C4+f4qgGga!XGxiJ^*K{JGBW#r50^5%FBE}pj){B;UYOWL$-zM+zXROqlGOvBo zhQ#3>=h)*G|0S@57ZQk6MW@RVn=US$nBP<2?DX}_(J0n!`>b^CAX$S-PvDroEE*Tj zsxXk9xi7r5I+k)h+ML-AO1N{)ov}}i4@G7zBI)3yBIx(idw{c{ajVR?{_;iN^r=a% zf`^mARn}#iWz+)rbl{Ax)C#87PwyC|m)NidZ+h(iK=N)Kw_&*&1appvrxzM$!}0{h z{q`R+LHp&S94LE@o|c)8{yFTz?B_Vh-XiikO+9QcP0W2i=F7m0`a#M0$bMPS$3V`LK}pxj8SHeRX!#L8CLB4bbtOObvlFj4$@&!AaFM) z^sdXQim}%^^YQUi$LbP=!z}ZHQ{xgb^-a{<5KK$K@nn$fcX4}b!25>N!}PQW&D{gq z^$1;>tVZ=_5!CGNVMGR-!Tt3pN0H>g4`tGNwQk+7I&xn{Jo^}*@u;SYOLBGpOe#qB zw@&cZYp$>NptLrTT_^J@de;x`MV%!@wCi=|#&z4Vmf6d_=5$Xr5&3DJrCF~ojq3aH z3WAA^&A5)Bk;YmtKCCSOG&FEw;WB) z3*}gT2sWo+G|N>0%jwL078O-htT(pFxN~-v7}wS7c^nW`_O7 zjTwT1>jMLYso05Ui@1VyBuq6r{X_S024UuBMn znNn3C2daq~`L%*VNl8gWbm})fMkXg&)?J&Q8a32!sk-DCdm5l1w)#9IJyRL73)wr|ojiHQpv`{vV@7Xl z$AP_Dbh5eXF33UuC}6KxnbOCkH%T`|FY9C9f4Z$*!t%pHEjXJ~Ou zggrxhOk2s_$m`y`H%AbBYGfZ!?UP&eIE(g&5bd^E%tEO8(_e%xIbI86V~hzbsvK)} zg2oLu-h0&=*at|RVe7AbJ`(osPh=;$mE79RHQ^of+AS@Fs`p7dL7tQ^%uac}hC*R_ z@yq&n(drEPRPg)n4CEaHfX08~f{xW-CM8Lbf9r!9_0k)A*WL%*v__gb~t!tB?`|kJu~jS5t!jazsrK(!FYv~u!9@Se@4qgKuF0}2Hc6MkB!ee0zqp% zS3clMZkQl{H+gj|afmIAXnwtg@Y>>wT48z~86sPHC^Lo)k`-#I<{{|1`Uu{c%gV>* zrGMcb4%I*93)lzKR)T5KhsG=rh#M6oO0N4m$#og9ZTd34aF_=0LE-&_Lx2JZ0zzSc z>L)g<=q)2>gJjQC11A|xHAXn3{3tzJK~+nhkbq%nG>to#x(4|zNUk3Nh=o!TEHKPBX5divK)Tw$7|ltw3nae$B%z*? zEeh<{W^U#_!Jjxj++6)q#A!-KRWH(JL?5Kq`m$>jIJXHZg|SHR_L;kVn7=xZTvl3b zFw6#Ml+yPhV=5TED0pkBM4QQ53G89>ANN2H{dNR)dO73F_+V%540>vyEk>L}Y;MBilUa^|lmDyXyWaM0cRF(S{5Yr} zNq$i@rj106RvL|H`5h{@da$zUt{FN415j-I)(fLQ#jU22;U1W^159>fGG(%dz3QbN zUs0WQ^vRG%J`(Bot~~kphaH84q2sUiizE^KEuZg2NB^bBm;-1IQrXlsCV~LmvQ*gD z6h48)Gy38^IIY1^AQ3z~BtT!X!Mym+HeR^tnn2`Qanx}%04#%nERA{-2tc?83W8vq zXDj0_zxO}~oLse1^<9FGI7Y?M0T>qvp)_!uH{G(S4Zu|W*b(sGeA!$*_)0@KPo~SY zzdYO}B)t!0rDviq|FjIuW|s6!jPN!1==$932rnTeNeEcVgAEurNyLXc7lLFL*rZXE zrW!c%?jDZIiId`0Aru8mdigq^hOirG0G}F6K%O2~2F7P2Fam;t0iz{q7Im%AgFk{;HQQ$d33 zU0p>P!FW{ce4R3YB{Yp=RY;S3`VM^bozVORAptHS@Mjz$0?Vxh%gr8;UUj(?akS{? z2U6*TU^41{u9i(k?$(2Nl`L_iAH!MPxFq+T>zE>rc))>^GJ}SprJ+nQgQHnHZW~+M z_?DeE16)ExbNhDU!h&&0MMZK^rdPk%*{5sjy+ECYI;kvI|kr*CMT2fZ#>VTS>oz+jp#g%az5E}QU&0_ihKbK!n z&{d8s2(EgwO8!TUZD7yt7ww5cOThmSme4$_p0jP__zfRjZ2|7KkN?y5aqvt!|Rx~rI1^5y6Big zOmSYcOU@KxSp5E-J9oyWrdUD5txyL#c%`n!_DxlBS%2Ttzq$Lr84Yc~ZQwmz+Mj6? z5^w;;80i?{1d0&}6yvFqq`f~pBcr|Us$NU7na^|v!(9A= zb8Ulq`UuM?pv^slP~olGar4d)@*2;d&!z-X-LKix%MSo*XrYl4FzAD%mzeX0+SfmU zLJ|OJO+!oT^1X3?NM!yp3u(z3SiO3+0snVS( zt^3t<+wf)S>JKQO0y`%>&AY%hgOMSTcmdCF?(9jr4?~q3@WRno{z?1_CW+{tDR?K? zFeB-XWH1@9-g=1WO>y-^G5o>gzT^Xo!qKZ?{@h>~lla=e_Fv$m?$w@25<*-mN1~lg z0XSWA;IZg+L)#REf@Hh6?%~{QV1q&gM;*`sTyd%JT(%xC`mT6O2_SD=gs9TtV#ETL$;7&Y@w`ydF0P8r z`bLh#Ut4WpXW;GMsA^^caj=nBwqp>20uVVkqC~u9@d9?iTfs#-ahvQQ52*RSIuCH6 zhhYg>{)1S3hi^wL)B-a^Q7y6LMJX$PFR3xkDST;^3f$x>9M6t@opGu!1V* zwit{F9ld~V)eI#A@jT+8B&l!jb8y&`(M}RIGP<)FJthLYOb|FU`hFNIB_+rK3I2%< zt=xsEw*8<@{Wa{Tq@zyDg_)f)S3v4NH$SgQN{XMZk{nB5RsK+^UPHTWZf7eXb~i?O zBT3hMGRBul8Tpf3(QYKsutg_?MQtMV(AhvR?zyCvaO(BZY?cy+(Z??kuPO>;D_*WM zD*^@@+O{jDSl=)(lE_}6Ssj2MMt()9TkYwF_?$rV`41DU(rnBlDPqf^>!Siz*sm9B zoF}0+5=YajK3@(IC;SztSzj~AGk9$NqAt{s4N;y&ZAiaUau#YSz!0jKq!d-CEMQzb( z8midobF8Q-;gC5B>+ML6!ff8-R~KjZdGSL*N%|fJKJ3`iL}`WXV^uB)kwqYPiTYGB zIP8XmmD9LewZ>FcYn(A#79C>83Oyo=>~1lljDfUF76&E;#4>l9vjVeT%4M2VOPy%4 zQ!5ncRJfv!H|tV9Prz7nOvgq9PzvV<>zaP&*cxY)L&5ffw}mxebpa)*jLq=qpP)O} zoAf;yA3JD3HdPgS$B3Cf?#wx1&5Vsa;vOAcnrz5YZ{AZ(#qtOEX=!f&f0GW;}*$7Kj6+oLOcf2kWm_u0pzI#%{u|r1I7Draxds<>dT*9 zV?XN~6j$DB$B(4S@()XJ-wD0T5hxs3tcG+F+kQkd0hdM~kHz55M>9MIR)m9VTS>Af zK7hgEN-EVIXpf{BYj_$^Cb}aEf4T{9~yn^&DLoFMMp z^)pOHtK`)Z!xYJ*iQN|M)I?NIJmxg&N1xk95s9T3{jj`eu@&#?Ws+yla_?bl{+tqd zG&Pw@n~CJKOD^B?kiAcE-^Fc z|D2mE<_B<^++Z|iuQE^+ZUF%)`WmqHI8Lz(6mNmqw3AACQ1;@IcnUrNfW#DV#MAq^ zrCat*p|U@>9TnFj>05?}V~x89x5W}iyh)k{>TF1!9L~KMt4s+}ww~t6kAbh5MGz?n zpn5H@F+&N{1l2iAJUBa2}opA!KQ6*P|SD^h(S4Ig@zm)l1lj+ZAN253q*mz^dl-F-@n4? zOHpzpVZbwBRyU+medr%F1Cnk{t}FmH0^WLU%y1HlM^FHgVh%T=>GL;W{4)yRm;cw$ z_rVx|*n0%{gf2LcBpcyI#Rl47HJ0pcXab($La}lh7MQ6X7_URW>CHiURj*%811+Rr ze0$8}Y7AzPof870b8+$9=H-BQ;4K#xvkQcbxU6k`kM|cI7=ozyrYlhZZryu9W&lyW z!DXW6N_U0=u0noSqGkX(F*y*IHS(g%4_e@5FJ#a#L{hgpY0B*co1Zs$-!3>bmJb{p z924}Y$UpQ|g8miIuG#Q`A#>p#a#4R5t1tNI)N72E;BWPGynqK_4Z$V)RGarfE5 zAi6dtn~MP*I8k>e=}@uZytzv`^gC%f*v&MDoFA1Z0!Jti0nX|{4em(+SNc9pVa@V} zXqOptUITz?^3ID$^9057j9l4DFr=}N&1L)@@UWn0Lm5zmJ1F!XPa`F6!Nq#6Hv-CW z4$~Uf>4IYl7{CjACLchN3N2exu1b5?6DIKCxb;j#Ho_Q8^}%KACcy&kAz_T3zychB z*5r!Ca|Xa=kZM$~ar`j`j-)jv_zdljh>8kl26Tmq@M0C403TC=Q=_DQjF=7+WPkCY z{sBm~m!slCDA{mCu%w>N^?({2*hY=B>%g)vl)Fcf>lCKdrv90XPFwDVsvR34g5_NiK&hHMvjt(10- zP?#M`PA^=|P)#Y2#1pJ@uV2xOBoPoi8Z#+x@k0PL#Kshyn@$pmqZ5~KL>R8SIta8= z^!oi=I6tf$2nQ%f#;T~c6}vUIHyYDC&~I_=QkXkl}Du=MB#z%>4 z(qj=;n4Qa?@KJvM;L{6$)Vu!m+Xz2lH|W9PlVj77#tY~reLkGqn=F3&0xM9Oq9t#L zn>eN{KRwOdpGNvDPI(USV7R=T>Lv)&Z=?zTJr5AtnG3PJ{JV!-0dQgX?1b~Mr}uIC zMGM%h2(TQG}m57V@gdI3UI@vFqzX&viOjU1rk>6xf=x4A4+6FjkXwH5WlJF132c$$9TF6bSU0B7zR&n5AF>z5CR0z90+d<};1+ z8_%v&_yZf#ff+&d0!Hr#-ag%$zKWj(MiS?@AqyA-N1oK3(F*9=zjU{hDjX$(pQ(v~G2&e68|F8$3 z2mrNG_L=~d^Ao9;X$N(aICa%oscb&5k^*O|N`OV~g4OL#aKwu-gQIc(M~944Odx^( zkFZG3El5{L2vfRB|L&COr(fMs@g-%~6`2IE#SOao%_scd^-Qw7p#~ZcYcOB4drQ%u zPB6019`+S#GNxnal$Ts@J3H|5&dF~fCnh3iU2{v7#|{rG4Y;SO*hc3$*PEL)j`=@G zsc<6F;nB5A$tTCnhrBda8)ps{eRY26g1j4NJ)#>=%faE5d5zp6Twr_dVvsJXq0Ouw z^V5U*H4v2p5S8^**62lCJJh4PY*vzGz|AFA-bOL_!?eJ0=W4FUM!yg>)i9KffKwZ@ zl@5zODKvIZEfYpV`_}q|$c)nbek-p(+J%+)e7j`hcaE{>;Ga@Jg|UX4BN&^95tqx=Oi+%q*nEb4|K&s9%4>GJKee9R{) z{Mbsx&z#p$Zn{%~cHeJfy|m5pYS%o!iHW64J-mY;vZe8XRxHIHjuT`phu`2?6sNMF6Q!DmqIExqDaLutVoQr}JjD~ODfl{ouZNGm89 zdH~=1;dl*_q{>dCkO~-=BpXv22muj}Z>NL~GREcs+m3!(77fD#fs8=!dUFsSK>#j- zX$h@;zD3l14a8FTJ&||rX=RiC78_r52bzyR$P*W;rx26Bgp$}UdYGghVt14=k{A03 zu`?l^R}#`L-lZHJ$uT*%;LBIApQir;5{{62(Xm(N+cc!#p0sSoml2Z*3>5kOP$`*c zXDnIj258`_a#5Gmds1>{;p16l+HK_Gps20b5@G5=kA#xpg^VtV-(gzT*1YKZCULdI zH)lKz$DS1RXZt##8(uBn<58Yk_v{g2%0uLG=#;x4-f$Sd>S0q~E zfT6*tK)h{ZKP#opp+4^S`PmMas55?i#T2sV?$z-J?@!i?RyYCAdhD~8Fy_5K$g>nJ z!j$t-DJ4Yz8sFRXN6VBYJ{#=R6ZVgO+_V`R;VAG?Ek$vV7Zqa9j>lT&-8Hdmc{R=- z32aV!B=V&l^!Z{D7PFPU%-D^QLJ7pfq0`8)FrQ!OC zNWLk23&wh`vO^FR@L~-dH00oi*$)^9b0}x@|y1@=_(b}W5c%xI&zz` zen%eI-MSSmVVpKGeKd-lY*5OJuH63`Sg^DdS|E=R&NtPam!{~GBOkg-`&3)(`~z9v ziREze`vT&gJ7jG6NpmV9|$;xu$ykgP}( zWm3lLS!nFrq%!*4VrHt1g`c)@`f_%^#Q;+&ue}$i*4W`y;c?5~f^7_nt%Rd0HKHcPVs0i(HCF_@4D) zAM1eYeB~hYD*1bz4ZRb$cVt6C@;bIp`Os6yWd5Rej89xM5(kOL@a&@eQne=6_fcm7mV7nx7P;C>aK_r04Kqtt1l49FYg7`(0d1 z0+@>u&lii_3gd~M?DOFn_%6J3tbekyx5A@*jF4(b&smBka%CqE!l6E4Hxr|ax9MNF z$vqApiq>}>mE|!)DXsq!K3LTj&MT4XnP}LmkN@H`wdO7+va@+&b6)xQ*kbbH*7r>^ zu2^k4%v+Ul{X%{%gpcb^Lst67eB@X2-q#;JkOr21XoDI}s}y)vIZNq~xPj%m?-N>% zE4Dsma`yqa&v3j!fqen`p$?eM@hm{nsP%)$(v!p!mjPc^dO@-b>MV(@x6>IY=|Ooov&GObID`u={!7w>y)Mz$DTNL} zSvb}4#ws(eaY&_lM()~9lhqTF+?0o??3DNDu`GVOUz+8J7)@F!*Ea0d)hPk{WMzG; zZ{}|9k!0zv5?Ema=DeK1;JXjWS%jgKV-ND=t;otp9&}0N&n#jx57wX=+YKLN3)eog zw(zA5)>DxiJrmAjD_=xizDee~dDS-1>r%ab#!9V@RXZOFddJ8rYA|g~d9!Jta=iw0 zAI^DNS}T~Pyepk>;9O394}oOrjl9SnDsEN<=A5#S4*LA!N7_7W&g~~P8-|)~CTFB` zd0*KRs}&GCvXeJ?)y7;R*Z%CJAkEoQYnbAtsugjUyhz`*P%~-BKCLii#~?Q)7}3>{ z`}<_ym;6Wb@!S*I?N(hG^r-K09M95r%RID_W6P`uwZKxRF225D8q;8NQ$Q9ol81O5 zGGTJC_o7aCzeqmtUV5EHN+BXuQ~6-eQvhKdux*w{{8-?7iKLySZ)?lWw(V58!|=$! zXZYi{wHVQLGn#Uz&)eDs0uTXsyZOScS`#BLn+_K}fMs*SBXe*FvcIW0k{tgWSofK# z4OIXR=k95k-6DJq;L2a}1WJ-j;0AVWrD6oG-Bi~z=QZ105Xd!+F-rUVIxms-F1w;~&sG29>#O=6n-YH1lU3+K}4y$h+w@W+`3>?`1&g^!taXLWWevQ^ny99<3E- z$75kOv8E}Rr6U6+(n#lgNZDWpcIhGhZoG+tkp@<0)f?R?7n;i?) zct4nFX3tAq!2dmrOEjy#7g7zM~5w2zThs1PG_6H)UIp~)wGo|HC~O3%)oUH9lWW?*f#B%M6i`>y5YkSl2A+fg{E;+ADSd&k zn-|e*z}jDyGu6SEQPQcI3Dj~0t}CZ4j(d2bEcY7r5WF0o^I4qoCTN$ocD*+VqhAAm zl&qx00vx6ZVi>OP%1;J|XD^ccBU~{Cm6A_X0FX8Tc(Rh))l707H1HQcudj`SRpZ8G z9`psF4p*2qEjN@o0e}fPlJ!No(c%x)ZW#?;(0kM*C!h8*lUxi2!c2*%E%d$vRQ zvX346m%aML&W#GYZi7v`5SqCWh6A9eW&Yef6xItqk}`%hn+SlBEuJV5U;TT_e*0T6 zardWBN(CVA+T%X&E6R*FNa0Q)rzI{xZ!ndAjGb;xgR+HpF^x{K<}PX2&I>ELx;OWD z0cPx^ety!=%DS2!3`=BMph5TnkOeRE@)-yjL51#m7!PkRCD>_jwt_2FfCiZ7kqo$w z9S{eW^GJ#SP!`Kb(I(@NirhNw_aCXVN z15(ADH5P(g@a-QYl_6p-CA((`wS zY@Xv;Yjr;X}TLPjC;Mi}S004{9G=(lz0XkvOgTq+1qy z#{R_oXE60&h>~Oeieu*R!((OJPGZH{I>o{Jl?pqjzsFv-|}KS8go z4{#;OC`Z)Z@hW!RYN`Che>=QjJYJDSmXA+qYD&4eLXDVDWg}fsy{07bJbL8WM{s|_~o?zy^4uyLpGJt$%<0-Jr#s}gc^$b|MOJD|C0%$K>Ed>I}2fD~;boP}3?<_@yBe3<5@~5hERp6GYn?&5@)g&Fw*?f&D(#kc~VZR03M|iKj10QL881X2Lu5> z!Bdocj033skOF(qD0X?%qkvPQ&SR);HCzU20kRmaWDASO7EItluFIT_#kZ>FboQ)F~g^3iBPY$c9|nLURsj6B0tVBOqaZdpN5(H0q;TcQBluNSgV z)zP}q(8m92_cyn-l{0e7zX7~=S5G&|b;GaA^#U(*-z#Nub?kJ%rUFc9LwlYU*M(M9fC(T}9eyGU&$w)m=H z%+=0$pHCK!aZW&~Yqn0)16@958DrDP2S|=jZg*&ICzgdM&X{XHqh;yhW9nNwzt-$K zJGs6_saU}D1kV1DTzyVcgQ2=jyl4jLRK|FQn{^Hx+h1gzel=CfR z(X;c)N=$LbU_}Et|$Ifw*qD^%)!165k_un9cOK@9xJj*Kt*zY*6cMDpF}&fhgMC ztXumQ!Mwk*ncFzzo>uBp`?Sbz(Wl_Y$BWE`3?t2X(eX4A*pdUg=ZHr#uPqfYc`reFmqdTiQ_^`ssgFf9a*jq`lXpO=Qi4-^;*@N2MYly>(StmcVRPi+E z6EZ$GId8uZKZ3_mh(A{2r5{QT3RHDMdl9c`^X)NgK@$De8+;aL(D0~!fY+wu1{8*)rD#v75+?M#U5cNsz zJI`E|sa@tme5HX=`GNd&r{tgz=G=f@C32S&etO$UxWWx#RH5yP5`3@RDeO!$ex2Hg zsYEs+-wKJHgT6~!%}S9Ze*yhk;dB#!0 zdPR$Q@X@$bxr6J6`x1xjsXxEdDQjNHj3hRcp7pJ(u!}iXf5&!X*0PBBiF1Zf-O*p@ zm~bLH9Jv+#VkmA#;#UCbd_&z=v(WCTg=ML_n0Dd$H|lL3UkD?=$&0A;B>qfr+eFmM z9`N)ym*DjIE+rc)5VgyD7PCvBl%?^uRDO|anyLLWV>BVI^A>b0F-FJYni+p{$23$a zSP^rMrt-S4*Ug-`!K?=LJjW$GDke~$8CwkvLjhkToY32mQ%8GS^*45hxyd#OP>SZG z!@fVHGT_1yh5DWE7BW2CkJ0O2+&6ZRCNjvK@vNDZ`3=5y5E@&b6e$IU$oilhSha;Q z#ReBLIa;+04XrLRAJjOaDa5p7q~9GK16FP=wR9NT+4>i zLh_3(xxXf@=HR5btE~Y)?ZzipxhIC)_ooUFDfX5cJ*J_3A>U(CguGIl%R*FgQ)&?d z-@Dtm$}L@RX2Rmm2wPCu}KUDt3)ianiED+f_h;|>?g_{o=_ zGE_P-0yQ-q?GgWBNds@M)#%aBF-fs5=KF8S7o?b=gBPE$8)Q6u5avntR8TMQ^8)vO z4mnCpdt#064nfj6&G8?9iQsM?=$r9*IY#^b(}CS*oe`Qx?&^#!4a2VFmRD&BX9{Ma zu_Yr_-P_W9`MfWnc(fx`>^8@wV@C&O65GDg+u{vlj-aceiak3Po1d zw%dIqpnf+~R>H8=;vkZbZGiQ4+a8`|spv1y>?YW8Od(akttZ2EhJp;0VgF z8f}x5f55%k#_yL{duL-_$(%VIG+Yu;u_e1 zE0ZmmjjS^SX4zj?6G;Zz{=sLz4<~ zwbhEN0qb`ra5UW?uFz-p8}TNt+mJ}!yE=VXoR<$S7Fv7+tx8h?7yGfoEpPs(YhU4U z1x@&4&*m>sC=4%n4~&Y5)ime69vC4icyMqbKoD%uS>fMduBo3b-`k&?#4$5Eq%Hs;Ix;-Kw zu?Kn6r=Q?n7|_Jm8W+6++Hu9#?!2CQP}?H{Vqnt}`eeog%AlehjDCfX>w~8|=gFjE zO~@Z+bO~oK_sy+42;;PruA-+RE??ZX)1J$W@P-3dj zi%E`juv+pbmJdsY^x49`s?DLVPQlD)0S`XVBf$kH_xthc_W)yRr0Z?pM%UXeZ%{GT zPj8^n39e5PUOfkq-sL0F-ZvU6g~f`N@L-D6_Z(A2t`-G+CO&nwbk0GSKfhxyNimLx zrx-FSBaiqS99-s`Rn10@uAcaK?U{$!W%D1CnZ#;moz))fR0X>8+*fq4Q=q%%ATl$_ z0_uv5SOb&sC{VgLAr~t%YH%W(7_(74IBxN2IxHU+o}6y?H;^dJ(KbX~+p_A0CUd9Q zfUB})Vq1JP6F!%@EuGA4PYx>!w$8Br1yZc{*h+%;J~iRSxf!JPZ8|Y$Ns37_ zoZ1AcT)XY;Fg~;P3ySArdEzq`O@wWtF))DN+1VZ2^*d*MQqk1ibUd8Nd0!j@?<>~O z?AfeuSSl_O8a(M!H}3=W2%7l?8iT6*9$nidsnp+{bp1SHS2nFClfPJ6k1-wdm-eWr zs6NvnE7vu|2&l)bd@0J}@)6lOI5CrRNUI}u*zu{nJe0PwbLsk0@?U=*4|W%r+{^6h zO9Vu?mVG=9RmR7-zG=ol1g_UK1K7@*5~TbJ=mV$(*K;JEkdgg39X?k;f!`;%w%sIe zf28iUp#?uaJ03`sIMX;e_~f+(n2GauLSwB1Tw}kckj?Em-~BJ-|C}_KSII~Wq1-w- zKf&x!k0u=5IX`mZ`N{3)yfjv`DLQiN-gT@`T2aBCqUaptzEG>W@r5D3t*ux+f>z^f+{Nr zn_RDGM;>={6x%eHN14K;!#K#}b$ky?xoT4}S?B8nVO$OfDOAN|&n$o0d+$9(MVsvL z!bJlNq!~Kp-%U_N1L_P+y5;fYv_Djumx$Mms$lW7@3oM-ugga1OMkCQP>&u?Wx9l` zjE+DH7c0fl(9{#;IoeXATw?o?4X802HdZTP9u=EacfC%hp(Q-DdH%(7(P!+a!b7n@ zn3ZpOea^$A1c`B^wq~9%I|M%&Fkx~k^^BH)6MNKPRLrM(o@H3CB1|E!m*&_uLnLa1 z*9Jl{Jo~teBHnX+d3877F@|E%@1Qhy_a8C zd4qPB{JqVwV@scm18>1cwW7&_vr+ki!%x==&P>kZmBfrEH;xl3kj6F5!{l_o{8h%j zf08P@v0?A-INCM|Zs)6v)7!&3?~v6C*wWe3c`10+nm`!!$wlfUqGCk95#$Tp8OR5W zT_myWmb<-mb_)k~1~7nVvU5o_JzUn?G^c{G2*Ut$Sq*5WkcfTIJ6OoTC(3O#K~ zSQb0aNzjDlms9CjfCPD{%<3A8`_zG0(g2?j9(o5yT^w{(OzK4X~wB(md*5A>@@?n1Tkt@J^T zvHCPV8F=}}cTFh!JpQ6gjzJ_QL(AH;LB_|Seo}7UqkvfL;k>+|)n3{7j3MN%_L^sk zHQkRA8_hv`j4ZX$vL!9Ki9nSS#>}nHx_Bbj)IiBj^ZXd8tQp8F(q{kTZLmJd#`;U) zlDU#}Q&7Fx(o(i?rReHARo?D{n>BD1UbiESh`X#$oSORvYi&h9lgdio$<*$Bu%2bYc1f_IFyavrF!u)vetG~*0UP)KfDn%$ZyILwGh)*7Fdkk=pdz7^{ z(Ly*U6VB?RgJt&*3ER>w@=bf~$;CEZtX}X>RKxO>t=JvFFCFk?Gkp36z&I@*N%0u{ zM=rS4l9ZfP$|nJPPtFk~8$U~1wo$}Z{j)`b=VmE_=t*~5w@J`;^Xk)JJA5}xy^;Q- zQTQO2jpg@=-MpW4=hJ-YD;Yb9?6ih6pJ~Zb^xf)5_%ejFig^9JTy<1}k3L$qPrO$~ z5{n2Z_Y&M8i-MZFn9k(N)_k*~`oLz9^OfDmu327);c}pT+s{q2qL3@g2^JeG zDFfLd)(^=~;`Lcu62Hbyej@nP=vzl4H>s9HtS(TP3GT(&mN(TQomVpoG<%RbRyuHc z3q-T!>93v&Cjm0Mnju(Cs-1gNW0_IC+VMY7WoX~Tkd@|q(*+JL*J?~eR@?(+p_x`HNV9}kqrac zU3}S9{wr8o`dZsxd)dTHNy1Nhxh`3yBqMgp@3c6@8~Hju#ca*D#*|^pLTDgQr^O-- zzdk)?9uq&Xqcl@iK~|37frv|qDj{&+t~G?mJ*-J- z9$9CH@V7sTF`68-e4zSkRia>)YrP2509QQv(rp?;v2o%EZO$CbrXBHTD@d6@_`oU6 zKZY5_4*{!4SwIZb%(QK`sS0!xlb2q#tunTvN{vIOhgex!@%}&Ty=7QcUDq%wh)7FF z_oln0Lvj<+-O@;RcS|=Y-5?>LB8_xO3P>y6NH?6h@8`Xr`}y8;-gA9_&-I-@?8Vw^ z&o$>5V~!qUcIsz;s+n$}L~pi@k*$gj^PQGyUdU=zN?vS-N|gF91{B8)o!h;zu-LXnKDs@D)ZU{y9ZrDZ4hD*p_Z~5A0UprS7PxH<9{Y#Ef9ikU=dtdxp zQ}o4!_cq^?bK#+6CVnSy>5Dn&vM6rk(0y>#$Gtvzxz9Cp35?QS2Pi6%B#1!J%S~BS zz69P*6lb?BVt7-KtV#49#|C8Pxq`F=;;bp-?3@8_6hr)H!>e;WeY1&ug+NVI6JL+ zbhr_oF8cO5yiPDIm>454hKkS$tHDi^8y|Isg|;qGxZ3CeKa|QJi~+4IdO@NlH<|Dk z77#&63^4guqGc^fS3w@(B>BI;m7?$lv^@TSp9-S{Xq4=`;x+$ReUF0ZVdA@Xhmwd3 zh;I`hzW<{DT?}4F9TB2b$`5s3%F#3YZaYUuG+X zG8Ehr-Ai@Z?sB^Cay=czY9TRF zsH)hZ%$HxgMjjUv6`6us0<2BYoJ=A96p8#v(9BLhh^^XysXdE(z;CSYz~W~yInrxT zpRt0ft^H$Zi4_zE_bl`KNIrsN19LDWum-usgCNzqEGAkHLjrVPi*hmG6O!5#kyJ|E`M@IxN*c?1~KuKa(GjAa$%%F!~flmTaPEx>53#@n-h zwt~n0hlicB;)^|k{t{d|4+vPMrSEbLRtHt6x+x+ z)OspM8`-6k*oTMd3a`FT3x07b?1#p;HFE2zH zh0|g{Nu4TnB7xJQq(=j&zL2)7!DNczORKuFwp;iqH@=Cr<1gpaEL830;~hFTFqVMF z&ro0*32OGHcj`Y&&46YqhjZ(?GZ|!~*wL~tQs<6E>UA#oKo62jSa5e0%7hdH!tquu z!M5>Y+W?VrMvuJ#!oIcCX=OX>^IGNay_GM!{xp4&J6nw(`egK=i)Z9}v@4z%dTrq< znhdH7r~R%cv1bBl7Jt$g+|?zNne?D!UJV%y7Learsre++wd0v+KlGfJnu6ekwcoIK zYo$H0+EC>=-3PdwFy}7@w#FT2O0LWtsNaWms={}z^#XsQXoZ;=$yXft`z3 zP_0kwv8ZwVWx4IElH_^#KkY8dfVv~D!732lfd@#9kNy9a$pXt@`>WxI6O-!6S~91` zv+4(e+JpY_F^vFMYE2Vzb9G`+f+Y$)(&4DcoaK)gD=;w zldK~`akcrW2D%@zidl)SVP>8-FE=JoaJiIJPQQ4^!wlu+hK7Yd_M0q~uR_EmjUc7n z6+P#$zMmZ}l56;GMehCidpJmp{&LZ#wexVs1}w>s*Ic=Ya;RnZC!z-QUp6Z1&3fYl zmXCp|9aIOV6u-lf@h=k!^VA zHFYv7uOAZe>4`L+Du~vgChZ(H%6Nm6-IY0exsbYyIGcfswhLYDAEyzbUpdge=dd`T z4^*<@?V;^`3{kJtSq)CjS@cK`2Zg9-L`4cMR~8pPca;1F!zf(1CF|`3o(Vu{mz*L_ zcfP9XM%Y@GqBbWHCAn^!o?R?4-Cb+zOW=_j$zcF8RpO;OYUYypc zfs?!Ide)37Yh>1VUBVqRmp6^Wy;mRTkZT&3nEv^p3UABmQoa0a;p=A3g!7zr4QWd< zTeCvRf@|_8-_ydRi9WTtHWqiGS67p{X5WF=kyM6+a&oHSJXNd6pzmj%-@ftI?3Dcv+Ltur%!dZQ9;f$m>vA*lgG5Ey1PuF}~bFym2lDA&$S|lPerV_a_kM;4Acx0Vge^i0lyPfb)(Ww#7 zBwN0BSdFm{OdFZz$Au%jcyBq@`5fJTI@iDHeGs=^NYc8%`+f^A?Z4u)sbty6X1nNb zzE1r46;dasbgvg_%B8Jo)xot3@?ZC|r$9X^7c<&e@-^O9?F`QKIh)06oooY61M=M; zj<0t>6-UGuox;~L;+9dTHeXZ5#fqdXi?r!^PM(Say71Q4P8<^{;aMY`aI4@hBK$E+ z^FpxHCf%S&cfO$(&DzLvr!qC>y!Oqus*dmPF#F}nhl_w>Kz{b$ZqsAKx%cXL+vmqS#q$+Bn3?WZ>k|bcIBqZGKZi5eFkem( zLSJ;z6y6-X7d6`XB=RDpA3pxmJGQ$K53>n$UZ{8o<-B{&2%=to{b!5aJ*x30<|*Cp zQ%==Ft7w@%<5Kn;s=D*LwPMf^YSeShias&l7ql84((R&`yM zK?cq#c!7O~;&3wqBFs7M3Z9>t<=*V(+|8opzBF){eYlM`v+GS_eIWPQ zgtN5NS^xd8?{RRNsldt7nh=VBEeHDc(s09V8-HVEVl*)Vr6z7}Q=luW6h3a4hNQO= z`zPhc$e~H0r#Noc0m~Dgq}j7Uoo_KTlnsXZdng6srxhn9$RFE>FJFc>d~n zV;LtsMB%MwmAA}@>@7OvKBdt;&)PS+sLLw;IL)u9kMV_JKx?8}^qLx#5TfKC^vLI` zgd6z$(`?RcMxpxY{l)IHt7`36uRu@YJ^ak>2m~kv7Xp1WXIEl?U6FIimQ&D1FwpMe@BX_dsbp|AsMLOG(kCZrWjeeBL_HkJ<+v%B#jylzf*?VY?92Og+`6rih=+Y!cZX^!7~AUQNh6H7dfo{73L-OZpXH_FRn!tUuyyiXyo#vk zARL$L(B{1UQnKx&x3Zhb?ki=#-rSy+sxV};dROMiOl!G6acHw9K`!kq)H+7V^@Lm( zMOR1`>6Q0aiDY)Mtw89nUdT1PDt48nF(++D`XaF1RF2F4JS~oOze0s$h8bI4u<1>) zAyG70VQDs}cjdd0LTB@&nAwx5nu62XzL;tK2tvQHDfpC+Nr4xgB0 z&9zdyYx;hl4fw!Cy^L?3B>z2IpO|na?ri%TjlZNyN1$0!Y+si#v8mY1=-HOQ1TsFJ3IXmcZj$NaU8%fnVMAYNAcC|~V4c+|8vkQt zvtU41wDisx*aUj;DN?3GxgU}rA!p=1GEcm+S=&Nlh~_l%A1oN=;+c<0k!q}pl*71a zzG$*~8tp#3)yi`%)o9cv7B5E^?!R4=P0lEM3z7ZwSmrQ+HSN!S=29(*$>KG?89X&f~VXNOvxG1xD>3wHgyr*lzBeKV>Wl7G*<&zQ4!9 zuPr2HzTRYnKaJ$W)%JcCD|7fusw zpAMzZ9%3_)nEPG`kun|6h-nU48(%#=CE&hu35kFrrjn~<8#N_z(>7Grcr4R%?O%@P zK6Vx|P)A5{m%fs$*cMizac0(wCowGzs8&c>4}=ozpN|*bp!s)J$*pOMo6LL`kgB#4 zKDB2M79ahJBfI%LzgSgibAsv;0#Q^?AarRNnvOJDQO+-}dxCvfbm))I;yi4eOQMqg zLy?kjo~^F?Yk=J9jOpImQU9&3-YOYGd4zj(acHf(vH{b=3`%=!U0U6lye;FUYwSyV zZ>@w4`vo^@-<0OMaXucIkOnf-QVzLS8VGNMn!+~ryf{S^LzC*% zjwHvEotm-|5Tq3V;dXEFB_MFh1=i6Q^6?WQ#~{NE7I1tUk&+EWV3rK5>lg2FHYrPy z%B&Iin`J`kHA0UPI((2bH1TScsm0(+sKs>52J6`ApMoVh8;@t!?IS7U%~lsFL;}X# z1zRy%3h3+sHiw1Rjx89>^34}QhUj5UtX>HKnT5w-kZr6nDu~UxV$;Tln%*<#l(njf zqZwAxSnmXj(F7{l$@i%aU@yglKQbS;+Hzt>&Ikd!ihrkr9_+wjc`(tI&#%qI!7@(f zf4Mlt6k0x)p*I&{9LbP{M_m(1WGz3$Xl(C3j$hdCj6lFh4P4qS@_=UE=}-sA zTtF+)Z#!r9iU$pSTSggtZ0$Jf$<%cHZuWayjfKdOO6;40~H$vFR`B zkMWPpv3MUNCpx=4n70Y>^1N8VXrwDl1=DeS#ku-qanUM`EnjPMJ(IdX zCG;Dcx(wW?mxNlK(VCI1Lhhx8fq|#mJ%0-&Cc9rH8oN9K^GBun6#i0SN%)}sd5*jH z%aRwebiZ$D6w4eSC8Aij*#d3a%8}%E3W~{<%0=ShZTiqZ8VoPe`BvX?{TgJQ*2f79MY# z0(>Sd0F!k=Ry=|;;Mw#S!;KK+guu^$Sam)a#-t%gzXBWL2huB1IP=79`R+%giU8BU zi37Kj#b8jKKW*`)c?jrDCX<7j@D=^UromgC@Qi$!{Z2s5Fizey~8KG zq9&1rr%vF3pAr!CBgqtV{Q0oJ;vIm7WCIj3kgyWtIcLVIede_Za)F5AP55qQ-HjhLgP4tf}Lu=AdTtKsS7LD9y?!Sf^r{cZY?IB=8j!6QHgdR@Un9n@% z$=hyBQh@sY@pa2%>Vy}71Aa<$dW38i27ame(ODI{p8?#ea!Lp>qV@>~e0J5fvH`Vqt+R^Y$3YN>|4RTQn ztLrcu9|ZwG4HAr1TmfNS70|LV1Qq^S{)f+_mhFE zz1sC*>I5b*Vk6|c8^CfU5DNVjsiq6m?Bd`q6@q2K>l7(kMnnn9AS@UP;J7g;;hqAt zIXfO@HkHF>S3!anjvx)>hLz+`KEVAuUc&IJZd)P;4;$_J2KI-?3~>bP#I?O^E+1?t z0Gdiu_oWE3Sp>Lp|LUxX-A@DNqd&2s3&5=j19wjahXppl6L43hgKI!}5R&{n_;o@D z5{#HINg@&hC151*>5sy^D|nxR7B-^fKlJnnnJ)$)rVC}nRkmZNZVU8a>@tlP4hJWC zknGOXEIYikdGm(-k}W8Nt>xhk9B6>~O!Sr=1qDS}Q!^Z#jB_!(?%o{BkChojZ*F=VWq&CHiXlazX@hA<(7?J#pCE+cmjEOrZpP24*@*zn73TNV0^lf80BQ+k zY6^go0gyjD2p^*M-NjVbS-Q?(pR;nY<1;91q~!1=E;j(5Us}>n=nI5)DmF z@#e(SnX9X--sWraka(fvNXn#Be$eI;jlW3T8XQRUbdR|S)a`z>o}{IEifah^e{0v8 zJV|3Uc(cYwUkqAxvpcOwN(fjhGWM!lY~MT+xuj4R0gXltZa^zB<`pAr?2mxOKx}5> z$CLlzl0kQ1Bdv^3!0m$1I)C&`IOGH|cR<$W>1>vCaFbZ<(ag?``qJrfB6B^3;+Bg| zZhRXVrZLM7%WaEW*xv2TOfYoAx5jyQFYAi zTHi zIS(>;o{r1Jj%r|AZzhbCbC$QaFK@X{DAJ}HFa~hxD+Hs;57Z*!w zrILv@*v-GNU2K}#%2FNp)E-gS?;21=fQeir1voQ{zHcT13EPn zQA})XeIwa|7w8&SMaPy~g)&3s3j?QIE*lDkOy%|UF70YSseZ?|NfopJ6DceX)za{q zbYN?qp>X;)17=nyPm>vu4C5k=*TX<1{}1AP0Wp>WyZLsm@zlSOE6JFvx&06JhSd+V6X3kL{M zP8ALU6y%r$AViVAIS~Ne!xT)FzvPEP3c?a)g1nC8hN%RQ0=8dWG_d*90a7F6-X11C zLZ%1vJ9v!E{uF$={0LG3?wY?>3;6WEp<#>!zQ1vJ3jWMw5bC}Mi}}3UwxRtCqL%}|a;KGv2s@I7OV z7p!`O-HPRj0&g8lm?9;Jz5nMq;xn(x-W})W^H$NZh1g%A1rkJ}Nqo<&P1+>kU%;eq zR{kJ2j#c0!}5xiemJNROAfhPSHD($+@XtapnZhZ%T1uM`$6aR8qx|KAcj# zY4@cSS7XFHJm4)kd;93^MW2}?JZsIj9Qr5QIe)vWA3=z3~v)AOT-MXJu`(R=$AoScG3y+*Fjn~avip?>&p zL>dTu?splU=dh!X8~7wf(keH3;tAv|(W_dMSWGMSl+YQzD7Fl%#% zE5Mrq*=>dP+r=7i))g`eI6at>t-+s(1_T%$*oNkCjHT3YTtKW1+PQg4;_Y8;8*$vf zH63FM?q9#?D30Zy$i_L`3JFkU@HU)7R?;C8=c3I!9yG+g6Wd3( zG#;(d?94ut`P7CZMW@);QG&Ndu0tiWa8|gZMrXR&KqmGbz0`EGokhlfBHGJ{@7V_j zf249pgo9wAZOF07PCTpBu5b+{YqoSN#6xwQuf*4lVaNZN@3wps9Cia}deHiCi- zV6Y7|+x~?Z&22YG!8k*|SWk&nX^UmP_9W5kvtz}2RKgdC8S4_lYtYc^b4xY(O&cH2 zEN?T<9e?)u&GL-sxJ;>AjWbd5zEii`{h+6tc$7QtE2;uRL`~77$LRZzLSOnFm%?_@ zm^LUu&IG)$0R|1-NJprAoOl@ zbT>BO<=Li`>fMU<>d&T?ld;Tx1k++Rgkji&hx_nrzHG3(md*1B3?bQYU zGbDhAZW#G=z@sW4nnG#2kNFsss2U(lWrdQh&DYla0~W9a;(A(i%$Mq}Ww z9?{E+_!FneKgb#=X6|JF+nERZBTT+U_#`ucz80`R?A&{#Vj%9imqQXR8g z@LEf5PJ~aETW_Pzz5B*4fz1wFkpD990;K@^&?!>dB&&Y&!>k*hE20f=5H5@hp zE||H>?^R>$kHCI~>`pN+Mp`y(m~Mu%Sdl;VLH_M!J&WFJOHnXn;|5dWHsM|BWRx6>tlWg`|r%h!TcC(*FnD;7=ln z1pe$~P@Lt9*SG63^+WyldZC1I9AH`;tdG*^;G$uiQ2QG}wH?q=X1{z=82-l`re=qL zH&AuyEEG@hGKE!>sNk519H{`W0Y-g_m(2kDtp6zK|HZXPSVIyA3nV>6f!GOk z!e=3{NA_0)ll!;6{!0utIc5R17r=OjsHKIyOcc`vubAlUNe8}U5+Ee{gQP46VV1wy z!}SN|9q8$686&Rq>Yssw&&Z#pkNR|UtJNZO&g+g}^pdIfXG^g*4BaQdCjD>8`){dZ zD-WAAGgGoN0eD*kZq|0vf9k6LMf{|4r&rHrexC{Eh&ikpCQ6b4xknX9?C7=j;HeLl z!iGw(M0$i{+3cp~-eA4sAq(qM=FFzGUL~>vkfG~gMFNK*;U^NPQM2OPx z5rA!NC8!)p0pgDUQqS4ycjgzE7izZsoQpWbb9?65>(Rl61YQ>*#u`DmfgnEBs5QaP z{`FwNtuUa5wK+Zcqx9=e@_ZS0cuWBdJFJf0D-OQ_Gf3` zI1aenPQi21qX}FIfyJ<0QK3(T;{Io(;1#+cruCNOKzY41_cIt7{NqOX$OK^1teNJe z6ALI~im}&I_SYd8(Mby^ED0UoV3!dIe=Tsf9R%jfSbtbPm}OETYXiML@kw-Vf~+wb zcqo?Am-Q9A1bC~H;Qz00|DRxU{IMM@P=`cmTN1Rb2z_nIX%ESap$pW;J`h@ld)e2e z;wf?e8a0t5@*C+XWvU!y8!wvD%SQ)+7lEsQ;jd=@&XF{dTsi4lcPITbrOp{v)a^xo zg5IzM6%o~DJ@WrechvqcK1WUEb|^nV~KemGZ-ImnTW)p8(5~;t zInu!;p8(sa95_#u5GrIZbyKhf!s0_@JDk8~AklKBwN(XtfR+WV3lhW)=(89ux&|-; zKH%I8nqR7ewO8z4_-umXL9BiM)a_Oo7BNhOWYmShcOK zWlaavUxN+1=Wb#~lWRl!(~+Hq6l70OKpOB;1=jkIX=u&}Dx4<=;b#Watjn{S-Yt4M5p42;;X}7D^$icawBdN~f zr)>oZzVCtuF|xZ7>^YT;#cMfO8TW2iQPAQRZ9MX?E}TKgD`=GVL@iMOCv8$W zg;lrhpX93;(PW_&gS$VmY@tKz;zh;t{jxoZEj}HQI=eHM5%_C&)QmMkx}k}Ye1z$8 zMQ9d-RC7xMjdEfXEO(&1g+r9CGlNzQgZ}+_D1=ic7;*;;_gai$LzHQ^(jTJ7OP_QZSL2(=qg0SC_g)|-%UhHKCwEf8z zT1riRoK&dgY}K~w;6pq4@B4 zU<}t}w8-F`Pwszp?4MVx)wajRRiygu6d4+cxi!D^c7=+rK$kjM#7$H;Zlc&l<*PBa zH!siY(E!n?mVtEwtG$GaWO@;APH)312`dvql(@{8X_@7Ot4<`%)VymPy8P`2&2MxQ zfaFO1u}BR@9bWE>W(7IPKkG}YSqRgnej8lA`a2P$JZ`hWqh(@ds01yYogryHe|w;H#CloD6f>5BlE_2m+HM#hoQYeI9GLS8 z{ZCkM#?|D|h26ApyH1oL7Uflh7M1FZeg8*Ep|HA1i~P-4Z^=;kBjic;kNL=9eXpc8 zx!z+5w4i*R$+c){zMH@363j=sa;aFWe}#m6@wGouTaR~vQBBetmyAnF+xF#B%uytd zTPG7KFJqXAYC~{W#joS5v6E@C5I;{FF39)AGCb?4yUrJR(GB|fx9&SS#K3BqcQ-ID71t_XAKHCi>HR;IlAQvS1CdK}x~ zG>{sM(MwTo-~^0I!AT0Nf{Fg77KIUU!0MRQnZxDap8|(ychyUHU_bs>&>SX{X9EPy zH1O`sdFG0m%ho~(oqe?)Eq?HTdc?Zg^(Wd-`g;@V9vax(mhlCMwBSCm3V0I|iF2jj z7U~fJk2I1Kct3MaKuU)r;shZIxCmCkRLVCp>rq!XbdMR2RlhhKVqf8z6TM$^VC9&BQ( z-C5?;ABgRl$A3f(Om4-0r1C zuE~cJl)`4l$!F#&B~i{I*dJjLN}rkZSsf6-OLTkF1C{+H28i}6FU)3w#dZGr=vVQf zcwbj`^kB#t$MNBA&t&g%)REG$Pfk!dh2rRE5<`z| zRJ=EA)dRK=Z0|dBIqu)BQpats9~4ulvhq(<8^uK=e_j3VmvOZE48karXm^t?gZA3Q zwOQyqZTz#k^R+GaGK&Exq*6!W1R}uwyN`zNoH5UgEbazK4~QUp~=D-9B^0Sfd~M zCk<~8oAP-jOB(BYObB}RN^#O3ya?s1L>%Rdu0HlF@==_qv$y?8_MvfQFU|A>uZw*8 z>0~Awi;0)iAAqNGI_2_lb(!o* zQ8T@|j)`l1qtx>f$rwUYtroj}x&Agr5rZA3i9xmrI&C%TI;$v0FT_O~ce$t9VsWF7 zX?<@IP91Dt_Hhx%uI?|*a@j$V1*>cn4r?cxlc+KFyBQ6HP^(l=lGH2-WN0m}^Z#c- z4{Vrce_22MNPh^L5}j7*u|G1yx#Q`4ag8AHd|{qQCZG0}cFmu7djpdJXKBfzAEP}o z?IVd4ep2D=CmZPD(zu{EqWi$6d06;fq}%zBflP5(%MRb_R~{qleoUp{;kn6Gf{h15i-uu!6tu@t*3HoUksH`UxSZo zT)<>L3PL(AeI@28^M2fX4gaX%;*XWWyXcLD=5n1lekL9>O;0^ylnr$`4+XAU4XNI2 zQL^7DTTujF+Zoz2KNHkjolmpK&@D-fv}PzT&~ft&1tL$O!tEsMB?_HB0?PD_TLk;Z zM}wDv5BsR3@^t)?hfa8~#v_}=lJ10B@)*6> zxSK8OM$+~P&3!1l9P+9FmPN6%q|p@v7Y$t_vD^xChL+7Ab)CXv=^m|i63cW^6+T%Z z#&PvA&l74b{vE~5gXEhO-wEO3MUB_x8uxpQzuDkl9S{@weqy!XwpA7BT}m)LNUBbO z@;%d^$Sx_6J@h<=K20aHE;BIsZO76(?oCv)6qcf;XYu2UqCog*`F+_!EDg5OMeqUL zI`)__)lHGsK_iL+eW=z!$HeLhJR~-z@NjJ=zl)aOLTNkEyUCLC4mhyi(Qi`0`M~0` z9)6kGZZPw^R5K<80Yfq{TWWzA$YW|pN0>|oJ{orLdCmqXX&kzZ-j9Ff-K~{Ra&vx9 z2PvSRo})9m)*=!Q8x=%Ig6OJCqhFx?@Svb2RcB1%ygQ9HQ$^l%wi%6e%du*PGGU2_ z)D7Dvs{NJo5rHra%TQT3d+j;`TySeyx?E~E!1Yk)V3$b<9PJihQs&`QmXvTqFr`+Z zT$-ee(0n{H`L`JMN35VwlG)9~IQl^dF>Y#kf*1yPdi@b+Iz^BK&?#Jz5-w4YGe8hA zaR9aZ58&Fp;CehGJrFKI%pT|=>uDF-l_hm$^Ta&|%FEi!qddT)b1p$a7H53-G$7Uq zpDb!}L^rrP%KuX+V*gvf^bx{g`3%gEHZgd9ShNynSU(h@4why3!IPgKgbUGOnoC}! zLJ-0P?0XCrn>HV97bFLvQ)U4_6&u=)GTDxB1nhBwcMg67U*{_v#R=nX_ASRamcpYz zG>Rf91{P2F%S#DPXVu|`*pf)Xm%#FzDxBE~fL{g&0EhFn3IR5jC9 z1v5?1K<1&9wj@Zja)#y0(PcCuA)BRwH)tL?JA*Y7!%+VcD-#~M2n1j0Np+`v)9d=* zxtad>H_c%8BY=4*D6f(HM^>tFCCtm?_T;Mwz|1VT&TJKriT~R>|B4SJJ|ahVCP4oV zd{G3s0`wJ2L?0%Jox=z(p2ddfkV|8=1|qFB3Y#k!UA|8qU1wGFJ!J$ zt|h3T0>ksy?J2zb-%UR?DWgt62E%3MSsm~LSD691pkz9dOu}2*-z)?34gyEOJAfMU z^c4@Ge8>I?$!)e332XfPCGbvAj+1juyEf|IkfzH~C&qODM!vdu@Zx-uev@4rwS*+} zBq*dh7fI$fhG*;<*A>ZIW;wa)KaIc7?}ZE18O=lMMKPoeuaX|%2+TR-FGf=nntZ~` zZ@x_{^_nX|+uk(`j$Y(wsY$%?py%Hb2~3&Wzc{!!lPG@}3o87+uWYg*NbN`gV+zF7 z#u(spqwH}Z#oxJWaWruMBn%{VV1n<@modPfnj>w?ud7evDeIa^!%%5ock)E%9dv4C zMR?}Q6%n}KPIhHnuGjw}HwsnfL{8;vTfsCT^^@Le-969qkVmbk5hfdGqxUtxigXKu z!-6;Ta!}sdyC0=fcK_|WHkuV^AnHfr=fIo46{)N4cl^AYN(BfQEW7=e9olqZarg}d zclVz&?nC4s)j(;ZRlILqB}^pESYoGGj-dghW%QD&p_-Lkw%zQPl6p<<4zry-K3@x6WNq(nyW#>H=$wW3{z(tS;C!h;GgJcF7D?Xm zDWLIDbHJ_E*XX;*hABwW#dd$h+9i4v5I$`v0D7+-&TqBA zig>bkX{<*aiP#Z$r<~hz*hkbxmos;#H5s?xU5eFc1FMW$iSGx@xBTqlDdl{nC=uw= zAuTp+z1dt({T#TB^NQ8h!d!(0F2{m(i_EJ2Y*=(l88-^CqU|k2cLkiNNFdo>&$U9d z0*`>)$(a)582cH9tVJFYF4`WvBOm{Ab~eIvcjoY=!UpQw6nX(`fjKhWXk!hATZlVFyoGx$S6g0QPsOv5 z)tQCin+^SlqrQl!`>!E<625Ih%A~KT7KCHz{M_F57TOqOhF-S;YsFqt2PfF zl=v*O;cQGVo`ZrgtIN9+r0-Ro9baCo$r{e_;NgNaFp{Wq^Ia9bcAyyV{7NNKH*7}o zAvd54W)81I-6*_@OaIL_C^J>6^fEiL)ylLKn!Gx8G}C}3WZU9;UKG=aVxb@=Zl)Yy zASgGQ6Jj$aIeqgjiUX-zu;rD0cLN22^YCzd#=Tys3?l zJ*}KaH}WU5xIK0LByx&)F472Rm}q__gIZe3TcCV?&{1Mxy8l)_9z>yy#O2n~Kyt!a zpOW-$t`-$vA-sXjE@l?(XTMv!7(oxRH#MS*y#XhgbJ{RzB7d#_9^qaZSeDH}|1VMX zepi|SPcaZ^l^H~iz+^WYPd|Lo#6j!3#kXhh1>+9#`Zo1Rf3B8|tC1}Z7S8PKTkB(U z-=XXe<8($Xo8K$hyXETWf;&y=sCW{@%(Mi>QRyJM%VAb7>6Gau4}U`XKQMGH;S!HE zw87yx2u*7H?eZQ-m?3^zCC|wDOhXPo0V$!A z$|ff3Gpgl64=Ork(Za6(5w!^t6aX2!-JtARu(`{$%w=%h;aDI--^ zRz4=#SeALzk%LNEG2!!?vP^35T>idj9k z>pjgi2jP9_~PD@bAB5YTga zXmp#S$Z#(ag1{*Y*l{v9KR$8{O!e-cb)rVj0O{w6I*i}=fI2-4%bu=GeXUQOKm&9I z9eA4pYldhAbu{Aef(et8%>(xCkR_)2yZ0Bw*hRm}bS1pY`Xjkr^H-9>CZeboIUy04 ze<;}H1vn(J*9Ox6gwc&8#NllBmmf+(<5Hu~PYcwVRf-;4o_{Cj6cG3ly-;>NTrS-Z zEuz=%ihmrR9r_j(-?%xlQ1-#Ino@XWoIV)mtt2zgHv?9g!fih}KWF{-bYWvZP$a*6 zl6yMAhY(*Tz5CdPV;$8J!fh(_d`ECXE4PwcA&Y3Ut3vut7Oy_@2;$?TOaZCznZi)_+j-7?f9@4^r@PJQ zO&se{%_-pQJ;U>juCw>-VBA85a5Ik1X)bkhB}|l6M!Z^qk5WFmQ$&H zgHE8knpo0eMWpWQQO*Z1UJC&d;Mq+B}M2PuV5O8p-o zw#oL45~{LVYC~N%QXf0E&`wb2lW;5xY^L1HONnXvzKb2cGjQJ1Baxz0kk}jqrmC+L zqz0TMy(|_IgO#s&)5!I4Q){$?O6Jx}@<%9jFYD_KHq?S0(^BKj?@Fc{qDDx-ac#%D z7&fph@Jz7%f&MuDT_^~MJn=`NDIFCbI!4d9pQ^`7h|9@-I4)~Bly;5(cy-NRHW~L~ z`&t(2p{yET$b{U%k}Ywt@(AL<8|}%M7Wc(}yZ2or9REH0_zauOZyH#LsPIVX!&uWT z%cbPIFpLk}aq`S5)m6QsQTj2BnD2~Yb3Qj0y)XP&39}z|TM^;!63CxP)aAXB}4#xa|u+x|bAAYejpoNo{rJrn!Er}2x^OO87eqr>X* zWS@u?XNR#*4n`Cn?fo3Do%gcjqB9og>QDNHFc+D!r6w4#-@RDc3OvGlauFogui|hZ z)Xp*U>l#_7#z-IoxJZaE5Yf5aWVKYwNeJ=M1!GO5Nxbn@6`Dj(=|APfqW9;De8Xh( zW{Y3yD?I64_a{n6Vr=DNn460Z*1=hd(&?5c0uh!e^IkvcR!+nC{FCR1>oc)P1GSz5 zWea`O%&sDLI*0Y!;It(U4I-J3)+An2G)NA1;ab!&GNHYYA10QcNLlXvJyGEyJ}aPh zQbC}`%#M)vqfgDa>>6IRkN+8WiQffe6{FZHc_EQ1tJPU1;VTOknX^Y6iF_&AsW7SN z$uisHHG1CM+pY97)J^q$)1S@xFPp;UVg;jtE3iL(6j2AGx2Er5o5Sr2qS*OfPqOJ! z<)W}p`Q(a*K14>74l zFR99)#sME=3J>BT_5{U6&lsBr5_0JucPu|=rKBa(sXO<_DCU(T=bs9(+jHgKI4;3rHtvq;uLKv%BqzMj9ej!x8mP zLaNGhjc!CkCR&;{@jXaatPt7$b&U5k_9oYRW+obQ>JBr7|2Nrbnjtixh!R7_&S)1PFtu#Ai|pZ%KydQ zTL$H|Y-^)H-Vh*of&?cJ+$FdLcL)}o;1b+|Taci^gS)(VaEBNcBv^0=?hriq*K@7C z_Fkv%x%cdIzFT#_AGc~%7B$~Fdv=c=J^C3>k1-}S5izl<+R@;LAt;F${5KE0dnhxH z1R$H9o4Z9!)qYVaId!P{R1swv^m()(R_p&2-+d7Uoy$FMJ=m5`$Qd&UUE$-_;nO!l zjEA$X$qOiUZ&j|#U)IP4T$Sn`Ld#pP z#WP+nEVo3(d_k$#C4&@A#a7dRVta$azjBWpkP^6)lIgcB1}u2HvvM!MIjSkZU)C$#oA3 z$+k7OltikTPn7hck0^T~+@%00rtE6Vi8FGs`C7Do0c%7g9x`dbIUNGd2^07^VJ$)b zB}^0yVfiaeJSOKGosyCiQav@cZJf!*kG*FHvYa018O)6R`DIWTQ@DmnKa@xbe0!Y# zg!=;-W!EWBhsuejP3vCh$&_8zf|3)PJ$LL`w8;|DL$e!U< zb8E&!l@ezJyAA^Qy9rqCu?+k zUuwJ)xY+lvP_YJ(qxN_vA?_`pk4)bJswk<`Ne7pW5xnJb&Dw5$BS@q9d3G6_m9?K= zo5G0tB1F{7OW;`!_Tk|n(*w>sfTti7KvGmxEG{ng?_Pe?Lear{gDTT`0cNMn zP~03!CWB(wrdGdA$#z1Tp`aBPc~3$+!7(4?8UHK0crp{{&#rtwj2X)KcpPl}p4tK8 zK4!`OuX1F1%`hFK-uO+XqQ)MSv6_bXA=o{d=(f5?Fv)$_b@3Cd28u%)z8mA zH5xAQ&5f$HqLDz@9gWicQ`sU13$6oKz~CElF)b1?_mN?e_kw^M%)zmIdsk&IqR&T? zK33B1cOj{l<(+eUz`sg?rET_*mX<1j9q^f4G?Ptq;AO$k%Zgv1C&D;DViVnihXXwK zKrI04Phm3uIwJcA0opE)GzF#c3e~5lz?`5Y8Lj*#5Q^rn0a=YYr^qyz6wp4)H$N4G zBddE>?i#akUfz(ZhcHh}*9~aRYN{Ty|3N5lT z;`XowSaB~qZZ73v@-jk9tTR7u6(5jA8o~QbZ75~IOzx|w>`#A43Vh&we^1l_#zy-W zSdF^mK4g{U3$K)4_lE=F{QP~_`5>nzriiJJO-o4{NeOguWvq|W{$ET>J_whk^D!p@ zXZ1gsfPN2_@YFm(1dO-y1Mp8xMSou^cp=ErmQqAA2alWn=wo9k0;tDH=W9wODVXr7 z4<-E|o0}pY+{Yms?;PuY7t*C{@KvkWYD?I6UO=aVG)p>pBR*zSUXa@oD}H~dRuQN) zaC={h{w<63&j3Ftf-xDiwbOL3>~Tai{}x>tNCJbaTN_di3hWCCo=%!v`WmXL27t7} zvGMVji=TpDgG|9#8S2QP`+uZDntK-x!HY7(Ck*{a=jP_#KeZ*=+TO-_D>1KT+AxE0 z9TWI*{nux#?u(P{s0EO682tM8Vrb~x!~Gb?4S$npK4hC8WgbH$7dJIS!k3vZuxfk0 zr!z1zs_N=qx*u&$E=LPs>NeO(dU;*8!%rJdt9nUR?q*ch)!_w*b{Z9Kn;m-iU7vP4 zTKa_OdRbnd%;k7l_?Zle+^BtYg*=&NexJu$f>vJ~_c4=<^aTVv@U0 zUMX5nZ?41+GG0x09%R2!2p&qHS94g;a9_GZ{u_Z`Kp^K+QB@UoR8&;R zm-oDmk3TK48Gj%IsggJ`-8qIkl{$53(&Ro|-ooTbtcKAwgZ%eD8euogxuvl;bT5Ad zDT1LLHz$={UPMFX4fD;c`1n5ta&g_D{5~ryuii{hQ(`~t*sf^Fe${4BN$Gz)lwbogJ~Ll0 zp)wh?PKHTtv)j!!`aDxCNJHaKv`fuvuK{_NRPGtetI3A*-HXFuj6;US5v=W#ul;7~ zZHr4vI40d)U9a`8)rqXj)%O%K_{Xu`wDcVBCNb-V+`W%a=q2SmmPMX)8;CI@xuos zM82rC#L4vBg4f=B6w^+?X8|ey)NJU`c8l}xWt@50-Q(wS##JW}aqb1N%2C9gW}Ljr>3kN3!DwO%K_U_e%P*_htnX98KQZz;8EOJ&WX!r!XU z!8#T?Sg{i2n%mK;u3bq%u4X?`ksL)LYu_SBZ{=uCRnOkY+EmlmZ`duicM2MX+cUfWb*4kgVi88^6RUE zZt`wWu-g;`Cd8s3@J!uq!-9cD{b~Pu_Rkv#s>26e#P)=wPcOdSs%YnDFp}T=3dOL> ztv3Fp`Y7j6A~>)wM&x=^ImBhejyO?fIx0VA(D3ABH9xJgS z-=9CqKfux#U!GV2u*C{!8%sc=R&*o0@h$pVdT!Ty;nYviR%GJif{o zA3kAIO~2%v+|AEmy>Qp|6LHzisg`NU;<6ifd3m}k#WR}C#IM2>^h_@JZ5JF@s>MOQ zYGr+$xHaM6Whkk+gGzMJ4wu0$Y}B#2zN?f}-t$!^T4Bz~D6a;(zViTg*X1;7{s*gu zn7uo@kBRzKSEnw`Vh4+Ctk&b?n4{E=TF>UaqT2Gf)!6gdG0FIf4=~V*?z1#@5*B4h z5+zrUu@eRDHGFKgIZIkA;+9#@VUi2QUw{B0K>foGZ+`Qkt^gydYRBu0hucE~kin~w z)mgA9_=(J%9WK7aU=|IvE~}$#EydAT1(gW}rWg@Ww;g z6l5SC6g*Nex%!FY=m|@6%SHE5Pu~!@a5v$K3CH(2ZRomO*FemTlO@MfnzpL#kS>^X zI75cZq;XnAv+KqD8Xe5m6AJybcYTHy#Z~{IN9}H=+iSR}-2Yym$4yoFE$e31MLFm* zJbtsF=F1!9d&N}K>5q740bpPRwu?p)nNAY$|9;hbQesGX5Y!{`B#{T(JPd5}O2r(` z)PLlQ>@<yH-oQ}9qtBY?Wr_c$5g%798fs-CUCpQ65x1z74)>8JN#!2JMW^(c%RF;Ty}17?8F zUl3Uya9D^gEV4*cATyThnFEOw5+;QIzJKDh9feNX^ktzO_kU(h{}~X1$;dkjrNwyd zLI3#%qvYnBTiv5ACsf}pBo6Sp*O(+9M zz|i{;SJ+9~d+fyk$pQ%Ulv^3It9hFjuE1!$u$%gp9Qv;8G~{x2uGW20YIRjZBT3@ILB_ zKM)iqMa)NBklu*k(-U_mmhMWJb?GhjtQ|os*%0NuvC)Z`PgjD+CudY5tV4v!?>ruV zAbj?*(|3In<1CNjc(CQ37xKFBcLDyGibc1U(6sO@3y^Q3iu}@v0oMVlPjl~K3cU^} zr55|7?+b+O7ekkAWxY@dO_}WzTazHl?8y)7b^+7tjwoxTt8ELNV zWh5iB)U7D&!)qkTyOdOu^=%n4`Okmfu-bPkPUwf24OM+lhP<}GjwcGORn}|>tx!(zAPyCSQc>xk=r4#fz*eLIdf zSzn6SJ(v|WAmE;7e#QH$d?x5TRI%IUP|1KIEIuYP29LEhvM(HsDP7>fH_ZwT*cfa3 z(cQ*;strRw58~6)xmoISQuVwM1O;a))`_e+3z4mt`YZuo}FXkTBh~w(Ob^ zcEzysGD-<6;H{;y9>%G`aCBXE)rw@6xL2;Ey^?Lrse#)Ara2c~bmigScpsxkpqx>T zh>gI}_TPkzLc0VyAP)d5Mpq*^z(H&_JgK?FczeU>eO2vzFk-X)eU8V!p<%BR^*&bj zHDji@##dhTghav&A(8Wo6Vbll%IlTn-%HZ0p%d$$W9*ymOz3-K`uePYMuD~dKF|`5 zIGJ%yk}7I(??O2vx15JFm%>m4$a#LXcrj?)JKT=<~MFaZ6HHQe&MK zU^LyX35EVvk!ex{EO)>g{NHENKORk_rb1N3H-jw~(@bej_gw-en4R?W%_N&BgO05%?IfRf#V|69$=wu5f7|5eU!SkA!+A#fUce+g ztf1{zkGEY4D}GE~5pCoFUI88YYVWm_PgjPF_rJjFBy?h@&(JGa237Xi!ZW?1MmV=G z`;+Na6DI-t8u;%LpoTBDVn~?Kwlx%}_9r3mzh{i^#YqZmuQsyt1x7*~nFnM(itr=f zz{()U7EQxA@}D(yS^x^E_6i2UX@4t~>i-pD7s`x63HMaA$8cOtzSY~lHkx`cIX4+D za^JrFPaZh3K{lKq z3l7wn1Q#rH@lRipWjckS-A3(OP6~RWB$m(_(am;)pTw=`m&TU0k|X5?ixEJsFv)cq zssu(1@y1s7|Js6e_(U0_r=9%UY06Rrv7se$73vT#V+e(suG(s4sO+7-0TY|^z!Tdo?o(}U>Y2W z{|He259ygTKmXi0s%e32Apj98S`ThDi(p!R5^xOuD(H@|kN1BBnavLjkx(7Lf8~|( zK^Eptk4Y5_m>1FYxWDRob-$$ksRHDS$t*bf6jZGMYwZnUtmrft^oUd!B80+#imp@z za_U2nX`Kv0V)`kGLf|bBi4_j3hGC80x}D& z+3<0DASRJSlq~28T|^fGf3iZFDA<=5Zx9ob@r#7&d1hk*Rpu#U86T&Z?XxOdOX5kBmBRGuDRg=_2yYa-ZZ2ZD#`&n)yR<)pYLXpI5Kd zHI&}RGb;$?;>eVtz>lF~#pcaF3vJI|UOIps25!bEda+|tApR!@GOsP5+5}LHC2&Q< zbRm!nhLH)K!dU3SnU*mdl#6I)Tj%7dDwzvN}%3ArAmQ{p2_?*(DeF;<7 z>aSRU=N?)G@qO9;3$%itk>mXrV(JH3B9j}BYeZ%*-;W{u-nf^XtNit4uYmI*Sk1hU zmD$7mkGxJ*jV0GHZxYacRG`G^DrRdkf8;GsISc%uvTonD3dwJ9gCG0NvhY}!bS(yR z+b;&mD}Rk=Z4Lhv=&c@pW%e|>TO-A7ybMfU#svi$9-$YVNO z-pviFs(_rh>w=EvE>R{=G@T3X117WQ#u$D?8lyfMjMe86=b33mDMwtLAWIkTk_ z#_$0`S3eu6)*e#?T7LeK@t)5t7bWfuWsu#)B%YjR&hD_a9n`_M={CS6*8UGVp32;)r{ zUL(FKh}hN@X^~7wChqP}WA?C@kZJTW>h1k~LW=cPk0{js`m?mk!uJr?n_nG{Uq?m{ zejnLLtSE{X^)wW(xGY-HE=GrHbutIiKf$$HoYj8(o#ox@`lU6I`Fk-#F~pTEB)8{j z0;>A-`nr$v_*+_@w4c8?>GD*_dcjytAEGF5_A{FHKmY0e{BGCl&`GcF3;w~O)< z`T?@z%e(dCDOi6i_(TX*WWdQ=7R6 zjxtMEz53oGba9H?o=~Kt{Uu|oZzT!_fiN@f(uAKcw#ZGNg~O#dBRb{i@LZ^cfp!F| zo)`U|$C9>avo7vP(g+Wm|LyOtlaa(bv%a^>goZldb;LY*CC2F_+*;xFZF7_3laX0| zmq$_)-|hE335PA8<%5qz-h5787O}gIaJk9m&Y5PMxft+`Ekbz+S~1CUGAXy*v+0MUxJ7i1A_Cz^B9AWtl2}0 zt5FQ{uNAiz`+}XvC4F_*3j01ZZD&YtL~)rJ)t=^L9OLIJDC4O;Gv_Q(KYgA{ zhJZ;bnV(%wyZZrCT}s)F%S7JAqS^XZR|}bwk|gOMNfPYHhndwB5gHd3X>YkN~5 z1o%7<#s45c@e>oN=dt^WC>}OSC_*(IiDzUm0_;i@L3<@MVG{?*3tJT5!&!hLqBW&- zqWNNe|7J@MuKCWULX)$|B63@`EtY(j6t#IU-Kl9VENOaLiq!2FigVv8b3tl6L$?yY zf_-a;y1;;j`RIVFZ7Gi}H|@Ldlb~;djumAs>j&p+L%}7*rNh%FGwEJELjpdju`>6$ zc6&a%^iGYsu2VT@Y|qv2(rLBvmB*V`6mGNmO{s~Qt)bN&k8vro*(B|ieWzW^SN75k z?IPzI)2r09QEKn!m}oE9DDt~XlfrnEeU?q;HUCT?eEP2UJ1tLg0!yu zxAD@Vrsm4Rk1{!n{QI>JYCW$tulJ(;Qrin~f{=N&`k1xrls@;vOI*|Ht*W$-E~PeJ zSh)<4?|p9=%7V|3dp@7=JA1D0ek9U=;`x?;Bpt!ixmS)VLT3P8x+(RZ`MaXXWxXaL zz1!s-<@eh+xC65JIU&8)v_t65K65++<35^7%r`$xoIT5A1o{}#6+%)UJSQjA9=9`{ zp`;46EJ*ompntaftz${=w)-9unC}nwD1N#Dt!SW?@WBSVm+5f8qXQU>;-@{h=(nzX zCQR+mbZEZO^VoiEtFB^4Al&T{-)N;f*1EpX_5fH0^Gadt`=c&VjxPgC59S4)16x*w_k7~=4`?FjK0YbU&JOe>^oRfBJCfu#UK2!#1e@ZHoE&H1A~v)n#1XPQS~#kl5+A+u%*1 zlz?H(CnVaC!#?YEZAPuF0aWtKn`~bp`E-xnD~~lIA+PXWgFFj6U(ShLGwaVko=1`K zizbfOt&vKPugNvF56WiLAL~iu7tkWtju=>2Tuz*;|! zDfcPIG1Fcn5@GZ$d$nmh`2xT6sogN0^OO0KI%4i@d;24WX3p-lU9&-fw8Nunk@A|a zi?6b3u5LzK(tYnMU}khoN0vl}5sg)z`E6m;`dnP|TeSMHs-^UHx2M5lJp4w{M#ZtH zYM;lc_0t!-SPW=v^YG#Jw0HDmCJ@;u+3a%cJCv{qootVfqI{r9OcR+YI~PTOPPcm> zyP?2l#lZTFCMiZjS_JbMEFSX#+uxrYg(BkW9f!~}BP=kF_ICG7V|knl80~S3(ernb zB z@JZF2Aaiwb+8rYg6c~8tLl_eoFduK2j30=0lEU6e?)3yPP+(&snyT_16W&Ke`5geB z$D$vlf}K(T9a?h()`e56`H&`{t@rWeP5y0bH~e#}IQ@89)F>W|0vi`RDKkk33kU<& zN|J>B!83|A&@8+3U?8@?XgZE4{#R|Ig!=&ws1y;Oo@@*r09&^9Oapt8IAHf=SEv3} zN-FV|hQ+*J!h556ud$I_)1$$>{z#Xjy=?w7LlZ?S-mD?>WI zrZllf1qN+MqYeu&0&NFA={f=;T!2KF#Cv^_35JoT3xqtNx7n8ltLJekwzhzWSC1xS zBwFRGOcM~rIq?YCF*Xec6E zo0vDyK#xxmV0ucRJBR*sSV;Onv|C8jWB~{;Z$)r%`?uA64AhVU>0cdw7Au+b<~nAE zZ>Xb?HN&M0_rZf!)KF;tS+Reb;-n!V?SV#|q%qYI102AhKjn14KoRZ40xco z-9rT0({Kk!p=l(M2lWj@1XC2P;fwtELXwKIesud!&>#`CzmWO+%LDo85uuxjQJ?^g zqA)t317em3oi^-J8ug`tOv%MR2Ux=7X|XBC&CR|2{_S!rY6#S&HeApp;yeG%E+sV3 zz$cZ>plTR#Kyb%ZmT9f~r0GA8f2v*}k_Sg+*cg}*&* z?>pc!k&~ZK{e<>O$;WguYuo$9xy@2|8r)lRc`{VUr=y+iZGsX3nqW*7V41llJ{%IG zXa+JJUj4R%9d;KeI}=?EGSK@WaPc6WtWKN)hLp~PhNwe=E{6YAOeO&(KpAz?W+T)4 zLR;u~tR_{^F0$Qe`XHJFt+wG?CaavRtfr@R$)QuV`t$c43u@A$U^%ub10}eQ++-65 zGYP7Cl)UM@0e?-BWBVh3+&wT$m;wGn;7aQw-oGZ)rM;%uTMO=%usgDNFY3@d&*d&6 zio_d$5FQhzz>r>odpz?wLI6V<&E ze^D(Im%iNNFQQsx>yLmQt%HYz7Z7UqiGUhRNQi;L$SI&{Qy26%KxdzU?#5?ZJwVC; zx6RMq|4TU_O{s9X8(;~Ry<`N_(K8BOpincyx?*bWcCG^LGX z2W=w1Yk7%ks>&m3A&c?`-1YD=Z-`iD5!3;U%zAxk@LaPD9SV{d=M;$%*;9)rWN3a5i7f3#`vXW}&V#K!8ijQUMh4*@Hyj^#>c|=P;754VHR$b%A4>%pG z)|uc_WzIrUcoS=DNlFn(Q9`~cr|`H3%P)A3jFQrY0=K+o+4ur^R?+SG$5m@v8!a8{ z@IB5Wzuo@$gxJ_Z22OWbv3Ab2%Z^J!ec=2O@tEiHM@qnp;TiyPjPr0k%84_AX{2ga zEacCSy#}Cd|M}|rT5)}aK^M!TfOvR#iM6!xlP6ECENT+6GIPsTFfH~@<_eJl7;MTtBHY^n7#7q^~T~Op4XfoClFT3H z)U{lZuMWZ3D8-ZV&L?tonM)+t6}~=<;TzfvO0-G%KA&^b=4HK`RG#{DlkNJb_b$K5 zG|qIbPjW1S#>LvXh2_--&c!fB12N)gKD#(Fu`2kQ^unc#1=t7iS$ws=O5VNb_0!L; zBW~DQ_iNG>3P*FTi+y^#Z;wSs^+bcf=lr1%+nFSlu7Z%y<;G{_rzP%=IldQ$>+tGk z&5oPxHvZQu*i0_7P5FqfCO>5X6V?G-)8vg;_q`kygX|w>R%D5+0U*m&q9?ZXk>uFRaY;4P0Y-t-)Mwd-3sW z4Og}@R)?+GMX;y@Sf-D|<9jKvIe?|=*&swgoB$}Li3SY~zzjXepp6*O-A5XJ4Aw6G z)LsUG_E_7Mwvabc-O0(t-U$!mR~b@9j`mj){rWLNrQ02TaJ%+>M^e|xk2)<@rE=dG z%~%6lF18Jax5=4Cm#{=M16aVz%9z9nKW?r1+4ucDU8iZ0)h^EC5}~i zPdajSQIs#pni%Y*@rtvUG&;KGnb%NLD`gQplDM|{$awL!fyHg@`s;vu+Jk4DuG>M* zTnE{zI!$&pN#)u_^G_uC`^TJu`Gk(oq_c)o71JU+QHyhOv|R_H7R6Vfm_?EN-4e8zen_j!j$GWlMPWx9HhuV@5ci*A1kj@cZ`Vcy12F zM2ezWRy7%y=Tm6y6E>L?#I#ut+*GZHbO`0wpFSNQ=T3Pb0+-@>Qta1}sbisZkB_6L zLOa1iX(oLyEPelGt#Zw?`GM=%@=;=P{Az1UadqK*O3PSJ5piYHNct@O#7V=Pg%Vc* z;goBn`RyfMjW4;P&=T8#uUn2>_)(;2@5uY0uB~2=q3wpXW`3J?U)Lj%v6nY;>`V0H zdM9p6!w+hI*11Qf_m0$Qc`MqpYFjB0wHa0P-KQHCbpB{XyCLSRI9J)>1$Q2w=l1)Z z)^y4ujyLX_>hg%)l0{>+`y!q&Z1y@hlQ?HTDw|6V8c7BNsa=U;V6H`(EYtUddGw3vT5wZWx z+-+dre&UO`RLTQ=?!r6pB0e(>ZQWAkr>|7S?CO25PAZAkZkzgIfTM#=1lF3p3ZP14aBd*A(P~f|M@5Indh~Tb zq|ey^SUg0-2elQ~KY|CxZ4%e?#eP)1z!^5GP}1%2XbC?$O32E}Q=D<*r44oJZ}E9* zH}`TyDx`W~0lvo1&nRf;^9$A4xM<$v-iUpNUrH<)-Wpmn?z)fg>6fTP?$eBC15(*9 zP)(G(Be}I2r(%R$P1fr=rq7?gVpRK}7F5nD-L#evUEb8+iq&&E+~JO8%Q&~$+F}vb zgl3-sA014I&wzKWFx@5E8e9167}%O94W-KdVLw+G8+4C%-@2rtM6E-)(&5r{ z^eNSs>PQ7S?tNi7jdDCwbDv9ixxI?5Z5v0r?`Ve2ijD%->BAozl(+DXPd(}UN3#f@ z9mbm#3)l44+vHZ)N`wT~F{zrRut-D#2Ze1 zB$|B*R$p_B5TiH((gEafgCy4{u*Xv1FZP6rRFprKD-EdN-9yhg^4S2DV6Quz*ekHx zx9sOeg|Zoi-w+FvL>JchvTRE^$ z5k-{36rlTe!|d2d2eM#%SxuzH)_{saIq*Sg3>EdTiDPh2f){*HI>Cq<6bRW_IMo;F zeJi`xCW|V^GqJLo;{I^zj2(|)t*PiW1K6XCfq(qad4&lD_>+L+VyD2q1sy!dq9g^b z%P<5vSq#lX{kMUD^$27o(4OH-!l>d!DQI9%H5x3ZM$8!cm9#X?n*r!ec>m6bS~c#- z#za0sM|WCXRW0wZL}#4msrB)@03sf1ONA{H9N^~YgBR@e1zBIo13S8~vpFjOF3k7F zDFNKO0d`NxP{x{Pops=1MHEfhr+1Ps7fsk;#&Q(E!y_U}tF=3Fvz<36Q7cl4wkl^r zMnrKD0rEbx5WS0C1s>YDgYpHsaAhHL56IgEEY#d@$y1a+`5~`L5;r#Rog~i39%}3` z)BqzhRSQ*Jx1F((@og6nc=RP-2_FdMUqsO#9Kuo-1h+Z!?*Qrl(=T-(1yi#9_^pgs zPi~lzDVX&)qQ%uc_&ocG4KDqQ_vm>97-{ONQD79`uR*WovJmk^p}pY)*1s>}XNi)h zq3TBl9=aA;E-ngNrjYplNg(+%8Doi3+<@^diHP$9r!^M{^r3!xKPKV?X2ii@3mb2yzS3H%;TNHLaxV)_sCTkzUCuB zGB*Zb>7m*z6LNqLBqhFoUrI@K^b;9p!ue-Od%9GtzX|W%QVFc) z7)i`X$yxd&h_NxJ2B+_3het^QpY#k866*toLs~GCBF;rN_TYjCqjEFo&12yIKlCW< zM?usAOR$MQ^Ie2EesE-T0CpH3q8Em~j&vG6i>Ek7)Ih51mLq#*z+Fm&fZu?t3ePGl z9-Dy_dN6tK3L+e1(l>FDuu(FA4!O8Rz7f>}!~1IX>K+r3wu`8QAOb*)8{QVGp2nTr z+^Rrbei{HhJu??Ky8)o_P;1_0`=3s>zvv2BhmX7CQYf$qz&jjq)V@nTjtG?MLO^jL z20Gu6WAPH@%Muac0~+Hg5M#y;Lj*0LQ@DS| z?a8(CAB@9B0e8_!IG_jlD+8tUCK9x5i3ZQP#;Rb$0)Z_>BYJcP$UO^)`JBmNIvD(x z=Jn@W0b2+}2D(9k%?#|DWvrP23JkCi4@p=vL;Q(BbF=T!xWy2V?0`_5)QHBA0tkVr z_=@1f!xX@@g}lLp8h{P%dIsxeL;0NmS}yuHTK}ijLcJ?SVF?<|j%7zc!v6IL%=Qpc zkv@=QYty|y-}?XU;H__A(@2PQb$1golbRRq5vQgmrexAwl83&1`*yI#6(g!|uzzyD z@uG{Sps0|6epAHc)A>%*X@F_0PkmdSczL;PADgTDiJI<0aeCSH+3|VPj!sgH*|71C zZMJP>B-_4z6uZodA3e@s{(nJJ%4q_Fs3&bD!0=XL7|9fWpEhiGzpwE0*5^`OAoz->H^C)6IiCf=m zW(;y(l?i>196p?c{DB#{leC#zc;M`-qsxruqXrST%-de_J@x0I$BCTEBcI&owXC!g z2h-R!Vgzec)(OR2MpPfTob1~>ugJ!b1@pf;b{OzEa6Q>o%|-O_+&IBXw)&lY!!W14 zIOZ^Tyv*gg#CyFx73{g5hsSTWh2z;LxbX5#|NESvortqtJ6TOu}TZ&Hls!o ze)EKjW4a%Jve|Gr^3IEuE9H5m8qUXe5^B?4aJeQ}L~~TH}iAi9R{Mcw@&epxJ`qrmEc zi3xkg(cq%2;`7S3)v&Z>8qyN-Ifqgf{>z8g*TWX-x)a%B2@lt~l+p(S2|wYR zN+;x>>q)q~<}&DhoP;BJCM<0D`QF*M7r9^V*cWP2$H^Cfo08?ZKAssA6lpsr^I5m4 zahVYzcU)`SNgp*;mYs9ib*~*m$?_sgD?+^&rC!H(P-}f{&YdoBfsdd8wAi;618HA2hEsH-dIUIH)B_E?h`QSLbBKGLhNxcnyK zvYA}84URyKGl$~NMG(fV*RF29*0}7aTeyx6Rk)a{c6wh2#c0{c+|Cce3u3e?wRBsW z_J-2mRZkqw9oN{GYB{Q=?>_7c7WD@E?N@hS^?g#X&+MDZ(%t|NdT4*@f(d|L6hd{e zO*;7cRVLVK4;2!!tYCj^TEK$tpr+{F@O8Hu(kdcSafO`j+f;;>YMN`NwzF?56co}B ze;h8}wm&a&wzim;k8s^>V0AJ5He}zdo9u1Xd~;K25y@qx3TTKfbn=b#hYVa=J=bd` z@~$Ts4QWXImqGn?&Ox3hBh3T4+)grn&bIn_45g*q)*GU2wn~e7rv+-RL+R(uyU8No z11A=SsgCfse&XDzGafKM*;CDrh-|$$VLl^#%dJnVl#-pb;MAd`pbwYYd*SE`e{?%U zOR$k-=sT3o#V}gu*DinUg;$!z_qEo07g_P?#LuZwxhG$Pn}76Vt>HUYZ}{!AbB{61 zwC44+TWqY*KNy(a-}<>I-v2gRc`T220j1hk%Aj2RcK%6pzML$l)=!NwdU`(9ZW7-r z{MLg^=aL?@+%&%%eP+)ys%x8@Qft)O)a`Rm;-;jWC#65EuQp!lv^kb~;^S?GCJ#7O ztSm$33vAztT#K6}^-GVSFt^ zOxJyfqq?BT!%#UylhARr8-;|?QuBT30+{b*y&3b$#?q2sf z)!xwAPD_u~WDvMOhMt%Nj9?f*0Fn=Bh|jaDgitcG>cs&S=mfc1kp=LI2m!n@vOHjd2|^(<0oZ3FNdG)C>&typPXi7D zN^;=Zwzi1tZStgrtF^$k5j9=4zAB)m^?HD$53q+mK!{r$3}kOa)d7&p4NvZ+#0HLc z?t3G1;Py~NgNsYNf^wmvbfAwpD$cirrm>ftxzA8sa3}Rfa|!UgM%ZgU8-G}&%RnWme0$VG}f+LSx)>Z&QIt%&`L5)1%KyvR%o48Ms3KRi%W z1vD+TxbzapD-`@GjGmEn4V@JaT>S--vw_~w@7ne$a@KDkWC0vwJF8QoIP;Cmx(5M+ z^!vJ~_-|nVlG%arin%K%;0U>74a2IuFgIY=K0E^O7i9;ylVPMtf8bybPKUqZ4Ny)p zB!@a~DlSeLhTrGhY5VJx_Ybf9-;YOP)c1&o9MO^28z}^?gT37h+}vN&9Kd-v_HQI` zJn>&1Vgti^MFyr=*qG=TJHWwgxT%P&XeFN-3nvE;oY4PcoBV%1p$GW;2txMfVOHP8 zVW$a_CsB%heKPyc-G`X;${h1TO8oZ8^gS~}fjsOXO{bD>4)X(({d?#gCOB6HjxB)d zAiKa6M*xN-Ox4NlGLKI3hp7jig7Ca%0r*_)=Jk_rb>^Qng$~yZcj|`MlJLPa|LC$c zo7QZWmyRYcZ5N7YLk)(`%Kza~{`aKVxqN?N-1hdh491%J@Vgrymql&b((-aeS=qO8 z@`@UY`fW(NyViDA;GoNUoqG6JfX~2LljmTXKwt$0_)=HTe;slHNeDVg_5Q-OL5Xxq z)V!OBoG(4Ge=cGWbkO3QoH?TM_Pkhq~E~l)hD&;v*QFMe&qys$EQ*aLZrQY9P z0QfPpv3E2?DM*0)95&eTfo1~&Dwh0tp)~kSumkuwnKAE?g(R|%G9;}~Z3j0g8AE^n zAz%XNyz0LSA^499p`@LVN%%`es}>2Id`AGApktasfbigtaS=R0O zzLX~nvY-0ir!GgAnpC1AW|Hy-O7(q=He-2ohKd8sgA1~EUvUikhy98Guzjsk9*2s$&0KXFUh`&Q^!N zKUjkZBs;exs+ZGIGS*7Hno`eNMjqV3Y~m)a->5tr`cYZgtR8UchPPtA9ni$OQ45bB zNcWi5P%VhdMOvR4$yiOjzIv=Lc=S*kEAbqTQX|w*U!S7$Tzz3OKle&+JVi&K-w_f_6AM%w;GoUmA*IJt}{v<4(QK@Jx6qC<8l!jnzmDUuJ z7POFgb>Yx3moo!|;?6N(m8bmFb|Pi8&~oq7#CV6+TWjm>lTmwvY+Neuq%ytI7I31! zvO+EWP?6U%LHNZ7&4SfcIR%-KgZOTnkGT!pcE=77$qHbO&B48IT)zD>Z8slrPe3NV zmse9D-LPj-=^UOod=8VwnT&OWoY z74;Zu^Xd&P*FahPyZ6N)r_nkDN4iKUBVT|#WxH$&e}m15QGenJeEop2a)g^_z5)sh zJJFTTc%ZRxv88adqqfFJF9J$ZhGLBMounnCroU=m$dwzlZ;yTHF~z6TVb-D~bc(`k z*kYhtpKP^L_3@%hYY+uPI1hq1VC(%eN{$+s$ahujC`c8Ms5M9)0&v^-4(MudjtVX; z7b4UZo9B3Vs*5itYpO3%UT7cZEi4o|f`HJA5AiNXJ~bzKAh>{h-&cKs19ye>Ha(AA z&#B_Zdp~bbHVagZC{;JmC~cP_y4x?CTRJe=9AZA663-*jvGh#e`7>$rIl;m(6j1>D zJn05uwi%gpY7nd_E=}rNm71o*wLi|Ip{h}k-p}OY^TWz>J1?frY0jJ~3k?8!<$dp3 zxzI?WSMBwZ$p)`8rLZ>2>$^O!f1fpMTqdXIb)=?BVUXqHZ9iq8qS7d5Mg+8o`t?ee z09T_Q`N@`QzD-pB#h3JF&DV=fgI0wvuP?U3?+=blE9wsP=JTaKwQAi<;Z&CN@aTJ5 zl;0Kes3ar=zkIezb~$QNr`kG0iO(}P)91u?Jz=b2NJLHKd^BTA?fI)8{VJU@`4K}Y zIXog)FSHEUMtwd$$N3+G@&E{1y&*7h`?cx9)@UI!TA}GQymHfT@3l(D#om*PliM3{ zdfDE!+5v?1m7#qQ6ld#?tMApuR>OQE#o;j1noo+xs83^M#9dF6b%g#r9bZ%O5(YT8L&D zFtE{jB>7c~C427*VsM+NJ(zZB;k&NsP2Yi++dS9t4EN}G98ymW$jxE7I*rjh6;>c@eibqx0DS366<6Ex8<%~P=9`w}t#zKcg1}Yh zfbGlmZS%5hgIif>WVBns#ztA&=)FScD%-t2@O?baPwjtOx=a&2`e4nog=}rQrCmtQ zWi?n&oM3v-VH{_MzA0E=zFMO0JAF$jLMnWGfC_)24Hs858P%}fY}uv`o!`? zMbp4a=^l5g&5!B?%Xw+jg*)onYBs(Ds^uQZL*~wN)9+sfx%t^z6nLz%NVgN>$C=^P_!QSP$e54pE9w%5Z4YRJ8#0Nero*tCLs{2A+Mg=hC8vBH-JN5-D3&^H~X|6>T%BnlF`T z&FF^4ben6Q3CW=yzm1hOLB?X+ev-1iJDqVxqe1jm<6W{%Mcg+x>xz= zIUAQqOcf#v1C;Vl0QE;)rsx&KKlp{X0H7|B4B$<(cM2&|1r+wGPmM#5qWusjQUM77 z%BtI!O`m(xgoPqL7phOw6~Gm()-ohF*N)#MDh^*H5PKw~#G2=8de$Zx836aC514UP z%|#{-0x*oXkT=isw$9p`$&QanZCaNMc!M6W!ZIWqY^40Qqo9wNq2WhO1DTi(w!>O-h|TKB_JgzCEd~u z(hZW*dtN?|@B6N`_TKAzzx5r*{;_|^aeK#gU31Pc#~kAvaVoga6RE$LHydrzLdRKK z^>}m-^6&10XaqkQ1xWx1Mu8N-phPJ$3=J@pf+aI2aLyrs2_iv);gVqh=T?=qYH3{u z#PCd|axo_7hsrALX4KI@=4DO z>DE?nQ`W0juYBFKtq}n(Iy3g2Tv6C1B1Cow1;jvcz)F{?ED<0{Lc!FF2pu^1@4(>? z<2GIk0SS#zbZq`njvo@DTtF-cP=&{K!lYheVVj9JW7OH~iTC?m@M(;V78(s=T^|bI zbx%@IQ`0ce)5}4*dv-QzQm&;8#Wr!NiG{#ov8p>yhLYArAiK&;sdEp^uam_S^}B(P z4HcBx{t0Y*QedlICGZPfCV@dE|D*(^Jdo?$T=rg4=w^i@>3ZiWl$esr;afx*0w3rG zqL_4id^($jg+0C9&t8h94UatgiVR%6JAqGtoVg-DLKh?3f_Aw~A3Vleejvo7n4iFY z#emX!Uy_imN|0%TNq8RG-gyKZ-L6mu^5@7Pq1e)5(=C3lXb6!q;@@z}{&TAN??mDN z9-Li+!01aDEavLN%b#;u2a zMN(4I7vz943h~9oOazzVSE`ta;c~MOkhAAgFdzbka?_op^cC7`bGDnPV7VZjW6I0^ z0CDLJkVN||lp+;VRh2pqeE2!}493chH(VgAGxf>Nz58hjY<6v zlD|RDig>668YmfYArqfJ7q09Q&>5Ed1AwE$f#+)z266p=pb0+G8C6y4Qc_a#N=m0X z>+|+_IxD+An*(}?3^M@k!&mWoacM~OX=3PQWtU!}Og53JDrZ5x^FXSd6@!Ac>ND|> zxOW9{?4P_gndZ4}*6@-F569a-fekwE_{`tz?=*VNIA;lY^Mlrh zseYghT8a#fQc8jQyr)G|3QUUJ;aeTiTc)svF1pEVXrvF{*Z4r0d&PBD03tyG$0Qx1 zC2F9kAavk6dtH|OqYQ+CJUmY!F>7bX_ABT%mc(ZAhEl4=ZYB>7;ob&JhGgntnHny! zb01sOhEBzc*Gl}GR6@TNgNrVXQ)Lqq69*n^I_Bi$&@nSBHn{B$HwPjRd#V~4ee8~< z-3GnQCXP2ps%@v(aq#eptE)Ba?Cc0RE#Fz&*qpRJU@54q9M+jZAr;8K9+Ssl^e?HZ z9Jac-I_E#^qC5c&;kGuiqutVKx2dV9cq@D5>1TRXWr$a=thZ}y1aGg7lK5RgT8u$c zS(y^4|5b&O$hAwU;a6SYI}iL4CCg}S6=&9|Y&&nfo?I_|S8-%!X4V~+I`zq7e{HnZ z!6-lPgOT4B~;yby+&^r9$`p`KUOZD$DQoswVyp=@mAhMPp(^ZafZm%>dMc4U-25f4Z8QtLw?0 z?lUztv)kH%o3HkB^>e<{>m^?jLE|$Hb7cY(vmyHYlqJ^6!|qcPsf6nYe707YY7H%8 zY6<73^Z`SDybaJOf{!t_q@00{PDM#6DmGH+{A5&3dAF?dbb@}_>vU&x#hf|=N0D2P zafFbN@bnxV4rt+9zeznUaNLP~Wbvu?B(C|6Nh!d~v)7l^8j6 zMEx!r0p%O8cT$=cld+5+8!w6|wQC#}_PSy~gU=Zl`RQon_2h_{rX P8eudco?Q6 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 +* Juan Miguel Sánchez Arce diff --git a/shopfloor_reception/readme/DESCRIPTION.rst b/shopfloor_reception/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..15ab64dec3 --- /dev/null +++ b/shopfloor_reception/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +Shopfloor implementation of the reception scenario. +Allows to receive products and create the proper packs for each logistic unit. diff --git a/shopfloor_reception/readme/ROADMAP.rst b/shopfloor_reception/readme/ROADMAP.rst new file mode 100644 index 0000000000..e1c96af631 --- /dev/null +++ b/shopfloor_reception/readme/ROADMAP.rst @@ -0,0 +1 @@ +Implement methods in the backend to cancel lines (to be used by the frontend in select_line & set_quantity). diff --git a/shopfloor_reception/services/__init__.py b/shopfloor_reception/services/__init__.py new file mode 100644 index 0000000000..aa19bba8ce --- /dev/null +++ b/shopfloor_reception/services/__init__.py @@ -0,0 +1 @@ +from . import reception diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py new file mode 100644 index 0000000000..b4042d9593 --- /dev/null +++ b/shopfloor_reception/services/reception.py @@ -0,0 +1,1331 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +import pytz + +from odoo import fields + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component +from odoo.addons.shopfloor.utils import to_float + + +class Reception(Component): + """ + Methods for the Reception Process + + You can find a sequence diagram describing states and endpoints relationships + [here](../docs/reception_sequence_graph.png) + Keep [the sequence diagram](../docs/reception_sequence_graph.mermaid) + up-to-date if you change endpoints. + + Process a receipt transfer and track progress by product. + + Once a transfer is selected, you need to: + 1. Select a product (you can scan its barcode or one of its packaging barcodes). + 2. Set the processed quantity. + 3. Put it in an internal PACK (this is optional but can be made mandatory by menu + configuration). this PACK can be a new one (like an empty pallet) or an existing + one you add products to (like a pallet you continue to fill in). + 4. Set the location where you put the product (iow. the location where + is the transport trolley or pallet), unless you fill an existing PACK as its + location was already defined when its first product was put on it. + + In case of product tracked by lot, you will have to enter the lot number and its + expiry date (unless it is already known by the system). + + Moves are not validated as they are processed. It is the responsibility of the + user to decide when to mark as done already processed lines. + Any remaining lines will be pushed to a backorder. + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.reception" + _usage = "reception" + _description = __doc__ + + def _move_line_by_product(self, product): + return self.env["stock.move.line"].search( + self._domain_move_line_by_product(product) + ) + + def _move_line_by_packaging(self, packaging): + return self.env["stock.move.line"].search( + self._domain_move_line_by_packaging(packaging) + ) + + def _scheduled_date_today_domain(self): + domain = [] + today_start, today_end = self._get_today_start_end_datetime() + domain.append(("scheduled_date", ">=", today_start)) + domain.append(("scheduled_date", "<=", today_end)) + return domain + + def _get_today_start_end_datetime(self): + company = self.env.company + tz = company.partner_id.tz or "UTC" + today = fields.Datetime.today() + today_start = fields.Datetime.start_of(today, "day") + today_end = fields.Datetime.end_of(today, "day") + today_start_localized = ( + pytz.timezone(tz).localize(today_start).astimezone(pytz.utc) + ) + today_end_localized = pytz.timezone(tz).localize(today_end).astimezone(pytz.utc) + return (today_start_localized, today_end_localized) + + # DOMAIN METHODS + + def _domain_move_line_by_packaging(self, packaging): + return [ + ("move_id.picking_id.picking_type_id", "in", self.picking_types.ids), + ("move_id.picking_id.state", "=", "assigned"), + ("move_id.picking_id.user_id", "in", [False, self.env.uid]), + ("package_id.product_packaging_id", "=", packaging.id), + ] + + def _domain_move_line_by_product(self, product): + return [ + ("move_id.picking_id.picking_type_id", "in", self.picking_types.ids), + ("move_id.picking_id.state", "=", "assigned"), + ("move_id.picking_id.user_id", "in", [False, self.env.uid]), + ("product_id", "=", product.id), + ] + + def _domain_stock_picking(self, today_only=False): + domain = [ + ("state", "=", "assigned"), + ("picking_type_id", "in", self.picking_types.ids), + ] + if today_only: + domain.extend(self._scheduled_date_today_domain()) + return domain + + def _select_picking(self, picking): + if picking.picking_type_id not in self.picking_types: + return self._response_for_select_document( + message=self.msg_store.cannot_move_something_in_picking_type() + ) + if picking.state != "assigned": + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_available(picking) + ) + return self._response_for_select_move(picking) + + def _response_for_select_move(self, picking, message=None): + self._assign_user_to_picking(picking) + data = {"picking": self._data_for_stock_picking(picking, with_lines=True)} + return self._response(next_state="select_move", data=data, message=message) + + def _response_for_confirm_done(self, picking, message=None): + self._assign_user_to_picking(picking) + data = {"picking": self._data_for_stock_picking(picking, with_lines=True)} + return self._response(next_state="confirm_done", data=data, message=message) + + def _response_for_confirm_new_package( + self, picking, line, new_package_name, message=None + ): + data = { + "selected_move_line": self._data_for_move_lines(line), + "picking": self._data_for_stock_picking(picking, with_lines=True), + "new_package_name": new_package_name, + } + return self._response( + next_state="confirm_new_package", data=data, message=message + ) + + def _select_document_from_product(self, product): + """Select the document by product + + next states: + - set_lot: a single picking has been found for this packaging + - select_document: A single or no pickings has been found for this packaging + """ + move_lines = self._move_line_by_product(product).filtered( + lambda l: l.picking_id.picking_type_id.id in self.picking_types.ids + ) + pickings = move_lines.mapped("move_id.picking_id") + if len(pickings) == 1: + self._assign_user_to_picking(pickings) + if product.tracking not in ("lot", "serial"): + return self._response_for_set_quantity(pickings, move_lines) + return self._response_for_set_lot(pickings, move_lines) + elif len(pickings) > 1: + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.multiple_picks_found_select_manually(), + ) + # If no available picking with the right state has been found, + # return an error + return self._response_for_select_document( + message=self.msg_store.product_not_found_in_pickings() + ) + + def _select_document_from_packaging(self, packaging): + """Select the document by packaging + + next states: + - set_lot: a single picking has been found for this packaging + - select_document: A single or no pickings has been found for this packaging + """ + move_lines = self._move_line_by_packaging(packaging).filtered( + lambda l: l.picking_id.picking_type_id.id in self.picking_types.ids + ) + pickings = move_lines.mapped("move_id.picking_id") + if len(pickings) == 1: + self._assign_user_to_picking(pickings) + if packaging.product_id.tracking not in ("lot", "serial"): + return self._response_for_set_quantity(pickings, move_lines) + return self._response_for_set_lot(pickings, move_lines) + elif len(pickings) > 1: + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.multiple_picks_found_select_manually(), + ) + # If no available picking with the right state has been found, + # return a barcode not found error message + return self._response_for_select_document( + message=self.msg_store.no_transfer_for_packaging(), + ) + + def _select_line_from_product(self, picking, move, product): + line = fields.first( + picking.move_line_ids.filtered( + lambda l: l.product_id == product + and not l.result_package_id + and l.shopfloor_user_id.id in [False, self.env.uid] + ) + ) + if line: + # The line quantity to do needs to correspond to + # the remaining quantity to do of its move. + line.product_uom_qty = move.product_uom_qty - move.quantity_done + else: + qty_todo_remaining = move.product_uom_qty - move.quantity_done + values = move._prepare_move_line_vals(quantity=qty_todo_remaining) + line = self.env["stock.move.line"].create(values) + self._assign_user_to_picking(picking) + self._assign_user_to_line(line) + line.qty_done += 1 + if product.tracking not in ("lot", "serial"): + return self._response_for_set_quantity(picking, line) + return self._response_for_set_lot(picking, line) + + def _select_line_from_packaging(self, picking, move, packaging): + line = fields.first( + picking.move_line_ids.filtered( + lambda l: l.package_id.product_packaging_id == packaging + and not l.result_package_id + and l.shopfloor_user_id.id in [False, self.env.uid] + ) + ) + if line: + # The line quantity to do needs to correspond to + # the remaining quantity to do of its move. + line.product_uom_qty = move.product_uom_qty - move.quantity_done + else: + qty_to_do = move.product_uom_qty - move.quantity_done + values = move._prepare_move_line_vals(quantity=qty_to_do) + line = self.env["stock.move.line"].create(values) + self._assign_user_to_picking(picking) + self._assign_user_to_line(line) + line.qty_done += packaging.qty + if packaging.product_id.tracking not in ("lot", "serial"): + return self._response_for_set_quantity(picking, line) + return self._response_for_set_lot(picking, line) + + def _order_stock_picking(self): + # We sort by scheduled date first. However, there might be a case + # where two pickings have the exact same scheduled date. + # In that case, we sort by id. + return "scheduled_date ASC, id ASC" + + def _scan_document__by_picking(self, barcode): + search = self._actions_for("search") + picking_filter_result = search.picking_from_scan(barcode, use_origin=True) + reception_pickings = picking_filter_result.filtered( + lambda p: p.picking_type_id.id in self.picking_types.ids + ) + if picking_filter_result and not reception_pickings: + return self._response_for_select_document( + message=self.msg_store.cannot_move_something_in_picking_type() + ) + if reception_pickings: + message = self._check_picking_status(reception_pickings) + if message: + return self._response_for_select_document( + pickings=reception_pickings, message=message + ) + # There is a case where scanning the source document + # could return more than one picking. + # If there's only one picking due today, we go to the next screen. + # Otherwise, we ask the user to scan a package instead. + today_start, today_end = self._get_today_start_end_datetime() + picking_filter_result_due_today = picking_filter_result.filtered( + lambda p: today_start + <= p.scheduled_date.astimezone(pytz.utc) + < today_end + ) + if len(picking_filter_result_due_today) == 1: + return self._select_picking(picking_filter_result_due_today) + if len(picking_filter_result) > 1: + return self._response_for_select_document( + pickings=reception_pickings, + message=self.msg_store.source_document_multiple_pickings_scan_package(), + ) + return self._select_picking(reception_pickings) + + def _scan_document__by_product(self, barcode): + search = self._actions_for("search") + # TODO: use_packaging should be removed after merging the no_prefill_qty changes + # from PR #483. + product = search.product_from_scan(barcode, use_packaging=False) + if product: + return self._select_document_from_product(product) + + def _scan_document__by_packaging(self, barcode): + search = self._actions_for("search") + packaging = search.packaging_from_scan(barcode) + if packaging: + return self._select_document_from_packaging(packaging) + + def _scan_line__by_product(self, picking, barcode): + search = self._actions_for("search") + # TODO: use_packaging should be removed after merging the no_prefill_qty changes + # from PR #483. + product = search.product_from_scan(barcode, use_packaging=False) + if product: + move = picking.move_lines.filtered(lambda m: m.product_id == product) + message = self._check_move_available(move, "product") + if message: + return self._response_for_select_move( + picking, + message=message, + ) + return self._select_line_from_product(picking, move, product) + + def _scan_line__by_packaging(self, picking, barcode): + search = self._actions_for("search") + packaging = search.packaging_from_scan(barcode) + if packaging: + move = picking.move_lines.filtered( + lambda m: packaging in m.product_id.packaging_ids + ) + message = self._check_move_available(move, "packaging") + if message: + return self._response_for_select_move( + picking, + message=message, + ) + return self._select_line_from_packaging(picking, move, packaging) + + def _check_move_available(self, move, message_code="product"): + if not move and message_code == "product": + return self.msg_store.product_not_found_or_already_in_dest_package() + if not move and message_code == "packaging": + return self.msg_store.packaging_not_found_or_already_in_dest_package() + line_without_package = any( + not ml.result_package_id for ml in move.move_line_ids + ) + if move.product_uom_qty - move.quantity_done < 1 and not line_without_package: + return self.msg_store.move_already_done() + + def _set_quantity__by_product(self, picking, selected_line, barcode): + search = self._actions_for("search") + # TODO: use_packaging should be removed after merging the no_prefill_qty changes + # from PR #483. + product = search.product_from_scan(barcode, use_packaging=False) + if product: + if product.id != selected_line.product_id.id: + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.wrong_record(product), + ) + selected_line.qty_done += 1 + return self._response_for_set_quantity(picking, selected_line) + + def _set_quantity__by_packaging(self, picking, selected_line, barcode): + search = self._actions_for("search") + packaging = search.packaging_from_scan(barcode) + if packaging: + if packaging.product_id.id != selected_line.product_id.id: + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.wrong_record(packaging), + ) + selected_line.qty_done += packaging.qty + return self._response_for_set_quantity(picking, selected_line) + + def _set_quantity__by_package(self, picking, selected_line, barcode): + search = self._actions_for("search") + package = search.package_from_scan(barcode) + if package: + dest_location = selected_line.location_dest_id + child_locations = self.env["stock.location"].search( + [("id", "child_of", dest_location.id)] + ) + pack_location = package.location_id + if pack_location: + if pack_location not in child_locations: + # If the scanned package has a location that isn't a child + # of the move dest, return an error + message = self.msg_store.dest_location_not_allowed() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + else: + # If the scanned package has a valid destination, + # set both package and destination on the package, + # and go back to the selection line screen + selected_line.result_package_id = package + selected_line.location_dest_id = pack_location + return self._response_for_select_move(picking) + # Scanned package has no location, move to the location selection + # screen + selected_line.result_package_id = package + return self._response_for_set_destination(picking, selected_line) + + def _set_quantity__by_location(self, picking, selected_line, barcode): + search = self._actions_for("search") + location = search.location_from_scan(barcode) + if location: + dest_location = selected_line.location_dest_id + child_locations = self.env["stock.location"].search( + [("id", "child_of", dest_location.id)] + ) + if location not in child_locations: + # Scanned location isn't a child of the move's dest location + message = self.msg_store.dest_location_not_allowed() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + # process without pack, set destination location, and go back to + # `select_move` + selected_line.location_dest_id = location + return self._response_for_select_move(picking) + + def _use_handlers(self, handlers, *args, **kwargs): + for handler in handlers: + response = handler(*args, **kwargs) + if response: + return response + + def _assign_user_to_picking(self, picking): + picking.user_id = self.env.user + + def _assign_user_to_line(self, line): + line.shopfloor_user_id = self.env.user + + # DATA METHODS + + def _data_for_stock_picking(self, picking, with_lines=False): + data = self.data.picking(picking, with_progress=True) + if with_lines: + data.update({"moves": self._data_for_moves(picking.move_lines)}) + return data + + def _data_for_stock_pickings(self, pickings, with_lines=False): + return [ + self._data_for_stock_picking(picking, with_lines=with_lines) + for picking in pickings + ] + + def _data_for_move_lines(self, lines, **kw): + return self.data.move_lines(lines, **kw) + + def _data_for_moves(self, moves, **kw): + return self.data.moves(moves, **kw) + + # RESPONSES + + def _response_for_select_document(self, pickings=None, message=None): + if not pickings: + pickings = self.env["stock.picking"].search( + self._domain_stock_picking(today_only=True), + order=self._order_stock_picking(), + ) + else: + # We sort by scheduled date first. However, there might be a case + # where two pickings have the exact same scheduled date. + # In that case, we sort by id. + pickings = pickings.sorted( + lambda p: (p.scheduled_date, p.id), reverse=False + ) + data = {"pickings": self._data_for_stock_pickings(pickings, with_lines=False)} + return self._response(next_state="select_document", data=data, message=message) + + def _response_for_manual_selection(self): + pickings = self.env["stock.picking"].search( + self._domain_stock_picking(), + order=self._order_stock_picking(), + ) + data = {"pickings": self._data_for_stock_pickings(pickings, with_lines=False)} + return self._response(next_state="manual_selection", data=data) + + def _response_for_set_lot(self, picking, line, message=None): + return self._response( + next_state="set_lot", + data={ + "selected_move_line": self._data_for_move_lines(line), + "picking": self.data.picking(picking), + }, + message=message, + ) + + def _response_for_set_quantity(self, picking, line, message=None): + return self._response( + next_state="set_quantity", + data={ + "selected_move_line": self._data_for_move_lines(line), + "picking": self.data.picking(picking), + }, + message=message, + ) + + def _response_for_set_destination(self, picking, line, message=None): + return self._response( + next_state="set_destination", + data={ + "selected_move_line": self._data_for_move_lines(line), + "picking": self.data.picking(picking), + }, + message=message, + ) + + def _response_for_select_dest_package(self, picking, line, message=None): + # NOTE: code taken from the checkout scenario. + # Maybe refactor it to avoid repetitions. + packages = picking.move_line_ids.result_package_id + if not packages: + return self._response_for_set_quantity( + picking, + line, + message=self.msg_store.no_valid_package_to_select(), + ) + packages_data = self.data.packages( + packages.with_context(picking_id=picking.id).sorted(), + picking=picking, + with_packaging=True, + ) + return self._response( + next_state="select_dest_package", + data={ + "selected_move_line": self._data_for_move_lines(line), + "packages": packages_data, + "picking": self.data.picking(picking), + }, + message=message, + ) + + # ENDPOINTS + + def start(self): + return self._response_for_select_document() + + def scan_document(self, barcode): + """Scan a picking, a product or a packaging. + + Input: + barcode: the barcode of a product, a packaging or a picking name + + transitions: + - select_document: Error: barcode not found + - select_document: Multiple picking matching the product / packaging barcode + - select_move: Picking scanned, one has been found + - manual_selection: Press 'manual select' button, all available pickings are displayed + - set_lot: Packaging / Product has been scanned, + single correspondance. Tracked product + - set_quantity: Packaging / Product has been scanned, + single correspondance. Not tracked product + """ + handlers = ( + self._scan_document__by_picking, + self._scan_document__by_product, + self._scan_document__by_packaging, + ) + response = self._use_handlers(handlers, barcode) + if response: + return response + # If nothing has been found, return a barcode not found error message + return self._response_for_select_document( + message=self.msg_store.barcode_not_found() + ) + + def list_stock_pickings(self): + """Select a picking manually + + transitions: + - select_document: Press 'back' button + - select_move: Picking selected + - set_lot: Picking selected, single correspondance. Tracked product + - set_quantity: Picking selected, single correspondance. Not tracked product + + This endpoint returns the list of all pickings available + so that the user can select one manually + + Since there's no scan in the manual_selection screen + there are only two options: + - Select an available picking and move to the next screen + - Go back to select_document + + This means there should be no room for error + """ + return self._response_for_manual_selection() + + def scan_line(self, picking_id, barcode): + """Scan a product or a packaging + + input: + barcode: The barcode of a product or a packaging + + transitions: + - select_move: Error: barcode not found + - set_lot: Packaging / Product has been scanned. Tracked product + - set_quantity: Packaging / Product has been scanned. Not tracked product + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_move(picking, message=message) + handlers = ( + self._scan_line__by_product, + self._scan_line__by_packaging, + ) + response = self._use_handlers(handlers, picking, barcode) + if response: + return response + # Nothing has been found, return an error + return self._response_for_select_move( + picking, message=self.msg_store.barcode_not_found() + ) + + def done_action(self, picking_id, confirmation=False): + """Mark a picking as done + + input: + confirmation: if false, ask for confirmation; if true, mark as done + + transitions: + - select_move: Error: no qty done + - select_move: Error: picking not found + - confirm_done: Ask for confirmation + - select_document: Mark as done + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_move(picking, message=message) + if all(line.qty_done == 0 for line in picking.move_line_ids): + # If no line has been processed, refuse to set the picking as done + return self._response_for_select_move( + picking, message=self.msg_store.transfer_no_qty_done() + ) + if not confirmation: + to_backorder = picking._check_backorder() + if to_backorder: + # Not all lines are fully done, ask the user to confirm the + # backorder creation + return self._response_for_confirm_done( + picking, message=self.msg_store.transfer_confirm_done() + ) + # all lines are done, ask the user to confirm anyway + return self._response_for_confirm_done( + picking, message=self.msg_store.need_confirmation() + ) + self._handle_backorder(picking) + return self._response_for_select_document( + message=self.msg_store.transfer_done_success(picking) + ) + + def _handle_backorder(self, picking): + """This method handles backorders that could be created at picking confirm.""" + backorders_before = picking.backorder_ids + picking._action_done() + backorders_after = picking.backorder_ids - backorders_before + # Remove user_id on the backorder, if any + backorders_after.user_id = False + + def set_lot( + self, picking_id, selected_line_id, lot_name=None, expiration_date=None + ): + """Set lot and its expiration date + + Input: + barcode: The barcode of a lot + expiration_date: The expiration_date + + transitions: + - select_move: User clicked on back + - set_lot: Barcode not found. Ask user to create one from barcode + - set_lot: expiration_date has been set on the selected line + - set_lot: lot_it has been set on the selected line + - set_lot: Error: expiration_date is required + - set_quantity: User clicked on the confirm button + """ + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_lot(picking, selected_line, message=message) + if not selected_line.exists(): + message = self.msg_store.record_not_found() + return self._response_for_set_lot(picking, selected_line, message=message) + search = self._actions_for("search") + if lot_name: + product = selected_line.product_id + lot = search.lot_from_scan(lot_name, products=product) + if not lot: + lot = self.env["stock.production.lot"].create( + self._create_lot_values(product, lot_name) + ) + selected_line.lot_id = lot.id + selected_line._onchange_lot_id() + elif expiration_date: + selected_line.write({"expiration_date": expiration_date}) + selected_line.lot_id.write({"expiration_date": expiration_date}) + return self._response_for_set_lot(picking, selected_line) + + def _create_lot_values(self, product, lot_name): + return { + "name": lot_name, + "product_id": product.id, + "company_id": self.env.company.id, + "use_expiration_date": product.use_expiration_date, + } + + def set_lot_confirm_action(self, picking_id, selected_line_id): + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + if message: + return self._response_for_set_lot(picking, selected_line, message=message) + message = self._check_expiry_date(selected_line) + if message: + return self._response_for_set_lot(picking, selected_line, message=message) + return self._response_for_set_quantity(picking, selected_line) + + def _check_expiry_date(self, line): + use_expiration_date = ( + line.product_id.use_expiration_date or line.lot_id.use_expiration_date + ) + if use_expiration_date and not line.expiration_date: + return self.msg_store.expiration_date_missing() + + def set_quantity( + self, + picking_id, + selected_line_id, + quantity=None, + barcode=None, + confirmation=False, + ): + """Set the quantity done + + Input: + quantity: the quantity to set + barcode: Barcode of a product / packaging to determine the qty to increment + barcode: Barcode of a package / location to set on the line + + transitions: + - select_move: User clicked on back + - set_lot: Barcode not found. Ask user to create one from barcode + - set_lot: expiration_date has been set on the selected line + - set_lot: lot_it has been set on the selected line + - set_lot: Error: expiration_date is required + - set_quantity: User clicked on the confirm button + """ + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + if not selected_line.exists(): + message = self.msg_store.record_not_found() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + if quantity: + # We set qty_done to be equal to the qty of the picker + # at the moment of the scan. + selected_line.qty_done = quantity + if barcode: + # Then, we add the qty of whatever was scanned + # on top of the qty of the picker. + handlers = ( + self._set_quantity__by_product, + self._set_quantity__by_packaging, + self._set_quantity__by_package, + self._set_quantity__by_location, + ) + response = self._use_handlers(handlers, picking, selected_line, barcode) + if response: + return response + # Nothing found, ask user if we should create a new pack for the scanned + # barcode + if not confirmation: + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.create_new_pack_ask_confirmation(barcode), + ) + package = self.env["stock.quant.package"].create({"name": barcode}) + selected_line.result_package_id = package + return self._response_for_set_destination(picking, selected_line) + return self._response_for_set_quantity( + picking, selected_line, message=self.msg_store.barcode_not_found() + ) + + def process_with_existing_pack(self, picking_id, selected_line_id, quantity): + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + new_line, qty_check = selected_line._split_qty_to_be_done( + quantity, lot_id=False, shopfloor_user_id=False, expiration_date=False + ) + if qty_check == "greater": + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.unable_to_pick_more( + selected_line.product_uom_qty + ), + ) + selected_line.qty_done = quantity + return self._response_for_select_dest_package(picking, selected_line) + + def process_with_new_pack(self, picking_id, selected_line_id, quantity): + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + new_line, qty_check = selected_line._split_qty_to_be_done( + quantity, lot_id=False, shopfloor_user_id=False, expiration_date=False + ) + if qty_check == "greater": + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.unable_to_pick_more( + selected_line.product_uom_qty + ), + ) + selected_line.qty_done = quantity + picking._put_in_pack(selected_line) + return self._response_for_set_destination(picking, selected_line) + + def process_without_pack(self, picking_id, selected_line_id, quantity): + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + new_line, qty_check = selected_line._split_qty_to_be_done( + quantity, lot_id=False, shopfloor_user_id=False, expiration_date=False + ) + if qty_check == "greater": + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.unable_to_pick_more( + selected_line.product_uom_qty + ), + ) + selected_line.qty_done = quantity + return self._response_for_set_destination(picking, selected_line) + + def _auto_post_line(self, selected_line, picking): + new_move = selected_line.move_id.split_other_move_lines( + selected_line, intersection=True + ) + new_move.extract_and_action_done() + # TODO: by using split_other_move_lines above in reception, + # we encounter a strange behaviour where a line is created in the original picking + # with qty_done=0 and no related move_id. + # This is probably caused by package_level_id. + + # The workaround is to look for any orphan lines and delete them, + # but this should be investigated to find a better solution. + lines = picking.move_line_ids.filtered( + lambda l: l.product_id == selected_line.product_id + ) + for line in lines: + if not line.move_id: + line.unlink() + + def set_destination( + self, picking_id, selected_line_id, location_name, confirmation=False + ): + """Set the destination on the move line. + + input: + location_name: The name of the location + + transitions: + - set_destination: Warning: User scanned a child location of the picking type. + Ask for confirmation + - set_destination: Error: User tried to scan a non-valid location + - select_move: User scanned a child location of the move's dest location + """ + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_destination( + picking, selected_line, message=message + ) + if not selected_line.exists(): + message = self.msg_store.record_not_found() + return self._response_for_set_destination( + picking, selected_line, message=message + ) + search = self._actions_for("search") + location = search.location_from_scan(location_name) + move_dest_location = selected_line.location_dest_id + move_child_locations = self.env["stock.location"].search( + [("id", "child_of", move_dest_location.id)] + ) + pick_type_dest_location = picking.picking_type_id.default_location_dest_id + pick_type_child_locations = self.env["stock.location"].search( + [("id", "child_of", pick_type_dest_location.id)] + ) + if location not in move_child_locations | pick_type_child_locations: + return self._response_for_set_destination( + picking, + selected_line, + message=self.msg_store.dest_location_not_allowed(), + ) + if location in move_child_locations: + # If location is a child of move's dest location, assign it without asking + selected_line.location_dest_id = location + elif location in pick_type_child_locations: + # If location is a child of picking types's dest location, + # ask for confirmation before assigning + if not confirmation: + return self._response_for_set_destination( + picking, + selected_line, + message=self.msg_store.place_in_location_ask_confirmation( + location.name + ), + ) + selected_line.location_dest_id = location + if self.work.menu.auto_post_line: + # If option auto_post_line is active in the shopfloor menu, + # create a split order with this line. + self._auto_post_line(selected_line, picking) + return self._response_for_select_move(picking) + + def select_dest_package( + self, picking_id, selected_line_id, barcode, confirmation=False + ): + """Select the destination package for the move line + + Input: + barcode: The barcode of the package + + transitions: + - select_move: User scanned a valid package + - select_dest_package: Warning: User scanned an unknown barcode. + Confirm to create one. + - select_dest_package: Error: User scanned a non-empty package + """ + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_dest_package( + picking, selected_line, message=message + ) + if not selected_line.exists(): + message = self.msg_store.record_not_found() + return self._response_for_select_dest_package( + picking, selected_line, message=message + ) + search = self._actions_for("search") + package = search.package_from_scan(barcode) + if not package and confirmation: + package = self.env["stock.quant.package"].create({"name": barcode}) + if package: + # Do not allow user to create a non-empty package + if package.quant_ids: + return self._response_for_select_dest_package( + picking, + selected_line, + message=self.msg_store.package_not_empty(package), + ) + selected_line.result_package_id = package + if self.work.menu.auto_post_line: + # If option auto_post_line is active in the shopfloor menu, + # create a split order with this line. + self._auto_post_line(selected_line, picking) + return self._response_for_select_move(picking) + message = self.msg_store.create_new_pack_ask_confirmation(barcode) + self._assign_user_to_picking(picking) + return self._response_for_confirm_new_package( + picking, selected_line, new_package_name=barcode, message=message + ) + + +class ShopfloorReceptionValidator(Component): + _inherit = "base.shopfloor.validator" + _name = "shopfloor.reception.validator" + _usage = "reception.validator" + + def start(self): + return {} + + def scan_document(self): + return {"barcode": {"required": True, "type": "string"}} + + def list_stock_pickings(self): + return {} + + def scan_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def set_lot(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "lot_name": {"type": "string"}, + "expiration_date": {"type": "string"}, + } + + def set_quantity(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "quantity": {"type": "float"}, + "barcode": {"type": "string"}, + "confirmation": {"type": "boolean"}, + } + + def process_with_existing_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "quantity": {"coerce": to_float, "type": "float"}, + } + + def process_with_new_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "quantity": {"coerce": to_float, "type": "float"}, + } + + def process_without_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "quantity": {"coerce": to_float, "type": "float"}, + } + + def set_destination(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "location_name": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean"}, + } + + def select_dest_package(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "barcode": {"type": "string", "required": True}, + "confirmation": {"type": "boolean"}, + } + + def done_action(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "confirmation": {"type": "boolean"}, + } + + def set_lot_confirm_action(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + } + + +class ShopfloorReceptionValidatorResponse(Component): + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.reception.validator.response" + _usage = "reception.validator.response" + + _start_state = "select_document" + + # STATES + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "select_document": self._schema_select_document, + "manual_selection": self._schema_manual_selection, + "select_move": self._schema_select_move, + "confirm_done": self._schema_confirm_done, + "set_lot": self._schema_set_lot, + "set_quantity": self._schema_set_quantity, + "set_destination": self._schema_set_destination, + "select_dest_package": self._schema_select_dest_package, + "confirm_new_package": self._schema_confirm_new_package, + } + + def _start_next_states(self): + return {"select_document"} + + def _scan_document_next_states(self): + return { + "select_document", + "select_move", + "set_lot", + "set_quantity", + "manual_selection", + } + + def _list_stock_pickings_next_states(self): + return { + "select_document", + "select_move", + "set_lot", + "set_quantity", + "manual_selection", + } + + def _scan_line_next_states(self): + return {"select_move", "set_lot", "set_quantity"} + + def _set_lot_next_states(self): + return {"select_move", "set_lot", "set_quantity"} + + def _set_quantity_next_states(self): + return {"set_quantity", "select_move", "set_destination"} + + def _set_destination_next_states(self): + return {"set_destination", "select_move"} + + def _select_dest_package_next_states(self): + return {"set_lot", "select_dest_package", "confirm_new_package", "select_move"} + + def _done_next_states(self): + return {"select_document", "select_move", "confirm_done"} + + def _set_lot_confirm_action_next_states(self): + return {"set_lot", "set_quantity"} + + def _process_with_existing_pack_next_states(self): + return {"select_dest_package"} + + def _process_with_new_pack_next_states(self): + return {"set_destination"} + + def _process_without_pack_next_states(self): + return {"set_destination"} + + # SCHEMAS + + @property + def _schema_select_document(self): + return { + "pickings": self.schemas._schema_list_of( + self.schemas.picking(), required=True + ) + } + + @property + def _schema_manual_selection(self): + return { + "pickings": self.schemas._schema_list_of( + self.schemas.picking(), required=True + ) + } + + @property + def _schema_select_move(self): + return { + "picking": self.schemas._schema_dict_of( + self._schema_stock_picking_with_lines(), required=True + ) + } + + @property + def _schema_confirm_done(self): + return { + "picking": self.schemas._schema_dict_of( + self._schema_stock_picking_with_lines(), required=True + ) + } + + @property + def _schema_set_lot(self): + return { + "picking": {"type": "dict", "schema": self.schemas.picking()}, + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + } + + @property + def _schema_set_quantity(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } + + @property + def _schema_set_destination(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } + + @property + def _schema_select_dest_package(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "packages": { + "type": "list", + "schema": { + "type": "dict", + "schema": self.schemas.package(with_packaging=True), + }, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } + + @property + def _schema_confirm_new_package(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "picking": self.schemas._schema_dict_of( + self._schema_stock_picking_with_lines(), required=True + ), + "new_package_name": {"type": "string"}, + } + + def _schema_stock_picking_with_lines(self, lines_with_packaging=False): + # TODO: ideally, we should use self.schemas_detail.picking_detail + # instead of this method. + schema = self.schemas.picking() + schema.update({"moves": self.schemas._schema_list_of(self.schemas.move())}) + return schema + + # ENDPOINTS + + def start(self): + return self._response_schema(next_states=self._start_next_states()) + + def scan_document(self): + return self._response_schema(next_states=self._scan_document_next_states()) + + def list_stock_pickings(self): + return self._response_schema( + next_states=self._list_stock_pickings_next_states() + ) + + def scan_line(self): + return self._response_schema(next_states=self._scan_line_next_states()) + + def set_lot(self): + return self._response_schema(next_states=self._set_lot_next_states()) + + def set_lot_confirm_action(self): + return self._response_schema( + next_states=self._set_lot_confirm_action_next_states() + ) + + def set_quantity(self): + return self._response_schema(next_states=self._set_quantity_next_states()) + + def process_with_existing_pack(self): + return self._response_schema( + next_states=self._process_with_existing_pack_next_states() + ) + + def process_with_new_pack(self): + return self._response_schema( + next_states=self._process_with_new_pack_next_states() + ) + + def process_without_pack(self): + return self._response_schema( + next_states=self._process_without_pack_next_states() + ) + + def set_destination(self): + return self._response_schema(next_states=self._set_destination_next_states()) + + def select_dest_package(self): + return self._response_schema( + next_states=self._select_dest_package_next_states() + ) + + def done_action(self): + return self._response_schema(next_states=self._done_next_states()) diff --git a/shopfloor_reception/tests/__init__.py b/shopfloor_reception/tests/__init__.py new file mode 100644 index 0000000000..fc68acdf29 --- /dev/null +++ b/shopfloor_reception/tests/__init__.py @@ -0,0 +1,11 @@ +from . import test_start +from . import test_select_document +from . import test_manual_selection +from . import test_select_move +from . import test_reception_done +from . import test_set_lot +from . import test_set_lot_confirm +from . import test_set_quantity +from . import test_set_quantity_action +from . import test_set_destination +from . import test_select_dest_package diff --git a/shopfloor_reception/tests/common.py b/shopfloor_reception/tests/common.py new file mode 100644 index 0000000000..b447e82f48 --- /dev/null +++ b/shopfloor_reception/tests/common.py @@ -0,0 +1,138 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo import fields + +from odoo.addons.shopfloor.tests.common import CommonCase as BaseCommonCase + + +class CommonCase(BaseCommonCase): + @classmethod + def _create_picking(cls, picking_type=None, lines=None, confirm=True, **kw): + picking = super()._create_picking( + picking_type=picking_type, lines=lines, confirm=confirm, **kw + ) + picking.user_id = False + return picking + + @classmethod + def _create_lot(cls, **kwargs): + vals = { + "product_id": cls.product_a.id, + "company_id": cls.env.company.id, + } + vals.update(kwargs) + return cls.env["stock.production.lot"].create(vals) + + @classmethod + def _add_package(cls, picking): + packaging_ids = [ + cls.product_a_packaging.id, + cls.product_c_packaging.id, + cls.product_b_packaging.id, + cls.product_d_packaging.id, + ] + packagings = cls.env["product.packaging"].browse(packaging_ids) + for line in picking.move_line_ids: + product = line.product_id + packaging = packagings.filtered(lambda p: p.product_id == product) + package = cls.env["stock.quant.package"].create( + {"product_packaging_id": packaging.id} + ) + line.package_id = package + + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor_reception.shopfloor_menu_demo_reception") + cls.profile = cls.env.ref("shopfloor.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.wh.sudo().reception_steps = "two_steps" + + def _data_for_move_lines(self, lines, **kw): + return self.data.move_lines(lines, **kw) + + def _data_for_picking_with_line(self, picking): + picking_data = self._data_for_picking(picking) + move_lines_data = self._data_for_move_lines(picking.move_line_ids) + picking_data.update({"move_lines": move_lines_data}) + return picking_data + + def _data_for_pickings_with_line(self, pickings): + res = [] + for picking in pickings: + res.append(self._data_for_picking_with_line(picking)) + return res + + def _data_for_picking_with_moves(self, picking): + picking_data = self._data_for_picking(picking) + moves_data = self._data_for_moves(picking.move_lines) + picking_data.update({"moves": moves_data}) + return picking_data + + def _data_for_picking(self, picking): + return self.data.picking(picking, with_progress=True) + + def _data_for_pickings(self, pickings): + res = [] + for picking in pickings: + res.append(self._data_for_picking(picking)) + return res + + def _data_for_move(self, move): + return self.data.move(move) + + def _data_for_moves(self, moves): + res = [] + for move in moves: + res.append(self._data_for_move(move)) + return res + + def setUp(self): + super().setUp() + self.service = self.get_service( + "reception", menu=self.menu, profile=self.profile + ) + + def _stock_picking_data(self, picking, **kw): + return self.service._data_for_stock_picking(picking, **kw) + + # we test the methods that structure data in test_actions_data.py + def _picking_summary_data(self, picking): + return self.data.picking(picking) + + def _move_line_data(self, move_line): + return self.data.move_line(move_line) + + def _package_data(self, package, picking): + return self.data.package(package, picking=picking, with_packaging=True) + + def _packaging_data(self, packaging): + return self.data.packaging(packaging) + + def _get_all_pickings(self): + return self.env["stock.picking"].search( + [ + ("state", "=", "assigned"), + ("picking_type_id", "=", self.picking_type.id), + ("user_id", "=", False), + ], + order="scheduled_date ASC", + ) + + def _get_today_pickings(self): + return self.env["stock.picking"].search( + [ + ("state", "=", "assigned"), + ("picking_type_id", "=", self.picking_type.id), + ("user_id", "=", False), + ("scheduled_date", "=", fields.Datetime.today()), + ], + order="scheduled_date ASC", + ) diff --git a/shopfloor_reception/tests/test_manual_selection.py b/shopfloor_reception/tests/test_manual_selection.py new file mode 100644 index 0000000000..af3cffab8e --- /dev/null +++ b/shopfloor_reception/tests/test_manual_selection.py @@ -0,0 +1,37 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import datetime + +from odoo import fields + +from .common import CommonCase + + +class TestManualSelection(CommonCase): + def test_list_stock_pickings(self): + response = self.service.dispatch("list_stock_pickings") + self.assert_response( + response, + next_state="manual_selection", + data={"pickings": []}, + ) + # Create a picking due today + today = fields.Datetime.today() + picking_due_today = self._create_picking() + picking_due_today.write({"scheduled_date": today}) + # Create a picking due tomorrow + tomorrow = today + datetime.timedelta(days=1) + picking_due_tomorrow = self._create_picking() + picking_due_tomorrow.write({"scheduled_date": tomorrow}) + + # Both pickings will be returned + response = self.service.dispatch("list_stock_pickings") + pickings = self.env["stock.picking"].browse( + [picking_due_today.id, picking_due_tomorrow.id] + ) + self.assert_response( + response, + next_state="manual_selection", + data={"pickings": self._data_for_pickings(pickings)}, + ) diff --git a/shopfloor_reception/tests/test_reception_done.py b/shopfloor_reception/tests/test_reception_done.py new file mode 100644 index 0000000000..0be6548e44 --- /dev/null +++ b/shopfloor_reception/tests/test_reception_done.py @@ -0,0 +1,93 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from datetime import timedelta + +from odoo import fields + +from .common import CommonCase + + +class TestSelectDestPackage(CommonCase): + def test_set_done_no_backorder(self): + picking = self._create_picking() + picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_done": True}) + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id} + ) + # User is asked to confirm the action + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._data_for_picking_with_moves(picking)}, + message={"message_type": "warning", "body": "Are you sure?"}, + ) + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id, "confirmation": True} + ) + self.assertEqual(picking.state, "done") + # No more picking to process. Success message + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "success", + "body": f"Transfer {picking.name} done", + }, + ) + + def test_set_done_no_qty_processed(self): + picking = self._create_picking() + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id} + ) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(picking)}, + message={ + "message_type": "warning", + "body": "No quantity has been processed, unable to complete the transfer.", + }, + ) + + def test_set_done_with_backorder(self): + picking = self._create_picking( + scheduled_date=fields.Datetime.today() + timedelta(days=1) + ) + picking_due_today = self._create_picking(scheduled_date=fields.Datetime.today()) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.qty_done = 10.0 + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id} + ) + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._data_for_picking_with_moves(picking)}, + message={ + "message_type": "warning", + "body": ( + "Not all lines have been processed with full quantity. " + "Do you confirm partial operation?" + ), + }, + ) + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id, "confirmation": True} + ) + self.assertEqual(picking.state, "done") + backorder = picking.backorder_ids + self.assertEqual(backorder.move_line_ids.product_id, self.product_b) + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(picking_due_today)}, + message={ + "message_type": "success", + "body": f"Transfer {picking.name} done", + }, + ) diff --git a/shopfloor_reception/tests/test_select_dest_package.py b/shopfloor_reception/tests/test_select_dest_package.py new file mode 100644 index 0000000000..4e1a579a7e --- /dev/null +++ b/shopfloor_reception/tests/test_select_dest_package.py @@ -0,0 +1,131 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestSelectDestPackage(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + package_model = cls.env["stock.quant.package"] + cls.package = package_model.create( + { + "name": "FOO", + "packaging_id": cls.product_a_packaging.id, + } + ) + + def test_scan_new_package(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + response = self.service.dispatch( + "select_dest_package", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FooBar", + }, + ) + # Package doesn't exist, odoo asks for a confirmation to create it + self.assertFalse(selected_move_line.result_package_id) + self.assert_response( + response, + next_state="confirm_new_package", + data={ + "picking": self._data_for_picking_with_moves(picking), + "selected_move_line": self.data.move_lines(selected_move_line), + "new_package_name": "FooBar", + }, + message={ + "message_type": "warning", + "body": ("Create new PACK FooBar? " "Scan it again to confirm."), + }, + ) + # Try again with confirmation = True + response = self.service.dispatch( + "select_dest_package", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FooBar", + "confirmation": True, + }, + ) + self.assertEqual(selected_move_line.result_package_id.name, "FooBar") + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(picking)}, + ) + + def test_scan_not_empty_package(self): + self._update_qty_in_location( + self.packing_location, self.product_a, 10, package=self.package + ) + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # Assigning a package to a different move line + # so that the package is available for the picking. + different_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_b + ) + different_move_line.result_package_id = self.package + response = self.service.dispatch( + "select_dest_package", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FOO", + }, + ) + self.assertFalse(selected_move_line.result_package_id.name) + picking_data = self.data.picking(picking) + package_data = self.data.packages( + self.package.with_context(picking_id=picking.id), + picking=picking, + with_packaging=True, + ) + self.assert_response( + response, + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": package_data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={ + "message_type": "warning", + "body": "Package FOO is not empty.", + }, + ) + + def test_scan_existing_package(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # Assigning a package to a different move line + # so that the package is available for the picking. + different_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_b + ) + different_move_line.result_package_id = self.package + response = self.service.dispatch( + "select_dest_package", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FOO", + }, + ) + self.assertEqual(selected_move_line.result_package_id.name, self.package.name) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(picking)}, + ) diff --git a/shopfloor_reception/tests/test_select_document.py b/shopfloor_reception/tests/test_select_document.py new file mode 100644 index 0000000000..efcfc15e6e --- /dev/null +++ b/shopfloor_reception/tests/test_select_document.py @@ -0,0 +1,217 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from freezegun import freeze_time + +from odoo import fields + +from .common import CommonCase + +_TODAY = "2022-12-07" +_TOMORROW = "2022-12-08" + + +class TestSelectDocument(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a.tracking = "lot" + + def test_scan_barcode_not_found(self): + # next step is select_document, with an error message + response = self.service.dispatch("scan_document", params={"barcode": "NOPE"}) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={"message_type": "error", "body": "Barcode not found"}, + ) + + def test_scan_picking_name(self): + picking = self._create_picking() + response = self.service.dispatch( + "scan_document", params={"barcode": picking.name} + ) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(picking)}, + ) + + def test_scan_picking_origin_multiple_pickings(self): + # Multiple pickings with this origin are found. + # Return the filtered list of pickings. + picking_1 = self._create_picking() + picking_2 = self._create_picking() + pickings = picking_1 | picking_2 + pickings = pickings.sorted(lambda p: (p.scheduled_date, p.id), reverse=False) + pickings.write({"origin": "Somewhere together"}) + response = self.service.dispatch( + "scan_document", params={"barcode": "Somewhere together"} + ) + message = ( + "This source document is part of multiple transfers, please scan a package." + ) + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(pickings)}, + message={ + "message_type": "warning", + "body": message, + }, + ) + + @freeze_time(_TODAY) + def test_scan_picking_origin_multiple_pickings_one_today(self): + # Multiple pickings with this origin are found, + # but only one is due today. + # Select that one and move to the next screen. + picking_today = self._create_picking(scheduled_date=_TODAY) + picking_tomorrow = self._create_picking(scheduled_date=_TOMORROW) + pickings = picking_today | picking_tomorrow + pickings = pickings.sorted(lambda p: (p.scheduled_date, p.id), reverse=False) + pickings.write({"origin": "Somewhere together"}) + response = self.service.dispatch( + "scan_document", params={"barcode": "Somewhere together"} + ) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(picking_today)}, + ) + + def test_scan_picking_origin_one_picking(self): + # Only 1 picking with this origin is found. + # Move to select_move. + picking = self._create_picking() + picking.write({"origin": "Somewhere"}) + response = self.service.dispatch( + "scan_document", params={"barcode": "Somewhere"} + ) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(picking)}, + ) + + def test_scan_packaging_single_picking(self): + # next step is set_lot + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_a_packaging.barcode} + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_product_single_picking(self): + # next_step is set_lot + picking = self._create_picking() + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_a.barcode} + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_not_tracked_product_single_picking(self): + # next_step is set_quantity + self.product_a.tracking = "none" + picking = self._create_picking() + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_a.barcode} + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_packaging_multiple_pickings(self): + # next step is select_document, with documents filtered based on the product + p1 = self._create_picking() + p2 = self._create_picking() + self._add_package(p1) + self._add_package(p2) + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_a_packaging.barcode} + ) + body = "Several transfers found, please select a transfer manually." + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(p1 | p2)}, + message={"message_type": "error", "body": body}, + ) + + def test_scan_product_multiple_pickings(self): + # next step is select_document, with documents filtered based on the packaging + p1 = self._create_picking() + p2 = self._create_picking() + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_a.barcode} + ) + body = "Several transfers found, please select a transfer manually." + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(p1 | p2)}, + message={"message_type": "error", "body": body}, + ) + + def test_scan_product_no_picking(self): + # next_step is select_document, with an error message + picking = self._create_picking() + picking.write({"scheduled_date": fields.Datetime.today()}) + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_c.barcode} + ) + body = "No product found among current transfers." + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(picking)}, + message={"message_type": "warning", "body": body}, + ) + + def test_scan_packaging_no_picking(self): + # next step is select_document, with an error message + picking = self._create_picking() + picking.write({"scheduled_date": fields.Datetime.today()}) + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_c_packaging.barcode} + ) + body = "No transfer found for the scanned packaging." + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(picking)}, + message={"message_type": "error", "body": body}, + ) diff --git a/shopfloor_reception/tests/test_select_move.py b/shopfloor_reception/tests/test_select_move.py new file mode 100644 index 0000000000..c6153b88c5 --- /dev/null +++ b/shopfloor_reception/tests/test_select_move.py @@ -0,0 +1,274 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields + +from .common import CommonCase + + +class TestSelectLine(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a.tracking = "lot" + + def test_scan_barcode_not_found(self): + picking = self._create_picking() + response = self.service.dispatch( + "scan_line", params={"picking_id": picking.id, "barcode": "NOPE"} + ) + data = self.data.picking(picking, with_progress=True) + data.update({"moves": self.data.moves(picking.move_lines)}) + self.assert_response( + response, + next_state="select_move", + data={"picking": data}, + message={"message_type": "error", "body": "Barcode not found"}, + ) + + def test_scan_product(self): + picking = self._create_picking() + response = self.service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_packaging(self): + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_not_tracked_product(self): + self.product_a.tracking = "none" + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_not_tracked_packaging(self): + self.product_a.tracking = "none" + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_product_not_found(self): + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_c.barcode}, + ) + error_msg = "Product not found in the current transfer or already in a package." + data = self.data.picking(picking, with_progress=True) + data.update({"moves": self.data.moves(picking.move_lines)}) + self.assert_response( + response, + next_state="select_move", + data={"picking": data}, + message={"message_type": "warning", "body": error_msg}, + ) + + def test_scan_packaging_not_found(self): + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_c_packaging.barcode, + }, + ) + error_msg = ( + "Packaging not found in the current transfer or already in a package." + ) + data = self.data.picking(picking, with_progress=True) + data.update({"moves": self.data.moves(picking.move_lines)}) + self.assert_response( + response, + next_state="select_move", + data={"picking": data}, + message={"message_type": "warning", "body": error_msg}, + ) + + def test_assign_user_to_picking(self): + picking = self._create_picking() + self.assertEqual(picking.user_id.id, False) + self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a.barcode, + }, + ) + self.assertEqual(picking.user_id.id, self.env.uid) + + def test_assign_shopfloor_user_to_line(self): + picking = self._create_picking() + for line in picking.move_line_ids: + self.assertEqual(line.shopfloor_user_id.id, False) + self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a.barcode, + }, + ) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + other_move_line = fields.first( + picking.move_line_ids.filtered(lambda l: l.product_id != self.product_a) + ) + self.assertEqual(selected_move_line.shopfloor_user_id.id, self.env.uid) + self.assertEqual(other_move_line.shopfloor_user_id.id, False) + + def test_create_new_line_none_available(self): + # If all lines for a product are already assigned to a different user + # and there's still qty todo remaining + # a new line will be created for that qty todo. + picking = self._create_picking() + self.assertEqual(len(picking.move_line_ids), 2) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # The picking and the selected line have been previously assigned to a different user + # and this user has completed a total of 3 units. + another_user = fields.first( + self.env["res.users"].search([("id", "!=", self.env.uid)]) + ) + selected_move_line.shopfloor_user_id = another_user + selected_move_line.qty_done = 3 + # When the user scans that product, + # a new line will be generated with the remaining qty todo. + self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a.barcode, + }, + ) + self.assertEqual(len(picking.move_line_ids), 3) + created_line = picking.move_line_ids[2] + self.assertEqual(created_line.product_uom_qty, 7) + self.assertEqual(created_line.shopfloor_user_id.id, self.env.uid) + + def test_done_action(self): + picking = self._create_picking() + + # These are needed to test that we get a valid list of pickings + # when returning to select_document. + self._create_picking( + picking_type=picking.picking_type_id, scheduled_date=fields.Datetime.today() + ) + self._create_picking( + picking_type=picking.picking_type_id, scheduled_date=fields.Datetime.today() + ) + + for line in picking.move_line_ids: + line.qty_done = line.product_uom_qty + lot = (self._create_lot(product_id=line.product_id.id),) + line.lot_id = lot + # Ask for confirmation to mark the package as done. + response = self.service.dispatch( + "done_action", + params={ + "picking_id": picking.id, + }, + ) + data = {"picking": self._data_for_picking_with_moves(picking)} + self.assert_response( + response, + next_state="confirm_done", + data=data, + message={"message_type": "warning", "body": "Are you sure?"}, + ) + # Confirm the package is done. + response = self.service.dispatch( + "done_action", + params={ + "picking_id": picking.id, + "confirmation": True, + }, + ) + pickings = self.env["stock.picking"].search( + [ + ("state", "=", "assigned"), + ("picking_type_id", "=", picking.picking_type_id.id), + ("user_id", "=", False), + ("scheduled_date", "=", fields.Datetime.today()), + ], + order="scheduled_date ASC", + ) + message = "Transfer {} done".format(picking.name) + pickings.sorted(lambda p: (p.scheduled_date, p.id), reverse=False) + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(pickings)}, + message={"message_type": "success", "body": message}, + ) diff --git a/shopfloor_reception/tests/test_set_destination.py b/shopfloor_reception/tests/test_set_destination.py new file mode 100644 index 0000000000..e18187ead9 --- /dev/null +++ b/shopfloor_reception/tests/test_set_destination.py @@ -0,0 +1,161 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestSetDestination(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.packing_location.sudo().active = True + cls.location_dest = cls.env.ref("stock.stock_location_stock") + + @classmethod + def _change_line_dest(cls, line): + # Modify the location dest on the move, so we have different children + # for move's dest_location and pick type's dest_location + line.location_dest_id = cls.location_dest + + def test_scan_location_child_of_dest_location(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self._change_line_dest(selected_move_line) + response = self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.shelf2.name, + }, + ) + self.assertEqual(selected_move_line.location_dest_id, self.shelf2) + data = self.data.picking(picking, with_progress=True) + data.update({"moves": self.data.moves(picking.move_lines)}) + self.assert_response( + response, + next_state="select_move", + data={"picking": data}, + ) + + def test_scan_location_child_of_pick_type_dest_location(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self._change_line_dest(selected_move_line) + response = self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.dispatch_location.name, + }, + ) + # location is a child of the picking type's location. destination location + # hasn't been set + self.assertNotEqual(selected_move_line.location_dest_id, self.dispatch_location) + # But a confirmation has been asked + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={ + "message_type": "warning", + "body": f"Place it in {self.dispatch_location.name}?", + }, + ) + # Send the same message with confirmation=True to confirm + response = self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.dispatch_location.name, + "confirmation": True, + }, + ) + self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) + data = self.data.picking(picking, with_progress=True) + data.update({"moves": self.data.moves(picking.move_lines)}) + self.assert_response( + response, + next_state="select_move", + data={"picking": data}, + ) + + def test_scan_location_not_child_of_dest_locations(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + response = self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.shelf1.name, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_auto_posting(self): + self.menu.sudo().auto_post_line = True + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + + # User has previously scanned a total of 3 units (with 7 still to do). + # A new pack has been created and assigned to the line. + self.service.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 3, + }, + ) + + # If the auto_post_line option is checked, + # and dest package & dest location are set, + # a line with 3 demand will be automatically extracted + # in a new picking, which will be marked as done. + self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.dispatch_location.name, + }, + ) + # The line has been moved to a different picking. + self.assertNotEqual(picking, selected_move_line.picking_id) + # Its qty_done is 3. + self.assertEqual(selected_move_line.qty_done, 3) + # The new picking is marked as done. + self.assertEqual(selected_move_line.picking_id.state, "done") + + # The line that remained in the original picking + # for that product has a product_uom_qty of 7 + # and a qty_done of 0. + line_in_picking = picking.move_line_ids.filtered( + lambda l: l.product_id == selected_move_line.product_id + ) + self.assertEqual(line_in_picking.product_uom_qty, 7) + self.assertEqual(line_in_picking.qty_done, 0) diff --git a/shopfloor_reception/tests/test_set_lot.py b/shopfloor_reception/tests/test_set_lot.py new file mode 100644 index 0000000000..016f023d5d --- /dev/null +++ b/shopfloor_reception/tests/test_set_lot.py @@ -0,0 +1,158 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestSetLot(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a.tracking = "lot" + + def test_set_existing_lot(self): + picking = self._create_picking() + lot = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot.name, + }, + ) + self.assertEqual(selected_move_line.lot_id, lot) + self.assertFalse(selected_move_line.expiration_date) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_set_new_lot_on_line_with_lot(self): + picking = self._create_picking() + lot_before = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + selected_move_line.lot_id = lot_before + lot_after = self._create_lot() + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot_after.name, + }, + ) + self.assertEqual(selected_move_line.lot_id, lot_after) + self.assertFalse(selected_move_line.expiration_date) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_set_existing_lot_with_expiration_date(self): + self.product_a.use_expiration_date = True + picking = self._create_picking() + expiration_date = "2022-08-23 12:00:00" + lot = self._create_lot(expiration_date=expiration_date) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot.name, + }, + ) + self.assertEqual(str(selected_move_line.expiration_date), expiration_date) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_set_new_lot(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": "FooBar", + }, + ) + self.assertEqual(selected_move_line.lot_id.name, "FooBar") + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_set_expiry_date(self): + # First, set the lot + picking = self._create_picking() + lot = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot.name, + }, + ) + # Then, set the expiration date + expiration_date = "2022-08-24 12:00:00" + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "expiration_date": expiration_date, + }, + ) + self.assertEqual(str(lot.expiration_date), expiration_date) + self.assertEqual(str(selected_move_line.expiration_date), expiration_date) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) diff --git a/shopfloor_reception/tests/test_set_lot_confirm.py b/shopfloor_reception/tests/test_set_lot_confirm.py new file mode 100644 index 0000000000..ff4882980a --- /dev/null +++ b/shopfloor_reception/tests/test_set_lot_confirm.py @@ -0,0 +1,65 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestSetLotConfirm(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a.tracking = "lot" + + def test_ensure_expiry_date(self): + picking = self._create_picking() + self.product_a.use_expiration_date = True + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + # product has been set as requiring a expiration date. + # Trying to move to the next screen should return an error + response = self.service.dispatch( + "set_lot_confirm_action", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={"message_type": "error", "body": "Missing expiration date."}, + ) + # Now, set the expiry date + expiration_date = "2022-08-24 12:00:00" + self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "expiration_date": expiration_date, + }, + ) + # And try to confirm again + response = self.service.dispatch( + "set_lot_confirm_action", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py new file mode 100644 index 0000000000..a2c5190c7e --- /dev/null +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -0,0 +1,348 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestSetQuantity(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a_packaging.qty = 5.0 + cls.packing_location.sudo().active = True + package_model = cls.env["stock.quant.package"] + cls.package_without_location = package_model.create( + { + "name": "PKG_WO_LOCATION", + "packaging_id": cls.product_a_packaging.id, + } + ) + cls.package_with_location = package_model.create( + { + "name": "PKG_W_LOCATION", + "packaging_id": cls.product_a_packaging.id, + } + ) + cls.package_with_location_child_of_dest = package_model.create( + { + "name": "PKG_W_LOCATION_CHILD", + "packaging_id": cls.product_a_packaging.id, + } + ) + cls._update_qty_in_location( + cls.packing_location, cls.product_a, 10, package=cls.package_with_location + ) + cls._update_qty_in_location( + cls.dispatch_location, + cls.product_a, + 10, + package=cls.package_with_location_child_of_dest, + ) + + def test_set_quantity_scan_product(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 10.0, + "barcode": selected_move_line.product_id.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 11.0) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_set_quantity_scan_packaging(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 10.0, + "barcode": selected_move_line.product_id.packaging_ids.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 15.0) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_product(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 1.0) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + # Scan again, and ensure qty increments + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a.barcode, + }, + ) + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a.barcode, + }, + ) + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 4.0) + + def test_scan_packaging(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 5.0) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + # Scan again, and ensure qty increments + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 10.0) + + def test_scan_package_with_destination_child_of_dest_location(self): + # next step is select_move + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.package_with_location_child_of_dest.name, + }, + ) + self.assertEqual( + selected_move_line.result_package_id, + self.package_with_location_child_of_dest, + ) + self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) + data = self.data.picking(picking, with_progress=True) + data.update({"moves": self.data.moves(picking.move_lines)}) + self.assert_response(response, next_state="select_move", data={"picking": data}) + + def test_scan_package_with_destination_not_child_of_dest_location(self): + # next step is set_quantity with error + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.package_with_location.name, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_scan_package_without_location(self): + # next_step is set_destination + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.package_without_location.name, + }, + ) + self.assertEqual( + selected_move_line.result_package_id, self.package_without_location + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_location_child_of_dest_location(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.dispatch_location.barcode, + }, + ) + self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) + data = self.data.picking(picking, with_progress=True) + data.update({"moves": self.data.moves(picking.move_lines)}) + self.assert_response(response, next_state="select_move", data={"picking": data}) + + def test_scan_location_not_child_of_dest_location(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.packing_location.barcode, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_scan_new_package(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FooBar", + }, + ) + picking_data = self.data.picking(picking) + self.assertFalse(selected_move_line.result_package_id) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": picking_data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={ + "message_type": "warning", + "body": "Create new PACK FooBar? Scan it again to confirm.", + }, + ) + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FooBar", + "confirmation": True, + }, + ) + self.assertEqual(selected_move_line.result_package_id.name, "FooBar") + picking_data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": picking_data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) diff --git a/shopfloor_reception/tests/test_set_quantity_action.py b/shopfloor_reception/tests/test_set_quantity_action.py new file mode 100644 index 0000000000..7cf981eacc --- /dev/null +++ b/shopfloor_reception/tests/test_set_quantity_action.py @@ -0,0 +1,86 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestSetQuantityAction(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = cls._create_picking() + cls.selected_move_line = cls.picking.move_line_ids.filtered( + lambda l: l.product_id == cls.product_a + ) + + def test_process_with_existing_package(self): + package = self.env["stock.quant.package"].create( + { + "name": "FOO", + "packaging_id": self.product_a_packaging.id, + } + ) + self.selected_move_line.result_package_id = package + response = self.service.dispatch( + "process_with_existing_pack", + params={ + "picking_id": self.picking.id, + "selected_line_id": self.selected_move_line.id, + "quantity": 2, + }, + ) + picking_data = self.data.picking(self.picking) + package_data = self.data.packages( + package.with_context(picking_id=self.picking.id), + picking=self.picking, + with_packaging=True, + ) + self.assert_response( + response, + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": package_data, + "selected_move_line": self.data.move_lines(self.selected_move_line), + }, + ) + + def test_process_with_new_package(self): + response = self.service.dispatch( + "process_with_new_pack", + params={ + "picking_id": self.picking.id, + "selected_line_id": self.selected_move_line.id, + "quantity": 2, + }, + ) + data = self.data.picking(self.picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(self.selected_move_line), + }, + ) + self.assertTrue(self.selected_move_line.result_package_id) + + def test_process_without_package(self): + response = self.service.dispatch( + "process_without_pack", + params={ + "picking_id": self.picking.id, + "selected_line_id": self.selected_move_line.id, + "quantity": 2, + }, + ) + data = self.data.picking(self.picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(self.selected_move_line), + }, + ) + self.assertFalse(self.selected_move_line.result_package_id) diff --git a/shopfloor_reception/tests/test_start.py b/shopfloor_reception/tests/test_start.py new file mode 100644 index 0000000000..ec99c87e59 --- /dev/null +++ b/shopfloor_reception/tests/test_start.py @@ -0,0 +1,50 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +from freezegun import freeze_time + +from odoo import fields + +from .common import CommonCase + +_YESTERDAY = "2022-12-05" +_TODAY = "2022-12-06" +_TOMORROW = "2022-12-07" + + +@freeze_time(_TODAY) +class TestStart(CommonCase): + def test_domain_stock_picking(self): + dates = (_YESTERDAY, _TODAY, _TOMORROW) + pickings = {} + for date in dates: + for _i in range(1, 4): + picking = self._create_picking(scheduled_date=date) + pickings.setdefault(date, []).append(picking) + domain = self.service._domain_stock_picking(today_only=True) + pickings_due_today = self.env["stock.picking"].search(domain) + self.assertEqual(len(pickings_due_today), 3) + for picking in pickings_due_today: + self.assertEqual(picking.scheduled_date, fields.Datetime.today()) + + def test_start(self): + response = self.service.dispatch("start") + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + ) + # Create a picking due today + picking_due_today = self._create_picking() + picking_due_today.write({"scheduled_date": _TODAY}) + # Create a picking due tomorrow + self._create_picking().write({"scheduled_date": _TOMORROW}) + + # Only the one due today will be returned in the first page + response = self.service.dispatch("start") + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(picking_due_today)}, + ) diff --git a/shopfloor_reception/views/shopfloor_menu.xml b/shopfloor_reception/views/shopfloor_menu.xml new file mode 100644 index 0000000000..d5f7fded57 --- /dev/null +++ b/shopfloor_reception/views/shopfloor_menu.xml @@ -0,0 +1,18 @@ + + + + shopfloor.menu + + + + + + + + + + + From 180e5ba046a6aaa3b7506ae07eba8e5fd30e2b26 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 21 Nov 2022 09:37:29 +0100 Subject: [PATCH 02/84] add shopfloor_reception_mobile shopfloor_reception_mobile: introduce multiuser --- shopfloor_reception/services/reception.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index b4042d9593..ef4f7ccfe4 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -277,9 +277,7 @@ def _scan_document__by_picking(self, barcode): def _scan_document__by_product(self, barcode): search = self._actions_for("search") - # TODO: use_packaging should be removed after merging the no_prefill_qty changes - # from PR #483. - product = search.product_from_scan(barcode, use_packaging=False) + product = search.product_from_scan(barcode) if product: return self._select_document_from_product(product) @@ -291,9 +289,7 @@ def _scan_document__by_packaging(self, barcode): def _scan_line__by_product(self, picking, barcode): search = self._actions_for("search") - # TODO: use_packaging should be removed after merging the no_prefill_qty changes - # from PR #483. - product = search.product_from_scan(barcode, use_packaging=False) + product = search.product_from_scan(barcode) if product: move = picking.move_lines.filtered(lambda m: m.product_id == product) message = self._check_move_available(move, "product") @@ -332,9 +328,7 @@ def _check_move_available(self, move, message_code="product"): def _set_quantity__by_product(self, picking, selected_line, barcode): search = self._actions_for("search") - # TODO: use_packaging should be removed after merging the no_prefill_qty changes - # from PR #483. - product = search.product_from_scan(barcode, use_packaging=False) + product = search.product_from_scan(barcode) if product: if product.id != selected_line.product_id.id: return self._response_for_set_quantity( From 3e31c27a7ba38d8486c1be33dfd71649910124f8 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Tue, 31 Jan 2023 08:24:35 +0000 Subject: [PATCH 03/84] [UPD] Update shopfloor_reception.pot --- .../i18n/shopfloor_reception.pot | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 shopfloor_reception/i18n/shopfloor_reception.pot diff --git a/shopfloor_reception/i18n/shopfloor_reception.pot b/shopfloor_reception/i18n/shopfloor_reception.pot new file mode 100644 index 0000000000..92e8a19cb9 --- /dev/null +++ b/shopfloor_reception/i18n/shopfloor_reception.pot @@ -0,0 +1,60 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_reception +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: shopfloor_reception +#: model:ir.model.fields,help:shopfloor_reception.field_shopfloor_menu__auto_post_line +msgid "" +"\n" +"When setting result pack & destination,\n" +"automatically post the corresponding line\n" +"if this option is checked.\n" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu__auto_post_line_is_possible +msgid "Auto Post Line Is Possible" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu__auto_post_line +msgid "Automatically post line" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu__id +msgid "ID" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model,name:shopfloor_reception.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor_reception +#: model:shopfloor.menu,name:shopfloor_reception.shopfloor_menu_demo_reception +#: model:shopfloor.scenario,name:shopfloor_reception.scenario_reception +#: model:stock.picking.type,name:shopfloor_reception.picking_type_reception_demo +msgid "Reception" +msgstr "" From 95ede081c0f9f945996bf6aea13a23aa6d02e5a2 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 31 Jan 2023 08:38:33 +0000 Subject: [PATCH 04/84] [UPD] README.rst --- shopfloor_reception/README.rst | 92 +++- .../static/description/index.html | 428 ++++++++++++++++++ 2 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 shopfloor_reception/static/description/index.html diff --git a/shopfloor_reception/README.rst b/shopfloor_reception/README.rst index f130ce14c7..75fa74117c 100644 --- a/shopfloor_reception/README.rst +++ b/shopfloor_reception/README.rst @@ -1 +1,91 @@ -wait 4 da bot +=================== +Shopfloor Reception +=================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github + :target: https://github.com/OCA/wms/tree/14.0/shopfloor_reception + :alt: OCA/wms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor_reception + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/285/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Shopfloor implementation of the reception scenario. +Allows to receive products and create the proper packs for each logistic unit. + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +Implement methods in the backend to cancel lines (to be used by the frontend in select_line & set_quantity). + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Matthieu Méquignon +* Juan Miguel Sánchez Arce + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-mmequignon| image:: https://github.com/mmequignon.png?size=40px + :target: https://github.com/mmequignon + :alt: mmequignon +.. |maintainer-JuMiSanAr| image:: https://github.com/JuMiSanAr.png?size=40px + :target: https://github.com/JuMiSanAr + :alt: JuMiSanAr + +Current `maintainers `__: + +|maintainer-mmequignon| |maintainer-JuMiSanAr| + +This module is part of the `OCA/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor_reception/static/description/index.html b/shopfloor_reception/static/description/index.html new file mode 100644 index 0000000000..8c0a3aaff2 --- /dev/null +++ b/shopfloor_reception/static/description/index.html @@ -0,0 +1,428 @@ + + + + + + +Shopfloor Reception + + + +

+ + From 632120656462feff55c08f5bcf99bb4ce4ef4b78 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 31 Jan 2023 08:38:34 +0000 Subject: [PATCH 05/84] [ADD] icon.png --- shopfloor_reception/static/description/icon.png | Bin 0 -> 9455 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 shopfloor_reception/static/description/icon.png diff --git a/shopfloor_reception/static/description/icon.png b/shopfloor_reception/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 From 891fc9d07afa1dfcce7c1debca2f98dd6615d9f8 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Thu, 2 Feb 2023 07:34:59 +0100 Subject: [PATCH 06/84] shopfloor_reception: exclude view locations from scan --- shopfloor_reception/services/reception.py | 2 +- .../tests/test_set_quantity.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index ef4f7ccfe4..1ddf72bdbf 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -387,7 +387,7 @@ def _set_quantity__by_location(self, picking, selected_line, barcode): if location: dest_location = selected_line.location_dest_id child_locations = self.env["stock.location"].search( - [("id", "child_of", dest_location.id)] + [("id", "child_of", dest_location.id), ("usage", "!=", "view")] ) if location not in child_locations: # Scanned location isn't a child of the move's dest location diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py index a2c5190c7e..abb5635138 100644 --- a/shopfloor_reception/tests/test_set_quantity.py +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -299,6 +299,33 @@ def test_scan_location_not_child_of_dest_location(self): message={"message_type": "error", "body": "You cannot place it here"}, ) + def test_scan_location_view_usage(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + self.dispatch_location.sudo().quant_ids.unlink() + self.dispatch_location.sudo().usage = "view" + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.dispatch_location.barcode, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + def test_scan_new_package(self): picking = self._create_picking() selected_move_line = picking.move_line_ids.filtered( From 5c9e733d278d617822e193c05547fb549a546fca Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 2 Feb 2023 08:30:31 +0000 Subject: [PATCH 07/84] shopfloor_reception 14.0.1.0.1 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index bd8bafd619..33a8f7bd8d 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.1.0.0", + "version": "14.0.1.0.1", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From 919b22472f50ce6471577335b04210f8ee2af9a5 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 6 Feb 2023 09:01:07 +0100 Subject: [PATCH 08/84] shopfloor_reception: fix test_done_action --- shopfloor_reception/tests/test_select_move.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shopfloor_reception/tests/test_select_move.py b/shopfloor_reception/tests/test_select_move.py index c6153b88c5..decab1f9f3 100644 --- a/shopfloor_reception/tests/test_select_move.py +++ b/shopfloor_reception/tests/test_select_move.py @@ -262,10 +262,9 @@ def test_done_action(self): ("user_id", "=", False), ("scheduled_date", "=", fields.Datetime.today()), ], - order="scheduled_date ASC", + order="scheduled_date ASC, id ASC", ) message = "Transfer {} done".format(picking.name) - pickings.sorted(lambda p: (p.scheduled_date, p.id), reverse=False) self.assert_response( response, next_state="select_document", From 87bf9c40042f0439a65070e00f681f7f74f3ec61 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 8 Feb 2023 15:04:39 +0000 Subject: [PATCH 09/84] shopfloor_reception 14.0.1.0.2 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 33a8f7bd8d..36c445e5dd 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.1.0.1", + "version": "14.0.1.0.2", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From c70660b268a2ce4628b19c7e9d110691d2b4291b Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Fri, 17 Feb 2023 14:39:12 +0100 Subject: [PATCH 10/84] shopfloor_reception: exclude view locations in set_destination --- shopfloor_reception/services/reception.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 1ddf72bdbf..0122ccc775 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -358,7 +358,7 @@ def _set_quantity__by_package(self, picking, selected_line, barcode): if package: dest_location = selected_line.location_dest_id child_locations = self.env["stock.location"].search( - [("id", "child_of", dest_location.id)] + [("id", "child_of", dest_location.id), ("usage", "!=", "view")] ) pack_location = package.location_id if pack_location: @@ -889,11 +889,11 @@ def set_destination( location = search.location_from_scan(location_name) move_dest_location = selected_line.location_dest_id move_child_locations = self.env["stock.location"].search( - [("id", "child_of", move_dest_location.id)] + [("id", "child_of", move_dest_location.id), ("usage", "!=", "view")] ) pick_type_dest_location = picking.picking_type_id.default_location_dest_id pick_type_child_locations = self.env["stock.location"].search( - [("id", "child_of", pick_type_dest_location.id)] + [("id", "child_of", pick_type_dest_location.id), ("usage", "!=", "view")] ) if location not in move_child_locations | pick_type_child_locations: return self._response_for_set_destination( From 12626bbc61ed95653e62e0be350c579c17a245aa Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 20 Feb 2023 07:55:33 +0100 Subject: [PATCH 11/84] shopfloor_reception: fix auto posting when scan package --- shopfloor_reception/services/reception.py | 35 +++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 0122ccc775..e5e4229f0b 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -370,11 +370,30 @@ def _set_quantity__by_package(self, picking, selected_line, barcode): picking, selected_line, message=message ) else: + quantity = selected_line.qty_done + new_line, qty_check = selected_line._split_qty_to_be_done( + quantity, + lot_id=False, + shopfloor_user_id=False, + expiration_date=False, + ) + if qty_check == "greater": + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.unable_to_pick_more( + selected_line.product_uom_qty + ), + ) # If the scanned package has a valid destination, # set both package and destination on the package, # and go back to the selection line screen selected_line.result_package_id = package selected_line.location_dest_id = pack_location + if self.work.menu.auto_post_line: + # If option auto_post_line is active in the shopfloor menu, + # create a split order with this line. + self._auto_post_line(selected_line, picking) return self._response_for_select_move(picking) # Scanned package has no location, move to the location selection # screen @@ -888,23 +907,21 @@ def set_destination( search = self._actions_for("search") location = search.location_from_scan(location_name) move_dest_location = selected_line.location_dest_id - move_child_locations = self.env["stock.location"].search( - [("id", "child_of", move_dest_location.id), ("usage", "!=", "view")] - ) pick_type_dest_location = picking.picking_type_id.default_location_dest_id - pick_type_child_locations = self.env["stock.location"].search( - [("id", "child_of", pick_type_dest_location.id), ("usage", "!=", "view")] - ) - if location not in move_child_locations | pick_type_child_locations: + # TODO: Extract in different method, use everywhere to check locations + if location.usage == "view" or not ( + move_dest_location.parent_path.startswith(location.parent_path) + or pick_type_dest_location.parent_path.startswith(location.parent_path) + ): return self._response_for_set_destination( picking, selected_line, message=self.msg_store.dest_location_not_allowed(), ) - if location in move_child_locations: + if move_dest_location.parent_path.startswith(location.parent_path): # If location is a child of move's dest location, assign it without asking selected_line.location_dest_id = location - elif location in pick_type_child_locations: + elif pick_type_dest_location.parent_path.startswith(location.parent_path): # If location is a child of picking types's dest location, # ask for confirmation before assigning if not confirmation: From 5e7bac17b856a295f99e72a60ad8ec20c59dab3e Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 20 Feb 2023 08:26:41 +0100 Subject: [PATCH 12/84] shopfloor_reception: normalize location check --- shopfloor_reception/services/reception.py | 60 ++++++++++++++--------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index e5e4229f0b..c4099348ff 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -356,13 +356,13 @@ def _set_quantity__by_package(self, picking, selected_line, barcode): search = self._actions_for("search") package = search.package_from_scan(barcode) if package: - dest_location = selected_line.location_dest_id - child_locations = self.env["stock.location"].search( - [("id", "child_of", dest_location.id), ("usage", "!=", "view")] - ) pack_location = package.location_id if pack_location: - if pack_location not in child_locations: + ( + move_dest_location_ok, + pick_type_dest_location_ok, + ) = self._check_location_ok(pack_location, selected_line, picking) + if not (move_dest_location_ok or pick_type_dest_location_ok): # If the scanned package has a location that isn't a child # of the move dest, return an error message = self.msg_store.dest_location_not_allowed() @@ -371,7 +371,7 @@ def _set_quantity__by_package(self, picking, selected_line, barcode): ) else: quantity = selected_line.qty_done - new_line, qty_check = selected_line._split_qty_to_be_done( + __, qty_check = selected_line._split_qty_to_be_done( quantity, lot_id=False, shopfloor_user_id=False, @@ -404,11 +404,10 @@ def _set_quantity__by_location(self, picking, selected_line, barcode): search = self._actions_for("search") location = search.location_from_scan(barcode) if location: - dest_location = selected_line.location_dest_id - child_locations = self.env["stock.location"].search( - [("id", "child_of", dest_location.id), ("usage", "!=", "view")] + move_dest_location_ok, pick_type_dest_location_ok = self._check_location_ok( + location, selected_line, picking ) - if location not in child_locations: + if not (move_dest_location_ok or pick_type_dest_location_ok): # Scanned location isn't a child of the move's dest location message = self.msg_store.dest_location_not_allowed() return self._response_for_set_quantity( @@ -419,6 +418,24 @@ def _set_quantity__by_location(self, picking, selected_line, barcode): selected_line.location_dest_id = location return self._response_for_select_move(picking) + def _check_location_ok(self, location, selected_line, picking): + if location.usage == "view": + return (False, False) + + move_dest_location = selected_line.location_dest_id + pick_type_dest_location = picking.picking_type_id.default_location_dest_id + + move_dest_location_ok = move_dest_location.parent_path.startswith( + location.parent_path + ) + pick_type_dest_location_ok = pick_type_dest_location.parent_path.startswith( + location.parent_path + ) + if move_dest_location_ok or pick_type_dest_location_ok: + return (move_dest_location_ok, pick_type_dest_location_ok) + + return (False, False) + def _use_handlers(self, handlers, *args, **kwargs): for handler in handlers: response = handler(*args, **kwargs) @@ -800,7 +817,7 @@ def process_with_existing_pack(self, picking_id, selected_line_id, quantity): return self._response_for_set_quantity( picking, selected_line, message=message ) - new_line, qty_check = selected_line._split_qty_to_be_done( + __, qty_check = selected_line._split_qty_to_be_done( quantity, lot_id=False, shopfloor_user_id=False, expiration_date=False ) if qty_check == "greater": @@ -822,7 +839,7 @@ def process_with_new_pack(self, picking_id, selected_line_id, quantity): return self._response_for_set_quantity( picking, selected_line, message=message ) - new_line, qty_check = selected_line._split_qty_to_be_done( + __, qty_check = selected_line._split_qty_to_be_done( quantity, lot_id=False, shopfloor_user_id=False, expiration_date=False ) if qty_check == "greater": @@ -845,7 +862,7 @@ def process_without_pack(self, picking_id, selected_line_id, quantity): return self._response_for_set_quantity( picking, selected_line, message=message ) - new_line, qty_check = selected_line._split_qty_to_be_done( + __, qty_check = selected_line._split_qty_to_be_done( quantity, lot_id=False, shopfloor_user_id=False, expiration_date=False ) if qty_check == "greater": @@ -905,23 +922,21 @@ def set_destination( picking, selected_line, message=message ) search = self._actions_for("search") + location = search.location_from_scan(location_name) - move_dest_location = selected_line.location_dest_id - pick_type_dest_location = picking.picking_type_id.default_location_dest_id - # TODO: Extract in different method, use everywhere to check locations - if location.usage == "view" or not ( - move_dest_location.parent_path.startswith(location.parent_path) - or pick_type_dest_location.parent_path.startswith(location.parent_path) - ): + move_dest_location_ok, pick_type_dest_location_ok = self._check_location_ok( + location, selected_line, picking + ) + if not (move_dest_location_ok or pick_type_dest_location_ok): return self._response_for_set_destination( picking, selected_line, message=self.msg_store.dest_location_not_allowed(), ) - if move_dest_location.parent_path.startswith(location.parent_path): + if move_dest_location_ok: # If location is a child of move's dest location, assign it without asking selected_line.location_dest_id = location - elif pick_type_dest_location.parent_path.startswith(location.parent_path): + elif pick_type_dest_location_ok: # If location is a child of picking types's dest location, # ask for confirmation before assigning if not confirmation: @@ -933,6 +948,7 @@ def set_destination( ), ) selected_line.location_dest_id = location + if self.work.menu.auto_post_line: # If option auto_post_line is active in the shopfloor menu, # create a split order with this line. From c7c37b9a423779cd96345ea4a0cb4e94edf0c13b Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 20 Feb 2023 15:38:24 +0100 Subject: [PATCH 13/84] shopfloor_reception: prevent selecting multiple moves after scan --- shopfloor_reception/services/reception.py | 25 ++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index c4099348ff..587a2aa295 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -5,6 +5,7 @@ import pytz from odoo import fields +from odoo.tools import float_compare from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -291,7 +292,17 @@ def _scan_line__by_product(self, picking, barcode): search = self._actions_for("search") product = search.product_from_scan(barcode) if product: - move = picking.move_lines.filtered(lambda m: m.product_id == product) + move = fields.first( + picking.move_lines.filtered( + lambda m: m.product_id == product + and float_compare( + m.quantity_done, + m.product_uom_qty, + precision_rounding=m.product_uom.rounding, + ) + == -1 + ) + ) message = self._check_move_available(move, "product") if message: return self._response_for_select_move( @@ -304,8 +315,16 @@ def _scan_line__by_packaging(self, picking, barcode): search = self._actions_for("search") packaging = search.packaging_from_scan(barcode) if packaging: - move = picking.move_lines.filtered( - lambda m: packaging in m.product_id.packaging_ids + move = fields.first( + picking.move_lines.filtered( + lambda m: packaging in m.product_id.packaging_ids + and float_compare( + m.quantity_done, + m.product_uom_qty, + precision_rounding=m.product_uom.rounding, + ) + == -1 + ) ) message = self._check_move_available(move, "packaging") if message: From fdc7af82e54bc7b4ca06a4ff0c33c1efc1def679 Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Tue, 21 Feb 2023 11:54:50 +0100 Subject: [PATCH 14/84] shopfloor_reception: Fix auto-posting Split line when creating a new pack from barcode --- shopfloor_reception/README.rst | 1 + shopfloor_reception/readme/CONTRIBUTORS.rst | 1 + shopfloor_reception/services/reception.py | 65 +++++++++++++-------- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/shopfloor_reception/README.rst b/shopfloor_reception/README.rst index 75fa74117c..7bfd9a89c3 100644 --- a/shopfloor_reception/README.rst +++ b/shopfloor_reception/README.rst @@ -61,6 +61,7 @@ Contributors * Matthieu Méquignon * Juan Miguel Sánchez Arce +* Jacques-Etienne Baudoux (BCIM) Maintainers ~~~~~~~~~~~ diff --git a/shopfloor_reception/readme/CONTRIBUTORS.rst b/shopfloor_reception/readme/CONTRIBUTORS.rst index af182f7ae6..d29acfb39d 100644 --- a/shopfloor_reception/readme/CONTRIBUTORS.rst +++ b/shopfloor_reception/readme/CONTRIBUTORS.rst @@ -1,2 +1,3 @@ * Matthieu Méquignon * Juan Miguel Sánchez Arce +* Jacques-Etienne Baudoux (BCIM) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 587a2aa295..4962bee33b 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -1,4 +1,5 @@ # Copyright 2022 Camptocamp SA +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) @@ -388,32 +389,31 @@ def _set_quantity__by_package(self, picking, selected_line, barcode): return self._response_for_set_quantity( picking, selected_line, message=message ) - else: - quantity = selected_line.qty_done - __, qty_check = selected_line._split_qty_to_be_done( - quantity, - lot_id=False, - shopfloor_user_id=False, - expiration_date=False, + quantity = selected_line.qty_done + __, qty_check = selected_line._split_qty_to_be_done( + quantity, + lot_id=False, + shopfloor_user_id=False, + expiration_date=False, + ) + if qty_check == "greater": + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.unable_to_pick_more( + selected_line.product_uom_qty + ), ) - if qty_check == "greater": - return self._response_for_set_quantity( - picking, - selected_line, - message=self.msg_store.unable_to_pick_more( - selected_line.product_uom_qty - ), - ) - # If the scanned package has a valid destination, - # set both package and destination on the package, - # and go back to the selection line screen - selected_line.result_package_id = package - selected_line.location_dest_id = pack_location - if self.work.menu.auto_post_line: - # If option auto_post_line is active in the shopfloor menu, - # create a split order with this line. - self._auto_post_line(selected_line, picking) - return self._response_for_select_move(picking) + # If the scanned package has a valid destination, + # set both package and destination on the package, + # and go back to the selection line screen + selected_line.result_package_id = package + selected_line.location_dest_id = pack_location + if self.work.menu.auto_post_line: + # If option auto_post_line is active in the shopfloor menu, + # create a split order with this line. + self._auto_post_line(selected_line, picking) + return self._response_for_select_move(picking) # Scanned package has no location, move to the location selection # screen selected_line.result_package_id = package @@ -822,6 +822,21 @@ def set_quantity( message=self.msg_store.create_new_pack_ask_confirmation(barcode), ) package = self.env["stock.quant.package"].create({"name": barcode}) + quantity = selected_line.qty_done + __, qty_check = selected_line._split_qty_to_be_done( + quantity, + lot_id=False, + shopfloor_user_id=False, + expiration_date=False, + ) + if qty_check == "greater": + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.unable_to_pick_more( + selected_line.product_uom_qty + ), + ) selected_line.result_package_id = package return self._response_for_set_destination(picking, selected_line) return self._response_for_set_quantity( From 816ae081c8d00a7686dc7316664d2649ba6f6df3 Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Tue, 21 Feb 2023 11:56:08 +0100 Subject: [PATCH 15/84] shopfloor_reception: Fix wrong location scan --- shopfloor_reception/services/reception.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 4962bee33b..74adf0bd15 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -958,6 +958,10 @@ def set_destination( search = self._actions_for("search") location = search.location_from_scan(location_name) + if not location: + return self._response_for_set_destination( + picking, selected_line, message=self.msg_store.no_location_found() + ) move_dest_location_ok, pick_type_dest_location_ok = self._check_location_ok( location, selected_line, picking ) From c4ea787766a1a3735ac8104732aa54be7edb121b Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 6 Mar 2023 11:31:20 +0100 Subject: [PATCH 16/84] shopfloor_reception: update tests after auto-posting --- .../tests/test_set_destination.py | 30 ++++++++++++------- .../tests/test_set_quantity.py | 13 +++++--- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/shopfloor_reception/tests/test_set_destination.py b/shopfloor_reception/tests/test_set_destination.py index e18187ead9..a72d37e3c7 100644 --- a/shopfloor_reception/tests/test_set_destination.py +++ b/shopfloor_reception/tests/test_set_destination.py @@ -9,7 +9,12 @@ class TestSetDestination(CommonCase): def setUpClassBaseData(cls): super().setUpClassBaseData() cls.packing_location.sudo().active = True - cls.location_dest = cls.env.ref("stock.stock_location_stock") + cls.parent_location_dest = cls.env.ref("stock.stock_location_stock") + cls.location_dest = cls.shelf2 + cls.another_location = cls.env["stock.location"].search( + [("parent_path", "not ilike", cls.parent_location_dest.parent_path)], + limit=1, + ) @classmethod def _change_line_dest(cls, line): @@ -19,6 +24,7 @@ def _change_line_dest(cls, line): def test_scan_location_child_of_dest_location(self): picking = self._create_picking() + picking.sudo().picking_type_id.default_location_dest_id = self.another_location selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) @@ -28,10 +34,10 @@ def test_scan_location_child_of_dest_location(self): params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "location_name": self.shelf2.name, + "location_name": self.parent_location_dest.name, }, ) - self.assertEqual(selected_move_line.location_dest_id, self.shelf2) + self.assertEqual(selected_move_line.location_dest_id, self.parent_location_dest) data = self.data.picking(picking, with_progress=True) data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response( @@ -42,21 +48,24 @@ def test_scan_location_child_of_dest_location(self): def test_scan_location_child_of_pick_type_dest_location(self): picking = self._create_picking() + picking.sudo().picking_type_id.default_location_dest_id = self.shelf2 selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) - self._change_line_dest(selected_move_line) + selected_move_line.location_dest_id = self.another_location response = self.service.dispatch( "set_destination", params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "location_name": self.dispatch_location.name, + "location_name": self.parent_location_dest.name, }, ) # location is a child of the picking type's location. destination location # hasn't been set - self.assertNotEqual(selected_move_line.location_dest_id, self.dispatch_location) + self.assertNotEqual( + selected_move_line.location_dest_id, self.parent_location_dest + ) # But a confirmation has been asked data = self.data.picking(picking) self.assert_response( @@ -68,7 +77,7 @@ def test_scan_location_child_of_pick_type_dest_location(self): }, message={ "message_type": "warning", - "body": f"Place it in {self.dispatch_location.name}?", + "body": f"Place it in {self.parent_location_dest.name}?", }, ) # Send the same message with confirmation=True to confirm @@ -77,11 +86,11 @@ def test_scan_location_child_of_pick_type_dest_location(self): params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "location_name": self.dispatch_location.name, + "location_name": self.parent_location_dest.name, "confirmation": True, }, ) - self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) + self.assertEqual(selected_move_line.location_dest_id, self.parent_location_dest) data = self.data.picking(picking, with_progress=True) data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response( @@ -120,6 +129,7 @@ def test_auto_posting(self): selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) + self._change_line_dest(selected_move_line) # User has previously scanned a total of 3 units (with 7 still to do). # A new pack has been created and assigned to the line. @@ -141,7 +151,7 @@ def test_auto_posting(self): params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "location_name": self.dispatch_location.name, + "location_name": self.parent_location_dest.name, }, ) # The line has been moved to a different picking. diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py index abb5635138..030d4a75d3 100644 --- a/shopfloor_reception/tests/test_set_quantity.py +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -11,6 +11,8 @@ def setUpClassBaseData(cls): cls.product_a_packaging.qty = 5.0 cls.packing_location.sudo().active = True package_model = cls.env["stock.quant.package"] + cls.parent_location_dest = cls.env.ref("stock.stock_location_stock") + cls.location_dest = cls.shelf2 cls.package_without_location = package_model.create( { "name": "PKG_WO_LOCATION", @@ -29,11 +31,12 @@ def setUpClassBaseData(cls): "packaging_id": cls.product_a_packaging.id, } ) + cls.package_with_location_child_of_dest.location_id = cls.location_dest cls._update_qty_in_location( cls.packing_location, cls.product_a, 10, package=cls.package_with_location ) cls._update_qty_in_location( - cls.dispatch_location, + cls.parent_location_dest, cls.product_a, 10, package=cls.package_with_location_child_of_dest, @@ -183,6 +186,7 @@ def test_scan_package_with_destination_child_of_dest_location(self): selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) + selected_move_line.location_dest_id = self.parent_location_dest selected_move_line.shopfloor_user_id = self.env.uid response = self.service.dispatch( "set_quantity", @@ -196,7 +200,7 @@ def test_scan_package_with_destination_child_of_dest_location(self): selected_move_line.result_package_id, self.package_with_location_child_of_dest, ) - self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) + self.assertEqual(selected_move_line.location_dest_id, self.parent_location_dest) data = self.data.picking(picking, with_progress=True) data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response(response, next_state="select_move", data={"picking": data}) @@ -260,16 +264,17 @@ def test_scan_location_child_of_dest_location(self): selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) + selected_move_line.location_dest_id = self.location_dest selected_move_line.shopfloor_user_id = self.env.uid response = self.service.dispatch( "set_quantity", params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "barcode": self.dispatch_location.barcode, + "barcode": self.parent_location_dest.barcode, }, ) - self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) + self.assertEqual(selected_move_line.location_dest_id, self.parent_location_dest) data = self.data.picking(picking, with_progress=True) data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response(response, next_state="select_move", data={"picking": data}) From 3a939560162b4088bdcc76ab05718d102182e5d8 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 7 Mar 2023 10:05:44 +0000 Subject: [PATCH 17/84] [UPD] README.rst --- shopfloor_reception/static/description/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/shopfloor_reception/static/description/index.html b/shopfloor_reception/static/description/index.html index 8c0a3aaff2..1305199e34 100644 --- a/shopfloor_reception/static/description/index.html +++ b/shopfloor_reception/static/description/index.html @@ -408,6 +408,7 @@

Contributors

From 543ea095e8c1305c3343533a444db241ebad5c98 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 7 Mar 2023 10:05:44 +0000 Subject: [PATCH 18/84] shopfloor_reception 14.0.1.0.3 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 36c445e5dd..cbb6680ca6 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.1.0.2", + "version": "14.0.1.0.3", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From 03cd372024055b9ae61c898d5bb5de893d036ffc Mon Sep 17 00:00:00 2001 From: Michael Tietz Date: Fri, 6 Jan 2023 22:18:11 +0100 Subject: [PATCH 19/84] shopfloor_reception: allow scan lots --- shopfloor_reception/README.rst | 1 + shopfloor_reception/readme/CONTRIBUTORS.rst | 1 + shopfloor_reception/services/reception.py | 155 ++++++++++++------ .../static/description/index.html | 1 + 4 files changed, 105 insertions(+), 53 deletions(-) diff --git a/shopfloor_reception/README.rst b/shopfloor_reception/README.rst index 7bfd9a89c3..5ee7cd7550 100644 --- a/shopfloor_reception/README.rst +++ b/shopfloor_reception/README.rst @@ -62,6 +62,7 @@ Contributors * Matthieu Méquignon * Juan Miguel Sánchez Arce * Jacques-Etienne Baudoux (BCIM) +* Michael Tietz (MT Software) Maintainers ~~~~~~~~~~~ diff --git a/shopfloor_reception/readme/CONTRIBUTORS.rst b/shopfloor_reception/readme/CONTRIBUTORS.rst index d29acfb39d..02b86acf88 100644 --- a/shopfloor_reception/readme/CONTRIBUTORS.rst +++ b/shopfloor_reception/readme/CONTRIBUTORS.rst @@ -1,3 +1,4 @@ * Matthieu Méquignon * Juan Miguel Sánchez Arce * Jacques-Etienne Baudoux (BCIM) +* Michael Tietz (MT Software) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 74adf0bd15..5a050f2bd9 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -1,5 +1,6 @@ # Copyright 2022 Camptocamp SA # Copyright 2023 Jacques-Etienne Baudoux (BCIM) +# Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) @@ -57,6 +58,9 @@ def _move_line_by_packaging(self, packaging): self._domain_move_line_by_packaging(packaging) ) + def _move_line_by_lot(self, lot): + return self.env["stock.move.line"].search(self._domain_move_line_by_lot(lot)) + def _scheduled_date_today_domain(self): domain = [] today_start, today_end = self._get_today_start_end_datetime() @@ -94,6 +98,16 @@ def _domain_move_line_by_product(self, product): ("product_id", "=", product.id), ] + def _domain_move_line_by_lot(self, lot): + return [ + ("move_id.picking_id.picking_type_id", "in", self.picking_types.ids), + ("move_id.picking_id.state", "=", "assigned"), + ("move_id.picking_id.user_id", "=", False), + "|", + ("lot_id.name", "=", lot), + ("lot_name", "=", lot), + ] + def _domain_stock_picking(self, today_only=False): domain = [ ("state", "=", "assigned"), @@ -136,20 +150,15 @@ def _response_for_confirm_new_package( next_state="confirm_new_package", data=data, message=message ) - def _select_document_from_product(self, product): - """Select the document by product - - next states: - - set_lot: a single picking has been found for this packaging - - select_document: A single or no pickings has been found for this packaging - """ - move_lines = self._move_line_by_product(product).filtered( - lambda l: l.picking_id.picking_type_id.id in self.picking_types.ids - ) + def _select_document_from_move_lines(self, move_lines, msg_func): pickings = move_lines.mapped("move_id.picking_id") if len(pickings) == 1: self._assign_user_to_picking(pickings) - if product.tracking not in ("lot", "serial"): + if ( + move_lines.product_id.tracking not in ("lot", "serial") + or move_lines.lot_id + or move_lines.lot_name + ): return self._response_for_set_quantity(pickings, move_lines) return self._response_for_set_lot(pickings, move_lines) elif len(pickings) > 1: @@ -159,8 +168,20 @@ def _select_document_from_product(self, product): ) # If no available picking with the right state has been found, # return an error - return self._response_for_select_document( - message=self.msg_store.product_not_found_in_pickings() + return self._response_for_select_document(message=msg_func()) + + def _select_document_from_product(self, product): + """Select the document by product + + next states: + - set_lot: a single picking has been found for this packaging + - select_document: A single or no pickings has been found for this packaging + """ + move_lines = self._move_line_by_product(product).filtered( + lambda l: l.picking_id.picking_type_id.id in self.picking_types.ids + ) + return self._select_document_from_move_lines( + move_lines, self.msg_store.product_not_found_in_pickings ) def _select_document_from_packaging(self, packaging): @@ -173,31 +194,27 @@ def _select_document_from_packaging(self, packaging): move_lines = self._move_line_by_packaging(packaging).filtered( lambda l: l.picking_id.picking_type_id.id in self.picking_types.ids ) - pickings = move_lines.mapped("move_id.picking_id") - if len(pickings) == 1: - self._assign_user_to_picking(pickings) - if packaging.product_id.tracking not in ("lot", "serial"): - return self._response_for_set_quantity(pickings, move_lines) - return self._response_for_set_lot(pickings, move_lines) - elif len(pickings) > 1: - return self._response_for_select_document( - pickings=pickings, - message=self.msg_store.multiple_picks_found_select_manually(), - ) - # If no available picking with the right state has been found, - # return a barcode not found error message - return self._response_for_select_document( - message=self.msg_store.no_transfer_for_packaging(), + return self._select_document_from_move_lines( + move_lines, self.msg_store.no_transfer_for_packaging ) - def _select_line_from_product(self, picking, move, product): - line = fields.first( - picking.move_line_ids.filtered( - lambda l: l.product_id == product - and not l.result_package_id - and l.shopfloor_user_id.id in [False, self.env.uid] - ) + def _select_document_from_lot(self, lot): + """Select the document by lot + + next states: + - set_lot: a single picking has been found for this packaging + - select_document: A single or no pickings has been found for this packaging + """ + move_lines = self._move_line_by_lot(lot) + if not move_lines: + return + return self._select_document_from_move_lines( + move_lines, self.msg_store.no_transfer_for_lot ) + + def _select_line(self, picking, line, increase_qty_done_by=1): + move = line.move_id + product = line.product_id if line: # The line quantity to do needs to correspond to # the remaining quantity to do of its move. @@ -208,11 +225,21 @@ def _select_line_from_product(self, picking, move, product): line = self.env["stock.move.line"].create(values) self._assign_user_to_picking(picking) self._assign_user_to_line(line) - line.qty_done += 1 - if product.tracking not in ("lot", "serial"): + line.qty_done += increase_qty_done_by + if product.tracking not in ("lot", "serial") or (line.lot_id or line.lot_name): return self._response_for_set_quantity(picking, line) return self._response_for_set_lot(picking, line) + def _select_line_from_product(self, picking, move, product): + line = fields.first( + picking.move_line_ids.filtered( + lambda l: l.product_id == product + and not l.result_package_id + and l.shopfloor_user_id.id in [False, self.env.uid] + ) + ) + return self._select_line(picking, line) + def _select_line_from_packaging(self, picking, move, packaging): line = fields.first( picking.move_line_ids.filtered( @@ -221,20 +248,19 @@ def _select_line_from_packaging(self, picking, move, packaging): and l.shopfloor_user_id.id in [False, self.env.uid] ) ) - if line: - # The line quantity to do needs to correspond to - # the remaining quantity to do of its move. - line.product_uom_qty = move.product_uom_qty - move.quantity_done - else: - qty_to_do = move.product_uom_qty - move.quantity_done - values = move._prepare_move_line_vals(quantity=qty_to_do) - line = self.env["stock.move.line"].create(values) - self._assign_user_to_picking(picking) - self._assign_user_to_line(line) - line.qty_done += packaging.qty - if packaging.product_id.tracking not in ("lot", "serial"): - return self._response_for_set_quantity(picking, line) - return self._response_for_set_lot(picking, line) + return self._select_line(picking, line, packaging.qty) + + def _select_line_from_lot(self, picking, move, lot): + line = fields.first( + picking.move_line_ids.filtered( + lambda l: (l.lot_id.name == lot or l.lot_name == lot) + and not l.result_package_id + and l.shopfloor_user_id.id in [False, self.env.uid] + ) + ) + if not line: + return + return self._select_line(picking, line) def _order_stock_picking(self): # We sort by scheduled date first. However, there might be a case @@ -289,6 +315,9 @@ def _scan_document__by_packaging(self, barcode): if packaging: return self._select_document_from_packaging(packaging) + def _scan_document__by_lot(self, barcode): + return self._select_document_from_lot(barcode) + def _scan_line__by_product(self, picking, barcode): search = self._actions_for("search") product = search.product_from_scan(barcode) @@ -335,6 +364,18 @@ def _scan_line__by_packaging(self, picking, barcode): ) return self._select_line_from_packaging(picking, move, packaging) + def _scan_line__by_lot(self, picking, barcode): + move = picking.move_lines.move_line_ids.filtered( + lambda l: barcode == l.lot_id.name or barcode == l.lot_name + ) + message = self._check_move_available(move) + if message: + return self._response_for_select_move( + picking, + message=message, + ) + return self._select_line_from_lot(picking, move, barcode) + def _check_move_available(self, move, message_code="product"): if not move and message_code == "product": return self.msg_store.product_not_found_or_already_in_dest_package() @@ -437,6 +478,11 @@ def _set_quantity__by_location(self, picking, selected_line, barcode): selected_line.location_dest_id = location return self._response_for_select_move(picking) + def _set_quantity__by_lot(self, picking, selected_line, barcode): + if selected_line.lot_id.name == barcode or selected_line.lot_name == barcode: + selected_line.qty_done += 1 + return self._response_for_set_quantity(picking, selected_line) + def _check_location_ok(self, location, selected_line, picking): if location.usage == "view": return (False, False) @@ -577,7 +623,7 @@ def scan_document(self, barcode): """Scan a picking, a product or a packaging. Input: - barcode: the barcode of a product, a packaging or a picking name + barcode: the barcode of a product, a packaging, a picking name or a lot transitions: - select_document: Error: barcode not found @@ -593,6 +639,7 @@ def scan_document(self, barcode): self._scan_document__by_picking, self._scan_document__by_product, self._scan_document__by_packaging, + self._scan_document__by_lot, ) response = self._use_handlers(handlers, barcode) if response: @@ -627,7 +674,7 @@ def scan_line(self, picking_id, barcode): """Scan a product or a packaging input: - barcode: The barcode of a product or a packaging + barcode: The barcode of a product, a packaging or a lot transitions: - select_move: Error: barcode not found @@ -641,6 +688,7 @@ def scan_line(self, picking_id, barcode): handlers = ( self._scan_line__by_product, self._scan_line__by_packaging, + self._scan_line__by_lot, ) response = self._use_handlers(handlers, picking, barcode) if response: @@ -809,6 +857,7 @@ def set_quantity( self._set_quantity__by_packaging, self._set_quantity__by_package, self._set_quantity__by_location, + self._set_quantity__by_lot, ) response = self._use_handlers(handlers, picking, selected_line, barcode) if response: diff --git a/shopfloor_reception/static/description/index.html b/shopfloor_reception/static/description/index.html index 1305199e34..45cc9b6614 100644 --- a/shopfloor_reception/static/description/index.html +++ b/shopfloor_reception/static/description/index.html @@ -409,6 +409,7 @@

Contributors

  • Matthieu Méquignon <matthieu.mequignon@camptocamp.com>
  • Juan Miguel Sánchez Arce <juan.sanchez@camptocamp.com>
  • Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
  • +
  • Michael Tietz (MT Software) <mtietz@mt-software.de>
  • From 274b4311791cc007a2c6835dd54114b663355b18 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Thu, 2 Feb 2023 12:57:42 +0100 Subject: [PATCH 20/84] shopfloor_reception: improve scanning of lots --- shopfloor_reception/services/reception.py | 47 ++++++++++++------- shopfloor_reception/tests/test_select_move.py | 24 ++++++++++ 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 5a050f2bd9..6e337034c4 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -212,8 +212,7 @@ def _select_document_from_lot(self, lot): move_lines, self.msg_store.no_transfer_for_lot ) - def _select_line(self, picking, line, increase_qty_done_by=1): - move = line.move_id + def _select_line(self, picking, line, move, increase_qty_done_by=1): product = line.product_id if line: # The line quantity to do needs to correspond to @@ -238,7 +237,7 @@ def _select_line_from_product(self, picking, move, product): and l.shopfloor_user_id.id in [False, self.env.uid] ) ) - return self._select_line(picking, line) + return self._select_line(picking, line, move) def _select_line_from_packaging(self, picking, move, packaging): line = fields.first( @@ -248,7 +247,7 @@ def _select_line_from_packaging(self, picking, move, packaging): and l.shopfloor_user_id.id in [False, self.env.uid] ) ) - return self._select_line(picking, line, packaging.qty) + return self._select_line(picking, line, move, packaging.qty) def _select_line_from_lot(self, picking, move, lot): line = fields.first( @@ -260,7 +259,7 @@ def _select_line_from_lot(self, picking, move, lot): ) if not line: return - return self._select_line(picking, line) + return self._select_line(picking, line, move) def _order_stock_picking(self): # We sort by scheduled date first. However, there might be a case @@ -333,7 +332,7 @@ def _scan_line__by_product(self, picking, barcode): == -1 ) ) - message = self._check_move_available(move, "product") + message = self._check_move_available(move, message_code="product") if message: return self._response_for_select_move( picking, @@ -356,7 +355,7 @@ def _scan_line__by_packaging(self, picking, barcode): == -1 ) ) - message = self._check_move_available(move, "packaging") + message = self._check_move_available(move, message_code="packaging") if message: return self._response_for_select_move( picking, @@ -365,22 +364,34 @@ def _scan_line__by_packaging(self, picking, barcode): return self._select_line_from_packaging(picking, move, packaging) def _scan_line__by_lot(self, picking, barcode): - move = picking.move_lines.move_line_ids.filtered( + line = picking.move_line_ids.filtered( lambda l: barcode == l.lot_id.name or barcode == l.lot_name ) - message = self._check_move_available(move) - if message: - return self._response_for_select_move( - picking, - message=message, + move = line.move_id + search = self._actions_for("search") + lot = search.lot_from_scan(barcode) + if not move: + line = picking.move_line_ids.filtered( + lambda l: not l.lot_id + and not l.lot_name + and l.product_id == lot.product_id ) - return self._select_line_from_lot(picking, move, barcode) + if line: + return self._select_line(picking, line, move) + else: + message = self._check_move_available(move, message_code="lot") + if message: + return self._response_for_select_move( + picking, + message=message, + ) + if lot: + return self._select_line_from_lot(picking, move, barcode) def _check_move_available(self, move, message_code="product"): - if not move and message_code == "product": - return self.msg_store.product_not_found_or_already_in_dest_package() - if not move and message_code == "packaging": - return self.msg_store.packaging_not_found_or_already_in_dest_package() + if not move: + message_code = message_code.capitalize() + return self.msg_store.x_not_found_or_already_in_dest_package(message_code) line_without_package = any( not ml.result_package_id for ml in move.move_line_ids ) diff --git a/shopfloor_reception/tests/test_select_move.py b/shopfloor_reception/tests/test_select_move.py index decab1f9f3..ddef2627b0 100644 --- a/shopfloor_reception/tests/test_select_move.py +++ b/shopfloor_reception/tests/test_select_move.py @@ -68,6 +68,30 @@ def test_scan_packaging(self): }, ) + def test_scan_lot(self): + picking = self._create_picking() + lot = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.lot_id = lot + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": lot.name, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + def test_scan_not_tracked_product(self): self.product_a.tracking = "none" picking = self._create_picking() From c1f098b92335ba41af508c051f23d7e7b2a30345 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 7 Mar 2023 15:05:34 +0000 Subject: [PATCH 21/84] shopfloor_reception 14.0.1.1.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index cbb6680ca6..95b16f443c 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.1.0.3", + "version": "14.0.1.1.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From d07505590e3fc02b90182da3652bf404006d6265 Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Tue, 14 Feb 2023 08:44:25 +0100 Subject: [PATCH 22/84] shopfloor_reception: Solved TODO The underlying issue has been fixed. Removing the temporary workaround. --- shopfloor_reception/services/reception.py | 13 ------------- shopfloor_reception/tests/test_set_destination.py | 1 + 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 6e337034c4..087e3ef8b9 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -975,19 +975,6 @@ def _auto_post_line(self, selected_line, picking): selected_line, intersection=True ) new_move.extract_and_action_done() - # TODO: by using split_other_move_lines above in reception, - # we encounter a strange behaviour where a line is created in the original picking - # with qty_done=0 and no related move_id. - # This is probably caused by package_level_id. - - # The workaround is to look for any orphan lines and delete them, - # but this should be investigated to find a better solution. - lines = picking.move_line_ids.filtered( - lambda l: l.product_id == selected_line.product_id - ) - for line in lines: - if not line.move_id: - line.unlink() def set_destination( self, picking_id, selected_line_id, location_name, confirmation=False diff --git a/shopfloor_reception/tests/test_set_destination.py b/shopfloor_reception/tests/test_set_destination.py index a72d37e3c7..2475f4e7d6 100644 --- a/shopfloor_reception/tests/test_set_destination.py +++ b/shopfloor_reception/tests/test_set_destination.py @@ -169,3 +169,4 @@ def test_auto_posting(self): ) self.assertEqual(line_in_picking.product_uom_qty, 7) self.assertEqual(line_in_picking.qty_done, 0) + self.assertEqual(picking.state, "assigned") From 8d3eb7c8f82f3235b20b59b33dc93dcdb4160fdc Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 8 Mar 2023 10:17:08 +0000 Subject: [PATCH 23/84] shopfloor_reception 14.0.1.1.1 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 95b16f443c..026d6b91bb 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.1.1.0", + "version": "14.0.1.1.1", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From f49b3c725641a96336f34c2bd890da28b7e82e44 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Tue, 2 May 2023 10:43:59 +0200 Subject: [PATCH 24/84] shopfloor: make auto_post menu option available to all scenarios --- shopfloor_reception/__init__.py | 1 - shopfloor_reception/__manifest__.py | 1 - shopfloor_reception/models/__init__.py | 1 - shopfloor_reception/models/shopfloor_menu.py | 29 -------------------- shopfloor_reception/views/shopfloor_menu.xml | 18 ------------ 5 files changed, 50 deletions(-) delete mode 100644 shopfloor_reception/models/__init__.py delete mode 100644 shopfloor_reception/models/shopfloor_menu.py delete mode 100644 shopfloor_reception/views/shopfloor_menu.xml diff --git a/shopfloor_reception/__init__.py b/shopfloor_reception/__init__.py index 71a02422d5..99464a7510 100644 --- a/shopfloor_reception/__init__.py +++ b/shopfloor_reception/__init__.py @@ -1,2 +1 @@ -from . import models from . import services diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 026d6b91bb..24ff046565 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -12,7 +12,6 @@ "depends": ["shopfloor"], "data": [ "data/shopfloor_scenario_data.xml", - "views/shopfloor_menu.xml", ], "demo": [ "demo/stock_picking_type_demo.xml", diff --git a/shopfloor_reception/models/__init__.py b/shopfloor_reception/models/__init__.py deleted file mode 100644 index 8bd3d5195c..0000000000 --- a/shopfloor_reception/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import shopfloor_menu diff --git a/shopfloor_reception/models/shopfloor_menu.py b/shopfloor_reception/models/shopfloor_menu.py deleted file mode 100644 index d797a1cbe9..0000000000 --- a/shopfloor_reception/models/shopfloor_menu.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import api, fields, models - -AUTO_POST_LINE = """ -When setting result pack & destination, -automatically post the corresponding line -if this option is checked. -""" - - -class ShopfloorMenu(models.Model): - _inherit = "shopfloor.menu" - - auto_post_line = fields.Boolean( - string="Automatically post line", - default=False, - help=AUTO_POST_LINE, - ) - auto_post_line_is_possible = fields.Boolean( - compute="_compute_auto_post_line_is_possible" - ) - - @api.depends("scenario_id") - def _compute_auto_post_line_is_possible(self): - for menu in self: - menu.auto_post_line_is_possible = bool( - menu.scenario_id.has_option("auto_post_line") - ) diff --git a/shopfloor_reception/views/shopfloor_menu.xml b/shopfloor_reception/views/shopfloor_menu.xml deleted file mode 100644 index d5f7fded57..0000000000 --- a/shopfloor_reception/views/shopfloor_menu.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - shopfloor.menu - - - - - - - - - - - From af5cf4d13e5d14e3b4fcdb64c54deda4fb381dc7 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Wed, 5 Apr 2023 13:12:10 +0200 Subject: [PATCH 25/84] shopfloor_reception: ask for confirmation when scanning new package --- shopfloor_reception/services/reception.py | 11 ++++++++++- shopfloor_reception/tests/test_select_document.py | 1 + shopfloor_reception/tests/test_select_move.py | 3 +++ shopfloor_reception/tests/test_set_lot_confirm.py | 1 + shopfloor_reception/tests/test_set_quantity.py | 8 ++++++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 087e3ef8b9..5bc7b9ca30 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -580,12 +580,15 @@ def _response_for_set_lot(self, picking, line, message=None): message=message, ) - def _response_for_set_quantity(self, picking, line, message=None): + def _response_for_set_quantity( + self, picking, line, message=None, asking_confirmation=False + ): return self._response( next_state="set_quantity", data={ "selected_move_line": self._data_for_move_lines(line), "picking": self.data.picking(picking), + "confirmation_required": asking_confirmation, }, message=message, ) @@ -880,6 +883,7 @@ def set_quantity( picking, selected_line, message=self.msg_store.create_new_pack_ask_confirmation(barcode), + asking_confirmation=True, ) package = self.env["stock.quant.package"].create({"name": barcode}) quantity = selected_line.qty_done @@ -1340,6 +1344,11 @@ def _schema_set_quantity(self): "schema": {"type": "dict", "schema": self.schemas.move_line()}, }, "picking": {"type": "dict", "schema": self.schemas.picking()}, + "confirmation_required": { + "type": "boolean", + "nullable": True, + "required": False, + }, } @property diff --git a/shopfloor_reception/tests/test_select_document.py b/shopfloor_reception/tests/test_select_document.py index efcfc15e6e..9308bf4129 100644 --- a/shopfloor_reception/tests/test_select_document.py +++ b/shopfloor_reception/tests/test_select_document.py @@ -151,6 +151,7 @@ def test_scan_not_tracked_product_single_picking(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, ) diff --git a/shopfloor_reception/tests/test_select_move.py b/shopfloor_reception/tests/test_select_move.py index ddef2627b0..bd36cbc1d6 100644 --- a/shopfloor_reception/tests/test_select_move.py +++ b/shopfloor_reception/tests/test_select_move.py @@ -89,6 +89,7 @@ def test_scan_lot(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, ) @@ -113,6 +114,7 @@ def test_scan_not_tracked_product(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, ) @@ -137,6 +139,7 @@ def test_scan_not_tracked_packaging(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, ) diff --git a/shopfloor_reception/tests/test_set_lot_confirm.py b/shopfloor_reception/tests/test_set_lot_confirm.py index ff4882980a..7878eef0fe 100644 --- a/shopfloor_reception/tests/test_set_lot_confirm.py +++ b/shopfloor_reception/tests/test_set_lot_confirm.py @@ -61,5 +61,6 @@ def test_ensure_expiry_date(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, ) diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py index 030d4a75d3..1a4ee7ac20 100644 --- a/shopfloor_reception/tests/test_set_quantity.py +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -65,6 +65,7 @@ def test_set_quantity_scan_product(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, ) @@ -91,6 +92,7 @@ def test_set_quantity_scan_packaging(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, ) @@ -116,6 +118,7 @@ def test_scan_product(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, ) # Scan again, and ensure qty increments @@ -167,6 +170,7 @@ def test_scan_packaging(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, ) # Scan again, and ensure qty increments @@ -227,6 +231,7 @@ def test_scan_package_with_destination_not_child_of_dest_location(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, message={"message_type": "error", "body": "You cannot place it here"}, ) @@ -300,6 +305,7 @@ def test_scan_location_not_child_of_dest_location(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, message={"message_type": "error", "body": "You cannot place it here"}, ) @@ -327,6 +333,7 @@ def test_scan_location_view_usage(self): data={ "picking": data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": False, }, message={"message_type": "error", "body": "You cannot place it here"}, ) @@ -353,6 +360,7 @@ def test_scan_new_package(self): data={ "picking": picking_data, "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": True, }, message={ "message_type": "warning", From ad8ab22445354ab95fb8bdca13f63d683ca0a1ec Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Wed, 5 Apr 2023 14:40:46 +0200 Subject: [PATCH 26/84] shopfloor_reception: fix check_location_ok applied wrong way --- shopfloor_reception/services/reception.py | 8 ++--- .../tests/test_set_destination.py | 31 +++++++------------ .../tests/test_set_quantity.py | 13 +++----- 3 files changed, 19 insertions(+), 33 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 5bc7b9ca30..ea8ddf3aab 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -501,11 +501,11 @@ def _check_location_ok(self, location, selected_line, picking): move_dest_location = selected_line.location_dest_id pick_type_dest_location = picking.picking_type_id.default_location_dest_id - move_dest_location_ok = move_dest_location.parent_path.startswith( - location.parent_path + move_dest_location_ok = location.parent_path.startswith( + move_dest_location.parent_path ) - pick_type_dest_location_ok = pick_type_dest_location.parent_path.startswith( - location.parent_path + pick_type_dest_location_ok = location.parent_path.startswith( + pick_type_dest_location.parent_path ) if move_dest_location_ok or pick_type_dest_location_ok: return (move_dest_location_ok, pick_type_dest_location_ok) diff --git a/shopfloor_reception/tests/test_set_destination.py b/shopfloor_reception/tests/test_set_destination.py index 2475f4e7d6..c0838fabfe 100644 --- a/shopfloor_reception/tests/test_set_destination.py +++ b/shopfloor_reception/tests/test_set_destination.py @@ -9,12 +9,7 @@ class TestSetDestination(CommonCase): def setUpClassBaseData(cls): super().setUpClassBaseData() cls.packing_location.sudo().active = True - cls.parent_location_dest = cls.env.ref("stock.stock_location_stock") - cls.location_dest = cls.shelf2 - cls.another_location = cls.env["stock.location"].search( - [("parent_path", "not ilike", cls.parent_location_dest.parent_path)], - limit=1, - ) + cls.location_dest = cls.env.ref("stock.stock_location_stock") @classmethod def _change_line_dest(cls, line): @@ -24,7 +19,6 @@ def _change_line_dest(cls, line): def test_scan_location_child_of_dest_location(self): picking = self._create_picking() - picking.sudo().picking_type_id.default_location_dest_id = self.another_location selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) @@ -34,10 +28,10 @@ def test_scan_location_child_of_dest_location(self): params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "location_name": self.parent_location_dest.name, + "location_name": self.shelf2.name, }, ) - self.assertEqual(selected_move_line.location_dest_id, self.parent_location_dest) + self.assertEqual(selected_move_line.location_dest_id, self.shelf2) data = self.data.picking(picking, with_progress=True) data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response( @@ -48,24 +42,22 @@ def test_scan_location_child_of_dest_location(self): def test_scan_location_child_of_pick_type_dest_location(self): picking = self._create_picking() - picking.sudo().picking_type_id.default_location_dest_id = self.shelf2 selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) - selected_move_line.location_dest_id = self.another_location + self._change_line_dest(selected_move_line) response = self.service.dispatch( "set_destination", params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "location_name": self.parent_location_dest.name, + "location_name": self.dispatch_location.name, }, ) + # location is a child of the picking type's location. destination location # hasn't been set - self.assertNotEqual( - selected_move_line.location_dest_id, self.parent_location_dest - ) + self.assertNotEqual(selected_move_line.location_dest_id, self.dispatch_location) # But a confirmation has been asked data = self.data.picking(picking) self.assert_response( @@ -77,7 +69,7 @@ def test_scan_location_child_of_pick_type_dest_location(self): }, message={ "message_type": "warning", - "body": f"Place it in {self.parent_location_dest.name}?", + "body": f"Place it in {self.dispatch_location.name}?", }, ) # Send the same message with confirmation=True to confirm @@ -86,11 +78,11 @@ def test_scan_location_child_of_pick_type_dest_location(self): params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "location_name": self.parent_location_dest.name, + "location_name": self.dispatch_location.name, "confirmation": True, }, ) - self.assertEqual(selected_move_line.location_dest_id, self.parent_location_dest) + self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) data = self.data.picking(picking, with_progress=True) data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response( @@ -129,7 +121,6 @@ def test_auto_posting(self): selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) - self._change_line_dest(selected_move_line) # User has previously scanned a total of 3 units (with 7 still to do). # A new pack has been created and assigned to the line. @@ -151,7 +142,7 @@ def test_auto_posting(self): params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "location_name": self.parent_location_dest.name, + "location_name": self.dispatch_location.name, }, ) # The line has been moved to a different picking. diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py index 1a4ee7ac20..6d1ea2833e 100644 --- a/shopfloor_reception/tests/test_set_quantity.py +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -11,8 +11,6 @@ def setUpClassBaseData(cls): cls.product_a_packaging.qty = 5.0 cls.packing_location.sudo().active = True package_model = cls.env["stock.quant.package"] - cls.parent_location_dest = cls.env.ref("stock.stock_location_stock") - cls.location_dest = cls.shelf2 cls.package_without_location = package_model.create( { "name": "PKG_WO_LOCATION", @@ -31,12 +29,11 @@ def setUpClassBaseData(cls): "packaging_id": cls.product_a_packaging.id, } ) - cls.package_with_location_child_of_dest.location_id = cls.location_dest cls._update_qty_in_location( cls.packing_location, cls.product_a, 10, package=cls.package_with_location ) cls._update_qty_in_location( - cls.parent_location_dest, + cls.dispatch_location, cls.product_a, 10, package=cls.package_with_location_child_of_dest, @@ -190,7 +187,6 @@ def test_scan_package_with_destination_child_of_dest_location(self): selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) - selected_move_line.location_dest_id = self.parent_location_dest selected_move_line.shopfloor_user_id = self.env.uid response = self.service.dispatch( "set_quantity", @@ -204,7 +200,7 @@ def test_scan_package_with_destination_child_of_dest_location(self): selected_move_line.result_package_id, self.package_with_location_child_of_dest, ) - self.assertEqual(selected_move_line.location_dest_id, self.parent_location_dest) + self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) data = self.data.picking(picking, with_progress=True) data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response(response, next_state="select_move", data={"picking": data}) @@ -269,17 +265,16 @@ def test_scan_location_child_of_dest_location(self): selected_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_a ) - selected_move_line.location_dest_id = self.location_dest selected_move_line.shopfloor_user_id = self.env.uid response = self.service.dispatch( "set_quantity", params={ "picking_id": picking.id, "selected_line_id": selected_move_line.id, - "barcode": self.parent_location_dest.barcode, + "barcode": self.dispatch_location.barcode, }, ) - self.assertEqual(selected_move_line.location_dest_id, self.parent_location_dest) + self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) data = self.data.picking(picking, with_progress=True) data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response(response, next_state="select_move", data={"picking": data}) From 64a8bcf4bfbdaff6959b7f81cb6b29eac69f3e81 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Thu, 6 Apr 2023 11:08:02 +0200 Subject: [PATCH 27/84] shopfloor_reception: do not open move after scanning product/lot --- shopfloor_reception/services/reception.py | 30 ++++++--- .../tests/test_select_document.py | 66 +------------------ 2 files changed, 23 insertions(+), 73 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index ea8ddf3aab..5c205d547c 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -151,7 +151,7 @@ def _response_for_confirm_new_package( ) def _select_document_from_move_lines(self, move_lines, msg_func): - pickings = move_lines.mapped("move_id.picking_id") + pickings = move_lines.move_id.picking_id if len(pickings) == 1: self._assign_user_to_picking(pickings) if ( @@ -180,9 +180,12 @@ def _select_document_from_product(self, product): move_lines = self._move_line_by_product(product).filtered( lambda l: l.picking_id.picking_type_id.id in self.picking_types.ids ) - return self._select_document_from_move_lines( - move_lines, self.msg_store.product_not_found_in_pickings - ) + pickings = move_lines.move_id.picking_id + if pickings: + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.multiple_picks_found_select_manually(), + ) def _select_document_from_packaging(self, packaging): """Select the document by packaging @@ -194,9 +197,12 @@ def _select_document_from_packaging(self, packaging): move_lines = self._move_line_by_packaging(packaging).filtered( lambda l: l.picking_id.picking_type_id.id in self.picking_types.ids ) - return self._select_document_from_move_lines( - move_lines, self.msg_store.no_transfer_for_packaging - ) + pickings = move_lines.move_id.picking_id + if pickings: + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.multiple_picks_found_select_manually(), + ) def _select_document_from_lot(self, lot): """Select the document by lot @@ -208,9 +214,12 @@ def _select_document_from_lot(self, lot): move_lines = self._move_line_by_lot(lot) if not move_lines: return - return self._select_document_from_move_lines( - move_lines, self.msg_store.no_transfer_for_lot - ) + pickings = move_lines.move_id.picking_id + if pickings: + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.multiple_picks_found_select_manually(), + ) def _select_line(self, picking, line, move, increase_qty_done_by=1): product = line.product_id @@ -294,6 +303,7 @@ def _scan_document__by_picking(self, barcode): < today_end ) if len(picking_filter_result_due_today) == 1: + self._assign_user_to_picking(picking_filter_result_due_today) return self._select_picking(picking_filter_result_due_today) if len(picking_filter_result) > 1: return self._response_for_select_document( diff --git a/shopfloor_reception/tests/test_select_document.py b/shopfloor_reception/tests/test_select_document.py index 9308bf4129..7846b666b5 100644 --- a/shopfloor_reception/tests/test_select_document.py +++ b/shopfloor_reception/tests/test_select_document.py @@ -95,66 +95,6 @@ def test_scan_picking_origin_one_picking(self): data={"picking": self._data_for_picking_with_moves(picking)}, ) - def test_scan_packaging_single_picking(self): - # next step is set_lot - picking = self._create_picking() - self._add_package(picking) - response = self.service.dispatch( - "scan_document", params={"barcode": self.product_a_packaging.barcode} - ) - data = self.data.picking(picking) - selected_move_line = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - ) - self.assert_response( - response, - next_state="set_lot", - data={ - "picking": data, - "selected_move_line": self.data.move_lines(selected_move_line), - }, - ) - - def test_scan_product_single_picking(self): - # next_step is set_lot - picking = self._create_picking() - response = self.service.dispatch( - "scan_document", params={"barcode": self.product_a.barcode} - ) - data = self.data.picking(picking) - selected_move_line = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - ) - self.assert_response( - response, - next_state="set_lot", - data={ - "picking": data, - "selected_move_line": self.data.move_lines(selected_move_line), - }, - ) - - def test_scan_not_tracked_product_single_picking(self): - # next_step is set_quantity - self.product_a.tracking = "none" - picking = self._create_picking() - response = self.service.dispatch( - "scan_document", params={"barcode": self.product_a.barcode} - ) - data = self.data.picking(picking) - selected_move_line = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - ) - self.assert_response( - response, - next_state="set_quantity", - data={ - "picking": data, - "selected_move_line": self.data.move_lines(selected_move_line), - "confirmation_required": False, - }, - ) - def test_scan_packaging_multiple_pickings(self): # next step is select_document, with documents filtered based on the product p1 = self._create_picking() @@ -194,12 +134,12 @@ def test_scan_product_no_picking(self): response = self.service.dispatch( "scan_document", params={"barcode": self.product_c.barcode} ) - body = "No product found among current transfers." + body = "Barcode not found" self.assert_response( response, next_state="select_document", data={"pickings": self._data_for_pickings(picking)}, - message={"message_type": "warning", "body": body}, + message={"message_type": "error", "body": body}, ) def test_scan_packaging_no_picking(self): @@ -209,7 +149,7 @@ def test_scan_packaging_no_picking(self): response = self.service.dispatch( "scan_document", params={"barcode": self.product_c_packaging.barcode} ) - body = "No transfer found for the scanned packaging." + body = "Barcode not found" self.assert_response( response, next_state="select_document", From 053226484d7572cacc905405f38e47082a12597b Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Thu, 6 Apr 2023 15:09:09 +0200 Subject: [PATCH 28/84] shopfloor_reception: auto post line if scanning new package --- shopfloor_reception/services/reception.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 5c205d547c..7a0477a3dd 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -474,7 +474,7 @@ def _set_quantity__by_package(self, picking, selected_line, barcode): if self.work.menu.auto_post_line: # If option auto_post_line is active in the shopfloor menu, # create a split order with this line. - self._auto_post_line(selected_line, picking) + self._auto_post_line(selected_line) return self._response_for_select_move(picking) # Scanned package has no location, move to the location selection # screen @@ -887,7 +887,7 @@ def set_quantity( if response: return response # Nothing found, ask user if we should create a new pack for the scanned - # barcode + # barcode. if not confirmation: return self._response_for_set_quantity( picking, @@ -895,6 +895,7 @@ def set_quantity( message=self.msg_store.create_new_pack_ask_confirmation(barcode), asking_confirmation=True, ) + # Nothing found and we already ask for confirmation, create the new package. package = self.env["stock.quant.package"].create({"name": barcode}) quantity = selected_line.qty_done __, qty_check = selected_line._split_qty_to_be_done( @@ -912,6 +913,10 @@ def set_quantity( ), ) selected_line.result_package_id = package + if self.work.menu.auto_post_line: + # If option auto_post_line is active in the shopfloor menu, + # create a split order with this line. + self._auto_post_line(selected_line) return self._response_for_set_destination(picking, selected_line) return self._response_for_set_quantity( picking, selected_line, message=self.msg_store.barcode_not_found() @@ -984,7 +989,7 @@ def process_without_pack(self, picking_id, selected_line_id, quantity): selected_line.qty_done = quantity return self._response_for_set_destination(picking, selected_line) - def _auto_post_line(self, selected_line, picking): + def _auto_post_line(self, selected_line): new_move = selected_line.move_id.split_other_move_lines( selected_line, intersection=True ) @@ -1051,7 +1056,7 @@ def set_destination( if self.work.menu.auto_post_line: # If option auto_post_line is active in the shopfloor menu, # create a split order with this line. - self._auto_post_line(selected_line, picking) + self._auto_post_line(selected_line) return self._response_for_select_move(picking) def select_dest_package( @@ -1096,7 +1101,7 @@ def select_dest_package( if self.work.menu.auto_post_line: # If option auto_post_line is active in the shopfloor menu, # create a split order with this line. - self._auto_post_line(selected_line, picking) + self._auto_post_line(selected_line) return self._response_for_select_move(picking) message = self.msg_store.create_new_pack_ask_confirmation(barcode) self._assign_user_to_picking(picking) From bcae7edced0afe15d0b817b2976237969515ae33 Mon Sep 17 00:00:00 2001 From: oca-ci Date: Mon, 22 May 2023 10:50:46 +0000 Subject: [PATCH 29/84] [UPD] Update shopfloor_reception.pot --- .../i18n/shopfloor_reception.pot | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/shopfloor_reception/i18n/shopfloor_reception.pot b/shopfloor_reception/i18n/shopfloor_reception.pot index 92e8a19cb9..d18e79f3f6 100644 --- a/shopfloor_reception/i18n/shopfloor_reception.pot +++ b/shopfloor_reception/i18n/shopfloor_reception.pot @@ -13,45 +13,6 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" -#. module: shopfloor_reception -#: model:ir.model.fields,help:shopfloor_reception.field_shopfloor_menu__auto_post_line -msgid "" -"\n" -"When setting result pack & destination,\n" -"automatically post the corresponding line\n" -"if this option is checked.\n" -msgstr "" - -#. module: shopfloor_reception -#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu__auto_post_line_is_possible -msgid "Auto Post Line Is Possible" -msgstr "" - -#. module: shopfloor_reception -#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu__auto_post_line -msgid "Automatically post line" -msgstr "" - -#. module: shopfloor_reception -#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu__display_name -msgid "Display Name" -msgstr "" - -#. module: shopfloor_reception -#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu__id -msgid "ID" -msgstr "" - -#. module: shopfloor_reception -#: model:ir.model.fields,field_description:shopfloor_reception.field_shopfloor_menu____last_update -msgid "Last Modified on" -msgstr "" - -#. module: shopfloor_reception -#: model:ir.model,name:shopfloor_reception.model_shopfloor_menu -msgid "Menu displayed in the scanner application" -msgstr "" - #. module: shopfloor_reception #: model:shopfloor.menu,name:shopfloor_reception.shopfloor_menu_demo_reception #: model:shopfloor.scenario,name:shopfloor_reception.scenario_reception From f8a3da491b90ae41ca5606073fb43ef55028807e Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 22 May 2023 11:10:53 +0000 Subject: [PATCH 30/84] shopfloor_reception 14.0.1.2.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 24ff046565..9f59d2c494 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.1.1.1", + "version": "14.0.1.2.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From 3b6588cd190db8b942ecee297955756a53b8e696 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 29 May 2023 09:11:51 +0000 Subject: [PATCH 31/84] shopfloor_reception 14.0.1.3.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 9f59d2c494..bd5f5efb4d 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.1.2.0", + "version": "14.0.1.3.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From 5ff29d8f18d5621ab30da43e62b8df4085ce23b9 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Wed, 15 Mar 2023 13:52:42 +0100 Subject: [PATCH 32/84] shopfloor: Fix product/lot not found message Rephrasing the error message when a product or a lot has been found for a barcode. But there is no picking that matches it in the current context. --- shopfloor_reception/services/reception.py | 12 ++++++++++++ shopfloor_reception/tests/test_select_document.py | 8 ++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 7a0477a3dd..f662131729 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -186,6 +186,10 @@ def _select_document_from_product(self, product): pickings=pickings, message=self.msg_store.multiple_picks_found_select_manually(), ) + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.product_not_found_in_pickings(), + ) def _select_document_from_packaging(self, packaging): """Select the document by packaging @@ -203,6 +207,10 @@ def _select_document_from_packaging(self, packaging): pickings=pickings, message=self.msg_store.multiple_picks_found_select_manually(), ) + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.product_not_found_in_pickings(), + ) def _select_document_from_lot(self, lot): """Select the document by lot @@ -220,6 +228,10 @@ def _select_document_from_lot(self, lot): pickings=pickings, message=self.msg_store.multiple_picks_found_select_manually(), ) + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.lot_not_found_in_pickings(), + ) def _select_line(self, picking, line, move, increase_qty_done_by=1): product = line.product_id diff --git a/shopfloor_reception/tests/test_select_document.py b/shopfloor_reception/tests/test_select_document.py index 7846b666b5..4683bce94e 100644 --- a/shopfloor_reception/tests/test_select_document.py +++ b/shopfloor_reception/tests/test_select_document.py @@ -134,12 +134,12 @@ def test_scan_product_no_picking(self): response = self.service.dispatch( "scan_document", params={"barcode": self.product_c.barcode} ) - body = "Barcode not found" + message = self.service.msg_store.product_not_found_in_pickings() self.assert_response( response, next_state="select_document", data={"pickings": self._data_for_pickings(picking)}, - message={"message_type": "error", "body": body}, + message=message, ) def test_scan_packaging_no_picking(self): @@ -149,10 +149,10 @@ def test_scan_packaging_no_picking(self): response = self.service.dispatch( "scan_document", params={"barcode": self.product_c_packaging.barcode} ) - body = "Barcode not found" + message = self.service.msg_store.product_not_found_in_pickings() self.assert_response( response, next_state="select_document", data={"pickings": self._data_for_pickings(picking)}, - message={"message_type": "error", "body": body}, + message=message, ) From f24410f280248c3c342116de1410e119e64085c2 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 29 May 2023 21:12:44 +0000 Subject: [PATCH 33/84] shopfloor_reception 14.0.1.4.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index bd5f5efb4d..0ecaf48152 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.1.3.0", + "version": "14.0.1.4.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From 03b68c34c48a84475f6ecceb63f829cbb5bb63a2 Mon Sep 17 00:00:00 2001 From: MmeQuignon Date: Tue, 31 Jan 2023 12:21:25 +0100 Subject: [PATCH 34/84] shopfloor_reception: scan_document - use search.find --- shopfloor_reception/services/reception.py | 413 +++++++++++----------- 1 file changed, 208 insertions(+), 205 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index f662131729..f4d01a9d83 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -233,8 +233,15 @@ def _select_document_from_lot(self, lot): message=self.msg_store.lot_not_found_in_pickings(), ) - def _select_line(self, picking, line, move, increase_qty_done_by=1): - product = line.product_id + def _scan_line__find_or_create_line(self, picking, move, qty_done=1): + line = fields.first( + move.move_line_ids.filtered( + lambda l: ( + not l.result_package_id + and l.shopfloor_user_id.id in [False, self.env.uid] + ) + ) + ) if line: # The line quantity to do needs to correspond to # the remaining quantity to do of its move. @@ -243,9 +250,13 @@ def _select_line(self, picking, line, move, increase_qty_done_by=1): qty_todo_remaining = move.product_uom_qty - move.quantity_done values = move._prepare_move_line_vals(quantity=qty_todo_remaining) line = self.env["stock.move.line"].create(values) + return self._scan_line__assign_user(picking, line, qty_done) + + def _scan_line__assign_user(self, picking, line, qty_done): + product = line.product_id self._assign_user_to_picking(picking) self._assign_user_to_line(line) - line.qty_done += increase_qty_done_by + line.qty_done += qty_done if product.tracking not in ("lot", "serial") or (line.lot_id or line.lot_name): return self._response_for_set_quantity(picking, line) return self._response_for_set_lot(picking, line) @@ -260,27 +271,14 @@ def _select_line_from_product(self, picking, move, product): ) return self._select_line(picking, line, move) - def _select_line_from_packaging(self, picking, move, packaging): - line = fields.first( - picking.move_line_ids.filtered( + def _select_line__filter_lines_by_packaging(self, lines, packaging): + return fields.first( + lines.filtered( lambda l: l.package_id.product_packaging_id == packaging and not l.result_package_id and l.shopfloor_user_id.id in [False, self.env.uid] ) ) - return self._select_line(picking, line, move, packaging.qty) - - def _select_line_from_lot(self, picking, move, lot): - line = fields.first( - picking.move_line_ids.filtered( - lambda l: (l.lot_id.name == lot or l.lot_name == lot) - and not l.result_package_id - and l.shopfloor_user_id.id in [False, self.env.uid] - ) - ) - if not line: - return - return self._select_line(picking, line, move) def _order_stock_picking(self): # We sort by scheduled date first. However, there might be a case @@ -288,9 +286,8 @@ def _order_stock_picking(self): # In that case, we sort by id. return "scheduled_date ASC, id ASC" - def _scan_document__by_picking(self, barcode): - search = self._actions_for("search") - picking_filter_result = search.picking_from_scan(barcode, use_origin=True) + def _scan_document__by_picking(self, pickings, barcode): + picking_filter_result = pickings reception_pickings = picking_filter_result.filtered( lambda p: p.picking_type_id.id in self.picking_types.ids ) @@ -324,24 +321,27 @@ def _scan_document__by_picking(self, barcode): ) return self._select_picking(reception_pickings) - def _scan_document__by_product(self, barcode): - search = self._actions_for("search") - product = search.product_from_scan(barcode) + def _scan_document__by_product(self, product, barcode): if product: return self._select_document_from_product(product) - def _scan_document__by_packaging(self, barcode): - search = self._actions_for("search") - packaging = search.packaging_from_scan(barcode) + def _scan_document__by_packaging(self, packaging, barcode): if packaging: return self._select_document_from_packaging(packaging) def _scan_document__by_lot(self, barcode): return self._select_document_from_lot(barcode) - def _scan_line__by_product(self, picking, barcode): - search = self._actions_for("search") - product = search.product_from_scan(barcode) + def _scan_document__fallback(self, empty_recordset): + assert not empty_recordset, "recordset in fallback handler should be empty" + return self._response_for_select_document( + message=self.msg_store.barcode_not_found() + ) + + def _scan_line__by_product(self, picking, product): + message = self._check_picking_status(picking) + if message: + return self._response_for_select_move(picking, message=message) if product: move = fields.first( picking.move_lines.filtered( @@ -362,9 +362,10 @@ def _scan_line__by_product(self, picking, barcode): ) return self._select_line_from_product(picking, move, product) - def _scan_line__by_packaging(self, picking, barcode): - search = self._actions_for("search") - packaging = search.packaging_from_scan(barcode) + def _scan_line__by_packaging(self, picking, packaging): + message = self._check_picking_status(picking) + if message: + return self._response_for_select_move(picking, message=message) if packaging: move = fields.first( picking.move_lines.filtered( @@ -385,30 +386,47 @@ def _scan_line__by_packaging(self, picking, barcode): ) return self._select_line_from_packaging(picking, move, packaging) - def _scan_line__by_lot(self, picking, barcode): - line = picking.move_line_ids.filtered( - lambda l: barcode == l.lot_id.name or barcode == l.lot_name + def _scan_line__by_lot(self, picking, lot): + lines = picking.move_line_ids.filtered( + lambda l: ( + lot == l.lot_id + or (lot.name == l.lot_name and lot.product_id == l.product_id) + and not l.result_package_id + ) ) - move = line.move_id - search = self._actions_for("search") - lot = search.lot_from_scan(barcode) - if not move: - line = picking.move_line_ids.filtered( - lambda l: not l.lot_id - and not l.lot_name - and l.product_id == lot.product_id + if not lines: + return self._scan_line__by_product(picking, lot.product_id) + # TODO probably suboptimal + # We might have an available line, but it might be the last one. + # Loop over the recordset and break as soon as we find one. + for line in lines: + message = self._check_move_available(line.move_id, message_code="lot") + if not message: + break + if message: + return self._response_for_select_move( + picking, + message=message, ) - if line: - return self._select_line(picking, line, move) - else: - message = self._check_move_available(move, message_code="lot") - if message: - return self._response_for_select_move( - picking, - message=message, - ) - if lot: - return self._select_line_from_lot(picking, move, barcode) + return self._scan_line__assign_user(picking, line, 1) + + def _scan_line__fallback(self, picking, barcode): + # We might have lines with no lot, but with a lot_name. + lines = picking.move_line_ids.filtered( + lambda l: l.lot_name == barcode and not l.result_package_id + ) + if not lines: + return self._response_for_select_move( + picking, message=self.msg_store.barcode_not_found() + ) + for line in lines: + message = self._check_move_available(line.move_id, message_code="lot") + if not message: + return self._scan_line__assign_user(picking, line, 1) + return self._response_for_select_move( + picking, + message=message, + ) def _check_move_available(self, move, message_code="product"): if not move: @@ -420,96 +438,84 @@ def _check_move_available(self, move, message_code="product"): if move.product_uom_qty - move.quantity_done < 1 and not line_without_package: return self.msg_store.move_already_done() - def _set_quantity__by_product(self, picking, selected_line, barcode): - search = self._actions_for("search") - product = search.product_from_scan(barcode) - if product: - if product.id != selected_line.product_id.id: + def _set_quantity__by_product(self, picking, selected_line, product): + if product.id != selected_line.product_id.id: + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.wrong_record(product), + ) + selected_line.qty_done += 1 + return self._response_for_set_quantity(picking, selected_line) + + def _set_quantity__by_packaging(self, picking, selected_line, packaging): + if packaging.product_id.id != selected_line.product_id.id: + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.wrong_record(packaging), + ) + selected_line.qty_done += packaging.qty + return self._response_for_set_quantity(picking, selected_line) + + def _set_quantity__by_package(self, picking, selected_line, package): + pack_location = package.location_id + if pack_location: + ( + move_dest_location_ok, + pick_type_dest_location_ok, + ) = self._check_location_ok(pack_location, selected_line, picking) + if not (move_dest_location_ok or pick_type_dest_location_ok): + # If the scanned package has a location that isn't a child + # of the move dest, return an error + message = self.msg_store.dest_location_not_allowed() return self._response_for_set_quantity( - picking, - selected_line, - message=self.msg_store.wrong_record(product), + picking, selected_line, message=message ) - selected_line.qty_done += 1 - return self._response_for_set_quantity(picking, selected_line) - - def _set_quantity__by_packaging(self, picking, selected_line, barcode): - search = self._actions_for("search") - packaging = search.packaging_from_scan(barcode) - if packaging: - if packaging.product_id.id != selected_line.product_id.id: + quantity = selected_line.qty_done + __, qty_check = selected_line._split_qty_to_be_done( + quantity, + lot_id=False, + shopfloor_user_id=False, + expiration_date=False, + ) + if qty_check == "greater": return self._response_for_set_quantity( picking, selected_line, - message=self.msg_store.wrong_record(packaging), - ) - selected_line.qty_done += packaging.qty - return self._response_for_set_quantity(picking, selected_line) - - def _set_quantity__by_package(self, picking, selected_line, barcode): - search = self._actions_for("search") - package = search.package_from_scan(barcode) - if package: - pack_location = package.location_id - if pack_location: - ( - move_dest_location_ok, - pick_type_dest_location_ok, - ) = self._check_location_ok(pack_location, selected_line, picking) - if not (move_dest_location_ok or pick_type_dest_location_ok): - # If the scanned package has a location that isn't a child - # of the move dest, return an error - message = self.msg_store.dest_location_not_allowed() - return self._response_for_set_quantity( - picking, selected_line, message=message - ) - quantity = selected_line.qty_done - __, qty_check = selected_line._split_qty_to_be_done( - quantity, - lot_id=False, - shopfloor_user_id=False, - expiration_date=False, + message=self.msg_store.unable_to_pick_more( + selected_line.product_uom_qty + ), ) - if qty_check == "greater": - return self._response_for_set_quantity( - picking, - selected_line, - message=self.msg_store.unable_to_pick_more( - selected_line.product_uom_qty - ), - ) - # If the scanned package has a valid destination, - # set both package and destination on the package, - # and go back to the selection line screen - selected_line.result_package_id = package - selected_line.location_dest_id = pack_location - if self.work.menu.auto_post_line: - # If option auto_post_line is active in the shopfloor menu, - # create a split order with this line. - self._auto_post_line(selected_line) - return self._response_for_select_move(picking) - # Scanned package has no location, move to the location selection - # screen + # If the scanned package has a valid destination, + # set both package and destination on the package, + # and go back to the selection line screen selected_line.result_package_id = package - return self._response_for_set_destination(picking, selected_line) + selected_line.location_dest_id = pack_location + if self.work.menu.auto_post_line: + # If option auto_post_line is active in the shopfloor menu, + # create a split order with this line. + self._auto_post_line(selected_line, picking) + return self._response_for_select_move(picking) + # Scanned package has no location, move to the location selection + # screen + selected_line.result_package_id = package + return self._response_for_set_destination(picking, selected_line) - def _set_quantity__by_location(self, picking, selected_line, barcode): - search = self._actions_for("search") - location = search.location_from_scan(barcode) - if location: - move_dest_location_ok, pick_type_dest_location_ok = self._check_location_ok( - location, selected_line, picking + def _set_quantity__by_location(self, picking, selected_line, location): + move_dest_location_ok, pick_type_dest_location_ok = self._check_location_ok( + location, selected_line, picking + ) + if not (move_dest_location_ok or pick_type_dest_location_ok): + # Scanned location isn't a child of the move's dest location + message = self.msg_store.dest_location_not_allowed() + return self._response_for_set_quantity( + picking, selected_line, message=message ) - if not (move_dest_location_ok or pick_type_dest_location_ok): - # Scanned location isn't a child of the move's dest location - message = self.msg_store.dest_location_not_allowed() - return self._response_for_set_quantity( - picking, selected_line, message=message - ) - # process without pack, set destination location, and go back to - # `select_move` - selected_line.location_dest_id = location - return self._response_for_select_move(picking) + # process without pack, set destination location, and go back to + # `select_move` + selected_line.location_dest_id = location + return self._response_for_select_move(picking) def _set_quantity__by_lot(self, picking, selected_line, barcode): if selected_line.lot_id.name == barcode or selected_line.lot_name == barcode: @@ -655,6 +661,17 @@ def _response_for_select_dest_package(self, picking, line, message=None): def start(self): return self._response_for_select_document() + def _scan_document__get_handlers_by_type(self): + return { + "picking": self._scan_document__by_picking, + "product": self._scan_document__by_product, + "packaging": self._scan_document__by_packaging, + "lot": self._scan_document__by_lot, + } + + def _scan_document__get_find_kw(self): + return {"picking": {"use_origin": True}} + def scan_document(self, barcode): """Scan a picking, a product or a packaging. @@ -671,19 +688,16 @@ def scan_document(self, barcode): - set_quantity: Packaging / Product has been scanned, single correspondance. Not tracked product """ - handlers = ( - self._scan_document__by_picking, - self._scan_document__by_product, - self._scan_document__by_packaging, - self._scan_document__by_lot, - ) - response = self._use_handlers(handlers, barcode) - if response: - return response - # If nothing has been found, return a barcode not found error message - return self._response_for_select_document( - message=self.msg_store.barcode_not_found() + handlers_by_type = self._scan_document__get_handlers_by_type() + search = self._actions_for("search") + find_kw = self._scan_document__get_find_kw() + search_result = search.find( + barcode, handlers_by_type.keys(), handler_kw=find_kw ) + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(search_result.record, barcode) + return self._scan_document__fallback() def list_stock_pickings(self): """Select a picking manually @@ -718,21 +732,18 @@ def scan_line(self, picking_id, barcode): - set_quantity: Packaging / Product has been scanned. Not tracked product """ picking = self.env["stock.picking"].browse(picking_id) - message = self._check_picking_status(picking) - if message: - return self._response_for_select_move(picking, message=message) - handlers = ( - self._scan_line__by_product, - self._scan_line__by_packaging, - self._scan_line__by_lot, - ) - response = self._use_handlers(handlers, picking, barcode) - if response: - return response - # Nothing has been found, return an error - return self._response_for_select_move( - picking, message=self.msg_store.barcode_not_found() - ) + handlers_by_type = { + "product": self._scan_line__by_product, + "packaging": self._scan_line__by_packaging, + "lot": self._scan_line__by_lot, + } + search = self._actions_for("search") + search_result = search.find(barcode, handlers_by_type.keys()) + # Fallback handler, returns a barcode not found error + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(picking, search_result.record) + return self._scan_line__fallback(picking, barcode) def done_action(self, picking_id, confirmation=False): """Mark a picking as done @@ -846,6 +857,37 @@ def _check_expiry_date(self, line): if use_expiration_date and not line.expiration_date: return self.msg_store.expiration_date_missing() + def _set_quantity__get_handlers_by_type(self): + return { + "product": self._set_quantity__by_product, + "packaging": self._set_quantity__by_packaging, + "package": self._set_quantity__by_package, + "location": self._set_quantity__by_location, + "lot": self._set_quantity__by_lot, + } + + def _set_quantity__by_barcode( + self, picking, selected_line, barcode, confirmation=False + ): + handlers_by_type = self._set_quantity__get_handlers_by_type() + search = self._actions_for("search") + search_result = search.find(barcode, handlers_by_type.keys()) + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(picking, selected_line, search_result.record) + # Nothing found, ask user if we should create a new pack for the scanned + # barcode + if not confirmation: + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.create_new_pack_ask_confirmation(barcode), + asking_confirmation=True, + ) + package = self.env["stock.quant.package"].create({"name": barcode}) + selected_line.result_package_id = package + return self._response_for_set_destination(picking, selected_line) + def set_quantity( self, picking_id, @@ -888,48 +930,9 @@ def set_quantity( if barcode: # Then, we add the qty of whatever was scanned # on top of the qty of the picker. - handlers = ( - self._set_quantity__by_product, - self._set_quantity__by_packaging, - self._set_quantity__by_package, - self._set_quantity__by_location, - self._set_quantity__by_lot, + return self._set_quantity__by_barcode( + picking, selected_line, barcode, confirmation ) - response = self._use_handlers(handlers, picking, selected_line, barcode) - if response: - return response - # Nothing found, ask user if we should create a new pack for the scanned - # barcode. - if not confirmation: - return self._response_for_set_quantity( - picking, - selected_line, - message=self.msg_store.create_new_pack_ask_confirmation(barcode), - asking_confirmation=True, - ) - # Nothing found and we already ask for confirmation, create the new package. - package = self.env["stock.quant.package"].create({"name": barcode}) - quantity = selected_line.qty_done - __, qty_check = selected_line._split_qty_to_be_done( - quantity, - lot_id=False, - shopfloor_user_id=False, - expiration_date=False, - ) - if qty_check == "greater": - return self._response_for_set_quantity( - picking, - selected_line, - message=self.msg_store.unable_to_pick_more( - selected_line.product_uom_qty - ), - ) - selected_line.result_package_id = package - if self.work.menu.auto_post_line: - # If option auto_post_line is active in the shopfloor menu, - # create a split order with this line. - self._auto_post_line(selected_line) - return self._response_for_set_destination(picking, selected_line) return self._response_for_set_quantity( picking, selected_line, message=self.msg_store.barcode_not_found() ) From 99733e164bca671a3b21356b37dc041a830da03d Mon Sep 17 00:00:00 2001 From: MmeQuignon Date: Wed, 8 Feb 2023 10:39:15 +0100 Subject: [PATCH 35/84] shopfloor_reception: Enable allow_rma --- shopfloor_reception/__init__.py | 1 + .../data/shopfloor_scenario_data.xml | 3 +- shopfloor_reception/models/__init__.py | 1 + shopfloor_reception/models/stock_picking.py | 10 + shopfloor_reception/services/reception.py | 283 ++++++++++++++---- shopfloor_reception/tests/__init__.py | 4 + shopfloor_reception/tests/common.py | 5 - .../tests/reception_return_common.py | 136 +++++++++ .../tests/test_reception_done.py | 2 +- .../tests/test_return_reception_done.py | 132 ++++++++ .../tests/test_return_scan_document.py | 95 ++++++ .../tests/test_return_scan_line.py | 102 +++++++ .../tests/test_return_set_quantity.py | 115 +++++++ 13 files changed, 826 insertions(+), 63 deletions(-) create mode 100644 shopfloor_reception/models/__init__.py create mode 100644 shopfloor_reception/models/stock_picking.py create mode 100644 shopfloor_reception/tests/reception_return_common.py create mode 100644 shopfloor_reception/tests/test_return_reception_done.py create mode 100644 shopfloor_reception/tests/test_return_scan_document.py create mode 100644 shopfloor_reception/tests/test_return_scan_line.py create mode 100644 shopfloor_reception/tests/test_return_set_quantity.py diff --git a/shopfloor_reception/__init__.py b/shopfloor_reception/__init__.py index 99464a7510..5b1c56416a 100644 --- a/shopfloor_reception/__init__.py +++ b/shopfloor_reception/__init__.py @@ -1 +1,2 @@ from . import services +from . import models diff --git a/shopfloor_reception/data/shopfloor_scenario_data.xml b/shopfloor_reception/data/shopfloor_scenario_data.xml index ae8db3c78b..d9ef7cf264 100644 --- a/shopfloor_reception/data/shopfloor_scenario_data.xml +++ b/shopfloor_reception/data/shopfloor_scenario_data.xml @@ -8,7 +8,8 @@ reception { - "auto_post_line": true + "auto_post_line": true, + "allow_return": true } diff --git a/shopfloor_reception/models/__init__.py b/shopfloor_reception/models/__init__.py new file mode 100644 index 0000000000..ae4c27227f --- /dev/null +++ b/shopfloor_reception/models/__init__.py @@ -0,0 +1 @@ +from . import stock_picking diff --git a/shopfloor_reception/models/stock_picking.py b/shopfloor_reception/models/stock_picking.py new file mode 100644 index 0000000000..ecbf29c0cd --- /dev/null +++ b/shopfloor_reception/models/stock_picking.py @@ -0,0 +1,10 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + is_shopfloor_return = fields.Boolean() diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index f4d01a9d83..895cce45e0 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -48,6 +48,14 @@ class Reception(Component): _usage = "reception" _description = __doc__ + def _check_picking_status(self, pickings): + # When returns are allowed, + # the created picking might be empty and cannot be assigned. + states = ["assigned"] + if self.work.menu.allow_return: + states.append("draft") + return super()._check_picking_status(pickings, states=states) + def _move_line_by_product(self, product): return self.env["stock.move.line"].search( self._domain_move_line_by_product(product) @@ -170,6 +178,12 @@ def _select_document_from_move_lines(self, move_lines, msg_func): # return an error return self._response_for_select_document(message=msg_func()) + def _scan_document__create_return(self, picking, return_type, barcode): + stock = self._actions_for("stock") + return_picking = stock.create_return_picking(picking, return_type, barcode) + return_picking.action_confirm() + return return_picking + def _select_document_from_product(self, product): """Select the document by product @@ -261,17 +275,24 @@ def _scan_line__assign_user(self, picking, line, qty_done): return self._response_for_set_quantity(picking, line) return self._response_for_set_lot(picking, line) - def _select_line_from_product(self, picking, move, product): - line = fields.first( - picking.move_line_ids.filtered( - lambda l: l.product_id == product + def _select_line__filter_lines_by_packaging__return(self, lines, packaging): + return_line = fields.first( + lines.filtered( + lambda l: not l.package_id.product_packaging_id and not l.result_package_id - and l.shopfloor_user_id.id in [False, self.env.uid] + and l.shopfloor_user_id.id in (False, self.env.uid) ) ) - return self._select_line(picking, line, move) + if return_line: + return return_line def _select_line__filter_lines_by_packaging(self, lines, packaging): + if self.work.menu.allow_return: + line = self._select_line__filter_lines_by_packaging__return( + lines, packaging + ) + if line: + return line return fields.first( lines.filtered( lambda l: l.package_id.product_packaging_id == packaging @@ -329,62 +350,129 @@ def _scan_document__by_packaging(self, packaging, barcode): if packaging: return self._select_document_from_packaging(packaging) - def _scan_document__by_lot(self, barcode): - return self._select_document_from_lot(barcode) + def _scan_document__by_lot(self, lot, barcode): + return self._select_document_from_lot(lot) - def _scan_document__fallback(self, empty_recordset): - assert not empty_recordset, "recordset in fallback handler should be empty" + def _scan_document__by_origin_move(self, moves, barcode): + if not self.work.menu.allow_return: + # A return picking has been scanned, but allow rma is disabled. + return self._scan_document__fallback() + pickings = moves.picking_id + outgoing_pickings = pickings.filtered( + lambda p: (p.picking_type_code == "outgoing") + ) + # If we find valid pickings for a return, then we create an empty + # return picking + if outgoing_pickings: + # But first, check that return types are correctly set up, + # as we cannot create a return move with empty locations. + return_types = self.picking_types.filtered( + lambda t: t.default_location_src_id and t.default_location_dest_id + ) + if not return_types: + message = self.msg_store.no_default_location_on_picking_type() + return self._response_for_select_document(message=message) + return_picking = self._scan_document__create_return( + fields.first(outgoing_pickings), fields.first(return_types), barcode + ) + return self._response_for_select_move(return_picking) + + def _scan_document__fallback(self): return self._response_for_select_document( message=self.msg_store.barcode_not_found() ) + def _scan_line__create_return_move(self, return_picking, origin_moves): + # copied from odoo/src/addons/stock/wizard/stock_picking_return.py + stock = self._actions_for("stock") + return stock.create_return_move(return_picking, origin_moves) + + def _scan_line__by_product__return(self, picking, product): + search = self._actions_for("search") + origin_move_domain = [ + ("picking_id.picking_type_code", "=", "outgoing"), + ] + origin_moves = search.origin_move_from_scan( + picking.origin, extra_domain=origin_move_domain + ) + origin_moves_for_product = origin_moves.filtered( + lambda m: m.product_id == product + ) + # If we have an origin picking but no origin move, then user + # scanned a wrong product. Warn him about this. + if origin_moves and not origin_moves_for_product: + message = self.msg_store.product_not_found_in_current_picking() + return self._response_for_select_move(picking, message=message) + if origin_moves_for_product: + return_move = self._scan_line__create_return_move( + picking, origin_moves_for_product + ) + if not return_move: + # It means that among all origin moves, none has been found with + # max qty to return being positive. + # Which means all lines have already been returned. + message = self.msg_store.move_already_returned() + return self._response_for_select_move(picking, message=message) + picking.action_confirm() + picking.action_assign() + return self._scan_line__find_or_create_line(picking, return_move) + def _scan_line__by_product(self, picking, product): - message = self._check_picking_status(picking) + move = picking.move_lines.filtered(lambda m: m.product_id == product) + # Only create a return if don't already have a maching reception move + if not move and self.work.menu.allow_return: + response = self._scan_line__by_product__return(picking, product) + if response: + return response + # Otherwise, the picking isn't a return, and should be a regular reception + message = self._check_move_available(move, "product") if message: + return self._response_for_select_move( + picking, + message=message, + ) + return self._scan_line__find_or_create_line(picking, move) + + def _scan_line__by_packaging__return(self, picking, packaging): + search = self._actions_for("search") + origin_move_domain = [ + ("picking_id.picking_type_code", "=", "outgoing"), + ] + origin_moves = search.origin_move_from_scan( + picking.origin, extra_domain=origin_move_domain + ) + origin_moves_for_packaging = origin_moves.filtered( + lambda m: packaging in m.product_id.packaging_ids + ) + if origin_moves and not origin_moves_for_packaging: + message = self.msg_store.packaging_not_found_in_picking() return self._response_for_select_move(picking, message=message) - if product: - move = fields.first( - picking.move_lines.filtered( - lambda m: m.product_id == product - and float_compare( - m.quantity_done, - m.product_uom_qty, - precision_rounding=m.product_uom.rounding, - ) - == -1 - ) + # If we have an origin move, create the return move, and go to next screen + if origin_moves_for_packaging: + return_move = self._scan_line__create_return_move( + picking, origin_moves_for_packaging + ) + return_move._action_confirm() + return self._scan_line__find_or_create_line( + picking, return_move, packaging.qty ) - message = self._check_move_available(move, message_code="product") - if message: - return self._response_for_select_move( - picking, - message=message, - ) - return self._select_line_from_product(picking, move, product) def _scan_line__by_packaging(self, picking, packaging): - message = self._check_picking_status(picking) + move = picking.move_lines.filtered( + lambda m: packaging in m.product_id.packaging_ids + ) + # Only create a return if don't already have a maching reception move + if not move and self.work.menu.allow_return: + response = self._scan_line__by_packaging__return(picking, packaging) + if response: + return response + message = self._check_move_available(move, "packaging") if message: - return self._response_for_select_move(picking, message=message) - if packaging: - move = fields.first( - picking.move_lines.filtered( - lambda m: packaging in m.product_id.packaging_ids - and float_compare( - m.quantity_done, - m.product_uom_qty, - precision_rounding=m.product_uom.rounding, - ) - == -1 - ) + return self._response_for_select_move( + picking, + message=message, ) - message = self._check_move_available(move, message_code="packaging") - if message: - return self._response_for_select_move( - picking, - message=message, - ) - return self._select_line_from_packaging(picking, move, packaging) + return self._scan_line__find_or_create_line(picking, move) def _scan_line__by_lot(self, picking, lot): lines = picking.move_line_ids.filtered( @@ -439,6 +527,14 @@ def _check_move_available(self, move, message_code="product"): return self.msg_store.move_already_done() def _set_quantity__by_product(self, picking, selected_line, product): + # This is a general rule here. whether the return has been created from + # shopfloor or not, you cannot return more than what was shipped. + # Therefore, we cannot use the `is_shopfloor_return` here. + previous_vals = { + "qty_done": selected_line.qty_done, + } + is_return_line = bool(selected_line.move_id.origin_returned_move_id) + max_qty_done = selected_line.move_id.product_uom_qty if product.id != selected_line.product_id.id: return self._response_for_set_quantity( picking, @@ -446,9 +542,35 @@ def _set_quantity__by_product(self, picking, selected_line, product): message=self.msg_store.wrong_record(product), ) selected_line.qty_done += 1 - return self._response_for_set_quantity(picking, selected_line) + response = self._response_for_set_quantity(picking, selected_line) + if self.work.menu.allow_return and is_return_line: + message_type = response.get("message", {}).get("message_type") + # If we have an error, return it, since this is also true for return lines + if message_type == "error": + return response + rounding = selected_line.product_uom_id.rounding + compare = float_compare( + selected_line.qty_done, max_qty_done, precision_rounding=rounding + ) + # We cannot set a qty_done superior to what has initally been sent + if compare == 1: + # If so, reset selected_line to its previous state, and return an error + selected_line.write(previous_vals) + message = self.msg_store.return_line_invalid_qty() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + return response def _set_quantity__by_packaging(self, picking, selected_line, packaging): + # This is a general rule here. whether the return has been created from + # shopfloor or not, you cannot return more than what was shipped. + # Therefore, we cannot use the `is_shopfloor_return` here. + previous_vals = { + "qty_done": selected_line.qty_done, + } + is_return_line = bool(selected_line.move_id.origin_returned_move_id) + max_qty_done = selected_line.move_id.product_uom_qty if packaging.product_id.id != selected_line.product_id.id: return self._response_for_set_quantity( picking, @@ -456,7 +578,26 @@ def _set_quantity__by_packaging(self, picking, selected_line, packaging): message=self.msg_store.wrong_record(packaging), ) selected_line.qty_done += packaging.qty - return self._response_for_set_quantity(picking, selected_line) + response = self._response_for_set_quantity(picking, selected_line) + if self.work.menu.allow_return and is_return_line: + message_type = response.get("message", {}).get("message_type") + # If we have an error, return it, since this is also true for return lines + if message_type == "error": + return response + # We cannot set a qty_done superior to what has initally been sent + rounding = selected_line.product_uom_id.rounding + compare = float_compare( + selected_line.qty_done, max_qty_done, precision_rounding=rounding + ) + # We cannot set a qty_done superior to what has initally been sent + if compare == 1: + # If so, reset selected_line to its previous state, and return an error + selected_line.write(previous_vals) + message = self.msg_store.return_line_invalid_qty() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + return response def _set_quantity__by_package(self, picking, selected_line, package): pack_location = package.location_id @@ -667,14 +808,20 @@ def _scan_document__get_handlers_by_type(self): "product": self._scan_document__by_product, "packaging": self._scan_document__by_packaging, "lot": self._scan_document__by_lot, + "origin_move": self._scan_document__by_origin_move, } def _scan_document__get_find_kw(self): - return {"picking": {"use_origin": True}} + return { + "picking": {"use_origin": True}, + "delivered_picking": {"use_origin": True}, + } def scan_document(self, barcode): """Scan a picking, a product or a packaging. + If an outgoing done move's origin is scanned, a return picking will be created. + Input: barcode: the barcode of a product, a packaging, a picking name or a lot @@ -732,6 +879,9 @@ def scan_line(self, picking_id, barcode): - set_quantity: Packaging / Product has been scanned. Not tracked product """ picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_move(picking, message=message) handlers_by_type = { "product": self._scan_line__by_product, "packaging": self._scan_line__by_packaging, @@ -767,6 +917,12 @@ def done_action(self, picking_id, confirmation=False): picking, message=self.msg_store.transfer_no_qty_done() ) if not confirmation: + # Do not create a backorder if this is a shopfloor return. + if picking.is_shopfloor_return and self.work.menu.allow_return: + picking.with_context(cancel_backorder=True)._action_done() + return self._response_for_select_document( + message=self.msg_store.transfer_done_success(picking) + ) to_backorder = picking._check_backorder() if to_backorder: # Not all lines are fully done, ask the user to confirm the @@ -888,6 +1044,19 @@ def _set_quantity__by_barcode( selected_line.result_package_id = package return self._response_for_set_destination(picking, selected_line) + def _set_quantity__assign_quantity(self, picking, selected_line, quantity): + # If this is a return line, we cannot assign more qty_done than what + # was originally sent. + is_return_line = bool(selected_line.move_id.origin_returned_move_id) + max_qty_done = selected_line.move_id.product_uom_qty + if is_return_line and self.work.menu.allow_return: + if quantity > max_qty_done: + message = self.msg_store.return_line_invalid_qty() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + selected_line.qty_done = quantity + def set_quantity( self, picking_id, @@ -926,16 +1095,18 @@ def set_quantity( if quantity: # We set qty_done to be equal to the qty of the picker # at the moment of the scan. - selected_line.qty_done = quantity + response = self._set_quantity__assign_quantity( + picking, selected_line, quantity + ) + if response: + return response if barcode: # Then, we add the qty of whatever was scanned # on top of the qty of the picker. return self._set_quantity__by_barcode( picking, selected_line, barcode, confirmation ) - return self._response_for_set_quantity( - picking, selected_line, message=self.msg_store.barcode_not_found() - ) + return self._response_for_set_quantity(picking, selected_line) def process_with_existing_pack(self, picking_id, selected_line_id, quantity): picking = self.env["stock.picking"].browse(picking_id) diff --git a/shopfloor_reception/tests/__init__.py b/shopfloor_reception/tests/__init__.py index fc68acdf29..a5861ce292 100644 --- a/shopfloor_reception/tests/__init__.py +++ b/shopfloor_reception/tests/__init__.py @@ -9,3 +9,7 @@ from . import test_set_quantity_action from . import test_set_destination from . import test_select_dest_package +from . import test_return_scan_document +from . import test_return_scan_line +from . import test_return_set_quantity +from . import test_return_reception_done diff --git a/shopfloor_reception/tests/common.py b/shopfloor_reception/tests/common.py index b447e82f48..7718d56521 100644 --- a/shopfloor_reception/tests/common.py +++ b/shopfloor_reception/tests/common.py @@ -50,11 +50,6 @@ def setUpClassVars(cls, *args, **kwargs): cls.picking_type = cls.menu.picking_type_ids cls.wh = cls.picking_type.warehouse_id - @classmethod - def setUpClassBaseData(cls, *args, **kwargs): - super().setUpClassBaseData(*args, **kwargs) - cls.wh.sudo().reception_steps = "two_steps" - def _data_for_move_lines(self, lines, **kw): return self.data.move_lines(lines, **kw) diff --git a/shopfloor_reception/tests/reception_return_common.py b/shopfloor_reception/tests/reception_return_common.py new file mode 100644 index 0000000000..b765341efd --- /dev/null +++ b/shopfloor_reception/tests/reception_return_common.py @@ -0,0 +1,136 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.tests import Form + +from .common import CommonCase + + +class CommonCaseReturn(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # In order to have the `picking_type_reception_demo` picking_type + # on returned pickings and moves + cls.reception_type = cls.env.ref( + "shopfloor_reception.picking_type_reception_demo" + ) + ship_type = cls.env.ref("stock.picking_type_out") + ship_type.sudo().return_picking_type_id = cls.reception_type + cls.location_src = cls.env.ref("stock.stock_location_stock") + cls.location_dest = cls.env.ref("stock.stock_location_company") + cls.location = cls.location_src + cls.product = cls.product_a + cls.order = cls.create_sale_order() + cls._add_stock_to_product(cls.product, cls.location, 20.0) + packaging_ids = [ + cls.product_a_packaging.id, + cls.product_c_packaging.id, + cls.product_b_packaging.id, + cls.product_d_packaging.id, + ] + packagings = cls.env["product.packaging"].browse(packaging_ids) + packagings.write({"qty": 10.0}) + + @classmethod + def _shopfloor_user_values(cls): + vals = super()._shopfloor_user_values() + group_ids = vals.get("groups_id") + if group_ids: + ids = group_ids[0][2] + else: + ids = [] + ids.append(cls.env.ref("sales_team.group_sale_salesman").id) + vals["groups_id"] = [(6, 0, ids)] + return vals + + @classmethod + def create_sale_order(cls): + form = Form(cls.env["sale.order"]) + form.partner_id = cls.customer + lines = [(cls.product, 20)] + for product, qty in lines: + with form.order_line.new() as line: + line.product_id = product + line.product_uom_qty = qty + return form.save() + + @classmethod + def create_delivery(cls): + cls.order.action_confirm() + cls.cache_existing_record_ids() + return cls.order.picking_ids + + @classmethod + def _add_package_to_order(cls, order): + packaging_ids = [ + cls.product_a_packaging.id, + cls.product_c_packaging.id, + cls.product_b_packaging.id, + cls.product_d_packaging.id, + ] + packagings = cls.env["product.packaging"].browse(packaging_ids) + for line in order.order_line: + product = line.product_id + packaging = packagings.filtered(lambda p: p.product_id == product) + line.product_packaging = packaging + + @classmethod + def deliver(cls, pickings): + while "there's a ready picking": + ready_picking = pickings.filtered( + lambda p: p.state in ("ready", "confirmed", "assigned") + ) + if not ready_picking: + break + for line in ready_picking.move_line_ids: + line.qty_done = line.product_qty + ready_picking._action_done() + + def assert_return_of(self, picking_in, origin): + self.assertEqual(origin, picking_in.origin) + self.assertEqual( + picking_in.location_dest_id, self.reception_type.default_location_dest_id + ) + self.assertEqual( + picking_in.location_id, self.reception_type.default_location_src_id + ) + + @classmethod + def cache_existing_record_ids(cls): + # store ids of pickings, moves and move lines already created before + # tests are run. + cls.existing_picking_ids = cls.env["stock.picking"].search([]).ids + cls.existing_move_ids = cls.env["stock.move"].search([]).ids + cls.existing_move_line_ids = cls.env["stock.move.line"].search([]).ids + + @classmethod + def get_new_pickings(cls): + res = cls.env["stock.picking"].search( + [("id", "not in", cls.existing_picking_ids)] + ) + cls.cache_existing_record_ids() + return res + + @classmethod + def get_new_move_lines(cls): + res = cls.env["stock.move.line"].search( + [("id", "not in", cls.existing_move_line_ids)] + ) + cls.cache_existing_record_ids() + return res + + @classmethod + def _add_stock_to_product(cls, product, location, qty): + """Set the stock quantity of the product.""" + values = { + "product_id": product.id, + "location_id": location.id, + "inventory_quantity": qty, + } + cls.env["stock.quant"].sudo().with_context(inventory_mode=True).create(values) + cls.cache_existing_record_ids() + + @classmethod + def _enable_allow_return(cls): + cls.menu.sudo().allow_return = True diff --git a/shopfloor_reception/tests/test_reception_done.py b/shopfloor_reception/tests/test_reception_done.py index 0be6548e44..e02b80c28b 100644 --- a/shopfloor_reception/tests/test_reception_done.py +++ b/shopfloor_reception/tests/test_reception_done.py @@ -8,7 +8,7 @@ from .common import CommonCase -class TestSelectDestPackage(CommonCase): +class TestReceptionDone(CommonCase): def test_set_done_no_backorder(self): picking = self._create_picking() picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_done": True}) diff --git a/shopfloor_reception/tests/test_return_reception_done.py b/shopfloor_reception/tests/test_return_reception_done.py new file mode 100644 index 0000000000..895a7ca7f7 --- /dev/null +++ b/shopfloor_reception/tests/test_return_reception_done.py @@ -0,0 +1,132 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .reception_return_common import CommonCaseReturn + + +class TestReturn(CommonCaseReturn): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._enable_allow_return() + delivery = cls.create_delivery() + cls.deliver(delivery) + + def setUp(self): + super().setUp() + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + self.return_picking = self.get_new_pickings() + product = self.product + self.service.dispatch( + "scan_line", + params={"picking_id": self.return_picking.id, "barcode": product.barcode}, + ) + self.selected_move_line = self.get_new_move_lines() + + def _set_quantity_done(self, qty_done=20): + params = { + "picking_id": self.return_picking.id, + "selected_line_id": self.selected_move_line.id, + "quantity": qty_done, + } + self.service.dispatch("set_quantity", params=params) + + def _dispatch(self, confirmation=False): + params = {"picking_id": self.return_picking.id, "confirmation": confirmation} + response = self.service.dispatch("done_action", params=params) + # Here, since we receive goods in input location, which triggers creation of + # an internal shipping, sending goods from input to stock. + # This isn't related to the reception scenario, we do not want + # those internal shippings to be retrieved with `get_new_pickings`. + self.cache_existing_record_ids() + return response + + def test_set_done_full_qty_done(self): + self._set_quantity_done() + response = self._dispatch() + self.assertEqual(self.return_picking.state, "done") + # No more picking to process. Success message + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "success", + "body": f"Transfer {self.return_picking.name} done", + }, + ) + + def test_set_done_partial_qty_done(self): + self.assertEqual(self.selected_move_line.product_uom_qty, 20.0) + self._set_quantity_done(qty_done=10.0) + response = self._dispatch() + # As this is a return created by the app, no backorder is created, + # even if qty_done doesn't match que product_uom_qty + self.assertEqual(self.return_picking.state, "done") + self.assertEqual(self.selected_move_line.qty_done, 10.0) + self.assertFalse(bool(self.return_picking.backorder_ids)) + # Success message + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "success", + "body": f"Transfer {self.return_picking.name} done", + }, + ) + # Now, since we still have returned 10 units out of ten, try to return + # the next ones + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking_2 = self.get_new_pickings() + self.service.dispatch( + "scan_line", + params={"picking_id": return_picking_2.id, "barcode": self.product.barcode}, + ) + selected_line_2 = self.get_new_move_lines() + # Ensure that the max qty to return is 10.0 + self.assertEqual(selected_line_2.product_uom_qty, 10.0) + # Set qty done == 10.0 + params = { + "picking_id": return_picking_2.id, + "selected_line_id": selected_line_2.id, + "quantity": 10.0, + } + self.service.dispatch("set_quantity", params=params) + # Set to done + params = {"picking_id": return_picking_2.id} + response = self.service.dispatch("done_action", params=params) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "success", + "body": f"Transfer {return_picking_2.name} done", + }, + ) + + def test_already_returned(self): + # Set return move as completely done (20/20) + self._set_quantity_done() + # Confirm return picking + response = self._dispatch() + # Select the same document again + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + second_return_picking = self.get_new_pickings() + # Now, try to select a product that has already been completely returned + product = self.product + response = self.service.dispatch( + "scan_line", + params={"picking_id": second_return_picking.id, "barcode": product.barcode}, + ) + expected_message = { + "message_type": "error", + "body": "The product/packaging you selected has already been returned.", + } + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(second_return_picking)}, + message=expected_message, + ) diff --git a/shopfloor_reception/tests/test_return_scan_document.py b/shopfloor_reception/tests/test_return_scan_document.py new file mode 100644 index 0000000000..17b31f063f --- /dev/null +++ b/shopfloor_reception/tests/test_return_scan_document.py @@ -0,0 +1,95 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .reception_return_common import CommonCaseReturn + + +class TestScanDocumentReturn(CommonCaseReturn): + def test_scan_wrong_barcode(self): + # Same test as in shopfloor_delivery, to ensure this module doesn't break + # the base behaviour + self._enable_allow_return() + self.create_delivery() + response = self.service.dispatch("scan_document", params={"barcode": "NOPE"}) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={"message_type": "error", "body": "Barcode not found"}, + ) + + def test_scan_document_no_default_location(self): + # Remove default locations on picking type. + # Ensure an error message is returned in such case. + self.picking_type.sudo().write( + {"default_location_src_id": False, "default_location_dest_id": False} + ) + self._enable_allow_return() + delivery = self.create_delivery() + self.deliver(delivery) + response = self.service.dispatch( + "scan_document", params={"barcode": self.order.name} + ) + return_picking = self.get_new_pickings() + self.assertFalse(return_picking) + message = { + "message_type": "error", + "body": ( + "Operation types for this menu are missing " + "default source and destination locations." + ), + } + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message=message, + ) + + def test_scan_undelivered_order(self): + # An order has been created and confirmed, but hasn't been delivered yet. + # Therefore, delivery isn't done, and using SO name as input should return + # a "barcode not found" error + self._enable_allow_return() + self.create_delivery() + response = self.service.dispatch( + "scan_document", params={"barcode": self.order.name} + ) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "error", + "body": "You cannot move this using this menu.", + }, + ) + + def test_scan_delivered_order(self): + # Order has been delivered. + # Now, the SO name can be used as an input on the `scan_document` to create + # a return. + delivery = self.create_delivery() + self.deliver(delivery) + # First try, `allow_return` is disabled, we should get a barcode error + response = self.service.dispatch( + "scan_document", params={"barcode": self.order.name} + ) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={"message_type": "error", "body": "Barcode not found"}, + ) + # Now, enable `allow_return` + self._enable_allow_return() + response = self.service.dispatch( + "scan_document", params={"barcode": self.order.name} + ) + return_picking = self.get_new_pickings() + self.assert_return_of(return_picking, self.order.name) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(return_picking)}, + ) diff --git a/shopfloor_reception/tests/test_return_scan_line.py b/shopfloor_reception/tests/test_return_scan_line.py new file mode 100644 index 0000000000..cb5a218048 --- /dev/null +++ b/shopfloor_reception/tests/test_return_scan_line.py @@ -0,0 +1,102 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .reception_return_common import CommonCaseReturn + + +class TestScanLineReturn(CommonCaseReturn): + def test_scan_product_not_in_delivery(self): + self._enable_allow_return() + delivery = self.create_delivery() + self.deliver(delivery) + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking = self.get_new_pickings() + wrong_product = self.product_b + response = self.service.dispatch( + "scan_line", + params={"picking_id": return_picking.id, "barcode": wrong_product.barcode}, + ) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(return_picking)}, + message={ + "message_type": "error", + "body": "Product is not in the current transfer.", + }, + ) + + def test_scan_product_in_delivery(self): + self._enable_allow_return() + delivery = self.create_delivery() + self.deliver(delivery) + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking = self.get_new_pickings() + product = self.product + response = self.service.dispatch( + "scan_line", + params={"picking_id": return_picking.id, "barcode": product.barcode}, + ) + data = self.data.picking(return_picking) + selected_move_line = self.get_new_move_lines() + self.assert_response( + response, + next_state="set_quantity", + data={ + "confirmation_required": False, + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + self.assertEqual(selected_move_line.qty_done, 1.0) + + def test_scan_packaging_not_in_delivery(self): + self._enable_allow_return() + self._add_package_to_order(self.order) + delivery = self.create_delivery() + self.deliver(delivery) + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking = self.get_new_pickings() + wrong_packaging = self.product_b_packaging + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": return_picking.id, + "barcode": wrong_packaging.barcode, + }, + ) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(return_picking)}, + message={ + "message_type": "warning", + "body": "Packaging not found in the current transfer.", + }, + ) + + def test_scan_packaging_in_delivery(self): + self._enable_allow_return() + delivery = self.create_delivery() + self.deliver(delivery) + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking = self.get_new_pickings() + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": return_picking.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + data = self.data.picking(return_picking) + selected_move_line = self.get_new_move_lines() + self.assert_response( + response, + next_state="set_quantity", + data={ + "confirmation_required": False, + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + self.assertEqual(selected_move_line.qty_done, self.product_a_packaging.qty) diff --git a/shopfloor_reception/tests/test_return_set_quantity.py b/shopfloor_reception/tests/test_return_set_quantity.py new file mode 100644 index 0000000000..de4fed4db3 --- /dev/null +++ b/shopfloor_reception/tests/test_return_set_quantity.py @@ -0,0 +1,115 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .reception_return_common import CommonCaseReturn + + +class TestSetQuantityReturn(CommonCaseReturn): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._enable_allow_return() + delivery = cls.create_delivery() + cls.deliver(delivery) + + def setUp(self): + super().setUp() + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + self.return_picking = self.get_new_pickings() + product = self.product + self.service.dispatch( + "scan_line", + params={"picking_id": self.return_picking.id, "barcode": product.barcode}, + ) + self.selected_move_line = self.get_new_move_lines() + + def _dispatch(self, quantity=None, barcode=None): + params = { + "picking_id": self.return_picking.id, + "selected_line_id": self.selected_move_line.id, + } + if barcode: + params["barcode"] = barcode + if quantity: + params["quantity"] = quantity + return self.service.dispatch( + "set_quantity", + params=params, + ) + + def _get_data(self): + return { + "confirmation_required": False, + "picking": self.data.picking(self.return_picking), + "selected_move_line": self.data.move_lines(self.selected_move_line), + } + + def test_set_quantity(self): + # Max allowed qty_done is 10.0 + response = self._dispatch(quantity=20.0) + self.assertEqual(self.selected_move_line.qty_done, 20.0) + self.assert_response(response, next_state="set_quantity", data=self._get_data()) + # Now, we try to set more qty_done that what's allowed. + response = self._dispatch(quantity=21.0) + # Qty done has been kept as it was + self.assertEqual(self.selected_move_line.qty_done, 20.0) + message = { + "message_type": "error", + "body": "You cannot return more quantity than what was initially sent.", + } + self.assert_response( + response, + next_state="set_quantity", + data=self._get_data(), + message=message, + ) + + def test_set_quantity_by_product(self): + expected_qty = 1.0 + # Here, the max qty is 20.0, since this is the originally sent qty + # We are allowed to increment qty 9 times + for __ in range(19): + response = self._dispatch(barcode=self.product.barcode) + expected_qty += 1 + self.assertEqual(self.selected_move_line.qty_done, expected_qty) + self.assert_response( + response, next_state="set_quantity", data=self._get_data() + ) + # Already tested, but make it explicit + self.assertEqual(self.selected_move_line.qty_done, 20.0) + # If we try once more, we should get an error + response = self._dispatch(barcode=self.product.barcode) + # We are not allowed to set qty_done 21.0, since the origin move's qty was 10.0 + self.assertEqual(self.selected_move_line.qty_done, 20.0) + message = { + "message_type": "error", + "body": "You cannot return more quantity than what was initially sent.", + } + self.assert_response( + response, + next_state="set_quantity", + data=self._get_data(), + message=message, + ) + + def test_set_quantity_by_packaging(self): + packaging = self.product_a_packaging + response = self._dispatch(barcode=packaging.barcode) + # By selecting the line by product, qty done was set to 1.0 + # Now, we increment by packaging_qty which is 10.0 + self.assertEqual(self.selected_move_line.qty_done, 11.0) + self.assert_response(response, next_state="set_quantity", data=self._get_data()) + # Sent qty was 20.0. We cannot create a return with more returned qty + # Therefore, qty isn't increased, and an error is returned + response = self._dispatch(barcode=packaging.barcode) + self.assertEqual(self.selected_move_line.qty_done, 11.0) + message = { + "message_type": "error", + "body": "You cannot return more quantity than what was initially sent.", + } + self.assert_response( + response, + next_state="set_quantity", + data=self._get_data(), + message=message, + ) From e32a7fe691353babecfe4751fd323d3321db147b Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Wed, 14 Jun 2023 10:51:13 +0200 Subject: [PATCH 36/84] shopfloor: rename is_shopfloor_return to is_shopfloor_created --- shopfloor_reception/models/stock_picking.py | 2 +- shopfloor_reception/services/reception.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/shopfloor_reception/models/stock_picking.py b/shopfloor_reception/models/stock_picking.py index ecbf29c0cd..bd69e105a0 100644 --- a/shopfloor_reception/models/stock_picking.py +++ b/shopfloor_reception/models/stock_picking.py @@ -7,4 +7,4 @@ class StockPicking(models.Model): _inherit = "stock.picking" - is_shopfloor_return = fields.Boolean() + is_shopfloor_created = fields.Boolean() diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 895cce45e0..c254718aaa 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -529,7 +529,7 @@ def _check_move_available(self, move, message_code="product"): def _set_quantity__by_product(self, picking, selected_line, product): # This is a general rule here. whether the return has been created from # shopfloor or not, you cannot return more than what was shipped. - # Therefore, we cannot use the `is_shopfloor_return` here. + # Therefore, we cannot use the `is_shopfloor_created` here. previous_vals = { "qty_done": selected_line.qty_done, } @@ -565,7 +565,7 @@ def _set_quantity__by_product(self, picking, selected_line, product): def _set_quantity__by_packaging(self, picking, selected_line, packaging): # This is a general rule here. whether the return has been created from # shopfloor or not, you cannot return more than what was shipped. - # Therefore, we cannot use the `is_shopfloor_return` here. + # Therefore, we cannot use the `is_shopfloor_created` here. previous_vals = { "qty_done": selected_line.qty_done, } @@ -918,7 +918,7 @@ def done_action(self, picking_id, confirmation=False): ) if not confirmation: # Do not create a backorder if this is a shopfloor return. - if picking.is_shopfloor_return and self.work.menu.allow_return: + if picking.is_shopfloor_created and self.work.menu.allow_return: picking.with_context(cancel_backorder=True)._action_done() return self._response_for_select_document( message=self.msg_store.transfer_done_success(picking) From 949b9cf6c423ced2ae4dc1fa8f790c69e983b2d2 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Thu, 15 Jun 2023 10:55:20 +0200 Subject: [PATCH 37/84] shopfloor_reception: post lines without backorder for shopfloor transfers --- shopfloor_reception/services/reception.py | 47 +++++++++++++++-------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index c254718aaa..0dcdabc6e8 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -629,14 +629,13 @@ def _set_quantity__by_package(self, picking, selected_line, package): ), ) # If the scanned package has a valid destination, - # set both package and destination on the package, - # and go back to the selection line screen + # set both package and destination on the package. selected_line.result_package_id = package selected_line.location_dest_id = pack_location - if self.work.menu.auto_post_line: - # If option auto_post_line is active in the shopfloor menu, - # create a split order with this line. - self._auto_post_line(selected_line, picking) + + response = self._post_line(selected_line) + if response: + return response return self._response_for_select_move(picking) # Scanned package has no location, move to the location selection # screen @@ -1175,6 +1174,27 @@ def process_without_pack(self, picking_id, selected_line_id, quantity): selected_line.qty_done = quantity return self._response_for_set_destination(picking, selected_line) + def _post_line(self, selected_line): + if ( + selected_line.picking_id.is_shopfloor_created + and self.work.menu.allow_return + ): + # If the transfer is not planned, and we allow unplanned returns, + # process the returned qty and mark it as done. + return self._post_shopfloor_created_line(selected_line) + + if self.work.menu.auto_post_line: + # If option auto_post_line is active in the shopfloor menu, + # create a split order with this line. + self._auto_post_line(selected_line) + + def _post_shopfloor_created_line(self, selected_line): + selected_line.product_uom_qty = selected_line.qty_done + selected_line.picking_id.with_context(cancel_backorder=True)._action_done() + return self._response_for_select_document( + message=self.msg_store.transfer_done_success(selected_line.picking_id) + ) + def _auto_post_line(self, selected_line): new_move = selected_line.move_id.split_other_move_lines( selected_line, intersection=True @@ -1238,11 +1258,9 @@ def set_destination( ), ) selected_line.location_dest_id = location - - if self.work.menu.auto_post_line: - # If option auto_post_line is active in the shopfloor menu, - # create a split order with this line. - self._auto_post_line(selected_line) + response = self._post_line(selected_line) + if response: + return response return self._response_for_select_move(picking) def select_dest_package( @@ -1284,10 +1302,9 @@ def select_dest_package( message=self.msg_store.package_not_empty(package), ) selected_line.result_package_id = package - if self.work.menu.auto_post_line: - # If option auto_post_line is active in the shopfloor menu, - # create a split order with this line. - self._auto_post_line(selected_line) + response = self._post_line(selected_line) + if response: + return response return self._response_for_select_move(picking) message = self.msg_store.create_new_pack_ask_confirmation(barcode) self._assign_user_to_picking(picking) From 8b371b440a2dbb468fabd1d452d31f8c3e8d229c Mon Sep 17 00:00:00 2001 From: oca-ci Date: Mon, 3 Jul 2023 07:33:22 +0000 Subject: [PATCH 38/84] [UPD] Update shopfloor_reception.pot --- .../i18n/shopfloor_reception.pot | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/shopfloor_reception/i18n/shopfloor_reception.pot b/shopfloor_reception/i18n/shopfloor_reception.pot index d18e79f3f6..8da30e74a9 100644 --- a/shopfloor_reception/i18n/shopfloor_reception.pot +++ b/shopfloor_reception/i18n/shopfloor_reception.pot @@ -13,9 +13,34 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__id +msgid "ID" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__is_shopfloor_created +msgid "Is Shopfloor Created" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking____last_update +msgid "Last Modified on" +msgstr "" + #. module: shopfloor_reception #: model:shopfloor.menu,name:shopfloor_reception.shopfloor_menu_demo_reception #: model:shopfloor.scenario,name:shopfloor_reception.scenario_reception #: model:stock.picking.type,name:shopfloor_reception.picking_type_reception_demo msgid "Reception" msgstr "" + +#. module: shopfloor_reception +#: model:ir.model,name:shopfloor_reception.model_stock_picking +msgid "Transfer" +msgstr "" From 5056ca88c3dc6e77a7c03655e2df8bf7309ffb79 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 3 Jul 2023 07:50:42 +0000 Subject: [PATCH 39/84] shopfloor_reception 14.0.2.0.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 0ecaf48152..d2c864a2ed 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.1.4.0", + "version": "14.0.2.0.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From 460d1964d2162393f203b60fea1fac112b9d8ab7 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Mon, 10 Jul 2023 13:29:40 +0200 Subject: [PATCH 40/84] shopfloor_reception: fix when shopfloor doesn't split the selected move --- shopfloor_reception/services/reception.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 0dcdabc6e8..7d4346675d 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -1199,7 +1199,12 @@ def _auto_post_line(self, selected_line): new_move = selected_line.move_id.split_other_move_lines( selected_line, intersection=True ) - new_move.extract_and_action_done() + if new_move: + # A new move is created in case of partial quantity + new_move.extract_and_action_done() + return + # In case of full quantity, post the initial move + selected_line.move_id.extract_and_action_done() def set_destination( self, picking_id, selected_line_id, location_name, confirmation=False From ba73ad02d0789291139384378c11c7e0f1fb5ec0 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Tue, 11 Jul 2023 16:55:58 +0200 Subject: [PATCH 41/84] shopfloor_reception: Allow users to work concurrently --- shopfloor_reception/services/reception.py | 34 +++ .../tests/test_set_quantity.py | 211 ++++++++++++++++++ 2 files changed, 245 insertions(+) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 7d4346675d..a95f906b32 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -748,9 +748,43 @@ def _response_for_set_lot(self, picking, line, message=None): message=message, ) + def _align_product_uom_qties(self, move): + # This method aligns product uom qties on move lines. + # In the shopfloor context, we might have multiple users working at + # the same time on the same move. This is done by creating one move line + # per user, with shopfloor_user_id = user. + # This method ensures that the product_uom_qty reflects what remains to + # be done, so we can display coherent numbers on the UI. + + # for a given line, product_uom_qty is computed as this: + # remaining_todo = move.product_uom_qty - move.quantity_done + # line.product_uom_qty = line.qty_done + remaining_todo + + # However, if the overall qty_done is > to the move's product_uom_qty, + # then we're not updating the line's product_uom_qty. + + # TODO, do we need to check move's state? + # If move is already done, do not update lines qties + # if move.state in ("done", "cancel"): + # return + + qty_todo = move.product_uom_qty + qty_done = sum(move.move_line_ids.mapped("qty_done")) + rounding = move.product_id.uom_id.rounding + compare = float_compare(qty_done, qty_todo, precision_rounding=rounding) + if compare < 1: # If qty done <= qty todo, align qty todo on move lines + remaining_todo = qty_todo - qty_done + # if we didn't bypass reservation update, the quant reservation + # would be reduced as much as the deduced quantity, which is wrong + # as we only moved the quantity to a new move line + lines = move.move_line_ids.with_context(bypass_reservation_update=True) + for line in lines: + line.product_uom_qty = line.qty_done + remaining_todo + def _response_for_set_quantity( self, picking, line, message=None, asking_confirmation=False ): + self._align_product_uom_qties(line.move_id) return self._response( next_state="set_quantity", data={ diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py index 6d1ea2833e..cb6a944546 100644 --- a/shopfloor_reception/tests/test_set_quantity.py +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -381,3 +381,214 @@ def test_scan_new_package(self): "selected_move_line": self.data.move_lines(selected_move_line), }, ) + + @classmethod + def _shopfloor_manager_values(cls): + vals = super()._shopfloor_manager_values() + vals["groups_id"] = [(6, 0, [cls.env.ref("stock.group_stock_user").id])] + return vals + + def _get_service_for_user(self, user): + user_env = self.env(user=user) + return self.get_service( + "reception", menu=self.menu, profile=self.profile, env=user_env + ) + + def test_concurrent_update(self): + # We're testing that move line's product uom qties are updated correctly + # when users are workng on the same move in parallel + picking = self._create_picking() + self.service.dispatch("scan_document", params={"barcode": picking.name}) + self.service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assertEqual(len(selected_move_line), 1) + self.assertEqual(selected_move_line.qty_done, 1.0) + + # Let's make the first user work a little bit, and pick a total of 4 units + for __ in range(4): + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": selected_move_line.qty_done, + "barcode": selected_move_line.product_id.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 5.0) + self.assertEqual(selected_move_line.product_uom_qty, 10.0) + + # Now, concurrently pick products with another user for the same move + manager_user = self.shopfloor_manager + new_service = self._get_service_for_user(manager_user) + new_service.dispatch("scan_document", params={"barcode": picking.name}) + new_service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + new_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + and l.shopfloor_user_id == manager_user + ) + + move_lines = selected_move_line | new_line + line_service_mapping = [ + (selected_move_line, self.service), + (new_line, new_service), + ] + + # Now, we picked 5 for the original line, then 1 for the new one. + # Total qty done should be 6 + lines_qty_done = sum(move_lines.mapped("qty_done")) + self.assertEqual(lines_qty_done, 6.0) + # should be equal to the moves quantity_done + self.assertEqual(lines_qty_done, move_lines.move_id.quantity_done) + # And also, the remaining qty is 4.0, then for each move line, + # product_uom_qty = line.qty_done + move's remaining_qty + for line in move_lines: + self.assertEqual(line.product_uom_qty, line.qty_done + 4.0) + + # Now, let the new user finish its work + for __ in range(4): + new_service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": new_line.id, + "quantity": new_line.qty_done, + "barcode": new_line.product_id.barcode, + }, + ) + + # We should have a qty_done == 10.0 on both moves and move lines + lines_qty_done = sum(move_lines.mapped("qty_done")) + self.assertEqual(lines_qty_done, 10.0) + self.assertEqual(lines_qty_done, move_lines.move_id.quantity_done) + + # And also, the product_uom_qty should be == qty_done == 5.0 + for line in move_lines: + self.assertEqual(line.product_uom_qty, 5.0) + self.assertEqual(line.product_uom_qty, line.qty_done) + + # However, if we pick more than move's product_uom_qty, then lines + # product_uom_qty isn't updated, in order to be able to display an error + # in the frontend + + for __ in range(2): + new_service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": new_line.id, + "quantity": new_line.qty_done, + "barcode": new_line.product_id.barcode, + }, + ) + + # We're 2 over move's product_uom_qty + lines_qty_done = sum(move_lines.mapped("qty_done")) + self.assertEqual(lines_qty_done, 12.0) + self.assertEqual(lines_qty_done, move_lines.move_id.quantity_done) + + # We shouldn't be able to process any of those move lines + error_msg = { + "message_type": "error", + "body": "You cannot pick that much units.", + } + picking_data = self.data.picking(picking) + for line, service in line_service_mapping: + response = service.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": line.id, + "quantity": line.qty_done, + }, + ) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": picking_data, + "selected_move_line": self.data.move_lines(line), + "confirmation_required": False, + }, + message=error_msg, + ) + + # But line's product_uom_qty hasn't changed and is still 10.0 + self.assertEqual(sum(move_lines.mapped("product_uom_qty")), 10.0) + + # If we lower by 2 the first move qty done, qty_todo will be updated correctly + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 2.0, + "barcode": selected_move_line.product_id.barcode, + }, + ) + + self.assertEqual(selected_move_line.qty_done, 3.0) + self.assertEqual(selected_move_line.product_uom_qty, 3.0) + + self.assertEqual(new_line.qty_done, 7.0) + self.assertEqual(new_line.product_uom_qty, 7.0) + + # And everything's fine on the move + move = move_lines.move_id + self.assertEqual(move.product_uom_qty, move.quantity_done) + self.assertEqual(move.product_uom_qty, 10.0) + + def test_split_move_line(self): + picking = self._create_picking() + self.service.dispatch("scan_document", params={"barcode": picking.name}) + self.service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assertEqual(len(selected_move_line), 1) + self.assertEqual(selected_move_line.qty_done, 1.0) + # Now, concurrently pick products with another user for the same move + manager_user = self.shopfloor_manager + new_service = self._get_service_for_user(manager_user) + new_service.dispatch("scan_document", params={"barcode": picking.name}) + new_service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + new_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + and l.shopfloor_user_id == manager_user + ) + + # Try to process the first line + response = self.service.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 1.0, + }, + ) + picking_data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": picking_data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + # there should be 3 lines now + move_lines = picking.move_line_ids.filtered(lambda l: l.product_id == self.product_a) + self.assertEqual(len(move_lines), 3) From aae367db4cd8487dfe271a1603a1920933781f26 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Wed, 12 Jul 2023 15:38:21 +0200 Subject: [PATCH 42/84] shopfloor_reception: Do not process lines with invalid qty --- shopfloor_reception/services/reception.py | 110 ++++++++---------- .../tests/test_set_quantity.py | 10 +- 2 files changed, 54 insertions(+), 66 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index a95f906b32..0331b9e03c 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -526,6 +526,13 @@ def _check_move_available(self, move, message_code="product"): if move.product_uom_qty - move.quantity_done < 1 and not line_without_package: return self.msg_store.move_already_done() + def _set_quantity__check_quantity_done(self, selected_line): + move = selected_line.move_id + max_qty_done = move.product_uom_qty + qty_done = sum(move.move_line_ids.mapped("qty_done")) + rounding = selected_line.product_uom_id.rounding + return float_compare(qty_done, max_qty_done, precision_rounding=rounding) + def _set_quantity__by_product(self, picking, selected_line, product): # This is a general rule here. whether the return has been created from # shopfloor or not, you cannot return more than what was shipped. @@ -534,7 +541,6 @@ def _set_quantity__by_product(self, picking, selected_line, product): "qty_done": selected_line.qty_done, } is_return_line = bool(selected_line.move_id.origin_returned_move_id) - max_qty_done = selected_line.move_id.product_uom_qty if product.id != selected_line.product_id.id: return self._response_for_set_quantity( picking, @@ -548,10 +554,7 @@ def _set_quantity__by_product(self, picking, selected_line, product): # If we have an error, return it, since this is also true for return lines if message_type == "error": return response - rounding = selected_line.product_uom_id.rounding - compare = float_compare( - selected_line.qty_done, max_qty_done, precision_rounding=rounding - ) + compare = self._set_quantity__check_quantity_done(selected_line) # We cannot set a qty_done superior to what has initally been sent if compare == 1: # If so, reset selected_line to its previous state, and return an error @@ -570,7 +573,6 @@ def _set_quantity__by_packaging(self, picking, selected_line, packaging): "qty_done": selected_line.qty_done, } is_return_line = bool(selected_line.move_id.origin_returned_move_id) - max_qty_done = selected_line.move_id.product_uom_qty if packaging.product_id.id != selected_line.product_id.id: return self._response_for_set_quantity( picking, @@ -584,11 +586,7 @@ def _set_quantity__by_packaging(self, picking, selected_line, packaging): # If we have an error, return it, since this is also true for return lines if message_type == "error": return response - # We cannot set a qty_done superior to what has initally been sent - rounding = selected_line.product_uom_id.rounding - compare = float_compare( - selected_line.qty_done, max_qty_done, precision_rounding=rounding - ) + compare = self._set_quantity__check_quantity_done(selected_line) # We cannot set a qty_done superior to what has initally been sent if compare == 1: # If so, reset selected_line to its previous state, and return an error @@ -614,20 +612,11 @@ def _set_quantity__by_package(self, picking, selected_line, package): picking, selected_line, message=message ) quantity = selected_line.qty_done - __, qty_check = selected_line._split_qty_to_be_done( - quantity, - lot_id=False, - shopfloor_user_id=False, - expiration_date=False, + response = self._set_quantity__process__set_qty_and_split( + picking, selected_line, quantity ) - if qty_check == "greater": - return self._response_for_set_quantity( - picking, - selected_line, - message=self.msg_store.unable_to_pick_more( - selected_line.product_uom_qty - ), - ) + if response: + return response # If the scanned package has a valid destination, # set both package and destination on the package. selected_line.result_package_id = package @@ -1141,6 +1130,28 @@ def set_quantity( ) return self._response_for_set_quantity(picking, selected_line) + def _set_quantity__process__set_qty_and_split(self, picking, line, quantity): + move = line.move_id + sum(move.move_line_ids.mapped("qty_done")) + savepoint = self._actions_for("savepoint").new() + line.qty_done = quantity + compare = self._set_quantity__check_quantity_done(line) + if compare == 1: + # If move's qty_done > to move's qty_todo, rollback and return an error + savepoint.rollback() + return self._response_for_set_quantity( + picking, line, message=self.msg_store.unable_to_pick_qty() + ) + savepoint.release() + # Only if total_qty_done < qty_todo, we split the move line + if compare == -1: + default_values = { + "lot_id": False, + "shopfloor_user_id": False, + "expiration_date": False, + } + line._split_qty_to_be_done(quantity, **default_values) + def process_with_existing_pack(self, picking_id, selected_line_id, quantity): picking = self.env["stock.picking"].browse(picking_id) selected_line = self.env["stock.move.line"].browse(selected_line_id) @@ -1149,18 +1160,11 @@ def process_with_existing_pack(self, picking_id, selected_line_id, quantity): return self._response_for_set_quantity( picking, selected_line, message=message ) - __, qty_check = selected_line._split_qty_to_be_done( - quantity, lot_id=False, shopfloor_user_id=False, expiration_date=False + response = self._set_quantity__process__set_qty_and_split( + picking, selected_line, quantity ) - if qty_check == "greater": - return self._response_for_set_quantity( - picking, - selected_line, - message=self.msg_store.unable_to_pick_more( - selected_line.product_uom_qty - ), - ) - selected_line.qty_done = quantity + if response: + return response return self._response_for_select_dest_package(picking, selected_line) def process_with_new_pack(self, picking_id, selected_line_id, quantity): @@ -1171,18 +1175,11 @@ def process_with_new_pack(self, picking_id, selected_line_id, quantity): return self._response_for_set_quantity( picking, selected_line, message=message ) - __, qty_check = selected_line._split_qty_to_be_done( - quantity, lot_id=False, shopfloor_user_id=False, expiration_date=False + response = self._set_quantity__process__set_qty_and_split( + picking, selected_line, quantity ) - if qty_check == "greater": - return self._response_for_set_quantity( - picking, - selected_line, - message=self.msg_store.unable_to_pick_more( - selected_line.product_uom_qty - ), - ) - selected_line.qty_done = quantity + if response: + return response picking._put_in_pack(selected_line) return self._response_for_set_destination(picking, selected_line) @@ -1194,18 +1191,11 @@ def process_without_pack(self, picking_id, selected_line_id, quantity): return self._response_for_set_quantity( picking, selected_line, message=message ) - __, qty_check = selected_line._split_qty_to_be_done( - quantity, lot_id=False, shopfloor_user_id=False, expiration_date=False + response = self._set_quantity__process__set_qty_and_split( + picking, selected_line, quantity ) - if qty_check == "greater": - return self._response_for_set_quantity( - picking, - selected_line, - message=self.msg_store.unable_to_pick_more( - selected_line.product_uom_qty - ), - ) - selected_line.qty_done = quantity + if response: + return response return self._response_for_set_destination(picking, selected_line) def _post_line(self, selected_line): @@ -1541,13 +1531,13 @@ def _set_lot_confirm_action_next_states(self): return {"set_lot", "set_quantity"} def _process_with_existing_pack_next_states(self): - return {"select_dest_package"} + return {"set_quantity", "select_dest_package"} def _process_with_new_pack_next_states(self): - return {"set_destination"} + return {"set_quantity", "set_destination"} def _process_without_pack_next_states(self): - return {"set_destination"} + return {"set_quantity", "set_destination"} # SCHEMAS diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py index cb6a944546..39f6dcb551 100644 --- a/shopfloor_reception/tests/test_set_quantity.py +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -498,7 +498,7 @@ def test_concurrent_update(self): # We shouldn't be able to process any of those move lines error_msg = { "message_type": "error", - "body": "You cannot pick that much units.", + "body": "You cannot process that much units.", } picking_data = self.data.picking(picking) for line, service in line_service_mapping: @@ -566,10 +566,6 @@ def test_split_move_line(self): "scan_line", params={"picking_id": picking.id, "barcode": self.product_a.barcode}, ) - new_line = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - and l.shopfloor_user_id == manager_user - ) # Try to process the first line response = self.service.dispatch( @@ -590,5 +586,7 @@ def test_split_move_line(self): }, ) # there should be 3 lines now - move_lines = picking.move_line_ids.filtered(lambda l: l.product_id == self.product_a) + move_lines = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) self.assertEqual(len(move_lines), 3) From 72b00d58cb531192431465f5861ff44750edd884 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Thu, 13 Jul 2023 10:58:52 +0200 Subject: [PATCH 43/84] shopfloor_reception: Allow to set qty to 0 --- shopfloor_reception/services/reception.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 0331b9e03c..d79fd1724c 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -1114,7 +1114,7 @@ def set_quantity( return self._response_for_set_quantity( picking, selected_line, message=message ) - if quantity: + if quantity is not None: # We set qty_done to be equal to the qty of the picker # at the moment of the scan. response = self._set_quantity__assign_quantity( From 9490ad44842666d8869d95fe5b493d8ed82f5ce7 Mon Sep 17 00:00:00 2001 From: Michael Tietz Date: Sat, 15 Jul 2023 01:10:15 +0200 Subject: [PATCH 44/84] [IMP] shopfloor_reception: Refactor test for select_move --- shopfloor_reception/tests/common.py | 15 +++++++++++---- shopfloor_reception/tests/test_reception_done.py | 2 +- .../tests/test_select_dest_package.py | 4 ++-- shopfloor_reception/tests/test_select_document.py | 6 +++--- shopfloor_reception/tests/test_select_move.py | 12 +++--------- shopfloor_reception/tests/test_set_destination.py | 12 ++---------- shopfloor_reception/tests/test_set_quantity.py | 12 ++++++------ 7 files changed, 28 insertions(+), 35 deletions(-) diff --git a/shopfloor_reception/tests/common.py b/shopfloor_reception/tests/common.py index 7718d56521..73da099b67 100644 --- a/shopfloor_reception/tests/common.py +++ b/shopfloor_reception/tests/common.py @@ -65,14 +65,14 @@ def _data_for_pickings_with_line(self, pickings): res.append(self._data_for_picking_with_line(picking)) return res - def _data_for_picking_with_moves(self, picking): - picking_data = self._data_for_picking(picking) + def _data_for_picking_with_moves(self, picking, with_progress=True): + picking_data = self._data_for_picking(picking, with_progress) moves_data = self._data_for_moves(picking.move_lines) picking_data.update({"moves": moves_data}) return picking_data - def _data_for_picking(self, picking): - return self.data.picking(picking, with_progress=True) + def _data_for_picking(self, picking, with_progress=True): + return self.data.picking(picking, with_progress=with_progress) def _data_for_pickings(self, pickings): res = [] @@ -89,6 +89,13 @@ def _data_for_moves(self, moves): res.append(self._data_for_move(move)) return res + def _data_for_select_move(self, picking, with_progress=True): + picking_data = self._data_for_picking_with_moves(picking, with_progress) + data = { + "picking": picking_data, + } + return data + def setUp(self): super().setUp() self.service = self.get_service( diff --git a/shopfloor_reception/tests/test_reception_done.py b/shopfloor_reception/tests/test_reception_done.py index e02b80c28b..324503bf9e 100644 --- a/shopfloor_reception/tests/test_reception_done.py +++ b/shopfloor_reception/tests/test_reception_done.py @@ -45,7 +45,7 @@ def test_set_done_no_qty_processed(self): self.assert_response( response, next_state="select_move", - data={"picking": self._data_for_picking_with_moves(picking)}, + data=self._data_for_select_move(picking), message={ "message_type": "warning", "body": "No quantity has been processed, unable to complete the transfer.", diff --git a/shopfloor_reception/tests/test_select_dest_package.py b/shopfloor_reception/tests/test_select_dest_package.py index 4e1a579a7e..0a005f1d1e 100644 --- a/shopfloor_reception/tests/test_select_dest_package.py +++ b/shopfloor_reception/tests/test_select_dest_package.py @@ -58,7 +58,7 @@ def test_scan_new_package(self): self.assert_response( response, next_state="select_move", - data={"picking": self._data_for_picking_with_moves(picking)}, + data=self._data_for_select_move(picking), ) def test_scan_not_empty_package(self): @@ -127,5 +127,5 @@ def test_scan_existing_package(self): self.assert_response( response, next_state="select_move", - data={"picking": self._data_for_picking_with_moves(picking)}, + data=self._data_for_select_move(picking), ) diff --git a/shopfloor_reception/tests/test_select_document.py b/shopfloor_reception/tests/test_select_document.py index 4683bce94e..58ba78d12f 100644 --- a/shopfloor_reception/tests/test_select_document.py +++ b/shopfloor_reception/tests/test_select_document.py @@ -35,7 +35,7 @@ def test_scan_picking_name(self): self.assert_response( response, next_state="select_move", - data={"picking": self._data_for_picking_with_moves(picking)}, + data=self._data_for_select_move(picking), ) def test_scan_picking_origin_multiple_pickings(self): @@ -78,7 +78,7 @@ def test_scan_picking_origin_multiple_pickings_one_today(self): self.assert_response( response, next_state="select_move", - data={"picking": self._data_for_picking_with_moves(picking_today)}, + data=self._data_for_select_move(picking_today), ) def test_scan_picking_origin_one_picking(self): @@ -92,7 +92,7 @@ def test_scan_picking_origin_one_picking(self): self.assert_response( response, next_state="select_move", - data={"picking": self._data_for_picking_with_moves(picking)}, + data=self._data_for_select_move(picking), ) def test_scan_packaging_multiple_pickings(self): diff --git a/shopfloor_reception/tests/test_select_move.py b/shopfloor_reception/tests/test_select_move.py index bd36cbc1d6..36cd693e76 100644 --- a/shopfloor_reception/tests/test_select_move.py +++ b/shopfloor_reception/tests/test_select_move.py @@ -17,12 +17,10 @@ def test_scan_barcode_not_found(self): response = self.service.dispatch( "scan_line", params={"picking_id": picking.id, "barcode": "NOPE"} ) - data = self.data.picking(picking, with_progress=True) - data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response( response, next_state="select_move", - data={"picking": data}, + data=self._data_for_select_move(picking), message={"message_type": "error", "body": "Barcode not found"}, ) @@ -151,12 +149,10 @@ def test_scan_product_not_found(self): params={"picking_id": picking.id, "barcode": self.product_c.barcode}, ) error_msg = "Product not found in the current transfer or already in a package." - data = self.data.picking(picking, with_progress=True) - data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response( response, next_state="select_move", - data={"picking": data}, + data=self._data_for_select_move(picking), message={"message_type": "warning", "body": error_msg}, ) @@ -173,12 +169,10 @@ def test_scan_packaging_not_found(self): error_msg = ( "Packaging not found in the current transfer or already in a package." ) - data = self.data.picking(picking, with_progress=True) - data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response( response, next_state="select_move", - data={"picking": data}, + data=self._data_for_select_move(picking), message={"message_type": "warning", "body": error_msg}, ) diff --git a/shopfloor_reception/tests/test_set_destination.py b/shopfloor_reception/tests/test_set_destination.py index c0838fabfe..7a9ec07e35 100644 --- a/shopfloor_reception/tests/test_set_destination.py +++ b/shopfloor_reception/tests/test_set_destination.py @@ -32,12 +32,8 @@ def test_scan_location_child_of_dest_location(self): }, ) self.assertEqual(selected_move_line.location_dest_id, self.shelf2) - data = self.data.picking(picking, with_progress=True) - data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response( - response, - next_state="select_move", - data={"picking": data}, + response, next_state="select_move", data=self._data_for_select_move(picking) ) def test_scan_location_child_of_pick_type_dest_location(self): @@ -83,12 +79,8 @@ def test_scan_location_child_of_pick_type_dest_location(self): }, ) self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) - data = self.data.picking(picking, with_progress=True) - data.update({"moves": self.data.moves(picking.move_lines)}) self.assert_response( - response, - next_state="select_move", - data={"picking": data}, + response, next_state="select_move", data=self._data_for_select_move(picking) ) def test_scan_location_not_child_of_dest_locations(self): diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py index 39f6dcb551..a3b08ad5b3 100644 --- a/shopfloor_reception/tests/test_set_quantity.py +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -201,9 +201,9 @@ def test_scan_package_with_destination_child_of_dest_location(self): self.package_with_location_child_of_dest, ) self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) - data = self.data.picking(picking, with_progress=True) - data.update({"moves": self.data.moves(picking.move_lines)}) - self.assert_response(response, next_state="select_move", data={"picking": data}) + self.assert_response( + response, next_state="select_move", data=self._data_for_select_move(picking) + ) def test_scan_package_with_destination_not_child_of_dest_location(self): # next step is set_quantity with error @@ -275,9 +275,9 @@ def test_scan_location_child_of_dest_location(self): }, ) self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) - data = self.data.picking(picking, with_progress=True) - data.update({"moves": self.data.moves(picking.move_lines)}) - self.assert_response(response, next_state="select_move", data={"picking": data}) + self.assert_response( + response, next_state="select_move", data=self._data_for_select_move(picking) + ) def test_scan_location_not_child_of_dest_location(self): picking = self._create_picking() From c745957c7eef3ffcdc2d998f5a38ed7e13c2fd85 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 25 Jul 2023 10:11:46 +0000 Subject: [PATCH 45/84] shopfloor_reception 14.0.2.1.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index d2c864a2ed..d594f44de5 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.2.0.0", + "version": "14.0.2.1.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From 6c80e99539854f33689dc21b8ecd7e1e1becaf2e Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 25 Jul 2023 10:33:43 +0000 Subject: [PATCH 46/84] shopfloor_reception 14.0.2.1.1 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index d594f44de5..749ccc8b8a 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.2.1.0", + "version": "14.0.2.1.1", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From b3439195ab669bce6bee77495816d19b814947c3 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 25 Jul 2023 14:08:46 +0000 Subject: [PATCH 47/84] shopfloor_reception 14.0.2.2.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 749ccc8b8a..aa8a9f1410 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.2.1.1", + "version": "14.0.2.2.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From 4728b78cf15f713b0564bafd696fed9781e0bb99 Mon Sep 17 00:00:00 2001 From: Michael Tietz Date: Wed, 19 Jul 2023 14:26:22 +0200 Subject: [PATCH 48/84] [IMP] shopfloor_reception: Change select_move screen use manual-select --- shopfloor_reception/services/reception.py | 13 +++++++++++ shopfloor_reception/tests/test_select_move.py | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index d79fd1724c..971b3f07d1 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -917,6 +917,11 @@ def scan_line(self, picking_id, barcode): return handler(picking, search_result.record) return self._scan_line__fallback(picking, barcode) + def manual_select_move(self, move_id): + move = self.env["stock.move"].browse(move_id) + picking = move.picking_id + return self._scan_line__find_or_create_line(picking, move) + def done_action(self, picking_id, confirmation=False): """Mark a picking as done @@ -1362,6 +1367,11 @@ def scan_line(self): "barcode": {"required": True, "type": "string"}, } + def manual_select_move(self): + return { + "move_id": {"required": True, "type": "integer"}, + } + def set_lot(self): return { "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, @@ -1661,6 +1671,9 @@ def list_stock_pickings(self): def scan_line(self): return self._response_schema(next_states=self._scan_line_next_states()) + def manual_select_move(self): + return self._response_schema(next_states=self._scan_line_next_states()) + def set_lot(self): return self._response_schema(next_states=self._set_lot_next_states()) diff --git a/shopfloor_reception/tests/test_select_move.py b/shopfloor_reception/tests/test_select_move.py index 36cd693e76..ea50f0f365 100644 --- a/shopfloor_reception/tests/test_select_move.py +++ b/shopfloor_reception/tests/test_select_move.py @@ -292,3 +292,25 @@ def test_done_action(self): data={"pickings": self._data_for_pickings(pickings)}, message={"message_type": "success", "body": message}, ) + + def test_manual_select_move(self): + picking = self._create_picking() + selected_move = picking.move_lines.filtered( + lambda m: m.product_id == self.product_a + ) + response = self.service.dispatch( + "manual_select_move", + params={"move_id": selected_move.id}, + ) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) From 3be0d5e4ae25e69d6ef50b204f998e4255df6308 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 26 Jul 2023 07:18:48 +0000 Subject: [PATCH 49/84] shopfloor_reception 14.0.2.3.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index aa8a9f1410..55d96e6d02 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.2.2.0", + "version": "14.0.2.3.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From fa4bf7090672dcd14853c03ee06582939e2f134d Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Mon, 17 Jul 2023 13:01:50 +0200 Subject: [PATCH 50/84] sh reception: fix select destination package with location --- shopfloor_reception/services/reception.py | 66 +++++++++++-------- .../tests/test_select_dest_package.py | 7 ++ 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 971b3f07d1..107ae5e697 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -597,38 +597,48 @@ def _set_quantity__by_packaging(self, picking, selected_line, packaging): ) return response - def _set_quantity__by_package(self, picking, selected_line, package): + def _set_package_on_move_line(self, picking, line, package): + """Assign a package already on a move line. + + If the package is already at a location : + * check the location is valid + * Split the move if doing a partial quantity + + On error, return to the set quantity screen. + + """ pack_location = package.location_id - if pack_location: - ( - move_dest_location_ok, - pick_type_dest_location_ok, - ) = self._check_location_ok(pack_location, selected_line, picking) - if not (move_dest_location_ok or pick_type_dest_location_ok): - # If the scanned package has a location that isn't a child - # of the move dest, return an error - message = self.msg_store.dest_location_not_allowed() - return self._response_for_set_quantity( - picking, selected_line, message=message - ) - quantity = selected_line.qty_done - response = self._set_quantity__process__set_qty_and_split( - picking, selected_line, quantity - ) - if response: - return response - # If the scanned package has a valid destination, - # set both package and destination on the package. - selected_line.result_package_id = package - selected_line.location_dest_id = pack_location + if not pack_location: + line.result_package_id = package + return None + ( + move_dest_location_ok, + pick_type_dest_location_ok, + ) = self._check_location_ok(pack_location, line, picking) + if not (move_dest_location_ok or pick_type_dest_location_ok): + # Package location is not a child of the move destination + message = self.msg_store.dest_location_not_allowed() + return self._response_for_set_quantity(picking, line, message=message) + quantity = line.qty_done + response = self._set_quantity__process__set_qty_and_split( + picking, line, quantity + ) + if response: + return response + # If the scanned package has a valid destination, + # set both package and destination on the package. + line.result_package_id = package + line.location_dest_id = pack_location + def _set_quantity__by_package(self, picking, selected_line, package): + response = self._set_package_on_move_line(picking, selected_line, package) + if response: + return response + if package.location_id: response = self._post_line(selected_line) if response: return response return self._response_for_select_move(picking) - # Scanned package has no location, move to the location selection - # screen - selected_line.result_package_id = package return self._response_for_set_destination(picking, selected_line) def _set_quantity__by_location(self, picking, selected_line, location): @@ -1335,7 +1345,9 @@ def select_dest_package( selected_line, message=self.msg_store.package_not_empty(package), ) - selected_line.result_package_id = package + response = self._set_package_on_move_line(picking, selected_line, package) + if response: + return response response = self._post_line(selected_line) if response: return response diff --git a/shopfloor_reception/tests/test_select_dest_package.py b/shopfloor_reception/tests/test_select_dest_package.py index 0a005f1d1e..672692ab8e 100644 --- a/shopfloor_reception/tests/test_select_dest_package.py +++ b/shopfloor_reception/tests/test_select_dest_package.py @@ -15,6 +15,11 @@ def setUpClassBaseData(cls): "packaging_id": cls.product_a_packaging.id, } ) + cls.input_sublocation = ( + cls.env["stock.location"] + .sudo() + .create({"name": "Input A", "location_id": cls.input_location.id}) + ) def test_scan_new_package(self): picking = self._create_picking() @@ -114,6 +119,7 @@ def test_scan_existing_package(self): different_move_line = picking.move_line_ids.filtered( lambda l: l.product_id == self.product_b ) + self.package.location_id = self.input_sublocation different_move_line.result_package_id = self.package response = self.service.dispatch( "select_dest_package", @@ -124,6 +130,7 @@ def test_scan_existing_package(self): }, ) self.assertEqual(selected_move_line.result_package_id.name, self.package.name) + self.assertEqual(selected_move_line.location_dest_id, self.input_sublocation) self.assert_response( response, next_state="select_move", From 2c2e6da4d78b865cc8592d8db74097d87e8a5c39 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Thu, 27 Jul 2023 09:48:25 +0200 Subject: [PATCH 51/84] shopfloor_reception: prevent creating lines with negative demand --- shopfloor_reception/services/reception.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 107ae5e697..47d0a83c37 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -261,7 +261,7 @@ def _scan_line__find_or_create_line(self, picking, move, qty_done=1): # the remaining quantity to do of its move. line.product_uom_qty = move.product_uom_qty - move.quantity_done else: - qty_todo_remaining = move.product_uom_qty - move.quantity_done + qty_todo_remaining = max(0, move.product_uom_qty - move.quantity_done) values = move._prepare_move_line_vals(quantity=qty_todo_remaining) line = self.env["stock.move.line"].create(values) return self._scan_line__assign_user(picking, line, qty_done) From b472217031021d97ed953a9df23294c4d71a751b Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sun, 30 Jul 2023 09:05:35 +0000 Subject: [PATCH 52/84] shopfloor_reception 14.0.2.3.1 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 55d96e6d02..9cabc73441 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.2.3.0", + "version": "14.0.2.3.1", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From ddff7f061cf80b05cae30a6036e8872e5bb237b8 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Aug 2023 12:37:26 +0200 Subject: [PATCH 53/84] shopfloor_*: fix install/uninstall Due to the way sf.app endpoints are registered if at install/uninstall you don't take care of refreshing routes you'll end up w/ non working services or stale entries in the route table. --- shopfloor_reception/__init__.py | 1 + shopfloor_reception/__manifest__.py | 2 ++ shopfloor_reception/hooks.py | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 shopfloor_reception/hooks.py diff --git a/shopfloor_reception/__init__.py b/shopfloor_reception/__init__.py index 5b1c56416a..2d9836a742 100644 --- a/shopfloor_reception/__init__.py +++ b/shopfloor_reception/__init__.py @@ -1,2 +1,3 @@ from . import services from . import models +from .hooks import post_init_hook, uninstall_hook diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 9cabc73441..1da9b57f25 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -17,4 +17,6 @@ "demo/stock_picking_type_demo.xml", "demo/shopfloor_menu_demo.xml", ], + "post_init_hook": "post_init_hook", + "uninstall_hook": "uninstall_hook", } diff --git a/shopfloor_reception/hooks.py b/shopfloor_reception/hooks.py new file mode 100644 index 0000000000..5bc64b4f28 --- /dev/null +++ b/shopfloor_reception/hooks.py @@ -0,0 +1,24 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import SUPERUSER_ID, api + +from odoo.addons.shopfloor_base.utils import purge_endpoints, register_new_services + +from .services.reception import Reception as Service + +_logger = logging.getLogger(__file__) + + +def post_init_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + _logger.info("Register routes for %s", Service._usage) + register_new_services(env, Service) + + +def uninstall_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + _logger.info("Refreshing routes for existing apps") + purge_endpoints(env, Service._usage) From 6e879490ba4a2fcf773038a6cc1407843eaddf7c Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 16 Aug 2023 10:00:37 +0000 Subject: [PATCH 54/84] shopfloor_reception 14.0.2.3.2 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 1da9b57f25..2dff49424a 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.2.3.1", + "version": "14.0.2.3.2", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From af49c84603083ddc6380615f1ab2dfd873d40e9a Mon Sep 17 00:00:00 2001 From: Michael Tietz Date: Tue, 15 Aug 2023 13:48:46 +0200 Subject: [PATCH 55/84] [IMP] shopfloor_reception: adding kwargs to _data_for_stock_picking --- shopfloor_reception/services/reception.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 47d0a83c37..f1d421deb2 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -693,8 +693,10 @@ def _assign_user_to_line(self, line): # DATA METHODS - def _data_for_stock_picking(self, picking, with_lines=False): - data = self.data.picking(picking, with_progress=True) + def _data_for_stock_picking(self, picking, with_lines=False, **kw): + if "with_progress" not in kw: + kw["with_progress"] = True + data = self.data.picking(picking, **kw) if with_lines: data.update({"moves": self._data_for_moves(picking.move_lines)}) return data From ae7f0f222b5247bfcffc7e98d511c5876086d0a6 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 23 Aug 2023 07:29:28 +0000 Subject: [PATCH 56/84] shopfloor_reception 14.0.2.4.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 2dff49424a..113827aa2a 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.2.3.2", + "version": "14.0.2.4.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From 1f0ee43243b4ebe20cae61ca8ed6fbac1873922b Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 23 Aug 2023 09:09:28 +0000 Subject: [PATCH 57/84] shopfloor_reception 14.0.2.5.0 --- shopfloor_reception/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py index 113827aa2a..ea7ca31c75 100644 --- a/shopfloor_reception/__manifest__.py +++ b/shopfloor_reception/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Reception", "summary": "Reception scenario for shopfloor", - "version": "14.0.2.4.0", + "version": "14.0.2.5.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", From b11a65f7bffd31d1083cd51935c00e3e4eba8f37 Mon Sep 17 00:00:00 2001 From: mymage Date: Thu, 24 Aug 2023 11:23:04 +0000 Subject: [PATCH 58/84] Added translation using Weblate (Italian) --- shopfloor_reception/i18n/it.po | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 shopfloor_reception/i18n/it.po diff --git a/shopfloor_reception/i18n/it.po b/shopfloor_reception/i18n/it.po new file mode 100644 index 0000000000..e5b8c367da --- /dev/null +++ b/shopfloor_reception/i18n/it.po @@ -0,0 +1,47 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_reception +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__id +msgid "ID" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__is_shopfloor_created +msgid "Is Shopfloor Created" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor_reception +#: model:shopfloor.menu,name:shopfloor_reception.shopfloor_menu_demo_reception +#: model:shopfloor.scenario,name:shopfloor_reception.scenario_reception +#: model:stock.picking.type,name:shopfloor_reception.picking_type_reception_demo +msgid "Reception" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model,name:shopfloor_reception.model_stock_picking +msgid "Transfer" +msgstr "" From 97502a33d9f61bcc562097d0d72ee439bfb86def Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Sun, 3 Sep 2023 18:07:17 +0000 Subject: [PATCH 59/84] [UPD] README.rst --- shopfloor_reception/README.rst | 15 ++++--- .../static/description/index.html | 40 ++++++++++--------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/shopfloor_reception/README.rst b/shopfloor_reception/README.rst index 5ee7cd7550..1d05f76058 100644 --- a/shopfloor_reception/README.rst +++ b/shopfloor_reception/README.rst @@ -2,10 +2,13 @@ Shopfloor Reception =================== -.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:87e1bfc7de3a33326afa876b4ea293f2429e30045749344a4b5ec187db45b47a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status @@ -19,11 +22,11 @@ Shopfloor Reception .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png :target: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor_reception :alt: Translate me on Weblate -.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png - :target: https://runbot.odoo-community.org/runbot/285/14.0 - :alt: Try me on Runbot +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/wms&target_branch=14.0 + :alt: Try me on Runboat -|badge1| |badge2| |badge3| |badge4| |badge5| +|badge1| |badge2| |badge3| |badge4| |badge5| Shopfloor implementation of the reception scenario. Allows to receive products and create the proper packs for each logistic unit. @@ -43,7 +46,7 @@ Bug Tracker Bugs are tracked on `GitHub Issues `_. In case of trouble, please check there if your issue has already been reported. -If you spotted it first, help us smashing it by providing a detailed and welcomed +If you spotted it first, help us to smash it by providing a detailed and welcomed `feedback `_. Do not contact contributors directly about support or help with technical issues. diff --git a/shopfloor_reception/static/description/index.html b/shopfloor_reception/static/description/index.html index 45cc9b6614..90c50f17b1 100644 --- a/shopfloor_reception/static/description/index.html +++ b/shopfloor_reception/static/description/index.html @@ -1,20 +1,20 @@ - + - + Shopfloor Reception
    +

    Shopfloor Reception

    + + +

    Beta License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runbot

    +

    Shopfloor implementation of the reception scenario. +Allows to receive products and create the proper packs for each logistic unit.

    +

    Table of contents

    + +
    +

    Known issues / Roadmap

    +

    Implement methods in the backend to cancel lines (to be used by the frontend in select_line & set_quantity).

    +
    +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • Camptocamp
    • +
    +
    +
    +

    Contributors

    + +
    +
    +

    Maintainers

    +

    This module is maintained by the OCA.

    +Odoo Community Association +

    OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

    +

    Current maintainers:

    +

    mmequignon JuMiSanAr

    +

    This module is part of the OCA/wms project on GitHub.

    +

    You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

    +
    +
    +