mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-25 16:12:01 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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',
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
@ -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',
|
||||
]
|
||||
|
|
@ -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']
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class SaleOrderTemplateLine(models.Model):
|
||||
_inherit = 'sale.order.template.line'
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue