[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:
jem-odoo
2018-01-30 09:20:41 +01:00
39 changed files with 1156 additions and 442 deletions
@@ -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
+5
View File
@@ -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,
})
+1 -1
View File
@@ -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>
+4 -8
View File
@@ -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', '&gt;', 1)]}"/>
<div name="standard_price_uom" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '&gt;', 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', '&gt;', 1)]}"/>
<div name="standard_price_uom" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '&gt;', 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)]}"
+26 -41
View File
@@ -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})
+8 -1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+284
View File
@@ -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")
+1 -1
View File
@@ -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
View File
@@ -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
+8 -5
View File
@@ -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>
+1
View File
@@ -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': [],
+1 -1
View File
@@ -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'
+19
View File
@@ -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)
+39 -7
View File
@@ -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>
+13 -12
View File
@@ -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):
+1
View File
@@ -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',
+1
View File
@@ -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'
+31 -26
View File
@@ -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
-17
View File
@@ -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>
+5
View File
@@ -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'),
+1 -3
View File
@@ -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'
+10 -2
View File
@@ -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):
+30 -19
View File
@@ -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
###########################################
+1
View File
@@ -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
+43 -99
View File
@@ -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({
+20 -20
View File
@@ -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
View File
@@ -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