[MERGE] sale,sale_*: computation of delivered quantity on sale line
This commit refactors the computation of the delivered quantity on sale order line, using a computed field and a manual field. This quantity is automatically computed when using stock moves, timsheet or expenses (through analytic line). This simplify code, avoid having trigger in `create` and `write` method of many models, ... Once this big clean done, we wanted to go futher and clean the reinvoice flow for analytic line (vendor bills and hr expenses) by : - using the reinvoice policy has it should be - flagging sale order line generated from expenses - clarify 'reinvoice policy' and 'can be expensed' fields usage - erase some usability issues (with onchanges and views reoganization) This commit also add tests suites for reinvoice flow, and delivered quantity computation. See sub commits for more details. Thanks @rco-odoo for the onchange fix, and @tde-banana-odoo for its review. Task 40226
This commit is contained in:
@@ -16,3 +16,8 @@ class ProductTemplate(models.Model):
|
||||
if vals.get('can_be_expensed', False):
|
||||
vals.update({'supplier_taxes_id': False})
|
||||
return super(ProductTemplate, self).create(vals)
|
||||
|
||||
@api.onchange('type')
|
||||
def _onchange_type_for_expense(self):
|
||||
if self.type not in ['consu', 'service']: # stockable can not be expensed.
|
||||
self.can_be_expensed = False
|
||||
|
||||
@@ -51,3 +51,8 @@ class TestExpenseCommon(TestAccountNoChartCommon):
|
||||
'employee_id': cls.employee.id,
|
||||
'journal_id': cls.journal.id,
|
||||
})
|
||||
cls.expense_sheet2 = cls.env['hr.expense.sheet'].create({
|
||||
'name': 'Second Expense for Johnny Employee',
|
||||
'employee_id': cls.employee.id,
|
||||
'journal_id': cls.journal.id,
|
||||
})
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
<field name="inherit_id" ref="product.product_template_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<div name="options" position="inside">
|
||||
<div>
|
||||
<div attrs="{'invisible': [('type','not in', ['consu', 'service'])]}">
|
||||
<field name="can_be_expensed"/>
|
||||
<label for="can_be_expensed"/>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
</group>
|
||||
<group name="group_standard_price">
|
||||
<field name="list_price" string="Sales Price" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
|
||||
<label for="standard_price" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '>', 1)]}"/>
|
||||
<div name="standard_price_uom" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '>', 1)]}">
|
||||
<field name="standard_price" widget='monetary' options="{'currency_field': 'currency_id'}"/>
|
||||
</div>
|
||||
<field name="company_id" groups="base.group_multi_company"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="uom_id" groups="product.group_uom" options="{'no_create': True}"/>
|
||||
@@ -97,14 +101,6 @@
|
||||
</group>
|
||||
</page>
|
||||
<page string="Inventory" name="inventory" groups="stock.group_stock_user,product.group_stock_packaging" attrs="{'invisible':[('type', '=', 'service')]}">
|
||||
<group name="inventory">
|
||||
<group string="Inventory Valuation" name="inventory_valuation">
|
||||
<label for="standard_price" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '>', 1)]}"/>
|
||||
<div name="standard_price_uom" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '>', 1)]}">
|
||||
<field name="standard_price" widget='monetary' options="{'currency_field': 'currency_id'}"/>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<group name="packaging" string="Packaging"
|
||||
colspan="4"
|
||||
attrs="{'invisible':['|', ('type', 'not in', ['product', 'consu']), ('product_variant_count', '>', 1)]}"
|
||||
|
||||
@@ -8,50 +8,31 @@ from odoo.exceptions import UserError
|
||||
class AccountAnalyticLine(models.Model):
|
||||
_inherit = "account.analytic.line"
|
||||
|
||||
so_line = fields.Many2one('sale.order.line', string='Sales Order Line')
|
||||
def _default_sale_line_domain(self):
|
||||
""" This is only used for delivered quantity of SO line based on analytic line, and timesheet
|
||||
(see sale_timesheet). This can be override to allow further customization.
|
||||
"""
|
||||
return [('qty_delivered_method', '=', 'analytic')]
|
||||
|
||||
so_line = fields.Many2one('sale.order.line', string='Sales Order Line', domain=lambda self: self._default_sale_line_domain())
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
result = super(AccountAnalyticLine, self).create(values)
|
||||
result._sale_postprocess(values)
|
||||
if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy != 'no': # allow to force a False value for so_line
|
||||
result.sudo()._sale_determine_order_line()
|
||||
return result
|
||||
|
||||
@api.multi
|
||||
def write(self, values):
|
||||
# get current so lines for which update qty wil be required
|
||||
sale_order_lines = self.env['sale.order.line']
|
||||
if 'so_line' in values:
|
||||
sale_order_lines = self.sudo().mapped('so_line')
|
||||
result = super(AccountAnalyticLine, self).write(values)
|
||||
self._sale_postprocess(values, additional_so_lines=sale_order_lines)
|
||||
if 'so_line' not in values: # allow to force a False value for so_line
|
||||
self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no')._sale_determine_order_line()
|
||||
return result
|
||||
|
||||
@api.multi
|
||||
def unlink(self):
|
||||
sale_order_lines = self.sudo().mapped('so_line')
|
||||
res = super(AccountAnalyticLine, self).unlink()
|
||||
sale_order_lines.with_context(sale_analytic_force_recompute=True)._analytic_compute_delivered_quantity()
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _sale_get_fields_delivered_qty(self):
|
||||
""" Returns a list with the field impacting the delivered quantity on SO line. """
|
||||
return ['so_line', 'unit_amount', 'product_uom_id']
|
||||
|
||||
@api.multi
|
||||
def _sale_postprocess(self, values, additional_so_lines=None):
|
||||
if 'so_line' not in values: # allow to force a False value for so_line
|
||||
self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no').with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
|
||||
|
||||
if any(field_name in values for field_name in self._sale_get_fields_delivered_qty()):
|
||||
if not self._context.get('sale_analytic_norecompute'):
|
||||
so_lines = self.sudo().filtered(lambda aal: aal.so_line).mapped('so_line')
|
||||
if additional_so_lines:
|
||||
so_lines |= additional_so_lines
|
||||
so_lines.sudo()._analytic_compute_delivered_quantity()
|
||||
|
||||
# NOTE JEM: thoses method are used in vendor bills to reinvoice at cost (see test `test_cost_invoicing`)
|
||||
# some cleaning are still necessary
|
||||
# ----------------------------------------------------------
|
||||
# Vendor Bill / Expense : determine the Sale Order to reinvoice
|
||||
# ----------------------------------------------------------
|
||||
|
||||
@api.multi
|
||||
def _sale_get_invoice_price(self, order):
|
||||
@@ -96,7 +77,7 @@ class AccountAnalyticLine(models.Model):
|
||||
'product_id': self.product_id.id,
|
||||
'product_uom': self.product_uom_id.id,
|
||||
'product_uom_qty': 0.0,
|
||||
'qty_delivered': self.unit_amount,
|
||||
'is_expense': True,
|
||||
}
|
||||
|
||||
@api.multi
|
||||
@@ -119,6 +100,7 @@ class AccountAnalyticLine(models.Model):
|
||||
# determine SO : first SO open linked to AA
|
||||
sale_order_map = self._sale_determine_order()
|
||||
# determine so line
|
||||
value_to_write = {}
|
||||
for analytic_line in self:
|
||||
sale_order = sale_order_map.get(analytic_line.id)
|
||||
if not sale_order:
|
||||
@@ -127,24 +109,27 @@ class AccountAnalyticLine(models.Model):
|
||||
if sale_order.state != 'sale':
|
||||
raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name)
|
||||
|
||||
price = analytic_line._sale_get_invoice_price(sale_order)
|
||||
so_line = None
|
||||
price = analytic_line._sale_get_invoice_price(sale_order)
|
||||
if analytic_line.product_id.expense_policy == 'sales_price' and analytic_line.product_id.invoice_policy == 'delivery':
|
||||
so_line = self.env['sale.order.line'].search([
|
||||
('order_id', '=', sale_order.id),
|
||||
('price_unit', '=', price),
|
||||
('product_id', '=', self.product_id.id)
|
||||
('product_id', '=', self.product_id.id),
|
||||
('is_expense', '=', True),
|
||||
], limit=1)
|
||||
|
||||
if not so_line:
|
||||
# generate a new SO line
|
||||
if sale_order.state != 'sale':
|
||||
raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name)
|
||||
so_line_values = analytic_line._sale_prepare_sale_order_line_values(sale_order, price)
|
||||
so_line = self.env['sale.order.line'].create(so_line_values)
|
||||
so_line._compute_tax_id()
|
||||
else:
|
||||
so_line.write({'qty_delivered': so_line.qty_delivered + analytic_line.unit_amount})
|
||||
|
||||
if so_line: # if so line found or created, then update AAL (this will trigger the recomputation of qty delivered on SO line)
|
||||
analytic_line.with_context(sale_analytic_norecompute=True).write({'so_line': so_line.id})
|
||||
value_to_write.setdefault(so_line.id, self.env['account.analytic.line'])
|
||||
value_to_write[so_line.id] |= analytic_line
|
||||
|
||||
# write so line on (maybe) multiple AAL to trigger only one read_group per SO line
|
||||
for so_line_id, analytic_lines in value_to_write.items():
|
||||
if analytic_lines:
|
||||
analytic_lines.write({'so_line': so_line_id})
|
||||
|
||||
@@ -17,7 +17,7 @@ class ProductTemplate(models.Model):
|
||||
sale_line_warn_msg = fields.Text('Message for Sales Order Line')
|
||||
expense_policy = fields.Selection(
|
||||
[('no', 'No'), ('cost', 'At cost'), ('sales_price', 'Sales price')],
|
||||
string='Re-Invoice Expenses',
|
||||
string='Re-Invoice Policy',
|
||||
default='no')
|
||||
|
||||
@api.multi
|
||||
@@ -52,3 +52,10 @@ class ProductTemplate(models.Model):
|
||||
help='Ordered Quantity: Invoice based on the quantity the customer ordered.\n'
|
||||
'Delivered Quantity: Invoiced based on the quantity the vendor delivered (time or deliveries).',
|
||||
default='order')
|
||||
|
||||
@api.onchange('type')
|
||||
def _onchange_type(self):
|
||||
""" Force values to stay consistent with integrity constraints """
|
||||
if self.type == 'consu':
|
||||
self.invoice_policy = 'order'
|
||||
self.service_type = 'manual'
|
||||
|
||||
+100
-67
@@ -716,11 +716,6 @@ class SaleOrderLine(models.Model):
|
||||
else:
|
||||
line.product_updatable = True
|
||||
|
||||
@api.depends('product_id.invoice_policy', 'order_id.state')
|
||||
def _compute_qty_delivered_updateable(self):
|
||||
for line in self:
|
||||
line.qty_delivered_updateable = (line.order_id.state == 'sale') and (line.product_id.service_type == 'manual') and (line.product_id.expense_policy == 'no')
|
||||
|
||||
@api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'order_id.state')
|
||||
def _get_to_invoice_qty(self):
|
||||
"""
|
||||
@@ -873,8 +868,17 @@ class SaleOrderLine(models.Model):
|
||||
# Non-stored related field to allow portal user to see the image of the product he has ordered
|
||||
product_image = fields.Binary('Product Image', related="product_id.image", store=False)
|
||||
|
||||
qty_delivered_updateable = fields.Boolean(compute='_compute_qty_delivered_updateable', string='Can Edit Delivered', readonly=True, default=True)
|
||||
qty_delivered = fields.Float(string='Delivered', copy=False, digits=dp.get_precision('Product Unit of Measure'), default=0.0)
|
||||
qty_delivered_method = fields.Selection([
|
||||
('manual', 'Manual'),
|
||||
('analytic', 'Analytic From Expenses')
|
||||
], string="Method to update delivered qty", compute='_compute_qty_delivered_method', compute_sudo=True, store=True, readonly=True,
|
||||
help="According to product configuration, the delivered quantity can be automatically computed by mechanism :\n"
|
||||
" - Manual: the quantity is set manually on the line\n"
|
||||
" - Analytic From expenses: the quantity is the quantity sum from posted expenses\n"
|
||||
" - Timesheet: the quantity is the sum of hours recorded on tasks linked to this sale line\n"
|
||||
" - Stock Moves: the quantity comes from confirmed pickings\n")
|
||||
qty_delivered = fields.Float('Delivered', copy=False, compute='_compute_qty_delivered', inverse='_inverse_qty_delivered', compute_sudo=True, store=True, digits=dp.get_precision('Product Unit of Measure'), default=0.0)
|
||||
qty_delivered_manual = fields.Float('Delivered Manually', copy=False, digits=dp.get_precision('Product Unit of Measure'), default=0.0)
|
||||
qty_to_invoice = fields.Float(
|
||||
compute='_get_to_invoice_qty', string='To Invoice', store=True, readonly=True,
|
||||
digits=dp.get_precision('Product Unit of Measure'))
|
||||
@@ -887,6 +891,8 @@ class SaleOrderLine(models.Model):
|
||||
company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, readonly=True)
|
||||
order_partner_id = fields.Many2one(related='order_id.partner_id', store=True, string='Customer')
|
||||
analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags')
|
||||
analytic_line_ids = fields.One2many('account.analytic.line', 'so_line', string="Analytic lines")
|
||||
is_expense = fields.Boolean('Is expense', help="Is true if the sales order line comes from an expense or a vendor bills")
|
||||
is_downpayment = fields.Boolean(
|
||||
string="Is a down payment", help="Down payments are made when creating invoices from a sales order."
|
||||
" They are not copied when duplicating a sales order.")
|
||||
@@ -906,6 +912,93 @@ class SaleOrderLine(models.Model):
|
||||
layout_category_sequence = fields.Integer(string='Layout Sequence')
|
||||
# TODO: remove layout_category_sequence in master or make it work properly
|
||||
|
||||
@api.multi
|
||||
@api.depends('state', 'is_expense')
|
||||
def _compute_qty_delivered_method(self):
|
||||
""" Sale module compute delivered qty for product [('type', 'in', ['consu']), ('service_type', '=', 'manual')]
|
||||
- consu + expense_policy : analytic (sum of analytic unit_amount)
|
||||
- consu + no expense_policy : manual (set manually on SOL)
|
||||
- service (+ service_type='manual', the only available option) : manual
|
||||
|
||||
This is true when only sale is installed: sale_stock redifine the behavior for 'consu' type,
|
||||
and sale_timesheet implements the behavior of 'service' + service_type=timesheet.
|
||||
"""
|
||||
for line in self:
|
||||
if line.is_expense:
|
||||
line.qty_delivered_method = 'analytic'
|
||||
else: # service and consu
|
||||
line.qty_delivered_method = 'manual'
|
||||
|
||||
@api.multi
|
||||
@api.depends('qty_delivered_method', 'qty_delivered_manual', 'analytic_line_ids.so_line', 'analytic_line_ids.unit_amount', 'analytic_line_ids.product_uom_id')
|
||||
def _compute_qty_delivered(self):
|
||||
""" This method compute the delivered quantity of the SO lines: it covers the case provide by sale module, aka
|
||||
expense/vendor bills (sum of unit_amount of AAL), and manual case.
|
||||
This method should be overriden to provide other way to automatically compute delivered qty. Overrides should
|
||||
take their concerned so lines, compute and set the `qty_delivered` field, and call super with the remaining
|
||||
records.
|
||||
"""
|
||||
# compute for analytic lines
|
||||
lines_by_analytic = self.filtered(lambda sol: sol.qty_delivered_method == 'analytic')
|
||||
mapping = lines_by_analytic._get_delivered_quantity_by_analytic([('amount', '<=', 0.0)])
|
||||
for so_line in lines_by_analytic:
|
||||
so_line.qty_delivered = mapping.get(so_line.id, 0.0)
|
||||
# compute for manual lines
|
||||
for line in self:
|
||||
if line.qty_delivered_method == 'manual':
|
||||
line.qty_delivered = line.qty_delivered_manual or 0.0
|
||||
|
||||
@api.multi
|
||||
def _get_delivered_quantity_by_analytic(self, additional_domain):
|
||||
""" Compute and write the delivered quantity of current SO lines, based on their related
|
||||
analytic lines.
|
||||
:param additional_domain: domain to restrict AAL to include in computation (required since timesheet is an AAL with a project ...)
|
||||
"""
|
||||
result = {}
|
||||
# avoid recomputation if no SO lines concerned
|
||||
if not self:
|
||||
return result
|
||||
|
||||
# group anaytic lines by product uom and so line
|
||||
domain = expression.AND([[('so_line', 'in', self.ids)], additional_domain])
|
||||
data = self.env['account.analytic.line'].read_group(
|
||||
domain,
|
||||
['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False
|
||||
)
|
||||
|
||||
# convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines
|
||||
# browse so lines and product uoms here to make them share the same prefetch
|
||||
lines_map = {line.id: line for line in self}
|
||||
product_uom_ids = [item['product_uom_id'][0] for item in data if item['product_uom_id']]
|
||||
product_uom_map = {uom.id: uom for uom in self.env['product.uom'].browse(product_uom_ids)}
|
||||
for item in data:
|
||||
if not item['product_uom_id']:
|
||||
continue
|
||||
so_line_id = item['so_line'][0]
|
||||
so_line = lines_map[so_line_id]
|
||||
result.setdefault(so_line_id, 0.0)
|
||||
uom = product_uom_map.get(item['product_uom_id'][0])
|
||||
if so_line.product_uom.category_id == uom.category_id:
|
||||
qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom)
|
||||
else:
|
||||
qty = item['unit_amount']
|
||||
result[so_line_id] += qty
|
||||
|
||||
return result
|
||||
|
||||
@api.multi
|
||||
@api.onchange('qty_delivered')
|
||||
def _inverse_qty_delivered(self):
|
||||
""" When writing on qty_delivered, if the value should be modify manually (`qty_delivered_method` = 'manual' only),
|
||||
then we put the value in `qty_delivered_manual`. Otherwise, `qty_delivered_manual` should be False since the
|
||||
delivered qty is automatically compute by other mecanisms.
|
||||
"""
|
||||
for line in self:
|
||||
if line.qty_delivered_method == 'manual':
|
||||
line.qty_delivered_manual = line.qty_delivered
|
||||
else:
|
||||
line.qty_delivered_manual = 0.0
|
||||
|
||||
@api.multi
|
||||
def _prepare_invoice_line(self, qty):
|
||||
"""
|
||||
@@ -1071,15 +1164,6 @@ class SaleOrderLine(models.Model):
|
||||
raise UserError(_('You can not remove an order line once the sales order is confirmed.\nYou should rather set the quantity to 0.'))
|
||||
return super(SaleOrderLine, self).unlink()
|
||||
|
||||
@api.multi
|
||||
def _get_delivered_qty(self):
|
||||
'''
|
||||
Intended to be overridden in sale_stock and sale_mrp
|
||||
:return: the quantity delivered
|
||||
:rtype: float
|
||||
'''
|
||||
return 0.0
|
||||
|
||||
def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id):
|
||||
"""Retrieve the price before applying the pricelist
|
||||
:param obj product: object of current product record
|
||||
@@ -1153,54 +1237,3 @@ class SaleOrderLine(models.Model):
|
||||
discount = (new_list_price - price) / new_list_price * 100
|
||||
if discount > 0:
|
||||
self.discount = discount
|
||||
|
||||
###########################
|
||||
# Analytic Methods
|
||||
###########################
|
||||
|
||||
@api.multi
|
||||
def _analytic_compute_delivered_quantity_domain(self):
|
||||
""" Return the domain of the analytic lines to use to recompute the delivered quantity
|
||||
on SO lines. This method is a hook: since analytic line are used for timesheet,
|
||||
expense, ... each use case should provide its part of the domain.
|
||||
"""
|
||||
return [('so_line', 'in', self.ids), ('amount', '<=', 0.0)]
|
||||
|
||||
@api.multi
|
||||
def _analytic_compute_delivered_quantity(self):
|
||||
""" Compute and write the delivered quantity of current SO lines, based on their related
|
||||
analytic lines.
|
||||
"""
|
||||
# avoid recomputation if no SO lines concerned
|
||||
if not self:
|
||||
return False
|
||||
|
||||
# group anaytic lines by product uom and so line
|
||||
domain = self._analytic_compute_delivered_quantity_domain()
|
||||
data = self.env['account.analytic.line'].read_group(
|
||||
domain,
|
||||
['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False
|
||||
)
|
||||
# Force recompute for the "unlink last line" case: if remove the last AAL link to the SO, the read_group
|
||||
# will give no value for the qty of the SOL, so we need to reset it to 0.0
|
||||
value_to_write = {}
|
||||
if self._context.get('sale_analytic_force_recompute'):
|
||||
value_to_write = dict.fromkeys([sol for sol in self], 0.0)
|
||||
# convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines
|
||||
for item in data:
|
||||
if not item['product_uom_id']:
|
||||
continue
|
||||
so_line = self.browse(item['so_line'][0])
|
||||
value_to_write.setdefault(so_line, 0.0)
|
||||
uom = self.env['product.uom'].browse(item['product_uom_id'][0])
|
||||
if so_line.product_uom.category_id == uom.category_id:
|
||||
qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom)
|
||||
else:
|
||||
qty = item['unit_amount']
|
||||
value_to_write[so_line] += qty
|
||||
|
||||
# write the delivered quantity
|
||||
for so_line, qty in value_to_write.items():
|
||||
so_line.write({'qty_delivered': qty})
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_sale_to_invoice
|
||||
from . import test_sale_order
|
||||
from . import test_product_id_change
|
||||
from . import test_reinvoice
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
# -*- 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 TestReInvoice(TestCommonSaleNoChart):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestReInvoice, cls).setUpClass()
|
||||
|
||||
cls.setUpExpenseProducts()
|
||||
cls.setUpAdditionalAccounts()
|
||||
cls.setUpAccountJournal()
|
||||
|
||||
cls.analytic_account = cls.env['account.analytic.account'].create({
|
||||
'name': 'Test AA',
|
||||
'code': 'TESTSALE_REINVOICE',
|
||||
'company_id': cls.partner_customer_usd.company_id.id,
|
||||
'partner_id': cls.partner_customer_usd.id
|
||||
})
|
||||
|
||||
cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
|
||||
'partner_id': cls.partner_customer_usd.id,
|
||||
'partner_invoice_id': cls.partner_customer_usd.id,
|
||||
'partner_shipping_id': cls.partner_customer_usd.id,
|
||||
'analytic_account_id': cls.analytic_account.id,
|
||||
})
|
||||
|
||||
Invoice = cls.env['account.invoice'].with_context(mail_notrack=True, mail_create_nolog=True)
|
||||
cls.invoiceA = Invoice.create({
|
||||
'name': 'Test Invoice',
|
||||
'type': 'in_invoice',
|
||||
'partner_id': cls.partner_customer_usd.id,
|
||||
'account_id': cls.account_payable.id,
|
||||
'journal_id': cls.journal_purchase.id,
|
||||
'currency_id': cls.env.user.company_id.currency_id.id,
|
||||
})
|
||||
cls.invoiceB = Invoice.create({
|
||||
'name': 'Test Invoice 2',
|
||||
'type': 'in_invoice',
|
||||
'partner_id': cls.partner_customer_usd.id,
|
||||
'account_id': cls.account_payable.id,
|
||||
'journal_id': cls.journal_purchase.id,
|
||||
'currency_id': cls.env.user.company_id.currency_id.id,
|
||||
})
|
||||
|
||||
def test_at_cost(self):
|
||||
""" Test vendor bill at cost for product based on ordered and delivered quantities. """
|
||||
# create SO line and confirm SO (with only one line)
|
||||
sale_order_line1 = self.env['sale.order.line'].create({
|
||||
'name': self.product_ordered_cost.name,
|
||||
'product_id': self.product_ordered_cost.id,
|
||||
'product_uom_qty': 2,
|
||||
'qty_delivered': 1,
|
||||
'product_uom': self.product_ordered_cost.uom_id.id,
|
||||
'price_unit': self.product_ordered_cost.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
sale_order_line1.product_id_change()
|
||||
sale_order_line2 = self.env['sale.order.line'].create({
|
||||
'name': self.product_deliver_cost.name,
|
||||
'product_id': self.product_deliver_cost.id,
|
||||
'product_uom_qty': 4,
|
||||
'qty_delivered': 1,
|
||||
'product_uom': self.product_deliver_cost.uom_id.id,
|
||||
'price_unit': self.product_deliver_cost.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
sale_order_line2.product_id_change()
|
||||
|
||||
self.sale_order.onchange_partner_id()
|
||||
self.sale_order._compute_tax_id()
|
||||
self.sale_order.action_confirm()
|
||||
|
||||
# create invoice lines and validate it
|
||||
invoice_lineA1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_ordered_cost.name,
|
||||
'product_id': self.product_ordered_cost.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_ordered_cost.uom_id.id,
|
||||
'price_unit': self.product_ordered_cost.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
invoice_lineA2 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_deliver_cost.name,
|
||||
'product_id': self.product_deliver_cost.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_deliver_cost.uom_id.id,
|
||||
'price_unit': self.product_deliver_cost.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
self.invoiceA.action_invoice_open()
|
||||
|
||||
sale_order_line3 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol.product_id == self.product_ordered_cost)
|
||||
sale_order_line4 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol.product_id == self.product_deliver_cost)
|
||||
|
||||
self.assertTrue(sale_order_line3, "A new sale line should have been created with ordered product")
|
||||
self.assertTrue(sale_order_line4, "A new sale line should have been created with delivered product")
|
||||
self.assertEquals(len(self.sale_order.order_line), 4, "There should be 4 lines on the SO (2 vendor bill lines created)")
|
||||
self.assertEquals(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 2, "There should be 4 lines on the SO (2 vendor bill lines created)")
|
||||
|
||||
self.assertEquals(sale_order_line1.qty_delivered, 1, "Exising SO line 1 should not be impacted by reinvoicing product at cost")
|
||||
self.assertEquals(sale_order_line2.qty_delivered, 1, "Exising SO line 2 should not be impacted by reinvoicing product at cost")
|
||||
|
||||
self.assertEquals((sale_order_line3.price_unit, sale_order_line3.qty_delivered, sale_order_line3.product_uom_qty, sale_order_line3.qty_invoiced), (self.product_ordered_cost.standard_price, invoice_lineA1.quantity, 0, 0), 'Sale line is wrong after confirming vendor invoice')
|
||||
self.assertEquals((sale_order_line4.price_unit, sale_order_line4.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line4.qty_invoiced), (self.product_deliver_cost.standard_price, invoice_lineA2.quantity, 0, 0), 'Sale line is wrong after confirming vendor invoice')
|
||||
|
||||
self.assertEquals(sale_order_line3.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line should be computed by analytic amount")
|
||||
self.assertEquals(sale_order_line4.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line should be computed by analytic amount")
|
||||
|
||||
# create second invoice lines and validate it
|
||||
invoice_lineB1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_ordered_cost.name,
|
||||
'product_id': self.product_ordered_cost.id,
|
||||
'quantity': 2,
|
||||
'uom_id': self.product_ordered_cost.uom_id.id,
|
||||
'price_unit': self.product_ordered_cost.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceB.id,
|
||||
})
|
||||
invoice_lineB2 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_deliver_cost.name,
|
||||
'product_id': self.product_deliver_cost.id,
|
||||
'quantity': 2,
|
||||
'uom_id': self.product_deliver_cost.uom_id.id,
|
||||
'price_unit': self.product_deliver_cost.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceB.id,
|
||||
})
|
||||
self.invoiceB.action_invoice_open()
|
||||
|
||||
sale_order_line5 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol != sale_order_line3 and sol.product_id == self.product_ordered_cost)
|
||||
sale_order_line6 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol != sale_order_line4 and sol.product_id == self.product_deliver_cost)
|
||||
|
||||
self.assertTrue(sale_order_line5, "A new sale line should have been created with ordered product")
|
||||
self.assertTrue(sale_order_line6, "A new sale line should have been created with delivered product")
|
||||
|
||||
self.assertEquals(len(self.sale_order.order_line), 6, "There should be still 4 lines on the SO, no new created")
|
||||
self.assertEquals(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 4, "There should be still 2 expenses lines on the SO")
|
||||
|
||||
self.assertEquals((sale_order_line5.price_unit, sale_order_line5.qty_delivered, sale_order_line5.product_uom_qty, sale_order_line5.qty_invoiced), (self.product_ordered_cost.standard_price, invoice_lineB1.quantity, 0, 0), 'Sale line 5 is wrong after confirming 2e vendor invoice')
|
||||
self.assertEquals((sale_order_line6.price_unit, sale_order_line6.qty_delivered, sale_order_line6.product_uom_qty, sale_order_line6.qty_invoiced), (self.product_deliver_cost.standard_price, invoice_lineB2.quantity, 0, 0), 'Sale line 6 is wrong after confirming 2e vendor invoice')
|
||||
|
||||
def test_sales_price(self):
|
||||
""" Test invoicing vendor bill at sales price for products based on delivered and ordered quantities. Check no existing SO line is incremented, but when invoicing a
|
||||
second time, increment only the delivered so line.
|
||||
"""
|
||||
# create SO line and confirm SO (with only one line)
|
||||
sale_order_line1 = self.env['sale.order.line'].create({
|
||||
'name': self.product_deliver_sales_price.name,
|
||||
'product_id': self.product_deliver_sales_price.id,
|
||||
'product_uom_qty': 2,
|
||||
'qty_delivered': 1,
|
||||
'product_uom': self.product_deliver_sales_price.uom_id.id,
|
||||
'price_unit': self.product_deliver_sales_price.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
sale_order_line1.product_id_change()
|
||||
sale_order_line2 = self.env['sale.order.line'].create({
|
||||
'name': self.product_order_sales_price.name,
|
||||
'product_id': self.product_order_sales_price.id,
|
||||
'product_uom_qty': 3,
|
||||
'qty_delivered': 1,
|
||||
'product_uom': self.product_order_sales_price.uom_id.id,
|
||||
'price_unit': self.product_order_sales_price.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
sale_order_line2.product_id_change()
|
||||
self.sale_order._compute_tax_id()
|
||||
self.sale_order.action_confirm()
|
||||
|
||||
# create invoice lines and validate it
|
||||
invoice_lineA1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_deliver_sales_price.name,
|
||||
'product_id': self.product_deliver_sales_price.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_deliver_sales_price.uom_id.id,
|
||||
'price_unit': self.product_deliver_sales_price.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
invoice_lineA2 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_order_sales_price.name,
|
||||
'product_id': self.product_order_sales_price.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_order_sales_price.uom_id.id,
|
||||
'price_unit': self.product_order_sales_price.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
self.invoiceA.action_invoice_open()
|
||||
|
||||
sale_order_line3 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol.product_id == self.product_deliver_sales_price)
|
||||
sale_order_line4 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol.product_id == self.product_order_sales_price)
|
||||
|
||||
self.assertTrue(sale_order_line3, "A new sale line should have been created with ordered product")
|
||||
self.assertTrue(sale_order_line4, "A new sale line should have been created with delivered product")
|
||||
self.assertEquals(len(self.sale_order.order_line), 4, "There should be 4 lines on the SO (2 vendor bill lines created)")
|
||||
self.assertEquals(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 2, "There should be 4 lines on the SO (2 vendor bill lines created)")
|
||||
|
||||
self.assertEquals(sale_order_line1.qty_delivered, 1, "Exising SO line 1 should not be impacted by reinvoicing product at cost")
|
||||
self.assertEquals(sale_order_line2.qty_delivered, 1, "Exising SO line 2 should not be impacted by reinvoicing product at cost")
|
||||
|
||||
self.assertEquals((sale_order_line3.price_unit, sale_order_line3.qty_delivered, sale_order_line3.product_uom_qty, sale_order_line3.qty_invoiced), (self.product_deliver_sales_price.list_price, invoice_lineA1.quantity, 0, 0), 'Sale line is wrong after confirming vendor invoice')
|
||||
self.assertEquals((sale_order_line4.price_unit, sale_order_line4.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line4.qty_invoiced), (self.product_order_sales_price.list_price, invoice_lineA2.quantity, 0, 0), 'Sale line is wrong after confirming vendor invoice')
|
||||
|
||||
self.assertEquals(sale_order_line3.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line 3 should be computed by analytic amount")
|
||||
self.assertEquals(sale_order_line4.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line 4 should be computed by analytic amount")
|
||||
|
||||
# create second invoice lines and validate it
|
||||
invoice_lineB1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_deliver_sales_price.name,
|
||||
'product_id': self.product_deliver_sales_price.id,
|
||||
'quantity': 2,
|
||||
'uom_id': self.product_deliver_sales_price.uom_id.id,
|
||||
'price_unit': self.product_deliver_sales_price.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceB.id,
|
||||
})
|
||||
invoice_lineB2 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_order_sales_price.name,
|
||||
'product_id': self.product_order_sales_price.id,
|
||||
'quantity': 2,
|
||||
'uom_id': self.product_order_sales_price.uom_id.id,
|
||||
'price_unit': self.product_order_sales_price.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceB.id,
|
||||
})
|
||||
self.invoiceB.action_invoice_open()
|
||||
|
||||
sale_order_line5 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol != sale_order_line3 and sol.product_id == self.product_deliver_sales_price)
|
||||
sale_order_line6 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol != sale_order_line4 and sol.product_id == self.product_order_sales_price)
|
||||
|
||||
self.assertFalse(sale_order_line5, "No new sale line should have been created with delivered product !!")
|
||||
self.assertTrue(sale_order_line6, "A new sale line should have been created with ordered product")
|
||||
|
||||
self.assertEquals(len(self.sale_order.order_line), 5, "There should be 5 lines on the SO, 1 new created and 1 incremented")
|
||||
self.assertEquals(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 3, "There should be 3 expenses lines on the SO")
|
||||
|
||||
self.assertEquals((sale_order_line6.price_unit, sale_order_line6.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line6.qty_invoiced), (self.product_order_sales_price.list_price, invoice_lineB2.quantity, 0, 0), 'Sale line is wrong after confirming 2e vendor invoice')
|
||||
|
||||
def test_no_expense(self):
|
||||
""" Test invoicing vendor bill with no policy. Check nothing happen. """
|
||||
# confirm SO
|
||||
sale_order_line = self.env['sale.order.line'].create({
|
||||
'name': self.product_no_expense.name,
|
||||
'product_id': self.product_no_expense.id,
|
||||
'product_uom_qty': 2,
|
||||
'qty_delivered': 1,
|
||||
'product_uom': self.product_no_expense.uom_id.id,
|
||||
'price_unit': self.product_no_expense.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
self.sale_order._compute_tax_id()
|
||||
self.sale_order.action_confirm()
|
||||
|
||||
# create invoice lines and validate it
|
||||
invoice_lineA1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_no_expense.name,
|
||||
'product_id': self.product_no_expense.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_no_expense.uom_id.id,
|
||||
'price_unit': self.product_no_expense.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
self.invoiceA.action_invoice_open()
|
||||
|
||||
self.assertEquals(len(self.sale_order.order_line), 1, "No SO line should have been created (or removed) when validating vendor bill")
|
||||
self.assertEquals(sale_order_line.qty_delivered, 1, "The delivered quantity of SO line should not have been incremented")
|
||||
self.assertTrue(invoice_lineA1.invoice_id.move_id.mapped('line_ids.analytic_line_ids'), "Analytic lines should be generated")
|
||||
@@ -143,7 +143,7 @@ class TestCommonSaleNoChart(TestAccountNoChartCommon):
|
||||
# Create an expense journal
|
||||
user_type_expense = cls.env.ref('account.data_account_type_expenses')
|
||||
cls.account_expense_for_products = cls.env['account.account'].create({
|
||||
'code': 'NC1113',
|
||||
'code': 'EXP_PROD13',
|
||||
'name': 'Expense - Test Purchase Account',
|
||||
'user_type_id': user_type_expense.id
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import odoo
|
||||
from odoo.exceptions import UserError, AccessError
|
||||
|
||||
from .test_sale_common import TestCommonSaleNoChart
|
||||
|
||||
@@ -247,7 +247,8 @@
|
||||
<field name="layout_category_id" groups="sale.group_sale_layout"/>
|
||||
<field name="invoice_status" invisible="1"/>
|
||||
<field name="qty_to_invoice" invisible="1"/>
|
||||
<field name="qty_delivered_updateable" invisible="1"/>
|
||||
<field name="qty_delivered_manual" invisible="1"/>
|
||||
<field name="qty_delivered_method" invisible="1"/>
|
||||
<field name="price_subtotal" invisible="1"/>
|
||||
<label for="product_uom_qty" string="Ordered Quantity"/>
|
||||
<div>
|
||||
@@ -259,7 +260,7 @@
|
||||
</div>
|
||||
<label for="qty_delivered" string="Delivered Quantity" attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}"/>
|
||||
<div attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}">
|
||||
<field name="qty_delivered" attrs="{'readonly': [('qty_delivered_updateable', '=', False)]}"/>
|
||||
<field name="qty_delivered" attrs="{'readonly': [('qty_delivered_method', '!=', 'manual')]}"/>
|
||||
</div>
|
||||
<label for="qty_invoiced" string="Invoiced Quantity" attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}"/>
|
||||
<div attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}">
|
||||
@@ -308,7 +309,9 @@
|
||||
context="{'partner_id':parent.partner_id, 'quantity':product_uom_qty, 'pricelist':parent.pricelist_id, 'uom':product_uom, 'company_id': parent.company_id}"
|
||||
/>
|
||||
<field name="qty_delivered"
|
||||
attrs="{'column_invisible': [('parent.state', 'not in', ['sale', 'done'])], 'readonly': [('qty_delivered_updateable', '=', False)]}"/>
|
||||
attrs="{'column_invisible': [('parent.state', 'not in', ['sale', 'done'])], 'readonly': [('qty_delivered_method', '!=', 'manual')]}"/>
|
||||
<field name="qty_delivered_manual" invisible="1"/>
|
||||
<field name="qty_delivered_method" invisible="1"/>
|
||||
<field name="qty_invoiced"
|
||||
attrs="{'column_invisible': [('parent.state', 'not in', ['sale', 'done'])]}"/>
|
||||
<field name="qty_to_invoice" invisible="1"/>
|
||||
@@ -324,7 +327,6 @@
|
||||
<field name="discount" groups="sale.group_discount_per_so_line"/>
|
||||
<field name="price_subtotal" widget="monetary" groups="sale.group_show_price_subtotal"/>
|
||||
<field name="price_total" widget="monetary" groups="sale.group_show_price_total"/>
|
||||
<field name="qty_delivered_updateable" invisible="1"/>
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="invoice_status" invisible="1"/>
|
||||
<field name="customer_lead" invisible="1"/>
|
||||
@@ -392,7 +394,7 @@
|
||||
<group string="Reporting" name="technical" groups="base.group_no_one">
|
||||
<field groups="base.group_no_one" name="origin"/>
|
||||
</group>
|
||||
<group name="utm_link" groups="base.group_no_one"/>
|
||||
<group name="utm_link" groups="base.group_no_one"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
@@ -686,6 +688,7 @@
|
||||
<group string="Invoicing" name="invoicing">
|
||||
<field name="invoice_policy" widget="radio"/>
|
||||
<field name="service_type" widget="radio" invisible="True"/>
|
||||
<field name="expense_policy" widget="radio"/>
|
||||
</group>
|
||||
</group>
|
||||
</field>
|
||||
|
||||
@@ -20,6 +20,7 @@ This module allow to reinvoice employee expense, by setting the SO directly on t
|
||||
'security/sale_expense_security.xml',
|
||||
'views/product_view.xml',
|
||||
'views/hr_expense_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
],
|
||||
'demo': ['sale_expense_demo.xml'],
|
||||
'test': [],
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
|
||||
from . import analytic
|
||||
from . import hr_expense
|
||||
from . import product_template
|
||||
from . import sale_order
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
||||
@api.onchange('can_be_expensed')
|
||||
def _onchange_can_be_expensed(self):
|
||||
if not self.can_be_expensed:
|
||||
self.expense_policy = 'no'
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
expense_ids = fields.One2many('hr.expense', 'sale_order_id', string='Expenses', domain=[('state', '=', 'done')], readonly=True, copy=False)
|
||||
expense_count = fields.Integer("# of Expenses", compute='_compute_expense_count')
|
||||
|
||||
@api.multi
|
||||
@api.depends('expense_ids')
|
||||
def _compute_expense_count(self):
|
||||
expense_data = self.env['hr.expense'].read_group([('sale_order_id', 'in', self.ids), ('state', '=', 'done')], ['sale_order_id'], ['sale_order_id'])
|
||||
mapped_data = dict([(item['sale_order_id'][0], item['sale_order_id_count']) for item in expense_data])
|
||||
for sale_order in self:
|
||||
sale_order.expense_count = mapped_data.get(sale_order.id, 0)
|
||||
@@ -76,12 +76,16 @@ class TestReInvoice(TestExpenseCommon, TestCommonSaleNoChart):
|
||||
self.assertEquals(sale_order_line.qty_delivered, 0, "Exising SO line should not be impacted by reinvoicing product at cost")
|
||||
|
||||
sol_ordered = self.sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_ordered_cost and sol != sale_order_line)
|
||||
self.assertTrue(sol_ordered.is_expense, "The new SOL should be flag as coming from an expense")
|
||||
self.assertEquals(sol_ordered.qty_delivered_method, 'analytic', 'Delivered quantity of expense SO line should be computed by analytic amount')
|
||||
self.assertTrue(sol_ordered, "A new line with ordered expense should have been created on expense report posting")
|
||||
self.assertEquals(sol_ordered.price_unit, expense1.unit_amount, "The unit price of new SO line should be the one from the expense (at cost)")
|
||||
self.assertEquals(sol_ordered.product_uom_qty, 0, "The ordered quantity of new SO line should be zero")
|
||||
self.assertEquals(sol_ordered.qty_delivered, expense1.quantity, "The delivered quantity of new SO line should be the one from the expense")
|
||||
|
||||
sol_deliver = self.sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_deliver_cost and sol != sale_order_line)
|
||||
self.assertTrue(sol_deliver.is_expense, "The new SOL should be flag as coming from an expense")
|
||||
self.assertEquals(sol_deliver.qty_delivered_method, 'analytic', 'Delivered quantity of expense SO line should be computed by analytic amount')
|
||||
self.assertTrue(sol_deliver, "A new line with delivered expense should have been created on expense report posting")
|
||||
self.assertEquals(sol_deliver.price_unit, expense2.unit_amount, "The unit price of new SO line should be the one from the expense (at cost)")
|
||||
self.assertEquals(sol_deliver.product_uom_qty, 0, "The ordered quantity of new SO line should be zero")
|
||||
@@ -116,15 +120,18 @@ class TestReInvoice(TestExpenseCommon, TestCommonSaleNoChart):
|
||||
self.assertEquals(len(self.sale_order.order_line), 1, "A new So line should have been created on expense report posting")
|
||||
|
||||
sol_ordered = self.sale_order.order_line.filtered(lambda sol: sol.product_id == expense1.product_id)
|
||||
self.assertTrue(sol_ordered.is_expense, "The new SOL should be flag as coming from an expense")
|
||||
self.assertTrue(sol_ordered, "A new line with ordered expense should have been created on expense report posting")
|
||||
self.assertEquals(sol_ordered.price_unit, 10, "The unit price of new SO line should be the one from the expense (at sales price)")
|
||||
self.assertEquals(sol_ordered.product_uom_qty, 0, "The ordered quantity of new SO line should be zero")
|
||||
self.assertEquals(sol_ordered.qty_delivered, expense1.quantity, "The delivered quantity of new SO line should be the one from the expense")
|
||||
|
||||
def test_sales_price_delivered(self):
|
||||
""" Test invoicing expenses at sales price for product based on delivered quantities. Check the existing SO line is incremented. """
|
||||
""" Test invoicing expenses at sales price for product based on delivered quantities. Check the existing SO line is not incremented, but a new
|
||||
expense SOL will be created and incremented.
|
||||
"""
|
||||
# create SO line and confirm SO (with only one line)
|
||||
sale_order_line = self.env['sale.order.line'].create({
|
||||
sale_order_line1 = self.env['sale.order.line'].create({
|
||||
'name': self.product_deliver_sales_price.name,
|
||||
'product_id': self.product_deliver_sales_price.id,
|
||||
'product_uom_qty': 2,
|
||||
@@ -132,7 +139,7 @@ class TestReInvoice(TestExpenseCommon, TestCommonSaleNoChart):
|
||||
'price_unit': self.product_deliver_sales_price.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
sale_order_line.product_id_change()
|
||||
sale_order_line1.product_id_change()
|
||||
self.sale_order._compute_tax_id()
|
||||
self.sale_order.action_confirm()
|
||||
|
||||
@@ -155,11 +162,36 @@ class TestReInvoice(TestExpenseCommon, TestCommonSaleNoChart):
|
||||
self.expense_sheet.approve_expense_sheets()
|
||||
self.expense_sheet.action_sheet_move_create()
|
||||
|
||||
self.assertEquals(len(self.sale_order.order_line), 1, "No SO line should have been created (or removed) on expense report posting")
|
||||
self.assertEquals(len(self.sale_order.order_line), 2, "A new expense SOL should have been created.")
|
||||
|
||||
self.assertEquals(sale_order_line.price_unit, 10, "The unit price of SO line should be the same")
|
||||
self.assertEquals(sale_order_line.product_uom_qty, 2, "The ordered quantity of new SO line should be zero")
|
||||
self.assertEquals(sale_order_line.qty_delivered, expense1.quantity, "The delivered quantity of SO line should have been incremented")
|
||||
sale_order_line2 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1)
|
||||
self.assertTrue(sale_order_line2.is_expense, "The new SOL should be flag as coming from an expense")
|
||||
self.assertEquals(sale_order_line2.price_unit, 10, "The unit price of SO line should be the same")
|
||||
self.assertEquals(sale_order_line2.product_uom_qty, 0, "The ordered quantity of new SO line should be zero")
|
||||
self.assertEquals(sale_order_line2.qty_delivered, expense1.quantity, "The delivered quantity of SO line should have be the same of the expense")
|
||||
|
||||
# create 2nd expense lines
|
||||
expense2 = self.env['hr.expense'].create({
|
||||
'name': 'Expense for delivered product at sales price',
|
||||
'employee_id': self.employee.id,
|
||||
'product_id': self.product_deliver_sales_price.id,
|
||||
'unit_amount': 15,
|
||||
'quantity': 3,
|
||||
'sheet_id': self.expense_sheet2.id,
|
||||
'sale_order_id': self.sale_order.id,
|
||||
'analytic_account_id': self.sale_order.analytic_account_id.id,
|
||||
})
|
||||
expense2._onchange_product_id()
|
||||
|
||||
# approve and generate entries
|
||||
self.expense_sheet2.approve_expense_sheets()
|
||||
self.expense_sheet2.action_sheet_move_create()
|
||||
|
||||
self.assertEquals(len(self.sale_order.order_line), 2, "No new expense SOL should have been created.")
|
||||
|
||||
self.assertEquals(sale_order_line2.price_unit, 10, "The unit price of SO line should be the same")
|
||||
self.assertEquals(sale_order_line2.product_uom_qty, 0, "The ordered quantity of new SO line should remains zero")
|
||||
self.assertEquals(sale_order_line2.qty_delivered, expense1.quantity + expense2.quantity, "The delivered quantity of SO line should have been incremented")
|
||||
|
||||
def test_no_expense(self):
|
||||
""" Test invoicing expenses with no policy. Check nothing happen. """
|
||||
|
||||
@@ -21,4 +21,12 @@
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_expense_action_from_sale_order" model="ir.actions.act_window">
|
||||
<field name="name">Expenses</field>
|
||||
<field name="res_model">hr.expense</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="domain">[('sale_order_id', '=', active_id), ('state', '=', 'done')]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record model="ir.ui.view" id="product_template_form_view_expense_policy">
|
||||
<field name="name">product.template.expense.policy</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="sale.product_template_form_view_invoice_policy"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="service_type" position="after">
|
||||
<field name="expense_policy" widget="radio"
|
||||
attrs="{'invisible': [('can_be_expensed','=',False)]}"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_product_view_form_inherit_sale_expense" model="ir.ui.view">
|
||||
<field name="name">product.template.expense</field>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?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.sale.expense</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<data>
|
||||
<xpath expr="//button[@name='action_view_invoice']" position="before">
|
||||
<button type="action"
|
||||
name="%(sale_expense.hr_expense_action_from_sale_order)d"
|
||||
class="oe_stat_button"
|
||||
icon="fa-money"
|
||||
attrs="{'invisible': [('expense_count', '=', 0)]}">
|
||||
<field name="expense_count" widget="statinfo" string="Expenses"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</data>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -9,19 +9,20 @@ class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
@api.multi
|
||||
def _get_delivered_qty(self):
|
||||
self.ensure_one()
|
||||
def _compute_qty_delivered(self):
|
||||
super(SaleOrderLine, self)._compute_qty_delivered()
|
||||
|
||||
# In the case of a kit, we need to check if all components are shipped. Since the BOM might
|
||||
# have changed, we don't compute the quantities but verify the move state.
|
||||
bom = self.env['mrp.bom']._bom_find(product=self.product_id)
|
||||
if bom and bom.type == 'phantom':
|
||||
bom_delivered = all([move.state == 'done' for move in self.move_ids])
|
||||
if bom_delivered:
|
||||
return self.product_uom_qty
|
||||
else:
|
||||
return 0.0
|
||||
return super(SaleOrderLine, self)._get_delivered_qty()
|
||||
for line in self:
|
||||
if line.qty_delivered_method == 'stock_move':
|
||||
# In the case of a kit, we need to check if all components are shipped. Since the BOM might
|
||||
# have changed, we don't compute the quantities but verify the move state.
|
||||
bom = self.env['mrp.bom']._bom_find(product=line.product_id)
|
||||
if bom and bom.type == 'phantom':
|
||||
bom_delivered = all([move.state == 'done' for move in line.move_ids])
|
||||
if bom_delivered:
|
||||
line.qty_delivered = line.product_uom_qty
|
||||
else:
|
||||
line.qty_delivered = 0.0
|
||||
|
||||
@api.multi
|
||||
def _get_bom_component_qty(self, bom):
|
||||
|
||||
@@ -34,6 +34,7 @@ You can choose flexible invoicing methods:
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/account_invoice_views.xml',
|
||||
'views/sale_stock_portal_template.xml',
|
||||
'views/product_template_views.xml',
|
||||
'report/sale_order_report_templates.xml',
|
||||
'report/stock_report_deliveryslip.xml',
|
||||
'data/sale_stock_data.xml',
|
||||
|
||||
@@ -6,3 +6,4 @@ from . import res_company
|
||||
from . import sale_order
|
||||
from . import res_config_settings
|
||||
from . import stock
|
||||
from . import product_template
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = 'product.template'
|
||||
|
||||
@api.onchange('type')
|
||||
def _onchange_type(self):
|
||||
""" We want to prevent stockable product to be expensed, since it make no sense as when confirm
|
||||
expenses, the product is already out of our stock.
|
||||
"""
|
||||
super(ProductTemplate, self)._onchange_type()
|
||||
if self.type == 'product':
|
||||
self.expense_policy = 'no'
|
||||
self.service_type = 'manual'
|
||||
@@ -86,10 +86,40 @@ class SaleOrder(models.Model):
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
|
||||
product_packaging = fields.Many2one('product.packaging', string='Package', default=False)
|
||||
route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict')
|
||||
move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
|
||||
|
||||
@api.multi
|
||||
@api.depends('product_id.type')
|
||||
def _compute_qty_delivered_method(self):
|
||||
""" Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])]
|
||||
For SO line coming from expense, no picking should be generate: we don't manage stock for
|
||||
thoses lines, even if the product is a stockable.
|
||||
"""
|
||||
super(SaleOrderLine, self)._compute_qty_delivered_method()
|
||||
|
||||
for line in self:
|
||||
if not line.is_expense and line.product_id.type in ['consu', 'product']:
|
||||
line.qty_delivered_method = 'stock_move'
|
||||
|
||||
@api.multi
|
||||
@api.depends('move_ids.state', 'move_ids.scrapped', 'move_ids.product_uom_qty', 'move_ids.product_uom')
|
||||
def _compute_qty_delivered(self):
|
||||
super(SaleOrderLine, self)._compute_qty_delivered()
|
||||
|
||||
for line in self: # TODO: maybe one day, this should be done in SQL for performance sake
|
||||
if line.qty_delivered_method == 'stock_move':
|
||||
qty = 0.0
|
||||
for move in line.move_ids.filtered(lambda r: r.state == 'done' and not r.scrapped):
|
||||
if move.location_dest_id.usage == "customer":
|
||||
if not move.origin_returned_move_id:
|
||||
qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
|
||||
elif move.location_dest_id.usage != "customer" and move.to_refund:
|
||||
qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
|
||||
line.qty_delivered = qty
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
line = super(SaleOrderLine, self).create(values)
|
||||
@@ -103,12 +133,11 @@ class SaleOrderLine(models.Model):
|
||||
if 'product_uom_qty' in values:
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
lines = self.filtered(
|
||||
lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1)
|
||||
lambda r: r.state == 'sale' and not r.is_expense and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1)
|
||||
res = super(SaleOrderLine, self).write(values)
|
||||
if lines:
|
||||
lines._action_launch_procurement_rule()
|
||||
return res
|
||||
|
||||
|
||||
@api.depends('order_id.state')
|
||||
def _compute_invoice_status(self):
|
||||
@@ -134,17 +163,6 @@ class SaleOrderLine(models.Model):
|
||||
else:
|
||||
line.product_updatable = False
|
||||
|
||||
@api.multi
|
||||
@api.depends('product_id')
|
||||
def _compute_qty_delivered_updateable(self):
|
||||
# prefetch field before filtering
|
||||
self.mapped('product_id')
|
||||
# on consumable or stockable products, qty_delivered_updateable defaults
|
||||
# to False; on other lines use the original computation
|
||||
lines = self.filtered(lambda line: line.product_id.type not in ('consu', 'product'))
|
||||
lines = lines.with_prefetch(self._prefetch)
|
||||
super(SaleOrderLine, lines)._compute_qty_delivered_updateable()
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id_set_customer_lead(self):
|
||||
self.customer_lead = self.product_id.sale_delay
|
||||
@@ -271,19 +289,6 @@ class SaleOrderLine(models.Model):
|
||||
raise UserError('\n'.join(errors))
|
||||
return True
|
||||
|
||||
@api.multi
|
||||
def _get_delivered_qty(self):
|
||||
self.ensure_one()
|
||||
super(SaleOrderLine, self)._get_delivered_qty()
|
||||
qty = 0.0
|
||||
for move in self.move_ids.filtered(lambda r: r.state == 'done' and not r.scrapped):
|
||||
if move.location_dest_id.usage == "customer":
|
||||
if not move.origin_returned_move_id:
|
||||
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom)
|
||||
elif move.location_dest_id.usage != "customer" and move.to_refund:
|
||||
qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom)
|
||||
return qty
|
||||
|
||||
@api.multi
|
||||
def _check_package(self):
|
||||
default_uom = self.product_id.uom_id
|
||||
|
||||
@@ -26,23 +26,6 @@ class StockMove(models.Model):
|
||||
keys_sorted.append(move.sale_line_id.id)
|
||||
return keys_sorted
|
||||
|
||||
def _action_done(self):
|
||||
result = super(StockMove, self)._action_done()
|
||||
for line in self.mapped('sale_line_id'):
|
||||
line.qty_delivered = line._get_delivered_qty()
|
||||
return result
|
||||
|
||||
@api.multi
|
||||
def write(self, vals):
|
||||
res = super(StockMove, self).write(vals)
|
||||
if 'product_uom_qty' in vals:
|
||||
for move in self:
|
||||
if move.state == 'done':
|
||||
sale_order_lines = self.filtered(lambda move: move.sale_line_id).mapped('sale_line_id')
|
||||
for line in sale_order_lines:
|
||||
line.qty_delivered = line._get_delivered_qty()
|
||||
return res
|
||||
|
||||
|
||||
class ProcurementGroup(models.Model):
|
||||
_inherit = 'procurement.group'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="product_template_view_form_view_inherit_invoice_policy" model="ir.ui.view">
|
||||
<field name="name">product.template.form.expense.policy.inherit</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="sale.product_template_form_view_invoice_policy"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='expense_policy']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': [('type','=', 'product')]}</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -3,11 +3,16 @@
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
class AccountAnalyticLine(models.Model):
|
||||
_inherit = 'account.analytic.line'
|
||||
|
||||
def _default_sale_line_domain(self):
|
||||
domain = super(AccountAnalyticLine, self)._default_sale_line_domain()
|
||||
return expression.OR([domain, [('qty_delivered_method', '=', 'timesheet')]])
|
||||
|
||||
timesheet_invoice_type = fields.Selection([
|
||||
('billable_time', 'Billable Time'),
|
||||
('billable_fixed', 'Billable Fixed'),
|
||||
|
||||
@@ -56,9 +56,7 @@ class ProductTemplate(models.Model):
|
||||
|
||||
@api.onchange('type')
|
||||
def _onchange_type(self):
|
||||
super(ProductTemplate, self)._onchange_type()
|
||||
if self.type == 'service':
|
||||
self.invoice_policy = 'order'
|
||||
self.service_type = 'timesheet'
|
||||
elif self.type == 'consu':
|
||||
self.invoice_policy = 'order'
|
||||
self.service_type = 'manual'
|
||||
|
||||
@@ -8,7 +8,7 @@ from odoo.exceptions import ValidationError
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line', readonly=True, help="Sale order line from which the project has been created. Used for tracability.")
|
||||
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line', domain=[('is_expense', '=', False)], readonly=True, help="Sale order line from which the project has been created. Used for tracability.")
|
||||
|
||||
@api.multi
|
||||
def action_view_timesheet(self):
|
||||
@@ -56,7 +56,15 @@ class Project(models.Model):
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id)]")
|
||||
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False)]")
|
||||
|
||||
@api.multi
|
||||
@api.constrains('sale_line_id')
|
||||
def _check_sale_line_type(self):
|
||||
for task in self:
|
||||
if task.sale_line_id:
|
||||
if not task.sale_line_id.is_service or task.sale_line_id.is_expense:
|
||||
raise ValidationError(_("The Sales order line should be one selling a service, and no coming from expense."))
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
|
||||
@@ -136,8 +136,35 @@ class SaleOrder(models.Model):
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = "sale.order.line"
|
||||
|
||||
qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
|
||||
task_id = fields.Many2one('project.task', 'Task', help="Task generated by the sales order item")
|
||||
is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.")
|
||||
analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense)
|
||||
|
||||
@api.multi
|
||||
@api.depends('product_id.type', 'product_id.service_type')
|
||||
def _compute_qty_delivered_method(self):
|
||||
""" Sale Timesheet module compute delivered qty for product [('type', 'in', ['service']), ('service_type', '=', 'timesheet')] """
|
||||
super(SaleOrderLine, self)._compute_qty_delivered_method()
|
||||
for line in self:
|
||||
if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet':
|
||||
line.qty_delivered_method = 'timesheet'
|
||||
|
||||
@api.multi
|
||||
@api.depends('analytic_line_ids.project_id')
|
||||
def _compute_qty_delivered(self):
|
||||
super(SaleOrderLine, self)._compute_qty_delivered()
|
||||
|
||||
lines_by_timesheet = self.filtered(lambda sol: sol.qty_delivered_method == 'timesheet')
|
||||
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
|
||||
mapping = lines_by_timesheet._get_delivered_quantity_by_analytic(domain)
|
||||
for line in lines_by_timesheet:
|
||||
line.qty_delivered = mapping.get(line.id, 0.0)
|
||||
|
||||
@api.multi
|
||||
def _timesheet_compute_delivered_quantity_domain(self):
|
||||
""" Hook for validated timesheet in addionnal module """
|
||||
return [('project_id', '!=', False)]
|
||||
|
||||
@api.multi
|
||||
@api.depends('product_id.type')
|
||||
@@ -156,28 +183,12 @@ class SaleOrderLine(models.Model):
|
||||
@api.model
|
||||
def create(self, values):
|
||||
line = super(SaleOrderLine, self).create(values)
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
# check ordered quantity to avoid create project/task when expensing service products
|
||||
if line.state == 'sale' and not float_is_zero(line.product_uom_qty, precision_digits=precision):
|
||||
# Do not generate task/project when expense SO line, but allow
|
||||
# generate task with hours=0.
|
||||
if line.state == 'sale' and not line.is_expense:
|
||||
line._timesheet_service_generation()
|
||||
return line
|
||||
|
||||
###########################################
|
||||
### Analytic : auto recompute delivered quantity
|
||||
###########################################
|
||||
|
||||
def _timesheet_compute_delivered_quantity_domain(self):
|
||||
# TODO JEM: avoid increment delivered for all AAL or just timesheet ?
|
||||
# see nim commit https://github.com/odoo/odoo/commit/21fbb9776a5fbd1838b189f1f7cf8c5d40663e14
|
||||
so_line_ids = self.filtered(lambda sol: sol.product_id.service_type != 'manual').ids
|
||||
return ['&', ('so_line', 'in', so_line_ids), ('project_id', '!=', False)]
|
||||
|
||||
@api.multi
|
||||
def _analytic_compute_delivered_quantity_domain(self):
|
||||
domain = super(SaleOrderLine, self)._analytic_compute_delivered_quantity_domain()
|
||||
timesheet_domain = self._timesheet_compute_delivered_quantity_domain()
|
||||
return expression.OR([domain, timesheet_domain])
|
||||
|
||||
###########################################
|
||||
## Service : Project and task generation
|
||||
###########################################
|
||||
|
||||
@@ -5,3 +5,4 @@ from . import common
|
||||
from . import test_sale_timesheet
|
||||
from . import test_sale_service
|
||||
from . import test_timesheet_revenue
|
||||
from . import test_reinvoice
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests import common
|
||||
from odoo.addons.sale.tests.test_sale_common import TestCommonSaleNoChart
|
||||
|
||||
|
||||
class CommonTest(common.SavepointCase):
|
||||
class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CommonTest, cls).setUpClass()
|
||||
def setUpEmployees(cls):
|
||||
# Create employees
|
||||
cls.employee_user = cls.env['hr.employee'].create({
|
||||
'name': 'Employee User',
|
||||
'timesheet_cost': 15,
|
||||
})
|
||||
cls.employee_manager = cls.env['hr.employee'].create({
|
||||
'name': 'Employee Manager',
|
||||
'timesheet_cost': 45,
|
||||
})
|
||||
|
||||
# Create accounts
|
||||
cls.account_debit = cls.env['account.account'].create({
|
||||
'code': 'X1012',
|
||||
'name': 'Debtors - (test)',
|
||||
'reconcile': True,
|
||||
'user_type_id': cls.env.ref('account.data_account_type_receivable').id,
|
||||
})
|
||||
cls.account_credit = cls.env['account.account'].create({
|
||||
'code': 'X1111',
|
||||
'name': 'Creditors - (test)',
|
||||
'reconcile': True,
|
||||
'user_type_id': cls.env.ref('account.data_account_type_payable').id,
|
||||
})
|
||||
@classmethod
|
||||
def setUpServiceProducts(cls):
|
||||
""" Create Service product for all kind, with each tracking policy. """
|
||||
# Account and project
|
||||
cls.account_sale = cls.env['account.account'].create({
|
||||
'code': 'X2020',
|
||||
'code': 'SERV-2020',
|
||||
'name': 'Product Sales - (test)',
|
||||
'reconcile': True,
|
||||
'user_type_id': cls.env.ref('account.data_account_type_revenue').id,
|
||||
})
|
||||
|
||||
# Create project
|
||||
cls.project_global = cls.env['project.project'].create({
|
||||
'name': 'Project for selling timesheets',
|
||||
'allow_timesheets': True,
|
||||
})
|
||||
|
||||
# Create service products
|
||||
uom_hour = cls.env.ref('product.product_uom_hour')
|
||||
|
||||
# -- ordered quantities (ordered, timesheet)
|
||||
cls.product_order_timesheet1 = cls.env['product.product'].create({
|
||||
@@ -45,8 +44,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 13,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'order',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-ORDERED1',
|
||||
'service_type': 'timesheet',
|
||||
'service_tracking': 'no',
|
||||
@@ -60,8 +59,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 90,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'order',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-ORDERED2',
|
||||
'service_type': 'timesheet',
|
||||
'service_tracking': 'task_global_project',
|
||||
@@ -75,8 +74,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 20,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'order',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-ORDERED3',
|
||||
'service_type': 'timesheet',
|
||||
'service_tracking': 'task_new_project',
|
||||
@@ -90,8 +89,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 30,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'order',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-ORDERED4',
|
||||
'service_type': 'timesheet',
|
||||
'service_tracking': 'project_only',
|
||||
@@ -107,8 +106,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 13,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'delivery',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-DELI1',
|
||||
'service_type': 'timesheet',
|
||||
'service_tracking': 'no',
|
||||
@@ -122,8 +121,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 90,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'delivery',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-DELI2',
|
||||
'service_type': 'timesheet',
|
||||
'service_tracking': 'task_global_project',
|
||||
@@ -137,8 +136,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 20,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'delivery',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-DELI3',
|
||||
'service_type': 'timesheet',
|
||||
'service_tracking': 'task_new_project',
|
||||
@@ -152,8 +151,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 30,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'delivery',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-DELI4',
|
||||
'service_type': 'timesheet',
|
||||
'service_tracking': 'project_only',
|
||||
@@ -162,15 +161,15 @@ class CommonTest(common.SavepointCase):
|
||||
'property_account_income_id': cls.account_sale.id,
|
||||
})
|
||||
|
||||
# -- milestons (delivered, manual)
|
||||
# -- milestones (delivered, manual)
|
||||
cls.product_delivery_manual1 = cls.env['product.product'].create({
|
||||
'name': "Service delivered, create no task",
|
||||
'standard_price': 11,
|
||||
'list_price': 13,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'delivery',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-DELI1',
|
||||
'service_type': 'manual',
|
||||
'service_tracking': 'no',
|
||||
@@ -184,8 +183,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 90,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'delivery',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-DELI2',
|
||||
'service_type': 'manual',
|
||||
'service_tracking': 'task_global_project',
|
||||
@@ -199,8 +198,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 20,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'delivery',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-DELI3',
|
||||
'service_type': 'manual',
|
||||
'service_tracking': 'task_new_project',
|
||||
@@ -214,8 +213,8 @@ class CommonTest(common.SavepointCase):
|
||||
'list_price': 30,
|
||||
'type': 'service',
|
||||
'invoice_policy': 'delivery',
|
||||
'uom_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
|
||||
'uom_id': uom_hour.id,
|
||||
'uom_po_id': uom_hour.id,
|
||||
'default_code': 'SERV-DELI4',
|
||||
'service_type': 'manual',
|
||||
'service_tracking': 'project_only',
|
||||
@@ -223,58 +222,3 @@ class CommonTest(common.SavepointCase):
|
||||
'taxes_id': False,
|
||||
'property_account_income_id': cls.account_sale.id,
|
||||
})
|
||||
|
||||
# Create pricelists
|
||||
cls.pricelist_usd = cls.env['product.pricelist'].create({
|
||||
'name': 'USD pricelist',
|
||||
'active': True,
|
||||
'currency_id': cls.env.ref('base.USD').id,
|
||||
'company_id': cls.env.user.company_id.id,
|
||||
})
|
||||
|
||||
cls.bank_journal_euro = cls.env['account.journal'].create({
|
||||
'name': 'Sale Journal - Test',
|
||||
'type': 'sale',
|
||||
'code': 'SJT',
|
||||
'currency_id': cls.env.ref('base.EUR').id,
|
||||
})
|
||||
|
||||
cls.bank_journal_usd = cls.env['account.journal'].create({
|
||||
'name': 'Sale Journal - Test US',
|
||||
'type': 'sale',
|
||||
'code': 'SJTU',
|
||||
'currency_id': cls.env.ref('base.USD').id,
|
||||
})
|
||||
|
||||
cls.pricelist_eur = cls.env['product.pricelist'].create({
|
||||
'name': 'EUR pricelist',
|
||||
'active': True,
|
||||
'currency_id': cls.env.ref('base.EUR').id,
|
||||
'company_id': cls.env.user.company_id.id,
|
||||
})
|
||||
|
||||
# Create partners
|
||||
cls.partner_usd = cls.env['res.partner'].create({
|
||||
'name': 'Cool Partner in USD',
|
||||
'email': 'partner.usd@test.com',
|
||||
'property_product_pricelist': cls.pricelist_usd.id,
|
||||
'property_account_payable_id': cls.account_credit.id,
|
||||
'property_account_receivable_id': cls.account_debit.id,
|
||||
})
|
||||
cls.partner_eur = cls.env['res.partner'].create({
|
||||
'name': 'Cool partner in EUR',
|
||||
'email': 'partner.eur@test.com',
|
||||
'property_product_pricelist': cls.pricelist_eur.id,
|
||||
'property_account_payable_id': cls.account_credit.id,
|
||||
'property_account_receivable_id': cls.account_debit.id,
|
||||
})
|
||||
|
||||
# Create employees
|
||||
cls.employee_user = cls.env['hr.employee'].create({
|
||||
'name': 'Employee User',
|
||||
'timesheet_cost': 15,
|
||||
})
|
||||
cls.employee_manager = cls.env['hr.employee'].create({
|
||||
'name': 'Employee Manager',
|
||||
'timesheet_cost': 45,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheetNoChart
|
||||
|
||||
|
||||
class TestReInvoice(TestCommonSaleTimesheetNoChart):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestReInvoice, cls).setUpClass()
|
||||
|
||||
cls.setUpEmployees()
|
||||
cls.setUpExpenseProducts()
|
||||
cls.setUpAdditionalAccounts()
|
||||
cls.setUpAccountJournal()
|
||||
|
||||
# patch expense products to make them services creating task/project
|
||||
service_values = {
|
||||
'type': 'service',
|
||||
'service_type': 'timesheet',
|
||||
'service_tracking': 'task_new_project'
|
||||
}
|
||||
cls.product_ordered_cost.write(service_values)
|
||||
cls.product_deliver_cost.write(service_values)
|
||||
cls.product_order_sales_price.write(service_values)
|
||||
cls.product_deliver_sales_price.write(service_values)
|
||||
cls.product_no_expense.write(service_values)
|
||||
|
||||
# create AA, SO and invoices
|
||||
cls.analytic_account = cls.env['account.analytic.account'].create({
|
||||
'name': 'Test AA',
|
||||
'code': 'TESTSALE_TIMESHEET_REINVOICE',
|
||||
'company_id': cls.partner_customer_usd.company_id.id,
|
||||
'partner_id': cls.partner_customer_usd.id
|
||||
})
|
||||
|
||||
cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
|
||||
'partner_id': cls.partner_customer_usd.id,
|
||||
'partner_invoice_id': cls.partner_customer_usd.id,
|
||||
'partner_shipping_id': cls.partner_customer_usd.id,
|
||||
'analytic_account_id': cls.analytic_account.id,
|
||||
})
|
||||
|
||||
Invoice = cls.env['account.invoice'].with_context(mail_notrack=True, mail_create_nolog=True)
|
||||
cls.invoiceA = Invoice.create({
|
||||
'name': 'Test Invoice',
|
||||
'type': 'in_invoice',
|
||||
'partner_id': cls.partner_customer_usd.id,
|
||||
'account_id': cls.account_payable.id,
|
||||
'journal_id': cls.journal_purchase.id,
|
||||
'currency_id': cls.env.user.company_id.currency_id.id,
|
||||
})
|
||||
cls.invoiceB = Invoice.create({
|
||||
'name': 'Test Invoice 2',
|
||||
'type': 'in_invoice',
|
||||
'partner_id': cls.partner_customer_usd.id,
|
||||
'account_id': cls.account_payable.id,
|
||||
'journal_id': cls.journal_purchase.id,
|
||||
'currency_id': cls.env.user.company_id.currency_id.id,
|
||||
})
|
||||
|
||||
def test_at_cost(self):
|
||||
""" Test vendor bill at cost for product based on ordered and delivered quantities. """
|
||||
# create SO line and confirm SO (with only one line)
|
||||
sale_order_line1 = self.env['sale.order.line'].create({
|
||||
'name': self.product_ordered_cost.name,
|
||||
'product_id': self.product_ordered_cost.id,
|
||||
'product_uom_qty': 2,
|
||||
'product_uom': self.product_ordered_cost.uom_id.id,
|
||||
'price_unit': self.product_ordered_cost.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
sale_order_line1.product_id_change()
|
||||
sale_order_line2 = self.env['sale.order.line'].create({
|
||||
'name': self.product_deliver_cost.name,
|
||||
'product_id': self.product_deliver_cost.id,
|
||||
'product_uom_qty': 4,
|
||||
'product_uom': self.product_deliver_cost.uom_id.id,
|
||||
'price_unit': self.product_deliver_cost.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
sale_order_line2.product_id_change()
|
||||
|
||||
self.sale_order.onchange_partner_id()
|
||||
self.sale_order._compute_tax_id()
|
||||
self.sale_order.action_confirm()
|
||||
|
||||
self.assertEquals(sale_order_line1.qty_delivered_method, 'timesheet', "Delivered quantity of 'service' SO line should be computed by timesheet amount")
|
||||
self.assertEquals(sale_order_line2.qty_delivered_method, 'timesheet', "Delivered quantity of 'service' SO line should be computed by timesheet amount")
|
||||
|
||||
# let's log some timesheets (on the project created by sale_order_line1)
|
||||
task_sol1 = sale_order_line1.task_id
|
||||
self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line',
|
||||
'project_id': task_sol1.project_id.id,
|
||||
'task_id': task_sol1.id,
|
||||
'unit_amount': 1,
|
||||
'employee_id': self.employee_user.id,
|
||||
})
|
||||
|
||||
# create invoice lines and validate it
|
||||
invoice_lineA1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_ordered_cost.name,
|
||||
'product_id': self.product_ordered_cost.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_ordered_cost.uom_id.id,
|
||||
'price_unit': self.product_ordered_cost.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
invoice_lineA2 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_deliver_cost.name,
|
||||
'product_id': self.product_deliver_cost.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_deliver_cost.uom_id.id,
|
||||
'price_unit': self.product_deliver_cost.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
self.invoiceA.action_invoice_open()
|
||||
|
||||
sale_order_line3 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol.product_id == self.product_ordered_cost)
|
||||
sale_order_line4 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol.product_id == self.product_deliver_cost)
|
||||
|
||||
self.assertTrue(sale_order_line3, "A new sale line should have been created with ordered product")
|
||||
self.assertTrue(sale_order_line4, "A new sale line should have been created with delivered product")
|
||||
self.assertEquals(len(self.sale_order.order_line), 4, "There should be 4 lines on the SO (2 vendor bill lines created)")
|
||||
self.assertEquals(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 2, "There should be 4 lines on the SO (2 vendor bill lines created)")
|
||||
|
||||
self.assertEquals(sale_order_line1.qty_delivered, 1, "Exising SO line 1 should not be impacted by reinvoicing product at cost")
|
||||
self.assertEquals(sale_order_line2.qty_delivered, 0, "Exising SO line 2 should not be impacted by reinvoicing product at cost")
|
||||
|
||||
self.assertFalse(sale_order_line3.task_id, "Adding a new expense SO line should not create a task (sol3)")
|
||||
self.assertFalse(sale_order_line4.task_id, "Adding a new expense SO line should not create a task (sol4)")
|
||||
self.assertEquals(len(self.sale_order.project_ids), 1, "SO create only one project with its service line. Adding new expense SO line should not impact that")
|
||||
|
||||
self.assertEquals((sale_order_line3.price_unit, sale_order_line3.qty_delivered, sale_order_line3.product_uom_qty, sale_order_line3.qty_invoiced), (self.product_ordered_cost.standard_price, invoice_lineA1.quantity, 0, 0), 'Sale line is wrong after confirming vendor invoice')
|
||||
self.assertEquals((sale_order_line4.price_unit, sale_order_line4.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line4.qty_invoiced), (self.product_deliver_cost.standard_price, invoice_lineA2.quantity, 0, 0), 'Sale line is wrong after confirming vendor invoice')
|
||||
|
||||
self.assertEquals(sale_order_line3.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line should be computed by analytic amount")
|
||||
self.assertEquals(sale_order_line4.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line should be computed by analytic amount")
|
||||
|
||||
# create second invoice lines and validate it
|
||||
invoice_lineB1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_ordered_cost.name,
|
||||
'product_id': self.product_ordered_cost.id,
|
||||
'quantity': 2,
|
||||
'uom_id': self.product_ordered_cost.uom_id.id,
|
||||
'price_unit': self.product_ordered_cost.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceB.id,
|
||||
})
|
||||
invoice_lineB2 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_deliver_cost.name,
|
||||
'product_id': self.product_deliver_cost.id,
|
||||
'quantity': 2,
|
||||
'uom_id': self.product_deliver_cost.uom_id.id,
|
||||
'price_unit': self.product_deliver_cost.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceB.id,
|
||||
})
|
||||
self.invoiceB.action_invoice_open()
|
||||
|
||||
sale_order_line5 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol != sale_order_line3 and sol.product_id == self.product_ordered_cost)
|
||||
sale_order_line6 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol != sale_order_line4 and sol.product_id == self.product_deliver_cost)
|
||||
|
||||
self.assertTrue(sale_order_line5, "A new sale line should have been created with ordered product")
|
||||
self.assertTrue(sale_order_line6, "A new sale line should have been created with delivered product")
|
||||
|
||||
self.assertEquals(len(self.sale_order.order_line), 6, "There should be still 4 lines on the SO, no new created")
|
||||
self.assertEquals(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 4, "There should be still 2 expenses lines on the SO")
|
||||
|
||||
self.assertEquals((sale_order_line5.price_unit, sale_order_line5.qty_delivered, sale_order_line5.product_uom_qty, sale_order_line5.qty_invoiced), (self.product_ordered_cost.standard_price, invoice_lineB1.quantity, 0, 0), 'Sale line 5 is wrong after confirming 2e vendor invoice')
|
||||
self.assertEquals((sale_order_line6.price_unit, sale_order_line6.qty_delivered, sale_order_line6.product_uom_qty, sale_order_line6.qty_invoiced), (self.product_deliver_cost.standard_price, invoice_lineB2.quantity, 0, 0), 'Sale line 6 is wrong after confirming 2e vendor invoice')
|
||||
|
||||
def test_sales_price(self):
|
||||
""" Test invoicing vendor bill at sales price for products based on delivered and ordered quantities. Check no existing SO line is incremented, but when invoicing a
|
||||
second time, increment only the delivered so line.
|
||||
"""
|
||||
# create SO line and confirm SO (with only one line)
|
||||
sale_order_line1 = self.env['sale.order.line'].create({
|
||||
'name': self.product_deliver_sales_price.name,
|
||||
'product_id': self.product_deliver_sales_price.id,
|
||||
'product_uom_qty': 2,
|
||||
'qty_delivered': 1,
|
||||
'product_uom': self.product_deliver_sales_price.uom_id.id,
|
||||
'price_unit': self.product_deliver_sales_price.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
sale_order_line1.product_id_change()
|
||||
sale_order_line2 = self.env['sale.order.line'].create({
|
||||
'name': self.product_order_sales_price.name,
|
||||
'product_id': self.product_order_sales_price.id,
|
||||
'product_uom_qty': 3,
|
||||
'qty_delivered': 1,
|
||||
'product_uom': self.product_order_sales_price.uom_id.id,
|
||||
'price_unit': self.product_order_sales_price.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
sale_order_line2.product_id_change()
|
||||
self.sale_order._compute_tax_id()
|
||||
self.sale_order.action_confirm()
|
||||
|
||||
# let's log some timesheets (on the project created by sale_order_line1)
|
||||
task_sol1 = sale_order_line1.task_id
|
||||
self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line',
|
||||
'project_id': task_sol1.project_id.id,
|
||||
'task_id': task_sol1.id,
|
||||
'unit_amount': 1,
|
||||
'employee_id': self.employee_user.id,
|
||||
})
|
||||
|
||||
# create invoice lines and validate it
|
||||
invoice_lineA1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_deliver_sales_price.name,
|
||||
'product_id': self.product_deliver_sales_price.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_deliver_sales_price.uom_id.id,
|
||||
'price_unit': self.product_deliver_sales_price.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
invoice_lineA2 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_order_sales_price.name,
|
||||
'product_id': self.product_order_sales_price.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_order_sales_price.uom_id.id,
|
||||
'price_unit': self.product_order_sales_price.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
self.invoiceA.action_invoice_open()
|
||||
|
||||
sale_order_line3 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol.product_id == self.product_deliver_sales_price)
|
||||
sale_order_line4 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol.product_id == self.product_order_sales_price)
|
||||
|
||||
self.assertTrue(sale_order_line3, "A new sale line should have been created with ordered product")
|
||||
self.assertTrue(sale_order_line4, "A new sale line should have been created with delivered product")
|
||||
self.assertEquals(len(self.sale_order.order_line), 4, "There should be 4 lines on the SO (2 vendor bill lines created)")
|
||||
self.assertEquals(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 2, "There should be 4 lines on the SO (2 vendor bill lines created)")
|
||||
|
||||
self.assertEquals(sale_order_line1.qty_delivered, 1, "Exising SO line 1 should not be impacted by reinvoicing product at cost")
|
||||
self.assertEquals(sale_order_line2.qty_delivered, 0, "Exising SO line 2 should not be impacted by reinvoicing product at cost")
|
||||
|
||||
self.assertFalse(sale_order_line3.task_id, "Adding a new expense SO line should not create a task (sol3)")
|
||||
self.assertFalse(sale_order_line4.task_id, "Adding a new expense SO line should not create a task (sol4)")
|
||||
self.assertEquals(len(self.sale_order.project_ids), 1, "SO create only one project with its service line. Adding new expense SO line should not impact that")
|
||||
|
||||
self.assertEquals((sale_order_line3.price_unit, sale_order_line3.qty_delivered, sale_order_line3.product_uom_qty, sale_order_line3.qty_invoiced), (self.product_deliver_sales_price.list_price, invoice_lineA1.quantity, 0, 0), 'Sale line is wrong after confirming vendor invoice')
|
||||
self.assertEquals((sale_order_line4.price_unit, sale_order_line4.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line4.qty_invoiced), (self.product_order_sales_price.list_price, invoice_lineA2.quantity, 0, 0), 'Sale line is wrong after confirming vendor invoice')
|
||||
|
||||
self.assertEquals(sale_order_line3.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line 3 should be computed by analytic amount")
|
||||
self.assertEquals(sale_order_line4.qty_delivered_method, 'analytic', "Delivered quantity of 'expense' SO line 4 should be computed by analytic amount")
|
||||
|
||||
# create second invoice lines and validate it
|
||||
invoice_lineB1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_deliver_sales_price.name,
|
||||
'product_id': self.product_deliver_sales_price.id,
|
||||
'quantity': 2,
|
||||
'uom_id': self.product_deliver_sales_price.uom_id.id,
|
||||
'price_unit': self.product_deliver_sales_price.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceB.id,
|
||||
})
|
||||
invoice_lineB2 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_order_sales_price.name,
|
||||
'product_id': self.product_order_sales_price.id,
|
||||
'quantity': 2,
|
||||
'uom_id': self.product_order_sales_price.uom_id.id,
|
||||
'price_unit': self.product_order_sales_price.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceB.id,
|
||||
})
|
||||
self.invoiceB.action_invoice_open()
|
||||
|
||||
sale_order_line5 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line1 and sol != sale_order_line3 and sol.product_id == self.product_deliver_sales_price)
|
||||
sale_order_line6 = self.sale_order.order_line.filtered(lambda sol: sol != sale_order_line2 and sol != sale_order_line4 and sol.product_id == self.product_order_sales_price)
|
||||
|
||||
self.assertFalse(sale_order_line5, "No new sale line should have been created with delivered product !!")
|
||||
self.assertTrue(sale_order_line6, "A new sale line should have been created with ordered product")
|
||||
|
||||
self.assertEquals(len(self.sale_order.order_line), 5, "There should be 5 lines on the SO, 1 new created and 1 incremented")
|
||||
self.assertEquals(len(self.sale_order.order_line.filtered(lambda sol: sol.is_expense)), 3, "There should be 3 expenses lines on the SO")
|
||||
|
||||
self.assertEquals((sale_order_line6.price_unit, sale_order_line6.qty_delivered, sale_order_line4.product_uom_qty, sale_order_line6.qty_invoiced), (self.product_order_sales_price.list_price, invoice_lineB2.quantity, 0, 0), 'Sale line is wrong after confirming 2e vendor invoice')
|
||||
|
||||
def test_no_expense(self):
|
||||
""" Test invoicing vendor bill with no policy. Check nothing happen. """
|
||||
# confirm SO
|
||||
sale_order_line = self.env['sale.order.line'].create({
|
||||
'name': self.product_no_expense.name,
|
||||
'product_id': self.product_no_expense.id,
|
||||
'product_uom_qty': 2,
|
||||
'qty_delivered': 1,
|
||||
'product_uom': self.product_no_expense.uom_id.id,
|
||||
'price_unit': self.product_no_expense.list_price,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
self.sale_order._compute_tax_id()
|
||||
self.sale_order.action_confirm()
|
||||
|
||||
# create invoice lines and validate it
|
||||
invoice_lineA1 = self.env['account.invoice.line'].create({
|
||||
'name': self.product_no_expense.name,
|
||||
'product_id': self.product_no_expense.id,
|
||||
'quantity': 3,
|
||||
'uom_id': self.product_no_expense.uom_id.id,
|
||||
'price_unit': self.product_no_expense.standard_price,
|
||||
'account_analytic_id': self.analytic_account.id,
|
||||
'account_id': self.account_income.id,
|
||||
'invoice_id': self.invoiceA.id,
|
||||
})
|
||||
self.invoiceA.action_invoice_open()
|
||||
|
||||
# let's log some timesheets (on the project created by sale_order_line1)
|
||||
task_sol1 = sale_order_line.task_id
|
||||
self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line',
|
||||
'project_id': task_sol1.project_id.id,
|
||||
'task_id': task_sol1.id,
|
||||
'unit_amount': 1,
|
||||
'employee_id': self.employee_user.id,
|
||||
})
|
||||
|
||||
self.assertEquals(len(self.sale_order.order_line), 1, "No SO line should have been created (or removed) when validating vendor bill")
|
||||
self.assertEquals(sale_order_line.qty_delivered, 1, "The delivered quantity of SO line should not have been incremented")
|
||||
self.assertTrue(invoice_lineA1.invoice_id.move_id.mapped('line_ids.analytic_line_ids'), "Analytic lines should be generated")
|
||||
@@ -1,44 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_timesheet.tests.common import CommonTest
|
||||
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheetNoChart
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestSaleService(CommonTest):
|
||||
class TestSaleService(TestCommonSaleTimesheetNoChart):
|
||||
""" This test suite provide checks for miscellaneous small things. """
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestSaleService, cls).setUpClass()
|
||||
# set up
|
||||
cls.setUpEmployees()
|
||||
cls.setUpServiceProducts()
|
||||
|
||||
cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
|
||||
'partner_id': cls.partner_customer_usd.id,
|
||||
'partner_invoice_id': cls.partner_customer_usd.id,
|
||||
'partner_shipping_id': cls.partner_customer_usd.id,
|
||||
})
|
||||
|
||||
def test_sale_service(self):
|
||||
""" Test task creation when confirming a sale_order with the corresponding product """
|
||||
sale_order_vals = {
|
||||
'partner_id': self.partner_usd.id,
|
||||
'partner_invoice_id': self.partner_usd.id,
|
||||
'partner_shipping_id': self.partner_usd.id,
|
||||
'order_line': [(0, 0, {
|
||||
'name': self.product_delivery_timesheet2.name,
|
||||
'product_id': self.product_delivery_timesheet2.id,
|
||||
'product_uom_qty': 50,
|
||||
'product_uom': self.product_delivery_timesheet2.uom_id.id,
|
||||
'price_unit': self.product_delivery_timesheet2.list_price
|
||||
}),
|
||||
],
|
||||
'pricelist_id': self.pricelist_usd.id,
|
||||
}
|
||||
sale_order = self.env['sale.order'].create(sale_order_vals)
|
||||
sale_order.order_line._compute_product_updatable()
|
||||
self.assertTrue(sale_order.order_line[0].product_updatable)
|
||||
sale_order.action_confirm()
|
||||
sale_order.order_line._compute_product_updatable()
|
||||
self.assertFalse(sale_order.order_line[0].product_updatable)
|
||||
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
|
||||
sale_order_line = self.env['sale.order.line'].create({
|
||||
'order_id': self.sale_order.id,
|
||||
'name': self.product_delivery_timesheet2.name,
|
||||
'product_id': self.product_delivery_timesheet2.id,
|
||||
'product_uom_qty': 50,
|
||||
'product_uom': self.product_delivery_timesheet2.uom_id.id,
|
||||
'price_unit': self.product_delivery_timesheet2.list_price
|
||||
})
|
||||
|
||||
self.sale_order.order_line._compute_product_updatable()
|
||||
self.assertTrue(sale_order_line.product_updatable)
|
||||
self.sale_order.action_confirm()
|
||||
self.sale_order.order_line._compute_product_updatable()
|
||||
|
||||
self.assertFalse(sale_order_line.product_updatable)
|
||||
self.assertEqual(self.sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
|
||||
|
||||
# check task creation
|
||||
project = self.project_global
|
||||
task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (sale_order.name, self.product_delivery_timesheet2.name))
|
||||
task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (self.sale_order.name, self.product_delivery_timesheet2.name))
|
||||
self.assertTrue(task, 'Sale Service: task is not created')
|
||||
self.assertEqual(task.partner_id, sale_order.partner_id, 'Sale Service: customer should be the same on task and on SO')
|
||||
self.assertEqual(task.email_from, sale_order.partner_id.email, 'Sale Service: Task Email should be the same as the SO customer Email')
|
||||
# register timesheet on task
|
||||
self.assertEqual(task.partner_id, self.sale_order.partner_id, 'Sale Service: customer should be the same on task and on SO')
|
||||
self.assertEqual(task.email_from, self.sale_order.partner_id.email, 'Sale Service: Task Email should be the same as the SO customer Email')
|
||||
|
||||
# log timesheet on task
|
||||
self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line',
|
||||
'project_id': project.id,
|
||||
@@ -46,12 +55,12 @@ class TestSaleService(CommonTest):
|
||||
'unit_amount': 50,
|
||||
'employee_id': self.employee_manager.id,
|
||||
})
|
||||
self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Service: there should be sale_ordermething to invoice after registering timesheets')
|
||||
sale_order.action_invoice_create()
|
||||
line = sale_order.order_line
|
||||
self.assertTrue(line.product_uom_qty == line.qty_delivered == line.qty_invoiced, 'Sale Service: line should be invoiced completely')
|
||||
self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Service: SO should be invoiced')
|
||||
self.assertEqual(sale_order.tasks_count, 1, "A task should have been created on SO confirmation.")
|
||||
self.assertEqual(self.sale_order.invoice_status, 'to invoice', 'Sale Service: there should be sale_ordermething to invoice after registering timesheets')
|
||||
self.sale_order.action_invoice_create()
|
||||
|
||||
self.assertTrue(sale_order_line.product_uom_qty == sale_order_line.qty_delivered == sale_order_line.qty_invoiced, 'Sale Service: line should be invoiced completely')
|
||||
self.assertEqual(self.sale_order.invoice_status, 'invoiced', 'Sale Service: SO should be invoiced')
|
||||
self.assertEqual(self.sale_order.tasks_count, 1, "A task should have been created on SO confirmation.")
|
||||
|
||||
# Add a line on the confirmed SO, and it should generate a new task directly
|
||||
product_service_task = self.env['product.product'].create({
|
||||
@@ -74,71 +83,58 @@ class TestSaleService(CommonTest):
|
||||
'product_uom_qty': 10,
|
||||
'product_uom': product_service_task.uom_id.id,
|
||||
'price_unit': product_service_task.list_price,
|
||||
'order_id': sale_order.id,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
|
||||
self.assertEqual(sale_order.tasks_count, 2, "Adding a new service line on a confirmer SO should create a new task.")
|
||||
self.assertEqual(self.sale_order.tasks_count, 2, "Adding a new service line on a confirmer SO should create a new task.")
|
||||
|
||||
def test_timesheet_uom(self):
|
||||
""" Test timesheet invoicing and uom conversion """
|
||||
# create SO and confirm it
|
||||
uom_days = self.env.ref('product.product_uom_day')
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_usd.id,
|
||||
'partner_invoice_id': self.partner_usd.id,
|
||||
'partner_shipping_id': self.partner_usd.id,
|
||||
'order_line': [
|
||||
(0, 0, {
|
||||
'name': self.product_delivery_timesheet3.name,
|
||||
'product_id': self.product_delivery_timesheet3.id,
|
||||
'product_uom_qty': 5,
|
||||
'product_uom': uom_days.id,
|
||||
'price_unit': self.product_delivery_timesheet3.list_price
|
||||
})
|
||||
],
|
||||
'pricelist_id': self.pricelist_usd.id,
|
||||
sale_order_line = self.env['sale.order.line'].create({
|
||||
'order_id': self.sale_order.id,
|
||||
'name': self.product_delivery_timesheet3.name,
|
||||
'product_id': self.product_delivery_timesheet3.id,
|
||||
'product_uom_qty': 5,
|
||||
'product_uom': uom_days.id,
|
||||
'price_unit': self.product_delivery_timesheet3.list_price
|
||||
})
|
||||
sale_order.action_confirm()
|
||||
task = self.env['project.task'].search([('sale_line_id', '=', sale_order.order_line.id)])
|
||||
self.sale_order.action_confirm()
|
||||
task = self.env['project.task'].search([('sale_line_id', '=', sale_order_line.id)])
|
||||
|
||||
# let's log some timesheets
|
||||
self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line',
|
||||
'project_id': sale_order.project_project_id.id,
|
||||
'project_id': self.sale_order.project_project_id.id,
|
||||
'task_id': task.id,
|
||||
'unit_amount': 16,
|
||||
'employee_id': self.employee_manager.id,
|
||||
})
|
||||
self.assertEqual(sale_order.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong')
|
||||
self.assertEqual(self.sale_order.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong')
|
||||
|
||||
self.env['account.analytic.line'].create({
|
||||
'name': 'Test Line',
|
||||
'project_id': sale_order.project_project_id.id,
|
||||
'project_id': self.sale_order.project_project_id.id,
|
||||
'task_id': task.id,
|
||||
'unit_amount': 24,
|
||||
'employee_id': self.employee_user.id,
|
||||
})
|
||||
sale_order.action_invoice_create()
|
||||
self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
|
||||
self.sale_order.action_invoice_create()
|
||||
self.assertEqual(self.sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
|
||||
|
||||
def test_task_so_line_assignation(self):
|
||||
# create SO and confirm it
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_usd.id,
|
||||
'partner_invoice_id': self.partner_usd.id,
|
||||
'partner_shipping_id': self.partner_usd.id,
|
||||
'pricelist_id': self.pricelist_usd.id,
|
||||
})
|
||||
so_line_deliver_global_project = self.env['sale.order.line'].create({
|
||||
'name': self.product_delivery_timesheet2.name,
|
||||
'product_id': self.product_delivery_timesheet2.id,
|
||||
'product_uom_qty': 10,
|
||||
'product_uom': self.product_delivery_timesheet2.uom_id.id,
|
||||
'price_unit': self.product_delivery_timesheet2.list_price,
|
||||
'order_id': sale_order.id,
|
||||
'order_id': self.sale_order.id,
|
||||
})
|
||||
so_line_deliver_global_project.product_id_change()
|
||||
sale_order.action_confirm()
|
||||
self.sale_order.action_confirm()
|
||||
task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
|
||||
|
||||
# let's log some timesheets (on the project created by so_line_ordered_project_only)
|
||||
@@ -169,7 +165,7 @@ class TestSaleService(CommonTest):
|
||||
self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "Timesheet to a billable time task should be billable")
|
||||
|
||||
# invoice SO, and validate invoice
|
||||
invoice_id = sale_order.action_invoice_create()[0]
|
||||
invoice_id = self.sale_order.action_invoice_create()[0]
|
||||
invoice = self.env['account.invoice'].browse(invoice_id)
|
||||
invoice.action_invoice_open()
|
||||
|
||||
@@ -180,9 +176,9 @@ class TestSaleService(CommonTest):
|
||||
def test_delivered_quantity(self):
|
||||
# create SO and confirm it
|
||||
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
|
||||
'partner_id': self.partner_usd.id,
|
||||
'partner_invoice_id': self.partner_usd.id,
|
||||
'partner_shipping_id': self.partner_usd.id,
|
||||
'partner_id': self.partner_customer_usd.id,
|
||||
'partner_invoice_id': self.partner_customer_usd.id,
|
||||
'partner_shipping_id': self.partner_customer_usd.id,
|
||||
'pricelist_id': self.pricelist_usd.id,
|
||||
})
|
||||
so_line_deliver_new_task_project = self.env['sale.order.line'].create({
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo.tools import float_is_zero, float_compare
|
||||
from odoo.tools import float_is_zero
|
||||
|
||||
from odoo.addons.sale_timesheet.tests.common import CommonTest
|
||||
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheetNoChart
|
||||
|
||||
|
||||
class TestSaleTimesheet(CommonTest):
|
||||
class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
|
||||
""" This test suite provide tests for the 3 main flows of selling services:
|
||||
- Selling services based on ordered quantities
|
||||
- Selling timesheet based on delivered quantities
|
||||
@@ -14,6 +14,13 @@ class TestSaleTimesheet(CommonTest):
|
||||
quantities changes, ...
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestSaleTimesheet, cls).setUpClass()
|
||||
# set up
|
||||
cls.setUpEmployees()
|
||||
cls.setUpServiceProducts()
|
||||
|
||||
def test_timesheet_order(self):
|
||||
""" Test timesheet invoicing with 'invoice on order' timetracked products
|
||||
1. create SO with 2 ordered product and confirm
|
||||
@@ -24,9 +31,9 @@ class TestSaleTimesheet(CommonTest):
|
||||
"""
|
||||
# create SO and confirm it
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_usd.id,
|
||||
'partner_invoice_id': self.partner_usd.id,
|
||||
'partner_shipping_id': self.partner_usd.id,
|
||||
'partner_id': self.partner_customer_usd.id,
|
||||
'partner_invoice_id': self.partner_customer_usd.id,
|
||||
'partner_shipping_id': self.partner_customer_usd.id,
|
||||
'pricelist_id': self.pricelist_usd.id,
|
||||
})
|
||||
so_line_ordered_project_only = self.env['sale.order.line'].create({
|
||||
@@ -126,9 +133,9 @@ class TestSaleTimesheet(CommonTest):
|
||||
"""
|
||||
# create SO and confirm it
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_usd.id,
|
||||
'partner_invoice_id': self.partner_usd.id,
|
||||
'partner_shipping_id': self.partner_usd.id,
|
||||
'partner_id': self.partner_customer_usd.id,
|
||||
'partner_invoice_id': self.partner_customer_usd.id,
|
||||
'partner_shipping_id': self.partner_customer_usd.id,
|
||||
'pricelist_id': self.pricelist_usd.id,
|
||||
})
|
||||
so_line_deliver_global_project = self.env['sale.order.line'].create({
|
||||
@@ -222,9 +229,9 @@ class TestSaleTimesheet(CommonTest):
|
||||
"""
|
||||
# create SO and confirm it
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_usd.id,
|
||||
'partner_invoice_id': self.partner_usd.id,
|
||||
'partner_shipping_id': self.partner_usd.id,
|
||||
'partner_id': self.partner_customer_usd.id,
|
||||
'partner_invoice_id': self.partner_customer_usd.id,
|
||||
'partner_shipping_id': self.partner_customer_usd.id,
|
||||
'pricelist_id': self.pricelist_usd.id,
|
||||
})
|
||||
so_line_manual_global_project = self.env['sale.order.line'].create({
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<group string="Logistics">
|
||||
<field name="route_ids" widget="many2many_tags" groups="stock.group_adv_location"/>
|
||||
<div colspan="2" attrs="{'invisible': [('parent_id', '=', False)]}">
|
||||
The following routes will apply to the products in this category taking into account parent categories:
|
||||
The following routes will apply to the products in this category taking into account parent categories:
|
||||
<field name="total_route_ids" nolabel="1" widget="many2many_tags"/>
|
||||
</div>
|
||||
<field name="removal_strategy_id" options="{'no_create': True}"/>
|
||||
@@ -83,25 +83,25 @@
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="product.product_template_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<group name="inventory_valuation" position="before">
|
||||
<group string="Operations" name="operations">
|
||||
<field name="route_ids" widget="many2many_checkboxes" attrs="{'invisible':[('type', 'in', ['service', 'digital'])]}"/>
|
||||
<field name="route_from_categ_ids" widget="many2many_tags" readonly="1" attrs="{'invisible':[('route_from_categ_ids', '=', [])]}"/>
|
||||
<label for="sale_delay"/>
|
||||
<div>
|
||||
<field name="sale_delay" attrs="{'readonly':[('sale_ok','=',False)]}" class="oe_inline" style="vertical-align:baseline"/> days
|
||||
</div>
|
||||
<xpath expr="//group[@name='packaging']" position="before">
|
||||
<group name="inventory">
|
||||
<group string="Operations" name="operations">
|
||||
<field name="route_ids" widget="many2many_checkboxes" attrs="{'invisible':[('type', 'in', ['service', 'digital'])]}"/>
|
||||
<field name="route_from_categ_ids" widget="many2many_tags" readonly="1" attrs="{'invisible':[('route_from_categ_ids', '=', [])]}"/>
|
||||
<label for="sale_delay"/>
|
||||
<div>
|
||||
<field name="sale_delay" attrs="{'readonly':[('sale_ok','=',False)]}" class="oe_inline" style="vertical-align:baseline"/> days
|
||||
</div>
|
||||
</group>
|
||||
<group string="Traceability" name="traceability" groups="stock.group_production_lot">
|
||||
<field name="tracking" widget="radio" attrs="{'invisible':[('type', 'in', ['service', 'digital'])]}"/>
|
||||
</group>
|
||||
<group string="Counterpart Locations" name="stock_property" groups="base.group_no_one">
|
||||
<field name="property_stock_production" domain="[('usage','=','production')]"/>
|
||||
<field name="property_stock_inventory" domain="[('usage','=','inventory')]"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Traceability" name="traceability" groups="stock.group_production_lot">
|
||||
<field name="tracking" widget="radio" attrs="{'invisible':[('type', 'in', ['service', 'digital'])]}"/>
|
||||
</group>
|
||||
</group>
|
||||
<group name="inventory_valuation" position="after">
|
||||
<group string="Counterpart Locations" name="stock_property" groups="base.group_no_one">
|
||||
<field name="property_stock_production" domain="[('usage','=','production')]"/>
|
||||
<field name="property_stock_inventory" domain="[('usage','=','inventory')]"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
<page name="inventory" position="inside">
|
||||
<group string="Description for Delivery Orders">
|
||||
<field name="description_pickingout" nolabel="1" placeholder="This note will show up on delivery orders."/>
|
||||
@@ -227,7 +227,7 @@
|
||||
</data>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
<!-- view used for product.template only -->
|
||||
<record model="ir.ui.view" id="product_template_form_view_procurement_button">
|
||||
<field name="name">product.template_procurement</field>
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="purchase.view_product_supplier_inherit"/>
|
||||
<field name="arch" type="xml">
|
||||
<group name="inventory_valuation" position="inside">
|
||||
<field name="landed_cost_ok" class="oe_inline" attrs="{'invisible':[('type', 'not in', ['product', 'consu', 'service'])]}"/>
|
||||
<field name="split_method" attrs="{'invisible':['|', ('landed_cost_ok', '=' ,False), ('type', 'not in', ['product', 'consu', 'service'])]}"/>
|
||||
</group>
|
||||
<xpath expr="//group[@name='inventory']" position="inside">
|
||||
<group string="Inventory Valuation" name="inventory_valuation">
|
||||
<field name="landed_cost_ok" class="oe_inline" attrs="{'invisible':[('type', 'not in', ['product', 'consu', 'service'])]}"/>
|
||||
<field name="split_method" attrs="{'invisible':['|', ('landed_cost_ok', '=' ,False), ('type', 'not in', ['product', 'consu', 'service'])]}"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
|
||||
+5
-4
@@ -5031,10 +5031,11 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
|
||||
subtree = subtree[name]
|
||||
|
||||
# collect values from dirty fields
|
||||
result['value'] = {
|
||||
name: self._fields[name].convert_to_onchange(record[name], record, subnames[name])
|
||||
for name in dirty
|
||||
}
|
||||
with env.do_in_onchange():
|
||||
result['value'] = {
|
||||
name: self._fields[name].convert_to_onchange(record[name], record, subnames[name])
|
||||
for name in dirty
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
Reference in New Issue
Block a user