[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:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user