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.
- odoo/addons/shopfloor/README.rst +160 -0
- odoo/addons/shopfloor/__init__.py +4 -0
- odoo/addons/shopfloor/__manifest__.py +65 -0
- odoo/addons/shopfloor/actions/__init__.py +15 -0
- odoo/addons/shopfloor/actions/change_package_lot.py +164 -0
- odoo/addons/shopfloor/actions/completion_info.py +42 -0
- odoo/addons/shopfloor/actions/data.py +329 -0
- odoo/addons/shopfloor/actions/data_detail.py +154 -0
- odoo/addons/shopfloor/actions/inventory.py +150 -0
- odoo/addons/shopfloor/actions/location_content_transfer_sorter.py +89 -0
- odoo/addons/shopfloor/actions/message.py +846 -0
- odoo/addons/shopfloor/actions/move_line_search.py +119 -0
- odoo/addons/shopfloor/actions/packaging.py +59 -0
- odoo/addons/shopfloor/actions/savepoint.py +44 -0
- odoo/addons/shopfloor/actions/schema.py +182 -0
- odoo/addons/shopfloor/actions/schema_detail.py +98 -0
- odoo/addons/shopfloor/actions/search.py +187 -0
- odoo/addons/shopfloor/actions/stock.py +239 -0
- odoo/addons/shopfloor/actions/stock_unreserve.py +66 -0
- odoo/addons/shopfloor/components/__init__.py +5 -0
- odoo/addons/shopfloor/components/scan_handler_location.py +26 -0
- odoo/addons/shopfloor/components/scan_handler_lot.py +26 -0
- odoo/addons/shopfloor/components/scan_handler_package.py +26 -0
- odoo/addons/shopfloor/components/scan_handler_product.py +26 -0
- odoo/addons/shopfloor/components/scan_handler_transfer.py +26 -0
- odoo/addons/shopfloor/data/shopfloor_scenario_data.xml +73 -0
- odoo/addons/shopfloor/demo/shopfloor_app_demo.xml +12 -0
- odoo/addons/shopfloor/demo/shopfloor_menu_demo.xml +64 -0
- odoo/addons/shopfloor/demo/shopfloor_profile_demo.xml +8 -0
- odoo/addons/shopfloor/demo/stock_picking_type_demo.xml +93 -0
- odoo/addons/shopfloor/docs/checkout_diag_seq.plantuml +61 -0
- odoo/addons/shopfloor/docs/checkout_diag_seq.png +0 -0
- odoo/addons/shopfloor/docs/cluster_picking_diag_seq.plantuml +112 -0
- odoo/addons/shopfloor/docs/cluster_picking_diag_seq.png +0 -0
- odoo/addons/shopfloor/docs/delivery_diag_seq.plantuml +56 -0
- odoo/addons/shopfloor/docs/delivery_diag_seq.png +0 -0
- odoo/addons/shopfloor/docs/location_content_transfer_diag_seq.plantuml +66 -0
- odoo/addons/shopfloor/docs/location_content_transfer_diag_seq.png +0 -0
- odoo/addons/shopfloor/docs/oca_logo.png +0 -0
- odoo/addons/shopfloor/docs/single_pack_transfer_diag_seq.plantuml +36 -0
- odoo/addons/shopfloor/docs/single_pack_transfer_diag_seq.png +0 -0
- odoo/addons/shopfloor/docs/zone_picking_diag_seq.plantuml +85 -0
- odoo/addons/shopfloor/docs/zone_picking_diag_seq.png +0 -0
- odoo/addons/shopfloor/exceptions.py +6 -0
- odoo/addons/shopfloor/i18n/ca.po +1802 -0
- odoo/addons/shopfloor/i18n/de.po +1791 -0
- odoo/addons/shopfloor/i18n/es_AR.po +2147 -0
- odoo/addons/shopfloor/i18n/pt_BR.po +1791 -0
- odoo/addons/shopfloor/i18n/shopfloor.pot +1877 -0
- odoo/addons/shopfloor/models/__init__.py +12 -0
- odoo/addons/shopfloor/models/priority_postpone_mixin.py +41 -0
- odoo/addons/shopfloor/models/shopfloor_app.py +9 -0
- odoo/addons/shopfloor/models/shopfloor_menu.py +436 -0
- odoo/addons/shopfloor/models/stock_location.py +76 -0
- odoo/addons/shopfloor/models/stock_move.py +119 -0
- odoo/addons/shopfloor/models/stock_move_line.py +307 -0
- odoo/addons/shopfloor/models/stock_package_level.py +50 -0
- odoo/addons/shopfloor/models/stock_picking.py +118 -0
- odoo/addons/shopfloor/models/stock_picking_batch.py +41 -0
- odoo/addons/shopfloor/models/stock_picking_type.py +26 -0
- odoo/addons/shopfloor/models/stock_quant.py +31 -0
- odoo/addons/shopfloor/models/stock_quant_package.py +101 -0
- odoo/addons/shopfloor/readme/CONTRIBUTORS.rst +18 -0
- odoo/addons/shopfloor/readme/CREDITS.rst +5 -0
- odoo/addons/shopfloor/readme/DESCRIPTION.rst +17 -0
- odoo/addons/shopfloor/readme/HISTORY.rst +4 -0
- odoo/addons/shopfloor/readme/ROADMAP.rst +4 -0
- odoo/addons/shopfloor/readme/USAGE.rst +6 -0
- odoo/addons/shopfloor/security/groups.xml +17 -0
- odoo/addons/shopfloor/services/__init__.py +16 -0
- odoo/addons/shopfloor/services/checkout.py +1763 -0
- odoo/addons/shopfloor/services/cluster_picking.py +1628 -0
- odoo/addons/shopfloor/services/delivery.py +828 -0
- odoo/addons/shopfloor/services/forms/__init__.py +1 -0
- odoo/addons/shopfloor/services/forms/picking_form.py +78 -0
- odoo/addons/shopfloor/services/location_content_transfer.py +1194 -0
- odoo/addons/shopfloor/services/menu.py +60 -0
- odoo/addons/shopfloor/services/picking_batch.py +126 -0
- odoo/addons/shopfloor/services/service.py +101 -0
- odoo/addons/shopfloor/services/single_pack_transfer.py +366 -0
- odoo/addons/shopfloor/services/zone_picking.py +1938 -0
- odoo/addons/shopfloor/static/description/icon.png +0 -0
- odoo/addons/shopfloor/static/description/index.html +500 -0
- odoo/addons/shopfloor/tests/__init__.py +83 -0
- odoo/addons/shopfloor/tests/common.py +324 -0
- odoo/addons/shopfloor/tests/models.py +29 -0
- odoo/addons/shopfloor/tests/test_actions_change_package_lot.py +1175 -0
- odoo/addons/shopfloor/tests/test_actions_data.py +376 -0
- odoo/addons/shopfloor/tests/test_actions_data_base.py +244 -0
- odoo/addons/shopfloor/tests/test_actions_data_detail.py +322 -0
- odoo/addons/shopfloor/tests/test_actions_search.py +248 -0
- odoo/addons/shopfloor/tests/test_actions_stock.py +48 -0
- odoo/addons/shopfloor/tests/test_checkout_auto_post.py +67 -0
- odoo/addons/shopfloor/tests/test_checkout_base.py +81 -0
- odoo/addons/shopfloor/tests/test_checkout_cancel_line.py +154 -0
- odoo/addons/shopfloor/tests/test_checkout_change_packaging.py +184 -0
- odoo/addons/shopfloor/tests/test_checkout_done.py +133 -0
- odoo/addons/shopfloor/tests/test_checkout_list_delivery_packaging.py +131 -0
- odoo/addons/shopfloor/tests/test_checkout_list_package.py +327 -0
- odoo/addons/shopfloor/tests/test_checkout_new_package.py +88 -0
- odoo/addons/shopfloor/tests/test_checkout_no_package.py +95 -0
- odoo/addons/shopfloor/tests/test_checkout_scan.py +174 -0
- odoo/addons/shopfloor/tests/test_checkout_scan_line.py +377 -0
- odoo/addons/shopfloor/tests/test_checkout_scan_line_base.py +25 -0
- odoo/addons/shopfloor/tests/test_checkout_scan_line_no_prefill_qty.py +91 -0
- odoo/addons/shopfloor/tests/test_checkout_scan_package_action.py +451 -0
- odoo/addons/shopfloor/tests/test_checkout_scan_package_action_no_prefill_qty.py +107 -0
- odoo/addons/shopfloor/tests/test_checkout_select.py +74 -0
- odoo/addons/shopfloor/tests/test_checkout_select_line.py +130 -0
- odoo/addons/shopfloor/tests/test_checkout_select_package_base.py +64 -0
- odoo/addons/shopfloor/tests/test_checkout_set_qty.py +257 -0
- odoo/addons/shopfloor/tests/test_checkout_summary.py +69 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_base.py +83 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_batch.py +109 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_change_pack_lot.py +111 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_is_zero.py +98 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_scan_destination.py +376 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_scan_destination_no_prefill_qty.py +115 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_scan_line.py +402 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_scan_line_location_or_pack_first.py +114 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_scan_line_no_prefill_qty.py +70 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_select.py +387 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_skip.py +90 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_stock_issue.py +364 -0
- odoo/addons/shopfloor/tests/test_cluster_picking_unload.py +911 -0
- odoo/addons/shopfloor/tests/test_delivery_base.py +155 -0
- odoo/addons/shopfloor/tests/test_delivery_done.py +108 -0
- odoo/addons/shopfloor/tests/test_delivery_list_stock_picking.py +49 -0
- odoo/addons/shopfloor/tests/test_delivery_reset_qty_done_line.py +119 -0
- odoo/addons/shopfloor/tests/test_delivery_reset_qty_done_pack.py +107 -0
- odoo/addons/shopfloor/tests/test_delivery_scan_deliver.py +557 -0
- odoo/addons/shopfloor/tests/test_delivery_select.py +38 -0
- odoo/addons/shopfloor/tests/test_delivery_set_qty_done_line.py +91 -0
- odoo/addons/shopfloor/tests/test_delivery_set_qty_done_pack.py +135 -0
- odoo/addons/shopfloor/tests/test_delivery_sublocation.py +180 -0
- odoo/addons/shopfloor/tests/test_location_content_transfer_base.py +136 -0
- odoo/addons/shopfloor/tests/test_location_content_transfer_get_work.py +125 -0
- odoo/addons/shopfloor/tests/test_location_content_transfer_mix.py +509 -0
- odoo/addons/shopfloor/tests/test_location_content_transfer_putaway.py +143 -0
- odoo/addons/shopfloor/tests/test_location_content_transfer_scan_location.py +34 -0
- odoo/addons/shopfloor/tests/test_location_content_transfer_set_destination_all.py +343 -0
- odoo/addons/shopfloor/tests/test_location_content_transfer_set_destination_package_or_line.py +1074 -0
- odoo/addons/shopfloor/tests/test_location_content_transfer_single.py +748 -0
- odoo/addons/shopfloor/tests/test_location_content_transfer_start.py +359 -0
- odoo/addons/shopfloor/tests/test_menu_base.py +261 -0
- odoo/addons/shopfloor/tests/test_menu_counters.py +61 -0
- odoo/addons/shopfloor/tests/test_misc.py +25 -0
- odoo/addons/shopfloor/tests/test_move_action_assign.py +87 -0
- odoo/addons/shopfloor/tests/test_openapi.py +21 -0
- odoo/addons/shopfloor/tests/test_picking_form.py +62 -0
- odoo/addons/shopfloor/tests/test_scan_anything.py +49 -0
- odoo/addons/shopfloor/tests/test_single_pack_transfer.py +1121 -0
- odoo/addons/shopfloor/tests/test_single_pack_transfer_base.py +32 -0
- odoo/addons/shopfloor/tests/test_single_pack_transfer_putaway.py +104 -0
- odoo/addons/shopfloor/tests/test_stock_split.py +204 -0
- odoo/addons/shopfloor/tests/test_user.py +42 -0
- odoo/addons/shopfloor/tests/test_zone_picking_base.py +608 -0
- odoo/addons/shopfloor/tests/test_zone_picking_change_pack_lot.py +140 -0
- odoo/addons/shopfloor/tests/test_zone_picking_select_line.py +723 -0
- odoo/addons/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py +207 -0
- odoo/addons/shopfloor/tests/test_zone_picking_select_line_first_scan_location.py.bak +202 -0
- odoo/addons/shopfloor/tests/test_zone_picking_select_line_no_prefill_qty.py +107 -0
- odoo/addons/shopfloor/tests/test_zone_picking_select_picking_type.py +26 -0
- odoo/addons/shopfloor/tests/test_zone_picking_set_line_destination.py +643 -0
- odoo/addons/shopfloor/tests/test_zone_picking_set_line_destination_no_prefill_qty.py +146 -0
- odoo/addons/shopfloor/tests/test_zone_picking_set_line_destination_pick_pack.py +241 -0
- odoo/addons/shopfloor/tests/test_zone_picking_start.py +206 -0
- odoo/addons/shopfloor/tests/test_zone_picking_stock_issue.py +121 -0
- odoo/addons/shopfloor/tests/test_zone_picking_unload_all.py +353 -0
- odoo/addons/shopfloor/tests/test_zone_picking_unload_buffer_lines.py +113 -0
- odoo/addons/shopfloor/tests/test_zone_picking_unload_set_destination.py +374 -0
- odoo/addons/shopfloor/tests/test_zone_picking_unload_single.py +123 -0
- odoo/addons/shopfloor/tests/test_zone_picking_zero_check.py +43 -0
- odoo/addons/shopfloor/utils.py +13 -0
- odoo/addons/shopfloor/views/shopfloor_menu.xml +167 -0
- odoo/addons/shopfloor/views/stock_location.xml +20 -0
- odoo/addons/shopfloor/views/stock_move_line.xml +52 -0
- odoo/addons/shopfloor/views/stock_picking_type.xml +19 -0
- odoo_addon_shopfloor-16.0.1.0.0.24.dist-info/METADATA +192 -0
- odoo_addon_shopfloor-16.0.1.0.0.24.dist-info/RECORD +182 -0
- odoo_addon_shopfloor-16.0.1.0.0.24.dist-info/WHEEL +5 -0
- 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
|