|
| 1 | +# Copyright 2023 ForgeFlow SL. |
| 2 | +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). |
| 3 | +from odoo import api, fields, models |
| 4 | +from odoo.tools import OrderedSet, float_compare, float_is_zero, float_round |
| 5 | + |
| 6 | + |
| 7 | +class StockMove(models.Model): |
| 8 | + _inherit = "stock.move" |
| 9 | + |
| 10 | + reserve_area_line_ids = fields.One2many("stock.move.reserve.area.line", "move_id") |
| 11 | + reserve_area_ids = fields.Many2many( |
| 12 | + "stock.reserve.area", compute="_compute_reserve_area_ids", store=True |
| 13 | + ) |
| 14 | + area_reserved_availability = fields.Float( |
| 15 | + string="Reserved in Area", |
| 16 | + digits="Product Unit of Measure", |
| 17 | + readonly=True, |
| 18 | + copy=False, |
| 19 | + help="Quantity that has been reserved in all reserve" |
| 20 | + " Areas of the source location.", |
| 21 | + compute="_compute_area_reserved_availability", # minimum of area's reserved |
| 22 | + store=True, |
| 23 | + ) |
| 24 | + |
| 25 | + @api.depends("reserve_area_line_ids.reserved_availability") |
| 26 | + def _compute_area_reserved_availability(self): |
| 27 | + for move in self: |
| 28 | + if move.reserve_area_line_ids: |
| 29 | + move.area_reserved_availability = min( |
| 30 | + move.reserve_area_line_ids.mapped("reserved_availability") |
| 31 | + ) |
| 32 | + else: |
| 33 | + move.area_reserved_availability = 0 |
| 34 | + |
| 35 | + @api.depends("location_id") |
| 36 | + def _compute_reserve_area_ids(self): |
| 37 | + loc_to_area_map = dict() |
| 38 | + for location in self.mapped("location_id"): |
| 39 | + reserve_areas = self.env["stock.reserve.area"].search([]) |
| 40 | + for reserve_area in reserve_areas: |
| 41 | + if reserve_area.is_location_in_area(location): |
| 42 | + if not loc_to_area_map.get(location.id): |
| 43 | + loc_to_area_map[location.id] = reserve_area |
| 44 | + else: |
| 45 | + loc_to_area_map[location.id] |= reserve_area |
| 46 | + for move in self: |
| 47 | + move.reserve_area_ids = loc_to_area_map.get(move.location_id.id) |
| 48 | + |
| 49 | + def _is_out_area(self, reserve_area_id): |
| 50 | + # out of area = true if source location in area and dest location outside |
| 51 | + for move in self: |
| 52 | + if not reserve_area_id.is_location_in_area( |
| 53 | + move.location_dest_id |
| 54 | + ) and reserve_area_id.is_location_in_area(move.location_id): |
| 55 | + return True |
| 56 | + return False |
| 57 | + |
| 58 | + def create_reserve_area_lines(self): |
| 59 | + line_ids = self.reserve_area_line_ids |
| 60 | + for reserve_area in self.reserve_area_ids: |
| 61 | + if self._is_out_area(reserve_area) and not self.env[ |
| 62 | + "stock.move.reserve.area.line" |
| 63 | + ].search( |
| 64 | + [("move_id", "=", self.id), ("reserve_area_id", "=", reserve_area.id)] |
| 65 | + ): |
| 66 | + line_ids += self.env["stock.move.reserve.area.line"].create( |
| 67 | + { |
| 68 | + "move_id": self.id, |
| 69 | + "reserve_area_id": reserve_area.id, |
| 70 | + } |
| 71 | + ) |
| 72 | + return line_ids |
| 73 | + |
| 74 | + def _action_area_assign(self): |
| 75 | + for move in self.filtered( |
| 76 | + lambda m: m.state in ["confirmed", "waiting", "partially_available"] |
| 77 | + and m.reserve_area_line_ids |
| 78 | + ): |
| 79 | + move.reserve_area_line_ids._action_area_assign() |
| 80 | + |
| 81 | + def _action_assign(self): |
| 82 | + for move in self.filtered( |
| 83 | + lambda m: m.state in ["confirmed", "waiting", "partially_available"] |
| 84 | + ): |
| 85 | + move.reserve_area_line_ids = move.create_reserve_area_lines() |
| 86 | + self._action_area_assign() # new method to assign globally |
| 87 | + super()._action_assign() |
| 88 | + |
| 89 | + def _get_available_quantity( |
| 90 | + self, |
| 91 | + location_id, |
| 92 | + lot_id=None, |
| 93 | + package_id=None, |
| 94 | + owner_id=None, |
| 95 | + strict=False, |
| 96 | + allow_negative=False, |
| 97 | + ): |
| 98 | + local_available = super()._get_available_quantity( |
| 99 | + location_id, |
| 100 | + lot_id=lot_id, |
| 101 | + package_id=package_id, |
| 102 | + owner_id=owner_id, |
| 103 | + strict=strict, |
| 104 | + allow_negative=allow_negative, |
| 105 | + ) |
| 106 | + if self.reserve_area_line_ids: |
| 107 | + return min(local_available, self.area_reserved_availability) |
| 108 | + return local_available |
| 109 | + |
| 110 | + def _do_area_unreserve(self): |
| 111 | + # we will delete area_reserve_line_ids from the elegible moves |
| 112 | + moves_to_unreserve = OrderedSet() |
| 113 | + for move in self: |
| 114 | + if ( |
| 115 | + move.state == "cancel" |
| 116 | + or (move.state == "done" and move.scrapped) |
| 117 | + or not move.reserve_area_line_ids |
| 118 | + ): |
| 119 | + # We may have cancelled move in an open picking in a |
| 120 | + # "propagate_cancel" scenario. |
| 121 | + # We may have done move in an open picking in a scrap scenario. |
| 122 | + continue |
| 123 | + moves_to_unreserve.add(move.id) |
| 124 | + self.env["stock.move"].browse(moves_to_unreserve).mapped( |
| 125 | + "reserve_area_line_ids" |
| 126 | + ).unlink() |
| 127 | + |
| 128 | + def _do_unreserve(self): |
| 129 | + super()._do_unreserve() |
| 130 | + self._do_area_unreserve() |
| 131 | + return True |
| 132 | + |
| 133 | + def _action_done(self, cancel_backorder=False): |
| 134 | + res = super()._action_done(cancel_backorder) |
| 135 | + res.reserve_area_line_ids.unlink() |
| 136 | + return res |
| 137 | + |
| 138 | + def _free_reservation_area(self, product_id, reserve_area_id, quantity): |
| 139 | + area_available_quantity = self.env[ |
| 140 | + "stock.quant" |
| 141 | + ]._get_reserve_area_available_quantity(product_id, reserve_area_id) |
| 142 | + if quantity > area_available_quantity: |
| 143 | + outdated_move_domain = [ |
| 144 | + ("state", "not in", ["done", "cancel"]), |
| 145 | + ("product_id", "=", product_id.id), |
| 146 | + ("reserve_area_ids", "in", reserve_area_id.id), |
| 147 | + ] |
| 148 | + # We take the pickings with the latest scheduled date |
| 149 | + outdated_candidates = ( |
| 150 | + self.env["stock.move"] |
| 151 | + .search(outdated_move_domain) |
| 152 | + .sorted( |
| 153 | + lambda cand: ( |
| 154 | + -cand.picking_id.scheduled_date.timestamp() |
| 155 | + if cand.picking_id |
| 156 | + else -cand.id, |
| 157 | + ) |
| 158 | + ) |
| 159 | + ) |
| 160 | + # As the move's state is not computed over the move lines, we'll have to manually |
| 161 | + # recompute the moves which we adapted their lines. |
| 162 | + move_to_recompute_state = self |
| 163 | + |
| 164 | + for candidate in outdated_candidates: |
| 165 | + rounding = candidate.product_uom.rounding |
| 166 | + quantity_uom = product_id.uom_id._compute_quantity( |
| 167 | + quantity, candidate.product_uom, rounding_method="HALF-UP" |
| 168 | + ) |
| 169 | + reserve_area_line = self.env["stock.move.reserve.area.line"].search( |
| 170 | + [ |
| 171 | + ("move_id", "=", candidate.id), |
| 172 | + ("reserve_area_id", "=", reserve_area_id.id), |
| 173 | + ("reserved_availability", ">", 0.0), |
| 174 | + ] |
| 175 | + ) |
| 176 | + if reserve_area_line: |
| 177 | + if ( |
| 178 | + float_compare( |
| 179 | + reserve_area_line.reserved_availability, |
| 180 | + quantity_uom, |
| 181 | + precision_rounding=rounding, |
| 182 | + ) |
| 183 | + <= 0 |
| 184 | + ): |
| 185 | + quantity_uom -= reserve_area_line.reserved_availability |
| 186 | + reserve_area_line.reserved_availability = 0 |
| 187 | + move_to_recompute_state |= candidate |
| 188 | + if float_is_zero(quantity_uom, precision_rounding=rounding): |
| 189 | + break |
| 190 | + else: |
| 191 | + # split this move line and assign the new part to our extra move |
| 192 | + quantity_left = float_round( |
| 193 | + reserve_area_line.reserved_availability - quantity_uom, |
| 194 | + precision_rounding=rounding, |
| 195 | + rounding_method="UP", |
| 196 | + ) |
| 197 | + reserve_area_line.reserved_availability = quantity_left |
| 198 | + move_to_recompute_state |= candidate |
| 199 | + # cover case where units have been removed from the area and then a |
| 200 | + # move has reserved locally but not in area |
| 201 | + if ( |
| 202 | + float_compare( |
| 203 | + candidate.area_reserved_availability, |
| 204 | + candidate.reserved_availability, |
| 205 | + precision_rounding=rounding, |
| 206 | + ) |
| 207 | + < 0 |
| 208 | + ): |
| 209 | + to_remove = float_round( |
| 210 | + candidate.reserved_availability |
| 211 | + - candidate.area_reserved_availability, |
| 212 | + precision_rounding=rounding, |
| 213 | + rounding_method="UP", |
| 214 | + ) |
| 215 | + # treiem les quants d'algun move line |
| 216 | + mls = candidate.move_line_ids |
| 217 | + removed = 0 |
| 218 | + for ml in mls: |
| 219 | + while removed < to_remove: |
| 220 | + ml_removed = min(ml.product_uom_qty, to_remove) |
| 221 | + ml.product_uom_qty -= ml_removed |
| 222 | + removed += ml_removed |
| 223 | + break |
| 224 | + move_to_recompute_state._recompute_state() |
0 commit comments