odoo-addon-shopfloor 16.0.1.0.0.24__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. odoo/addons/shopfloor/README.rst +160 -0
  2. odoo/addons/shopfloor/__init__.py +4 -0
  3. odoo/addons/shopfloor/__manifest__.py +65 -0
  4. odoo/addons/shopfloor/actions/__init__.py +15 -0
  5. odoo/addons/shopfloor/actions/change_package_lot.py +164 -0
  6. odoo/addons/shopfloor/actions/completion_info.py +42 -0
  7. odoo/addons/shopfloor/actions/data.py +329 -0
  8. odoo/addons/shopfloor/actions/data_detail.py +154 -0
  9. odoo/addons/shopfloor/actions/inventory.py +150 -0
  10. odoo/addons/shopfloor/actions/location_content_transfer_sorter.py +89 -0
  11. odoo/addons/shopfloor/actions/message.py +846 -0
  12. odoo/addons/shopfloor/actions/move_line_search.py +119 -0
  13. odoo/addons/shopfloor/actions/packaging.py +59 -0
  14. odoo/addons/shopfloor/actions/savepoint.py +44 -0
  15. odoo/addons/shopfloor/actions/schema.py +182 -0
  16. odoo/addons/shopfloor/actions/schema_detail.py +98 -0
  17. odoo/addons/shopfloor/actions/search.py +187 -0
  18. odoo/addons/shopfloor/actions/stock.py +239 -0
  19. odoo/addons/shopfloor/actions/stock_unreserve.py +66 -0
  20. odoo/addons/shopfloor/components/__init__.py +5 -0
  21. odoo/addons/shopfloor/components/scan_handler_location.py +26 -0
  22. odoo/addons/shopfloor/components/scan_handler_lot.py +26 -0
  23. odoo/addons/shopfloor/components/scan_handler_package.py +26 -0
  24. odoo/addons/shopfloor/components/scan_handler_product.py +26 -0
  25. odoo/addons/shopfloor/components/scan_handler_transfer.py +26 -0
  26. odoo/addons/shopfloor/data/shopfloor_scenario_data.xml +73 -0
  27. odoo/addons/shopfloor/demo/shopfloor_app_demo.xml +12 -0
  28. odoo/addons/shopfloor/demo/shopfloor_menu_demo.xml +64 -0
  29. odoo/addons/shopfloor/demo/shopfloor_profile_demo.xml +8 -0
  30. odoo/addons/shopfloor/demo/stock_picking_type_demo.xml +93 -0
  31. odoo/addons/shopfloor/docs/checkout_diag_seq.plantuml +61 -0
  32. odoo/addons/shopfloor/docs/checkout_diag_seq.png +0 -0
  33. odoo/addons/shopfloor/docs/cluster_picking_diag_seq.plantuml +112 -0
  34. odoo/addons/shopfloor/docs/cluster_picking_diag_seq.png +0 -0
  35. odoo/addons/shopfloor/docs/delivery_diag_seq.plantuml +56 -0
  36. odoo/addons/shopfloor/docs/delivery_diag_seq.png +0 -0
  37. odoo/addons/shopfloor/docs/location_content_transfer_diag_seq.plantuml +66 -0
  38. odoo/addons/shopfloor/docs/location_content_transfer_diag_seq.png +0 -0
  39. odoo/addons/shopfloor/docs/oca_logo.png +0 -0
  40. odoo/addons/shopfloor/docs/single_pack_transfer_diag_seq.plantuml +36 -0
  41. odoo/addons/shopfloor/docs/single_pack_transfer_diag_seq.png +0 -0
  42. odoo/addons/shopfloor/docs/zone_picking_diag_seq.plantuml +85 -0
  43. odoo/addons/shopfloor/docs/zone_picking_diag_seq.png +0 -0
  44. odoo/addons/shopfloor/exceptions.py +6 -0
  45. odoo/addons/shopfloor/i18n/ca.po +1802 -0
  46. odoo/addons/shopfloor/i18n/de.po +1791 -0
  47. odoo/addons/shopfloor/i18n/es_AR.po +2147 -0
  48. odoo/addons/shopfloor/i18n/pt_BR.po +1791 -0
  49. odoo/addons/shopfloor/i18n/shopfloor.pot +1877 -0
  50. odoo/addons/shopfloor/models/__init__.py +12 -0
  51. odoo/addons/shopfloor/models/priority_postpone_mixin.py +41 -0
  52. odoo/addons/shopfloor/models/shopfloor_app.py +9 -0
  53. odoo/addons/shopfloor/models/shopfloor_menu.py +436 -0
  54. odoo/addons/shopfloor/models/stock_location.py +76 -0
  55. odoo/addons/shopfloor/models/stock_move.py +119 -0
  56. odoo/addons/shopfloor/models/stock_move_line.py +307 -0
  57. odoo/addons/shopfloor/models/stock_package_level.py +50 -0
  58. odoo/addons/shopfloor/models/stock_picking.py +118 -0
  59. odoo/addons/shopfloor/models/stock_picking_batch.py +41 -0
  60. odoo/addons/shopfloor/models/stock_picking_type.py +26 -0
  61. odoo/addons/shopfloor/models/stock_quant.py +31 -0
  62. odoo/addons/shopfloor/models/stock_quant_package.py +101 -0
  63. odoo/addons/shopfloor/readme/CONTRIBUTORS.rst +18 -0
  64. odoo/addons/shopfloor/readme/CREDITS.rst +5 -0
  65. odoo/addons/shopfloor/readme/DESCRIPTION.rst +17 -0
  66. odoo/addons/shopfloor/readme/HISTORY.rst +4 -0
  67. odoo/addons/shopfloor/readme/ROADMAP.rst +4 -0
  68. odoo/addons/shopfloor/readme/USAGE.rst +6 -0
  69. odoo/addons/shopfloor/security/groups.xml +17 -0
  70. odoo/addons/shopfloor/services/__init__.py +16 -0
  71. odoo/addons/shopfloor/services/checkout.py +1763 -0
  72. odoo/addons/shopfloor/services/cluster_picking.py +1628 -0
  73. odoo/addons/shopfloor/services/delivery.py +828 -0
  74. odoo/addons/shopfloor/services/forms/__init__.py +1 -0
  75. odoo/addons/shopfloor/services/forms/picking_form.py +78 -0
  76. odoo/addons/shopfloor/services/location_content_transfer.py +1194 -0
  77. odoo/addons/shopfloor/services/menu.py +60 -0
  78. odoo/addons/shopfloor/services/picking_batch.py +126 -0
  79. odoo/addons/shopfloor/services/service.py +101 -0
  80. odoo/addons/shopfloor/services/single_pack_transfer.py +366 -0
  81. odoo/addons/shopfloor/services/zone_picking.py +1938 -0
  82. odoo/addons/shopfloor/static/description/icon.png +0 -0
  83. odoo/addons/shopfloor/static/description/index.html +500 -0
  84. odoo/addons/shopfloor/tests/__init__.py +83 -0
  85. odoo/addons/shopfloor/tests/common.py +324 -0
  86. odoo/addons/shopfloor/tests/models.py +29 -0
  87. odoo/addons/shopfloor/tests/test_actions_change_package_lot.py +1175 -0
  88. odoo/addons/shopfloor/tests/test_actions_data.py +376 -0
  89. odoo/addons/shopfloor/tests/test_actions_data_base.py +244 -0
  90. odoo/addons/shopfloor/tests/test_actions_data_detail.py +322 -0
  91. odoo/addons/shopfloor/tests/test_actions_search.py +248 -0
  92. odoo/addons/shopfloor/tests/test_actions_stock.py +48 -0
  93. odoo/addons/shopfloor/tests/test_checkout_auto_post.py +67 -0
  94. odoo/addons/shopfloor/tests/test_checkout_base.py +81 -0
  95. odoo/addons/shopfloor/tests/test_checkout_cancel_line.py +154 -0
  96. odoo/addons/shopfloor/tests/test_checkout_change_packaging.py +184 -0
  97. odoo/addons/shopfloor/tests/test_checkout_done.py +133 -0
  98. odoo/addons/shopfloor/tests/test_checkout_list_delivery_packaging.py +131 -0
  99. odoo/addons/shopfloor/tests/test_checkout_list_package.py +327 -0
  100. odoo/addons/shopfloor/tests/test_checkout_new_package.py +88 -0
  101. odoo/addons/shopfloor/tests/test_checkout_no_package.py +95 -0
  102. odoo/addons/shopfloor/tests/test_checkout_scan.py +174 -0
  103. odoo/addons/shopfloor/tests/test_checkout_scan_line.py +377 -0
  104. odoo/addons/shopfloor/tests/test_checkout_scan_line_base.py +25 -0
  105. odoo/addons/shopfloor/tests/test_checkout_scan_line_no_prefill_qty.py +91 -0
  106. odoo/addons/shopfloor/tests/test_checkout_scan_package_action.py +451 -0
  107. odoo/addons/shopfloor/tests/test_checkout_scan_package_action_no_prefill_qty.py +107 -0
  108. odoo/addons/shopfloor/tests/test_checkout_select.py +74 -0
  109. odoo/addons/shopfloor/tests/test_checkout_select_line.py +130 -0
  110. odoo/addons/shopfloor/tests/test_checkout_select_package_base.py +64 -0
  111. odoo/addons/shopfloor/tests/test_checkout_set_qty.py +257 -0
  112. odoo/addons/shopfloor/tests/test_checkout_summary.py +69 -0
  113. odoo/addons/shopfloor/tests/test_cluster_picking_base.py +83 -0
  114. odoo/addons/shopfloor/tests/test_cluster_picking_batch.py +109 -0
  115. odoo/addons/shopfloor/tests/test_cluster_picking_change_pack_lot.py +111 -0
  116. odoo/addons/shopfloor/tests/test_cluster_picking_is_zero.py +98 -0
  117. odoo/addons/shopfloor/tests/test_cluster_picking_scan_destination.py +376 -0
  118. odoo/addons/shopfloor/tests/test_cluster_picking_scan_destination_no_prefill_qty.py +115 -0
  119. odoo/addons/shopfloor/tests/test_cluster_picking_scan_line.py +402 -0
  120. odoo/addons/shopfloor/tests/test_cluster_picking_scan_line_location_or_pack_first.py +114 -0
  121. odoo/addons/shopfloor/tests/test_cluster_picking_scan_line_no_prefill_qty.py +70 -0
  122. odoo/addons/shopfloor/tests/test_cluster_picking_select.py +387 -0
  123. odoo/addons/shopfloor/tests/test_cluster_picking_skip.py +90 -0
  124. odoo/addons/shopfloor/tests/test_cluster_picking_stock_issue.py +364 -0
  125. odoo/addons/shopfloor/tests/test_cluster_picking_unload.py +911 -0
  126. odoo/addons/shopfloor/tests/test_delivery_base.py +155 -0
  127. odoo/addons/shopfloor/tests/test_delivery_done.py +108 -0
  128. odoo/addons/shopfloor/tests/test_delivery_list_stock_picking.py +49 -0
  129. odoo/addons/shopfloor/tests/test_delivery_reset_qty_done_line.py +119 -0
  130. odoo/addons/shopfloor/tests/test_delivery_reset_qty_done_pack.py +107 -0
  131. odoo/addons/shopfloor/tests/test_delivery_scan_deliver.py +557 -0
  132. odoo/addons/shopfloor/tests/test_delivery_select.py +38 -0
  133. odoo/addons/shopfloor/tests/test_delivery_set_qty_done_line.py +91 -0
  134. odoo/addons/shopfloor/tests/test_delivery_set_qty_done_pack.py +135 -0
  135. odoo/addons/shopfloor/tests/test_delivery_sublocation.py +180 -0
  136. odoo/addons/shopfloor/tests/test_location_content_transfer_base.py +136 -0
  137. odoo/addons/shopfloor/tests/test_location_content_transfer_get_work.py +125 -0
  138. odoo/addons/shopfloor/tests/test_location_content_transfer_mix.py +509 -0
  139. odoo/addons/shopfloor/tests/test_location_content_transfer_putaway.py +143 -0
  140. odoo/addons/shopfloor/tests/test_location_content_transfer_scan_location.py +34 -0
  141. odoo/addons/shopfloor/tests/test_location_content_transfer_set_destination_all.py +343 -0
  142. odoo/addons/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +1074 -0
  143. odoo/addons/shopfloor/tests/test_location_content_transfer_single.py +748 -0
  144. odoo/addons/shopfloor/tests/test_location_content_transfer_start.py +359 -0
  145. odoo/addons/shopfloor/tests/test_menu_base.py +261 -0
  146. odoo/addons/shopfloor/tests/test_menu_counters.py +61 -0
  147. odoo/addons/shopfloor/tests/test_misc.py +25 -0
  148. odoo/addons/shopfloor/tests/test_move_action_assign.py +87 -0
  149. odoo/addons/shopfloor/tests/test_openapi.py +21 -0
  150. odoo/addons/shopfloor/tests/test_picking_form.py +62 -0
  151. odoo/addons/shopfloor/tests/test_scan_anything.py +49 -0
  152. odoo/addons/shopfloor/tests/test_single_pack_transfer.py +1121 -0
  153. odoo/addons/shopfloor/tests/test_single_pack_transfer_base.py +32 -0
  154. odoo/addons/shopfloor/tests/test_single_pack_transfer_putaway.py +104 -0
  155. odoo/addons/shopfloor/tests/test_stock_split.py +204 -0
  156. odoo/addons/shopfloor/tests/test_user.py +42 -0
  157. odoo/addons/shopfloor/tests/test_zone_picking_base.py +608 -0
  158. odoo/addons/shopfloor/tests/test_zone_picking_change_pack_lot.py +140 -0
  159. odoo/addons/shopfloor/tests/test_zone_picking_select_line.py +723 -0
  160. odoo/addons/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py +207 -0
  161. odoo/addons/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py.bak +202 -0
  162. odoo/addons/shopfloor/tests/test_zone_picking_select_line_no_prefill_qty.py +107 -0
  163. odoo/addons/shopfloor/tests/test_zone_picking_select_picking_type.py +26 -0
  164. odoo/addons/shopfloor/tests/test_zone_picking_set_line_destination.py +643 -0
  165. odoo/addons/shopfloor/tests/test_zone_picking_set_line_destination_no_prefill_qty.py +146 -0
  166. odoo/addons/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py +241 -0
  167. odoo/addons/shopfloor/tests/test_zone_picking_start.py +206 -0
  168. odoo/addons/shopfloor/tests/test_zone_picking_stock_issue.py +121 -0
  169. odoo/addons/shopfloor/tests/test_zone_picking_unload_all.py +353 -0
  170. odoo/addons/shopfloor/tests/test_zone_picking_unload_buffer_lines.py +113 -0
  171. odoo/addons/shopfloor/tests/test_zone_picking_unload_set_destination.py +374 -0
  172. odoo/addons/shopfloor/tests/test_zone_picking_unload_single.py +123 -0
  173. odoo/addons/shopfloor/tests/test_zone_picking_zero_check.py +43 -0
  174. odoo/addons/shopfloor/utils.py +13 -0
  175. odoo/addons/shopfloor/views/shopfloor_menu.xml +167 -0
  176. odoo/addons/shopfloor/views/stock_location.xml +20 -0
  177. odoo/addons/shopfloor/views/stock_move_line.xml +52 -0
  178. odoo/addons/shopfloor/views/stock_picking_type.xml +19 -0
  179. odoo_addon_shopfloor-16.0.1.0.0.24.dist-info/METADATA +192 -0
  180. odoo_addon_shopfloor-16.0.1.0.0.24.dist-info/RECORD +182 -0
  181. odoo_addon_shopfloor-16.0.1.0.0.24.dist-info/WHEEL +5 -0
  182. odoo_addon_shopfloor-16.0.1.0.0.24.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1938 @@
1
+ # Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com)
2
+ # Copyright 2020-2021 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
3
+ # Copyright 2023 Michael Tietz (MT Software) <mtietz@mt-software.de>
4
+ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
5
+ import functools
6
+ from collections import defaultdict
7
+
8
+ from odoo.fields import first
9
+ from odoo.tools.float_utils import float_compare, float_is_zero
10
+
11
+ from odoo.addons.base_rest.components.service import to_bool, to_int
12
+ from odoo.addons.component.core import Component
13
+
14
+ from ..exceptions import ConcurentWorkOnTransfer
15
+ from ..utils import to_float
16
+
17
+
18
+ class ZonePicking(Component):
19
+ """
20
+ Methods for the Zone Picking Process
21
+
22
+ Zone picking of move lines.
23
+
24
+ You will find a sequence diagram describing states and endpoints
25
+ relationships [here](../docs/zone_picking_diag_seq.png).
26
+ Keep [the sequence diagram](../docs/zone_picking_diag_seq.plantuml)
27
+ up-to-date if you change endpoints.
28
+
29
+ Note:
30
+
31
+ * Several operation types could be linked to a single menu item
32
+ * If several operator work in a same zone, they’ll see the same move lines but
33
+ will only posts theirs when unloading their goods, which means that when they
34
+ scan lines, the backend has to store the user id on the move lines
35
+
36
+ Workflow:
37
+
38
+ 1. The operator scans the zone location with goods to pick (zone location
39
+ meaning a parent location, not a leaf)
40
+ 2. If the zone contains lines from different picking types, the operator
41
+ chooses the type to work with
42
+ 3. The client application shows the list of move lines, with an option
43
+ to choose the sorting of the lines
44
+ 4. The operator selects a line to pick, by scanning one of:
45
+
46
+ * location, if only a single move line there; if a location is scanned
47
+ and it contains several move lines, the view is updated to show only
48
+ them. The next scan (e.g. a product) will be based on the previous
49
+ scanned location.
50
+ * package, if it is linked to a move line. If the package is not linked
51
+ to an existing move line but can be a replacement for one, the view is
52
+ updated to show only the fitting move lines. And the user can confirm
53
+ the change of package by scanning it a second time.
54
+ * product, if only a single move line matches. Otherwise the view is updated
55
+ to show only the matching move lines, The next scan (e.g. a location) will
56
+ be based on the previous product scanned.
57
+ * lot
58
+
59
+ 5. The operator scans the destination for the line they scanned, this is where
60
+ the path splits:
61
+
62
+ * they scan a location, in which case the move line's destination is
63
+ updated with it and the move is done
64
+ * they scan a package, which becomes the destination package of the move
65
+ line, the move line is not set to done, its ``qty_done`` is updated
66
+ and a field ``shopfloor_user_id`` is set to the user; consider the
67
+ move line is set in a buffer
68
+
69
+ 6. At any point, from the list of moves, the operator can reach the
70
+ "unload" screens to unload what they had put into the buffer (scanned a
71
+ destination package during step 5.). This is optional as they can directly
72
+ move whole pallets by scanning the destination in step 5.
73
+ 7. The unload screens (similar to those of the Cluster Picking workflow) are
74
+ used to move what has been put in the buffer:
75
+
76
+ * if the original destination of all the lines is unique, screen allows
77
+ to scan a single destination; they can use a "split" button to go to
78
+ the line by line screen
79
+ * if the lines have different destinations, they have to scan the destination
80
+ package, then scan the destination location, scan the next package and its
81
+ destination and so on.
82
+
83
+ The list of move lines (point 4.) has support functions:
84
+
85
+ * Change a lot or pack: if the expected lot is at the very bottom of the
86
+ location or a stock error forces a user to change lot or pack, user can
87
+ do it during the picking.
88
+ * Declare stock out: if a good is in fact not in stock or only partially.
89
+ Note the move lines may become unavailable or partially unavailable and
90
+ generate a back-order.
91
+
92
+ """
93
+
94
+ _inherit = "base.shopfloor.process"
95
+ _name = "shopfloor.zone.picking"
96
+ _usage = "zone_picking"
97
+ _description = __doc__
98
+
99
+ @property
100
+ def _validation_rules(self):
101
+ return super()._validation_rules + (
102
+ # rule to apply, active flag handler
103
+ (self.ZONE_LOCATION_ID_HEADER_RULE, self._requires_header_zone_picking),
104
+ (self.PICKING_TYPE_ID_HEADER_RULE, self._requires_header_zone_picking),
105
+ (self.LINES_ORDER_HEADER_RULE, self._requires_header_zone_picking),
106
+ )
107
+
108
+ def _requires_header_zone_picking(self, request, method):
109
+ # TODO: maybe we should have a decorator?
110
+ return method not in ("select_zone", "scan_location")
111
+
112
+ ZONE_LOCATION_ID_HEADER_RULE = (
113
+ # header name, coerce func, ctx handler, mandatory
114
+ "HTTP_SERVICE_CTX_ZONE_LOCATION_ID",
115
+ int,
116
+ "_work_ctx_get_zone_location_id",
117
+ True,
118
+ )
119
+ PICKING_TYPE_ID_HEADER_RULE = (
120
+ # header name, coerce func, ctx handler, mandatory
121
+ "HTTP_SERVICE_CTX_PICKING_TYPE_ID",
122
+ int,
123
+ "_work_ctx_get_picking_type_id",
124
+ True,
125
+ )
126
+ LINES_ORDER_HEADER_RULE = (
127
+ # header name, coerce func, ctx handler, mandatory
128
+ "HTTP_SERVICE_CTX_LINES_ORDER",
129
+ str,
130
+ "_work_ctx_get_lines_order",
131
+ True,
132
+ )
133
+
134
+ def _work_ctx_get_zone_location_id(self, rec_id):
135
+ return (
136
+ "current_zone_location",
137
+ self.env["stock.location"].browse(rec_id).exists(),
138
+ )
139
+
140
+ def _work_ctx_get_picking_type_id(self, rec_id):
141
+ return (
142
+ "current_picking_type",
143
+ self.env["stock.picking.type"].browse(rec_id).exists(),
144
+ )
145
+
146
+ def _work_ctx_get_lines_order(self, order):
147
+ return "current_lines_order", order
148
+
149
+ @property
150
+ def zone_location(self):
151
+ return self.work.current_zone_location
152
+
153
+ @property
154
+ def picking_type(self):
155
+ return getattr(self.work, "current_picking_type", None)
156
+
157
+ @property
158
+ def lines_order(self):
159
+ return getattr(self.work, "current_lines_order", "priority")
160
+
161
+ def _pick_pack_same_time(self):
162
+ return self.work.menu.pick_pack_same_time
163
+
164
+ def _response_for_start(self, message=None):
165
+ zones = self.work.menu.picking_type_ids.mapped(
166
+ "default_location_src_id.child_ids"
167
+ )
168
+ data = {"zones": self._data_for_select_zone(zones)}
169
+ buffer = self._find_buffer_move_lines()
170
+ if buffer:
171
+ # Some lines can be unloaded, let the user know
172
+ # The call to the endpoint will need the location and picking id
173
+ line = first(buffer)
174
+ picking_type = line.picking_id.picking_type_id
175
+ zone = line.move_id.location_id
176
+ data["buffer"] = {
177
+ "zone_location": self.data.location(zone),
178
+ "picking_type": self.data.picking_type(picking_type),
179
+ }
180
+ return self._response(
181
+ next_state="start",
182
+ data=data,
183
+ message=message,
184
+ )
185
+
186
+ def _response_for_select_picking_type(
187
+ self, zone_location, picking_types, message=None
188
+ ):
189
+ return self._response(
190
+ next_state="select_picking_type",
191
+ data=self._data_for_select_picking_type(zone_location, picking_types),
192
+ message=message,
193
+ )
194
+
195
+ def _response_for_select_line(
196
+ self,
197
+ move_lines,
198
+ message=None,
199
+ popup=None,
200
+ confirmation_required=False,
201
+ product=False,
202
+ sublocation=False,
203
+ package=False,
204
+ ):
205
+ if confirmation_required and not message:
206
+ message = self.msg_store.need_confirmation()
207
+ data = self._data_for_move_lines(
208
+ move_lines, product=product, sublocation=sublocation, package=package
209
+ )
210
+ data["confirmation_required"] = confirmation_required
211
+ data["scan_location_or_pack_first"] = self.work.menu.scan_location_or_pack_first
212
+ return self._response(
213
+ next_state="select_line",
214
+ data=data,
215
+ message=message,
216
+ popup=popup,
217
+ )
218
+
219
+ def _response_for_set_line_destination(
220
+ self,
221
+ move_line,
222
+ message=None,
223
+ confirmation_required=False,
224
+ **kw,
225
+ ):
226
+ if confirmation_required and not message:
227
+ message = self.msg_store.need_confirmation()
228
+ data = self._data_for_move_line(move_line)
229
+ data["move_line"].update(kw)
230
+ data["confirmation_required"] = confirmation_required
231
+ return self._response(
232
+ next_state="set_line_destination", data=data, message=message
233
+ )
234
+
235
+ def _response_for_zero_check(self, move_line, message=None):
236
+ data = self._data_for_location(move_line.location_id)
237
+ data["move_line"] = self.data.move_line(move_line)
238
+ return self._response(
239
+ next_state="zero_check",
240
+ data=data,
241
+ message=message,
242
+ )
243
+
244
+ def _response_for_change_pack_lot(self, move_line, message=None):
245
+ return self._response(
246
+ next_state="change_pack_lot",
247
+ data=self._data_for_move_line(move_line),
248
+ message=message,
249
+ )
250
+
251
+ def _response_for_unload_all(
252
+ self,
253
+ move_lines,
254
+ message=None,
255
+ confirmation_required=False,
256
+ ):
257
+ if confirmation_required and not message:
258
+ message = self.msg_store.need_confirmation()
259
+ data = self._data_for_move_lines(move_lines)
260
+ data["confirmation_required"] = confirmation_required
261
+ return self._response(next_state="unload_all", data=data, message=message)
262
+
263
+ def _response_for_unload_single(self, move_line, message=None, popup=None):
264
+ buffer_lines = self._find_buffer_move_lines()
265
+ completion_info = self._actions_for("completion.info")
266
+ completion_info_popup = completion_info.popup(buffer_lines)
267
+ return self._response(
268
+ next_state="unload_single",
269
+ data=self._data_for_move_line(move_line),
270
+ message=message,
271
+ popup=popup or completion_info_popup,
272
+ )
273
+
274
+ def _response_for_unload_set_destination(
275
+ self,
276
+ move_line,
277
+ message=None,
278
+ confirmation_required=False,
279
+ ):
280
+ if confirmation_required and not message:
281
+ message = self.msg_store.need_confirmation()
282
+ data = self._data_for_move_line(move_line)
283
+ data["confirmation_required"] = confirmation_required
284
+ return self._response(
285
+ next_state="unload_set_destination", data=data, message=message
286
+ )
287
+
288
+ def _data_for_select_picking_type(self, zone_location, picking_types):
289
+ data = {
290
+ "zone_location": self.data.location(zone_location),
291
+ # available picking types to choose from
292
+ "picking_types": self.data.picking_types(picking_types),
293
+ }
294
+ for datum in data["picking_types"]:
295
+ picking_type = self.env["stock.picking.type"].browse(datum["id"])
296
+ zone_lines = self._picking_type_zone_lines(zone_location, picking_type)
297
+ counters = self._counters_for_zone_lines(zone_lines)
298
+ datum.update(counters)
299
+ return data
300
+
301
+ def _counters_for_zone_lines(self, zone_lines):
302
+ return self.search_move_line.counters_for_lines(zone_lines)
303
+
304
+ def _picking_type_zone_lines(self, zone_location, picking_type):
305
+ return self.search_move_line.search_move_lines_by_location(
306
+ zone_location, picking_type=picking_type
307
+ )
308
+
309
+ def _data_for_move_line(
310
+ self, move_line, zone_location=None, picking_type=None, **kw
311
+ ):
312
+ zone_location = zone_location or self.zone_location
313
+ picking_type = picking_type or self.picking_type
314
+ line_data = self.data.move_line(move_line, with_picking=True)
315
+ line_data.update(kw)
316
+ return {
317
+ "zone_location": self.data.location(zone_location),
318
+ "picking_type": self.data.picking_type(picking_type),
319
+ "move_line": line_data,
320
+ }
321
+
322
+ def _data_for_move_lines(
323
+ self,
324
+ move_lines,
325
+ zone_location=None,
326
+ picking_type=None,
327
+ product=None,
328
+ sublocation=None,
329
+ package=None,
330
+ ):
331
+ zone_location = zone_location or self.zone_location
332
+ picking_type = picking_type or self.picking_type
333
+ data = {
334
+ "zone_location": self.data.location(zone_location),
335
+ "picking_type": self.data.picking_type(picking_type),
336
+ "move_lines": self.data.move_lines(move_lines, with_picking=True),
337
+ }
338
+ if product:
339
+ data["product"] = self.data.product(product)
340
+ if sublocation and sublocation != zone_location:
341
+ data["sublocation"] = self.data.location(sublocation)
342
+ if package:
343
+ data["package"] = self.data.package(package)
344
+ for data_move_line in data["move_lines"]:
345
+ # TODO: this could be expensive, think about a better way
346
+ # to retrieve if location will be empty.
347
+ # Maybe group lines by location and compute only once.
348
+ move_line = self.env["stock.move.line"].browse(data_move_line["id"])
349
+ # `location_will_be_empty` flag states if, by processing this move line
350
+ # and picking the product, the location will be emptied.
351
+ data_move_line[
352
+ "location_will_be_empty"
353
+ ] = move_line.location_id.planned_qty_in_location_is_empty(move_line)
354
+ return data
355
+
356
+ def _data_for_location(self, location, zone_location=None, picking_type=None):
357
+ zone_location = zone_location or self.zone_location
358
+ picking_type = picking_type or self.picking_type
359
+ return {
360
+ "zone_location": self.data.location(zone_location),
361
+ "picking_type": self.data.picking_type(picking_type),
362
+ "location": self.data.location(location),
363
+ }
364
+
365
+ def _zone_lines(self, zones):
366
+ return self._find_location_move_lines(zones)
367
+
368
+ def _data_for_select_zone(self, zones):
369
+ """Retrieve detailed info for each zone.
370
+
371
+ Zone without lines are skipped.
372
+ Zone with lines will have line counters by operation type.
373
+
374
+ :param zones: zone location recordset
375
+ :return: see _schema_for_select_zone
376
+ """
377
+ res = []
378
+ for zone in zones:
379
+ zone_data = self.data.location(zone)
380
+ zone_lines = self._zone_lines(zone)
381
+ if not zone_lines:
382
+ continue
383
+ lines_by_op_type = defaultdict(list)
384
+ for line in zone_lines:
385
+ lines_by_op_type[line.picking_id.picking_type_id].append(line)
386
+
387
+ zone_data["operation_types"] = []
388
+ zone_counters = defaultdict(int)
389
+ for picking_type, lines in lines_by_op_type.items():
390
+ op_type_data = self.data.picking_type(picking_type)
391
+ counters = self._counters_for_zone_lines(lines)
392
+ op_type_data.update(counters)
393
+ zone_data["operation_types"].append(op_type_data)
394
+ for k, v in counters.items():
395
+ zone_counters[k] += v
396
+ zone_data.update(zone_counters)
397
+ res.append(zone_data)
398
+ return res
399
+
400
+ def _find_location_move_lines(
401
+ self,
402
+ locations=None,
403
+ picking_type=None,
404
+ package=None,
405
+ product=None,
406
+ lot=None,
407
+ match_user=False,
408
+ enforce_empty_package=False,
409
+ ):
410
+ """Find lines that potentially need work in given locations."""
411
+ return self.search_move_line.search_move_lines_by_location(
412
+ locations or self.zone_location,
413
+ picking_type=picking_type or self.picking_type,
414
+ order=self.lines_order,
415
+ package=package,
416
+ product=product,
417
+ lot=lot,
418
+ match_user=match_user,
419
+ enforce_empty_package=enforce_empty_package,
420
+ )
421
+
422
+ def _find_buffer_move_lines_domain(self, dest_package=None):
423
+ domain = [
424
+ ("picking_id.picking_type_id", "in", self.picking_types.ids),
425
+ ("qty_done", ">", 0),
426
+ ("state", "not in", ("cancel", "done")),
427
+ ("result_package_id", "!=", False),
428
+ ("shopfloor_user_id", "=", self.env.user.id),
429
+ ]
430
+ if dest_package:
431
+ domain.append(("result_package_id", "=", dest_package.id))
432
+ return domain
433
+
434
+ def _find_buffer_move_lines(self, dest_package=None):
435
+ """Find lines that belongs to the operator's buffer and return them
436
+ grouped by destination package.
437
+ """
438
+ domain = self._find_buffer_move_lines_domain(dest_package)
439
+ return (
440
+ self.env["stock.move.line"]
441
+ .search(domain)
442
+ .sorted(self.search_move_line._sort_key_move_lines(self.lines_order))
443
+ )
444
+
445
+ def _group_buffer_move_lines_by_package(self, move_lines):
446
+ data = {}
447
+ for move_line in move_lines:
448
+ data.setdefault(move_line.result_package_id, move_line.browse())
449
+ data[move_line.result_package_id] |= move_line
450
+ return data
451
+
452
+ def select_zone(self):
453
+ """Retrieve all available zones to work with.
454
+
455
+ A zone is defined by the first level location below the source location
456
+ of the operation types linked to the menu.
457
+
458
+ The count of lines to process by available operations is computed per each zone.
459
+ """
460
+ return self._response_for_start()
461
+
462
+ def scan_location(self, barcode):
463
+ """Scan the zone location where the picking should occur
464
+
465
+ The location must be a sub-location of one of the picking types'
466
+ default source locations of the menu.
467
+
468
+ Transitions:
469
+ * start: invalid barcode
470
+ * select_picking_type: the location is valid, user has to choose a picking type
471
+ """
472
+ search = self._actions_for("search")
473
+ zone_location = search.location_from_scan(barcode)
474
+ if not zone_location:
475
+ return self._response_for_start(message=self.msg_store.no_location_found())
476
+ if not self.is_src_location_valid(zone_location):
477
+ return self._response_for_start(
478
+ message=self.msg_store.location_not_allowed()
479
+ )
480
+ move_lines = self._find_location_move_lines(zone_location)
481
+ if not move_lines:
482
+ return self._response_for_start(
483
+ message=self.msg_store.no_lines_to_process()
484
+ )
485
+ picking_types = move_lines.picking_id.picking_type_id
486
+ return self._response_for_select_picking_type(zone_location, picking_types)
487
+
488
+ def list_move_lines(self):
489
+ """List all move lines to pick, sorted
490
+
491
+ Transitions:
492
+ * select_line: show the list of move lines
493
+ """
494
+ return self._list_move_lines(self.zone_location)
495
+
496
+ def _list_move_lines(
497
+ self, location, product=False, sublocation=False, package=False
498
+ ):
499
+ move_lines = self._find_location_move_lines(
500
+ sublocation or location, product=product, package=package, match_user=True
501
+ )
502
+ return self._response_for_select_line(
503
+ move_lines, product=product, sublocation=sublocation, package=package
504
+ )
505
+
506
+ def _scan_source_location(
507
+ self,
508
+ barcode,
509
+ confirmation=False,
510
+ product_id=False,
511
+ sublocation=False,
512
+ package=False,
513
+ ):
514
+ """Search a location and find available lines into it."""
515
+ response = None
516
+ message = None
517
+ search = self._actions_for("search")
518
+ location = search.location_from_scan(barcode)
519
+ if not location:
520
+ return response, message
521
+
522
+ if not location.is_sublocation_of(self.zone_location):
523
+ response = self._list_move_lines(self.zone_location)
524
+ message = self.msg_store.location_not_allowed()
525
+ return response, message
526
+
527
+ if package and package.location_id != location:
528
+ # Do not search based on a package from a previous location
529
+ package = False
530
+ product, lot, packages = self._find_product_in_location(
531
+ location, product_id, package
532
+ )
533
+ if len(packages) > 1:
534
+ message = self.msg_store.several_packs_in_location(location)
535
+ elif len(packages) == 1 and self.work.menu.scan_location_or_pack_first:
536
+ message = self.msg_store.scan_the_package()
537
+ elif len(product) > 1 and not message:
538
+ message = self.msg_store.several_products_in_location(location)
539
+ elif len(lot) > 1 and not message:
540
+ message = self.msg_store.several_lots_in_location(location)
541
+ if message:
542
+ response = self._list_move_lines(
543
+ location, sublocation=location, package=package
544
+ )
545
+ return response, message
546
+ move_lines = self._find_location_move_lines(
547
+ location,
548
+ product=product,
549
+ lot=lot,
550
+ package=package,
551
+ match_user=True,
552
+ )
553
+ if move_lines:
554
+ move_line = first(move_lines)
555
+ response = self._response_for_set_line_destination(
556
+ move_line, qty_done=self._get_prefill_qty(move_line)
557
+ )
558
+ else:
559
+ response = self._list_move_lines(self.zone_location)
560
+ message = self.msg_store.wrong_record(location)
561
+ return response, message
562
+
563
+ def _find_product_in_location(self, location, product_id, package=False):
564
+ """Find the prooducts in stock in given location move line in the location."""
565
+ domain = [("location_id", "=", location.id)]
566
+ if product_id:
567
+ domain.append(("product_id", "=", product_id))
568
+ if package:
569
+ domain.append(("package_id", "=", package.id))
570
+ quants = self.env["stock.quant"].search(domain)
571
+ product = quants.product_id
572
+ lot = quants.lot_id
573
+ package = quants.package_id
574
+ return product, lot, package
575
+
576
+ def _scan_source_package(
577
+ self,
578
+ barcode,
579
+ confirmation=False,
580
+ product_id=False,
581
+ sublocation=False,
582
+ package=False,
583
+ ):
584
+ """Search a package and find available lines for it.
585
+
586
+ First search for lines that have the specific package.
587
+ If none are found search for lines whose package could be replaced
588
+ by the one selected and in that case ask for confirmation.
589
+ """
590
+ message = None
591
+ response = None
592
+ search = self._actions_for("search")
593
+ packaging = self._actions_for("packaging")
594
+ package = search.package_from_scan(barcode)
595
+ if not package:
596
+ return response, message
597
+ if not package.location_id.is_sublocation_of(self.zone_location):
598
+ # Package is not in an allowed location
599
+ response = self._list_move_lines(self.zone_location)
600
+ message = self.msg_store.location_not_allowed()
601
+ return response, message
602
+
603
+ move_lines = self._find_location_move_lines(
604
+ locations=sublocation, package=package
605
+ )
606
+ if move_lines:
607
+ if packaging.package_has_several_products(package):
608
+ message = self.msg_store.several_products_in_package(package)
609
+ if packaging.package_has_several_lots(package):
610
+ message = self.msg_store.several_lots_in_package(package)
611
+ if message:
612
+ return (
613
+ self._list_move_lines(
614
+ self.zone_location,
615
+ sublocation=sublocation,
616
+ package=package,
617
+ ),
618
+ message,
619
+ )
620
+ move_line = first(move_lines)
621
+ # Fix me for a package prefill qty is zero ?
622
+ qty_done = self._get_prefill_qty(move_line)
623
+ response = self._response_for_set_line_destination(
624
+ move_line, qty_done=qty_done
625
+ )
626
+ return response, message
627
+ # Check if the package selected can be a substitute on a move line
628
+ products = package.quant_ids.filtered(lambda q: q.quantity > 0).product_id
629
+ for product in products:
630
+ move_lines |= self._find_location_move_lines(
631
+ locations=package.location_id,
632
+ product=product,
633
+ )
634
+ if move_lines:
635
+ if not confirmation:
636
+ message = self.msg_store.package_different_change()
637
+ response = self._response_for_select_line(
638
+ move_lines, message, confirmation_required=True
639
+ )
640
+ else:
641
+ change_package_lot = self._actions_for("change.package.lot")
642
+ response = change_package_lot.change_package(
643
+ first(move_lines),
644
+ package,
645
+ # FIXME we may need to pass the quantity being done
646
+ self._response_for_set_line_destination,
647
+ self._response_for_change_pack_lot,
648
+ )
649
+ else:
650
+ response = self._list_move_lines(sublocation or self.zone_location)
651
+ message = self.msg_store.package_has_no_product_to_take(barcode)
652
+ return response, message
653
+
654
+ def _get_prefill_qty(self, move_line, qty=0):
655
+ """Returns the done quantity to use on the selection of a move line.
656
+
657
+ Before the introduction of the no prefill quantity parameter on scenarios,
658
+ when a move line was selected the done quantity was equal to the quantity
659
+ on the line. This is still the default behaviour.
660
+ But when the no prefill quantity is set. The quantity done will be set
661
+ according to the scanned barcode.
662
+
663
+ """
664
+ if self.work.menu.no_prefill_qty:
665
+ return qty
666
+ return move_line.reserved_uom_qty
667
+
668
+ def _scan_source_product(
669
+ self,
670
+ barcode,
671
+ confirmation=False,
672
+ product_id=False,
673
+ sublocation=False,
674
+ package=False,
675
+ ):
676
+ """Search a product and find available lines for it."""
677
+ message = None
678
+ response = None
679
+ search = self._actions_for("search")
680
+ product = search.product_from_scan(barcode)
681
+ packaging = self.env["product.packaging"].browse()
682
+ if not product:
683
+ packaging = search.packaging_from_scan(barcode)
684
+ product = packaging.product_id
685
+ if not product:
686
+ return response, message
687
+ move_lines = self._find_location_move_lines(
688
+ locations=sublocation,
689
+ product=product,
690
+ package=package,
691
+ enforce_empty_package=self.work.menu.scan_location_or_pack_first,
692
+ )
693
+
694
+ move_lines_with_package_ids = []
695
+ move_lines_without_package_ids = []
696
+ if not package and self.work.menu.scan_location_or_pack_first:
697
+ for move_line in move_lines:
698
+ if move_line.package_id:
699
+ move_lines_with_package_ids.append(move_line.id)
700
+ else:
701
+ move_lines_without_package_ids.append(move_line.id)
702
+ move_lines = move_lines.browse(move_lines_without_package_ids)
703
+
704
+ if len(move_lines.location_id) > 1:
705
+ message = self.msg_store.several_move_in_different_location()
706
+ elif len(move_lines.lot_id) > 1:
707
+ message = self.msg_store.several_move_with_different_lot()
708
+ if message:
709
+ response = self._list_move_lines(
710
+ self.zone_location, product, sublocation=sublocation, package=package
711
+ )
712
+ elif move_lines:
713
+ move_line = first(move_lines)
714
+ qty_done = self._get_prefill_qty(move_line, qty=(packaging.qty or 1.0))
715
+ response = self._response_for_set_line_destination(
716
+ move_line, qty_done=qty_done
717
+ )
718
+ else:
719
+ response = self._list_move_lines(
720
+ sublocation or self.zone_location,
721
+ sublocation=sublocation,
722
+ package=package,
723
+ )
724
+ if move_lines_with_package_ids:
725
+ message = self.msg_store.product_not_unitary_in_package_scan_package()
726
+ else:
727
+ message = self.msg_store.product_not_found_in_pickings()
728
+ return response, message
729
+
730
+ def _scan_source_lot(
731
+ self,
732
+ barcode,
733
+ confirmation=False,
734
+ product_id=False,
735
+ sublocation=False,
736
+ package=False,
737
+ ):
738
+ """Search a lot and find available lines for it."""
739
+ message = None
740
+ response = None
741
+ search = self._actions_for("search")
742
+ products = self.env["product.product"].browse(product_id)
743
+ # Could get several lots from different products, check each of them
744
+ lots = search.lot_from_scan(barcode, products=products, limit=None)
745
+ if not lots:
746
+ return response, message
747
+ move_lines_with_package_ids = []
748
+ move_lines_without_package_ids = []
749
+ for lot in lots:
750
+ move_lines = self._find_location_move_lines(
751
+ locations=sublocation, lot=lot, package=package
752
+ )
753
+ if not move_lines:
754
+ continue
755
+ if not package and self.work.menu.scan_location_or_pack_first:
756
+ for move_line in move_lines:
757
+ if move_line.package_id:
758
+ move_lines_with_package_ids.append(move_line.id)
759
+ else:
760
+ move_lines_without_package_ids.append(move_line.id)
761
+ move_lines = move_lines.browse(move_lines_without_package_ids)
762
+
763
+ if len(move_lines.location_id) > 1:
764
+ message = self.msg_store.several_move_in_different_location()
765
+ response = self.list_move_lines()
766
+ else:
767
+ move_line = first(move_lines)
768
+ qty_done = self._get_prefill_qty(move_line, qty=1.0)
769
+ response = self._response_for_set_line_destination(
770
+ move_line, qty_done=qty_done
771
+ )
772
+ return response, message
773
+ message = self.msg_store.lot_not_found_in_pickings()
774
+ response = self._list_move_lines(
775
+ sublocation or self.zone_location, package=package
776
+ )
777
+ if move_lines_with_package_ids:
778
+ message = self.msg_store.lot_mixed_package_scan_package()
779
+ else:
780
+ message = self.msg_store.lot_not_found_in_pickings()
781
+ return response, message
782
+
783
+ def scan_source(
784
+ self,
785
+ barcode,
786
+ confirmation=False,
787
+ product_id=None,
788
+ sublocation_id=None,
789
+ package_id=None,
790
+ ):
791
+ """Select a move line or narrow the list of move lines
792
+
793
+ When the barcode is a location and we can unambiguously know which move
794
+ line is picked (the quants in the location has one product/lot/package,
795
+ matching a single move line), then the move line is selected.
796
+ Otherwise, the list of move lines is refreshed with a filter on the
797
+ scanned location, showing the move lines that have this location as
798
+ source.
799
+
800
+ When the barcode is a package, a product or a lot, the first matching
801
+ line is selected.
802
+
803
+ A selected line goes to the next screen to select the destination
804
+ location or package.
805
+
806
+ If a product is passed to the function the search on move line will
807
+ be filtered based on it as well.
808
+
809
+ And if a sublocation_id is passed the search on move line will be restriced
810
+ to it.
811
+
812
+ Transitions:
813
+ * select_line: barcode not found or narrow the list on a location
814
+ * set_line_destination: a line has been selected for picking
815
+ """
816
+ # select corresponding move line from barcode (location, package, product, lot)
817
+ sublocation = (
818
+ self.env["stock.location"].browse(sublocation_id).exists()
819
+ if sublocation_id
820
+ else self.env["stock.location"]
821
+ )
822
+ selected_package = (
823
+ self.env["stock.quant.package"].browse(package_id).exists()
824
+ if package_id
825
+ else self.env["stock.quant.package"]
826
+ )
827
+ handlers = (
828
+ # search by location 1st
829
+ self._scan_source_location,
830
+ # then by package
831
+ self._scan_source_package,
832
+ ) + (
833
+ # if first scan location or pack option is not set
834
+ # or the sublocation has already been scanned
835
+ (
836
+ # by product
837
+ self._scan_source_product,
838
+ # then by lot
839
+ self._scan_source_lot,
840
+ )
841
+ if not self.work.menu.scan_location_or_pack_first
842
+ or sublocation_id
843
+ or selected_package
844
+ else ()
845
+ )
846
+ for handler in handlers:
847
+ response, message = handler(
848
+ barcode,
849
+ confirmation=confirmation,
850
+ product_id=product_id,
851
+ sublocation=sublocation,
852
+ package=selected_package,
853
+ )
854
+ if response:
855
+ return self._response(base_response=response, message=message)
856
+ response = self._list_move_lines(
857
+ self.zone_location, sublocation=sublocation, package=selected_package
858
+ )
859
+ return self._response(
860
+ base_response=response, message=self.msg_store.barcode_not_found()
861
+ )
862
+
863
+ def _set_destination_location(self, move_line, quantity, confirmation, location):
864
+ location_changed = False
865
+ response = None
866
+
867
+ # A valid location is a sub-location of the original destination, or a
868
+ # any sub-location of the picking type's default destination location
869
+ # if `confirmation is True
870
+ # Ask confirmation to the user if the scanned location is not in the
871
+ # expected ones but is valid (in picking type's default destination)
872
+ if not self.is_dest_location_valid(move_line.move_id, location):
873
+ response = self._response_for_set_line_destination(
874
+ move_line,
875
+ message=self.msg_store.dest_location_not_allowed(),
876
+ qty_done=quantity,
877
+ )
878
+ return (location_changed, response)
879
+
880
+ if not confirmation and self.is_dest_location_to_confirm(
881
+ move_line.location_dest_id, location
882
+ ):
883
+ response = self._response_for_set_line_destination(
884
+ move_line,
885
+ message=self.msg_store.confirm_location_changed(
886
+ move_line.location_dest_id, location
887
+ ),
888
+ confirmation_required=True,
889
+ qty_done=quantity,
890
+ )
891
+ return (location_changed, response)
892
+
893
+ # If no destination package
894
+ if not move_line.result_package_id:
895
+ response = self._response_for_set_line_destination(
896
+ move_line,
897
+ message=self.msg_store.dest_package_required(),
898
+ qty_done=quantity,
899
+ )
900
+ return (location_changed, response)
901
+ # destination location set to the scanned one
902
+ self._write_destination_on_lines(move_line, location)
903
+ stock = self._actions_for("stock")
904
+ try:
905
+ stock.mark_move_line_as_picked(move_line, quantity, check_user=True)
906
+ except ConcurentWorkOnTransfer as error:
907
+ response = self._response_for_set_line_destination(
908
+ move_line,
909
+ message={
910
+ "message_type": "error",
911
+ "body": str(error),
912
+ },
913
+ qty_done=quantity,
914
+ )
915
+ return (location_changed, response)
916
+ stock.validate_moves(move_line.move_id)
917
+ location_changed = True
918
+ # Zero check
919
+ zero_check = self.picking_type.shopfloor_zero_check
920
+ if zero_check and move_line.location_id.planned_qty_in_location_is_empty():
921
+ response = self._response_for_zero_check(move_line)
922
+ return (location_changed, response)
923
+
924
+ def _is_package_empty(self, package):
925
+ return not bool(package.quant_ids)
926
+
927
+ def _is_package_already_used(self, package):
928
+ # Deprecated, use planned_move_line_ids instead
929
+ return bool(package.planned_move_line_ids)
930
+
931
+ def _move_line_compare_qty(self, move_line, qty):
932
+ rounding = move_line.product_uom_id.rounding
933
+ return float_compare(
934
+ qty, move_line.reserved_uom_qty, precision_rounding=rounding
935
+ )
936
+
937
+ def _move_line_full_qty(self, move_line, qty):
938
+ rounding = move_line.product_uom_id.rounding
939
+ return float_is_zero(
940
+ move_line.reserved_uom_qty - qty, precision_rounding=rounding
941
+ )
942
+
943
+ def _set_destination_package(self, move_line, quantity, package):
944
+ package_changed = False
945
+ response = None
946
+ # A valid package is:
947
+ # * an empty package
948
+ # * not used as destination for another move line
949
+ if not self._is_package_empty(package):
950
+ response = self._response_for_set_line_destination(
951
+ move_line,
952
+ message=self.msg_store.package_not_empty(package),
953
+ qty_done=quantity,
954
+ )
955
+ return (package_changed, response)
956
+ multiple_move_allowed = self.work.menu.multiple_move_single_pack
957
+ if package.planned_move_line_ids and not multiple_move_allowed:
958
+ response = self._response_for_set_line_destination(
959
+ move_line,
960
+ message=self.msg_store.package_already_used(package),
961
+ qty_done=quantity,
962
+ )
963
+ return (package_changed, response)
964
+ # the quantity done is set to the passed quantity
965
+ # but if we move a partial qty, we need to split the move line
966
+ compare = self._move_line_compare_qty(move_line, quantity)
967
+ qty_greater = compare == 1
968
+ if qty_greater:
969
+ response = self._response_for_set_line_destination(
970
+ move_line,
971
+ message=self.msg_store.unable_to_pick_more(move_line.reserved_uom_qty),
972
+ qty_done=quantity,
973
+ )
974
+ return (package_changed, response)
975
+ stock = self._actions_for("stock")
976
+ try:
977
+ stock.mark_move_line_as_picked(
978
+ move_line, quantity, package, check_user=True
979
+ )
980
+ except ConcurentWorkOnTransfer as error:
981
+ response = self._response_for_set_line_destination(
982
+ move_line,
983
+ message={
984
+ "message_type": "error",
985
+ "body": str(error),
986
+ },
987
+ qty_done=quantity,
988
+ )
989
+ return (package_changed, response)
990
+ package_changed = True
991
+ # Zero check
992
+ zero_check = self.picking_type.shopfloor_zero_check
993
+ if zero_check and move_line.location_id.planned_qty_in_location_is_empty():
994
+ response = self._response_for_zero_check(move_line)
995
+ return (package_changed, response)
996
+
997
+ def _set_destination_update_quantity(self, move_line, quantity, barcode):
998
+ """Handle the done quantity increment on set_destination end point."""
999
+ response = None
1000
+ if not self.work.menu.no_prefill_qty:
1001
+ return response
1002
+ search = self._actions_for("search")
1003
+ # Handle barcode of product or packaging
1004
+ product = search.product_from_scan(barcode)
1005
+ packaging = self.env["product.packaging"].browse()
1006
+ if not product:
1007
+ packaging = search.packaging_from_scan(barcode)
1008
+ product = packaging.product_id
1009
+ if product and move_line.product_id == product:
1010
+ quantity += packaging.qty or 1.0
1011
+ response = self._response_for_set_line_destination(
1012
+ move_line, qty_done=quantity
1013
+ )
1014
+ return response
1015
+ # Handle barcode of a lot
1016
+ lot = search.lot_from_scan(barcode)
1017
+ if lot and move_line.lot_id == lot:
1018
+ quantity += 1.0
1019
+ response = self._response_for_set_line_destination(
1020
+ move_line, qty_done=quantity
1021
+ )
1022
+ return response
1023
+ return response
1024
+
1025
+ # flake8: noqa: C901
1026
+ def set_destination(
1027
+ self,
1028
+ move_line_id,
1029
+ barcode,
1030
+ quantity,
1031
+ confirmation=False,
1032
+ ):
1033
+ """Set a destination location (and done) or a destination package (in buffer)
1034
+
1035
+ When a line is picked, it can either:
1036
+
1037
+ * be moved directly to a destination location, typically a pallet
1038
+ * be moved to a destination package, that we'll call buffer in the docstrings
1039
+
1040
+ When the barcode is a valid location, actions on the move line:
1041
+
1042
+ * destination location set to the scanned one
1043
+ * the quantity done is set to the passed quantity
1044
+ * if the move has other move lines, it is split to have only this move line
1045
+ * set to done (without backorder)
1046
+
1047
+ A valid location is a sub-location of the original destination, or a
1048
+ sub-location of the picking type's default destination location if
1049
+ ``confirmation`` is True.
1050
+
1051
+ When the barcode is a valid package, actions on the move line:
1052
+
1053
+ * destination package is set to the scanned one
1054
+ * the quantity done is set to the passed quantity
1055
+ * the field ``shopfloor_user_id`` is updated with the current user
1056
+
1057
+ Those fields will be used to identify which move lines are in the buffer.
1058
+
1059
+ A valid package is:
1060
+
1061
+ * an empty package
1062
+ * not used as destination for another move line
1063
+
1064
+ With the addition of the no prefill quantity parameter this endpoint can also
1065
+ be used to change the done quantity on the move line before setting a
1066
+ destination.
1067
+
1068
+ When the barcode is the product (or its packaging) or the lot on the line:
1069
+ * The done quantity is incremented by one or the packaging quantity.
1070
+
1071
+ Transitions:
1072
+ * select_line: destination has been set, showing the next lines to pick
1073
+ * zero_check: if the option is active and if the quantity of product
1074
+ moved is 0 in the source location after the move (beware: at this
1075
+ point the product we put in the buffer is still considered to be in
1076
+ the source location, so we have to compute the source location's
1077
+ quantity - qty_done).
1078
+ * set_line_destination: the scanned location is invalid, user has to
1079
+ scan another one
1080
+ * set_line_destination+confirmation_required: the scanned location is not
1081
+ in the expected one but is valid (in picking type's default destination)
1082
+ """
1083
+ move_line = self.env["stock.move.line"].browse(move_line_id)
1084
+ if not move_line.exists():
1085
+ return self._response_for_start(message=self.msg_store.record_not_found())
1086
+
1087
+ pkg_moved = False
1088
+ search = self._actions_for("search")
1089
+ accept_only_package = not self._move_line_full_qty(move_line, quantity)
1090
+
1091
+ response = self._set_destination_update_quantity(move_line, quantity, barcode)
1092
+ if response:
1093
+ return response
1094
+
1095
+ if quantity <= 0:
1096
+ message = self.msg_store.picking_zero_quantity()
1097
+ return self._response_for_set_line_destination(
1098
+ move_line,
1099
+ message=message,
1100
+ qty_done=self._get_prefill_qty(move_line, qty=0),
1101
+ )
1102
+
1103
+ extra_message = ""
1104
+ if not accept_only_package:
1105
+ # When the barcode is a location
1106
+ location = search.location_from_scan(barcode)
1107
+ if location:
1108
+ if self._pick_pack_same_time():
1109
+ (
1110
+ good_for_packing,
1111
+ message,
1112
+ ) = self._handle_pick_pack_same_time_for_location(move_line)
1113
+ # TODO: we should append the msg instead.
1114
+ # To achieve this, we should refactor `response.message` to a list
1115
+ # or, to no break backward compat, we could add `extra_messages`
1116
+ # to allow backend to send a main message and N additional messages.
1117
+ extra_message = message
1118
+ if not good_for_packing:
1119
+ return self._response_for_set_line_destination(
1120
+ move_line, message=message, qty_done=quantity
1121
+ )
1122
+ pkg_moved, response = self._set_destination_location(
1123
+ move_line,
1124
+ quantity,
1125
+ confirmation,
1126
+ location,
1127
+ )
1128
+ if response:
1129
+ if extra_message:
1130
+ if response.get("message"):
1131
+ response["message"]["body"] += "\n" + extra_message["body"]
1132
+ else:
1133
+ response["message"] = extra_message
1134
+ return response
1135
+
1136
+ # When the barcode is a package
1137
+ package = search.package_from_scan(barcode)
1138
+ if package:
1139
+ if self._pick_pack_same_time():
1140
+ (
1141
+ good_for_packing,
1142
+ message,
1143
+ ) = self._handle_pick_pack_same_time_for_package(move_line, package)
1144
+ if not good_for_packing:
1145
+ return self._response_for_set_line_destination(
1146
+ move_line, message=message, qty_done=quantity
1147
+ )
1148
+ location = move_line.location_dest_id
1149
+ pkg_moved, response = self._set_destination_package(
1150
+ move_line, quantity, package
1151
+ )
1152
+ if response:
1153
+ return response
1154
+
1155
+ message = None
1156
+
1157
+ if not pkg_moved and not package:
1158
+ if accept_only_package:
1159
+ message = self.msg_store.package_not_found_for_barcode(barcode)
1160
+ else:
1161
+ # we don't know if user wanted to scan a location or a package
1162
+ message = self.msg_store.barcode_not_found()
1163
+ return self._response_for_set_line_destination(
1164
+ move_line, message=message, qty_done=quantity
1165
+ )
1166
+
1167
+ if pkg_moved:
1168
+ message = self.msg_store.confirm_pack_moved()
1169
+ if extra_message:
1170
+ message["body"] += "\n" + extra_message["body"]
1171
+
1172
+ # Process the next line
1173
+ response = self.list_move_lines()
1174
+ return self._response(base_response=response, message=message)
1175
+
1176
+ def _handle_pick_pack_same_time_for_location(self, move_line):
1177
+ """Automatically put product in carrier-specific package.
1178
+
1179
+ :param move_line: current move line to process
1180
+ :return: tuple like ($succes_flag, $success_or_failure_message)
1181
+ """
1182
+ good_for_packing = False
1183
+ message = ""
1184
+ picking = move_line.picking_id
1185
+ carrier = picking.ship_carrier_id or picking.carrier_id
1186
+ if carrier:
1187
+ actions = self._actions_for("packaging")
1188
+ pkg = actions.create_delivery_package(carrier)
1189
+ move_line.write({"result_package_id": pkg.id})
1190
+ message = self.msg_store.goods_packed_in(pkg)
1191
+ good_for_packing = True
1192
+ else:
1193
+ message = self.msg_store.picking_without_carrier_cannot_pack(picking)
1194
+ return good_for_packing, message
1195
+
1196
+ def _handle_pick_pack_same_time_for_package(self, move_line, package):
1197
+ """Validate package for packing at the same time.
1198
+
1199
+ :param move_line: current move line to process
1200
+ :param package: package to validate
1201
+ :return: tuple like ($succes_flag, $success_or_failure_message)
1202
+ """
1203
+ good_for_packing = False
1204
+ message = None
1205
+ picking = move_line.picking_id
1206
+ carrier = picking.ship_carrier_id or picking.carrier_id
1207
+ if carrier:
1208
+ actions = self._actions_for("packaging")
1209
+ if actions.packaging_valid_for_carrier(
1210
+ package.product_packaging_id, carrier
1211
+ ):
1212
+ good_for_packing = True
1213
+ else:
1214
+ message = self.msg_store.packaging_invalid_for_carrier(
1215
+ package.product_packaging_id, carrier
1216
+ )
1217
+ else:
1218
+ message = self.msg_store.picking_without_carrier_cannot_pack(picking)
1219
+ return good_for_packing, message
1220
+
1221
+ def is_zero(self, move_line_id, zero):
1222
+ """Confirm or not if the source location of a move has zero qty
1223
+
1224
+ If the user confirms there is zero quantity, it means the stock was
1225
+ correct and there is nothing to do. If the user says "no", a draft
1226
+ empty inventory is created for the product (with lot if tracked).
1227
+
1228
+ Transitions:
1229
+ * select_line: whether the user confirms or not the location is empty,
1230
+ go back to the picking of lines
1231
+ """
1232
+ move_line = self.env["stock.move.line"].browse(move_line_id)
1233
+ if not move_line.exists():
1234
+ return self._response_for_start(message=self.msg_store.record_not_found())
1235
+ return self.list_move_lines()
1236
+
1237
+ def _domain_stock_issue_unlink_lines(self, move_line):
1238
+ # Since we have not enough stock, delete the move lines, which will
1239
+ # in turn unreserve the moves. The moves lines we delete are those
1240
+ # in the same location, and not yet started.
1241
+ # The goal is to prevent the same operator to declare twice the same
1242
+ # stock issue for the same product/lot/package.
1243
+ move = move_line.move_id
1244
+ lot = move_line.lot_id
1245
+ package = move_line.package_id
1246
+ location = move_line.location_id
1247
+ domain = [
1248
+ ("location_id", "=", location.id),
1249
+ ("product_id", "=", move.product_id.id),
1250
+ ("package_id", "=", package.id),
1251
+ ("lot_id", "=", lot.id),
1252
+ ("state", "not in", ("cancel", "done")),
1253
+ ("qty_done", "=", 0),
1254
+ ]
1255
+ return domain
1256
+
1257
+ def stock_issue(self, move_line_id):
1258
+ """Declare a stock issue for a line
1259
+
1260
+ After errors in the stock, the user cannot take all the products
1261
+ because there is physically not enough goods. The move line is deleted
1262
+ (unreserve), and an inventory is created to reduce the quantity in the
1263
+ source location to prevent future errors until a correction. Beware:
1264
+ the quantity already reserved and having a qty_done set on other lines
1265
+ in the same location should remain reserved so the inventory's quantity
1266
+ must be set to the total of qty_done of other lines.
1267
+
1268
+ The other lines not yet picked (no qty_done) in the same location for
1269
+ the same product, lot, package are unreserved as well (moves lines
1270
+ deleted, which unreserve their quantity on the move).
1271
+
1272
+ A second inventory is created in draft to have someone do an inventory
1273
+ check.
1274
+
1275
+ At the end, it tries to reserve the goods again, and if the current
1276
+ line could be reserved in the current zone location, it transitions
1277
+ directly to the screen to set the destination.
1278
+
1279
+ Transitions:
1280
+ * select_line: go back to the picking of lines for the next ones (nothing
1281
+ could be reserved as replacement)
1282
+ * set_line_destination: something could be reserved instead of the original
1283
+ move line
1284
+ """
1285
+ move_line = self.env["stock.move.line"].browse(move_line_id)
1286
+ if not move_line.exists():
1287
+ return self._response_for_start(message=self.msg_store.record_not_found())
1288
+ inventory = self._actions_for("inventory")
1289
+ # create a draft inventory for a user to check
1290
+ inventory.create_control_stock(
1291
+ move_line.location_id,
1292
+ move_line.product_id,
1293
+ move_line.package_id,
1294
+ move_line.lot_id,
1295
+ )
1296
+ move = move_line.move_id
1297
+ lot = move_line.lot_id
1298
+ package = move_line.package_id
1299
+ location = move_line.location_id
1300
+
1301
+ # unreserve every lines for the same product/lot in the same location and
1302
+ # not done yet, so the same user doesn't have to declare 2 times the
1303
+ # stock issue for the same thing!
1304
+ domain = self._domain_stock_issue_unlink_lines(move_line)
1305
+ unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain)
1306
+ unreserve_moves = unreserve_move_lines.mapped("move_id").sorted()
1307
+ unreserve_move_lines.unlink()
1308
+
1309
+ # Then, create an inventory with just enough qty so the other assigned
1310
+ # move lines for the same product in other batches and the other move lines
1311
+ # already picked stay assigned.
1312
+ inventory.create_stock_issue(move, location, package, lot)
1313
+
1314
+ # try to reassign the moves in case we have stock in another location
1315
+ unreserve_moves._action_assign()
1316
+
1317
+ if move.move_line_ids:
1318
+ return self._response_for_set_line_destination(move.move_line_ids[0])
1319
+ return self.list_move_lines()
1320
+
1321
+ def change_pack_lot(self, move_line_id, barcode):
1322
+ """Change the source package or the lot of a move line
1323
+
1324
+ If the expected lot or package is at the very bottom of the location or
1325
+ a stock error forces a user to change lot or package, user can change the
1326
+ package or lot of the current move line.
1327
+
1328
+ If the pack or lot was not supposed to be in the source location,
1329
+ a draft inventory is created to have this checked.
1330
+
1331
+ Transitions:
1332
+ * change_pack_lot: the barcode scanned is invalid or change could not be done
1333
+ * set_line_destination: the package / lot has been changed, can be
1334
+ moved to destination now
1335
+ * select_line: if the move line does not exist anymore
1336
+ """
1337
+ move_line = self.env["stock.move.line"].browse(move_line_id)
1338
+ if not move_line.exists():
1339
+ return self._response_for_start(message=self.msg_store.record_not_found())
1340
+ search = self._actions_for("search")
1341
+ # pre-configured callable used to generate the response as the
1342
+ # change.package.lot component is not aware of the needed response type
1343
+ # and related parameters for zone picking scenario
1344
+ response_ok_func = functools.partial(self._response_for_set_line_destination)
1345
+ response_error_func = functools.partial(self._response_for_change_pack_lot)
1346
+ response = None
1347
+ change_package_lot = self._actions_for("change.package.lot")
1348
+ # handle lot
1349
+ lot = search.lot_from_scan(barcode, products=move_line.product_id)
1350
+ if lot:
1351
+ response = change_package_lot.change_lot(
1352
+ move_line, lot, response_ok_func, response_error_func
1353
+ )
1354
+ # handle package
1355
+ package = search.package_from_scan(barcode)
1356
+ if package:
1357
+ return change_package_lot.change_package(
1358
+ move_line, package, response_ok_func, response_error_func
1359
+ )
1360
+ # if the response is not an error, we check the move_line status
1361
+ # to adapt the response ('set_line_destination' or 'select_line')
1362
+ # TODO not sure to understand how 'move_line' could not exist here?
1363
+ if response and response["message"]["message_type"] == "success":
1364
+ # TODO adapt the response based on the move_line.exists()
1365
+ if move_line.exists():
1366
+ return response
1367
+ return response
1368
+
1369
+ return self._response_for_change_pack_lot(
1370
+ move_line,
1371
+ message=self.msg_store.no_package_or_lot_for_barcode(barcode),
1372
+ )
1373
+
1374
+ def prepare_unload(self):
1375
+ """Initiate the unloading of the buffer
1376
+
1377
+ The buffer is composed of move lines:
1378
+
1379
+ * in the current zone location and picking type
1380
+ * not done or canceled
1381
+ * with a qty_done > 0
1382
+ * have a destination package
1383
+ * with ``shopfloor_user_id`` equal to the current user
1384
+
1385
+ The lines are grouped by their destination package. The destination
1386
+ package is what is shown on the screen (with their content, which is
1387
+ the move lines with the package as destination), and this is what is
1388
+ passed along in the ``package_id`` parameters in the unload methods.
1389
+
1390
+ It goes to different screens depending if there is only one move line,
1391
+ or if all the move lines have the same destination or not.
1392
+
1393
+ Transitions:
1394
+ * unload_single: move lines have different destinations, return data
1395
+ for the next destination package
1396
+ * unload_set_destination: there is only one move line in the buffer
1397
+ * unload_all: the move lines in the buffer all have the same
1398
+ destination location
1399
+ * select_line: no remaining move lines in buffer
1400
+ """
1401
+ move_lines = self._find_buffer_move_lines()
1402
+ location_dest = move_lines.mapped("location_dest_id")
1403
+ if len(move_lines) == 1:
1404
+ return self._response_for_unload_set_destination(move_lines)
1405
+ elif len(move_lines) > 1 and len(location_dest) == 1:
1406
+ return self._response_for_unload_all(move_lines)
1407
+ elif len(move_lines) > 1 and len(location_dest) > 1:
1408
+ return self._response_for_unload_single(first(move_lines))
1409
+ return self.list_move_lines()
1410
+
1411
+ def _set_destination_all_response(self, buffer_lines, message=None):
1412
+ if buffer_lines:
1413
+ return self._response_for_unload_all(buffer_lines, message=message)
1414
+ move_lines = self._find_location_move_lines()
1415
+ if move_lines:
1416
+ return self._response_for_select_line(move_lines, message=message)
1417
+ return self._response_for_start(message=message)
1418
+
1419
+ def set_destination_all(self, barcode, confirmation=False):
1420
+ """Set the destination for all the lines in the buffer
1421
+
1422
+ Look in ``prepare_unload`` for the definition of the buffer.
1423
+
1424
+ This method must be used only if all the buffer's move lines which have
1425
+ a destination package, qty done > 0, and have the same destination
1426
+ location.
1427
+
1428
+ A scanned location outside of the destination location of the operation
1429
+ type is invalid.
1430
+
1431
+ The move lines are then set to done, without backorders.
1432
+
1433
+ Transitions:
1434
+ * unload_all: the scanned destination is invalid, user has to
1435
+ scan another one
1436
+ * unload_all + confirmation: the scanned location is not in the
1437
+ expected one but is valid (in picking type's default destination)
1438
+ * select_line: no remaining move lines in buffer
1439
+ """
1440
+ search = self._actions_for("search")
1441
+ location = search.location_from_scan(barcode)
1442
+ message = None
1443
+ buffer_lines = self._find_buffer_move_lines()
1444
+ if location:
1445
+ error = None
1446
+ location_dest = buffer_lines.mapped("location_dest_id")
1447
+ # check if move lines share the same destination
1448
+ if len(location_dest) != 1:
1449
+ error = self.msg_store.lines_different_dest_location()
1450
+ # check if the scanned location is allowed
1451
+ moves = buffer_lines.mapped("move_id")
1452
+ if not self.is_dest_location_valid(moves, location):
1453
+ error = self.msg_store.location_not_allowed()
1454
+ if error:
1455
+ return self._set_destination_all_response(buffer_lines, message=error)
1456
+ # check if the destination location is not the expected one
1457
+ # - OK if the scanned destination is a child of the current
1458
+ # destination set on buffer lines
1459
+ # - To confirm if the scanned destination is not a child of the
1460
+ # current destination set on buffer lines
1461
+ if not confirmation and self.is_dest_location_to_confirm(
1462
+ buffer_lines.location_dest_id, location
1463
+ ):
1464
+ return self._response_for_unload_all(
1465
+ buffer_lines,
1466
+ message=self.msg_store.confirm_location_changed(
1467
+ first(buffer_lines.location_dest_id), location
1468
+ ),
1469
+ confirmation_required=True,
1470
+ )
1471
+ # the scanned location is still valid, use it
1472
+ self._write_destination_on_lines(buffer_lines, location)
1473
+ stock = self._actions_for("stock")
1474
+ stock.validate_moves(moves)
1475
+ message = self.msg_store.buffer_complete()
1476
+ buffer_lines = self._find_buffer_move_lines()
1477
+ else:
1478
+ message = self.msg_store.no_location_found()
1479
+ return self._set_destination_all_response(buffer_lines, message=message)
1480
+
1481
+ def _write_destination_on_lines(self, lines, location):
1482
+ self._lock_lines(lines)
1483
+ lines.location_dest_id = location
1484
+ lines.package_level_id.location_dest_id = location
1485
+ if self.work.menu.unload_package_at_destination:
1486
+ lines.result_package_id = False
1487
+
1488
+ def unload_split(self):
1489
+ """Indicates that now the buffer must be treated line per line
1490
+
1491
+ Called from a button, users decides to handle destinations one by one.
1492
+ Even if the move lines to unload all have the same destination.
1493
+
1494
+ Look in ``prepare_unload`` for the definition of the buffer.
1495
+
1496
+ Transitions:
1497
+ * unload_single: more than one remaining line in the buffer
1498
+ * unload_set_destination: there is only one remaining line in the buffer
1499
+ * select_line: no remaining move lines in buffer
1500
+ """
1501
+ buffer_lines = self._find_buffer_move_lines()
1502
+ # more than one remaining move line in the buffer
1503
+ if len(buffer_lines) > 1:
1504
+ return self._response_for_unload_single(first(buffer_lines))
1505
+ # only one move line to process in the buffer
1506
+ elif len(buffer_lines) == 1:
1507
+ return self._response_for_unload_set_destination(first(buffer_lines))
1508
+ # no remaining move lines in buffer
1509
+ move_lines = self._find_location_move_lines()
1510
+ return self._response_for_select_line(
1511
+ move_lines,
1512
+ message=self.msg_store.buffer_complete(),
1513
+ )
1514
+
1515
+ def _unload_response(self, unload_single_message=None):
1516
+ """Prepare the right response depending on the move lines to process."""
1517
+ # if there are still move lines to process from the buffer
1518
+ move_lines = self._find_buffer_move_lines()
1519
+ if move_lines:
1520
+ return self._response_for_unload_single(
1521
+ first(move_lines),
1522
+ message=unload_single_message,
1523
+ )
1524
+ # if there are still move lines to process from the picking type
1525
+ # => buffer complete!
1526
+ move_lines = self._find_location_move_lines()
1527
+ if move_lines:
1528
+ return self._response_for_select_line(
1529
+ move_lines,
1530
+ message=self.msg_store.buffer_complete(),
1531
+ )
1532
+ # no more move lines to process from the current picking type
1533
+ # => picking type complete!
1534
+ return self._response_for_start(
1535
+ message=self.msg_store.picking_type_complete(self.picking_type)
1536
+ )
1537
+
1538
+ def unload_scan_pack(self, package_id, barcode):
1539
+ """Scan the destination package to check user moves the good one
1540
+
1541
+ The "unload_single" screen proposes a package (which has been
1542
+ previously been set as destination package of lines of the buffer).
1543
+ The user has to scan the package to validate they took the good one.
1544
+
1545
+ Transitions:
1546
+ * unload_single: the scanned barcode does not match the package
1547
+ * unload_set_destination: the scanned barcode matches the package
1548
+ * select_line: no remaining move lines in buffer
1549
+ * start: no remaining move lines in picking type
1550
+ """
1551
+ package = self.env["stock.quant.package"].browse(package_id)
1552
+ if not package.exists():
1553
+ return self._unload_response(
1554
+ unload_single_message=self.msg_store.record_not_found(),
1555
+ )
1556
+ search = self._actions_for("search")
1557
+ scanned_package = search.package_from_scan(barcode)
1558
+ # the scanned barcode matches the package
1559
+ if scanned_package == package:
1560
+ move_lines = self._find_buffer_move_lines(dest_package=scanned_package)
1561
+ if move_lines:
1562
+ return self._response_for_unload_set_destination(first(move_lines))
1563
+ return self._unload_response(
1564
+ unload_single_message=self.msg_store.barcode_no_match(package.name),
1565
+ )
1566
+
1567
+ def _lock_lines(self, lines):
1568
+ """Lock move lines"""
1569
+ self._actions_for("lock").for_update(lines)
1570
+
1571
+ def unload_set_destination(self, package_id, barcode, confirmation=False):
1572
+ """Scan the final destination for move lines in the buffer with the
1573
+ destination package
1574
+
1575
+ All the move lines in the buffer with the package_id as destination
1576
+ package are updated with the scanned location.
1577
+
1578
+ The move lines are then set to done, without backorders.
1579
+
1580
+ Look in ``prepare_unload`` for the definition of the buffer.
1581
+
1582
+ Transitions:
1583
+ * unload_single: buffer still contains move lines, unload the next package
1584
+ * unload_set_destination: the scanned location is invalid, user has to
1585
+ scan another one
1586
+ * unload_set_destination+confirmation_required: the scanned location is not
1587
+ in the expected one but is valid (in picking type's default destination)
1588
+ * select_line: no remaining move lines in buffer
1589
+ * start: no remaining move lines to process in the picking type
1590
+ """
1591
+ package = self.env["stock.quant.package"].browse(package_id)
1592
+ buffer_lines = self._find_buffer_move_lines(dest_package=package)
1593
+ if not package.exists() or not buffer_lines:
1594
+ move_lines = self._find_location_move_lines()
1595
+ return self._response_for_select_line(
1596
+ move_lines,
1597
+ message=self.msg_store.record_not_found(),
1598
+ )
1599
+ search = self._actions_for("search")
1600
+ location = search.location_from_scan(barcode)
1601
+ if location:
1602
+ moves = buffer_lines.mapped("move_id")
1603
+ if not self.is_dest_location_valid(moves, location):
1604
+ return self._response_for_unload_set_destination(
1605
+ first(buffer_lines),
1606
+ message=self.msg_store.dest_location_not_allowed(),
1607
+ )
1608
+ # check if the destination location is not the expected one
1609
+ # - OK if the scanned destination is a child of the current
1610
+ # destination set on buffer lines
1611
+ # - To confirm if the scanned destination is not a child of the
1612
+ # current destination set on buffer lines
1613
+ if not confirmation and self.is_dest_location_to_confirm(
1614
+ buffer_lines.location_dest_id, location
1615
+ ):
1616
+ return self._response_for_unload_set_destination(
1617
+ first(buffer_lines),
1618
+ message=self.msg_store.confirm_location_changed(
1619
+ first(buffer_lines.location_dest_id), location
1620
+ ),
1621
+ confirmation_required=True,
1622
+ )
1623
+ # the scanned location is valid, use it
1624
+ self._write_destination_on_lines(buffer_lines, location)
1625
+ # set lines to done + refresh buffer lines (should be empty)
1626
+ # split move lines to a backorder move
1627
+ # if quantity is not fully satisfied
1628
+ for move in moves:
1629
+ move.split_other_move_lines(buffer_lines & move.move_line_ids)
1630
+
1631
+ stock = self._actions_for("stock")
1632
+ stock.validate_moves(moves)
1633
+ buffer_lines = self._find_buffer_move_lines()
1634
+
1635
+ if buffer_lines:
1636
+ # TODO: return success message if line has been processed
1637
+ return self._response_for_unload_single(first(buffer_lines))
1638
+ move_lines = self._find_location_move_lines()
1639
+ if move_lines:
1640
+ return self._response_for_select_line(
1641
+ move_lines,
1642
+ message=self.msg_store.buffer_complete(),
1643
+ )
1644
+ return self._response_for_start(
1645
+ message=self.msg_store.picking_type_complete(self.picking_type)
1646
+ )
1647
+ # TODO: when we have no lines here
1648
+ # we should not redirect to `unload_set_destination`
1649
+ # because we'll have nothing to display (currently the UI is broken).
1650
+ return self._response_for_unload_set_destination(
1651
+ first(buffer_lines),
1652
+ message=self.msg_store.no_location_found(),
1653
+ )
1654
+
1655
+
1656
+ class ShopfloorZonePickingValidator(Component):
1657
+ """Validators for the Zone Picking endpoints"""
1658
+
1659
+ _inherit = "base.shopfloor.validator"
1660
+ _name = "shopfloor.zone_picking.validator"
1661
+ _usage = "zone_picking.validator"
1662
+
1663
+ def select_zone(self):
1664
+ return {}
1665
+
1666
+ def scan_location(self):
1667
+ return {"barcode": {"required": True, "type": "string"}}
1668
+
1669
+ def list_move_lines(self):
1670
+ return {
1671
+ "barcode": {"required": False, "nullable": True, "type": "string"},
1672
+ }
1673
+
1674
+ def scan_source(self):
1675
+ return {
1676
+ "barcode": {"required": False, "nullable": True, "type": "string"},
1677
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
1678
+ "product_id": {"required": False, "nullable": True, "type": "integer"},
1679
+ "sublocation_id": {"required": False, "nullable": True, "type": "integer"},
1680
+ "package_id": {"required": False, "nullable": True, "type": "integer"},
1681
+ }
1682
+
1683
+ def set_destination(self):
1684
+ return {
1685
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1686
+ "barcode": {"required": False, "nullable": True, "type": "string"},
1687
+ "quantity": {
1688
+ "coerce": to_float,
1689
+ "required": True,
1690
+ "type": "float",
1691
+ },
1692
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
1693
+ }
1694
+
1695
+ def is_zero(self):
1696
+ return {
1697
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1698
+ "zero": {"coerce": to_bool, "required": True, "type": "boolean"},
1699
+ }
1700
+
1701
+ def stock_issue(self):
1702
+ return {
1703
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1704
+ }
1705
+
1706
+ def change_pack_lot(self):
1707
+ return {
1708
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1709
+ "barcode": {"required": False, "nullable": True, "type": "string"},
1710
+ }
1711
+
1712
+ def prepare_unload(self):
1713
+ return {}
1714
+
1715
+ def set_destination_all(self):
1716
+ return {
1717
+ "barcode": {"required": False, "nullable": True, "type": "string"},
1718
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
1719
+ }
1720
+
1721
+ def unload_split(self):
1722
+ return {}
1723
+
1724
+ def unload_scan_pack(self):
1725
+ return {
1726
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
1727
+ "barcode": {"required": False, "nullable": True, "type": "string"},
1728
+ }
1729
+
1730
+ def unload_set_destination(self):
1731
+ return {
1732
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
1733
+ "barcode": {"required": False, "nullable": True, "type": "string"},
1734
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
1735
+ }
1736
+
1737
+
1738
+ class ShopfloorZonePickingValidatorResponse(Component):
1739
+ """Validators for the Zone Picking endpoints responses"""
1740
+
1741
+ _inherit = "base.shopfloor.validator.response"
1742
+ _name = "shopfloor.zone_picking.validator.response"
1743
+ _usage = "zone_picking.validator.response"
1744
+
1745
+ def _states(self):
1746
+ """List of possible next states
1747
+
1748
+ With the schema of the data send to the client to transition
1749
+ to the next state.
1750
+ """
1751
+ return {
1752
+ "start": self._schema_for_select_zone,
1753
+ "select_picking_type": self._schema_for_select_picking_type,
1754
+ "select_line": self._schema_for_move_lines_empty_location,
1755
+ "set_line_destination": self._schema_for_move_line,
1756
+ "zero_check": self._schema_for_zero_check,
1757
+ "change_pack_lot": self._schema_for_move_line,
1758
+ "unload_all": self._schema_for_move_lines,
1759
+ "unload_single": self._schema_for_move_line,
1760
+ "unload_set_destination": self._schema_for_move_line,
1761
+ }
1762
+
1763
+ def select_zone(self):
1764
+ return self._response_schema(next_states={"start"})
1765
+
1766
+ def scan_location(self):
1767
+ return self._response_schema(next_states={"start", "select_picking_type"})
1768
+
1769
+ def list_move_lines(self):
1770
+ return self._response_schema(next_states={"select_line"})
1771
+
1772
+ def scan_source(self):
1773
+ return self._response_schema(
1774
+ next_states={"select_line", "set_line_destination"}
1775
+ )
1776
+
1777
+ def set_destination(self):
1778
+ return self._response_schema(
1779
+ next_states={"select_line", "set_line_destination", "zero_check"}
1780
+ )
1781
+
1782
+ def is_zero(self):
1783
+ return self._response_schema(next_states={"select_line"})
1784
+
1785
+ def stock_issue(self):
1786
+ return self._response_schema(
1787
+ next_states={"select_line", "set_line_destination"}
1788
+ )
1789
+
1790
+ def change_pack_lot(self):
1791
+ return self._response_schema(
1792
+ next_states={"change_pack_lot", "set_line_destination", "select_line"}
1793
+ )
1794
+
1795
+ def prepare_unload(self):
1796
+ return self._response_schema(
1797
+ next_states={
1798
+ "unload_all",
1799
+ "unload_single",
1800
+ "unload_set_destination",
1801
+ "select_line",
1802
+ }
1803
+ )
1804
+
1805
+ def set_destination_all(self):
1806
+ return self._response_schema(next_states={"unload_all", "select_line"})
1807
+
1808
+ def unload_split(self):
1809
+ return self._response_schema(
1810
+ next_states={"unload_single", "unload_set_destination", "select_line"}
1811
+ )
1812
+
1813
+ def unload_scan_pack(self):
1814
+ return self._response_schema(
1815
+ next_states={
1816
+ "unload_single",
1817
+ "unload_set_destination",
1818
+ "select_line",
1819
+ "start",
1820
+ }
1821
+ )
1822
+
1823
+ def unload_set_destination(self):
1824
+ return self._response_schema(
1825
+ next_states={"unload_single", "unload_set_destination", "select_line"}
1826
+ )
1827
+
1828
+ @property
1829
+ def _schema_for_select_zone(self):
1830
+ zone_schema = self.schemas.location()
1831
+ picking_type_schema = self.schemas.picking_type()
1832
+ picking_type_schema.update(self._schema_for_zone_line_counters)
1833
+ zone_schema["operation_types"] = self.schemas._schema_list_of(
1834
+ picking_type_schema
1835
+ )
1836
+ zone_schema.update(self._schema_for_zone_line_counters)
1837
+ zone_schema = {
1838
+ "zones": self.schemas._schema_list_of(zone_schema),
1839
+ "buffer": {
1840
+ "type": "dict",
1841
+ "nullable": False,
1842
+ "required": False,
1843
+ "schema": {
1844
+ "zone_location": self.schemas._schema_dict_of(
1845
+ self.schemas.location(), nullable=False, required=False
1846
+ ),
1847
+ "picking_type": self.schemas._schema_dict_of(
1848
+ self.schemas.picking_type(), nullable=False, required=False
1849
+ ),
1850
+ },
1851
+ },
1852
+ }
1853
+ return zone_schema
1854
+
1855
+ @property
1856
+ def _schema_for_zone_line_counters(self):
1857
+ return self.schemas.move_lines_counters()
1858
+
1859
+ @property
1860
+ def _schema_for_select_picking_type(self):
1861
+ picking_type = self.schemas.picking_type()
1862
+ picking_type.update(self._schema_for_zone_line_counters)
1863
+ schema = {
1864
+ "zone_location": self.schemas._schema_dict_of(self.schemas.location()),
1865
+ "picking_types": self.schemas._schema_list_of(picking_type),
1866
+ }
1867
+ return schema
1868
+
1869
+ @property
1870
+ def _schema_for_move_line(self):
1871
+ schema = {
1872
+ "zone_location": self.schemas._schema_dict_of(self.schemas.location()),
1873
+ "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
1874
+ "move_line": self.schemas._schema_dict_of(
1875
+ self.schemas.move_line(with_picking=True)
1876
+ ),
1877
+ "confirmation_required": {
1878
+ "type": "boolean",
1879
+ "nullable": True,
1880
+ "required": False,
1881
+ },
1882
+ "product_id": {
1883
+ "type": "integer",
1884
+ "nullable": True,
1885
+ "required": False,
1886
+ },
1887
+ }
1888
+ return schema
1889
+
1890
+ @property
1891
+ def _schema_for_move_lines(self):
1892
+ schema = {
1893
+ "zone_location": self.schemas._schema_dict_of(self.schemas.location()),
1894
+ "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
1895
+ "move_lines": self.schemas._schema_list_of(
1896
+ self.schemas.move_line(with_picking=True)
1897
+ ),
1898
+ "confirmation_required": {
1899
+ "type": "boolean",
1900
+ "nullable": True,
1901
+ "required": False,
1902
+ },
1903
+ "product": self.schemas._schema_dict_of(
1904
+ self.schemas.product(), required=False
1905
+ ),
1906
+ "sublocation": self.schemas._schema_dict_of(
1907
+ self.schemas.location(), required=False
1908
+ ),
1909
+ "package": self.schemas._schema_dict_of(
1910
+ self.schemas.package(), required=False
1911
+ ),
1912
+ }
1913
+ return schema
1914
+
1915
+ @property
1916
+ def _schema_for_move_lines_empty_location(self):
1917
+ schema = self._schema_for_move_lines
1918
+ schema["move_lines"]["schema"]["schema"]["location_will_be_empty"] = {
1919
+ "type": "boolean",
1920
+ "nullable": False,
1921
+ "required": True,
1922
+ }
1923
+ schema["scan_location_or_pack_first"] = {
1924
+ "type": "boolean",
1925
+ "nullable": False,
1926
+ "required": True,
1927
+ }
1928
+ return schema
1929
+
1930
+ @property
1931
+ def _schema_for_zero_check(self):
1932
+ schema = {
1933
+ "zone_location": self.schemas._schema_dict_of(self.schemas.location()),
1934
+ "picking_type": self.schemas._schema_dict_of(self.schemas.picking_type()),
1935
+ "location": self.schemas._schema_dict_of(self.schemas.location()),
1936
+ "move_line": self.schemas._schema_dict_of(self.schemas.move_line()),
1937
+ }
1938
+ return schema