|
| 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)]} |
0 commit comments