diff options
| author | jbm-odoo <jbm@odoo.com> | 2020-08-12 14:03:20 +0000 |
|---|---|---|
| committer | jbm-odoo <jbm@odoo.com> | 2020-08-19 15:52:35 +0000 |
| commit | 49f8ee9e40399af77025717650569e6b1f39a2ae (patch) | |
| tree | a37ab78e22b83205ebd207f6f32fd18103f93218 /addons | |
| parent | a98f0621169144f53f4ebc9361f3b7c6b60556c8 (diff) | |
[IMP] sale_timesheet: Billable project
Split completely 'non billable' from 'task rate' for more clarity.
Simplify the task form view of non-billable projects.
TaskID 2032048
closes odoo/odoo#55972
Related: odoo/enterprise#12441
Related: odoo/upgrade#1662
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
Diffstat (limited to 'addons')
27 files changed, 683 insertions, 257 deletions
diff --git a/addons/project/static/src/js/project_kanban.js b/addons/project/static/src/js/project_kanban.js index 8a7387e2ae2..c102fc332a1 100644 --- a/addons/project/static/src/js/project_kanban.js +++ b/addons/project/static/src/js/project_kanban.js @@ -65,4 +65,6 @@ KanbanColumn.include({ }); view_registry.add('project_kanban', ProjectKanbanView); + +return ProjectKanbanController; }); diff --git a/addons/sale_project/models/product.py b/addons/sale_project/models/product.py index 1b2fc87a7e9..e4f6318684b 100644 --- a/addons/sale_project/models/product.py +++ b/addons/sale_project/models/product.py @@ -20,10 +20,10 @@ class ProductTemplate(models.Model): creating a new project based on the selected template.") project_id = fields.Many2one( 'project.project', 'Project', company_dependent=True, - help='Select a non billable project on which tasks can be created. This setting must be set for each company.') + help='Select a 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, 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.') + help='Select a billable project to be the skeleton of the new created project when selling the current product. Its stages and tasks will be duplicated.') @api.constrains('project_id', 'project_template_id') def _check_project_and_template(self): diff --git a/addons/sale_project/models/sale_order.py b/addons/sale_project/models/sale_order.py index b9bb84afa90..ffeeae5ffa4 100644 --- a/addons/sale_project/models/sale_order.py +++ b/addons/sale_project/models/sale_order.py @@ -66,7 +66,7 @@ class SaleOrder(models.Model): if len(task_projects) == 1 and len(self.tasks_ids) > 1: # redirect to task of the project (with kanban stage, ...) action = self.with_context(active_id=task_projects.id).env.ref( 'project.act_project_project_2_project_task_all').read()[0] - action['domain'] = [('project_id', '=', task_projects.id)] + action['domain'] = [('id', 'in', self.tasks_ids.ids)] if action.get('context'): eval_context = self.env['ir.actions.actions']._get_eval_context() eval_context.update({'active_id': task_projects.id}) diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 55ca50305e5..eb640411bdb 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -33,6 +33,9 @@ have real delivered quantities in sales orders. 'wizard/sale_make_invoice_advance_views.xml', 'wizard/project_task_create_sale_order_views.xml' ], + 'qweb': [ + 'static/src/xml/sale_project_templates.xml', + ], 'demo': [ 'data/sale_service_demo.xml', ], diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 2c5ca7f1495..eb575a7941e 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -18,12 +18,16 @@ class AccountAnalyticLine(models.Model): ('billable_time', 'Billed on Timesheets'), ('billable_fixed', 'Billed at a Fixed price'), ('non_billable', 'Non Billable Tasks'), + ('non_billable_timesheet', 'Non Billable Timesheet'), ('non_billable_project', 'No task found')], string="Billable Type", compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True) timesheet_invoice_id = fields.Many2one('account.move', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet") + non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheet will not be billed.") - @api.depends('so_line.product_id', 'project_id', 'task_id') + @api.depends('so_line.product_id', 'project_id', 'task_id', 'non_allow_billable') def _compute_timesheet_invoice_type(self): - for timesheet in self: + non_allowed_billable = self.filtered('non_allow_billable') + non_allowed_billable.timesheet_invoice_type = 'non_billable_timesheet' + for timesheet in self - non_allowed_billable: if timesheet.project_id: # AAL will be set to False invoice_type = 'non_billable_project' if not timesheet.task_id else 'non_billable' if timesheet.task_id and timesheet.so_line.product_id.type == 'service': @@ -40,10 +44,10 @@ class AccountAnalyticLine(models.Model): @api.onchange('employee_id') def _onchange_task_id_employee_id(self): - if self.project_id: # timesheet only - if self.task_id.billable_type == 'task_rate': + if self.project_id and self.task_id.allow_billable: # timesheet only + if self.task_id.bill_type == 'customer_task' or self.task_id.pricing_type == 'fixed_rate': self.so_line = self.task_id.sale_line_id - elif self.task_id.billable_type == 'employee_rate': + elif self.task_id.pricing_type == 'employee_rate': self.so_line = self._timesheet_determine_sale_line(self.task_id, self.employee_id, self.project_id) else: self.so_line = False @@ -100,24 +104,22 @@ class AccountAnalyticLine(models.Model): 1/ timesheet on task rate: the so line will be the one from the task 2/ timesheet on employee rate task: find the SO line in the map of the project (even for subtask), or fallback on the SO line of the task, or fallback on the one on the project - NOTE: this have to be consistent with `_compute_billable_type` on project.task. """ - if not project.allow_billable and project.sale_line_id and not task: - if project.billable_type == 'employee_rate': + if project.sale_line_id and not task: + if project.bill_type == 'customer_project' and project.pricing_type == 'employee_rate': map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', project.id), ('employee_id', '=', employee.id)]) if map_entry: return map_entry.sale_line_id return project.sale_line_id - elif task.billable_type != 'no': - if task.billable_type == 'employee_rate': + if task.allow_billable: + if task.bill_type == 'customer_task': + return task.sale_line_id + if task.pricing_type == 'fixed_rate': + return task.sale_line_id + elif task.pricing_type == 'employee_rate': map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', task.project_id.id), ('employee_id', '=', employee.id)]) if map_entry: return map_entry.sale_line_id - if task.sale_line_id: - return task.sale_line_id - return task.project_id.sale_line_id - elif task.billable_type == 'task_rate': - return task.sale_line_id return self.env['sale.order.line'] def _timesheet_get_portal_domain(self): diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index 774e54dbd02..1563ccb209d 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -19,14 +19,13 @@ class ProductTemplate(models.Model): ('timesheet', 'Timesheets on project (one fare per SO/Project)'), ], ondelete={'timesheet': 'set default'}) # override domain - project_id = fields.Many2one(domain="[('billable_type', '=', 'no'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]") - project_template_id = fields.Many2one(domain="[('billable_type', '=', 'no'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]") + project_id = fields.Many2one(domain="[('allow_billable', '=', True), ('bill_type', '=', 'customer_task'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]") + project_template_id = fields.Many2one(domain="[('allow_billable', '=', True), ('bill_type', '=', 'customer_project'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]") def _default_visible_expense_policy(self): visibility = self.user_has_groups('project.group_project_user') return visibility or super(ProductTemplate, self)._default_visible_expense_policy() - def _compute_visible_expense_policy(self): super(ProductTemplate, self)._compute_visible_expense_policy() diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 5c66df9526f..36098ebd814 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from collections import defaultdict + from odoo import api, fields, models, _ from odoo.exceptions import ValidationError @@ -22,19 +24,20 @@ class Project(models.Model): def _default_timesheet_product_id(self): return self.env.ref('sale_timesheet.time_product', False) - billable_type = fields.Selection([ - ('task_rate', 'At Task Rate'), - ('employee_rate', 'At Employee Rate'), - ('no', 'No Billable') - ], string="Billable Type", compute='_compute_billable_type', compute_sudo=True, store=True, - help='At which rate timesheets will be billed:\n' - ' - At task rate: each time spend on a task is billed at task rate.\n' - ' - At employee rate: each employee log time billed at his rate.\n' - ' - No Billable: track time without invoicing it') + bill_type = fields.Selection([ + ('customer_task', 'Invoice tasks separately to different customers'), + ('customer_project', 'Invoice all tasks to a single customer') + ], string="Customer Type", default="customer_task", + help='When billing tasks individually, a Sales Order will be created from each task. It is perfect if you would like to bill different services to different customers at different rates. \n When billing the whole project, a Sales Order will be created from the project instead. This option is better if you would like to bill all the tasks of a given project to a specific customer either at a fixed rate, or at an employee rate.') + pricing_type = fields.Selection([ + ('fixed_rate', 'Fixed rate'), + ('employee_rate', 'Employee rate') + ], string="Pricing", default="fixed_rate", + help='The fixed rate is perfect if you bill a service at a fixed rate per hour or day worked regardless of the employee who performed it. The employee rate is preferable if your employees deliver the same service at a different rate. For instance, junior and senior consultants would deliver the same service (= consultancy), but at a different rate because of their level of seniority.') sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False, help="Employee/Sale Order Item Mapping:\n Defines to which sales order item an employee's timesheet entry will be linked." "By extension, it defines the rate at which an employee's time on the project is billed.") - allow_billable = fields.Boolean("Bill from Tasks", help="Invoice your time and material from tasks.") + allow_billable = fields.Boolean("Billable", help="Invoice your time and material from tasks.") display_create_order = fields.Boolean(compute='_compute_display_create_order') timesheet_product_id = fields.Many2one( 'product.product', string='Timesheet Product', @@ -45,6 +48,7 @@ class Project(models.Model): '|', ('company_id', '=', False), ('company_id', '=', company_id)]""", help='Select a Service product with which you would like to bill your time spent on tasks.', default=_default_timesheet_product_id) + warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate') _sql_constraints = [ ('timesheet_product_required_if_billable_and_timesheets', """ @@ -58,34 +62,14 @@ class Project(models.Model): ] - @api.depends('billable_type', 'allow_billable', 'sale_order_id', 'partner_id') + @api.depends('allow_billable', 'sale_order_id', 'partner_id', 'bill_type') def _compute_display_create_order(self): for project in self: show = True - if not project.partner_id or project.billable_type != 'no' or project.allow_billable or project.sale_order_id: + if not project.partner_id or project.bill_type != 'customer_project' or not project.allow_billable or project.sale_order_id: show = False project.display_create_order = show - @api.depends('sale_order_id.create_date', 'sale_line_id', 'sale_line_employee_ids') - def _compute_billable_type(self): - for project in self: - billable_type = 'no' - if project.sale_order_id: - if project.sale_line_employee_ids: - billable_type = 'employee_rate' - else: - billable_type = 'task_rate' - project.billable_type = billable_type - - @api.onchange('sale_line_employee_ids', 'billable_type') - def _onchange_sale_line_employee_ids(self): - if self.billable_type == 'task_rate': - if self.sale_line_employee_ids: - self.billable_type = 'employee_rate' - else: - if self.billable_type == 'no': - self.sale_line_employee_ids = False - @api.depends('allow_timesheets', 'allow_billable') def _compute_timesheet_product_id(self): default_product = self.env.ref('sale_timesheet.time_product', False) @@ -95,15 +79,45 @@ class Project(models.Model): elif not project.timesheet_product_id: project.timesheet_product_id = default_product - @api.constrains('sale_line_id', 'billable_type') + @api.depends('pricing_type', 'allow_timesheets', 'allow_billable', 'sale_line_employee_ids', 'sale_line_employee_ids.employee_id', 'bill_type') + def _compute_warning_employee_rate(self): + projects = self.filtered(lambda p: p.allow_billable and p.allow_timesheets and p.bill_type == 'customer_project' and p.pricing_type == 'employee_rate') + employees = self.env['account.analytic.line'].read_group([('project_id', 'in', projects.ids)], ['employee_id', 'project_id'], ['employee_id', 'project_id'], lazy=False) + dict_project_employee = defaultdict(list) + for line in employees: + dict_project_employee[line['project_id'][0]] += [line['employee_id'][0]] + for project in projects: + project.warning_employee_rate = any(x not in project.sale_line_employee_ids.employee_id.ids for x in dict_project_employee[project.id]) + + (self - projects).warning_employee_rate = False + + + @api.constrains('sale_line_id', 'pricing_type') def _check_sale_line_type(self): for project in self: - if project.billable_type == 'task_rate': + if project.pricing_type == 'fixed_rate': if project.sale_line_id and not project.sale_line_id.is_service: raise ValidationError(_("A billable project should be linked to a Sales Order Item having a Service product.")) if project.sale_line_id and project.sale_line_id.is_expense: raise ValidationError(_("A billable project should be linked to a Sales Order Item that does not come from an expense or a vendor bill.")) + @api.onchange('allow_billable') + def _onchange_allow_billable(self): + if self.task_ids._get_timesheet() and self.allow_timesheets and not self.allow_billable: + message = _("All timesheet hours that are not yet invoiced will be removed from Sales Order on save. Discard to avoid the change.") + return {'warning': { + 'title': _("Warning"), + 'message': message + }} + + def write(self, values): + res = super(Project, self).write(values) + if 'allow_billable' in values and not values.get('allow_billable'): + self.task_ids._get_timesheet().write({ + 'so_line': False, + }) + return res + def action_view_timesheet(self): self.ensure_one() if self.allow_timesheets: @@ -161,19 +175,34 @@ class Project(models.Model): class ProjectTask(models.Model): _inherit = "project.task" + @api.model + def default_get(self, fields): + result = super(ProjectTask, self).default_get(fields) + + if not result.get('timesheet_product_id', False) and 'project_id' in result: + project = self.env['project.project'].browse(result['project_id']) + if project.bill_type != 'customer_project' or project.pricing_type != 'employee_rate': + result['timesheet_product_id'] = project.timesheet_product_id.id + return result + # override sale_order_id and make it computed stored field instead of regular field. sale_order_id = fields.Many2one(compute='_compute_sale_order_id', store=True, readonly=False, domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]") analytic_account_id = fields.Many2one('account.analytic.account', related='sale_order_id.analytic_account_id') - billable_type = fields.Selection([ - ('task_rate', 'At Task Rate'), - ('employee_rate', 'At Employee Rate'), - ('no', 'No Billable') - ], string="Billable Type", compute='_compute_billable_type', compute_sudo=True, store=True) + bill_type = fields.Selection(related="project_id.bill_type") + pricing_type = fields.Selection(related="project_id.pricing_type") is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty') has_multi_sol = fields.Boolean(compute='_compute_has_multi_sol', compute_sudo=True) allow_billable = fields.Boolean(related="project_id.allow_billable") display_create_order = fields.Boolean(compute='_compute_display_create_order') + timesheet_product_id = fields.Many2one( + 'product.product', string='Service', + domain="""[ + ('type', '=', 'service'), + ('invoice_policy', '=', 'delivery'), + ('service_type', '=', 'timesheet'), + '|', ('company_id', '=', False), ('company_id', '=', company_id)]""", + help='Select a Service product with which you would like to bill your time spent on this task.') @api.depends( 'allow_billable', 'allow_timesheets', 'sale_order_id') @@ -181,8 +210,8 @@ class ProjectTask(models.Model): for task in self: show = True if not task.allow_billable or not task.allow_timesheets or \ - task.billable_type == 'employee_rate' or not task.partner_id or \ - task.sale_order_id: + (task.bill_type != 'customer_task' and not task.timesheet_product_id) or not task.partner_id or \ + task.sale_order_id or (task.bill_type != 'customer_task' and task.pricing_type != 'employee_rate'): show = False task.display_create_order = show @@ -218,26 +247,16 @@ class ProjectTask(models.Model): for task in self: task.analytic_account_active = task.analytic_account_active or task.analytic_account_id.active - @api.depends('sale_line_id', 'project_id', 'billable_type') + @api.depends('sale_line_id', 'project_id', 'allow_billable', 'bill_type') def _compute_sale_order_id(self): for task in self: - if task.billable_type == 'task_rate': - task.sale_order_id = task.sale_line_id.sudo().order_id or task.project_id.sale_order_id - elif task.billable_type == 'employee_rate': + if task.allow_billable and task.bill_type == 'customer_project': task.sale_order_id = task.project_id.sale_order_id - elif task.billable_type == 'no': + elif task.allow_billable and task.bill_type == 'customer_task': + task.sale_order_id = task.sale_line_id.sudo().order_id + elif not task.sale_order_id: task.sale_order_id = False - @api.depends('project_id.billable_type', 'sale_line_id') - def _compute_billable_type(self): - for task in self: - billable_type = 'no' - if task.project_id.billable_type == 'employee_rate': - billable_type = task.project_id.billable_type - elif (task.project_id.billable_type in ['task_rate', 'no'] and task.sale_line_id): # create a task in global project (non billable) - billable_type = 'task_rate' - task.billable_type = billable_type - @api.depends('project_id.sale_line_employee_ids') def _compute_is_project_map_empty(self): for task in self: @@ -250,31 +269,25 @@ class ProjectTask(models.Model): @api.onchange('project_id') def _onchange_project(self): - if self.project_id: - if self.project_id.billable_type == 'employee_rate': - if not self.partner_id: - self.partner_id = self.project_id.sale_order_id.partner_id - elif self.project_id.billable_type == 'task_rate': - if not self.sale_line_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 - # set domain on SO: on non billable project, all SOL of customer, otherwise the one from the SO + if self.project_id and self.project_id.bill_type == 'customer_project': + if not self.partner_id: + self.partner_id = self.project_id.partner_id + if not self.sale_line_id: + self.sale_line_id = self.project_id.sale_line_id def write(self, values): - old_sale_line_id = dict([(t.id, t.sale_line_id.id) for t in self]) res = super(ProjectTask, self).write(values) # Done after super to avoid constraints on field recomputation if values.get('project_id'): project_dest = self.env['project.project'].browse(values['project_id']) - if project_dest.billable_type == 'employee_rate': + if project_dest.bill_type == 'customer_project' and project_dest.pricing_type == 'employee_rate': self.write({'sale_line_id': False}) if 'sale_line_id' in values and self.filtered('allow_timesheets').sudo().timesheet_ids: so = self.env['sale.order.line'].browse(values['sale_line_id']).order_id if so and not so.analytic_account_id: so.analytic_account_id = self.project_id.analytic_account_id timesheet_ids = self.filtered('allow_timesheets').timesheet_ids.filtered( - lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') and t.so_line.id == old_sale_line_id[t.task_id.id] + lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') ) timesheet_ids.write({'so_line': values['sale_line_id']}) if 'project_id' in values: @@ -298,7 +311,7 @@ class ProjectTask(models.Model): 'active_id': self.id, 'active_model': 'project.task', 'form_view_initial_mode': 'edit', - 'default_product_id': self.project_id.timesheet_product_id.id, + 'default_product_id': self.timesheet_product_id.id or self.project_id.timesheet_product_id.id, }, } diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py index dcaba19bc5d..c55fa64e9e2 100644 --- a/addons/sale_timesheet/models/project_overview.py +++ b/addons/sale_timesheet/models/project_overview.py @@ -33,7 +33,7 @@ class Project(models.Model): company_uom = self.env.company.timesheet_encode_uom_id is_uom_day = company_uom == self.env.ref('uom.product_uom_day') hour_rounding = uom_hour.rounding - billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'billable_fixed'] + billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'non_billable_timesheet', 'billable_fixed'] values = { 'projects': self, @@ -140,6 +140,7 @@ class Project(models.Model): non_billable_project=0.0, non_billable=0.0, billable_time=0.0, + non_billable_timesheet=0.0, billable_fixed=0.0, canceled=0.0, total=0.0, @@ -152,6 +153,7 @@ class Project(models.Model): non_billable_project=0.0, non_billable=0.0, billable_time=0.0, + non_billable_timesheet=0.0, billable_fixed=0.0, canceled=0.0, total=0.0, @@ -162,7 +164,7 @@ class Project(models.Model): repartition_employee[employee_id]['total'] = sum([vals[inv_type] for inv_type in [*billable_types, 'canceled']]) if is_uom_day: # convert all times from hours to days - for time_type in ['non_billable_project', 'non_billable', 'billable_time', 'billable_fixed', 'canceled', 'total']: + for time_type in ['non_billable_project', 'non_billable', 'billable_time', 'non_billable_timesheet', 'billable_fixed', 'canceled', 'total']: if repartition_employee[employee_id][time_type]: repartition_employee[employee_id][time_type] = round(uom_hour._compute_quantity(repartition_employee[employee_id][time_type], company_uom, raise_if_failure=False), 2) hours_per_employee = [repartition_employee[employee_id]['total'] for employee_id in repartition_employee] @@ -388,7 +390,7 @@ class Project(models.Model): task_order_line_ids = [ol['sale_line_id'][0] for ol in task_order_line_ids] if self.env.user.has_group('sales_team.group_sale_salesman'): - if not self.sale_line_id and not task_order_line_ids: + if self.bill_type == 'customer_project' and self.allow_billable and not self.sale_order_id: actions.append({ 'label': _("Create a Sales Order"), 'type': 'action', @@ -439,7 +441,8 @@ class Project(models.Model): # if only one project, add it in the context as default value tasks_domain = [('project_id', 'in', self.ids)] - tasks_context = self.env.context + tasks_context = self.env.context.copy() + tasks_context.pop('search_default_name', False) late_tasks_domain = [('project_id', 'in', self.ids), ('date_deadline', '<', fields.Date.to_string(fields.Date.today())), ('date_end', '=', False)] overtime_tasks_domain = [('project_id', 'in', self.ids), ('overtime', '>', 0), ('planned_hours', '>', 0)] diff --git a/addons/sale_timesheet/models/project_sale_line_employee_map.py b/addons/sale_timesheet/models/project_sale_line_employee_map.py index a1988db6c8c..68d940bc62e 100644 --- a/addons/sale_timesheet/models/project_sale_line_employee_map.py +++ b/addons/sale_timesheet/models/project_sale_line_employee_map.py @@ -8,18 +8,51 @@ class ProjectProductEmployeeMap(models.Model): _name = 'project.sale.line.employee.map' _description = 'Project Sales line, employee mapping' - @api.model - def _default_project_id(self): - if self._context.get('active_id'): - return self._context['active_id'] - return False - - project_id = fields.Many2one('project.project', "Project", domain=[('billable_type', '!=', 'no')], required=True, default=_default_project_id) + project_id = fields.Many2one('project.project', "Project", required=True) employee_id = fields.Many2one('hr.employee', "Employee", required=True) - sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", domain=[('is_service', '=', True)], required=True) - price_unit = fields.Float(related='sale_line_id.price_unit', readonly=True) - currency_id = fields.Many2one(related="sale_line_id.currency_id") + sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", domain=[('is_service', '=', True)]) + company_id = fields.Many2one('res.company', string='Company', related='project_id.company_id') + timesheet_product_id = fields.Many2one( + 'product.product', string='Service', + domain="""[ + ('type', '=', 'service'), + ('invoice_policy', '=', 'delivery'), + ('service_type', '=', 'timesheet'), + '|', ('company_id', '=', False), ('company_id', '=', company_id)]""") + price_unit = fields.Float("Unit Price", compute='_compute_price_unit', store=True, readonly=False) + currency_id = fields.Many2one('res.currency', string="Currency", compute='_compute_price_unit', store=True, readonly=False) _sql_constraints = [ ('uniqueness_employee', 'UNIQUE(project_id,employee_id)', 'An employee cannot be selected more than once in the mapping. Please remove duplicate(s) and try again.'), ] + + @api.depends('sale_line_id', 'sale_line_id.price_unit', 'timesheet_product_id') + def _compute_price_unit(self): + for line in self: + if line.sale_line_id: + line.price_unit = line.sale_line_id.price_unit + line.currency_id = line.sale_line_id.currency_id + elif line.timesheet_product_id: + line.price_unit = line.timesheet_product_id.lst_price + line.currency_id = line.timesheet_product_id.currency_id + else: + line.price_unit = 0 + line.currency_id = False + + @api.onchange('timesheet_product_id') + def _onchange_timesheet_product_id(self): + if self.timesheet_product_id: + self.price_unit = self.timesheet_product_id.lst_price + else: + self.price_unit = 0.0 + + @api.model + def create(self, values): + res = super(ProjectProductEmployeeMap, self).create(values) + for project_id in res.filtered(lambda l: l.sale_line_id).project_id: + if project_id.allow_timesheets and project_id.allow_billable and project_id.task_ids._get_timesheet(): + timesheet_ids = project_id.task_ids._get_timesheet() + for employee_id in res.filtered(lambda l: l.project_id == project_id).employee_id: + sale_line_id = res.filtered(lambda l: l.project_id == project_id and l.employee_id == employee_id).sale_line_id + timesheet_ids.filtered(lambda t: t.employee_id == employee_id).so_line = sale_line_id + return res diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index f4b47ebc552..e6d13265f36 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -12,7 +12,7 @@ class SaleOrder(models.Model): timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids', groups="hr_timesheet.group_hr_timesheet_user") # override domain - project_id = fields.Many2one(domain="[('billable_type', 'in', ('no', 'task_rate')), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]") + project_id = fields.Many2one(domain="['|', ('bill_type', '=', 'customer_task'), ('pricing_type', '=', 'fixed_rate'), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]") timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id') timesheet_total_duration = fields.Integer("Timesheet Total Duration", compute='_compute_timesheet_total_duration', help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit") @@ -33,7 +33,7 @@ class SaleOrder(models.Model): for sale_order in self: timesheets = sale_order.timesheet_ids if self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') else sale_order.timesheet_ids.filtered(lambda t: t.user_id.id == self.env.uid) total_time = 0.0 - for timesheet in timesheets: + for timesheet in timesheets.filtered(lambda t: not t.non_allow_billable): # Timesheets may be stored in a different unit of measure, so first we convert all of them to the reference unit total_time += timesheet.unit_amount * timesheet.product_uom_id.factor_inv # Now convert to the proper unit of measure @@ -53,7 +53,9 @@ class SaleOrder(models.Model): def action_view_timesheet(self): self.ensure_one() action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order") - action['context'] = {} # erase default filters + action['context'] = { + 'search_default_billable_timesheet': True + } # erase default filters if self.timesheet_count > 0: action['domain'] = [('so_line', 'in', self.order_line.ids)] else: @@ -85,7 +87,7 @@ class SaleOrderLine(models.Model): if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet': line.qty_delivered_method = 'timesheet' - @api.depends('analytic_line_ids.project_id') + @api.depends('analytic_line_ids.project_id', 'analytic_line_ids.non_allow_billable', 'project_id.pricing_type', 'project_id.bill_type') def _compute_qty_delivered(self): super(SaleOrderLine, self)._compute_qty_delivered() @@ -97,7 +99,7 @@ class SaleOrderLine(models.Model): def _timesheet_compute_delivered_quantity_domain(self): """ Hook for validated timesheet in addionnal module """ - return [('project_id', '!=', False)] + return [('project_id', '!=', False), ('non_allow_billable', '=', False)] ########################################### # Service : Project and task generation @@ -116,6 +118,14 @@ class SaleOrderLine(models.Model): project.write({'allow_timesheets': True}) return project + def _timesheet_create_project_prepare_values(self): + """Generate project values""" + values = super()._timesheet_create_project_prepare_values() + values['allow_billable'] = True + values['bill_type'] = 'customer_project' + values['pricing_type'] = 'fixed_rate' + return values + def _recompute_qty_to_invoice(self, start_date, end_date): """ Recompute the qty_to_invoice field for product containing timesheets diff --git a/addons/sale_timesheet/security/ir.model.access.csv b/addons/sale_timesheet/security/ir.model.access.csv index 74e49f82654..a391d6610e4 100644 --- a/addons/sale_timesheet/security/ir.model.access.csv +++ b/addons/sale_timesheet/security/ir.model.access.csv @@ -3,6 +3,6 @@ access_project_profitability_report_analysis_manager,project.profitability.repor access_project_sale_line_employee_map,access_project_sale_line_employee_map,model_project_sale_line_employee_map,base.group_user,1,0,0,0 access_project_sale_line_employee_map_manager,access_project_sale_line_employee_map_project_manager,model_project_sale_line_employee_map,project.group_project_manager,1,1,1,1 access_project_create_sale_order,access.project.create.sale.order,model_project_create_sale_order,sales_team.group_sale_salesman,1,1,1,0 -access_project_create_sale_order_line,access.project.create.sale.order.line,model_project_create_sale_order_line,sales_team.group_sale_salesman,1,1,1,0 +access_project_create_sale_order_line,access.project.create.sale.order.line,model_project_create_sale_order_line,sales_team.group_sale_salesman,1,1,1,1 access_project_create_invoice,access.project.create.invoice,model_project_create_invoice,sales_team.group_sale_salesman_all_leads,1,1,1,0 access_project_task_create_sale_order,access.project.task.create.sale.order,model_project_task_create_sale_order,sales_team.group_sale_salesman,1,1,1,0 diff --git a/addons/sale_timesheet/static/src/js/sale_project_kanban_controller.js b/addons/sale_timesheet/static/src/js/sale_project_kanban_controller.js new file mode 100644 index 00000000000..17181808487 --- /dev/null +++ b/addons/sale_timesheet/static/src/js/sale_project_kanban_controller.js @@ -0,0 +1,111 @@ +odoo.define('sale_timesheet.sale_project_kanban_controller', function (require) { +"use strict"; + +var core = require('web.core'); +var ProjectKanbanController = require('project.project_kanban'); +var session = require('web.session'); + +var QWeb = core.qweb; + +var SaleProjectKanbanController = ProjectKanbanController.include({ + events: _.extend({}, ProjectKanbanController.prototype.events, { + 'click .o_create_sale_order': '_onCreateSaleOrder' + }), + + /** + * @override + */ + willStart: async function () { + const _super = this._super.bind(this); + await this._showCreateSOButton(); + return _super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _showCreateSOButton: async function () { + var self = this; + this.activeProjectIds = this.initialState.context.active_ids; + + if (!this.activeProjectIds || this.activeProjectIds.length !== 1) { + this.showCreateSaleOrder = false; + return; + } + var canCreateSO = await session.user_has_group('sales_team.group_sale_salesman'); + if (canCreateSO) { + await this._rpc({ + model: 'project.project', + method: 'search_count', + args: [[ + ["id", "in", this.activeProjectIds], + ["bill_type", "=", "customer_project"], + ["sale_order_id", "=", false], + ["allow_billable", "=", true], + ["allow_timesheets", "=", true], + ]], + }).then(function (projectCount) { + self.showCreateSaleOrder = projectCount !== 0; + }); + } else { + this.showCreateSaleOrder = false; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @param {jQuery} [$node] + */ + renderButtons: function ($node) { + this._super.apply(this, arguments); + this.$saleButtons = $(QWeb.render('SaleProjectKanbanView.buttons')); + var state = this.model.get(this.handle, {raw: true}); + var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn || !this.showCreateSaleOrder; + this.$saleButtons.toggleClass('o_hidden', createHidden); + this.$saleButtons.appendTo(this.$buttons); + + }, + + /** + * @override + */ + updateButtons: async function () { + if (!this.$buttons) { + return; + } + this._super.apply(this, arguments); + + await this._showCreateSOButton(); + var state = this.model.get(this.handle, {raw: true}); + var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn || !this.showCreateSaleOrder; + this.$buttons.find('.o_create_sale_order').toggleClass('o_hidden', createHidden); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onCreateSaleOrder: function (ev) { + ev.preventDefault(); + this.do_action('sale_timesheet.project_project_action_multi_create_sale_order', { + additional_context: { + 'active_id': this.activeProjectIds && this.activeProjectIds[0], + 'active_model': "project.project", + }, + on_close: async () => await this.reload() + }); + }, +}); + +return SaleProjectKanbanController; + +}); diff --git a/addons/sale_timesheet/static/src/scss/sale_timesheet.scss b/addons/sale_timesheet/static/src/scss/sale_timesheet.scss index 5d2bbc4b09d..c23f7459eaa 100644 --- a/addons/sale_timesheet/static/src/scss/sale_timesheet.scss +++ b/addons/sale_timesheet/static/src/scss/sale_timesheet.scss @@ -75,6 +75,11 @@ $canceled-color: gray; color: white; cursor: pointer; } + .o_progress_non_billable_timesheet { + background-color: rgb(11, 194, 1); + color: white; + cursor: pointer; + } .o_progress_non_billable { background-color: purple; color: white; diff --git a/addons/sale_timesheet/static/src/xml/sale_project_templates.xml b/addons/sale_timesheet/static/src/xml/sale_project_templates.xml new file mode 100644 index 00000000000..0801ecf264b --- /dev/null +++ b/addons/sale_timesheet/static/src/xml/sale_project_templates.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates xml:space="preserve"> + + <button t-name="SaleProjectKanbanView.buttons" type="button" class="btn btn-secondary o_create_sale_order"> + Create Sales Order + </button> + +</templates> diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py index 7bc60560a9b..ee26b46396c 100644 --- a/addons/sale_timesheet/tests/common.py +++ b/addons/sale_timesheet/tests/common.py @@ -70,6 +70,7 @@ class TestCommonSaleTimesheet(TestSaleCommon): 'name': 'Project for selling timesheets', 'allow_timesheets': True, 'analytic_account_id': cls.analytic_account_sale.id, + 'allow_billable': True, }) cls.project_template = cls.env['project.project'].create({ 'name': 'Project TEMPLATE for services', diff --git a/addons/sale_timesheet/tests/test_project_billing.py b/addons/sale_timesheet/tests/test_project_billing.py index eaa9aa91fe7..9570595ca30 100644 --- a/addons/sale_timesheet/tests/test_project_billing.py +++ b/addons/sale_timesheet/tests/test_project_billing.py @@ -80,13 +80,13 @@ class TestProjectBilling(TestCommonSaleTimesheet): cls.project_subtask = Project.create({ 'name': "Sub Task Project (non billable)", 'allow_timesheets': True, - 'billable_type': 'no', + 'allow_billable': False, 'partner_id': False, }) cls.project_non_billable = Project.create({ 'name': "Non Billable Project", 'allow_timesheets': True, - 'billable_type': 'no', + 'allow_billable': False, 'partner_id': False, 'subtask_project_id': cls.project_subtask.id, }) @@ -96,7 +96,9 @@ class TestProjectBilling(TestCommonSaleTimesheet): cls.project_employee_rate = Project.create({ 'name': "Project billed at Employee Rate", 'allow_timesheets': True, - 'billable_type': 'employee_rate', + 'allow_billable': True, + 'bill_type': 'customer_project', + 'pricing_type': 'employee_rate', 'sale_order_id': cls.sale_order_1.id, 'partner_id': cls.sale_order_1.partner_id.id, 'subtask_project_id': cls.project_subtask.id, @@ -113,7 +115,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) def test_make_billable_at_task_rate(self): - """ Starting from a non billable project, make it billable at task rate, using the wizard """ + """ Starting from a non billable project, make it billable at task rate """ Timesheet = self.env['account.analytic.line'] Task = self.env['project.task'] # set a customer on the project @@ -141,31 +143,35 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'employee_id': self.employee_tde.id, }) - # create wizard - wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({ - 'product_id': self.product_delivery_timesheet3.id, # product creates new T in new P - 'price_unit': self.product_delivery_timesheet3.list_price, - 'billable_type': 'project_rate', + # Change project to billable at task rate + self.project_non_billable.write({ + 'allow_billable': True, + 'bill_type': 'customer_project', + 'pricing_type': 'fixed_rate', }) + task.timesheet_product_id = self.product_delivery_timesheet3 + + # create wizard + wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({}) - self.assertEqual(self.project_non_billable.billable_type, 'no', "The project should still be non billable") self.assertEqual(wizard.partner_id, self.project_non_billable.partner_id, "The wizard should have the same partner as the project") + self.assertEqual(len(wizard.line_ids), 1, "The wizard should have one line") + self.assertEqual(wizard.line_ids.product_id, self.product_delivery_timesheet3, "The wizard should have one line with right product") # create the SO from the project action = wizard.action_create_sale_order() sale_order = self.env['sale.order'].browse(action['res_id']) - self.assertEqual(self.project_non_billable.billable_type, 'task_rate', "The project should be 'task rate' billable") self.assertEqual(sale_order.partner_id, self.project_non_billable.partner_id, "The customer of the SO should be the same as the project") self.assertEqual(len(sale_order.order_line), 1, "The SO should have 1 line") - self.assertEqual(sale_order.order_line.product_id, wizard.product_id, "The product of the only SOL should be the selected on the wizard") + self.assertEqual(sale_order.order_line.product_id, wizard.line_ids.product_id, "The product of the only SOL should be the selected on the wizard") self.assertEqual(sale_order.order_line.project_id, self.project_non_billable, "SOL should be linked to the project") self.assertTrue(sale_order.order_line.task_id, "The SOL creates a task as they were no task already present in the project (system limitation)") self.assertEqual(sale_order.order_line.task_id.project_id, self.project_non_billable, "The created task should be in the project") self.assertEqual(sale_order.order_line.qty_delivered, timesheet1.unit_amount + timesheet2.unit_amount, "The create SOL should have an delivered quantity equals to the sum of tasks'timesheets") def test_make_billable_at_employee_rate(self): - """ Starting from a non billable project, make it billable at employee rate, using the wizard """ + """ Starting from a non billable project, make it billable at employee rate """ Timesheet = self.env['account.analytic.line'] Task = self.env['project.task'] # set a customer on the project @@ -193,9 +199,15 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'employee_id': self.employee_user.id, }) + # Change project to billable at employee rate + self.project_non_billable.write({ + 'allow_billable': True, + 'bill_type': 'customer_project', + 'pricing_type': 'employee_rate', + }) + # create wizard wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({ - 'billable_type': 'employee_rate', 'partner_id': self.partner_2.id, 'line_ids': [ (0, 0, {'product_id': self.product_delivery_timesheet1.id, 'price_unit': 15, 'employee_id': self.employee_tde.id}), # product creates no T @@ -204,7 +216,6 @@ class TestProjectBilling(TestCommonSaleTimesheet): ] }) - self.assertEqual(self.project_non_billable.billable_type, 'no', "The project should still be non billable") self.assertEqual(wizard.partner_id, self.project_non_billable.partner_id, "The wizard should have the same partner as the project") self.assertEqual(wizard.project_id, self.project_non_billable, "The wizard'project should be the non billable project") @@ -212,7 +223,6 @@ class TestProjectBilling(TestCommonSaleTimesheet): action = wizard.action_create_sale_order() sale_order = self.env['sale.order'].browse(action['res_id']) - self.assertEqual(self.project_non_billable.billable_type, 'employee_rate', "The project should be 'employee rate' billable") self.assertEqual(sale_order.partner_id, self.project_non_billable.partner_id, "The customer of the SO should be the same as the project") self.assertEqual(len(sale_order.order_line), 2, "The SO should have 2 lines, as in wizard map there were 2 time the same product with the same price (for 2 different employees)") self.assertEqual(len(self.project_non_billable.sale_line_employee_ids), 3, "The project have 3 lines in its map") @@ -249,7 +259,9 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'partner_id': self.partner_a.id, }) - self.assertEqual(task.billable_type, 'employee_rate', "Task in project 'employee rate' should be billed at employee rate") + self.assertTrue(task.allow_billable, "Task in project 'employee rate' should be billable") + self.assertEqual(task.bill_type, 'customer_project', "Task in project 'employee rate' should be billed at employee rate") + self.assertEqual(task.pricing_type, 'employee_rate', "Task in project 'employee rate' should be billed at employee rate") self.assertFalse(task.sale_line_id, "Task created in a project billed on 'employee rate' should not be linked to a SOL") self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project") @@ -271,8 +283,8 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'parent_id': task.id, }) - self.assertEqual(subtask.billable_type, 'no', "Subtask in non billable project should be non billable too") - self.assertEqual(subtask.project_id.billable_type, 'no', "The subtask project is non billable even if the subtask is") + self.assertFalse(subtask.allow_billable, "Subtask in non billable project should be non billable too") + self.assertFalse(subtask.project_id.allow_billable, "The subtask project is non billable even if the subtask is") self.assertEqual(subtask.partner_id, subtask.parent_id.partner_id, "Subtask should have the same customer as the one from their mother") # log timesheet on subtask @@ -294,7 +306,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) task._onchange_project() - self.assertEqual(task.billable_type, 'task_rate', "Task in project 'task rate' should be billed at task rate") + self.assertTrue(task.allow_billable, "Task in project 'task rate' should be billed at task rate") self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Task moved in a task rate billable project") self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project") @@ -303,7 +315,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'project_id': self.project_task_rate2.id, }) - self.assertEqual(task.billable_type, 'task_rate', "Subtask should keep the billable type from its parent, even when they are moved into another project") + self.assertTrue(task.allow_billable, "Subtask should keep the billable type from its parent, even when they are moved into another project") self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Subtask should keep the same sale order line than their mother, even when they are moved into another project") # create a second task in employee rate project @@ -324,10 +336,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): self.assertFalse(timesheet3.so_line, "The timesheet should not be linked to SOL as there is no fallback at all (no map, no SOL on task, no SOL on project)") - # add a SOL on the project as fallback - self.project_employee_rate.write({'sale_line_id': self.so1_line_deliver_no_task.id}) - - # log timesheet on task in 'employee rate' project wit the project fallback only (no map, no SOL on task, but SOL on project) + # log timesheet on task in 'employee rate' project (no map, no SOL on task, but SOL on project) timesheet4 = Timesheet.create({ 'name': 'Test Line ', 'project_id': task2.project_id.id, @@ -336,7 +345,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'employee_id': self.employee_tde.id, }) - self.assertEqual(timesheet4.so_line, self.so1_line_deliver_no_task, "The timesheet should be linked to SOL on the project, as no entry for TDE in project map and no SOL on task") + self.assertFalse(timesheet4.so_line, "The timesheet should not be linked to SOL, as no entry for TDE in project map") def test_billing_task_rate(self): """ @@ -355,7 +364,6 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) task._onchange_project() - self.assertEqual(task.billable_type, 'task_rate', "Task in project 'task rate' should be billed at task rate") self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Task created in a project billed on 'task rate' should be linked to a SOL of the project") self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project") @@ -376,8 +384,6 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'parent_id': task.id, }) - self.assertEqual(subtask.billable_type, 'task_rate', "Subtask in a non billable project with a so line set is task rate billable") - self.assertEqual(subtask.project_id.billable_type, 'no', "The subtask project is non billable even if the subtask is") self.assertEqual(subtask.partner_id, subtask.parent_id.partner_id, "Subtask should have the same customer as the one from their mother") # log timesheet on subtask @@ -390,7 +396,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) self.assertEqual(subtask.project_id, timesheet2.project_id, "The timesheet is in the subtask project") - self.assertEqual(timesheet2.so_line, subtask.sale_line_id, "The timesheet should be linked to SOL as the task even in a non billable project") + self.assertFalse(timesheet2.so_line, "The timesheet should not be linked to SOL as it's a non billable project") # move task and subtask into task rate project task.write({ @@ -402,10 +408,8 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) subtask._onchange_project() - self.assertEqual(task.billable_type, 'employee_rate', "Task moved in project 'employee rate' should be billed at employee rate") self.assertFalse(task.sale_line_id, "Task moved in a employee rate billable project have empty so line") self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project") - self.assertEqual(subtask.billable_type, 'employee_rate', "subtask moved in project 'employee rate' should be billed at employee rate") self.assertFalse(subtask.sale_line_id, "Subask moved in a employee rate billable project have empty so line") self.assertEqual(subtask.partner_id, task.project_id.partner_id, "Subask created in a project billed on 'employee rate' should have the same customer as the one from the project") diff --git a/addons/sale_timesheet/tests/test_project_billing_multicompany.py b/addons/sale_timesheet/tests/test_project_billing_multicompany.py index 11c83d6eb91..ef4b855751c 100644 --- a/addons/sale_timesheet/tests/test_project_billing_multicompany.py +++ b/addons/sale_timesheet/tests/test_project_billing_multicompany.py @@ -16,15 +16,17 @@ class TestProjectBillingMulticompany(TestCommonSaleTimesheet): cls.project_non_billable = Project.create({ 'name': "Non Billable Project", 'allow_timesheets': True, - 'billable_type': 'no', + 'allow_billable': True, + 'bill_type': 'customer_project', 'company_id': cls.env.company.id, }) def test_makeBillable_multiCompany(self): wizard = self.env['project.create.sale.order'].with_context(allowed_company_ids=[self.company_data_2['company'].id, self.env.company.id], company_id=self.company_data_2['company'].id, active_id=self.project_non_billable.id, active_model='project.project').create({ - 'product_id': self.product_delivery_timesheet3.id, # product creates new Timesheet in new Project - 'price_unit': self.product_delivery_timesheet3.list_price, - 'billable_type': 'project_rate', + 'line_ids': [(0, 0, { + 'product_id': self.product_delivery_timesheet3.id, # product creates new Timesheet in new Project + 'price_unit': self.product_delivery_timesheet3.list_price + })], 'partner_id': self.partner_a.id, }) diff --git a/addons/sale_timesheet/tests/test_project_overview.py b/addons/sale_timesheet/tests/test_project_overview.py index 46b198d9730..07ac601bfb0 100644 --- a/addons/sale_timesheet/tests/test_project_overview.py +++ b/addons/sale_timesheet/tests/test_project_overview.py @@ -35,7 +35,7 @@ class TestSaleProject(TestReporting): task = self.env['project.task'].create({ 'name': 'Task', 'project_id': project_so.id, - 'billable_type': 'no', + 'allow_billable': False, 'sale_line_id': False }) timesheet5 = self._log_timesheet_user(project_so, 5, task) diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index ee37a45d448..373cbe337b7 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -591,7 +591,6 @@ class TestSaleService(TestCommonSaleTimesheet): project_copy = project.copy() self.assertFalse(project_copy.sale_line_id, "Duplicating project should erase its Sale line") self.assertFalse(project_copy.sale_order_id, "Duplicating project should erase its Sale order") - self.assertEqual(project_copy.billable_type, 'no', "Duplicating project should reset its billable type to none billable") self.assertEqual(len(project.tasks), len(project_copy.tasks), "Copied project must have the same number of tasks") self.assertFalse(project_copy.tasks.mapped('sale_line_id'), "The tasks of the duplicated project should not have a Sale Line set.") diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index d3b057cf615..6a44f41789b 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -5,6 +5,7 @@ <xpath expr="." position="inside"> <link rel="stylesheet" type="text/scss" href="/sale_timesheet/static/src/scss/sale_timesheet.scss"/> <script type="text/javascript" src="/sale_timesheet/static/src/js/project_overview.js"></script> + <script type="text/javascript" src="/sale_timesheet/static/src/js/sale_project_kanban_controller.js"/> </xpath> </template> @@ -140,6 +141,27 @@ </a> </td> </tr> + <tr t-if="dashboard['time']['non_billable_timesheet'] > 0"> + <td class="o_timesheet_plan_dashboard_cell"> + <t t-if="is_uom_day" t-esc="dashboard['time']['non_billable_timesheet']" t-options="{'widget': 'timesheet_uom'}"/> + <t t-else="" t-esc="dashboard['time']['non_billable_timesheet']" t-options="{'widget': 'float_time'}"/> + </td> + <td class="o_timesheet_plan_dashboard_cell"> + (<t t-esc="dashboard['rates']['non_billable_timesheet']"/> %) + </td> + <td> + <a type="action" + data-model="account.analytic.line" + data-views='[[false, "list"], [false, "form"]]' + t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_timesheet')])" + > + <span class="btn-link" + style="font-weight:normal;"> + Non Billable Timesheets + </span> + </a> + </td> + </tr> <tr t-if="dashboard['time']['canceled'] > 0"> <td class="o_timesheet_plan_dashboard_cell"> <t t-if="is_uom_day" t-esc="dashboard['time']['canceled']" t-options="{'widget': 'timesheet_uom'}"/> @@ -262,6 +284,9 @@ <span t-if="dashboard['time']['non_billable_project'] != 0" class="badge badge-pill o_progress_non_billable_project"> <a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_project')])" tabindex="-1">No task found</a> </span> + <span t-if="dashboard['time']['non_billable_timesheet'] != 0" class="badge badge-pill o_progress_non_billable_timesheet"> + <a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_timesheet')])" tabindex="-1">Non billable timesheets</a> + </span> <span class="badge badge-pill o_progress_non_billable"> <a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable')])" tabindex="-1">Non billable tasks</a> </span> @@ -309,6 +334,10 @@ <t t-set="key" t-translation="off">non_billable_project</t> </t> <t t-call="sale_timesheet.progressbar"> + <t t-set="label">Non billable timesheets</t> + <t t-set="key" t-translation="off">non_billable_timesheet</t> + </t> + <t t-call="sale_timesheet.progressbar"> <t t-set="label">Non billable tasks</t> <t t-set="key" t-translation="off">non_billable</t> </t> diff --git a/addons/sale_timesheet/views/hr_timesheet_views.xml b/addons/sale_timesheet/views/hr_timesheet_views.xml index b73298bcde2..31a02c6eedb 100644 --- a/addons/sale_timesheet/views/hr_timesheet_views.xml +++ b/addons/sale_timesheet/views/hr_timesheet_views.xml @@ -10,6 +10,9 @@ <field name="timesheet_invoice_type"/> </xpath> <xpath expr="//filter[@name='month']" position="before"> + <filter name="billable_timesheet" string="Billable" domain="[('non_allow_billable', '=', False)]"/> + <filter name="non_billable_timesheet" string="Non Billable" domain="[('non_allow_billable', '=', True)]"/> + <separator/> <filter name="billable_time" string="Billed on Timesheets" domain="[('timesheet_invoice_type', '=', 'billable_time')]"/> <filter name="billable_fixed" string="Billed at a Fixed Price" domain="[('timesheet_invoice_type', '=', 'billable_fixed')]"/> <filter name="non_billable" string="Non Billable Tasks" domain="[('timesheet_invoice_type', '=', 'non_billable')]"/> @@ -33,6 +36,17 @@ </field> </record> + <record id="timesheet_view_tree_sale" model="ir.ui.view"> + <field name="name">account.analytic.line.view.tree.with.allow.billable</field> + <field name="model">account.analytic.line</field> + <field name="inherit_id" ref="hr_timesheet.timesheet_view_tree_user"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='user_id']" position="after"> + <field name="non_allow_billable" optional="hide"/> + </xpath> + </field> + </record> + <!-- Timesheet from Sales Order --> diff --git a/addons/sale_timesheet/views/product_views.xml b/addons/sale_timesheet/views/product_views.xml index d238c6ea8ca..d9ff53b50e1 100644 --- a/addons/sale_timesheet/views/product_views.xml +++ b/addons/sale_timesheet/views/product_views.xml @@ -12,6 +12,9 @@ <xpath expr="//field[@name='service_type']" position="after"> <field name="service_policy" widget="radio" attrs="{'invisible': [('type','!=','service')]}"/> </xpath> + <xpath expr="//field[@name='project_id']" position="attributes"> + <attribute name="context">{'default_allow_billable': True}</attribute> + </xpath> </field> </record> diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index bc694b5e06f..081e61397d1 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -7,24 +7,29 @@ <field name="inherit_id" ref="hr_timesheet.project_invoice_form"/> <field name="arch" type="xml"> <div class="oe_button_box" position="inside"> - <button string="Project Overview" class="oe_stat_button" type="object" name="action_view_timesheet" icon="fa-puzzle-piece"/> + <button string="Project Overview" class="oe_stat_button" type="object" name="action_view_timesheet" icon="fa-puzzle-piece" attrs="{'invisible': [('allow_billable', '=', False)]}"/> </div> <xpath expr="//header" position="inside"> <button name="action_make_billable" string="Create Sales Order" type="object" attrs="{'invisible': [('display_create_order', '=', False)]}" groups="sales_team.group_sale_salesman"/> </xpath> <xpath expr="//page[@name='settings']" position="after"> - <page name="billing_employee_rate" string="Invoicing" attrs="{'invisible': [('billable_type', '=', 'no')]}"> + <page name="billing_employee_rate" string="Invoicing" attrs="{'invisible': [('allow_billable', '=', False)]}"> <group> <field name="display_create_order" invisible="1"/> - <field name="billable_type" invisible="1"/> - <field name="sale_order_id" attrs="{'invisible': [('billable_type', '=', 'no')], 'readonly': [('sale_order_id', '!=', False)]}" context="{'default_partner_id': partner_id}"/> - <field name="sale_line_id" attrs="{'invisible': [('billable_type', '=', 'no')], 'readonly': [('sale_order_id', '=', False)]}" options="{'no_create': True, 'no_edit': True, 'delete': False}"/> + <field name="bill_type" widget="radio"/> + <field name="pricing_type" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('bill_type', '!=', 'customer_project')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/> + <field name="timesheet_product_id" attrs="{'invisible': ['|', '|', ('allow_timesheets', '=', False), ('sale_order_id', '!=', False), '&', ('pricing_type', '!=', 'fixed_rate'), ('bill_type', '!=', 'customer_task')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" context="{'default_type': 'service'}"/> + <field name="sale_order_id" attrs="{'invisible': ['|', ('sale_order_id', '=', False), ('bill_type', '!=', 'customer_project')], 'readonly': [('sale_order_id', '!=', False)]}" context="{'default_partner_id': partner_id}"/> + <field name="sale_line_id" attrs="{'invisible': ['|', '|', ('sale_order_id', '=', False), ('bill_type', '!=', 'customer_project'), ('pricing_type', '!=', 'fixed_rate')], 'readonly': [('sale_order_id', '=', False)]}" options="{'no_create': True, 'no_edit': True, 'delete': False}"/> </group> - <field name="sale_line_employee_ids"> + <field name="sale_line_employee_ids" attrs="{'invisible': ['|', ('bill_type', '!=', 'customer_project'), ('pricing_type', '!=', 'employee_rate')]}"> <tree editable="top"> + <field name="company_id" invisible="1"/> + <field name="project_id" invisible="1"/> <field name="employee_id" options="{'no_create': True}"/> - <field name="sale_line_id" options="{'no_create': True}" domain="[('order_id','=',parent.sale_order_id), ('is_service', '=', True)]"/> - <field name="price_unit" widget="monetary"/> + <field name="timesheet_product_id" attrs="{'column_invisible': [('parent.sale_order_id', '!=', False)]}"/> + <field name="sale_line_id" options="{'no_create': True}" attrs="{'column_invisible': [('parent.sale_order_id', '=', False)]}" domain="[('order_id','=',parent.sale_order_id), ('is_service', '=', True)]"/> + <field name="price_unit" widget="monetary" options="{'currency_field': 'currency_id'}" attrs="{'readonly': [('parent.sale_order_id', '!=', False)]}"/> <field name="currency_id" invisible="1"/> </tree> </field> @@ -32,16 +37,15 @@ </xpath> <xpath expr="//div[@id='timesheet_settings']/.." position="after"> <div class="row mt16 o_settings_container"> - <div class="col-lg-6 o_setting_box" id="allow_billable_container" attrs="{'invisible': [('billable_type', '=', 'employee_rate')]}"> + <div class="col-lg-6 o_setting_box" id="allow_billable_container"> <div class="o_setting_left_pane"> - <field name="allow_billable" attrs="{'readonly': [('sale_order_id', '!=', False)]}"/> + <field name="allow_billable"/> </div> <div class="o_setting_right_pane"> <label for="allow_billable"/> <div class="text-muted" id="allow_billable_setting"> - Invoice your time and material from tasks + Invoice your time and material to customers </div> - <field name="timesheet_product_id" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('allow_timesheets', '=', False)], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" context="{'default_type': 'service'}"/> </div> </div> </div> @@ -55,10 +59,12 @@ <field name="inherit_id" ref="hr_timesheet.project_project_view_form_simplified_inherit_timesheet"/> <field name="arch" type="xml"> <field name="allow_timesheets" position="after"> - <field name="billable_type" invisible="1"/> <field name="company_id" invisible="1"/> - <field name="allow_billable" attrs="{'invisible': [('billable_type', '=', 'employee_rate')]}"/> - <field name="timesheet_product_id" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('allow_timesheets', '=', False)], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}"/> + <field name="allow_billable"/> + <field name="bill_type" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('allow_timesheets', '=', False)], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/> + <field name="partner_id" attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), ('bill_type', '!=', 'customer_project')], 'required': [('bill_type', '=', 'customer_project')]}"/> + <field name="pricing_type" attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), ('bill_type', '!=', 'customer_project')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/> + <field name="timesheet_product_id" string="Default Service" attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate')], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}"/> </field> </field> </record> @@ -68,8 +74,20 @@ <field name="model">project.project</field> <field name="inherit_id" ref="hr_timesheet.view_project_kanban_inherited"/> <field name="arch" type="xml"> + <xpath expr="//field[@name='allow_timesheets']" position="after"> + <field name="allow_billable"/> + <field name="warning_employee_rate"/> + </xpath> + <xpath expr="//div[hasclass('o_kanban_card_manage_pane')]" position="after"> + <t t-if="record.warning_employee_rate.raw_value"> + <i class="float-right mx-4 fa fa-exclamation-triangle text-danger" role="img" title="Some of the employees who are recording time on this project are not linked to any Sales Order Item. This means that their time will be considered as non-billable." aria-label="Some of the employees who are recording time on this project are not linked to any Sales Order Item. This means that their time will be considered as non-billable."/> + </t> + </xpath> + <xpath expr="//a[@name='action_view_account_analytic_line']" position="attributes"> + <attribute name="t-if">record.analytic_account_id.raw_value and !record.allow_timesheets.raw_value and record.allow_billable.raw_value</attribute> + </xpath> <xpath expr="//a[@t-if='record.allow_timesheets.raw_value']" position="replace"> - <a t-if="record.allow_timesheets.raw_value" name="action_view_timesheet" type="object" class="o_project_kanban_box o_project_timesheet_box" groups="project.group_project_manager"> + <a t-if="record.allow_timesheets.raw_value and record.allow_billable.raw_value" name="action_view_timesheet" type="object" class="o_project_kanban_box o_project_timesheet_box" groups="project.group_project_manager"> <div> <span class="o_label">Overview</span> </div> @@ -89,9 +107,6 @@ <field name="display_create_order" invisible="1"/> <button name="action_make_billable" string="Create Sales Order" type="object" attrs="{'invisible': [('display_create_order', '=', False)]}" groups="sales_team.group_sale_salesman"/> </xpath> - <xpath expr="//field[@name='date_deadline']" position="before"> - <field name="billable_type" invisible="1"/> - </xpath> <xpath expr="//field[@name='email_cc']" position="after"> <field name="analytic_account_id" groups="base.group_no_one"/> </xpath> @@ -110,13 +125,44 @@ <field name="is_project_map_empty" invisible="1"/> <field name="has_multi_sol" invisible="1"/> </xpath> + <xpath expr="//field[@name='partner_email']" position="after"> + <field name="bill_type" invisible="1"/> + <field name="pricing_type" invisible="1"/> + <field name="timesheet_product_id" attrs="{'invisible': ['|', '|', '|', '|', ('allow_billable', '=', False), ('partner_id', '=', False), ('allow_timesheets', '=', False), ('sale_order_id', '!=', False), '&', ('pricing_type', '!=', 'fixed_rate'), ('bill_type', '!=', 'customer_task')]}" context="{'default_type': 'service'}" placeholder="Leave empty if non-billable"/> + </xpath> <xpath expr="//field[@name='timesheet_ids']/tree" position="inside"> <field name="timesheet_invoice_id" invisible="1"/> - <field name="so_line" readonly="1" attrs="{'column_invisible': [('parent.has_multi_sol', '=', False), '|', ('parent.is_project_map_empty', '=', True), ('parent.billable_type', '!=', 'employee_rate')]}"/> + <field name="so_line" readonly="1" attrs="{'column_invisible': [('parent.has_multi_sol', '=', False), '|', ('parent.is_project_map_empty', '=', True), ('parent.pricing_type', '!=', 'employee_rate')]}" optional="hide"/> + <field name="non_allow_billable" attrs="{'column_invisible': ['|', ('parent.pricing_type', '=', 'employee_rate'), '&', ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False)]}" optional="hide"/> </xpath> </field> </record> + <record id="view_task_form2_inherit_sale_timesheet" model="ir.ui.view"> + <field name="name">view.task.form2.inherit</field> + <field name="model">project.task</field> + <field name="inherit_id" ref="sale_project.view_sale_project_inherit_form"/> + <field name="arch" type="xml"> + <xpath expr="//field[@name='sale_line_id']" position="attributes"> + <attribute name="attrs"> + {'invisible': ['|', '|', ('sale_order_id', '=', False), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate'), ('partner_id', '=', False)], 'readonly': [('sale_order_id', '=', False)]} + </attribute> + <attribute name="placeholder">Leave empty if non-billable</attribute> + </xpath> + </field> + </record> + + <record id="quick_create_task_form_sale_timesheet" model="ir.ui.view"> + <field name="name">project.task.form.inherit.timesheet</field> + <field name="model">project.task</field> + <field name="inherit_id" ref="project.quick_create_task_form"/> + <field name="arch" type="xml"> + <field name="project_id" position="after"> + <field name="timesheet_product_id" invisible="1"/> + </field> + </field> + </record> + <record id="project_timesheet_action_client_timesheet_plan" model="ir.actions.act_window"> <field name="name">Overview</field> <field name="res_model">project.project</field> diff --git a/addons/sale_timesheet/wizard/project_create_sale_order.py b/addons/sale_timesheet/wizard/project_create_sale_order.py index 89abafe8335..b56a8458fa1 100644 --- a/addons/sale_timesheet/wizard/project_create_sale_order.py +++ b/addons/sale_timesheet/wizard/project_create_sale_order.py @@ -20,45 +20,100 @@ class ProjectCreateSalesOrder(models.TransientModel): active_id = self._context.get('active_id') if 'project_id' in fields and active_id: project = self.env['project.project'].browse(active_id) - if project.billable_type != 'no': - raise UserError(_("The project is already billable.")) + if project.sale_order_id: + raise UserError(_("The project has already a sale order.")) result['project_id'] = active_id if not result.get('partner_id', False): result['partner_id'] = project.partner_id.id + if project.bill_type == 'customer_project' and not result.get('line_ids', False): + if project.pricing_type == 'employee_rate': + default_product = self.env.ref('sale_timesheet.time_product', False) + result['line_ids'] = [ + (0, 0, { + 'employee_id': e.employee_id.id, + 'product_id': e.timesheet_product_id.id or default_product.id, + 'price_unit': e.price_unit or default_product.lst_price + }) for e in project.sale_line_employee_ids] + employee_from_timesheet = project.task_ids.timesheet_ids.employee_id - project.sale_line_employee_ids.employee_id + result['line_ids'] += [ + (0, 0, { + 'employee_id': e.id, + 'product_id': default_product.id, + 'price_unit': default_product.lst_price + }) for e in employee_from_timesheet] + else: + result['line_ids'] = [ + (0, 0, { + 'product_id': p.id, + 'price_unit': p.lst_price + }) for p in project.task_ids.timesheet_product_id] return result project_id = fields.Many2one('project.project', "Project", domain=[('sale_line_id', '=', False)], help="Project for which we are creating a sales order", required=True) company_id = fields.Many2one(related='project_id.company_id') partner_id = fields.Many2one('res.partner', string="Customer", required=True, help="Customer of the sales order") - product_id = fields.Many2one('product.product', domain=[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'timesheet')], string="Service", help="Product of the sales order item. Must be a service invoiced based on timesheets on tasks.") - price_unit = fields.Float("Unit Price", help="Unit price of the sales order item.") - currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id', readonly=False) + commercial_partner_id = fields.Many2one(related='partner_id.commercial_partner_id') - billable_type = fields.Selection([ - ('project_rate', 'At Project Rate'), - ('employee_rate', 'At Employee Rate'), - ], string="Billing Type", default='project_rate', required=True, help="* At Project Rate: All timesheets on the project will be billed at the same rate\n* At Employee Rate: Timesheets will be billed at a rate defined at employee level") + pricing_type = fields.Selection(related="project_id.pricing_type") + link_selection = fields.Selection([('create', 'Create a new sales order'), ('link', 'Link to an existing sales order')], required=True, default='create') + sale_order_id = fields.Many2one( + 'sale.order', string="Sales Order", + domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]") line_ids = fields.One2many('project.create.sale.order.line', 'wizard_id', string='Lines') - @api.onchange('billable_type', 'product_id') - def _onchange_product_id(self): - if self.billable_type == 'project_rate': - if self.product_id: - self.price_unit = self.product_id.lst_price + @api.onchange('partner_id') + def _onchange_partner_id(self): + self.sale_order_id = False + + def action_link_sale_order(self): + task_no_sale_line = self.project_id.tasks.filtered(lambda task: not task.sale_line_id) + # link the project to the SO line + self.project_id.write({ + 'sale_line_id': self.sale_order_id.order_line[0].id, + 'sale_order_id': self.sale_order_id.id, + 'partner_id': self.partner_id.id, + }) + + if self.pricing_type == 'employee_rate': + lines_already_present = dict([(l.employee_id.id, l) for l in self.project_id.sale_line_employee_ids]) + EmployeeMap = self.env['project.sale.line.employee.map'].sudo() + + for wizard_line in self.line_ids: + if wizard_line.employee_id.id not in lines_already_present: + EmployeeMap.create({ + 'project_id': self.project_id.id, + 'sale_line_id': wizard_line.sale_line_id.id, + 'employee_id': wizard_line.employee_id.id, + }) + else: + lines_already_present[wizard_line.employee_id.id].write({ + 'sale_line_id': wizard_line.sale_line_id.id + }) + # assign SOL to timesheets + for map_entry in self.project_id.sale_line_employee_ids: + self.env['account.analytic.line'].search([('task_id', 'in', self.project_id.tasks.ids), ('employee_id', '=', map_entry.employee_id.id), ('so_line', '=', False)]).write({ + 'so_line': map_entry.sale_line_id.id + }) else: - self.price_unit = 0.0 + dict_product_sol = dict([(l.product_id.id, l.id) for l in self.sale_order_id.order_line]) + # remove SOL for task without product + # and if a task has a product that match a product from a SOL, we put this SOL on task. + for task in task_no_sale_line: + if not task.timesheet_product_id: + task.sale_line_id = False + elif task.timesheet_product_id.id in dict_product_sol: + task.write({'sale_line_id': dict_product_sol[task.timesheet_product_id.id]}) def action_create_sale_order(self): # if project linked to SO line or at least on tasks with SO line, then we consider project as billable. if self.project_id.sale_line_id: raise UserError(_("The project is already linked to a sales order item.")) + # at least one line + if not self.line_ids: + raise UserError(_("At least one line should be filled.")) - if self.billable_type == 'employee_rate': - # at least one line - if not self.line_ids: - raise UserError(_("At least one line should be filled.")) - + if self.pricing_type == 'employee_rate': # all employee having timesheet should be in the wizard map timesheet_employees = self.env['account.analytic.line'].search([('task_id', 'in', self.project_id.tasks.ids)]).mapped('employee_id') map_employees = self.line_ids.mapped('employee_id') @@ -107,46 +162,53 @@ class ProjectCreateSalesOrder(models.TransientModel): return sale_order def _make_billable(self, sale_order): - if self.billable_type == 'project_rate': + if self.pricing_type == 'fixed_rate': self._make_billable_at_project_rate(sale_order) else: self._make_billable_at_employee_rate(sale_order) def _make_billable_at_project_rate(self, sale_order): - # trying to simulate the SO line created a task, according to the product configuration - # To avoid, generating a task when confirming the SO - task_id = False - if self.product_id.service_tracking in ['task_in_project', 'task_global_project']: - task_id = self.env['project.task'].search([('project_id', '=', self.project_id.id)], order='create_date DESC', limit=1).id - - # create SO line - sale_order_line = self.env['sale.order.line'].create({ - 'order_id': sale_order.id, - 'product_id': self.product_id.id, - 'price_unit': self.price_unit, - 'project_id': self.project_id.id, # prevent to re-create a project on confirmation - 'task_id': task_id, - 'product_uom_qty': 0.0, - }) + task_left = self.project_id.tasks.filtered(lambda task: not task.sale_line_id) + for wizard_line in self.line_ids: + task_ids = self.project_id.tasks.filtered(lambda task: not task.sale_line_id and task.timesheet_product_id == wizard_line.product_id) + task_left -= task_ids + # trying to simulate the SO line created a task, according to the product configuration + # To avoid, generating a task when confirming the SO + task_id = False + if task_ids and wizard_line.product_id.service_tracking in ['task_in_project', 'task_global_project']: + task_id = task_ids.ids[0] + + # create SO line + sale_order_line = self.env['sale.order.line'].create({ + 'order_id': sale_order.id, + 'product_id': wizard_line.product_id.id, + 'price_unit': wizard_line.price_unit, + 'project_id': self.project_id.id, # prevent to re-create a project on confirmation + 'task_id': task_id, + 'product_uom_qty': 0.0, + }) + + # link the tasks to the SO line + task_ids.write({ + 'sale_line_id': sale_order_line.id, + 'partner_id': sale_order.partner_id.id, + 'email_from': sale_order.partner_id.email, + }) + + # assign SOL to timesheets + self.env['account.analytic.line'].search([('task_id', 'in', task_ids.ids), ('so_line', '=', False)]).write({ + 'so_line': sale_order_line.id + }) - # link the project and the tasks to the SO line + # link the project to the SO line self.project_id.write({ 'sale_order_id': sale_order.id, - 'sale_line_id': sale_order_line.id, + 'sale_line_id': sale_order_line.id, # we take the last sale_order_line created 'partner_id': self.partner_id.id, }) - self.project_id.tasks.filtered(lambda task: task.billable_type == 'no').write({ - 'sale_line_id': sale_order_line.id, - 'partner_id': sale_order.partner_id.id, - 'email_from': sale_order.partner_id.email, - }) - # assign SOL to timesheets - self.env['account.analytic.line'].search([('task_id', 'in', self.project_id.tasks.ids), ('so_line', '=', False)]).write({ - 'so_line': sale_order_line.id - }) - - return sale_order_line + if task_left: + task_left.sale_line_id = False def _make_billable_at_employee_rate(self, sale_order): # trying to simulate the SO line created a task, according to the product configuration @@ -154,7 +216,9 @@ class ProjectCreateSalesOrder(models.TransientModel): task_id = self.env['project.task'].search([('project_id', '=', self.project_id.id)], order='create_date DESC', limit=1).id project_id = self.project_id.id - non_billable_tasks = self.project_id.tasks.filtered(lambda task: task.billable_type == 'no') + lines_already_present = dict([(l.employee_id.id, l) for l in self.project_id.sale_line_employee_ids]) + + non_billable_tasks = self.project_id.tasks.filtered(lambda task: not task.sale_line_id) map_entries = self.env['project.sale.line.employee.map'] EmployeeMap = self.env['project.sale.line.employee.map'].sudo() @@ -178,11 +242,17 @@ class ProjectCreateSalesOrder(models.TransientModel): sale_order_line = self.env['sale.order.line'].create(values) map_product_price_sol[map_key] = sale_order_line - map_entries |= EmployeeMap.create({ - 'project_id': self.project_id.id, - 'sale_line_id': map_product_price_sol[map_key].id, - 'employee_id': wizard_line.employee_id.id, - }) + if wizard_line.employee_id.id not in lines_already_present: + map_entries |= EmployeeMap.create({ + 'project_id': self.project_id.id, + 'sale_line_id': map_product_price_sol[map_key].id, + 'employee_id': wizard_line.employee_id.id, + }) + else: + map_entries |= lines_already_present[wizard_line.employee_id.id] + lines_already_present[wizard_line.employee_id.id].write({ + 'sale_line_id': map_product_price_sol[map_key].id + }) # link the project to the SO self.project_id.write({ @@ -191,7 +261,6 @@ class ProjectCreateSalesOrder(models.TransientModel): 'partner_id': self.partner_id.id, }) non_billable_tasks.write({ - 'sale_line_id': sale_order.order_line[0].id, 'partner_id': sale_order.partner_id.id, 'email_from': sale_order.partner_id.email, }) @@ -211,17 +280,29 @@ class ProjectCreateSalesOrderLine(models.TransientModel): _order = 'id,create_date' wizard_id = fields.Many2one('project.create.sale.order', required=True) - product_id = fields.Many2one('product.product', domain=[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'timesheet')], string="Service", required=True, + product_id = fields.Many2one('product.product', domain=[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'timesheet')], string="Service", help="Product of the sales order item. Must be a service invoiced based on timesheets on tasks.") - price_unit = fields.Float("Unit Price", default=1.0, help="Unit price of the sales order item.") - currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id', readonly=False) - employee_id = fields.Many2one('hr.employee', string="Employee", required=True, help="Employee that has timesheets on the project.") + price_unit = fields.Float("Unit Price", compute="_compute_price_unit", store=True, readonly=False, default=1.0, help="Unit price of the sales order item.") + currency_id = fields.Many2one('res.currency', string="Currency") + employee_id = fields.Many2one('hr.employee', string="Employee", help="Employee that has timesheets on the project.") + sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", compute='_compute_sale_line_id', store=True, readonly=False) _sql_constraints = [ ('unique_employee_per_wizard', 'UNIQUE(wizard_id, employee_id)', "An employee cannot be selected more than once in the mapping. Please remove duplicate(s) and try again."), ] - @api.onchange('product_id') - def _onchange_product_id(self): - if self.product_id: - self.price_unit = self.product_id.lst_price + @api.depends('product_id', 'sale_line_id') + def _compute_price_unit(self): + for line in self: + if line.wizard_id.link_selection == 'link': + line.price_unit = line.sale_line_id.price_unit + line.currency_id = line.sale_line_id.currency_id + else: + line.price_unit = line.product_id.lst_price or 0 + line.currency_id = line.product_id.currency_id + + @api.depends('wizard_id.sale_order_id') + def _compute_sale_line_id(self): + for line in self: + if line.sale_line_id and line.sale_line_id.order_id != line.wizard_id.sale_order_id: + line.sale_line_id = False diff --git a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml index a4a01bf2efa..5d16ce78e59 100644 --- a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml +++ b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml @@ -7,36 +7,34 @@ <field name="arch" type="xml"> <form string="Create a Sales Order"> <group> + <field name="link_selection" nolabel="1" widget="radio" options="{'horizontal': true}"/> + </group> + <group> <group> <field name="project_id" readonly="1"/> <field name="company_id" invisible="1"/> <field name="partner_id" domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]"/> + <field name="pricing_type" invisible="1"/> </group> - <group> - <field name="billable_type" widget="radio"/> - </group> - </group> - <group attrs="{'invisible': [('billable_type', '=', 'employee_rate')]}"> - <group> - <field name="product_id" options="{'no_create_edit': True, 'no_create': True}" attrs="{'required': [('billable_type', '=', 'project_rate')]}"/> - </group> - <group> - <field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" attrs="{'required': [('billable_type', '=', 'project_rate')]}"/> - <field name="currency_id" invisible="1"/> + <group attrs="{'invisible': [('link_selection', '!=', 'link')]}"> + <field name="sale_order_id" options="{'no_create': True, 'no_create_edit': True}" attrs="{'required': [('link_selection', '=', 'link')]}"/> + <field name="commercial_partner_id" invisible="1"/> </group> </group> - <group attrs="{'invisible': [('billable_type', '=', 'project_rate')]}"> - <field name="line_ids" nolabel="1" attrs="{'required': [('billable_type', '=', 'employee_rate')]}"> + <group attrs="{'invisible': ['&', ('link_selection', '=', 'link'), ('pricing_type', '!=', 'employee_rate')]}"> + <field name="line_ids" nolabel="1" attrs="{'required': [('pricing_type', '=', 'employee_rate')]}"> <tree editable="bottom"> - <field name="employee_id" options="{'no_create_edit': True, 'no_create': True}"/> - <field name="product_id" options="{'no_create_edit': True, 'no_create': True}"/> - <field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/> + <field name="employee_id" options="{'no_create_edit': True, 'no_create': True}" attrs="{'column_invisible': [('parent.pricing_type', '=', 'fixed_rate')], 'required': [('parent.pricing_type', '=', 'employee_rate')]}"/> + <field name="sale_line_id" options="{'no_create_edit': True, 'no_create': True}" domain="[('is_service', '=', True), ('order_id', '=', parent.sale_order_id)]" attrs="{'column_invisible': ['|', ('parent.link_selection', '!=', 'link'), ('parent.pricing_type', '=', 'fixed_rate')], 'required': ['&', ('parent.link_selection', '=', 'link'), ('parent.pricing_type', '=', 'employee_rate')]}"/> + <field name="product_id" options="{'no_create_edit': True, 'no_create': True}" attrs="{'column_invisible': [('parent.link_selection', '=', 'link')], 'required': [('parent.link_selection', '!=', 'link')]}"/> + <field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" attrs="{'readonly': [('parent.link_selection', '=', 'link')]}"/> <field name="currency_id" invisible="1"/> </tree> </field> </group> <footer> - <button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight"/> + <button string="Link to Sales Order" type="object" name="action_link_sale_order" class="oe_highlight" attrs="{'invisible': [('link_selection', '!=', 'link')]}"/> + <button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight" attrs="{'invisible': [('link_selection', '=', 'link')]}"/> <button string="Cancel" special="cancel" type="object" class="btn btn-secondary oe_inline"/> </footer> </form> diff --git a/addons/sale_timesheet/wizard/project_task_create_sale_order.py b/addons/sale_timesheet/wizard/project_task_create_sale_order.py index 9558974aae8..a88bebf3166 100644 --- a/addons/sale_timesheet/wizard/project_task_create_sale_order.py +++ b/addons/sale_timesheet/wizard/project_task_create_sale_order.py @@ -20,18 +20,46 @@ class ProjectTaskCreateSalesOrder(models.TransientModel): active_id = self._context.get('active_id') if 'task_id' in fields and active_id: task = self.env['project.task'].browse(active_id) - if task.billable_type != 'no': - raise UserError(_("The task is already billable.")) + if task.sale_order_id: + raise UserError(_("The task has already a sale order.")) result['task_id'] = active_id if not result.get('partner_id', False): result['partner_id'] = task.partner_id.id return result + link_selection = fields.Selection([('create', 'Create a new sales order'), ('link', 'Link to an existing sales order')], required=True, default='create') + task_id = fields.Many2one('project.task', "Task", domain=[('sale_line_id', '=', False)], help="Task for which we are creating a sales order", required=True) - partner_id = fields.Many2one('res.partner', string="Customer", required=True, help="Customer of the sales order") + partner_id = fields.Many2one('res.partner', string="Customer", help="Customer of the sales order") product_id = fields.Many2one('product.product', domain=[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'timesheet')], string="Service", help="Product of the sales order item. Must be a service invoiced based on timesheets on tasks. The existing timesheet will be linked to this product.", required=True) price_unit = fields.Float("Unit Price", help="Unit price of the sales order item.") currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id', readonly=False) + commercial_partner_id = fields.Many2one(related='partner_id.commercial_partner_id') + sale_order_id = fields.Many2one( + 'sale.order', string="Sales Order", + domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]") + sale_line_id = fields.Many2one( + 'sale.order.line', 'Sales Order Item', + domain="[('is_service', '=', True), ('order_partner_id', 'child_of', commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_id', '=?', sale_order_id)]") + + info_invoice = fields.Char(compute='_compute_info_invoice') + + @api.depends('sale_line_id', 'price_unit', 'link_selection') + def _compute_info_invoice(self): + for line in self: + domain = self.env['sale.order.line']._timesheet_compute_delivered_quantity_domain() + timesheet = self.env['account.analytic.line'].read_group(domain + [('task_id', '=', self.task_id.id), ('so_line', '=', False), ('timesheet_invoice_id', '=', False)], ['unit_amount'], ['task_id']) + unit_amount = round(timesheet[0].get('unit_amount', 0), 2) if timesheet else 0 + company_uom = self.env.company.timesheet_encode_uom_id + label = _("hours") + if company_uom == self.env.ref('uom.product_uom_day'): + label = _("days") + if line.link_selection == 'create' and line.price_unit: + line.info_invoice = _("%(amount)s %(label)s will be added to the new Sales Order Item.", amount=unit_amount, label=label) + elif line.sale_line_id: + line.info_invoice = _("%(amount)s %(label)s will be added to the selected Sales Order Item.", amount=unit_amount, label=label) + else: + line.info_invoice = False @api.onchange('product_id') def _onchange_product_id(self): @@ -40,6 +68,28 @@ class ProjectTaskCreateSalesOrder(models.TransientModel): else: self.price_unit = 0.0 + @api.onchange('partner_id') + def _onchange_partner_id(self): + self.sale_order_id = False + self.sale_line_id = False + + @api.onchange('sale_order_id') + def _onchange_sale_order_id(self): + self.sale_line_id = False + + def action_link_sale_order(self): + # link task to SOL + self.task_id.write({ + 'sale_line_id': self.sale_line_id.id, + 'partner_id': self.partner_id.id, + 'email_from': self.partner_id.email, + }) + + # assign SOL to timesheets + self.env['account.analytic.line'].search([('task_id', '=', self.task_id.id), ('so_line', '=', False), ('timesheet_invoice_id', '=', False)]).write({ + 'so_line': self.sale_line_id.id + }) + def action_create_sale_order(self): sale_order = self._prepare_sale_order() sale_order.action_confirm() diff --git a/addons/sale_timesheet/wizard/project_task_create_sale_order_views.xml b/addons/sale_timesheet/wizard/project_task_create_sale_order_views.xml index 7723545d87f..8f172bb723e 100644 --- a/addons/sale_timesheet/wizard/project_task_create_sale_order_views.xml +++ b/addons/sale_timesheet/wizard/project_task_create_sale_order_views.xml @@ -7,18 +7,28 @@ <field name="arch" type="xml"> <form string="Create a Sales Order"> <group> + <field name="link_selection" nolabel="1" widget="radio" options="{'horizontal': true}"/> + </group> + <group> <group> <field name="task_id" readonly="1"/> <field name="partner_id"/> </group> - <group> - <field name="product_id" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet'}"/> - <field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" attrs="{'required': True}"/> + <group attrs="{'invisible': [('link_selection', '=', 'link')]}"> + <field name="product_id" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet'}" attrs="{'required': [('link_selection', '!=', 'link')]}"/> + <field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" attrs="{'required': [('link_selection', '!=', 'link')]}"/> <field name="currency_id" invisible="1"/> </group> + <group attrs="{'invisible': [('link_selection', '!=', 'link')]}"> + <field name="sale_order_id" options="{'no_create': True, 'no_create_edit': True}" attrs="{'required': [('link_selection', '=', 'link')]}"/> + <field name="sale_line_id" options="{'no_create': True, 'no_create_edit': True}" attrs="{'required': [('link_selection', '=', 'link')]}"/> + <field name="commercial_partner_id" invisible="1"/> + </group> + <field name="info_invoice"/> </group> <footer> - <button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight"/> + <button string="Link to Sales Order" type="object" name="action_link_sale_order" class="oe_highlight" attrs="{'invisible': [('link_selection', '!=', 'link')]}"/> + <button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight" attrs="{'invisible': [('link_selection', '=', 'link')]}"/> <button string="Cancel" special="cancel" type="object" class="btn btn-secondary oe_inline"/> </footer> </form> |
