From 21b12f83ad26d80855e619d0619a4d1dc35348ad Mon Sep 17 00:00:00 2001 From: Yannick Vaucher Date: Wed, 10 Sep 2014 10:12:05 +0200 Subject: [PATCH] reactivate module stock_reserve_sale --- 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 | 234 ++++++++++++++++++ stock_reserve_sale/model/stock_reserve.py | 42 ++++ stock_reserve_sale/test/sale_line_reserve.yml | 129 ++++++++++ stock_reserve_sale/test/sale_reserve.yml | 64 +++++ 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 | 103 ++++++++ .../wizard/sale_stock_reserve_view.xml | 44 ++++ 12 files changed, 846 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 000000000..f2bf938cb --- /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 000000000..7d9d2b9b0 --- /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', + ], + 'test': ['test/sale_reserve.yml', + 'test/sale_line_reserve.yml', + ], + 'installable': True, + 'auto_install': False, + } diff --git a/stock_reserve_sale/model/__init__.py b/stock_reserve_sale/model/__init__.py new file mode 100644 index 000000000..5c9fc5067 --- /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 000000000..1dd688a56 --- /dev/null +++ b/stock_reserve_sale/model/sale.py @@ -0,0 +1,234 @@ +# -*- 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 SaleOrder(models.Model): + _inherit = 'sale.order' + + @api.multi + @api.depends('state', + 'order_line.reservation_ids', + 'order_line.is_stock_reservable') + def _stock_reservation(self): + for sale in self: + has_stock_reservation = False + is_stock_reservable = False + for line in sale.order_line: + if line.reservation_ids: + has_stock_reservation = True + if line.is_stock_reservable: + is_stock_reservable = True + if sale.state not in ('draft', 'sent'): + is_stock_reservable = False + sale.is_stock_reservable = is_stock_reservable + sale.has_stock_reservation = has_stock_reservation + + has_stock_reservation = fields.Boolean( + compute='_stock_reservation', + readonly=True, + multi='stock_reservation', + store=True, + string='Has Stock Reservations') + is_stock_reservable = fields.Boolean( + compute='_stock_reservation', + readonly=True, + multi='stock_reservation', + store=True, + string='Can Have Stock Reservations') + + @api.multi + def release_all_stock_reservation(self): + line_ids = [line.id for order in self for line in order.order_line] + lines = self.env['sale.order.line'].browse(line_ids) + lines.release_stock_reservation() + return True + + @api.multi + def action_button_confirm(self): + self.release_all_stock_reservation() + return super(SaleOrder, self).action_button_confirm() + + @api.multi + def action_cancel(self): + self.release_all_stock_reservation() + return super(SaleOrder, self).action_cancel() + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + @api.multi + def _get_line_rule(self): + """ Get applicable rule for this product + + Reproduce get suitable rule from procurement + to predict source location """ + ProcurementRule = self.env['procurement.rule'] + product = self.product_id + product_route_ids = [x.id for x in product.route_ids + + product.categ_id.total_route_ids] + rules = ProcurementRule.search([('route_id', 'in', product_route_ids)], + order='route_sequence, sequence', + limit=1) + + if not rules: + warehouse = self.order_id.warehouse_id + wh_routes = warehouse.route_ids + wh_route_ids = [route.id for route in wh_routes] + domain = ['|', ('warehouse_id', '=', warehouse.id), + ('warehouse_id', '=', False), + ('route_id', 'in', wh_route_ids)] + + rules = ProcurementRule.search(domain, + order='route_sequence, sequence') + + if rules: + return rules[0] + return False + + @api.multi + def _get_procure_method(self): + """ Get procure_method depending on product routes """ + rule = self._get_line_rule() + if rule: + return rule.procure_method + return False + + @api.multi + @api.depends('state', + 'product_id.route_ids', + 'product_id.type') + def _is_stock_reservable(self): + for line in self: + reservable = False + if (not (line.state != 'draft' + or line._get_procure_method() == 'make_to_order' + or not line.product_id + or line.product_id.type == 'service') + and not line.reservation_ids): + reservable = True + line.is_stock_reservable = reservable + + reservation_ids = fields.One2many( + 'stock.reservation', + 'sale_line_id', + string='Stock Reservation', + copy=False) + is_stock_reservable = fields.Boolean( + compute='_is_stock_reservable', + readonly=True, + string='Can be reserved') + + @api.multi + def release_stock_reservation(self): + reserv_ids = [reserv.id for line in self + for reserv in line.reservation_ids] + reservations = self.env['stock.reservation'].browse(reserv_ids) + reservations.release() + 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(SaleOrderLine, 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 + + @api.multi + def write(self, vals): + 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: + if not line.reservation_ids: + continue + raise 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(SaleOrderLine, self).write(vals) + if test_update: + for line in self: + if not line.reservation_ids: + continue + if len(line.reservation_ids) > 1: + raise 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.write( + {'price_unit': line.price_unit, + 'product_uom_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 000000000..db68449bb --- /dev/null +++ b/stock_reserve_sale/model/stock_reserve.py @@ -0,0 +1,42 @@ +# -*- 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 StockReservation(models.Model): + _inherit = 'stock.reservation' + + sale_line_id = fields.Many2one( + 'sale.order.line', + string='Sale Order Line', + ondelete='cascade', + copy=False) + sale_id = fields.Many2one( + 'sale.order', + string='Sale Order', + related='sale_line_id.order_id') + + @api.multi + def release(self): + for rec in self: + rec.sale_line_id = False + return super(StockReservation, self).release() 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 000000000..421d5da2f --- /dev/null +++ b/stock_reserve_sale/test/sale_line_reserve.yml @@ -0,0 +1,129 @@ +- + 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 + 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." +- + I set product_12 to MTO (doesn't work) +- + !record {model: product.product, id: product.product_product_12}: + route_ids: + - stock.route_warehouse0_mto +- + I set MTO for real +- + !python {model: product.product}: | + product = self.browse(cr, uid, ref('product.product_product_12'), context=context) + self.write(cr, uid, ref('product.product_product_12'), + {'route_ids': [(6, False, [ref('stock.route_warehouse0_mto')])]}, context=context) +- + 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 + product_uom_qty: 4 + product_uom: product.product_uom_unit +- + 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 000000000..2671f40e1 --- /dev/null +++ b/stock_reserve_sale/test/sale_reserve.yml @@ -0,0 +1,64 @@ +- + 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 + 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 000000000..e5849b04c --- /dev/null +++ b/stock_reserve_sale/view/sale.xml @@ -0,0 +1,67 @@ + + + + + + sale.order.form.reserve + sale.order + + + + + + {"reload_on_button": 1} + + + + +