19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -1,10 +1,13 @@
# -*- coding: utf-8 -*-
from . import account_move
from . import product
from . import project
from . import account_move_line
from . import product_product
from . import product_template
from . import project_milestone
from . import sale_order
from . import project_project
from . import project_task_recurrence
from . import project_task_type
from . import project_task
from . import project_update
from . import sale_order_line
from . import sale_order_template_line
from . import res_config_settings
from . import sale_order

View file

@ -1,13 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
class AccountMove(models.Model):
_inherit = "account.move"
def _compute_analytic_distribution(self):
# when a project creates an aml, it adds an analytic account to it. the following filter is to save this
# analytic account from being overridden by analytic default rules and lack thereof
project_amls = self.filtered(lambda aml: aml.analytic_distribution and any(aml.sale_line_ids.project_id))
super(AccountMoveLine, self - project_amls)._compute_analytic_distribution()
def _get_action_per_item(self):
action = self.env.ref('account.action_move_out_invoice_type').id
return {invoice.id: action for invoice in self}

View file

@ -0,0 +1,73 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.fields import Domain
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def _compute_analytic_distribution(self):
# when a project creates an aml, it adds an analytic account to it. the following filter is to save this
# analytic account from being overridden by analytic default rules and lack thereof
project_amls = self.filtered(lambda aml: aml.analytic_distribution and any(aml.sale_line_ids.project_id))
super(AccountMoveLine, self - project_amls)._compute_analytic_distribution()
project_id = self.env.context.get('project_id', False)
if project_id:
project = self.env['project.project'].browse(project_id)
lines = self.filtered(lambda line: line.account_type not in ['asset_receivable', 'liability_payable'])
lines.analytic_distribution = project._get_analytic_distribution()
def _get_so_mapping_domain(self):
return Domain.OR(
Domain.AND(
Domain(self.env['account.analytic.account'].browse(int(account_id)).root_plan_id._column_name(), "=", int(account_id))
for account_id in key.split(",")
)
for line in self
for key in line.analytic_distribution or []
)
def _get_so_mapping_from_project(self):
""" Get the mapping of move.line with the sale.order record on which its analytic entries should be reinvoiced.
A sale.order matches a move.line if the sale.order's project contains all the same analytic accounts
as the ones in the distribution of the move.line.
:return a dict where key is the move line id, and value is sale.order record (or None).
"""
mapping = {}
projects = self.env['project.project'].search(domain=self._get_so_mapping_domain())
orders_per_project = dict(self.env['sale.order']._read_group(
domain=[('project_id', 'in', projects.ids)],
groupby=['project_id'],
aggregates=['id:recordset']
))
project_per_accounts = {
next(iter(project._get_analytic_distribution())): project
for project in projects
}
for move_line in self:
analytic_distribution = move_line.analytic_distribution
if not analytic_distribution:
continue
for accounts in analytic_distribution:
project = project_per_accounts.get(accounts)
if not project:
continue
orders = orders_per_project.get(project)
if not orders:
continue
orders = orders.sorted('create_date')
in_sale_state_orders = orders.filtered(lambda s: s.state == 'sale')
mapping[move_line.id] = in_sale_state_orders[0] if in_sale_state_orders else orders[0]
# map the move line index with the SO on which it needs to be reinvoiced. May be empty if no SO found
return mapping
def _sale_determine_order(self):
mapping_from_invoice = super()._sale_determine_order()
mapping_from_invoice.update(self._get_so_mapping_from_project())
return mapping_from_invoice

View file

@ -1,219 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _, SUPERUSER_ID
from odoo.exceptions import ValidationError
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.model
def _selection_service_policy(self):
service_policies = [
# (service_policy, string)
('ordered_prepaid', _('Prepaid/Fixed Price')),
('delivered_manual', _('Based on Delivered Quantity (Manual)')),
]
user = self.env['res.users'].sudo().browse(SUPERUSER_ID)
if (self.user_has_groups('project.group_project_milestone') or
(self.env.user.has_group('base.group_public') and user.has_group('project.group_project_milestone'))
):
service_policies.insert(1, ('delivered_milestones', _('Based on Milestones')))
return service_policies
service_tracking = fields.Selection(
selection=[
('no', 'Nothing'),
('task_global_project', 'Task'),
('task_in_project', 'Project & Task'),
('project_only', 'Project'),
],
string="Create on Order", default="no",
help="On Sales order confirmation, this product can generate a project and/or task. \
From those, you can track the service you are selling.\n \
'In sale order\'s project': Will use the sale order\'s configured project if defined or fallback to \
creating a new project based on the selected template.")
project_id = fields.Many2one(
'project.project', 'Project', company_dependent=True,
domain="[('company_id', '=', current_company_id)]")
project_template_id = fields.Many2one(
'project.project', 'Project Template', company_dependent=True, copy=True,
domain="[('company_id', '=', current_company_id)]")
service_policy = fields.Selection('_selection_service_policy', string="Service Invoicing Policy", compute_sudo=True, compute='_compute_service_policy', inverse='_inverse_service_policy')
service_type = fields.Selection(selection_add=[
('milestones', 'Project Milestones'),
])
@api.depends('invoice_policy', 'service_type', 'type')
def _compute_service_policy(self):
for product in self:
product.service_policy = self._get_general_to_service(product.invoice_policy, product.service_type)
if not product.service_policy and product.type == 'service':
product.service_policy = 'ordered_prepaid'
@api.depends('service_tracking', 'service_policy', 'type')
def _compute_product_tooltip(self):
super()._compute_product_tooltip()
for record in self.filtered(lambda record: record.type == 'service'):
if record.service_policy == 'ordered_prepaid':
if record.service_tracking == 'no':
record.product_tooltip = _(
"Invoice ordered quantities as soon as this service is sold."
)
elif record.service_tracking == 'task_global_project':
record.product_tooltip = _(
"Invoice ordered quantities as soon as this service is sold. "
"Create a task in an existing project to track the time spent."
)
elif record.service_tracking == 'project_only':
record.product_tooltip = _(
"Invoice ordered quantities as soon as this service is sold. "
"Create an empty project for the order to track the time spent."
)
elif record.service_tracking == 'task_in_project':
record.product_tooltip = _(
"Invoice ordered quantities as soon as this service is sold. "
"Create a project for the order with a task for each sales order line "
"to track the time spent."
)
elif record.service_policy == 'delivered_milestones':
if record.service_tracking == 'no':
record.product_tooltip = _(
"Invoice your milestones when they are reached."
)
elif record.service_tracking == 'task_global_project':
record.product_tooltip = _(
"Invoice your milestones when they are reached. "
"Create a task in an existing project to track the time spent."
)
elif record.service_tracking == 'project_only':
record.product_tooltip = _(
"Invoice your milestones when they are reached. "
"Create an empty project for the order to track the time spent."
)
elif record.service_tracking == 'task_in_project':
record.product_tooltip = _(
"Invoice your milestones when they are reached. "
"Create a project for the order with a task for each sales order line "
"to track the time spent."
)
elif record.service_policy == 'delivered_manual':
if record.service_tracking == 'no':
record.product_tooltip = _(
"Invoice this service when it is delivered (set the quantity by hand on your sales order lines). "
)
elif record.service_tracking == 'task_global_project':
record.product_tooltip = _(
"Invoice this service when it is delivered (set the quantity by hand on your sales order lines). "
"Create a task in an existing project to track the time spent."
)
elif record.service_tracking == 'project_only':
record.product_tooltip = _(
"Invoice this service when it is delivered (set the quantity by hand on your sales order lines). "
"Create an empty project for the order to track the time spent."
)
elif record.service_tracking == 'task_in_project':
record.product_tooltip = _(
"Invoice this service when it is delivered (set the quantity by hand on your sales order lines). "
"Create a project for the order with a task for each sales order line "
"to track the time spent."
)
def _get_service_to_general_map(self):
return {
# service_policy: (invoice_policy, service_type)
'ordered_prepaid': ('order', 'manual'),
'delivered_milestones': ('delivery', 'milestones'),
'delivered_manual': ('delivery', 'manual'),
}
def _get_general_to_service_map(self):
return {v: k for k, v in self._get_service_to_general_map().items()}
def _get_service_to_general(self, service_policy):
return self._get_service_to_general_map().get(service_policy, (False, False))
def _get_general_to_service(self, invoice_policy, service_type):
general_to_service = self._get_general_to_service_map()
return general_to_service.get((invoice_policy, service_type), False)
@api.onchange('service_policy')
def _inverse_service_policy(self):
for product in self:
if product.service_policy:
product.invoice_policy, product.service_type = self._get_service_to_general(product.service_policy)
@api.constrains('project_id', 'project_template_id')
def _check_project_and_template(self):
""" NOTE 'service_tracking' should be in decorator parameters but since ORM check constraints twice (one after setting
stored fields, one after setting non stored field), the error is raised when company-dependent fields are not set.
So, this constraints does cover all cases and inconsistent can still be recorded until the ORM change its behavior.
"""
for product in self:
if product.service_tracking == 'no' and (product.project_id or product.project_template_id):
raise ValidationError(_('The product %s should not have a project nor a project template since it will not generate project.') % (product.name,))
elif product.service_tracking == 'task_global_project' and product.project_template_id:
raise ValidationError(_('The product %s should not have a project template since it will generate a task in a global project.') % (product.name,))
elif product.service_tracking in ['task_in_project', 'project_only'] and product.project_id:
raise ValidationError(_('The product %s should not have a global project since it will generate a project.') % (product.name,))
@api.onchange('service_tracking')
def _onchange_service_tracking(self):
if self.service_tracking == 'no':
self.project_id = False
self.project_template_id = False
elif self.service_tracking == 'task_global_project':
self.project_template_id = False
elif self.service_tracking in ['task_in_project', 'project_only']:
self.project_id = False
@api.onchange('type')
def _onchange_type(self):
res = super(ProductTemplate, self)._onchange_type()
if self.type != 'service':
self.service_tracking = 'no'
return res
def write(self, vals):
if 'type' in vals and vals['type'] != 'service':
vals.update({
'service_tracking': 'no',
'project_id': False
})
return super(ProductTemplate, self).write(vals)
class ProductProduct(models.Model):
_inherit = 'product.product'
@api.onchange('service_tracking')
def _onchange_service_tracking(self):
if self.service_tracking == 'no':
self.project_id = False
self.project_template_id = False
elif self.service_tracking == 'task_global_project':
self.project_template_id = False
elif self.service_tracking in ['task_in_project', 'project_only']:
self.project_id = False
def _inverse_service_policy(self):
for product in self:
if product.service_policy:
product.invoice_policy, product.service_type = self.product_tmpl_id._get_service_to_general(product.service_policy)
@api.onchange('type')
def _onchange_type(self):
res = super(ProductProduct, self)._onchange_type()
if self.type != 'service':
self.service_tracking = 'no'
return res
def write(self, vals):
if 'type' in vals and vals['type'] != 'service':
vals.update({
'service_tracking': 'no',
'project_id': False
})
return super(ProductProduct, self).write(vals)

View file

@ -0,0 +1,31 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class ProductProduct(models.Model):
_inherit = 'product.product'
@api.onchange('service_tracking')
def _onchange_service_tracking(self):
if self.service_tracking == 'no':
self.project_id = False
self.project_template_id = False
elif self.service_tracking == 'task_global_project':
self.project_template_id = False
elif self.service_tracking in ['task_in_project', 'project_only']:
self.project_id = False
def _inverse_service_policy(self):
for product in self:
if product.service_policy:
product.invoice_policy, product.service_type = self.product_tmpl_id._get_service_to_general(product.service_policy)
def write(self, vals):
if 'type' in vals and vals['type'] != 'service':
vals.update({
'service_tracking': 'no',
'project_id': False
})
return super().write(vals)

View file

@ -0,0 +1,153 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _, SUPERUSER_ID
from odoo.exceptions import ValidationError
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.model
def _selection_service_policy(self):
service_policies = [
# (service_policy, string)
('ordered_prepaid', _('Prepaid/Fixed Price')),
('delivered_manual', _('Based on Delivered Quantity (Manual)')),
]
if self.env['res.groups']._is_feature_enabled('project.group_project_milestone'):
service_policies.insert(1, ('delivered_milestones', _('Based on Milestones')))
return service_policies
service_tracking = fields.Selection(
selection_add=[
('task_global_project', 'Task'),
('task_in_project', 'Project & Task'),
('project_only', 'Project'),
], ondelete={
'task_global_project': 'set default',
'task_in_project': 'set default',
'project_only': 'set default',
},
)
project_id = fields.Many2one(
'project.project', 'Project', company_dependent=True, copy=True, domain='[("is_template", "=", False)]'
)
project_template_id = fields.Many2one(
'project.project', 'Project Template', company_dependent=True, copy=True,
domain='[("is_template", "=", True)]',
)
task_template_id = fields.Many2one('project.task', 'Task Template',
domain="[('is_template', '=', True), ('project_id', '=', project_id)]",
company_dependent=True, copy=True, compute='_compute_task_template', store=True, readonly=False
)
service_policy = fields.Selection('_selection_service_policy', string="Service Invoicing Policy", compute_sudo=True, compute='_compute_service_policy', inverse='_inverse_service_policy', tracking=True)
service_type = fields.Selection(selection_add=[
('milestones', 'Project Milestones'),
])
@api.depends('invoice_policy', 'service_type', 'type')
def _compute_service_policy(self):
for product in self:
product.service_policy = self._get_general_to_service(product.invoice_policy, product.service_type)
if not product.service_policy and product.type == 'service':
product.service_policy = 'ordered_prepaid'
@api.depends('project_id')
def _compute_task_template(self):
for product in self:
if product.task_template_id and product.task_template_id.project_id != product.project_id:
product.task_template_id = False
@api.depends('service_policy')
def _compute_product_tooltip(self):
super()._compute_product_tooltip()
def _prepare_service_tracking_tooltip(self):
if self.service_tracking == 'task_global_project':
return _("Create a task in an existing project to track the time spent.")
elif self.service_tracking == 'project_only':
return _(
"Create an empty project for the order to track the time spent."
)
elif self.service_tracking == 'task_in_project':
return _(
"Create a project for the order with a task for each sales order line "
"to track the time spent."
)
elif self.service_tracking == 'no':
return _(
"Create projects or tasks later, and link them to order to track the time spent."
)
return super()._prepare_service_tracking_tooltip()
def _prepare_invoicing_tooltip(self):
if self.service_policy == 'delivered_milestones':
return _("Invoice your milestones when they are reached.")
# ordered_prepaid and delivered_manual are handled in the super call, according to the
# corresponding value in the `invoice_policy` field (delivered/ordered quantities)
return super()._prepare_invoicing_tooltip()
def _get_service_to_general_map(self):
return {
# service_policy: (invoice_policy, service_type)
'ordered_prepaid': ('order', 'manual'),
'delivered_milestones': ('delivery', 'milestones'),
'delivered_manual': ('delivery', 'manual'),
}
def _get_general_to_service_map(self):
return {v: k for k, v in self._get_service_to_general_map().items()}
def _get_service_to_general(self, service_policy):
return self._get_service_to_general_map().get(service_policy, (False, False))
def _get_general_to_service(self, invoice_policy, service_type):
general_to_service = self._get_general_to_service_map()
return general_to_service.get((invoice_policy, service_type), False)
@api.onchange('service_policy')
def _inverse_service_policy(self):
for product in self:
if product.service_policy:
product.invoice_policy, product.service_type = self._get_service_to_general(product.service_policy)
@api.constrains('project_id', 'project_template_id')
def _check_project_and_template(self):
""" NOTE 'service_tracking' should be in decorator parameters but since ORM check constraints twice (one after setting
stored fields, one after setting non stored field), the error is raised when company-dependent fields are not set.
So, this constraints does cover all cases and inconsistent can still be recorded until the ORM change its behavior.
"""
for product in self:
if product.service_tracking == 'no' and (product.project_id or product.project_template_id):
raise ValidationError(_('The product %s should not have a project nor a project template since it will not generate project.', product.name))
elif product.service_tracking == 'task_global_project' and product.project_template_id:
raise ValidationError(_('The product %s should not have a project template since it will generate a task in a global project.', product.name))
elif product.service_tracking in ['task_in_project', 'project_only'] and product.project_id:
raise ValidationError(_('The product %s should not have a global project since it will generate a project.', product.name))
@api.onchange('service_tracking')
def _onchange_service_tracking(self):
if self.service_tracking == 'no':
self.project_id = False
self.project_template_id = False
elif self.service_tracking == 'task_global_project':
self.project_template_id = False
elif self.service_tracking in ['task_in_project', 'project_only']:
self.project_id = False
def write(self, vals):
if 'type' in vals and vals['type'] != 'service':
vals.update({
'service_tracking': 'no',
'project_id': False
})
return super().write(vals)
@api.model
def _get_saleable_tracking_types(self):
return super()._get_saleable_tracking_types() + [
'task_global_project',
'task_in_project',
'project_only',
]

View file

@ -1,771 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from odoo import api, fields, models, _, _lt
from odoo.exceptions import ValidationError, AccessError
from odoo.osv import expression
from odoo.tools import Query
from odoo.tools.misc import unquote
from datetime import date
from functools import reduce
class Project(models.Model):
_inherit = 'project.project'
def _domain_sale_line_id(self):
domain = expression.AND([
self.env['sale.order.line']._sellable_lines_domain(),
[
('is_service', '=', True),
('is_expense', '=', False),
('state', 'in', ['sale', 'done']),
('order_partner_id', '=?', unquote("partner_id")),
'|', ('company_id', '=', False), ('company_id', '=', unquote("company_id")),
],
])
return domain
allow_billable = fields.Boolean("Billable")
sale_line_id = fields.Many2one(
'sale.order.line', 'Sales Order Item', copy=False,
compute="_compute_sale_line_id", store=True, readonly=False, index='btree_not_null',
domain=lambda self: str(self._domain_sale_line_id()),
help="Sales order item that will be selected by default on the tasks and timesheets of this project,"
" except if the employee set on the timesheets is explicitely linked to another sales order item on the project.\n"
"It can be modified on each task and timesheet entry individually if necessary.")
sale_order_id = fields.Many2one(string='Sales Order', related='sale_line_id.order_id', help="Sales order to which the project is linked.")
has_any_so_to_invoice = fields.Boolean('Has SO to Invoice', compute='_compute_has_any_so_to_invoice')
sale_order_count = fields.Integer(compute='_compute_sale_order_count', groups='sales_team.group_sale_salesman')
has_any_so_with_nothing_to_invoice = fields.Boolean('Has a SO with an invoice status of No', compute='_compute_has_any_so_with_nothing_to_invoice')
invoice_count = fields.Integer(compute='_compute_invoice_count', groups='account.group_account_readonly')
vendor_bill_count = fields.Integer(related='analytic_account_id.vendor_bill_count', groups='account.group_account_readonly')
@api.model
def _map_tasks_default_valeus(self, task, project):
defaults = super()._map_tasks_default_valeus(task, project)
defaults['sale_line_id'] = False
return defaults
@api.depends('partner_id')
def _compute_sale_line_id(self):
self.filtered(
lambda p:
p.sale_line_id and (
not p.partner_id or p.sale_line_id.order_partner_id.commercial_partner_id != p.partner_id.commercial_partner_id
)
).update({'sale_line_id': False})
def _get_projects_for_invoice_status(self, invoice_status):
""" Returns a recordset of project.project that has any Sale Order which invoice_status is the same as the
provided invoice_status.
:param invoice_status: The invoice status.
"""
self.env.cr.execute("""
SELECT id
FROM project_project pp
WHERE pp.active = true
AND ( EXISTS(SELECT 1
FROM sale_order so
JOIN project_task pt ON pt.sale_order_id = so.id
WHERE pt.project_id = pp.id
AND pt.active = true
AND so.invoice_status = %(invoice_status)s)
OR EXISTS(SELECT 1
FROM sale_order so
JOIN sale_order_line sol ON sol.order_id = so.id
WHERE sol.id = pp.sale_line_id
AND so.invoice_status = %(invoice_status)s))
AND id in %(ids)s""", {'ids': tuple(self.ids), 'invoice_status': invoice_status})
return self.env['project.project'].browse([x[0] for x in self.env.cr.fetchall()])
@api.depends('sale_order_id.invoice_status', 'tasks.sale_order_id.invoice_status')
def _compute_has_any_so_to_invoice(self):
"""Has any Sale Order whose invoice_status is set as To Invoice"""
if not self.ids:
self.has_any_so_to_invoice = False
return
project_to_invoice = self._get_projects_for_invoice_status('to invoice')
project_to_invoice.has_any_so_to_invoice = True
(self - project_to_invoice).has_any_so_to_invoice = False
@api.depends('sale_order_id', 'task_ids.sale_order_id')
def _compute_sale_order_count(self):
sale_order_items_per_project_id = self._fetch_sale_order_items_per_project_id({'project.task': [('is_closed', '=', False)]})
for project in self:
project.sale_order_count = len(sale_order_items_per_project_id.get(project.id, self.env['sale.order.line']).order_id)
def _compute_invoice_count(self):
query = self.env['account.move.line']._search([('move_id.move_type', 'in', ['out_invoice', 'out_refund'])])
query.add_where('account_move_line.analytic_distribution ?| %s', [[str(project.analytic_account_id.id) for project in self]])
query.order = None
query_string, query_param = query.select(
'jsonb_object_keys(account_move_line.analytic_distribution) as account_id',
'COUNT(DISTINCT move_id) as move_count',
)
query_string = f"{query_string} GROUP BY jsonb_object_keys(account_move_line.analytic_distribution)"
self._cr.execute(query_string, query_param)
data = {int(row.get('account_id')): row.get('move_count') for row in self._cr.dictfetchall()}
for project in self:
project.invoice_count = data.get(project.analytic_account_id.id, 0)
def action_view_sos(self):
self.ensure_one()
all_sale_orders = self._fetch_sale_order_items({'project.task': [('is_closed', '=', False)]}).order_id
action_window = {
"type": "ir.actions.act_window",
"res_model": "sale.order",
'name': _("%(name)s's Sales Order", name=self.name),
"context": {"create": False, "show_sale": True},
}
if len(all_sale_orders) == 1:
action_window.update({
"res_id": all_sale_orders.id,
"views": [[False, "form"]],
})
else:
action_window.update({
"domain": [('id', 'in', all_sale_orders.ids)],
"views": [[False, "tree"], [False, "kanban"], [False, "calendar"], [False, "pivot"],
[False, "graph"], [False, "activity"], [False, "form"]],
})
return action_window
def action_get_list_view(self):
action = super().action_get_list_view()
if self.allow_billable:
action['views'] = [(self.env.ref('sale_project.project_milestone_view_tree').id, 'tree'), (False, 'form')]
return action
def action_profitability_items(self, section_name, domain=None, res_id=False):
if section_name in ['service_revenues', 'other_revenues']:
view_types = ['list', 'kanban', 'form']
action = {
'name': _('Sales Order Items'),
'type': 'ir.actions.act_window',
'res_model': 'sale.order.line',
'context': {'create': False, 'edit': False},
}
if res_id:
action['res_id'] = res_id
view_types = ['form']
else:
action['domain'] = domain
action['views'] = [(False, v) for v in view_types]
return action
return super().action_profitability_items(section_name, domain, res_id)
@api.depends('sale_order_id.invoice_status', 'tasks.sale_order_id.invoice_status')
def _compute_has_any_so_with_nothing_to_invoice(self):
"""Has any Sale Order whose invoice_status is set as No"""
if not self.ids:
self.has_any_so_with_nothing_to_invoice = False
return
project_nothing_to_invoice = self._get_projects_for_invoice_status('no')
project_nothing_to_invoice.has_any_so_with_nothing_to_invoice = True
(self - project_nothing_to_invoice).has_any_so_with_nothing_to_invoice = False
def action_create_invoice(self):
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_view_sale_advance_payment_inv")
so_ids = (self.sale_order_id | self.task_ids.sale_order_id).filtered(lambda so: so.invoice_status in ['to invoice', 'no']).ids
action['context'] = {
'active_id': so_ids[0] if len(so_ids) == 1 else False,
'active_ids': so_ids
}
if not self.has_any_so_to_invoice:
action['context']['default_advance_payment_method'] = 'percentage'
return action
def action_open_project_invoices(self):
query = self.env['account.move.line']._search([('move_id.move_type', 'in', ['out_invoice', 'out_refund'])])
query.add_where('account_move_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.order = None
query_string, query_param = query.select('DISTINCT move_id')
self._cr.execute(query_string, query_param)
invoice_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
action = {
'name': _('Invoices'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'views': [[False, 'tree'], [False, 'form'], [False, 'kanban']],
'domain': [('id', 'in', invoice_ids)],
'context': {
'create': False,
}
}
if len(invoice_ids) == 1:
action['views'] = [[False, 'form']]
action['res_id'] = invoice_ids[0]
return action
# ----------------------------
# Project Updates
# ----------------------------
def _fetch_sale_order_items_per_project_id(self, domain_per_model=None):
if not self:
return {}
if len(self) == 1:
return {self.id: self._fetch_sale_order_items(domain_per_model)}
query_str, params = self._get_sale_order_items_query(domain_per_model).select('id', 'ARRAY_AGG(DISTINCT sale_line_id) AS sale_line_ids')
query = f"""
{query_str}
GROUP BY id
"""
self._cr.execute(query, params)
return {row['id']: self.env['sale.order.line'].browse(row['sale_line_ids']) for row in self._cr.dictfetchall()}
def _fetch_sale_order_items(self, domain_per_model=None, limit=None, offset=None):
return self.env['sale.order.line'].browse(self._fetch_sale_order_item_ids(domain_per_model, limit, offset))
def _fetch_sale_order_item_ids(self, domain_per_model=None, limit=None, offset=None):
if not self or not self.filtered('allow_billable'):
return []
query = self._get_sale_order_items_query(domain_per_model)
query.limit = limit
query.offset = offset
query_str, params = query.select('DISTINCT sale_line_id')
self._cr.execute(query_str, params)
return [row[0] for row in self._cr.fetchall()]
def _get_sale_orders(self):
return self._get_sale_order_items().order_id
def _get_sale_order_items(self):
return self._fetch_sale_order_items()
def _get_sale_order_items_query(self, domain_per_model=None):
if domain_per_model is None:
domain_per_model = {}
billable_project_domain = [('allow_billable', '=', True)]
project_domain = [('id', 'in', self.ids), ('sale_line_id', '!=', False)]
if 'project.project' in domain_per_model:
project_domain = expression.AND([
domain_per_model['project.project'],
project_domain,
billable_project_domain,
])
project_query = self.env['project.project']._where_calc(project_domain)
self._apply_ir_rules(project_query, 'read')
project_query_str, project_params = project_query.select('id', 'sale_line_id')
Task = self.env['project.task']
task_domain = [('project_id', 'in', self.ids), ('sale_line_id', '!=', False)]
if Task._name in domain_per_model:
task_domain = expression.AND([
domain_per_model[Task._name],
task_domain,
])
task_query = Task._where_calc(task_domain)
Task._apply_ir_rules(task_query, 'read')
task_query_str, task_params = task_query.select(f'{Task._table}.project_id AS id', f'{Task._table}.sale_line_id')
ProjectMilestone = self.env['project.milestone']
milestone_domain = [('project_id', 'in', self.ids), ('allow_billable', '=', True), ('sale_line_id', '!=', False)]
if ProjectMilestone._name in domain_per_model:
milestone_domain = expression.AND([
domain_per_model[ProjectMilestone._name],
milestone_domain,
billable_project_domain,
])
milestone_query = ProjectMilestone._where_calc(milestone_domain)
ProjectMilestone._apply_ir_rules(milestone_query)
milestone_query_str, milestone_params = milestone_query.select(
f'{ProjectMilestone._table}.project_id AS id',
f'{ProjectMilestone._table}.sale_line_id',
)
query = Query(self._cr, 'project_sale_order_item', ' UNION '.join([project_query_str, task_query_str, milestone_query_str]))
query._where_params = project_params + task_params + milestone_params
return query
def get_panel_data(self):
panel_data = super().get_panel_data()
return {
**panel_data,
'sale_items': self._get_sale_items(),
}
def get_sale_items_data(self, domain=None, offset=0, limit=None, with_action=True):
if not self.user_has_groups('project.group_project_user'):
return {}
sols = self.env['sale.order.line'].sudo().search(
domain or self._get_sale_items_domain(),
offset=offset,
limit=limit,
)
# filter to only get the action for the SOLs that the user can read
action_per_sol = sols.sudo(False)._filter_access_rules_python('read')._get_action_per_item() if with_action else {}
def get_action(sol_id):
""" Return the action vals to call it in frontend if the user can access to the SO related """
action, res_id = action_per_sol.get(sol_id, (None, None))
return {'action': {'name': action, 'resId': res_id, 'buttonContext': json.dumps({'active_id': sol_id, 'default_project_id': self.id})}} if action else {}
return [{
**sol_read,
**get_action(sol_read['id']),
} for sol_read in sols.with_context(with_price_unit=True).read(['display_name', 'product_uom_qty', 'qty_delivered', 'qty_invoiced', 'product_uom'])]
def _get_sale_items_domain(self, additional_domain=None):
sale_items = self.sudo()._get_sale_order_items()
domain = [
('order_id', 'in', sale_items.sudo().order_id.ids),
('is_downpayment', '=', False),
('state', 'in', ['sale', 'done']),
('display_type', '=', False),
'|',
'|',
('project_id', 'in', self.ids),
('project_id', '=', False),
('id', 'in', sale_items.ids),
]
if additional_domain:
domain = expression.AND([domain, additional_domain])
return domain
def _get_sale_items(self, with_action=True):
domain = self._get_sale_items_domain()
return {
'total': self.env['sale.order.line'].sudo().search_count(domain),
'data': self.get_sale_items_data(domain, limit=5, with_action=with_action),
}
def _show_profitability(self):
self.ensure_one()
return self.allow_billable and super()._show_profitability()
def _get_profitability_labels(self):
return {
**super()._get_profitability_labels(),
'service_revenues': _lt('Other Services'),
'other_revenues': _lt('Materials'),
'other_invoice_revenues': _lt('Other Revenues'),
}
def _get_profitability_sequence_per_invoice_type(self):
return {
**super()._get_profitability_sequence_per_invoice_type(),
'service_revenues': 6,
'other_revenues': 7,
'other_invoice_revenues': 8,
}
def _get_service_policy_to_invoice_type(self):
return {
'ordered_prepaid': 'service_revenues',
'delivered_milestones': 'service_revenues',
'delivered_manual': 'service_revenues',
}
def _get_profitability_sale_order_items_domain(self, domain=None):
if domain is None:
domain = []
return expression.AND([
[
('product_id', '!=', False),
('is_expense', '=', False),
('is_downpayment', '=', False),
('state', 'in', ['sale', 'done']),
'|', ('qty_to_invoice', '>', 0), ('qty_invoiced', '>', 0),
],
domain,
])
def _get_revenues_items_from_sol(self, domain=None, with_action=True):
sale_line_read_group = self.env['sale.order.line'].sudo()._read_group(
self._get_profitability_sale_order_items_domain(domain),
['product_id', 'ids:array_agg(id)', 'untaxed_amount_to_invoice', 'untaxed_amount_invoiced', 'currency_id'],
['product_id', 'currency_id'],
lazy=False,
)
display_sol_action = with_action and len(self) == 1 and self.user_has_groups('sales_team.group_sale_salesman')
revenues_dict = {}
total_to_invoice = total_invoiced = 0.0
if sale_line_read_group:
# Get conversion rate from currencies of the sale order lines to currency of project
currency_ids = list(set([line['currency_id'][0] for line in sale_line_read_group] + [self.currency_id.id]))
rates = self.env['res.currency'].browse(currency_ids)._get_rates(self.company_id, date.today())
conversion_rates = {cid: rates[self.currency_id.id] / rate_from for cid, rate_from in rates.items()}
sols_per_product = {}
for group in sale_line_read_group:
product_id = group['product_id'][0]
currency_id = group['currency_id'][0]
sols_total_amounts = sols_per_product.setdefault(product_id, (0, 0, []))
sols_current_amounts = (
group['untaxed_amount_to_invoice'] * conversion_rates[currency_id],
group['untaxed_amount_invoiced'] * conversion_rates[currency_id],
group['ids'],
)
sols_per_product[product_id] = tuple(reduce(lambda x, y: x + y, pair) for pair in zip(sols_total_amounts, sols_current_amounts))
product_read_group = self.env['product.product'].sudo()._read_group(
[('id', 'in', list(sols_per_product))],
['invoice_policy', 'service_type', 'type', 'ids:array_agg(id)'],
['invoice_policy', 'service_type', 'type'],
lazy=False,
)
service_policy_to_invoice_type = self._get_service_policy_to_invoice_type()
general_to_service_map = self.env['product.template']._get_general_to_service_map()
for res in product_read_group:
product_ids = res['ids']
service_policy = None
if res['type'] == 'service':
service_policy = general_to_service_map.get(
(res['invoice_policy'], res['service_type']),
'ordered_prepaid')
for product_id, (amount_to_invoice, amount_invoiced, sol_ids) in sols_per_product.items():
if product_id in product_ids:
invoice_type = service_policy_to_invoice_type.get(service_policy, 'other_revenues')
revenue = revenues_dict.setdefault(invoice_type, {'invoiced': 0.0, 'to_invoice': 0.0})
revenue['to_invoice'] += amount_to_invoice
total_to_invoice += amount_to_invoice
revenue['invoiced'] += amount_invoiced
total_invoiced += amount_invoiced
if display_sol_action and invoice_type in ['service_revenues', 'other_revenues']:
revenue.setdefault('record_ids', []).extend(sol_ids)
if display_sol_action:
section_name = 'other_revenues'
other_revenues = revenues_dict.get(section_name, {})
sale_order_items = self.env['sale.order.line'] \
.browse(other_revenues.pop('record_ids', [])) \
._filter_access_rules_python('read')
if sale_order_items:
if sale_order_items:
args = [section_name, [('id', 'in', sale_order_items.ids)]]
if len(sale_order_items) == 1:
args.append(sale_order_items.id)
action_params = {
'name': 'action_profitability_items',
'type': 'object',
'args': json.dumps(args),
}
other_revenues['action'] = action_params
sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type()
return {
'data': [{'id': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], **vals} for invoice_type, vals in revenues_dict.items()],
'total': {'to_invoice': total_to_invoice, 'invoiced': total_invoiced},
}
def _get_revenues_items_from_invoices_domain(self, domain=None):
if domain is None:
domain = []
included_invoice_line_ids = self._get_already_included_profitability_invoice_line_ids()
return expression.AND([
domain,
[('move_id.move_type', 'in', self.env['account.move'].get_sale_types()),
('parent_state', 'in', ['draft', 'posted']),
('price_subtotal', '!=', 0),
('is_downpayment', '=', False),
('id', 'not in', included_invoice_line_ids)],
])
def _get_revenues_items_from_invoices(self, excluded_move_line_ids=None):
"""
Get all revenues items from invoices, and put them into their own
"other_invoice_revenues" section.
If the final total is 0 for either to_invoice or invoiced (ex: invoice -> credit note),
we don't output a new section
:param excluded_move_line_ids a list of 'account.move.line' to ignore
when fetching the move lines, for example a list of invoices that were
generated from a sales order
"""
if excluded_move_line_ids is None:
excluded_move_line_ids = []
query = self.env['account.move.line'].sudo()._search(
self._get_revenues_items_from_invoices_domain([('id', 'not in', excluded_move_line_ids)]),
)
query.add_where('account_move_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
# account_move_line__move_id is the alias of the joined table account_move in the query
# we can use it, because of the "move_id.move_type" clause in the domain of the query, which generates the join
# this is faster than a search_read followed by a browse on the move_id to retrieve the move_type of each account.move.line
query_string, query_param = query.select('balance', 'parent_state', 'account_move_line.company_currency_id', 'account_move_line.analytic_distribution', 'account_move_line__move_id.move_type')
self._cr.execute(query_string, query_param)
invoices_move_line_read = self._cr.dictfetchall()
if invoices_move_line_read:
# Get conversion rate from currencies to currency of the project
currency_ids = {iml['company_currency_id'] for iml in invoices_move_line_read + [{'company_currency_id': self.currency_id.id}]}
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(self.company_id, date.today())
conversion_rates = {cid: rates[self.currency_id.id] / rate_from for cid, rate_from in rates.items()}
amount_invoiced = amount_to_invoice = 0.0
for moves_read in invoices_move_line_read:
line_balance = self.currency_id.round(moves_read['balance'] * conversion_rates[moves_read['company_currency_id']])
analytic_contribution = moves_read['analytic_distribution'][str(self.analytic_account_id.id)] / 100.
if moves_read['parent_state'] == 'draft':
amount_to_invoice -= line_balance * analytic_contribution
else: # moves_read['parent_state'] == 'posted'
amount_invoiced -= line_balance * analytic_contribution
# don't display the section if the final values are both 0 (invoice -> credit note)
if amount_invoiced != 0 or amount_to_invoice != 0:
section_id = 'other_invoice_revenues'
invoices_revenues = {
'id': section_id,
'sequence': self._get_profitability_sequence_per_invoice_type()[section_id],
'invoiced': amount_invoiced,
'to_invoice': amount_to_invoice,
}
return {
'data': [invoices_revenues],
'total': {
'invoiced': amount_invoiced,
'to_invoice': amount_to_invoice,
},
}
return {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}}
def _get_profitability_items(self, with_action=True):
profitability_items = super()._get_profitability_items(with_action)
sale_items = self.sudo()._get_sale_order_items()
domain = [
('order_id', 'in', sale_items.order_id.ids),
'|',
'|',
('project_id', 'in', self.ids),
('project_id', '=', False),
('id', 'in', sale_items.ids),
]
revenue_items_from_sol = self._get_revenues_items_from_sol(
domain,
with_action,
)
revenues = profitability_items['revenues']
revenues['data'] += revenue_items_from_sol['data']
revenues['total']['to_invoice'] += revenue_items_from_sol['total']['to_invoice']
revenues['total']['invoiced'] += revenue_items_from_sol['total']['invoiced']
sale_line_read_group = self.env['sale.order.line'].sudo()._read_group(
self._get_profitability_sale_order_items_domain(domain),
['ids:array_agg(id)'],
['product_id'],
)
revenue_items_from_invoices = self._get_revenues_items_from_invoices(
excluded_move_line_ids=self.env['sale.order.line'].browse(
[sol_id for sol_read in sale_line_read_group for sol_id in sol_read['ids']]
).sudo().invoice_lines.ids
)
revenues['data'] += revenue_items_from_invoices['data']
revenues['total']['to_invoice'] += revenue_items_from_invoices['total']['to_invoice']
revenues['total']['invoiced'] += revenue_items_from_invoices['total']['invoiced']
return profitability_items
def _get_stat_buttons(self):
buttons = super(Project, self)._get_stat_buttons()
if self.user_has_groups('sales_team.group_sale_salesman_all_leads'):
buttons.append({
'icon': 'dollar',
'text': _lt('Sales Orders'),
'number': self.sale_order_count,
'action_type': 'object',
'action': 'action_view_sos',
'show': self.sale_order_count > 0,
'sequence': 1,
})
if self.user_has_groups('account.group_account_readonly'):
buttons.append({
'icon': 'pencil-square-o',
'text': _lt('Invoices'),
'number': self.invoice_count,
'action_type': 'object',
'action': 'action_open_project_invoices',
'show': bool(self.analytic_account_id) and self.invoice_count > 0,
'sequence': 30,
})
if self.user_has_groups('account.group_account_readonly'):
buttons.append({
'icon': 'pencil-square-o',
'text': _lt('Vendor Bills'),
'number': self.vendor_bill_count,
'action_type': 'object',
'action': 'action_open_project_vendor_bills',
'show': self.vendor_bill_count > 0,
'sequence': 48,
})
return buttons
def action_open_project_vendor_bills(self):
query = self.env['account.move.line']._search([('move_id.move_type', 'in', ['in_invoice', 'in_refund'])])
query.add_where('account_move_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.order = None
query_string, query_param = query.select('DISTINCT move_id')
self._cr.execute(query_string, query_param)
vendor_bill_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
action_window = {
'name': _('Vendor Bills'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'views': [[False, 'tree'], [False, 'form'], [False, 'kanban']],
'domain': [('id', 'in', vendor_bill_ids)],
'context': {
'create': False,
}
}
if len(vendor_bill_ids) == 1:
action_window['views'] = [[False, 'form']]
action_window['res_id'] = vendor_bill_ids[0]
return action_window
class ProjectTask(models.Model):
_inherit = "project.task"
def _domain_sale_line_id(self):
domain = expression.AND([
self.env['sale.order.line']._sellable_lines_domain(),
[
('company_id', '=', unquote('company_id')),
'|', ('order_partner_id', 'child_of', unquote('commercial_partner_id if commercial_partner_id else []')),
('order_partner_id', '=?', unquote('partner_id')),
('is_service', '=', True), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']),
],
])
return domain
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',
copy=True, tracking=True, index='btree_not_null', recursive=True,
compute='_compute_sale_line', store=True, readonly=False,
domain=lambda self: str(self._domain_sale_line_id()),
help="Sales Order Item to which the time spent on this task will be added in order to be invoiced to your customer.\n"
"By default the sales order item set on the project will be selected. In the absence of one, the last prepaid sales order item that has time remaining will be used.\n"
"Remove the sales order item in order to make this task non billable. You can also change or remove the sales order item of each timesheet entry individually.")
project_sale_order_id = fields.Many2one('sale.order', string="Project's sale order", related='project_id.sale_order_id')
task_to_invoice = fields.Boolean("To invoice", compute='_compute_task_to_invoice', search='_search_task_to_invoice', groups='sales_team.group_sale_salesman_all_leads')
# Project sharing fields
display_sale_order_button = fields.Boolean(string='Display Sales Order', compute='_compute_display_sale_order_button')
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS | {'sale_order_id', 'sale_line_id', 'display_sale_order_button'}
@api.depends('sale_line_id', 'project_id', 'commercial_partner_id')
def _compute_sale_order_id(self):
for task in self:
sale_order_id = task.sale_order_id or self.env["sale.order"]
if task.sale_line_id:
sale_order_id = task.sale_line_id.sudo().order_id
elif task.project_id.sale_order_id:
sale_order_id = task.project_id.sale_order_id
if task.commercial_partner_id != sale_order_id.partner_id.commercial_partner_id:
sale_order_id = False
if sale_order_id and not task.partner_id:
task.partner_id = sale_order_id.partner_id
task.sale_order_id = sale_order_id
@api.depends('commercial_partner_id', 'sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'milestone_id.sale_line_id')
def _compute_sale_line(self):
for task in self:
if not task.sale_line_id:
# if the display_project_id is set then it means the task is classic task or a subtask with another project than its parent.
task.sale_line_id = task.display_project_id.sale_line_id or task.parent_id.sale_line_id or task.project_id.sale_line_id or task.milestone_id.sale_line_id
# check sale_line_id and customer are coherent
if task.sale_line_id.order_partner_id.commercial_partner_id != task.partner_id.commercial_partner_id:
task.sale_line_id = False
@api.depends('sale_order_id')
def _compute_display_sale_order_button(self):
if not self.sale_order_id:
self.display_sale_order_button = False
return
try:
sale_orders = self.env['sale.order'].search([('id', 'in', self.sale_order_id.ids)])
for task in self:
task.display_sale_order_button = task.sale_order_id in sale_orders
except AccessError:
self.display_sale_order_button = False
@api.constrains('sale_line_id')
def _check_sale_line_type(self):
for task in self.sudo():
if task.sale_line_id:
if not task.sale_line_id.is_service or task.sale_line_id.is_expense:
raise ValidationError(_(
'You cannot link the order item %(order_id)s - %(product_id)s to this task because it is a re-invoiced expense.',
order_id=task.sale_line_id.order_id.name,
product_id=task.sale_line_id.product_id.display_name,
))
# ---------------------------------------------------
# Actions
# ---------------------------------------------------
def _get_action_view_so_ids(self):
return self.sale_order_id.ids
def action_view_so(self):
so_ids = self._get_action_view_so_ids()
action_window = {
"type": "ir.actions.act_window",
"res_model": "sale.order",
"name": _("Sales Order"),
"views": [[False, "tree"], [False, "kanban"], [False, "form"]],
"context": {"create": False, "show_sale": True},
"domain": [["id", "in", so_ids]],
}
if len(so_ids) == 1:
action_window["views"] = [[False, "form"]]
action_window["res_id"] = so_ids[0]
return action_window
def action_project_sharing_view_so(self):
self.ensure_one()
if not self.display_sale_order_button:
return {}
return {
"name": _("Portal Sale Order"),
"type": "ir.actions.act_url",
"url": self.sale_order_id.access_url,
}
def _rating_get_partner(self):
partner = self.partner_id or self.sale_line_id.order_id.partner_id
return partner or super()._rating_get_partner()
@api.depends('sale_order_id.invoice_status', 'sale_order_id.order_line')
def _compute_task_to_invoice(self):
for task in self:
if task.sale_order_id:
task.task_to_invoice = bool(task.sale_order_id.invoice_status not in ('no', 'invoiced'))
else:
task.task_to_invoice = False
@api.model
def _search_task_to_invoice(self, operator, value):
query = """
SELECT so.id
FROM sale_order so
WHERE so.invoice_status != 'invoiced'
AND so.invoice_status != 'no'
"""
operator_new = 'inselect'
if(bool(operator == '=') ^ bool(value)):
operator_new = 'not inselect'
return [('sale_order_id', operator_new, (query, ()))]
@api.onchange('sale_line_id')
def _onchange_partner_id(self):
if not self.partner_id and self.sale_line_id:
self.partner_id = self.sale_line_id.order_partner_id
class ProjectTaskRecurrence(models.Model):
_inherit = 'project.task.recurrence'
def _new_task_values(self, task):
values = super(ProjectTaskRecurrence, self)._new_task_values(task)
task = self.sudo().task_ids[0]
values['sale_line_id'] = self._get_sale_line_id(task)
return values
def _get_sale_line_id(self, task):
return task.sale_line_id.id

View file

@ -1,21 +1,64 @@
# -*- 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, fields, models, _
class ProjectMilestone(models.Model):
_name = 'project.milestone'
_inherit = 'project.milestone'
allow_billable = fields.Boolean(related='project_id.allow_billable')
project_partner_id = fields.Many2one(related='project_id.partner_id')
def _default_sale_line_id(self):
sale_line_id = self.env.context.get('default_sale_line_id')
if sale_line_id:
return self.env['sale.order.line'].search([
('id', '=', sale_line_id),
('qty_delivered_method', '=', 'milestones'),
], limit=1)
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', help='Sales Order Item that will be updated once the milestone is reached.',
project_id = self.env.context.get('default_project_id')
if not project_id:
return []
project = self.env['project.project'].browse(project_id)
return self.env['sale.order.line'].search([
('order_id', '=', project.sale_order_id.id),
('qty_delivered_method', '=', 'milestones'),
], limit=1)
allow_billable = fields.Boolean(related='project_id.allow_billable', export_string_translation=False)
project_partner_id = fields.Many2one(related='project_id.partner_id', export_string_translation=False)
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, help='Sales Order Item that will be updated once the milestone is reached.',
index='btree_not_null',
domain="[('order_partner_id', '=?', project_partner_id), ('qty_delivered_method', '=', 'milestones')]")
quantity_percentage = fields.Float('Quantity', help='Percentage of the ordered quantity that will automatically be delivered once the milestone is reached.')
quantity_percentage = fields.Float('Quantity (%)', compute="_compute_quantity_percentage", store=True, help='Percentage of the ordered quantity that will automatically be delivered once the milestone is reached.')
sale_line_name = fields.Text(related='sale_line_id.name')
sale_line_display_name = fields.Char("Sale Line Display Name", related='sale_line_id.display_name', export_string_translation=False)
product_uom_id = fields.Many2one(related="sale_line_id.product_uom_id", export_string_translation=False)
product_uom_qty = fields.Float("Quantity", compute="_compute_product_uom_qty", readonly=False)
@api.depends('sale_line_id.product_uom_qty', 'product_uom_qty')
def _compute_quantity_percentage(self):
for milestone in self:
milestone.quantity_percentage = milestone.sale_line_id.product_uom_qty and milestone.product_uom_qty / milestone.sale_line_id.product_uom_qty
@api.depends('sale_line_id', 'quantity_percentage')
def _compute_product_uom_qty(self):
for milestone in self:
if milestone.quantity_percentage:
milestone.product_uom_qty = milestone.quantity_percentage * milestone.sale_line_id.product_uom_qty
else:
milestone.product_uom_qty = milestone.sale_line_id.product_uom_qty
@api.model
def _get_fields_to_export(self):
return super()._get_fields_to_export() + ['allow_billable', 'quantity_percentage', 'sale_line_name']
return super()._get_fields_to_export() + ['allow_billable', 'quantity_percentage', 'sale_line_display_name']
def action_view_sale_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Sales Order'),
'res_model': 'sale.order',
'res_id': self.sale_line_id.order_id.id,
'view_mode': 'form',
}

View file

@ -0,0 +1,936 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import json
from collections import defaultdict
from odoo import api, fields, models
from odoo.fields import Domain
from odoo.tools import Query, SQL
from odoo.tools.misc import unquote
from odoo.tools.translate import _
class ProjectProject(models.Model):
_inherit = 'project.project'
def _domain_sale_line_id(self):
domain = Domain.AND([
self.env['sale.order.line']._sellable_lines_domain(),
self.env['sale.order.line']._domain_sale_line_service(),
[
('order_partner_id', '=?', unquote("partner_id")),
],
])
return domain
allow_billable = fields.Boolean("Billable")
sale_line_id = fields.Many2one(
'sale.order.line', 'Sales Order Item', copy=False,
compute="_compute_sale_line_id", store=True, readonly=False, index='btree_not_null',
domain=lambda self: str(self._domain_sale_line_id()),
help="Sales order item that will be selected by default on the tasks and timesheets of this project,"
" except if the employee set on the timesheets is explicitely linked to another sales order item on the project.\n"
"It can be modified on each task and timesheet entry individually if necessary.")
sale_order_id = fields.Many2one(related='sale_line_id.order_id', export_string_translation=False)
has_any_so_to_invoice = fields.Boolean('Has SO to Invoice', compute='_compute_has_any_so_to_invoice', export_string_translation=False)
sale_order_line_count = fields.Integer(compute='_compute_sale_order_count', groups='sales_team.group_sale_salesman', export_string_translation=False)
sale_order_count = fields.Integer(compute='_compute_sale_order_count', groups='sales_team.group_sale_salesman', export_string_translation=False)
has_any_so_with_nothing_to_invoice = fields.Boolean('Has a SO with an invoice status of No', compute='_compute_has_any_so_with_nothing_to_invoice', export_string_translation=False)
invoice_count = fields.Integer(compute='_compute_invoice_count', groups='account.group_account_readonly', export_string_translation=False)
vendor_bill_count = fields.Integer(related='account_id.vendor_bill_count', groups='account.group_account_readonly', compute_sudo=False, export_string_translation=False)
partner_id = fields.Many2one(compute="_compute_partner_id", store=True, readonly=False)
display_sales_stat_buttons = fields.Boolean(compute='_compute_display_sales_stat_buttons', export_string_translation=False)
sale_order_state = fields.Selection(related='sale_order_id.state', export_string_translation=False)
reinvoiced_sale_order_id = fields.Many2one('sale.order', string='Sales Order', groups='sales_team.group_sale_salesman', copy=False, domain="[('partner_id', '=', partner_id)]", index='btree_not_null',
help="Products added to stock pickings, whose operation type is configured to generate analytic costs, will be re-invoiced in this sales order if they are set up for it.",
)
@api.model
def default_get(self, fields):
defaults = super().default_get(fields)
if self.env.context.get('order_state') == 'sale':
order_id = self.env.context.get('order_id')
sale_line_id = self.env['sale.order.line'].search(
[('order_id', '=', order_id), ('is_service', '=', True)],
limit=1).id
defaults.update({
'reinvoiced_sale_order_id': order_id,
'sale_line_id': sale_line_id,
})
return defaults
@api.model
def _map_tasks_default_values(self, project):
defaults = super()._map_tasks_default_values(project)
defaults['sale_line_id'] = False
return defaults
@api.depends('allow_billable', 'partner_id.company_id')
def _compute_partner_id(self):
for project in self:
# Ensures that the partner_id and its project do not have different companies set
if not project.allow_billable or (project.company_id and project.partner_id.company_id and project.company_id != project.partner_id.company_id):
project.partner_id = False
@api.depends('partner_id')
def _compute_sale_line_id(self):
self.filtered(
lambda p:
p.sale_line_id and (
not p.partner_id or p.sale_line_id.order_partner_id.commercial_partner_id != p.partner_id.commercial_partner_id
)
).update({'sale_line_id': False})
def _get_projects_for_invoice_status(self, invoice_status):
""" Returns a recordset of project.project that has any Sale Order which invoice_status is the same as the
provided invoice_status.
:param invoice_status: The invoice status.
"""
result = self.env.execute_query(SQL("""
SELECT id
FROM project_project pp
WHERE pp.active = true
AND ( EXISTS(SELECT 1
FROM sale_order so
JOIN project_task pt ON pt.sale_order_id = so.id
WHERE pt.project_id = pp.id
AND pt.active = true
AND so.invoice_status = %(invoice_status)s)
OR EXISTS(SELECT 1
FROM sale_order so
JOIN sale_order_line sol ON sol.order_id = so.id
WHERE sol.id = pp.sale_line_id
AND so.invoice_status = %(invoice_status)s))
AND id in %(ids)s""", ids=tuple(self.ids), invoice_status=invoice_status))
return self.env['project.project'].browse(id_ for id_, in result)
@api.depends('sale_order_id.invoice_status', 'tasks.sale_order_id.invoice_status')
def _compute_has_any_so_to_invoice(self):
"""Has any Sale Order whose invoice_status is set as To Invoice"""
if not self.ids:
self.has_any_so_to_invoice = False
return
project_to_invoice = self._get_projects_for_invoice_status('to invoice')
project_to_invoice.has_any_so_to_invoice = True
(self - project_to_invoice).has_any_so_to_invoice = False
@api.depends('sale_order_id', 'task_ids.sale_order_id')
def _compute_sale_order_count(self):
sale_order_items_per_project_id = self._fetch_sale_order_items_per_project_id({'project.task': [('is_closed', '=', False)]})
for project in self:
sale_order_lines = sale_order_items_per_project_id.get(project.id, self.env['sale.order.line'])
project.sale_order_line_count = len(sale_order_lines)
# Use sudo to avoid AccessErrors when the SOLs belong to different companies.
project.sale_order_count = len(sale_order_lines.sudo().order_id or project.reinvoiced_sale_order_id)
def _compute_invoice_count(self):
data = self.env['account.move.line']._read_group(
[('move_id.move_type', 'in', ['out_invoice', 'out_refund']), ('analytic_distribution', 'in', self.account_id.ids)],
groupby=['analytic_distribution'],
aggregates=['__count'],
)
data = {int(account_id): move_count for account_id, move_count in data}
for project in self:
project.invoice_count = data.get(project.account_id.id, 0)
@api.depends('allow_billable', 'partner_id')
def _compute_display_sales_stat_buttons(self):
for project in self:
project.display_sales_stat_buttons = project.allow_billable and project.partner_id
def action_customer_preview(self):
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'target': 'self',
'url': self.get_portal_url(),
}
@api.onchange('reinvoiced_sale_order_id')
def _onchange_reinvoiced_sale_order_id(self):
if (
not self.sale_line_id
and (service_sols := self.reinvoiced_sale_order_id.order_line.filtered('is_service'))
):
self.sale_line_id = service_sols[0]
@api.onchange('sale_line_id')
def _onchange_sale_line_id(self):
if not self.reinvoiced_sale_order_id and self.sale_line_id:
self.reinvoiced_sale_order_id = self.sale_line_id.order_id
def _ensure_sale_order_linked(self, sol_ids):
""" Orders created from project/task are supposed to be confirmed to match the typical flow from sales, but since
we allow SO creation from the project/task itself we want to confirm newly created SOs immediately after creation.
However this would leads to SOs being confirmed without a single product, so we'd rather do it on record save.
"""
quotations = self.env['sale.order.line'].sudo()._read_group(
domain=[('state', '=', 'draft'), ('id', 'in', sol_ids)],
aggregates=['order_id:recordset'],
)[0][0]
if quotations:
quotations.action_confirm()
@api.model_create_multi
def create(self, vals_list):
projects = super().create(vals_list)
sol_ids = set()
for project, vals in zip(projects, vals_list):
if (vals.get('sale_line_id')):
sol_ids.add(vals['sale_line_id'])
if project.sale_order_id and not project.sale_order_id.project_id:
project.sale_order_id.project_id = project.id
elif project.sudo().reinvoiced_sale_order_id and not project.sudo().reinvoiced_sale_order_id.project_id:
project.sudo().reinvoiced_sale_order_id.project_id = project.id
if sol_ids:
projects._ensure_sale_order_linked(list(sol_ids))
return projects
def write(self, vals):
project = super().write(vals)
if sol_id := vals.get('sale_line_id'):
self._ensure_sale_order_linked([sol_id])
return project
def _get_sale_orders_domain(self, all_sale_orders):
return [("id", "in", all_sale_orders.ids)]
def _get_view_action(self):
return self.env["ir.actions.act_window"]._for_xml_id("sale.action_orders")
def action_view_sols(self):
self.ensure_one()
all_sale_order_lines = self._fetch_sale_order_items({'project.task': [('is_closed', '=', False)]})
action_window = {
'type': 'ir.actions.act_window',
'res_model': 'sale.order.line',
'name': _("%(name)s's Sales Order Items", name=self.name),
'context': {
'show_sale': True,
'link_to_project': self.id,
'form_view_ref': 'sale_project.sale_order_line_view_form_editable', # Necessary for some logic in the form view
'action_view_sols': True,
'default_partner_id': self.partner_id.id,
'default_company_id': self.company_id.id,
'default_order_id': self.sale_order_id.id,
},
'views': [(self.env.ref('sale_project.sale_order_line_view_form_editable').id, 'form')],
}
if len(all_sale_order_lines) <= 1:
action_window['res_id'] = all_sale_order_lines.id
else:
action_window.update({
'domain': [('id', 'in', all_sale_order_lines.ids)],
'views': [
(self.env.ref('sale_project.view_order_line_tree_with_create').id, 'list'),
(self.env.ref('sale_project.sale_order_line_view_form_editable').id, 'form'),
],
})
return action_window
def action_view_sos(self):
self.ensure_one()
all_sale_orders = self._fetch_sale_order_items({'project.task': [('is_closed', '=', False)]}).sudo().order_id
embedded_action_context = self.env.context.get('from_embedded_action', False)
action_window = self._get_view_action()
action_window["display_name"] = self.env._("%(name)s's %(action_name)s", name=self.name, action_name=action_window.get('name'))
action_window["domain"] = self._get_sale_orders_domain(all_sale_orders)
action_window['context'] = {
**ast.literal_eval(action_window['context']),
"create": self.env.context.get("create_for_project_id", embedded_action_context),
"show_sale": True,
"default_partner_id": self.partner_id.id,
"default_project_id": self.id,
"create_for_project_id": self.id if not embedded_action_context else False,
"from_embedded_action": embedded_action_context,
}
if len(all_sale_orders) <= 1 and not embedded_action_context:
action_window.update({
"res_id": all_sale_orders.id,
"views": [[False, "form"]],
})
return action_window
def action_get_list_view(self):
action = super().action_get_list_view()
if self.allow_billable:
action['views'] = [(self.env.ref('sale_project.project_milestone_view_tree').id, view_type) if view_type == 'list' else (view_id, view_type) for view_id, view_type in action['views']]
return action
def action_profitability_items(self, section_name, domain=None, res_id=False):
if section_name in ['service_revenues', 'materials']:
view_types = ['list', 'kanban', 'form']
action = {
'name': _('Sales Order Items'),
'type': 'ir.actions.act_window',
'res_model': 'sale.order.line',
'context': {'create': False, 'edit': False},
}
if res_id:
action['res_id'] = res_id
view_types = ['form']
else:
action['domain'] = domain
action['views'] = [(False, v) for v in view_types]
return action
if section_name in ['other_invoice_revenues', 'downpayments']:
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_out_invoice_type")
action['domain'] = domain if domain else []
action['context'] = {
**ast.literal_eval(action['context']),
'default_partner_id': self.partner_id.id,
'project_id': self.id,
}
if res_id:
action['views'] = [(False, 'form')]
action['view_mode'] = 'form'
action['res_id'] = res_id
return action
if section_name == 'cost_of_goods_sold':
action = {
'name': _('Cost of Goods Sold Items'),
'type': 'ir.actions.act_window',
'res_model': 'account.move.line',
'views': [[False, 'list'], [False, 'form']],
'domain': [('move_id', '=', res_id), ('display_type', '=', 'cogs')],
'context': {'create': False, 'edit': False},
}
return action
return super().action_profitability_items(section_name, domain, res_id)
@api.depends('sale_order_id.invoice_status', 'tasks.sale_order_id.invoice_status')
def _compute_has_any_so_with_nothing_to_invoice(self):
"""Has any Sale Order whose invoice_status is set as No"""
if not self.ids:
self.has_any_so_with_nothing_to_invoice = False
return
project_nothing_to_invoice = self._get_projects_for_invoice_status('no')
project_nothing_to_invoice.has_any_so_with_nothing_to_invoice = True
(self - project_nothing_to_invoice).has_any_so_with_nothing_to_invoice = False
def action_create_invoice(self):
action = self.env["ir.actions.actions"]._for_xml_id("sale.action_view_sale_advance_payment_inv")
so_ids = (self.sale_order_id | self.task_ids.sale_order_id).filtered(lambda so: so.invoice_status in ['to invoice', 'no']).ids
action['context'] = {
'active_id': so_ids[0] if len(so_ids) == 1 else False,
'active_ids': so_ids
}
if not self.has_any_so_to_invoice:
action['context']['default_advance_payment_method'] = 'percentage'
return action
def action_open_project_invoices(self):
move_lines = self.env['account.move.line'].search_fetch(
[
('move_id.move_type', 'in', ['out_invoice', 'out_refund']),
('analytic_distribution', 'in', self.account_id.ids),
],
['move_id'],
)
invoice_ids = move_lines.move_id.ids
action = {
'name': _('Invoices'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'views': [[False, 'list'], [False, 'form'], [False, 'kanban']],
'domain': [('id', 'in', invoice_ids)],
'context': {
'default_move_type': 'out_invoice',
'default_partner_id': self.partner_id.id,
'project_id': self.id
},
'help': "<p class='o_view_nocontent_smiling_face'>%s</p><p>%s</p>" %
(_("Create a customer invoice"),
_("Create invoices, register payments and keep track of the discussions with your customers."))
}
if len(invoice_ids) == 1 and not self.env.context.get('from_embedded_action', False):
action['views'] = [[False, 'form']]
action['res_id'] = invoice_ids[0]
return action
# ----------------------------
# Project Updates
# ----------------------------
def _fetch_sale_order_items_per_project_id(self, domain_per_model=None):
if not self:
return {}
if len(self) == 1:
return {self.id: self._fetch_sale_order_items(domain_per_model)}
sql = self._get_sale_order_items_query(domain_per_model).select('id', 'ARRAY_AGG(DISTINCT sale_line_id) AS sale_line_ids')
sql = SQL("%s GROUP BY id", sql)
return {
id_: self.env['sale.order.line'].browse(sale_line_ids)
for id_, sale_line_ids in self.env.execute_query(sql)
}
def _fetch_sale_order_items(self, domain_per_model=None, limit=None, offset=None):
return self.env['sale.order.line'].browse(self._fetch_sale_order_item_ids(domain_per_model, limit, offset))
def _fetch_sale_order_item_ids(self, domain_per_model=None, limit=None, offset=None):
if not self or not self.filtered('allow_billable'):
return []
query = self._get_sale_order_items_query(domain_per_model)
query.limit = limit
query.offset = offset
return [id_ for id_, in self.env.execute_query(query.select('DISTINCT sale_line_id'))]
def _get_sale_orders(self):
return self._get_sale_order_items().order_id
def _get_sale_order_items(self):
return self._fetch_sale_order_items()
def _get_sale_order_items_query(self, domain_per_model=None):
if domain_per_model is None:
domain_per_model = {}
billable_project_domain = [('allow_billable', '=', True)]
project_domain = [('id', 'in', self.ids), ('sale_line_id', '!=', False)]
if 'project.project' in domain_per_model:
project_domain = Domain.AND([
domain_per_model['project.project'],
project_domain,
billable_project_domain,
])
project_query = self.env['project.project']._search(project_domain)
project_sql = project_query.select(f'{self._table}.id ', f'{self._table}.sale_line_id')
Task = self.env['project.task']
task_domain = [('project_id', 'in', self.ids), ('sale_line_id', '!=', False)]
if Task._name in domain_per_model:
task_domain = Domain.AND([
domain_per_model[Task._name],
task_domain,
])
task_query = Task._search(task_domain)
task_sql = task_query.select(f'{Task._table}.project_id AS id', f'{Task._table}.sale_line_id')
ProjectMilestone = self.env['project.milestone']
milestone_domain = [('project_id', 'in', self.ids), ('allow_billable', '=', True), ('sale_line_id', '!=', False)]
if ProjectMilestone._name in domain_per_model:
milestone_domain = Domain.AND([
domain_per_model[ProjectMilestone._name],
milestone_domain,
billable_project_domain,
])
milestone_query = ProjectMilestone._search(milestone_domain)
milestone_sql = milestone_query.select(
f'{ProjectMilestone._table}.project_id AS id',
f'{ProjectMilestone._table}.sale_line_id',
)
SaleOrderLine = self.env['sale.order.line']
sale_order_line_domain = [
'&',
('display_type', '=', False),
('order_id', 'any', ['|',
('id', 'in', self.reinvoiced_sale_order_id.ids),
('project_id', 'in', self.ids),
]),
]
sale_order_line_query = SaleOrderLine._search(sale_order_line_domain, bypass_access=True)
sale_order_line_sql = sale_order_line_query.select(
f'{SaleOrderLine._table}.project_id AS id',
f'{SaleOrderLine._table}.id AS sale_line_id',
)
return Query(self.env, 'project_sale_order_item', SQL('(%s)', SQL(' UNION ').join([
project_sql, task_sql, milestone_sql, sale_order_line_sql,
])))
def get_panel_data(self):
panel_data = super().get_panel_data()
foldable_sections = self._get_foldable_section()
if self._show_profitability() and 'revenues' in panel_data['profitability_items']:
for section in panel_data['profitability_items']['revenues']['data']:
if section['id'] in foldable_sections:
section['isSectionFoldable'] = True
return {
**panel_data,
'show_sale_items': self.allow_billable,
}
def _get_foldable_section(self):
return ['materials', 'service_revenues']
def get_sale_items_data(self, offset=0, limit=None, with_action=True, section_id=None):
if not self.env.user.has_group('project.group_project_user'):
return {}
all_sols = self.env['sale.order.line'].sudo().search(
self._get_domain_from_section_id(section_id),
offset=offset,
limit=limit + 1,
)
display_load_more = False
if len(all_sols) > limit:
all_sols = all_sols - all_sols[limit]
display_load_more = True
# filter to only get the action for the SOLs that the user can read
action_per_sol = all_sols.sudo(False)._filtered_access('read')._get_action_per_item() if with_action else {}
def get_action(sol_id):
""" Return the action vals to call it in frontend if the user can access to the SO related """
action, res_id = action_per_sol.get(sol_id, (None, None))
return {'action': {'name': action, 'resId': res_id, 'buttonContext': json.dumps({'active_id': sol_id, 'default_project_id': self.id})}} if action else {}
return {
'sol_items': [{
**sol_read,
**get_action(sol_read['id']),
} for sol_read in all_sols.with_context(with_price_unit=True)._read_format(['display_name', 'product_uom_qty', 'qty_delivered', 'qty_invoiced', 'product_uom_id', 'product_id'])],
'displayLoadMore': display_load_more,
}
def _get_sale_items_domain(self, additional_domain=None):
sale_items = self.sudo()._get_sale_order_items()
domain = [
('order_id', 'in', sale_items.sudo().order_id.ids),
('is_downpayment', '=', False),
('state', '=', 'sale'),
('display_type', '=', False),
'|',
('project_id', 'in', [*self.ids, False]),
('id', 'in', sale_items.ids),
]
if additional_domain:
domain = Domain.AND([domain, additional_domain])
return domain
def _get_domain_from_section_id(self, section_id):
# When the sale_timesheet module is not installed, all service products are grouped under the 'service revenues' section.
return self._get_sale_items_domain([('product_type', '!=' if section_id == 'materials' else '=', 'service')])
def _show_profitability(self):
self.ensure_one()
return self.allow_billable and super()._show_profitability()
def _show_profitability_helper(self):
return True
def _get_profitability_labels(self):
return {
**super()._get_profitability_labels(),
'service_revenues': self.env._('Other Services'),
'materials': self.env._('Materials'),
'other_invoice_revenues': self.env._('Customer Invoices'),
'downpayments': self.env._('Down Payments'),
'cost_of_goods_sold': self.env._('Cost of Goods Sold'),
}
def _get_profitability_sequence_per_invoice_type(self):
return {
**super()._get_profitability_sequence_per_invoice_type(),
'service_revenues': 6,
'materials': 7,
'other_invoice_revenues': 9,
'downpayments': 20,
'cost_of_goods_sold': 21,
}
def _get_service_policy_to_invoice_type(self):
return {
'ordered_prepaid': 'service_revenues',
'delivered_milestones': 'service_revenues',
'delivered_manual': 'service_revenues',
}
def _get_profitability_sale_order_items_domain(self, domain=None):
domain = Domain(domain or Domain.TRUE)
return Domain([
'|', ('product_id', '!=', False), ('is_downpayment', '=', True),
('is_expense', '=', False),
('state', '=', 'sale'),
'|', ('qty_to_invoice', '>', 0), ('qty_invoiced', '>', 0),
]) & domain
def _get_revenues_items_from_sol(self, domain=None, with_action=True):
sale_line_read_group = self.env['sale.order.line'].sudo()._read_group(
self._get_profitability_sale_order_items_domain(domain),
['currency_id', 'product_id', 'is_downpayment'],
['id:array_agg', 'untaxed_amount_to_invoice:sum', 'untaxed_amount_invoiced:sum'],
)
display_sol_action = with_action and len(self) == 1 and self.env.user.has_group('sales_team.group_sale_salesman')
revenues_dict = {}
total_to_invoice = total_invoiced = 0.0
data = []
sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type()
if sale_line_read_group:
# Get conversion rate from currencies of the sale order lines to currency of project
convert_company = self.company_id or self.env.company
sols_per_product = defaultdict(lambda: [0.0, 0.0, []])
downpayment_amount_invoiced = 0
downpayment_sol_ids = []
for currency, product, is_downpayment, sol_ids, untaxed_amount_to_invoice, untaxed_amount_invoiced in sale_line_read_group:
if is_downpayment:
downpayment_amount_invoiced += currency._convert(untaxed_amount_invoiced, convert_company.currency_id, convert_company, round=False)
downpayment_sol_ids += sol_ids
else:
sols_per_product[product.id][0] += currency._convert(untaxed_amount_to_invoice, convert_company.currency_id, convert_company)
sols_per_product[product.id][1] += currency._convert(untaxed_amount_invoiced, convert_company.currency_id, convert_company)
sols_per_product[product.id][2] += sol_ids
if downpayment_amount_invoiced:
downpayments_data = {
'id': 'downpayments',
'sequence': sequence_per_invoice_type['downpayments'],
'invoiced': downpayment_amount_invoiced,
'to_invoice': -downpayment_amount_invoiced,
}
if with_action and (
self.env.user.has_group('sales_team.group_sale_salesman_all_leads,')
or self.env.user.has_group('account.group_account_invoice,')
or self.env.user.has_group('account.group_account_readonly')
):
invoices = self.env['account.move'].search([('line_ids.sale_line_ids', 'in', downpayment_sol_ids)])
args = ['downpayments', [('id', 'in', invoices.ids)]]
if len(invoices) == 1:
args.append(invoices.id)
downpayments_data['action'] = {
'name': 'action_profitability_items',
'type': 'object',
'args': json.dumps(args),
}
data += [downpayments_data]
total_invoiced += downpayment_amount_invoiced
total_to_invoice -= downpayment_amount_invoiced
product_read_group = self.env['product.product'].sudo()._read_group(
[('id', 'in', list(sols_per_product))],
['invoice_policy', 'service_type', 'type'],
['id:array_agg'],
)
service_policy_to_invoice_type = self._get_service_policy_to_invoice_type()
general_to_service_map = self.env['product.template']._get_general_to_service_map()
for invoice_policy, service_type, type_, product_ids in product_read_group:
service_policy = None
if type_ == 'service':
service_policy = general_to_service_map.get(
(invoice_policy, service_type),
'ordered_prepaid')
for product_id, (amount_to_invoice, amount_invoiced, sol_ids) in sols_per_product.items():
if product_id in product_ids:
invoice_type = service_policy_to_invoice_type.get(service_policy, 'materials')
revenue = revenues_dict.setdefault(invoice_type, {'invoiced': 0.0, 'to_invoice': 0.0})
revenue['to_invoice'] += amount_to_invoice
total_to_invoice += amount_to_invoice
revenue['invoiced'] += amount_invoiced
total_invoiced += amount_invoiced
if display_sol_action and invoice_type in ['service_revenues', 'materials']:
revenue.setdefault('record_ids', []).extend(sol_ids)
if display_sol_action:
section_name = 'materials'
materials = revenues_dict.get(section_name, {})
sale_order_items = self.env['sale.order.line'] \
.browse(materials.pop('record_ids', [])) \
._filtered_access('read')
if sale_order_items:
args = [section_name, [('id', 'in', sale_order_items.ids)]]
if len(sale_order_items) == 1:
args.append(sale_order_items.id)
action_params = {
'name': 'action_profitability_items',
'type': 'object',
'args': json.dumps(args),
}
if len(sale_order_items) == 1:
action_params['res_id'] = sale_order_items.id
materials['action'] = action_params
sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type()
data += [{
'id': invoice_type,
'sequence': sequence_per_invoice_type[invoice_type],
**vals,
} for invoice_type, vals in revenues_dict.items()]
return {
'data': data,
'total': {'to_invoice': total_to_invoice, 'invoiced': total_invoiced},
}
def _get_items_from_invoices_domain(self, domain=None):
domain = Domain(domain or Domain.TRUE)
included_invoice_line_ids = self._get_already_included_profitability_invoice_line_ids()
return domain & Domain([
('move_id.move_type', 'in', self.env['account.move'].get_sale_types()),
('parent_state', 'in', ['draft', 'posted']),
('price_subtotal', '!=', 0),
('is_downpayment', '=', False),
('id', 'not in', included_invoice_line_ids),
])
def _get_items_from_invoices(self, excluded_move_line_ids=None, with_action=True):
"""
Get all items from invoices, and put them into their own respective section
(either costs or revenues)
If the final total is 0 for either to_invoice or invoiced (ex: invoice -> credit note),
we don't output a new section
:param excluded_move_line_ids a list of 'account.move.line' to ignore
when fetching the move lines, for example a list of invoices that were
generated from a sales order
"""
if excluded_move_line_ids is None:
excluded_move_line_ids = []
aml_fetch_fields = [
'balance', 'parent_state', 'company_currency_id', 'analytic_distribution', 'move_id',
'display_type', 'date',
]
invoices_move_lines = self.env['account.move.line'].sudo().search_fetch(
Domain.AND([
self._get_items_from_invoices_domain([('id', 'not in', excluded_move_line_ids)]),
[('analytic_distribution', 'in', self.account_id.ids)]
]),
aml_fetch_fields,
)
res = {
'revenues': {
'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}
},
'costs': {
'data': [], 'total': {'billed': 0.0, 'to_bill': 0.0}
},
}
# TODO: invoices_move_lines.with_context(prefetch_fields=False).move_id.move_type ??
if invoices_move_lines:
revenues_lines = []
cogs_lines = []
for move_line in invoices_move_lines:
if move_line['display_type'] == 'cogs':
cogs_lines.append(move_line)
else:
revenues_lines.append(move_line)
for move_lines, ml_type in ((revenues_lines, 'revenues'), (cogs_lines, 'costs')):
amount_invoiced = amount_to_invoice = 0.0
for move_line in move_lines:
currency = move_line.company_currency_id
line_balance = currency._convert(move_line.balance, self.currency_id, self.company_id, move_line.date)
# an analytic account can appear several time in an analytic distribution with different repartition percentage
analytic_contribution = sum(
percentage for ids, percentage in move_line.analytic_distribution.items()
if str(self.account_id.id) in ids.split(',')
) / 100.
if move_line.parent_state == 'draft':
amount_to_invoice -= line_balance * analytic_contribution
else: # move_line.parent_state == 'posted'
amount_invoiced -= line_balance * analytic_contribution
# don't display the section if the final values are both 0 (invoice -> credit note)
if amount_invoiced != 0 or amount_to_invoice != 0:
section_id = 'other_invoice_revenues' if ml_type == 'revenues' else 'cost_of_goods_sold'
invoices_items = {
'id': section_id,
'sequence': self._get_profitability_sequence_per_invoice_type()[section_id],
'invoiced' if ml_type == 'revenues' else 'billed': amount_invoiced,
'to_invoice' if ml_type == 'revenues' else 'to_bill': amount_to_invoice,
}
if with_action and (
self.env.user.has_group('sales_team.group_sale_salesman_all_leads')
or self.env.user.has_group('account.group_account_invoice')
or self.env.user.has_group('account.group_account_readonly')
):
invoices_items['action'] = self._get_action_for_profitability_section(invoices_move_lines.move_id.ids, section_id)
res[ml_type] = {
'data': [invoices_items],
'total': {
'invoiced' if ml_type == 'revenues' else 'billed': amount_invoiced,
'to_invoice' if ml_type == 'revenues' else 'to_bill': amount_to_invoice,
},
}
return res
def _add_invoice_items(self, domain, profitability_items, with_action=True):
sale_lines = self.env['sale.order.line'].sudo()._read_group(
self._get_profitability_sale_order_items_domain(domain),
[],
['id:recordset'],
)[0][0]
items_from_invoices = self._get_items_from_invoices(
excluded_move_line_ids=sale_lines.invoice_lines.ids,
with_action=with_action
)
profitability_items['revenues']['data'] += items_from_invoices['revenues']['data']
profitability_items['revenues']['total']['to_invoice'] += items_from_invoices['revenues']['total']['to_invoice']
profitability_items['revenues']['total']['invoiced'] += items_from_invoices['revenues']['total']['invoiced']
profitability_items['costs']['data'] += items_from_invoices['costs']['data']
profitability_items['costs']['total']['to_bill'] += items_from_invoices['costs']['total']['to_bill']
profitability_items['costs']['total']['billed'] += items_from_invoices['costs']['total']['billed']
def _get_profitability_items(self, with_action=True):
profitability_items = super()._get_profitability_items(with_action)
sale_items = self.sudo()._get_sale_order_items()
domain = [
('order_id', 'in', sale_items.order_id.ids),
'|',
'|',
('project_id', 'in', self.ids),
('project_id', '=', False),
('id', 'in', sale_items.ids),
]
revenue_items_from_sol = self._get_revenues_items_from_sol(
domain,
with_action,
)
profitability_items['revenues']['data'] += revenue_items_from_sol['data']
profitability_items['revenues']['total']['to_invoice'] += revenue_items_from_sol['total']['to_invoice']
profitability_items['revenues']['total']['invoiced'] += revenue_items_from_sol['total']['invoiced']
self._add_invoice_items(domain, profitability_items, with_action=with_action)
self._add_purchase_items(profitability_items, with_action=with_action)
return profitability_items
def _get_stat_buttons(self):
buttons = super()._get_stat_buttons()
if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
buttons.append({
'icon': 'dollar',
'text': self.env._('Sales Orders'),
'number': self.sale_order_count,
'action_type': 'object',
'action': 'action_view_sos',
'additional_context': json.dumps({
'create_for_project_id': self.id,
}),
'show': self.display_sales_stat_buttons and self.sale_order_count > 0,
'sequence': 27,
})
if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
buttons.append({
'icon': 'dollar',
'text': self.env._('Sales Order Items'),
'number': self.sale_order_line_count,
'action_type': 'object',
'action': 'action_view_sols',
'show': self.display_sales_stat_buttons,
'sequence': 28,
})
if self.env.user.has_group('account.group_account_readonly'):
buttons.append({
'icon': 'pencil-square-o',
'text': self.env._('Invoices'),
'number': self.invoice_count,
'action_type': 'object',
'action': 'action_open_project_invoices',
'show': bool(self.account_id) and self.invoice_count > 0,
'sequence': 30,
})
if self.env.user.has_group('account.group_account_readonly'):
buttons.append({
'icon': 'pencil-square-o',
'text': self.env._('Vendor Bills'),
'number': self.vendor_bill_count,
'action_type': 'object',
'action': 'action_open_project_vendor_bills',
'show': self.vendor_bill_count > 0,
'sequence': 38,
})
return buttons
def _get_profitability_values(self):
if not self.allow_billable:
return {}, False
return super()._get_profitability_values()
# ---------------------------------------------------
# Actions
# ---------------------------------------------------
def _get_hide_partner(self):
return not self.allow_billable
def _get_projects_to_make_billable_domain(self):
return Domain.AND([
super()._get_projects_to_make_billable_domain(),
[('allow_billable', '=', False)],
])
def action_view_tasks(self):
if self.env.context.get('generate_milestone'):
line_id = self.env.context.get('default_sale_line_id')
default_line = self.env['sale.order.line'].browse(line_id)
milestone = self.env['project.milestone'].create({
'name': default_line.name,
'project_id': self.id,
'sale_line_id': line_id,
'quantity_percentage': 1,
})
if default_line.product_id.service_tracking == 'task_in_project':
default_line.task_id.milestone_id = milestone.id
action = super().action_view_tasks()
action['context']['hide_partner'] = self._get_hide_partner()
action['context']['allow_billable'] = self.allow_billable
if self.env.context.get("from_sale_order_action"):
context = dict(action.get("context", {}))
context.pop("search_default_open_tasks", None)
if sale_order_id := self.env.context.get('default_reinvoiced_sale_order_id') or self.reinvoiced_sale_order_id.id:
context["search_default_sale_order_id"] = sale_order_id
if not self.sale_order_id:
sale_order = self.env["sale.order"].browse(self.env.context.get("active_id"))
context["default_sale_order_id"] = sale_order.id
action["context"] = context
return action
def action_open_project_vendor_bills(self):
move_lines = self.env['account.move.line'].search_fetch(
[
('move_id.move_type', 'in', ['in_invoice', 'in_refund']),
('analytic_distribution', 'in', self.account_id.ids),
],
['move_id'],
)
vendor_bill_ids = move_lines.move_id.ids
action_window = {
'name': _('Vendor Bills'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'views': [[False, 'list'], [False, 'form'], [False, 'kanban']],
'domain': [('id', 'in', vendor_bill_ids)],
'context': {
'default_move_type': 'in_invoice',
'project_id': self.id,
},
'help': "<p class='o_view_nocontent_smiling_face'>%s</p><p>%s</p>" % (
_("Create a vendor bill"),
_("Create invoices, register payments and keep track of the discussions with your vendors."),
),
}
if not self.env.context.get('from_embedded_action') and len(vendor_bill_ids) == 1:
action_window['views'] = [[False, 'form']]
action_window['res_id'] = vendor_bill_ids[0]
return action_window
def _fetch_products_linked_to_template(self, limit=None):
self.ensure_one()
return self.env['product.template'].search([('project_template_id', '=', self.id)], limit=limit)
def template_to_project_confirmation_callback(self, callbacks):
super().template_to_project_confirmation_callback(callbacks)
if callbacks.get('unlink_template_products'):
self._fetch_products_linked_to_template().project_template_id = False
def _get_template_to_project_confirmation_callbacks(self):
callbacks = super()._get_template_to_project_confirmation_callbacks()
if self._fetch_products_linked_to_template(limit=1):
callbacks['unlink_template_products'] = True
return callbacks
def _get_template_to_project_warnings(self):
self.ensure_one()
res = super()._get_template_to_project_warnings()
if self.is_template and self._fetch_products_linked_to_template(limit=1):
res.append(self.env._('Converting this template to a regular project will unlink it from its associated products.'))
return res
def _get_template_default_context_whitelist(self):
return [
*super()._get_template_default_context_whitelist(),
'allow_billable',
'from_sale_order_action',
]

View file

@ -0,0 +1,260 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, AccessError
from odoo.fields import Domain
from odoo.tools import SQL
from odoo.tools.misc import unquote
class ProjectTask(models.Model):
_inherit = "project.task"
def _domain_sale_line_id(self):
domain = Domain.AND([
self.env['sale.order.line']._sellable_lines_domain(),
self.env['sale.order.line']._domain_sale_line_service(),
[
'|',
('order_partner_id.commercial_partner_id.id', 'parent_of', unquote('partner_id if partner_id else []')),
('order_partner_id', '=?', unquote('partner_id')),
],
])
return domain
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.", group_expand="_group_expand_sales_order")
sale_line_id = fields.Many2one(
'sale.order.line', 'Sales Order Item',
copy=True, tracking=True, index='btree_not_null', recursive=True,
compute='_compute_sale_line', store=True, readonly=False,
domain=lambda self: str(self._domain_sale_line_id()),
help="Sales Order Item to which the time spent on this task will be added in order to be invoiced to your customer.\n"
"By default the sales order item set on the project will be selected. In the absence of one, the last prepaid sales order item that has time remaining will be used.\n"
"Remove the sales order item in order to make this task non billable. You can also change or remove the sales order item of each timesheet entry individually.")
project_sale_order_id = fields.Many2one('sale.order', string="Project's sale order", related='project_id.sale_order_id')
sale_order_state = fields.Selection(related='sale_order_id.state')
task_to_invoice = fields.Boolean("To invoice", compute='_compute_task_to_invoice', search='_search_task_to_invoice', groups='sales_team.group_sale_salesman_all_leads')
allow_billable = fields.Boolean(related="project_id.allow_billable")
partner_id = fields.Many2one(inverse='_inverse_partner_id')
# Project sharing fields
display_sale_order_button = fields.Boolean(string='Display Sales Order', compute='_compute_display_sale_order_button')
@property
def TASK_PORTAL_READABLE_FIELDS(self):
return super().TASK_PORTAL_READABLE_FIELDS | {'allow_billable', 'sale_order_id', 'sale_line_id', 'display_sale_order_button'}
@api.model
def default_get(self, fields):
default = super().default_get(fields)
if self.env.context.get("from_sale_order_action"):
sol = self.env['sale.order.line'].search([
("order_id", "=", self.env.context.get("default_sale_order_id")),
("project_id", "=", self.env.context.get("active_id")),
], limit=1)
if sol:
default["sale_line_id"] = sol.id
return default
@api.model
def _group_expand_sales_order(self, sales_orders, domain):
start_date = self.env.context.get('gantt_start_date')
scale = self.env.context.get('gantt_scale')
if not (start_date and scale):
return sales_orders
search_on_comodel = self._search_on_comodel(domain, "sale_order_id", "sale.order")
if search_on_comodel:
return search_on_comodel
return sales_orders
@api.depends('sale_line_id', 'project_id', 'allow_billable', 'project_id.reinvoiced_sale_order_id')
def _compute_sale_order_id(self):
for task in self:
if not (task.allow_billable and task.sale_line_id):
task.sale_order_id = False
continue
sale_order = (
task.sale_line_id.order_id
or task.project_id.sale_order_id
or task.project_id.reinvoiced_sale_order_id
or task.sale_order_id
)
if sale_order and not task.partner_id:
task.partner_id = sale_order.partner_id
consistent_partners = (
sale_order.partner_id
| sale_order.partner_invoice_id
| sale_order.partner_shipping_id
).commercial_partner_id
if task.partner_id.commercial_partner_id in consistent_partners:
task.sale_order_id = sale_order
else:
task.sale_order_id = False
@api.depends('allow_billable')
def _compute_partner_id(self):
billable_task = self.filtered(lambda t: t.allow_billable or (not self._origin and t.parent_id.allow_billable))
(self - billable_task).partner_id = False
super(ProjectTask, billable_task)._compute_partner_id()
def _inverse_partner_id(self):
for task in self:
# check that sale_line_id/sale_order_id and customer are consistent
consistent_partners = (
task.sale_order_id.partner_id
| task.sale_order_id.partner_invoice_id
| task.sale_order_id.partner_shipping_id
).commercial_partner_id
if task.sale_order_id and task.partner_id.commercial_partner_id not in consistent_partners:
task.sale_order_id = task.sale_line_id = False
@api.depends('sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'milestone_id.sale_line_id', 'allow_billable')
def _compute_sale_line(self):
for task in self:
if not (task.allow_billable or task.parent_id.allow_billable):
task.sale_line_id = False
continue
if not task.sale_line_id:
# if the project_id is set then it means the task is classic task or a subtask with another project than its parent.
# To determine the sale_line_id, we first need to look at the parent before the project to manage the case of subtasks.
# Two sub-tasks in the same project do not necessarily have the same sale_line_id (need to look at the parent task).
sale_line = False
if task.parent_id.sale_line_id and task.parent_id.partner_id.commercial_partner_id == task.partner_id.commercial_partner_id:
sale_line = task.parent_id.sale_line_id
elif task.milestone_id.sale_line_id:
sale_line = task.milestone_id.sale_line_id
elif task.project_id.sale_line_id and task.project_id.partner_id.commercial_partner_id == task.partner_id.commercial_partner_id:
sale_line = task.project_id.sale_line_id
task.sale_line_id = sale_line
@api.depends('sale_order_id')
def _compute_display_sale_order_button(self):
if not self.sale_order_id:
self.display_sale_order_button = False
return
try:
sale_orders = self.env['sale.order'].search([('id', 'in', self.sale_order_id.ids)])
for task in self:
task.display_sale_order_button = task.sale_order_id in sale_orders
except AccessError:
self.display_sale_order_button = False
@api.constrains('sale_line_id')
def _check_sale_line_type(self):
for task in self.sudo():
if task.sale_line_id:
if not task.sale_line_id.is_service or task.sale_line_id.is_expense:
raise ValidationError(_(
'You cannot link the order item %(order_id)s - %(product_id)s to this task because it is a re-invoiced expense.',
order_id=task.sale_line_id.order_id.name,
product_id=task.sale_line_id.product_id.display_name,
))
def _ensure_sale_order_linked(self, sol_ids):
""" Orders created from project/task are supposed to be confirmed to match the typical flow from sales, but since
we allow SO creation from the project/task itself we want to confirm newly created SOs immediately after creation.
However this would leads to SOs being confirmed without a single product, so we'd rather do it on record save.
"""
quotations = self.env['sale.order.line'].sudo()._read_group(
domain=[('state', '=', 'draft'), ('id', 'in', sol_ids)],
aggregates=['order_id:recordset'],
)[0][0]
if quotations:
quotations.action_confirm()
@api.model_create_multi
def create(self, vals_list):
tasks = super().create(vals_list)
sol_ids = {
vals['sale_line_id']
for vals in vals_list
if vals.get('sale_line_id')
}
if sol_ids:
tasks._ensure_sale_order_linked(list(sol_ids))
return tasks
def write(self, vals):
task = super().write(vals)
if sol_id := vals.get('sale_line_id'):
self._ensure_sale_order_linked([sol_id])
return task
# ---------------------------------------------------
# Actions
# ---------------------------------------------------
def _get_action_view_so_ids(self):
return self.sale_order_id.ids
def action_view_so(self):
so_ids = self._get_action_view_so_ids()
action_window = {
"type": "ir.actions.act_window",
"res_model": "sale.order",
"name": _("Sales Order"),
"views": [[False, "list"], [False, "kanban"], [False, "form"]],
"context": {"create": False, "show_sale": True},
"domain": [["id", "in", so_ids]],
}
if len(so_ids) == 1:
action_window["views"] = [[False, "form"]]
action_window["res_id"] = so_ids[0]
return action_window
def action_project_sharing_view_so(self):
self.ensure_one()
if not self.display_sale_order_button:
return {}
return {
"name": self.env._("Portal Sale Order"),
"type": "ir.actions.act_url",
"url": self.sale_order_id.access_url,
}
def _rating_get_partner(self):
partner = self.partner_id or self.sale_line_id.order_id.partner_id
return partner or super()._rating_get_partner()
@api.depends('sale_order_id.invoice_status', 'sale_order_id.order_line')
def _compute_task_to_invoice(self):
for task in self:
if task.sale_order_id:
task.task_to_invoice = bool(task.sale_order_id.invoice_status not in ('no', 'invoiced'))
else:
task.task_to_invoice = False
@api.model
def _search_task_to_invoice(self, operator, value):
if operator != 'in':
return NotImplemented
sql = SQL("""(
SELECT so.id
FROM sale_order so
WHERE so.invoice_status != 'invoiced'
AND so.invoice_status != 'no'
)""")
return [('sale_order_id', 'in', sql)]
@api.onchange('sale_line_id')
def _onchange_partner_id(self):
if not self.partner_id and self.sale_line_id:
self.partner_id = self.sale_line_id.order_partner_id
def _get_projects_to_make_billable_domain(self, additional_domain=None):
return Domain.AND([
super()._get_projects_to_make_billable_domain(additional_domain),
[
('partner_id', '!=', False),
('allow_billable', '=', False),
('project_id', '!=', False),
],
])
def _get_template_default_context_whitelist(self):
return [
*super()._get_template_default_context_whitelist(),
'sale_line_id',
'from_sale_order_action',
]

View file

@ -0,0 +1,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class ProjectTaskRecurrence(models.Model):
_inherit = 'project.task.recurrence'
@api.model
def _get_recurring_fields_to_copy(self):
return super()._get_recurring_fields_to_copy() + ['sale_line_id']

View file

@ -0,0 +1,17 @@
from odoo import api, fields, models
class ProjectTaskType(models.Model):
_inherit = 'project.task.type'
show_rating_active = fields.Boolean(compute='_compute_show_rating_active', export_string_translation=False)
@api.depends('project_ids.allow_billable')
def _compute_show_rating_active(self):
for stage in self:
stage.show_rating_active = any(stage.project_ids.mapped('allow_billable'))
@api.onchange('project_ids')
def _onchange_project_ids(self):
if not any(self.project_ids.mapped('allow_billable')):
self.rating_active = False

View file

@ -0,0 +1,26 @@
from odoo import api, models
from odoo.tools import format_duration
class ProjectUpdate(models.Model):
_inherit = 'project.update'
@api.model
def _get_template_values(self, project):
template_values = super()._get_template_values(project)
profitability_values = template_values.get('profitability')
if profitability_values and 'revenues' in profitability_values and 'data' in profitability_values['revenues']:
for section in profitability_values['revenues']['data']:
all_sols = self.env['sale.order.line'].sudo().search(
project._get_domain_from_section_id(section["id"]),
)
sols = all_sols.with_context(with_price_unit=True)._read_format([
'name', 'product_uom_qty', 'qty_delivered', 'qty_invoiced', 'product_uom_id', 'product_id'
])
for sol in sols:
if sol['product_uom_id'][1] == 'Hours':
sol['product_uom_qty'] = format_duration(sol['product_uom_qty'])
sol['qty_delivered'] = format_duration(sol['qty_delivered'])
sol['qty_invoiced'] = format_duration(sol['qty_invoiced'])
section["sol"] = sols
return template_values

View file

@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
def set_values(self):
super().set_values()
if self.group_project_milestone:
# Search the milestones containing a SOL and change the qty_delivered_method field of the SOL and the
# service_policy field set on the product to convert from manual to milestones.
milestone_read_group = self.env['project.milestone'].read_group(
[('sale_line_id', '!=', False)],
['sale_line_ids:array_agg(sale_line_id)'],
[],
)
sale_line_ids = milestone_read_group[0]['sale_line_ids'] if milestone_read_group else []
sale_lines = self.env['sale.order.line'].sudo().browse(sale_line_ids)
sale_lines.product_id.service_policy = 'delivered_milestones'
else:
product_domain = [('type', '=', 'service'), ('service_type', '=', 'milestones')]
products = self.env['product.product'].search(product_domain)
products.service_policy = 'delivered_manual'
self.env['sale.order.line'].sudo().search([('product_id', 'in', products.ids)]).qty_delivered_method = 'manual'

View file

@ -1,35 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
from collections import defaultdict
from odoo import api, fields, models, _, Command
from odoo.tools.misc import clean_context
from odoo.tools.safe_eval import safe_eval
from odoo import api, fields, models, _
from odoo.fields import Command, Domain
from odoo.exceptions import UserError
from odoo.addons.project.models.project_task import CLOSED_STATES
class SaleOrder(models.Model):
_inherit = 'sale.order'
tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', search='_search_tasks_ids', string='Tasks associated to this sale')
tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user")
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if 'origin' in fields and (task_id := self.env.context.get('create_for_task_id')):
task = self.env['project.task'].browse(task_id)
res['origin'] = self.env._('[Project] %(task_name)s', task_name=task.name)
return res
visible_project = fields.Boolean('Display project', compute='_compute_visible_project', readonly=True)
project_id = fields.Many2one(
'project.project', 'Project', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
help='Select a non billable project on which tasks can be created.')
project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user", help="Projects used in this sales order.")
project_count = fields.Integer(string='Number of Projects', compute='_compute_project_ids', groups='project.group_project_user')
milestone_count = fields.Integer(compute='_compute_milestone_count')
is_product_milestone = fields.Boolean(compute='_compute_is_product_milestone')
tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', search='_search_tasks_ids', groups="project.group_project_user", string='Tasks associated with this sale', export_string_translation=False)
tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user", export_string_translation=False)
visible_project = fields.Boolean('Display project', compute='_compute_visible_project', readonly=True, export_string_translation=False)
project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user,project.group_project_milestone", export_string_translation=False)
project_count = fields.Integer(string='Number of Projects', compute='_compute_project_ids', groups='project.group_project_user', export_string_translation=False)
milestone_count = fields.Integer(compute='_compute_milestone_count', export_string_translation=False)
is_product_milestone = fields.Boolean(compute='_compute_is_product_milestone', export_string_translation=False)
show_create_project_button = fields.Boolean(compute='_compute_show_project_and_task_button', groups='project.group_project_user', export_string_translation=False)
show_project_button = fields.Boolean(compute='_compute_show_project_and_task_button', groups='project.group_project_user', export_string_translation=False)
closed_task_count = fields.Integer(compute='_compute_tasks_ids', groups="project.group_project_user", export_string_translation=False)
completed_task_percentage = fields.Float(compute="_compute_completed_task_percentage", groups="project.group_project_user", export_string_translation=False)
project_id = fields.Many2one('project.project', domain=[('allow_billable', '=', True), ('is_template', '=', False)], copy=False, index='btree_not_null',
help="A task will be created for the project upon sales order confirmation. The analytic distribution of this project will also serve as a reference for newly created sales order items.")
project_account_id = fields.Many2one('account.analytic.account', related='project_id.account_id')
def _compute_milestone_count(self):
read_group = self.env['project.milestone']._read_group(
[('sale_line_id', 'in', self.order_line.ids)],
['sale_line_id'],
['sale_line_id'],
['__count'],
)
line_data = {res['sale_line_id'][0]: res['sale_line_id_count'] for res in read_group}
line_data = {sale_line.id: count for sale_line, count in read_group}
for order in self:
order.milestone_count = sum(line_data.get(line.id, 0) for line in order.order_line)
@ -37,44 +50,59 @@ class SaleOrder(models.Model):
for order in self:
order.is_product_milestone = order.order_line.product_id.filtered(lambda p: p.service_policy == 'delivered_milestones')
def _compute_show_project_and_task_button(self):
is_project_manager = self.env.user.has_group('project.group_project_manager')
show_button_ids = self.env['sale.order.line']._read_group([
('order_id', 'in', self.ids),
('order_id.state', 'not in', ['draft', 'sent']),
], aggregates=['order_id:array_agg'])[0][0]
for order in self:
state = order.state not in ['draft', 'sent']
order.show_project_button = state and order.project_count
order.show_create_project_button = (
is_project_manager
and order.id in show_button_ids
and not order.project_count
)
@api.model
def _search_tasks_ids(self, operator, value):
is_name_search = operator in ['=', '!=', 'like', '=like', 'ilike', '=ilike'] and isinstance(value, str)
is_id_eq_search = operator in ['=', '!='] and isinstance(value, int)
is_id_in_search = operator in ['in', 'not in'] and isinstance(value, list) and all(isinstance(item, int) for item in value)
if not (is_name_search or is_id_eq_search or is_id_in_search):
raise NotImplementedError(_('Operation not supported'))
if is_name_search:
tasks_ids = self.env['project.task']._name_search(value, operator=operator, limit=None)
elif is_id_eq_search:
tasks_ids = value if operator == '=' else self.env['project.task']._search([('id', '!=', value)], order='id')
else: # is_id_in_search
tasks_ids = self.env['project.task']._search([('id', operator, value)], order='id')
tasks = self.env['project.task'].browse(tasks_ids)
return [('id', 'in', tasks.sale_order_id.ids)]
if operator in Domain.NEGATIVE_OPERATORS:
return NotImplemented
task_domain = [
('display_name' if isinstance(value, str) else 'id', operator, value),
('sale_order_id', '!=', False),
]
query = self.env['project.task']._search(task_domain)
return [('id', 'in', query.subselect('sale_order_id'))]
@api.depends('order_line.product_id.project_id')
def _compute_tasks_ids(self):
tasks_per_so = self.env['project.task']._read_group(
domain=self._tasks_ids_domain(),
fields=['sale_order_id', 'ids:array_agg(id)'],
groupby=['sale_order_id'],
groupby=['sale_order_id', 'state'],
aggregates=['id:recordset', '__count']
)
so_to_tasks_and_count = {}
for group in tasks_per_so:
if group['sale_order_id']:
so_to_tasks_and_count[group['sale_order_id'][0]] = {'task_ids': group['ids'], 'count': group['sale_order_id_count']}
so_with_tasks = self.env['sale.order']
for order, state, tasks_ids, tasks_count in tasks_per_so:
if order:
order.tasks_ids += tasks_ids
order.tasks_count += tasks_count
order.closed_task_count += state in CLOSED_STATES and tasks_count
so_with_tasks += order
else:
# tasks that have no sale_order_id need to be associated with the SO from their sale_line_id
for task in self.env['project.task'].browse(group['ids']):
so_to_tasks_item = so_to_tasks_and_count.setdefault(task.sale_line_id.order_id.id, {'task_ids': [], 'count': 0})
so_to_tasks_item['task_ids'].append(task.id)
so_to_tasks_item['count'] += 1
for order in self:
order.tasks_ids = [Command.set(so_to_tasks_and_count.get(order.id, {}).get('task_ids', []))]
order.tasks_count = so_to_tasks_and_count.get(order.id, {}).get('count', 0)
for task in tasks_ids:
task_so = task.sale_line_id.order_id
task_so.tasks_ids = [Command.link(task.id)]
task_so.tasks_count += 1
task_so.closed_task_count += state in CLOSED_STATES
so_with_tasks += task_so
remaining_orders = self - so_with_tasks
if remaining_orders:
remaining_orders.tasks_ids = [Command.clear()]
remaining_orders.tasks_count = 0
remaining_orders.closed_task_count = 0
@api.depends('order_line.product_id.service_tracking')
def _compute_visible_project(self):
@ -87,101 +115,143 @@ class SaleOrder(models.Model):
@api.depends('order_line.product_id', 'order_line.project_id')
def _compute_project_ids(self):
is_project_manager = self.user_has_groups('project.group_project_manager')
projects = self.env['project.project'].search([('sale_order_id', 'in', self.ids)])
projects = self.env['project.project'].search(['|', ('sale_order_id', 'in', self.ids), ('reinvoiced_sale_order_id', 'in', self.ids)])
projects_per_so = defaultdict(lambda: self.env['project.project'])
for project in projects:
projects_per_so[project.sale_order_id.id] |= project
projects_per_so[project.sale_order_id.id or project.reinvoiced_sale_order_id.id] |= project
for order in self:
projects = order.order_line.mapped('product_id.project_id')
projects |= order.order_line.mapped('project_id')
projects = order.order_line.filtered(
lambda sol:
sol.is_service
and not (sol._is_line_optional() and sol.product_uom_qty == 0)
).mapped('product_id.project_id')
projects |= order.project_id
projects |= order.order_line.mapped('project_id')
projects |= projects_per_so[order.id or order._origin.id]
if not is_project_manager:
projects = projects._filter_access_rules('read')
projects = projects._filtered_access('read')
order.project_ids = projects
order.project_count = len(projects)
@api.onchange('project_id')
def _onchange_project_id(self):
""" Set the SO analytic account to the selected project's analytic account """
if self.project_id.analytic_account_id:
self.analytic_account_id = self.project_id.analytic_account_id
order.project_count = len(projects.filtered('active'))
def _action_confirm(self):
""" On SO confirmation, some lines should generate a task or a project. """
result = super()._action_confirm()
context = clean_context(self._context)
if self.env.context.get('disable_project_task_generation'):
return super()._action_confirm()
if len(self.company_id) == 1:
# All orders are in the same company
self.order_line.sudo().with_context(context).with_company(self.company_id)._timesheet_service_generation()
self.order_line.sudo().with_company(self.company_id)._timesheet_service_generation()
else:
# Orders from different companies are confirmed together
for order in self:
order.order_line.sudo().with_context(context).with_company(order.company_id)._timesheet_service_generation()
return result
order.order_line.sudo().with_company(order.company_id)._timesheet_service_generation()
def action_view_task(self):
self.ensure_one()
list_view_id = self.env.ref('project.view_task_tree2').id
form_view_id = self.env.ref('project.view_task_form2').id
action = {'type': 'ir.actions.act_window_close'}
task_projects = self.tasks_ids.mapped('project_id')
if len(task_projects) == 1 and len(self.tasks_ids) > 1: # redirect to task of the project (with kanban stage, ...)
action = self.with_context(active_id=task_projects.id).env['ir.actions.actions']._for_xml_id(
'project.act_project_project_2_project_task_all')
if action.get('context'):
eval_context = self.env['ir.actions.actions']._get_eval_context()
eval_context.update({'active_id': task_projects.id})
action_context = safe_eval(action['context'], eval_context)
action_context.update(eval_context)
action['context'] = action_context
else:
action = self.env["ir.actions.actions"]._for_xml_id("project.action_view_task")
action['context'] = {} # erase default context to avoid default filter
if len(self.tasks_ids) > 1: # cross project kanban task
action['views'] = [[False, 'kanban'], [list_view_id, 'tree'], [form_view_id, 'form'], [False, 'graph'], [False, 'calendar'], [False, 'pivot']]
elif len(self.tasks_ids) == 1: # single task -> form view
action['views'] = [(form_view_id, 'form')]
action['res_id'] = self.tasks_ids.id
# filter on the task of the current SO
action['domain'] = self._tasks_ids_domain()
action.setdefault('context', {})
return action
# If the order has exactly one project and that project comes from a template, set the company of the template
# on the project.
for order in self.sudo(): # Salesman may not have access to projects
if len(order.project_ids) == 1:
project = order.project_ids[0]
for sol in order.order_line:
if project == sol.project_id and (project_template := sol.product_template_id.project_template_id):
project.sudo().company_id = project_template.sudo().company_id
break
return super()._action_confirm()
def _tasks_ids_domain(self):
return ['&', ('display_project_id', '!=', False), '|', ('sale_line_id', 'in', self.order_line.ids), ('sale_order_id', 'in', self.ids)]
return ['&', ('is_template', '=', False), ('project_id', '!=', False), '|', ('sale_line_id', 'in', self.order_line.ids), ('sale_order_id', 'in', self.ids), ('has_template_ancestor', '=', False)]
def action_create_project(self):
self.ensure_one()
if not self.show_create_project_button:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'danger',
'message': self.env._("The project couldn't be created as the Sales Order must be confirmed or is already linked to a project."),
}
}
sorted_line = self.order_line.sorted('sequence')
default_sale_line = next((
sol for sol in sorted_line
if sol.product_id.type == 'service' and not sol.is_downpayment
), self.env['sale.order.line'])
view_id = self.env.ref('sale_project.sale_project_view_form_simplified_template', raise_if_not_found=False)
return {
**self.env['project.template.create.wizard'].action_open_template_view(),
'name': self.env._('Create a Project'),
'views': [(view_id.id, 'form')],
'context': {
'default_sale_order_id': self.id,
'default_reinvoiced_sale_order_id': self.id,
'default_sale_line_id': default_sale_line.id,
'default_partner_id': self.partner_id.id,
'default_user_ids': [self.env.uid],
'default_allow_billable': 1,
'hide_allow_billable': True,
'default_company_id': self.company_id.id,
'generate_milestone': default_sale_line.product_id.service_policy == 'delivered_milestones',
'default_name': self.name,
'default_allow_milestones': 'delivered_milestones' in self.order_line.product_id.mapped('service_policy'),
},
}
def action_view_project_ids(self):
self.ensure_one()
view_form_id = self.env.ref('project.edit_project').id
view_kanban_id = self.env.ref('project.view_project_kanban').id
action = {
'type': 'ir.actions.act_window',
'domain': [('id', 'in', self.with_context(active_test=False).project_ids.ids), ('active', 'in', [True, False])],
'view_mode': 'kanban,form',
'name': _('Projects'),
'res_model': 'project.project',
}
if len(self.with_context(active_test=False).project_ids) == 1:
action.update({'views': [(view_form_id, 'form')], 'res_id': self.project_ids.id})
if not self.order_line:
return {'type': 'ir.actions.act_window_close'}
sorted_line = self.order_line.sorted('sequence')
default_sale_line = next((
sol for sol in sorted_line if sol.product_id.type == 'service'
), self.env['sale.order.line'])
project_ids = self.project_ids
partner = self.partner_shipping_id or self.partner_id
if len(project_ids) == 1:
action = self.env['ir.actions.actions'].with_context(
active_id=self.project_ids.id,
)._for_xml_id('project.act_project_project_2_project_task_all')
action['context'] = {
'active_id': project_ids.id,
'default_partner_id': partner.id,
'default_project_id': self.project_ids.id,
'default_sale_line_id': default_sale_line.id,
'default_user_ids': [self.env.uid],
'search_default_sale_order_id': self.id,
}
return action
else:
action['views'] = [(view_kanban_id, 'kanban'), (view_form_id, 'form')]
return action
action = self.env['ir.actions.actions']._for_xml_id('project.open_view_project_all')
action['domain'] = [
'|',
('sale_order_id', '=', self.id),
('id', 'in', project_ids.ids),
]
action['context'] = {
**self.env.context,
'default_partner_id': partner.id,
'default_reinvoiced_sale_order_id': self.id,
'default_sale_line_id': default_sale_line.id,
'default_allow_billable': 1,
'from_sale_order_action': True,
}
return action
def action_view_milestone(self):
self.ensure_one()
default_project = self.project_ids and self.project_ids[0]
default_sale_line = default_project.sale_line_id or self.order_line and self.order_line[0]
sorted_line = self.order_line.sorted('sequence')
default_sale_line = next((
sol for sol in sorted_line
if sol.is_service and sol.product_id.service_policy == 'delivered_milestones'
), self.env['sale.order.line'])
return {
'type': 'ir.actions.act_window',
'name': _('Milestones'),
'domain': [('sale_line_id', 'in', self.order_line.ids)],
'res_model': 'project.milestone',
'views': [(self.env.ref('sale_project.sale_project_milestone_view_tree').id, 'tree')],
'view_mode': 'tree',
'views': [(self.env.ref('sale_project.project_milestone_view_tree').id, 'list')],
'view_mode': 'list',
'help': _("""
<p class="o_view_nocontent_smiling_face">
No milestones found. Let's create one!
@ -191,17 +261,46 @@ class SaleOrder(models.Model):
"""),
'context': {
**self.env.context,
'default_project_id' : default_project.id,
'default_sale_line_id' : default_sale_line.id,
'default_project_id': default_project.id,
'default_sale_line_id': default_sale_line.id,
}
}
def write(self, values):
if 'state' in values and values['state'] == 'cancel':
self.project_id.sudo().sale_line_id = False
return super(SaleOrder, self).write(values)
@api.model_create_multi
def create(self, vals_list):
created_records = super().create(vals_list)
project = self.env['project.project'].browse(self.env.context.get('create_for_project_id'))
task = self.env['project.task'].browse(self.env.context.get('create_for_task_id'))
if project or task:
service_sol = next((sol for sol in created_records.order_line if sol.is_service), self.env['sale.order.line'])
if project and not project.sale_line_id:
project.sale_line_id = service_sol
if not project.reinvoiced_sale_order_id:
project.reinvoiced_sale_order_id = service_sol.order_id or created_records[0] if created_records else False
if task and not task.sale_line_id:
created_records.with_context(disable_project_task_generation=True).action_confirm()
task.sale_line_id = service_sol
return created_records
def _prepare_analytic_account_data(self, prefix=None):
result = super(SaleOrder, self)._prepare_analytic_account_data(prefix=prefix)
result['plan_id'] = self.company_id.analytic_plan_id.id or result['plan_id']
return result
def write(self, vals):
res = super().write(vals)
if 'state' in vals and vals['state'] == 'cancel':
# Remove sale line field reference from all projects
self.env['project.project'].sudo().search([('sale_line_id.order_id', 'in', self.ids)]).sale_line_id = False
return res
def _compute_completed_task_percentage(self):
for so in self:
so.completed_task_percentage = so.tasks_count and so.closed_task_count / so.tasks_count
def action_confirm(self):
if len(self) == 1 and self.env.context.get('create_for_project_id') and self.state == 'sale':
# do nothing since the SO has been automatically confirmed during its creation
return True
return super().action_confirm()
def get_first_service_line(self):
line = next((sol for sol in self.order_line if sol.is_service), False)
if not line:
raise UserError(self.env._('The Sales Order must contain at least one service product.'))
return line

View file

@ -1,11 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import api, Command, fields, models, _
from odoo.tools import clean_context, format_amount
from odoo.tools.sql import column_exists, create_column
from odoo.exceptions import AccessError, UserError
class SaleOrderLine(models.Model):
@ -14,41 +10,57 @@ class SaleOrderLine(models.Model):
qty_delivered_method = fields.Selection(selection_add=[('milestones', 'Milestones')])
project_id = fields.Many2one(
'project.project', 'Generated Project',
index=True, copy=False)
index=True, copy=False, export_string_translation=False)
task_id = fields.Many2one(
'project.task', 'Generated Task',
index=True, copy=False)
# used to know if generate a task and/or a project, depending on the product settings
is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True)
reached_milestones_ids = fields.One2many('project.milestone', 'sale_line_id', string='Reached Milestones', domain=[('is_reached', '=', True)])
index=True, copy=False, export_string_translation=False)
reached_milestones_ids = fields.One2many('project.milestone', 'sale_line_id', string='Reached Milestones', domain=[('is_reached', '=', True)], export_string_translation=False)
def name_get(self):
res = super().name_get()
with_price_unit = self.env.context.get('with_price_unit')
if with_price_unit:
names = dict(res)
result = []
sols_by_so_dict = defaultdict(lambda: self.env[self._name]) # key: (sale_order_id, product_id), value: sale order line
for line in self:
sols_by_so_dict[line.order_id.id, line.product_id.id] += line
def _get_product_from_sol_name_domain(self, product_name):
return [
('name', 'ilike', product_name),
('type', '=', 'service'),
('company_id', 'in', [False, self.env.company.id]),
]
for sols in sols_by_so_dict.values():
if len(sols) > 1 and all(sols.mapped('is_service')):
result += [(
line.id,
'%s - %s' % (
names.get(line.id), format_amount(self.env, line.price_unit, line.currency_id))
) for line in sols]
else:
result += [(line.id, names.get(line.id)) for line in sols]
return result
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if self.env.context.get('form_view_ref') == 'sale_project.sale_order_line_view_form_editable':
default_values = {
'name': _("New Sales Order Item"),
}
# If we can't add order lines to the default order, discard it
if 'order_id' in res:
try:
self.env['sale.order'].browse(res['order_id']).check_access('write')
except AccessError:
del res['order_id']
if 'order_id' in fields and not res.get('order_id'):
assert (partner_id := self.env.context.get('default_partner_id'))
project_id = self.env.context.get('link_to_project')
sale_order = None
so_create_values = {
'partner_id': partner_id,
'company_id': self.env.context.get('default_company_id') or self.env.company.id,
}
if project_id:
try:
project_so = self.env['project.project'].browse(project_id).sale_order_id
project_so.check_access('write')
sale_order = project_so or self.env['sale.order'].search([('project_id', '=', project_id)], limit=1)
except AccessError:
pass
if not sale_order:
so_create_values['project_ids'] = [Command.link(project_id)]
if not sale_order:
sale_order = self.env['sale.order'].create(so_create_values)
default_values['order_id'] = sale_order.id
return {**res, **default_values}
return res
@api.depends('product_id.type')
def _compute_is_service(self):
for so_line in self:
so_line.is_service = so_line.product_id.type == 'service'
@api.depends('product_id.type')
def _compute_product_updatable(self):
super()._compute_product_updatable()
@ -56,21 +68,6 @@ class SaleOrderLine(models.Model):
if line.product_id.type == 'service' and line.state == 'sale':
line.product_updatable = False
def _auto_init(self):
"""
Create column to stop ORM from computing it himself (too slow)
"""
if not column_exists(self.env.cr, 'sale_order_line', 'is_service'):
create_column(self.env.cr, 'sale_order_line', 'is_service', 'bool')
self.env.cr.execute("""
UPDATE sale_order_line line
SET is_service = (pt.type = 'service')
FROM product_product pp
LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
WHERE pp.id = line.product_id
""")
return super()._auto_init()
@api.depends('product_id')
def _compute_qty_delivered_method(self):
milestones_lines = self.filtered(lambda sol:
@ -81,52 +78,101 @@ class SaleOrderLine(models.Model):
milestones_lines.qty_delivered_method = 'milestones'
super(SaleOrderLine, self - milestones_lines)._compute_qty_delivered_method()
@api.depends('qty_delivered_method', 'product_uom_qty', 'reached_milestones_ids.quantity_percentage')
@api.depends('product_uom_qty', 'reached_milestones_ids.quantity_percentage')
def _compute_qty_delivered(self):
super()._compute_qty_delivered()
def _prepare_qty_delivered(self):
lines_by_milestones = self.filtered(lambda sol: sol.qty_delivered_method == 'milestones')
super(SaleOrderLine, self - lines_by_milestones)._compute_qty_delivered()
delivered_qties = super(SaleOrderLine, self - lines_by_milestones)._prepare_qty_delivered()
if not lines_by_milestones:
return
return delivered_qties
project_milestone_read_group = self.env['project.milestone'].read_group(
project_milestone_read_group = self.env['project.milestone']._read_group(
[('sale_line_id', 'in', lines_by_milestones.ids), ('is_reached', '=', True)],
['sale_line_id', 'quantity_percentage'],
['sale_line_id'],
['quantity_percentage:sum'],
)
reached_milestones_per_sol = {res['sale_line_id'][0]: res['quantity_percentage'] for res in project_milestone_read_group}
reached_milestones_per_sol = {sale_line.id: percentage_sum for sale_line, percentage_sum in project_milestone_read_group}
for line in lines_by_milestones:
sol_id = line.id or line._origin.id
line.qty_delivered = reached_milestones_per_sol.get(sol_id, 0.0) * line.product_uom_qty
delivered_qties[line] = reached_milestones_per_sol.get(sol_id, 0.0) * line.product_uom_qty
return delivered_qties
@api.depends('order_id.partner_id', 'product_id', 'order_id.project_id')
def _compute_analytic_distribution(self):
ctx_project = self.env['project.project'].browse(self.env.context.get('project_id'))
project_lines = self.filtered(lambda l: not l.display_type and (ctx_project or l.product_id.project_id or l.order_id.project_id))
empty_project_lines = project_lines.filtered(lambda l: not l.analytic_distribution)
super(SaleOrderLine, (self - project_lines) + empty_project_lines)._compute_analytic_distribution()
for line in project_lines:
project = ctx_project or line.product_id.project_id or line.order_id.project_id
if line.analytic_distribution:
applied_root_plans = self.env['account.analytic.account'].browse(
list({int(account_id) for ids in line.analytic_distribution for account_id in ids.split(",")})
).exists().root_plan_id
if accounts_to_add := project._get_analytic_accounts().filtered(
lambda account: account.root_plan_id not in applied_root_plans
):
# project account is added to each analytic distribution line
line.analytic_distribution = {
f"{account_ids},{','.join(map(str, accounts_to_add.ids))}": percentage
for account_ids, percentage in line.analytic_distribution.items()
}
else:
line.analytic_distribution = project._get_analytic_distribution()
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
# Do not generate task/project when expense SO line, but allow
# generate task with hours=0.
context = clean_context(self._context)
for line in lines:
if line.state == 'sale' and not line.is_expense:
has_task = bool(line.task_id)
line.sudo().with_context(context)._timesheet_service_generation()
# if the SO line created a task, post a message on the order
if line.task_id and not has_task:
msg_body = _("Task Created (%s): %s", line.product_id.name, line.task_id._get_html_link())
line.order_id.message_post(body=msg_body)
confirmed_lines = lines.filtered(lambda sol: sol.state == 'sale' and not sol.is_expense)
# We track the lines that already generated a task, so we know we won't have to post a message for them after calling the generation service
has_task_lines = confirmed_lines.filtered('task_id')
confirmed_lines.sudo()._timesheet_service_generation()
# if the SO line created a task, post a message on the order
for line in confirmed_lines - has_task_lines:
if line.task_id:
msg_body = _("Task Created (%(name)s): %(link)s", name=line.product_id.name, link=line.task_id._get_html_link())
line.order_id.message_post(body=msg_body)
# Set a service SOL on the project, if any is given
if project_id := self.env.context.get('link_to_project'):
assert (service_line := next((line for line in lines if line.is_service), False))
project = self.env['project.project'].browse(project_id)
if not project.sale_line_id:
project.sale_line_id = service_line
if not project.reinvoiced_sale_order_id:
project.reinvoiced_sale_order_id = service_line.order_id
return lines
def write(self, values):
result = super().write(values)
# changing the ordered quantity should change the planned hours on the
def write(self, vals):
sols_with_no_qty_ordered = self.env['sale.order.line']
if 'product_uom_qty' in vals and vals.get('product_uom_qty') > 0:
sols_with_no_qty_ordered = self.filtered(lambda sol: sol.product_uom_qty == 0)
result = super().write(vals)
# changing the ordered quantity should change the allocated hours on the
# task, whatever the SO state. It will be blocked by the super in case
# of a locked sale order.
if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False):
if vals.get('product_uom_qty') and sols_with_no_qty_ordered:
sols_with_no_qty_ordered.filtered(lambda l: l.is_service and l.state == 'sale' and not l.is_expense)._timesheet_service_generation()
if 'product_uom_qty' in vals and not self.env.context.get('no_update_allocated_hours', False):
for line in self:
if line.task_id and line.product_id.type == 'service':
planned_hours = line._convert_qty_company_hours(line.task_id.company_id)
line.task_id.write({'planned_hours': planned_hours})
allocated_hours = line._convert_qty_company_hours(line.task_id.company_id or self.env.user.company_id)
line.task_id.write({'allocated_hours': allocated_hours})
return result
def copy_data(self, default=None):
data = super().copy_data(default)
for origin, datum in zip(self, data):
if origin.analytic_distribution == origin.order_id.project_id.sudo()._get_analytic_distribution():
datum['analytic_distribution'] = False
return data
###########################################
# Service : Project and task generation
###########################################
@ -136,39 +182,35 @@ class SaleOrderLine(models.Model):
def _timesheet_create_project_prepare_values(self):
"""Generate project values"""
account = self.order_id.analytic_account_id
if not account:
service_products = self.order_id.order_line.product_id.filtered(lambda p: p.type == 'service' and p.default_code)
default_code = service_products.default_code if len(service_products) == 1 else None
self.order_id._create_analytic_account(prefix=default_code)
account = self.order_id.analytic_account_id
# create the project or duplicate one
return {
'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name,
'analytic_account_id': account.id,
'account_id': self.env.context.get('project_account_id') or self.order_id.project_account_id.id or self.env['account.analytic.account'].create(self.order_id._prepare_analytic_account_data()).id,
'partner_id': self.order_id.partner_id.id,
'sale_line_id': self.id,
'active': True,
'company_id': self.company_id.id,
'allow_billable': True,
'user_id': self.product_id.project_template_id.user_id.id,
}
def _timesheet_create_project(self):
""" Generate project for the given so line, and link it.
:param project: record of project.project in which the task should be created
:return task: record of the created task
:return: record of the created project
"""
self.ensure_one()
values = self._timesheet_create_project_prepare_values()
if self.product_id.project_template_id:
values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name)
# The no_create_folder context key is used in documents_project
project = self.product_id.project_template_id.with_context(no_create_folder=True).copy(values)
project_template = self.product_id.project_template_id
if project_template:
values['name'] = "%s - %s" % (values['name'], project_template.name)
if project_template.is_template:
project = project_template.action_create_from_template(values)
else:
project = project_template.copy(values)
project.tasks.write({
'sale_line_id': self.id,
'partner_id': self.order_id.partner_id.id,
'email_from': self.order_id.partner_id.email,
})
# duplicating a project doesn't set the SO on sub-tasks
project.tasks.filtered('parent_id').write({
@ -182,29 +224,53 @@ class SaleOrderLine(models.Model):
])
if project_only_sol_count == 1:
values['name'] = "%s - [%s] %s" % (values['name'], self.product_id.default_code, self.product_id.name) if self.product_id.default_code else "%s - %s" % (values['name'], self.product_id.name)
# The no_create_folder context key is used in documents_project
project = self.env['project.project'].with_context(no_create_folder=True).create(values)
values.update(self._timesheet_create_project_account_vals(self.order_id.project_id))
project = self.env['project.project'].create(values)
# Avoid new tasks to go to 'Undefined Stage'
if not project.type_ids:
project.type_ids = self.env['project.task.type'].create({'name': _('New')})
project.type_ids = self.env['project.task.type'].create([{
'name': name,
'fold': fold,
'sequence': sequence,
} for name, fold, sequence in [
(_('To Do'), False, 5),
(_('In Progress'), False, 10),
(_('Done'), False, 15),
(_('Cancelled'), True, 20),
]])
# link project as generated by current so line
self.write({'project_id': project.id})
project.reinvoiced_sale_order_id = self.order_id
return project
def _timesheet_create_project_account_vals(self, project):
return {
fname: project[fname].id for fname in project._get_plan_fnames() if fname != 'account_id' and project[fname]
}
def _timesheet_create_task_prepare_values(self, project):
self.ensure_one()
planned_hours = self._convert_qty_company_hours(self.company_id)
allocated_hours = 0.0
if self.product_id.service_type != 'milestones':
allocated_hours = self._convert_qty_company_hours(self.company_id)
sale_line_name_parts = self.name.split('\n')
title = sale_line_name_parts[0] or self.product_id.name
description = '<br/>'.join(sale_line_name_parts[1:])
if sale_line_name_parts and sale_line_name_parts[0] == self.product_id.display_name:
sale_line_name_parts.pop(0)
if len(sale_line_name_parts) == 1 and sale_line_name_parts[0]:
title = sale_line_name_parts[0]
description = ''
else:
title = self.product_id.display_name
description = '<br/>'.join(sale_line_name_parts)
return {
'name': title if project.sale_line_id else '%s - %s' % (self.order_id.name or '', title),
'analytic_account_id': project.analytic_account_id.id,
'planned_hours': planned_hours,
'allocated_hours': allocated_hours,
'partner_id': self.order_id.partner_id.id,
'email_from': self.order_id.partner_id.email,
'description': description,
'project_id': project.id,
'sale_line_id': self.id,
@ -213,19 +279,61 @@ class SaleOrderLine(models.Model):
'user_ids': False, # force non assigned task, as created as sudo()
}
@api.model
def _get_product_service_policy(self):
return ['ordered_prepaid']
def _prepare_task_template_vals(self, template, project):
if template.allocated_hours:
allocated_hours = template.allocated_hours
else:
allocated_hours = sum(
sol._convert_qty_company_hours(self.company_id)
for sol in self.order_id.order_line
if sol.product_id.task_template_id.id == template.id
and sol.product_id.service_policy in self._get_product_service_policy()
)
return {
'name': '%s - %s' % (self.order_id.name, template.name),
'allocated_hours': allocated_hours,
'project_id': project.id,
'sale_line_id': self.id,
'sale_order_id': self.order_id.id,
}
def _get_sale_order_partner_id(self, project):
return self.order_id.partner_id.id
def _timesheet_create_task(self, project):
""" Generate task for the given so line, and link it.
:param project: record of project.project in which the task should be created
:return task: record of the created task
"""
values = self._timesheet_create_task_prepare_values(project)
task = self.env['project.task'].sudo().create(values)
self.write({'task_id': task.id})
if template := self.product_id.task_template_id:
vals = self._prepare_task_template_vals(template, project)
task_id = template.with_context(
default_partner_id=self._get_sale_order_partner_id(project),
).action_create_from_template(vals)
task = self.env['project.task'].sudo().browse(task_id)
else:
values = self._timesheet_create_task_prepare_values(project)
task = self.env['project.task'].sudo().create(values)
self.task_id = task
# post message on task
task_msg = _("This task has been created from: %s (%s)", self.order_id._get_html_link(), self.product_id.name)
task_msg = _("This task has been created from: %(order_link)s (%(product_name)s)",
order_link=self.order_id._get_html_link(),
product_name=self.product_id.name,
)
task.message_post(body=task_msg)
return task
def _get_so_lines_task_global_project(self):
return self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project')
def _get_so_lines_new_project(self):
return self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_in_project'])
def _timesheet_service_generation(self):
""" For service lines, create the task or the project. If already exists, it simply links
the existing one to the line.
@ -233,8 +341,15 @@ class SaleOrderLine(models.Model):
new project/task. This explains the searches on 'sale_line_id' on project/task. This also
implied if so line of generated task has been modified, we may regenerate it.
"""
so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project')
so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_in_project'])
sale_order_lines = self.filtered(
lambda sol:
sol.is_service
and sol.product_id.service_tracking in ['project_only', 'task_in_project', 'task_global_project']
and not (sol._is_line_optional() and sol.product_uom_qty == 0)
)
so_line_task_global_project = sale_order_lines._get_so_lines_task_global_project()
so_line_new_project = sale_order_lines._get_so_lines_new_project()
task_templates = self.env['project.task']
# search so lines from SO of current so lines having their project generated, in order to check if the current one can
# create its own project, or reuse the one of its order.
@ -259,33 +374,25 @@ class SaleOrderLine(models.Model):
return True
return False
def _determine_project(so_line):
"""Determine the project for this sale order line.
Rules are different based on the service_tracking:
- 'project_only': the project_id can only come from the sale order line itself
- 'task_in_project': the project_id comes from the sale order line only if no project_id was configured
on the parent sale order"""
if so_line.product_id.service_tracking == 'project_only':
return so_line.project_id
elif so_line.product_id.service_tracking == 'task_in_project':
return so_line.order_id.project_id or so_line.project_id
return False
# task_global_project: create task in global project
for so_line in so_line_task_global_project:
if not so_line.task_id:
if map_sol_project.get(so_line.id) and so_line.product_uom_qty > 0:
so_line._timesheet_create_task(project=map_sol_project[so_line.id])
# we store the reference analytic account per SO
map_account_per_so = {}
# project_only, task_in_project: create a new project, based or not on a template (1 per SO). May be create a task too.
# if 'task_in_project' and project_id configured on SO, use that one instead
for so_line in so_line_new_project:
project = _determine_project(so_line)
for so_line in so_line_new_project.sorted(lambda sol: (sol.sequence, sol.id)):
project = False
if so_line.product_id.service_tracking in ['project_only', 'task_in_project']:
project = so_line.project_id
if not project and _can_create_project(so_line):
project = so_line._timesheet_create_project()
# If no reference analytic account exists, set the account of the generated project to the account of the project's SO or create a new one
account = map_account_per_so.get(so_line.order_id.id)
if not account:
account = so_line.order_id.project_account_id or self.env['account.analytic.account'].create(so_line.order_id._prepare_analytic_account_data())
map_account_per_so[so_line.order_id.id] = account
project = so_line.with_context(project_account_id=account.id)._timesheet_create_project()
# If the SO generates projects on confirmation and the project's SO is not set, set it to the project's SOL with the lowest (sequence, id)
if not so_line.order_id.project_id:
so_line.order_id.project_id = project
if so_line.product_id.project_template_id:
map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project
else:
@ -302,12 +409,45 @@ class SaleOrderLine(models.Model):
project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)]
else:
project = map_so_project[so_line.order_id.id]
if not so_line.task_id:
if not so_line.task_id and so_line.product_id.task_template_id not in task_templates:
task_templates |= so_line.product_id.task_template_id
so_line._timesheet_create_task(project=project)
so_line._generate_milestone()
so_line._handle_milestones(project)
def _generate_milestone(self):
if self.product_id.service_policy == 'delivered_milestones':
# task_global_project: if not set, set the project's SO by looking at global projects
for so_line in so_line_task_global_project.sorted(lambda sol: (sol.sequence, sol.id)):
if not so_line.order_id.project_id:
so_line.order_id.project_id = map_sol_project.get(so_line.id)
# task_global_project: create task in global projects
for so_line in so_line_task_global_project:
if not so_line.task_id:
project = map_sol_project.get(so_line.id) or so_line.order_id.project_id
if project and so_line.product_uom_qty > 0:
if so_line.product_id.task_template_id not in task_templates:
task_templates |= so_line.product_id.task_template_id
so_line._timesheet_create_task(project)
elif not project:
raise UserError(_(
"A project must be defined on the quotation %(order)s or on the form of products creating a task on order.\n"
"The following product need a project in which to put its task: %(product_name)s",
order=so_line.order_id.name,
product_name=so_line.product_id.name,
))
def _handle_milestones(self, project):
self.ensure_one()
if self.product_id.service_policy != 'delivered_milestones':
return
if not self.project_id.allow_milestones:
self.project_id.allow_milestones = True
if (milestones := project.milestone_ids.filtered(lambda milestone: not milestone.sale_line_id)):
milestones.write({
'sale_line_id': self.id,
'product_uom_qty': self.product_uom_qty / len(milestones),
})
else:
milestone = self.env['project.milestone'].create({
'name': self.name,
'project_id': self.project_id.id or self.order_id.project_id.id,
@ -323,29 +463,21 @@ class SaleOrderLine(models.Model):
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.get('analytic_distribution'):
task_analytic_account = self.task_id._get_task_analytic_account_id() if self.task_id else False
if task_analytic_account:
values['analytic_distribution'] = {task_analytic_account.id: 100}
elif self.project_id.analytic_account_id:
values['analytic_distribution'] = {self.project_id.analytic_account_id.id: 100}
values = super()._prepare_invoice_line(**optional_values)
if not values.get('analytic_distribution') and not self.analytic_distribution:
if self.task_id.project_id.account_id:
values['analytic_distribution'] = {self.task_id.project_id.account_id.id: 100}
elif self.project_id.account_id:
values['analytic_distribution'] = {self.project_id.account_id.id: 100}
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),
[accounts] = self.env['project.project']._read_group([
('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_distribution'] = {analytic_account_ids.pop(): 100}
('tasks.sale_line_id', '=', self.id),
], aggregates=['account_id:recordset'])[0]
if len(accounts) == 1:
values['analytic_distribution'] = {accounts.id: 100}
return values
def _get_action_per_item(self):
@ -354,3 +486,9 @@ class SaleOrderLine(models.Model):
:returns: Dict containing id of SOL as key and the action as value
"""
return {}
def _prepare_procurement_values(self):
values = super()._prepare_procurement_values()
if self.order_id.project_id:
values['project_id'] = self.order_id.project_id.id
return values

View file

@ -3,6 +3,7 @@
from odoo import models
class SaleOrderTemplateLine(models.Model):
_inherit = 'sale.order.template.line'