All sale orders containing a line that will produce an FSM task, i.e. lines that are linked to a product of type service and with service_tracking set to task_global_project, will be processed to make sure that all such lines fall under a service visit. If a visit already exists on the SO but lines are not covered by it, the visit will be promoted to be the top item on the order before confirmation. Otherwise, a default visit will be added to the top of the order prior to proceeding with confirmation.
298 lines
12 KiB
Python
298 lines
12 KiB
Python
from odoo import fields, models, api, _, Command
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.tools import float_round
|
|
import re
|
|
|
|
|
|
class SaleOrderLine(models.Model):
|
|
_inherit = 'sale.order.line'
|
|
valid_equipment_ids = fields.One2many(
|
|
comodel_name="bemade_fsm.equipment",
|
|
related="order_id.valid_equipment_ids"
|
|
)
|
|
|
|
visit_ids = fields.One2many(
|
|
comodel_name="bemade_fsm.visit",
|
|
inverse_name="so_section_id",
|
|
string="Visits",
|
|
copy=True,
|
|
)
|
|
|
|
visit_id = fields.Many2one(
|
|
comodel_name="bemade_fsm.visit",
|
|
compute="_compute_visit_id",
|
|
string="Visit",
|
|
ondelete='cascade',
|
|
store=True,
|
|
)
|
|
|
|
is_fully_delivered = fields.Boolean(
|
|
string="Fully Delivered",
|
|
compute="_compute_is_fully_delivered",
|
|
help="Indicates whether a line or all the lines in a section have been entirely delivered."
|
|
)
|
|
|
|
is_fully_delivered_and_invoiced = fields.Boolean(
|
|
string="Fully Invoiced",
|
|
compute="_compute_is_fully_invoiced",
|
|
help="Indicates whether a line or all the lines in a section have been entirely delivered and invoiced."
|
|
)
|
|
|
|
equipment_ids = fields.Many2many(
|
|
string="Equipment to Service",
|
|
comodel_name="bemade_fsm.equipment",
|
|
relation="bemade_fsm_equipment_sale_order_line_rel",
|
|
column1="sale_order_line_id",
|
|
column2="equipment_id"
|
|
)
|
|
|
|
is_field_service = fields.Boolean(
|
|
string="Is Field Service",
|
|
compute="_compute_is_field_service",
|
|
store=True
|
|
)
|
|
|
|
task_duration = fields.Float(
|
|
string="Estimated Duration",
|
|
compute="_compute_task_duration"
|
|
)
|
|
|
|
is_fsm = fields.Boolean(
|
|
string='Is FSM',
|
|
compute='_compute_is_fsm',
|
|
store=True,
|
|
)
|
|
|
|
section_line_ids = fields.One2many(
|
|
comodel_name='sale.order.line',
|
|
compute='_compute_section_line_ids',
|
|
)
|
|
|
|
@api.depends('visit_ids')
|
|
def _compute_visit_id(self):
|
|
for rec in self:
|
|
rec.visit_id = rec.visit_ids and rec.visit_ids[0]
|
|
|
|
@api.depends('product_id')
|
|
def _compute_is_field_service(self):
|
|
for rec in self:
|
|
rec.is_field_service = rec.product_id.is_field_service
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals):
|
|
recs = super().create(vals)
|
|
for rec in recs:
|
|
if rec.order_id.default_equipment_ids and not rec.equipment_ids:
|
|
rec.equipment_ids = rec.order_id.default_equipment_ids
|
|
return recs
|
|
|
|
def copy_data(self, default=None):
|
|
if default is None:
|
|
default = {}
|
|
if 'visit_ids' not in default:
|
|
default['visit_ids'] = [(0, 0, visit.copy_data()[0]) for visit in self.visit_ids]
|
|
return super().copy_data(default)
|
|
|
|
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
|
|
|
|
Override to add the logic needed to implement task templates and equipment linkages."""
|
|
|
|
def _create_task_from_template(project, template, parent):
|
|
""" Recursively generates the task and any subtasks from a project.task.template.
|
|
|
|
:param project: project.project record to set on the task's project_id field.
|
|
:param template: project.task.template to use to create the task.
|
|
:param parent: project.task to set as the parent to this task.
|
|
"""
|
|
values = _timesheet_create_task_prepare_values_from_template(project, template, parent)
|
|
task = self.env['project.task'].sudo().create(values)
|
|
subtasks = []
|
|
for t in template.subtasks:
|
|
subtask = _create_task_from_template(project, t, task)
|
|
subtasks.append(subtask)
|
|
task.write({'child_ids': [Command.set([t.id for t in subtasks])]})
|
|
# We don't want to see the sub-tasks on the SO
|
|
task.child_ids.write({'sale_order_id': None, 'sale_line_id': None, })
|
|
return task
|
|
|
|
def _timesheet_create_task_prepare_values_from_template(project, template,
|
|
parent):
|
|
""" Copies the values from a project.task.template over to the set of values used to create a project.task.
|
|
|
|
:param project: project.project record to set on the task's project_id field.
|
|
Pass the project.project model or an empty recordset to leave task project_id blank.
|
|
DO NOT pass False or None as this will cause an error in _timesheet_create_task_prepare_values(project).
|
|
:param template: project.task.template to use to create the task.
|
|
:param parent: project.task to set as the parent to this task.
|
|
"""
|
|
vals = self._timesheet_create_task_prepare_values(project)
|
|
vals['name'] = template.name
|
|
vals['description'] = template.description or '' if parent else vals['description']
|
|
vals['parent_id'] = parent and parent.id
|
|
vals['user_ids'] = template.assignees.ids
|
|
vals['tag_ids'] = template.tags.ids
|
|
vals['planned_hours'] = template.planned_hours
|
|
vals['sequence'] = template.sequence
|
|
if template.equipment_ids:
|
|
vals['equipment_ids'] = template.equipment_ids.ids
|
|
return vals
|
|
|
|
tmpl = self.product_id.task_template_id
|
|
if not tmpl:
|
|
task = super()._timesheet_create_task(project)
|
|
else:
|
|
task = _create_task_from_template(project, tmpl, None)
|
|
self.write({'task_id': task.id})
|
|
# post message on task
|
|
task_msg = _(
|
|
"This task has been created from: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%s)") % (
|
|
self.order_id.id, self.order_id.name, self.product_id.name)
|
|
task.message_post(body=task_msg)
|
|
if not task.equipment_ids and self.equipment_ids:
|
|
task.equipment_ids = self.equipment_ids.ids
|
|
task.planned_hours = self.task_duration
|
|
return task
|
|
|
|
def _timesheet_service_generation(self):
|
|
super()._timesheet_service_generation()
|
|
visit_lines = self.filtered(lambda l: l.visit_id)
|
|
for index, line in enumerate(visit_lines):
|
|
task_ids = line.get_section_line_ids().mapped('task_id')
|
|
if not task_ids:
|
|
continue
|
|
if len(set([task.project_id for task in task_ids])) > 1:
|
|
# Can't group up the tasks if they're part of different projects
|
|
return
|
|
project_id = task_ids[0].project_id
|
|
line.visit_id.task_id = line._generate_task_for_visit_line(project_id, index + 1)
|
|
task_ids.write({'parent_id': line.visit_id.task_id.id})
|
|
(self.mapped('task_id') | self.visit_ids.task_id).synchronize_name_fsm()
|
|
|
|
def _generate_task_for_visit_line(self, project, visit_no: int):
|
|
self.ensure_one()
|
|
|
|
task = self.env['project.task'].create({
|
|
'name': f"{self.order_id.name} - " + _("Visit") + f" {visit_no} - {self.name}",
|
|
'project_id': project.id,
|
|
'equipment_ids': self.get_section_line_ids().mapped('equipment_ids').ids,
|
|
'sale_order_id': self.order_id.id,
|
|
'partner_id': self.order_id.partner_shipping_id.id,
|
|
'visit_id': self.visit_id.id,
|
|
'date_deadline': self.visit_id.approx_date,
|
|
'planned_hours': self.task_duration,
|
|
'user_ids': False, # Force to empty or it uses the current user
|
|
})
|
|
return task
|
|
|
|
@api.depends(
|
|
'order_id.order_line',
|
|
'display_type',
|
|
'qty_to_deliver',
|
|
'order_id.order_line.qty_to_deliver',
|
|
'order_id.order_line.display_type'
|
|
)
|
|
def _compute_is_fully_delivered(self):
|
|
for rec in self:
|
|
rec.is_fully_delivered = rec._iterate_items_compute_bool(
|
|
lambda l: l.qty_to_deliver == 0)
|
|
|
|
@api.depends('is_fully_delivered')
|
|
def _compute_is_fully_invoiced(self):
|
|
for rec in self:
|
|
if not rec.is_fully_delivered:
|
|
rec.is_fully_delivered_and_invoiced = False
|
|
return
|
|
rec.is_fully_delivered_and_invoiced = rec._iterate_items_compute_bool(
|
|
lambda l: l.qty_to_invoice == 0)
|
|
|
|
def get_section_line_ids(self):
|
|
self.ensure_one()
|
|
assert self.display_type == 'line_section', "Cannot get section lines for a non-section."
|
|
found = False
|
|
lines = []
|
|
for line in self.order_id.order_line.sorted(lambda l: l.sequence):
|
|
if line == self:
|
|
found = True
|
|
continue
|
|
if not found:
|
|
continue
|
|
if line.display_type == 'line_section': # Stop when we hit the next section
|
|
break
|
|
else:
|
|
lines.append(line)
|
|
return self.env['sale.order.line'].union(*lines)
|
|
|
|
@api.depends('display_type', 'order_id.order_line')
|
|
def _compute_section_line_ids(self):
|
|
for rec in self:
|
|
if rec.display_type == 'line_section':
|
|
rec.section_line_ids = [Command.set(rec.get_section_line_ids().ids)]
|
|
else:
|
|
rec.section_line_ids = False
|
|
|
|
def _iterate_items_compute_bool(self, single_line_func):
|
|
if not self.display_type:
|
|
return single_line_func(self)
|
|
elif self.display_type == 'line_note':
|
|
return True
|
|
else:
|
|
for line in self.order_id.order_line:
|
|
found = False
|
|
if line == self:
|
|
found = True
|
|
if not found:
|
|
continue
|
|
if found and line.display_type == 'line_section':
|
|
return True
|
|
val = single_line_func(self)
|
|
if not val:
|
|
return val
|
|
return True
|
|
|
|
@api.depends('task_duration', 'product_id', 'product_id.planning_enabled', 'state')
|
|
def _compute_planning_hours_to_plan(self):
|
|
# Override the method from sale_planning to use time estimates from the task
|
|
# template if appropriate. Also compute the value for visit lines.
|
|
planning_lines = self.filtered_domain([
|
|
('product_id.planning_enabled', '=', True),
|
|
('state', 'not in', ['draft', 'sent'])
|
|
])
|
|
for line in planning_lines:
|
|
line.planning_hours_to_plan = line.task_duration
|
|
for line in self - planning_lines:
|
|
line.planning_hours_to_plan = 0.0
|
|
|
|
@api.depends('product_id', 'visit_id')
|
|
def _compute_task_duration(self):
|
|
uom_hour = self.env.ref('uom.product_uom_hour')
|
|
uom_unit = self.env.ref('uom.product_uom_unit')
|
|
templated_lines = self.filtered(lambda l: l.product_id.task_template_id
|
|
and l.product_id.task_template_id.planned_hours)
|
|
visit_lines = self.filtered(lambda l: l.visit_id)
|
|
regular_lines = self - templated_lines - visit_lines
|
|
for line in regular_lines:
|
|
if line.product_uom == uom_hour or line.product_uom == uom_unit:
|
|
line.task_duration = line.product_uom_qty
|
|
else:
|
|
line.task_duration = float_round(
|
|
line.product_uom._compute_quantity(line.product_uom_qty, uom_hour,
|
|
raise_if_failure=False),
|
|
precision_digits=2)
|
|
for line in templated_lines:
|
|
line.task_duration = line.product_id.task_template_id.planned_hours
|
|
if line.product_uom_category_id == self.env.ref(
|
|
'uom.product_uom_unit').category_id:
|
|
line.task_duration *= line.product_uom_qty
|
|
visit_lines = self.filtered(lambda l: l.visit_id)
|
|
for line in visit_lines:
|
|
line.task_duration = sum(line.get_section_line_ids().
|
|
mapped('task_duration'))
|
|
|
|
@api.depends('product_id.detailed_type', 'product_id.service_tracking')
|
|
def _compute_is_fsm(self):
|
|
for rec in self:
|
|
rec.is_fsm = (rec.product_id.detailed_type == 'service'
|
|
and rec.product_id.service_tracking == 'task_global_project')
|