Skip to content

Commit b9a7b54

Browse files
committed
Add shopfloor automatic creation of picking batch
When a user uses the "Get Work" button on the barcode device, if no transfer batch is available, it automatically creates a new batch for the user.
1 parent bfbd60e commit b9a7b54

18 files changed

+490
-5
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../../shopfloor_batch_automatic_creation
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import setuptools
2+
3+
setuptools.setup(
4+
setup_requires=['setuptools-odoo'],
5+
odoo_addon=True,
6+
)

shopfloor/readme/CONTRIBUTORS.rst

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
* Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
2+
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
23

34
ADD YOURSELF

shopfloor/services/cluster_picking.py

-1
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,6 @@ def _batch_picking_filter(self, picking):
239239
"cancel",
240240
)
241241

242-
# TODO this may be used in other scenarios? if so, extract
243242
def _select_a_picking_batch(self, batches):
244243
# look for in progress + assigned to self first
245244
candidates = batches.filtered(

shopfloor/views/shopfloor_menu.xml

+6-4
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@
5757
/>
5858
</group>
5959
<group name="options" string="Scenario Options">
60-
<field name="move_create_is_possible" invisible="1" />
61-
<field
62-
name="allow_move_create"
60+
<group
61+
name="move_create"
6362
attrs="{'invisible': [('move_create_is_possible', '=', False)]}"
64-
/>
63+
>
64+
<field name="move_create_is_possible" invisible="1" />
65+
<field name="allow_move_create" />
66+
</group>
6567
</group>
6668
</sheet>
6769
</form>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import actions
2+
from . import models
3+
from . import services
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright 2020 Camptocamp
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
{
5+
"name": "Shopfloor - Batch Transfer Automatic Creation",
6+
"summary": "Create batch transfers for Cluster Picking",
7+
"version": "13.0.1.0.0",
8+
"development_status": "Alpha",
9+
"category": "Inventory",
10+
"website": "https://github.com/OCA/wms",
11+
"author": "Camptocamp, Odoo Community Association (OCA)",
12+
"license": "AGPL-3",
13+
"application": True,
14+
"depends": [
15+
"shopfloor",
16+
"stock_packaging_calculator", # OCA/stock-logistics-warehouse
17+
"product_packaging_dimension", # OCA/stock-logistics-warehouse
18+
"product_total_weight_from_packaging", # OCA/product-attribute
19+
],
20+
"data": ["views/shopfloor_menu_views.xml"],
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import picking_batch_auto_create
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Copyright 2020 Camptocamp
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
import hashlib
5+
import logging
6+
import struct
7+
8+
from odoo import fields, tools
9+
10+
from odoo.addons.component.core import Component
11+
12+
_logger = logging.getLogger(__name__)
13+
14+
15+
class PickingBatchAutoCreateAction(Component):
16+
"""Automatic creation of picking batches"""
17+
18+
_name = "shopfloor.picking.batch.auto.create"
19+
_inherit = "shopfloor.process.action"
20+
_usage = "picking.batch.auto.create"
21+
22+
_advisory_lock_name = "shopfloor_batch_picking_create"
23+
24+
def create_batch(self, picking_types, max_pickings=0, max_weight=0, max_volume=0):
25+
self._lock()
26+
pickings = self._search_pickings(picking_types, user=self.env.user)
27+
if not pickings:
28+
pickings = self._search_pickings(picking_types)
29+
30+
pickings = self._sort(pickings)
31+
pickings = self._apply_limits(pickings, max_pickings, max_weight, max_volume)
32+
if not pickings:
33+
return self.env["stock.picking.batch"].browse()
34+
return self._create_batch(pickings)
35+
36+
def _lock(self):
37+
"""Lock to prevent concurrent creation of batch
38+
39+
Use a blocking advisory lock to prevent 2 transactions to create
40+
a batch at the same time. The lock is released at the commit or
41+
rollback of the transaction.
42+
43+
The creation of a new batch should be short enough not to block
44+
the users for too long.
45+
"""
46+
_logger.info(
47+
"trying to acquire lock to create a picking batch (%s)", self.env.user.login
48+
)
49+
hasher = hashlib.sha1(str(self._advisory_lock_name).encode())
50+
# pg_lock accepts an int8 so we build an hash composed with
51+
# contextual information and we throw away some bits
52+
int_lock = struct.unpack("q", hasher.digest()[:8])
53+
54+
self.env.cr.execute("SELECT pg_advisory_xact_lock(%s);", (int_lock,))
55+
self.env.cr.fetchone()[0]
56+
# Note: if the lock had to wait, the snapshot of the transaction is
57+
# very much probably outdated already (i.e. if the transaction which
58+
# had the lock before this one set a 'batch_id' on stock.picking this
59+
# transaction will not be aware of), we'll probably have a retry. But
60+
# the lock can help limit the number of retries.
61+
_logger.info(
62+
"lock acquired to create a picking batch (%s)", self.env.user.login
63+
)
64+
65+
def _search_pickings_domain(self, picking_types, user=None):
66+
domain = [
67+
("picking_type_id", "in", picking_types.ids),
68+
("state", "in", ("assigned", "partially_available")),
69+
("batch_id", "=", False),
70+
("user_id", "=", user.id if user else False),
71+
]
72+
return domain
73+
74+
def _search_pickings(self, picking_types, user=None):
75+
# We can't use a limit in the SQL search because the 'priority' fields
76+
# is sometimes empty (it seems the inverse StockPicking.priority field
77+
# mess up with default on stock.move), we have to sort in Python.
78+
return self.env["stock.picking"].search(
79+
self._search_pickings_domain(picking_types, user=user)
80+
)
81+
82+
def _sort(self, pickings):
83+
return pickings.sorted(
84+
lambda picking: (
85+
-(int(picking.priority) if picking.priority else 1),
86+
picking.scheduled_date,
87+
picking.id,
88+
)
89+
)
90+
91+
def _precision_weight(self):
92+
return self.env["decimal.precision"].precision_get("Product Unit of Measure")
93+
94+
def _precision_volume(self):
95+
return max(
96+
6,
97+
self.env["decimal.precision"].precision_get("Product Unit of Measure") * 2,
98+
)
99+
100+
def _apply_limits(self, pickings, max_pickings, max_weight, max_volume):
101+
current_priority = fields.first(pickings).priority or "1"
102+
selected_pickings = self.env["stock.picking"].browse()
103+
104+
precision_weight = self._precision_weight()
105+
precision_volume = self._precision_volume()
106+
107+
def gt(value1, value2, digits):
108+
"""Return True if value1 is greater than value2"""
109+
return tools.float_compare(value1, value2, precision_digits=digits) == 1
110+
111+
total_weight = 0.0
112+
total_volume = 0.0
113+
for picking in pickings:
114+
if (picking.priority or "1") != current_priority:
115+
# as we sort by priority, exit as soon as the priority changes,
116+
# we do not mix priorities to make delivery of high priority
117+
# transfers faster
118+
break
119+
120+
weight = self._picking_weight(picking)
121+
volume = self._picking_volume(picking)
122+
if max_weight and gt(total_weight + weight, max_weight, precision_weight):
123+
continue
124+
125+
if max_volume and gt(total_volume + volume, max_volume, precision_volume):
126+
continue
127+
128+
selected_pickings |= picking
129+
total_weight += weight
130+
total_volume += volume
131+
132+
if max_pickings and len(selected_pickings) == max_pickings:
133+
# selected enough!
134+
break
135+
136+
return selected_pickings
137+
138+
def _picking_weight(self, picking):
139+
weight = 0.0
140+
for line in picking.move_lines:
141+
weight += line.product_id.get_total_weight_from_packaging(
142+
line.product_uom_qty
143+
)
144+
return weight
145+
146+
def _picking_volume(self, picking):
147+
volume = 0.0
148+
for line in picking.move_lines:
149+
product = line.product_id
150+
packagings_with_volume = product.with_context(
151+
_packaging_filter=lambda p: p.volume
152+
).product_qty_by_packaging(line.product_uom_qty)
153+
for packaging_info in packagings_with_volume:
154+
if packaging_info.get("is_unit"):
155+
pack_volume = product.volume
156+
else:
157+
packaging = self.env["product.packaging"].browse(
158+
packaging_info["id"]
159+
)
160+
pack_volume = packaging.volume
161+
162+
volume += pack_volume * packaging_info["qty"]
163+
return volume
164+
165+
def _create_batch(self, pickings):
166+
return self.env["stock.picking.batch"].create(
167+
self._create_batch_values(pickings)
168+
)
169+
170+
def _create_batch_values(self, pickings):
171+
return {"picking_ids": [(6, 0, pickings.ids)]}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import shopfloor_menu
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright 2020 Camptocamp
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
from odoo import fields, models
5+
6+
7+
class ShopfloorMenu(models.Model):
8+
_inherit = "shopfloor.menu"
9+
10+
batch_create = fields.Boolean(
11+
string="Automatic Batch Creation",
12+
default=False,
13+
help='Automatically create a batch when an operator uses the "Get Work"'
14+
" button and no existing batch has been found. The system will first look"
15+
" for priority transfers and fill up the batch till the defined"
16+
" constraints (max of transfers, volume, weight, ...)."
17+
" It never mixes priorities, so if you get 2 available priority transfers"
18+
" and a max quantity of 3, the batch will only contain the 2"
19+
" priority transfers.",
20+
)
21+
batch_create_max_picking = fields.Integer(
22+
string="Max transfers",
23+
default=0,
24+
help="Maximum number of transfers to add in an automatic batch."
25+
" 0 means no limit.",
26+
)
27+
batch_create_max_volume = fields.Float(
28+
string="Max volume (m³)",
29+
default=0,
30+
digits=(8, 4),
31+
help="Maximum volume in cubic meters of goods in transfers to"
32+
" add in an automatic batch. 0 means no limit.",
33+
)
34+
batch_create_max_weight = fields.Float(
35+
string="Max Weight (kg)",
36+
default=0,
37+
help="Maximum weight in kg of goods in transfers to add"
38+
" in an automatic batch. 0 means no limit.",
39+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
Extension for Shopfloor's cluster picking.
2+
3+
When a user uses the "Get Work" button on the barcode device, if no transfer
4+
batch is available, it automatically creates a new batch for the user.
5+
6+
Some options can be configured on the Shopfloor menu:
7+
8+
* Activate or not the batch creation
9+
* Max number of transfer per batch
10+
* Max weight per batch
11+
* Max volume per batch
12+
13+
The rules are:
14+
15+
* Transfers of higher priority are put first in the batch
16+
* If some transfers are assigned to the user, the batch will only contain
17+
those, otherwise, it looks for unassigned transfers
18+
* Priorities are not mixed to make transfers with higher priority faster
19+
e.g. if the limit is 5, with 3 Very Urgent transfer and 10 Normal transfer,
20+
the batch will contain only the 3 Very Urgent despite the higher limit
21+
* The weight and volume are based on the Product Packaging when their weight and
22+
volume are defined
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import cluster_picking
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2020 Camptocamp
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
3+
4+
from odoo.addons.component.core import Component
5+
6+
7+
class ClusterPicking(Component):
8+
_inherit = "shopfloor.cluster.picking"
9+
10+
def _select_a_picking_batch(self, batches):
11+
batch = super()._select_a_picking_batch(batches)
12+
if not batch and self.work.menu.batch_create:
13+
batch = self._batch_auto_create()
14+
batch.write({"user_id": self.env.uid, "state": "in_progress"})
15+
return batch
16+
17+
def _batch_auto_create(self):
18+
auto_batch = self.actions_for("picking.batch.auto.create")
19+
menu = self.work.menu
20+
return auto_batch.create_batch(
21+
self.picking_types,
22+
max_pickings=menu.batch_create_max_picking,
23+
max_volume=menu.batch_create_max_volume,
24+
max_weight=menu.batch_create_max_weight,
25+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_batch_create

0 commit comments

Comments
 (0)