mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-26 23:52:05 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue