diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py
index cbe9140b59a..b12a9e58308 100644
--- a/addons/hr_timesheet/models/hr_timesheet.py
+++ b/addons/hr_timesheet/models/hr_timesheet.py
@@ -4,7 +4,7 @@
from lxml import etree
import re
-from odoo import api, fields, models, _
+from odoo import api, Command, fields, models, _
from odoo.exceptions import UserError, AccessError
from odoo.osv import expression
@@ -178,6 +178,18 @@ class AccountAnalyticLine(models.Model):
Overrride this to compute on the fly some field that can not be computed fields.
:param values: dict values for `create`or `write`.
"""
+ # task implies analytic account and tags
+ if vals.get('task_id') and not vals.get('account_id'):
+ task = self.env['project.task'].browse(vals.get('task_id'))
+ task_analytic_account_id = task._get_task_analytic_account_id()
+ vals['account_id'] = task_analytic_account_id.id
+ vals['company_id'] = task_analytic_account_id.company_id.id or task.company_id.id
+ if vals.get('tag_ids'):
+ vals['tag_ids'] += [Command.link(tag_id.id) for tag_id in task.analytic_tag_ids]
+ else:
+ vals['tag_ids'] = [Command.set(task.analytic_tag_ids.ids)]
+ if not task_analytic_account_id.active:
+ raise UserError(_('You cannot add timesheets to a project or a task linked to an inactive analytic account.'))
# project implies analytic account
if vals.get('project_id') and not vals.get('account_id'):
project = self.env['project.project'].browse(vals.get('project_id'))
@@ -225,7 +237,7 @@ class AccountAnalyticLine(models.Model):
# (re)compute the amount (depending on unit_amount, employee_id for the cost, and account_id for currency)
if any(field_name in values for field_name in ['unit_amount', 'employee_id', 'account_id']):
for timesheet in sudo_self:
- cost = timesheet.employee_id.timesheet_cost or 0.0
+ cost = timesheet._employee_timesheet_cost()
amount = -timesheet.unit_amount * cost
amount_converted = timesheet.employee_id.currency_id._convert(
amount, timesheet.account_id.currency_id, self.env.company, timesheet.date)
@@ -246,3 +258,7 @@ class AccountAnalyticLine(models.Model):
def _get_timesheet_time_day(self):
return self._convert_hours_to_days(self.unit_amount)
+
+ def _employee_timesheet_cost(self):
+ self.ensure_one()
+ return self.employee_id.timesheet_cost or 0.0
diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py
index d7e4d41b4c9..96d41250b3f 100644
--- a/addons/hr_timesheet/models/project.py
+++ b/addons/hr_timesheet/models/project.py
@@ -204,11 +204,11 @@ class Task(models.Model):
def _compute_encode_uom_in_days(self):
self.encode_uom_in_days = self._uom_in_days()
- @api.depends('project_id.analytic_account_id.active')
+ @api.depends('analytic_account_id.active', 'project_id.analytic_account_id.active')
def _compute_analytic_account_active(self):
""" Overridden in sale_timesheet """
for task in self:
- task.analytic_account_active = task.project_id.analytic_account_id.active
+ task.analytic_account_active = task._get_task_analytic_account_id().active
@api.depends('timesheet_ids.unit_amount')
def _compute_effective_hours(self):
diff --git a/addons/project/models/__init__.py b/addons/project/models/__init__.py
index 83a5980fd1c..4bf11751b09 100644
--- a/addons/project/models/__init__.py
+++ b/addons/project/models/__init__.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import analytic_account
+from . import analytic_account_tag
from . import project_milestone
from . import project_project_stage
from . import project_task_recurrence
diff --git a/addons/project/models/analytic_account_tag.py b/addons/project/models/analytic_account_tag.py
new file mode 100644
index 00000000000..f686c30dd9d
--- /dev/null
+++ b/addons/project/models/analytic_account_tag.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+class AccountAnalyticTag(models.Model):
+ _inherit = 'account.analytic.tag'
+
+ task_ids = fields.Many2many('project.task', string='Tasks')
diff --git a/addons/project/models/project.py b/addons/project/models/project.py
index 009cdf542eb..b8895e7a484 100644
--- a/addons/project/models/project.py
+++ b/addons/project/models/project.py
@@ -1040,6 +1040,16 @@ class Task(models.Model):
repeat_show_week = fields.Boolean(compute='_compute_repeat_visibility')
repeat_show_month = fields.Boolean(compute='_compute_repeat_visibility')
+ # Account analytic
+ analytic_account_id = fields.Many2one('account.analytic.account', ondelete='set null',
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", check_company=True,
+ help="Analytic account to which this task is linked for financial management. "
+ "Use an analytic account to record cost and revenue on your task. "
+ "If empty, the analytic account of the project will be used.")
+ project_analytic_account_id = fields.Many2one('account.analytic.account', string='Project Analytic Account', related='project_id.analytic_account_id')
+ analytic_tag_ids = fields.Many2many('account.analytic.tag',
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", check_company=True)
+
@property
def SELF_READABLE_FIELDS(self):
return PROJECT_TASK_READABLE_FIELDS | self.SELF_WRITABLE_FIELDS
@@ -1979,6 +1989,13 @@ class Task(models.Model):
portal_users = task.message_partner_ids.user_ids.filtered('share')
task.message_unsubscribe(partner_ids=portal_users.partner_id.ids)
+ # ---------------------------------------------------
+ # Analytic accounting
+ # ---------------------------------------------------
+ def _get_task_analytic_account_id(self):
+ self.ensure_one()
+ return self.analytic_account_id or self.project_analytic_account_id
+
class ProjectTags(models.Model):
""" Tags of project's tasks """
_name = "project.tags"
diff --git a/addons/project/models/project_task_recurrence.py b/addons/project/models/project_task_recurrence.py
index 7a690690b14..f9964deb313 100644
--- a/addons/project/models/project_task_recurrence.py
+++ b/addons/project/models/project_task_recurrence.py
@@ -146,7 +146,7 @@ class ProjectTaskRecurrence(models.Model):
return ['message_partner_ids', 'company_id', 'description', 'displayed_image_id', 'email_cc',
'parent_id', 'partner_email', 'partner_id', 'partner_phone', 'planned_hours',
'project_id', 'project_privacy_visibility', 'sequence', 'tag_ids', 'recurrence_id',
- 'name', 'recurring_task']
+ 'name', 'recurring_task', 'analytic_account_id']
def _get_weekdays(self, n=1):
self.ensure_one()
diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml
index b48ab46a6cb..ad4f69b3f52 100644
--- a/addons/project/views/project_views.xml
+++ b/addons/project/views/project_views.xml
@@ -930,6 +930,8 @@
+
+
diff --git a/addons/sale_margin/models/sale_order.py b/addons/sale_margin/models/sale_order.py
index 4749b0885eb..7f897968245 100644
--- a/addons/sale_margin/models/sale_order.py
+++ b/addons/sale_margin/models/sale_order.py
@@ -23,31 +23,8 @@ class SaleOrderLine(models.Model):
line.purchase_price = 0.0
continue
line = line.with_company(line.company_id)
- product = line.product_id
- product_cost = product.standard_price
- if not product_cost:
- # If the standard_price is 0
- # Avoid unnecessary computations
- # and currency conversions
- if not line.purchase_price:
- line.purchase_price = 0.0
- continue
- fro_cur = product.cost_currency_id
- to_cur = line.currency_id or line.order_id.currency_id
- if line.product_uom and line.product_uom != product.uom_id:
- product_cost = product.uom_id._compute_price(
- product_cost,
- line.product_uom,
- )
- line.purchase_price = fro_cur._convert(
- from_amount=product_cost,
- to_currency=to_cur,
- company=line.company_id or self.env.company,
- date=line.order_id.date_order or fields.Date.today(),
- round=False,
- ) if to_cur and product_cost else product_cost
- # The pricelist may not have been set, therefore no conversion
- # is needed because we don't know the target currency..
+ product_cost = line.product_id.standard_price
+ line.purchase_price = line._convert_price(product_cost, line.product_id.uom_id)
@api.depends('price_subtotal', 'product_uom_qty', 'purchase_price')
def _compute_margin(self):
@@ -55,6 +32,31 @@ class SaleOrderLine(models.Model):
line.margin = line.price_subtotal - (line.purchase_price * line.product_uom_qty)
line.margin_percent = line.price_subtotal and line.margin/line.price_subtotal
+ def _convert_price(self, product_cost, from_uom):
+ self.ensure_one()
+ if not product_cost:
+ # If the standard_price is 0
+ # Avoid unnecessary computations
+ # and currency conversions
+ if not self.purchase_price:
+ return product_cost
+ from_currency = self.product_id.cost_currency_id
+ to_cur = self.currency_id or self.order_id.currency_id
+ to_uom = self.product_uom
+ if to_uom and to_uom != from_uom:
+ product_cost = from_uom._compute_price(
+ product_cost,
+ to_uom,
+ )
+ return from_currency._convert(
+ from_amount=product_cost,
+ to_currency=to_cur,
+ company=self.company_id or self.env.company,
+ date=self.order_id.date_order or fields.Date.today(),
+ round=False,
+ ) if to_cur and product_cost else product_cost
+ # The pricelist may not have been set, therefore no conversion
+ # is needed because we don't know the target currency..
class SaleOrder(models.Model):
_inherit = "sale.order"
diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py
index 10bb47f2a5d..204ed64dd5f 100644
--- a/addons/sale_project/models/project.py
+++ b/addons/sale_project/models/project.py
@@ -12,7 +12,7 @@ class Project(models.Model):
sale_line_id = fields.Many2one(
'sale.order.line', 'Sales Order Item', copy=False,
- compute="_compute_sale_line_id", store=True, readonly=False,
+ compute="_compute_sale_line_id", store=True, readonly=False, index=True,
domain="[('is_service', '=', True), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_partner_id', '=?', partner_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
help="Sales order item to which the project is linked. Link the timesheet entry to the sales order item defined on the project. "
"Only applies on tasks without sale order item defined, and if the employee is not in the 'Employee/Sales Order Item Mapping' of the project.")
@@ -61,7 +61,7 @@ class ProjectTask(models.Model):
sale_order_id = fields.Many2one('sale.order', 'Sales Order', compute='_compute_sale_order_id', store=True, help="Sales order to which the task is linked.")
sale_line_id = fields.Many2one(
'sale.order.line', 'Sales Order Item', domain="[('company_id', '=', company_id), ('is_service', '=', True), ('order_partner_id', 'child_of', commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]",
- compute='_compute_sale_line', recursive=True, store=True, readonly=False, copy=False, tracking=True,
+ compute='_compute_sale_line', recursive=True, store=True, readonly=False, copy=False, tracking=True, index=True,
help="Sales Order Item to which the time spent on this task will be added, in order to be invoiced to your customer.")
project_sale_order_id = fields.Many2one('sale.order', string="Project's sale order", related='project_id.sale_order_id')
invoice_count = fields.Integer("Number of invoices", related='sale_order_id.invoice_count')
diff --git a/addons/sale_project/models/sale_order.py b/addons/sale_project/models/sale_order.py
index b91be8db811..48e6062241e 100644
--- a/addons/sale_project/models/sale_order.py
+++ b/addons/sale_project/models/sale_order.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models, _
+from odoo import api, Command, fields, models, _
from odoo.tools.safe_eval import safe_eval
from odoo.tools.sql import column_exists, create_column
@@ -353,3 +353,40 @@ class SaleOrderLine(models.Model):
project = map_so_project[so_line.order_id.id]
if not so_line.task_id:
so_line._timesheet_create_task(project=project)
+
+ def _prepare_invoice_line(self, **optional_values):
+ """
+ If the sale order line isn't linked to a sale order which already have a default analytic account,
+ this method allows to retrieve the analytic account which is linked to project or task directly linked
+ to this sale order line, or the analytic account of the project which uses this sale order line, if it exists.
+ """
+ values = super(SaleOrderLine, self)._prepare_invoice_line(**optional_values)
+ if not values['analytic_account_id']:
+ if self.task_id.analytic_account_id:
+ values['analytic_account_id'] = self.task_id._get_task_analytic_account_id().id
+ elif self.project_id.analytic_account_id:
+ values['analytic_account_id'] = self.project_id.analytic_account_id.id
+ elif self.is_service and not self.is_expense:
+ task_analytic_account_id = self.env['project.task'].read_group([
+ ('sale_line_id', '=', self.id),
+ ('analytic_account_id', '!=', False),
+ ], ['analytic_account_id'], ['analytic_account_id'])
+ project_analytic_account_id = self.env['project.project'].read_group([
+ ('analytic_account_id', '!=', False),
+ '|',
+ ('sale_line_id', '=', self.id),
+ '&',
+ ('tasks.sale_line_id', '=', self.id),
+ ('tasks.analytic_account_id', '=', False)
+ ], ['analytic_account_id'], ['analytic_account_id'])
+ analytic_account_ids = {rec['analytic_account_id'][0] for rec in (task_analytic_account_id + project_analytic_account_id)}
+ if len(analytic_account_ids) == 1:
+ values['analytic_account_id'] = analytic_account_ids.pop()
+ if self.task_id.analytic_tag_ids:
+ values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in self.task_id.analytic_tag_ids]
+ elif self.is_service and not self.is_expense:
+ tag_ids = self.env['account.analytic.tag'].search([
+ ('task_ids.sale_line_id', '=', self.id)
+ ])
+ values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in tag_ids]
+ return values
diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py
index 3704da2b4da..8b9e65168b4 100644
--- a/addons/sale_timesheet/models/account.py
+++ b/addons/sale_timesheet/models/account.py
@@ -98,7 +98,7 @@ class AccountAnalyticLine(models.Model):
if not self.task_id:
if self.project_id.pricing_type == 'employee_rate':
- map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', self.project_id.id), ('employee_id', '=', self.employee_id.id)])
+ map_entry = self._get_employee_mapping_entry()
if map_entry:
return map_entry.sale_line_id
if self.project_id.sale_line_id:
@@ -149,3 +149,14 @@ class AccountAnalyticLine(models.Model):
def _unlink_except_invoiced(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.'))
+
+ def _get_employee_mapping_entry(self):
+ self.ensure_one()
+ return self.env['project.sale.line.employee.map'].search([('project_id', '=', self.project_id.id), ('employee_id', '=', self.employee_id.id)])
+
+ def _employee_timesheet_cost(self):
+ if self.project_id.pricing_type == 'employee_rate':
+ mapping_entry = self._get_employee_mapping_entry()
+ if mapping_entry:
+ return mapping_entry.cost
+ return super()._employee_timesheet_cost()
diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py
index 9af4cd514b6..217c9fe5040 100644
--- a/addons/sale_timesheet/models/project.py
+++ b/addons/sale_timesheet/models/project.py
@@ -347,7 +347,7 @@ class ProjectTask(models.Model):
return res
sale_order_id = fields.Many2one(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')
+ so_analytic_account_id = fields.Many2one(related='sale_order_id.analytic_account_id', string='Sale Order Analytic Account')
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)
@@ -384,11 +384,11 @@ class ProjectTask(models.Model):
for task in self:
task.remaining_hours_so = mapped_remaining_hours[task._origin.id]
- @api.depends('analytic_account_id.active')
+ @api.depends('so_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
+ task.analytic_account_active = task.analytic_account_active or task.so_analytic_account_id.active
@api.depends('allow_billable')
def _compute_sale_order_id(self):
@@ -437,4 +437,4 @@ class ProjectTaskRecurrence(models.Model):
@api.model
def _get_recurring_fields(self):
- return ['analytic_account_id'] + super(ProjectTaskRecurrence, self)._get_recurring_fields()
+ return ['so_analytic_account_id'] + super(ProjectTaskRecurrence, self)._get_recurring_fields()
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 7e12c500274..187e0bcbb60 100644
--- a/addons/sale_timesheet/models/project_sale_line_employee_map.py
+++ b/addons/sale_timesheet/models/project_sale_line_employee_map.py
@@ -10,7 +10,7 @@ class ProjectProductEmployeeMap(models.Model):
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", compute="_compute_sale_line_id", store=True, readonly=False, required=True,
+ sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", compute="_compute_sale_line_id", store=True, readonly=False,
domain="""[
('is_service', '=', True),
('is_expense', '=', False),
@@ -21,6 +21,10 @@ class ProjectProductEmployeeMap(models.Model):
partner_id = fields.Many2one(related='project_id.partner_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_currency_id', store=True, readonly=False)
+ cost = fields.Monetary(currency_field='cost_currency_id', compute='_compute_cost', store=True, readonly=False,
+ help="This cost overrides the employee's default timesheet cost in employee's HR Settings")
+ cost_currency_id = fields.Many2one('res.currency', string="Cost Currency", related='employee_id.currency_id', readonly=True)
+ is_cost_changed = fields.Boolean('Is Cost Manually Changed', compute='_compute_is_cost_changed', store=True)
_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.'),
@@ -48,6 +52,17 @@ class ProjectProductEmployeeMap(models.Model):
for line in self:
line.currency_id = line.sale_line_id.currency_id if line.sale_line_id else False
+ @api.depends('employee_id.timesheet_cost')
+ def _compute_cost(self):
+ for map_entry in self:
+ if not map_entry.is_cost_changed:
+ map_entry.cost = map_entry.employee_id.timesheet_cost or 0.0
+
+ @api.depends('cost')
+ def _compute_is_cost_changed(self):
+ for map_entry in self:
+ map_entry.is_cost_changed = map_entry.employee_id and map_entry.cost != map_entry.employee_id.timesheet_cost
+
@api.model
def create(self, values):
res = super(ProjectProductEmployeeMap, self).create(values)
diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml
index 5221810b28a..159a4514528 100644
--- a/addons/sale_timesheet/views/project_task_views.xml
+++ b/addons/sale_timesheet/views/project_task_views.xml
@@ -33,7 +33,9 @@
+
+
@@ -115,9 +117,6 @@
-
-
-
diff --git a/addons/sale_timesheet_margin/__init__.py b/addons/sale_timesheet_margin/__init__.py
new file mode 100644
index 00000000000..dc5e6b693d1
--- /dev/null
+++ b/addons/sale_timesheet_margin/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/addons/sale_timesheet_margin/__manifest__.py b/addons/sale_timesheet_margin/__manifest__.py
new file mode 100644
index 00000000000..c257457bf32
--- /dev/null
+++ b/addons/sale_timesheet_margin/__manifest__.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Service Margins in Sales Orders',
+ 'version': '1.0',
+ 'summary': 'Bridge module between Sales Margin and Sales Timesheet',
+ 'description': """
+Allows to compute accurate margin for Service sales.
+======================================================
+""",
+ 'category': 'Hidden',
+ 'depends': ['sale_margin', 'sale_timesheet'],
+ 'demo': [],
+ 'data': [],
+ 'auto_install': True,
+ 'license': 'LGPL-3',
+}
diff --git a/addons/sale_timesheet_margin/models/__init__.py b/addons/sale_timesheet_margin/models/__init__.py
new file mode 100644
index 00000000000..83c06ee1343
--- /dev/null
+++ b/addons/sale_timesheet_margin/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import sale_order_line
diff --git a/addons/sale_timesheet_margin/models/sale_order_line.py b/addons/sale_timesheet_margin/models/sale_order_line.py
new file mode 100644
index 00000000000..4ab35913c9f
--- /dev/null
+++ b/addons/sale_timesheet_margin/models/sale_order_line.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import api, models
+
+class SaleOrderLine(models.Model):
+ _inherit = "sale.order.line"
+
+ @api.depends('analytic_line_ids.amount', 'qty_delivered_method')
+ def _compute_purchase_price(self):
+ timesheet_sols = self.filtered(
+ lambda sol: sol.qty_delivered_method == 'timesheet' and not sol.product_id.standard_price
+ )
+ super(SaleOrderLine, self - timesheet_sols)._compute_purchase_price()
+ if timesheet_sols:
+ group_amount = self.env['account.analytic.line'].read_group(
+ [('so_line', 'in', timesheet_sols.ids), ('project_id', '!=', False)],
+ ['so_line', 'amount:sum', 'unit_amount:sum'],
+ ['so_line'])
+ mapped_sol_timesheet_amount = {
+ amount['so_line'][0]: -amount['amount'] / amount['unit_amount']
+ for amount in group_amount
+ }
+ for line in timesheet_sols:
+ line = line.with_company(line.company_id)
+ product_cost = mapped_sol_timesheet_amount.get(line.id, line.product_id.standard_price)
+ if line.product_id.uom_id != line.company_id.project_time_mode_id and\
+ line.product_id.uom_id.category_id.id == line.company_id.project_time_mode_id.category_id.id:
+ product_cost = line.company_id.project_time_mode_id._compute_quantity(
+ product_cost,
+ line.product_id.uom_id
+ )
+ line.purchase_price = line._convert_price(product_cost, line.product_id.uom_id)