From 23b8b91fcdc3e81d4f868803ff936a74b9fcddf0 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Thu, 19 Apr 2018 10:50:32 +0200 Subject: [PATCH 1/3] [FIX] sale_timesheet: exludes expense service SOL in plan project In the table of the project overview, we don't want to see sale lines generated from expenses and vendor bills. --- addons/sale_timesheet/controllers/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index 8d983164445..2df7b83cff7 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -302,7 +302,7 @@ class SaleTimesheetController(http.Controller): def _table_get_empty_so_lines(self, projects): """ get the Sale Order Lines having no timesheet but having generated a task or a project """ - so_lines = projects.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service) + so_lines = projects.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense) return set(so_lines.ids), set(so_lines.mapped('order_id').ids) # -------------------------------------------------- From 7724bd0c2f90f0714953bf3924ddd02ef06b46e5 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Tue, 24 Apr 2018 10:14:25 +0200 Subject: [PATCH 2/3] [IMP] sale_timesheet: simplify the profitability report SQL query --- .../report/project_profitability_report_analysis.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis.py b/addons/sale_timesheet/report/project_profitability_report_analysis.py index 50eb685d103..66ecab92f72 100644 --- a/addons/sale_timesheet/report/project_profitability_report_analysis.py +++ b/addons/sale_timesheet/report/project_profitability_report_analysis.py @@ -38,8 +38,8 @@ class ProfitabilityAnalysis(models.Model): P.user_id AS user_id, TIMESHEET_AMOUNT.project_id AS project_id, TIMESHEET_AMOUNT.sale_line_id AS sale_line_id, - AA.id AS analytic_account_id, - AA.partner_id AS partner_id, + P.analytic_account_id AS analytic_account_id, + P.partner_id AS partner_id, C.id AS company_id, C.currency_id AS currency_id, S.id AS sale_order_id, @@ -50,8 +50,7 @@ class ProfitabilityAnalysis(models.Model): TIMESHEET_AMOUNT.timesheet_unit_amount AS timesheet_unit_amount, TIMESHEET_AMOUNT.timesheet_cost AS timesheet_cost FROM project_project P - LEFT JOIN account_analytic_account AA ON P.analytic_account_id = AA.id - JOIN res_company C ON C.id = AA.company_id + JOIN res_company C ON C.id = P.company_id LEFT JOIN ( SELECT T.sale_line_id AS sale_line_id, T.project_id AS project_id @@ -67,7 +66,6 @@ class ProfitabilityAnalysis(models.Model): SELECT project_id AS project_id, so_line AS sale_line_id, - array_agg(id), SUM(TS.unit_amount) AS timesheet_unit_amount, SUM(TS.amount) AS timesheet_cost FROM account_analytic_line TS From 92b57a5781d7a359119f9d842ce86dcf67b3bf59 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Tue, 24 Apr 2018 11:33:44 +0200 Subject: [PATCH 3/3] [IMP] sale_timesheet: expense in project plan dashboard On project overview, user can now see the expense costs of the analytic account linked to the project. It also displays the reinvoice amount of those costs (based on their related SO lines), and computed like the amount to invoice for invoice. This commit also provides some tests to avoid problem when modifying the SQL query of profitability report. Task #49214 --- addons/sale_timesheet/controllers/main.py | 6 +- .../project_profitability_report_analysis.py | 130 ++++-- addons/sale_timesheet/tests/__init__.py | 1 + addons/sale_timesheet/tests/test_reporting.py | 382 ++++++++++++++++++ .../views/hr_timesheet_templates.xml | 51 ++- 5 files changed, 531 insertions(+), 39 deletions(-) create mode 100644 addons/sale_timesheet/tests/test_reporting.py diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index 2df7b83cff7..c2e51c9d7c3 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -70,12 +70,14 @@ class SaleTimesheetController(http.Controller): dashboard_values['rates']['total'] += rate # profitability, using profitability SQL report - profit = dict.fromkeys(['invoiced', 'to_invoice', 'cost', 'total'], 0.0) - profitability_raw_data = request.env['project.profitability.report'].read_group([('project_id', 'in', projects.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost'], ['project_id']) + profit = dict.fromkeys(['invoiced', 'to_invoice', 'cost', 'expense_cost', 'expense_amount_untaxed_invoiced', 'total'], 0.0) + profitability_raw_data = request.env['project.profitability.report'].read_group([('project_id', 'in', projects.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_invoiced'], ['project_id']) for data in profitability_raw_data: profit['invoiced'] += data.get('amount_untaxed_invoiced', 0.0) profit['to_invoice'] += data.get('amount_untaxed_to_invoice', 0.0) profit['cost'] += data.get('timesheet_cost', 0.0) + profit['expense_cost'] += data.get('expense_cost', 0.0) + profit['expense_amount_untaxed_invoiced'] += data.get('expense_amount_untaxed_invoiced', 0.0) profit['total'] = sum([profit[item] for item in profit.keys()]) dashboard_values['profit'] = profit diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis.py b/addons/sale_timesheet/report/project_profitability_report_analysis.py index 66ecab92f72..484aefa0b92 100644 --- a/addons/sale_timesheet/report/project_profitability_report_analysis.py +++ b/addons/sale_timesheet/report/project_profitability_report_analysis.py @@ -20,13 +20,17 @@ class ProfitabilityAnalysis(models.Model): # cost timesheet_unit_amount = fields.Float("Timesheet Unit Amount", digits=(16, 2), readonly=True, group_operator="sum") timesheet_cost = fields.Float("Timesheet Cost", digits=(16, 2), readonly=True, group_operator="sum") + expense_cost = fields.Float("Other Cost", digits=(16, 2), readonly=True, group_operator="sum") # sale revenue order_confirmation_date = fields.Datetime('Sales Order Confirmation Date', readonly=True) sale_line_id = fields.Many2one('sale.order.line', string='Sale Order Line', readonly=True) sale_order_id = fields.Many2one('sale.order', string='Sale Order', readonly=True) product_id = fields.Many2one('product.product', string='Product', readonly=True) - amount_untaxed_to_invoice = fields.Float("Untaxed Amout To Invoice", digits=(16, 2), readonly=True, group_operator="sum") - amount_untaxed_invoiced = fields.Float("Untaxed Amout Invoiced", digits=(16, 2), readonly=True, group_operator="sum") + + amount_untaxed_to_invoice = fields.Float("Untaxed Amount To Invoice", digits=(16, 2), readonly=True, group_operator="sum") + amount_untaxed_invoiced = fields.Float("Untaxed Amount Invoiced", digits=(16, 2), readonly=True, group_operator="sum") + expense_amount_untaxed_to_invoice = fields.Float("Untaxed Amount to Re-invoiced", digits=(16, 2), readonly=True, group_operator="sum") + expense_amount_untaxed_invoiced = fields.Float("Untaxed Re-invoiced Amount", digits=(16, 2), readonly=True, group_operator="sum") def init(self): tools.drop_view_if_exists(self._cr, self._table) @@ -35,9 +39,9 @@ class ProfitabilityAnalysis(models.Model): WITH currency_rate as (%s) SELECT ROW_NUMBER() OVER (ORDER BY P.id, SOL.id) AS id, + P.id AS project_id, P.user_id AS user_id, - TIMESHEET_AMOUNT.project_id AS project_id, - TIMESHEET_AMOUNT.sale_line_id AS sale_line_id, + SOL.id AS sale_line_id, P.analytic_account_id AS analytic_account_id, P.partner_id AS partner_id, C.id AS company_id, @@ -45,40 +49,110 @@ class ProfitabilityAnalysis(models.Model): S.id AS sale_order_id, S.date_order AS order_confirmation_date, SOL.product_id AS product_id, - (SOL.price_reduce / COALESCE(CR.rate, 1.0)) * SOL.qty_to_invoice AS amount_untaxed_to_invoice, - (SOL.price_reduce / COALESCE(CR.rate, 1.0)) * SOL.qty_invoiced AS amount_untaxed_invoiced, - TIMESHEET_AMOUNT.timesheet_unit_amount AS timesheet_unit_amount, - TIMESHEET_AMOUNT.timesheet_cost AS timesheet_cost + SOL.qty_delivered_method AS sale_qty_delivered_method, + IP.value_float as standar_price, + CASE + WHEN SOL.qty_delivered_method = 'analytic' THEN (SOL.price_reduce / COALESCE(CR.rate, 1.0)) * SOL.qty_to_invoice + ELSE 0.0 + END AS expense_amount_untaxed_to_invoice, + CASE + WHEN SOL.qty_delivered_method = 'analytic' AND SOL.invoice_status != 'no' + THEN + CASE + WHEN T.expense_policy = 'sales_price' + THEN (SOL.price_reduce / COALESCE(CR.rate, 1.0)) * SOL.qty_invoiced + ELSE IP.value_float * SOL.qty_invoiced + END + ELSE 0.0 + END AS expense_amount_untaxed_invoiced, + CASE + WHEN SOL.qty_delivered_method = 'timesheet' THEN (SOL.price_reduce / COALESCE(CR.rate, 1.0)) * SOL.qty_to_invoice + ELSE 0.0 + END AS amount_untaxed_to_invoice, + CASE + WHEN SOL.qty_delivered_method = 'timesheet' THEN (SOL.price_reduce / COALESCE(CR.rate, 1.0)) * SOL.qty_invoiced + ELSE 0.0 + END AS amount_untaxed_invoiced, + COST_SUMMARY.timesheet_unit_amount AS timesheet_unit_amount, + COST_SUMMARY.timesheet_cost AS timesheet_cost, + COST_SUMMARY.expense_cost AS expense_cost FROM project_project P JOIN res_company C ON C.id = P.company_id - LEFT JOIN - ( - SELECT T.sale_line_id AS sale_line_id, T.project_id AS project_id - FROM project_task T - WHERE T.sale_line_id IS NOT NULL - UNION - SELECT P.sale_line_id AS sale_line_id, P.id AS project_id - FROM project_project P - WHERE P.sale_line_id IS NOT NULL - ) SOL_PER_PROJECT ON P.id = SOL_PER_PROJECT.project_id - RIGHT OUTER JOIN - ( + LEFT JOIN ( + SELECT + project_id, + analytic_account_id, + sale_line_id, + SUM(timesheet_unit_amount) AS timesheet_unit_amount, + SUM(timesheet_cost) AS timesheet_cost, + SUM(expense_cost) AS expense_cost + FROM ( SELECT - project_id AS project_id, - so_line AS sale_line_id, + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, + TS.so_line AS sale_line_id, SUM(TS.unit_amount) AS timesheet_unit_amount, - SUM(TS.amount) AS timesheet_cost - FROM account_analytic_line TS - WHERE project_id IS NOT NULL - GROUP BY project_id, so_line - ) TIMESHEET_AMOUNT ON TIMESHEET_AMOUNT.project_id = SOL_PER_PROJECT.project_id AND TIMESHEET_AMOUNT.sale_line_id = SOL_PER_PROJECT.sale_line_id - LEFT JOIN sale_order_line SOL ON SOL_PER_PROJECT.sale_line_id = SOL.id + SUM(TS.amount) AS timesheet_cost, + 0.0 AS expense_cost + FROM account_analytic_line TS, project_project P + WHERE TS.project_id IS NOT NULL AND P.id = TS.project_id AND P.active = 't' AND P.allow_timesheets = 't' + GROUP BY P.id, TS.so_line + + UNION + + SELECT + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, + AAL.so_line AS sale_line_id, + 0.0 AS timesheet_unit_amount, + 0.0 AS timesheet_cost, + SUM(AAL.amount) AS expense_cost + FROM project_project P + LEFT JOIN account_analytic_account AA ON P.analytic_account_id = AA.id + LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id + WHERE AAL.amount < 0.0 AND AAL.project_id IS NULL AND P.active = 't' AND P.allow_timesheets = 't' + GROUP BY P.id, AA.id, AAL.so_line + + UNION + + SELECT + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, + SOL.id AS sale_line_id, + 0.0 AS timesheet_unit_amount, + 0.0 AS timesheet_cost, + 0.0 AS expense_cost + FROM sale_order_line SOL + INNER JOIN project_project P ON SOL.project_id = P.id + WHERE P.active = 't' AND P.allow_timesheets = 't' + + UNION + + SELECT + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, + SOL.id AS sale_line_id, + 0.0 AS timesheet_unit_amount, + 0.0 AS timesheet_cost, + 0.0 AS expense_cost + FROM sale_order_line SOL + INNER JOIN project_task T ON SOL.task_id = T.id + INNER JOIN project_project P ON P.id = T.project_id + WHERE P.active = 't' AND P.allow_timesheets = 't' + ) SUB_COST_SUMMARY + GROUP BY project_id, analytic_account_id, sale_line_id + ) COST_SUMMARY ON COST_SUMMARY.project_id = P.id + LEFT JOIN sale_order_line SOL ON COST_SUMMARY.sale_line_id = SOL.id LEFT JOIN sale_order S ON SOL.order_id = S.id + LEFT JOIN product_product PP on (SOL.product_id=PP.id) + LEFT JOIN product_template T on (PP.product_tmpl_id=T.id) + LEFT JOIN ir_property IP ON (IP.name='standard_price' AND IP.res_id=CONCAT('product.product,',PP.id) AND IP.company_id=S.company_id) LEFT JOIN currency_rate CR ON (CR.currency_id = SOL.currency_id AND CR.currency_id != C.currency_id AND CR.company_id = SOL.company_id AND CR.date_start <= COALESCE(S.date_order, now()) AND (CR.date_end IS NULL OR cr.date_end > COALESCE(S.date_order, now()))) + WHERE P.active = 't' AND P.analytic_account_id IS NOT NULL ) """ % (self._table, self.env['res.currency']._select_companies_rates()) self._cr.execute(query) diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index 1c072a7a43e..5232437fea0 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_sale_timesheet from . import test_sale_service from . import test_timesheet_revenue from . import test_reinvoice +from . import test_reporting diff --git a/addons/sale_timesheet/tests/test_reporting.py b/addons/sale_timesheet/tests/test_reporting.py new file mode 100644 index 00000000000..644e13e13f9 --- /dev/null +++ b/addons/sale_timesheet/tests/test_reporting.py @@ -0,0 +1,382 @@ +# -*- 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.addons.sale_timesheet.tests.common import TestCommonSaleTimesheetNoChart + + +class TestReporting(TestCommonSaleTimesheetNoChart): + + @classmethod + def setUpClass(cls): + super(TestReporting, cls).setUpClass() + + cls.setUpEmployees() + cls.setUpServiceProducts() + cls.setUpAdditionalAccounts() + cls.setUpAccountJournal() + + # expense product + cls.product_expense = cls.env['product.product'].with_context(mail_notrack=True, mail_create_nolog=True).create({ + 'name': "Expense service", + 'standard_price': 10, + 'list_price': 20, + 'type': 'service', + 'invoice_policy': 'delivery', + 'expense_policy': 'sales_price', + 'default_code': 'EXP', + 'service_type': 'manual', + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + + # create Analytic Accounts + cls.analytic_account_1 = cls.env['account.analytic.account'].create({ + 'name': 'Test AA 1', + 'code': 'AA1', + 'company_id': cls.partner_customer_usd.company_id.id, + 'partner_id': cls.partner_customer_usd.id + }) + cls.analytic_account_2 = cls.env['account.analytic.account'].create({ + 'name': 'Test AA 2', + 'code': 'AA2', + 'company_id': cls.partner_customer_usd.company_id.id, + 'partner_id': cls.partner_customer_usd.id + }) + + # Sale orders each will create project and a task in a global project (one SO is 'delivered', the other is 'ordered') + cls.sale_order_1 = 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_1.id, + }) + cls.so_line_deliver_project = cls.env['sale.order.line'].create({ + 'name': cls.product_delivery_timesheet3.name, + 'product_id': cls.product_delivery_timesheet3.id, + 'product_uom_qty': 5, + 'product_uom': cls.product_delivery_timesheet3.uom_id.id, + 'price_unit': cls.product_delivery_timesheet3.list_price, + 'order_id': cls.sale_order_1.id, + }) + cls.so_line_deliver_task = cls.env['sale.order.line'].create({ + 'name': cls.product_delivery_timesheet2.name, + 'product_id': cls.product_delivery_timesheet2.id, + 'product_uom_qty': 7, + 'product_uom': cls.product_delivery_timesheet2.uom_id.id, + 'price_unit': cls.product_delivery_timesheet2.list_price, + 'order_id': cls.sale_order_1.id, + }) + + cls.sale_order_2 = 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_2.id, + }) + cls.so_line_order_project = cls.env['sale.order.line'].create({ + 'name': cls.product_order_timesheet3.name, + 'product_id': cls.product_order_timesheet3.id, + 'product_uom_qty': 5, + 'product_uom': cls.product_order_timesheet3.uom_id.id, + 'price_unit': cls.product_order_timesheet3.list_price, + 'order_id': cls.sale_order_2.id, + }) + cls.so_line_order_task = cls.env['sale.order.line'].create({ + 'name': cls.product_order_timesheet2.name, + 'product_id': cls.product_order_timesheet2.id, + 'product_uom_qty': 7, + 'product_uom': cls.product_order_timesheet2.uom_id.id, + 'price_unit': cls.product_order_timesheet2.list_price, + 'order_id': cls.sale_order_2.id, + }) + + def _log_timesheet_user(self, project, unit_amount, task=False): + """ Utility method to log timesheet """ + Timesheet = self.env['account.analytic.line'] + return Timesheet.create({ + 'name': 'timesheet employee on project_so_1 only', + 'account_id': project.analytic_account_id.id, + 'project_id': project.id, + 'employee_id': self.employee_user.id, + 'unit_amount': unit_amount, + 'task_id': task.id if task else False, + }) + + def _log_timesheet_manager(self, project, unit_amount, task=False): + """ Utility method to log timesheet """ + Timesheet = self.env['account.analytic.line'] + return Timesheet.create({ + 'name': 'timesheet employee on project_so_1 only', + 'account_id': project.analytic_account_id.id, + 'project_id': project.id, + 'employee_id': self.employee_manager.id, + 'unit_amount': unit_amount, + 'task_id': task.id if task else False, + }) + + def test_profitability_report(self): + + # this test suppose everything is in the same currency as the current one + currency = self.env.user.company_id.currency_id + rounding = currency.rounding + + project_global_stat = self.env['project.profitability.report'].search([('project_id', '=', self.project_global.id)]).read()[0] + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['timesheet_unit_amount'], precision_rounding=rounding), "The timesheet unit amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['timesheet_cost'], precision_rounding=rounding), "The timesheet cost of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + # confirm sales orders + self.sale_order_1.action_confirm() + self.sale_order_2.action_confirm() + + project_so_1 = self.so_line_deliver_project.project_id + project_so_2 = self.so_line_order_project.project_id + task_so_1 = self.so_line_deliver_project.task_id + task_so_2 = self.so_line_order_project.task_id + task_in_global_1 = self.so_line_deliver_task.task_id + task_in_global_2 = self.so_line_order_task.task_id + + # deliver project should not be impacted, as no timesheet are logged yet + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['timesheet_unit_amount'], precision_rounding=rounding), "The timesheet unit amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['timesheet_cost'], precision_rounding=rounding), "The timesheet cost of the global project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO1 should be 0.0") + + # order project should be to invoice, but nothing has been delivered yet + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_to_invoice'], self.so_line_order_project.price_unit * self.so_line_order_project.qty_to_invoice, precision_rounding=rounding), 0, "The amount to invoice should be the one from the SO line, as we are in ordered quantity") + self.assertTrue(float_is_zero(project_so_2_stat['timesheet_unit_amount'], precision_rounding=rounding), "The timesheet unit amount of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['timesheet_cost'], precision_rounding=rounding), "The timesheet cost of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project now contain 2 tasks: 'deliver' one which has no effect (no timesheet yet), and the 'order' one which should update the 'to invoice' amount + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the global project should be 0.0") + self.assertEqual(float_compare(project_global_stat['amount_untaxed_to_invoice'], self.so_line_order_task.price_unit * self.so_line_order_task.qty_to_invoice, precision_rounding=rounding), 0, "The amount to invoice of global project should take the task in 'oredered qty' into account") + self.assertTrue(float_is_zero(project_global_stat['timesheet_unit_amount'], precision_rounding=rounding), "The timesheet unit amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['timesheet_cost'], precision_rounding=rounding), "The timesheet cost of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + # logged some timesheets: on project only, then on tasks with different employees + timesheet1 = self._log_timesheet_user(project_so_1, 2) + timesheet2 = self._log_timesheet_user(project_so_2, 2) + timesheet3 = self._log_timesheet_user(project_so_1, 3, task_so_1) + timesheet4 = self._log_timesheet_user(project_so_2, 3, task_so_2) + + timesheet5 = self._log_timesheet_manager(project_so_1, 1, task_so_1) + timesheet6 = self._log_timesheet_manager(project_so_2, 1, task_so_2) + timesheet7 = self._log_timesheet_manager(self.project_global, 3, task_in_global_1) + timesheet8 = self._log_timesheet_manager(self.project_global, 3, task_in_global_2) + + # deliver project should now have cost and something to invoice + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_1_timesheet_cost = timesheet1.amount + timesheet3.amount + timesheet5.amount + project_so_1_timesheet_sold_unit = timesheet3.unit_amount + timesheet5.unit_amount + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['amount_untaxed_to_invoice'], self.so_line_deliver_project.price_unit * project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The amount to invoice of the project from SO1 should only include timesheet linked to task") + self.assertEqual(float_compare(project_so_1_stat['timesheet_unit_amount'], project_so_1_timesheet_sold_unit + timesheet1.unit_amount, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO1 should include all timesheet in project") + self.assertEqual(float_compare(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO1 should include all timesheet") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO1 should be 0.0") + + # order project still have something to invoice but has costs now + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_2_timesheet_cost = timesheet2.amount + timesheet4.amount + timesheet6.amount + project_so_2_timesheet_sold_unit = timesheet4.unit_amount + timesheet6.unit_amount + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_to_invoice'], self.so_line_order_project.price_unit * self.so_line_order_project.qty_to_invoice, precision_rounding=rounding), 0, "The amount to invoice should be the one from the SO line, as we are in ordered quantity") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit + timesheet2.unit_amount, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO2 should include all timesheet") + self.assertEqual(float_compare(project_so_2_stat['timesheet_cost'], project_so_2_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO2 should include all timesheet") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project should have more to invoice, and should now have costs + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_global_timesheet_cost = timesheet7.amount + timesheet8.amount + project_global_timesheet_unit = timesheet7.unit_amount + timesheet8.unit_amount + project_global_to_invoice = (self.so_line_order_task.price_unit * self.so_line_order_task.product_uom_qty) + (self.so_line_deliver_task.price_unit * timesheet7.unit_amount) + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the global project should be 0.0") + self.assertEqual(float_compare(project_global_stat['amount_untaxed_to_invoice'], project_global_to_invoice, precision_rounding=rounding), 0, "The amount to invoice of global project should take the task in 'oredered qty' and the delivered timesheets into account") + self.assertEqual(float_compare(project_global_stat['timesheet_unit_amount'], project_global_timesheet_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the global project should include all timesheet") + self.assertEqual(float_compare(project_global_stat['timesheet_cost'], project_global_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the global project should include all timesheet") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + InvoiceWizard = self.env['sale.advance.payment.inv'].with_context(mail_notrack=True) + + # invoice the Sales Order SO1 (based on delivered qty) + context = { + "active_model": 'sale.order', + "active_ids": [self.sale_order_1.id], + "active_id": self.sale_order_1.id, + 'open_invoices': True, + } + payment = InvoiceWizard.create({ + 'advance_payment_method': 'delivered', + }) + action_invoice = payment.with_context(context).create_invoices() + invoice_id = action_invoice['res_id'] + invoice_1 = self.env['account.invoice'].browse(invoice_id) + + # deliver project should now have cost and something invoiced + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_1_timesheet_cost = timesheet1.amount + timesheet3.amount + timesheet5.amount + project_so_1_timesheet_sold_unit = timesheet3.unit_amount + timesheet5.unit_amount + self.assertEqual(float_compare(project_so_1_stat['amount_untaxed_invoiced'], self.so_line_deliver_project.price_unit * project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The invoiced amount of the project from SO1 should only include timesheet linked to task") + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['timesheet_unit_amount'], project_so_1_timesheet_sold_unit + timesheet1.unit_amount, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO1 should include all timesheet in project") + self.assertEqual(float_compare(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO1 should include all timesheet") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO1 should be 0.0") + + # order project has still nothing invoiced + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_2_timesheet_cost = timesheet2.amount + timesheet4.amount + timesheet6.amount + project_so_2_timesheet_sold_unit = timesheet4.unit_amount + timesheet6.unit_amount + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_invoiced'], precision_rounding=rounding), "The invoiced amount of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_to_invoice'], self.so_line_order_project.price_unit * self.so_line_order_project.qty_to_invoice, precision_rounding=rounding), 0, "The amount to invoice should be the one from the SO line, as we are in ordered quantity") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit + timesheet2.unit_amount, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO2 should include all timesheet") + self.assertEqual(float_compare(project_so_2_stat['timesheet_cost'], project_so_2_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO2 should include all timesheet") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project should have more to invoice, and should now have costs + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_global_timesheet_cost = timesheet7.amount + timesheet8.amount + project_global_timesheet_unit = timesheet7.unit_amount + timesheet8.unit_amount + project_global_to_invoice = self.so_line_order_task.price_unit * self.so_line_order_task.product_uom_qty + project_global_invoiced = self.so_line_deliver_task.price_unit * timesheet7.unit_amount + self.assertEqual(float_compare(project_global_stat['amount_untaxed_invoiced'], project_global_invoiced, precision_rounding=rounding), 0, "The invoiced amount of the global project should be 0.0") + self.assertEqual(float_compare(project_global_stat['amount_untaxed_to_invoice'], project_global_to_invoice, precision_rounding=rounding), 0, "The amount to invoice of global project should take the task in 'oredered qty' and the delivered timesheets into account") + self.assertEqual(float_compare(project_global_stat['timesheet_unit_amount'], project_global_timesheet_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the global project should include all timesheet") + self.assertEqual(float_compare(project_global_stat['timesheet_cost'], project_global_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the global project should include all timesheet") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + # invoice the Sales Order SO2 + context = { + "active_model": 'sale.order', + "active_ids": [self.sale_order_2.id], + "active_id": self.sale_order_2.id, + 'open_invoices': True, + } + payment = InvoiceWizard.create({ + 'advance_payment_method': 'delivered', + }) + action_invoice = payment.with_context(context).create_invoices() + invoice_id = action_invoice['res_id'] + invoice_2 = self.env['account.invoice'].browse(invoice_id) + + # deliver project should not be impacted by the invoice of the other SO + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_1_timesheet_cost = timesheet1.amount + timesheet3.amount + timesheet5.amount + project_so_1_timesheet_sold_unit = timesheet3.unit_amount + timesheet5.unit_amount + self.assertEqual(float_compare(project_so_1_stat['amount_untaxed_invoiced'], self.so_line_deliver_project.price_unit * project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The invoiced amount of the project from SO1 should only include timesheet linked to task") + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['timesheet_unit_amount'], project_so_1_timesheet_sold_unit + timesheet1.unit_amount, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO1 should include all timesheet in project") + self.assertEqual(float_compare(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO1 should include all timesheet") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO1 should be 0.0") + + # order project is now totally invoiced, as we are in ordered qty + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_2_timesheet_cost = timesheet2.amount + timesheet4.amount + timesheet6.amount + project_so_2_timesheet_sold_unit = timesheet4.unit_amount + timesheet6.unit_amount + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_invoiced'], self.so_line_order_project.price_unit * self.so_line_order_project.product_uom_qty, precision_rounding=rounding), 0, "The invoiced amount should be the one from the SO line, as we are in ordered quantity") + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice should be the one 0.0, as all ordered quantity is invoiced") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit + timesheet2.unit_amount, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO2 should include all timesheet") + self.assertEqual(float_compare(project_so_2_stat['timesheet_cost'], project_so_2_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO2 should include all timesheet") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project should be totally invoiced + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_global_timesheet_cost = timesheet7.amount + timesheet8.amount + project_global_timesheet_unit = timesheet7.unit_amount + timesheet8.unit_amount + project_global_invoiced = self.so_line_order_task.price_unit * self.so_line_order_task.product_uom_qty + self.so_line_deliver_task.price_unit * timesheet7.unit_amount + self.assertEqual(float_compare(project_global_stat['amount_untaxed_invoiced'], project_global_invoiced, precision_rounding=rounding), 0, "The invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of global project should take the task in 'oredered qty' and the delivered timesheets into account") + self.assertEqual(float_compare(project_global_stat['timesheet_unit_amount'], project_global_timesheet_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the global project should include all timesheet") + self.assertEqual(float_compare(project_global_stat['timesheet_cost'], project_global_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the global project should include all timesheet") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the global project should be 0.0") + + # add expense AAL: 20% margin when reinvoicing + AnalyticLine = self.env['account.analytic.line'] + expense1 = AnalyticLine.create({ + 'name': 'expense on project_so_1', + 'account_id': project_so_1.analytic_account_id.id, + 'employee_id': self.employee_user.id, + 'unit_amount': 4, + 'amount': 4 * self.product_expense.list_price * -1, + 'product_id': self.product_expense.id, + 'product_uom_id': self.product_expense.uom_id.id, + }) + expense2 = AnalyticLine.create({ + 'name': 'expense on global project', + 'account_id': self.project_global.analytic_account_id.id, + 'employee_id': self.employee_user.id, + 'unit_amount': 2, + 'amount': 2 * self.product_expense.list_price * -1, + 'product_id': self.product_expense.id, + 'product_uom_id': self.product_expense.uom_id.id, + }) + + # deliver project should now have expense cost, and expense to reinvoice as there is a still open sales order linked to the AA1 + project_so_1_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_1.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_1_timesheet_cost = timesheet1.amount + timesheet3.amount + timesheet5.amount + project_so_1_timesheet_sold_unit = timesheet3.unit_amount + timesheet5.unit_amount + self.assertEqual(float_compare(project_so_1_stat['amount_untaxed_invoiced'], self.so_line_deliver_project.price_unit * project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The invoiced amount of the project from SO1 should only include timesheet linked to task") + self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['timesheet_unit_amount'], project_so_1_timesheet_sold_unit + timesheet1.unit_amount, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO1 should include all timesheet in project") + self.assertEqual(float_compare(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO1 should include all timesheet") + self.assertEqual(float_compare(project_so_1_stat['expense_amount_untaxed_to_invoice'], -1 * expense1.amount, precision_rounding=rounding), 0, "The expense cost to reinvoice of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_so_1_stat['expense_cost'], expense1.amount, precision_rounding=rounding), 0, "The expense cost of the project from SO1 should be 0.0") + + # order project is not impacted by the expenses + project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_so_2_timesheet_cost = timesheet2.amount + timesheet4.amount + timesheet6.amount + project_so_2_timesheet_sold_unit = timesheet4.unit_amount + timesheet6.unit_amount + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_invoiced'], self.so_line_order_project.price_unit * self.so_line_order_project.product_uom_qty, precision_rounding=rounding), 0, "The invoiced amount should be the one from the SO line, as we are in ordered quantity") + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice should be the one 0.0, as all ordered quantity is invoiced") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit + timesheet2.unit_amount, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO2 should include all timesheet") + self.assertEqual(float_compare(project_so_2_stat['timesheet_cost'], project_so_2_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO2 should include all timesheet") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the project from SO2 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertTrue(float_is_zero(project_so_2_stat['expense_cost'], precision_rounding=rounding), "The expense cost of the project from SO2 should be 0.0") + + # global project should have an expense, but not reinvoicable + project_global_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', self.project_global.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0] + project_global_timesheet_cost = timesheet7.amount + timesheet8.amount + project_global_timesheet_unit = timesheet7.unit_amount + timesheet8.unit_amount + project_global_invoiced = self.so_line_order_task.price_unit * self.so_line_order_task.product_uom_qty + self.so_line_deliver_task.price_unit * timesheet7.unit_amount + self.assertEqual(float_compare(project_global_stat['amount_untaxed_invoiced'], project_global_invoiced, precision_rounding=rounding), 0, "The invoiced amount of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of global project should take the task in 'oredered qty' and the delivered timesheets into account") + self.assertEqual(float_compare(project_global_stat['timesheet_unit_amount'], project_global_timesheet_unit, precision_rounding=rounding), 0, "The timesheet unit amount of the global project should include all timesheet") + self.assertEqual(float_compare(project_global_stat['timesheet_cost'], project_global_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the global project should include all timesheet") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_to_invoice'], precision_rounding=rounding), "The expense cost to reinvoice of the global project should be 0.0") + self.assertTrue(float_is_zero(project_global_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0") + self.assertEqual(float_compare(project_global_stat['expense_cost'], expense2.amount, precision_rounding=rounding), 0, "The expense cost of the global project should be 0.0") diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 2192583395e..171cc1dcdd3 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -39,24 +39,25 @@ + - - - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + +
+ Hours Timesheets + Rates % Timesheets + Profitability @@ -101,8 +102,18 @@ % Non billable + + Other costs + + Total
@@ -111,10 +122,32 @@ Total - - Total + + Re-invoiced costs
+ + Total