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)