[ADD] stock_location_product_restriction: Prevent to mix different products into the same stock location

This commit is contained in:
Laurent Mignon (ACSONE)
2020-09-17 11:25:51 +02:00
committed by Denis Roussel
parent c90b8aa282
commit b986e9a920
19 changed files with 1461 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
==================================
Stock Location Product Restriction
==================================
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github
:target: https://github.com/OCA/stock-logistics-warehouse/tree/10.0/stock_location_product_restriction
:alt: OCA/stock-logistics-warehouse
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-10-0/stock-logistics-warehouse-10-0-stock_location_product_restriction
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
:target: https://runbot.odoo-community.org/runbot/153/10.0
:alt: Try me on Runbot
|badge1| |badge2| |badge3| |badge4| |badge5|
This module extends the functionality of stock to allow you to prevent to put
items of different products into the same stock location.
**Table of contents**
.. contents::
:local:
Usage
=====
By default, Odoo allows you to put items of any product into the same location.
This behaviour remains the one by default once the addon is installed.
Once installed, you can specify at any level of the stock location hierarchy
if you want to restrict the usage of the location to only items of the same
product. This property is inherited by all the children locations while you
don't specify an other specific value on a child location. The constrains only
applies location by location.
Once a location is configured to only contains items of the same product, the
system will prevent you to move items of any others products into a location
that already contains product items. A new filter into the tree view of the
stock locations will also allow you to find all the location where this new
restriction is violated.
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/stock-logistics-warehouse/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
`feedback <https://github.com/OCA/stock-logistics-warehouse/issues/new?body=module:%20stock_location_product_restriction%0Aversion:%2010.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* ACSONE SA/NV
Contributors
~~~~~~~~~~~~
* Laurent Mignon <laurent.mignon@acsone.eu> (https://www.acsone.eu/)
Other credits
~~~~~~~~~~~~~
The development of this module has been financially supported by:
* ACSONE SA/NV
* Alcyon Benelux
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/stock-logistics-warehouse <https://github.com/OCA/stock-logistics-warehouse/tree/10.0/stock_location_product_restriction>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@@ -0,0 +1,2 @@
from . import models
from .hooks import pre_init_hook

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Stock Location Product Restriction",
"summary": """
Prevent to mix different products into the same stock location""",
"version": "10.0.1.0.0",
"license": "AGPL-3",
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
"website": "https://acsone.eu/",
"depends": ["stock"],
"data": ["views/stock_location.xml"],
"demo": [],
"pre_init_hook": "pre_init_hook",
}

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import logging
_logger = logging.getLogger(__name__)
def column_exists(cr, tablename, columnname):
""" Return whether the given column exists. """
query = """ SELECT 1 FROM information_schema.columns
WHERE table_name=%s AND column_name=%s """
cr.execute(query, (tablename, columnname))
return cr.rowcount
def pre_init_hook(cr):
_logger.info("Initialize product_restriction on table stock_location")
if not column_exists(cr, "stock_location", "product_restriction"):
cr.execute(
"""
ALTER TABLE stock_location
ADD COLUMN product_restriction character varying;
ALTER TABLE stock_location
ADD COLUMN parent_product_restriction character varying;
"""
)
cr.execute(
"""
UPDATE stock_location set product_restriction = 'any';
UPDATE stock_location set parent_product_restriction = 'any'
where location_id is not null;
"""
)

View File

@@ -0,0 +1,2 @@
from . import stock_location
from . import stock_move

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models, _
from odoo.osv.expression import NEGATIVE_TERM_OPERATORS
class StockLocation(models.Model):
_inherit = "stock.location"
product_restriction = fields.Selection(
string="Product restriction",
selection="_selection_product_restriction",
help="If 'Same product' is selected the system will prevent to put "
"items of different products into the same location.",
index=True,
required=True,
compute="_compute_product_restriction",
store=True,
default="any",
)
specific_product_restriction = fields.Selection(
string="Specific product restriction",
selection="_selection_product_restriction",
help="If specified the restriction specified will apply to "
"the current location and all its children",
default=False,
)
parent_product_restriction = fields.Selection(
selection="_selection_product_restriction",
store=True,
readonly=True,
related="location_id.product_restriction",
)
has_restriction_violation = fields.Boolean(
compute="_compute_restriction_violation",
search="_search_has_restriction_violation",
)
restriction_violation_message = fields.Char(
compute="_compute_restriction_violation"
)
@api.model
def _selection_product_restriction(self):
return [
("any", "Items of any products are allowed into the location"),
(
"same",
"Only items of the same product allowed into the location",
),
]
@api.depends("specific_product_restriction", "parent_product_restriction")
def _compute_product_restriction(self):
default_value = "any"
for rec in self:
rec.product_restriction = (
rec.specific_product_restriction
or rec.parent_product_restriction
or default_value
)
@api.depends("product_restriction")
def _compute_restriction_violation(self):
records = self
if self.env.in_onchange:
records = self._origin
if not records:
# case where the compute is called from the create form
return
ProductProduct = self.env["product.product"]
SQL = """
SELECT
stock_quant.location_id,
array_agg(distinct(product_id))
FROM
stock_quant,
stock_location
WHERE
stock_quant.location_id in %s
and stock_location.id = stock_quant.location_id
and stock_location.product_restriction = 'same'
GROUP BY
stock_quant.location_id
HAVING count(distinct(product_id)) > 1
"""
self.env.cr.execute(SQL, (tuple(records.ids),))
product_ids_by_location_id = dict(self.env.cr.fetchall())
for record in self:
record_id = record.id
if self.env.in_onchange:
record_id = self._origin.id
has_restriction_violation = False
restriction_violation_message = False
product_ids = product_ids_by_location_id.get(record_id)
if product_ids:
products = ProductProduct.browse(product_ids)
has_restriction_violation = True
restriction_violation_message = _(
"This location should only contain items of the same "
"product but it contains items of products %s"
) % " | ".join(products.mapped("name"))
record.has_restriction_violation = has_restriction_violation
record.restriction_violation_message = (
restriction_violation_message
)
def _search_has_restriction_violation(self, operator, value):
search_has_violation = (
# has_restriction_violation != False
(operator in NEGATIVE_TERM_OPERATORS and not value)
or
# has_restriction_violation = True
(operator not in NEGATIVE_TERM_OPERATORS and value)
)
SQL = """
SELECT
stock_quant.location_id
FROM
stock_quant,
stock_location
WHERE
stock_location.id = stock_quant.location_id
and stock_location.product_restriction = 'same'
GROUP BY
stock_quant.location_id
HAVING count(distinct(product_id)) > 1
"""
self.env.cr.execute(SQL)
violation_ids = [r[0] for r in self.env.cr.fetchall()]
if search_has_violation:
op = "in"
else:
op = "not in"
return [("id", op, violation_ids)]

View File

@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from collections import defaultdict
from odoo import api, models, _
from odoo.exceptions import ValidationError
class StockMove(models.Model):
_inherit = "stock.move"
@api.multi
def _check_location_product_restriction(self):
"""
Check if the move can be executed according to potential restriction
defined on the stock_location
"""
StockLocation = self.env["stock.location"]
ProductProduct = self.env["product.product"]
# We only check moves with a location_dest that can
# only contain the same product
moves_to_ckeck = self.filtered(
lambda m: m.location_dest_id.product_restriction == "same"
)
if not moves_to_ckeck:
return
product_ids_location_dest_id = defaultdict(set)
error_msgs = []
# check dest locations into the stock moves
for move in moves_to_ckeck:
product_ids_location_dest_id[move.location_dest_id.id].add(
move.product_id.id
)
for location_id, product_ids in product_ids_location_dest_id.items():
if len(product_ids) > 1:
location = StockLocation.browse(location_id)
products = ProductProduct.browse(list(product_ids))
error_msgs.append(
_(
"The location %s can only contain items of the same "
"product. You plan to move different products to "
"this location. (%s)"
)
% (location.name, ", ".join(products.mapped("name")))
)
# check dest locations by taking into account product already into the
# locations
# here we use a plain SQL to avoid performance issue
SQL = """
SELECT
location_id,
array_agg(distinct(product_id))
FROM
stock_quant
WHERE
location_id in %s
GROUP BY
location_id
"""
self.env.cr.execute(
SQL, (tuple(moves_to_ckeck.mapped("location_dest_id").ids),)
)
existing_product_ids_by_location_id = dict(self.env.cr.fetchall())
for (
location_dest_id,
existing_product_ids,
) in existing_product_ids_by_location_id.items():
product_ids_to_move = product_ids_location_dest_id[
location_dest_id
]
if set(existing_product_ids).symmetric_difference(
product_ids_to_move
):
location = StockLocation.browse(location_dest_id)
existing_products = ProductProduct.browse(existing_product_ids)
to_move_products = ProductProduct.browse(
list(product_ids_to_move)
)
error_msgs.append(
_(
"You plan to move the product %s to the location %s "
"but the location must only contain items of same "
"product and already contains items of other "
"product(s) "
"(%s)."
)
% (
" | ".join(to_move_products.mapped("name")),
location.name,
" | ".join(existing_products.mapped("name")),
)
)
if error_msgs:
raise ValidationError("\n".join(error_msgs))
@api.multi
def action_done(self):
self._check_location_product_restriction()
return super(StockMove, self).action_done()

View File

@@ -0,0 +1 @@
* Laurent Mignon <laurent.mignon@acsone.eu> (https://www.acsone.eu/)

View File

@@ -0,0 +1,4 @@
The development of this module has been financially supported by:
* ACSONE SA/NV
* Alcyon Benelux

View File

@@ -0,0 +1,2 @@
This module extends the functionality of stock to allow you to prevent to put
items of different products into the same stock location.

View File

@@ -0,0 +1,13 @@
By default, Odoo allows you to put items of any product into the same location.
This behaviour remains the one by default once the addon is installed.
Once installed, you can specify at any level of the stock location hierarchy
if you want to restrict the usage of the location to only items of the same
product. This property is inherited by all the children locations while you
don't specify an other specific value on a child location. The constrains only
applies location by location.
Once a location is configured to only contains items of the same product, the
system will prevent you to move items of any others products into a location
that already contains product items. A new filter into the tree view of the
stock locations will also allow you to find all the location where this new
restriction is violated.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,445 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.15.1: http://docutils.sourceforge.net/" />
<title>Stock Location Product Restriction</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="stock-location-product-restriction">
<h1 class="title">Stock Location Product Restriction</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/tree/10.0/stock_location_product_restriction"><img alt="OCA/stock-logistics-warehouse" src="https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/stock-logistics-warehouse-10-0/stock-logistics-warehouse-10-0-stock_location_product_restriction"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/153/10.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
<p>This module extends the functionality of stock to allow you to prevent to put
items of different products into the same stock location.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="id1">Usage</a></li>
<li><a class="reference internal" href="#bug-tracker" id="id2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id5">Contributors</a></li>
<li><a class="reference internal" href="#other-credits" id="id6">Other credits</a></li>
<li><a class="reference internal" href="#maintainers" id="id7">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#id1">Usage</a></h1>
<p>By default, Odoo allows you to put items of any product into the same location.
This behaviour remains the one by default once the addon is installed.
Once installed, you can specify at any level of the stock location hierarchy
if you want to restrict the usage of the location to only items of the same
product. This property is inherited by all the children locations while you
dont specify an other specific value on a child location. The constrains only
applies location by location.</p>
<p>Once a location is configured to only contains items of the same product, the
system will prevent you to move items of any others products into a location
that already contains product items. A new filter into the tree view of the
stock locations will also allow you to find all the location where this new
restriction is violated.</p>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id2">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/issues/new?body=module:%20stock_location_product_restriction%0Aversion:%2010.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id3">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id4">Authors</a></h2>
<ul class="simple">
<li>ACSONE SA/NV</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id5">Contributors</a></h2>
<ul class="simple">
<li>Laurent Mignon &lt;<a class="reference external" href="mailto:laurent.mignon&#64;acsone.eu">laurent.mignon&#64;acsone.eu</a>&gt; (<a class="reference external" href="https://www.acsone.eu/">https://www.acsone.eu/</a>)</li>
</ul>
</div>
<div class="section" id="other-credits">
<h2><a class="toc-backref" href="#id6">Other credits</a></h2>
<p>The development of this module has been financially supported by:</p>
<ul class="simple">
<li>ACSONE SA/NV</li>
<li>Alcyon Benelux</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id7">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/tree/10.0/stock_location_product_restriction">OCA/stock-logistics-warehouse</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,2 @@
from . import test_stock_location
from . import test_stock_move

View File

@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo.tests.common import SavepointCase
class TestStockLocation(SavepointCase):
@classmethod
def setUpClass(cls):
super(TestStockLocation, cls).setUpClass()
cls.StockLocation = cls.env["stock.location"]
cls.StockLocation._parent_store_compute()
cls.loc_lvl = cls.env.ref("stock.stock_location_locations")
cls.loc_lvl_1 = cls.StockLocation.create(
{"name": "level_1", "location_id": cls.loc_lvl.id}
)
cls.loc_lvl_1_1 = cls.StockLocation.create(
{"name": "level_1_1", "location_id": cls.loc_lvl_1.id}
)
cls.loc_lvl_1_1_1 = cls.StockLocation.create(
{"name": "level_1_1_1", "location_id": cls.loc_lvl_1_1.id}
)
cls.loc_lvl_1_1_2 = cls.StockLocation.create(
{"name": "level_1_1_1", "location_id": cls.loc_lvl_1_1.id}
)
cls.default_product_restriction = "any"
# products
Product = cls.env["product.product"]
cls.uom_unit = cls.env.ref("product.product_uom_unit")
cls.product_1 = Product.create(
{"name": "Wood", "uom_id": cls.uom_unit.id}
)
cls.product_2 = Product.create(
{"name": "Stone", "uom_id": cls.uom_unit.id}
)
# quants
StockQuant = cls.env["stock.quant"]
StockQuant.create(
{
"product_id": cls.product_1.id,
"location_id": cls.loc_lvl_1_1_1.id,
"qty": 10.0,
"owner_id": cls.env.user.id,
}
)
StockQuant.create(
{
"product_id": cls.product_2.id,
"location_id": cls.loc_lvl_1_1_1.id,
"qty": 10.0,
"owner_id": cls.env.user.id,
}
)
StockQuant.create(
{
"product_id": cls.product_1.id,
"location_id": cls.loc_lvl_1_1_2.id,
"qty": 10.0,
"owner_id": cls.env.user.id,
}
)
StockQuant.create(
{
"product_id": cls.product_2.id,
"location_id": cls.loc_lvl_1_1_2.id,
"qty": 10.0,
"owner_id": cls.env.user.id,
}
)
def test_00(self):
"""
Data:
A 3 depths location hierarchy without
specific_product_restriction
Test Case:
1. Specify a specific_product_restriction at root level
Expected result:
The value at each level must modified.
"""
self.loc_lvl.specific_product_restriction = "same"
children = self.loc_lvl.child_ids
def check_field(locs, name):
for loc in locs:
self.assertEqual(
name,
loc.product_restriction,
"Wrong product restriction on loc %s" % loc.name,
)
check_field(loc.child_ids, name)
check_field(children, "same")
def test_01(self):
"""
Data:
A 3 depths location hierarchy without
specific_product_restriction
Test Case:
1. Specify a specific_product_restriction at level_1_1
Expected result:
The value at root level and level 1 is the default
The value at level_1_1 and level_1_1_1 is the new one
"""
self.loc_lvl_1_1.specific_product_restriction = "same"
self.assertEqual(
self.default_product_restriction, self.loc_lvl.product_restriction,
)
self.assertEqual(
self.default_product_restriction,
self.loc_lvl_1.product_restriction,
)
self.assertEqual("same", self.loc_lvl_1_1.product_restriction)
self.assertEqual("same", self.loc_lvl_1_1_1.product_restriction)
def test_02(self):
"""
Data:
Location level_1_1_1 with 2 different products no restriction
Location level_1_1_2 with 2 different products no restriction
Test Case:
1. Search location child of loc_lvl with restriction violation
2. Search location child of loc_lvl without restriction violation
Expected result:
1. No result
2. All child location are returned
"""
self.loc_lvl_1_1_1.product_restriction = "any"
self.loc_lvl_1_1_2.product_restriction = "any"
# has violation
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "=", True),
]
)
self.assertFalse(res)
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "!=", False),
]
)
self.assertFalse(res)
# without violation
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "=", False),
]
)
self.assertIn(self.loc_lvl_1_1_1, res)
self.assertIn(self.loc_lvl_1_1_2, res)
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "!=", True),
]
)
self.assertIn(self.loc_lvl_1_1_1, res)
self.assertIn(self.loc_lvl_1_1_2, res)
def test_03(self):
"""
Data:
* Location level_1_1_1 with 2 different products no restriction
* Location level_1_1_2 with 2 different products
with restriction same
Test Case:
1. Search location child of loc_lvl with restriction violation
2. Search location child of loc_lvl without restriction violation
3. Set restriction 'same' on location level_1_1_1
4. Search location child of loc_lvl with restriction violation
Expected result:
1. result = level_1_1_2
2. level_1_1_2 is not into result but level_1_1_1 is
4. result = level_1_1_2 and level_1_1_1
"""
self.loc_lvl_1_1_1.product_restriction = "any"
self.loc_lvl_1_1_2.product_restriction = "same"
# 1
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "=", True),
]
)
self.assertEqual(self.loc_lvl_1_1_2, res)
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "!=", False),
]
)
self.assertEqual(self.loc_lvl_1_1_2, res)
# 2
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "=", False),
]
)
self.assertIn(self.loc_lvl_1_1_1, res)
self.assertNotIn(self.loc_lvl_1_1_2, res)
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "!=", True),
]
)
self.assertIn(self.loc_lvl_1_1_1, res)
self.assertNotIn(self.loc_lvl_1_1_2, res)
# 3
self.loc_lvl_1_1_1.product_restriction = "same"
self.loc_lvl_1_1_2.product_restriction = "same"
# 4
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "=", True),
]
)
self.assertEqual(self.loc_lvl_1_1_2 | self.loc_lvl_1_1_1, res)
res = self.StockLocation.search(
[
("id", "child_of", self.loc_lvl.id),
("has_restriction_violation", "!=", False),
]
)
self.assertEqual(self.loc_lvl_1_1_2 | self.loc_lvl_1_1_1, res)
def test_04(self):
"""
Data:
* Location level_1_1_1 with 2 different products no restriction
Test Case:
1. Check restriction message
3. Set restriction 'same' on location level_1_1_1
4. Check restriction message
Expected result:
1. No restriction message
3. Retriction message
"""
self.loc_lvl_1_1_1.product_restriction = "any"
self.assertFalse(self.loc_lvl_1_1_1.has_restriction_violation)
self.assertFalse(self.loc_lvl_1_1_1.restriction_violation_message)
self.loc_lvl_1_1_1.product_restriction = "same"
self.assertTrue(self.loc_lvl_1_1_1.has_restriction_violation)
self.assertTrue(self.loc_lvl_1_1_1.restriction_violation_message)

View File

@@ -0,0 +1,289 @@
# -*- coding: utf-8 -*-
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from collections import namedtuple
from odoo.tests.common import SavepointCase
from odoo.exceptions import ValidationError
ShortMoveInfo = namedtuple(
"ShortMoveInfo", ["product", "location_dest", "qty"]
)
class TestStockMove(SavepointCase):
@classmethod
def setUpClass(cls):
"""
Data:
2 products: product_1, product_2
1 new warehouse: warehouse_1
2 new locations: location_1 and location_2 are child of
warehouse_1's stock location and without
restriction
stock:
* 50 product_1 in location_1
* 0 product_2 en stock
"""
super(TestStockMove, cls).setUpClass()
cls.uom_unit = cls.env.ref("product.product_uom_unit")
Product = cls.env["product.product"]
cls.product_1 = Product.create(
{"name": "Wood", "uom_id": cls.uom_unit.id}
)
cls.product_2 = Product.create(
{"name": "Stone", "uom_id": cls.uom_unit.id}
)
# Warehouses
cls.warehouse_1 = cls.env["stock.warehouse"].create(
{
"name": "Base Warehouse",
"reception_steps": "one_step",
"delivery_steps": "ship_only",
"code": "BWH",
}
)
# Locations
cls.location_1 = cls.env["stock.location"].create(
{
"name": "TestLocation1",
"posx": 3,
"location_id": cls.warehouse_1.lot_stock_id.id,
}
)
cls.location_2 = cls.env["stock.location"].create(
{
"name": "TestLocation2",
"posx": 4,
"location_id": cls.warehouse_1.lot_stock_id.id,
}
)
cls.supplier_location = cls.env.ref("stock.stock_location_suppliers")
# partner
cls.partner_1 = cls.env["res.partner"].create(
{"name": "ACSONE SA/NV", "email": "info@acsone.eu"}
)
# picking type
cls.picking_type_in = cls.env.ref("stock.picking_type_in")
# Inventory Add product_1 to location_1
cls._change_product_qty(cls.product_1, cls.location_1, 50)
cls.StockMove = cls.env["stock.move"]
cls.StockPicking = cls.env["stock.picking"]
@classmethod
def _change_product_qty(cls, product, location, qty):
inventory_wizard = cls.env["stock.change.product.qty"].create(
{
"product_id": product.id,
"new_quantity": qty,
"location_id": location.id,
}
)
inventory_wizard.change_product_qty()
def _get_products_in_location(self, location):
return (
self.env["stock.quant"]
.search([("location_id", "=", location.id)])
.mapped("product_id")
)
def _create_and_assign_picking(self, short_move_infos, location_dest=None):
location_dest = location_dest or self.location_1
picking_in = self.StockPicking.create(
{
"partner_id": self.partner_1.id,
"picking_type_id": self.picking_type_in.id,
"location_id": self.supplier_location.id,
"location_dest_id": location_dest.id,
}
)
for move_info in short_move_infos:
self.StockMove.create(
{
"name": move_info.product.name,
"product_id": move_info.product.id,
"product_uom_qty": move_info.qty,
"product_uom": move_info.product.uom_id.id,
"picking_id": picking_in.id,
"location_id": self.supplier_location.id,
"location_dest_id": move_info.location_dest.id,
}
)
picking_in.action_confirm()
return picking_in
def _process_picking(self, picking):
picking.force_assign()
for pack in picking.pack_operation_product_ids:
pack.qty_done = pack.product_qty
picking.do_new_transfer()
def test_00(self):
"""
Data:
location_1 without product_restriction
location_1 with 50 product_1
Test case:
Add qty of product_2 into location_1
Expected result:
The location contains the 2 products
"""
self.assertEqual(
self.product_1, self._get_products_in_location(self.location_1)
)
self._change_product_qty(self.product_2, self.location_1, 10)
self.assertEqual(
self.product_1 | self.product_2,
self._get_products_in_location(self.location_1),
)
def test_01(self):
"""
Data:
location_1 with same product restriction
location_1 with 50 product_1
Test case:
Add qty of product_2 into location_1
Expected result:
ValidationError
"""
self.assertEqual(
self.product_1, self._get_products_in_location(self.location_1)
)
self.location_1.specific_product_restriction = "same"
with self.assertRaises(ValidationError):
self._change_product_qty(self.product_2, self.location_1, 10)
def test_02(self):
"""
Data:
location_2 without product nor product restriction
a picking with two move with destination location_2
Test case:
Process the picking
Expected result:
The two product are into location 2
"""
picking = self._create_and_assign_picking(
[
ShortMoveInfo(
product=self.product_1,
location_dest=self.location_2,
qty=2,
),
ShortMoveInfo(
product=self.product_2,
location_dest=self.location_2,
qty=2,
),
],
location_dest=self.location_2,
)
self._process_picking(picking)
self.assertEqual(
self.product_1 | self.product_2,
self._get_products_in_location(self.location_2),
)
def test_03(self):
"""
Data:
location_2 without product but with product restriction = 'same'
a picking with two move with destination location_2
Test case:
Process the picking
Expected result:
ValidationError
"""
self.location_2.specific_product_restriction = "same"
picking = self._create_and_assign_picking(
[
ShortMoveInfo(
product=self.product_1,
location_dest=self.location_2,
qty=2,
),
ShortMoveInfo(
product=self.product_2,
location_dest=self.location_2,
qty=2,
),
],
location_dest=self.location_2,
)
with self.assertRaises(ValidationError):
self._process_picking(picking)
def test_04(self):
"""
Data:
location_1 with product_1 and wihout product restriction = 'same'
a picking with two moves:
* product_1 -> location_1,
* product_2 -> location_1
Test case:
Process the picking
Expected result:
We now have two product into the same location
"""
self.assertEqual(
self.product_1, self._get_products_in_location(self.location_1),
)
self.location_1.specific_product_restriction = "any"
picking = self._create_and_assign_picking(
[
ShortMoveInfo(
product=self.product_1,
location_dest=self.location_1,
qty=2,
),
ShortMoveInfo(
product=self.product_2,
location_dest=self.location_1,
qty=2,
),
],
location_dest=self.location_1,
)
self._process_picking(picking)
self.assertEqual(
self.product_1 | self.product_2,
self._get_products_in_location(self.location_1),
)
def test_05(self):
"""
Data:
location_1 with product_1 but with product restriction = 'same'
a picking with one move: product_2 -> location_1
Test case:
Process the picking
Expected result:
ValidationError
"""
self.assertEqual(
self.product_1, self._get_products_in_location(self.location_1),
)
self.location_1.specific_product_restriction = "same"
picking = self._create_and_assign_picking(
[
ShortMoveInfo(
product=self.product_2,
location_dest=self.location_1,
qty=2,
),
],
location_dest=self.location_1,
)
with self.assertRaises(ValidationError):
self._process_picking(picking)

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="stock_location_form_view">
<field name="name">stock.location.form (in stock_location_unique_product)</field>
<field name="model">stock.location</field>
<field name="inherit_id" ref="stock.view_location_form"/>
<field name="arch" type="xml">
<div name="button_box" position="after">
<div
class="alert alert-danger"
role="alert"
attrs="{'invisible': [('restriction_violation_message','=',False)]}"
><field name="restriction_violation_message" />
</div>
</div>
<xpath expr="//group[last()]">
<group name="restrictions" string="Restrictions">
<field name="product_restriction"/>
<field name="specific_product_restriction" class="oe_edit_only"/>
</group>
</xpath>
</field>
</record>
<record model="ir.ui.view" id="stock_location_search_view">
<field name="name">stock.location.search (in stock_location_unique_product)</field>
<field name="model">stock.location</field>
<field name="inherit_id" ref="stock.view_location_search"/>
<field name="arch" type="xml">
<filter name="inactive" position="after">
<filter string="With restriction violation" name="has_restriction_violation" domain="[('has_restriction_violation','=',True)]"/>
</filter>
</field>
</record>
<record model="ir.ui.view" id="stock_location_tree_view">
<field name="name">stock.location.tree (in stock_location_unique_product)</field>
<field name="model">stock.location</field>
<field name="inherit_id" ref="stock.view_location_tree2"/>
<field name="arch" type="xml">
<field name="company_id" position="before">
<field name="has_restriction_violation"/>
</field>
</field>
</record>
</odoo>