← Back to team overview

openerp-community-reviewer team mailing list archive

[Merge] lp:~camptocamp/sale-wkfl/add-sale_exception_nostock-jge into lp:sale-wkfl

 

Joël Grand-Guillaume @ camptocamp has proposed merging lp:~camptocamp/sale-wkfl/add-sale_exception_nostock-jge into lp:sale-wkfl.

Commit message:
[ADD] sale_exception_nostock module to raise a warning when a SO try to be confirmed and there is no stock / procurement planned for the desired delivery date.

Requested reviews:
  Sale Core Editors (sale-core-editors)

For more details, see:
https://code.launchpad.net/~camptocamp/sale-wkfl/add-sale_exception_nostock-jge/+merge/193591

Hi,


This MP propose the new module sale_exception_notstock. 

This addon adds two new sales exceptions to be used by sale_exception addon.
The first one will ensure that an order line can be delivered on the delivery
date if it is in MTS. Validation is done by using the order line location via
related shop and using the line delay.

The second one will create a sales exception if the current SO will break a
sales order already placed in the future.
The second test will only look for stock moves that pass by the line location.
So if your stock have children or if you have configured automated stock actions
they must pass by the location related to the SO line, else they will be ignored.


Reagrds
-- 
https://code.launchpad.net/~camptocamp/sale-wkfl/add-sale_exception_nostock-jge/+merge/193591
Your team Sale Core Editors is requested to review the proposed merge of lp:~camptocamp/sale-wkfl/add-sale_exception_nostock-jge into lp:sale-wkfl.
=== added directory 'sale_exception_nostock'
=== added file 'sale_exception_nostock/__init__.py'
--- sale_exception_nostock/__init__.py	1970-01-01 00:00:00 +0000
+++ sale_exception_nostock/__init__.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    Author: Nicolas Bessi
+#    Copyright 2013 Camptocamp SA
+#
+#    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 model
+from . import test

=== added file 'sale_exception_nostock/__openerp__.py'
--- sale_exception_nostock/__openerp__.py	1970-01-01 00:00:00 +0000
+++ sale_exception_nostock/__openerp__.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    Author: Nicolas Bessi
+#    Copyright 2013 Camptocamp SA
+#
+#    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': 'Sale stock exception',
+ 'version': '0.1',
+ 'author': 'Camptocamp',
+ 'maintainer': 'Camptocamp',
+ 'category': 'sale',
+ 'complexity': "normal",
+ 'depends': ['sale_exceptions', 'sale_stock'],
+ 'description': """
+Sale stock exception
+--------------------
+
+This addon adds two new sales exceptions to be used by sale_exception addon.
+The first one will ensure that an order line can be delivered on the delivery
+date if it is in MTS. Validation is done by using the order line location via
+related shop and using the line delay.
+
+The second one will create a sales exception if the current SO will break a
+sales order already placed in the future.
+The second test will only look for stock moves that pass by the line location.
+So if your stock have children or if you have configured automated stock actions
+they must pass by the location related to the SO line, else they will be ignored.
+
+**Warning:**
+
+The second test is a workaround to compensate the lack of
+stock reservation process in OpenERP. This can be a performance killer
+and should not be be used if you have hundreds of simultanous open SO.
+""",
+ 'website': 'http://www.camptocamp.com',
+ 'data': ["data/data.xml"],
+ 'demo': [],
+ 'test': ['test/no_stock_test.yml'],
+ 'installable': True,
+ 'auto_install': False,
+ 'license': 'AGPL-3',
+ 'application': False,
+ }

=== added directory 'sale_exception_nostock/data'
=== added file 'sale_exception_nostock/data/data.xml'
--- sale_exception_nostock/data/data.xml	1970-01-01 00:00:00 +0000
+++ sale_exception_nostock/data/data.xml	2013-11-01 12:59:36 +0000
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<openerp>
+  <data noupdate="1">
+    <record id="no_stock_at_date" model="sale.exception">
+      <field name="name">Not enough stock at delivery date</field>
+      <field name="description">Not enough stock at delivery date for this SO line
+to be delivered using MTS procument method</field>
+      <field name="sequence">20</field>
+      <field name="model">sale.order.line</field>
+      <field name="code">if not object.can_command_at_delivery_date():
+    failed = True</field>
+      <field name="active" eval="True"/>
+    </record>
+
+    <record id="no_stock_in_future" model="sale.exception">
+      <field name="name">Not enough stock to satisfy existing placed orders planned in the future</field>
+      <field name="description">This order contains one or more lines procured on MTS that will prevent later order to be delivered on the estimated delivery date.
+      You shoud review and reschedule the deliveries or replennish the stock.
+      </field>
+      <field name="sequence">40</field>
+      <field name="model">sale.order.line</field>
+      <field name="code">if object.future_orders_are_affected():
+    failed = True</field>
+      <field name="active" eval="True"/>
+    </record>
+  </data>
+</openerp>

=== added directory 'sale_exception_nostock/i18n'
=== added directory 'sale_exception_nostock/model'
=== added file 'sale_exception_nostock/model/__init__.py'
--- sale_exception_nostock/model/__init__.py	1970-01-01 00:00:00 +0000
+++ sale_exception_nostock/model/__init__.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    Author: Nicolas Bessi
+#    Copyright 2013 Camptocamp SA
+#
+#    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 sale

=== added file 'sale_exception_nostock/model/sale.py'
--- sale_exception_nostock/model/sale.py	1970-01-01 00:00:00 +0000
+++ sale_exception_nostock/model/sale.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,117 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    Author: Nicolas Bessi
+#    Copyright 2013 Camptocamp SA
+#
+#    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/>.
+#
+##############################################################################
+import datetime
+from openerp.osv import orm
+from openerp.tools import (DEFAULT_SERVER_DATE_FORMAT,
+                           DEFAULT_SERVER_DATETIME_FORMAT)
+
+
+class sale_order_line(orm.Model):
+    """Adds two exception functions to be call by sale_exception addon.
+    The first one will ensure that an order line can be delivered on the
+    delivery date, if the related product is in MTS. Validation is done by
+    using the shop related to the sales order line location and using the line
+    delay.
+    The second one will raise a sales exception if the current SO will break a
+    order already placed in future"""
+
+    _inherit = "sale.order.line"
+
+    def _compute_line_delivery_date(self, line_br, context=None):
+        date_order = line_br.order_id.date_order
+        date_order = datetime.datetime.strptime(date_order,
+                                                DEFAULT_SERVER_DATE_FORMAT)
+        # delay is a float, that is perfectly supported by timedelta
+        return date_order + datetime.timedelta(days=line_br.delay)
+
+    def _get_line_location(self, line_br, context=None):
+        return line_br.order_id.shop_id.warehouse_id.lot_stock_id.id
+
+    def can_command_at_delivery_date(self, cr, uid, l_id, context=None):
+        """This checks if SO line (l_id) can be delivered at delivery date.
+        Delivery date is computed using date of the order + line delay.
+        Location is taken from the shop linked to the line"""
+        if context is None:
+            context = {}
+        prod_obj = self.pool['product.product']
+        if isinstance(l_id, (tuple, list)):
+            assert len(l_id) == 1, "Only one id supported"
+            l_id = l_id[0]
+        line = self.browse(cr, uid, l_id, context=context)
+        if not line.product_id or not line.type == 'make_to_stock':
+            return True
+        delivery_date = self._compute_line_delivery_date(line, context=context)
+        ctx = context.copy()
+        ctx['to_date'] = delivery_date.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+        ctx['location'] = self._get_line_location(line, context=context)
+        ctx['compute_child'] = True  # Virtual qty is made on all childs of chosen location
+        prod_for_virtual_qty = prod_obj.read(cr, uid, line.product_id.id,
+                                             ['virtual_available'], context=ctx)
+        if prod_for_virtual_qty['virtual_available'] < line.product_uom_qty:
+            return False
+        return True
+
+    def _get_states(self):
+        return ('waiting', 'confirmed', 'assigned')
+
+    def _get_affected_dates(self, cr, location_id, product_id, delivery_date, context=None):
+        """Determine future dates where virtual stock has to be checked.
+        It will only look for stock move that pass by this location_id.
+        If your stock location have children or you have configured automated stock action
+        they must pass by the location related to SO line, else the will be ignored"""
+        sql = ("SELECT date FROM stock_move"
+               "  WHERE state IN %s"
+               "   AND date > %s"
+               "   AND product_id = %s"
+               "   AND location_id = %s")
+        cr.execute(sql, (self._get_states(),
+                         delivery_date,
+                         product_id,
+                         location_id))
+        return (row[0] for row in cr.fetchall())
+
+    def future_orders_are_affected(self, cr, uid, l_id, context=None):
+        """This function is a naive workaround for the lack of stock reservation
+        management in OpenERP. This can be a performance killer, you should not use it
+        if you have constantly a lot of running Orders"""
+        if context is None:
+            context = {}
+        prod_obj = self.pool['product.product']
+        if isinstance(l_id, (tuple, list)):
+            assert len(l_id) == 1, "Only one id supported"
+            l_id = l_id[0]
+        line = self.browse(cr, uid, l_id, context=context)
+        if not line.product_id or not line.type == 'make_to_stock':
+            return False
+        delivery_date = self._compute_line_delivery_date(line, context=context)
+        ctx = context.copy()
+        location_id = self._get_line_location(line, context=context)
+        ctx['location'] = location_id
+        ctx['compute_child'] = True  # Virtual qty is made on all childs of chosen location
+        dates = self._get_affected_dates(cr, location_id, line.product_id.id,
+                                         delivery_date, context=context)
+        for aff_date in dates:
+            ctx['to_date'] = aff_date
+            prod_for_virtual_qty = prod_obj.read(cr, uid, line.product_id.id,
+                                                 ['virtual_available'], context=ctx)
+            if prod_for_virtual_qty['virtual_available'] < line.product_uom_qty:
+                return True
+        return False

=== added directory 'sale_exception_nostock/test'
=== added file 'sale_exception_nostock/test/__init__.py'
--- sale_exception_nostock/test/__init__.py	1970-01-01 00:00:00 +0000
+++ sale_exception_nostock/test/__init__.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    Author: Nicolas Bessi
+#    Copyright 2013 Camptocamp SA
+#
+#    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 test_utils

=== added file 'sale_exception_nostock/test/no_stock_test.yml'
--- sale_exception_nostock/test/no_stock_test.yml	1970-01-01 00:00:00 +0000
+++ sale_exception_nostock/test/no_stock_test.yml	2013-11-01 12:59:36 +0000
@@ -0,0 +1,190 @@
+-
+  In order to test stock sale exception we have to ensure that
+  if a sale order that can not be delivired on time it should not be confirmed.
+  We have also to test that even if our sale order can be delivered
+  it should not break any already placed SO,
+-
+  For testing purpose I deactivate all sale exception(s) that could interfer
+-
+  !python {model: sale.exception}: |
+    ids_to_keep = [ref("no_stock_at_date"), ref("no_stock_in_future")]
+    ids_to_silent = self.search(cr, uid, [('id', 'not in', ids_to_keep)])
+    data = {}
+    data['active'] = False
+    self.write(cr, uid, ids_to_silent, data)
+-
+  I create a new firesteel product for my test
+-
+  !record {model: product.product, id: product_firesteel}:
+    categ_id: product.product_category_1
+    name: Firesteel
+    procure_method: make_to_stock
+    supply_method: buy
+    type: product
+    uom_id: product.product_uom_unit
+    uom_po_id: product.product_uom_unit
+    property_stock_inventory: stock.location_inventory
+    property_stock_procurement: stock.location_procurement
+    property_stock_production: stock.location_production
+-
+  I create a sale order to be delivered for the 30th of March for product Firesteel
+-
+  !record {model: sale.order, id: so_0}:
+    client_order_ref: ref firesteel 0
+    date_order: !eval time.strftime('%Y-03-30')
+    invoice_quantity: order
+    name: 30th of March
+    order_line:
+      - name: Firesteel
+        price_unit: 0.20
+        product_uom: product.product_uom_unit
+        product_uom_qty: 10000
+        state: draft
+        delay: 7.0
+        product_id: product_firesteel
+        product_uos_qty: 10000
+        type: make_to_stock
+    order_policy: manual
+    partner_id: base.res_partner_4
+    partner_invoice_id: base.res_partner_address_7
+    partner_shipping_id: base.res_partner_address_7
+    picking_policy: direct
+    pricelist_id: product.list0
+    shop_id: sale.sale_shop_1
+-
+  Then I confirm the the 30th of March firesteel order
+-
+  !workflow {model: sale.order, action: order_confirm, ref: so_0}
+
+-
+ I check that the sale order is not confirmed and his linked to no_stock_at_date exception
+-
+  !python {model: sale.order}: |
+    sale_order = self.browse(cr, uid, ref("so_0"))
+    from openerp.addons.sale_exception_nostock.test.test_utils import check_state_and_exceptions
+    check_state_and_exceptions(sale_order, 'draft', ref("no_stock_at_date"))
+-
+ I create a stock move to make an appro for the 15th of April
+-
+  !record {model: stock.move, id: sm_0}:
+    company_id: base.main_company
+    date: !eval time.strftime('%Y-04-15 00:00:00')
+    date_expected: !eval time.strftime('%Y-04-15 00:00:00')
+    location_dest_id: stock.stock_location_stock
+    location_id: stock.location_inventory
+    name: 'PO 1 for the 5th of April'
+    product_id: product_firesteel
+    product_qty: 10500
+    product_uom: product.product_uom_unit
+    product_uos_qty: 10500
+-
+ I force move assignation
+-
+  !python {model: stock.move}: |
+    self.write(cr, uid, [ref('sm_0')], {'state': 'done'})
+-
+  Then I confirm the firesteel order
+-
+  !workflow {model: sale.order, action: order_confirm, ref: so_0}
+
+-
+ I check that the sale order is not confirmed and is only linked to no_stock_at_date exception
+-
+  !python {model: sale.order}: |
+    sale_order = self.browse(cr, uid, ref("so_0"))
+    from openerp.addons.sale_exception_nostock.test.test_utils import check_state_and_exceptions
+    check_state_and_exceptions(sale_order, 'draft', ref("no_stock_at_date"))
+-
+ I create a stock move to make an appro for the 25th of March
+-
+  !record {model: stock.move, id: sm_1}:
+    company_id: base.main_company
+    date: !eval time.strftime('%Y-03-25 00:00:00')
+    date_expected: !eval time.strftime('%Y-03-25 00:00:00')
+    location_dest_id: stock.stock_location_stock
+    location_id: stock.location_inventory
+    name: 'PO 1 for the 25th of March'
+    product_id: product_firesteel
+    product_qty: 10500
+    product_uom: product.product_uom_unit
+    product_uos_qty: 10500
+-
+ I force move PO 1 for the 25 of march assignation
+-
+  !python {model: stock.move}: |
+    self.write(cr, uid, [ref('sm_1')], {'state': 'done'})
+-
+  Then I confirm the the 30th of March firesteel order
+-
+  !workflow {model: sale.order, action: order_confirm, ref: so_0}
+-
+ I check that the sale order is confirmed
+-
+  !python {model: sale.order}: |
+    sale_order = self.browse(cr, uid, ref("so_0"))
+    assert sale_order.state == "manual", "The sale order was not confirmed when it should"
+-
+ I create an SO for the 26th of March with a short delay of one day
+-
+  !record {model: sale.order, id: so_1}:
+    client_order_ref: ref firesteel 1
+    date_order: !eval time.strftime('%Y-03-26')
+    invoice_quantity: order
+    name: 26th of March
+    order_line:
+      - name: Firesteel
+        price_unit: 0.20
+        product_uom: product.product_uom_unit
+        product_uom_qty: 10000
+        state: draft
+        delay: 1.2
+        product_id: product_firesteel
+        product_uos_qty: 10000
+        type: make_to_stock
+    order_policy: manual
+    partner_id: base.res_partner_4
+    partner_invoice_id: base.res_partner_address_7
+    partner_shipping_id: base.res_partner_address_7
+    picking_policy: direct
+    pricelist_id: product.list0
+    shop_id: sale.sale_shop_1
+-
+  Then I confirm the the 26th of March firesteel order
+-
+  !workflow {model: sale.order, action: order_confirm, ref: so_1}
+-
+ Then my sale order should be in draft state and only related to no_stock_in_future exception
+-
+  !python {model: sale.order}: |
+    sale_order = self.browse(cr, uid, ref("so_1"))
+    from openerp.addons.sale_exception_nostock.test.test_utils import check_state_and_exceptions
+    check_state_and_exceptions(sale_order, 'draft', ref("no_stock_in_future"))
+-
+ I create a quick stock move to make an appro for the 27th of March in the morning
+-
+  !record {model: stock.move, id: sm_3}:
+    company_id: base.main_company
+    date: !eval time.strftime('%Y-03-27 00:00:00')
+    date_expected: !eval time.strftime('%Y-03-27 00:00:00')
+    location_dest_id: stock.stock_location_stock
+    location_id: stock.location_inventory
+    name: 'PO 1 for the 27th of March'
+    product_id: product_firesteel
+    product_qty: 10500
+    product_uom: product.product_uom_unit
+    product_uos_qty: 10500
+-
+ I force move PO 1 for the 27 of march assignation
+-
+  !python {model: stock.move}: |
+    self.write(cr, uid, [ref('sm_3')], {'state': 'done'})
+-
+  Then I confirm the the 26th of March firesteel order
+-
+  !workflow {model: sale.order, action: order_confirm, ref: so_1}
+-
+ I check that the sale order is confirmed
+-
+  !python {model: sale.order}: |
+    sale_order = self.browse(cr, uid, ref("so_1"))
+    assert sale_order.state == "manual", "The sale order was not confirmed when it should"
\ No newline at end of file

=== added file 'sale_exception_nostock/test/test_utils.py'
--- sale_exception_nostock/test/test_utils.py	1970-01-01 00:00:00 +0000
+++ sale_exception_nostock/test/test_utils.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    Author: Nicolas Bessi
+#    Copyright 2013 Camptocamp SA
+#
+#    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/>.
+#
+##############################################################################
+
+
+def check_state_and_exceptions(sale_order, state, exc_id):
+    assert sale_order.state == state, "Incorrect state %s instead of %s" % (sale_order.state,
+                                                                            state)
+    assert exc_id in [x.id for x in sale_order.exceptions_ids],\
+        "No exception for %s" % sale_order.name
+
+    assert not [x for x in sale_order.exceptions_ids if x.id != exc_id],\
+        "Wrong sale exception detected for %s" % sale_order.name

=== added directory 'sale_exceptions'
=== added file 'sale_exceptions/__init__.py'
--- sale_exceptions/__init__.py	1970-01-01 00:00:00 +0000
+++ sale_exceptions/__init__.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#    
+#    OpenERP, Open Source Management Solution
+#    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
+#
+#    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/>.     
+#
+##############################################################################
+
+import sale
+import wizard
+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
+

=== added file 'sale_exceptions/__openerp__.py'
--- sale_exceptions/__openerp__.py	1970-01-01 00:00:00 +0000
+++ sale_exceptions/__openerp__.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#    
+#    OpenERP, Open Source Management Solution
+#    Copyright (C) 2011 Akretion LTDA.
+#    authors: Raphaël Valyi, Renato Lima
+#    Copyright (C) 2010-2012 Akretion Sébastien BEAU <sebastien.beau@xxxxxxxxxxxx>
+#    Copyright (C) 2012 Camptocamp SA (Guewen Baconnier)
+#
+#    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': 'Sale Exceptions',
+    'version': '0.1',
+    'category': 'Generic Modules/Sale',
+    'description': """
+This module allows you attach several customizable exceptions to your sale order in a way that you can filter orders by exceptions type and fix them.
+This is especially useful in an order importation scenario such as with the base_sale_multi_channels module, because it's likely a few orders have errors when you import them (like product not found in OpenERP, wrong line format etc...)
+""",
+    'author': 'Akretion',
+    'website': 'http://www.akretion.com',
+    'depends': ['sale'],
+    'init_xml': [
+                   'settings/sale.exception.csv',
+                ],
+    'update_xml': ['sale_workflow.xml',
+                   'sale_view.xml',
+                   'sale_exceptions_data.xml',
+                   'wizard/sale_exception_confirm_view.xml',
+                   'security/ir.model.access.csv'],
+    'demo_xml': [],
+    'installable': True,
+}
+# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:

=== added file 'sale_exceptions/sale.py'
--- sale_exceptions/sale.py	1970-01-01 00:00:00 +0000
+++ sale_exceptions/sale.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,208 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+#    OpenERP, Open Source Management Solution
+#    Copyright (C) 2011 Akretion LTDA.
+#    Copyright (C) 2010-2012 Akretion Sébastien BEAU <sebastien.beau@xxxxxxxxxxxx>
+#    Copyright (C) 2012 Camptocamp SA (Guewen Baconnier)
+#
+#    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/>.
+#
+##############################################################################
+
+import time
+import netsvc
+
+from openerp.osv.orm import Model
+from openerp.osv import fields
+from openerp.osv.osv import except_osv
+from tools.safe_eval import safe_eval as eval
+from tools.translate import _
+
+class sale_exception(Model):
+    _name = "sale.exception"
+    _description = "Sale Exceptions"
+    _order="active desc, sequence asc"
+    _columns = {
+        'name': fields.char('Exception Name', size=64, required=True, translate=True),
+        'description': fields.text('Description', translate=True),
+        'sequence': fields.integer('Sequence', help="Gives the sequence order when applying the test"),
+        'model': fields.selection([('sale.order', 'Sale Order'),
+                                   ('sale.order.line', 'Sale Order Line')],
+                                  string='Apply on', required=True),
+        'active': fields.boolean('Active'),
+        'code': fields.text('Python Code',
+                    help="Python code executed to check if the exception apply or not. " \
+                         "The code must apply block = True to apply the exception."),
+        'sale_order_ids': fields.many2many('sale.order', 'sale_order_exception_rel',
+                                           'exception_id', 'sale_order_id',
+                                           string='Sale Orders', readonly=True),
+    }
+
+    _defaults = {
+        'code': """# Python code. Use failed = True to block the sale order.
+# You can use the following variables :
+#  - self: ORM model of the record which is checked
+#  - order or line: browse_record of the sale order or sale order line
+#  - object: same as order or line, browse_record of the sale order or sale order line
+#  - pool: ORM model pool (i.e. self.pool)
+#  - time: Python time module
+#  - cr: database cursor
+#  - uid: current user id
+#  - context: current context
+"""
+    }
+
+class sale_order(Model):
+    _inherit = "sale.order"
+
+    _order = 'main_exception_id asc, date_order desc, name desc'
+
+    def _get_main_error(self, cr, uid, ids, name, args, context=None):
+        res = {}
+        for sale_order in self.browse(cr, uid, ids, context=context):
+            if sale_order.state == 'draft' and sale_order.exceptions_ids:
+                res[sale_order.id] = sale_order.exceptions_ids[0].id
+            else:
+                res[sale_order.id] = False
+        return res
+
+    _columns = {
+        'main_exception_id': fields.function(_get_main_error,
+                        type='many2one',
+                        relation="sale.exception",
+                        string='Main Exception',
+                        store={
+                            'sale.order': (lambda self, cr, uid, ids, c={}: ids, ['exceptions_ids', 'state'], 10),
+                        }),
+        'exceptions_ids': fields.many2many('sale.exception', 'sale_order_exception_rel',
+                                           'sale_order_id', 'exception_id',
+                                           string='Exceptions'),
+        'ignore_exceptions': fields.boolean('Ignore Exceptions'),
+    }
+
+    def test_all_draft_orders(self, cr, uid, context=None):
+        ids = self.search(cr, uid, [('state', '=', 'draft')])
+        self.test_exceptions(cr, uid, ids)
+        return True
+
+    def _popup_exceptions(self, cr, uid, order_id, context=None):
+        model_data_obj = self.pool.get('ir.model.data')
+        list_obj = self.pool.get('sale.exception.confirm')
+        ctx = context.copy()
+        ctx.update({'active_id': order_id,
+                    'active_ids': [order_id]})
+        list_id = list_obj.create(cr, uid, {}, context=ctx)
+        view_id = model_data_obj.get_object_reference(
+            cr, uid, 'sale_exceptions', 'view_sale_exception_confirm')[1]
+        action = {
+            'name': _("Exceptions On Sale Order"),
+            'type': 'ir.actions.act_window',
+            'view_type': 'form',
+            'view_mode': 'form',
+            'res_model': 'sale.exception.confirm',
+            'view_id': [view_id],
+            'target': 'new',
+            'nodestroy': True,
+            'res_id': list_id,
+        }
+        return action
+
+    def action_button_confirm(self, cr, uid, ids, context=None):
+        exception_ids = self.detect_exceptions(cr, uid, ids, context=context)
+        if exception_ids:
+            return self._popup_exceptions(cr, uid, ids[0],  context=context)
+        else:
+            return super(sale_order, self).action_button_confirm(cr, uid, ids, context=context)
+
+    def test_exceptions(self, cr, uid, ids, context=None):
+        """
+        Condition method for the workflow from draft to confirm
+        """
+        exception_ids = self.detect_exceptions(cr, uid, ids, context=context)
+        if exception_ids:
+            return False
+        return True
+
+    def detect_exceptions(self, cr, uid, ids, context=None):
+        exception_obj = self.pool.get('sale.exception')
+        order_exception_ids = exception_obj.search(cr, uid,
+            [('model', '=', 'sale.order')], context=context)
+        line_exception_ids = exception_obj.search(cr, uid,
+            [('model', '=', 'sale.order.line')], context=context)
+
+        order_exceptions = exception_obj.browse(cr, uid, order_exception_ids, context=context)
+        line_exceptions = exception_obj.browse(cr, uid, line_exception_ids, context=context)
+
+        exception_ids = False
+        for order in self.browse(cr, uid, ids):
+            if order.ignore_exceptions:
+                continue
+            exception_ids = self._detect_exceptions(cr, uid, order,
+                order_exceptions, line_exceptions, context=context)
+
+            self.write(cr, uid, [order.id], {'exceptions_ids': [(6, 0, exception_ids)]})
+        return exception_ids
+
+    def _exception_rule_eval_context(self, cr, uid, obj_name, obj, context=None):
+        if context is None:
+            context = {}
+
+        return {obj_name: obj,
+                'self': self.pool.get(obj._name),
+                'object': obj,
+                'obj': obj,
+                'pool': self.pool,
+                'cr': cr,
+                'uid': uid,
+                'user': self.pool.get('res.users').browse(cr, uid, uid),
+                'time': time,
+                # copy context to prevent side-effects of eval
+                'context': dict(context),}
+
+    def _rule_eval(self, cr, uid, rule, obj_name, obj, context):
+        expr = rule.code
+        space = self._exception_rule_eval_context(cr, uid, obj_name, obj,
+                                                  context=context)
+        try:
+            eval(expr, space,
+                 mode='exec', nocopy=True) # nocopy allows to return 'result'
+        except Exception, e:
+            raise except_osv(_('Error'), _('Error when evaluating the sale exception rule :\n %s \n(%s)') %
+                                 (rule.name, e))
+        return space.get('failed', False)
+
+    def _detect_exceptions(self, cr, uid, order, order_exceptions, line_exceptions, context=None):
+        exception_ids = []
+        for rule in order_exceptions:
+            if self._rule_eval(cr, uid, rule, 'order', order, context):
+                exception_ids.append(rule.id)
+
+        for order_line in order.order_line:
+            for rule in line_exceptions:
+                if rule.id in exception_ids:
+                    continue  # we do not matter if the exception as already been
+                    # found for an order line of this order
+                if self._rule_eval(cr, uid, rule, 'line', order_line, context):
+                    exception_ids.append(rule.id)
+
+        return exception_ids
+
+    def copy(self, cr, uid, id, default=None, context=None):
+        if default is None:
+            default = {}
+        default.update({
+            'ignore_exceptions': False,
+        })
+        return super(sale_order, self).copy(cr, uid, id, default=default, context=context)

=== added file 'sale_exceptions/sale_exceptions_data.xml'
--- sale_exceptions/sale_exceptions_data.xml	1970-01-01 00:00:00 +0000
+++ sale_exceptions/sale_exceptions_data.xml	2013-11-01 12:59:36 +0000
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<openerp>
+    <data noupdate="1">
+
+        <record forcecreate="True" id="ir_cron_test_orders" model="ir.cron">
+            <field name="name">Test Draft Orders</field>
+            <field eval="False" name="active"/>
+            <field name="user_id" ref="base.user_root"/>
+            <field name="interval_number">20</field>
+            <field name="interval_type">minutes</field>
+            <field name="numbercall">-1</field>
+            <field eval="False" name="doall"/>
+            <field eval="'sale.order'" name="model"/>
+            <field eval="'test_all_draft_orders'" name="function"/>
+            <field eval="'()'" name="args"/>
+        </record>
+    
+    </data>
+</openerp>

=== added file 'sale_exceptions/sale_view.xml'
--- sale_exceptions/sale_view.xml	1970-01-01 00:00:00 +0000
+++ sale_exceptions/sale_view.xml	2013-11-01 12:59:36 +0000
@@ -0,0 +1,106 @@
+<?xml version="1.0" ?>
+<openerp>
+    <data>
+
+        <record id="view_sale_exception_tree" model="ir.ui.view">
+            <field name="name">sale.exception.tree</field>
+            <field name="model">sale.exception</field>
+            <field name="type">tree</field>
+            <field name="arch" type="xml">
+                <tree string="Sale Exception">
+                    <field name="active"/>
+                    <field name="name"/>
+                    <field name="description"/>
+                    <field name="model"/>
+                    <field name="sequence"/>
+                </tree>
+            </field>
+        </record>
+
+        <record id="view_sale_exception_form" model="ir.ui.view">
+            <field name="name">sale.exception.form</field>
+            <field name="model">sale.exception</field>
+            <field name="type">form</field>
+            <field name="arch" type="xml">
+                <form string="Sale Exception Setup">
+                    <group colspan="4" col="2">
+                        <field name="name"/>
+                        <field name="description"/>
+                    </group>
+                    <group col="4" colspan="4" groups="base.group_sale_manager">
+                        <field name="active"/>
+                        <field name="sequence"/>
+                        <group colspan="4" col="2" groups="base.group_system">
+                            <field name="model"/>
+                            <field name="code"/>
+                        </group>
+                    </group>
+                    <group colspan="4" col="2">
+                        <separator string="Affected Sales Orders"/>
+                        <newline/>
+                        <field name="sale_order_ids" nolabel="1" domain="[('state', '=', 'draft')]"/>
+                    </group>
+                </form>
+            </field>
+        </record>
+
+        <record id="action_sale_test_tree" model="ir.actions.act_window">
+                  <field name="name">Exception Rules</field>
+                  <field name="res_model">sale.exception</field>
+                  <field name="view_type">form</field>
+                  <field name="view_mode">tree,form</field>
+                  <field name="view_id" ref="view_sale_exception_tree"/>
+                  <field name="context">{'active_test': False}</field>
+              </record>
+
+        <menuitem action="action_sale_test_tree" id="menu_sale_test" parent="base.menu_sale_config_sales" />
+
+
+        <record id="view_order_form" model="ir.ui.view">
+            <field name="name">sale_exceptions.view_order_form</field>
+            <field name="model">sale.order</field>
+            <field name="type">form</field>
+            <field name="priority">100</field>
+            <field name="inherit_id" ref="sale.view_order_form"/>
+            <field name="arch" type="xml">
+                <field name="name" position="after">
+                    <field name="main_exception_id" nolabel="1"
+                           attrs="{'invisible':[('main_exception_id','=', False)]}"/>
+                </field>
+                <xpath expr="//page[@string='Other Information']/group"
+                        position="inside">
+                    <group name="exception" colspan="2" col="2">
+                        <separator string="Exception" colspan="2"/>
+                        <field name="exceptions_ids" colspan="2" nolabel="1"/>
+                    </group>
+                </xpath>
+            </field>
+        </record>
+
+        <record id="view_order_tree" model="ir.ui.view">
+            <field name="name">sale_exceptions.view_order_tree</field>
+            <field name="model">sale.order</field>
+            <field name="type">tree</field>
+            <field name="inherit_id" ref="sale.view_order_tree"/>
+            <field name="arch" type="xml">
+                <field name="state" position="after">
+                    <field name="main_exception_id"/>
+                </field>
+            </field>
+        </record>
+
+        <record id="view_sales_order_filter" model="ir.ui.view">
+            <field name="name">sale_exceptions.view_sales_order_filter</field>
+            <field name="model">sale.order</field>
+            <field name="inherit_id" ref="sale.view_sales_order_filter" />
+            <field name="type">search</field>
+            <field eval="32" name="priority"/>
+            <field name="arch" type="xml">
+                <filter icon="terp-check" string="Sales" position="after">
+                    <separator orientation="vertical"/>
+                    <filter icon="terp-emblem-important" name="tofix" string="TO FIX" domain="[('main_exception_id','!=',False)]"/>
+                </filter>
+            </field>
+        </record>
+    </data>
+</openerp>

=== added file 'sale_exceptions/sale_workflow.xml'
--- sale_exceptions/sale_workflow.xml	1970-01-01 00:00:00 +0000
+++ sale_exceptions/sale_workflow.xml	2013-11-01 12:59:36 +0000
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<openerp>
+    <data>
+        <record id="sale.trans_draft_router" model="workflow.transition">
+            <field name="signal">order_confirm</field>
+            <field name="condition">test_exceptions()</field>
+        </record>
+    </data>
+</openerp>

=== added directory 'sale_exceptions/security'
=== added file 'sale_exceptions/security/ir.model.access.csv'
--- sale_exceptions/security/ir.model.access.csv	1970-01-01 00:00:00 +0000
+++ sale_exceptions/security/ir.model.access.csv	2013-11-01 12:59:36 +0000
@@ -0,0 +1,3 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_sale_exception","sale.exception","model_sale_exception","base.group_user",1,0,0,0
+"access_sale_exception_manager","sale.exception","model_sale_exception","base.group_sale_manager",1,1,1,1

=== added directory 'sale_exceptions/settings'
=== added file 'sale_exceptions/settings/sale.exception.csv'
--- sale_exceptions/settings/sale.exception.csv	1970-01-01 00:00:00 +0000
+++ sale_exceptions/settings/sale.exception.csv	2013-11-01 12:59:36 +0000
@@ -0,0 +1,5 @@
+"id","name","description","sequence","model","code","active"
+"excep_no_zip","No ZIP code on destination",,50,"sale.order","if not order.partner_shipping_id.zip:
+    failed=True",False
+"excep_no_stock","Not Enough Virtual Stock",,50,"sale.order.line","if line.product_id and line.product_id.type == 'product' and line.product_id.virtual_available < line.product_uom_qty:
+    failed=True",False

=== added directory 'sale_exceptions/wizard'
=== added file 'sale_exceptions/wizard/__init__.py'
--- sale_exceptions/wizard/__init__.py	1970-01-01 00:00:00 +0000
+++ sale_exceptions/wizard/__init__.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,1 @@
+import sale_exception_confirm

=== added file 'sale_exceptions/wizard/sale_exception_confirm.py'
--- sale_exceptions/wizard/sale_exception_confirm.py	1970-01-01 00:00:00 +0000
+++ sale_exceptions/wizard/sale_exception_confirm.py	2013-11-01 12:59:36 +0000
@@ -0,0 +1,56 @@
+# -*- encoding: utf-8 -*-
+##############################################################################
+#
+#    Copyright Camptocamp SA
+#    @author: Guewen Baconnier
+#
+#    This program is free software: you can redistribute it and/or modify
+#    it under the terms of the GNU 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 General Public License for more details.
+#
+#    You should have received a copy of the GNU General Public License
+#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+##############################################################################
+
+import netsvc
+
+from osv import osv, fields
+
+
+class SaleExceptionConfirm(osv.osv_memory):
+
+    _name = 'sale.exception.confirm'
+
+    _columns = {
+        'sale_id': fields.many2one('sale.order', 'Sale'),
+        'exception_ids': fields.many2many('sale.exception', string='Exceptions to resolve', readonly=True),
+        'ignore': fields.boolean('Ignore Exceptions'),
+    }
+
+    def default_get(self, cr, uid, fields, context=None):
+        res = super(SaleExceptionConfirm, self).default_get(cr, uid, fields, context=context)
+        order_obj = self.pool.get('sale.order')
+        sale_id = context.get('active_id', False)
+        if sale_id:
+            sale = order_obj.browse(cr, uid, sale_id, context=context)
+            exception_ids = [e.id for e in sale.exceptions_ids]
+            res.update({'exception_ids': [(6, 0, exception_ids)]})
+
+        res.update({'sale_id': sale_id})
+        return res
+
+    def action_confirm(self, cr, uid, ids, context=None):
+        form = self.browse(cr, uid, ids[0], context=context)
+        if form.ignore:
+            self.pool.get('sale.order').write(cr, uid, form.sale_id.id,
+                    {'ignore_exceptions': True}, context=context)
+        return {'type': 'ir.actions.act_window_close'}
+
+SaleExceptionConfirm()

=== added file 'sale_exceptions/wizard/sale_exception_confirm_view.xml'
--- sale_exceptions/wizard/sale_exception_confirm_view.xml	1970-01-01 00:00:00 +0000
+++ sale_exceptions/wizard/sale_exception_confirm_view.xml	2013-11-01 12:59:36 +0000
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<openerp>
+    <data>
+
+        <record id="view_sale_exception_confirm" model="ir.ui.view">
+            <field name="name">Sale Exceptions</field>
+            <field name="model">sale.exception.confirm</field>
+            <field name="type">form</field>
+            <field name="arch" type="xml">
+                <form string="Sale Exceptions On Sale Order" version="7.0">
+                    <group>
+                        <field name="exception_ids" nolabel="1" colspan="4">
+                            <form string="Sale Exception">
+                               <field name="name" colspan="4"/>
+                               <field name="description" colspan="4"/>
+                            </form>
+                            <tree string="Sale Exceptions">
+                                <field name="name"/>
+                                <field name="description"/>
+                            </tree>
+                        </field>
+                        <newline/>
+                        <field name="ignore" groups='base.group_sale_manager'/>
+                    </group>
+                    <footer>
+                        <button name="action_confirm" string="_Ok"
+                            colspan="1" type="object" icon="gtk-ok" />
+                    </footer>
+                </form>
+            </field>
+        </record>
+
+        <record id="action_sale_exception_confirm" model="ir.actions.act_window">
+            <field name="name">Sale Exceptions</field>
+            <field name="type">ir.actions.act_window</field>
+            <field name="res_model">sale.exception.confirm</field>
+            <field name="view_type">form</field>
+            <field name="view_mode">form</field>
+            <field name="view_id" ref="view_sale_exception_confirm"/>
+            <field name="target">new</field>
+        </record>
+
+    </data>
+</openerp>


Follow ups