diff --git a/addons/purchase_requisition/views/purchase_requisition_views.xml b/addons/purchase_requisition/views/purchase_requisition_views.xml index d0bb39927dc..01d90b20a6f 100644 --- a/addons/purchase_requisition/views/purchase_requisition_views.xml +++ b/addons/purchase_requisition/views/purchase_requisition_views.xml @@ -337,7 +337,7 @@ - + diff --git a/addons/sale_purchase/__init__.py b/addons/sale_purchase/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/sale_purchase/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/sale_purchase/__manifest__.py b/addons/sale_purchase/__manifest__.py new file mode 100644 index 00000000000..7fcf2bb81e5 --- /dev/null +++ b/addons/sale_purchase/__manifest__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Sale Purchase', + 'summary': 'Sale based on service outsourcing.', + 'description': """ +Allows the outsourcing of services. This module allows one to sell services provided +by external providers and will automatically generate purchase orders directed to the service seller. + """, + 'version': '1.0', + 'website': 'https://www.odoo.com/', + 'category': 'Hidden', + 'depends': [ + 'sale', + 'purchase', + ], + 'data': [ + 'data/mail_data.xml', + 'views/product_views.xml', + 'views/sale_views.xml', + ], + 'demo': [ + ], + 'auto_install': True, +} diff --git a/addons/sale_purchase/data/mail_data.xml b/addons/sale_purchase/data/mail_data.xml new file mode 100644 index 00000000000..1531720fef8 --- /dev/null +++ b/addons/sale_purchase/data/mail_data.xml @@ -0,0 +1,68 @@ + + + + + + + + + + diff --git a/addons/sale_purchase/models/__init__.py b/addons/sale_purchase/models/__init__.py new file mode 100644 index 00000000000..99593ce83f2 --- /dev/null +++ b/addons/sale_purchase/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import product_template +from . import sale_order +from . import purchase_order diff --git a/addons/sale_purchase/models/product_template.py b/addons/sale_purchase/models/product_template.py new file mode 100644 index 00000000000..bae3bd1819c --- /dev/null +++ b/addons/sale_purchase/models/product_template.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + service_to_purchase = fields.Boolean("Purchase Automatically", help="If ticked, each time you sell this product through a SO, a RfQ is automatically created to buy the product. Tip: don't forget to set a vendor on the product.") + + _sql_constraints = [ + ('service_to_purchase', "CHECK((type != 'service' AND service_to_purchase != true) or (type = 'service'))", 'Product that is not a service can not create RFQ.'), + ] + + @api.onchange('type') + def _onchange_product_type(self): + if self.type != 'service': + self.service_to_purchase = False + + @api.onchange('expense_policy') + def _onchange_expense_policy(self): + if self.expense_policy != 'no': + self.service_to_purchase = False diff --git a/addons/sale_purchase/models/purchase_order.py b/addons/sale_purchase/models/purchase_order.py new file mode 100644 index 00000000000..6ec184055f7 --- /dev/null +++ b/addons/sale_purchase/models/purchase_order.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + @api.multi + def button_cancel(self): + result = super(PurchaseOrder, self).button_cancel() + self.sudo()._activity_cancel_on_sale() + return result + + @api.multi + def _activity_cancel_on_sale(self): + """ If some PO are cancelled, we need to put an activity on their origin SO (only the open ones). Since a PO can have + been modified by several SO, when cancelling one PO, many next activities can be schedulded on different SO. + """ + sale_to_notify_map = {} # map SO -> recordset of PO as {sale.order: set(purchase.order.line)} + for order in self: + for purchase_line in order.order_line: + if purchase_line.sale_line_id: + sale_order = purchase_line.sale_line_id.order_id + sale_to_notify_map.setdefault(sale_order, self.env['purchase.order.line']) + sale_to_notify_map[sale_order] |= purchase_line + + for sale_order, purchase_order_lines in sale_to_notify_map.items(): + sale_order.activity_schedule_with_view('mail.mail_activity_data_warning', fields.Datetime.now(), + user_id=sale_order.user_id.id or self.env.uid, + views_or_xmlid='sale_purchase.exception_sale_on_purchase_cancellation', + render_context={ + 'purchase_orders': purchase_order_lines.mapped('order_id'), + 'purchase_lines': purchase_order_lines, + }) + + +class PurchaseOrderLine(models.Model): + _inherit = 'purchase.order.line' + + sale_order_id = fields.Many2one(related='sale_line_id.order_id', string="Sale Order", store=True, readonly=True) + sale_line_id = fields.Many2one('sale.order.line', string="Origin Sale Item", index=True) diff --git a/addons/sale_purchase/models/sale_order.py b/addons/sale_purchase/models/sale_order.py new file mode 100644 index 00000000000..ebd4895c7c4 --- /dev/null +++ b/addons/sale_purchase/models/sale_order.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import float_compare + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + purchase_order_count = fields.Integer("Number of Purchase Order", compute='_compute_purchase_order_count', groups='purchase.group_purchase_user') + + @api.depends('order_line.purchase_line_ids') + def _compute_purchase_order_count(self): + purchase_line_data = self.env['purchase.order.line'].read_group( + [('sale_order_id', 'in', self.ids)], + ['sale_order_id', 'purchase_order_count:count_distinct(order_id)'], ['sale_order_id'] + ) + purchase_count_map = {item['sale_order_id'][0]: item['purchase_order_count'] for item in purchase_line_data} + for order in self: + order.purchase_order_count = purchase_count_map.get(order.id, 0) + + @api.multi + def _action_confirm(self): + result = super(SaleOrder, self)._action_confirm() + for order in self: + order.order_line.sudo()._purchase_service_generation() + return result + + @api.multi + def action_cancel(self): + result = super(SaleOrder, self).action_cancel() + # When a sale person cancel a SO, he might not have the rights to write + # on PO. But we need the system to create an activity on the PO (so 'write' + # access), hence the `sudo`. + self.sudo()._activity_cancel_on_purchase() + return result + + @api.multi + def action_view_purchase(self): + action = self.env.ref('purchase.purchase_rfq').read()[0] + action['domain'] = [('id', 'in', self.mapped('order_line.purchase_line_ids.order_id').ids)] + return action + + @api.multi + def _activity_cancel_on_purchase(self): + """ If some SO are cancelled, we need to put an activity on their generated purchase. If sale lines of + differents sale orders impact different purchase, we only want one activity to be attached. + """ + purchase_to_notify_map = {} # map PO -> recordset of SOL as {purchase.order: set(sale.orde.liner)} + + purchase_order_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.mapped('order_line').ids), ('state', '!=', 'cancel')]) + for purchase_line in purchase_order_lines: + purchase_to_notify_map.setdefault(purchase_line.order_id, self.env['sale.order.line']) + purchase_to_notify_map[purchase_line.order_id] |= purchase_line.sale_line_id + + for purchase_order, sale_order_lines in purchase_to_notify_map.items(): + purchase_order.activity_schedule_with_view('mail.mail_activity_data_warning', fields.Datetime.now(), + user_id=purchase_order.user_id.id or self.env.uid, + views_or_xmlid='sale_purchase.exception_purchase_on_sale_cancellation', + render_context={ + 'sale_orders': sale_order_lines.mapped('order_id'), + 'sale_order_lines': sale_order_lines, + }) + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + purchase_line_ids = fields.One2many('purchase.order.line', 'sale_line_id', string="Generated Purchase Lines", readonly=True, help="Purchase line generated by this Sales item on order confirmation, or when the quantity was increased.") + purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count', compute_sudo=True) + + @api.multi + @api.depends('purchase_line_ids') + def _compute_purchase_count(self): + database_data = self.env['purchase.order.line'].read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id']) + mapped_data = dict([(db['sale_line_id'][0], db['sale_line_id_count']) for db in database_data]) + for line in self: + line.purchase_line_count = mapped_data.get(line.id, 0) + + @api.onchange('product_uom_qty') + def _onchange_service_product_uom_qty(self): + if self.state == 'sale' and self.product_id.type == 'service' and self.product_id.service_to_purchase: + if self.product_uom_qty < self._origin.product_uom_qty: + if self.product_uom_qty < self.qty_delivered: + return {} + warning_mess = { + 'title': _('Ordered quantity decreased!'), + 'message': _('You are decreasing the ordered quantity! Do not forget to manually update the purchase order if needed.'), + } + return {'warning': warning_mess} + return {} + + # -------------------------- + # CRUD + # -------------------------- + + @api.model + def create(self, values): + line = super(SaleOrderLine, self).create(values) + # Do not generate purchase when expense SO line since the product is already delivered + if line.state == 'sale' and not line.is_expense: + line.sudo()._purchase_service_generation() + return line + + @api.multi + def write(self, values): + increased_lines = None + decreased_lines = None + increased_values = {} + decreased_values = {} + if 'product_uom_qty' in values: + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + increased_lines = self.sudo().filtered(lambda r: r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1) + decreased_lines = self.sudo().filtered(lambda r: r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == 1) + increased_values = {line.id: line.product_uom_qty for line in increased_lines} + decreased_values = {line.id: line.product_uom_qty for line in decreased_lines} + + result = super(SaleOrderLine, self).write(values) + + if increased_lines: + increased_lines._purchase_increase_ordered_qty(values['product_uom_qty'], increased_values) + if decreased_lines: + decreased_lines._purchase_decrease_ordered_qty(values['product_uom_qty'], decreased_values) + return result + + # -------------------------- + # Business Methods + # -------------------------- + + @api.multi + def _purchase_decrease_ordered_qty(self, new_qty, origin_values): + """ Decrease the quantity from SO line will add a next acitivities on the related purchase order + :param new_qty: new quantity (lower than the current one on SO line), expressed + in UoM of SO line. + :param origin_values: map from sale line id to old value for the ordered quantity (dict) + """ + purchase_to_notify_map = {} # map PO -> set(SOL) + last_purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.ids)]) + for purchase_line in last_purchase_lines: + purchase_to_notify_map.setdefault(purchase_line.order_id, self.env['sale.order.line']) + purchase_to_notify_map[purchase_line.order_id] |= purchase_line.sale_line_id + + # create next activity + for purchase_order, sale_lines in purchase_to_notify_map.items(): + render_context = { + 'sale_lines': sale_lines, + 'sale_orders': sale_lines.mapped('order_id'), + 'origin_values': origin_values, + } + purchase_order.activity_schedule_with_view('mail.mail_activity_data_warning', fields.Datetime.now(), + user_id=purchase_order.user_id.id or self.env.uid, + views_or_xmlid='sale_purchase.exception_purchase_on_sale_quantity_decreased', + render_context=render_context) + + @api.multi + def _purchase_increase_ordered_qty(self, new_qty, origin_values): + """ Increase the quantity on the related purchase lines + :param new_qty: new quantity (higher than the current one on SO line), expressed + in UoM of SO line. + :param origin_values: map from sale line id to old value for the ordered quantity (dict) + """ + for line in self: + last_purchase_line = self.env['purchase.order.line'].search([('sale_line_id', '=', line.id)], order='create_date DESC', limit=1) + if last_purchase_line.state in ['draft', 'sent', 'to approve']: # update qty for draft PO lines + quantity = line.product_uom._compute_quantity(new_qty, last_purchase_line.product_uom) + last_purchase_line.write({'product_qty': quantity}) + elif last_purchase_line.state in ['purchase', 'done', 'cancel']: # create new PO, by forcing the quantity as the difference from SO line + quantity = line.product_uom._compute_quantity(new_qty - origin_values.get(line.id, 0.0), last_purchase_line.product_uom) + line._purchase_service_create(quantity=quantity) + + @api.multi + def _purchase_get_date_order(self, supplierinfo): + """ return the ordered date for the purchase order, computed as : SO commitment date - supplier delay """ + commitment_date = fields.Datetime.from_string(self.order_id.commitment_date or fields.Datetime.now()) + return commitment_date - relativedelta(days=int(supplierinfo.delay)) + + @api.multi + def _purchase_service_prepare_order_values(self, supplierinfo): + """ Returns the values to create the purchase order from the current SO line. + :param supplierinfo: record of product.supplierinfo + :rtype: dict + """ + self.ensure_one() + partner_supplier = supplierinfo.name + fiscal_position_id = self.env['account.fiscal.position'].sudo().with_context(company_id=self.company_id.id).get_fiscal_position(partner_supplier.id) + date_order = self._purchase_get_date_order(supplierinfo) + return { + 'partner_id': partner_supplier.id, + 'partner_ref': partner_supplier.ref, + 'company_id': self.company_id.id, + 'currency_id': partner_supplier.property_purchase_currency_id.id or self.env.user.company_id.currency_id.id, + 'dest_address_id': self.order_id.partner_shipping_id.id, + 'origin': self.order_id.name, + 'payment_term_id': partner_supplier.property_supplier_payment_term_id.id, + 'date_order': date_order, + 'fiscal_position_id': fiscal_position_id, + } + + @api.multi + def _purchase_service_prepare_line_values(self, purchase_order, quantity=False): + """ Returns the values to create the purchase order line from the current SO line. + :param purchase_order: record of purchase.order + :rtype: dict + :param quantity: the quantity to force on the PO line, expressed in SO line UoM + """ + self.ensure_one() + # compute quantity from SO line UoM + product_quantity = self.product_uom_qty + if quantity: + product_quantity = quantity + + purchase_qty_uom = self.product_uom._compute_quantity(product_quantity, self.product_id.uom_po_id) + + # determine vendor (real supplier, sharing the same partner as the one from the PO, but with more accurate informations like validity, quantity, ...) + # Note: one partner can have multiple supplier info for the same product + supplierinfo = self.product_id._select_seller( + partner_id=purchase_order.partner_id, + quantity=purchase_qty_uom, + date=purchase_order.date_order, # and purchase_order.date_order[:10], + uom_id=self.product_id.uom_po_id + ) + fpos = purchase_order.fiscal_position_id + taxes = fpos.map_tax(self.product_id.supplier_taxes_id) if fpos else self.product_id.supplier_taxes_id + if taxes: + taxes = taxes.filtered(lambda t: t.company_id.id == self.company_id.id) + + # compute unit price + price_unit = 0.0 + if supplierinfo: + price_unit = self.env['account.tax'].sudo()._fix_tax_included_price_company(supplierinfo.price, self.product_id.supplier_taxes_id, taxes, self.company_id) + if purchase_order.currency_id and supplierinfo.currency_id != purchase_order.currency_id: + price_unit = supplierinfo.currency_id.compute(price_unit, purchase_order.currency_id) + + # purchase line description in supplier lang + product_in_supplier_lang = self.product_id.with_context({ + 'lang': supplierinfo.name.lang, + 'partner_id': supplierinfo.name.id, + }) + name = '[%s] %s' % (self.product_id.default_code, product_in_supplier_lang.display_name) + if product_in_supplier_lang.description_purchase: + name += '\n' + product_in_supplier_lang.description_purchase + + return { + 'name': '[%s] %s' % (self.product_id.default_code, self.name) if self.product_id.default_code else self.name, + 'product_qty': purchase_qty_uom, + 'product_id': self.product_id.id, + 'product_uom': self.product_id.uom_po_id.id, + 'price_unit': price_unit, + 'date_planned': fields.Date.from_string(purchase_order.date_order) + relativedelta(days=int(supplierinfo.delay)), + 'taxes_id': [(6, 0, taxes.ids)], + 'order_id': purchase_order.id, + 'sale_line_id': self.id, + } + + @api.multi + def _purchase_service_create(self, quantity=False): + """ On Sales Order confirmation, some lines (services ones) can create a purchase order line and maybe a purchase order. + If a line should create a RFQ, it will check for existing PO. If no one is find, the SO line will create one, then adds + a new PO line. The created purchase order line will be linked to the SO line. + :param quantity: the quantity to force on the PO line, expressed in SO line UoM + """ + PurchaseOrder = self.env['purchase.order'] + supplier_po_map = {} + sale_line_purchase_map = {} + for line in self: + # determine vendor of the order (take the first matching company and product) + suppliers = line.product_id.seller_ids.filtered(lambda vendor: (not vendor.company_id or vendor.company_id == line.company_id) and (not vendor.product_id or vendor.product_id == line.product_id)) + if not suppliers: + raise UserError(_("There is no vendor associated to the product %s. Please define a vendor for this product.") % (line.product_id.display_name,)) + supplierinfo = suppliers[0] + partner_supplier = supplierinfo.name # yes, this field is not explicit .... it is a res.partner ! + + # determine (or create) PO + purchase_order = supplier_po_map.get(partner_supplier.id) + if not purchase_order: + purchase_order = PurchaseOrder.search([ + ('partner_id', '=', partner_supplier.id), + ('state', '=', 'draft'), + ('company_id', '=', line.company_id.id), + ], limit=1) + if not purchase_order: + values = line._purchase_service_prepare_order_values(supplierinfo) + purchase_order = PurchaseOrder.create(values) + else: # update origin of existing PO + so_name = line.order_id.name + origins = [] + if purchase_order.origin: + origins = purchase_order.origin.split(', ') + origins + if so_name not in origins: + origins += [so_name] + purchase_order.write({ + 'origin': ', '.join(origins) + }) + supplier_po_map[partner_supplier.id] = purchase_order + + # add a PO line to the PO + values = line._purchase_service_prepare_line_values(purchase_order, quantity=quantity) + purchase_line = self.env['purchase.order.line'].create(values) + + # link the generated purchase to the SO line + sale_line_purchase_map.setdefault(line, self.env['purchase.order.line']) + sale_line_purchase_map[line] |= purchase_line + return sale_line_purchase_map + + @api.multi + def _purchase_service_generation(self): + """ Create a Purchase for the first time from the sale line. If the SO line already created a PO, it + will not create a second one. + """ + sale_line_purchase_map = {} + for line in self: + # Do not regenerate PO line if the SO line has already created one in the past (SO cancel/reconfirmation case) + if line.product_id.service_to_purchase and not line.purchase_line_count: + result = line._purchase_service_create() + sale_line_purchase_map.update(result) + return sale_line_purchase_map diff --git a/addons/sale_purchase/tests/__init__.py b/addons/sale_purchase/tests/__init__.py new file mode 100644 index 00000000000..f45d517b810 --- /dev/null +++ b/addons/sale_purchase/tests/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_access_rights +from . import test_sale_purchase diff --git a/addons/sale_purchase/tests/common.py b/addons/sale_purchase/tests/common.py new file mode 100644 index 00000000000..6012de38181 --- /dev/null +++ b/addons/sale_purchase/tests/common.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.sale.tests.test_sale_common import TestCommonSaleNoChart + + +class TestCommonSalePurchaseNoChart(TestCommonSaleNoChart): + + @classmethod + def setUpUsers(cls): + super(TestCommonSalePurchaseNoChart, cls).setUpUsers() + + group_salemanager = cls.env.ref('sales_team.group_sale_manager') + group_salesman = cls.env.ref('sales_team.group_sale_salesman') + cls.user_manager.write({'groups_id': [(6, 0, [group_salemanager.id])]}) + cls.user_employee.write({'groups_id': [(6, 0, [group_salesman.id])]}) + + @classmethod + def setUpServicePurchaseProducts(cls): + uom_unit = cls.env.ref('uom.product_uom_unit') + uom_dozen = cls.env.ref('uom.product_uom_dozen') + + user_type_income = cls.env.ref('account.data_account_type_direct_costs') + cls.account_income_product_purchase = cls.env['account.account'].create({ + 'code': 'INCOME_PROD_PURCHASE', + 'name': 'Icome - Test Account', + 'user_type_id': user_type_income.id, + }) + # Create category + cls.product_category_purchase = cls.env['product.category'].create({ + 'name': 'Product Category with Income account', + 'property_account_income_categ_id': cls.account_income_product_purchase.id + }) + + cls.partner_vendor_service = cls.env['res.partner'].create({ + 'name': 'Super Service Supplier', + 'email': 'supplier.serv@supercompany.com', + 'supplier': True, + }) + + cls.service_purchase_1 = cls.env['product.product'].create({ + 'name': "Out-sourced Service 1", + 'standard_price': 200.0, + 'list_price': 180.0, + 'type': 'service', + 'uom_id': uom_unit.id, + 'uom_po_id': uom_unit.id, + 'invoice_policy': 'delivery', + 'expense_policy': 'no', + 'default_code': 'SERV_DEL', + 'service_type': 'manual', + 'taxes_id': False, + 'categ_id': cls.product_category_purchase.id, + 'service_to_purchase': True, + }) + cls.service_purchase_2 = cls.env['product.product'].create({ + 'name': "Out-sourced Service 2", + 'standard_price': 20.0, + 'list_price': 15.0, + 'type': 'service', + 'uom_id': uom_dozen.id, # different UoM + 'uom_po_id': uom_unit.id, + 'invoice_policy': 'order', + 'expense_policy': 'no', + 'default_code': 'SERV_ORD', + 'service_type': 'manual', + 'taxes_id': False, + 'categ_id': cls.product_category_purchase.id, + 'service_to_purchase': True, + }) + + cls.supplierinfo1 = cls.env['product.supplierinfo'].create({ + 'name': cls.partner_vendor_service.id, + 'price': 100, + 'product_tmpl_id': cls.service_purchase_1.product_tmpl_id.id, + 'delay': 1, + }) + cls.supplierinfo2 = cls.env['product.supplierinfo'].create({ + 'name': cls.partner_vendor_service.id, + 'price': 10, + 'product_tmpl_id': cls.service_purchase_2.product_tmpl_id.id, + 'delay': 5, + }) diff --git a/addons/sale_purchase/tests/test_access_rights.py b/addons/sale_purchase/tests/test_access_rights.py new file mode 100644 index 00000000000..0757d81451f --- /dev/null +++ b/addons/sale_purchase/tests/test_access_rights.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.exceptions import AccessError +from odoo.addons.sale_purchase.tests.common import TestCommonSalePurchaseNoChart + + +class TestAccessRights(TestCommonSalePurchaseNoChart): + + @classmethod + def setUpClass(cls): + super(TestAccessRights, cls).setUpClass() + + cls.setUpServicePurchaseProducts() + + # Create a users + group_sale_user = cls.env.ref('sales_team.group_sale_salesman') + group_purchase_user = cls.env.ref('purchase.group_purchase_user') + cls.user_salesperson = cls.env['res.users'].with_context(no_reset_password=True).create({ + 'name': 'Le Grand Jojo User', + 'login': 'grand.jojo', + 'email': 'grand.jojo@chansonbelge.com', + 'groups_id': [(6, 0, [group_sale_user.id])] + }) + cls.user_purchaseperson = cls.env['res.users'].with_context(no_reset_password=True).create({ + 'name': 'Jean-Luc Fonck', + 'login': 'jl.fonck', + 'email': 'jl.fonck@chansonbelge.com', + 'groups_id': [(6, 0, [group_purchase_user.id])] + }) + + def test_access_saleperson(self): + """ Check a saleperson (only) can generate a PO and a PO user can not confirm a SO """ + SaleOrder = self.env['sale.order'].with_context(tracking_disable=True) + + sale_order = SaleOrder.sudo(user=self.user_salesperson).create({ + 'partner_id': self.partner_customer_usd.id, + 'user_id': self.user_salesperson.id + }) + + sol_service_purchase = self.env['sale.order.line'].sudo(user=self.user_salesperson).create({ + 'name': self.service_purchase_1.name, + 'product_id': self.service_purchase_1.id, + 'product_uom_qty': 4, + 'product_uom': self.service_purchase_1.uom_id.id, + 'price_unit': self.service_purchase_1.list_price, + 'order_id': sale_order.id, + 'tax_id': False, + }) + + # confirming SO will create the PO even if you don't have the rights + sale_order.action_confirm() + sale_order.action_cancel() + + self.assertTrue(sale_order.name, "Saleperson can read its own SO") + + action = sale_order.sudo().action_view_purchase() + + # try to access PO as sale person + with self.assertRaises(AccessError): + purchase_orders = self.env['purchase.order'].sudo(user=self.user_salesperson).search(action['domain']) + purchase_orders.read() + + # try to access PO as purchase person + purchase_orders = self.env['purchase.order'].sudo(user=self.user_purchaseperson).search(action['domain']) + purchase_orders.read() + + # try to access the PO lines from the SO, as sale person + with self.assertRaises(AccessError): + sol_service_purchase.sudo(user=self.user_salesperson).purchase_line_ids.read() diff --git a/addons/sale_purchase/tests/test_sale_purchase.py b/addons/sale_purchase/tests/test_sale_purchase.py new file mode 100644 index 00000000000..99a37a3ad95 --- /dev/null +++ b/addons/sale_purchase/tests/test_sale_purchase.py @@ -0,0 +1,257 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.exceptions import UserError, AccessError + +from odoo.addons.sale_purchase.tests.common import TestCommonSalePurchaseNoChart + + +class TestSalePurchase(TestCommonSalePurchaseNoChart): + + @classmethod + def setUpClass(cls): + super(TestSalePurchase, cls).setUpClass() + + # set up users + cls.setUpUsers() + group_salemanager = cls.env.ref('sales_team.group_sale_manager') + group_salesman = cls.env.ref('sales_team.group_sale_salesman') + cls.user_manager.write({'groups_id': [(6, 0, [group_salemanager.id])]}) + cls.user_employee.write({'groups_id': [(6, 0, [group_salesman.id])]}) + + # set up accounts and products and journals + cls.setUpAdditionalAccounts() + cls.setUpAccountJournal() + cls.setUpClassicProducts() + cls.setUpServicePurchaseProducts() + + # create a generic Sale Order with 2 classical products and a purchase service + SaleOrder = cls.env['sale.order'].with_context(tracking_disable=True) + cls.sale_order_1 = SaleOrder.create({ + 'partner_id': cls.partner_customer_usd.id, + 'partner_invoice_id': cls.partner_customer_usd.id, + 'partner_shipping_id': cls.partner_customer_usd.id, + 'pricelist_id': cls.pricelist_usd.id, + }) + cls.sol1_service_deliver = cls.env['sale.order.line'].create({ + 'name': cls.service_deliver.name, + 'product_id': cls.service_deliver.id, + 'product_uom_qty': 1, + 'product_uom': cls.service_deliver.uom_id.id, + 'price_unit': cls.service_deliver.list_price, + 'order_id': cls.sale_order_1.id, + 'tax_id': False, + }) + cls.sol1_product_order = cls.env['sale.order.line'].create({ + 'name': cls.product_order.name, + 'product_id': cls.product_order.id, + 'product_uom_qty': 2, + 'product_uom': cls.product_order.uom_id.id, + 'price_unit': cls.product_order.list_price, + 'order_id': cls.sale_order_1.id, + 'tax_id': False, + }) + cls.sol1_service_purchase_1 = cls.env['sale.order.line'].create({ + 'name': cls.service_purchase_1.name, + 'product_id': cls.service_purchase_1.id, + 'product_uom_qty': 4, + 'product_uom': cls.service_purchase_1.uom_id.id, + 'price_unit': cls.service_purchase_1.list_price, + 'order_id': cls.sale_order_1.id, + 'tax_id': False, + }) + + cls.sale_order_2 = SaleOrder.create({ + 'partner_id': cls.partner_customer_usd.id, + 'partner_invoice_id': cls.partner_customer_usd.id, + 'partner_shipping_id': cls.partner_customer_usd.id, + 'pricelist_id': cls.pricelist_usd.id, + }) + cls.sol2_product_deliver = cls.env['sale.order.line'].create({ + 'name': cls.product_deliver.name, + 'product_id': cls.product_deliver.id, + 'product_uom_qty': 5, + 'product_uom': cls.product_deliver.uom_id.id, + 'price_unit': cls.product_deliver.list_price, + 'order_id': cls.sale_order_2.id, + 'tax_id': False, + }) + cls.sol2_service_order = cls.env['sale.order.line'].create({ + 'name': cls.service_order.name, + 'product_id': cls.service_order.id, + 'product_uom_qty': 6, + 'product_uom': cls.service_order.uom_id.id, + 'price_unit': cls.service_order.list_price, + 'order_id': cls.sale_order_2.id, + 'tax_id': False, + }) + cls.sol2_service_purchase_2 = cls.env['sale.order.line'].create({ + 'name': cls.service_purchase_2.name, + 'product_id': cls.service_purchase_2.id, + 'product_uom_qty': 7, + 'product_uom': cls.service_purchase_2.uom_id.id, + 'price_unit': cls.service_purchase_2.list_price, + 'order_id': cls.sale_order_2.id, + 'tax_id': False, + }) + + def test_sale_create_purchase(self): + """ Confirming 2 sales orders with a service that should create a PO, then cancelling the PO should shedule 1 next activity per SO """ + self.sale_order_1.action_confirm() + self.sale_order_2.action_confirm() + + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.supplierinfo1.name.id), ('state', '=', 'draft')]) + purchase_lines_so1 = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.sale_order_1.order_line.ids)]) + purchase_line1 = purchase_lines_so1[0] + + purchase_lines_so2 = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.sale_order_2.order_line.ids)]) + purchase_line2 = purchase_lines_so2[0] + + self.assertEqual(len(purchase_order), 1, "Only one PO should have been created, from the 2 Sales orders") + self.assertEqual(len(purchase_order.order_line), 2, "The purchase order should have 2 lines") + self.assertEqual(len(purchase_lines_so1), 1, "Only one SO line from SO 1 should have create a PO line") + self.assertEqual(len(purchase_lines_so2), 1, "Only one SO line from SO 2 should have create a PO line") + self.assertEqual(len(purchase_order.activity_ids), 0, "No activity should be scheduled on the PO") + self.assertEqual(purchase_order.state, 'draft', "The created PO should be in draft state") + + self.assertNotEqual(purchase_line1.product_id, purchase_line2.product_id, "The 2 PO line should have different products") + self.assertEqual(purchase_line1.product_id, self.sol1_service_purchase_1.product_id, "The create PO line must have the same product as its mother SO line") + self.assertEqual(purchase_line2.product_id, self.sol2_service_purchase_2.product_id, "The create PO line must have the same product as its mother SO line") + + purchase_order.button_cancel() + + self.assertEqual(len(self.sale_order_1.activity_ids), 1, "One activity should be scheduled on the SO 1 since the PO has been cancelled") + self.assertEqual(self.sale_order_1.user_id, self.sale_order_1.activity_ids[0].user_id, "The activity should be assigned to the SO responsible") + + self.assertEqual(len(self.sale_order_2.activity_ids), 1, "One activity should be scheduled on the SO 2 since the PO has been cancelled") + self.assertEqual(self.sale_order_2.user_id, self.sale_order_2.activity_ids[0].user_id, "The activity should be assigned to the SO responsible") + + def test_uom_conversion(self): + """ Test generated PO use the right UoM according to product configuration """ + self.sale_order_2.action_confirm() + purchase_line = self.env['purchase.order.line'].search([('sale_line_id', '=', self.sol2_service_purchase_2.id)]) # only one line + + self.assertTrue(purchase_line, "The SO line should generate a PO line") + self.assertEqual(purchase_line.product_uom, self.service_purchase_2.uom_po_id, "The UoM on the purchase line should be the one from the product configuration") + self.assertNotEqual(purchase_line.product_uom, self.sol2_service_purchase_2.product_uom, "As the product configuration, the UoM on the SO line should still be different from the one on the PO line") + self.assertEqual(purchase_line.product_qty, self.sol2_service_purchase_2.product_uom_qty * 12, "The quantity from the SO should be converted with th UoM factor on the PO line") + + def test_no_supplier(self): + """ Test confirming SO with product with no supplier raise Error """ + # delete the suppliers + self.supplierinfo1.unlink() + # confirm the SO should raise UserError + with self.assertRaises(UserError): + self.sale_order_1.action_confirm() + + def test_reconfirm_sale_order(self): + """ Confirm SO, cancel it, then re-confirm it should not regenerate a purchase line """ + self.sale_order_1.action_confirm() + + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.supplierinfo1.name.id), ('state', '=', 'draft')]) + purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.sale_order_1.order_line.ids)]) + purchase_line = purchase_lines[0] + + self.assertEqual(len(purchase_lines), 1, "Only one purchase line should be created on SO confirmation") + self.assertEqual(len(purchase_order), 1, "One purchase order should have been created on SO confirmation") + self.assertEqual(len(purchase_order.order_line), 1, "Only one line on PO, after SO confirmation") + self.assertEqual(purchase_order, purchase_lines.order_id, "The generated purchase line should be in the generated purchase order") + self.assertEqual(purchase_order.state, 'draft', "Generated purchase should be in draft state") + self.assertEqual(purchase_line.price_unit, self.supplierinfo1.price, "Purchase line price is the one from the supplier") + self.assertEqual(purchase_line.product_qty, self.sol1_service_purchase_1.product_uom_qty, "Quantity on SO line is not the same on the purchase line (same UoM)") + + self.sale_order_1.action_cancel() + + self.assertEqual(len(purchase_order.activity_ids), 1, "One activity should be scheduled on the PO since a SO has been cancelled") + + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.supplierinfo1.name.id), ('state', '=', 'draft')]) + purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.sale_order_1.order_line.ids)]) + purchase_line = purchase_lines[0] + + self.assertEqual(len(purchase_lines), 1, "Always one purchase line even after SO cancellation") + self.assertTrue(purchase_order, "Always one purchase order even after SO cancellation") + self.assertEqual(len(purchase_order.order_line), 1, "Still one line on PO, even after SO cancellation") + self.assertEqual(purchase_order, purchase_lines.order_id, "The generated purchase line should still be in the generated purchase order") + self.assertEqual(purchase_order.state, 'draft', "Generated purchase should still be in draft state") + self.assertEqual(purchase_line.price_unit, self.supplierinfo1.price, "Purchase line price is still the one from the supplier") + self.assertEqual(purchase_line.product_qty, self.sol1_service_purchase_1.product_uom_qty, "Quantity on SO line should still be the same on the purchase line (same UoM)") + + self.sale_order_1.action_draft() + self.sale_order_1.action_confirm() + + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.supplierinfo1.name.id), ('state', '=', 'draft')]) + purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.sale_order_1.order_line.ids)]) + purchase_line = purchase_lines[0] + + self.assertEqual(len(purchase_lines), 1, "Still only one purchase line should be created even after SO reconfirmation") + self.assertEqual(len(purchase_order), 1, "Still one purchase order should be after SO reconfirmation") + self.assertEqual(len(purchase_order.order_line), 1, "Only one line on PO, even after SO reconfirmation") + self.assertEqual(purchase_order, purchase_lines.order_id, "The generated purchase line should be in the generated purchase order") + self.assertEqual(purchase_order.state, 'draft', "Generated purchase should be in draft state") + self.assertEqual(purchase_line.price_unit, self.supplierinfo1.price, "Purchase line price is the one from the supplier") + self.assertEqual(purchase_line.product_qty, self.sol1_service_purchase_1.product_uom_qty, "Quantity on SO line is not the same on the purchase line (same UoM)") + + def test_update_ordered_sale_quantity(self): + """ Test the purchase order behovior when changing the ordered quantity on the sale order line. + Increase of qty on the SO + - If PO is draft ['draft', 'sent', 'to approve'] : increase the quantity on the PO + - If PO is confirmed ['purchase', 'done', 'cancel'] : create a new PO + + Decrease of qty on the SO + - If PO is draft ['draft', 'sent', 'to approve'] : next activity on the PO + - If PO is confirmed ['purchase', 'done', 'cancel'] : next activity on the PO + """ + self.sale_order_1.action_confirm() + + purchase_order = self.env['purchase.order'].search([('partner_id', '=', self.supplierinfo1.name.id), ('state', '=', 'draft')]) + purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.sale_order_1.order_line.ids)]) + purchase_line = purchase_lines[0] + + self.assertEqual(purchase_order.state, 'draft', "The created purchase should be in draft state") + self.assertFalse(purchase_order.activity_ids, "There is no activities on the PO") + self.assertEqual(purchase_line.product_qty, self.sol1_service_purchase_1.product_uom_qty, "Quantity on SO line is not the same on the purchase line (same UoM)") + + # increase the ordered quantity on sale line + self.sol1_service_purchase_1.write({'product_uom_qty': self.sol1_service_purchase_1.product_uom_qty + 12}) # product_uom_qty = 16 + self.assertEqual(purchase_line.product_qty, self.sol1_service_purchase_1.product_uom_qty, "The quantity of draft PO line should be increased as the one from the sale line changed") + + sale_line_old_quantity = self.sol1_service_purchase_1.product_uom_qty + + # decrease the ordered quantity on sale line + self.sol1_service_purchase_1.write({'product_uom_qty': self.sol1_service_purchase_1.product_uom_qty - 3}) # product_uom_qty = 13 + self.assertEqual(len(purchase_order.activity_ids), 1, "One activity should have been created on the PO") + self.assertEqual(purchase_order.activity_ids.user_id, purchase_order.user_id, "Activity assigned to PO responsible") + self.assertEqual(purchase_order.activity_ids.state, 'today', "Activity is for today, as it is urgent") + + # confirm the PO + purchase_order.button_confirm() + + # decrease the ordered quantity on sale line + self.sol1_service_purchase_1.write({'product_uom_qty': self.sol1_service_purchase_1.product_uom_qty - 5}) # product_uom_qty = 8 + + purchase_order.invalidate_cache() # Note: creating a second activity will not refresh the cache + + self.assertEqual(purchase_line.product_qty, sale_line_old_quantity, "The quantity on the PO line should not have changed.") + self.assertEqual(len(purchase_order.activity_ids), 2, "a second activity should have been created on the PO") + self.assertEqual(purchase_order.activity_ids.mapped('user_id'), purchase_order.user_id, "Activities assigned to PO responsible") + self.assertEqual(purchase_order.activity_ids.mapped('state'), ['today', 'today'], "Activities are for today, as it is urgent") + + # increase the ordered quantity on sale line + delta = 8 + self.sol1_service_purchase_1.write({'product_uom_qty': self.sol1_service_purchase_1.product_uom_qty + delta}) # product_uom_qty = 16 + + self.assertEqual(purchase_line.product_qty, sale_line_old_quantity, "The quantity on the PO line should not have changed.") + self.assertEqual(len(purchase_order.activity_ids), 2, "Always 2 activity on confirmed the PO") + + purchase_order2 = self.env['purchase.order'].search([('partner_id', '=', self.supplierinfo1.name.id), ('state', '=', 'draft')]) + purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.sale_order_1.order_line.ids)]) + purchase_lines2 = purchase_lines.filtered(lambda pol: pol.order_id == purchase_order2) + purchase_line2 = purchase_lines2[0] + + self.assertTrue(purchase_order2, "A second PO is created by increasing sale quantity when first PO is confirmed") + self.assertEqual(purchase_order2.state, 'draft', "The second PO is in draft state") + self.assertNotEqual(purchase_order, purchase_order2, "The 2 PO are different") + self.assertEqual(len(purchase_lines), 2, "The same Sale Line has created 2 purchase lines") + self.assertEqual(len(purchase_order2.order_line), 1, "The 2nd PO has only one line") + self.assertEqual(purchase_line2.sale_line_id, self.sol1_service_purchase_1, "The 2nd PO line came from the SO line sol1_service_purchase_1") + self.assertEqual(purchase_line2.product_qty, delta, "The quantity of the new PO line is the quantity added on the Sale Line, after first PO confirmation") diff --git a/addons/sale_purchase/views/product_views.xml b/addons/sale_purchase/views/product_views.xml new file mode 100644 index 00000000000..71fffeaa5af --- /dev/null +++ b/addons/sale_purchase/views/product_views.xml @@ -0,0 +1,17 @@ + + + + + product.template.form.inherit + product.template + + + + + + + + + + + \ No newline at end of file diff --git a/addons/sale_purchase/views/sale_views.xml b/addons/sale_purchase/views/sale_views.xml new file mode 100644 index 00000000000..6919d8a929a --- /dev/null +++ b/addons/sale_purchase/views/sale_views.xml @@ -0,0 +1,22 @@ + + + + + sale.order.form.inherit + sale.order + + + + + + + + + diff --git a/addons/stock_dropshipping/__manifest__.py b/addons/stock_dropshipping/__manifest__.py index b6404c71201..de26d991087 100644 --- a/addons/stock_dropshipping/__manifest__.py +++ b/addons/stock_dropshipping/__manifest__.py @@ -20,7 +20,7 @@ going through the retailer's warehouse. In this case no internal transfer document is needed. """, - 'depends': ['purchase_stock', 'sale_stock'], + 'depends': ['sale_purchase', 'sale_stock'], 'data': ['data/stock_data.xml'], 'installable': True, 'auto_install': False, diff --git a/addons/stock_dropshipping/models/purchase.py b/addons/stock_dropshipping/models/purchase.py index 7a2362656ae..afd07a3b212 100644 --- a/addons/stock_dropshipping/models/purchase.py +++ b/addons/stock_dropshipping/models/purchase.py @@ -1,14 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, models, fields +from odoo import api, models class PurchaseOrderLine(models.Model): _inherit = "purchase.order.line" - sale_line_id = fields.Many2one('sale.order.line') - @api.multi def _prepare_stock_moves(self, picking): res = super(PurchaseOrderLine, self)._prepare_stock_moves(picking) @@ -25,4 +23,3 @@ class StockRule(models.Model): res = super(StockRule, self)._prepare_purchase_order_line(product_id, product_qty, product_uom, values, po, supplier) res['sale_line_id'] = values.get('sale_line_id', False) return res -