mirror of
https://github.com/OCA/stock-logistics-warehouse.git
synced 2025-01-21 14:27:28 +02:00
new module 'stock_available_unreserved' (#206)
* [ADD] new module 'stock_available_unreserved'
This commit is contained in:
committed by
sbejaoui
parent
8bb6375f92
commit
1d6f01f59e
64
stock_available_unreserved/README.rst
Normal file
64
stock_available_unreserved/README.rst
Normal file
@@ -0,0 +1,64 @@
|
||||
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
|
||||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
|
||||
:alt: License: AGPL-3
|
||||
|
||||
==========================
|
||||
Stock Available Unreserved
|
||||
==========================
|
||||
|
||||
This module allows users to check the quantity of a stocked product that is
|
||||
available on-hand, and that has not yet been reserved for use anywhere else.
|
||||
|
||||
This key figure is very important during the monitoring of the warehouse
|
||||
execution, because it assists users to ensure that the flow of products will
|
||||
not be stuck due to a sudden unavailability of stock.
|
||||
|
||||
If the warehouse personnel ensures that the unreserved quantity on hand > 0,
|
||||
then nobody will be stuck in pickings or manufacturing orders waiting for
|
||||
the availability of unreserved stock.
|
||||
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
|
||||
:alt: Try me on Runbot
|
||||
:target: https://runbot.odoo-community.org/runbot/153/9.0
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
Images
|
||||
------
|
||||
|
||||
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_.
|
||||
|
||||
Contributors
|
||||
------------
|
||||
|
||||
* Jordi Ballester Alomar <jordi.ballester@eficent.com>
|
||||
|
||||
|
||||
Maintainer
|
||||
----------
|
||||
|
||||
.. image:: https://odoo-community.org/logo.png
|
||||
:alt: Odoo Community Association
|
||||
:target: https://odoo-community.org
|
||||
|
||||
This module is maintained by the OCA.
|
||||
|
||||
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.
|
||||
|
||||
To contribute to this module, please visit https://odoo-community.org.
|
||||
7
stock_available_unreserved/__init__.py
Normal file
7
stock_available_unreserved/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
|
||||
# Copyright 2016 Eficent Business and IT Consulting Services S.L.
|
||||
# (http://www.eficent.com)
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
from . import models
|
||||
21
stock_available_unreserved/__openerp__.py
Normal file
21
stock_available_unreserved/__openerp__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
|
||||
# Copyright 2016 Eficent Business and IT Consulting Services S.L.
|
||||
# (http://www.eficent.com)
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
{
|
||||
"name": "Stock Available Unreserved",
|
||||
"summary": "Quantity of stock available for inmediate use",
|
||||
"version": "9.0.1.0.0",
|
||||
"author": "Eficent Business and IT Consulting Services S.L,"
|
||||
"Odoo Community Association (OCA)",
|
||||
"website": "https://www.odoo-community.org",
|
||||
"category": "Warehouse Management",
|
||||
"depends": ["stock"],
|
||||
"data": ["views/stock_quant_view.xml",
|
||||
"views/product_view.xml"
|
||||
],
|
||||
"license": "AGPL-3",
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
7
stock_available_unreserved/models/__init__.py
Normal file
7
stock_available_unreserved/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
|
||||
# Copyright 2016 Eficent Business and IT Consulting Services S.L.
|
||||
# (http://www.eficent.com)
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
from . import product
|
||||
141
stock_available_unreserved/models/product.py
Normal file
141
stock_available_unreserved/models/product.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
|
||||
# Copyright 2016 Eficent Business and IT Consulting Services S.L.
|
||||
# (http://www.eficent.com)
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
from openerp import api, fields, models, _
|
||||
from openerp.tools.float_utils import float_round
|
||||
from openerp.addons import decimal_precision as dp
|
||||
|
||||
UNIT = dp.get_precision('Product Unit of Measure')
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
||||
qty_available_not_res = fields.Float(
|
||||
string='Quantity On Hand Unreserved', digits=UNIT,
|
||||
compute='_compute_product_available_not_res')
|
||||
|
||||
qty_available_stock_text = fields.Char(
|
||||
compute='_compute_product_available_not_res',
|
||||
string='Unreserved stock quantity')
|
||||
|
||||
@api.multi
|
||||
def _compute_product_available_not_res(self):
|
||||
no_new = self.filtered(
|
||||
lambda x: not isinstance(x.id, models.NewId))
|
||||
res = no_new._product_available()
|
||||
for tmpl in no_new:
|
||||
qty = res[tmpl.id]['qty_available_not_res']
|
||||
tmpl.qty_available_not_res = qty
|
||||
text = res[tmpl.id]['qty_available_stock_text']
|
||||
tmpl.qty_available_stock_text = text
|
||||
|
||||
@api.multi
|
||||
def _product_available(self, name=None, arg=False):
|
||||
prod_available = super(ProductTemplate, self)._product_available(name,
|
||||
arg)
|
||||
|
||||
variants = self.env['product.product']
|
||||
for product in self:
|
||||
variants += product.product_variant_ids
|
||||
variant_available = variants._product_available()
|
||||
|
||||
for product in self:
|
||||
if isinstance(product.id, models.NewId):
|
||||
continue
|
||||
qty_available_not_res = 0.0
|
||||
text = ''
|
||||
for p in product.product_variant_ids:
|
||||
qty = variant_available[p.id]["qty_available_not_res"]
|
||||
qty_available_not_res += qty
|
||||
text = variant_available[p.id]["qty_available_stock_text"]
|
||||
prod_available[product.id].update({
|
||||
"qty_available_not_res": qty_available_not_res,
|
||||
"qty_available_stock_text": text,
|
||||
})
|
||||
return prod_available
|
||||
|
||||
@api.multi
|
||||
def action_open_quants_unreserved(self):
|
||||
products = self._get_products()
|
||||
result = self._get_act_window_dict('stock.product_open_quants')
|
||||
result['domain'] = "[('product_id','in',[" + ','.join(
|
||||
map(str, products)) + "]), ('reservation_id', '=', False)]"
|
||||
result[
|
||||
'context'] = "{'search_default_locationgroup': 1, " \
|
||||
"'search_default_internal_loc': 1}"
|
||||
return result
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = 'product.product'
|
||||
|
||||
qty_available_not_res = fields.Float(
|
||||
string='Qty Available Not Reserved', digits=UNIT,
|
||||
compute='_compute_qty_available_not_res')
|
||||
|
||||
qty_available_stock_text = fields.Char(
|
||||
compute='_compute_qty_available_not_res', string='Available per stock')
|
||||
|
||||
@api.multi
|
||||
def _compute_qty_available_not_res(self):
|
||||
res = self._product_available()
|
||||
for prod in self:
|
||||
qty = res[prod.id]['qty_available_not_res']
|
||||
text = res[prod.id]['qty_available_stock_text']
|
||||
prod.qty_available_not_res = qty
|
||||
prod.qty_available_stock_text = text
|
||||
|
||||
@api.model
|
||||
def _prepare_domain_available_not_res(self, products):
|
||||
domain_products = [('product_id', 'in', products.mapped('id'))]
|
||||
domain_quant = []
|
||||
domain_quant_loc, _, _ = products._get_domain_locations()
|
||||
|
||||
domain_quant += domain_products
|
||||
|
||||
domain_quant.append(('reservation_id', '=', False))
|
||||
|
||||
domain_quant += domain_quant_loc
|
||||
|
||||
return domain_quant
|
||||
|
||||
@api.multi
|
||||
def _product_available_not_res_hook(self, quants):
|
||||
"""Hook used to introduce possible variations"""
|
||||
return False
|
||||
|
||||
@api.multi
|
||||
def _product_available(self, field_names=None, arg=False):
|
||||
|
||||
res = super(ProductProduct, self).\
|
||||
_product_available(field_names=field_names,
|
||||
arg=arg)
|
||||
|
||||
domain_quant = self._prepare_domain_available_not_res(self)
|
||||
|
||||
quants = self.env['stock.quant'].read_group(
|
||||
domain_quant,
|
||||
['product_id', 'location_id', 'qty'],
|
||||
['product_id', 'location_id'],
|
||||
lazy=False)
|
||||
values_prod = {}
|
||||
for quant in quants:
|
||||
# create a dictionary with the total value per products
|
||||
values_prod.setdefault(quant['product_id'][0], 0)
|
||||
values_prod[quant['product_id'][0]] += quant['qty']
|
||||
for product in self:
|
||||
# get total qty for the product
|
||||
qty = float_round(values_prod.get(product.id, 0.0),
|
||||
precision_rounding=product.uom_id.rounding)
|
||||
qty_available_not_res = qty
|
||||
res[product.id].update({'qty_available_not_res':
|
||||
qty_available_not_res})
|
||||
text = str(qty_available_not_res) + _(" On Hand")
|
||||
res[product.id].update({'qty_available_stock_text': text})
|
||||
self._product_available_not_res_hook(quants)
|
||||
|
||||
return res
|
||||
BIN
stock_available_unreserved/static/description/icon.png
Normal file
BIN
stock_available_unreserved/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
7
stock_available_unreserved/tests/__init__.py
Normal file
7
stock_available_unreserved/tests/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
|
||||
# Copyright 2016 Eficent Business and IT Consulting Services S.L.
|
||||
# (http://www.eficent.com)
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
from . import test_stock_available_unreserved
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2016 ACSONE SA/NV (<http://acsone.eu>)
|
||||
# Copyright 2016 Eficent Business and IT Consulting Services S.L.
|
||||
# (http://www.eficent.com)
|
||||
# Copyright 2016 Therp BV <http://therp.nl>
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
|
||||
|
||||
from openerp.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestStockLogisticsWarehouse(TransactionCase):
|
||||
|
||||
def test01_stock_levels(self):
|
||||
"""checking that qty_available_not_res actually reflects \
|
||||
the variations in stock, both on product and template"""
|
||||
pickingObj = self.env['stock.picking']
|
||||
productObj = self.env['product.product']
|
||||
templateObj = self.env['product.template']
|
||||
supplier_location = self.env.ref('stock.stock_location_suppliers')
|
||||
stock_location = self.env.ref('stock.stock_location_stock')
|
||||
customer_location = self.env.ref('stock.stock_location_customers')
|
||||
uom_unit = self.env.ref('product.product_uom_unit')
|
||||
|
||||
# Create product template
|
||||
templateAB = templateObj.create(
|
||||
{'name': 'templAB',
|
||||
'uom_id': uom_unit.id,
|
||||
})
|
||||
|
||||
# Create product A and B
|
||||
productA = productObj.create(
|
||||
{'name': 'product A',
|
||||
'standard_price': 1,
|
||||
'type': 'product',
|
||||
'uom_id': uom_unit.id,
|
||||
'default_code': 'A',
|
||||
'product_tmpl_id': templateAB.id,
|
||||
})
|
||||
|
||||
productB = productObj.create(
|
||||
{'name': 'product B',
|
||||
'standard_price': 1,
|
||||
'type': 'product',
|
||||
'uom_id': uom_unit.id,
|
||||
'default_code': 'B',
|
||||
'product_tmpl_id': templateAB.id,
|
||||
})
|
||||
|
||||
# Create a picking move from INCOMING to STOCK
|
||||
pickingInA = pickingObj.create({
|
||||
'picking_type_id': self.ref('stock.picking_type_in'),
|
||||
'location_id': supplier_location.id,
|
||||
'location_dest_id': stock_location.id,
|
||||
'move_lines': [
|
||||
(0, 0, {
|
||||
'name': 'Test move',
|
||||
'product_id': productA.id,
|
||||
'product_uom': productA.uom_id.id,
|
||||
'product_uom_qty': 2,
|
||||
'location_id': supplier_location.id,
|
||||
'location_dest_id': stock_location.id,
|
||||
})]
|
||||
})
|
||||
|
||||
pickingInB = pickingObj.create({
|
||||
'picking_type_id': self.ref('stock.picking_type_in'),
|
||||
'location_id': supplier_location.id,
|
||||
'location_dest_id': stock_location.id,
|
||||
'move_lines': [
|
||||
(0, 0, {
|
||||
'name': 'Test move',
|
||||
'product_id': productB.id,
|
||||
'product_uom': productB.uom_id.id,
|
||||
'product_uom_qty': 3,
|
||||
'location_id': supplier_location.id,
|
||||
'location_dest_id': stock_location.id,
|
||||
})]
|
||||
})
|
||||
|
||||
def compare_qty_available_not_res(product, value):
|
||||
# Refresh, because the function field is not recalculated between
|
||||
# transactions
|
||||
product.refresh()
|
||||
self.assertEqual(product.qty_available_not_res, value)
|
||||
|
||||
compare_qty_available_not_res(productA, 0)
|
||||
compare_qty_available_not_res(templateAB, 0)
|
||||
|
||||
pickingInA.action_confirm()
|
||||
compare_qty_available_not_res(productA, 0)
|
||||
compare_qty_available_not_res(templateAB, 0)
|
||||
|
||||
pickingInA.action_assign()
|
||||
compare_qty_available_not_res(productA, 0)
|
||||
compare_qty_available_not_res(templateAB, 0)
|
||||
|
||||
pickingInA.action_done()
|
||||
compare_qty_available_not_res(productA, 2)
|
||||
compare_qty_available_not_res(templateAB, 2)
|
||||
|
||||
# will directly trigger action_done on productB
|
||||
pickingInB.action_done()
|
||||
compare_qty_available_not_res(productA, 2)
|
||||
compare_qty_available_not_res(productB, 3)
|
||||
compare_qty_available_not_res(templateAB, 5)
|
||||
|
||||
# Create a picking from STOCK to CUSTOMER
|
||||
pickingOutA = pickingObj.create({
|
||||
'picking_type_id': self.ref('stock.picking_type_out'),
|
||||
'location_id': stock_location.id,
|
||||
'location_dest_id': customer_location.id,
|
||||
'move_lines': [
|
||||
(0, 0, {
|
||||
'name': 'Test move',
|
||||
'product_id': productB.id,
|
||||
'product_uom': productB.uom_id.id,
|
||||
'product_uom_qty': 2,
|
||||
'location_id': stock_location.id,
|
||||
'location_dest_id': customer_location.id,
|
||||
})]
|
||||
})
|
||||
|
||||
compare_qty_available_not_res(productB, 3)
|
||||
compare_qty_available_not_res(templateAB, 5)
|
||||
|
||||
pickingOutA.action_confirm()
|
||||
compare_qty_available_not_res(productB, 3)
|
||||
compare_qty_available_not_res(templateAB, 5)
|
||||
|
||||
pickingOutA.action_assign()
|
||||
compare_qty_available_not_res(productB, 1)
|
||||
compare_qty_available_not_res(templateAB, 3)
|
||||
|
||||
pickingOutA.action_done()
|
||||
compare_qty_available_not_res(productB, 1)
|
||||
compare_qty_available_not_res(templateAB, 3)
|
||||
78
stock_available_unreserved/views/product_view.xml
Normal file
78
stock_available_unreserved/views/product_view.xml
Normal file
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<openerp>
|
||||
<data>
|
||||
|
||||
|
||||
<record id="view_stock_product_template_tree" model="ir.ui.view">
|
||||
<field name="name">product.template.stock.tree.inherit</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id"
|
||||
ref="stock.view_stock_product_template_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="qty_available" position="after">
|
||||
<field name="qty_available_not_res"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="product_template_kanban_stock_view">
|
||||
<field name="name">Product Template Kanban Stock</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id"
|
||||
ref="stock.product_template_kanban_stock_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<ul position="inside">
|
||||
<li t-if="record.type.raw_value == 'product'">Unreserved: <field name="qty_available_not_res"/> <field name="uom_id"/></li>
|
||||
</ul>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_stock_product_tree" model="ir.ui.view">
|
||||
<field name="name">product.stock.tree.inherit</field>
|
||||
<field name="model">product.product</field>
|
||||
<field name="inherit_id" ref="stock.view_stock_product_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="qty_available" position="after">
|
||||
<field name="qty_available_not_res"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="product_template_form_view_procurement_button">
|
||||
<field name="name">product.template_procurement</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id"
|
||||
ref="stock.product_template_form_view_procurement_button"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="button_box" position="inside">
|
||||
<button type="object"
|
||||
name="action_open_quants_unreserved"
|
||||
attrs="{'invisible':[('type', '!=', 'product')]}"
|
||||
class="oe_stat_button" icon="fa-building-o">
|
||||
<field name="qty_available_not_res" widget="statinfo"
|
||||
string="Unreserved"/>
|
||||
</button>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="product_form_view_procurement_button">
|
||||
<field name="name">product.product.procurement</field>
|
||||
<field name="model">product.product</field>
|
||||
<field name="inherit_id"
|
||||
ref="stock.product_form_view_procurement_button"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="button_box" position="inside">
|
||||
<button class="oe_stat_button"
|
||||
name="%(product_open_quants_unreserved)d"
|
||||
icon="fa-building-o"
|
||||
type="action" attrs="{'invisible':[('type', '!=', 'product')]}">
|
||||
<field name="qty_available_not_res" widget="statinfo"
|
||||
string="Unreserved"/>
|
||||
</button>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</openerp>
|
||||
31
stock_available_unreserved/views/stock_quant_view.xml
Normal file
31
stock_available_unreserved/views/stock_quant_view.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<openerp>
|
||||
<data>
|
||||
|
||||
<record id="quant_search_view" model="ir.ui.view">
|
||||
<field name="name">stock.quant.search</field>
|
||||
<field name="model">stock.quant</field>
|
||||
<field name="inherit_id" ref="stock.quant_search_view"/>
|
||||
<field eval="10" name="priority"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="owner_id" position="after">
|
||||
<field name="reservation_id"/>
|
||||
</field>
|
||||
<filter name="internal_loc" position="after">
|
||||
<filter name='internal_unreserved'
|
||||
string="Internal Unreserved"
|
||||
domain="[('reservation_id','=', False), ('location_id.usage','=', 'internal')]"/>
|
||||
</filter>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.act_window"
|
||||
id="product_open_quants_unreserved">
|
||||
<field name="name">Stock On Hand (Unreserved)</field>
|
||||
<field name="context">{'search_default_internal_loc': 1, 'search_default_locationgroup':1}</field>
|
||||
<field name="domain">[('product_id', '=', active_id), ('reservation_id', '=', False)]</field>
|
||||
<field name="res_model">stock.quant</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</openerp>
|
||||
Reference in New Issue
Block a user