mirror of
https://github.com/OCA/stock-logistics-warehouse.git
synced 2025-01-21 14:27:28 +02:00
@@ -13,13 +13,6 @@ class ProcurementGroup(models.Model):
|
||||
rule = super()._get_rule(product_id, location_id, values)
|
||||
# If there isn't a date planned in the values it means that this
|
||||
# method has been called outside of a procurement process.
|
||||
if (
|
||||
rule
|
||||
and not values.get("group_id")
|
||||
and rule.auto_create_group
|
||||
and values.get("date_planned")
|
||||
):
|
||||
group_data = rule._prepare_auto_procurement_group_data()
|
||||
group = self.env["procurement.group"].create(group_data)
|
||||
values["group_id"] = group
|
||||
if rule and rule.auto_create_group and values.get("date_planned"):
|
||||
values["group_id"] = rule._get_auto_procurement_group(product_id)
|
||||
return rule
|
||||
|
||||
@@ -16,15 +16,18 @@ class StockRule(models.Model):
|
||||
if self.group_propagation_option != "propagate":
|
||||
self.auto_create_group = False
|
||||
|
||||
def _get_auto_procurement_group(self, product):
|
||||
group_data = self._prepare_auto_procurement_group_data(product)
|
||||
return self.env["procurement.group"].create(group_data)
|
||||
|
||||
def _push_prepare_move_copy_values(self, move_to_copy, new_date):
|
||||
new_move_vals = super()._push_prepare_move_copy_values(move_to_copy, new_date)
|
||||
if self.auto_create_group:
|
||||
group_data = self._prepare_auto_procurement_group_data()
|
||||
group = self.env["procurement.group"].create(group_data)
|
||||
group = self._get_auto_procurement_group(move_to_copy.product_id)
|
||||
new_move_vals["group_id"] = group.id
|
||||
return new_move_vals
|
||||
|
||||
def _prepare_auto_procurement_group_data(self):
|
||||
def _prepare_auto_procurement_group_data(self, product):
|
||||
name = self.env["ir.sequence"].next_by_code("procurement.group") or False
|
||||
if not name:
|
||||
raise UserError(_("No sequence defined for procurement group."))
|
||||
|
||||
@@ -25,7 +25,7 @@ class TestProcurementAutoCreateGroup(TransactionCase):
|
||||
|
||||
# Create rules and routes:
|
||||
pull_push_route_auto = self.route_obj.create({"name": "Auto Create Group"})
|
||||
self.rule_1 = self.rule_obj.create(
|
||||
self.pull_push_rule_auto = self.rule_obj.create(
|
||||
{
|
||||
"name": "rule with autocreate",
|
||||
"route_id": pull_push_route_auto.id,
|
||||
@@ -54,7 +54,7 @@ class TestProcurementAutoCreateGroup(TransactionCase):
|
||||
}
|
||||
)
|
||||
push_route_auto = self.route_obj.create({"name": "Auto Create Group"})
|
||||
self.rule_1 = self.rule_obj.create(
|
||||
self.push_rule_auto = self.rule_obj.create(
|
||||
{
|
||||
"name": "route_auto",
|
||||
"location_src_id": self.location.id,
|
||||
@@ -114,8 +114,12 @@ class TestProcurementAutoCreateGroup(TransactionCase):
|
||||
}
|
||||
)
|
||||
|
||||
self.group = self.group_obj.create({"name": "SO0001"})
|
||||
|
||||
def _procure(self, product):
|
||||
values = {}
|
||||
values = {
|
||||
"group_id": self.group,
|
||||
}
|
||||
self.group_obj.run(
|
||||
[
|
||||
self.env["procurement.group"].Procurement(
|
||||
@@ -170,8 +174,10 @@ class TestProcurementAutoCreateGroup(TransactionCase):
|
||||
[("product_id", "=", self.prod_no_auto_pull_push.id)]
|
||||
)
|
||||
self.assertTrue(move)
|
||||
self.assertFalse(
|
||||
move.group_id, "Procurement Group should not have been assigned."
|
||||
self.assertEqual(
|
||||
move.group_id,
|
||||
self.group,
|
||||
"Procurement Group should not have been assigned.",
|
||||
)
|
||||
|
||||
def test_02_pull_push_auto_create_group(self):
|
||||
@@ -189,7 +195,7 @@ class TestProcurementAutoCreateGroup(TransactionCase):
|
||||
|
||||
def test_03_onchange_method(self):
|
||||
"""Test onchange method for stock rule."""
|
||||
proc_rule = self.rule_1
|
||||
proc_rule = self.push_rule_auto
|
||||
self.assertTrue(proc_rule.auto_create_group)
|
||||
proc_rule.write({"group_propagation_option": "none"})
|
||||
proc_rule._onchange_group_propagation_option()
|
||||
|
||||
1
procurement_auto_create_group_by_product/README.rst
Normal file
1
procurement_auto_create_group_by_product/README.rst
Normal file
@@ -0,0 +1 @@
|
||||
will be generated by the boat
|
||||
1
procurement_auto_create_group_by_product/__init__.py
Normal file
1
procurement_auto_create_group_by_product/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
17
procurement_auto_create_group_by_product/__manifest__.py
Normal file
17
procurement_auto_create_group_by_product/__manifest__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Copyright 2023 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
{
|
||||
"name": "Procurement Auto Create Group By Product",
|
||||
"version": "14.0.1.0.0",
|
||||
"license": "AGPL-3",
|
||||
"summary": "Generate one picking per product on the procurement run.",
|
||||
"author": "BCIM, Odoo Community Association (OCA)",
|
||||
"website": "https://github.com/OCA/stock-logistics-warehouse",
|
||||
"category": "Warehouse",
|
||||
"depends": ["procurement_auto_create_group"],
|
||||
"data": [
|
||||
"views/stock_rule.xml",
|
||||
"views/procurement_group.xml",
|
||||
],
|
||||
"installable": True,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
from . import stock_rule
|
||||
from . import stock_move
|
||||
from . import procurement_group
|
||||
from . import product_product
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copyright 2023 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ProcurementGroup(models.Model):
|
||||
_inherit = "procurement.group"
|
||||
|
||||
product_id = fields.Many2one("product.product", index=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copyright 2023 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = "product.product"
|
||||
|
||||
auto_create_procurement_group_ids = fields.One2many(
|
||||
"procurement.group", "product_id"
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright 2023 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
from itertools import groupby
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = "stock.move"
|
||||
|
||||
def _merge_moves(self, merge_into=False):
|
||||
sorted_moves_by_rule = sorted(self, key=lambda m: m.rule_id.id)
|
||||
res_moves = self.browse()
|
||||
for _rule, move_list in groupby(
|
||||
sorted_moves_by_rule, key=lambda m: m.rule_id.id
|
||||
):
|
||||
moves = self.browse(m.id for m in move_list)
|
||||
res_moves |= super(StockMove, moves)._merge_moves(merge_into=merge_into)
|
||||
return res_moves
|
||||
|
||||
def _prepare_merge_moves_distinct_fields(self):
|
||||
result = super()._prepare_merge_moves_distinct_fields()
|
||||
if self.rule_id.auto_create_group_by_product:
|
||||
# Allow to merge moves on a pick operation having different
|
||||
# deadlines
|
||||
if "date_deadline" in result:
|
||||
result.remove("date_deadline")
|
||||
return result
|
||||
@@ -0,0 +1,52 @@
|
||||
# Copyright 2023 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
def pg_advisory_lock(env, int_lock: int):
|
||||
"""Attempts to acquire a Postgres transactional advisory lock.
|
||||
|
||||
Raises an OperationalError LOCK_NOT_AVAILABLE if the lock could not be acquired.
|
||||
"""
|
||||
env.cr.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT pg_try_advisory_xact_lock(%s) THEN
|
||||
RAISE EXCEPTION USING
|
||||
MESSAGE = 'Lock not available',
|
||||
ERRCODE = '55P03';
|
||||
END IF;
|
||||
END $$;
|
||||
""",
|
||||
(int_lock),
|
||||
)
|
||||
|
||||
|
||||
class StockRule(models.Model):
|
||||
_inherit = "stock.rule"
|
||||
|
||||
auto_create_group_by_product = fields.Boolean(string="Procurement Group by Product")
|
||||
|
||||
def _get_auto_procurement_group(self, product):
|
||||
if self.auto_create_group_by_product:
|
||||
if product.auto_create_procurement_group_ids:
|
||||
return fields.first(product.auto_create_procurement_group_ids)
|
||||
# Make sure that two transactions cannot create a procurement group
|
||||
# For the same product at the same time.
|
||||
lock_name = f"product.product,{product.id}-auto-proc-group"
|
||||
hasher = hashlib.sha1(str(lock_name).encode())
|
||||
bigint_lock = struct.unpack("q", hasher.digest()[:8])
|
||||
pg_advisory_lock(self.env, bigint_lock)
|
||||
return super()._get_auto_procurement_group(product)
|
||||
|
||||
def _prepare_auto_procurement_group_data(self, product):
|
||||
result = super()._prepare_auto_procurement_group_data(product)
|
||||
if self.auto_create_group_by_product:
|
||||
result["product_id"] = product.id
|
||||
result["partner_id"] = False
|
||||
return result
|
||||
@@ -0,0 +1,10 @@
|
||||
#. Go to *Inventory / Configuration / Settings* and check the option
|
||||
'Multi-Step Routes' and press the 'Save' button.
|
||||
#. Activate the developer mode.
|
||||
#. Go to *Inventory / Configuration / Warehouse Management / Routes*
|
||||
and select the route you want to change. Select the rule you wish
|
||||
to change, and in case of a Pull rule or Push & Pull rule Select
|
||||
'Propagation of Procurement Group': 'Propagate'. The checkbox
|
||||
'Auto-create Procurement Group' will then appear and you can set
|
||||
it if you want to procurement group to be automatically created.
|
||||
Activate also the checkbox 'Procurement Group by Product'.
|
||||
@@ -0,0 +1 @@
|
||||
* Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
@@ -0,0 +1,2 @@
|
||||
Allow to have one picking per product by using a procurement group per product
|
||||
during the procurement run.
|
||||
@@ -0,0 +1 @@
|
||||
from . import test_auto_create_by_product
|
||||
@@ -0,0 +1,145 @@
|
||||
# Copyright 2023 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
import psycopg2
|
||||
|
||||
from odoo import api, registry, tools
|
||||
from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY
|
||||
|
||||
from odoo.addons.procurement_auto_create_group.tests.test_auto_create import (
|
||||
TestProcurementAutoCreateGroup,
|
||||
)
|
||||
|
||||
|
||||
class TestProcurementAutoCreateGroupByProduct(TestProcurementAutoCreateGroup):
|
||||
def test_pull_push_auto_create_group_not_by_product(self):
|
||||
"""Test pull flow that without option to group by product"""
|
||||
self.pull_push_rule_auto.auto_create_group_by_product = False
|
||||
# Behavior should be the same
|
||||
super(
|
||||
TestProcurementAutoCreateGroupByProduct, self
|
||||
).test_02_pull_push_auto_create_group()
|
||||
|
||||
def test_pull_push_auto_create_group_by_product(self):
|
||||
"""Test pull flow that with option to group by product"""
|
||||
self.pull_push_rule_auto.auto_create_group_by_product = True
|
||||
move = self.move_obj.search([("product_id", "=", self.prod_auto_pull_push.id)])
|
||||
self.assertFalse(move)
|
||||
group = self.group_obj.search(
|
||||
[("product_id", "=", self.prod_auto_pull_push.id)]
|
||||
)
|
||||
self.assertFalse(move)
|
||||
self._procure(self.prod_auto_pull_push)
|
||||
move = self.move_obj.search([("product_id", "=", self.prod_auto_pull_push.id)])
|
||||
self.assertTrue(move)
|
||||
self.assertTrue(move.group_id, "Procurement Group not assigned.")
|
||||
self.assertEqual(
|
||||
move.group_id.product_id,
|
||||
self.prod_auto_pull_push,
|
||||
"Procurement Group product missing.",
|
||||
)
|
||||
self.assertEqual(
|
||||
move.product_uom_qty,
|
||||
5.0,
|
||||
"Move invalid quantity.",
|
||||
)
|
||||
self._procure(self.prod_auto_pull_push)
|
||||
group = self.group_obj.search(
|
||||
[("product_id", "=", self.prod_auto_pull_push.id)]
|
||||
)
|
||||
self.assertEqual(
|
||||
len(group),
|
||||
1,
|
||||
"Procurement Group per product should be unique.",
|
||||
)
|
||||
# The second move should be merged with the previous one
|
||||
self.assertEqual(
|
||||
move.product_uom_qty,
|
||||
10.0,
|
||||
"Move invalid quantity.",
|
||||
)
|
||||
|
||||
def test_push_auto_create_group_not_by_product(self):
|
||||
"""Test push flow that without option to group by product"""
|
||||
self.push_rule_auto.auto_create_group_by_product = False
|
||||
super(
|
||||
TestProcurementAutoCreateGroupByProduct, self
|
||||
).test_05_push_auto_create_group()
|
||||
|
||||
def test_push_auto_create_group_by_product(self):
|
||||
"""Test push flow that with option to group by product"""
|
||||
self.push_rule_auto.auto_create_group_by_product = True
|
||||
move = self.move_obj.search(
|
||||
[
|
||||
("product_id", "=", self.prod_auto_push.id),
|
||||
("location_dest_id", "=", self.loc_components.id),
|
||||
]
|
||||
)
|
||||
self.assertFalse(move)
|
||||
self._push_trigger(self.prod_auto_push)
|
||||
move = self.move_obj.search(
|
||||
[
|
||||
("product_id", "=", self.prod_auto_push.id),
|
||||
("location_dest_id", "=", self.loc_components.id),
|
||||
]
|
||||
)
|
||||
self.assertTrue(move)
|
||||
self.assertTrue(move.group_id, "Procurement Group not assigned.")
|
||||
self.assertEqual(
|
||||
move.group_id.product_id,
|
||||
self.prod_auto_push,
|
||||
"Procurement Group product missing.",
|
||||
)
|
||||
self._push_trigger(self.prod_auto_push)
|
||||
group = self.group_obj.search([("product_id", "=", self.prod_auto_push.id)])
|
||||
self.assertEqual(
|
||||
len(group),
|
||||
1,
|
||||
"Procurement Group per product should be unique.",
|
||||
)
|
||||
move = self.move_obj.search(
|
||||
[
|
||||
("product_id", "=", self.prod_auto_push.id),
|
||||
("location_dest_id", "=", self.loc_components.id),
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
len(move),
|
||||
1,
|
||||
"Invalid amount of moves.",
|
||||
)
|
||||
self.assertEqual(
|
||||
move.group_id.product_id,
|
||||
self.prod_auto_push,
|
||||
"Procurement Group product missing.",
|
||||
)
|
||||
|
||||
def test_concurrent_procurement_group_creation(self):
|
||||
"""Check for the same product, no multiple procurement groups are created."""
|
||||
rule = self.pull_push_rule_auto
|
||||
rule.auto_create_group_by_product = True
|
||||
product = self.prod_auto_pull_push
|
||||
# Check that no procurement group exist for the product
|
||||
self.assertFalse(product.auto_create_procurement_group_ids)
|
||||
# So create one and an adisory lock will be created
|
||||
rule._get_auto_procurement_group(product)
|
||||
self.assertTrue(product.auto_create_procurement_group_ids)
|
||||
# Use another transaction to test the advisory lock
|
||||
with registry(self.env.cr.dbname).cursor() as new_cr:
|
||||
new_env = api.Environment(new_cr, self.env.uid, self.env.context)
|
||||
new_env["product.product"].invalidate_cache(
|
||||
["auto_create_procurement_group_ids"],
|
||||
[
|
||||
product.id,
|
||||
],
|
||||
)
|
||||
rule2 = new_env["stock.rule"].browse(rule.id)
|
||||
rule2.auto_create_group_by_product = True
|
||||
product2 = new_env["product.product"].browse(product.id)
|
||||
self.assertFalse(product2.auto_create_procurement_group_ids)
|
||||
with self.assertRaises(psycopg2.OperationalError) as cm, tools.mute_logger(
|
||||
"odoo.sql_db"
|
||||
):
|
||||
rule2._get_auto_procurement_group(product2)
|
||||
self.assertTrue(cm.exception.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY)
|
||||
new_cr.rollback()
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="procurement_group_form_view" model="ir.ui.view">
|
||||
<field name="name">procurement_auto_create_group_by_product</field>
|
||||
<field name="model">procurement.group</field>
|
||||
<field name="inherit_id" ref="stock.procurement_group_form_view" />
|
||||
<field name="arch" type="xml">
|
||||
<field name="move_type" position="after">
|
||||
<field name="product_id" />
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record model="ir.ui.view" id="view_stock_rule_form">
|
||||
<field
|
||||
name="name"
|
||||
>stock.rule.form - procurement_auto_create_group_by_product</field>
|
||||
<field name="model">stock.rule</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="procurement_auto_create_group.view_stock_rule_form"
|
||||
/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath
|
||||
expr="//group[@name='propagation_group']/field[@name='auto_create_group']"
|
||||
position="after"
|
||||
>
|
||||
<field
|
||||
name="auto_create_group_by_product"
|
||||
attrs="{'invisible':['|', ('auto_create_group','!=',True),('group_propagation_option','!=','propagate')]}"
|
||||
/>
|
||||
</xpath>
|
||||
<xpath
|
||||
expr="//group[field[@name='auto']]/field[@name='auto_create_group']"
|
||||
position="after"
|
||||
>
|
||||
<field
|
||||
name="auto_create_group_by_product"
|
||||
attrs="{'invisible':['|',('auto_create_group','!=',True),('action','!=','push')]}"
|
||||
/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1 @@
|
||||
../../../../procurement_auto_create_group_by_product
|
||||
6
setup/procurement_auto_create_group_by_product/setup.py
Normal file
6
setup/procurement_auto_create_group_by_product/setup.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import setuptools
|
||||
|
||||
setuptools.setup(
|
||||
setup_requires=['setuptools-odoo'],
|
||||
odoo_addon=True,
|
||||
)
|
||||
Reference in New Issue
Block a user