[ADD] sale_purchase: purchase automatically for services

This commit adds a new module providing the automatically services
purchasing feature ("Service Subcontracting").

The product of 'service' type can be flag (in the purchase tab of
its form view) as "purchase automatically".
When adding an sale order line with that kind of product, it will
generate a Purchase Order for the supplier of the product, on Sales
Order confirmation. The PO will contains one line with the quantity
on the sale line, with the UoM conversion (purchase uom might be
different from the sale uom on product configuration). The generated
PO is in draft state.

Then several cases can happen:
1/ Increasing the quantity on the sale line
    - If PO is draft state ('draft', 'sent', 'to approve') : increase
      the quantity on the PO
    - If PO is confirmed state ('purchase', 'done', 'cancel') : create
      a new PO
2/ Decreasing of quantity on the sale line
    - in any cases: shedule a next activity on the PO
3/ Cancelling SO: shedule a next activity on the impacted PO
4/ Cancelling PO: shedule a next activity on the origin SO

The introduction of this module required to move the link between
sale.order.line and purchase.order.line from stock_dropshipping to
thie module, and put sale_purchase as a dependency of
stock_dropshipping.

This commit also brings some test cases.

Task #39340
Closes #25221
This commit is contained in:
jem-odoo
2018-08-10 16:30:20 +02:00
parent 8cdc95a331
commit 6ea1011416
16 changed files with 949 additions and 6 deletions
@@ -337,7 +337,7 @@
<field name="inherit_id" ref="purchase.view_product_supplier_inherit"/>
<field name="arch" type="xml">
<group name="bill" position="before">
<group string="Reordering">
<group string="Reordering" attrs="{'invisible': [('type', '=', 'service')]}">
<field name="purchase_requisition" widget="radio"/>
</group>
</group>
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+26
View File
@@ -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,
}
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="exception_purchase_on_sale_quantity_decreased" name="Message: Alert on purchase orders when ordered quantity decreased on Sales Orders">
<div>
<p>
Exception(s) occurred on the sale order(s):
<t t-foreach="sale_orders" t-as="sale_order">
<a href="#" data-oe-model="sale.order" t-att-data-oe-id="sale_order.id"><t t-esc="sale_order.name"/></a>.
</t>
Manual actions may be needed.
</p>
<div class="mt16">
<p>Exception(s):</p>
<ul t-foreach="sale_lines" t-as="sale_line">
<li>
<t t-set="new_qty" t-value="sale_line.product_uom_qty"/>
<t t-set="old_qty" t-value="origin_values.get(sale_line.id, 0.0)"/>
<a href="#" data-oe-model="sale.order" t-att-data-oe-id="sale_line.order_id.id"><t t-esc="sale_line.order_id.name"/></a>:
<t t-esc="new_qty"/> <t t-esc="sale_line.product_uom.name"/> of <t t-esc="sale_line.product_id.name"/> ordered instead of <t t-esc="old_qty"/> <t t-esc="sale_line.product_uom.name"/>
</li>
</ul>
</div>
</div>
</template>
<template id="exception_purchase_on_sale_cancellation" name="Message: Alert on purchase orders when sales orders are cancelled">
<div>
<p>
Exception(s) occurred on the sale order(s):
<t t-foreach="sale_orders" t-as="sale_order">
<a href="#" data-oe-model="sale.order" t-att-data-oe-id="sale_order.id"><t t-esc="sale_order.name"/></a>
</t>.
Manual actions may be needed.
</p>
<div class="mt16">
<p>Exception(s):</p>
<ul t-foreach="sale_order_lines" t-as="sale_line">
<li>
<a href="#" data-oe-model="sale.order" t-att-data-oe-id="sale_line.order_id.id"><t t-esc="sale_line.order_id.name"/></a>: <t t-esc="sale_line.product_uom_qty"/> <t t-esc="sale_line.product_uom.name"/> of <t t-esc="sale_line.product_id.name"/> cancelled
</li>
</ul>
</div>
</div>
</template>
<template id="exception_sale_on_purchase_cancellation" name="Message: Alert on sale orders when purchase orders are cancelled">
<div>
<p>
Exception(s) occurred on the purchase order(s):
<t t-foreach="purchase_orders" t-as="purchase_order">
<a href="#" data-oe-model="purchase.order" t-att-data-oe-id="purchase_order.id"><t t-esc="purchase_order.name"/></a>
</t>.
Manual actions may be needed.
</p>
<div class="mt16">
<p>Exception(s):</p>
<ul t-foreach="purchase_order_lines" t-as="purchase_line">
<li>
<a href="#" data-oe-model="purchase.order" t-att-data-oe-id="purchase_line.order_id.id"><t t-esc="purchase_line.order_id.name"/></a>: <t t-esc="purchase_line.product_qty"/> <t t-esc="purchase_line.product_uom.name"/> of <t t-esc="purchase_line.product_id.name"/> cancelled
</li>
</ul>
</div>
</div>
</template>
</odoo>
+6
View File
@@ -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
@@ -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
@@ -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)
+320
View File
@@ -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
+6
View File
@@ -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
+83
View File
@@ -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,
})
@@ -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()
@@ -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")
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="product_template_form_view_inherit" model="ir.ui.view">
<field name="name">product.template.form.inherit</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="purchase.view_product_supplier_inherit"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='bill']" position="before">
<group string="Reordering" attrs="{'invisible': [('type','!=','service')]}">
<field name="service_to_purchase"/>
</group>
</xpath>
</field>
</record>
</odoo>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_order_form_view_inherit" model="ir.ui.view">
<field name="name">sale.order.form.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_view_invoice']" position="before">
<button type="object"
name="action_view_purchase"
class="oe_stat_button"
icon="fa-shopping-cart"
attrs="{'invisible': [('purchase_order_count', '=', 0)]}"
groups="purchase.group_purchase_user">
<field name="purchase_order_count" widget="statinfo" string="Purchase Orders"/>
</button>
</xpath>
</field>
</record>
</odoo>
+1 -1
View File
@@ -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,
+1 -4
View File
@@ -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