← Back to team overview

openerp-community-reviewer team mailing list archive

[Merge] lp:~numerigraphe-team/stock-logistic-warehouse/7.0-add-stock-available-sale into lp:stock-logistic-warehouse

 

Lionel Sausin - Numérigraphe has proposed merging lp:~numerigraphe-team/stock-logistic-warehouse/7.0-add-stock-available-sale into lp:stock-logistic-warehouse with lp:~numerigraphe-team/stock-logistic-warehouse/7.0-add-stock-available as a prerequisite.

Requested reviews:
  Stock and Logistic Core Editors (stock-logistic-core-editors)

For more details, see:
https://code.launchpad.net/~numerigraphe-team/stock-logistic-warehouse/7.0-add-stock-available-sale/+merge/220761

Add stock_available_sale: take sale quotations into account in the stock quantity available to promise" stock_available stock_available_sale.

This branch builds upon lp:~numerigraphe-team/stock-logistic-warehouse/7.0-add-stock-available, which adds a generic module to compute the stock available to promise in a configurable way.

Module Co-authored by Loïc Bellier and your humble servant.
-- 
https://code.launchpad.net/~numerigraphe-team/stock-logistic-warehouse/7.0-add-stock-available-sale/+merge/220761
Your team Stock and Logistic Core Editors is requested to review the proposed merge of lp:~numerigraphe-team/stock-logistic-warehouse/7.0-add-stock-available-sale into lp:stock-logistic-warehouse.
=== modified file 'stock_available/res_config.py'
--- stock_available/res_config.py	2014-05-23 07:53:49 +0000
+++ stock_available/res_config.py	2014-05-23 07:53:49 +0000
@@ -31,4 +31,11 @@
             help="This will subtract incoming quantities from the quantities"
                  "available to promise.\n"
                  "This installs the module stock_available_immediately."),
+        'module_stock_available_sale': fields.boolean(
+            'Exclude goods already in sale quotations',
+            help="This will subtract quantities from the sale quotations from"
+                 "the quantities available to promise.\n"
+                 "This installs the modules stock_available_sale.\n"
+                 "If the modules sale and sale_delivery_date are not "
+                 "installed, this will install them too"),
     }

=== modified file 'stock_available/res_config_view.xml'
--- stock_available/res_config_view.xml	2014-05-23 07:53:49 +0000
+++ stock_available/res_config_view.xml	2014-05-23 07:53:49 +0000
@@ -15,6 +15,10 @@
                                     <field name="module_stock_available_immediately" class="oe_inline" />
                                     <label for="module_stock_available_immediately" />
                                 </div>
+                                <div>
+                                    <field name="module_stock_available_sale" class="oe_inline" />
+                                    <label for="module_stock_available_sale" />
+                                </div>
                             </div>
                         </group>
                     </xpath>

=== added directory 'stock_available_sale'
=== added file 'stock_available_sale/__init__.py'
--- stock_available_sale/__init__.py	1970-01-01 00:00:00 +0000
+++ stock_available_sale/__init__.py	2014-05-23 07:53:49 +0000
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    This module is copyright (C) 2014 Numérigraphe SARL. All Rights Reserved.
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU Affero General Public License as
+#    published by the Free Software Foundation, either version 3 of the
+#    License, or (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU Affero General Public License for more details.
+#
+#    You should have received a copy of the GNU Affero General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+##############################################################################
+
+from . import product
+from . import sale_stock

=== added file 'stock_available_sale/__openerp__.py'
--- stock_available_sale/__openerp__.py	1970-01-01 00:00:00 +0000
+++ stock_available_sale/__openerp__.py	2014-05-23 07:53:49 +0000
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    This module is copyright (C) 2014 Numérigraphe SARL. All Rights Reserved.
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU Affero General Public License as
+#    published by the Free Software Foundation, either version 3 of the
+#    License, or (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU Affero General Public License for more details.
+#
+#    You should have received a copy of the GNU Affero General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+##############################################################################
+
+{
+    'name': 'Quotations in quantity available to promise',
+    'version': '2.0',
+    'author': u'Numérigraphe SÀRL',
+    'category': 'Hidden',
+    'depends': [
+        'stock_available',
+        'sale_order_dates',
+        'sale_stock',
+    ],
+    'description': """
+This module computes the quoted quantity of the Products, and subtracts it from
+the quantities available to promise .
+
+"Quoted" is defined as the sum of the quantities of this product in Quotations,
+taking the context's shop or warehouse into account.
+
+When entering sale orders, the salesperson get warned if the quantity available
+to promise is insufficient (instead when the virtual stock is
+insufficient).""",
+    'data': [
+        'product_view.xml',
+    ],
+    'test': [
+        'test/quoted_qty.yml',
+    ],
+    'license': 'AGPL-3',
+}

=== added file 'stock_available_sale/product.py'
--- stock_available_sale/product.py	1970-01-01 00:00:00 +0000
+++ stock_available_sale/product.py	2014-05-23 07:53:49 +0000
@@ -0,0 +1,232 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    This module is copyright (C) 2014 Numérigraphe SARL. All Rights Reserved.
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU Affero General Public License as
+#    published by the Free Software Foundation, either version 3 of the
+#    License, or (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU Affero General Public License for more details.
+#
+#    You should have received a copy of the GNU Affero General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+##############################################################################
+
+from openerp.osv import orm, fields
+import decimal_precision as dp
+
+# Function which uses the pool to call the method from the other modules too.
+from openerp.addons.stock_available import _product_available_fnct
+
+
+class ProductProduct(orm.Model):
+    """Add the computation for the stock available to promise"""
+    _inherit = 'product.product'
+
+    def _product_available(self, cr, uid, ids, field_names=None, arg=False,
+                           context=None):
+        """Compute the quantities in Quotations."""
+        # Check the context
+        if context is None:
+            context = {}
+        # Prepare an alternative context without 'uom', to avoid cross-category
+        # conversions when reading the available stock of components
+        if 'uom' in context:
+            context_wo_uom = context.copy()
+            del context_wo_uom['uom']
+        else:
+            context_wo_uom = context
+
+        if field_names is None:
+            field_names = []
+
+        # Compute the core quantities
+        res = super(ProductProduct, self)._product_available(
+            cr, uid, ids, field_names=field_names, arg=arg, context=context)
+
+        # Compute the quantities quoted/available to promise
+        if ('quoted_qty' in field_names
+                or 'immediately_usable_qty' in field_names):
+            date_str, date_args = self._get_dates(cr, uid, ids,
+                                                  context=context)
+
+            # Limit the search to some shops according to the context
+            shop_str, shop_args = self._get_shops(cr, uid, ids,
+                                                  context=context)
+
+            # Query the total by Product and UoM
+            cr.execute(
+                """
+                SELECT sum(product_uom_qty), product_id, product_uom
+                FROM sale_order_line
+                INNER JOIN sale_order
+                     ON (sale_order_line.order_id = sale_order.id)
+                WHERE product_id in %s
+                      AND sale_order_line.state = 'draft' """
+                + date_str + shop_str +
+                "GROUP BY sale_order_line.product_id, product_uom",
+                (tuple(ids),) + date_args + shop_args)
+            results = cr.fetchall()
+
+            # Get the UoM resources we'll need for conversion
+            # UoMs from the products
+            uoms_o = {}
+            product2uom = {}
+            for product in self.browse(cr, uid, ids, context=context):
+                product2uom[product.id] = product.uom_id
+                uoms_o[product.uom_id.id] = product.uom_id
+            # UoM from the results and the context
+            uom_obj = self.pool['product.uom']
+            uoms = map(lambda stock_product_uom_qty: stock_product_uom_qty[2],
+                       results)
+            if context.get('uom', False):
+                uoms.append(context['uom'])
+            uoms = filter(lambda stock_product_uom_qty:
+                          stock_product_uom_qty not in uoms_o.keys(), uoms)
+            if uoms:
+                uoms = uom_obj.browse(cr, uid, list(set(uoms)),
+                                      context=context)
+            for o in uoms:
+                uoms_o[o.id] = o
+
+            # Compute the quoted quantity
+            for (amount, prod_id, prod_uom) in results:
+                # Convert the amount to the product's UoM without rounding
+                amount = amount / uoms_o[prod_uom].factor
+                if ('quoted_qty' in field_names):
+                    res[prod_id]['quoted_qty'] -= amount
+                if ('immediately_usable_qty' in field_names):
+                    res[prod_id]['immediately_usable_qty'] -= amount
+
+            # Round and optionally convert the results to the requested UoM
+            for prod_id, stock_qty in res.iteritems():
+                if context.get('uom', False):
+                    # Convert to the requested UoM
+                    res_uom = uoms_o[context['uom']]
+                else:
+                    # The conversion is unneeded but we do need the rounding
+                    res_uom = product2uom[prod_id]
+                if ('quoted_qty' in field_names):
+                    stock_qty['quoted_qty'] = uom_obj._compute_qty_obj(
+                        cr, uid, product2uom[prod_id],
+                        stock_qty['quoted_qty'],
+                        res_uom)
+                if ('immediately_usable_qty' in field_names):
+                    stock_qty['immediately_usable_qty'] = \
+                        uom_obj._compute_qty_obj(
+                            cr, uid, product2uom[prod_id],
+                            stock_qty['immediately_usable_qty'],
+                            res_uom)
+        return self._update_virtual_available(cr, uid, res, context=context)
+
+    def _get_shops(self, cr, uid, ids, context=None):
+        """Find the shops matching the current context
+
+        See the helptext for the field quoted_qty for details"""
+        shop_ids = []
+        # Account for one or several locations in the context
+        # Take any shop using any warehouse that has these locations as stock
+        # location
+        if context.get('location', False):
+            # Either a single or multiple locations can be in the context
+            if not isinstance(context['location'], list):
+                location_ids = [context['location']]
+            else:
+                location_ids = context['location']
+            # Add the children locations
+            if context.get('compute_child', True):
+                child_location_ids = self.pool['stock.location'].search(
+                    cr, uid, [('location_id', 'child_of', location_ids)])
+                location_ids = child_location_ids or location_ids
+            # Get the corresponding Shops
+            cr.execute(
+                """
+                SELECT id FROM sale_shop
+                WHERE warehouse_id IN (
+                    SELECT id
+                    FROM stock_warehouse
+                    WHERE lot_stock_id IN %s)""",
+                (tuple(location_ids),))
+            res_location = cr.fetchone()
+            if res_location:
+                shop_ids.append(res_location)
+
+        # Account for a warehouse in the context
+        # Take any draft order in any shop using this warehouse
+        if context.get('warehouse', False):
+            cr.execute("SELECT id "
+                       "FROM sale_shop "
+                       "WHERE warehouse_id = %s",
+                       (int(context['warehouse']),))
+            res_wh = cr.fetchone()
+            if res_wh:
+                shop_ids.append(res_wh)
+
+        # If we are in a single Shop context, only count the quotations from
+        # this shop
+        if context.get('shop', False):
+            shop_ids.append(context['shop'])
+        # Build the SQL to restrict to the selected shops
+        shop_str = ''
+        if shop_ids:
+            shop_str = 'AND sale_order.shop_id IN %s'
+
+        if shop_ids:
+            shop_ids = (tuple(shop_ids),)
+        else:
+            shop_ids = ()
+        return shop_str, shop_ids
+
+    def _get_dates(self, cr, uid, ids, context=None):
+        """Build SQL criteria to match the context's from/to dates"""
+        # If we are in a context with dates, only consider the quotations to be
+        # delivered at these dates.
+        # If no delivery date was entered, use the order date instead
+        if not context:
+            return '', ()
+
+        from_date = context.get('from_date', False)
+        to_date = context.get('to_date', False)
+        date_str = ''
+        date_args = []
+        if from_date:
+            date_str = """AND COALESCE(
+                              sale_order.requested_date,
+                              sale_order.date_order) >= %s """
+            date_args.append(from_date)
+        if to_date:
+            date_str += """AND COALESCE(
+                               sale_order.requested_date,
+                               sale_order.date_order) <= %s """
+            date_args.append(to_date)
+
+        if date_args:
+            date_args = (tuple(date_args),)
+        else:
+            date_args = ()
+        return date_str, date_args
+
+    _columns = {
+        'quoted_qty': fields.function(
+            _product_available_fnct, method=True, multi='qty_available',
+            type='float',
+            digits_compute=dp.get_precision('Product Unit of Measure'),
+            string='Quoted',
+            help="Total quantity of this Product that have been included in "
+                 "Quotations (Draft Sale Orders).\n"
+                 "In a context with a single Shop, this includes the "
+                 "Quotation processed at this Shop.\n"
+                 "In a context with a single Warehouse, this includes "
+                 "Quotation processed in any Shop using this Warehouse.\n"
+                 "In a context with a single Stock Location, this includes "
+                 "Quotation processed at any shop using any Warehouse using "
+                 "this Location, or any of its children, as it's Stock "
+                 "Location.\n"
+                 "Otherwise, this includes every Quotation."),
+    }

=== added file 'stock_available_sale/product_view.xml'
--- stock_available_sale/product_view.xml	1970-01-01 00:00:00 +0000
+++ stock_available_sale/product_view.xml	2014-05-23 07:53:49 +0000
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<openerp>
+    <data>
+        <!-- Add the quantity available to promise in the product form -->
+        <record id="view_product_form_quoted_qty" model="ir.ui.view">
+            <field name="name">product.form.quoted_qty</field>
+            <field name="model">product.product</field>
+            <field name="type">form</field>
+            <field name="inherit_id" ref="stock_available.view_stock_available_form" />
+            <field name="arch" type="xml">
+                <data>
+                    <xpath expr="//field[@name='immediately_usable_qty']" position="after">
+                        <field name="quoted_qty"/>
+                    </xpath>
+                </data>
+            </field>
+        </record>
+    </data>
+</openerp>

=== added file 'stock_available_sale/sale_stock.py'
--- stock_available_sale/sale_stock.py	1970-01-01 00:00:00 +0000
+++ stock_available_sale/sale_stock.py	2014-05-23 07:53:49 +0000
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    This module is copyright (C) 2014 Numérigraphe SARL. All Rights Reserved.
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU Affero General Public License as
+#    published by the Free Software Foundation, either version 3 of the
+#    License, or (at your option) any later version.
+#
+#    This program is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#    GNU Affero General Public License for more details.
+#
+#    You should have received a copy of the GNU Affero General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+##############################################################################
+
+from openerp.osv import osv
+
+
+class sale_order_line(osv.osv):
+    _inherit = "sale.order.line"
+
+    def product_id_change(self, cr, uid, ids, pricelist, product, qty=0,
+                          uom=False, qty_uos=0, uos=False, name='',
+                          partner_id=False, lang=False, update_tax=True,
+                          date_order=False, packaging=False,
+                          fiscal_position=False, flag=False, context=None):
+        """Base the stock checking on the quantity available to promise
+
+        This is done by tweaking the context, to keep the impact minimum"""
+        if context is None:
+            context = {}
+        context = dict(context, virtual_is_immediately_usable=True)
+        return super(sale_order_line, self).product_id_change(
+            cr, uid, ids, pricelist, product, qty=qty,
+            uom=uom, qty_uos=qty_uos, uos=uos, name=name,
+            partner_id=partner_id, lang=lang, update_tax=update_tax,
+            date_order=date_order, packaging=packaging,
+            fiscal_position=fiscal_position, flag=flag, context=context)

=== added directory 'stock_available_sale/test'
=== added file 'stock_available_sale/test/quoted_qty.yml'
--- stock_available_sale/test/quoted_qty.yml	1970-01-01 00:00:00 +0000
+++ stock_available_sale/test/quoted_qty.yml	2014-05-23 07:53:49 +0000
@@ -0,0 +1,66 @@
+- Test the computation of the quoted quantity on product.product_product_10
+
+- Create a UoM in the category of PCE
+- !record {model: product.uom, id: thousand}:
+    name: Thousand
+    factor: 0.001
+    rounding: 0.00
+    uom_type: bigger
+    category_id: product.product_uom_categ_unit
+
+- Cancel all the previous Quotations
+- !python {model: sale.order}: |
+    line_ids = self.pool['sale.order.line'].search(
+          cr, uid, [('product_id', '=', ref('product.product_product_10')),
+                    ('state', '=', 'draft')])
+    ids = [l.order_id.id for l in self.pool['sale.order.line'].browse(cr, uid, line_ids)]
+    if ids:
+      self.action_cancel(cr, uid, ids)
+- The quoted quantity should be 0
+- !assert {model: product.product, id: product.product_product_10, string: "Check quoted_qty"}:
+    - quoted_qty == 0.0
+  
+- Enter a Quotation
+- !record {model: sale.order, id: order1}:
+    order_line:
+      - name: Quotation 1
+        product_uom: product.product_uom_unit
+        product_uom_qty: 107.0
+        state: draft
+        product_id: product.product_product_10
+    partner_id: base.res_partner_2
+    partner_invoice_id: base.res_partner_address_8
+    partner_shipping_id: base.res_partner_address_8
+    pricelist_id: product.list0
+- The quoted qty should match the single quotation
+- !assert {model: product.product, id: product.product_product_10, string: "Check quoted_qty"}:
+    - quoted_qty == -107.0
+
+- Enter another Quotation
+- !record {model: sale.order, id: order2}:
+    order_line:
+      - name: Quotation 1
+        product_uom: thousand
+        product_uom_qty: 0.613
+        state: draft
+        product_id: product.product_product_10
+    partner_id: base.res_partner_2
+    partner_invoice_id: base.res_partner_address_9
+    partner_shipping_id: base.res_partner_address_9
+    pricelist_id: product.list0
+- The quoted qty should match the total of the quotations
+- !assert {model: product.product, id: product.product_product_10, string: "Check quoted quantity"}:
+    - quoted_qty == -720.0
+- Use the context to report in another UoM
+- !assert {model: product.product, id: product.product_product_10, string: "Check in other UoM", context: "{'uom': ref('thousand')}"}:
+  - quoted_qty == -0.72
+- Use the context to report in the default UoM
+- !assert {model: product.product, id: product.product_product_10, string: "Check in False UoM", context: "{'uom': False}"}:
+  - quoted_qty == -720.0
+
+- Confirm one of the Quotations
+- !workflow {model: sale.order, action: order_confirm, ref: order1}
+- The quoted qty should match the remaining quotation
+- !assert {model: product.product, id: product.product_product_10, string: "Check quoted quantity"}:
+    - quoted_qty == -613.0
+


Follow ups