diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis.py b/addons/sale_timesheet/report/project_profitability_report_analysis.py index 8455d1217db..af1763eeee2 100644 --- a/addons/sale_timesheet/report/project_profitability_report_analysis.py +++ b/addons/sale_timesheet/report/project_profitability_report_analysis.py @@ -135,7 +135,6 @@ class ProfitabilityAnalysis(models.Model): SUM(timesheet_cost) AS timesheet_cost, SUM(expense_cost) AS expense_cost, SUM(other_revenues) AS other_revenues, - SUM(downpayment_invoiced) AS downpayment_invoiced, SUM(expense_amount_untaxed_to_invoice) AS expense_amount_untaxed_to_invoice, SUM(expense_amount_untaxed_invoiced) AS expense_amount_untaxed_invoiced, SUM(amount_untaxed_to_invoice) AS amount_untaxed_to_invoice, @@ -151,7 +150,6 @@ class ProfitabilityAnalysis(models.Model): TS.amount AS timesheet_cost, 0.0 AS other_revenues, 0.0 AS expense_cost, - 0.0 AS downpayment_invoiced, 0.0 AS expense_amount_untaxed_to_invoice, 0.0 AS expense_amount_untaxed_invoiced, 0.0 AS amount_untaxed_to_invoice, @@ -162,7 +160,7 @@ class ProfitabilityAnalysis(models.Model): UNION ALL - -- Get the other revenues + -- Get the other revenues (products that are not services) SELECT P.id AS project_id, P.analytic_account_id AS analytic_account_id, @@ -171,7 +169,6 @@ class ProfitabilityAnalysis(models.Model): 0.0 AS timesheet_cost, AAL.amount AS other_revenues, 0.0 AS expense_cost, - 0.0 AS downpayment_invoiced, 0.0 AS expense_amount_untaxed_to_invoice, 0.0 AS expense_amount_untaxed_invoiced, 0.0 AS amount_untaxed_to_invoice, @@ -180,16 +177,12 @@ class ProfitabilityAnalysis(models.Model): FROM project_project P JOIN account_analytic_account AA ON P.analytic_account_id = AA.id JOIN account_analytic_line AAL ON AAL.account_id = AA.id - JOIN product_product PP ON PP.id = AAL.product_id - JOIN product_template PT ON PT.id = PP.product_tmpl_id LEFT JOIN sale_order_line_invoice_rel SOINV ON SOINV.invoice_line_id = AAL.move_id LEFT JOIN sale_order_line SOL ON SOINV.order_line_id = SOL.id WHERE AAL.amount > 0.0 AND AAL.project_id IS NULL AND P.active = 't' AND P.allow_timesheets = 't' - AND PT.service_type = 'manual' -- default value or Milestone service for services products - AND PT.service_tracking = 'no' -- default value or not a tracking service for services products AND (SOL.id IS NULL - OR (SOL.is_expense IS NOT TRUE AND SOL.is_downpayment IS NOT TRUE)) + OR (SOL.is_expense IS NOT TRUE AND SOL.is_downpayment IS NOT TRUE AND SOL.is_service IS NOT TRUE)) UNION ALL @@ -202,64 +195,32 @@ class ProfitabilityAnalysis(models.Model): 0.0 AS timesheet_cost, 0.0 AS other_revenues, AAL.amount AS expense_cost, - 0.0 AS downpayment_invoiced, 0.0 AS expense_amount_untaxed_to_invoice, 0.0 AS expense_amount_untaxed_invoiced, 0.0 AS amount_untaxed_to_invoice, 0.0 AS amount_untaxed_invoiced, AAL.date AS line_date 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' - - UNION ALL - - -- Get the invoiced downpayments - SELECT - P.id AS project_id, - P.analytic_account_id AS analytic_account_id, - MY_SOLS.id AS sale_line_id, - 0.0 AS timesheet_unit_amount, - 0.0 AS timesheet_cost, - 0.0 AS other_revenues, - 0.0 AS expense_cost, - CASE WHEN MY_SOLS.invoice_status = 'invoiced' THEN MY_SOLS.price_reduce ELSE 0.0 END AS downpayment_invoiced, - 0.0 AS expense_amount_untaxed_to_invoice, - 0.0 AS expense_amount_untaxed_invoiced, - 0.0 AS amount_untaxed_to_invoice, - 0.0 AS amount_untaxed_invoiced, - MY_S.date_order AS line_date - FROM project_project P - LEFT JOIN sale_order_line MY_SOL ON P.sale_line_id = MY_SOL.id - LEFT JOIN sale_order MY_S ON MY_SOL.order_id = MY_S.id - LEFT JOIN sale_order_line MY_SOLS ON MY_SOLS.order_id = MY_S.id - WHERE MY_SOLS.is_downpayment = 't' - - UNION ALL - - -- Get the expense costs from sale order line - SELECT - P.id AS project_id, - P.analytic_account_id AS analytic_account_id, - OLIS.id AS sale_line_id, - 0.0 AS timesheet_unit_amount, - 0.0 AS timesheet_cost, - 0.0 AS other_revenues, - OLIS.price_reduce AS expense_cost, - 0.0 AS downpayment_invoiced, - 0.0 AS expense_amount_untaxed_to_invoice, - 0.0 AS expense_amount_untaxed_invoiced, - 0.0 AS amount_untaxed_to_invoice, - 0.0 AS amount_untaxed_invoiced, - ANLI.date AS line_date - FROM project_project P - LEFT JOIN account_analytic_account ANAC ON P.analytic_account_id = ANAC.id - LEFT JOIN account_analytic_line ANLI ON ANAC.id = ANLI.account_id - LEFT JOIN sale_order_line OLI ON P.sale_line_id = OLI.id - LEFT JOIN sale_order ORD ON OLI.order_id = ORD.id - LEFT JOIN sale_order_line OLIS ON ORD.id = OLIS.order_id - WHERE OLIS.product_id = ANLI.product_id AND OLIS.is_downpayment = 't' AND ANLI.amount < 0.0 AND ANLI.project_id IS NULL AND P.active = 't' AND P.allow_timesheets = 't' + JOIN account_analytic_account AA ON P.analytic_account_id = AA.id + JOIN account_analytic_line AAL ON AAL.account_id = AA.id + LEFT JOIN account_move_line RINVL ON AAL.move_id = RINVL.id + AND RINVL.parent_state = 'posted' + AND RINVL.exclude_from_invoice_tab = 'f' + -- Check if the AAL is not related to a reversed credit note + LEFT JOIN account_move RINV ON RINV.id = RINVL.move_id + LEFT JOIN account_move_line INVL ON INVL.move_id = RINV.reversed_entry_id + AND INVL.parent_state = 'posted' + AND INVL.exclude_from_invoice_tab = 'f' + AND INVL.product_id = RINVL.product_id + LEFT JOIN sale_order_line_invoice_rel SOINV ON SOINV.invoice_line_id = INVL.id + LEFT JOIN sale_order_line SOL ON SOINV.order_line_id = SOL.id + AND SOL.product_id = AAL.product_id + -- Check if the AAL is not related to a consumed downpayment (when the SOL is fully invoiced - with downpayment discounted.) + LEFT JOIN sale_order_line_invoice_rel SOINVDOWN ON SOINVDOWN.invoice_line_id = RINVL.id + LEFT JOIN sale_order_line SOLDOWN on SOINVDOWN.order_line_id = SOLDOWN.id AND SOLDOWN.is_downpayment = 't' + WHERE AAL.amount < 0.0 AND AAL.project_id IS NULL + AND SOL.id IS NULL AND SOLDOWN.id IS NULL -- Not linked to a credit note and not a downpayment + AND P.active = 't' AND P.allow_timesheets = 't' UNION ALL @@ -273,17 +234,16 @@ class ProfitabilityAnalysis(models.Model): 0.0 AS timesheet_cost, 0.0 AS other_revenues, 0.0 AS expense_cost, - 0.0 AS downpayment_invoiced, CASE WHEN SOL.qty_delivered_method = 'analytic' THEN (SOL.untaxed_amount_to_invoice / CASE COALESCE(S.currency_rate, 0) WHEN 0 THEN 1.0 ELSE S.currency_rate END) ELSE 0.0 END AS expense_amount_untaxed_to_invoice, CASE - WHEN SOL.qty_delivered_method = 'analytic' AND SOL.invoice_status != 'no' + WHEN SOL.qty_delivered_method = 'analytic' AND SOL.invoice_status = 'invoiced' THEN CASE WHEN T.expense_policy = 'sales_price' - THEN (SOL.price_reduce / CASE COALESCE(S.currency_rate, 0) WHEN 0 THEN 1.0 ELSE S.currency_rate END) * SOL.qty_invoiced + THEN (SOL.untaxed_amount_invoiced / CASE COALESCE(S.currency_rate, 0) WHEN 0 THEN 1.0 ELSE S.currency_rate END) ELSE -AMOUNT_UNTAXED.expense_cost END ELSE 0.0 @@ -293,31 +253,80 @@ class ProfitabilityAnalysis(models.Model): ELSE 0.0 END AS amount_untaxed_to_invoice, CASE - WHEN SOL.qty_delivered_method IN ('timesheet', 'manual') THEN (COALESCE(SOL.untaxed_amount_invoiced, AMOUNT_UNTAXED.downpayment_invoiced) / CASE COALESCE(S.currency_rate, 0) WHEN 0 THEN 1.0 ELSE S.currency_rate END) + WHEN SOL.qty_delivered_method IN ('timesheet', 'manual') THEN (SOL.untaxed_amount_invoiced / CASE COALESCE(S.currency_rate, 0) WHEN 0 THEN 1.0 ELSE S.currency_rate END) ELSE 0.0 END AS amount_untaxed_invoiced, S.date_order AS line_date FROM project_project P JOIN res_company C ON C.id = P.company_id LEFT JOIN ( + -- Gets SOL linked to timesheets SELECT P.id AS project_id, P.analytic_account_id AS analytic_account_id, AAL.so_line AS sale_line_id, - 0.0 AS expense_cost, - 0.0 AS downpayment_invoiced + 0.0 AS expense_cost FROM account_analytic_line AAL, project_project P WHERE AAL.project_id IS NOT NULL AND P.id = AAL.project_id AND P.active = 't' GROUP BY P.id, AAL.so_line - + UNION + -- Service SOL linked to a project task AND not yet timesheeted + SELECT + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, + SOL.id AS sale_line_id, + 0.0 AS expense_cost + FROM sale_order_line SOL + JOIN project_task T ON T.sale_line_id = SOL.id + JOIN project_project P ON T.project_id = P.id + LEFT JOIN account_analytic_line AAL ON AAL.task_id = T.id + WHERE SOL.is_service = 't' + AND AAL.id IS NULL -- not timesheeted + AND P.active = 't' AND P.allow_timesheets = 't' + GROUP BY P.id, SOL.id + UNION + -- Service SOL linked to project AND not yet timesheeted + SELECT + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, + SOL.id AS sale_line_id, + 0.0 AS expense_cost + FROM sale_order_line SOL + JOIN project_project P ON P.sale_line_id = SOL.id + LEFT JOIN account_analytic_line AAL ON AAL.project_id = P.id + LEFT JOIN project_task T ON T.sale_line_id = SOL.id + WHERE SOL.is_service = 't' + AND AAL.id IS NULL -- not timesheeted + AND (T.id IS NULL OR T.project_id != P.id) -- not linked to a task in this project + AND P.active = 't' AND P.allow_timesheets = 't' + GROUP BY P.id, SOL.id + UNION + -- Service SOL linked to analytic account AND not yet timesheeted + SELECT + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, + SOL.id AS sale_line_id, + 0.0 AS expense_cost + FROM sale_order_line SOL + JOIN sale_order SO ON SO.id = SOL.order_id + JOIN account_analytic_account AA ON AA.id = SO.analytic_account_id + JOIN project_project P ON P.analytic_account_id = AA.id + LEFT JOIN project_project PSOL ON PSOL.sale_line_id = SOL.id + LEFT JOIN project_task TSOL ON TSOL.sale_line_id = SOL.id + LEFT JOIN account_analytic_line AAL ON AAL.so_line = SOL.id + WHERE SOL.is_service = 't' + AND AAL.id IS NULL -- not timesheeted + AND TSOL.id IS NULL -- not linked to a task + AND PSOL.id IS NULL -- not linked to a project + AND P.active = 't' AND P.allow_timesheets = 't' + GROUP BY P.id, SOL.id 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 expense_cost, - 0.0 AS downpayment_invoiced + 0.0 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 @@ -328,8 +337,7 @@ class ProfitabilityAnalysis(models.Model): P.id AS project_id, P.analytic_account_id AS analytic_account_id, AAL.so_line AS sale_line_id, - SUM(AAL.amount) AS expense_cost, - 0.0 AS downpayment_invoiced + 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 @@ -339,37 +347,31 @@ class ProfitabilityAnalysis(models.Model): SELECT P.id AS project_id, P.analytic_account_id AS analytic_account_id, - MY_SOLS.id AS sale_line_id, - 0.0 AS expense_cost, - CASE WHEN MY_SOLS.invoice_status = 'invoiced' THEN MY_SOLS.price_reduce ELSE 0.0 END AS downpayment_invoiced + SOLDOWN.id AS sale_line_id, + 0.0 AS expense_cost FROM project_project P - LEFT JOIN sale_order_line MY_SOL ON P.sale_line_id = MY_SOL.id - LEFT JOIN sale_order MY_S ON MY_SOL.order_id = MY_S.id - LEFT JOIN sale_order_line MY_SOLS ON MY_SOLS.order_id = MY_S.id - WHERE MY_SOLS.is_downpayment = 't' - GROUP BY P.id, MY_SOLS.id - UNION - SELECT - P.id AS project_id, - P.analytic_account_id AS analytic_account_id, - OLIS.id AS sale_line_id, - OLIS.price_reduce AS expense_cost, - 0.0 AS downpayment_invoiced - FROM project_project P - LEFT JOIN account_analytic_account ANAC ON P.analytic_account_id = ANAC.id - LEFT JOIN account_analytic_line ANLI ON ANAC.id = ANLI.account_id - LEFT JOIN sale_order_line OLI ON P.sale_line_id = OLI.id - LEFT JOIN sale_order ORD ON OLI.order_id = ORD.id - LEFT JOIN sale_order_line OLIS ON ORD.id = OLIS.order_id - WHERE OLIS.product_id = ANLI.product_id AND OLIS.is_downpayment = 't' AND ANLI.amount < 0.0 AND ANLI.project_id IS NULL AND P.active = 't' AND P.allow_timesheets = 't' - GROUP BY P.id, OLIS.id + LEFT JOIN sale_order_line SOL ON P.sale_line_id = SOL.id + LEFT JOIN sale_order SO ON SO.id = SOL.order_id OR SO.analytic_account_id = P.analytic_account_id + LEFT JOIN sale_order_line SOLDOWN ON SOLDOWN.order_id = SO.id AND SOLDOWN.is_downpayment = 't' + LEFT JOIN sale_order_line_invoice_rel SOINV ON SOINV.order_line_id = SOLDOWN.id + LEFT JOIN account_move_line INVL ON SOINV.invoice_line_id = INVL.id + AND INVL.parent_state = 'posted' + AND INVL.exclude_from_invoice_tab = 'f' + LEFT JOIN account_move RINV ON INVL.move_id = RINV.reversed_entry_id + LEFT JOIN account_move_line RINVL ON RINV.id = RINVL.move_id + AND RINVL.parent_state = 'posted' + AND RINVL.exclude_from_invoice_tab = 'f' + AND RINVL.product_id = SOLDOWN.product_id + LEFT JOIN account_analytic_line ANLI ON ANLI.move_id = RINVL.id AND ANLI.amount < 0.0 + WHERE ANLI.id IS NULL -- there are no credit note for this downpayment + AND P.active = 't' AND P.allow_timesheets = 't' + GROUP BY P.id, SOLDOWN.id UNION SELECT P.id AS project_id, P.analytic_account_id AS analytic_account_id, SOL.id AS sale_line_id, - 0.0 AS expense_cost, - 0.0 AS downpayment_invoiced + 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' @@ -378,8 +380,7 @@ class ProfitabilityAnalysis(models.Model): P.id AS project_id, P.analytic_account_id AS analytic_account_id, SOL.id AS sale_line_id, - 0.0 AS expense_cost, - 0.0 AS downpayment_invoiced + 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 diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index 9206f5f0eab..fe85b799a05 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -2,6 +2,7 @@ # # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import common +from . import common_reporting from . import test_sale_timesheet from . import test_sale_service from . import test_project_billing diff --git a/addons/sale_timesheet/tests/common_reporting.py b/addons/sale_timesheet/tests/common_reporting.py new file mode 100644 index 00000000000..c88698e3ed9 --- /dev/null +++ b/addons/sale_timesheet/tests/common_reporting.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet + + +class TestCommonReporting(TestCommonSaleTimesheet): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # 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.company_data['default_account_revenue'].id, + }) + + # create Analytic Accounts + cls.analytic_account_1 = cls.env['account.analytic.account'].create({ + 'name': 'Test AA 1', + 'code': 'AA1', + 'company_id': cls.company_data['company'].id, + 'partner_id': cls.partner_a.id + }) + cls.analytic_account_2 = cls.env['account.analytic.account'].create({ + 'name': 'Test AA 2', + 'code': 'AA2', + 'company_id': cls.company_data['company'].id, + 'partner_id': cls.partner_a.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_a.id, + 'partner_invoice_id': cls.partner_a.id, + 'partner_shipping_id': cls.partner_a.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_a.id, + 'partner_invoice_id': cls.partner_a.id, + 'partner_shipping_id': cls.partner_a.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, + }) diff --git a/addons/sale_timesheet/tests/test_reporting.py b/addons/sale_timesheet/tests/test_reporting.py index 88d98cf9c52..844771321d2 100644 --- a/addons/sale_timesheet/tests/test_reporting.py +++ b/addons/sale_timesheet/tests/test_reporting.py @@ -1,115 +1,13 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.osv import expression from odoo.tools import float_is_zero, float_compare -from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet +from odoo.addons.sale_timesheet.tests.common_reporting import TestCommonReporting from odoo.tests import tagged @tagged('-at_install', 'post_install') -class TestReporting(TestCommonSaleTimesheet): - - @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) - - # 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.company_data['default_account_revenue'].id, - }) - - # create Analytic Accounts - cls.analytic_account_1 = cls.env['account.analytic.account'].create({ - 'name': 'Test AA 1', - 'code': 'AA1', - 'company_id': cls.company_data['company'].id, - 'partner_id': cls.partner_a.id - }) - cls.analytic_account_2 = cls.env['account.analytic.account'].create({ - 'name': 'Test AA 2', - 'code': 'AA2', - 'company_id': cls.company_data['company'].id, - 'partner_id': cls.partner_a.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_a.id, - 'partner_invoice_id': cls.partner_a.id, - 'partner_shipping_id': cls.partner_a.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_a.id, - 'partner_invoice_id': cls.partner_a.id, - 'partner_shipping_id': cls.partner_a.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, - }) +class TestReporting(TestCommonReporting): def test_profitability_report(self): @@ -397,3 +295,207 @@ class TestReporting(TestCommonSaleTimesheet): 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") + + def test_reversed_downpayment(self): + # this test suppose everything is in the same currency as the current one + currency = self.env.company.currency_id + rounding = currency.rounding + + self.sale_order_2.action_confirm() + context = { + 'active_model': 'sale.order', + 'active_ids': self.sale_order_2.ids, + 'active_id': self.sale_order_2.id, + 'default_journal_id': self.company_data['default_journal_sale'].id, + 'open_invoices': True, + } + # Let's do an invoice for a deposit of 100 + downpayment = self.env['sale.advance.payment.inv'].with_context(context).create({ + 'advance_payment_method': 'percentage', + 'amount': 10, + }) + action_invoice = downpayment.create_invoices() + invoice_id = action_invoice['res_id'] + invoice_downpayment = self.env['account.move'].browse(invoice_id) + invoice_downpayment.action_post() + posted_invoice_res_ids = [invoice_id] + downpayment2 = self.env['sale.advance.payment.inv'].with_context(context).create({ + 'advance_payment_method': 'percentage', + 'amount': 25, + }) + action_invoice = downpayment2.create_invoices() + invoice_downpayment2 = self.env['account.move'].search(expression.AND([action_invoice['domain'], [('id', 'not in', posted_invoice_res_ids)]])) + invoice_downpayment2.action_post() + posted_invoice_res_ids += invoice_downpayment2.ids + milestone_to_invoice = self.so_line_order_project.price_unit * self.so_line_order_project.qty_to_invoice + timesheets_to_invoice = self.so_line_order_task.price_unit * self.so_line_order_task.qty_to_invoice + total_product_price = milestone_to_invoice + timesheets_to_invoice + credit_note_wizard = self.env['account.move.reversal'].with_context({ + 'active_ids': invoice_downpayment2.ids, + 'active_id': invoice_downpayment2.id, + 'active_model': 'account.move' + }).create({ + 'refund_method': 'refund', + 'reason': 'reason test create', + 'journal_id': invoice_downpayment2.journal_id.id, + }) + action_moves = credit_note_wizard.reverse_moves() + credit_id = action_moves['res_id'] + invoice_credit = self.env['account.move'].browse(credit_id) + invoice_credit.action_post() + posted_invoice_res_ids += invoice_credit.ids + project_so_2 = self.so_line_order_project.project_id + task_so_2 = self.so_line_order_project.task_id + task_in_global_2 = self.so_line_order_task.task_id + + self.env['project.profitability.report'].flush() + 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', 'other_revenues'], + ['project_id'] + )[0] + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_invoiced'], 0.1 * total_product_price, precision_rounding=rounding), 0, + "The invoiced amount is the amount of downpayments not reversed") + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_to_invoice'], milestone_to_invoice - 0.1 * total_product_price, precision_rounding=rounding), 0, + "The amount to invoice is the milestone product minus the downpayment not reversed") + self.assertTrue(float_is_zero(project_so_2_stat['timesheet_unit_amount'], precision_rounding=rounding), + "The timesheet unit amount of the project from SO2 is 0") + self.assertTrue(float_is_zero(project_so_2_stat['timesheet_cost'], precision_rounding=rounding), + "The timesheet cost of the project from SO2 is 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 project from SO2 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") + self.assertTrue(float_is_zero(project_so_2_stat['other_revenues'], precision_rounding=rounding), + "The other revenues of the project from SO2 should be 0.0") + + # logged some timesheets: on project only, then on tasks with different employees + timesheet2 = self._log_timesheet_user(project_so_2, 2) + timesheet4 = self._log_timesheet_user(project_so_2, 3, task_so_2) + timesheet6 = self._log_timesheet_manager(project_so_2, 2, task_so_2) + self._log_timesheet_manager(self.project_global, 3, task_in_global_2) + + # simulate the auto creation of the SO line for expense, like we confirm a vendor bill. + so_line_expense = self.env['sale.order.line'].create({ + 'name': self.product_expense.name, + 'product_id': self.product_expense.id, + 'product_uom_qty': 0.0, + 'product_uom': self.product_expense.uom_id.id, + 'price_unit': self.product_expense.list_price, # reinvoice at sales price + 'order_id': self.sale_order_2.id, + 'is_expense': True, + }) + + expense1 = self.env['account.analytic.line'].create({ + 'name': 'expense on project_so_2', + 'account_id': project_so_2.analytic_account_id.id, + 'so_line': so_line_expense.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, + }) + + self.env['project.profitability.report'].flush() + + 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', 'other_revenues'], + ['project_id'] + )[0] + project_so_2_timesheet_cost = timesheet2.amount + timesheet4.amount + timesheet6.amount + project_so_2_timesheet_sold_unit = timesheet2.unit_amount + timesheet4.unit_amount + timesheet6.unit_amount + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_invoiced'], 0.1 * total_product_price, precision_rounding=rounding), 0, + "The invoiced amount of the project from SO2 should only include downpayment not reversed") + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_to_invoice'], milestone_to_invoice - 0.1 * total_product_price, precision_rounding=rounding), 0, + "The amount to invoice of the project from SO2 should include the milestone to invoice minus the downpayment") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit, precision_rounding=rounding), 0, + "The timesheet unit amount of the project from SO2 should include all timesheet in project") + 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.assertEqual(float_compare(project_so_2_stat['expense_amount_untaxed_to_invoice'], -expense1.amount, precision_rounding=rounding), 0, + "The expense cost to reinvoice of the project from SO2 should be the expense amount") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), + "The expense invoiced amount of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['expense_cost'], expense1.amount, precision_rounding=rounding), 0, + "The expense cost of the project from SO1 should be expense amount") + self.assertTrue(float_is_zero(project_so_2_stat['other_revenues'], precision_rounding=rounding), + "The other revenues of the project from SO2 should be 0.0") + + # invoice the Sales Order SO2 + context = { + "active_model": 'sale.order', + "active_ids": self.sale_order_2.ids, + "active_id": self.sale_order_2.id, + 'open_invoices': True, + 'mail_notrack': True, + } + payment = self.env['sale.advance.payment.inv'].with_context(mail_notrack=True).create({ + 'advance_payment_method': 'delivered', + }) + action_invoice = payment.with_context(context).create_invoices() + invoice_payment = self.env['account.move'].search(expression.AND([action_invoice['domain'], [('id', 'not in', posted_invoice_res_ids)]])) + invoice_payment.action_post() + self.env['project.profitability.report'].flush() + + 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', 'other_revenues'], + ['project_id'] + )[0] + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_invoiced'], milestone_to_invoice, precision_rounding=rounding), 0, + "The invoiced amount of the project from SO2 should only include timesheet linked to task") + self.assertTrue(float_is_zero(project_so_2_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), + "The amount to invoice of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit, precision_rounding=rounding), 0, + "The timesheet unit amount of the project from SO2 should include all timesheet in project") + 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 to invoice amount of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['expense_amount_untaxed_invoiced'], -1 * expense1.amount, precision_rounding=rounding), 0, + "The expense cost reinvoiced of the project from SO2 should be the expense amount") + self.assertEqual(float_compare(project_so_2_stat['expense_cost'], expense1.amount, precision_rounding=rounding), 0, + "The expense cost of the project from SO2 should be the expense amount") + self.assertTrue(float_is_zero(project_so_2_stat['other_revenues'], precision_rounding=rounding), + "The other revenues of the project from SO2 should be 0.0") + + credit_note_wizard = self.env['account.move.reversal'].with_context({ + 'active_ids': invoice_payment.ids, + 'active_id': invoice_payment.id, + 'active_model': 'account.move' + }).create({ + 'refund_method': 'refund', + 'reason': 'reason test create', + 'journal_id': invoice_payment.journal_id.id, + }) + action_moves = credit_note_wizard.reverse_moves() + credit_id = action_moves['res_id'] + invoice_credit = self.env['account.move'].browse(credit_id) + invoice_credit.action_post() + self.env['project.profitability.report'].flush() + + 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', 'other_revenues'], + ['project_id'] + )[0] + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_invoiced'], 0.1 * total_product_price, precision_rounding=rounding), 0, + "The invoiced amount of the project from SO2 should only include downpayment not reversed") + self.assertEqual(float_compare(project_so_2_stat['amount_untaxed_to_invoice'], milestone_to_invoice - 0.1 * total_product_price, precision_rounding=rounding), 0, + "The amount to invoice of the project from SO2 should include the milestone to invoice minus the downpayment") + self.assertEqual(float_compare(project_so_2_stat['timesheet_unit_amount'], project_so_2_timesheet_sold_unit, precision_rounding=rounding), 0, + "The timesheet unit amount of the project from SO2 should include all timesheet in project") + 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.assertEqual(float_compare(project_so_2_stat['expense_amount_untaxed_to_invoice'], -expense1.amount, precision_rounding=rounding), 0, + "The expense cost to reinvoice of the project from SO2 should be the expense amount") + self.assertTrue(float_is_zero(project_so_2_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), + "The expense invoiced amount of the project from SO2 should be 0.0") + self.assertEqual(float_compare(project_so_2_stat['expense_cost'], expense1.amount, precision_rounding=rounding), 0, + "The expense cost of the project from SO1 should be expense amount") + self.assertTrue(float_is_zero(project_so_2_stat['other_revenues'], precision_rounding=rounding), + "The other revenues of the project from SO2 should be 0.0")