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 1766ea8ddf9..11998476ce2 100644 --- a/addons/hr_timesheet/models/__init__.py +++ b/addons/hr_timesheet/models/__init__.py @@ -1,6 +1,8 @@ # -*- 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 from . import project 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/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..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): @@ -40,14 +41,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. @@ -58,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']) @@ -79,3 +88,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/models/project.py b/addons/hr_timesheet/models/project.py index 72a88277886..128f5fc7267 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -1,31 +1,100 @@ # -*- 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" - 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.") + 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_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') - @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): @@ -44,7 +113,19 @@ 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 + + @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') 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/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 + + + + + + + + + + + - - + + diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index fb018223922..619878cf459 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -48,6 +48,9 @@ + + + @@ -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 @@ - - - - - - - + + + + + + +
@@ -141,9 +151,8 @@
-
- +
+
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/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/__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..f59675358ff 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) @@ -494,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, @@ -627,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/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/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 eebd86b050b..4ec031f7aa7 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/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 @@ - + diff --git a/addons/sale_timesheet/__init__.py b/addons/sale_timesheet/__init__.py index 8c67f893b37..4480f53802e 100644 --- a/addons/sale_timesheet/__init__.py +++ b/addons/sale_timesheet/__init__.py @@ -2,4 +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 0cb9db63bae..ba22b0818f2 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -16,15 +16,18 @@ 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', 'views/product_views.xml', - 'views/hr_views.xml', 'views/project_task_views.xml', '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', + 'wizard/project_task_assign_so_line_views.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..124c6bc0da5 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -1,58 +1,62 @@ # -*- 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 +DEFAULT_MONTH_RANGE = 3 + 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 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,22 +64,37 @@ 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, 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: + 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( @@ -87,7 +106,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(): @@ -97,23 +116,191 @@ 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 _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 = len(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 + + 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, ... + # -------------------------------------------------- + + 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/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 + 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/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/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/project.py b/addons/sale_timesheet/models/project.py index 53e47d2f867..4d3a45876f7 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -41,21 +41,28 @@ 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): _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 @@ -66,6 +73,16 @@ 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): + 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') diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 2116722d9d0..96286cca513 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): @@ -18,7 +17,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,61 +40,56 @@ 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', 'order_line.project_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 + 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 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] + 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 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 @@ -137,7 +130,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, 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) @@ -186,11 +180,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): @@ -201,103 +199,125 @@ 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, + } + 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 - 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() + 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, - '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 + 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 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 = {} + 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.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 + + # 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. + 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': - so_line._timesheet_find_task() + 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) 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/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/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/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/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 bccf96eb630..0fd318085a0 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -37,13 +37,15 @@ 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') # 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') @@ -106,16 +108,16 @@ 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, }) - 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', - '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, @@ -124,7 +126,7 @@ class TestSaleService(TestCommonSaleTimesheetNoChart): 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 +176,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 @@ -227,3 +223,153 @@ 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.") + + 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") diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py index e48849e078c..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_project_id, "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() @@ -66,7 +68,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 +78,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 +88,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,14 +97,14 @@ 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, }) 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,16 +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)]) + 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(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.") def test_timesheet_delivery(self): """ Test timesheet invoicing with 'invoice on delivery' timetracked products @@ -159,21 +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)]) + 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, }) @@ -189,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, }) @@ -212,12 +217,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(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': sale_order.project_project_id.id, # global project + 'project_id': project_serv2.id, # global project 'unit_amount': 7, 'employee_id': self.employee_user.id, }) @@ -253,9 +258,13 @@ 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') + 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', @@ -267,11 +276,12 @@ 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, }) + 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") diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 59327067366..be2f850719f 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 @@
- + -
+
@@ -201,6 +201,56 @@
+ + +
+ +

Timesheets

+ + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
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 - - - - - - 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 - - - - 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 230999e2974..1b82269ca19 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -16,6 +16,18 @@
+ + project.project.search.inherit + project.project + + + + + + + + + sale.service.form.view.inherit project.task @@ -34,6 +46,18 @@ + + project.task.search.inherit + project.task + + + + + + + + + project.task.form.inherit.timesheet project.task @@ -47,4 +71,12 @@ + + + Overview + timesheet.plan + project.project + + +
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 + + + +