diff --git a/stock_reserve/__init__.py b/stock_reserve/__init__.py
new file mode 100644
index 000000000..643bee7ab
--- /dev/null
+++ b/stock_reserve/__init__.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Author: Guewen Baconnier
+# 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 .
+#
+##############################################################################
+
+from . import model
diff --git a/stock_reserve/__openerp__.py b/stock_reserve/__openerp__.py
new file mode 100644
index 000000000..d1114f1fc
--- /dev/null
+++ b/stock_reserve/__openerp__.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Author: Guewen Baconnier
+# 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 .
+#
+##############################################################################
+
+{'name': 'Stock Reservation',
+ 'summary': 'Stock reservations on products',
+ 'version': '0.1',
+ 'author': 'Camptocamp',
+ 'category': 'Warehouse',
+ 'license': 'AGPL-3',
+ 'complexity': 'normal',
+ 'images': [],
+ 'website': "http://www.camptocamp.com",
+ 'description': """
+Stock Reservation
+=================
+
+Allows to create stock reservations on products.
+
+Each reservation can have a validity date, once passed, the reservation
+is automatically lifted.
+
+The reserved products are substracted from the virtual stock. It means
+that if you reserved a quantity of products which bring the virtual
+stock below the minimum, the orderpoint will be triggered and new
+purchase orders will be generated. It also implies that the max may be
+exceeded if the reservations are canceled.
+
+Contributors
+------------
+
+* Guewen Baconnier
+* Yannick Vaucher
+
+""",
+ 'depends': ['stock',
+ ],
+ 'demo': [],
+ 'data': ['view/stock_reserve.xml',
+ 'view/product.xml',
+ 'data/stock_data.xml',
+ 'security/ir.model.access.csv',
+ ],
+ 'auto_install': False,
+ 'test': ['test/stock_reserve.yml',
+ ],
+ 'installable': True,
+ }
diff --git a/stock_reserve/data/stock_data.xml b/stock_reserve/data/stock_data.xml
new file mode 100644
index 000000000..be6e44224
--- /dev/null
+++ b/stock_reserve/data/stock_data.xml
@@ -0,0 +1,26 @@
+
+
+
+
+ Reservation Stock
+
+
+
+
+
+
+ Release the stock reservation having a passed validity date
+
+
+ 1
+ days
+ -1
+
+ stock.reservation
+ release_validity_exceeded
+ ()
+
+
+
+
diff --git a/stock_reserve/model/__init__.py b/stock_reserve/model/__init__.py
new file mode 100644
index 000000000..9adf1d54b
--- /dev/null
+++ b/stock_reserve/model/__init__.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Author: Guewen Baconnier
+# 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 .
+#
+##############################################################################
+
+from . import stock_reserve
+from . import product
diff --git a/stock_reserve/model/product.py b/stock_reserve/model/product.py
new file mode 100644
index 000000000..1349a660b
--- /dev/null
+++ b/stock_reserve/model/product.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Author: Guewen Baconnier
+# 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 .
+#
+##############################################################################
+
+from openerp import models, fields, api
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ reservation_count = fields.Integer(
+ compute='_reservation_count',
+ string='# Sales')
+
+ @api.multi
+ def _reservation_count(self):
+ StockReservation = self.env['stock.reservation']
+ product_ids = self._get_products()
+ domain = [('product_id', 'in', product_ids),
+ ('state', 'in', ['draft', 'assigned'])]
+ reservations = StockReservation.search(domain)
+ self.reservation_count = sum(reserv.product_uom_qty
+ for reserv in reservations)
+
+ @api.multi
+ def action_view_reservations(self):
+ assert len(self._ids) == 1, "Expected 1 ID, got %r" % self._ids
+ ref = 'stock_reserve.action_stock_reservation_tree'
+ product_ids = self._get_products()
+ action_dict = self._get_act_window_dict(ref)
+ action_dict['domain'] = ("[('product_id','in',[" +
+ ','.join(map(str, product_ids)) + "])]")
+ action_dict['context'] = {
+ 'search_default_draft': 1,
+ 'search_default_reserved': 1
+ }
+ return action_dict
+
+class ProductProduct(models.Model):
+ _inherit = 'product.product'
+
+ reservation_count = fields.Integer(
+ compute='_reservation_count',
+ string='# Sales')
+
+ @api.multi
+ def _reservation_count(self):
+ StockReservation = self.env['stock.reservation']
+ product_id = self._ids[0]
+ domain = [('product_id', '=', product_id),
+ ('state', 'in', ['draft', 'assigned'])]
+ reservations = StockReservation.search(domain)
+ self.reservation_count = sum(reserv.product_uom_qty
+ for reserv in reservations)
+
+ @api.multi
+ def action_view_reservations(self):
+ assert len(self._ids) == 1, "Expected 1 ID, got %r" % self._ids
+ ref = 'stock_reserve.action_stock_reservation_tree'
+ product_id = self._ids[0]
+ action_dict = self.product_tmpl_id._get_act_window_dict(ref)
+ action_dict['domain'] = ("[('product_id','='," + str(product_id) + ")]")
+ action_dict['context'] = {
+ 'search_default_draft': 1,
+ 'search_default_reserved': 1
+ }
+ return action_dict
diff --git a/stock_reserve/model/stock_reserve.py b/stock_reserve/model/stock_reserve.py
new file mode 100644
index 000000000..aa5e08844
--- /dev/null
+++ b/stock_reserve/model/stock_reserve.py
@@ -0,0 +1,197 @@
+# -*- coding: utf-8 -*-
+##############################################################################
+#
+# Author: Guewen Baconnier
+# 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 .
+#
+##############################################################################
+
+from openerp import models, fields, api
+from openerp.exceptions import except_orm
+from openerp.tools.translate import _
+
+
+class StockReservation(models.Model):
+ """ Allow to reserve products.
+
+ The fields mandatory for the creation of a reservation are:
+
+ * product_id
+ * product_uom_qty
+ * product_uom
+ * name
+
+ The following fields are required but have default values that you may
+ want to override:
+
+ * company_id
+ * location_id
+ * dest_location_id
+
+ Optionally, you may be interested to define:
+
+ * date_validity (once passed, the reservation will be released)
+ * note
+ """
+ _name = 'stock.reservation'
+ _description = 'Stock Reservation'
+ _inherits = {'stock.move': 'move_id'}
+
+ move_id = fields.Many2one(
+ 'stock.move',
+ 'Reservation Move',
+ required=True,
+ readonly=True,
+ ondelete='cascade',
+ select=1)
+ date_validity = fields.Date('Validity Date')
+
+ @api.model
+ def default_get(self, fields_list):
+ """
+ Ensure default value of computed field `product_qty` is not set
+ as it would raise an error
+ """
+ res = super(StockReservation, self).default_get(fields_list)
+ if 'product_qty' in res:
+ del res['product_qty']
+ return res
+
+ @api.model
+ def get_location_from_ref(self, ref):
+ """ Get a location from a xmlid if allowed
+ :param ref: tuple (module, xmlid)
+ """
+ data_obj = self.env['ir.model.data']
+ try:
+ location = data_obj.xmlid_to_object(ref, raise_if_not_found=True)
+ location.check_access_rule('read')
+ location_id = location.id
+ except (except_orm, ValueError):
+ location_id = False
+ return location_id
+
+ @api.model
+ def _default_picking_type_id(self):
+ """ Search for an internal picking type
+ """
+ move_obj = self.env['stock.move']
+ type_obj = self.env['stock.picking.type']
+
+ types = type_obj.search([('code', '=', 'internal')], limit=1)
+ if types:
+ return types[0].id
+ return False
+
+ @api.model
+ def _default_location_id(self):
+ move_obj = self.env['stock.move']
+ picking_type_id = self._default_picking_type_id()
+ return (move_obj
+ .with_context(default_picking_type_id=picking_type_id)
+ ._default_location_source())
+
+ @api.model
+ def _default_location_dest_id(self):
+ ref = 'stock_reserve.stock_location_reservation'
+ return self.get_location_from_ref(ref)
+
+ _defaults = {
+ 'picking_type_id': _default_picking_type_id,
+ 'location_id': _default_location_id,
+ 'location_dest_id': _default_location_dest_id,
+ 'product_uom_qty': 1.0,
+ }
+
+ @api.multi
+ def reserve(self):
+ """ Confirm a reservation
+
+ The reservation is done using the default UOM of the product.
+ A date until which the product is reserved can be specified.
+ """
+ move_recs = self.move_id
+ move_recs.date_expected = fields.Datetime.now()
+ move_recs.action_confirm()
+ move_recs.force_assign()
+ return True
+
+ @api.multi
+ def release(self):
+ """
+ Releas moves from reservation
+ """
+ move_recs = self.move_id
+ move_recs.action_cancel()
+ return True
+
+ @api.model
+ def release_validity_exceeded(self, ids=None):
+ """ Release all the reservation having an exceeded validity date """
+ domain = [('date_validity', '<', fields.date.today()),
+ ('state', '=', 'assigned')]
+ if ids:
+ domain.append(('id', 'in', ids))
+ reserv_ids = self.search(domain)
+ self.release(reserv_ids)
+ return True
+
+ @api.multi
+ def unlink(self):
+ """ Release the reservation before the unlink """
+ self.release()
+ return super(StockReservation, self).unlink()
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ """ set product_uom and name from product onchange """
+ # save value before reading of self.move_id as this last one erase
+ # product_id value
+ product = self.product_id
+ # WARNING this gettattr erase self.product_id
+ move = self.move_id
+ result = move.onchange_product_id(
+ prod_id=product.id, loc_id=False, loc_dest_id=False,
+ partner_id=False)
+ if result.get('value'):
+ vals = result['value']
+ # only keep the existing fields on the view
+ self.name = vals.get('name')
+ self.product_uom = vals.get('product_uom')
+ # repeat assignation of product_id so we don't loose it
+ self.product_id = product.id
+
+ @api.onchange('product_uom_qty')
+ def _onchange_quantity(self):
+ """ On change of product quantity avoid negative quantities """
+ if not self.product_id or self.product_uom_qty <= 0.0:
+ self.product_uom_qty = 0.0
+
+ @api.multi
+ def open_move(self):
+ assert len(self._ids) == 1, "1 ID expected, got %r" % self._ids
+ reserv = self[0].move_id
+ data_obj = self.env['ir.model.data']
+ ref_form2 = 'stock.action_move_form2'
+ action = data_obj.xmlid_to_object(ref_form2)
+ action_dict = action.read()
+ action_dict['name'] = _('Reservation Move')
+ # open directly in the form view
+ ref_form = 'stock.view_move_form'
+ view_id = data_obj.xmlid_to_res_id(ref_form)
+ action['views'] = [(view_id, 'form')]
+ action['res_id'] = reserv['move_id']
+ return action
diff --git a/stock_reserve/security/ir.model.access.csv b/stock_reserve/security/ir.model.access.csv
new file mode 100644
index 000000000..c48a8eefa
--- /dev/null
+++ b/stock_reserve/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_stock_reservation_manager,stock.reservation manager,model_stock_reservation,stock.group_stock_manager,1,1,1,1
+access_stock_reservation_user,stock.reservation user,model_stock_reservation,stock.group_stock_user,1,1,1,0
diff --git a/stock_reserve/test/stock_reserve.yml b/stock_reserve/test/stock_reserve.yml
new file mode 100644
index 000000000..ccced7023
--- /dev/null
+++ b/stock_reserve/test/stock_reserve.yml
@@ -0,0 +1,58 @@
+-
+ I create a product to test the stock reservation
+-
+ !record {model: product.product, id: product_sorbet}:
+ default_code: 001SORBET
+ name: Sorbet
+ type: product
+ categ_id: product.product_category_1
+ list_price: 100.0
+ standard_price: 70.0
+ uom_id: product.product_uom_kgm
+ uom_po_id: product.product_uom_kgm
+-
+ I update the current stock of the Sorbet with 10 kgm
+-
+ !record {model: stock.change.product.qty, id: change_qty}:
+ new_quantity: 10
+ product_id: product_sorbet
+-
+ !python {model: stock.change.product.qty}: |
+ context['active_id'] = ref('stock_reserve.product_sorbet')
+ self.change_product_qty(cr, uid, [ref('change_qty')], context=context)
+-
+ I check Virtual stock of Sorbet after update stock.
+-
+ !python {model: product.product}: |
+ product = self.browse(cr, uid, ref('stock_reserve.product_sorbet'), context=context)
+ assert product.virtual_available == 10, "Stock is not updated."
+-
+ I create a stock reservation for 5 kgm
+-
+ !record {model: stock.reservation, id: reserv_sorbet1}:
+ product_id: product_sorbet
+ product_uom_qty: 5.0
+ product_uom: product.product_uom_kgm
+ name: reserve 5 kgm of sorbet for test
+-
+ I confirm the reservation
+-
+ !python {model: stock.reservation}: |
+ self.reserve(cr, uid, [ref('reserv_sorbet1')], context=context)
+-
+ I check Virtual stock of Sorbet after update reservation
+-
+ !python {model: product.product}: |
+ product = self.browse(cr, uid, ref('stock_reserve.product_sorbet'), context=context)
+ assert product.virtual_available == 5, "Stock is not updated."
+-
+ I release the reservation
+-
+ !python {model: stock.reservation}: |
+ self.release(cr, uid, [ref('reserv_sorbet1')], context=context)
+-
+ I check Virtual stock of Sorbet after update reservation
+-
+ !python {model: product.product}: |
+ product = self.browse(cr, uid, ref('stock_reserve.product_sorbet'), context=context)
+ assert product.virtual_available == 10, "Stock is not updated."
diff --git a/stock_reserve/view/product.xml b/stock_reserve/view/product.xml
new file mode 100644
index 000000000..e4e1dc3fb
--- /dev/null
+++ b/stock_reserve/view/product.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+ product.template.reservation.button
+ product.template
+
+
+
+
+
+
+
+
+
+ product.template.reservation.button
+ product.product
+
+
+
+
+
+
+
+
+
diff --git a/stock_reserve/view/stock_reserve.xml b/stock_reserve/view/stock_reserve.xml
new file mode 100644
index 000000000..7ee9cbdd3
--- /dev/null
+++ b/stock_reserve/view/stock_reserve.xml
@@ -0,0 +1,135 @@
+
+
+
+
+ stock.reservation.form
+ stock.reservation
+
+
+
+
+
+
+ stock.reservation.tree
+ stock.reservation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stock.reservation.search
+ stock.reservation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Stock Reservations
+ stock.reservation
+ ir.actions.act_window
+
+
+ {'search_default_draft': 1,
+ 'search_default_reserved': 1,
+ 'search_default_groupby_product': 1}
+
+
+ Click to create a stock reservation.
+
+ This menu allow you to prepare and reserve some quantities
+ of products.
+