300 lines
12 KiB
Python
300 lines
12 KiB
Python
from odoo import fields, models, api, Command, _
|
|
from odoo.exceptions import ValidationError, UserError
|
|
from odoo.osv import expression
|
|
from collections import defaultdict, namedtuple
|
|
from odoo.addons.project.models.project_task import CLOSED_STATES
|
|
import re
|
|
|
|
|
|
class Task(models.Model):
|
|
_inherit = "project.task"
|
|
|
|
equipment_ids = fields.Many2many(
|
|
comodel_name="bemade_fsm.equipment",
|
|
relation="bemade_fsm_task_equipment_rel",
|
|
column1="task_id",
|
|
column2="equipment_id",
|
|
string="Equipment to Service",
|
|
tracking=True,
|
|
)
|
|
|
|
work_order_contacts = fields.Many2many(
|
|
comodel_name="res.partner",
|
|
relation="task_work_order_contact_rel",
|
|
column1="task_id",
|
|
column2="res_partner_id",
|
|
compute="_compute_contacts",
|
|
inverse="_inverse_contacts",
|
|
store=True
|
|
)
|
|
|
|
site_contacts = fields.Many2many(
|
|
comodel_name="res.partner",
|
|
relation="task_site_contact_rel",
|
|
column1="task_id",
|
|
column2="res_partner_id",
|
|
compute="_compute_contacts",
|
|
inverse="_inverse_contacts",
|
|
store=True
|
|
)
|
|
|
|
# planned_date_begin = fields.Datetime(
|
|
# string="Planned Start Date",
|
|
# tracking=True,
|
|
# task_dependency_tracking=True,
|
|
# compute="_compute_planned_dates",
|
|
# inverse="_inverse_planned_dates",
|
|
# store=True
|
|
# )
|
|
#
|
|
# planned_date_end = fields.Datetime(
|
|
# string="Planned End Date",
|
|
# tracking=True,
|
|
# task_dependency_tracking=True,
|
|
# compute="_compute_planned_dates",
|
|
# inverse="_inverse_planned_dates",
|
|
# store=True
|
|
# )
|
|
|
|
# Override related field to make it return false if this is an FSM subtask
|
|
allow_billable = fields.Boolean(
|
|
string="Can be billed",
|
|
related=False,
|
|
compute="_compute_allow_billable",
|
|
store=True
|
|
)
|
|
|
|
visit_id = fields.Many2one(
|
|
comodel_name='bemade_fsm.visit',
|
|
string="Visit",
|
|
)
|
|
|
|
relevant_order_lines = fields.Many2many(
|
|
comodel_name='sale.order.line',
|
|
store=False,
|
|
compute='_compute_relevant_order_lines',
|
|
)
|
|
|
|
work_order_number = fields.Char(readonly=True)
|
|
|
|
propagate_assignment = fields.Boolean(
|
|
string='Propagate Assignment',
|
|
help='Propagate assignment of this task to all subtasks.',
|
|
default=False,
|
|
)
|
|
|
|
is_closed = fields.Boolean(
|
|
compute="_compute_is_closed",
|
|
)
|
|
|
|
def _compute_is_closed(self):
|
|
for rec in self:
|
|
rec.is_closed = rec.state in CLOSED_STATES
|
|
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals):
|
|
res = super().create(vals)
|
|
for rec in res:
|
|
if rec.sale_order_id:
|
|
seq = 1
|
|
prev_seqs = self.sale_order_id.tasks_ids and \
|
|
self.sale_order_id.tasks_ids.mapped('work_order_number')
|
|
if prev_seqs:
|
|
pattern = re.compile(r"(\d+)$")
|
|
matches = map(lambda n: pattern.search(n), prev_seqs)
|
|
seq += max(map(lambda n: int(n.group(1)) if n else 0, matches))
|
|
rec.work_order_number = rec.sale_order_id.name.replace('SO', 'SVR', 1) \
|
|
+ f"-{seq}"
|
|
return res
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
if not self: # End recursion on empty RecordSet
|
|
return res
|
|
if 'propagate_assignment' in vals:
|
|
# When a user sets propagate assignment, it should propagate that setting all the way down the chain
|
|
self.child_ids.write({'propagate_assignment': vals['propagate_assignment']})
|
|
if 'user_ids' in vals:
|
|
to_propagate = self.filtered(lambda task: task.propagate_assignment)
|
|
# Here we use child_ids instead of _get_all_subtasks() so as to allow for setting propagate_assignment
|
|
# to false on a child task.
|
|
to_propagate.child_ids.write({'user_ids': vals['user_ids']})
|
|
return res
|
|
|
|
@api.depends('sale_order_id')
|
|
def _compute_relevant_order_lines(self):
|
|
for rec in self:
|
|
rec.relevant_order_lines = (
|
|
rec.sale_order_id and rec.sale_order_id.get_relevant_order_lines(
|
|
rec) or False)
|
|
|
|
def _get_closed_stage_by_project(self):
|
|
""" Gets the stage representing completed tasks for each project in
|
|
self.project_id. Copied from industry_fsm/.../project.py:217-221
|
|
for consistency.
|
|
|
|
:returns: Dict of project.project -> project.task.type"""
|
|
return {
|
|
project:
|
|
project.type_ids.filtered(lambda stage: stage.is_closed)[:1]
|
|
or project.type_ids[-1:]
|
|
for project in self.project_id
|
|
}
|
|
|
|
# def _get_related_planning_slots(self):
|
|
# domain = [('task_id', 'in', self.ids + self._get_all_subtasks().ids), ('start_datetime', '!=', False)]
|
|
# return self.env['planning.slot'].search(domain)
|
|
|
|
# @api.depends('planned_hours')
|
|
# def _compute_planned_dates(self):
|
|
# forecast_data = self._get_related_planning_slots()
|
|
# mapped_data = {}
|
|
# TimeSpan = namedtuple('timespan', ['start', 'end'])
|
|
# for d in forecast_data:
|
|
# if d not in mapped_data:
|
|
# mapped_data.update({d: TimeSpan(d.start_datetime, d.end_datetime)})
|
|
# continue
|
|
# if mapped_data[d].start > d.start_datetime:
|
|
# mapped_data[d].start = d.start_datetime
|
|
# if mapped_data[d].end < d.end_datetime:
|
|
# mapped_data[d].end = d.end_datetime
|
|
# for rec in self:
|
|
# if rec not in mapped_data:
|
|
# if not rec.planned_date_end:
|
|
# rec.planned_date_end = False
|
|
# if not rec.planned_date_begin:
|
|
# rec.planned_date_begin = False
|
|
# continue
|
|
# rec.planned_date_begin, rec.planned_date_end = mapped_data[rec]
|
|
#
|
|
# def _inverse_planned_dates(self):
|
|
# """ Modifying the planned dates for tasks with existing planning records
|
|
# (planning.slot) has no defined safe behaviour, so we block it."""
|
|
# if self._get_related_planning_slots():
|
|
# raise UserError(_("Modifying the planned start or end time on a task is not "
|
|
# "permitted when that task already has planning records "
|
|
# "associated to it. Please modify or delete the planning "
|
|
# "records instead."))
|
|
|
|
@api.depends('sale_line_id.order_id.site_contacts',
|
|
'sale_line_id.order_id.work_order_contacts')
|
|
def _compute_contacts(self):
|
|
""" The work order contacts and site contacts for a given task are taken from the sale order if the task
|
|
is related to one, and from the task's customer if there is no sale order related to the task."""
|
|
for rec in self:
|
|
site_contacts = self.sale_line_id and self.sale_line_id.order_id.site_contacts or \
|
|
self.partner_id.site_contacts
|
|
work_order_contacts = self.sale_line_id and self.sale_line_id.order_id.work_order_contacts or \
|
|
self.partner_id.work_order_contacts
|
|
rec.write({
|
|
'site_contacts': [Command.set(site_contacts.ids)],
|
|
'work_order_contacts': [Command.set(work_order_contacts.ids)]
|
|
})
|
|
|
|
def _inverse_contacts(self):
|
|
""" If the task is linked to a sales order, the sales order should have its contacts updated to match."""
|
|
for rec in self:
|
|
if rec.sale_line_id:
|
|
rec.sale_line_id.order_id.write({
|
|
'work_order_contacts': [Command.set(rec.work_order_contacts.ids)],
|
|
'site_contacts': [Command.set(rec.site_contacts.ids)],
|
|
})
|
|
|
|
@api.depends('parent_id.visit_id', 'project_id.is_fsm', 'project_id.allow_billable')
|
|
def _compute_allow_billable(self):
|
|
for rec in self:
|
|
# If an FSM task has a parent that is linked to an SO line, then the parent is the billable one
|
|
if rec.parent_id and not rec.parent_id.visit_id and rec.project_id and rec.project_id.is_fsm:
|
|
rec.allow_billable = False
|
|
else:
|
|
rec.allow_billable = rec.project_id.allow_billable
|
|
|
|
def action_fsm_validate(self, stop_running_timers=False):
|
|
all_tasks = self | self._get_all_subtasks()
|
|
non_visit_tasks = all_tasks.filtered(lambda task: not task.visit_id)
|
|
visit_tasks = all_tasks.filtered(lambda task: bool(task.visit_id))
|
|
result = visit_tasks._validate_task_without_creating_sale_order_line(stop_running_timers)
|
|
if isinstance(result, dict):
|
|
return result
|
|
result = super(Task, non_visit_tasks).action_fsm_validate(stop_running_timers)
|
|
if isinstance(result, dict):
|
|
return result
|
|
return result
|
|
|
|
def _validate_task_without_creating_sale_order_line(self, stop_running_timers=False):
|
|
# Reproduce the functionality of action_fsm_validate but don't apply logic from industry_fsm_sale
|
|
# so as to avoid creating order lines for visits
|
|
Timer = self.env['timer.timer']
|
|
tasks_running_timer_ids = Timer.search([('res_model', '=', 'project.task'), ('res_id', 'in', self.ids)])
|
|
timesheets = self.env['account.analytic.line'].sudo().search([('task_id', 'in', self.ids)])
|
|
timesheets_running_timer_ids = None
|
|
if timesheets:
|
|
timesheets_running_timer_ids = Timer.search([
|
|
('res_model', '=', 'account.analytic.line'),
|
|
('res_id', 'in', timesheets.ids)])
|
|
if tasks_running_timer_ids or timesheets_running_timer_ids:
|
|
if stop_running_timers:
|
|
self._stop_all_timers_and_create_timesheets(tasks_running_timer_ids, timesheets_running_timer_ids,
|
|
timesheets)
|
|
else:
|
|
wizard = self.env['project.task.stop.timers.wizard'].create({
|
|
'line_ids': [Command.create({'task_id': task.id}) for task in self],
|
|
})
|
|
return {
|
|
'name': _('Do you want to stop the running timers?'),
|
|
'type': 'ir.actions.act_window',
|
|
'view_mode': 'form',
|
|
'view_id': self.env.ref('industry_fsm.view_task_stop_timer_wizard_form').id,
|
|
'target': 'new',
|
|
'res_model': 'project.task.stop.timers.wizard',
|
|
'res_id': wizard.id,
|
|
}
|
|
closed_stage_by_project = {
|
|
project.id:
|
|
project.type_ids.filtered(lambda stage: stage.fold)[:1] or project.type_ids[-1:]
|
|
for project in self.project_id
|
|
}
|
|
for task in self:
|
|
# determine closed stage for task
|
|
closed_stage = closed_stage_by_project.get(self.project_id.id)
|
|
values = {'fsm_done': True}
|
|
if closed_stage:
|
|
values['stage_id'] = closed_stage.id
|
|
|
|
task.write(values)
|
|
|
|
def _get_full_hierarchy(self):
|
|
if self.child_ids:
|
|
return self | self.child_ids._get_full_hierarchy()
|
|
return self
|
|
|
|
def synchronize_name_fsm(self):
|
|
""" Applies naming to the entire task tree for tasks that are part of this
|
|
recordset. Root tasks are named:
|
|
|
|
Partner Shipping Name - Sale Line Name (Template Name)
|
|
|
|
Child tasks with sale_line_id are named by their template if set, sale line name
|
|
if not.
|
|
|
|
Child tasks not linked to sale lines are left with their original names."""
|
|
|
|
all_tasks = self | self._get_all_subtasks()
|
|
for rec in all_tasks:
|
|
assert rec.is_fsm, "This method should only be called on FSM tasks."
|
|
|
|
template = rec.sale_line_id and rec.sale_line_id.product_id.task_template_id
|
|
name_parts = rec.sale_line_id and rec.sale_line_id.name.split('\n')
|
|
title = name_parts and name_parts[0] or rec.sale_line_id.product_id.name
|
|
if not rec.parent_id:
|
|
rec.name = f"{rec.sale_order_id.partner_shipping_id.name} - " \
|
|
f"{title}"
|
|
if template:
|
|
rec.name += f" ({template.name})"
|
|
else:
|
|
rec.name = template.name or title or rec.name
|
|
|
|
@property
|
|
def root_ancestor(self):
|
|
return self.parent_id and self.parent_id.root_ancestor or self
|