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 @@
+
+
+
+
+
+
+ Exception(s) occurred on the sale order(s):
+
+ .
+
+ Manual actions may be needed.
+
+
+
Exception(s):
+
+
+
+
+
+ :
+ of ordered instead of
+
+
+
+
+
+
+
+
+
+ Exception(s) occurred on the sale order(s):
+
+
+ .
+ Manual actions may be needed.
+
+
+
Exception(s):
+
+
+ : of cancelled
+
+
+
+
+
+
+
+
+
+ Exception(s) occurred on the purchase order(s):
+
+
+ .
+ Manual actions may be needed.
+
+
+
Exception(s):
+
+
+ : of cancelled
+
+
+
+
+
+
+
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
-