From d4a0bcaa5a24842b99ad61e1e41a31152662e89c Mon Sep 17 00:00:00 2001 From: Thibault Libioulle Date: Thu, 29 Apr 2021 10:43:35 +0200 Subject: [PATCH 1/4] [IMP] sale_project: link project analytic account to invoice Previously when a user added products to a task, when invoicing the customer, in default case, no analytic account was defined on the invoice line generated for the sale order line. With this commit, we define a default analytic account based on the information of the sale order line. If the sale order line has a generated task or project associated to it, we set the project analytic account. If the sale order line is used on tasks or projects which all use the same analytic account, then the invoice line created will take this unique analytic account as default value. If a sale order line is used in distinct project or tasks from distinct project, then no default analytic account is defined. PR : #70527 task-2458135 --- addons/sale_project/models/project.py | 4 ++-- addons/sale_project/models/sale_order.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) 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..10795b84a15 100644 --- a/addons/sale_project/models/sale_order.py +++ b/addons/sale_project/models/sale_order.py @@ -353,3 +353,24 @@ 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.project_id.analytic_account_id: + values['analytic_account_id'] = self.project_id.analytic_account_id + elif self.task_id.project_id.analytic_account_id: + values['analytic_account_id'] = self.task_id.project_id.analytic_account_id + elif self.is_service and not self.is_expense: + project_analytic_account_id = self.env['project.project'].read_group([ + '|', ('sale_line_id', '=', self.id), ('task_ids.sale_line_id', '=', self.id) + ], ['analytic_account_id'], ['analytic_account_id']) + analytic_account_ids = {rec['analytic_account_id'][0] for rec in project_analytic_account_id} + if len(analytic_account_ids) == 1: + values['analytic_account_id'] = analytic_account_ids.pop() + return values From dd706ea024f757f359f0e557844e8a5329a235ea Mon Sep 17 00:00:00 2001 From: Thibault Libioulle Date: Mon, 5 Jul 2021 18:43:12 +0200 Subject: [PATCH 2/4] [IMP] (sale_)project,hr_timesheet: add analytic account and tags on task Previously, all costs allocated to a task were associated, if set, to the analytic account of their project. This commit adds a deeper level of granularity, as the user will be able to override the project analytic account and add analytic tags. When creating timesheets or creating account move line related to SO linked to a task, the analytic account of the task is now taken into account rather than the one defined on the project. If an analytic account is set on the SO, it remains the default account used. If a task is related to an SOL and an invoice line is created from this SOL, the analytic tags of the task are also given to the account move line. If different analytic account are linked to a same SOL through the project using this SOL or the tasks using this SOL, than no default analytic account is set on the invoice line. PR : #70527 task-2458135 --- addons/hr_timesheet/models/hr_timesheet.py | 14 +++++++- addons/hr_timesheet/models/project.py | 4 +-- addons/project/models/__init__.py | 1 + addons/project/models/analytic_account_tag.py | 9 ++++++ addons/project/models/project.py | 17 ++++++++++ .../project/models/project_task_recurrence.py | 2 +- addons/project/views/project_views.xml | 2 ++ addons/sale_project/models/sale_order.py | 32 ++++++++++++++----- addons/sale_timesheet/models/project.py | 8 ++--- .../views/project_task_views.xml | 3 -- 10 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 addons/project/models/analytic_account_tag.py diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index cbe9140b59a..9533ed792f3 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')) 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_project/models/sale_order.py b/addons/sale_project/models/sale_order.py index 10795b84a15..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 @@ -362,15 +362,31 @@ class SaleOrderLine(models.Model): """ values = super(SaleOrderLine, self)._prepare_invoice_line(**optional_values) if not values['analytic_account_id']: - if self.project_id.analytic_account_id: - values['analytic_account_id'] = self.project_id.analytic_account_id - elif self.task_id.project_id.analytic_account_id: - values['analytic_account_id'] = self.task_id.project_id.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: - project_analytic_account_id = self.env['project.project'].read_group([ - '|', ('sale_line_id', '=', self.id), ('task_ids.sale_line_id', '=', self.id) + 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']) - analytic_account_ids = {rec['analytic_account_id'][0] for rec in project_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/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/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 5221810b28a..91c69de17f3 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -115,9 +115,6 @@ - - - From dbbfce18a4d0f76360f7635c585bf01ba4df0275 Mon Sep 17 00:00:00 2001 From: Thibault Libioulle Date: Fri, 7 May 2021 11:31:52 +0200 Subject: [PATCH 3/4] [IMP] hr{,sale}_timesheet: add employee mapping cost Prior to this commit, the timesheet amount was computed based on the employee timesheet cost. The sale order line related to the employee was also required This commit allows the project owner to configure a employee mapping cost that will override the employee timesheet cost if defined. This commit removes the fact that the sale order line is no more required, thus the mapping will have no effect. PR : #70527 task-2458135 --- addons/hr_timesheet/models/hr_timesheet.py | 6 +++++- addons/sale_timesheet/models/account.py | 13 ++++++++++++- .../models/project_sale_line_employee_map.py | 17 ++++++++++++++++- .../sale_timesheet/views/project_task_views.xml | 2 ++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index 9533ed792f3..b12a9e58308 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -237,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) @@ -258,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/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_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 91c69de17f3..159a4514528 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -33,7 +33,9 @@ + + From 8bbbc69e40d0fbde9a90a2c31eea963eb6e87391 Mon Sep 17 00:00:00 2001 From: Thibault Libioulle Date: Fri, 7 May 2021 11:50:45 +0200 Subject: [PATCH 4/4] [IMP] sale{,_project}_margin: improve service product cost computation Prior to this commit, the cost of a service product was computed based on its standard_price. With this commit, the product cost is computed based on the timesheet amounts linked to the sale order, if the sale order has a delivering method of type timesheet. PR : #70527 See odoo/upgrade#2616 task-2458135 --- addons/sale_margin/models/sale_order.py | 52 ++++++++++--------- addons/sale_timesheet_margin/__init__.py | 4 ++ addons/sale_timesheet_margin/__manifest__.py | 18 +++++++ .../sale_timesheet_margin/models/__init__.py | 4 ++ .../models/sale_order_line.py | 32 ++++++++++++ 5 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 addons/sale_timesheet_margin/__init__.py create mode 100644 addons/sale_timesheet_margin/__manifest__.py create mode 100644 addons/sale_timesheet_margin/models/__init__.py create mode 100644 addons/sale_timesheet_margin/models/sale_order_line.py 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_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)