diff --git a/bemade_fsm/models/__init__.py b/bemade_fsm/models/__init__.py index 3cb9595..aef9e8c 100644 --- a/bemade_fsm/models/__init__.py +++ b/bemade_fsm/models/__init__.py @@ -1,7 +1,10 @@ from . import task_template from . import product_template +from . import sale_order_line from . import sale_order from . import equipment +from . import equipment_type +from . import equipment_tag from . import task from . import res_partner from . import fsm_visit diff --git a/bemade_fsm/models/equipment.py b/bemade_fsm/models/equipment.py index 9eb3160..dc08495 100644 --- a/bemade_fsm/models/equipment.py +++ b/bemade_fsm/models/equipment.py @@ -1,26 +1,6 @@ from odoo import api, fields, models -class EquipmentTag(models.Model): - _name = "bemade_fsm.equipment.tag" - _description = 'Field service equipment category' - - name = fields.Char('Name', required=True, translate=True) - color = fields.Integer('Color Index', default=10) - - _sql_constraints = [ - ('name_uniq', 'unique (name)', "Tag name already exists !"), - ] - - -class EquipmentType(models.Model): - _name = 'bemade_fsm.equipment.type' - _description = 'Field service equipment type' - _order = 'id' - - name = fields.Char(string='Intervention Name', required=True, translate=True) - - class Equipment(models.Model): _name = 'bemade_fsm.equipment' _rec_name = 'complete_name' @@ -33,26 +13,30 @@ class Equipment(models.Model): complete_name = fields.Char(string="Equipment Name", compute="_compute_complete_name", store=True) - tag_ids = fields.Many2many('bemade_fsm.equipment.tag', - string='Application', - help="Classify and analyze your equipment categories like: Boiler, Laboratory, " - "Waste water, Pure water") + tag_ids = fields.Many2many( + comodel_name='bemade_fsm.equipment.tag', + string='Application', + help="Classify and analyze your equipment categories like: Boiler, Laboratory, Waste water, Pure water" + ) - description = fields.Text(string="Description", - tracking=True) + description = fields.Text(string="Description", tracking=True) - partner_location_id = fields.Many2one('res.partner', - string="Physical Address", - tracking=True, - ondelete='cascade') + partner_location_id = fields.Many2one( + comodel_name='res.partner', + string="Physical Address", + tracking=True, + ondelete='cascade' + ) - location_notes = fields.Text(string="Physical Location Notes", - tracking=True) - task_ids = fields.Many2many(comodel_name='project.task', - relation="bemade_fsm_task_equipment_rel", - column1="equipment_id", - column2="task_id", - string='Interventions') + location_notes = fields.Text(string="Physical Location Notes", tracking=True) + + task_ids = fields.Many2many( + comodel_name='project.task', + relation="bemade_fsm_task_equipment_rel", + column1="equipment_id", + column2="task_id", + string='Interventions' + ) @api.depends('partner_location_id') def _compute_partner(self): diff --git a/bemade_fsm/models/equipment_tag.py b/bemade_fsm/models/equipment_tag.py new file mode 100644 index 0000000..3f9a82f --- /dev/null +++ b/bemade_fsm/models/equipment_tag.py @@ -0,0 +1,13 @@ +from odoo import api, fields, models + + +class EquipmentTag(models.Model): + _name = "bemade_fsm.equipment.tag" + _description = 'Field service equipment category' + + name = fields.Char('Name', required=True, translate=True) + color = fields.Integer('Color Index', default=10) + + _sql_constraints = [ + ('name_uniq', 'unique (name)', "Tag name already exists !"), + ] diff --git a/bemade_fsm/models/equipment_type.py b/bemade_fsm/models/equipment_type.py new file mode 100644 index 0000000..8cd6623 --- /dev/null +++ b/bemade_fsm/models/equipment_type.py @@ -0,0 +1,9 @@ +from odoo import api, fields, models + + +class EquipmentType(models.Model): + _name = 'bemade_fsm.equipment.type' + _description = 'Field service equipment type' + _order = 'id' + + name = fields.Char(string='Intervention Name', required=True, translate=True) diff --git a/bemade_fsm/models/fsm_visit.py b/bemade_fsm/models/fsm_visit.py index 319e72a..988d556 100644 --- a/bemade_fsm/models/fsm_visit.py +++ b/bemade_fsm/models/fsm_visit.py @@ -5,31 +5,43 @@ class FSMVisit(models.Model): _name = "bemade_fsm.visit" _description = 'Represents a single visit by assigned service personnel.' - label = fields.Text(string="Label", - required=True, - related='so_section_id.name', - readonly=False) + label = fields.Text(string="Label", required=True, related='so_section_id.name', readonly=False) + approx_date = fields.Date(string='Approximate Date') - so_section_id = fields.Many2one(comodel_name="sale.order.line", - string="Sale Order Section", - help="The section on the sale order that represents the labour and parts for " - "this visit", - ondelete="cascade") - sale_order_id = fields.Many2one(comodel_name="sale.order", - string="Sales Order", - required=True) - is_completed = fields.Boolean(string="Completed", - related="so_section_id.is_fully_delivered") - is_invoiced = fields.Boolean(string="Invoiced", - related="so_section_id.is_fully_delivered_and_invoiced") - summarized_equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment", - string="Equipment to Service", - compute="_compute_summarized_equipment_ids") - task_id = fields.Many2one(comodel_name="project.task", - compute="_compute_task_id", - string="Service Visit",) - task_ids = fields.One2many(comodel_name="project.task", - inverse_name="visit_id") + + so_section_id = fields.Many2one( + comodel_name="sale.order.line", + string="Sale Order Section", + help="The section on the sale order that represents the labour and parts for this visit", + ondelete="cascade" + ) + + sale_order_id = fields.Many2one( + comodel_name="sale.order", + string="Sales Order", + required=True + ) + + is_completed = fields.Boolean(string="Completed", related="so_section_id.is_fully_delivered") + + is_invoiced = fields.Boolean(string="Invoiced", related="so_section_id.is_fully_delivered_and_invoiced") + + summarized_equipment_ids = fields.Many2many( + comodel_name="bemade_fsm.equipment", + string="Equipment to Service", + compute="_compute_summarized_equipment_ids" + ) + + task_id = fields.Many2one( + comodel_name="project.task", + compute="_compute_task_id", + string="Service Visit", + ) + + task_ids = fields.One2many( + comodel_name="project.task", + inverse_name="visit_id" + ) @api.depends('task_ids') def _compute_task_id(self): diff --git a/bemade_fsm/models/product_template.py b/bemade_fsm/models/product_template.py index d3df23f..9ea7dbb 100644 --- a/bemade_fsm/models/product_template.py +++ b/bemade_fsm/models/product_template.py @@ -4,7 +4,13 @@ from odoo import fields, models, api class ProductTemplate(models.Model): _inherit = 'product.template' - task_template_id = fields.Many2one("project.task.template", string="Task Template", ondelete='restrict') - is_field_service = fields.Boolean("Plan as field service", - help="Products planned as field service will have travel time " - "considered in planning.") \ No newline at end of file + task_template_id = fields.Many2one( + comodel_name="project.task.template", + string="Task Template", + ondelete='restrict' + ) + + is_field_service = fields.Boolean( + string="Plan as field service", + help="Products planned as field service will have travel time considered in planning." + ) diff --git a/bemade_fsm/models/res_partner.py b/bemade_fsm/models/res_partner.py index 3c5427e..2baa4b2 100644 --- a/bemade_fsm/models/res_partner.py +++ b/bemade_fsm/models/res_partner.py @@ -4,45 +4,60 @@ from odoo import api, fields, models, Command class Partner(models.Model): _inherit = 'res.partner' - equipment_count = fields.Integer(compute='_compute_equipment_count', - string='Equipment Count') + equipment_count = fields.Integer(compute='_compute_equipment_count', string='Equipment Count') - owned_equipment_ids = fields.One2many(comodel_name="bemade_fsm.equipment", - compute="_compute_owned_equipment_ids", - string="Owned Equipments") + owned_equipment_ids = fields.One2many( + comodel_name="bemade_fsm.equipment", + compute="_compute_owned_equipment_ids", + string="Owned Equipments" + ) - equipment_ids = fields.One2many(comodel_name='bemade_fsm.equipment', - inverse_name='partner_location_id', - string='Site Equipment') + equipment_ids = fields.One2many( + comodel_name='bemade_fsm.equipment', + inverse_name='partner_location_id', + string='Site Equipment' + ) - is_site_contact = fields.Boolean(string='Is a site contact', - compute="_compute_is_site_contact", - search="_search_is_site_contact", ) + is_site_contact = fields.Boolean( + string='Is a site contact', + compute="_compute_is_site_contact", + search="_search_is_site_contact", + ) - site_ids = fields.Many2many(string='Work Sites', - comodel_name='res.partner', - relation='res_partner_site_contact_rel', - column1='site_contact_id', - column2='site_id', - tracking=True) + site_ids = fields.Many2many( + string='Work Sites', + comodel_name='res.partner', + relation='res_partner_site_contact_rel', + column1='site_contact_id', + column2='site_id', + tracking=True + ) - site_contacts = fields.Many2many(string='Site Contacts', - comodel_name='res.partner', - relation='res_partner_site_contact_rel', - column1='site_id', - column2='site_contact_id', - domain=[('is_company', '=', False)], - tracking=True) + site_contacts = fields.Many2many( + string='Site Contacts', + comodel_name='res.partner', + relation='res_partner_site_contact_rel', + column1='site_id', + column2='site_contact_id', + domain=[('is_company', '=', False)], + tracking=True + ) - work_order_contacts = fields.Many2many(string='Work Order Recipients', - comodel_name='res.partner', - relation='res_partner_work_order_contacts_rel', - column1='res_partner_id', - column2='work_order_contact_id', - domain=[('is_company', '=', False)], - tracking=True) + work_order_contacts = fields.Many2many( + string='Work Order Recipients', + comodel_name='res.partner', + relation='res_partner_work_order_contacts_rel', + column1='res_partner_id', + column2='work_order_contact_id', + domain=[('is_company', '=', False)], + tracking=True + ) - @api.depends('equipment_ids', 'child_ids.company_type', 'child_ids.equipment_ids') + @api.depends( + 'equipment_ids', + 'child_ids.company_type', + 'child_ids.equipment_ids' + ) def _compute_owned_equipment_ids(self): for rec in self: ids = rec.equipment_ids | rec.child_ids.filtered( diff --git a/bemade_fsm/models/sale_order.py b/bemade_fsm/models/sale_order.py index d686c19..cd69b0a 100644 --- a/bemade_fsm/models/sale_order.py +++ b/bemade_fsm/models/sale_order.py @@ -7,35 +7,48 @@ import re class SaleOrder(models.Model): _inherit = 'sale.order' - valid_equipment_ids = fields.One2many(comodel_name="bemade_fsm.equipment", - related="partner_id.owned_equipment_ids") - default_equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment", - string="Default Equipment to Service", - help="The default equipment to service for new sale order lines.", - compute="_compute_default_equipment", - inverse="_inverse_default_equipment", - store=True, ) + valid_equipment_ids = fields.One2many( + comodel_name="bemade_fsm.equipment", + related="partner_id.owned_equipment_ids" + ) - summary_equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment", - string="Equipment Being Serviced", - compute="_compute_summary_equipment_ids") + default_equipment_ids = fields.Many2many( + comodel_name="bemade_fsm.equipment", + string="Default Equipment to Service", + help="The default equipment to service for new sale order lines.", + compute="_compute_default_equipment", + inverse="_inverse_default_equipment", + store=True + ) - site_contacts = fields.Many2many(comodel_name='res.partner', - relation="sale_order_site_contacts_rel", - compute="_compute_default_contacts", - inverse="_inverse_default_contacts", - string='Site Contacts', - store=True) + summary_equipment_ids = fields.Many2many( + comodel_name="bemade_fsm.equipment", + string="Equipment Being Serviced", + compute="_compute_summary_equipment_ids") - work_order_contacts = fields.Many2many(comodel_name='res.partner', - relation='sale_order_work_order_contacts_rel', - compute='_compute_default_contacts', - inverse='_inverse_default_contacts', - string='Work Order Recipients', - store=True) - visit_ids = fields.One2many(comodel_name='bemade_fsm.visit', - inverse_name="sale_order_id", - readonly=False) + site_contacts = fields.Many2many( + comodel_name='res.partner', + relation="sale_order_site_contacts_rel", + compute="_compute_default_contacts", + inverse="_inverse_default_contacts", + string='Site Contacts', + store=True + ) + + work_order_contacts = fields.Many2many( + comodel_name='res.partner', + relation='sale_order_work_order_contacts_rel', + compute='_compute_default_contacts', + inverse='_inverse_default_contacts', + string='Work Order Recipients', + store=True + ) + + visit_ids = fields.One2many( + comodel_name='bemade_fsm.visit', + inverse_name="sale_order_id", + readonly=False + ) @api.depends('order_line.task_id') def get_relevant_order_lines(self, task_id): @@ -67,9 +80,12 @@ class SaleOrder(models.Model): def _inverse_default_contacts(self): pass - @api.depends('partner_id', 'partner_shipping_id', - 'partner_shipping_id.equipment_ids', - 'partner_id.owned_equipment_ids') + @api.depends( + 'partner_id', + 'partner_shipping_id', + 'partner_shipping_id.equipment_ids', + 'partner_id.owned_equipment_ids' + ) def _compute_default_equipment(self): for rec in self: if rec.partner_shipping_id.equipment_ids: @@ -82,241 +98,3 @@ class SaleOrder(models.Model): pass -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", ) - 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", ) - - @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 _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: %s (%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 line in 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) - task_ids.write({'parent_id': line.visit_id.task_id.id}) - self.mapped('task_id').synchronize_name_fsm() - - def _generate_task_for_visit_line(self, project): - self.ensure_one() - - task = self.env['project.task'].create({ - 'name': 'Temp Name', - 'description': f"Parent task for {self.order_id.name}, visit {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) - - 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')) diff --git a/bemade_fsm/models/sale_order_line.py b/bemade_fsm/models/sale_order_line.py new file mode 100644 index 0000000..b1cdcee --- /dev/null +++ b/bemade_fsm/models/sale_order_line.py @@ -0,0 +1,267 @@ +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" + ) + + 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" + ) + + @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 _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: %s (%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 line in 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) + task_ids.write({'parent_id': line.visit_id.task_id.id}) + self.mapped('task_id').synchronize_name_fsm() + + def _generate_task_for_visit_line(self, project): + self.ensure_one() + + task = self.env['project.task'].create({ + 'name': 'Temp Name', + 'description': f"Parent task for {self.order_id.name}, visit {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) + + 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')) diff --git a/bemade_fsm/models/task.py b/bemade_fsm/models/task.py index 3f0c519..c8a65a2 100644 --- a/bemade_fsm/models/task.py +++ b/bemade_fsm/models/task.py @@ -8,51 +8,71 @@ 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, ) + 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) + 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) + 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("Start Date", tracking=True, - task_dependency_tracking=True, - compute="_compute_planned_dates", - inverse="_inverse_planned_dates", - store=True) - planned_date_end = fields.Datetime("End Date", tracking=True, - task_dependency_tracking=True, - compute="_compute_planned_dates", - inverse="_inverse_planned_dates", - store=True) + planned_date_begin = fields.Datetime( + string="Start Date", + tracking=True, + task_dependency_tracking=True, + compute="_compute_planned_dates", + inverse="_inverse_planned_dates", + store=True + ) + + planned_date_end = fields.Datetime( + string="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) + 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') + 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', ) + relevant_order_lines = fields.Many2many( + comodel_name='sale.order.line', + store=False, + compute='_compute_relevant_order_lines', + ) work_order_number = fields.Char(readonly=True) diff --git a/bemade_fsm/models/task_template.py b/bemade_fsm/models/task_template.py index 0a2b181..7253fbf 100644 --- a/bemade_fsm/models/task_template.py +++ b/bemade_fsm/models/task_template.py @@ -10,25 +10,62 @@ class TaskTemplate(models.Model): return self.env.company name = fields.Char(string="Task Title", required=True) + description = fields.Html(string="Description") - assignees = fields.Many2many("res.users", string="Default Assignees", - help="Employees assigned to tasks created from this template.") - customer = fields.Many2one("res.partner", string="Default Customer", - help="Default customer for tasks created from this template.") - project = fields.Many2one("project.project", string="Default Project", - help="Default project for tasks created from this template.") - tags = fields.Many2many("project.tags", string="Default Tags", - help="Default tags for tasks created from this template.") - parent = fields.Many2one("project.task.template", string="Parent Task Template", ondelete='cascade') - subtasks = fields.One2many("project.task.template", inverse_name="parent", string="Subtask Templates") + + assignees = fields.Many2many( + comodel_name="res.users", + string="Default Assignees", + help="Employees assigned to tasks created from this template." + ) + + customer = fields.Many2one( + comodel_name="res.partner", + string="Default Customer", + help="Default customer for tasks created from this template." + ) + + project = fields.Many2one( + comodel_name="project.project", + string="Default Project", + help="Default project for tasks created from this template." + ) + + tags = fields.Many2many( + comodel_name="project.tags", + string="Default Tags", + help="Default tags for tasks created from this template." + ) + + parent = fields.Many2one( + comodel_name="project.task.template", + string="Parent Task Template", + ondelete='cascade' + ) + + subtasks = fields.One2many( + comodel_name="project.task.template", + inverse_name="parent", + string="Subtask Templates" + ) sequence = fields.Integer(string="Sequence") - company_id = fields.Many2one("res.company", string="Company", index=1, default=_current_company) + + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + index=1, + default=_current_company + ) + planned_hours = fields.Float("Initially Planned Hours") - equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment", - relation="bemade_fsm_task_template_equipment_rel", - column1="task_template_id", - column2="equipment_id", - string="Equipment to Service",) + + equipment_ids = fields.Many2many( + comodel_name="bemade_fsm.equipment", + relation="bemade_fsm_task_template_equipment_rel", + column1="task_template_id", + column2="equipment_id", + string="Equipment to Service", + ) def action_open_task(self): return {