-
- 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
diff --git a/addons/sale/tests/__init__.py b/addons/sale/tests/__init__.py
index 3aac8a509ba..8223c972244 100644
--- a/addons/sale/tests/__init__.py
+++ b/addons/sale/tests/__init__.py
@@ -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
diff --git a/addons/sale/tests/test_reinvoice.py b/addons/sale/tests/test_reinvoice.py
new file mode 100644
index 00000000000..6f7080307a4
--- /dev/null
+++ b/addons/sale/tests/test_reinvoice.py
@@ -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")
diff --git a/addons/sale/tests/test_sale_common.py b/addons/sale/tests/test_sale_common.py
index 78768f7d514..69bdff636db 100644
--- a/addons/sale/tests/test_sale_common.py
+++ b/addons/sale/tests/test_sale_common.py
@@ -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
})
diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py
index 87271c0324f..a68c992438b 100644
--- a/addons/sale/tests/test_sale_order.py
+++ b/addons/sale/tests/test_sale_order.py
@@ -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
diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml
index e8f9a70b3cf..2f1dbc16f45 100644
--- a/addons/sale/views/sale_views.xml
+++ b/addons/sale/views/sale_views.xml
@@ -247,7 +247,8 @@
-
+
+
@@ -259,7 +260,7 @@
-
+
@@ -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}"
/>
+ attrs="{'column_invisible': [('parent.state', 'not in', ['sale', 'done'])], 'readonly': [('qty_delivered_method', '!=', 'manual')]}"/>
+
+
@@ -324,7 +327,6 @@
-
@@ -392,7 +394,7 @@
-
+
@@ -686,6 +688,7 @@
+
diff --git a/addons/sale_expense/__manifest__.py b/addons/sale_expense/__manifest__.py
index 9708001629a..0700edc6772 100644
--- a/addons/sale_expense/__manifest__.py
+++ b/addons/sale_expense/__manifest__.py
@@ -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': [],
diff --git a/addons/sale_expense/models/__init__.py b/addons/sale_expense/models/__init__.py
index b5979a2dd23..cf2318e7b88 100644
--- a/addons/sale_expense/models/__init__.py
+++ b/addons/sale_expense/models/__init__.py
@@ -3,4 +3,4 @@
from . import analytic
from . import hr_expense
-from . import product_template
+from . import sale_order
diff --git a/addons/sale_expense/models/product_template.py b/addons/sale_expense/models/product_template.py
deleted file mode 100644
index 5e6299f2548..00000000000
--- a/addons/sale_expense/models/product_template.py
+++ /dev/null
@@ -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'
diff --git a/addons/sale_expense/models/sale_order.py b/addons/sale_expense/models/sale_order.py
new file mode 100644
index 00000000000..196733bb1f9
--- /dev/null
+++ b/addons/sale_expense/models/sale_order.py
@@ -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)
diff --git a/addons/sale_expense/tests/test_reinvoice.py b/addons/sale_expense/tests/test_reinvoice.py
index 613962c6361..f0c028d69e6 100644
--- a/addons/sale_expense/tests/test_reinvoice.py
+++ b/addons/sale_expense/tests/test_reinvoice.py
@@ -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. """
diff --git a/addons/sale_expense/views/hr_expense_views.xml b/addons/sale_expense/views/hr_expense_views.xml
index 8af3d92c768..c86de76cad0 100644
--- a/addons/sale_expense/views/hr_expense_views.xml
+++ b/addons/sale_expense/views/hr_expense_views.xml
@@ -21,4 +21,12 @@
+
+
+ Expenses
+ hr.expense
+ tree,form
+ [('sale_order_id', '=', active_id), ('state', '=', 'done')]
+
+
diff --git a/addons/sale_expense/views/product_view.xml b/addons/sale_expense/views/product_view.xml
index da32ab2e713..71117e25137 100644
--- a/addons/sale_expense/views/product_view.xml
+++ b/addons/sale_expense/views/product_view.xml
@@ -1,16 +1,5 @@
-
- product.template.expense.policy
- product.template
-
-
-
-
-
-
- product.template.expense
diff --git a/addons/sale_expense/views/sale_order_views.xml b/addons/sale_expense/views/sale_order_views.xml
new file mode 100644
index 00000000000..45caf6e2dde
--- /dev/null
+++ b/addons/sale_expense/views/sale_order_views.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ sale.order.form.inherit.sale.expense
+ sale.order
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/sale_mrp/models/sale_mrp.py b/addons/sale_mrp/models/sale_mrp.py
index adbcebd4f18..596fbba50ec 100644
--- a/addons/sale_mrp/models/sale_mrp.py
+++ b/addons/sale_mrp/models/sale_mrp.py
@@ -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):
diff --git a/addons/sale_stock/__manifest__.py b/addons/sale_stock/__manifest__.py
index 3e09974e3f5..c4b41c4ca3b 100644
--- a/addons/sale_stock/__manifest__.py
+++ b/addons/sale_stock/__manifest__.py
@@ -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',
diff --git a/addons/sale_stock/models/__init__.py b/addons/sale_stock/models/__init__.py
index aa85402192a..1f11274e77a 100644
--- a/addons/sale_stock/models/__init__.py
+++ b/addons/sale_stock/models/__init__.py
@@ -6,3 +6,4 @@ from . import res_company
from . import sale_order
from . import res_config_settings
from . import stock
+from . import product_template
diff --git a/addons/sale_stock/models/product_template.py b/addons/sale_stock/models/product_template.py
new file mode 100644
index 00000000000..f554fae8e4b
--- /dev/null
+++ b/addons/sale_stock/models/product_template.py
@@ -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'
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index 1be96680710..9b5dbb16b05 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -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
diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py
index 8ec7ced7756..975585fcef4 100644
--- a/addons/sale_stock/models/stock.py
+++ b/addons/sale_stock/models/stock.py
@@ -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'
diff --git a/addons/sale_stock/views/product_template_views.xml b/addons/sale_stock/views/product_template_views.xml
new file mode 100644
index 00000000000..9edec3db317
--- /dev/null
+++ b/addons/sale_stock/views/product_template_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ product.template.form.expense.policy.inherit
+ product.template
+
+
+
+ {'invisible': [('type','=', 'product')]}
+
+
+
+
+
diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py
index 417fb1950d1..ec51d2085a2 100644
--- a/addons/sale_timesheet/models/account.py
+++ b/addons/sale_timesheet/models/account.py
@@ -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'),
diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py
index aeddf1bd2ce..7bfb705f348 100644
--- a/addons/sale_timesheet/models/product.py
+++ b/addons/sale_timesheet/models/product.py
@@ -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'
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
index 3244e4990c0..facfdb04ce4 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -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):
diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py
index 53e3bb4dafd..335a6b657fe 100644
--- a/addons/sale_timesheet/models/sale_order.py
+++ b/addons/sale_timesheet/models/sale_order.py
@@ -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
###########################################
diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py
index c18361894ff..1c072a7a43e 100644
--- a/addons/sale_timesheet/tests/__init__.py
+++ b/addons/sale_timesheet/tests/__init__.py
@@ -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
diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py
index 3c13fa71216..1cb86b25bc3 100644
--- a/addons/sale_timesheet/tests/common.py
+++ b/addons/sale_timesheet/tests/common.py
@@ -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,
- })
diff --git a/addons/sale_timesheet/tests/test_reinvoice.py b/addons/sale_timesheet/tests/test_reinvoice.py
new file mode 100644
index 00000000000..1ccf022eb13
--- /dev/null
+++ b/addons/sale_timesheet/tests/test_reinvoice.py
@@ -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")
diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py
index a1fa081c102..db1046bf4a6 100644
--- a/addons/sale_timesheet/tests/test_sale_service.py
+++ b/addons/sale_timesheet/tests/test_sale_service.py
@@ -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({
diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py
index fa4b8d905fa..e48849e078c 100644
--- a/addons/sale_timesheet/tests/test_sale_timesheet.py
+++ b/addons/sale_timesheet/tests/test_sale_timesheet.py
@@ -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({
diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml
index 4d7a0f33c72..7c6c720eb0c 100644
--- a/addons/stock/views/product_views.xml
+++ b/addons/stock/views/product_views.xml
@@ -11,7 +11,7 @@
- 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:
@@ -83,25 +83,25 @@
product.template
-
-
-
-
-
-
- days
-
+
+
+
+
+
+
+
+ days
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
@@ -227,7 +227,7 @@
-
+
product.template_procurement
diff --git a/addons/stock_landed_costs/views/product_views.xml b/addons/stock_landed_costs/views/product_views.xml
index 5dc24b2daf8..3d71a3097f6 100644
--- a/addons/stock_landed_costs/views/product_views.xml
+++ b/addons/stock_landed_costs/views/product_views.xml
@@ -6,10 +6,12 @@
product.template
-
-
-
-
+
+
+
+
+
+
diff --git a/odoo/models.py b/odoo/models.py
index 9a5fb28218e..2086c137a61 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -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