From 89055c167ecbe4a948b167295e5517e0b3398642 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Thu, 16 Nov 2017 14:00:57 +0100 Subject: [PATCH 01/16] [IMP] sale_timesheet: add profitability report analysis This commit provide a real reporting tool for project profitability: graph and pivot views. You can now have a look at cost and revenues per project, so line, product, see which project manager brings the more money, which project costs many human time, ... The project overview is based on this report analysis to be consistent. However, a change of computation for 'amount invoiced' is introduced: we now based the amount on the price_reduce of the SO line, and not from the invoice lines. Also, once a draft invoice is generated, the quantity is considered as invoiced at the price of the SO line (no matter is extra discount happens when validating invoices). --- addons/sale_timesheet/__init__.py | 1 + addons/sale_timesheet/__manifest__.py | 3 + addons/sale_timesheet/controllers/main.py | 18 ++-- .../data/sale_timesheet_filters.xml | 28 ++++++ addons/sale_timesheet/report/__init__.py | 4 + .../project_profitability_report_analysis.py | 86 +++++++++++++++++++ ...ct_profitability_report_analysis_views.xml | 72 ++++++++++++++++ .../security/ir.model.access.csv | 2 + .../views/hr_timesheet_views.xml | 38 -------- 9 files changed, 207 insertions(+), 45 deletions(-) create mode 100644 addons/sale_timesheet/data/sale_timesheet_filters.xml create mode 100644 addons/sale_timesheet/report/__init__.py create mode 100644 addons/sale_timesheet/report/project_profitability_report_analysis.py create mode 100644 addons/sale_timesheet/report/project_profitability_report_analysis_views.xml create mode 100644 addons/sale_timesheet/security/ir.model.access.csv diff --git a/addons/sale_timesheet/__init__.py b/addons/sale_timesheet/__init__.py index 8c67f893b37..3226df762b3 100644 --- a/addons/sale_timesheet/__init__.py +++ b/addons/sale_timesheet/__init__.py @@ -3,3 +3,4 @@ from . import models from . import controllers +from . import report diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 0cb9db63bae..f0d059b1e65 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -16,6 +16,7 @@ have real delivered quantities in sales orders. 'website': 'https://www.odoo.com/page/warehouse', 'depends': ['sale_management', 'hr_timesheet'], 'data': [ + 'security/ir.model.access.csv', 'security/sale_timesheet_security.xml', 'views/account_invoice_views.xml', 'views/sale_order_views.xml', @@ -25,6 +26,8 @@ have real delivered quantities in sales orders. 'views/hr_timesheet_views.xml', 'views/res_config_settings_views.xml', 'views/hr_timesheet_templates.xml', + 'report/project_profitability_report_analysis_views.xml', + 'data/sale_timesheet_filters.xml', ], 'demo': [ 'data/sale_service_demo.xml', diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index dc4e2eb1185..08dfb5033da 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -60,13 +60,17 @@ class SaleTimesheetController(http.Controller): dashboard_values['rates'][billable_type] = rate dashboard_values['rates']['total'] += rate - # money_amount - so_lines = values['timesheet_lines'].mapped('so_line') - invoice_lines = so_lines.mapped('invoice_lines') - dashboard_values['money_amount']['invoiced'] = sum([inv_line.currency_id.with_context(date=inv_line.invoice_id.date_invoice).compute(inv_line.price_unit * inv_line.quantity, currency) for inv_line in invoice_lines.filtered(lambda line: line.invoice_id.state in ['open', 'paid'])]) - dashboard_values['money_amount']['to_invoice'] = sum([sol.currency_id.compute(sol.price_unit * (1 - (sol.discount or 0.0) / 100.0) * sol.qty_to_invoice, currency) for sol in so_lines]) + sum([i.currency_id.with_context(date=i.invoice_id.date_invoice).compute(i.price_unit * i.quantity, currency) for i in invoice_lines.filtered(lambda line: line.invoice_id.state == 'draft')]) - dashboard_values['money_amount']['cost'] = sum(values['timesheet_lines'].mapped('amount')) - dashboard_values['money_amount']['total'] = sum([dashboard_values['money_amount'][item] for item in dashboard_values['money_amount'].keys()]) + # profitability + project_ids = values['timesheet_lines'].mapped('project_id').ids + money_amount = dict.fromkeys(['invoiced', 'to_invoice', 'cost', 'total'], 0.0) + + profitability_raw_data = request.env['project.profitability.report'].read_group([('project_id', 'in', project_ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost'], ['project_id']) + for data in profitability_raw_data: + money_amount['invoiced'] += data.get('amount_untaxed_invoiced', 0.0) + money_amount['to_invoice'] += data.get('amount_untaxed_to_invoice', 0.0) + money_amount['cost'] += data.get('timesheet_cost', 0.0) + money_amount['total'] = sum([money_amount[item] for item in money_amount.keys()]) + dashboard_values['money_amount'] = money_amount values['dashboard'] = dashboard_values diff --git a/addons/sale_timesheet/data/sale_timesheet_filters.xml b/addons/sale_timesheet/data/sale_timesheet_filters.xml new file mode 100644 index 00000000000..e66c608bd93 --- /dev/null +++ b/addons/sale_timesheet/data/sale_timesheet_filters.xml @@ -0,0 +1,28 @@ + + + + + Costs and Revenues + project.profitability.report + + + { + 'group_by': ['project_id', 'sale_line_id'], + 'pivot_measures': ['amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost', 'timesheet_unit_amount'], + 'pivot_column_groupby': [], 'pivot_row_groupby': [] + } + + + + Product by Customer + project.profitability.report + + { + 'group_by': ['partner_id', 'product_id'], + 'graph_measure': 'amount_untaxed_to_invoice', + 'graph_mode': 'bar', + 'graph_groupbys': ['partner_id', 'product_id'] + } + + + diff --git a/addons/sale_timesheet/report/__init__.py b/addons/sale_timesheet/report/__init__.py new file mode 100644 index 00000000000..22d6147cc83 --- /dev/null +++ b/addons/sale_timesheet/report/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import project_profitability_report_analysis diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis.py b/addons/sale_timesheet/report/project_profitability_report_analysis.py new file mode 100644 index 00000000000..50eb685d103 --- /dev/null +++ b/addons/sale_timesheet/report/project_profitability_report_analysis.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, tools + + +class ProfitabilityAnalysis(models.Model): + + _name = "project.profitability.report" + _description = "Project Profitability Analysis" + _order = 'project_id, sale_line_id' + _auto = False + + analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', readonly=True) + project_id = fields.Many2one('project.project', string='Project', readonly=True) + currency_id = fields.Many2one('res.currency', string='Project Currency', readonly=True) + company_id = fields.Many2one('res.company', string='Project Company', readonly=True) + user_id = fields.Many2one('res.users', string='Project Manager', readonly=True) + partner_id = fields.Many2one('res.partner', string='Customer', readonly=True) + # 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") + # 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") + + def init(self): + tools.drop_view_if_exists(self._cr, self._table) + query = """ + CREATE VIEW %s AS ( + WITH currency_rate as (%s) + SELECT + ROW_NUMBER() OVER (ORDER BY P.id, SOL.id) AS id, + 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, + C.id AS company_id, + C.currency_id AS currency_id, + 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 + 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 + 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 + ( + 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 + 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 + LEFT JOIN sale_order S ON SOL.order_id = S.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()))) + ) + """ % (self._table, self.env['res.currency']._select_companies_rates()) + self._cr.execute(query) diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml b/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml new file mode 100644 index 00000000000..6f803ae126f --- /dev/null +++ b/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml @@ -0,0 +1,72 @@ + + + + + project.profitability.report.pivot + project.profitability.report + + + + + + + + + + + + project.profitability.report.graph + project.profitability.report + + + + + + + + + + + + + project.profitability.report.search + project.profitability.report + + + + + + + + + + + + + + + + + + + + Project Costs and Revenues + project.profitability.report + form + pivot,graph + + { + 'group_by_no_leaf':1, + 'group_by':[], + 'sale_show_order_product_name': 1, + } + This report allows you to analyse the profitability of your projects: compare the amount to invoice, the ones already invoiced and the project cost (via timesheet cost of your employees). + + + + + diff --git a/addons/sale_timesheet/security/ir.model.access.csv b/addons/sale_timesheet/security/ir.model.access.csv new file mode 100644 index 00000000000..bce0148e1f3 --- /dev/null +++ b/addons/sale_timesheet/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_project_profitability_report_analysis_manager,project.profitability.report.analysis,model_project_profitability_report,project.group_project_manager,1,1,1,1 diff --git a/addons/sale_timesheet/views/hr_timesheet_views.xml b/addons/sale_timesheet/views/hr_timesheet_views.xml index a9beb7585b3..34a1a8229cf 100644 --- a/addons/sale_timesheet/views/hr_timesheet_views.xml +++ b/addons/sale_timesheet/views/hr_timesheet_views.xml @@ -61,44 +61,6 @@ name="By Billing Rate" sequence="40"/> - - Costs and Revenues - account.analytic.line - - [('project_id', '!=', False)] - { - 'search_default_groupby_project':1, - 'search_default_groupby_employee':2, - } - - - - - pivot - - - - - - - tree - - - - - - - form - - - - - - From ca36c22c546c54deb6afca0363fbf1dac35fd7a2 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Mon, 19 Feb 2018 10:38:43 +0100 Subject: [PATCH 02/16] [IMP] sale_timesheet: remove project of a Sales Order The project of a SO will not be used anymore, as we planned to have a sale order creating multiple projects. The concept of 'only project from SO' does not make sense. --- addons/sale_timesheet/models/sale_order.py | 11 +--------- .../sale_timesheet/tests/test_sale_service.py | 4 ++-- .../tests/test_sale_timesheet.py | 22 ++++++++++--------- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 2116722d9d0..53d1291df8b 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -18,7 +18,6 @@ class SaleOrder(models.Model): tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', string='Tasks associated to this sale') tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user") - project_project_id = fields.Many2one('project.project', compute='_compute_project_project_id', string='Project associated to this sale') project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user", help="Projects used in this sales order.") @api.multi @@ -42,18 +41,10 @@ class SaleOrder(models.Model): order.tasks_count = len(order.tasks_ids) @api.multi - @api.depends('analytic_account_id.project_ids') - def _compute_project_project_id(self): - for order in self: - order.project_project_id = self.env['project.project'].search([('analytic_account_id', '=', order.analytic_account_id.id)]) - - @api.multi - @api.depends('order_line.product_id', 'project_project_id') + @api.depends('order_line.product_id') def _compute_project_ids(self): for order in self: projects = order.order_line.mapped('product_id.project_id') - if order.project_project_id: - projects |= order.project_project_id order.project_ids = projects @api.multi diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index bccf96eb630..202d3051149 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -106,7 +106,7 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): # let's log some timesheets self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': self.sale_order.project_project_id.id, + 'project_id': task.project_id.id, 'task_id': task.id, 'unit_amount': 16, 'employee_id': self.employee_manager.id, @@ -115,7 +115,7 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': self.sale_order.project_project_id.id, + 'project_id': task.project_id.id, 'task_id': task.id, 'unit_amount': 24, 'employee_id': self.employee_user.id, diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py index e48849e078c..212fa2b550c 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -58,7 +58,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_global_project.id)]) self.assertEqual(sale_order.tasks_count, 1, "One task should have been created on SO confirmation") - self.assertTrue(sale_order.project_project_id, "A project should have been created by the SO, when confirmed.") + self.assertTrue(sale_order.project_ids, "A project should have been created by the SO, when confirmed.") # create invoice sale_order.action_invoice_create() @@ -66,7 +66,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): # let's log some timesheets (on the project created by so_line_ordered_project_only) self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': sale_order.project_project_id.id, + 'project_id': task_serv2.project_id.id, 'task_id': task_serv2.id, 'unit_amount': 10.5, 'employee_id': self.employee_user.id, @@ -76,7 +76,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': sale_order.project_project_id.id, + 'project_id': task_serv2.project_id.id, 'task_id': task_serv2.id, 'unit_amount': 39.5, 'employee_id': self.employee_user.id, @@ -86,7 +86,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': sale_order.project_project_id.id, + 'project_id': task_serv2.project_id.id, 'unit_amount': 10, 'employee_id': self.employee_user.id, }) @@ -95,7 +95,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): # log timesheet on task in global project (higher than the initial ordrered qty) self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': sale_order.project_project_id.id, + 'project_id': task_serv2.project_id.id, 'task_id': task_serv2.id, 'unit_amount': 5, 'employee_id': self.employee_user.id, @@ -112,6 +112,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): 'order_id': sale_order.id, }) task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_task_new_project.id)]) + project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_ordered_project_only.id)]) self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: Adding a new service line (so line) should put the SO in "to invocie" state.') self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation") @@ -120,7 +121,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): invoice = self.env['account.invoice'].browse(invoice_id) self.assertEqual(len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO") self.assertTrue(float_is_zero(invoice.amount_total - so_line_ordered_task_new_project.price_unit * 3, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong') - self.assertEqual(sale_order.project_project_id, task_serv3.project_id, "When creating task in new project, the task should be in SO project (if already exists), otherwise it created one.") + self.assertEqual(project_serv2, task_serv3.project_id, "When creating task in new project, the task should be in SO project (if already exists), otherwise it created one.") def test_timesheet_delivery(self): """ Test timesheet invoicing with 'invoice on delivery' timetracked products @@ -161,6 +162,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): sale_order.action_confirm() task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + project_serv3 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) self.assertEqual(task_serv2.project_id, self.project_global, "Sale Timesheet: task should be created in global project") self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in global project") @@ -212,12 +214,12 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): 'price_unit': self.product_delivery_timesheet4.list_price, 'order_id': sale_order.id, }) - self.assertEqual(sale_order.project_project_id, task_serv3.project_id, "SO should not have create a second project, since so line 3 already create one project for the SO") + self.assertEqual(project_serv3, task_serv3.project_id, "SO should not have create a second project, since so line 3 already create one project for the SO") # let's log some timesheets on the project self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': sale_order.project_project_id.id, # global project + 'project_id': project_serv3.id, # global project 'unit_amount': 7, 'employee_id': self.employee_user.id, }) @@ -253,7 +255,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): # confirm SO sale_order.action_confirm() - self.assertTrue(sale_order.project_project_id, "Sales Order should have create a project") + self.assertTrue(sale_order.project_ids, "Sales Order should have create a project") self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: manually product should not need to be invoiced on so confirmation') # let's log some timesheets (on task and project) @@ -267,7 +269,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): timesheet2 = self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': sale_order.project_project_id.id, # global project + 'project_id': self.project_global.id, # global project 'unit_amount': 3, 'employee_id': self.employee_manager.id, }) From 8f3f1827cbb7d7ee0eae132b45394fc0547f940d Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Mon, 19 Feb 2018 10:39:05 +0100 Subject: [PATCH 03/16] [IMP] hr_timesheet: ability to track cost without selling time The cost of an employee should be in hr_timesheet for a long time, to allow user to track time on project/task: hours spent and their cost. This commit simply moves fields and related views. It also moves the 'postprocess' method from sale_timesheet to hr_timesheet since this method is responsible to set the amount (cost) on AAL. User can now track their time and cost, without having sale_timesheet installed. --- addons/hr_timesheet/models/__init__.py | 1 + .../models/hr.py | 1 - addons/hr_timesheet/models/hr_timesheet.py | 49 ++++++++++++++++--- addons/hr_timesheet/views/hr_views.xml | 8 ++- addons/sale_timesheet/__manifest__.py | 1 - addons/sale_timesheet/models/__init__.py | 1 - addons/sale_timesheet/models/account.py | 40 +++++---------- addons/sale_timesheet/views/hr_views.xml | 20 -------- 8 files changed, 63 insertions(+), 58 deletions(-) rename addons/{sale_timesheet => hr_timesheet}/models/hr.py (83%) delete mode 100644 addons/sale_timesheet/views/hr_views.xml diff --git a/addons/hr_timesheet/models/__init__.py b/addons/hr_timesheet/models/__init__.py index 1766ea8ddf9..4db761e1e09 100644 --- a/addons/hr_timesheet/models/__init__.py +++ b/addons/hr_timesheet/models/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import hr from . import hr_timesheet from . import res_config_settings from . import project diff --git a/addons/sale_timesheet/models/hr.py b/addons/hr_timesheet/models/hr.py similarity index 83% rename from addons/sale_timesheet/models/hr.py rename to addons/hr_timesheet/models/hr.py index a9f94f57bb9..afedd48561e 100644 --- a/addons/sale_timesheet/models/hr.py +++ b/addons/hr_timesheet/models/hr.py @@ -7,6 +7,5 @@ from odoo import fields, models class HrEmployee(models.Model): _inherit = 'hr.employee' - # FIXME: this field should be in module hr_timesheet, not sale_timesheet timesheet_cost = fields.Monetary('Timesheet Cost', currency_field='currency_id', default=0.0) currency_id = fields.Many2one('res.currency', related='company_id.currency_id', readonly=True) diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index 30bb3c9dff2..dd4b83dad8b 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -40,14 +40,20 @@ class AccountAnalyticLine(models.Model): line.department_id = line.employee_id.department_id @api.model - def create(self, vals): - vals = self._timesheet_preprocess(vals) - return super(AccountAnalyticLine, self).create(vals) + def create(self, values): + values = self._timesheet_preprocess(values) + result = super(AccountAnalyticLine, self).create(values) + if result.project_id: # applied only for timesheet + result._timesheet_postprocess(values) + return result @api.multi - def write(self, vals): - vals = self._timesheet_preprocess(vals) - return super(AccountAnalyticLine, self).write(vals) + def write(self, values): + values = self._timesheet_preprocess(values) + result = super(AccountAnalyticLine, self).write(values) + # applied only for timesheet + self.filtered(lambda t: t.project_id)._timesheet_postprocess(values) + return result def _timesheet_preprocess(self, vals): """ Deduce other field values from the one given. @@ -79,3 +85,34 @@ class AccountAnalyticLine(models.Model): if partner_id: vals['partner_id'] = partner_id return vals + + @api.multi + def _timesheet_postprocess(self, values): + """ Hook to update record one by one according to the values of a `write` or a `create`. """ + sudo_self = self.sudo() # this creates only one env for all operation that required sudo() in `_timesheet_postprocess_values`override + values_to_write = self._timesheet_postprocess_values(values) + for timesheet in sudo_self: + if values_to_write[timesheet.id]: + timesheet.write(values_to_write[timesheet.id]) + return values + + @api.multi + def _timesheet_postprocess_values(self, values): + """ Get the addionnal values to write on record + :param dict values: values for the model's fields, as a dictionary:: + {'field_name': field_value, ...} + :return: a dictionary mapping each record id to its corresponding + dictionnary values to write (may be empty). + """ + result = dict.fromkeys(self.ids, dict()) + sudo_self = self.sudo() # this creates only one env for all operation that required sudo() + # (re)compute the amount (depending on unit_amount, employee_id for the cost, and account_id for currency) + if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'account_id']]): + for timesheet in sudo_self: + cost = timesheet.employee_id.timesheet_cost or 0.0 + amount = -timesheet.unit_amount * cost + amount_converted = timesheet.employee_id.currency_id.compute(amount, timesheet.account_id.currency_id) + result[timesheet.id].update({ + 'amount': amount_converted, + }) + return result diff --git a/addons/hr_timesheet/views/hr_views.xml b/addons/hr_timesheet/views/hr_views.xml index 21c645d8159..170bd7e861f 100644 --- a/addons/hr_timesheet/views/hr_views.xml +++ b/addons/hr_timesheet/views/hr_views.xml @@ -19,8 +19,12 @@ - - + + diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index f0d059b1e65..b9cfe6c47e0 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -21,7 +21,6 @@ have real delivered quantities in sales orders. 'views/account_invoice_views.xml', 'views/sale_order_views.xml', 'views/product_views.xml', - 'views/hr_views.xml', 'views/project_task_views.xml', 'views/hr_timesheet_views.xml', 'views/res_config_settings_views.xml', diff --git a/addons/sale_timesheet/models/__init__.py b/addons/sale_timesheet/models/__init__.py index d22baec4d49..e6421022c33 100644 --- a/addons/sale_timesheet/models/__init__.py +++ b/addons/sale_timesheet/models/__init__.py @@ -3,7 +3,6 @@ from . import account from . import account_invoice -from . import hr from . import product from . import project from . import sale_order diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 23a5b3a5f67..87ec0a10f2c 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -21,14 +21,6 @@ class AccountAnalyticLine(models.Model): timesheet_invoice_id = fields.Many2one('account.invoice', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet") timesheet_revenue = fields.Monetary("Revenue", default=0.0, readonly=True, copy=False) - @api.model - def create(self, values): - result = super(AccountAnalyticLine, self).create(values) - # applied only for timesheet - if result.project_id: - result._timesheet_postprocess(values) - return result - @api.multi def write(self, values): # prevent to update invoiced timesheets if one line is of type delivery @@ -36,8 +28,6 @@ class AccountAnalyticLine(models.Model): if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'task_id', 'timesheet_revenue', 'so_line', 'amount', 'date']]): raise UserError(_('You can not modify already invoiced timesheets (linked to a Sales order items invoiced on Time and material).')) result = super(AccountAnalyticLine, self).write(values) - # applied only for timesheet - self.filtered(lambda t: t.project_id)._timesheet_postprocess(values) return result @api.model @@ -55,30 +45,23 @@ class AccountAnalyticLine(models.Model): return values @api.multi - def _timesheet_postprocess(self, values): + def _timesheet_postprocess_values(self, values): sudo_self = self.sudo() # this creates only one env for all operation that required sudo() - # (re)compute the amount (depending on unit_amount, employee_id for the cost, and account_id for currency) - if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'account_id']]): + result = super(AccountAnalyticLine, self)._timesheet_postprocess_values(values) + # (re)compute the UoM from the employee company + if any([field_name in values for field_name in ['employee_id']]): for timesheet in sudo_self: uom = timesheet.employee_id.company_id.project_time_mode_id - cost = timesheet.employee_id.timesheet_cost or 0.0 - amount = -timesheet.unit_amount * cost - amount_converted = timesheet.employee_id.currency_id.compute(amount, timesheet.account_id.currency_id) - timesheet.write({ - 'amount': amount_converted, + result[timesheet.id].update({ 'product_uom_id': uom.id, }) # (re)compute the theorical revenue if any([field_name in values for field_name in ['so_line', 'unit_amount', 'account_id']]): - sudo_self._timesheet_compute_theorical_revenue() - return values - - @api.multi - def _timesheet_compute_theorical_revenue(self): - for timesheet in self: - values = timesheet._timesheet_compute_theorical_revenue_values() - timesheet.write(values) - return True + for timesheet in sudo_self: + values_to_write = timesheet._timesheet_compute_theorical_revenue_values() + if values_to_write: + result[timesheet.id].update(values_to_write) + return result @api.multi def _timesheet_compute_theorical_revenue_values(self): @@ -93,6 +76,9 @@ class AccountAnalyticLine(models.Model): ) else: 0 + + :return: a dictionary mapping each record id to its corresponding + dictionnary values to write (may be empty). """ self.ensure_one() timesheet = self diff --git a/addons/sale_timesheet/views/hr_views.xml b/addons/sale_timesheet/views/hr_views.xml deleted file mode 100644 index ce032cc01e0..00000000000 --- a/addons/sale_timesheet/views/hr_views.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - hr.timesheet.employee.extd_form - hr.employee - - - - - - 0 - - - - From 7782265a8aa0376f69afdb3271f0fa74a59aace1 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Wed, 6 Dec 2017 13:27:12 +0100 Subject: [PATCH 04/16] [IMP] sale_timesheet: add wizard to link multiple task to SO lines Adding a wizard to assign multiple task to the same sale order line. This will allow user to invoice timesheet created before SO confirmation. --- addons/sale_timesheet/__init__.py | 1 + addons/sale_timesheet/__manifest__.py | 1 + addons/sale_timesheet/wizard/__init__.py | 4 +++ .../wizard/project_task_assign_so_line.py | 29 ++++++++++++++++ .../project_task_assign_so_line_views.xml | 34 +++++++++++++++++++ 5 files changed, 69 insertions(+) create mode 100644 addons/sale_timesheet/wizard/__init__.py create mode 100644 addons/sale_timesheet/wizard/project_task_assign_so_line.py create mode 100644 addons/sale_timesheet/wizard/project_task_assign_so_line_views.xml diff --git a/addons/sale_timesheet/__init__.py b/addons/sale_timesheet/__init__.py index 3226df762b3..4480f53802e 100644 --- a/addons/sale_timesheet/__init__.py +++ b/addons/sale_timesheet/__init__.py @@ -2,5 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import models +from . import wizard from . import controllers from . import report diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index b9cfe6c47e0..ba22b0818f2 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -27,6 +27,7 @@ have real delivered quantities in sales orders. 'views/hr_timesheet_templates.xml', 'report/project_profitability_report_analysis_views.xml', 'data/sale_timesheet_filters.xml', + 'wizard/project_task_assign_so_line_views.xml', ], 'demo': [ 'data/sale_service_demo.xml', diff --git a/addons/sale_timesheet/wizard/__init__.py b/addons/sale_timesheet/wizard/__init__.py new file mode 100644 index 00000000000..dc6b755ec0a --- /dev/null +++ b/addons/sale_timesheet/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import project_task_assign_so_line diff --git a/addons/sale_timesheet/wizard/project_task_assign_so_line.py b/addons/sale_timesheet/wizard/project_task_assign_so_line.py new file mode 100644 index 00000000000..48f424f9b73 --- /dev/null +++ b/addons/sale_timesheet/wizard/project_task_assign_so_line.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ProjectTaskAssignSOLine(models.TransientModel): + _name = 'project.task.assign.sale' + _description = "Assign Sale Order line to tasks" + + @api.model + def default_get(self, fields): + result = super(ProjectTaskAssignSOLine, self).default_get(fields) + if self._context.get('active_model') == 'project.task' and 'task_ids' in fields: + result['task_ids'] = self._context.get('active_ids', []) + return result + + sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain=[('is_service', '=', True)], help="Sale order line to link to selected tasks") + partner_id = fields.Many2one('res.partner', related='sale_line_id.order_partner_id', readonly=True) + task_ids = fields.Many2many('project.task', 'project_task_assign_so_line_rel', 'task_id', 'wizard_id', string='Tasks', domain=[('parent_id', '=', False)], help="Select the tasks to assign to the Sale Order Items. You can only choose task (no sub tasks).") + + @api.multi + def action_assign_sale_line(self): + for wizard in self: + wizard.task_ids.write({ + 'sale_line_id': wizard.sale_line_id.id, + 'partner_id': wizard.partner_id.id, + }) + return {'type': 'ir.actions.act_window_close'} diff --git a/addons/sale_timesheet/wizard/project_task_assign_so_line_views.xml b/addons/sale_timesheet/wizard/project_task_assign_so_line_views.xml new file mode 100644 index 00000000000..8bc70807a6c --- /dev/null +++ b/addons/sale_timesheet/wizard/project_task_assign_so_line_views.xml @@ -0,0 +1,34 @@ + + + + + project.task.assign.sale.wizard.form + project.task.assign.sale + +
+ + + + + +
+
+
+
+
+ + + Assign Sale Order Item + ir.actions.act_window + project.task.assign.sale + form + form + + new + True + + + +
From 60a8b88cc96abd9a8ee1f7142cf15599d413d245 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Wed, 20 Dec 2017 17:48:35 +0100 Subject: [PATCH 05/16] [IMP] sale_timesheet: revamp project overview The project overview is kind of badly design. This commit does not solve all the issue but tries to make it simpler and more efficient: - Does not depends on AAL anymore: the overview search is now about projects. You can see the overview of multiple projects. Even when no timesheet are encoded, the plan will not be empty. - "Time by people" displays each employee assigned to task, even if no timesheet are recorded. - Code now use reporting view to keep consistency and simple code in overview controller. --- addons/sale_timesheet/controllers/main.py | 175 ++++++++++++++---- addons/sale_timesheet/models/project.py | 18 +- .../static/src/js/timesheet_plan.js | 4 +- .../static/tests/timesheet_plan_tests.js | 8 +- .../views/hr_timesheet_templates.xml | 32 ++-- .../views/project_task_views.xml | 19 ++ 6 files changed, 185 insertions(+), 71 deletions(-) diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index 08dfb5033da..828ded7a0b8 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import babel +from dateutil.relativedelta import relativedelta -from odoo import http, _ +from odoo import http, fields, _ from odoo.http import request -from odoo.osv import expression from odoo.tools import float_round @@ -12,47 +13,49 @@ class SaleTimesheetController(http.Controller): @http.route('/timesheet/plan', type='json', auth="user") def plan(self, domain): - domain = expression.AND([domain, [('project_id', '!=', False)]]) # force timesheet and not AAL - values = self._prepare_plan_values(domain) + """ Get the HTML of the project plan for projects matching the given domain + :param domain: a domain for project.project + """ + projects = request.env['project.project'].search(domain) + values = self._plan_prepare_values(projects) view = request.env.ref('sale_timesheet.timesheet_plan') return { 'html_content': view.render(values) } - def _prepare_plan_values(self, domain): + def _plan_prepare_values(self, projects): - timesheet_lines = request.env['account.analytic.line'].search(domain) currency = request.env.user.company_id.currency_id - - values = { - 'currency': currency, - 'timesheet_lines': timesheet_lines, - 'domain': domain, - } - hour_rounding = request.env.ref('uom.product_uom_hour').rounding + hour_rounding = request.env.ref('product.product_uom_hour').rounding billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'billable_fixed'] - # -- Stat Buttons - values['stat_buttons'] = self._plan_get_stat_button(timesheet_lines) + values = { + 'projects': projects, + 'currency': currency, + 'timesheet_domain': [('project_id', 'in', projects.ids)], + 'stat_buttons': self._plan_get_stat_button(projects), + } - # -- Dashboard (per billable type) + # + # Hours, Rates and Profitability + # dashboard_values = { 'hours': dict.fromkeys(billable_types + ['total'], 0.0), 'rates': dict.fromkeys(billable_types + ['total'], 0.0), - 'money_amount': { + 'profit': { 'invoiced': 0.0, - 'to_invoiced': 0.0, + 'to_invoice': 0.0, 'cost': 0.0, 'total': 0.0, } } - dashboard_domain = domain + [('timesheet_invoice_type', '!=', False)] # force billable type - dashboard_data = request.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_revenue', 'timesheet_invoice_type'], ['timesheet_invoice_type']) + # hours (from timesheet) and rates (by billable type) + dashboard_domain = [('project_id', 'in', projects.ids), ('timesheet_invoice_type', '!=', False)] # force billable type + dashboard_data = request.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_revenue', 'timesheet_invoice_type'], ['timesheet_invoice_type']) dashboard_total_hours = sum([data['unit_amount'] for data in dashboard_data]) for data in dashboard_data: billable_type = data['timesheet_invoice_type'] - # hours dashboard_values['hours'][billable_type] = float_round(data.get('unit_amount'), precision_rounding=hour_rounding) dashboard_values['hours']['total'] += float_round(data.get('unit_amount'), precision_rounding=hour_rounding) # rates @@ -60,26 +63,37 @@ class SaleTimesheetController(http.Controller): dashboard_values['rates'][billable_type] = rate dashboard_values['rates']['total'] += rate - # profitability - project_ids = values['timesheet_lines'].mapped('project_id').ids - money_amount = dict.fromkeys(['invoiced', 'to_invoice', 'cost', 'total'], 0.0) - - profitability_raw_data = request.env['project.profitability.report'].read_group([('project_id', 'in', project_ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost'], ['project_id']) + # 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']) for data in profitability_raw_data: - money_amount['invoiced'] += data.get('amount_untaxed_invoiced', 0.0) - money_amount['to_invoice'] += data.get('amount_untaxed_to_invoice', 0.0) - money_amount['cost'] += data.get('timesheet_cost', 0.0) - money_amount['total'] = sum([money_amount[item] for item in money_amount.keys()]) - dashboard_values['money_amount'] = money_amount + 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['total'] = sum([profit[item] for item in profit.keys()]) + dashboard_values['profit'] = profit values['dashboard'] = dashboard_values - # -- Time Repartition (per employee) - repartition_domain = domain + [('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type + # + # Time Repartition (per employee per billable types) + # + employees = projects.mapped('tasks.user_id.employee_ids') | request.env['account.analytic.line'].search([('project_id', 'in', projects.ids)]).mapped('employee_id') + repartition_domain = [('project_id', 'in', projects.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type repartition_data = request.env['account.analytic.line'].read_group(repartition_domain, ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False) # set repartition per type per employee repartition_employee = {} + for employee in employees: + repartition_employee[employee.id] = dict( + employee_id=employee.id, + employee_name=employee.name, + non_billable_project=0.0, + non_billable=0.0, + billable_time=0.0, + billable_fixed=0.0, + total=0.0, + ) for data in repartition_data: employee_id = data['employee_id'][0] repartition_employee.setdefault(employee_id, dict( @@ -91,7 +105,7 @@ class SaleTimesheetController(http.Controller): billable_fixed=0.0, total=0.0, ))[data['timesheet_invoice_type']] = float_round(data.get('unit_amount', 0.0), precision_rounding=hour_rounding) - repartition_employee[employee_id]['__domain_'+data['timesheet_invoice_type']] = data['__domain'] + repartition_employee[employee_id]['__domain_' + data['timesheet_invoice_type']] = data['__domain'] # compute total for employee_id, vals in repartition_employee.items(): @@ -103,21 +117,102 @@ class SaleTimesheetController(http.Controller): return values - def _plan_get_stat_button(self, timesheet_lines): + def _table_header(self, projects): + initial_date = fields.Date.from_string(fields.Date.today()) + ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3 + + def _to_short_month_name(date): + month_index = fields.Date.from_string(date).month + return babel.dates.get_month_names('abbreviated', locale=request.env.context.get('lang', 'en_US'))[month_index] + + header = [_('Name'), _('Before')] + [_to_short_month_name(date) for date in ts_months] + [_('Done'), _('Sold'), _('Remaining')] + return header + + def _table_row_default(self, projects): + lenght = self._table_header(projects) + return [0.0] * (lenght - 1) # before, M1, M2, M3, Done, Sold, Remaining + + def _table_rows_sql_query(self, projects): + initial_date = fields.Date.from_string(fields.Date.today()) + ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3 + # build query + query = """ + SELECT + 'timesheet' AS type, + date_trunc('month', date)::date AS month_date, + E.id AS employee_id, + S.order_id AS sale_order_id, + A.so_line AS sale_line_id, + SUM(A.unit_amount) AS number_hours + FROM account_analytic_line A + JOIN hr_employee E ON E.id = A.employee_id + LEFT JOIN sale_order_line S ON S.id = A.so_line + WHERE A.project_id IS NOT NULL + AND A.project_id IN %s + AND A.date < %s + GROUP BY date_trunc('month', date)::date, S.order_id, A.so_line, E.id + """ + + last_ts_month = fields.Date.to_string(fields.Date.from_string(ts_months[-1]) + relativedelta(months=1)) + query_params = (tuple(projects.ids), last_ts_month) + return query, query_params + + def _table_rows_get_employee_lines(self, projects, data_from_db): + initial_date = fields.Date.from_string(fields.Date.today()) + ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3 + default_row_vals = self._table_row_default(projects) + + # extract employee names + employee_ids = set() + for data in data_from_db: + employee_ids.add(data['employee_id']) + map_empl_names = {empl.id: empl.name for empl in request.env['hr.employee'].sudo().browse(employee_ids)} + + # extract rows data for employee, sol and so rows + rows_employee = {} # (so, sol, employee) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted] + for data in data_from_db: + sale_line_id = data['sale_line_id'] + sale_order_id = data['sale_order_id'] + # employee row + row_key = (data['sale_order_id'], sale_line_id, data['employee_id']) + if row_key not in rows_employee: + meta_vals = { + 'label': map_empl_names.get(row_key[2]), + 'sale_line_id': sale_line_id, + 'sale_order_id': sale_order_id, + 'res_id': row_key[2], + 'res_model': 'hr.employee', + 'type': 'hr_employee' + } + rows_employee[row_key] = [meta_vals] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted + + index = False + if data['type'] == 'timesheet': + if data['month_date'] in ts_months: + index = ts_months.index(data['month_date']) + 2 + elif data['month_date'] < ts_months[0]: + index = 1 + rows_employee[row_key][index] += data['number_hours'] + rows_employee[row_key][5] += data['number_hours'] + return rows_employee + + # -------------------------------------------------- + # Actions: Stat buttons, ... + # -------------------------------------------------- + + def _plan_get_stat_button(self, projects): stat_buttons = [] stat_buttons.append({ 'name': _('Timesheets'), 'res_model': 'account.analytic.line', - 'domain': [('id', 'in', timesheet_lines.ids)], + 'domain': [('project_id', 'in', projects.ids)], 'icon': 'fa fa-calendar', }) - stat_project_ids = timesheet_lines.mapped('project_id').ids - stat_task_domain = [('project_id', 'in', stat_project_ids), '|', ('stage_id', '=', False), ('stage_id.fold', '=', False)] stat_buttons.append({ 'name': _('Tasks'), - 'count': request.env['project.task'].search_count(stat_task_domain), + 'count': sum(projects.mapped('task_count')), 'res_model': 'project.task', - 'domain': stat_task_domain, + 'domain': [('project_id', 'in', projects.ids)], 'icon': 'fa fa-tasks', }) return stat_buttons diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 53e47d2f867..2a28a279393 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -41,16 +41,16 @@ class Project(models.Model): @api.multi def action_view_timesheet_plan(self): - return { - 'name': _('Overview'), - 'type': 'ir.actions.client', - 'tag': 'timesheet.plan', - 'context': { - 'active_id': self.id, - 'active_ids': self.ids, - 'search_default_project_id': self.id, - } + action = self.env.ref('sale_timesheet.project_timesheet_action_client_timesheet_plan').read()[0] + action['params'] = { + 'project_ids': self.ids, } + action['context'] = { + 'active_id': self.id, + 'active_ids': self.ids, + 'search_default_display_name': self.name, + } + return action class ProjectTask(models.Model): diff --git a/addons/sale_timesheet/static/src/js/timesheet_plan.js b/addons/sale_timesheet/static/src/js/timesheet_plan.js index 0a0048d9720..dba77ff9003 100644 --- a/addons/sale_timesheet/static/src/js/timesheet_plan.js +++ b/addons/sale_timesheet/static/src/js/timesheet_plan.js @@ -33,7 +33,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, { var self = this; var view_id = this.action && this.action.search_view_id && this.action.search_view_id[0]; var def = this - .loadViews('account.analytic.line', this.action.context || {}, [[view_id, 'search']]) + .loadViews('project.project', this.action.context || {}, [[view_id, 'search']]) .then(function (result) { self.fields_view = result.search; }); @@ -63,7 +63,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, { search_defaults: search_defaults, }; - var dataset = new data.DataSetSearch(this, 'account.analytic.line'); + var dataset = new data.DataSetSearch(this, 'project.project'); this.searchview = new SearchView(this, dataset, this.fields_view, options); this.searchview.on('search', this, this._onSearch); diff --git a/addons/sale_timesheet/static/tests/timesheet_plan_tests.js b/addons/sale_timesheet/static/tests/timesheet_plan_tests.js index d352da61dc9..d1d71c4face 100644 --- a/addons/sale_timesheet/static/tests/timesheet_plan_tests.js +++ b/addons/sale_timesheet/static/tests/timesheet_plan_tests.js @@ -26,7 +26,7 @@ function createPlan(params) { QUnit.module('Timesheet Plan', { beforeEach: function () { this.data = { - 'account.analytic.line': { + 'project.project': { fields: { account_id: {string: "Analytic account", type: "many2one", relation: "account.analytic.account"}, }, @@ -41,7 +41,7 @@ QUnit.module('Timesheet Plan', { var plan = createPlan({ archs: { - 'account.analytic.line,false,search': '', + 'project.project,false,search': '', }, data: this.data, action: {}, @@ -61,7 +61,7 @@ QUnit.module('Timesheet Plan', { }, }); assert.verifySteps([ - "/web/dataset/call_kw/account.analytic.line", + "/web/dataset/call_kw/project.project", "/timesheet/plan" ]); assert.strictEqual(plan.$el.text(), 'Banach-Tarski', 'should have rendered html content'); @@ -75,7 +75,7 @@ QUnit.module('Timesheet Plan', { var plan = createPlan({ archs: { - 'account.analytic.line,false,search': '', + 'project.project,false,search': '', }, data: this.data, action: {name: 'Fibonacci'}, diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 59327067366..8aa9410dcab 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -43,24 +43,24 @@ - Hours + Hours Timesheets - Rates + Rates % Timesheets - Profitability + Profitability - + Invoiced @@ -74,7 +74,7 @@ Fixed - + To invoice @@ -88,19 +88,19 @@ No task found - + - Cost + Timesheet costs - Non Billable + Non billable % - Non Billable + Non billable @@ -112,7 +112,7 @@ - + Total @@ -128,16 +128,16 @@ @@ -145,7 +145,7 @@ @@ -153,7 +153,7 @@
- + -
+
diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 230999e2974..b659aaa9ba4 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -16,6 +16,17 @@ + + project.project.search.inherit + project.project + + + + + + + + sale.service.form.view.inherit project.task @@ -47,4 +58,12 @@ + + + Overview + timesheet.plan + project.project + + + From 3dd3e2648329995ddc309081787e19005d48ccd0 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Mon, 15 Jan 2018 17:14:47 +0100 Subject: [PATCH 06/16] [IMP] project: a project is not an analytic account anymore ! This commit removes the inheritS between account.analytic.account and project.project. This means : - When a project is created, an analytic account is not necessarily genareted - project has now its own name, company_id and partner_id fields - an analytic account can be linked to several project - project module does not depend anymore of analytic, but hr_timesheet does. This change implied - an analytic account is required on project to timesheet on its tasks. So, when checking 'allow_timsheets', if an AA is not provided at project creation, an AA will be generated. - to timesheet on project, an active AA is required. We don't want timesheeting on project linked to unused AA to avoid bad analytic entries. This prepare the fact a sale order can create multiple project, sharing the same analytic account (the one from the SO). --- addons/hr_timesheet/__manifest__.py | 2 +- .../hr_timesheet/data/hr_timesheet_demo.xml | 18 +++- addons/hr_timesheet/models/__init__.py | 1 + .../models/analytic_account.py} | 18 ++-- addons/hr_timesheet/models/hr_timesheet.py | 5 +- addons/hr_timesheet/models/project.py | 86 +++++++++++++++++-- .../hr_timesheet/security/ir.model.access.csv | 4 + addons/hr_timesheet/tests/test_timesheet.py | 7 ++ .../hr_timesheet/views/hr_timesheet_views.xml | 20 +++++ addons/hr_timesheet/views/project_views.xml | 28 ++++-- addons/project/__manifest__.py | 1 - addons/project/models/__init__.py | 1 - addons/project/models/project.py | 20 ++--- addons/project/security/ir.model.access.csv | 4 - addons/project/views/project_views.xml | 19 +--- .../sale_timesheet/data/sale_service_demo.xml | 16 +++- 16 files changed, 181 insertions(+), 69 deletions(-) rename addons/{project/models/analytic.py => hr_timesheet/models/analytic_account.py} (72%) diff --git a/addons/hr_timesheet/__manifest__.py b/addons/hr_timesheet/__manifest__.py index 1ee084d94f3..1b35f5f5c35 100644 --- a/addons/hr_timesheet/__manifest__.py +++ b/addons/hr_timesheet/__manifest__.py @@ -20,7 +20,7 @@ It is completely integrated with the cost accounting module. It allows you to se up a management by affair. """, 'website': 'https://www.odoo.com/page/employees', - 'depends': ['hr', 'project'], + 'depends': ['hr', 'analytic', 'project'], 'data': [ 'security/hr_timesheet_security.xml', 'security/ir.model.access.csv', diff --git a/addons/hr_timesheet/data/hr_timesheet_demo.xml b/addons/hr_timesheet/data/hr_timesheet_demo.xml index d3fbc2e3367..b3b26e3abb9 100644 --- a/addons/hr_timesheet/data/hr_timesheet_demo.xml +++ b/addons/hr_timesheet/data/hr_timesheet_demo.xml @@ -1,6 +1,20 @@ + + Research & Development Project + RD + + + + + + + + + + + Requirements analysis and specification @@ -35,7 +49,7 @@ 1.00 -30.00 - + Delivery and maintenance @@ -44,6 +58,6 @@ 1.00 -30.00 - + diff --git a/addons/hr_timesheet/models/__init__.py b/addons/hr_timesheet/models/__init__.py index 4db761e1e09..11998476ce2 100644 --- a/addons/hr_timesheet/models/__init__.py +++ b/addons/hr_timesheet/models/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import analytic_account from . import hr from . import hr_timesheet from . import res_config_settings diff --git a/addons/project/models/analytic.py b/addons/hr_timesheet/models/analytic_account.py similarity index 72% rename from addons/project/models/analytic.py rename to addons/hr_timesheet/models/analytic_account.py index 9cb748e1bac..347634ec185 100644 --- a/addons/project/models/analytic.py +++ b/addons/hr_timesheet/models/analytic_account.py @@ -13,9 +13,12 @@ class AccountAnalyticAccount(models.Model): project_ids = fields.One2many('project.project', 'analytic_account_id', string='Projects') project_count = fields.Integer("Project Count", compute='_compute_project_count') + @api.multi def _compute_project_count(self): + project_data = self.env['project.project'].read_group([('analytic_account_id', 'in', self.ids)], ['analytic_account_id'], ['analytic_account_id']) + mapping = {m['analytic_account_id'][0]: m['analytic_account_id_count'] for m in project_data} for account in self: - account.project_count = len(account.with_context(active_test=False).project_ids) + account.project_count = mapping.get(account.id, 0) @api.multi def unlink(self): @@ -25,18 +28,9 @@ class AccountAnalyticAccount(models.Model): raise UserError(_('Please remove existing tasks in the project linked to the accounts you want to delete.')) return super(AccountAnalyticAccount, self).unlink() - @api.model - def name_search(self, name, args=None, operator='ilike', limit=100): - if args is None: - args = [] - if self.env.context.get('current_model') == 'project.project': - return self.search(args + [('name', operator, name)], limit=limit).name_get() - - return super(AccountAnalyticAccount, self).name_search(name, args=args, operator=operator, limit=limit) - @api.multi - def projects_action(self): - projects = self.with_context(active_test=False).mapped('project_ids') + def action_view_projects(self): + projects = self.mapped('project_ids') result = { "type": "ir.actions.act_window", "res_model": "project.project", diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index dd4b83dad8b..d1d41829e7b 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, _ +from odoo.exceptions import UserError class AccountAnalyticLine(models.Model): @@ -64,6 +65,8 @@ class AccountAnalyticLine(models.Model): if vals.get('project_id') and not vals.get('account_id'): project = self.env['project.project'].browse(vals.get('project_id')) vals['account_id'] = project.analytic_account_id.id + if not project.analytic_account_id.active: + raise UserError(_('The project you are timesheeting on is not linked to a active analytic account. Please the project configuration.')) # employee implies user if vals.get('employee_id') and not vals.get('user_id'): employee = self.env['hr.employee'].browse(vals['employee_id']) diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index 72a88277886..cb41bf7c5bb 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -1,20 +1,89 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import models, fields, api -from odoo.exceptions import UserError -from odoo.tools.translate import _ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError class Project(models.Model): _inherit = "project.project" allow_timesheets = fields.Boolean("Allow timesheets", default=True) + analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", ondelete='set null', + help="Link this project to an analytic account if you need financial management on projects. " + "It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.") + + @api.onchange('partner_id') + def _onchange_partner_id(self): + domain = [] + if self.partner_id: + domain = [('partner_id', '=', self.partner_id.id)] + return {'domain': {'analytic_account_id': domain}} + + @api.constrains('allow_timesheets', 'analytic_account_id') + def _check_allow_timesheet(self): + for project in self: + if project.allow_timesheets and not project.analytic_account_id: + raise ValidationError(_('To allow timesheet, your project %s should have an analytic account set.' % (project.name,))) + + @api.model + def name_create(self, name): + """ Create a project with name_create should generate analytic account creation """ + values = { + 'name': name, + 'allow_timesheets': True, + } + return self.create(values).name_get()[0] + + @api.model + def create(self, values): + """ Create an analytic account if project allow timesheet and don't provide one """ + allow_timesheets = values['allow_timesheets'] if 'allow_timesheets' in values else self.default_get(['allow_timesheets'])['allow_timesheets'] + if allow_timesheets and not values.get('analytic_account_id'): + analytic_account = self._create_analytic_account(values) + values['analytic_account_id'] = analytic_account.id + return super(Project, self).create(values) + + @api.multi + def write(self, values): + result = super(Project, self).write(values) + if values.get('allow_timesheets'): + for project in self: + if not project.analytic_account_id: + analytic_account = project._create_analytic_account({ + 'name': project.name, + 'company_id': project.company_id.id, + 'partner_id': project.partner_id.id, + }) + project.write({'analytic_account_id': analytic_account.id}) + return result + + @api.multi + def unlink(self): + """ Delete the empty related analytic account """ + analytic_accounts_to_delete = self.env['account.analytic.account'] + for project in self: + if project.analytic_account_id and not project.analytic_account_id.line_ids: + analytic_accounts_to_delete |= project.analytic_account_id + result = super(Project, self).unlink() + analytic_accounts_to_delete.unlink() + return result + + def _create_analytic_account(self, values): + analytic_account = self.env['account.analytic.account'].create({ + 'name': values.get('name', _('Unkwon Analytic Account')), + 'company_id': values.get('company_id', self.env.user.company_id.id), + 'partner_id': values.get('partner_id'), + 'active': True, + }) + return analytic_account class Task(models.Model): _inherit = "project.task" + analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", related='project_id.analytic_account_id', readonly=True) + allow_timesheets = fields.Boolean("Allow timesheets", compute='_compute_allow_timesheets', help="Timesheets can be logged on this task.") remaining_hours = fields.Float("Remaining Hours", compute='_compute_progress_hours', inverse='_inverse_remaining_hours', store=True, help="Total remaining time, can be re-estimated periodically by the assignee of the task.") effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True, help="Computed using the sum of the task work done.") total_hours_spent = fields.Float("Total Hours", compute='_compute_progress_hours', store=True, help="Computed as: Time Spent + Sub-tasks Hours.") @@ -22,10 +91,10 @@ class Task(models.Model): subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', store=True, help="Sum of actually spent hours on the subtask(s)", oldname='children_hours') timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets') - @api.onchange('remaining_hours') - def _inverse_remaining_hours(self): + @api.depends('project_id.allow_timesheets', 'project_id.analytic_account_id') + def _compute_allow_timesheets(self): for task in self: - task.planned_hours = task.remaining_hours + task.effective_hours + task.subtask_effective_hours + task.allow_timesheets = task.project_id.allow_timesheets and task.project_id.analytic_account_id.active @api.depends('timesheet_ids.unit_amount') def _compute_effective_hours(self): @@ -47,6 +116,11 @@ class Task(models.Model): task.remaining_hours = task.planned_hours - task.effective_hours - task.subtask_effective_hours task.total_hours_spent = task.effective_hours + task.subtask_effective_hours + @api.onchange('remaining_hours') + def _inverse_remaining_hours(self): + for task in self: + task.planned_hours = task.remaining_hours + task.effective_hours + task.subtask_effective_hours + @api.depends('child_ids.effective_hours', 'child_ids.subtask_effective_hours') def _compute_subtask_effective_hours(self): for task in self: diff --git a/addons/hr_timesheet/security/ir.model.access.csv b/addons/hr_timesheet/security/ir.model.access.csv index 87656c6ef0c..63bc0cace28 100644 --- a/addons/hr_timesheet/security/ir.model.access.csv +++ b/addons/hr_timesheet/security/ir.model.access.csv @@ -2,6 +2,10 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_account_analytic_line_user,analytic.account.analytic.line.timesheet.user,analytic.model_account_analytic_line,hr_timesheet.group_hr_timesheet_user,1,1,1,1 access_account_analytic_line_portal,access_timesheets_portal,analytic.model_account_analytic_line,base.group_portal,1,0,0,0 access_account_analytic_user,analytic.account.analytic.timesheet.user,analytic.model_account_analytic_account,hr_timesheet.group_hr_timesheet_user,1,1,0,0 +access_account_analytic_account_user,account.analytic.account,analytic.model_account_analytic_account,project.group_project_user,1,0,0,0 +access_account_analytic_account_manager,account.analytic.account,analytic.model_account_analytic_account,project.group_project_manager,1,1,1,1 +access_account_analytic_account_portal,account_analytic_account,analytic.model_account_analytic_account,base.group_portal,1,0,0,0 +access_account_analytic_line_project,account.analytic.line project,analytic.model_account_analytic_line,project.group_project_manager,1,1,1,1 access_product_product_user,product.product.timesheet.user,product.model_product_product,hr_timesheet.group_hr_timesheet_user,1,1,1,0 access_product_template_timesheet_user,product.template.timesheet.user,product.model_product_template,hr_timesheet.group_hr_timesheet_user,1,1,1,0 access_uom_uom_hr_timesheet,uom.uom.timesheet.user,uom.model_uom_uom,hr_timesheet.group_hr_timesheet_user,1,0,0,0 diff --git a/addons/hr_timesheet/tests/test_timesheet.py b/addons/hr_timesheet/tests/test_timesheet.py index f5ad8f40076..616af044ff7 100644 --- a/addons/hr_timesheet/tests/test_timesheet.py +++ b/addons/hr_timesheet/tests/test_timesheet.py @@ -17,11 +17,18 @@ class TestCommonTimesheet(TransactionCase): 'customer': True, }) + self.analytic_account = self.env['account.analytic.account'].create({ + 'name': 'Analytic Account for Test Customer', + 'partner_id': self.partner.id, + 'code': 'TEST' + }) + # project and tasks self.project_customer = self.env['project.project'].create({ 'name': 'Project X', 'allow_timesheets': True, 'partner_id': self.partner.id, + 'analytic_account_id': self.analytic_account.id, }) self.task1 = self.env['project.task'].create({ 'name': 'Task One', diff --git a/addons/hr_timesheet/views/hr_timesheet_views.xml b/addons/hr_timesheet/views/hr_timesheet_views.xml index 9cda6102048..ccc13c433c1 100644 --- a/addons/hr_timesheet/views/hr_timesheet_views.xml +++ b/addons/hr_timesheet/views/hr_timesheet_views.xml @@ -1,6 +1,26 @@ + + + account.analytic.account.form.inherit + account.analytic.account + + + + + + + + + + +
+ + + @@ -58,7 +61,9 @@ - + + + @@ -71,7 +76,12 @@ - + +
+ Your project is linked to an inactive analytic account. Change it, or reactivate the current one to timesheet on the project. +
+
+ @@ -81,13 +91,13 @@ - - - - - - - + + + + + + +
diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 1bec9a995fe..5da8fb35d0c 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -11,7 +11,6 @@ 'depends': [ 'base_setup', 'product', - 'analytic', 'mail', 'portal', 'rating', diff --git a/addons/project/models/__init__.py b/addons/project/models/__init__.py index c020486360b..e202189a304 100644 --- a/addons/project/models/__init__.py +++ b/addons/project/models/__init__.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import analytic from . import project from . import res_config_settings from . import res_company diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 4b32b7bc4d3..cad7a8b89dc 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -69,7 +69,6 @@ class Project(models.Model): _name = "project.project" _description = "Project" _inherit = ['portal.mixin', 'mail.alias.mixin', 'mail.thread'] - _inherits = {'account.analytic.account': "analytic_account_id"} _order = "sequence, name, id" _period_number = 5 @@ -83,15 +82,10 @@ class Project(models.Model): @api.multi def unlink(self): - analytic_accounts_to_delete = self.env['account.analytic.account'] for project in self: if project.tasks: raise UserError(_('You cannot delete a project containing tasks. You can either delete all the project\'s tasks and then delete the project or simply deactivate the project.')) - if project.analytic_account_id and not project.analytic_account_id.line_ids: - analytic_accounts_to_delete |= project.analytic_account_id - res = super(Project, self).unlink() - analytic_accounts_to_delete.unlink() - return res + return super(Project, self).unlink() def _compute_attached_docs_count(self): Attachment = self.env['ir.attachment'] @@ -179,14 +173,14 @@ class Project(models.Model): def _get_default_favorite_user_ids(self): return [(6, 0, [self.env.uid])] + name = fields.Char("Name", index=True, required=True, track_visibility='onchange') active = fields.Boolean(default=True, help="If the active field is set to False, it will allow you to hide the project without removing it.") sequence = fields.Integer(default=10, help="Gives the sequence order when displaying a list of Projects.") - analytic_account_id = fields.Many2one( - 'account.analytic.account', string='Contract/Analytic', - help="Link this project to an analytic account if you need financial management on projects. " - "It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", - ondelete="cascade", required=True, auto_join=True) + partner_id = fields.Many2one('res.partner', string='Customer', auto_join=True, track_visibility='onchange') + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id) + currency_id = fields.Many2one('res.currency', related="company_id.currency_id", string="Currency", readonly=True) + favorite_user_ids = fields.Many2many( 'res.users', 'project_favorite_user_rel', 'project_id', 'user_id', default=_get_default_favorite_user_ids, @@ -315,8 +309,6 @@ class Project(models.Model): if 'active' in vals: # archiving/unarchiving a project does it on its tasks, too self.with_context(active_test=False).mapped('tasks').write({'active': vals['active']}) - # archiving/unarchiving a project implies that we don't want to use the analytic account anymore - self.with_context(active_test=False).mapped('analytic_account_id').write({'active': vals['active']}) if vals.get('partner_id') or vals.get('privacy_visibility'): for project in self.filtered(lambda project: project.privacy_visibility == 'portal'): project.message_subscribe(project.partner_id.ids) diff --git a/addons/project/security/ir.model.access.csv b/addons/project/security/ir.model.access.csv index 746cc34d51c..ed22a1b4da7 100644 --- a/addons/project/security/ir.model.access.csv +++ b/addons/project/security/ir.model.access.csv @@ -1,9 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_project_project,project.project,model_project_project,project.group_project_user,1,0,0,0 access_project_project_manager,project.project,model_project_project,project.group_project_manager,1,1,1,1 -access_account_analytic_account_user,account.analytic.account,analytic.model_account_analytic_account,project.group_project_user,1,0,0,0 -access_account_analytic_account_manager,account.analytic.account,analytic.model_account_analytic_account,project.group_project_manager,1,1,1,1 -access_account_analytic_account_portal,account_analytic_account,analytic.model_account_analytic_account,base.group_portal,1,0,0,0 access_project_task_type_user,project.task.type.user,model_project_task_type,base.group_user,1,0,0,0 access_project_task_type_project_user,project.task.type.project.user,model_project_task_type,project.group_project_user,1,0,0,0 access_project_task_type_manager,project.task.type manager,model_project_task_type,project.group_project_manager,1,1,1,1 @@ -15,7 +12,6 @@ access_task_on_partner,project.task on partners,model_project_task,base.group_us access_task_portal,task_portal,project.model_project_task,base.group_portal,1,0,0,0 access_project_user,project.project on partners,model_project_project,base.group_user,1,0,0,0 access_project_portal,project_portal,project.model_project_project,base.group_portal,1,0,0,0 -access_account_analytic_line_project,account.analytic.line project,analytic.model_account_analytic_line,project.group_project_manager,1,1,1,1 access_resource_calendar,project.resource_calendar user,resource.model_resource_calendar,project.group_project_user,1,0,0,0 access_resource_calendar_attendance,project.resource_calendar_attendance user,resource.model_resource_calendar_attendance,project.group_project_user,1,0,0,0 access_resource_calendar_leaves_user,resource.calendar.leaves user,resource.model_resource_calendar_leaves,project.group_project_user,1,1,1,1 diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index eebd86b050b..23282752dc2 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -102,7 +102,6 @@
-
- - - - Tasks project.task diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index cbfeb761016..446bb90a847 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -11,7 +11,7 @@ 5.0 - + 30 Fixed @@ -19,17 +19,31 @@ Done + + Internal - GAP Analysis + INT + + + Internal - GAP Analysis + 2 employees + + Support + INT + + + Support + From 9774b788525f9458fea7baec6459bc648f7ad9dc Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Wed, 17 Jan 2018 14:28:05 +0100 Subject: [PATCH 07/16] [IMP] sale: analytic_account field edition Now that an analytic account can be linked to several projects, you might want to change the analytic on the SO. Before this commit, the user was allow to create an analytic account on a confirmed SO but not modify it. This was usefull when you wanted to reinvoice an expense on a SO without AA set. This is now solved (AA is automatically created when validated expense). Now, we want to allow set manually the AA before confirming SO to match the one of potential projects. --- addons/sale/views/sale_views.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index b1686253c0e..de74cd194d2 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -384,7 +384,7 @@ - + From 236a7011b87b12e1ac79ea87c79dd4dab64d58a0 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Wed, 17 Jan 2018 13:29:44 +0100 Subject: [PATCH 08/16] [REF] sale_timesheet: project/task generation from SO When confirming a SO, only one project will be created, even if some so lines would create a project. Only the first line will create the project, the other will not be linked to its. `project_id` field is added on so line, to keep track of the generated project, as already done for the task. The '_timesheet_service_generation' method has been rewritten to be execute in batch and improve performance and prefetching. On SO confirmation, no message are now post in chatter to avoid having one message per so line that should have created a task. A message is only posted when user manually add SO lines on confirmed SO. --- addons/sale_timesheet/models/sale_order.py | 164 +++++++++--------- .../tests/test_sale_timesheet.py | 44 +++-- 2 files changed, 112 insertions(+), 96 deletions(-) diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 53d1291df8b..61ffb11def9 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -41,17 +41,18 @@ class SaleOrder(models.Model): order.tasks_count = len(order.tasks_ids) @api.multi - @api.depends('order_line.product_id') + @api.depends('order_line.product_id', 'order_line.project_id') def _compute_project_ids(self): for order in self: projects = order.order_line.mapped('product_id.project_id') + projects |= order.order_line.mapped('project_id') order.project_ids = projects @api.multi def action_confirm(self): """ On SO confirmation, some lines should generate a task or a project. """ result = super(SaleOrder, self).action_confirm() - self.order_line._timesheet_service_generation() + self.order_line.sudo()._timesheet_service_generation() return result @api.multi @@ -128,7 +129,8 @@ class SaleOrderLine(models.Model): _inherit = "sale.order.line" qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')]) - task_id = fields.Many2one('project.task', 'Task', index=True, help="Task generated by the sales order item") + project_id = fields.Many2one('project.project', 'Generated Project', index=True, help="Project generated by the sales order item") + task_id = fields.Many2one('project.task', 'Generated Task', index=True, help="Task generated by the sales order item") is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.") analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense) @@ -177,11 +179,15 @@ class SaleOrderLine(models.Model): # Do not generate task/project when expense SO line, but allow # generate task with hours=0. if line.state == 'sale' and not line.is_expense: - line._timesheet_service_generation() + line.sudo()._timesheet_service_generation() + # if the SO line created a task, post a message on the order + if line.task_id: + msg_body = _("Task Created (%s): %s") % (line.product_id.name, line.task_id.id, line.task_id.name) + line.order_id.message_post(body=msg_body) return line ########################################### - ## Service : Project and task generation + # Service : Project and task generation ########################################### def _convert_qty_company_hours(self): @@ -192,32 +198,33 @@ class SaleOrderLine(models.Model): planned_hours = self.product_uom_qty return planned_hours - def _timesheet_find_project(self): + @api.multi + def _timesheet_create_project(self): + """ Generate project for the given so line, and link it. + :param project: record of project.project in which the task should be created + :return task: record of the created task + """ self.ensure_one() - Project = self.env['project.project'] - project = self.product_id.with_context(force_company=self.company_id.id).project_id - if not project: - # find the project corresponding to the analytic account of the sales order + account = self.order_id.analytic_account_id + if not account: + self.order_id._create_analytic_account(prefix=self.product_id.default_code or None) account = self.order_id.analytic_account_id - if not account: - self.order_id._create_analytic_account(prefix=self.product_id.default_code or None) - account = self.order_id.analytic_account_id - project = Project.search([('analytic_account_id', '=', account.id)], limit=1) - if not project: - project_name = '%s (%s)' % (account.name, self.order_partner_id.ref) if self.order_partner_id.ref else account.name - project = Project.create({ - 'name': project_name, - 'allow_timesheets': self.product_id.service_type == 'timesheet', - 'analytic_account_id': account.id, - }) - # set the SO line origin if product should create project - if not project.sale_line_id and self.product_id.service_tracking in ['task_new_project', 'project_only']: - project.write({'sale_line_id': self.id}) + + # create the project or duplicate one + values = { + 'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name, + 'allow_timesheets': True, + 'analytic_account_id': account.id, + 'partner_id': self.order_id.partner_id.id, + 'sale_line_id': self.id, + } + project = self.env['project.project'].create(values) + # link project as generated by current so line + self.write({'project_id': project.id}) return project - def _timesheet_create_task_prepare_values(self): + def _timesheet_create_task_prepare_values(self, project): self.ensure_one() - project = self._timesheet_find_project() planned_hours = self._convert_qty_company_hours() return { 'name': '%s:%s' % (self.order_id.name or '', self.name.split('\n')[0] or self.product_id.name), @@ -229,66 +236,67 @@ class SaleOrderLine(models.Model): 'project_id': project.id, 'sale_line_id': self.id, 'company_id': self.company_id.id, - 'user_id': False, # force non assigned task, as created as sudo() + 'user_id': False, # force non assigned task, as created as sudo() } @api.multi - def _timesheet_create_task(self): + def _timesheet_create_task(self, project): """ Generate task for the given so line, and link it. - - :return a mapping with the so line id and its linked task - :rtype dict + :param project: record of project.project in which the task should be created + :return task: record of the created task """ - result = {} - for so_line in self: - # create task - values = so_line._timesheet_create_task_prepare_values() - task = self.env['project.task'].sudo().create(values) - so_line.write({'task_id': task.id}) - # post message on SO - msg_body = _("Task Created (%s): %s") % (so_line.product_id.name, task.id, task.name) - so_line.order_id.message_post(body=msg_body) - # post message on task - task_msg = _("This task has been created from: %s (%s)") % (so_line.order_id.id, so_line.order_id.name, so_line.product_id.name) - task.message_post(body=task_msg) - - result[so_line.id] = task - return result - - @api.multi - def _timesheet_find_task(self): - """ Find the task generated by the so lines. If no task linked, it will be - created automatically. - - :return a mapping with the so line id and its linked task - :rtype dict - """ - # one search for all so lines - tasks = self.env['project.task'].search([('sale_line_id', 'in', self.ids)]) - task_sol_mapping = {task.sale_line_id.id: task for task in tasks} - - result = {} - for so_line in self: - # If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a new task. - task = task_sol_mapping.get(so_line.id) - # If not found, create one task for the so line - if not task: - task = so_line._timesheet_create_task()[so_line.id] - result[so_line.id] = task - return result + values = self._timesheet_create_task_prepare_values(project) + task = self.env['project.task'].sudo().create(values) + self.write({'task_id': task.id}) + # post message on task + task_msg = _("This task has been created from: %s (%s)") % (self.order_id.id, self.order_id.name, self.product_id.name) + task.message_post(body=task_msg) + return task @api.multi def _timesheet_service_generation(self): """ For service lines, create the task or the project. If already exists, it simply links the existing one to the line. + Note: If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a + new project/task. This explains the searches on 'sale_line_id' on project/task. This also + implied if so line of generated task has been modified, we may regenerate it. """ - for so_line in self.filtered(lambda sol: sol.is_service): - # create task - if so_line.product_id.service_tracking == 'task_global_project': - so_line._timesheet_find_task() - # create project - if so_line.product_id.service_tracking == 'project_only': - so_line._timesheet_find_project() - # create project and task - if so_line.product_id.service_tracking == 'task_new_project': - so_line._timesheet_find_task() + so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project') + so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_new_project']) + + # search so lines from SO of current so lines having already generated their project, in order to check if the current one can + # create its own project, or reuse + map_so_project = {} + if so_line_new_project: + order_ids = self.mapped('order_id').ids + so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_new_project']), ('product_id.project_template_id', '=', False)]) + map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project} + + # search the global project of current SO lines, in which create their task + map_sol_project = {} + if so_line_task_global_project: + map_sol_project = {sol.id: sol.product_id.with_context(force_company=sol.company_id.id).project_id for sol in so_line_task_global_project} + + def _can_create_project(sol): + if not sol.project_id: + if sol.order_id.id not in map_so_project: + return True + return False + + # task_global_project: create task in global project + for so_line in so_line_task_global_project: + if not so_line.task_id: + if map_sol_project.get(so_line.id): + so_line._timesheet_create_task(project=map_sol_project[so_line.id]) + + # project_only, task_new_project: create a new project, based or not on a template (1 per SO). May be create a task too. + if so_line_new_project: + for so_line in so_line_new_project: + project = so_line.project_id + if not project and _can_create_project(so_line): + project = so_line._timesheet_create_project() + map_so_project[so_line.order_id.id] = project + if so_line.product_id.service_tracking == 'task_new_project': + if not project: + project = map_so_project[so_line.order_id.id] + so_line._timesheet_create_task(project=project) diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py index 212fa2b550c..588480a56a7 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -56,9 +56,11 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): so_line_ordered_global_project.product_id_change() sale_order.action_confirm() task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_global_project.id)]) + project_serv1 = self.env['project.project'].search([('sale_line_id', '=', so_line_ordered_project_only.id)]) self.assertEqual(sale_order.tasks_count, 1, "One task should have been created on SO confirmation") - self.assertTrue(sale_order.project_ids, "A project should have been created by the SO, when confirmed.") + self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 2 'task in global project'") + self.assertEqual(sale_order.analytic_account_id, project_serv1.analytic_account_id, "The created project should be linked to the analytic account of the SO") # create invoice sale_order.action_invoice_create() @@ -102,7 +104,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): }) self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') - # add so line with produdct "create task in new project". (project will be the one from SO) + # add so line with produdct "create task in new project". so_line_ordered_task_new_project = self.env['sale.order.line'].create({ 'name': self.product_order_timesheet3.name, 'product_id': self.product_order_timesheet3.id, @@ -111,17 +113,16 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): 'price_unit': self.product_order_timesheet3.list_price, 'order_id': sale_order.id, }) - task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_task_new_project.id)]) - project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_ordered_project_only.id)]) + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: Adding a new service line (so line) should put the SO in "to invocie" state.') self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation") + self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'new task in new project' product, since it reuse the one from 'project only'.") # create invoice invoice_id = sale_order.action_invoice_create() invoice = self.env['account.invoice'].browse(invoice_id) self.assertEqual(len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO") self.assertTrue(float_is_zero(invoice.amount_total - so_line_ordered_task_new_project.price_unit * 3, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong') - self.assertEqual(project_serv2, task_serv3.project_id, "When creating task in new project, the task should be in SO project (if already exists), otherwise it created one.") def test_timesheet_delivery(self): """ Test timesheet invoicing with 'invoice on delivery' timetracked products @@ -160,22 +161,24 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): # confirm SO sale_order.action_confirm() - task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) - task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) - project_serv3 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + task_serv1 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) - self.assertEqual(task_serv2.project_id, self.project_global, "Sale Timesheet: task should be created in global project") - self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in global project") - self.assertTrue(task_serv3, "Sale Timesheet: on SO confirmation, a task should have been created in a new project") + self.assertEqual(task_serv1.project_id, self.project_global, "Sale Timesheet: task should be created in global project") + self.assertTrue(task_serv1, "Sale Timesheet: on SO confirmation, a task should have been created in global project") + self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in a new project") self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation') - self.assertEqual(sale_order.analytic_account_id, task_serv3.project_id.analytic_account_id, "SO should have create a project") + self.assertEqual(sale_order.analytic_account_id, task_serv2.project_id.analytic_account_id, "SO should have create a project") self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation") + self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 1 'task in global project'") + self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO") # let's log some timesheets self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': task_serv2.project_id.id, # global project - 'task_id': task_serv2.id, + 'project_id': task_serv1.project_id.id, # global project + 'task_id': task_serv1.id, 'unit_amount': 10.5, 'employee_id': self.employee_manager.id, }) @@ -191,8 +194,8 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): # log some timesheets again self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': task_serv2.project_id.id, # global project - 'task_id': task_serv2.id, + 'project_id': task_serv1.project_id.id, # global project + 'task_id': task_serv1.id, 'unit_amount': 39.5, 'employee_id': self.employee_user.id, }) @@ -214,12 +217,12 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): 'price_unit': self.product_delivery_timesheet4.list_price, 'order_id': sale_order.id, }) - self.assertEqual(project_serv3, task_serv3.project_id, "SO should not have create a second project, since so line 3 already create one project for the SO") + self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'project only' product, since it reuse the one from 'new task in new project'.") # let's log some timesheets on the project self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': project_serv3.id, # global project + 'project_id': project_serv2.id, # global project 'unit_amount': 7, 'employee_id': self.employee_user.id, }) @@ -258,6 +261,10 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): self.assertTrue(sale_order.project_ids, "Sales Order should have create a project") self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: manually product should not need to be invoiced on so confirmation') + project_serv2 = so_line_manual_only_project.project_id + self.assertTrue(project_serv2, "A second project is created when selling 'project only' after SO confirmation.") + self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO") + # let's log some timesheets (on task and project) timesheet1 = self.env['account.analytic.line'].create({ 'name': 'Test Line', @@ -274,6 +281,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): 'employee_id': self.employee_manager.id, }) + self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one coming from SO line 1 'task in global project'.") self.assertEqual(so_line_manual_global_project.task_id.sale_line_id, so_line_manual_global_project, "Task from a milestone product should be linked to its SO line too") self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Milestone timesheet goes in billable fixed category") self.assertTrue(float_is_zero(so_line_manual_global_project.qty_delivered, precision_digits=2), "Milestone Timesheeting should not incremented the delivered quantity on the SO line") From eb5cb1359b11990159c6348ba82ec94dbc0862ed Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Fri, 19 Jan 2018 10:38:58 +0100 Subject: [PATCH 09/16] [IMP] sale_timesheet: improve tests --- .../sale_timesheet/tests/test_sale_service.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 202d3051149..2fe902fb8d5 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -37,6 +37,8 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): self.sale_order.action_confirm() self.sale_order.order_line._compute_product_updatable() + self.sale_order.action_confirm() + self.sale_order.order_line._compute_product_updatable() self.assertFalse(sale_order_line.product_updatable) self.assertEqual(self.sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation') @@ -111,7 +113,7 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): 'unit_amount': 16, 'employee_id': self.employee_manager.id, }) - self.assertEqual(self.sale_order.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong') + self.assertEqual(sale_order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong') self.env['account.analytic.line'].create({ 'name': 'Test Line', @@ -123,8 +125,9 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): self.sale_order.action_invoice_create() self.assertEqual(self.sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so') + def test_task_so_line_assignation(self): - # create SO and confirm it + # create SO line and confirm it so_line_deliver_global_project = self.env['sale.order.line'].create({ 'name': self.product_delivery_timesheet2.name, 'product_id': self.product_delivery_timesheet2.id, @@ -174,23 +177,17 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): task_serv2.write({'sale_line_id': False}) def test_delivered_quantity(self): - # create SO and confirm it - sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({ - 'partner_id': self.partner_customer_usd.id, - 'partner_invoice_id': self.partner_customer_usd.id, - 'partner_shipping_id': self.partner_customer_usd.id, - 'pricelist_id': self.pricelist_usd.id, - }) + # create SO line and confirm it so_line_deliver_new_task_project = self.env['sale.order.line'].create({ 'name': self.product_delivery_timesheet3.name, 'product_id': self.product_delivery_timesheet3.id, 'product_uom_qty': 10, 'product_uom': self.product_delivery_timesheet3.uom_id.id, 'price_unit': self.product_delivery_timesheet3.list_price, - 'order_id': sale_order.id, + 'order_id': self.sale_order.id, }) so_line_deliver_new_task_project.product_id_change() - sale_order.action_confirm() + self.sale_order.action_confirm() task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_new_task_project.id)]) # add a timesheet From 460f57f510be49910d10a786aa178c0fad1e420e Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Thu, 18 Jan 2018 10:07:20 +0100 Subject: [PATCH 10/16] [IMP] sale_timesheet: selling project based on template Selling product creating project can now be based on project template. When setting a project template on product, this will copy the project (like a duplicate) and replace its customer, ... with information from the SO line, on SO confirmation. There is only one project without template per SO, and one project per template per SO (so 2 SO lines with the same project template on their product will only create one project with this template). --- addons/sale_timesheet/models/product.py | 27 ++++- addons/sale_timesheet/models/sale_order.py | 91 +++++++++------ addons/sale_timesheet/tests/common.py | 58 +++++++++ .../sale_timesheet/tests/test_sale_service.py | 110 +++++++++++++++++- addons/sale_timesheet/views/product_views.xml | 6 +- .../views/project_task_views.xml | 1 + 6 files changed, 248 insertions(+), 45 deletions(-) diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index 53e674f5a8e..ceb8273549b 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError class ProductTemplate(models.Model): @@ -25,6 +26,9 @@ class ProductTemplate(models.Model): project_id = fields.Many2one( 'project.project', 'Project', company_dependent=True, domain=[('sale_line_id', '=', False)], help='Select a non billable project on which tasks can be created. This setting must be set for each company.') + project_template_id = fields.Many2one( + 'project.project', 'Project Template', company_dependent=True, domain=[('sale_line_id', '=', False)], copy=True, + help='Select a non billable project to be the skeleton of the new created project when selling the current product. Its stages and tasks will be duplicated.') @api.depends('invoice_policy', 'service_type') def _compute_service_policy(self): @@ -49,9 +53,28 @@ class ProductTemplate(models.Model): product.invoice_policy = 'delivery' product.service_type = 'manual' if policy == 'delivered_manual' else 'timesheet' + @api.constrains('project_id', 'project_template_id') + def _check_project_and_template(self): + """ NOTE 'service_tracking' should be in decorator parameters but since ORM check constraints twice (one after setting + stored fields, one after setting non stored field), the error is raised when company-dependent fields are not set. + So, this constraints does cover all cases and inconsistent can still be recorded until the ORM change its behavior. + """ + for product in self: + if product.service_tracking == 'no' and (product.project_id or product.project_template_id): + raise ValidationError(_('The product %s should not have a project nor a project template since it will not generate project.') % (product.name,)) + elif product.service_tracking == 'task_global_project' and product.project_template_id: + raise ValidationError(_('The product %s should not have a project template since it will generate a task in a global project.') % (product.name,)) + elif product.service_tracking in ['task_new_project', 'project_only'] and product.project_id: + raise ValidationError(_('The product %s should not have a global project since it will generate a project.') % (product.name,)) + @api.onchange('service_tracking') def _onchange_service_tracking(self): - if self.service_tracking != 'task_global_project': + if self.service_tracking == 'no': + self.project_id = False + self.project_template_id = False + elif self.service_tracking == 'task_global_project': + self.project_template_id = False + elif self.service_tracking in ['task_new_project', 'project_only']: self.project_id = False @api.onchange('type') diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 61ffb11def9..5777648fd3b 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -6,7 +6,6 @@ from odoo import api, fields, models, _ from odoo.exceptions import ValidationError from odoo.osv import expression from odoo.tools.safe_eval import safe_eval -from odoo.tools import float_is_zero class SaleOrder(models.Model): @@ -58,37 +57,35 @@ class SaleOrder(models.Model): @api.multi def action_view_task(self): self.ensure_one() - action = self.env.ref('project.action_view_task') + list_view_id = self.env.ref('project.view_task_tree2').id form_view_id = self.env.ref('project.view_task_form2').id - result = { - 'name': action.name, - 'help': action.help, - 'type': action.type, - 'views': [[False, 'kanban'], [list_view_id, 'tree'], [form_view_id, 'form'], [False, 'graph'], [False, 'calendar'], [False, 'pivot'], [False, 'graph']], - 'target': action.target, - 'context': "{'group_by':'stage_id'}", - 'res_model': action.res_model, - } - if len(self.tasks_ids) > 1: - result['domain'] = "[('id','in',%s)]" % self.tasks_ids.ids - elif len(self.tasks_ids) == 1: - result['views'] = [(form_view_id, 'form')] - result['res_id'] = self.tasks_ids.id + action = {'type': 'ir.actions.act_window_close'} + + task_projects = self.tasks_ids.mapped('project_id') + if len(task_projects) == 1 and len(self.tasks_ids) > 1: # redirect to task of the project (with kanban stage, ...) + action = self.env.ref('project.act_project_project_2_project_task_all').read()[0] + if action.get('context'): + eval_context = self.env['ir.actions.actions']._get_eval_context() + eval_context.update({'active_id': task_projects.id}) + action['context'] = safe_eval(action['context'], eval_context) else: - result = {'type': 'ir.actions.act_window_close'} - return result + action = self.env.ref('project.action_view_task').read()[0] + if len(self.tasks_ids) > 1: # cross project kanban task + action['views'] = [[False, 'kanban'], [list_view_id, 'tree'], [form_view_id, 'form'], [False, 'graph'], [False, 'calendar'], [False, 'pivot'], [False, 'graph']] + elif len(self.tasks_ids) == 1: # single task -> form view + action['views'] = [(form_view_id, 'form')] + action['res_id'] = self.tasks_ids.id + return action @api.multi def action_view_project_ids(self): self.ensure_one() - if len(self.project_ids) == 1: - if self.env.user.has_group("hr_timesheet.group_hr_timesheet_user"): - action = self.project_ids.action_view_timesheet_plan() - else: - action = self.env.ref("project.act_project_project_2_project_task_all").read()[0] - action['context'] = safe_eval(action.get('context', '{}'), {'active_id': self.project_ids.id, 'active_ids': self.project_ids.ids}) + # redirect to form or kanban view + billable_projects = self.project_ids.filtered(lambda project: project.sale_line_id) + if len(billable_projects) == 1: + action = billable_projects[0].action_view_timesheet_plan() else: view_form_id = self.env.ref('project.edit_project').id view_kanban_id = self.env.ref('project.view_project_kanban').id @@ -129,8 +126,8 @@ class SaleOrderLine(models.Model): _inherit = "sale.order.line" qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')]) - project_id = fields.Many2one('project.project', 'Generated Project', index=True, help="Project generated by the sales order item") - task_id = fields.Many2one('project.task', 'Generated Task', index=True, help="Task generated by the sales order item") + project_id = fields.Many2one('project.project', 'Generated Project', index=True, copy=False, help="Project generated by the sales order item") + task_id = fields.Many2one('project.task', 'Generated Task', index=True, copy=False, help="Task generated by the sales order item") is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.") analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense) @@ -218,7 +215,16 @@ class SaleOrderLine(models.Model): 'partner_id': self.order_id.partner_id.id, 'sale_line_id': self.id, } - project = self.env['project.project'].create(values) + if self.product_id.project_template_id: + values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name) + project = self.product_id.project_template_id.copy(values) + project.tasks.write({ + 'sale_line_id': self.id, + 'partner_id': self.order_id.partner_id.id, + 'email_from': self.order_id.partner_id.email, + }) + else: + project = self.env['project.project'].create(values) # link project as generated by current so line self.write({'project_id': project.id}) return project @@ -264,13 +270,15 @@ class SaleOrderLine(models.Model): so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project') so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_new_project']) - # search so lines from SO of current so lines having already generated their project, in order to check if the current one can - # create its own project, or reuse + # search so lines from SO of current so lines having their project generated, in order to check if the current one can + # create its own project, or reuse the one of its order. map_so_project = {} if so_line_new_project: order_ids = self.mapped('order_id').ids so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_new_project']), ('product_id.project_template_id', '=', False)]) map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project} + so_lines_with_project_templates = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_new_project']), ('product_id.project_template_id', '!=', False)]) + map_so_project_templates = {(sol.order_id.id, sol.product_id.project_template_id.id): sol.project_id for sol in so_lines_with_project_templates} # search the global project of current SO lines, in which create their task map_sol_project = {} @@ -279,7 +287,9 @@ class SaleOrderLine(models.Model): def _can_create_project(sol): if not sol.project_id: - if sol.order_id.id not in map_so_project: + if sol.product_id.project_template_id: + return (sol.order_id.id, sol.product_id.project_template_id.id) not in map_so_project_templates + elif sol.order_id.id not in map_so_project: return True return False @@ -290,13 +300,18 @@ class SaleOrderLine(models.Model): so_line._timesheet_create_task(project=map_sol_project[so_line.id]) # project_only, task_new_project: create a new project, based or not on a template (1 per SO). May be create a task too. - if so_line_new_project: - for so_line in so_line_new_project: - project = so_line.project_id - if not project and _can_create_project(so_line): - project = so_line._timesheet_create_project() + for so_line in so_line_new_project: + project = so_line.project_id + if not project and _can_create_project(so_line): + project = so_line._timesheet_create_project() + if so_line.product_id.project_template_id: + map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project + else: map_so_project[so_line.order_id.id] = project - if so_line.product_id.service_tracking == 'task_new_project': - if not project: + if so_line.product_id.service_tracking == 'task_new_project': + if not project: + if so_line.product_id.project_template_id: + project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] + else: project = map_so_project[so_line.order_id.id] - so_line._timesheet_create_task(project=project) + so_line._timesheet_create_task(project=project) diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py index caa12a006a3..4d8052809b8 100644 --- a/addons/sale_timesheet/tests/common.py +++ b/addons/sale_timesheet/tests/common.py @@ -29,10 +29,20 @@ class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart): 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, }) + # Create projects cls.project_global = cls.env['project.project'].create({ 'name': 'Project for selling timesheets', 'allow_timesheets': True, }) + cls.project_template = cls.env['project.project'].create({ + 'name': 'Project TEMPLATE for services', + 'allow_timesheets': True, + }) + cls.project_template_state = cls.env['project.task.type'].create({ + 'name': 'Only stage in project template', + 'sequence': 1, + 'project_ids': [(4, cls.project_template.id)] + }) # Create service products uom_hour = cls.env.ref('uom.product_uom_hour') @@ -98,6 +108,22 @@ class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart): 'taxes_id': False, 'property_account_income_id': cls.account_sale.id, }) + cls.product_order_timesheet5 = cls.env['product.product'].create({ + 'name': "Service Ordered, create project only based on template", + 'standard_price': 17, + 'list_price': 34, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': cls.env.ref('uom.product_uom_hour').id, + 'uom_po_id': cls.env.ref('uom.product_uom_hour').id, + 'default_code': 'SERV-ORDERED4', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_id': False, + 'project_template_id': cls.project_template.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) # -- timesheet on tasks (delivered, timesheet) cls.product_delivery_timesheet1 = cls.env['product.product'].create({ @@ -160,6 +186,22 @@ class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart): 'taxes_id': False, 'property_account_income_id': cls.account_sale.id, }) + cls.product_delivery_timesheet5 = cls.env['product.product'].create({ + 'name': "Service delivered, create project only based on template", + 'standard_price': 17, + 'list_price': 34, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('uom.product_uom_hour').id, + 'uom_po_id': cls.env.ref('uom.product_uom_hour').id, + 'default_code': 'SERV-DELI4', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_template_id': cls.project_template.id, + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) # -- milestones (delivered, manual) cls.product_delivery_manual1 = cls.env['product.product'].create({ @@ -222,3 +264,19 @@ class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart): 'taxes_id': False, 'property_account_income_id': cls.account_sale.id, }) + cls.product_delivery_manual5 = cls.env['product.product'].create({ + 'name': "Service delivered, create project only with template", + 'standard_price': 17, + 'list_price': 34, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('uom.product_uom_hour').id, + 'uom_po_id': cls.env.ref('uom.product_uom_hour').id, + 'default_code': 'SERV-DELI4', + 'service_type': 'manual', + 'service_tracking': 'project_only', + 'project_id': False, + 'project_template_id': cls.project_template.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 2fe902fb8d5..98378df0041 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -125,7 +125,6 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): self.sale_order.action_invoice_create() self.assertEqual(self.sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so') - def test_task_so_line_assignation(self): # create SO line and confirm it so_line_deliver_global_project = self.env['sale.order.line'].create({ @@ -224,3 +223,112 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): # remove timesheet2 timesheet2.unlink() self.assertEqual(so_line_deliver_new_task_project.qty_delivered, timesheet3.unit_amount, 'Delivered quantity should be reset to the sum of remaining timesheets unit amounts.') + + def test_sale_create_project(self): + """ A SO with multiple product that should create project (with and without template) like ; + Line 1 : Service 1 create project with Template A ===> project created with template A + Line 2 : Service 2 create project no template ==> empty project created + Line 3 : Service 3 create project with Template A ===> Don't create any project because line 1 has already created a project with template A + Line 4 : Service 4 create project no template ==> Don't create any project because line 2 has already created an empty project + Line 5 : Service 5 create project with Template B ===> project created with template B + """ + # second project template and its associated product + project_template2 = self.env['project.project'].create({ + 'name': 'Second Project TEMPLATE for services', + 'allow_timesheets': True, + }) + Stage = self.env['project.task.type'].with_context(default_project_id=project_template2.id) + stage1_tmpl2 = Stage.create({ + 'name': 'Stage 1', + 'sequence': 1 + }) + stage2_tmpl2 = Stage.create({ + 'name': 'Stage 2', + 'sequence': 2 + }) + product_deli_ts_tmpl = self.env['product.product'].create({ + 'name': "Service delivered, create project only based on template B", + 'standard_price': 17, + 'list_price': 34, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': self.env.ref('uom.product_uom_hour').id, + 'uom_po_id': self.env.ref('uom.product_uom_hour').id, + 'default_code': 'SERV-DELI4', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_template_id': project_template2.id, + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': self.account_sale.id, + }) + + # create 5 so lines + so_line1 = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet5.name, + 'product_id': self.product_delivery_timesheet5.id, + 'product_uom_qty': 11, + 'product_uom': self.product_delivery_timesheet5.uom_id.id, + 'price_unit': self.product_delivery_timesheet5.list_price, + 'order_id': self.sale_order.id, + }) + so_line2 = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet4.name, + 'product_id': self.product_order_timesheet4.id, + 'product_uom_qty': 10, + 'product_uom': self.product_order_timesheet4.uom_id.id, + 'price_unit': self.product_order_timesheet4.list_price, + 'order_id': self.sale_order.id, + }) + so_line3 = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet5.name, + 'product_id': self.product_delivery_timesheet5.id, + 'product_uom_qty': 5, + 'product_uom': self.product_delivery_timesheet5.uom_id.id, + 'price_unit': self.product_delivery_timesheet5.list_price, + 'order_id': self.sale_order.id, + }) + so_line4 = self.env['sale.order.line'].create({ + 'name': self.product_delivery_manual3.name, + 'product_id': self.product_delivery_manual3.id, + 'product_uom_qty': 4, + 'product_uom': self.product_delivery_manual3.uom_id.id, + 'price_unit': self.product_delivery_manual3.list_price, + 'order_id': self.sale_order.id, + }) + so_line5 = self.env['sale.order.line'].create({ + 'name': product_deli_ts_tmpl.name, + 'product_id': product_deli_ts_tmpl.id, + 'product_uom_qty': 8, + 'product_uom': product_deli_ts_tmpl.uom_id.id, + 'price_unit': product_deli_ts_tmpl.list_price, + 'order_id': self.sale_order.id, + }) + + # confirm SO + self.sale_order.action_confirm() + + # check each line has or no generate something + self.assertTrue(so_line1.project_id, "Line1 should have create a project based on template A") + self.assertTrue(so_line2.project_id, "Line2 should have create an empty project") + self.assertFalse(so_line3.project_id, "Line3 should not have create a project, since line1 already create a project based on template A") + self.assertFalse(so_line4.project_id, "Line4 should not have create a project, since line1 already create an empty project") + self.assertTrue(so_line4.task_id, "Line4 should have create a new task, even if no project created.") + self.assertTrue(so_line5.project_id, "Line5 should have create a project based on template B") + + # check generated stuff are correct + self.assertTrue(so_line1.project_id in self.project_template_state.project_ids, "Stage 1 from template B should be part of project from so line 1") + self.assertTrue(so_line1.project_id in self.project_template_state.project_ids, "Stage 1 from template B should be part of project from so line 1") + + self.assertTrue(so_line5.project_id in stage1_tmpl2.project_ids, "Stage 1 from template B should be part of project from so line 5") + self.assertTrue(so_line5.project_id in stage2_tmpl2.project_ids, "Stage 2 from template B should be part of project from so line 5") + + self.assertTrue(so_line1.project_id.allow_timesheets, "Create project should allow timesheets") + self.assertTrue(so_line2.project_id.allow_timesheets, "Create project should allow timesheets") + self.assertTrue(so_line5.project_id.allow_timesheets, "Create project should allow timesheets") + + self.assertEqual(so_line4.task_id.project_id, so_line2.project_id, "Task created with line 4 should have the project based on template A of the SO.") + + self.assertEqual(so_line1.project_id.sale_line_id, so_line1, "SO line of project with template A should be the one that create it.") + self.assertEqual(so_line2.project_id.sale_line_id, so_line2, "SO line of project should be the one that create it.") + self.assertEqual(so_line5.project_id.sale_line_id, so_line5, "SO line of project with template B should be the one that create it.") diff --git a/addons/sale_timesheet/views/product_views.xml b/addons/sale_timesheet/views/product_views.xml index 15704930b60..aea6f60a959 100644 --- a/addons/sale_timesheet/views/product_views.xml +++ b/addons/sale_timesheet/views/product_views.xml @@ -12,11 +12,9 @@ - + + - - diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index b659aaa9ba4..2b4705f1b4c 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -23,6 +23,7 @@ + From abfa921009dd39b50d66763913822086d73f8554 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Thu, 15 Feb 2018 14:04:35 +0100 Subject: [PATCH 11/16] [IMP] sale_timesheet: tasks linked to SOL of project When selling a project from a sale order, the SO line creates the project. When we create a task in that kind of project, by default, the task will be linked to the SO line of its project. However, for the SO line field to be visible on the task form view, the customer must be set. This commit set the customer from the SO line when changing the project (billable project only). --- addons/sale_timesheet/models/project.py | 16 ++++++++ .../sale_timesheet/tests/test_sale_service.py | 41 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 2a28a279393..d8b4cca03a2 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -56,6 +56,13 @@ class Project(models.Model): class ProjectTask(models.Model): _inherit = "project.task" + def _get_default_partner(self): + partner = super(ProjectTask, self)._get_default_partner() + if 'default_project_id' in self.env.context: # partner from SO line is prior on one from project + project = self.env['project.project'].browse(self.env.context['default_project_id']) + partner = project.sale_line_id.order_partner_id + return partner + @api.model def _default_sale_line_id(self): sale_line_id = False @@ -67,6 +74,15 @@ class ProjectTask(models.Model): sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False)]") + @api.onchange('project_id') + def _onchange_project(self): + result = super(ProjectTask, self)._onchange_project() + if self.project_id: + self.sale_line_id = self.project_id.sale_line_id + if not self.partner_id: + self.partner_id = self.sale_line_id.order_partner_id + return result + @api.multi @api.constrains('sale_line_id') def _check_sale_line_type(self): diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 98378df0041..49cc195da90 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -332,3 +332,44 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): self.assertEqual(so_line1.project_id.sale_line_id, so_line1, "SO line of project with template A should be the one that create it.") self.assertEqual(so_line2.project_id.sale_line_id, so_line2, "SO line of project should be the one that create it.") self.assertEqual(so_line5.project_id.sale_line_id, so_line5, "SO line of project with template B should be the one that create it.") + + def test_billable_task_and_subtask(self): + """ Test if subtasks and tasks are billed on the correct SO line """ + # create SO line and confirm it + so_line_deliver_new_task_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 10, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price, + 'order_id': self.sale_order.id, + }) + so_line_deliver_new_task_project.product_id_change() + self.sale_order.action_confirm() + + project = so_line_deliver_new_task_project.project_id + task = so_line_deliver_new_task_project.task_id + + self.assertEqual(project.sale_line_id, so_line_deliver_new_task_project, "The created project should be linked to the so line") + self.assertEqual(task.sale_line_id, so_line_deliver_new_task_project, "The created task should be linked to the so line") + + # create a new task and subtask + subtask = self.env['project.task'].create({ + 'parent_id': task.id, + 'project_id': project.id, + 'name': '%s: substask1' % (task.name,), + 'sale_line_id': False # forcing, but should have not effect + }) + task2 = self.env['project.task'].create({ + 'project_id': project.id, + 'name': '%s: substask1' % (task.name,) + }) + + self.assertEqual(subtask.sale_line_id, task.sale_line_id, "A child task should have the same SO line than its mother") + self.assertEqual(task2.sale_line_id, project.sale_line_id, "A new task in a billable project should have the same SO line than its project") + self.assertEqual(task2.partner_id, so_line_deliver_new_task_project.order_partner_id, "A new task in a billable project should have the same SO line than its project") + + # moving subtask in another project + subtask.write({'project_id': self.project_global.id}) + + self.assertEqual(subtask.sale_line_id, task.sale_line_id, "A child task should always have the same SO line than its mother, even when changing project") From 30f3d4144fea13557dc48d27ae277296860e57cb Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Thu, 22 Feb 2018 11:22:51 +0100 Subject: [PATCH 12/16] [IMP] sale_timesheet: plan project when timesheet only The code of the project overview is split to be used when only timesheet is installed (no forecast). The table part only display column for timesheeted time per month. When forecast is installed and activated, other columns comes to complete this table. This commit also makes visible SO line having no timesheet linked in project overview. --- addons/sale_timesheet/controllers/main.py | 92 ++++++++++++++++++- .../static/src/less/sale_timesheet.less | 34 +++++++ .../views/hr_timesheet_templates.xml | 50 ++++++++++ 3 files changed, 174 insertions(+), 2 deletions(-) diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index 828ded7a0b8..8773d68aaa8 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -5,9 +5,10 @@ from dateutil.relativedelta import relativedelta from odoo import http, fields, _ from odoo.http import request - from odoo.tools import float_round +DEFAULT_MONTH_RANGE = 3 + class SaleTimesheetController(http.Controller): @@ -115,6 +116,88 @@ class SaleTimesheetController(http.Controller): values['repartition_employee_max'] = (max(hours_per_employee) if hours_per_employee else 1) or 1 values['repartition_employee'] = repartition_employee + # + # Table grouped by SO / SOL / Employees + # + if not projects: + return values + + # build SQL query and fetch raw data + query, query_params = self._table_rows_sql_query(projects) + request.env.cr.execute(query, query_params) + raw_data = request.env.cr.dictfetchall() + rows_employee = self._table_rows_get_employee_lines(projects, raw_data) + default_row_vals = self._table_row_default(projects) + + empty_line_ids, empty_order_ids = self._table_get_empty_so_lines(projects) + + # extract row labels + sale_line_ids = set() + sale_order_ids = set() + for key_tuple, row in rows_employee.items(): + if row[0]['sale_line_id']: + sale_line_ids.add(row[0]['sale_line_id']) + if row[0]['sale_order_id']: + sale_order_ids.add(row[0]['sale_order_id']) + + sale_order_lines = request.env['sale.order.line'].sudo().browse(sale_line_ids | empty_line_ids) + map_so_names = {so.id: so.name for so in request.env['sale.order'].sudo().browse(sale_order_ids | empty_order_ids)} + map_sol = {sol.id: sol for sol in sale_order_lines} + map_sol_names = {sol.id: sol.name.split('\n')[0] if sol.name else _('No Sale Order Line') for sol in sale_order_lines} + map_sol_so = {sol.id: sol.order_id.id for sol in sale_order_lines} + + rows_sale_line = {} # (so, sol) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted] + for sale_line_id in empty_line_ids: # add service SO line having no timesheet + sale_line_row_key = (map_sol_so.get(sale_line_id), sale_line_id) + rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line_id, _('No Sale Order Line')), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line'}] + default_row_vals[:] + rows_sale_line[sale_line_row_key][-2] = map_sol.get(sale_line_id).product_uom_qty if sale_line_id in map_sol else 0.0 + + for row_key, row_employee in rows_employee.items(): + sale_line_id = row_key[1] + sale_order_id = row_key[0] + # sale line row + sale_line_row_key = (sale_order_id, sale_line_id) + if sale_line_row_key not in rows_sale_line: + sale_line = map_sol.get(sale_line_id, request.env['sale.order.line']) + rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line.id) if sale_line else _('No Sale Order Line'), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line'}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted + rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom_qty or 0.0 + + for index in range(len(rows_employee[row_key])): + if index != 0: + rows_sale_line[sale_line_row_key][index] += rows_employee[row_key][index] + rows_sale_line[sale_line_row_key][-1] = rows_sale_line[sale_line_row_key][-2] - rows_sale_line[sale_line_row_key][5] + + rows_sale_order = {} # so -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted] + for row_key, row_sale_line in rows_sale_line.items(): + sale_order_id = row_key[0] + # sale order row + if sale_order_id not in rows_sale_order: + rows_sale_order[sale_order_id] = [{'label': map_so_names.get(sale_order_id, _('No Sale Order')), 'res_id': sale_order_id, 'res_model': 'sale.order', 'type': 'sale_order'}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted + + for index in range(len(rows_sale_line[row_key])): + if index != 0: + rows_sale_order[sale_order_id][index] += rows_sale_line[row_key][index] + + # remaining computation of SO row, as Sold - Done (timesheet total) + for sale_order_id, sale_order_row in rows_sale_order.items(): + sale_order_row[-1] = sale_order_row[-2] - sale_order_row[5] + + # group rows SO, SOL and their related employee rows. + timesheet_forecast_table_rows = [] + for sale_order_id, sale_order_row in rows_sale_order.items(): + timesheet_forecast_table_rows.append(sale_order_row) + for sale_line_row_key, sale_line_row in rows_sale_line.items(): + if sale_order_id == sale_line_row_key[0]: + timesheet_forecast_table_rows.append(sale_line_row) + for employee_row_key, employee_row in rows_employee.items(): + if sale_order_id == employee_row_key[0] and sale_line_row_key[1] == employee_row_key[1]: + timesheet_forecast_table_rows.append(employee_row) + + # complete table data + values['timesheet_forecast_table'] = { + 'header': self._table_header(projects), + 'rows': timesheet_forecast_table_rows + } return values def _table_header(self, projects): @@ -129,7 +212,7 @@ class SaleTimesheetController(http.Controller): return header def _table_row_default(self, projects): - lenght = self._table_header(projects) + lenght = len(self._table_header(projects)) return [0.0] * (lenght - 1) # before, M1, M2, M3, Done, Sold, Remaining def _table_rows_sql_query(self, projects): @@ -196,6 +279,11 @@ class SaleTimesheetController(http.Controller): rows_employee[row_key][5] += data['number_hours'] return rows_employee + 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.mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service) + return set(so_lines.ids), set(so_lines.mapped('order_id').ids) + # -------------------------------------------------- # Actions: Stat buttons, ... # -------------------------------------------------- diff --git a/addons/sale_timesheet/static/src/less/sale_timesheet.less b/addons/sale_timesheet/static/src/less/sale_timesheet.less index c7bc4864566..194f5d591de 100644 --- a/addons/sale_timesheet/static/src/less/sale_timesheet.less +++ b/addons/sale_timesheet/static/src/less/sale_timesheet.less @@ -74,5 +74,39 @@ } } + .o_project_plan_project_timesheet_forecast { + th{ + text-align: center; + } + th.o_right_bordered, td.o_right_bordered { + border-right: 2px solid @odoo-brand-optional; + text-align: center; + } + + .o_timesheet_forecast_sale_order{ + font-weight: bold; + & > td:first-child { + color: @odoo-brand-optional; + } + } + .o_timesheet_forecast_sale_order_line{ + td:first-child > span { + margin-left: 15px; + } + td:first-child > a { + margin-left: 15px; + cursor: pointer; + } + } + .o_timesheet_forecast_hr_employee{ + td:first-child > span { + margin-left: 30px; + } + } + + .o_timesheet_plan_redirect { + cursor: pointer; + } + } } diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 8aa9410dcab..be2f850719f 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -201,6 +201,56 @@
+ + +
+ +

Timesheets

+ + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ From 29d64be400655d45de9acd986f45ea5ea7f5045c Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Thu, 22 Feb 2018 16:18:49 +0100 Subject: [PATCH 13/16] [IMP] sale_timesheet: group task by Sales Order From task stat button from SO form view, it is relevant to see the task of the SO only. So, we add a filter to do so. AS the kanban task use a read_group, it is required to store the sale_order_id field on task. --- addons/sale_timesheet/models/project.py | 1 + addons/sale_timesheet/models/sale_order.py | 4 ++++ addons/sale_timesheet/views/project_task_views.xml | 12 ++++++++++++ 3 files changed, 17 insertions(+) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index d8b4cca03a2..4d3a45876f7 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -73,6 +73,7 @@ class ProjectTask(models.Model): return sale_line_id sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False)]") + sale_order_id = fields.Many2one('sale.order', 'Sales Order', related='sale_line_id.order_id', store=True, readonly=True) @api.onchange('project_id') def _onchange_project(self): diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 5777648fd3b..c54c0df7089 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -72,11 +72,15 @@ class SaleOrder(models.Model): action['context'] = safe_eval(action['context'], eval_context) else: action = self.env.ref('project.action_view_task').read()[0] + action['context'] = {} # erase default context to avoid default filter if len(self.tasks_ids) > 1: # cross project kanban task action['views'] = [[False, 'kanban'], [list_view_id, 'tree'], [form_view_id, 'form'], [False, 'graph'], [False, 'calendar'], [False, 'pivot'], [False, 'graph']] elif len(self.tasks_ids) == 1: # single task -> form view action['views'] = [(form_view_id, 'form')] action['res_id'] = self.tasks_ids.id + # filter on the task of the current SO + action.setdefault('context', {}) + action['context'].update({'search_default_sale_order_id': self.id}) return action @api.multi diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 2b4705f1b4c..1b82269ca19 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -46,6 +46,18 @@
+ + project.task.search.inherit + project.task + + + + + + + + + project.task.form.inherit.timesheet project.task From 0cbcbf89d22f41976b9fccf553ddac2188d799d0 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Wed, 28 Mar 2018 15:42:08 +0200 Subject: [PATCH 14/16] [IMP] sale_order: name and description of generated task We want to split the sale order line description as follow: - first line will be the title of the task, prefixed by SO reference - the rest of SO line description should be the task description The purpose is to avoid duplicating the first SO line description in task title and description. Task #1831934 --- addons/sale_timesheet/models/sale_order.py | 7 +++++-- addons/sale_timesheet/tests/test_sale_service.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index c54c0df7089..f432ce18374 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -236,13 +236,16 @@ class SaleOrderLine(models.Model): def _timesheet_create_task_prepare_values(self, project): self.ensure_one() planned_hours = self._convert_qty_company_hours() + sale_line_name_parts = self.name.split('\n') + title = sale_line_name_parts[0] or self.product_id.name + description = '
'.join(sale_line_name_parts[1:]) return { - 'name': '%s:%s' % (self.order_id.name or '', self.name.split('\n')[0] or self.product_id.name), + 'name': title if project.sale_line_id else '%s: %s' % (self.order_id.name or '', title), 'planned_hours': planned_hours, 'remaining_hours': planned_hours, 'partner_id': self.order_id.partner_id.id, 'email_from': self.order_id.partner_id.email, - 'description': self.name + '
', + 'description': description, 'project_id': project.id, 'sale_line_id': self.id, 'company_id': self.company_id.id, diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 49cc195da90..0fd318085a0 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -44,8 +44,8 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): # check task creation project = self.project_global - task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (self.sale_order.name, self.product_delivery_timesheet2.name)) - self.assertTrue(task, 'Sale Service: task is not created') + task = project.task_ids.filtered(lambda t: t.name == '%s: %s' % (self.sale_order.name, self.product_delivery_timesheet2.name)) + self.assertTrue(task, 'Sale Service: task is not created, or it badly named') self.assertEqual(task.partner_id, self.sale_order.partner_id, 'Sale Service: customer should be the same on task and on SO') self.assertEqual(task.email_from, self.sale_order.partner_id.email, 'Sale Service: Task Email should be the same as the SO customer Email') From 3a40ee00973bfb2514e833a46c604d492ebdd40d Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Wed, 28 Mar 2018 16:25:12 +0200 Subject: [PATCH 15/16] [FIX] sale_timesheet: correct uom xml id --- 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 8773d68aaa8..124c6bc0da5 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -27,7 +27,7 @@ class SaleTimesheetController(http.Controller): def _plan_prepare_values(self, projects): currency = request.env.user.company_id.currency_id - hour_rounding = request.env.ref('product.product_uom_hour').rounding + hour_rounding = request.env.ref('uom.product_uom_hour').rounding billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'billable_fixed'] values = { From d50a1e262252fc089f7804c4708f2040658258d9 Mon Sep 17 00:00:00 2001 From: jem-odoo Date: Wed, 28 Mar 2018 11:22:17 +0200 Subject: [PATCH 16/16] [IMP] project,hr_timesheet: no 'remaining hours' field in project 'remaining_hours' is defined in project as a simple float field, manually updateable, only displayed when the group "Time Estimation on Task" is activated. But for now, there is no setting to active this group. Those are legacy useless stuff, so we can remove it. The purpose is to make 'time estimation' feature only available when timesheet is installed. Thie commit also split the compute methods of 'remaining_hours' and 'progress' fields to make 'remaining_hours' updateable, though inverse method. Without splitting the compute method, the calculation of the progress was done correctly during onchange but not when saving. --- addons/hr_timesheet/models/project.py | 13 +++++++--- addons/hr_timesheet/report/project_report.py | 2 +- addons/hr_timesheet/views/project_views.xml | 7 +++-- addons/project/data/project_demo.xml | 26 ------------------- addons/project/models/project.py | 3 --- .../project/report/project_report_views.xml | 4 +-- addons/project/security/project_security.xml | 5 ---- addons/project/views/project_views.xml | 1 - addons/sale_timesheet/models/sale_order.py | 1 - 9 files changed, 16 insertions(+), 46 deletions(-) diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index cb41bf7c5bb..128f5fc7267 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -84,9 +84,9 @@ class Task(models.Model): analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", related='project_id.analytic_account_id', readonly=True) allow_timesheets = fields.Boolean("Allow timesheets", compute='_compute_allow_timesheets', help="Timesheets can be logged on this task.") - remaining_hours = fields.Float("Remaining Hours", compute='_compute_progress_hours', inverse='_inverse_remaining_hours', store=True, help="Total remaining time, can be re-estimated periodically by the assignee of the task.") + remaining_hours = fields.Float("Remaining Hours", compute='_compute_remaining_hours', inverse='_inverse_remaining_hours', help="Total remaining time, can be re-estimated periodically by the assignee of the task.") effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True, help="Computed using the sum of the task work done.") - total_hours_spent = fields.Float("Total Hours", compute='_compute_progress_hours', store=True, help="Computed as: Time Spent + Sub-tasks Hours.") + total_hours_spent = fields.Float("Total Hours", compute='_compute_total_hours_spent', store=True, help="Computed as: Time Spent + Sub-tasks Hours.") progress = fields.Float("Progress", compute='_compute_progress_hours', store=True, group_operator="avg", help="Display progress of current task.") subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', store=True, help="Sum of actually spent hours on the subtask(s)", oldname='children_hours') timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets') @@ -113,14 +113,21 @@ class Task(models.Model): else: task.progress = 0.0 + @api.depends('effective_hours', 'subtask_effective_hours', 'planned_hours') + def _compute_remaining_hours(self): + for task in self: task.remaining_hours = task.planned_hours - task.effective_hours - task.subtask_effective_hours - task.total_hours_spent = task.effective_hours + task.subtask_effective_hours @api.onchange('remaining_hours') def _inverse_remaining_hours(self): for task in self: task.planned_hours = task.remaining_hours + task.effective_hours + task.subtask_effective_hours + @api.depends('effective_hours', 'subtask_effective_hours') + def _compute_total_hours_spent(self): + for task in self: + task.total_hours_spent = task.effective_hours + task.subtask_effective_hours + @api.depends('child_ids.effective_hours', 'child_ids.subtask_effective_hours') def _compute_subtask_effective_hours(self): for task in self: diff --git a/addons/hr_timesheet/report/project_report.py b/addons/hr_timesheet/report/project_report.py index d8173daffd3..85a5e43351d 100644 --- a/addons/hr_timesheet/report/project_report.py +++ b/addons/hr_timesheet/report/project_report.py @@ -16,7 +16,7 @@ class ReportProjectTaskUser(models.Model): return super(ReportProjectTaskUser, self)._select() + """, progress as progress, t.effective_hours as hours_effective, - remaining_hours as remaining_hours, + t.planned_hours - t.effective_hours - t.subtask_effective_hours as remaining_hours, planned_hours as hours_planned""" def _group_by(self): diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index 10eac1c36fc..619878cf459 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -96,7 +96,7 @@ - + @@ -151,9 +151,8 @@
-
- +
+
diff --git a/addons/project/data/project_demo.xml b/addons/project/data/project_demo.xml index 592acc33a7d..269fee0efb8 100644 --- a/addons/project/data/project_demo.xml +++ b/addons/project/data/project_demo.xml @@ -122,7 +122,6 @@ - 0 @@ -132,7 +131,6 @@ - 0 @@ -141,7 +139,6 @@ - 0 @@ -152,7 +149,6 @@ - 0 @@ -166,7 +162,6 @@ - 1 @@ -180,7 +175,6 @@ - 1 @@ -189,7 +183,6 @@ - 1 @@ -198,7 +191,6 @@ - 1 @@ -210,7 +202,6 @@ - 1 @@ -219,7 +210,6 @@ - 1 @@ -229,7 +219,6 @@ - 1 @@ -242,7 +231,6 @@ - 1 @@ -253,7 +241,6 @@ - 1 @@ -263,7 +250,6 @@ - 1 @@ -272,7 +258,6 @@ - 1 @@ -286,7 +271,6 @@ - 1 @@ -296,7 +280,6 @@ - 1 @@ -306,7 +289,6 @@ - 1 @@ -316,7 +298,6 @@ 24.0 - 24.0 @@ -327,7 +308,6 @@ 42.0 - 42.0 @@ -336,7 +316,6 @@ 14.0 - 14.0 @@ -347,7 +326,6 @@ 12.0 - 12.0 0 @@ -358,7 +336,6 @@ 15 15.0 - 15.0 @@ -368,7 +345,6 @@ 17 8.0 - 8.0 1 @@ -381,7 +357,6 @@ 20 20.0 - 20.0 Data import + Doc @@ -391,7 +366,6 @@ 20 35.0 - 35.0 Unit Testing diff --git a/addons/project/models/project.py b/addons/project/models/project.py index cad7a8b89dc..f59675358ff 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -486,7 +486,6 @@ class Task(models.Model): notes = fields.Text(string='Notes') planned_hours = fields.Float("Planned Hours", help='It is the time planned to achieve the task. If this document has sub-tasks, it means the time needed to achieve this tasks and its childs.',track_visibility='onchange') subtask_planned_hours = fields.Float("Subtasks", compute='_compute_subtask_planned_hours', help="Computed using sum of hours planned of all subtasks created from main task. Usually these hours are less or equal to the Planned Hours (of main task).") - remaining_hours = fields.Float(string='Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task.") user_id = fields.Many2one('res.users', string='Assigned to', default=lambda self: self.env.uid, @@ -619,8 +618,6 @@ class Task(models.Model): default = {} if not default.get('name'): default['name'] = _("%s (copy)") % self.name - if 'remaining_hours' not in default: - default['remaining_hours'] = self.planned_hours return super(Task, self).copy(default) # Override view according to the company definition diff --git a/addons/project/report/project_report_views.xml b/addons/project/report/project_report_views.xml index f87e3f5c4d5..085e655815d 100644 --- a/addons/project/report/project_report_views.xml +++ b/addons/project/report/project_report_views.xml @@ -34,7 +34,7 @@ Workload report.project.task.user - {'group_by': ['project_id'], 'measures': ['planned_hours','remaining_hours']} + {'group_by': ['project_id'], 'measures': ['planned_hours']} By Responsible @@ -46,7 +46,7 @@ Cumulative Flow report.project.task.user - {'group_by': ['stage_id', 'state'], 'measures': ['nbr','planned_hours','remaining_hours']} + {'group_by': ['stage_id', 'state'], 'measures': ['nbr','planned_hours']} diff --git a/addons/project/security/project_security.xml b/addons/project/security/project_security.xml index f2f70e949f9..cfa175258f9 100644 --- a/addons/project/security/project_security.xml +++ b/addons/project/security/project_security.xml @@ -14,11 +14,6 @@ - - Time Estimation on Tasks - - - Use Subtasks diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 23282752dc2..4ec031f7aa7 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -524,7 +524,6 @@ - diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index f432ce18374..96286cca513 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -242,7 +242,6 @@ class SaleOrderLine(models.Model): return { 'name': title if project.sale_line_id else '%s: %s' % (self.order_id.name or '', title), 'planned_hours': planned_hours, - 'remaining_hours': planned_hours, 'partner_id': self.order_id.partner_id.id, 'email_from': self.order_id.partner_id.email, 'description': description,