Skip to content

Commit ffa8732

Browse files
[ADD] stock_reserve_area
1 parent 9c13452 commit ffa8732

25 files changed

+1232
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../../stock_reserve_area

setup/stock_reserve_area/setup.py

Lines changed: 6 additions & 0 deletions
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+
)

stock_reserve_area/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import models
2+
from .hooks import post_init_hook
3+
from .hooks import pre_init_hook

stock_reserve_area/__manifest__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2023 ForgeFlow S.L.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
{
4+
"name": "Stock Reservation Area",
5+
"summary": "Stock reservations on areas (group of locations)",
6+
"version": "14.0.1.0.0",
7+
"author": "ForgeFlow, Odoo Community Association (OCA)",
8+
"category": "Warehouse",
9+
"license": "AGPL-3",
10+
"complexity": "normal",
11+
"website": "https://github.com/OCA/stock-logistics-warehouse",
12+
"depends": ["stock"],
13+
"data": [
14+
"security/ir.model.access.csv",
15+
"security/stock_reserve_area_security.xml",
16+
"views/stock_reserve_area_views.xml",
17+
"views/stock_location_views.xml",
18+
"views/stock_move_views.xml",
19+
"views/stock_picking_views.xml",
20+
],
21+
"auto_install": False,
22+
"installable": True,
23+
"post_init_hook": "post_init_hook",
24+
"pre_init_hook": "pre_init_hook",
25+
}

stock_reserve_area/hooks.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright 2023 ForgeFlow SL.
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
4+
from odoo import SUPERUSER_ID, api
5+
from odoo.tools.sql import column_exists
6+
7+
8+
def pre_init_hook(cr):
9+
if not column_exists(cr, "stock_move", "area_reserved_availability"):
10+
cr.execute(
11+
"""
12+
ALTER TABLE "stock_move"
13+
ADD COLUMN "area_reserved_availability" double precision DEFAULT 0
14+
"""
15+
)
16+
cr.execute(
17+
"""
18+
ALTER TABLE "stock_move" ALTER COLUMN "area_reserved_availability" DROP DEFAULT
19+
"""
20+
)
21+
22+
23+
def post_init_hook(cr, registry):
24+
"""
25+
This post-init-hook will create a Reserve Area for each existing WH.
26+
"""
27+
env = api.Environment(cr, SUPERUSER_ID, dict())
28+
warehouse_obj = env["stock.warehouse"]
29+
warehouses = warehouse_obj.search([])
30+
reserve_area_obj = env["stock.reserve.area"]
31+
for warehouse_id in warehouses.ids:
32+
warehouse = warehouse_obj.browse(warehouse_id)
33+
all_locations = env["stock.location"].search(
34+
[("id", "child_of", warehouse.view_location_id.id)]
35+
)
36+
reserve_area_obj.create(
37+
{
38+
"name": warehouse.name,
39+
"location_ids": [(6, 0, all_locations.ids)],
40+
"company_id": warehouse.company_id.id,
41+
}
42+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from . import stock_reserve_area
2+
from . import stock_move
3+
from . import stock_quant
4+
from . import stock_location
5+
from . import stock_picking
6+
from . import stock_warehouse
7+
from . import stock_move_line
8+
from . import stock_move_reserve_area_line
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Copyright 2023 ForgeFlow SL.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
from odoo import api, fields, models
5+
6+
7+
class StockLocation(models.Model):
8+
_inherit = "stock.location"
9+
10+
reserve_area_ids = fields.Many2many(
11+
"stock.reserve.area",
12+
relation="stock_reserve_area_stock_location_rel",
13+
column1="location_id",
14+
column2="reserve_area_id",
15+
readonly=True,
16+
)
17+
18+
def write(self, vals):
19+
res = super().write(vals)
20+
return res
21+
22+
@api.depends("reserve_area_ids")
23+
def _update_impacted_moves(self, vals):
24+
"""If a location is moved outside/inside an area we have to check stock_moves"""
25+
if vals.get("reserve_area_ids"):
26+
new_reserve_areas = (
27+
self.env["stock.reserve.area"].sudo().browse(vals["reserve_area_ids"])
28+
)
29+
moves_poss_impacted = self.search(
30+
[
31+
"|",
32+
("location_id", "=", self),
33+
("location_dest_id", "=", self),
34+
("state", "in", ("confirmed", "waiting", "partially_available")),
35+
]
36+
)
37+
for move in moves_poss_impacted:
38+
for reserve_area in new_reserve_areas:
39+
if move._is_out_area(reserve_area):
40+
move.reserve_area_line_ids += self.env[
41+
"stock.move.reserve.area.line"
42+
].create(
43+
{
44+
"move_id": move.id,
45+
"reserve_area_id": reserve_area.id,
46+
}
47+
)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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()
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright 2023 ForgeFlow SL.
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
from odoo import models
4+
5+
6+
class StockMoveLine(models.Model):
7+
_inherit = "stock.move.line"
8+
9+
def _free_reservation(
10+
self,
11+
product_id,
12+
location_id,
13+
quantity,
14+
lot_id=None,
15+
package_id=None,
16+
owner_id=None,
17+
ml_to_ignore=None,
18+
):
19+
super()._free_reservation(
20+
product_id,
21+
location_id,
22+
quantity,
23+
lot_id=lot_id,
24+
package_id=package_id,
25+
owner_id=owner_id,
26+
ml_to_ignore=ml_to_ignore,
27+
)
28+
reserve_area_ids = self.location_id.reserve_area_ids
29+
for area in reserve_area_ids:
30+
self.env["stock.move"]._free_reservation_area(
31+
self.product_id, area, self.qty_done
32+
)

0 commit comments

Comments
 (0)