[MERGE] sale_project: improve analytic account

This PR improves the analytic accounting in project:

1) Analytic account of the project is passed to the invoice lines
generated from the project's SO lines.
2) Analytic account and analytic tags are added on task in order to
gain in granularity. Those tags are passed to the invoice lines
generated from the task's SO lines.
3) Employee timesheet cost now may be overriden in the project
employee mapping, if there is an employee rate. This cost is used
to compute the amount (price) of the timesheets.
4) The sale order margin is computed based on the timesheeted
amounts (prices) per unit of measure, for services with quantity
delivered based on timesheets.

PR : #70527
PR upgrade: odoo/upgrade#2616

task-2458135

--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr

Signed-off-by: LTU-Odoo <IT-Ideas@users.noreply.github.com>
This commit is contained in:
Odoo's Mergebot
2021-09-03 16:29:54 +02:00
committed by GitHub
18 changed files with 209 additions and 42 deletions
+18 -2
View File
@@ -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
+2 -2
View File
@@ -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):
+1
View File
@@ -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
@@ -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')
+17
View File
@@ -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"
@@ -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()
+2
View File
@@ -930,6 +930,8 @@
<page name="extra_info" string="Extra Info" groups="base.group_no_one">
<group>
<group>
<field name="analytic_account_id" groups="analytic.group_analytic_accounting" context="{'default_partner_id': partner_id}"/>
<field name="analytic_tag_ids" groups="analytic.group_analytic_tags" widget="many2many_tags"/>
<field name="sequence" groups="base.group_no_one"/>
<field name="email_from" invisible="1"/>
<field name="email_cc" groups="base.group_no_one"/>
+27 -25
View File
@@ -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"
+2 -2
View File
@@ -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')
+38 -1
View File
@@ -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
+12 -1
View File
@@ -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()
+4 -4
View File
@@ -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()
@@ -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)
@@ -33,7 +33,9 @@
<field name="employee_id" options="{'no_create': True}"/>
<field name="sale_line_id" attrs="{'required': True}" options="{'no_create': True}"/>
<field name="price_unit" widget="monetary" force_save="1" options="{'currency_field': 'currency_id'}"/>
<field name="cost"/>
<field name="currency_id" invisible="1"/>
<field name="cost_currency_id" invisible="1"/>
</tree>
</field>
</page>
@@ -115,9 +117,6 @@
<xpath expr="//header" position='inside'>
<field name="allow_billable" invisible="1"/>
</xpath>
<xpath expr="//group/field[@name='sequence']" position="before">
<field name="analytic_account_id" groups="base.group_no_one"/>
</xpath>
</field>
</record>
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
@@ -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',
}
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import sale_order_line
@@ -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)