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,1628 @@
1
+ # Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com)
2
+ # Copyright 2020-2022 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
+ from odoo import _, fields
6
+ from odoo.osv import expression
7
+
8
+ from odoo.addons.base_rest.components.service import to_bool, to_int
9
+ from odoo.addons.component.core import Component
10
+
11
+ from ..utils import to_float
12
+
13
+
14
+ class ClusterPicking(Component):
15
+ """
16
+ Methods for the Cluster Picking Process
17
+
18
+ The goal of this scenario is to do the pickings for a Picking Batch, for
19
+ several customers at once.
20
+ The process assumes that picking batch records already exist.
21
+
22
+ At first, a user gets automatically a batch to work on (assigned to them),
23
+ or can select one from a list.
24
+
25
+ The scenario has 2 main phases, which can be done one after the other or a
26
+ bit of both. The first one is picking goods and put them in a roller-cage.
27
+
28
+ First phase, picking:
29
+
30
+ * Pick a good (move line) from a source location, scan it to confirm it's
31
+ the expected one
32
+ * Scan the label of a Bin (package) in a roller-cage, put the good inside
33
+ (physically). Once the first move line of a picking has been scanned, the
34
+ screen will show the same destination package for all the other lines of
35
+ the picking to help the user grouping goods together, and will prevent
36
+ lines from other pickings to be put in the same destination package.
37
+ * If odoo thinks a source location is empty after picking the goods, a
38
+ "zero check" is done: it asks the user to confirm if it is empty or not
39
+ * Repeat until the end of the batch or the roller-cage is full (there is
40
+ button to declare this)
41
+
42
+ Second phase, unload to destination:
43
+
44
+ * If all the goods (move lines) in the roller-cage go to the same destination,
45
+ a screen asking a single barcode for the destination is shown
46
+ * Otherwise, the user has to scan one destination per Bin (destination
47
+ package of the moves).
48
+ * If all the goods are supposed to go to the same destination but user doesn't
49
+ want or can't, a "split" allows to reach the screen to scan one destination
50
+ per Bin.
51
+ * When everything has a destination set and the batch is not finished yet,
52
+ the user goes to the first phase of pickings again for the rest.
53
+
54
+ Inside the main workflow, some actions are accessible from the client:
55
+
56
+ * Change a lot or pack: if the expected lot is at the very bottom of the
57
+ location or a stock error forces a user to change lot or pack, user can
58
+ do it during the picking.
59
+ * Skip a line: during picking, for instance because a line is not accessible
60
+ easily, it can be postponed, note that skipped lines have to be done, they
61
+ are only moved to the end of the queue.
62
+ * Declare stock out: if a good is in fact not in stock or only partially. Note
63
+ the move lines will become unavailable or partially unavailable and will
64
+ generate a back-order.
65
+ * Full bin: declaring a full bin allows to move directly to the first phase
66
+ (picking) to the second one (unload). The scenario will go
67
+ back to the first phase if some lines remain in the queue of lines to pick.
68
+
69
+ You will find a sequence diagram describing states and endpoints
70
+ relationships [here](../docs/cluster_picking_diag_seq.png).
71
+ Keep [the sequence diagram](../docs/cluster_picking_diag_seq.plantuml)
72
+ up-to-date if you change endpoints.
73
+ """
74
+
75
+ _inherit = "base.shopfloor.process"
76
+ _name = "shopfloor.cluster.picking"
77
+ _usage = "cluster_picking"
78
+ _description = __doc__
79
+
80
+ def _response_for_start(self, message=None, popup=None):
81
+ return self._response(next_state="start", message=message, popup=popup)
82
+
83
+ def _response_for_confirm_start(self, batch):
84
+ return self._response(
85
+ next_state="confirm_start",
86
+ data=self.data.picking_batch(batch, with_pickings=True),
87
+ )
88
+
89
+ def _response_for_manual_selection(self, batches, message=None):
90
+ data = {
91
+ "records": self.data.picking_batches(batches),
92
+ "size": len(batches),
93
+ }
94
+ return self._response(next_state="manual_selection", data=data, message=message)
95
+
96
+ def _response_for_start_line(
97
+ self, move_line, message=None, popup=None, sublocation=None
98
+ ):
99
+ kw = {"sublocation": self.data.location(sublocation)} if sublocation else {}
100
+ data = self._data_move_line(move_line, **kw)
101
+ return self._response(
102
+ next_state="start_line",
103
+ data=data,
104
+ message=message,
105
+ popup=popup,
106
+ )
107
+
108
+ def _response_for_scan_destination(self, move_line, message=None, qty_done=None):
109
+ if qty_done is None:
110
+ data = self._data_move_line(move_line)
111
+ else:
112
+ data = self._data_move_line(move_line, qty_done=qty_done)
113
+ last_picked_line = self._last_picked_line(move_line.picking_id)
114
+ if last_picked_line:
115
+ # suggest pack to be used for the next line
116
+ data["package_dest"] = self.data.package(
117
+ last_picked_line.result_package_id.with_context(
118
+ picking_id=move_line.picking_id.id
119
+ ),
120
+ picking=move_line.picking_id,
121
+ )
122
+ data["disable_full_bin_action"] = self.work.menu.disable_full_bin_action
123
+ return self._response(next_state="scan_destination", data=data, message=message)
124
+
125
+ def _response_for_change_pack_lot(self, move_line, message=None):
126
+ return self._response(
127
+ next_state="change_pack_lot",
128
+ data=self._data_move_line(move_line),
129
+ message=message,
130
+ )
131
+
132
+ def _response_for_zero_check(self, batch, move_line):
133
+ data = {
134
+ "id": move_line.id,
135
+ "location_src": self.data.location(move_line.location_id),
136
+ }
137
+ data["batch"] = self.data.picking_batch(batch)
138
+ return self._response(next_state="zero_check", data=data)
139
+
140
+ def _response_for_unload_all(self, batch, message=None):
141
+ return self._response(
142
+ next_state="unload_all",
143
+ data=self._data_for_unload_all(batch),
144
+ message=message,
145
+ )
146
+
147
+ def _response_for_confirm_unload_all(self, batch, message=None):
148
+ return self._response(
149
+ next_state="confirm_unload_all",
150
+ data=self._data_for_unload_all(batch),
151
+ message=message,
152
+ )
153
+
154
+ def _response_for_unload_single(self, batch, package, message=None, popup=None):
155
+ return self._response(
156
+ next_state="unload_single",
157
+ data=self._data_for_unload_single(batch, package),
158
+ message=message,
159
+ popup=popup,
160
+ )
161
+
162
+ def _response_for_unload_set_destination(self, batch, package, message=None):
163
+ return self._response(
164
+ next_state="unload_set_destination",
165
+ data=self._data_for_unload_single(batch, package),
166
+ message=message,
167
+ )
168
+
169
+ def _response_for_confirm_unload_set_destination(self, batch, package):
170
+ return self._response(
171
+ next_state="confirm_unload_set_destination",
172
+ data=self._data_for_unload_single(batch, package),
173
+ )
174
+
175
+ def find_batch(self):
176
+ """Find a picking batch to work on and start it
177
+
178
+ Usually the starting point of the scenario.
179
+
180
+ Business rules to find a batch, try in order:
181
+
182
+ a. Find a batch in progress assigned to the current user
183
+ b. Find a draft batch assigned to the current user:
184
+ 1. set it to 'in progress'
185
+ c. Find an unassigned draft batch:
186
+ 1. assign batch to the current user
187
+ 2. set it to 'in progress'
188
+
189
+ Transitions:
190
+ * confirm_start: when it could find a batch
191
+ * start: when no batch is available
192
+ """
193
+ batches = self._batch_picking_search()
194
+ selected = self._select_a_picking_batch(batches)
195
+ if selected:
196
+ return self._response_for_confirm_start(selected)
197
+ else:
198
+ return self._response_for_start(
199
+ message={
200
+ "message_type": "info",
201
+ "body": _("No more work to do, please create a new batch transfer"),
202
+ },
203
+ )
204
+
205
+ def list_batch(self):
206
+ """List picking batch on which user can work
207
+
208
+ Returns a list of all the available records for the current picking
209
+ type.
210
+
211
+ Transitions:
212
+ * manual_selection: to the selection screen
213
+ """
214
+ batches = self._batch_picking_search()
215
+ return self._response_for_manual_selection(batches)
216
+
217
+ def _batch_picking_base_search_domain(self):
218
+ return [
219
+ "|",
220
+ "&",
221
+ ("user_id", "=", False),
222
+ ("state", "=", "draft"),
223
+ "&",
224
+ ("user_id", "=", self.env.user.id),
225
+ ("state", "in", ("draft", "in_progress")),
226
+ ]
227
+
228
+ def _batch_picking_search(self, name_fragment=None, batch_ids=None):
229
+ domain = self._batch_picking_base_search_domain()
230
+ if name_fragment:
231
+ domain = expression.AND([domain, [("name", "ilike", name_fragment)]])
232
+ if batch_ids:
233
+ domain = expression.AND([domain, [("id", "in", batch_ids)]])
234
+ records = self.env["stock.picking.batch"].search(domain, order="id asc")
235
+ records = records.filtered(self._batch_filter)
236
+ return records
237
+
238
+ def _batch_filter(self, batch):
239
+ if not batch.picking_ids:
240
+ return False
241
+ return batch.picking_ids.filtered(self._batch_picking_filter)
242
+
243
+ def _batch_picking_filter(self, picking):
244
+ # Picking type guard
245
+ if picking.picking_type_id not in self.picking_types:
246
+ return False
247
+ # Include done/cancel because we want to be able to work on the
248
+ # batch even if some pickings are done/canceled. They'll should be
249
+ # ignored later.
250
+ # When the batch is already in progress, we do not care
251
+ # about state of the pickings, because we want to be able
252
+ # to recover it in any case, even if, for instance, a stock
253
+ # error changed a picking to unavailable after the user
254
+ # started to work on the batch.
255
+ return picking.batch_id.state == "in_progress" or picking.state in (
256
+ "assigned",
257
+ "done",
258
+ "cancel",
259
+ )
260
+
261
+ def _select_a_picking_batch(self, batches):
262
+ # look for in progress + assigned to self first
263
+ candidates = batches.filtered(
264
+ lambda batch: batch.state == "in_progress"
265
+ and batch.user_id == self.env.user
266
+ )
267
+ if candidates:
268
+ return candidates[0]
269
+ # then look for draft assigned to self
270
+ candidates = batches.filtered(lambda batch: batch.user_id == self.env.user)
271
+ if candidates:
272
+ batch = candidates[0]
273
+ batch.write({"state": "in_progress"})
274
+ return batch
275
+ # finally take any batch that search could return
276
+ if batches:
277
+ batch = batches[0]
278
+ batch.write({"user_id": self.env.uid, "state": "in_progress"})
279
+ return batch
280
+ return self.env["stock.picking.batch"]
281
+
282
+ def select(self, picking_batch_id):
283
+ """Manually select a picking batch
284
+
285
+ The client application can use the service /picking_batch/search
286
+ to get the list of candidate batches. Then, it starts to work on
287
+ the selected batch by calling this.
288
+
289
+ Note: it should be able to work only on batches which are in draft or
290
+ (in progress and assigned to the current user), the search method that
291
+ lists batches filter them, but it has to be checked again here in case
292
+ of race condition.
293
+
294
+ Transitions:
295
+ * manual_selection: a selected batch cannot be used (assigned to someone else
296
+ concurrently for instance)
297
+ * confirm_start: after the batch has been assigned to the user
298
+ """
299
+ batches = self._batch_picking_search(batch_ids=[picking_batch_id])
300
+ selected = self._select_a_picking_batch(batches)
301
+ if selected:
302
+ return self._response_for_confirm_start(selected)
303
+ else:
304
+ return self._response(
305
+ base_response=self.list_batch(),
306
+ message={
307
+ "message_type": "warning",
308
+ "body": _("This batch cannot be selected."),
309
+ },
310
+ )
311
+
312
+ def confirm_start(self, picking_batch_id):
313
+ """User confirms they start a batch
314
+
315
+ Should have no effect in odoo besides logging and routing the user to
316
+ the next action. The next action is "start_line" with data about the
317
+ line to pick.
318
+
319
+ Transitions:
320
+ * start_line: when the batch has at least one line without destination
321
+ package
322
+ * start: if the condition above is wrong (rare case of race condition...)
323
+ """
324
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
325
+ if not batch.exists():
326
+ return self._response_batch_does_not_exist()
327
+ return self._pick_next_line(batch)
328
+
329
+ def _pick_next_line(self, batch, message=None, force_line=None):
330
+ if force_line:
331
+ next_line = force_line
332
+ else:
333
+ next_line = self._next_line_for_pick(batch)
334
+ if not next_line:
335
+ return self.prepare_unload(batch.id)
336
+ return self._response_for_start_line(next_line, message=message)
337
+
338
+ @staticmethod
339
+ def _sort_key_lines(line):
340
+ return (
341
+ line.shopfloor_priority or 10,
342
+ line.location_id.shopfloor_picking_sequence or "",
343
+ line.location_id.name,
344
+ -int(line.move_id.priority or 0),
345
+ line.move_id.date,
346
+ line.move_id.sequence,
347
+ line.move_id.id,
348
+ line.id,
349
+ )
350
+
351
+ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x):
352
+ lines = picking_batch.mapped("picking_ids.move_line_ids").filtered(filter_func)
353
+ # TODO test line sorting and all these methods to retrieve lines
354
+
355
+ # Sort line by source location,
356
+ # so that the picker start w/ products in the same location.
357
+ # Postponed lines must come always
358
+ # after ALL the other lines in the batch are processed.
359
+ return lines.sorted(key=self._sort_key_lines)
360
+
361
+ def _lines_to_pick(self, picking_batch):
362
+ return self._lines_for_picking_batch(
363
+ picking_batch,
364
+ filter_func=lambda l: (
365
+ l.state in ("assigned", "partially_available")
366
+ # On 'StockPicking.action_assign()', result_package_id is set to
367
+ # the same package as 'package_id'. Here, we need to exclude lines
368
+ # that were already put into a bin, i.e. the destination package
369
+ # is different.
370
+ and (not l.result_package_id or l.result_package_id == l.package_id)
371
+ ),
372
+ )
373
+
374
+ def _last_picked_line(self, picking):
375
+ """Get the last line picked and put in a pack for this picking"""
376
+ return fields.first(
377
+ picking.move_line_ids.filtered(
378
+ lambda l: l.qty_done > 0
379
+ and l.result_package_id
380
+ # if we are moving the entire package, we shouldn't
381
+ # add stuff inside it, it's not a new package
382
+ and l.package_id != l.result_package_id
383
+ ).sorted(key="write_date", reverse=True)
384
+ )
385
+
386
+ def _next_line_for_pick(self, picking_batch):
387
+ remaining_lines = self._lines_to_pick(picking_batch)
388
+ return fields.first(remaining_lines)
389
+
390
+ def _response_batch_does_not_exist(self):
391
+ return self._response_for_start(message=self.msg_store.record_not_found())
392
+
393
+ def _data_move_line(self, line, **kw):
394
+ picking = line.picking_id
395
+ batch = picking.batch_id
396
+ product = line.product_id
397
+ data = self.data.move_line(line)
398
+ # additional values
399
+ # Ensure destination pack is never proposed on the frontend.
400
+ # This should happen only as proposal on `scan_destination`
401
+ # where we set the last used package.
402
+ data["package_dest"] = None
403
+ data["batch"] = self.data.picking_batch(batch)
404
+ data["picking"] = self.data.picking(picking)
405
+ data["postponed"] = line.shopfloor_postponed
406
+ data["product"]["qty_available"] = product.with_context(
407
+ location=line.location_id.id
408
+ ).qty_available
409
+ data["scan_location_or_pack_first"] = self.work.menu.scan_location_or_pack_first
410
+ data.update(kw)
411
+ return data
412
+
413
+ def unassign(self, picking_batch_id):
414
+ """Unassign and reset to draft a started picking batch
415
+
416
+ Transitions:
417
+ * "start" to work on a new batch
418
+ """
419
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
420
+ if batch.exists():
421
+ batch.write({"state": "draft", "user_id": False})
422
+ return self._response_for_start()
423
+
424
+ def scan_line(self, picking_batch_id, move_line_id, barcode, sublocation_id=None):
425
+ """Scan a location, a pack, a product or a lots
426
+
427
+ There is no side-effect, it is only to check that the operator takes
428
+ the expected pack or product.
429
+
430
+ User can scan a location if there is only pack inside. Otherwise, they
431
+ have to precise what they want by scanning one of:
432
+
433
+ * pack
434
+ * product
435
+ * lot
436
+
437
+ The result must be unambigous. For instance if we scan a product but the
438
+ product is tracked by lot, scanning the lot has to be required.
439
+
440
+ `sublocation_id` is used when the scan_location_or_pack_first option is
441
+ switched on and the location contains multiple products with no lot or package.
442
+ The user will first scan the location and then the product, the backend needs
443
+ to know a location has been scanned previously.
444
+
445
+ Transitions:
446
+ * start_line: with an appropriate message when user has
447
+ to scan for the same line again
448
+ * start_line: with the next line if the line was added to a
449
+ pack meanwhile (race condition).
450
+ * scan_destination: if the barcode matches.
451
+ """
452
+ sublocation = (
453
+ self.env["stock.location"].browse(sublocation_id).exists()
454
+ if sublocation_id
455
+ else self.env["stock.location"]
456
+ )
457
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
458
+ if not batch.exists():
459
+ return self._response_batch_does_not_exist()
460
+ move_line = self.env["stock.move.line"].browse(move_line_id)
461
+ if not move_line.exists():
462
+ return self._pick_next_line(
463
+ batch, message=self.msg_store.operation_not_found()
464
+ )
465
+
466
+ search = self._actions_for("search")
467
+
468
+ picking = move_line.picking_id
469
+
470
+ package = search.package_from_scan(barcode)
471
+ if package and move_line.package_id == package:
472
+ return self._scan_line_by_package(
473
+ picking, move_line, package, batch, sublocation
474
+ )
475
+
476
+ product = search.product_from_scan(barcode)
477
+ if product and move_line.product_id == product:
478
+ return self._scan_line_by_product(picking, move_line, product, sublocation)
479
+
480
+ packaging = search.packaging_from_scan(barcode)
481
+ if move_line.product_id == packaging.product_id:
482
+ return self._scan_line_by_packaging(
483
+ picking, move_line, packaging, sublocation
484
+ )
485
+
486
+ lot = search.lot_from_scan(barcode, products=move_line.product_id)
487
+ if lot and move_line.lot_id == lot:
488
+ return self._scan_line_by_lot(picking, move_line, lot, sublocation)
489
+
490
+ location = search.location_from_scan(barcode)
491
+ if location and move_line.location_id == location:
492
+ return self._scan_line_by_location(picking, move_line, location)
493
+
494
+ # Nothing matches what is expected from the move line.
495
+ for rec in (package, product, lot, location):
496
+ if rec:
497
+ return self._response_for_start_line(
498
+ move_line, message=self.msg_store.wrong_record(rec)
499
+ )
500
+ return self._response_for_start_line(
501
+ move_line, message=self.msg_store.barcode_not_found()
502
+ )
503
+
504
+ def _get_prefill_qty(self, move_line, qty=0):
505
+ """Returns the quantity to increment depending on no_prefill_qty optione."""
506
+ if self.work.menu.no_prefill_qty:
507
+ return qty
508
+ return move_line.reserved_uom_qty
509
+
510
+ def _check_first_scan_location_or_pack_first(
511
+ self, move_line, sublocation=None, location_scanned=False
512
+ ):
513
+ """Restrict scanning product or lot first with option on.
514
+
515
+ When the option first scan location or pack first is on.
516
+ When the line being worked on has a package, asked to scan the package first.
517
+ When the line as a lot ask to scan the location first.
518
+ """
519
+ if not self.work.menu.scan_location_or_pack_first:
520
+ return None
521
+ message = None
522
+ if move_line.package_id:
523
+ message = self.msg_store.line_has_package_scan_package()
524
+ elif not location_scanned and not sublocation:
525
+ message = self.msg_store.scan_the_location_first()
526
+ if message:
527
+ return self._response_for_start_line(
528
+ move_line,
529
+ message=message,
530
+ sublocation=location_scanned or sublocation or None,
531
+ )
532
+ return None
533
+
534
+ def _scan_line_by_package(self, picking, move_line, package, batch, sublocation):
535
+ """Package scanned, just work with it."""
536
+ quantity = self._get_prefill_qty(move_line)
537
+ return self._response_for_scan_destination(move_line, qty_done=quantity)
538
+
539
+ def _scan_line_by_product(self, picking, move_line, product, sublocation):
540
+ """Product scanned, check if we can work with it.
541
+
542
+ If scanned product is part of several packages in the same location,
543
+ we can't be sure it's the correct one, in such case, ask to scan a package.
544
+
545
+ If the product is tracked by lot and there is only one lot id in the location
546
+ not in a package. It can safely be picked up.
547
+ """
548
+ message = None
549
+ location_quants = move_line.location_id.quant_ids.filtered(
550
+ lambda quant: quant.quantity > 0 and quant.product_id == product
551
+ )
552
+ packages = location_quants.mapped("package_id")
553
+
554
+ response = self._check_first_scan_location_or_pack_first(move_line, sublocation)
555
+ if response:
556
+ return response
557
+
558
+ if move_line.product_id.tracking == "lot":
559
+ lots_at_location = location_quants.mapped("lot_id")
560
+ if len(lots_at_location) > 1 or packages:
561
+ message = self.msg_store.scan_lot_on_product_tracked_by_lot()
562
+ elif move_line.product_id.tracking == "serial":
563
+ message = self.msg_store.scan_lot_on_product_tracked_by_lot()
564
+ if message:
565
+ return self._response_for_start_line(move_line, message=message)
566
+
567
+ # Do not use mapped here: we want to see if we have more than one package,
568
+ # but also if we have one product as a package and the same product as
569
+ # a unit in another line. In both cases, we want the user to scan the
570
+ # package.
571
+ if packages and len({quant.package_id for quant in location_quants}) > 1:
572
+ return self._response_for_start_line(
573
+ move_line,
574
+ message=self.msg_store.product_multiple_packages_scan_package(),
575
+ )
576
+ quantity = self._get_prefill_qty(move_line, qty=1)
577
+ return self._response_for_scan_destination(move_line, qty_done=quantity)
578
+
579
+ def _scan_line_by_packaging(self, picking, move_line, packaging, sublocation):
580
+ """Packaging scanned, check if we can work with it.
581
+
582
+ If the packaging related product is part of several packages in the same location,
583
+ we can't be sure it's the correct one, in such case, ask to scan a package
584
+ """
585
+ response = self._check_first_scan_location_or_pack_first(move_line, sublocation)
586
+ if response:
587
+ return response
588
+
589
+ product = packaging.product_id
590
+ if move_line.product_id.tracking in ("lot", "serial"):
591
+ return self._response_for_start_line(
592
+ move_line, message=self.msg_store.scan_lot_on_product_tracked_by_lot()
593
+ )
594
+ other_product_lines = picking.move_line_ids.filtered(
595
+ lambda l: l.product_id == product and l.location_id == move_line.location_id
596
+ )
597
+ packages = other_product_lines.mapped("package_id")
598
+ # Do not use mapped here: we want to see if we have more than one package,
599
+ # but also if we have one product as a package and the same product as
600
+ # a unit in another line. In both cases, we want the user to scan the
601
+ # package.
602
+ if packages and len({line.package_id for line in other_product_lines}) > 1:
603
+ return self._response_for_start_line(
604
+ move_line,
605
+ message=self.msg_store.product_multiple_packages_scan_package(),
606
+ )
607
+ quantity = self._get_prefill_qty(move_line, packaging.qty)
608
+ return self._response_for_scan_destination(move_line, qty_done=quantity)
609
+
610
+ def _scan_line_by_lot(self, picking, move_line, lot, sublocation):
611
+ """Lot scanned, check if we can work with it.
612
+
613
+ If we scanned a lot and it's part of several packages, we can't be
614
+ sure the user scanned the correct one, in such case, ask to scan a package
615
+ """
616
+ response = self._check_first_scan_location_or_pack_first(move_line, sublocation)
617
+ if response:
618
+ return response
619
+
620
+ location_quants = move_line.location_id.quant_ids.filtered(
621
+ lambda quant: quant.quantity > 0 and quant.lot_id == lot
622
+ )
623
+ packages = location_quants.package_id
624
+
625
+ # Do not use mapped here: we want to see if we have more than one
626
+ # package, but also if we have one lot as a package and the same lot as
627
+ # a unit in another quant. In both cases, we want the user to scan the
628
+ # package.
629
+ if packages and len({quant.package_id for quant in location_quants}) > 1:
630
+ return self._response_for_start_line(
631
+ move_line, message=self.msg_store.lot_multiple_packages_scan_package()
632
+ )
633
+ quantity = self._get_prefill_qty(move_line, 1.0)
634
+ return self._response_for_scan_destination(move_line, qty_done=quantity)
635
+
636
+ def _scan_line_by_location(self, picking, move_line, location):
637
+ """Location scanned, check if we can work on goods contained into it.
638
+
639
+ When a user scan a location, we accept only when we knows that
640
+ they scanned the good thing, so if in the location we have
641
+ several lots (on a package or a product), several packages,
642
+ several products or a mix of several products and packages, we
643
+ ask to scan a more precise barcode.
644
+ """
645
+ response = self._check_first_scan_location_or_pack_first(
646
+ move_line, None, location_scanned=location
647
+ )
648
+ if response:
649
+ return response
650
+
651
+ location_quants = move_line.location_id.quant_ids.filtered(
652
+ lambda quant: quant.quantity > 0
653
+ )
654
+ lots = location_quants.lot_id
655
+ if len(lots) > 1:
656
+ return self._response_for_start_line(
657
+ move_line,
658
+ message=self.msg_store.several_lots_in_location(move_line.location_id),
659
+ sublocation=location,
660
+ )
661
+ packages = location_quants.package_id
662
+ products = location_quants.product_id
663
+ if len(packages) > 1 or len(products) > 1:
664
+ if move_line.package_id:
665
+ return self._response_for_start_line(
666
+ move_line,
667
+ message=self.msg_store.several_packs_in_location(
668
+ move_line.location_id,
669
+ ),
670
+ sublocation=location,
671
+ )
672
+ else:
673
+ return self._response_for_start_line(
674
+ move_line,
675
+ message=self.msg_store.several_products_in_location(
676
+ move_line.location_id,
677
+ ),
678
+ sublocation=location,
679
+ )
680
+ quantity = self._get_prefill_qty(move_line)
681
+ return self._response_for_scan_destination(move_line, qty_done=quantity)
682
+
683
+ def _set_destination_pack_update_quantity(self, move_line, quantity, barcode):
684
+ """Handle the done quantity increment on set_destination end point."""
685
+ response = None
686
+ if not self.work.menu.no_prefill_qty:
687
+ return response
688
+ search = self._actions_for("search")
689
+ # Handle barcode of product or packaging
690
+ product = search.product_from_scan(barcode)
691
+ packaging = self.env["product.packaging"].browse()
692
+ if not product:
693
+ packaging = search.packaging_from_scan(barcode)
694
+ product = packaging.product_id
695
+ if product and move_line.product_id == product:
696
+ quantity += packaging.qty or 1.0
697
+ response = self._response_for_scan_destination(move_line, qty_done=quantity)
698
+ return response
699
+ # Handle barcode of a lot
700
+ lot = search.lot_from_scan(barcode)
701
+ if lot and move_line.lot_id == lot:
702
+ quantity += 1.0
703
+ response = self._response_for_scan_destination(move_line, qty_done=quantity)
704
+ return response
705
+ return response
706
+
707
+ def scan_destination_pack(self, picking_batch_id, move_line_id, barcode, quantity):
708
+ """Scan the destination package (bin) for a move line
709
+
710
+ If the quantity picked (passed to the endpoint) is < expected quantity,
711
+ it splits the move line.
712
+ It changes the destination package of the move line and set the "qty done".
713
+ It prevents to put a move line of a picking in a destination package
714
+ used for another picking.
715
+
716
+ Transitions:
717
+ * zero_check: if the quantity of product moved is 0 in the
718
+ source location after the move (beware: at this point the product we put in
719
+ a bin is still considered to be in the source location, so we have to compute
720
+ the source location's quantity - qty_done).
721
+ * unload_all: when all lines have a destination package and they all
722
+ have the same destination.
723
+ * unload_single: when all lines have a destination package and they all
724
+ have the same destination.
725
+ * start_line: to pick the next line if any.
726
+ """
727
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
728
+ if not batch.exists():
729
+ return self._response_batch_does_not_exist()
730
+ move_line = self.env["stock.move.line"].browse(move_line_id)
731
+ if not move_line.exists():
732
+ return self._pick_next_line(
733
+ batch, message=self.msg_store.operation_not_found()
734
+ )
735
+
736
+ response = self._set_destination_pack_update_quantity(
737
+ move_line, quantity, barcode
738
+ )
739
+ if response:
740
+ return response
741
+
742
+ new_line, qty_check = move_line._split_qty_to_be_done(quantity)
743
+ if qty_check == "greater":
744
+ return self._response_for_scan_destination(
745
+ move_line,
746
+ message=self.msg_store.unable_to_pick_more(move_line.reserved_uom_qty),
747
+ qty_done=quantity,
748
+ )
749
+
750
+ search = self._actions_for("search")
751
+ bin_package = search.package_from_scan(barcode)
752
+ if not bin_package:
753
+ return self._response_for_scan_destination(
754
+ move_line,
755
+ message=self.msg_store.bin_not_found_for_barcode(barcode),
756
+ qty_done=quantity,
757
+ )
758
+
759
+ # the scanned package can contain only move lines of the same picking
760
+ different_picking = any(
761
+ ml.picking_id != move_line.picking_id
762
+ for ml in bin_package.planned_move_line_ids.filtered(
763
+ lambda x: x.state not in ("done", "cancel")
764
+ )
765
+ )
766
+ multi_pick_allowed = self.work.menu.multiple_move_single_pack
767
+ if not multi_pick_allowed and (bin_package.quant_ids or different_picking):
768
+ return self._response_for_scan_destination(
769
+ move_line,
770
+ message={
771
+ "message_type": "error",
772
+ "body": _(
773
+ "The destination bin {} is not empty, please take another."
774
+ ).format(bin_package.name),
775
+ },
776
+ qty_done=quantity,
777
+ )
778
+ move_line.write({"qty_done": quantity, "result_package_id": bin_package.id})
779
+
780
+ zero_check = move_line.picking_id.picking_type_id.shopfloor_zero_check
781
+ if zero_check and move_line.location_id.planned_qty_in_location_is_empty():
782
+ return self._response_for_zero_check(batch, move_line)
783
+
784
+ return self._pick_next_line(
785
+ batch,
786
+ message=self.msg_store.x_units_put_in_package(
787
+ move_line.qty_done, move_line.product_id, move_line.result_package_id
788
+ ),
789
+ # if we split the move line, we want to process the one generated by the
790
+ # split right now
791
+ force_line=new_line,
792
+ )
793
+
794
+ def _are_all_dest_location_same(self, batch):
795
+ lines_to_unload = self._lines_to_unload(batch)
796
+ return len(lines_to_unload.mapped("location_dest_id")) == 1
797
+
798
+ def prepare_unload(self, picking_batch_id):
799
+ """Initiate the unloading phase of the scenario
800
+
801
+ It goes to different screens depending if all the move lines have
802
+ the same destination or not.
803
+
804
+ Transitions:
805
+ * unload_all: when all lines go to the same destination
806
+ * unload_single: when lines have different destinations
807
+ """
808
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
809
+ if not batch.exists():
810
+ return self._response_batch_does_not_exist()
811
+ if self._are_all_dest_location_same(batch):
812
+ return self._response_for_unload_all(batch)
813
+ else:
814
+ # the lines have different destinations
815
+ return self._unload_next_package(batch)
816
+
817
+ def _data_for_unload_all(self, batch):
818
+ lines = self._lines_to_unload(batch)
819
+ # all the lines destinations are the same here, it looks
820
+ # only for the first one
821
+ first_line = fields.first(lines)
822
+ data = self.data.picking_batch(batch)
823
+ data.update({"location_dest": self.data.location(first_line.location_dest_id)})
824
+ return data
825
+
826
+ def _data_for_unload_single(self, batch, package):
827
+ line = fields.first(
828
+ package.planned_move_line_ids.filtered(self._filter_for_unload)
829
+ )
830
+ data = self.data.picking_batch(batch)
831
+ data.update(
832
+ {
833
+ "package": self.data.package(package),
834
+ "location_dest": self.data.location(line.location_dest_id),
835
+ }
836
+ )
837
+ return data
838
+
839
+ def _filter_for_unload(self, line):
840
+ return (
841
+ line.state in ("assigned", "partially_available")
842
+ and line.qty_done > 0
843
+ and line.result_package_id
844
+ and not line.shopfloor_unloaded
845
+ )
846
+
847
+ def _lines_to_unload(self, batch):
848
+ return self._lines_for_picking_batch(batch, filter_func=self._filter_for_unload)
849
+
850
+ def _bin_packages_to_unload(self, batch):
851
+ lines = self._lines_to_unload(batch)
852
+ packages = lines.mapped("result_package_id").sorted()
853
+ return packages
854
+
855
+ def _next_bin_package_for_unload_single(self, batch):
856
+ packages = self._bin_packages_to_unload(batch)
857
+ return fields.first(packages)
858
+
859
+ def is_zero(self, picking_batch_id, move_line_id, zero):
860
+ """Confirm or not if the source location of a move has zero qty
861
+
862
+ If the user confirms there is zero quantity, it means the stock was
863
+ correct and there is nothing to do. If the user says "no", a draft
864
+ empty inventory is created for the product (with lot if tracked).
865
+
866
+ Transitions:
867
+ * start_line: if the batch has lines without destination package (bin)
868
+ * unload_all: if all lines have a destination package and same
869
+ destination
870
+ * unload_single: if all lines have a destination package and different
871
+ destination
872
+ """
873
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
874
+ if not batch.exists():
875
+ return self._response_batch_does_not_exist()
876
+ move_line = self.env["stock.move.line"].browse(move_line_id)
877
+ if not move_line.exists():
878
+ return self._pick_next_line(
879
+ batch, message=self.msg_store.operation_not_found()
880
+ )
881
+
882
+ if not zero:
883
+ inventory = self._actions_for("inventory")
884
+ inventory.create_draft_check_empty(
885
+ move_line.location_id,
886
+ move_line.product_id,
887
+ ref=move_line.picking_id.name,
888
+ )
889
+
890
+ return self._pick_next_line(
891
+ batch,
892
+ message=self.msg_store.x_units_put_in_package(
893
+ move_line.qty_done, move_line.product_id, move_line.result_package_id
894
+ ),
895
+ )
896
+
897
+ def skip_line(self, picking_batch_id, move_line_id):
898
+ """Skip a line. The line will be processed at the end.
899
+
900
+ It adds a flag on the move line, when the next line to pick
901
+ is searched, lines with such flag at moved to the end.
902
+
903
+ A skipped line *must* be picked.
904
+
905
+ Transitions:
906
+ * start_line: with data for the next line (or itself if it's the last one,
907
+ in such case, a helpful message is returned)
908
+ """
909
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
910
+ if not batch.exists():
911
+ return self._response_batch_does_not_exist()
912
+ move_line = self.env["stock.move.line"].browse(move_line_id)
913
+ if not move_line.exists():
914
+ return self._pick_next_line(
915
+ batch, message=self.msg_store.operation_not_found()
916
+ )
917
+ # flag as postponed
918
+ move_line.shopfloor_postpone(self._lines_to_pick(batch))
919
+ return self._pick_after_skip_line(move_line)
920
+
921
+ def _pick_after_skip_line(self, move_line):
922
+ batch = move_line.picking_id.batch_id
923
+ return self._pick_next_line(batch)
924
+
925
+ def stock_issue(self, picking_batch_id, move_line_id):
926
+ """Declare a stock issue for a line
927
+
928
+ After errors in the stock, the user cannot take all the products
929
+ because there is physically not enough goods. The move line is deleted
930
+ (unreserve), and an inventory is created to reduce the quantity in the
931
+ source location to prevent future errors until a correction. Beware:
932
+ the quantity already reserved by other lines should remain reserved so
933
+ the inventory's quantity must be set to the quantity of lines reserved
934
+ by other move lines (but not the current one).
935
+
936
+ The other lines not yet picked in the batch for the same product, lot,
937
+ package are unreserved as well (moves lines deleted, which unreserve
938
+ their quantity on the move).
939
+
940
+ A second inventory is created in draft to have someone do an inventory
941
+ check.
942
+
943
+ Transitions:
944
+ * start_line: when the batch still contains lines without destination
945
+ package
946
+ * unload_all: if all lines have a destination package and same
947
+ destination
948
+ * unload_single: if all lines have a destination package and different
949
+ destination
950
+ * start: all lines are done/confirmed (because all lines were unloaded
951
+ and the last line has a stock issue). In this case, this method *has*
952
+ to handle the closing of the batch to create backorders (_unload_end)
953
+ """
954
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
955
+ if not batch.exists():
956
+ return self._response_batch_does_not_exist()
957
+ move_line = self.env["stock.move.line"].browse(move_line_id)
958
+ if not move_line.exists():
959
+ return self._pick_next_line(
960
+ batch, message=self.msg_store.operation_not_found()
961
+ )
962
+
963
+ inventory = self._actions_for("inventory")
964
+ # create a draft inventory for a user to check
965
+ inventory.create_control_stock(
966
+ move_line.location_id,
967
+ move_line.product_id,
968
+ move_line.package_id,
969
+ move_line.lot_id,
970
+ )
971
+ move = move_line.move_id
972
+ lot = move_line.lot_id
973
+ package = move_line.package_id
974
+ location = move_line.location_id
975
+
976
+ # unreserve every lines for the same product/lot in the same batch and
977
+ # not done yet, so the same user doesn't have to declare 2 times the
978
+ # stock issue for the same thing!
979
+ domain = self._domain_stock_issue_unlink_lines(move_line)
980
+ unreserve_move_lines = move_line | self.env["stock.move.line"].search(domain)
981
+ unreserve_moves = unreserve_move_lines.mapped("move_id").sorted()
982
+ unreserve_move_lines.unlink()
983
+
984
+ # Then, create an inventory with just enough qty so the other assigned
985
+ # move lines for the same product in other batches and the other move lines
986
+ # already picked stay assigned.
987
+ inventory.create_stock_issue(move, location, package, lot)
988
+
989
+ # try to reassign the moves in case we have stock in another location
990
+ unreserve_moves._action_assign()
991
+
992
+ return self._pick_next_line(batch)
993
+
994
+ def _domain_stock_issue_unlink_lines(self, move_line):
995
+ # Since we have not enough stock, delete the move lines, which will
996
+ # in turn unreserve the moves. The moves lines we delete are those
997
+ # in the same batch (we don't want to interfere with other operators
998
+ # work, they'll have to declare a stock issue), and not yet started.
999
+ # The goal is to prevent the same operator to declare twice the same
1000
+ # stock issue for the same product/lot/package.
1001
+ batch = move_line.picking_id.batch_id
1002
+ move = move_line.move_id
1003
+ lot = move_line.lot_id
1004
+ package = move_line.package_id
1005
+ location = move_line.location_id
1006
+ domain = [
1007
+ ("location_id", "=", location.id),
1008
+ ("product_id", "=", move.product_id.id),
1009
+ ("package_id", "=", package.id),
1010
+ ("lot_id", "=", lot.id),
1011
+ ("state", "not in", ("cancel", "done")),
1012
+ ("qty_done", "=", 0),
1013
+ ("picking_id.batch_id", "=", batch.id),
1014
+ ]
1015
+ return domain
1016
+
1017
+ def change_pack_lot(self, picking_batch_id, move_line_id, barcode, quantity=None):
1018
+ """Change the expected pack or the lot for a line
1019
+
1020
+ If the expected lot is at the very bottom of the location or a stock
1021
+ error forces a user to change lot or pack, user can change the pack or
1022
+ lot of the current line.
1023
+
1024
+ The change occurs when the pack/product/lot is normally scanned and
1025
+ goes directly to the scan of the destination package (bin) since we do
1026
+ not need to check it.
1027
+
1028
+ If the pack or lot was not supposed to be in the source location,
1029
+ a draft inventory is created to have this checked.
1030
+
1031
+ Transitions:
1032
+ * scan_destination: the pack or the lot could be changed
1033
+ * change_pack_lot: any error occurred during the change
1034
+ """
1035
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
1036
+ if not batch.exists():
1037
+ return self._response_batch_does_not_exist()
1038
+ move_line = self.env["stock.move.line"].browse(move_line_id)
1039
+ if not move_line.exists():
1040
+ return self._pick_next_line(
1041
+ batch, message=self.msg_store.operation_not_found()
1042
+ )
1043
+ search = self._actions_for("search")
1044
+ response_ok_func = self._response_for_scan_destination
1045
+ response_error_func = self._response_for_change_pack_lot
1046
+ change_package_lot = self._actions_for("change.package.lot")
1047
+ lot = search.lot_from_scan(barcode, products=move_line.product_id)
1048
+ if lot:
1049
+ response = change_package_lot.change_lot(
1050
+ move_line, lot, response_ok_func, response_error_func
1051
+ )
1052
+ if response:
1053
+ if "scan_destination" in response["data"] and quantity is not None:
1054
+ response["data"]["scan_destination"]["qty_done"] = quantity
1055
+ return response
1056
+
1057
+ package = search.package_from_scan(barcode)
1058
+ if package:
1059
+ response = change_package_lot.change_package(
1060
+ move_line, package, response_ok_func, response_error_func
1061
+ )
1062
+ if "scan_destination" in response["data"] and quantity is not None:
1063
+ response["data"]["scan_destination"]["qty_done"] = quantity
1064
+ return response
1065
+
1066
+ return self._response_for_change_pack_lot(
1067
+ move_line,
1068
+ message=self.msg_store.no_package_or_lot_for_barcode(barcode),
1069
+ )
1070
+
1071
+ def set_destination_all(self, picking_batch_id, barcode, confirmation=False):
1072
+ """Set the destination for all the lines of the batch with a dest. package
1073
+
1074
+ This method must be used only if all the move lines which have a destination
1075
+ package and qty done have the same destination location.
1076
+
1077
+ A scanned location outside of the source location of the operation type is
1078
+ invalid.
1079
+
1080
+ Transitions:
1081
+ * start_line: the batch still have move lines without destination package
1082
+ * unload_all: invalid destination, have to scan a good one
1083
+ * confirm_unload_all: the scanned location is not the expected one (but
1084
+ still a valid one)
1085
+ * start: batch is totally done. In this case, this method *has*
1086
+ to handle the closing of the batch to create backorders.
1087
+ """
1088
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
1089
+ if not batch.exists():
1090
+ return self._response_batch_does_not_exist()
1091
+
1092
+ # In case /set_destination_all was called and the destinations were
1093
+ # in fact no the same... restart the unloading step over
1094
+ if not self._are_all_dest_location_same(batch):
1095
+ return self.prepare_unload(batch.id)
1096
+
1097
+ lines = self._lines_to_unload(batch)
1098
+ if not lines:
1099
+ return self._unload_end(batch)
1100
+
1101
+ first_line = fields.first(lines)
1102
+ scanned_location = self._actions_for("search").location_from_scan(barcode)
1103
+ if not scanned_location:
1104
+ return self._response_for_unload_all(
1105
+ batch, message=self.msg_store.no_location_found()
1106
+ )
1107
+ if not self.is_dest_location_valid(lines.move_id, scanned_location):
1108
+ return self._response_for_unload_all(
1109
+ batch, message=self.msg_store.dest_location_not_allowed()
1110
+ )
1111
+
1112
+ if not confirmation and self.is_dest_location_to_confirm(
1113
+ first_line.location_dest_id, scanned_location
1114
+ ):
1115
+ return self._response_for_confirm_unload_all(batch)
1116
+
1117
+ self._unload_write_destination_on_lines(lines, scanned_location)
1118
+ completion_info = self._actions_for("completion.info")
1119
+ completion_info_popup = completion_info.popup(lines)
1120
+ return self._unload_end(batch, completion_info_popup=completion_info_popup)
1121
+
1122
+ def _unload_write_destination_on_lines(self, lines, location):
1123
+ lines.write({"shopfloor_unloaded": True, "location_dest_id": location.id})
1124
+ lines.package_level_id.location_dest_id = location
1125
+ for picking in lines.batch_id.picking_ids:
1126
+ picking_lines = lines.filtered(lambda l, p=picking: l.picking_id == p)
1127
+ self._unload_set_picking_to_done(picking, picking_lines)
1128
+
1129
+ def _unload_set_picking_to_done(self, picking, picking_lines):
1130
+ if picking.state == "done":
1131
+ return
1132
+ # We set the picking to done only when the last line is
1133
+ # unloaded to avoid backorders.
1134
+ all_lines_unloaded = all(
1135
+ line.shopfloor_unloaded for line in picking.move_line_ids
1136
+ )
1137
+ if self.work.menu.unload_package_at_destination and all_lines_unloaded:
1138
+ picking_lines.result_package_id = False
1139
+ if all_lines_unloaded:
1140
+ picking._action_done()
1141
+
1142
+ def _unload_end(self, batch, completion_info_popup=None):
1143
+ """Try to close the batch if all transfers are done.
1144
+
1145
+ Returns to `start_line` transition if some lines could still be processed,
1146
+ otherwise try to validate all the transfers of the batch.
1147
+ """
1148
+ all_pickings = batch.picking_ids
1149
+ if all(picking.state == "done" for picking in all_pickings):
1150
+ # do not use the 'done()' method because it does many things we
1151
+ # don't care about
1152
+ batch.state = "done"
1153
+ return self._response_for_start(
1154
+ message=self.msg_store.batch_transfer_complete(),
1155
+ popup=completion_info_popup,
1156
+ )
1157
+
1158
+ next_line = self._next_line_for_pick(batch)
1159
+ if next_line:
1160
+ return self._response_for_start_line(
1161
+ next_line,
1162
+ message=self.msg_store.batch_transfer_line_done(),
1163
+ popup=completion_info_popup,
1164
+ )
1165
+ else:
1166
+ # TODO add tests for this (for instance a picking is not 'done'
1167
+ # because a move was unassigned, we want to validate the batch to
1168
+ # produce backorders)
1169
+ all_pickings.filtered(lambda x: x.state == "assigned")._action_done()
1170
+ batch.state = "done"
1171
+ # Unassign not validated pickings from the batch, they will be
1172
+ # processed in another batch automatically later on
1173
+ all_pickings.invalidate_recordset(["state"])
1174
+ pickings_not_done = all_pickings.filtered(lambda p: p.state != "done")
1175
+ pickings_not_done.batch_id = False
1176
+ return self._response_for_start(
1177
+ message=self.msg_store.batch_transfer_complete(),
1178
+ popup=completion_info_popup,
1179
+ )
1180
+
1181
+ def unload_split(self, picking_batch_id):
1182
+ """Indicates that now the batch must be treated line per line
1183
+
1184
+ Even if the move lines to unload all have the same destination.
1185
+
1186
+ Note: if we go back to the first phase of picking and start a new
1187
+ phase of unloading, the flag is reevaluated to the initial condition.
1188
+
1189
+ Transitions:
1190
+ * unload_single: always goes here since we now want to unload line per line
1191
+ """
1192
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
1193
+ if not batch.exists():
1194
+ return self._response_batch_does_not_exist()
1195
+
1196
+ return self._unload_next_package(batch)
1197
+
1198
+ def unload_scan_pack(self, picking_batch_id, package_id, barcode):
1199
+ """Check that the operator scans the correct package (bin) on unload
1200
+
1201
+ If the scanned barcode is not the one of the Bin (package), ask to scan
1202
+ again.
1203
+
1204
+ Transitions:
1205
+ * unload_single: if the barcode does not match
1206
+ * unload_set_destination: barcode is correct
1207
+ """
1208
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
1209
+ if not batch.exists():
1210
+ return self._response_batch_does_not_exist()
1211
+ package = self.env["stock.quant.package"].browse(package_id)
1212
+ if not package.exists():
1213
+ return self._unload_next_package(batch)
1214
+ if package.name != barcode:
1215
+ return self._response_for_unload_single(
1216
+ batch,
1217
+ package,
1218
+ message={"message_type": "error", "body": _("Wrong bin")},
1219
+ )
1220
+ return self._response_for_unload_set_destination(batch, package)
1221
+
1222
+ def unload_scan_destination(
1223
+ self, picking_batch_id, package_id, barcode, confirmation=False
1224
+ ):
1225
+ """Scan the final destination for all the move lines moved with the Bin
1226
+
1227
+ It updates all the assigned move lines with the package to the
1228
+ destination.
1229
+
1230
+ Transitions:
1231
+ * unload_single: invalid scanned location or error
1232
+ * unload_single: line is processed and the next bin can be unloaded
1233
+ * confirm_unload_set_destination: the destination is valid but not the
1234
+ expected, ask a confirmation. This state has to call again the
1235
+ endpoint with confirmation=True
1236
+ * start_line: if the batch still has lines to pick
1237
+ * start: if the batch is done. In this case, this method *has*
1238
+ to handle the closing of the batch to create backorders.
1239
+
1240
+ """
1241
+ batch = self.env["stock.picking.batch"].browse(picking_batch_id)
1242
+ if not batch.exists():
1243
+ return self._response_batch_does_not_exist()
1244
+
1245
+ package = self.env["stock.quant.package"].browse(package_id)
1246
+ if not package.exists():
1247
+ return self._unload_next_package(batch)
1248
+
1249
+ # we work only on the lines of the scanned package
1250
+ lines = self._lines_to_unload(batch).filtered(
1251
+ lambda l: l.result_package_id == package
1252
+ )
1253
+ if not lines:
1254
+ return self._unload_end(batch)
1255
+
1256
+ return self._unload_scan_destination_lines(
1257
+ batch, package, lines, barcode, confirmation=confirmation
1258
+ )
1259
+
1260
+ def _lock_lines(self, lines):
1261
+ """Lock move lines"""
1262
+ self._actions_for("lock").for_update(lines)
1263
+
1264
+ def _unload_scan_destination_lines(
1265
+ self, batch, package, lines, barcode, confirmation=False
1266
+ ):
1267
+ # Lock move lines that will be updated
1268
+ self._lock_lines(lines)
1269
+ first_line = fields.first(lines)
1270
+ scanned_location = self._actions_for("search").location_from_scan(barcode)
1271
+ if not scanned_location:
1272
+ return self._response_for_unload_set_destination(
1273
+ batch, package, message=self.msg_store.no_location_found()
1274
+ )
1275
+ if not self.is_dest_location_valid(lines.move_id, scanned_location):
1276
+ return self._response_for_unload_set_destination(
1277
+ batch, package, message=self.msg_store.dest_location_not_allowed()
1278
+ )
1279
+ if not confirmation and self.is_dest_location_to_confirm(
1280
+ first_line.location_dest_id, scanned_location
1281
+ ):
1282
+ return self._response_for_confirm_unload_set_destination(batch, package)
1283
+
1284
+ self._unload_write_destination_on_lines(lines, scanned_location)
1285
+
1286
+ completion_info = self._actions_for("completion.info")
1287
+ completion_info_popup = completion_info.popup(lines)
1288
+
1289
+ return self._unload_next_package(
1290
+ batch, completion_info_popup=completion_info_popup
1291
+ )
1292
+
1293
+ def _unload_next_package(self, batch, completion_info_popup=None):
1294
+ next_package = self._next_bin_package_for_unload_single(batch)
1295
+ if next_package:
1296
+ return self._response_for_unload_single(
1297
+ batch, next_package, popup=completion_info_popup
1298
+ )
1299
+ return self._unload_end(batch, completion_info_popup=completion_info_popup)
1300
+
1301
+
1302
+ class ShopfloorClusterPickingValidator(Component):
1303
+ """Validators for the Cluster Picking endpoints"""
1304
+
1305
+ _inherit = "base.shopfloor.validator"
1306
+ _name = "shopfloor.cluster_picking.validator"
1307
+ _usage = "cluster_picking.validator"
1308
+
1309
+ def find_batch(self):
1310
+ return {}
1311
+
1312
+ def list_batch(self):
1313
+ return {}
1314
+
1315
+ def select(self):
1316
+ return {
1317
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
1318
+ }
1319
+
1320
+ def confirm_start(self):
1321
+ return {
1322
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
1323
+ }
1324
+
1325
+ def unassign(self):
1326
+ return {
1327
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
1328
+ }
1329
+
1330
+ def scan_line(self):
1331
+ return {
1332
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
1333
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1334
+ "barcode": {"required": True, "type": "string"},
1335
+ "sublocation_id": {"required": False, "nullable": True, "type": "integer"},
1336
+ }
1337
+
1338
+ def scan_destination_pack(self):
1339
+ return {
1340
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
1341
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1342
+ "barcode": {"required": True, "type": "string"},
1343
+ "quantity": {
1344
+ "coerce": to_float,
1345
+ "required": True,
1346
+ "nullable": True,
1347
+ "type": "float",
1348
+ },
1349
+ }
1350
+
1351
+ def prepare_unload(self):
1352
+ return {
1353
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
1354
+ }
1355
+
1356
+ def is_zero(self):
1357
+ return {
1358
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
1359
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1360
+ "zero": {"coerce": to_bool, "required": True, "type": "boolean"},
1361
+ }
1362
+
1363
+ def skip_line(self):
1364
+ return {
1365
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
1366
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1367
+ }
1368
+
1369
+ def stock_issue(self):
1370
+ return {
1371
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
1372
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1373
+ }
1374
+
1375
+ def change_pack_lot(self):
1376
+ return {
1377
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
1378
+ "move_line_id": {"coerce": to_int, "required": True, "type": "integer"},
1379
+ "barcode": {"required": True, "type": "string"},
1380
+ "quantity": {"required": False, "type": "float"},
1381
+ }
1382
+
1383
+ def set_destination_all(self):
1384
+ return {
1385
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
1386
+ "barcode": {"required": True, "type": "string"},
1387
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
1388
+ }
1389
+
1390
+ def unload_split(self):
1391
+ return {
1392
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"}
1393
+ }
1394
+
1395
+ def unload_scan_pack(self):
1396
+ return {
1397
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
1398
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
1399
+ "barcode": {"required": True, "type": "string"},
1400
+ }
1401
+
1402
+ def unload_scan_destination(self):
1403
+ return {
1404
+ "picking_batch_id": {"coerce": to_int, "required": True, "type": "integer"},
1405
+ "package_id": {"coerce": to_int, "required": True, "type": "integer"},
1406
+ "barcode": {"required": True, "type": "string"},
1407
+ "confirmation": {"type": "boolean", "nullable": True, "required": False},
1408
+ }
1409
+
1410
+
1411
+ class ShopfloorClusterPickingValidatorResponse(Component):
1412
+ """Validators for the Cluster Picking endpoints responses"""
1413
+
1414
+ _inherit = "base.shopfloor.validator.response"
1415
+ _name = "shopfloor.cluster_picking.validator.response"
1416
+ _usage = "cluster_picking.validator.response"
1417
+
1418
+ def _states(self):
1419
+ """List of possible next states
1420
+
1421
+ With the schema of the data send to the client to transition
1422
+ to the next state.
1423
+ """
1424
+ return {
1425
+ "confirm_start": self._schema_for_batch_details,
1426
+ "start_line": self._schema_for_single_line_details,
1427
+ "start": {},
1428
+ "manual_selection": self._schema_for_batch_selection,
1429
+ "scan_destination": self._schema_for_scan_destination,
1430
+ "zero_check": self._schema_for_zero_check,
1431
+ "unload_all": self._schema_for_unload_all,
1432
+ "confirm_unload_all": self._schema_for_unload_all,
1433
+ "unload_single": self._schema_for_unload_single,
1434
+ "unload_set_destination": self._schema_for_unload_single,
1435
+ "confirm_unload_set_destination": self._schema_for_unload_single,
1436
+ "change_pack_lot": self._schema_for_single_line_details,
1437
+ }
1438
+
1439
+ def find_batch(self):
1440
+ return self._response_schema(next_states={"confirm_start"})
1441
+
1442
+ def list_batch(self):
1443
+ return self._response_schema(next_states={"manual_selection"})
1444
+
1445
+ def select(self):
1446
+ return self._response_schema(next_states={"manual_selection", "confirm_start"})
1447
+
1448
+ def confirm_start(self):
1449
+ return self._response_schema(
1450
+ next_states={
1451
+ "start_line",
1452
+ # we reopen a batch already started where all the lines were
1453
+ # already picked and have to be unloaded to the same
1454
+ # destination
1455
+ "unload_all",
1456
+ # we reopen a batch already started where all the lines were
1457
+ # already picked and have to be unloaded to the different
1458
+ # destinations
1459
+ "unload_single",
1460
+ }
1461
+ )
1462
+
1463
+ def unassign(self):
1464
+ return self._response_schema(next_states={"start"})
1465
+
1466
+ def scan_line(self):
1467
+ return self._response_schema(next_states={"start_line", "scan_destination"})
1468
+
1469
+ def scan_destination_pack(self):
1470
+ return self._response_schema(
1471
+ next_states={
1472
+ # error during scan of pack (wrong barcode, ...)
1473
+ "scan_destination",
1474
+ # when we still have lines to process
1475
+ "start_line",
1476
+ # when the source location is empty
1477
+ "zero_check",
1478
+ # when all lines have been processed and have same
1479
+ # destination
1480
+ "unload_all",
1481
+ # when all lines have been processed and have different
1482
+ # destinations
1483
+ "unload_single",
1484
+ }
1485
+ )
1486
+
1487
+ def prepare_unload(self):
1488
+ return self._response_schema(
1489
+ next_states={
1490
+ # when all lines have been processed and have same
1491
+ # destination
1492
+ "unload_all",
1493
+ # when all lines have been processed and have different
1494
+ # destinations
1495
+ "unload_single",
1496
+ }
1497
+ )
1498
+
1499
+ def is_zero(self):
1500
+ return self._response_schema(
1501
+ next_states={
1502
+ # when we still have lines to process
1503
+ "start_line",
1504
+ # when all lines have been processed and have same
1505
+ # destination
1506
+ "unload_all",
1507
+ # when all lines have been processed and have different
1508
+ # destinations
1509
+ "unload_single",
1510
+ }
1511
+ )
1512
+
1513
+ def skip_line(self):
1514
+ return self._response_schema(next_states={"start_line"})
1515
+
1516
+ def stock_issue(self):
1517
+ return self._response_schema(
1518
+ next_states={
1519
+ # when we still have lines to process
1520
+ "start_line",
1521
+ # when all lines have been processed and have same
1522
+ # destination
1523
+ "unload_all",
1524
+ # when all lines have been processed and have different
1525
+ # destinations
1526
+ "unload_single",
1527
+ }
1528
+ )
1529
+
1530
+ def change_pack_lot(self):
1531
+ return self._response_schema(
1532
+ next_states={"change_pack_lot", "scan_destination"}
1533
+ )
1534
+
1535
+ def set_destination_all(self):
1536
+ return self._response_schema(
1537
+ next_states={
1538
+ # if the batch still contain lines
1539
+ "start_line",
1540
+ # invalid destination, have to scan a valid one
1541
+ "unload_all",
1542
+ # this endpoint was called but after checking, lines
1543
+ # have different destination locations
1544
+ "unload_single",
1545
+ # different destination to confirm
1546
+ "confirm_unload_all",
1547
+ # batch finished
1548
+ "start",
1549
+ }
1550
+ )
1551
+
1552
+ def unload_split(self):
1553
+ return self._response_schema(next_states={"unload_single"})
1554
+
1555
+ def unload_scan_pack(self):
1556
+ return self._response_schema(
1557
+ next_states={
1558
+ # go back to the same state if barcode issue
1559
+ "unload_single",
1560
+ # if the package to scan was deleted, was the last to unload
1561
+ # and we still have lines to pick
1562
+ "start_line",
1563
+ # next "logical" state, when the scan is ok
1564
+ "unload_set_destination",
1565
+ }
1566
+ )
1567
+
1568
+ def unload_scan_destination(self):
1569
+ return self._response_schema(
1570
+ next_states={
1571
+ "unload_single",
1572
+ "unload_set_destination",
1573
+ "confirm_unload_set_destination",
1574
+ "start",
1575
+ "start_line",
1576
+ }
1577
+ )
1578
+
1579
+ @property
1580
+ def _schema_for_batch_details(self):
1581
+ return self.schemas.picking_batch(with_pickings=True)
1582
+
1583
+ @property
1584
+ def _schema_for_single_line_details(self):
1585
+ schema = self.schemas.move_line()
1586
+ schema["picking"] = self.schemas._schema_dict_of(self.schemas.picking())
1587
+ schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch())
1588
+ schema["scan_location_or_pack_first"] = {
1589
+ "type": "boolean",
1590
+ "nullable": False,
1591
+ "required": False,
1592
+ }
1593
+ schema["sublocation"] = self.schemas._schema_dict_of(
1594
+ self.schemas.location(), nullable=False, required=False
1595
+ )
1596
+ return schema
1597
+
1598
+ @property
1599
+ def _schema_for_unload_all(self):
1600
+ schema = self.schemas.picking_batch()
1601
+ schema["location_dest"] = self.schemas._schema_dict_of(self.schemas.location())
1602
+ return schema
1603
+
1604
+ @property
1605
+ def _schema_for_unload_single(self):
1606
+ schema = self.schemas.picking_batch()
1607
+ schema["package"] = self.schemas._schema_dict_of(self.schemas.package())
1608
+ schema["location_dest"] = self.schemas._schema_dict_of(self.schemas.location())
1609
+ return schema
1610
+
1611
+ @property
1612
+ def _schema_for_zero_check(self):
1613
+ schema = {
1614
+ "id": {"required": True, "type": "integer"},
1615
+ }
1616
+ schema["location_src"] = self.schemas._schema_dict_of(self.schemas.location())
1617
+ schema["batch"] = self.schemas._schema_dict_of(self.schemas.picking_batch())
1618
+ return schema
1619
+
1620
+ @property
1621
+ def _schema_for_batch_selection(self):
1622
+ return self.schemas._schema_search_results_of(self.schemas.picking_batch())
1623
+
1624
+ @property
1625
+ def _schema_for_scan_destination(self):
1626
+ schema = self._schema_for_single_line_details
1627
+ schema["disable_full_bin_action"] = {"type": "boolean"}
1628
+ return schema