[ADD] stock_picking_product_interchangeable: Added module.

This commit is contained in:
geomer198
2023-03-09 23:22:14 +03:00
parent dc5800f0cd
commit 1ca7aa26d5
22 changed files with 743 additions and 0 deletions

View File

@@ -0,0 +1 @@
../../../../stock_picking_product_interchangeable

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,21 @@
{
"name": "Stock Picking Product Interchangeable",
"summary": """Stock Picking Product Interchangeable""",
"author": "Cetmix, Odoo Community Association (OCA)",
"version": "16.0.1.0.0",
"category": "Inventory/Inventory",
"website": "https://github.com/OCA/stock-logistics-warehouse",
"maintainers": ["geomer198", "CetmixGitDrone"],
"depends": ["stock_available"],
"data": [
"views/product_views.xml",
"views/stock_picking_type_views.xml",
"views/stock_picking_views.xml",
],
"demo": [
"demo/product_demo.xml",
"demo/stock_demo.xml",
],
"license": "AGPL-3",
"installable": True,
}

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1">
<record id="res_partner_bob" model="res.partner">
<field name="name">Bob</field>
<field name="email">bob@example.com</field>
</record>
<record id="product_template_plate" model="product.template">
<field name="name">Plate</field>
<field name="detailed_type">product</field>
</record>
<record id="product_template_napkin" model="product.template">
<field name="name">Napkin</field>
<field name="detailed_type">product</field>
</record>
<record
id="product_template_napkin_attribute_line"
model="product.template.attribute.line"
>
<field name="product_tmpl_id" ref="product_template_napkin" />
<field name="attribute_id" ref="product.product_attribute_2" />
<field
name="value_ids"
eval="[(4, ref('product.product_attribute_value_3')), (4, ref('product.product_attribute_value_4'))]"
/>
</record>
<record id="product_product_chopsticks" model="product.product">
<field name="name">Chopsticks</field>
<field name="detailed_type">product</field>
</record>
<record id="product_product_fork" model="product.product">
<field name="name">Fork</field>
<field name="detailed_type">product</field>
</record>
<record id="product_product_spoon" model="product.product">
<field name="name">Spoon</field>
<field name="detailed_type">product</field>
</record>
<record id="product_product_knife" model="product.product">
<field name="name">Knife</field>
<field name="detailed_type">product</field>
<field
name="product_interchangeable_ids"
eval="[(4,ref('product_product_fork')), (4, ref('product_product_spoon'))]"
/>
</record>
</odoo>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1">
<function model="stock.picking.type" name="write">
<value eval="[ref('stock.picking_type_out')]" />
<value eval="{'substitute_products_mode': 'all'}" />
</function>
<record id="stock_inventory_fork" model="stock.quant">
<field name="product_id" ref="product_product_fork" />
<field name="inventory_quantity">20.0</field>
<field
name="location_id"
model="stock.location"
eval="obj().env.ref('stock.warehouse0').lot_stock_id.id"
/>
</record>
<record id="stock_inventory_spoon" model="stock.quant">
<field name="product_id" ref="product_product_spoon" />
<field name="inventory_quantity">8.0</field>
<field
name="location_id"
model="stock.location"
eval="obj().env.ref('stock.warehouse0').lot_stock_id.id"
/>
</record>
<record id="stock_inventory_knife" model="stock.quant">
<field name="product_id" ref="product_product_knife" />
<field name="inventory_quantity">3.0</field>
<field
name="location_id"
model="stock.location"
eval="obj().env.ref('stock.warehouse0').lot_stock_id.id"
/>
</record>
<function model="stock.quant" name="action_apply_inventory">
<function
eval="[[('id', 'in', (ref('stock_inventory_fork'),
ref('stock_inventory_spoon'),
ref('stock_inventory_knife'),
))]]"
model="stock.quant"
name="search"
/>
</function>
</odoo>

View File

@@ -0,0 +1,4 @@
from . import product
from . import stock_move
from . import stock_picking
from . import stock_picking_type

View File

@@ -0,0 +1,66 @@
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = "product.template"
product_tmpl_interchangeable_ids = fields.Many2many(
comodel_name="product.product",
compute="_compute_product_tmpl_interchangeable_ids",
inverse="_inverse_product_tmpl_interchangeable_ids",
)
def _compute_product_tmpl_interchangeable_ids(self):
"""Compute interchangeable products"""
for rec in self:
rec.product_tmpl_interchangeable_ids = (
rec.product_variant_ids.product_interchangeable_ids
)
def _inverse_product_tmpl_interchangeable_ids(self):
"""Set new interchangeable product"""
for rec in self:
rec.product_variant_id.product_replaces_ids = (
rec.product_tmpl_interchangeable_ids
)
class Product(models.Model):
_inherit = "product.product"
product_interchangeable_ids = fields.Many2many(
comodel_name="product.product",
string="Replaces",
help="Products that can be substituted by current product",
inverse="_inverse_product_interchangeable_ids",
compute="_compute_product_interchangeable_ids",
)
product_replaces_ids = fields.Many2many(
comodel_name="product.product",
string="Replaces",
relation="product_substitute_rel",
column1="product_id",
column2="product_replaced_id",
help="Products that can be substituted by current product",
)
product_replaced_by_ids = fields.Many2many(
comodel_name="product.product",
string="Replaces",
relation="product_substitute_rel",
column1="product_replaced_id",
column2="product_id",
help="Products that can substitute current current product",
)
def _compute_product_interchangeable_ids(self):
"""Compute interchangeable products"""
for rec in self:
rec.product_interchangeable_ids = (
rec.product_replaces_ids | rec.product_replaced_by_ids
) - rec
def _inverse_product_interchangeable_ids(self):
"""Set new interchangeable product"""
for rec in self:
rec.product_replaces_ids = rec.product_interchangeable_ids

View File

@@ -0,0 +1,108 @@
from odoo import models
class StockMove(models.Model):
_inherit = "stock.move"
def _prepare_interchangeable_products(self, mode, for_qty):
"""
Preparing products for current product replacement
:param mode: stock.picking.type substitute_products_mode value
:param for_qty: product qty for replacement
:return: list of tuple [(product_obj, count), ...]
"""
qty = for_qty
if qty >= 0:
return False
products_info = []
for product in self.product_id.product_interchangeable_ids:
available_qty = product.immediately_usable_qty
if available_qty > 0 > qty:
product_qty = abs(qty) if available_qty + qty >= 0 else available_qty
products_info.append((product, product_qty))
qty += product_qty
if (mode == "all" and qty == 0) or mode == "any":
return products_info
return False
def _create_stock_move_interchangeable_products(self, products_info):
"""
Creates stock.move records for replacement product
:param products_info: struct list of tuple [(product_obj, count), ...]
:return: Stock Move object
"""
stock_move_obj = self.env["stock.move"]
if not products_info:
return stock_move_obj
return stock_move_obj.create(
[
{
"picking_id": self.picking_id.id,
"name": product.display_name,
"product_id": product.id,
"product_uom_qty": qty,
"location_id": self.location_id.id,
"location_dest_id": self.location_dest_id.id,
"company_id": self.company_id.id,
}
for product, qty in products_info
]
)
def _interchangeable_stock_move_filter(self):
"""
Filter for applying interchangeable behavior for stock.move
:return: True/False
"""
type_ = self.picking_type_id
mode = type_.substitute_products_mode
skip_behavior = not (mode and type_.code == "outgoing")
return not (skip_behavior or self.picking_id.pass_interchangeable)
def _add_note_interchangeable_picking_note(self, products_info, qty):
"""
Add note for product with interchangeable products
:param list products_info: struct list of tuple [(product_obj, count), ...]
"""
self.ensure_one()
product = self.product_id
qty = abs(qty)
note = rf"<b>{product.display_name}</b> missing qty <i>{qty}</i> was replaced with:<br\>" # noqa
lines = [
f"<li><b>{product.display_name}</b> <i>{qty}</i></li>"
for product, qty in products_info
]
note += f"<ul>{''.join(lines)}</ul><br/>"
picking = self.picking_id
if not picking.note:
picking.note = note
else:
picking.note += note
def _action_confirm(self, merge=True, merge_into=False):
moves = super(StockMove, self)._action_confirm(
merge=merge, merge_into=merge_into
)
inter_moves = moves.filtered(
lambda move: move._interchangeable_stock_move_filter()
)
if not inter_moves:
return moves
other_moves = moves - inter_moves
move_ids = inter_moves.filtered(
lambda m: m.product_id.product_interchangeable_ids
)
new_moves = self.env["stock.move"]
for move in move_ids:
mode = move.picking_type_id.substitute_products_mode
qty = move.product_id.immediately_usable_qty
products_info = move._prepare_interchangeable_products(mode, qty)
if products_info:
products_qty = sum(map(lambda item: item[1], products_info))
new_moves = move._create_stock_move_interchangeable_products(
products_info
)
new_moves |= new_moves._action_confirm(merge, merge_into)
move.product_uom_qty -= products_qty
move._add_note_interchangeable_picking_note(products_info, qty)
return inter_moves | new_moves | other_moves

View File

@@ -0,0 +1,16 @@
from odoo import api, fields, models
class StockPicking(models.Model):
_inherit = "stock.picking"
pass_interchangeable = fields.Boolean()
available_pass_interchangeable = fields.Boolean()
@api.onchange("picking_type_id")
def _onchange_available_pass_interchangeable(self):
"""Compute available to showing pass_interchangeable field"""
type_ = self.picking_type_id
self.available_pass_interchangeable = (
type_.substitute_products_mode and type_.code == "outgoing"
)

View File

@@ -0,0 +1,11 @@
from odoo import fields, models
class StockPickingType(models.Model):
_inherit = "stock.picking.type"
substitute_products_mode = fields.Selection(
selection=[("all", "If all available"), ("any", "If any available")],
string="Substitute Products",
required=False,
)

View File

@@ -0,0 +1,11 @@
Open **Inventory** -> **Configuration** -> **Operations types** and select an Operation Type you would like to use interchangeable products in.
Only Operation Types of type **Delivery** can use this feature.
Select **Substitute Products** mode:
- "If all available". Interchangeable products will be added products will be added only if it is possible to reserve all requested amount.
- "If any available". Interchangeable products will be added if there is at least a single one available.
In Product form open **Interchangeable** tab and add products that can be used as a substitute.
**Important**: if you are using product variants Interchangeable products must be defined for each variant separately.

View File

@@ -0,0 +1,5 @@
This module implements "interchangeable" products. If an interchangeable product is not available in stock in requested quantity its substitute products will be added to picking to complete the order.
Eg there is an "English Breakfast" tea and "Breakfast in England" tea which are absolutely the same inside and have the same price. Only package labels are different. And our customers don't care which of them will be actually delivered.
NB: Interchangeable products substitute each other. Eg if "English Breakfast" can substitutes "Breakfast in England" then "Breakfast in England" can substitute "English Breakfast".

View File

@@ -0,0 +1,3 @@
Click **Mark todo** or "Check availability" button in Stock Picking form.
If **Substitute Products** option is activated for picking operation type and there is not enough stock of an interchangeable product substitute products will be added to picking.

View File

@@ -0,0 +1,2 @@
from . import test_product
from . import test_stock_picking

View File

@@ -0,0 +1,31 @@
from odoo.tests import TransactionCase
class StockPickingProductInterchangeableCommon(TransactionCase):
@classmethod
def setUpClass(cls):
super(StockPickingProductInterchangeableCommon, cls).setUpClass()
cls.product_fork = cls.env.ref(
"stock_picking_product_interchangeable.product_product_fork"
)
cls.product_spoon = cls.env.ref(
"stock_picking_product_interchangeable.product_product_spoon"
)
cls.product_knife = cls.env.ref(
"stock_picking_product_interchangeable.product_product_knife"
)
cls.product_chopsticks = cls.env.ref(
"stock_picking_product_interchangeable.product_product_chopsticks"
)
cls.product_tmpl_napkin = cls.env.ref(
"stock_picking_product_interchangeable.product_template_napkin"
)
cls.product_tmpl_plate = cls.env.ref(
"stock_picking_product_interchangeable.product_template_plate"
)
cls.product_napkin = cls.product_tmpl_napkin.product_variant_id
cls.picking_type_in = cls.env.ref("stock.picking_type_in")
cls.picking_type_out = cls.env.ref("stock.picking_type_out")
cls.res_partner_bob = cls.env.ref(
"stock_picking_product_interchangeable.res_partner_bob"
)

View File

@@ -0,0 +1,44 @@
from odoo.tests import Form
from .common import StockPickingProductInterchangeableCommon
class TestProduct(StockPickingProductInterchangeableCommon):
def test_invisible_interchangeable_products(self):
"""
Test flow to hiding interchangeable table
for product templates with 2 and more product variants
"""
self.assertEqual(
len(self.product_tmpl_napkin.product_variant_ids),
2,
msg="Product variants count must be equal to 2",
)
with self.assertRaises(AssertionError):
with Form(
self.product_tmpl_napkin,
view="stock_picking_product_interchangeable.product_template_interchangeable_form_view", # noqa
) as form:
form.product_tmpl_interchangeable_ids.add(self.product_tmpl_plate)
def test_visible_interchangeable_products(self):
"""
Test flow to visible interchangeable table
for product templates with 1 product variant
"""
self.assertEqual(len(self.product_tmpl_plate.product_variant_ids), 1)
with Form(
self.product_tmpl_plate,
view="stock_picking_product_interchangeable.product_template_interchangeable_form_view", # noqa
) as form:
form.product_tmpl_interchangeable_ids.add(self.product_chopsticks)
self.assertEqual(
len(self.product_tmpl_plate.product_tmpl_interchangeable_ids),
1,
msg="Interchangeable products count must be equal to 1",
)
self.assertEqual(
self.product_tmpl_plate.product_tmpl_interchangeable_ids,
self.product_chopsticks,
msg="Products must be the same",
)

View File

@@ -0,0 +1,238 @@
from odoo.tests import Form
from .common import StockPickingProductInterchangeableCommon
class TestStockPicking(StockPickingProductInterchangeableCommon):
def test_access_to_pass_interchangeable_field(self):
"""Test flow to show/hide pass_interchangeable field"""
with self.assertRaises(AssertionError):
with Form(self.env["stock.picking"]) as form:
form.pass_interchangeable = True
with self.assertRaises(AssertionError):
with Form(self.env["stock.picking"]) as form:
form.picking_type_id = self.picking_type_in
form.pass_interchangeable = True
with Form(self.env["stock.picking"]) as form:
form.picking_type_id = self.picking_type_out
form.pass_interchangeable = True
self.assertTrue(
form.pass_interchangeable,
msg="'pass_interchangeable' field must be visible",
)
def _create_stock_picking(self, product_qty, pass_interchangeable=False):
"""
Create stock picking
:param int product_qty: Product Qty
:param bool pass_interchangeable: Pass Interchangeable flag
:return stock.picking: stock.picking record
"""
form = Form(self.env["stock.picking"])
form.partner_id = self.res_partner_bob
form.picking_type_id = self.picking_type_out
form.pass_interchangeable = pass_interchangeable
with form.move_ids_without_package.new() as line:
line.product_id = self.product_knife
line.product_uom_qty = product_qty
return form.save()
def test_picking_note_for_interchangeable_products(self):
"""Test flow to create pickings note for interchangeable products"""
expected_string = f"<b>{self.product_knife.display_name}</b> missing qty <i>27.0</i> was replaced with:<br>" # noqa
fork_str = f"<li><b>{self.product_fork.display_name}</b> <i>20.0</i></li>"
spoon_str = f"<li><b>{self.product_spoon.display_name}</b> <i>7.0</i></li>"
expected_string += f"<ul>{''.join([fork_str, spoon_str])}</ul><br>"
picking = self._create_stock_picking(30)
picking.action_confirm()
self.assertEqual(
str(picking.note), expected_string, msg="Strings must be the same"
)
def test_create_many_pickings(self):
"""Test flow to create and confirm pickings with different picking type"""
picking_1 = self._create_stock_picking(30)
form = Form(self.env["stock.picking"])
form.picking_type_id = self.picking_type_in
with form.move_ids_without_package.new() as line:
line.product_id = self.product_napkin
line.product_uom_qty = 10
picking_2 = form.save()
pickings = picking_1 | picking_2
pickings.action_confirm()
self.assertEqual(
len(pickings.move_ids), 4, msg="Total stock moves count must be equal to 4"
)
self.assertEqual(
len(picking_1.move_ids),
3,
msg="First picking moves count must be equal to 3",
)
self.assertEqual(
len(picking_2.move_ids),
1,
msg="Second picking moves count must be equal to 1",
)
def test_create_delivery_stock_picking_with_pass_interchangeable(self):
"""Test flow to skip interchangeable behavior for delivery stock.picking record"""
record = self._create_stock_picking(30, True)
self.assertEqual(len(record.move_ids), 1, msg="Moves count must be equal to 1")
knife_move = record.move_ids
self.assertEqual(
knife_move.product_id,
self.product_knife,
msg="Move product must be equal to 'Knife'",
)
self.assertEqual(
knife_move.forecast_availability,
-27,
msg="Forecast Availability must be equal to -27",
)
self.assertEqual(
knife_move.product_id.immediately_usable_qty,
3,
msg="Products count on hand must be equal to 3",
)
record.action_confirm()
self.assertEqual(
len(record.move_ids), 1, msg="Move lines count must be equal to 1"
)
knife_move = record.move_ids
self.assertEqual(
knife_move.product_id,
self.product_knife,
msg="Move product must be equal to 'Knife'",
)
self.assertEqual(
knife_move.product_uom_qty, 30, msg="Move products Qty must be equal to 30"
)
def test_create_delivery_stock_picking_with_substitute_products_all_01(self):
"""Test flow to stock picking with substitute products 'all'"""
record = self._create_stock_picking(30)
self.assertEqual(len(record.move_ids), 1, msg="Moves count must be equal to 1")
knife_move = record.move_ids
self.assertEqual(
knife_move.product_id,
self.product_knife,
msg="Move product must be equal to 'Knife'",
)
self.assertEqual(
knife_move.forecast_availability,
-27,
msg="Forecast Availability must be equal to -27",
)
self.assertEqual(
knife_move.product_id.immediately_usable_qty,
3,
msg="Products count on hand must be equal to 3",
)
record.action_confirm()
self.assertEqual(len(record.move_ids), 3, msg="Moves count must be equal to 3")
knife_move, fork_move, spoon_move = record.move_ids
self.assertEqual(
knife_move.product_id,
self.product_knife,
msg="Move product must be equal to 'Knife'",
)
self.assertEqual(
knife_move.product_uom_qty, 3, msg="Move products Qty must be equal to 3"
)
self.assertEqual(
fork_move.product_id,
self.product_fork,
msg="Move product must be equal to 'Fork'",
)
self.assertEqual(
fork_move.product_uom_qty, 20, msg="Move products Qty must be equal to 20"
)
self.assertEqual(
spoon_move.product_id,
self.product_spoon,
msg="Move product must be equal to 'Spoon'",
)
self.assertEqual(
spoon_move.product_uom_qty, 7, msg="Move products Qty must be equal to 7"
)
def test_create_delivery_stock_picking_with_substitute_products_all_02(self):
"""Test flow to stock picking with substitute products 'all'"""
record = self._create_stock_picking(32)
self.assertEqual(len(record.move_ids), 1, msg="Moves count must be equal to 1")
knife_move = record.move_ids
self.assertEqual(
knife_move.product_id,
self.product_knife,
msg="Move product must be equal to 'Knife'",
)
self.assertEqual(
knife_move.forecast_availability,
-29,
msg="Forecast Availability must be equal to -29",
)
self.assertEqual(
knife_move.product_id.immediately_usable_qty,
3,
msg="Products count on hand must be equal to 3",
)
record.action_confirm()
self.assertEqual(len(record.move_ids), 1, msg="Moves count must be equal to 1")
knife_move = record.move_ids
self.assertEqual(
knife_move.product_id,
self.product_knife,
msg="Move product must be equal to 'Knife'",
)
self.assertEqual(
knife_move.product_uom_qty, 32, msg="Move products Qty must be equal to 32"
)
def test_create_delivery_stock_picking_with_substitute_products_any(self):
"""Test flow to stock picking with substitute products 'any'"""
self.picking_type_out.write({"substitute_products_mode": "any"})
record = self._create_stock_picking(32)
self.assertEqual(len(record.move_ids), 1, msg="Moves count must be equal to 1")
knife_move = record.move_ids
self.assertEqual(
knife_move.product_id,
self.product_knife,
msg="Move product must be equal to 'Knife'",
)
self.assertEqual(
knife_move.forecast_availability,
-29,
msg="Forecast Availability must be equal to -29",
)
self.assertEqual(
knife_move.product_id.immediately_usable_qty,
3,
msg="Products count on hand must be equal to 3",
)
record.action_confirm()
self.assertEqual(len(record.move_ids), 3, msg="Moves count must be equal to 3")
knife_move, fork_move, spoon_move = record.move_ids
self.assertEqual(
knife_move.product_id,
self.product_knife,
msg="Move product must be equal to 'Knife'",
)
self.assertEqual(
knife_move.product_uom_qty, 4, msg="Move products Qty must be equal to 4"
)
self.assertEqual(fork_move.product_id, self.product_fork)
self.assertEqual(
fork_move.product_uom_qty, 20, msg="Move products Qty must be equal to 20"
)
self.assertEqual(spoon_move.product_id, self.product_spoon)
self.assertEqual(
spoon_move.product_uom_qty, 8, msg="Move products Qty must be equal to 8"
)

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="product_template_interchangeable_form_view" model="ir.ui.view">
<field name="name">product.template.interchangeable.product.form</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view" />
<field name="arch" type="xml">
<notebook position="inside">
<page
name="interchangeable"
string="Interchangeable"
attrs="{'invisible': [('product_variant_count', '&gt;', 1)]}"
>
<field name="product_tmpl_interchangeable_ids" nolabel="1" />
</page>
</notebook>
</field>
</record>
<record id="product_normal_form_view" model="ir.ui.view">
<field name="name">product.product.interchangeable.form</field>
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_normal_form_view" />
<field name="arch" type="xml">
<notebook position="inside">
<page name="interchangeable" string="Interchangeable">
<field name="product_interchangeable_ids" nolabel="1" />
</page>
</notebook>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="stock_picking_type" model="ir.ui.view">
<field name="name">stock.picking.type.substitute.products</field>
<field name="model">stock.picking.type</field>
<field name="inherit_id" ref="stock.view_picking_type_form" />
<field name="arch" type="xml">
<field name="code" position="after">
<field
name="substitute_products_mode"
attrs="{'invisible': [('code', '!=', 'outgoing')]}"
/>
</field>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_picking_form" model="ir.ui.view">
<field name="name">stock.picking.interchangeable.form</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form" />
<field name="arch" type="xml">
<field name="picking_type_id" position="after">
<field name="available_pass_interchangeable" invisible="1" />
<field
name="pass_interchangeable"
attrs="{'invisible': [('available_pass_interchangeable', '=', False)]}"
/>
</field>
</field>
</record>
</odoo>