From c4c80197c98e153029754d097a19af2f90d519af Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 5 Sep 2013 17:35:11 +0200 Subject: [PATCH 01/26] [ADD] started stock_reserve_sale to reserve lines of quotations --- stock_reserve_sale/__init__.py | 23 +++ stock_reserve_sale/__openerp__.py | 65 +++++++ stock_reserve_sale/model/__init__.py | 23 +++ stock_reserve_sale/model/sale.py | 184 ++++++++++++++++++ stock_reserve_sale/model/stock_reserve.py | 50 +++++ stock_reserve_sale/test/sale_line_reserve.yml | 118 +++++++++++ stock_reserve_sale/test/sale_reserve.yml | 65 +++++++ stock_reserve_sale/view/sale.xml | 67 +++++++ stock_reserve_sale/view/stock_reserve.xml | 30 +++ stock_reserve_sale/wizard/__init__.py | 22 +++ .../wizard/sale_stock_reserve.py | 111 +++++++++++ .../wizard/sale_stock_reserve_view.xml | 44 +++++ 12 files changed, 802 insertions(+) create mode 100644 stock_reserve_sale/__init__.py create mode 100644 stock_reserve_sale/__openerp__.py create mode 100644 stock_reserve_sale/model/__init__.py create mode 100644 stock_reserve_sale/model/sale.py create mode 100644 stock_reserve_sale/model/stock_reserve.py create mode 100644 stock_reserve_sale/test/sale_line_reserve.yml create mode 100644 stock_reserve_sale/test/sale_reserve.yml create mode 100644 stock_reserve_sale/view/sale.xml create mode 100644 stock_reserve_sale/view/stock_reserve.xml create mode 100644 stock_reserve_sale/wizard/__init__.py create mode 100644 stock_reserve_sale/wizard/sale_stock_reserve.py create mode 100644 stock_reserve_sale/wizard/sale_stock_reserve_view.xml diff --git a/stock_reserve_sale/__init__.py b/stock_reserve_sale/__init__.py new file mode 100644 index 000000000000..f2bf938cb9a9 --- /dev/null +++ b/stock_reserve_sale/__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 model +from . import wizard diff --git a/stock_reserve_sale/__openerp__.py b/stock_reserve_sale/__openerp__.py new file mode 100644 index 000000000000..99522e98c60d --- /dev/null +++ b/stock_reserve_sale/__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 Reserve Sales', + 'version': '0.1', + 'author': 'Camptocamp', + 'category': 'Warehouse', + 'license': 'AGPL-3', + 'complexity': 'normal', + 'images': [], + 'website': "http://www.camptocamp.com", + 'description': """ +Stock Reserve Sales +=================== + +Allows to create stock reservations for quotation lines before the +confirmation of the quotation. The reservations might have a validity +date and in any case they are lifted when the quotation is canceled or +confirmed. + +Reservations can be done only on "make to stock" and stockable products. + +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. + +If you want to prevent sales orders to be confirmed when the stock is +insufficient at the order date, you may want to install the +`sale_exception_nostock` module. + +""", + 'depends': ['sale_stock', + 'stock_reserve', + ], + 'demo': [], + 'data': ['wizard/sale_stock_reserve_view.xml', + 'view/sale.xml', + 'view/stock_reserve.xml', + ], + 'auto_install': False, + 'test': ['test/sale_reserve.yml', + 'test/sale_line_reserve.yml', + ], + 'installable': True, + } diff --git a/stock_reserve_sale/model/__init__.py b/stock_reserve_sale/model/__init__.py new file mode 100644 index 000000000000..5c9fc50677a6 --- /dev/null +++ b/stock_reserve_sale/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 sale +from . import stock_reserve diff --git a/stock_reserve_sale/model/sale.py b/stock_reserve_sale/model/sale.py new file mode 100644 index 000000000000..71c3374eca53 --- /dev/null +++ b/stock_reserve_sale/model/sale.py @@ -0,0 +1,184 @@ +# -*- 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.osv import orm, fields +from openerp.tools.translate import _ + + +class sale_order(orm.Model): + _inherit = 'sale.order' + + def _stock_reservation(self, cr, uid, ids, fields, args, context=None): + result = {} + for order_id in ids: + result[order_id] = {'has_stock_reservation': False, + 'is_stock_reservable': False} + for sale in self.browse(cr, uid, ids, context=context): + for line in sale.order_line: + if line.reservation_ids: + result[sale.id]['has_stock_reservation'] = True + if line.is_stock_reservable: + result[sale.id]['is_stock_reservable'] = True + if sale.state not in ('draft', 'sent'): + result[sale.id]['is_stock_reservable'] = False + return result + + _columns = { + 'has_stock_reservation': fields.function( + _stock_reservation, + type='boolean', + readonly=True, + multi='stock_reservation', + string='Has Stock Reservations'), + 'is_stock_reservable': fields.function( + _stock_reservation, + type='boolean', + readonly=True, + multi='stock_reservation', + string='Can Have Stock Reservations'), + } + + def release_all_stock_reservation(self, cr, uid, ids, context=None): + sales = self.browse(cr, uid, ids, context=context) + line_ids = [line.id for sale in sales for line in sale.order_line] + line_obj = self.pool.get('sale.order.line') + line_obj.release_stock_reservation(cr, uid, line_ids, context=context) + return True + + def action_button_confirm(self, cr, uid, ids, context=None): + self.release_all_stock_reservation(cr, uid, ids, context=context) + return super(sale_order, self).action_button_confirm( + cr, uid, ids, context=context) + + def action_cancel(self, cr, uid, ids, context=None): + self.release_all_stock_reservation(cr, uid, ids, context=context) + return super(sale_order, self).action_cancel( + cr, uid, ids, context=context) + + +class sale_order_line(orm.Model): + _inherit = 'sale.order.line' + + def _is_stock_reservable(self, cr, uid, ids, fields, args, context=None): + result = {}.fromkeys(ids, False) + for line in self.browse(cr, uid, ids, context=context): + if line.state != 'draft': + continue + if line.type == 'make_to_order': + continue + if (not line.product_id or line.product_id.type == 'service'): + continue + if not line.reservation_ids: + result[line.id] = True + return result + + _columns = { + 'reservation_ids': fields.one2many( + 'stock.reservation', + 'sale_line_id', + string='Stock Reservation'), + 'is_stock_reservable': fields.function( + _is_stock_reservable, + type='boolean', + readonly=True, + string='Can be reserved'), + } + + def copy_data(self, cr, uid, id, default=None, context=None): + if default is None: + default = {} + default['reservation_ids'] = False + return super(sale_order_line, self).copy_data( + cr, uid, id, default=default, context=context) + + def release_stock_reservation(self, cr, uid, ids, context=None): + lines = self.browse(cr, uid, ids, context=context) + reserv_ids = [reserv.id for line in lines + for reserv in line.reservation_ids] + reserv_obj = self.pool.get('stock.reservation') + reserv_obj.release(cr, uid, reserv_ids, context=context) + return True + + 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): + result = 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) + if not ids: # warn only if we change an existing line + return result + assert len(ids) == 1, "Expected 1 ID, got %r" % ids + line = self.browse(cr, uid, ids[0], context=context) + if qty != line.product_uom_qty and line.reservation_ids: + msg = _("As you changed the quantity of the line, " + "the quantity of the stock reservation will " + "be automatically adjusted to %.2f.") % qty + msg += "\n\n" + result.setdefault('warning', {}) + if result['warning'].get('message'): + result['warning']['message'] += msg + else: + result['warning'] = { + 'title': _('Configuration Error!'), + 'message': msg, + } + return result + + def write(self, cr, uid, ids, vals, context=None): + block_on_reserve = ('product_id', 'product_uom', 'product_uos', + 'type') + update_on_reserve = ('price_unit', 'product_uom_qty', 'product_uos_qty') + keys = set(vals.keys()) + test_block = keys.intersection(block_on_reserve) + test_update = keys.intersection(update_on_reserve) + if test_block: + for line in self.browse(cr, uid, ids, context=context): + if not line.reservation_ids: + continue + raise orm.except_orm( + _('Error'), + _('You cannot change the product or unit of measure ' + 'of lines with a stock reservation. ' + 'Release the reservation ' + 'before changing the product.')) + res = super(sale_order_line, self).write(cr, uid, ids, vals, context=context) + if test_update: + for line in self.browse(cr, uid, ids, context=context): + if not line.reservation_ids: + continue + if len(line.reservation_ids) > 1: + raise orm.except_orm( + _('Error'), + _('Several stock reservations are linked with the ' + 'line. Impossible to adjust their quantity. ' + 'Please release the reservation ' + 'before changing the quantity.')) + + line.reservation_ids[0].write( + {'price_unit': line.price_unit, + 'product_qty': line.product_uom_qty, + 'product_uos_qty': line.product_uos_qty, + } + ) + return res diff --git a/stock_reserve_sale/model/stock_reserve.py b/stock_reserve_sale/model/stock_reserve.py new file mode 100644 index 000000000000..0ea5fb958599 --- /dev/null +++ b/stock_reserve_sale/model/stock_reserve.py @@ -0,0 +1,50 @@ +# -*- 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.osv import orm, fields + + +class stock_reservation(orm.Model): + _inherit = 'stock.reservation' + + _columns = { + 'sale_line_id': fields.many2one( + 'sale.order.line', + string='Sale Order Line', + ondelete='cascade'), + 'sale_id': fields.related( + 'sale_line_id', 'order_id', + type='many2one', + relation='sale.order', + string='Sale Order') + } + + def release(self, cr, uid, ids, context=None): + self.write(cr, uid, ids, {'sale_line_id': False}, context=context) + return super(stock_reservation, self).release( + cr, uid, ids, context=context) + + def copy_data(self, cr, uid, id, default=None, context=None): + if default is None: + default = {} + default['sale_line_id'] = False + return super(stock_reservation, self).copy_data( + cr, uid, id, default=default, context=context) diff --git a/stock_reserve_sale/test/sale_line_reserve.yml b/stock_reserve_sale/test/sale_line_reserve.yml new file mode 100644 index 000000000000..2dfbee6efede --- /dev/null +++ b/stock_reserve_sale/test/sale_line_reserve.yml @@ -0,0 +1,118 @@ +- + I create a product to test the stock reservation +- + !record {model: product.product, id: product_yogurt}: + default_code: 001yogurt + name: yogurt + 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 + procure_method: make_to_stock + valuation: real_time + cost_method: average + property_stock_account_input: account.o_expense + property_stock_account_output: account.o_income +- + I update the current stock of the yogurt with 10 kgm +- + !record {model: stock.change.product.qty, id: change_qty}: + new_quantity: 10 + product_id: product_yogurt +- + !python {model: stock.change.product.qty}: | + context['active_id'] = ref('product_yogurt') + self.change_product_qty(cr, uid, [ref('change_qty')], context=context) +- + In order to test reservation of the sales order, I create a sales order +- + !record {model: sale.order, id: sale_reserve_02}: + partner_id: base.res_partner_2 + payment_term: account.account_payment_term +- + And I create a sales order line +- + !record {model: sale.order.line, id: sale_line_reserve_02_01, view: sale.view_order_line_tree}: + name: Yogurt + product_id: product_yogurt + product_uom_qty: 4 + product_uom: product.product_uom_kgm + order_id: sale_reserve_02 +- + And I create a stock reserve for this line +- + !record {model: sale.stock.reserve, id: wizard_reserve_02_01}: + note: Reservation for the sales order line +- + I call the wizard to reserve the products of the sales order +- + !python {model: sale.stock.reserve}: | + active_id = ref('sale_line_reserve_02_01') + context['active_id'] = active_id + context['active_ids'] = [active_id] + context['active_model'] = 'sale.order.line' + self.button_reserve(cr, uid, [ref('wizard_reserve_02_01')], context=context) +- + I check Virtual stock of yogurt after update reservation +- + !python {model: product.product}: | + product = self.browse(cr, uid, ref('product_yogurt'), context=context) + assert product.virtual_available == 6, "Stock is not updated." +- + And I create a MTO sales order line +- + !record {model: sale.order.line, id: sale_line_reserve_02_02, view: sale.view_order_line_tree}: + order_id: sale_reserve_02 + name: Mouse, Wireless + product_id: product.product_product_12 + type: make_to_order + product_uom_qty: 4 + product_uom: product.product_uom_kgm +- + And I try to create a stock reserve for this MTO line +- + !record {model: sale.stock.reserve, id: wizard_reserve_02_02}: + note: Reservation for the sales order line +- + I call the wizard to reserve the products of the sales order +- + !python {model: sale.stock.reserve}: | + active_id = ref('sale_line_reserve_02_02') + context['active_id'] = active_id + context['active_ids'] = [active_id] + context['active_model'] = 'sale.order.line' + self.button_reserve(cr, uid, [ref('wizard_reserve_02_02')], context=context) +- + I should not have a stock reservation for a MTO line +- + !python {model: stock.reservation}: | + reserv_ids = self.search( + cr, uid, + [('sale_line_id', '=', ref('sale_line_reserve_02_02'))], + context=context) + assert not reserv_ids, "No stock reservation should be created for MTO lines" +- + And I change the quantity in the first line +- + !record {model: sale.order.line, id: sale_line_reserve_02_01, view: sale.view_order_line_tree}: + product_uom_qty: 5 +- + + I check Virtual stock of yogurt after change of reservations +- + !python {model: product.product}: | + product = self.browse(cr, uid, ref('product_yogurt'), context=context) + assert product.virtual_available == 5, "Stock is not updated." +- + I release the sales order's reservations for the first line +- + !python {model: sale.order.line}: | + self.release_stock_reservation(cr, uid, [ref('sale_line_reserve_02_01')], context=context) +- + I check Virtual stock of yogurt after release of reservations +- + !python {model: product.product}: | + product = self.browse(cr, uid, ref('product_yogurt'), context=context) + assert product.virtual_available == 10, "Stock is not updated." diff --git a/stock_reserve_sale/test/sale_reserve.yml b/stock_reserve_sale/test/sale_reserve.yml new file mode 100644 index 000000000000..7fc9dff6d7c0 --- /dev/null +++ b/stock_reserve_sale/test/sale_reserve.yml @@ -0,0 +1,65 @@ +- + I create a product to test the stock reservation +- + !record {model: product.product, id: product_gelato}: + default_code: 001GELATO + name: Gelato + 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 + procure_method: make_to_stock + valuation: real_time + cost_method: average + property_stock_account_input: account.o_expense + property_stock_account_output: account.o_income +- + I update the current stock of the Gelato with 10 kgm +- + !record {model: stock.change.product.qty, id: change_qty}: + new_quantity: 10 + product_id: product_gelato +- + !python {model: stock.change.product.qty}: | + context['active_id'] = ref('product_gelato') + self.change_product_qty(cr, uid, [ref('change_qty')], context=context) +- + In order to test reservation of the sales order, I create a sales order +- + !record {model: sale.order, id: sale_reserve_01}: + partner_id: base.res_partner_2 + payment_term: account.account_payment_term + order_line: + - product_id: product_gelato + product_uom_qty: 4 +- + I call the wizard to reserve the products of the sales order +- + !record {model: sale.stock.reserve, id: wizard_reserve_01}: + note: Reservation for the sales order +- + !python {model: sale.stock.reserve}: | + active_id = ref('sale_reserve_01') + context['active_id'] = active_id + context['active_ids'] = [active_id] + context['active_model'] = 'sale.order' + self.button_reserve(cr, uid, [ref('wizard_reserve_01')], context=context) +- + I check Virtual stock of Gelato after update reservation +- + !python {model: product.product}: | + product = self.browse(cr, uid, ref('product_gelato'), context=context) + assert product.virtual_available == 6, "Stock is not updated." +- + I release the sales order's reservations +- + !python {model: sale.order}: | + self.release_all_stock_reservation(cr, uid, [ref('sale_reserve_01')], context=context) +- + I check Virtual stock of Gelato after release of reservations +- + !python {model: product.product}: | + product = self.browse(cr, uid, ref('product_gelato'), context=context) + assert product.virtual_available == 10, "Stock is not updated." diff --git a/stock_reserve_sale/view/sale.xml b/stock_reserve_sale/view/sale.xml new file mode 100644 index 000000000000..e5849b04c166 --- /dev/null +++ b/stock_reserve_sale/view/sale.xml @@ -0,0 +1,67 @@ + + + + + + sale.order.form.reserve + sale.order + + + + + + {"reload_on_button": 1} + + + + + - - - {"reload_on_button": 1} - - - - - + + + {"reload_on_button": 1} + + + + + - {"reload_on_button": 1} - - - - - - {"reload_on_button": 1} - - - - + + {"reload_on_button": 1} + + + +