Skip to content
Snippets Groups Projects
Commit 69c5a558 authored by Thibault Libioulle's avatar Thibault Libioulle
Browse files

[FIX] sale_timesheet: fix multiple issues in project profitability

This commit fixes multiple issues in project profitability report.

Steps to reproduce opw-2596224:
- Go to sale
- Make a RFQ for a service product with service_policy set at
delivered_timesheet (Timesheets on tasks), set quantity as 10
- Confirm order and create two invoices for 15%, confirm the two
invoices
- Create a credit note for one of the invoice and confirm it
- Go back to the Sale Order and click on Project Overview
=> Inconsistencies (Downpayment reported twice)
- Add an expense
=> Inconsistencies in expense amount untaxed invoiced
- Add timesheets
- Create an invoice from the SOL
=> Inconsistencies in Other costs, counting the downpayment.

This issue is fixed by :
- Excluding downpayments which are linked to a reversed invoice line
- Use expense amount to invoice and expense amount invoiced separetely
in the project overview.
- Do not report not invoiced SOLs in the expense amount invoiced.
- Exclude negative amounts in analytic account which are linked to
credit notes.
- Include all services and not timesheetes  SOLs linked to project,
project task or project's analytic account in the invoice/invoiced
subquery.
- Remove the unused downpayment column in the subqueries
- Remove unused subquery (relative to downpayment)

PR : #78230

opw-2596224
opw-2631163

X-original-commit: d0bce5403ef0876640022fea8951ff75ba7eb2ed
X-original-commit: 92a80d0f25652448f3cf445edea563f56370613a
Part-of: odoo/odoo#78523
parent 8aa5c39c
No related branches found
No related tags found
No related merge requests found
......@@ -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
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
SOLDOWN.id AS sale_line_id,
0.0 AS expense_cost
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
......
......@@ -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
......
# -*- 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 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,
})
This diff is collapsed.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment