summaryrefslogtreecommitdiff
path: root/addons/sale_timesheet/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/sale_timesheet/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/sale_timesheet/models')
-rw-r--r--addons/sale_timesheet/models/__init__.py10
-rw-r--r--addons/sale_timesheet/models/account.py155
-rw-r--r--addons/sale_timesheet/models/account_move.py80
-rw-r--r--addons/sale_timesheet/models/product.py136
-rw-r--r--addons/sale_timesheet/models/project.py377
-rw-r--r--addons/sale_timesheet/models/project_overview.py567
-rw-r--r--addons/sale_timesheet/models/project_sale_line_employee_map.py61
-rw-r--r--addons/sale_timesheet/models/sale_order.py213
8 files changed, 1599 insertions, 0 deletions
diff --git a/addons/sale_timesheet/models/__init__.py b/addons/sale_timesheet/models/__init__.py
new file mode 100644
index 00000000..d86f2677
--- /dev/null
+++ b/addons/sale_timesheet/models/__init__.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import account
+from . import account_move
+from . import product
+from . import project
+from . import project_overview
+from . import sale_order
+from . import project_sale_line_employee_map
diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py
new file mode 100644
index 00000000..df56dbd2
--- /dev/null
+++ b/addons/sale_timesheet/models/account.py
@@ -0,0 +1,155 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.exceptions import UserError, ValidationError
+
+from odoo import api, fields, models, _
+from odoo.osv import expression
+
+
+class AccountAnalyticLine(models.Model):
+ _inherit = 'account.analytic.line'
+
+ def _default_sale_line_domain(self):
+ domain = super(AccountAnalyticLine, self)._default_sale_line_domain()
+ return expression.OR([domain, [('qty_delivered_method', '=', 'timesheet')]])
+
+ timesheet_invoice_type = fields.Selection([
+ ('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.")
+ so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False)
+
+ # TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to bill_type and pricing_type of project (See in master)
+ @api.depends('so_line.product_id', 'project_id', 'task_id', 'non_allow_billable', 'task_id.bill_type', 'task_id.pricing_type', 'task_id.non_allow_billable')
+ def _compute_timesheet_invoice_type(self):
+ non_allowed_billable = self.filtered('non_allow_billable')
+ non_allowed_billable.timesheet_invoice_type = 'non_billable_timesheet'
+ non_allowed_billable_task = (self - non_allowed_billable).filtered(lambda t: t.task_id.bill_type == 'customer_project' and t.task_id.pricing_type == 'employee_rate' and t.task_id.non_allow_billable)
+ non_allowed_billable_task.timesheet_invoice_type = 'non_billable'
+
+ for timesheet in self - non_allowed_billable - non_allowed_billable_task:
+ 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':
+ if timesheet.so_line.product_id.invoice_policy == 'delivery':
+ if timesheet.so_line.product_id.service_type == 'timesheet':
+ invoice_type = 'billable_time'
+ else:
+ invoice_type = 'billable_fixed'
+ elif timesheet.so_line.product_id.invoice_policy == 'order':
+ invoice_type = 'billable_fixed'
+ timesheet.timesheet_invoice_type = invoice_type
+ else:
+ timesheet.timesheet_invoice_type = False
+
+ @api.onchange('employee_id')
+ def _onchange_task_id_employee_id(self):
+ 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.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
+
+ @api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'employee_id', 'project_id.allow_billable')
+ def _compute_so_line(self):
+ for timesheet in self._get_not_billed(): # Get only the timesheets are not yet invoiced
+ timesheet.so_line = timesheet.project_id.allow_billable and timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id, timesheet.project_id)
+
+ def _get_not_billed(self):
+ return self.filtered(lambda t: not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel')
+
+ def _check_timesheet_can_be_billed(self):
+ return self.so_line in self.project_id.mapped('sale_line_employee_ids.sale_line_id') | self.task_id.sale_line_id | self.project_id.sale_line_id
+
+ @api.constrains('so_line', 'project_id')
+ def _check_sale_line_in_project_map(self):
+ if not all(t._check_timesheet_can_be_billed() for t in self._get_not_billed().filtered(lambda t: t.project_id and t.so_line)):
+ raise ValidationError(_("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line."))
+
+ def write(self, values):
+ # prevent to update invoiced timesheets if one line is of type delivery
+ self._check_can_write(values)
+ result = super(AccountAnalyticLine, self).write(values)
+ return result
+
+ def _check_can_write(self, values):
+ if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and self.filtered(lambda t: t.timesheet_invoice_id and t.timesheet_invoice_id.state != 'cancel'):
+ if any(field_name in values for field_name in ['unit_amount', 'employee_id', 'project_id', 'task_id', 'so_line', 'amount', 'date']):
+ raise UserError(_('You can not modify already invoiced timesheets (linked to a Sales order items invoiced on Time and material).'))
+
+ @api.model
+ def _timesheet_preprocess(self, values):
+ if values.get('task_id') and not values.get('account_id'):
+ task = self.env['project.task'].browse(values.get('task_id'))
+ if task.analytic_account_id:
+ values['account_id'] = task.analytic_account_id.id
+ values['company_id'] = task.analytic_account_id.company_id.id
+ values = super(AccountAnalyticLine, self)._timesheet_preprocess(values)
+ return values
+
+ @api.model
+ def _timesheet_determine_sale_line(self, task, employee, project):
+ """ Deduce the SO line associated to the timesheet line:
+ 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
+ """
+ if 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
+ if project.sale_line_id:
+ return project.sale_line_id
+ if task.allow_billable and task.sale_line_id:
+ 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' and not task.non_allow_billable:
+ map_entry = project.sale_line_employee_ids.filtered(lambda map_entry: map_entry.employee_id == employee)
+ if map_entry:
+ return map_entry.sale_line_id
+ if task.sale_line_id or project.sale_line_id:
+ return task.sale_line_id or project.sale_line_id
+ return self.env['sale.order.line']
+
+ def _timesheet_get_portal_domain(self):
+ """ Only the timesheets with a product invoiced on delivered quantity are concerned.
+ since in ordered quantity, the timesheet quantity is not invoiced,
+ thus there is no meaning of showing invoice with ordered quantity.
+ """
+ domain = super(AccountAnalyticLine, self)._timesheet_get_portal_domain()
+ return expression.AND([domain, [('timesheet_invoice_type', 'in', ['billable_time', 'non_billable', 'billable_fixed'])]])
+
+ @api.model
+ def _timesheet_get_sale_domain(self, order_lines_ids, invoice_ids):
+ if not invoice_ids:
+ return [('so_line', 'in', order_lines_ids.ids)]
+
+ return [
+ '|',
+ '&',
+ ('timesheet_invoice_id', 'in', invoice_ids.ids),
+ # TODO : Master: Check if non_billable should be removed ?
+ ('timesheet_invoice_type', 'in', ['billable_time', 'non_billable']),
+ '&',
+ ('timesheet_invoice_type', '=', 'billable_fixed'),
+ ('so_line', 'in', order_lines_ids.ids)
+ ]
+
+ def _get_timesheets_to_merge(self):
+ res = super(AccountAnalyticLine, self)._get_timesheets_to_merge()
+ return res.filtered(lambda l: not l.timesheet_invoice_id or l.timesheet_invoice_id.state != 'posted')
+
+ def unlink(self):
+ if any(line.timesheet_invoice_id and line.timesheet_invoice_id.state == 'posted' for line in self):
+ raise UserError(_('You cannot remove a timesheet that has already been invoiced.'))
+ return super(AccountAnalyticLine, self).unlink()
diff --git a/addons/sale_timesheet/models/account_move.py b/addons/sale_timesheet/models/account_move.py
new file mode 100644
index 00000000..74317a66
--- /dev/null
+++ b/addons/sale_timesheet/models/account_move.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.osv import expression
+
+
+class AccountMove(models.Model):
+ _inherit = "account.move"
+
+ timesheet_ids = fields.One2many('account.analytic.line', 'timesheet_invoice_id', string='Timesheets', readonly=True, copy=False)
+ timesheet_count = fields.Integer("Number of timesheets", compute='_compute_timesheet_count')
+
+ @api.depends('timesheet_ids')
+ def _compute_timesheet_count(self):
+ timesheet_data = self.env['account.analytic.line'].read_group([('timesheet_invoice_id', 'in', self.ids)], ['timesheet_invoice_id'], ['timesheet_invoice_id'])
+ mapped_data = dict([(t['timesheet_invoice_id'][0], t['timesheet_invoice_id_count']) for t in timesheet_data])
+ for invoice in self:
+ invoice.timesheet_count = mapped_data.get(invoice.id, 0)
+
+ def action_view_timesheet(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Timesheets'),
+ 'domain': [('project_id', '!=', False)],
+ 'res_model': 'account.analytic.line',
+ 'view_id': False,
+ 'view_mode': 'tree,form',
+ 'help': _("""
+ <p class="o_view_nocontent_smiling_face">
+ Record timesheets
+ </p><p>
+ You can register and track your workings hours by project every
+ day. Every time spent on a project will become a cost and can be re-invoiced to
+ customers if required.
+ </p>
+ """),
+ 'limit': 80,
+ 'context': {
+ 'default_project_id': self.id,
+ 'search_default_project_id': [self.id]
+ }
+ }
+
+ def _link_timesheets_to_invoice(self, start_date=None, end_date=None):
+ """ Search timesheets from given period and link this timesheets to the invoice
+
+ When we create an invoice from a sale order, we need to
+ link the timesheets in this sale order to the invoice.
+ Then, we can know which timesheets are invoiced in the sale order.
+ :param start_date: the start date of the period
+ :param end_date: the end date of the period
+ """
+ for line in self.filtered(lambda i: i.move_type == 'out_invoice' and i.state == 'draft').invoice_line_ids:
+ sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet')
+ if sale_line_delivery:
+ domain = line._timesheet_domain_get_invoiced_lines(sale_line_delivery)
+ if start_date:
+ domain = expression.AND([domain, [('date', '>=', start_date)]])
+ if end_date:
+ domain = expression.AND([domain, [('date', '<=', end_date)]])
+ timesheets = self.env['account.analytic.line'].sudo().search(domain)
+ timesheets.write({'timesheet_invoice_id': line.move_id.id})
+
+
+class AccountMoveLine(models.Model):
+ _inherit = 'account.move.line'
+
+ @api.model
+ def _timesheet_domain_get_invoiced_lines(self, sale_line_delivery):
+ """ Get the domain for the timesheet to link to the created invoice
+ :param sale_line_delivery: recordset of sale.order.line to invoice
+ :return a normalized domain
+ """
+ return [
+ ('so_line', 'in', sale_line_delivery.ids),
+ ('project_id', '!=', False),
+ '|', ('timesheet_invoice_id', '=', False), ('timesheet_invoice_id.state', '=', 'cancel')
+ ]
diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py
new file mode 100644
index 00000000..3d6e0766
--- /dev/null
+++ b/addons/sale_timesheet/models/product.py
@@ -0,0 +1,136 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import threading
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ service_policy = fields.Selection([
+ ('ordered_timesheet', 'Prepaid'),
+ ('delivered_timesheet', 'Timesheets on tasks'),
+ ('delivered_manual', 'Milestones (manually set quantities on order)')
+ ], string="Service Invoicing Policy", compute='_compute_service_policy', inverse='_inverse_service_policy')
+ service_type = fields.Selection(selection_add=[
+ ('timesheet', 'Timesheets on project (one fare per SO/Project)'),
+ ], ondelete={'timesheet': 'set default'})
+ # override domain
+ project_id = fields.Many2one(domain="[('company_id', '=', current_company_id), ('allow_billable', '=', True), ('bill_type', '=', 'customer_task'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True])]")
+ project_template_id = fields.Many2one(domain="[('company_id', '=', current_company_id), ('allow_billable', '=', True), ('bill_type', '=', 'customer_project'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', 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):
+ visibility = self.user_has_groups('project.group_project_user')
+ for product_template in self:
+ if not product_template.visible_expense_policy:
+ product_template.visible_expense_policy = visibility
+ return super(ProductTemplate, self)._compute_visible_expense_policy()
+
+ @api.depends('invoice_policy', 'service_type')
+ def _compute_service_policy(self):
+ for product in self:
+ policy = None
+ if product.invoice_policy == 'delivery':
+ policy = 'delivered_manual' if product.service_type == 'manual' else 'delivered_timesheet'
+ elif product.invoice_policy == 'order' and (product.service_type == 'timesheet' or product.type == 'service'):
+ policy = 'ordered_timesheet'
+ product.service_policy = policy
+
+ def _inverse_service_policy(self):
+ for product in self:
+ policy = product.service_policy
+ if not policy and not product.invoice_policy =='delivery':
+ product.invoice_policy = 'order'
+ product.service_type = 'manual'
+ elif policy == 'ordered_timesheet':
+ product.invoice_policy = 'order'
+ product.service_type = 'timesheet'
+ else:
+ product.invoice_policy = 'delivery'
+ product.service_type = 'manual' if policy == 'delivered_manual' else 'timesheet'
+
+ @api.onchange('type')
+ def _onchange_type(self):
+ res = super(ProductTemplate, self)._onchange_type()
+ if self.type == 'service' and not self.invoice_policy:
+ self.invoice_policy = 'order'
+ self.service_type = 'timesheet'
+ elif self.type == 'service' and self.invoice_policy == 'order':
+ self.service_policy = 'ordered_timesheet'
+ elif self.type == 'consu' and not self.invoice_policy and self.service_policy == 'ordered_timesheet':
+ self.invoice_policy = 'order'
+ return res
+
+ @api.model
+ def _get_onchange_service_policy_updates(self, service_tracking, service_policy, project_id, project_template_id):
+ vals = {}
+ if service_tracking != 'no' and service_policy == 'delivered_timesheet':
+ if project_id and not project_id.allow_timesheets:
+ vals['project_id'] = False
+ elif project_template_id and not project_template_id.allow_timesheets:
+ vals['project_template_id'] = False
+ return vals
+
+ @api.onchange('service_policy')
+ def _onchange_service_policy(self):
+ vals = self._get_onchange_service_policy_updates(self.service_tracking,
+ self.service_policy,
+ self.project_id,
+ self.project_template_id)
+ if vals:
+ self.update(vals)
+
+ def unlink(self):
+ time_product = self.env.ref('sale_timesheet.time_product')
+ if time_product.product_tmpl_id in self:
+ raise ValidationError(_('The %s product is required by the Timesheet app and cannot be archived/deleted.') % time_product.name)
+ return super(ProductTemplate, self).unlink()
+
+ def write(self, vals):
+ # timesheet product can't be archived
+ test_mode = getattr(threading.currentThread(), 'testing', False) or self.env.registry.in_test_mode()
+ if not test_mode and 'active' in vals and not vals['active']:
+ time_product = self.env.ref('sale_timesheet.time_product')
+ if time_product.product_tmpl_id in self:
+ raise ValidationError(_('The %s product is required by the Timesheet app and cannot be archived/deleted.') % time_product.name)
+ return super(ProductTemplate, self).write(vals)
+
+
+class ProductProduct(models.Model):
+ _inherit = 'product.product'
+
+ def _is_delivered_timesheet(self):
+ """ Check if the product is a delivered timesheet """
+ self.ensure_one()
+ return self.type == 'service' and self.service_policy == 'delivered_timesheet'
+
+ @api.onchange('service_policy')
+ def _onchange_service_policy(self):
+ vals = self.product_tmpl_id._get_onchange_service_policy_updates(self.service_tracking,
+ self.service_policy,
+ self.project_id,
+ self.project_template_id)
+ if vals:
+ self.update(vals)
+
+ def unlink(self):
+ time_product = self.env.ref('sale_timesheet.time_product')
+ if time_product in self:
+ raise ValidationError(_('The %s product is required by the Timesheet app and cannot be archived/deleted.') % time_product.name)
+ return super(ProductProduct, self).unlink()
+
+ def write(self, vals):
+ # timesheet product can't be archived
+ test_mode = getattr(threading.currentThread(), 'testing', False) or self.env.registry.in_test_mode()
+ if not test_mode and 'active' in vals and not vals['active']:
+ time_product = self.env.ref('sale_timesheet.time_product')
+ if time_product in self:
+ raise ValidationError(_('The %s product is required by the Timesheet app and cannot be archived/deleted.') % time_product.name)
+ return super(ProductProduct, self).write(vals)
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
new file mode 100644
index 00000000..618e4eab
--- /dev/null
+++ b/addons/sale_timesheet/models/project.py
@@ -0,0 +1,377 @@
+# -*- 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
+
+
+# YTI PLEASE SPLIT ME
+class Project(models.Model):
+ _inherit = 'project.project'
+
+ @api.model
+ def default_get(self, fields):
+ """ Pre-fill timesheet product as "Time" data product when creating new project allowing billable tasks by default. """
+ result = super(Project, self).default_get(fields)
+ if 'timesheet_product_id' in fields and result.get('allow_billable') and result.get('allow_timesheets') and not result.get('timesheet_product_id'):
+ default_product = self.env.ref('sale_timesheet.time_product', False)
+ if default_product:
+ result['timesheet_product_id'] = default_product.id
+ return result
+
+ def _default_timesheet_product_id(self):
+ return self.env.ref('sale_timesheet.time_product', False)
+
+ bill_type = fields.Selection([
+ ('customer_task', 'Different customers'),
+ ('customer_project', 'A unique customer')
+ ], string="Invoice Tasks to", 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', 'Project 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("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',
+ 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 tasks.',
+ compute="_compute_timesheet_product_id", store=True, readonly=False,
+ default=_default_timesheet_product_id)
+ warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate')
+
+ _sql_constraints = [
+ ('timesheet_product_required_if_billable_and_timesheets', """
+ CHECK(
+ (allow_billable = 't' AND allow_timesheets = 't' AND timesheet_product_id IS NOT NULL)
+ OR (allow_billable IS NOT TRUE)
+ OR (allow_timesheets IS NOT TRUE)
+ OR (allow_billable IS NULL)
+ OR (allow_timesheets IS NULL)
+ )""", 'The timesheet product is required when the task can be billed and timesheets are allowed.'),
+
+ ]
+
+ @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.bill_type != 'customer_project' or not project.allow_billable or project.sale_order_id:
+ show = False
+ project.display_create_order = show
+
+ @api.depends('allow_timesheets', 'allow_billable')
+ def _compute_timesheet_product_id(self):
+ default_product = self.env.ref('sale_timesheet.time_product', False)
+ for project in self:
+ if not project.allow_timesheets or not project.allow_billable:
+ project.timesheet_product_id = False
+ elif not project.timesheet_product_id:
+ project.timesheet_product_id = default_product
+
+ @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')
+ tasks = projects.task_ids.filtered(lambda t: not t.non_allow_billable)
+ employees = self.env['account.analytic.line'].read_group([('task_id', 'in', tasks.ids), ('non_allow_billable', '=', False)], ['employee_id', 'project_id'], ['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]] if line['employee_id'] else []
+ 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.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 _get_not_billed_timesheets(self):
+ return self.sudo(False).mapped('timesheet_ids').filtered(
+ lambda t: not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel')
+
+ def _update_timesheets_sale_line_id(self):
+ for project in self.filtered(lambda p: p.allow_billable and p.allow_timesheets):
+ timesheet_ids = project._get_not_billed_timesheets()
+ if not timesheet_ids:
+ continue
+ for employee_id in project.sale_line_employee_ids.filtered(lambda l: l.project_id == project).employee_id:
+ sale_line_id = project.sale_line_employee_ids.filtered(lambda l: l.project_id == project and l.employee_id == employee_id).sale_line_id
+ timesheet_ids.filtered(lambda t: t.employee_id == employee_id).sudo().so_line = sale_line_id
+
+ def action_view_timesheet(self):
+ self.ensure_one()
+ if self.allow_timesheets:
+ return self.action_view_timesheet_plan()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Timesheets of %s', self.name),
+ 'domain': [('project_id', '!=', False)],
+ 'res_model': 'account.analytic.line',
+ 'view_id': False,
+ 'view_mode': 'tree,form',
+ 'help': _("""
+ <p class="o_view_nocontent_smiling_face">
+ Record timesheets
+ </p><p>
+ You can register and track your workings hours by project every
+ day. Every time spent on a project will become a cost and can be re-invoiced to
+ customers if required.
+ </p>
+ """),
+ 'limit': 80,
+ 'context': {
+ 'default_project_id': self.id,
+ 'search_default_project_id': [self.id]
+ }
+ }
+
+ def action_view_timesheet_plan(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.project_timesheet_action_client_timesheet_plan")
+ action['params'] = {
+ 'project_ids': self.ids,
+ }
+ action['context'] = {
+ 'active_id': self.id,
+ 'active_ids': self.ids,
+ 'search_default_name': self.name,
+ }
+ return action
+
+ def action_make_billable(self):
+ return {
+ "name": _("Create Sales Order"),
+ "type": 'ir.actions.act_window',
+ "res_model": 'project.create.sale.order',
+ "views": [[False, "form"]],
+ "target": 'new',
+ "context": {
+ 'active_id': self.id,
+ 'active_model': 'project.project',
+ 'default_product_id': self.timesheet_product_id.id,
+ },
+ }
+
+
+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')
+ 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.')
+
+ # TODO: [XBO] remove me in master
+ non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheets linked to this task will not be billed.")
+ remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so', compute_sudo=True)
+ remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available")
+
+ @api.depends('sale_line_id', 'timesheet_ids', 'timesheet_ids.unit_amount')
+ def _compute_remaining_hours_so(self):
+ # TODO This is not yet perfectly working as timesheet.so_line stick to its old value although changed
+ # in the task From View.
+ timesheets = self.timesheet_ids.filtered(lambda t: t.task_id.sale_line_id in (t.so_line, t._origin.so_line) and t.so_line.remaining_hours_available)
+
+ mapped_remaining_hours = {task._origin.id: task.sale_line_id and task.sale_line_id.remaining_hours or 0.0 for task in self}
+ uom_hour = self.env.ref('uom.product_uom_hour')
+ for timesheet in timesheets:
+ delta = 0
+ if timesheet._origin.so_line == timesheet.task_id.sale_line_id:
+ delta += timesheet._origin.unit_amount
+ if timesheet.so_line == timesheet.task_id.sale_line_id:
+ delta -= timesheet.unit_amount
+ if delta:
+ mapped_remaining_hours[timesheet.task_id._origin.id] += timesheet.product_uom_id._compute_quantity(delta, uom_hour)
+
+ for task in self:
+ task.remaining_hours_so = mapped_remaining_hours[task._origin.id]
+
+ @api.depends(
+ 'allow_billable', 'allow_timesheets', 'sale_order_id')
+ def _compute_display_create_order(self):
+ for task in self:
+ show = True
+ if not task.allow_billable or not task.allow_timesheets or \
+ (task.bill_type != 'customer_task' and not task.timesheet_product_id) or (not task.partner_id and task.bill_type != 'customer_task') or \
+ task.sale_order_id or (task.bill_type != 'customer_task' and task.pricing_type != 'employee_rate'):
+ show = False
+ task.display_create_order = show
+
+ @api.onchange('sale_line_id')
+ def _onchange_sale_line_id(self):
+ # TODO: remove me in master
+ return
+
+ @api.onchange('project_id')
+ def _onchange_project_id(self):
+ # TODO: remove me in master
+ return
+
+ @api.depends('analytic_account_id.active')
+ def _compute_analytic_account_active(self):
+ super()._compute_analytic_account_active()
+ 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', 'allow_billable', 'non_allow_billable')
+ def _compute_sale_order_id(self):
+ for task in self:
+ if not task.allow_billable or task.non_allow_billable:
+ task.sale_order_id = False
+ elif task.allow_billable:
+ if task.sale_line_id:
+ task.sale_order_id = task.sale_line_id.sudo().order_id
+ elif task.project_id.sale_order_id:
+ task.sale_order_id = task.project_id.sale_order_id
+ if task.sale_order_id and not task.partner_id:
+ task.partner_id = task.sale_order_id.partner_id
+
+ @api.depends('commercial_partner_id', 'sale_line_id.order_partner_id.commercial_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'allow_billable')
+ def _compute_sale_line(self):
+ billable_tasks = self.filtered('allow_billable')
+ super(ProjectTask, billable_tasks)._compute_sale_line()
+ for task in billable_tasks.filtered(lambda t: not t.sale_line_id):
+ task.sale_line_id = task._get_last_sol_of_customer()
+
+ @api.depends('project_id.sale_line_employee_ids')
+ def _compute_is_project_map_empty(self):
+ for task in self:
+ task.is_project_map_empty = not bool(task.sudo().project_id.sale_line_employee_ids)
+
+ @api.depends('timesheet_ids')
+ def _compute_has_multi_sol(self):
+ for task in self:
+ task.has_multi_sol = task.timesheet_ids and task.timesheet_ids.so_line != task.sale_line_id
+
+ @api.onchange('project_id')
+ def _onchange_project(self):
+ 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):
+ 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.bill_type == 'customer_project' and project_dest.pricing_type == 'employee_rate':
+ self.write({'sale_line_id': False})
+ if 'non_allow_billable' in values and self.filtered('allow_timesheets').sudo().timesheet_ids:
+ timesheet_ids = self.filtered('allow_timesheets').timesheet_ids.filtered(
+ lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel')
+ )
+ if values['non_allow_billable']:
+ timesheet_ids.write({'so_line': False})
+ self.sale_line_id = False
+ else:
+ # We write project on timesheet lines to call _timesheet_preprocess. This function will set correct the SOL
+ for project in timesheet_ids.project_id:
+ current_timesheet_ids = timesheet_ids.filtered(lambda t: t.project_id == project)
+ current_timesheet_ids.task_id.update({'sale_line_id': project.sale_line_id.id})
+ for employee in current_timesheet_ids.employee_id:
+ current_timesheet_ids.filtered(lambda t: t.employee_id == employee).write({'project_id': project.id})
+
+ return res
+
+ def _get_last_sol_of_customer(self):
+ # Get the last SOL made for the customer in the current task where we need to compute
+ self.ensure_one()
+ if not self.commercial_partner_id or not self.allow_billable:
+ return False
+ domain = [('company_id', '=', self.company_id.id), ('is_service', '=', True), ('order_partner_id', 'child_of', self.commercial_partner_id.id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]
+ if self.project_id.bill_type == 'customer_project' and self.project_sale_order_id:
+ domain.append(('order_id', '=?', self.project_sale_order_id.id))
+ sale_lines = self.env['sale.order.line'].search(domain)
+ for line in sale_lines:
+ if line.remaining_hours_available and line.remaining_hours > 0:
+ return line
+ return False
+
+ def action_make_billable(self):
+ return {
+ "name": _("Create Sales Order"),
+ "type": 'ir.actions.act_window',
+ "res_model": 'project.task.create.sale.order',
+ "views": [[False, "form"]],
+ "target": 'new',
+ "context": {
+ 'active_id': self.id,
+ 'active_model': 'project.task',
+ 'form_view_initial_mode': 'edit',
+ 'default_product_id': self.timesheet_product_id.id or self.project_id.timesheet_product_id.id,
+ },
+ }
+
+ def _get_timesheet(self):
+ # return not invoiced timesheet and timesheet without so_line or so_line linked to task
+ timesheet_ids = super(ProjectTask, self)._get_timesheet()
+ return timesheet_ids.filtered(lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') and (not t.so_line or t.so_line == t.task_id._origin.sale_line_id))
+
+ def _get_action_view_so_ids(self):
+ return list(set((self.sale_order_id + self.timesheet_ids.so_line.order_id).ids))
+
+class ProjectTaskRecurrence(models.Model):
+ _inherit = 'project.task.recurrence'
+
+ @api.model
+ def _get_recurring_fields(self):
+ return ['analytic_account_id'] + super(ProjectTaskRecurrence, self)._get_recurring_fields()
diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py
new file mode 100644
index 00000000..9761ac89
--- /dev/null
+++ b/addons/sale_timesheet/models/project_overview.py
@@ -0,0 +1,567 @@
+# -*- coding: utf-8 -*-
+import babel.dates
+from dateutil.relativedelta import relativedelta
+import itertools
+import json
+
+from odoo import fields, _, models
+from odoo.osv import expression
+from odoo.tools import float_round
+from odoo.tools.misc import get_lang
+
+from odoo.addons.web.controllers.main import clean_action
+from datetime import date
+
+DEFAULT_MONTH_RANGE = 3
+
+
+class Project(models.Model):
+ _inherit = 'project.project'
+
+
+ def _qweb_prepare_qcontext(self, view_id, domain):
+ values = super()._qweb_prepare_qcontext(view_id, domain)
+
+ projects = self.search(domain)
+ values.update(projects._plan_prepare_values())
+ values['actions'] = projects._plan_prepare_actions(values)
+
+ return values
+
+ def _plan_get_employee_ids(self):
+ aal_employee_ids = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids), ('employee_id', '!=', False)], ['employee_id'], ['employee_id'])
+ employee_ids = list(map(lambda x: x['employee_id'][0], aal_employee_ids))
+ return employee_ids
+
+ def _plan_prepare_values(self):
+ currency = self.env.company.currency_id
+ uom_hour = self.env.ref('uom.product_uom_hour')
+ 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', 'non_billable_timesheet', 'billable_fixed']
+
+ values = {
+ 'projects': self,
+ 'currency': currency,
+ 'timesheet_domain': [('project_id', 'in', self.ids)],
+ 'profitability_domain': [('project_id', 'in', self.ids)],
+ 'stat_buttons': self._plan_get_stat_button(),
+ 'is_uom_day': is_uom_day,
+ }
+
+ #
+ # Hours, Rates and Profitability
+ #
+ dashboard_values = {
+ 'time': dict.fromkeys(billable_types + ['total'], 0.0),
+ 'rates': dict.fromkeys(billable_types + ['total'], 0.0),
+ 'profit': {
+ 'invoiced': 0.0,
+ 'to_invoice': 0.0,
+ 'cost': 0.0,
+ 'total': 0.0,
+ }
+ }
+
+ # hours from non-invoiced timesheets that are linked to canceled so
+ canceled_hours_domain = [('project_id', 'in', self.ids), ('timesheet_invoice_type', '!=', False), ('so_line.state', '=', 'cancel')]
+ total_canceled_hours = sum(self.env['account.analytic.line'].search(canceled_hours_domain).mapped('unit_amount'))
+ canceled_hours = float_round(total_canceled_hours, precision_rounding=hour_rounding)
+ if is_uom_day:
+ # convert time from hours to days
+ canceled_hours = round(uom_hour._compute_quantity(canceled_hours, company_uom, raise_if_failure=False), 2)
+ dashboard_values['time']['canceled'] = canceled_hours
+ dashboard_values['time']['total'] += canceled_hours
+
+ # hours (from timesheet) and rates (by billable type)
+ dashboard_domain = [('project_id', 'in', self.ids), ('timesheet_invoice_type', '!=', False), '|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel')] # force billable type
+ dashboard_data = self.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_invoice_type'], ['timesheet_invoice_type'])
+ dashboard_total_hours = sum([data['unit_amount'] for data in dashboard_data]) + total_canceled_hours
+ for data in dashboard_data:
+ billable_type = data['timesheet_invoice_type']
+ amount = float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
+ if is_uom_day:
+ # convert time from hours to days
+ amount = round(uom_hour._compute_quantity(amount, company_uom, raise_if_failure=False), 2)
+ dashboard_values['time'][billable_type] = amount
+ dashboard_values['time']['total'] += amount
+ # rates
+ rate = round(data.get('unit_amount') / dashboard_total_hours * 100, 2) if dashboard_total_hours else 0.0
+ dashboard_values['rates'][billable_type] = rate
+ dashboard_values['rates']['total'] += rate
+ dashboard_values['time']['total'] = round(dashboard_values['time']['total'], 2)
+
+ # rates from non-invoiced timesheets that are linked to canceled so
+ dashboard_values['rates']['canceled'] = float_round(100 * total_canceled_hours / (dashboard_total_hours or 1), precision_rounding=hour_rounding)
+
+ # profitability, using profitability SQL report
+ field_map = {
+ 'amount_untaxed_invoiced': 'invoiced',
+ 'amount_untaxed_to_invoice': 'to_invoice',
+ 'timesheet_cost': 'cost',
+ 'expense_cost': 'expense_cost',
+ 'expense_amount_untaxed_invoiced': 'expense_amount_untaxed_invoiced',
+ 'other_revenues': 'other_revenues'
+ }
+ profit = dict.fromkeys(list(field_map.values()) + ['other_revenues', 'total'], 0.0)
+ profitability_raw_data = self.env['project.profitability.report'].read_group([('project_id', 'in', self.ids)], ['project_id'] + list(field_map), ['project_id'])
+ for data in profitability_raw_data:
+ company_id = self.env['project.project'].browse(data.get('project_id')[0]).company_id
+ from_currency = company_id.currency_id
+ for field in field_map:
+ value = data.get(field, 0.0)
+ if from_currency != currency:
+ value = from_currency._convert(value, currency, company_id, date.today())
+ profit[field_map[field]] += value
+ profit['total'] = sum([profit[item] for item in profit.keys()])
+ dashboard_values['profit'] = profit
+
+ values['dashboard'] = dashboard_values
+
+ #
+ # Time Repartition (per employee per billable types)
+ #
+ employee_ids = self._plan_get_employee_ids()
+ employee_ids = list(set(employee_ids))
+ # Retrieve the employees for which the current user can see theirs timesheets
+ employee_domain = expression.AND([[('company_id', 'in', self.env.companies.ids)], self.env['account.analytic.line']._domain_employee_id()])
+ employees = self.env['hr.employee'].sudo().browse(employee_ids).filtered_domain(employee_domain)
+ repartition_domain = [('project_id', 'in', self.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type
+ # repartition data, without timesheet on cancelled so
+ repartition_data = self.env['account.analytic.line'].read_group(repartition_domain + ['|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel')], ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False)
+ # read timesheet on cancelled so
+ cancelled_so_timesheet = self.env['account.analytic.line'].read_group(repartition_domain + [('so_line.state', '=', 'cancel')], ['employee_id', 'unit_amount'], ['employee_id'], lazy=False)
+ repartition_data += [{**canceled, 'timesheet_invoice_type': 'canceled'} for canceled in cancelled_so_timesheet]
+
+ # set repartition per type per employee
+ repartition_employee = {}
+ for employee in employees:
+ repartition_employee[employee.id] = dict(
+ employee_id=employee.id,
+ employee_name=employee.name,
+ non_billable_project=0.0,
+ non_billable=0.0,
+ billable_time=0.0,
+ non_billable_timesheet=0.0,
+ billable_fixed=0.0,
+ canceled=0.0,
+ total=0.0,
+ )
+ for data in repartition_data:
+ employee_id = data['employee_id'][0]
+ repartition_employee.setdefault(employee_id, dict(
+ employee_id=data['employee_id'][0],
+ employee_name=data['employee_id'][1],
+ 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,
+ ))[data['timesheet_invoice_type']] = float_round(data.get('unit_amount', 0.0), precision_rounding=hour_rounding)
+ repartition_employee[employee_id]['__domain_' + data['timesheet_invoice_type']] = data['__domain']
+ # compute total
+ for employee_id, vals in repartition_employee.items():
+ 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', '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]
+ values['repartition_employee_max'] = (max(hours_per_employee) if hours_per_employee else 1) or 1
+ values['repartition_employee'] = repartition_employee
+
+ #
+ # Table grouped by SO / SOL / Employees
+ #
+ timesheet_forecast_table_rows = self._table_get_line_values(employees)
+ if timesheet_forecast_table_rows:
+ values['timesheet_forecast_table'] = timesheet_forecast_table_rows
+ return values
+
+ def _table_get_line_values(self, employees=None):
+ """ return the header and the rows informations of the table """
+ if not self:
+ return False
+
+ uom_hour = self.env.ref('uom.product_uom_hour')
+ company_uom = self.env.company.timesheet_encode_uom_id
+ is_uom_day = company_uom and company_uom == self.env.ref('uom.product_uom_day')
+
+ # build SQL query and fetch raw data
+ query, query_params = self._table_rows_sql_query()
+ self.env.cr.execute(query, query_params)
+ raw_data = self.env.cr.dictfetchall()
+ rows_employee = self._table_rows_get_employee_lines(raw_data)
+ default_row_vals = self._table_row_default()
+
+ empty_line_ids, empty_order_ids = self._table_get_empty_so_lines()
+
+ # extract row labels
+ sale_line_ids = set()
+ sale_order_ids = set()
+ for key_tuple, row in rows_employee.items():
+ if row[0]['sale_line_id']:
+ sale_line_ids.add(row[0]['sale_line_id'])
+ if row[0]['sale_order_id']:
+ sale_order_ids.add(row[0]['sale_order_id'])
+
+ sale_orders = self.env['sale.order'].sudo().browse(sale_order_ids | empty_order_ids)
+ sale_order_lines = self.env['sale.order.line'].sudo().browse(sale_line_ids | empty_line_ids)
+ map_so_names = {so.id: so.name for so in sale_orders}
+ map_so_cancel = {so.id: so.state == 'cancel' for so in sale_orders}
+ map_sol = {sol.id: sol for sol in sale_order_lines}
+ map_sol_names = {sol.id: sol.name.split('\n')[0] if sol.name else _('No Sales Order Line') for sol in sale_order_lines}
+ map_sol_so = {sol.id: sol.order_id.id for sol in sale_order_lines}
+
+ rows_sale_line = {} # (so, sol) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
+ for sale_line_id in empty_line_ids: # add service SO line having no timesheet
+ sale_line_row_key = (map_sol_so.get(sale_line_id), sale_line_id)
+ sale_line = map_sol.get(sale_line_id)
+ is_milestone = sale_line.product_id.invoice_policy == 'delivery' and sale_line.product_id.service_type == 'manual' if sale_line else False
+ rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line_id, _('No Sales Order Line')), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line', 'is_milestone': is_milestone}] + default_row_vals[:]
+ if not is_milestone:
+ rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0
+
+ rows_sale_line_all_data = {}
+ if not employees:
+ employees = self.env['hr.employee'].sudo().search(self.env['account.analytic.line']._domain_employee_id())
+ for row_key, row_employee in rows_employee.items():
+ sale_order_id, sale_line_id, employee_id = row_key
+ # sale line row
+ sale_line_row_key = (sale_order_id, sale_line_id)
+ if sale_line_row_key not in rows_sale_line:
+ sale_line = map_sol.get(sale_line_id, self.env['sale.order.line'])
+ is_milestone = sale_line.product_id.invoice_policy == 'delivery' and sale_line.product_id.service_type == 'manual' if sale_line else False
+ rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line.id) if sale_line else _('No Sales Order Line'), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line', 'is_milestone': is_milestone}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
+ if not is_milestone:
+ rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0
+
+ if sale_line_row_key not in rows_sale_line_all_data:
+ rows_sale_line_all_data[sale_line_row_key] = [0] * len(row_employee)
+ for index in range(1, len(row_employee)):
+ if employee_id in employees.ids:
+ rows_sale_line[sale_line_row_key][index] += row_employee[index]
+ rows_sale_line_all_data[sale_line_row_key][index] += row_employee[index]
+ if not rows_sale_line[sale_line_row_key][0].get('is_milestone'):
+ rows_sale_line[sale_line_row_key][-1] = rows_sale_line[sale_line_row_key][-2] - rows_sale_line_all_data[sale_line_row_key][5]
+ else:
+ rows_sale_line[sale_line_row_key][-1] = 0
+
+ rows_sale_order = {} # so -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
+ for row_key, row_sale_line in rows_sale_line.items():
+ sale_order_id = row_key[0]
+ # sale order row
+ if sale_order_id not in rows_sale_order:
+ rows_sale_order[sale_order_id] = [{'label': map_so_names.get(sale_order_id, _('No Sales Order')), 'canceled': map_so_cancel.get(sale_order_id, False), 'res_id': sale_order_id, 'res_model': 'sale.order', 'type': 'sale_order'}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
+
+ for index in range(1, len(row_sale_line)):
+ rows_sale_order[sale_order_id][index] += row_sale_line[index]
+
+ # group rows SO, SOL and their related employee rows.
+ timesheet_forecast_table_rows = []
+ for sale_order_id, sale_order_row in rows_sale_order.items():
+ timesheet_forecast_table_rows.append(sale_order_row)
+ for sale_line_row_key, sale_line_row in rows_sale_line.items():
+ if sale_order_id == sale_line_row_key[0]:
+ sale_order_row[0]['has_children'] = True
+ timesheet_forecast_table_rows.append(sale_line_row)
+ for employee_row_key, employee_row in rows_employee.items():
+ if sale_order_id == employee_row_key[0] and sale_line_row_key[1] == employee_row_key[1] and employee_row_key[2] in employees.ids:
+ sale_line_row[0]['has_children'] = True
+ timesheet_forecast_table_rows.append(employee_row)
+
+ if is_uom_day:
+ # convert all values from hours to days
+ for row in timesheet_forecast_table_rows:
+ for index in range(1, len(row)):
+ row[index] = round(uom_hour._compute_quantity(row[index], company_uom, raise_if_failure=False), 2)
+ # complete table data
+ return {
+ 'header': self._table_header(),
+ 'rows': timesheet_forecast_table_rows
+ }
+ def _table_header(self):
+ initial_date = fields.Date.from_string(fields.Date.today())
+ ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
+
+ def _to_short_month_name(date):
+ month_index = fields.Date.from_string(date).month
+ return babel.dates.get_month_names('abbreviated', locale=get_lang(self.env).code)[month_index]
+
+ header_names = [_('Sales Order'), _('Before')] + [_to_short_month_name(date) for date in ts_months] + [_('Total'), _('Sold'), _('Remaining')]
+
+ result = []
+ for name in header_names:
+ result.append({
+ 'label': name,
+ 'tooltip': '',
+ })
+ # add tooltip for reminaing
+ result[-1]['tooltip'] = _('What is still to deliver based on sold hours and hours already done. Equals to sold hours - done hours.')
+ return result
+
+ def _table_row_default(self):
+ lenght = len(self._table_header())
+ return [0.0] * (lenght - 1) # before, M1, M2, M3, Done, Sold, Remaining
+
+ def _table_rows_sql_query(self):
+ initial_date = fields.Date.from_string(fields.Date.today())
+ ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
+ # build query
+ query = """
+ SELECT
+ 'timesheet' AS type,
+ date_trunc('month', date)::date AS month_date,
+ E.id AS employee_id,
+ S.order_id AS sale_order_id,
+ A.so_line AS sale_line_id,
+ SUM(A.unit_amount) AS number_hours
+ FROM account_analytic_line A
+ JOIN hr_employee E ON E.id = A.employee_id
+ LEFT JOIN sale_order_line S ON S.id = A.so_line
+ WHERE A.project_id IS NOT NULL
+ AND A.project_id IN %s
+ AND A.date < %s
+ GROUP BY date_trunc('month', date)::date, S.order_id, A.so_line, E.id
+ """
+
+ last_ts_month = fields.Date.to_string(fields.Date.from_string(ts_months[-1]) + relativedelta(months=1))
+ query_params = (tuple(self.ids), last_ts_month)
+ return query, query_params
+
+ def _table_rows_get_employee_lines(self, data_from_db):
+ initial_date = fields.Date.today()
+ ts_months = sorted([initial_date - relativedelta(months=i, day=1) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
+ default_row_vals = self._table_row_default()
+
+ # extract employee names
+ employee_ids = set()
+ for data in data_from_db:
+ employee_ids.add(data['employee_id'])
+ map_empl_names = {empl.id: empl.name for empl in self.env['hr.employee'].sudo().browse(employee_ids)}
+
+ # extract rows data for employee, sol and so rows
+ rows_employee = {} # (so, sol, employee) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
+ for data in data_from_db:
+ sale_line_id = data['sale_line_id']
+ sale_order_id = data['sale_order_id']
+ # employee row
+ row_key = (data['sale_order_id'], sale_line_id, data['employee_id'])
+ if row_key not in rows_employee:
+ meta_vals = {
+ 'label': map_empl_names.get(row_key[2]),
+ 'sale_line_id': sale_line_id,
+ 'sale_order_id': sale_order_id,
+ 'res_id': row_key[2],
+ 'res_model': 'hr.employee',
+ 'type': 'hr_employee'
+ }
+ rows_employee[row_key] = [meta_vals] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
+
+ index = False
+ if data['type'] == 'timesheet':
+ if data['month_date'] in ts_months:
+ index = ts_months.index(data['month_date']) + 2
+ elif data['month_date'] < ts_months[0]:
+ index = 1
+ rows_employee[row_key][index] += data['number_hours']
+ rows_employee[row_key][5] += data['number_hours']
+ return rows_employee
+
+ def _table_get_empty_so_lines(self):
+ """ get the Sale Order Lines having no timesheet but having generated a task or a project """
+ so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense and not sol.is_downpayment)
+ # include the service SO line of SO sharing the same project
+ sale_order = self.env['sale.order'].search([('project_id', 'in', self.ids)])
+ return set(so_lines.ids) | set(sale_order.mapped('order_line').filtered(lambda sol: sol.is_service and not sol.is_expense).ids), set(so_lines.mapped('order_id').ids) | set(sale_order.ids)
+
+ # --------------------------------------------------
+ # Actions: Stat buttons, ...
+ # --------------------------------------------------
+
+ def _plan_prepare_actions(self, values):
+ actions = []
+ if len(self) == 1:
+ task_order_line_ids = []
+ # retrieve all the sale order line that we will need later below
+ if self.env.user.has_group('sales_team.group_sale_salesman') or self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
+ task_order_line_ids = self.env['project.task'].read_group([('project_id', '=', self.id), ('sale_line_id', '!=', False)], ['sale_line_id'], ['sale_line_id'])
+ 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 self.bill_type == 'customer_project' and self.allow_billable and not self.sale_order_id:
+ actions.append({
+ 'label': _("Create a Sales Order"),
+ 'type': 'action',
+ 'action_id': 'sale_timesheet.project_project_action_multi_create_sale_order',
+ 'context': json.dumps({'active_id': self.id, 'active_model': 'project.project'}),
+ })
+ if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
+ to_invoice_amount = values['dashboard']['profit'].get('to_invoice', False) # plan project only takes services SO line with timesheet into account
+
+ sale_order_ids = self.env['sale.order.line'].read_group([('id', 'in', task_order_line_ids)], ['order_id'], ['order_id'])
+ sale_order_ids = [s['order_id'][0] for s in sale_order_ids]
+ sale_order_ids = self.env['sale.order'].search_read([('id', 'in', sale_order_ids), ('invoice_status', '=', 'to invoice')], ['id'])
+ sale_order_ids = list(map(lambda x: x['id'], sale_order_ids))
+
+ if to_invoice_amount and sale_order_ids:
+ if len(sale_order_ids) == 1:
+ actions.append({
+ 'label': _("Create Invoice"),
+ 'type': 'action',
+ 'action_id': 'sale.action_view_sale_advance_payment_inv',
+ 'context': json.dumps({'active_ids': sale_order_ids, 'active_model': 'project.project'}),
+ })
+ else:
+ actions.append({
+ 'label': _("Create Invoice"),
+ 'type': 'action',
+ 'action_id': 'sale_timesheet.project_project_action_multi_create_invoice',
+ 'context': json.dumps({'active_id': self.id, 'active_model': 'project.project'}),
+ })
+ return actions
+
+ def _plan_get_stat_button(self):
+ stat_buttons = []
+ num_projects = len(self)
+ if num_projects == 1:
+ action_data = _to_action_data('project.project', res_id=self.id,
+ views=[[self.env.ref('project.edit_project').id, 'form']])
+ else:
+ action_data = _to_action_data(action=self.env.ref('project.open_view_project_all_config').sudo(),
+ domain=[('id', 'in', self.ids)])
+
+ stat_buttons.append({
+ 'name': _('Project') if num_projects == 1 else _('Projects'),
+ 'count': num_projects,
+ 'icon': 'fa fa-puzzle-piece',
+ 'action': action_data
+ })
+
+ # if only one project, add it in the context as default value
+ tasks_domain = [('project_id', 'in', self.ids)]
+ 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)]
+
+ if len(self) == 1:
+ tasks_context = {**tasks_context, 'default_project_id': self.id}
+ elif len(self):
+ task_projects_ids = self.env['project.task'].read_group([('project_id', 'in', self.ids)], ['project_id'], ['project_id'])
+ task_projects_ids = [p['project_id'][0] for p in task_projects_ids]
+ if len(task_projects_ids) == 1:
+ tasks_context = {**tasks_context, 'default_project_id': task_projects_ids[0]}
+
+ stat_buttons.append({
+ 'name': _('Tasks'),
+ 'count': sum(self.mapped('task_count')),
+ 'icon': 'fa fa-tasks',
+ 'action': _to_action_data(
+ action=self.env.ref('project.action_view_task').sudo(),
+ domain=tasks_domain,
+ context=tasks_context
+ )
+ })
+ stat_buttons.append({
+ 'name': [_("Tasks"), _("Late")],
+ 'count': self.env['project.task'].search_count(late_tasks_domain),
+ 'icon': 'fa fa-tasks',
+ 'action': _to_action_data(
+ action=self.env.ref('project.action_view_task').sudo(),
+ domain=late_tasks_domain,
+ context=tasks_context,
+ ),
+ })
+ stat_buttons.append({
+ 'name': [_("Tasks"), _("in Overtime")],
+ 'count': self.env['project.task'].search_count(overtime_tasks_domain),
+ 'icon': 'fa fa-tasks',
+ 'action': _to_action_data(
+ action=self.env.ref('project.action_view_task').sudo(),
+ domain=overtime_tasks_domain,
+ context=tasks_context,
+ ),
+ })
+
+ if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
+ # read all the sale orders linked to the projects' tasks
+ task_so_ids = self.env['project.task'].search_read([
+ ('project_id', 'in', self.ids), ('sale_order_id', '!=', False)
+ ], ['sale_order_id'])
+ task_so_ids = [o['sale_order_id'][0] for o in task_so_ids]
+
+ sale_orders = self.mapped('sale_line_id.order_id') | self.env['sale.order'].browse(task_so_ids)
+ if sale_orders:
+ stat_buttons.append({
+ 'name': _('Sales Orders'),
+ 'count': len(sale_orders),
+ 'icon': 'fa fa-dollar',
+ 'action': _to_action_data(
+ action=self.env.ref('sale.action_orders').sudo(),
+ domain=[('id', 'in', sale_orders.ids)],
+ context={'create': False, 'edit': False, 'delete': False}
+ )
+ })
+
+ invoice_ids = self.env['sale.order'].search_read([('id', 'in', sale_orders.ids)], ['invoice_ids'])
+ invoice_ids = list(itertools.chain(*[i['invoice_ids'] for i in invoice_ids]))
+ invoice_ids = self.env['account.move'].search_read([('id', 'in', invoice_ids), ('move_type', '=', 'out_invoice')], ['id'])
+ invoice_ids = list(map(lambda x: x['id'], invoice_ids))
+
+ if invoice_ids:
+ stat_buttons.append({
+ 'name': _('Invoices'),
+ 'count': len(invoice_ids),
+ 'icon': 'fa fa-pencil-square-o',
+ 'action': _to_action_data(
+ action=self.env.ref('account.action_move_out_invoice_type').sudo(),
+ domain=[('id', 'in', invoice_ids), ('move_type', '=', 'out_invoice')],
+ context={'create': False, 'delete': False}
+ )
+ })
+
+ ts_tree = self.env.ref('hr_timesheet.hr_timesheet_line_tree')
+ ts_form = self.env.ref('hr_timesheet.hr_timesheet_line_form')
+ if self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
+ timesheet_label = [_('Days'), _('Recorded')]
+ else:
+ timesheet_label = [_('Hours'), _('Recorded')]
+
+ stat_buttons.append({
+ 'name': timesheet_label,
+ 'count': sum(self.mapped('total_timesheet_time')),
+ 'icon': 'fa fa-calendar',
+ 'action': _to_action_data(
+ 'account.analytic.line',
+ domain=[('project_id', 'in', self.ids)],
+ views=[(ts_tree.id, 'list'), (ts_form.id, 'form')],
+ )
+ })
+
+ return stat_buttons
+
+
+def _to_action_data(model=None, *, action=None, views=None, res_id=None, domain=None, context=None):
+ # pass in either action or (model, views)
+ if action:
+ assert model is None and views is None
+ act = clean_action(action.read()[0], env=action.env)
+ model = act['res_model']
+ views = act['views']
+ # FIXME: search-view-id, possibly help?
+ descr = {
+ 'data-model': model,
+ 'data-views': json.dumps(views),
+ }
+ if context is not None: # otherwise copy action's?
+ descr['data-context'] = json.dumps(context)
+ if res_id:
+ descr['data-res-id'] = res_id
+ elif domain:
+ descr['data-domain'] = json.dumps(domain)
+ return descr
diff --git a/addons/sale_timesheet/models/project_sale_line_employee_map.py b/addons/sale_timesheet/models/project_sale_line_employee_map.py
new file mode 100644
index 00000000..76a702d8
--- /dev/null
+++ b/addons/sale_timesheet/models/project_sale_line_employee_map.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ProjectProductEmployeeMap(models.Model):
+ _name = 'project.sale.line.employee.map'
+ _description = 'Project Sales line, employee mapping'
+
+ 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)])
+ 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=True)
+ 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)
+ res._update_project_timesheet()
+ return res
+
+ def write(self, values):
+ res = super(ProjectProductEmployeeMap, self).write(values)
+ self._update_project_timesheet()
+ return res
+
+ def _update_project_timesheet(self):
+ self.filtered(lambda l: l.sale_line_id).project_id._update_timesheets_sale_line_id()
diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py
new file mode 100644
index 00000000..af066c44
--- /dev/null
+++ b/addons/sale_timesheet/models/sale_order.py
@@ -0,0 +1,213 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.osv import expression
+import math
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ timesheet_ids = fields.Many2many('account.analytic.line', compute='_compute_timesheet_ids', string='Timesheet activities associated to this sale')
+ 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="['|', ('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")
+
+ @api.depends('analytic_account_id.line_ids')
+ def _compute_timesheet_ids(self):
+ for order in self:
+ if order.analytic_account_id:
+ order.timesheet_ids = self.env['account.analytic.line'].search(
+ [('so_line', 'in', order.order_line.ids),
+ ('amount', '<=', 0.0),
+ ('project_id', '!=', False)])
+ else:
+ order.timesheet_ids = []
+ order.timesheet_count = len(order.timesheet_ids)
+
+ @api.depends('timesheet_ids', 'company_id.timesheet_encode_uom_id')
+ def _compute_timesheet_total_duration(self):
+ 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.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
+ total_time *= sale_order.timesheet_encode_uom_id.factor
+ sale_order.timesheet_total_duration = total_time
+
+ def action_view_project_ids(self):
+ self.ensure_one()
+ # redirect to form or kanban view
+ billable_projects = self.project_ids.filtered(lambda project: project.sale_line_id)
+ if len(billable_projects) == 1 and self.env.user.has_group('project.group_project_manager'):
+ action = billable_projects[0].action_view_timesheet_plan()
+ else:
+ action = super().action_view_project_ids()
+ return action
+
+ 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'] = {
+ 'search_default_billable_timesheet': True
+ } # erase default filters
+ if self.timesheet_count > 0:
+ action['domain'] = [('so_line', 'in', self.order_line.ids)]
+ else:
+ action = {'type': 'ir.actions.act_window_close'}
+ return action
+
+ def _create_invoices(self, grouped=False, final=False, start_date=None, end_date=None):
+ """ Override the _create_invoice method in sale.order model in sale module
+ Add new parameter in this method, to invoice sale.order with a date. This date is used in sale_make_invoice_advance_inv into this module.
+ :param start_date: the start date of the period
+ :param end_date: the end date of the period
+ :return {account.move}: the invoices created
+ """
+ moves = super(SaleOrder, self)._create_invoices(grouped, final)
+ moves._link_timesheets_to_invoice(start_date, end_date)
+ return moves
+
+class SaleOrderLine(models.Model):
+ _inherit = "sale.order.line"
+
+ qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
+ analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense)
+ remaining_hours_available = fields.Boolean(compute='_compute_remaining_hours_available')
+ remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours')
+
+ def name_get(self):
+ res = super(SaleOrderLine, self).name_get()
+ if self.env.context.get('with_remaining_hours'):
+ names = dict(res)
+ result = []
+ uom_hour = self.env.ref('uom.product_uom_hour')
+ uom_day = self.env.ref('uom.product_uom_day')
+ for line in self:
+ name = names.get(line.id)
+ if line.remaining_hours_available:
+ company = self.env.company
+ encoding_uom = company.timesheet_encode_uom_id
+ remaining_time = ''
+ if encoding_uom == uom_hour:
+ hours, minutes = divmod(abs(line.remaining_hours) * 60, 60)
+ round_minutes = minutes / 30
+ minutes = math.ceil(round_minutes) if line.remaining_hours >= 0 else math.floor(round_minutes)
+ if minutes > 1:
+ minutes = 0
+ hours += 1
+ else:
+ minutes = minutes * 30
+ remaining_time =' ({sign}{hours:02.0f}:{minutes:02.0f})'.format(
+ sign='-' if line.remaining_hours < 0 else '',
+ hours=hours,
+ minutes=minutes)
+ elif encoding_uom == uom_day:
+ remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False)
+ remaining_time = ' ({qty:.02f} {unit})'.format(
+ qty=remaining_days,
+ unit=_('days') if abs(remaining_days) > 1 else _('day')
+ )
+ name = '{name}{remaining_time}'.format(
+ name=name,
+ remaining_time=remaining_time
+ )
+ result.append((line.id, name))
+ return result
+ return res
+
+ @api.depends('product_id.service_policy')
+ def _compute_remaining_hours_available(self):
+ uom_hour = self.env.ref('uom.product_uom_hour')
+ for line in self:
+ is_ordered_timesheet = line.product_id.service_policy == 'ordered_timesheet'
+ is_time_product = line.product_uom.category_id == uom_hour.category_id
+ line.remaining_hours_available = is_ordered_timesheet and is_time_product
+
+ @api.depends('qty_delivered', 'product_uom_qty', 'analytic_line_ids')
+ def _compute_remaining_hours(self):
+ uom_hour = self.env.ref('uom.product_uom_hour')
+ for line in self:
+ remaining_hours = None
+ if line.remaining_hours_available:
+ qty_left = line.product_uom_qty - line.qty_delivered
+ remaining_hours = line.product_uom._compute_quantity(qty_left, uom_hour)
+ line.remaining_hours = remaining_hours
+
+ @api.depends('product_id')
+ def _compute_qty_delivered_method(self):
+ """ Sale Timesheet module compute delivered qty for product [('type', 'in', ['service']), ('service_type', '=', 'timesheet')] """
+ super(SaleOrderLine, self)._compute_qty_delivered_method()
+ for line in self:
+ 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', '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()
+
+ lines_by_timesheet = self.filtered(lambda sol: sol.qty_delivered_method == 'timesheet')
+ domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
+ mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
+ for line in lines_by_timesheet:
+ line.qty_delivered = mapping.get(line.id or line._origin.id, 0.0)
+
+ def _timesheet_compute_delivered_quantity_domain(self):
+ """ Hook for validated timesheet in addionnal module """
+ return [('project_id', '!=', False), ('non_allow_billable', '=', False)]
+
+ ###########################################
+ # Service : Project and task generation
+ ###########################################
+
+ def _convert_qty_company_hours(self, dest_company):
+ company_time_uom_id = dest_company.project_time_mode_id
+ if self.product_uom.id != company_time_uom_id.id and self.product_uom.category_id.id == company_time_uom_id.category_id.id:
+ planned_hours = self.product_uom._compute_quantity(self.product_uom_qty, company_time_uom_id)
+ else:
+ planned_hours = self.product_uom_qty
+ return planned_hours
+
+ def _timesheet_create_project(self):
+ project = super()._timesheet_create_project()
+ 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
+
+ Search the existed timesheets between the given period in parameter.
+ Retrieve the unit_amount of this timesheet and then recompute
+ the qty_to_invoice for each current product.
+
+ :param start_date: the start date of the period
+ :param end_date: the end date of the period
+ """
+ lines_by_timesheet = self.filtered(lambda sol: sol.product_id and sol.product_id._is_delivered_timesheet())
+ domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
+ domain = expression.AND([domain, [
+ '|',
+ ('timesheet_invoice_id', '=', False),
+ ('timesheet_invoice_id.state', '=', 'cancel')]])
+ if start_date:
+ domain = expression.AND([domain, [('date', '>=', start_date)]])
+ if end_date:
+ domain = expression.AND([domain, [('date', '<=', end_date)]])
+ mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
+
+ for line in lines_by_timesheet:
+ line.qty_to_invoice = mapping.get(line.id, 0.0)