g This is a combination of 2 commits.

[MIG] bemade_fsm to 18.0

Aside from fixing standard 17.0..18.0 stuff and fixing XML ID
references:

1. **Remove `_get_closed_stage_by_project()`**: This method is
obsolete - it was copied from base FSM but no longer exists in
18.0. Base system now uses `is_closed` field.
2. **Implement State Propagation**: Enhance the `write()` method
to propagate done/cancelled states to child tasks using `is_closed` field logic.
3. **Test Task Creation**: Thoroughly test the custom task creation logic
against base 18.0

All 56 tests are passing.
This commit is contained in:
Marc Durepos 2025-09-09 14:02:42 -04:00
parent c7d0a1be2b
commit 5793351bbe
44 changed files with 5102 additions and 0 deletions

3
bemade_fsm/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from . import models
from . import reports
from . import wizard

View file

@ -0,0 +1,70 @@
######################################################################################
#
# Bemade Inc.
#
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : mdurepos@durpro.com)
#
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
# or modified copies of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
########################################################################################
{
"name": "Improved Field Service Management",
"version": "18.0.0.4.3",
"summary": (
"Adds functionality necessary for managing field service operations at Durpro."
),
"category": "Services/Field Service",
"author": "Bemade Inc.",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": [
"sale_stock",
"sale_project",
"account",
"project_enterprise",
"industry_fsm_stock",
"industry_fsm_report",
"industry_fsm_sale_report",
"fsm_equipment",
"bemade_partner_root_ancestor",
"mail",
],
"data": [
"data/fsm_data.xml",
"views/task_template_views.xml",
"security/ir.model.access.csv",
"views/product_views.xml",
"views/res_partner.xml",
"views/menus.xml",
"views/task_views.xml",
"views/sale_order_views.xml",
"reports/worksheet_custom_report_templates.xml",
"reports/worksheet_custom_reports.xml",
"wizard/new_task_from_template.xml",
"wizard/res_config_settings.xml",
],
"assets": {
"web.report_assets_common": ["bemade_fsm/static/src/scss/bemade_fsm.scss"],
"web.assets_backend": [
# BV: need to readd these files
# 'bemade_fsm/static/src/js/kanban_view.js',
# 'bemade_fsm/static/src/js/list_view.js',
],
"web.assets_qweb": [
"bemade_fsm/static/src/xml/project_view_buttons.xml",
],
},
"installable": True,
"auto_install": False,
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="planning_project_stage_waiting_parts" model="project.task.type">
<field name="sequence">2</field>
<field name="name">Waiting on Parts</field>
<field name="fold" eval="False" />
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
</record>
<record id="planning_project_stage_work_completed" model="project.task.type">
<field name="sequence">15</field>
<field name="name">Work Executed</field>
<field name="fold" eval="False" />
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
</record>
<record id="planning_project_stage_exception" model="project.task.type">
<field name="sequence">19</field>
<field name="name">Exception</field>
<field name="fold" eval="False" />
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
</record>
</odoo>

1098
bemade_fsm/i18n/fr.po Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
from . import task_template
from . import product_template
from . import sale_order_line
from . import sale_order
from . import task
from . import res_partner
from . import fsm_visit
from . import res_company
from . import project

View file

@ -0,0 +1,104 @@
from odoo import models, fields, api
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,
copy=True,
)
approx_date = fields.Date(string="Approximate Date", copy=False)
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",
readonly=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="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")
visit_no = fields.Integer(
compute="_compute_visit_no",
)
@api.depends("task_ids")
def _compute_task_id(self):
for rec in self:
rec.task_id = rec.task_ids and rec.task_ids[0]
@api.depends("so_section_id", "sale_order_id.summary_equipment_ids")
def _compute_summarized_equipment_ids(self):
for rec in self:
lines = rec.so_section_id.get_section_line_ids()
equipment_ids = []
for line in lines:
for equipment in line.equipment_ids:
equipment_ids.append(equipment)
rec.summarized_equipment_ids = equipment_ids
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for i, rec in enumerate(recs.filtered(lambda visit: not visit.so_section_id)):
rec.so_section_id = rec.env["sale.order.line"].create(
{
"order_id": rec.sale_order_id.id,
"display_type": "line_section",
"name": vals_list[i].get("label", False),
}
)
return recs
@api.depends(
"so_section_id",
"sale_order_id",
"sale_order_id.visit_ids",
"sale_order_id.visit_ids.so_section_id",
"sale_order_id.visit_ids.so_section_id.sequence",
)
def _compute_visit_no(self):
for rec in self:
ordered_visit_lines = self.sale_order_id.visit_ids.so_section_id.sorted(
"sequence"
)
# Just a straight O(n) search here since n will always be relatively small
for index, line in enumerate(ordered_visit_lines):
if line == rec.so_section_id:
rec.visit_no = index + 1
return

View file

@ -0,0 +1,19 @@
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = "product.template"
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."
),
)

View file

@ -0,0 +1,13 @@
from odoo import models
class Project(models.Model):
_inherit = "project.project"
def _fetch_sale_order_item_ids(
self, domain_per_model=None, limit=None, offset=None
):
# Override to flush the ORM cache to the database prior to running the query
# Temporary fix until Odoo fixes this method (PR #160067 submitted for this)
self.env.flush_all()
return super()._fetch_sale_order_item_ids(domain_per_model, limit, offset)

View file

@ -0,0 +1,8 @@
from odoo import models, fields
class Company(models.Model):
_inherit = "res.company"
split_time_from_materials_on_service_work_orders = fields.Boolean(default=False)
create_default_fsm_visit = fields.Boolean(default=False)

View file

@ -0,0 +1,61 @@
from odoo import api, fields, models
class Partner(models.Model):
_inherit = "res.partner"
is_site_contact = fields.Boolean(
string="Is a site contact",
compute="_compute_is_site_contact",
search="_search_is_site_contact",
)
is_service_site = fields.Boolean(
help="A partner is a service site if they have one or more equipments, "
"site contacts or work order contacts."
)
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,
domain=[("is_service_site", "=", True)],
)
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="res_partner_site_contact_rel",
column1="site_id",
column2="site_contact_id",
domain=[("is_service_site", "=", 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_service_site", "=", False)],
tracking=True,
)
@api.depends("site_ids")
def _compute_is_site_contact(self):
for rec in self:
rec.is_site_contact = rec.site_ids
@api.model
def _search_is_site_contact(self, operator, value):
return [("site_ids", "!=", False)]
@api.depends("equipment_ids", "site_contacts", "work_order_contacts")
def _compute_is_service_site(self):
for rec in self:
rec.is_service_site = (
rec.equipment_ids or rec.site_contacts or rec.work_order_contacts
)

View file

@ -0,0 +1,163 @@
from odoo import fields, models, api, _, Command
class SaleOrder(models.Model):
_inherit = "sale.order"
valid_equipment_ids = fields.One2many(
comodel_name="fsm.equipment",
related="partner_id.commercial_partner_id.owned_equipment_ids",
)
default_equipment_ids = fields.Many2many(
comodel_name="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,
)
summary_equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
string="Equipment Being Serviced",
compute="_compute_summary_equipment_ids",
)
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="sale_order_site_contacts_rel",
compute="_compute_default_contacts",
inverse="_inverse_default_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
)
is_fsm = fields.Boolean(
compute="_compute_is_fsm",
string="Is FSM",
store=True,
)
def get_relevant_order_lines(self, task_id):
self.ensure_one()
linked_lines = self.order_line.filtered(
lambda line: line.task_id == task_id
or line == task_id.visit_id.so_section_id
)
visit_lines = linked_lines.filtered(lambda line: line.visit_id)
for line in visit_lines:
linked_lines |= line.get_section_line_ids()
return linked_lines
@api.depends("order_line.equipment_ids")
def _compute_summary_equipment_ids(self):
for rec in self:
rec.summary_equipment_ids = rec.order_line.mapped("equipment_ids")
@api.onchange("partner_shipping_id")
def _onchange_partner_shipping_id(self):
res = super()._onchange_partner_shipping_id()
self._compute_default_equipment()
self._compute_default_contacts()
return res
@api.depends("partner_shipping_id")
def _compute_default_contacts(self):
for rec in self:
rec.site_contacts = rec.partner_shipping_id.site_contacts
rec.work_order_contacts = rec.partner_shipping_id.work_order_contacts
def _inverse_default_contacts(self):
pass
@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:
ids = rec.partner_shipping_id.equipment_ids
else:
ids = rec.partner_id.owned_equipment_ids
rec.default_equipment_ids = ids if len(ids) < 4 else False
def _inverse_default_equipment(self):
pass
def copy(self, default=None):
rec = super().copy(default)
rec.visit_ids = [Command.set(rec.order_line.visit_ids.ids)]
return rec
def _create_default_visit(self):
"""Called when an order is confirmed with lines that will create an FSM task,
in order to make sure there is a visit line grouping all the service being done.
"""
self.ensure_one()
visit = self.env["bemade_fsm.visit"].create(
{
"label": _("Service Visit"),
"sale_order_id": self.id,
}
)
# Make sure it goes to the top of the list
visit.so_section_id.sequence = 0
def _create_or_organize_visits_if_needed(self):
"""Adds a visit line to the top of the order if there are not already visit
lines for an order with lines that will create an FSM task."""
for order in self.filtered("company_id.create_default_fsm_visit"):
if not order.visit_ids and order.is_fsm:
order._create_default_visit()
if order.is_fsm:
# Make sure that all the lines producing FSM tasks are under a visit
visit_line_ids = (
order.mapped("visit_ids")
.mapped("so_section_id")
.mapped("section_line_ids")
)
if any(
[
True
for line in order.order_line.filtered(
lambda line: not line.display_type
)
if line not in visit_line_ids
]
):
# If not, promote the first visit to the top of the order items list
for line in order.order_line:
line.sequence += 1
order.mapped("visit_ids").mapped("so_section_id")[0].sequence = 0
@api.depends("order_line.is_fsm")
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = any([line.is_fsm for line in rec.order_line])
def action_confirm(self):
self._create_or_organize_visits_if_needed()
return super().action_confirm()
def write(self, vals):
res = super().write(vals)
if "partner_shipping_id" in vals:
for rec in self:
rec.tasks_ids.write({"partner_id": rec.partner_shipping_id.id})
return res

View file

@ -0,0 +1,304 @@
from odoo import fields, models, api, _, Command
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
valid_equipment_ids = fields.One2many(
comodel_name="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="fsm.equipment",
relation="bemade_fsm_equipment_sale_order_line_rel",
column1="sale_order_line_id",
column2="equipment_id",
)
is_field_service = fields.Boolean(compute="_compute_is_field_service", store=True)
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_list):
recs = super().create(vals_list)
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)
# We don't want to see the sub-tasks on the SO
if task.child_ids:
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["allocated_hours"] = template.planned_hours
vals["sequence"] = template.sequence
# Use shipping address for FSM tasks for consistency
if project and project.is_fsm:
vals["partner_id"] = self.order_id.partner_shipping_id.id
else:
vals["partner_id"] = self.order_id.partner_id.id
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)
# For FSM tasks without a template, update partner_id to use shipping address
if project.is_fsm and task:
task.partner_id = self.order_id.partner_shipping_id.id
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=%(so_id)d>%(so_name)s</a> (%(product_name)s)"
) % {
"so_id": self.order_id.id,
"so_name": self.order_id.name,
"product_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
return task
def _timesheet_service_generation(self):
super()._timesheet_service_generation()
visit_lines = self.filtered(lambda line: line.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, sum(task_ids.mapped("allocated_hours"))
)
task_ids.write({"parent_id": line.visit_id.task_id.id})
(self.mapped("task_id") | self.visit_ids.task_id).filtered(
"is_fsm"
).synchronize_name_fsm()
def _generate_task_for_visit_line(
self, project, visit_no: int, allocated_hours: int
):
self.ensure_one()
allocated_hours = sum(
self.get_section_line_ids().task_id.mapped("allocated_hours")
)
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,
"allocated_hours": allocated_hours,
"date_deadline": self.visit_id.approx_date,
"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 line: line.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 line: line.qty_to_invoice == 0
)
def get_section_line_ids(self):
"""Return a recordset of sale.order.line records that are in this sale order section."""
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 line: line.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("product_id.type", "product_id.service_tracking")
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = (
rec.product_id.type == "service"
and rec.product_id.service_tracking == "task_global_project"
)

257
bemade_fsm/models/task.py Normal file
View file

@ -0,0 +1,257 @@
from odoo import fields, models, api, Command
from odoo.addons.project.models.project_task import CLOSED_STATES
import re
from typing import cast, List
class Task(models.Model):
_inherit = "project.task"
work_order_contacts = fields.Many2many(
comodel_name="res.partner",
relation="task_work_order_contact_rel",
column1="task_id",
column2="res_partner_id",
)
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="task_site_contact_rel",
column1="task_id",
column2="res_partner_id",
)
# 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(
help="Propagate assignment of this task to all subtasks.",
default=False,
)
is_closed = fields.Boolean(
compute="_compute_is_closed",
)
root_ancestor = fields.Many2one(
comodel_name="project.task",
compute="_compute_root_ancestor",
recursive=True,
)
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_list):
res = super().create(vals_list)
for rec in res:
if rec.parent_id and rec.is_fsm:
# Always ensure FSM subtasks have a partner_id set from their parent
rec.partner_id = rec.parent_id.partner_id
if not rec.work_order_contacts and rec.parent_id:
rec.work_order_contacts = rec.parent_id.work_order_contacts
if not rec.site_contacts and rec.parent_id:
rec.site_contacts = rec.parent_id.site_contacts
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), cast(List[str], 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}"
)
# If the task is linked to a sales order and has no parent, it should inherit SO work order contacts
if (
not rec.parent_id
and not rec.work_order_contacts
and rec.sale_order_id.work_order_contacts
):
rec.work_order_contacts = rec.sale_order_id.work_order_contacts
if (
not rec.parent_id
and not rec.site_contacts
and rec.sale_order_id.site_contacts
):
rec.site_contacts = rec.sale_order_id.site_contacts
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"]})
for rec in self:
if rec.child_ids:
child_vals = {}
if "site_contacts" in vals:
child_vals.update(
site_contacts=[Command.set(rec.site_contacts.ids)]
)
if "work_order_contacts" in vals:
child_vals.update(
work_order_contacts=[Command.set(rec.work_order_contacts.ids)]
)
if "partner_id" in vals:
child_vals.update(partner_id=vals["partner_id"])
if "state" in vals and rec.state in CLOSED_STATES:
# Propagate task completion or cancelling to subtasks
child_vals.update(state=rec.state)
if child_vals:
rec.child_ids.write(child_vals)
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
)
@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 _fsm_create_sale_order_line(self):
# Override to not generate new lines for tasks that are just linked to a visit item
self.ensure_one()
if self.visit_id:
return
else:
super()._fsm_create_sale_order_line()
def action_fsm_validate(self, stop_running_timers=False):
# Override to close out subtasks as well
all_tasks = self | self._get_all_subtasks()
return super(Task, all_tasks).action_fsm_validate(stop_running_timers)
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
if template:
title = template.name
elif rec.sale_line_id:
name_parts = rec.sale_line_id.name.split("\n")
title = name_parts and name_parts[0] or rec.sale_line_id.product_id.name
elif rec.visit_id:
title = rec.visit_id.label
else:
rec.name = rec.name
return
client_name = rec.sale_order_id.partner_shipping_id.name
if not rec.parent_id:
rec.name = f"{rec.work_order_number} - {client_name} - {title}"
else:
rec.name = title
@api.depends("parent_id", "parent_id.root_ancestor")
def _compute_root_ancestor(self):
for rec in self:
rec.root_ancestor = rec.parent_id and rec.parent_id.root_ancestor or self
@api.depends(
"partner_id",
"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):
"""Override to prevent subtasks from inheriting parent's sale_line_id.
In the base implementation, if a task and its parent share the same commercial partner,
the task will inherit the parent's sale_line_id. This causes issues with our FSM tasks
where we explicitly want subtasks to NOT have a sale_line_id set.
"""
# Only run on root tasks
subtasks = self.filtered("parent_id")
(subtasks - subtasks.filtered("sale_line_id")).sale_line_id = False
super(Task, self - subtasks)._compute_sale_line()
@api.depends("parent_id.partner_id", "project_id")
def _compute_partner_id(self):
"""Override to prevent clearing partner_id for FSM tasks.
In the base implementation, if a task has a partner_id but no project_id or parent_id,
the partner_id is cleared. This causes issues with our FSM tasks where we want to
preserve the partner_id even if project_id or parent_id is temporarily not set.
"""
# Only run the standard logic on non-FSM tasks
non_fsm_tasks = self.filtered(lambda t: not t.is_fsm)
super(Task, non_fsm_tasks)._compute_partner_id()
# For FSM tasks, only set partner_id if it's not already set
fsm_tasks = self - non_fsm_tasks
for task in fsm_tasks:
if not task.partner_id:
task.partner_id = self._get_default_partner_id(
task.project_id, task.parent_id
)

View file

@ -0,0 +1,122 @@
from odoo import models, fields, api, Command
class TaskTemplate(models.Model):
_name = "project.task.template"
_description = "Template for new project tasks"
@api.model
def _current_company(self):
return self.env.company
name = fields.Char(string="Task Title", required=True)
description = fields.Html()
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.",
default=lambda self: self.parent.customer if self.parent else False,
)
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()
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="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 {
"view_mode": "form",
"res_model": "project.task.template",
"res_id": self.id,
"type": "ir.actions.act_window",
"context": self._context,
}
@api.onchange("customer")
def _onchange_customer(self):
for rec in self:
new_equipment_ids = [
eq.id for eq in rec.equipment_ids if eq.partner_id == rec.customer
]
rec.write({"equipment_ids": [Command.set(new_equipment_ids)]})
def _prepare_new_task_values_from_self(self, project, name=False, parent_id=False):
partner_id = self.customer or self.parent.customer or False
if not partner_id and parent_id:
parent = self.env["project.task"].browse(parent_id)
partner_id = parent.partner_id or parent.sale_order_id.partner_shipping_id
vals = {
"project_id": project.id,
"name": name or self.name,
"description": self.description,
"parent_id": parent_id,
"user_ids": self.assignees.ids,
"tag_ids": self.tags.ids,
"allocated_hours": self.planned_hours,
"sequence": self.sequence,
"equipment_ids": (
[Command.set(self.equipment_ids.ids)] if self.equipment_ids else False
),
"partner_id": partner_id and partner_id.id,
"company_id": self.company_id.id,
}
return vals
def create_task_from_self(self, project, name=False, parent_id=False):
"""Create a project.task from this template and return it.
Can be called on a RecordSet of multiple templates.
:param project: project.project record the task should be added to
:param name: name for the new task (defaults to template name)
:param parent_id: parent task for the new task (none by default)
:return: project.task record created from this template
"""
tasks = self.env["project.task"]
for rec in self:
vals = rec._prepare_new_task_values_from_self(project, name, parent_id)
task = rec.env["project.task"].create(vals)
rec.subtasks.create_task_from_self(project, name=False, parent_id=task.id)
tasks |= task
return tasks

View file

@ -0,0 +1 @@
from . import worksheet_custom_reports

View file

@ -0,0 +1,581 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="workorder_page_materials_table">
<t
t-set="order_lines"
t-value="doc.relevant_order_lines.filtered(lambda l:
l.product_id.type != 'service' and not l.is_downpayment and not l.visit_id)"
/>
<t t-if="order_lines">
<t t-set="visit_lines" t-value="order_lines.mapped('visit_id')" />
<t
t-set="section_lines"
t-value="order_lines.filtered(lambda l: l.display_type == 'line_section')"
/>
</t>
<div
t-if="order_lines.filtered(lambda l: not l.display_type)"
style="page-break-inside: avoid;"
>
<h2 t-if="order_lines">Materials</h2>
<div t-if="order_lines" class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th class="text-left">Description</th>
<th class="text-right">Ordered</th>
<th class="text-right">Delivered</th>
</tr>
</thead>
<tbody class="sale_tbody">
<t t-foreach="order_lines" t-as="line">
<t t-set="is_task" t-value="line == doc.sale_line_id" />
<tr
t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''"
>
<t
t-if="not line.display_type and not line.is_downpayment"
>
<td><span t-field="line.name" /></td>
<td class="text-right">
<span t-field="line.product_uom_qty" />
<span
t-field="line.product_uom"
groups="uom.group_uom"
/>
</td>
<td class="text-right">
<span t-field="line.qty_delivered" />
<span
t-field="line.product_uom"
groups="uom.group_uom"
/>
</td>
</t>
<t t-if="line.display_type == 'line_section'">
<td colspan="99">
<span t-field="line.name" />
</td>
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
<span t-field="line.name" />
</td>
</t>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</template>
<template id="workorder_page_sale_order_table_with_pricing_and_labour">
<t t-set="order_lines" t-value="doc.relevant_order_lines" />
<t t-if="order_lines">
<t t-set="visit_lines" t-value="order_lines.mapped('visit_id')" />
<t
t-if="visit_lines"
t-set="root_tasks"
t-value="visit_lines.mapped('task_id')"
/>
<t
t-else=""
t-set="root_tasks"
t-value="order_lines.mapped('task_id').filtered(lambda l: not l.parent_id)"
/>
<t t-set="final_subtotal" t-value="0" />
<t t-set="final_tax" t-value="0" />
<t t-set="final_total" t-value="0" />
<t t-set="is_any_total_discount_line" t-value="False" />
<t
t-set="section_lines"
t-value="order_lines.filtered(lambda l: l.display_type == 'line_section')"
/>
<t t-foreach="order_lines" t-as="line">
<t
t-set="final_subtotal"
t-value="final_subtotal + line.delivered_price_subtotal"
/>
<t
t-set="final_total"
t-value="final_total + line.delivered_price_total"
/>
<t t-set="final_tax" t-value="final_tax + line.delivered_price_tax" />
<t
t-set="is_any_total_discount_line"
t-value="is_any_total_discount_line or (line.discount and line.price_unit != 0 and line.delivered_price_total == 0)"
/>
</t>
<t
t-set="display_discount"
t-value="any(line.discount for line in order_lines)"
/>
</t>
<t t-else="" t-set="root_tasks" t-value="doc.root_ancestor" />
<div t-if="order_lines.filtered(lambda l: not l.display_type)">
<h2 t-if="order_lines">Time &amp; Material</h2>
<div t-if="order_lines" class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th class="text-left">Description</th>
<th class="text-right">Ordered</th>
<th class="text-right">Delivered</th>
<th class="text-right">Unit Price</th>
<th
t-if="display_discount"
class="text-right"
groups="sale.group_discount_per_so_line"
>
<span>Disc.%</span>
</th>
<th class="text-right">
<span>
Amount</span>
<span>
Total Price</span>
</th>
</tr>
</thead>
<tbody class="sale_tbody">
<t t-set="current_subtotal" t-value="0" />
<t t-foreach="order_lines" t-as="line">
<t t-set="is_task" t-value="line == doc.sale_line_id" />
<t
t-set="is_total_discount"
t-value="line.discount and line.price_unit != 0 and line.delivered_price_total == 0"
/>
<t
t-set="current_subtotal"
t-value="current_subtotal + line.delivered_price_subtotal"
/>
<t
t-set="current_total"
t-value="current_subtotal + line.delivered_price_total"
/>
<tr
t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''"
>
<t
t-if="not line.display_type and not line.is_downpayment"
>
<td><span t-field="line.name" /></td>
<td class="text-right">
<span t-field="line.product_uom_qty" />
<span
t-field="line.product_uom"
groups="uom.group_uom"
/>
</td>
<td class="text-right">
<span t-field="line.qty_delivered" />
<span
t-field="line.product_uom"
groups="uom.group_uom"
/>
</td>
<td class="text-right">
<span t-field="line.price_unit" />
</td>
<td
t-if="display_discount"
class="text-right"
groups="sale.group_discount_per_so_line"
>
<span t-field="line.discount" />
</td>
<td class="text-right o_price_total">
<span t-field="line.delivered_price_subtotal" />
</td>
</t>
<t t-if="line.display_type == 'line_section'">
<td colspan="99">
<span t-field="line.name" />
</td>
<t t-set="current_section" t-value="line" />
<t t-set="current_subtotal" t-value="0" />
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
<span t-field="line.name" />
</td>
</t>
</tr>
<t
t-if="current_section and len(section_lines) > 1 and (line_last or order_lines[line_index+1].display_type == 'line_section')"
>
<tr class="is-subtotal text-right">
<td colspan="99">
<strong class="mr16">Section
Subtotal</strong>
<span
t-esc="current_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'
/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
</div>
<div class="container_subtotal" name="so_total_summary">
<div id="total" class="row justify-content-end" name="total">
<div
t-attf-class="#{'col-auto' if report_type != 'html' else 'col-sm-2'}"
>
<table class="table table-sm">
<tr
t-if="final_tax"
class="border-black o_subtotal"
style=""
>
<td><strong>Untaxed amount</strong></td>
<td class="text-right">
<span
t-esc="final_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'
/>
</td>
</tr>
<tr t-if="final_tax" class="border-black o_subtotal">
<td><strong>Taxes</strong></td>
<td class="text-right">
<span
t-esc="final_tax"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'
/>
</td>
</tr>
<tr t-if="final_total" class="border-black o_total">
<td><strong>Total</strong></td>
<td class="text-right">
<span
t-esc="final_total"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'
/>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</template>
<template id="subtask_list">
<ul t-if="task.child_ids" class="ml-1 pl-1" style="list-style-type: none;">
<t t-foreach="task.child_ids" t-as="subtask">
<li class="ml-1">
<span t-out="subtask.name" />
<t t-call="bemade_fsm.subtask_list">
<t t-set="task" t-value="subtask" />
</t>
</li>
</t>
</ul>
</template>
<template id="workorder_page_tasks_table">
<t t-set="interventions" t-value="doc.child_ids" />
<t t-foreach="interventions" t-as="intervention">
<div style="page-break-inside: avoid;">
<h2 t-out="str(intervention_index + 1) + '. ' + intervention.name" />
<h3 t-if="intervention.equipment_ids">Equipment:
<t t-foreach="intervention.equipment_ids" t-as="equipment_id">
<span
t-out="equipment_id.display_name + (', ' if not equipment_id_last else '')"
/>
</t>
</h3>
<div>
<span t-esc="intervention.description" />
</div>
<t t-set="tasks" t-value="intervention.child_ids" />
<div t-if="tasks" class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th width="7%">Status</th>
<th width="39%">Task</th>
<th width="54%">Comments</th>
</tr>
</thead>
<tbody>
<t t-foreach="tasks" t-as="task">
<tr class="p-1 m-1">
<td t-out="task.stage_id.name" />
<td>
<span t-out="task.name" />
</td>
<td t-out="task.description or ''" />
</tr>
<tr t-if="task.child_ids" class="mt-0 pt-0">
<td />
<td>
<t t-call="bemade_fsm.subtask_list" />
</td>
<td />
</tr>
</t>
</tbody>
</table>
</div>
</div>
</t>
</template>
<template id="workorder_page_info_block">
<h1>
<span name="work_order_number" t-out="doc.name" />
</h1>
<h4 t-if="doc.sale_order_id and doc.sale_order_id.client_order_ref">
Purchase Order: <span
name="po_number"
t-out="doc.sale_order_id.client_order_ref"
/>
</h4>
<hr />
<div class="row" name="address_and_time">
<div
t-attf-class="{{('col-6' if report_type == 'pdf' else 'col-md-6 col-12') + ' mb-3'}}"
>
<t t-if="doc.partner_id">
<div><h6>Customer: </h6></div>
<div
t-esc="doc.partner_id"
t-options='{
"widget": "contact",
"fields": ["name", "address",],
"lang": "fr_FR"
}'
/>
</t>
</div>
<div
t-attf-class="{{('col-6' if report_type == 'pdf' else 'col-md-6 col-12') + ' mb-3'}}"
t-if="doc.planned_date_begin or doc.date_deadline"
>
<div t-if="doc.planned_date_begin"><h6>Planned start: </h6></div>
<div class="mb-3">
<div t-esc="context_timestamp(doc.planned_date_begin).strftime('%Y-%m-%d %H:%M')" />
</div>
<div t-if="doc.date_deadline"><h6>Planned end: </h6></div>
<div class="mb-3">
<div t-out="context_timestamp(doc.date_deadline).strftime('%Y-%m-%d %H:%M')" />
</div>
</div>
</div>
<div class="row" name="site_and_work_order_contacts">
<div
t-attf-class="{{'col-6' if report_type == 'pdf' else 'col-md-6 col-12'}}"
>
<div t-if="doc.site_contacts"><h6>Site Contacts: </h6></div>
<t t-foreach="doc.site_contacts" t-as="contact">
<div class="mb-3">
<div
t-esc="contact"
t-options='{
"widget": "contact",
"fields": ["name", "phone", "email"]
}'
/>
</div>
</t>
</div>
<div
t-attf-class="{{'col-6' if report_type == 'pdf' else 'col-md-6 col-12'}}"
>
<div t-if="doc.work_order_contacts"><h6>Work Order
Contacts: </h6></div>
<t t-foreach="doc.work_order_contacts" t-as="contact">
<div class="mb-3">
<div
t-esc="contact"
t-options='{
"widget": "contact",
"fields": ["name", "phone", "email"]
}'
/>
</div>
</t>
</div>
</div>
<hr />
<div
t-if="not is_html_empty(doc.description)"
class="row"
name="visit_description"
>
<div class="col-12">
<span t-out="doc.description" />
</div>
</div>
<hr t-if="not is_html_empty(doc.description)" />
</template>
<template id="workorder_equipment_summary">
<div
t-if="doc.equipment_ids and len(doc.child_ids) > 1"
style="page-break-inside: avoid;"
>
<h3>Equipment Serviced</h3>
<div class="table-responsive-sm">
<table class="table table-sm o_main_table overflow-hidden">
<thead>
<tr>
<th>Tag</th>
<th>Name</th>
<th>Application</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.equipment_ids" t-as="equipment">
<tr>
<td><span t-esc="equipment.code" /></td>
<td><span t-esc="equipment.name" /></td>
<td>
<t t-foreach="equipment.tag_ids" t-as="application">
<span
t-esc="application.name + (', ' if not application_last else '')"
/>
</t>
</td>
<td
t-esc="equipment.partner_id"
t-options='{
"widget": "contact",
"fields": ["name", "address"]}'
/>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</template>
<template id="workorder_page_timesheet_entries">
<t t-set="timesheets" t-value="doc.timesheet_ids" />
<div t-if="timesheets" style="page-break-inside: avoid;">
<h1>Time</h1>
<div class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th width="14%">Date</th>
<th width="30%">Technician</th>
<th width="49%">Work Completed</th>
<th width="7%" class="text-right">Time</th>
</tr>
</thead>
<tbody>
<tr t-foreach="timesheets" t-as="timesheet" class="p-1 m-1">
<td>
<span t-out="timesheet.date.strftime('%Y-%m-%d')" />
</td>
<td>
<span t-field="timesheet.employee_id.name" />
</td>
<td>
<span t-field="timesheet.name" />
</td>
<td>
<span
t-esc="timesheet.unit_amount"
t-options="{
'widget': 'duration',
'digital': True,
'unit': 'hour',
'round': 'minute'
}"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<template id="workorder_page_signature_block">
<div t-if="doc.worksheet_signature">
<div
t-if="report_type == html"
class="ribbon"
style="
position: absolute;
right: 0px; top: 0px;
z-index: 1;
overflow: hidden;
width: 75px; height: 75px;
text-align: right;"
>
<span
style="
font-size: 10px;
color: #fff;
text-transform: uppercase;
text-align: center;
font-weight: bold; line-height: 20px;
transform: rotate(45deg);
width: 100px; height: auto; display: block;
background: green;
position: absolute;
top: 19px; right: -21px; left: auto;
padding: 0;"
>
Signed
</span>
</div>
<div
t-attf-class="#{' col-12 col-lg-3' if report_type != 'html' else '
col-sm-7 col-md-4'} ml-auto text-right"
style="page-break-inside: avoid"
>
<h5>Signature</h5>
<img
t-att-src="image_data_uri(doc.worksheet_signature)"
style="max-height: 6rem; max-width: 100%; color:black;"
/><br />
<span t-field="doc.worksheet_signed_by" />
</div>
</div>
</template>
<template id="work_order_page">
<div class="page">
<t t-call="bemade_fsm.workorder_page_info_block" />
<t t-call="bemade_fsm.workorder_page_timesheet_entries" />
<t t-if="split_time_materials">
<t t-call="bemade_fsm.workorder_page_materials_table" />
</t>
<t t-else="">
<t
t-call="bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour"
/>
</t>
<t t-call="bemade_fsm.workorder_equipment_summary" />
<t t-call="bemade_fsm.workorder_page_signature_block" />
<t t-call="bemade_fsm.workorder_page_tasks_table" />
</div>
</template>
<template id="work_order">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-set="doc" t-value="doc.root_ancestor.with_context(tz=doc.partner_id.tz)" t-if="doc.parent_id"/>
<t t-set="lang" t-value="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or (doc.partner_id and doc.partner_id.lang) or (doc.user_id and doc.user_id.lang) or user.lang"/>
<t t-call="web.external_layout">
<t t-call="bemade_fsm.work_order_page" t-lang="lang"/>
</t>
</t>
</t>
</template>
<template
id="worksheet_custom"
inherit_id="industry_fsm.worksheet_custom"
priority="100"
>
<xpath expr="//t[@t-call='web.external_layout']" position="replace">
<t t-set="lang" t-value="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or (doc.partner_id and doc.partner_id.lang) or (doc.user_id and doc.user_id.lang) or user.lang"/>
<t t-call="web.external_layout" t-lang="lang">
<t t-call="bemade_fsm.work_order_page" t-lang="lang"/>
</t>
</xpath>
</template>
</odoo>

View file

@ -0,0 +1,13 @@
from odoo import models
class TaskCustomReport(models.AbstractModel):
_inherit = "report.industry_fsm.worksheet_custom"
def _get_report_values(self, docids, data=None):
vals = super()._get_report_values(docids, data)
split_time_materials = (
self.env.company.split_time_from_materials_on_service_work_orders
)
vals.update({"split_time_materials": split_time_materials})
return vals

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="industry_fsm.task_custom_report" model="ir.actions.report">
<field name="print_report_name">'%s %s' % (
object.planned_date_begin.strftime(
'%Y-%m-%d') if object.planned_date_begin else time.strftime('%Y-%m-%d'),
object.name
)
</field>
</record>
</odoo>

View file

@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bemade_fsm_task_template_manager,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1
access_bemade_fsm_task_template_user,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_visit,access_bemade_fsm_visit,model_bemade_fsm_visit,base.group_user,1,1,1,1
access_bemade_fsm_task_wizard,access_bemade_fsm_task_wizard,model_project_task_from_template_wizard,project.group_project_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_bemade_fsm_task_template_manager bemade_fsm_task_template model_project_task_template project.group_project_manager 1 1 1 1
3 access_bemade_fsm_task_template_user bemade_fsm_task_template model_project_task_template project.group_project_user 1 1 1 1
4 access_bemade_fsm_visit access_bemade_fsm_visit model_bemade_fsm_visit base.group_user 1 1 1 1
5 access_bemade_fsm_task_wizard access_bemade_fsm_task_wizard model_project_task_from_template_wizard project.group_project_user 1 1 1 1

View file

@ -0,0 +1,37 @@
/** @odoo-module **/
import KanbanController from 'web.KanbanController';
import KanbanView from 'web.KanbanView';
const viewRegistry = require('web.view_registry')
const ProjectKanbanController = KanbanController.extend({
buttons_template: 'project.KanbanView.buttons',
custom_events: _.extend({}, KanbanController.prototype.custom_events, {
create_from_template: '_onCreateFromTemplate',
}),
_onCreateFromTemplate(ev) {
ev.stopPropagation();
const record = this.model.get(this.handle, {raw: true});
let context = record.context;
context.active_id = record.project_id;
this.do_action({
type: 'ir.actions.act_window',
res_model: 'project.task.from.template.wizard',
views: [[false, 'form']],
target: 'new',
context: {...record.context, res_model: 'project.task'},
});
},
renderButtons ($node) {
this._super.apply(this, arguments);
this.$buttons.on('click', 'button.o-kanban-button-new-from-template', this._onCreateFromTemplate.bind(this))
},
})
const ProjectKanbanView = KanbanView.extend({
config: _.extend({}, KanbanView.prototype.config, {
Controller: ProjectKanbanController,
})
});
viewRegistry.add('project_kanban', ProjectKanbanView)

View file

@ -0,0 +1,36 @@
/** @odoo-module **/
import ListController from 'web.ListController';
import ListView from 'web.ListView';
const viewRegistry = require('web.view_registry')
const ProjectListController = ListController.extend({
buttons_template: 'project.ListView.buttons',
custom_events: _.extend({}, ListController.prototype.custom_events, {
create_from_template: '_onCreateFromTemplate',
}),
_onCreateFromTemplate(ev) {
ev.stopPropagation();
const record = this.model.get(this.handle, {raw: true});
this.do_action({
type: 'ir.actions.act_window',
res_model: 'project.task.from.template.wizard',
views: [[false, 'form']],
target: 'new',
context: {...record.context, res_model: 'project.task'},
});
},
renderButtons ($node) {
self = this;
this._super.apply(this, arguments);
this.$buttons.on('click', 'button.o_list_button_add_from_template', this._onCreateFromTemplate.bind(this))
},
})
const ProjectListView = ListView.extend({
config: _.extend({}, ListView.prototype.config, {
Controller: ProjectListController,
})
});
viewRegistry.add('project_list', ProjectListView)

View file

@ -0,0 +1,14 @@
/* This prevents repeating the header, which overlaps with the body due to a bug in
wkhtmltopdf rendering.
*/
thead {
display: table-row-group;
}
tfoot {
display: table-row-group;
}
tr {
page-break-inside: avoid;
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<!-- Add a button to create a task from a template to task (project) list and kanban views -->
<t t-name='project.KanbanView.buttons' t-inherit="web.KanbanView.Buttons" t-inherit-mode="primary">
<xpath expr="//button[contains(@t-attf-class, 'o-kanban-button-new')]" position="after">
<button title="Create Task from Template" t-if="!noCreate" type="button"
t-attf-class="btn {{ btnClass }} o-kanban-button-new-from-template">
<t t-esc="create_text &amp;&amp; (create_text + ' from Template') || _t('Create from Template')"/>
</button>
</xpath>
</t>
<t t-name='project.ListView.buttons' t-inherit="web.ListView.Buttons" t-inherit-mode="primary">
<xpath expr="//button[hasclass('o_list_button_add')]" position="after">
<!-- Create is enabled in the parent template at this point; check is done prior -->
<button type="button" class="btn ml-1 btn-primary o_list_button_add_from_template"
title="Create Task from Template">
Create from Template
</button>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,9 @@
from . import test_bemade_fsm_common
from . import test_task_template
from . import test_sale_order
from . import test_fsm_contact_setting
from . import test_fsm_visit
from . import test_task
from . import test_task_report
from . import test_settings
from . import test_equipment

View file

@ -0,0 +1,292 @@
from odoo.tests.common import TransactionCase, tagged
from odoo import Command
@tagged("-at_install", "post_install")
class BemadeFSMBaseTest(TransactionCase):
@classmethod
def _generate_project_manager_user(cls, name, login):
group_ids = cls.__get_user_groups()
user_group_project_manager = cls.env.ref("project.group_project_manager")
user_group_fsm_manager = cls.env.ref("industry_fsm.group_fsm_manager")
group_ids.append(user_group_fsm_manager.id)
group_ids.append(user_group_project_manager.id)
return cls.__generate_user(name, login, group_ids)
@classmethod
def _generate_project_user(cls, name, login):
group_ids = cls.__get_user_groups()
return cls.__generate_user(name, login, group_ids)
@classmethod
def __generate_user(cls, name, login, group_ids):
return (
cls.env["res.users"]
.with_context(no_reset_password=True)
.create(
{
"name": name,
"login": login,
"password": login,
"email": f"{login}@test.co",
"groups_id": [Command.set(group_ids)],
}
)
)
@classmethod
def __get_user_groups(cls):
user_group_employee = cls.env.ref("base.group_user")
user_group_project_user = cls.env.ref("project.group_project_user")
user_group_fsm_user = cls.env.ref("industry_fsm.group_fsm_user")
user_group_sales_user = cls.env.ref("sales_team.group_sale_salesman")
user_group_sales_manager = cls.env.ref("sales_team.group_sale_manager")
user_group_delivery_address = cls.env.ref(
"account.group_delivery_invoice_address"
)
user_product_customer = cls.env.ref(
"customer_product_code.group_product_customer_code_user", # pyright: ignore[reportGeneralTypeIssues]
raise_if_not_found=False,
)
group_ids = [
user_group_employee.id,
user_group_project_user.id,
user_group_fsm_user.id,
user_group_sales_manager.id,
user_group_sales_user.id,
user_group_delivery_address.id,
]
if user_product_customer:
group_ids.append(user_product_customer.id)
return group_ids
@classmethod
def _generate_partner(
cls,
name: str = "Test Company",
company_type: str = "company",
parent=None,
location_type="other",
):
"""Generates a partner with basic address filled in.
:param name: The partner's name.
:param company_type: The type of partner, either 'company' or 'person' are
accepted.
"""
return cls.env["res.partner"].create(
{
"name": name,
"company_type": company_type,
"street": "123 Street St.",
"city": "Montreal",
"state_id": cls.env.ref("base.state_ca_qc").id,
"country_id": cls.env.ref("base.ca").id,
"parent_id": parent and parent.id or False,
"type": location_type,
}
)
@classmethod
def _generate_sale_order(
cls,
partner=None,
client_order_ref="Test Order",
equipment=None,
shipping_location=None,
):
partner = partner or cls._generate_partner()
vals = {"partner_id": partner.id, "client_order_ref": client_order_ref}
if equipment:
vals.update({"default_equipment_ids": [Command.set([equipment.id])]})
if shipping_location:
vals.update({"partner_shipping_id": shipping_location.id})
return cls.env["sale.order"].create(vals)
@classmethod
def _generate_sale_order_line(
cls, sale_order, product=None, qty=1.0, uom=None, price=100.0, tax_id=False
):
if not product:
product = cls._generate_product()
return cls.env["sale.order.line"].create(
{
"order_id": sale_order.id,
"product_id": product.id,
"product_uom_qty": qty,
"product_uom": uom and uom.id or cls.env.ref("uom.product_uom_hour").id,
"price_unit": price,
"tax_id": tax_id,
}
)
@classmethod
def _generate_equipment(cls, name="test equipment", partner=None):
return cls.env["fsm.equipment"].create(
{
"name": name,
"partner_id": partner and partner.id or False,
}
)
@classmethod
def _generate_product(
cls,
name="Test Product",
product_type="service",
service_tracking="task_global_project",
project=None,
task_template=None,
service_policy="delivered_manual",
uom=None,
):
if "project" in service_tracking and not project:
project = cls.env.ref("industry_fsm.fsm_project")
uom_id = uom and uom.id or cls.env.ref("uom.product_uom_hour").id or False
return cls.env["product.product"].create(
{
"name": name,
"type": product_type,
"service_tracking": service_tracking,
"service_type": "timesheet",
"project_id": (
service_tracking in ("task_global_project", "project_only")
and project
and project.id
or False
),
"project_template_id": (
service_tracking == "task_in_project"
and project
and project.id
or False
),
"task_template_id": task_template and task_template.id or False,
"service_policy": service_policy,
"uom_id": uom_id,
"uom_po_id": uom_id,
}
)
@classmethod
def _generate_fsm_project(cls, name="Test Project"):
return cls.env["project.project"].create(
{
"name": name,
"allow_material": True,
"allow_timesheets": True,
"allow_quotations": True,
"allow_worksheets": True,
"is_fsm": True,
}
)
@classmethod
def _generate_task_template(
cls,
parent=None,
structure=None,
names=None,
planned_hours=1,
equipment=None,
customer=None,
):
"""Generates a task template with the specified structure and naming.
:param parent: The parent task template for the top-level task template being
generated
:param structure: A list of integers describing the number of tasks for each
level of descendants. An empty list represents only one
top-level task template. If no structure is given, an empty
list will be used in its place.
:param names: The name prefixes to be given to the task templates at each level.
Each prefix will be followed by a sequential integer for its
level. Child 1, Child 2, Grandchild 1, etc. If no names argument
is passed, a default ['Task Template'] argument will be used.
:param planned_hours: The number of planned hours for the top-level task
template being generated.
:param equipment: The equipment to add as linked equipment to the task template.
"""
if not names:
names = ["Task Template"]
if not structure:
structure = []
if len(structure) != len(names) - 1:
raise ValueError(
"The length of the structure argument must contain exactly one element"
" less than the names argument."
)
name = names.pop(0)
template = cls.env["project.task.template"].create(
{
"name": name,
"parent": parent and parent.id or False,
"planned_hours": planned_hours,
"equipment_ids": [Command.set(equipment and [equipment.id] or [])],
"customer": customer and customer.id or False,
}
)
parent = template
while structure:
subtasks = []
for i in range(0, structure[0]):
subtasks.append(
cls.env["project.task.template"].create(
{
"parent": parent.id,
"name": names[0] + f" {i}",
}
)
)
structure.pop(0)
names.pop(0)
parent = subtasks[0]
return template
def _invoice_sale_order(self, so):
wiz = (
self.env["sale.advance.payment.inv"]
.with_context(active_ids=[so.id])
.create({})
)
wiz.create_invoices()
inv = so.invoice_ids[-1]
inv.action_post()
return inv
def _generate_visit(self, sale_order, label="Test Label"):
return self.env["bemade_fsm.visit"].create(
[
{
"sale_order_id": sale_order.id,
"label": label,
}
]
)
def _generate_so_with_one_visit_two_lines(self):
so = self._generate_sale_order()
visit = self._generate_visit(sale_order=so)
sol1 = self._generate_sale_order_line(sale_order=so)
sol2 = self._generate_sale_order_line(sale_order=so)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
return so, visit, sol1, sol2
def _generate_so_with_one_visit_two_lines_and_descendants(self):
so = self._generate_sale_order()
visit = self._generate_visit(sale_order=so)
task_template = self._generate_task_template(
structure=[2, 2, 2],
names=["Parent", "Child", "Grandchild", "Great-grandchild"],
)
product = self._generate_product(task_template=task_template)
sol1 = self._generate_sale_order_line(sale_order=so, product=product)
sol2 = self._generate_sale_order_line(sale_order=so, product=product)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
return so, visit, sol1, sol2

View file

@ -0,0 +1,32 @@
from odoo.addons.bemade_fsm.tests.test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests import tagged, Form
@tagged("-at_install", "post_install")
class TestEquipment(BemadeFSMBaseTest):
def test_equipment_search_domain_on_sale_order(self):
"""Equipment from other clients was showing up in sale order line
equipment choices. Make sure this doesn't happen."""
partner = self._generate_partner()
partner_2 = self._generate_partner()
equipment_1 = self._generate_equipment(partner=partner)
equipment_2 = self._generate_equipment(partner=partner_2)
sale_order = self._generate_sale_order(partner=partner)
product = self._generate_product()
self.assertEqual(sale_order.valid_equipment_ids, equipment_1)
name_search_results = self.env["fsm.equipment"].name_search(
args=[
"&",
["id", "in", sale_order.valid_equipment_ids.ids],
"!",
["id", "in", []],
],
limit=8,
name="test",
operator="ilike",
)
self.assertNotIn(
(equipment_2.id, equipment_2.display_name), name_search_results
)
self.assertIn((equipment_1.id, equipment_1.display_name), name_search_results)

View file

@ -0,0 +1,129 @@
from odoo.tests import tagged, Form
from odoo import Command
from .test_bemade_fsm_common import BemadeFSMBaseTest
@tagged("-at_install", "post_install")
class SaleOrderFSMContactsCase(BemadeFSMBaseTest):
def test_site_contacts(self):
parent_co = self._generate_partner("Parent Co")
contact_1 = self._generate_partner("Contact 1", "person", parent_co)
contact_2 = self._generate_partner("Contact 2", "person", parent_co)
# Make sure the SO pulls the defaults from the partner correctly
parent_co.write({"site_contacts": [Command.set([contact_1.id, contact_2.id])]})
so = self._generate_sale_order(parent_co)
self.assertTrue(so.site_contacts == parent_co.site_contacts)
# Make sure updating the site contacts on the SO doesn't feed back to the
# partner
so.write({"site_contacts": [Command.set([contact_1.id])]})
self.assertTrue(contact_1 in so.site_contacts)
self.assertTrue(contact_2 not in so.site_contacts)
self.assertTrue(
so.site_contacts != so.partner_id.site_contacts
and len(so.partner_id.site_contacts) == 2
)
def test_default_workorder_contacts(self):
parent_co = self._generate_partner("Parent Co")
contact_1 = self._generate_partner("Contact 1", "person", parent_co)
contact_2 = self._generate_partner("Contact 2", "person", parent_co)
# Make sure the SO pulls the defaults from the partner correctly
parent_co.write(
{"work_order_contacts": [Command.set([contact_1.id, contact_2.id])]}
)
so = self._generate_sale_order(parent_co)
self.assertTrue(contact_1 in parent_co.work_order_contacts)
self.assertTrue(contact_2 in parent_co.work_order_contacts)
self.assertTrue(contact_1 in so.work_order_contacts)
self.assertTrue(contact_2 in so.work_order_contacts)
# Make sure setting the work order contacts on the SO doesn't feed back to the
# partner
so.write({"work_order_contacts": [Command.set([contact_1.id])]})
self.assertTrue(contact_1 in so.work_order_contacts)
self.assertTrue(contact_2 not in so.work_order_contacts)
self.assertTrue(
so.work_order_contacts != so.partner_id.work_order_contacts
and len(so.partner_id.work_order_contacts) == 2
)
def test_multilayer_site_contacts(self):
parent_co = self._generate_partner("Parent Co")
shipping_location = self._generate_partner(
"Shipping Location", "company", parent_co, "delivery"
)
wo_contact_1 = self._generate_partner(
"WO Contact 1", "person", shipping_location
)
wo_contact_2 = self._generate_partner(
"WO Contact 2", "person", shipping_location
)
site_contact_1 = self._generate_partner(
"Site Contact 1", "person", shipping_location
)
site_contact_2 = self._generate_partner(
"Site Contact 2", "person", shipping_location
)
shipping_location.write(
{
"work_order_contacts": [
Command.set([wo_contact_1.id, wo_contact_2.id])
],
"site_contacts": [Command.set([site_contact_1.id, site_contact_2.id])],
}
)
so = self._generate_sale_order(parent_co)
so.write({"partner_shipping_id": shipping_location.id})
self.assertEqual(so.site_contacts, shipping_location.site_contacts)
self.assertEqual(so.work_order_contacts, shipping_location.work_order_contacts)
def test_onchange_shipping_address(self):
self.env.user.groups_id += self.env.ref(
"account.group_delivery_invoice_address"
)
parent_co = self._generate_partner("Parent Co")
shipping_location = self._generate_partner(
"Shipping Location", "company", parent_co, "delivery"
)
wo_contact_1 = self._generate_partner(
"WO Contact 1", "person", shipping_location
)
wo_contact_2 = self._generate_partner(
"WO Contact 2", "person", shipping_location
)
site_contact_1 = self._generate_partner(
"Site Contact 1", "person", shipping_location
)
site_contact_2 = self._generate_partner(
"Site Contact 2", "person", shipping_location
)
shipping_location.write(
{
"work_order_contacts": [
Command.set([wo_contact_1.id, wo_contact_2.id])
],
"site_contacts": [Command.set([site_contact_1.id, site_contact_2.id])],
}
)
so = self._generate_sale_order(parent_co)
# Set back to a location without site or work order contacts
form = Form(so)
form.partner_shipping_id = parent_co
form.save()
# Make sure the contacts were reset on the SO
self.assertFalse(so.work_order_contacts)
self.assertFalse(so.site_contacts)
# Now set back to the location with the FSM contacts and make sure they get set
# on the SO
form.partner_shipping_id = shipping_location
form.save()
self.assertEqual(so.work_order_contacts, shipping_location.work_order_contacts)
self.assertEqual(so.site_contacts, shipping_location.site_contacts)

View file

@ -0,0 +1,196 @@
from odoo.tests import tagged
from .test_bemade_fsm_common import BemadeFSMBaseTest
from datetime import date, timedelta
@tagged("-at_install", "post_install")
class FSMVisitTest(BemadeFSMBaseTest):
def test_create_visit_sets_name_on_section(self):
so = self._generate_sale_order()
self._generate_sale_order_line(sale_order=so)
visit = self._generate_visit(so)
self.assertTrue(visit.so_section_id)
self.assertEqual(visit.so_section_id.name, visit.label)
def test_change_visit_section_name(self):
so = self._generate_sale_order()
visit = self._generate_visit(so, label="First Label")
line = visit.so_section_id
line.name = "Second Label"
self.assertEqual(visit.label, "Second Label")
def test_change_visit_label_changes_section_name(self):
so = self._generate_sale_order()
visit = self._generate_visit(so, label="First Label")
line = visit.so_section_id
visit.label = "Second Label"
self.assertEqual(line.name, "Second Label")
def test_visit_completes_when_task_completes(self):
so = self._generate_sale_order()
visit = self._generate_visit(so)
self._generate_sale_order_line(so)
so.action_confirm()
task = so.order_line.filtered(lambda line: line.task_id).task_id
task.action_fsm_validate()
self.assertTrue(visit.is_completed)
def test_visit_shows_invoiced_when_invoiced(self):
so = self._generate_sale_order()
visit = self._generate_visit(so)
self._generate_sale_order_line(so)
so.action_confirm()
task = so.order_line.filtered(lambda line: line.task_id).task_id
task.action_fsm_validate()
self._invoice_sale_order(so)
self.assertTrue(visit.is_invoiced)
def test_visit_groups_section_tasks_when_confirmed(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit_task = visit.task_id
self.assertTrue(visit_task)
visit_subtasks = visit_task.child_ids
self.assertTrue(
visit_subtasks
and sol1.task_id in visit_subtasks
and sol2.task_id in visit_subtasks
)
def test_visit_task_gets_correct_due_date_on_confirmation(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit_task = visit.task_id
self.assertEqual(visit_task.date_deadline, visit.approx_date)
def test_visit_task_gets_correct_duration_on_confirmation(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
visit = self._generate_visit(sale_order=so)
sol1 = self._generate_sale_order_line(sale_order=so, qty=4.0)
sol2 = self._generate_sale_order_line(sale_order=so, qty=4.0)
visit.approx_date = date.today() + timedelta(days=7)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
so.action_confirm()
visit_task = visit.task_id
self.assertEqual(sol1.task_id.allocated_hours, 4.0)
self.assertEqual(sol2.task_id.allocated_hours, 4.0)
self.assertEqual(visit_task.allocated_hours, 8.0)
def test_adding_visit_creates_one_sale_order_line(self):
self._generate_partner()
so = self._generate_sale_order()
self._generate_sale_order_line(sale_order=so)
self._generate_sale_order_line(sale_order=so)
self._generate_visit(sale_order=so)
self.assertEqual(len(so.order_line), 3)
def test_marking_visit_task_done_completes_descendants(self):
(
so,
visit,
sol1,
sol2,
) = self._generate_so_with_one_visit_two_lines_and_descendants()
so.action_confirm()
parent = visit.task_id
parent.action_fsm_validate()
self._assert_is_done(parent)
def _assert_is_done(self, task):
"""Recursively assert all tasks in a hierarchy are complete"""
self.assertTrue(task.is_closed)
for child in task.child_ids:
self._assert_is_done(child)
def test_marking_visit_task_done_does_not_create_sale_order_line(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit.task_id.action_fsm_validate()
self.assertEqual(len(so.order_line), 3)
def test_confirming_so_names_visit_properly(self):
"""Visits should be named <SO NUMBER> - Visit <visit #> - <visit label>"""
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.name = "SO12345"
so.action_confirm()
task = visit.task_id
supposed_name = "SVR12345-1 - Test Company - Test Label"
self.assertEqual(task.name, supposed_name)
def test_subtasks_inherit_partner_from_parent_task(self):
"""Test that subtasks of tasks created from FSM sales orders have their partner_id set correctly."""
# Create a sale order with a shipping address different from the billing address
partner = self._generate_partner(name="Customer")
shipping_partner = self.env['res.partner'].create({
'name': 'Shipping Address',
'parent_id': partner.id,
'type': 'delivery',
})
# Create a sale order with the customer and shipping address
so = self._generate_sale_order(partner=partner)
so.partner_shipping_id = shipping_partner
# Create a visit
visit = self._generate_visit(sale_order=so)
# Create a product with a task template that has subtasks
parent_template = self._generate_task_template(
structure=[1],
names=["Parent Task", "Child Task"],
)
product = self._generate_product(task_template=parent_template)
# Add the product to the sale order
sol = self._generate_sale_order_line(sale_order=so, product=product)
# Set the sequence to ensure proper ordering
visit.so_section_id.sequence = 1
sol.sequence = 2
# Confirm the sale order to create tasks
so.action_confirm()
# Get the created tasks
parent_task = sol.task_id
self.assertTrue(parent_task, "Parent task should be created")
# Check that the parent task has a partner_id set
self.assertTrue(parent_task.partner_id, "Parent task should have a partner set")
# Check that the subtask exists
self.assertTrue(parent_task.child_ids, "Parent task should have subtasks")
child_task = parent_task.child_ids[0]
# The key test: Check that the subtask has a partner_id set
self.assertTrue(child_task.partner_id, "Subtask should have a partner_id set")
# Check that the subtask has the same partner as the parent task
self.assertEqual(child_task.partner_id, parent_task.partner_id,
"Subtask should have the same partner as its parent task")

View file

@ -0,0 +1,474 @@
from .test_task_template import BemadeFSMBaseTest
from odoo.tests.common import tagged, Form
from odoo import Command
@tagged("-at_install", "post_install")
class TestSalesOrder(BemadeFSMBaseTest):
@tagged("-at_install", "post_install")
def test_order_confirmation_simple_template(self):
"""Confirming the order should create a task in the global project based on the
task template."""
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(planned_hours=8)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
task = sol.task_id
self.assertTrue(task)
self.assertTrue(task_template.name in task.name)
self.assertTrue(task_template.planned_hours == task.allocated_hours)
def test_task_template_tree_order_confirmation(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
parent_template = self._generate_task_template(
structure=[2, 1],
names=["Parent Template", "Child Template", "Grandchild Template"],
)
child_template_1 = parent_template.subtasks[0]
child_template_2 = parent_template.subtasks[1]
grandchild_template = parent_template.subtasks[0].subtasks[0]
product = self._generate_product(task_template=parent_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
parent = sol.task_id
c1, c2 = parent.child_ids
gc = c1.child_ids[:1]
self.assertTrue(parent.child_ids and len(parent.child_ids) == 2)
self.assertTrue(parent_template.name in parent.name)
self.assertEqual(child_template_1.name, c1.name)
self.assertEqual(child_template_2.name, c2.name)
self.assertTrue(c1.child_ids and len(c1.child_ids) == 1)
self.assertEqual(grandchild_template.name, gc.name)
def test_order_confirmation_single_equipment(self):
"""The equipment selected on the SO should transfer to the task."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner, equipment=equipment)
task_template = self._generate_task_template(planned_hours=8)
product1 = self._generate_product(task_template=task_template)
product2 = self._generate_product()
sol1 = self._generate_sale_order_line(so, product=product1)
sol2 = self._generate_sale_order_line(so, product=product2)
so.action_confirm()
task1 = sol1.task_id
task2 = sol2.task_id
self.assertEqual(task1.equipment_ids[0], equipment)
self.assertEqual(task2.equipment_ids[0], equipment)
def test_order_confirmation_multiple_equipment(self):
"""All equipment items should flow from the sale order line to the final task"""
partner = self._generate_partner()
for i in range(5):
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(
partner=partner
) # No default equipment since more than 3 on partner
sol1, sol2, sol3 = [
self._generate_sale_order_line(sale_order=sale_order) for _ in range(3)
]
sol1.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2)])
]
sol3.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2, 5)])
]
sale_order.action_confirm()
self.assertEqual(sol1.equipment_ids, sol1.task_id.equipment_ids)
self.assertEqual(sol2.equipment_ids, sol2.task_id.equipment_ids)
self.assertEqual(sol3.equipment_ids, sol3.task_id.equipment_ids)
def test_task_template_with_equipment_flow(self):
"""The equipment selected on a task template should flow down to the task
created on SO confirmation."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(equipment=equipment)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
self.assertEqual(sol.task_id.equipment_ids[0], equipment)
def test_sale_order_line_gets_default_equipment(self):
"""Sale order lines created on a SO with default equipment set should inherit
that default equipment."""
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
sol = self._generate_sale_order_line(sale_order=sale_order)
self.assertEqual(sol.equipment_ids, partner.equipment_ids)
def test_sale_order_gets_correct_default_equipment_from_partner(self):
"""Should pick up equipment from the partner."""
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
self.assertEqual(sale_order.default_equipment_ids, partner.owned_equipment_ids)
def test_sale_order_default_equipment_maximum_number(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(3):
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent)
self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids)
def test_sale_order_no_default_equipment_with_more_than_three_owned_on_partner(
self,
):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(4):
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent)
self.assertEqual(sale_order.default_equipment_ids, self.env["fsm.equipment"])
def test_sale_order_resets_default_equipment_on_partner_change(self):
partner_1 = self._generate_partner()
partner_2 = self._generate_partner()
self._generate_equipment(partner=partner_1)
sale_order = self._generate_sale_order(partner_1)
form = Form(sale_order)
form.partner_id = partner_2
form.save()
self.assertFalse(sale_order.default_equipment_ids)
def test_sale_order_prioritize_shipping_location_equipments(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent, location_type="delivery")
self._generate_equipment(partner=parent)
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent, shipping_location=child)
self.assertEqual(sale_order.default_equipment_ids, child.equipment_ids)
def test_default_equipment_transfers_to_sale_order_line(self):
partner = self._generate_partner()
for i in range(3):
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
for i in range(3):
self._generate_sale_order_line(sale_order=sale_order)
for line in sale_order.order_line:
self.assertEqual(line.equipment_ids, partner.equipment_ids)
def test_task_mark_done(self):
"""Marking the task linked to a SO line should mark the line delivered.
Marking sub-tasks done should not."""
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(
structure=[2], names=["Parent Task", "Subtask"]
)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
parent_task = sol.task_id
subtasks = parent_task._get_all_subtasks()
# Marking the subtasks done should not increment delivered quantity
subtasks.action_fsm_validate(True)
self.assertEqual(sol.qty_delivered, 0)
# Marking the top-level tasks done should set the delivered quantity to some
# non-zero value based on the UOM
parent_task.action_fsm_validate(True)
self.assertTrue(sol.qty_delivered != 0)
def test_task_contacts_through_sale_order(self):
"""Make sure the site contacts and work order contacts transfer correctly
from the SO to the task."""
partner = self._generate_partner()
contact1 = self._generate_partner("Site contact", "person", partner)
contact2 = self._generate_partner("Work order contact", "person", partner)
partner.write(
{
"site_contacts": [Command.set([contact1.id])],
"work_order_contacts": [Command.set([contact2.id])],
}
)
so = self._generate_sale_order(partner)
product = self._generate_product()
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
self.assertEqual(so.work_order_contacts, partner.work_order_contacts)
self.assertEqual(so.site_contacts, partner.site_contacts)
self.assertEqual(sol.task_id.work_order_contacts, partner.work_order_contacts)
self.assertEqual(sol.task_id.site_contacts, partner.site_contacts)
def test_tasks_created_at_order_confirmation_have_no_assignees(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
user = self._generate_project_user(name="User", login="login")
# We test as a specific user since testing as root may not produce the error
so.with_user(user).action_confirm()
visit_task = visit.task_id
subtask1 = visit_task.child_ids[0]
subtask2 = visit_task.child_ids[1]
self.assertFalse(visit_task.user_ids)
self.assertFalse(subtask1.user_ids)
self.assertFalse(subtask2.user_ids)
def test_long_line_name_overflows_to_task_description(self):
so = self._generate_sale_order()
product = self._generate_product()
product.description_sale = (
"This is a long product description.\n"
"It even spans multiple lines.\n"
"One could find this annoying in a task name."
)
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
task = sol.task_id
self.assertFalse("This is a long product description." in task.name)
self.assertFalse("It even spans multiple lines." in task.name)
self.assertFalse("One could find this annoying in a task name." in task.name)
self.assertTrue("It even spans multiple lines." in task.description)
self.assertTrue(
"One could find this annoying in a task name." in task.description
)
def test_subtask_templates_no_description_if_blank_on_template(self):
so = self._generate_sale_order()
template = self._generate_task_template(
structure=[5], names=["Parent", "Child"]
)
template.description = ""
template.subtasks[0].description = "Some fixed description"
for t in template.subtasks[1:]:
t.description = ""
product = self._generate_product(task_template=template)
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
task = sol.task_id
self.assertEqual(
task.child_ids[0].description, template.subtasks[0].description
)
for t in task.child_ids[1:]:
self.assertFalse(t.description)
def test_duplicate_sale_order_duplicates_visits(self):
"""Duplicated sales orders should have visits tied to their SO lines as in the
original. The copied visits should not have approximate dates set, however."""
so, visit, line1, line2 = self._generate_so_with_one_visit_two_lines()
so2 = so.copy()
self.assertTrue(so2.visit_ids)
visit2 = so2.visit_ids[0]
self.assertEqual(so2.order_line[0].visit_id, visit2)
self.assertEqual(visit2.label, visit.label)
self.assertFalse(visit2.approx_date)
def test_confirming_sale_order_creates_visit_if_none_created(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = True
product = self._generate_product()
self._generate_sale_order_line(so, product)
so.action_confirm()
visit_line = so.order_line.sorted("sequence")[0]
self.assertTrue(so.visit_ids)
self.assertEqual(visit_line.visit_id, so.visit_ids)
def test_confirming_sale_order_with_visit_creates_no_new_lines(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = True
self._generate_product()
self._generate_visit(so)
so.action_confirm()
self.assertEqual(len(so.visit_ids), 1)
def test_confirming_sale_order_creates_no_visit_if_setting_off(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = False
product = self._generate_product()
self._generate_sale_order_line(so, product)
so.action_confirm()
self.assertFalse(so.visit_ids)
def test_changing_sale_order_customer_follows_to_tasks_and_subtasks(self):
so = self._generate_sale_order()
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = False
task_template = self._generate_task_template(
parent=None,
structure=[2, 2],
names=["Parent", "Child", "Grandchild"],
)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product)
so.action_confirm()
parent_task = sol.task_id
for task in parent_task._get_all_subtasks() | parent_task:
self.assertEqual(so.partner_shipping_id, task.partner_id)
so.write(
{
"partner_shipping_id": self.env["res.partner"]
.create(
{
"name": "New shipping address",
"parent_id": so.partner_id.id,
"type": "delivery",
}
)
.id
}
)
for task in parent_task._get_all_subtasks() | parent_task:
self.assertEqual(
so.partner_shipping_id,
task.partner_id,
f"{task.name} has a different partner than the SO",
)
def test_task_hierarchy_maintained_after_cancel_reconfirm(self):
"""Test that task hierarchy and project assignments are maintained when canceling
and reconfirming a sale order with a templated FSM product."""
self.env.user.groups_id |= self.env.ref(
"account.group_delivery_invoice_address"
)
# Create a task template with subtasks
parent_template = self._generate_task_template(
structure=[2], # Two subtasks
names=["Main Service", "Subtask"],
planned_hours=8,
)
# Create FSM product with template
product = self._generate_product(task_template=parent_template)
# Create and confirm sale order
partner = self._generate_partner()
partner_2 = self._generate_partner(parent=partner, company_type="person")
self.assertEqual(partner_2.commercial_partner_id, partner)
so = self._generate_sale_order(partner=partner)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
# Get initial tasks and verify setup
main_task = sol.task_id
self.assertTrue(main_task, "Main task should be created")
self.assertTrue(main_task.project_id, "Main task should have a project")
subtasks = main_task.child_ids
self.assertEqual(len(subtasks), 2, "Should have created 2 subtasks")
self.assertEqual(so.tasks_count, 1, "Should have only 1 task on confirmation")
# Verify initial task hierarchy
initial_project = main_task.project_id
for subtask in subtasks:
self.assertEqual(
subtask.project_id,
initial_project,
"Subtask should have same project as main task",
)
self.assertFalse(
subtask.sale_order_id, "Subtask should not be linked to sale order"
)
self.assertFalse(
subtask.sale_line_id, "Subtask should not be linked to sale order line"
)
# Store initial names for comparison
initial_subtask_names = subtasks.mapped("name")
original_task_names = (main_task | main_task._get_all_subtasks()).mapped("name")
# Cancel and reconfirm the sale order
so.with_context(disable_cancel_warning=True).action_cancel()
so.action_draft()
so.write({"partner_shipping_id": partner_2.id})
# Get new tasks
new_main_task = sol.task_id
self.assertEqual(
new_main_task, main_task, "New main task should be same as old"
)
new_subtasks = new_main_task.child_ids
new_subtasks._compute_sale_line()
self.assertEqual(
len(new_subtasks), 2, "Should still have 2 subtasks after reconfirmation"
)
self.assertFalse(
new_subtasks.sale_line_id,
"Subtasks should not be linked to Sale Order Line",
)
new_task_names = (new_main_task | new_main_task._get_all_subtasks()).mapped(
"name"
)
self.assertEqual(
new_task_names, original_task_names, "New task names should be the same"
)
# Verify task hierarchy is maintained
self.assertEqual(
new_main_task.project_id,
initial_project,
"New main task should have same project",
)
for subtask in new_subtasks:
self.assertEqual(
subtask.project_id,
initial_project,
"New subtask should maintain same project as main task",
)
self.assertFalse(
subtask.sale_order_id, "New subtask should not be linked to sale order"
)
self.assertFalse(
subtask.sale_line_id,
"New subtask should not be linked to sale order line",
)
# Verify subtask names are maintained
self.assertEqual(
sorted(new_subtasks.mapped("name")),
sorted(initial_subtask_names),
"Subtask names should be maintained after reconfirmation",
)

View file

@ -0,0 +1,48 @@
from odoo.tests import TransactionCase, Form, tagged
@tagged("-at_install", "post_install")
class TestSettings(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.test_partner_co = cls.env["res.partner"].create(
{
"name": "Test Co",
}
)
cls.test_co = cls.env["res.company"].create(
{
"name": "Test Co",
"country_id": cls.env.ref("base.ca").id,
}
)
cls.env.user.company_id = cls.test_co
def test_enabling_separate_time_on_work_orders(self):
wizard = self.env["res.config.settings"].create({})
self.assertFalse(self.test_co.split_time_from_materials_on_service_work_orders)
with Form(wizard) as form:
form.separate_time_on_work_orders = True
self.assertTrue(self.test_co.split_time_from_materials_on_service_work_orders)
def test_disabling_separate_time_on_work_orders(self):
wizard = self.env["res.config.settings"].create({})
self.test_co.split_time_from_materials_on_service_work_orders = True
with Form(wizard) as form:
form.separate_time_on_work_orders = False
self.assertFalse(self.test_co.split_time_from_materials_on_service_work_orders)
def test_enabling_create_default_fsm_visit(self):
wizard = self.env["res.config.settings"].create({})
self.test_co.create_default_fsm_visit = False
with Form(wizard) as form:
form.create_default_fsm_visit = True
self.assertTrue(self.test_co.create_default_fsm_visit)
def test_disabling_create_default_fsm_visit(self):
wizard = self.env["res.config.settings"].create({})
self.test_co.create_default_fsm_visit = True
with Form(wizard) as form:
form.create_default_fsm_visit = False
self.assertFalse(self.test_co.create_default_fsm_visit)

View file

@ -0,0 +1,154 @@
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests.common import tagged, Form
from odoo import Command
@tagged("post_install", "-at_install")
class TaskTest(BemadeFSMBaseTest):
@classmethod
def setUpClass(cls):
# Chose to set up all tests the same way since this code was becoming very
# redundant
super().setUpClass()
cls.user = cls._generate_project_manager_user("Bob", "Bob")
def _generate_so_with_multilevel_task_template(self):
so = self._generate_sale_order()
template = self._generate_task_template(
names=["Parent", "Child", "Grandchild"], structure=[2, 1]
)
product = self._generate_product(task_template=template)
sol = self._generate_sale_order_line(sale_order=so, product=product)
return so, sol
def test_reassigning_assignment_propagating_task_changes_subtasks(self):
so, sol = self._generate_so_with_multilevel_task_template()
so.action_confirm()
task = sol.task_id
task.propagate_assignment = True
task.write(
{
"user_ids": [Command.set([self.user.id])],
"propagate_assignment": True,
}
)
self.assertTrue(
all([t.user_ids == self.user for t in task | task._get_all_subtasks()])
)
def test_reassigning_task_doesnt_propagate_by_default(self):
so, sol = self._generate_so_with_multilevel_task_template()
so.action_confirm()
task = sol.task_id
task.write(
{
"user_ids": [Command.set([self.user.id])],
}
)
self.assertFalse(any([t.user_ids for t in task.child_ids.child_ids]))
def test_unset_propagate_assignment_unsets_for_all_children(self):
so, sol = self._generate_so_with_multilevel_task_template()
so.action_confirm()
task = sol.task_id
# First, set propagation and assign
task.propagate_assignment = True
task.write({"user_ids": [Command.set([self.user.id])]})
# Then, unset propagation for the children and re-set assignment
task.child_ids.write({"propagate_assignment": False})
self.assertFalse(
any([t.propagate_assignment for t in task._get_all_subtasks()])
)
# Then, test that assigning the parent only assigns its children, not its
# grandchildren
task.write({"user_ids": [Command.set([])]})
self.assertTrue(all([not t.user_ids for t in task | task.child_ids]))
self.assertTrue(
all([t.user_ids == self.user for t in task.child_ids.child_ids])
)
def test_task_gets_work_order_contacts_from_sale_order(self):
so, sol = self._generate_so_with_multilevel_task_template()
work_order_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"work_order_contacts": [(6, 0, work_order_contacts.ids)]})
so.action_confirm()
task = sol.task_id
self.assertEqual(task.work_order_contacts, so.work_order_contacts)
# Just a safeguard to make sure we set it properly on the SO
self.assertEqual(len(task.work_order_contacts), 2)
# Make sure all subtasks got the same
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.work_order_contacts, so.work_order_contacts)
def test_task_gets_site_contacts_from_sale_order(self):
so, sol = self._generate_so_with_multilevel_task_template()
site_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"site_contacts": [(6, 0, site_contacts.ids)]})
so.action_confirm()
task = sol.task_id
self.assertEqual(task.site_contacts, so.site_contacts)
# Just a safeguard to make sure we set it properly on the SO
self.assertEqual(len(task.site_contacts), 2)
# Make sure all subtasks got the same
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.site_contacts, so.site_contacts)
def test_task_gets_work_order_contacts_from_parent(self):
so, sol = self._generate_so_with_multilevel_task_template()
work_order_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"work_order_contacts": [(6, 0, work_order_contacts.ids)]})
so.action_confirm()
task = sol.task_id
task.write(
{
"work_order_contacts": [
Command.link(self._generate_partner(parent=so.partner_id).id)
]
}
)
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.work_order_contacts, task.work_order_contacts)
with Form(task) as task_form:
with task_form.child_ids.new() as subtask:
subtask.name = "Subtask 1"
subtask = task.child_ids[-1]
self.assertEqual(subtask.work_order_contacts, task.work_order_contacts)
def test_task_gets_site_contacts_from_parent(self):
so, sol = self._generate_so_with_multilevel_task_template()
site_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"site_contacts": [(6, 0, site_contacts.ids)]})
so.action_confirm()
task = sol.task_id
task.write(
{
"site_contacts": [
Command.link(self._generate_partner(parent=so.partner_id).id)
]
}
)
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.site_contacts, task.site_contacts)
with Form(task) as task_form:
with task_form.child_ids.new() as subtask:
subtask.name = "Subtask 1"
subtask = task.child_ids[-1]
self.assertEqual(subtask.site_contacts, task.site_contacts)

View file

@ -0,0 +1,62 @@
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests import Form
class TestTaskReport(BemadeFSMBaseTest):
def test_split_time_materials_setting(self):
with Form(self.env["res.config.settings"]) as settings:
settings.separate_time_on_work_orders = True
with Form(self.env["res.config.settings"]):
self.assertTrue(settings.separate_time_on_work_orders)
so = self._generate_sale_order()
service_product = self._generate_product()
material_product = self._generate_product(
name="Material Product",
product_type="consu",
service_tracking="no",
)
visit = self._generate_visit(sale_order=so)
self._generate_sale_order_line(sale_order=so, product=service_product)
self._generate_sale_order_line(sale_order=so, product=material_product)
so.action_confirm()
task = visit.task_id
# Add timesheet entry to make the report renderable
self.env["account.analytic.line"].create(
{
"name": "Test timesheet entry",
"task_id": task.id,
"project_id": task.project_id.id,
"unit_amount": 2.0,
"employee_id": self.env["hr.employee"]
.create(
{
"name": "Test Employee",
"user_id": self.env.user.id,
}
)
.id,
}
)
html_content = (
self.env["ir.actions.report"]
._render(
"industry_fsm.worksheet_custom", [task.id]
)[ # pyright: ignore[reportOptionalSubscript]
0
]
.decode("utf-8")
.split("\n")
)
strings_to_find = ["<h2>Materials</h2>", "<span>Material Product</span>"]
for line in strings_to_find:
line_found = False
for html_line in html_content:
if line in html_line:
line_found = True
self.assertTrue(line_found, f"{line} should be in file.")

View file

@ -0,0 +1,80 @@
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests.common import tagged, Form
from odoo.exceptions import MissingError
from odoo.tools import mute_logger
import psycopg2
@tagged("-at_install", "post_install")
class TestTaskTemplate(BemadeFSMBaseTest):
def test_delete_task_template(self):
"""User should never be able to delete a task template used on a product"""
task_template = self._generate_task_template(names=["Template 1"])
self._generate_product(name="Test Product 1", task_template=task_template)
with self.assertRaises(psycopg2.errors.ForeignKeyViolation):
with mute_logger("odoo.sql_db"):
task_template.unlink()
def test_delete_subtask_template(self):
"""Deletion of a child task should be OK even if the parent is on a product.
Children of the deleted subtask should be deleted."""
parent_task = self._generate_task_template(
structure=[2, 1],
names=["Parent Template", "Child Template", "Grandchild Template"],
)
grandchild_task = parent_task.subtasks[0].subtasks[0]
parent_task.subtasks[0].unlink()
# Reading deleted child's name field should be impossible
with self.assertRaises(MissingError):
_ = grandchild_task.name
def test_dissociating_customer_resets_equipment_appropriately(self):
partner1 = self._generate_partner()
partner2 = self._generate_partner()
equipment1 = self._generate_equipment(partner=partner1)
task = self._generate_task_template(customer=partner1, equipment=equipment1)
form = Form(task)
# Switching the partner should trigger on_change that makes sure equipments are
# linked to the new partner
form.customer = partner2
form.save()
self.assertFalse(equipment1 in task.equipment_ids)
def test_child_task_names_are_short_version(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
template = self._generate_task_template(names=["Task"])
product = self._generate_product(task_template=template)
sol1.name = "Short Name 1"
sol2.name = "Short Name 2"
sol3 = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
self.assertEqual(sol1.task_id.name, "Short Name 1")
self.assertEqual(sol2.task_id.name, "Short Name 2")
self.assertEqual(sol3.task_id.name, "Task")
def test_task_creation_directly_from_template(self):
project = self.env.ref("industry_fsm.fsm_project")
template = self._generate_task_template(
names=["Task", "Child", "Grandchild"], structure=[2, 1]
)
task = template.create_task_from_self(project, "My new task")
self.assertEqual(len(task.child_ids), len(template.subtasks))
self.assertEqual(
len(task.child_ids[0].child_ids), len(template.subtasks[0].subtasks)
)
self.assertEqual(
len(task.child_ids[1].child_ids), len(template.subtasks[1].subtasks)
)
self.assertEqual(task.name, "My new task")
self.assertEqual(task.child_ids[0].name, template.subtasks[0].name)
self.assertTrue(
all([t.project_id == project for t in task | task._get_all_subtasks()])
)

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<menuitem
id="project_task_template_menu"
name="Task Templates"
parent="project.menu_main_pm"
action="task_template_act_window"
groups="project.group_project_manager,project.group_project_user"
/>
<menuitem
id="service_task_template_meny"
name="Task Templates"
action="task_template_act_window"
parent="industry_fsm.fsm_menu_root"
groups="project.group_project_manager,industry_fsm.group_fsm_manager"
/>
</odoo>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="product_template_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.product.template.form</field>
<field name="model">product.template</field>
<field
name="inherit_id"
ref="sale_project.product_template_form_view_invoice_policy_inherit_sale_project"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='project_id']" position="after">
<field
name="task_template_id"
invisible="service_tracking not in ('task_global_project', 'task_in_project')"
domain="[('parent', '=', False)]"
/>
</xpath>
</field>
</record>
<!-- BV: Did comment that one cause can not match inherit_id and don't understand it use -->
<!-- <record id="product_search_form_view_inherit_bemade_fsm" model="ir.ui.view">-->
<!-- <field name="name">bemade_fsm.product_search_form_view_inherit_bemade_fsm</field>-->
<!-- <field name="model">product.product</field>-->
<!-- <field name="inherit_id" ref="industry_fsm_sale.product_search_form_view_inherit_fsm_sale"/>-->
<!-- <field name="arch" type="xml">-->
<!-- <xpath expr="//searchpanel//field[@name='categ_id']" position="attributes">-->
<!-- <attribute name="limit">0</attribute>-->
<!-- </xpath>-->
<!-- </field>-->
<!-- </record>-->
</odoo>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" ?>
<odoo>
<record id="partner_equipment_location_view_form" model="ir.ui.view">
<field name="name">partner.equipment.location.view.form</field>
<field name="model">res.partner</field>
<field
name="inherit_id"
ref="fsm_equipment.partner_equipment_location_view_form"
/>
<field name="arch" type="xml">
<page name="equipment" position="attributes">
<attribute name="invisible">site_ids</attribute>
</page>
<page name="equipment" position="after">
<page
name="Service Contacts"
invisible="site_ids">
<field
name="site_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
<field
name="work_order_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
</page>
<field name="is_service_site" invisible="True"/>
<page name="Service Sites" invisible="is_service_site">
<group>
<field name="site_ids">
<list editable="bottom">
<field name="name" widget="res_partner_many2one" />
</list>
</field>
</group>
</page>
</page>
</field>
</record>
<record id="fsm_contacts_view_list" model="ir.ui.view">
<field name="name">bemade_fsm.contacts.list</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="name" />
<field name="email" widget="email" />
<field name="phone" widget="phone" />
<field name="mobile" widget="phone" />
</list>
</field>
</record>
</odoo>

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="sale_order_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.sale_order.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//page[@name='other_information']" position="before">
<page name="field_service" string="Field Service">
<group name="fsm_visits" string="Service Visits">
<field
name="visit_ids"
context="{'list_view_ref': 'bemade_fsm.bemade_fsm_visit_list'}"
/>
</group>
<group name="field_service_info" string="Contacts and Equipment">
<field name="valid_equipment_ids" invisible="1" />
<field
name="summary_equipment_ids"
context="{'default_partner_id': partner_shipping_id,}"
widget="many2many_tags"
groups="account.group_delivery_invoice_address"
/>
<field
name="site_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
<field
name="work_order_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
<field
name="default_equipment_ids"
context="{'default_partner_id': partner_shipping_id,}"
widget="many2many_tags"
groups="account.group_delivery_invoice_address"
/>
</group>
</page>
</xpath>
<xpath expr="//list//field[@name='name']" position="after">
<field name="valid_equipment_ids" invisible="1" />
<field
name="equipment_ids"
widget="many2many_tags"
domain="[('id', 'in', valid_equipment_ids)]"
/>
</xpath>
</field>
</record>
<record id="bemade_fsm_visit_list" model="ir.ui.view">
<field name="name">bemade_fsm.visit.list</field>
<field name="model">bemade_fsm.visit</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="label" />
<field name="approx_date" />
<field name="is_completed" widget="boolean" />
<field name="is_invoiced" widget="boolean" />
</list>
</field>
</record>
</odoo>

View file

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="task_template_form_view" model="ir.ui.view">
<field name="name">bemade_fsm.task_template.form</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<form string="Task Template">
<sheet>
<div class="oe_title">
<label for="name" />
<h1>
<field name="name" placeholder="Title" />
</h1>
</div>
<group>
<group>
<field name="project" />
<field name="assignees" widget="many2many_avatar_user" />
<field name="parent" />
<field name="planned_hours" />
</group>
<group>
<field name="customer" />
<field
name="equipment_ids"
domain="[('partner_id', '=', customer)]"
context="{'list_view_ref': 'fsm_equipment.equipment_view_list'}"
/>
<field name="tags" widget="many2many_tags" />
<field name="company_id" />
</group>
</group>
<notebook>
<page name="description_page" string="Description">
<field
name="description"
type="html"
options="{'collaborative': true}"
/>
</page>
<page name="subtasks_page" string="Subtasks">
<field name="subtasks">
<list editable="bottom">
<field name="sequence" widget="handle" />
<field name="name" />
<field name="customer" />
<field
name="assignees"
widget="many2many_avatar_user"
/>
<button
name="action_open_task"
type="object"
title="View Task"
string="View Task"
class="btn btn-link pull-right"
/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="task_template_list_view" model="ir.ui.view">
<field name="name">project.task_template.list</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<list>
<field name="name" />
<field name="assignees" widget="many2many_avatar_user" />
<field name="project" />
<field name="parent" />
<field name="planned_hours" />
</list>
</field>
</record>
<record id="task_template_search_view" model="ir.ui.view">
<field name="name">project.task_template.search</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<search string="Task Template">
<field name="name" />
<field name="project" />
<field name="assignees" />
<field name="parent" />
<field name="subtasks" />
<field name="planned_hours" />
<group expand="1" string="Group By">
<filter
string="Project"
name="groupby_project"
domain="[]"
context="{'group_by':'project'}"
/>
<filter
string="Parent Task"
name="groupby_parent"
domain="[]"
context="{'group_by':'parent'}"
/>
<filter
string="Customer"
name="groupby_customer"
domain="[]"
context="{'group_by':'customer'}"
/>
</group>
</search>
</field>
</record>
<record id="task_template_act_window" model="ir.actions.act_window">
<field name="name">Task Template</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">project.task.template</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
There are no task templates, click above to create one.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,229 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="bemade_fsm_project_task_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.project_task.form</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.view_task_form2_inherit" />
<field name="priority" eval="8" />
<field name="arch" type="xml">
<xpath expr="//field[@name='name']/.." position="before">
<h1 class="d-flex flex-row justify-content-between">
<field
name="work_order_number"
invisible="work_order_number == False"
/>
</h1>
</xpath>
<xpath expr="//page[@name='extra_info']" position="after">
<page string="Equipment and Contacts" name="equipment_contacts"
translate="True">
<group name="equipment_and_contacts">
<field
name="equipment_ids"
domain="[('partner_id', '=', partner_id)]"
context="{'list_view_ref': 'fsm_equipment.equipment_view_list'}"
/>
<field
name="site_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
<field
name="work_order_contacts"
context="{'list_view_ref': 'bemade_fsm.fsm_contacts_view_list'}"
/>
</group>
</page>
</xpath>
<button
name="action_fsm_validate"
class='btn-primary'
position="attributes"
>
<attribute name="string">Mark as Delivered</attribute>
<attribute name="groups">industry_fsm.group_fsm_manager</attribute>
</button>
<button
name="action_fsm_validate"
class='btn-secondary'
position="attributes"
>
<attribute name="string">Mark as Delivered</attribute>
<attribute name="groups">industry_fsm.group_fsm_manager</attribute>
</button>
</field>
</record>
<record id="view_task_form2_inherit" model="ir.ui.view">
<field name="inherit_id" ref="project.view_task_form2" />
<field name="model">project.task</field>
<field name="name">bemade_fsm.project_task.form2</field>
<field name="arch" type="xml">
<xpath
expr="//field[@name='child_ids']/list//field[@name='name']"
position="after"
>
<field name="description" string="Description/Comments" />
</xpath>
<field name="user_ids" position="after">
<field name="propagate_assignment" />
</field>
</field>
</record>
<!-- Add parent_id = false to domain for My Tasks, All Tasks: To Schedule, All Tasks and To Invoice-->
<record id="industry_fsm.project_task_action_fsm" model="ir.actions.act_window">
<field name="context">{'search_default_is_parent_task': True}</field>
</record>
<record id="project_task_view_list_fsm_inherit" model="ir.ui.view">
<field name="name">project.task.view.list.fsm.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.project_task_view_list_fsm" />
<field name="arch" type="xml">
<list position="attributes">
<attribute name="js_class">project_list</attribute>
</list>
<field name="name" position="before">
<field name="work_order_number" optional="show" />
</field>
<field name="company_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="worksheet_template_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="project_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="progress" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="activity_ids" position="attributes">
<attribute name="optional">hide</attribute>
</field>
</field>
</record>
<record id="industry_fsm.project_task_action_fsm_map" model="ir.actions.act_window">
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm.project_task_action_to_schedule_fsm"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_all_fsm" model="ir.actions.act_window">
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm_sale.project_task_action_to_invoice_fsm"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False),
('invoice_status', '=', 'to invoice')]
</field>
</record>
<!-- Add parent_id = false to domain for planning actions as well -->
<record
id="industry_fsm.project_task_action_fsm_planning_groupby_user"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm.project_task_action_fsm_planning_groupby_project"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm_report.project_task_action_fsm_planning_groupby_worksheet"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<!-- <record id="project_task_view_calendar_fsm" model="ir.ui.view">-->
<!-- <field name="name">bemade_fsm.project_task_view_calendar_fsm</field>-->
<!-- <field name="inherit_id" ref="industry_fsm.project_task_view_calendar_fsm"/>-->
<!-- <field name="model">project.task</field>-->
<!-- <field name="arch" type="xml">-->
<!-- <field name="user_ids" position="attributes">-->
<!-- <attribute name="filters">1</attribute>-->
<!-- <attribute name="color">user_ids</attribute>-->
<!-- </field>-->
<!-- </field>-->
<!-- </record>-->
<record id="project_task_view_calendar_fsm_no_worksheet" model="ir.ui.view">
<field name="name">bemade_fsm.project_task_view_calendar_no_worksheet</field>
<field
name="inherit_id"
ref="industry_fsm_report.project_task_view_calendar_fsm_worksheet"
/>
<field name="model">project.task</field>
<field name="priority" eval="100" />
<field name="arch" type="xml">
<xpath expr="//field[@name='worksheet_template_id']" position="replace" />
<xpath expr="//calendar" position="attributes">
<attribute name="color">user_ids</attribute>
</xpath>
</field>
</record>
<record id="project_task_view_search_fsm_inherit" model="ir.ui.view">
<field name="name">project.task.view.search.fsm.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.project_task_view_search_fsm" />
<field name="arch" type="xml">
<filter name="schedule" position="attributes">
<attribute name="domain">
[
'&amp;',
('fsm_done', '=', False),
'|',
('user_ids', '=', False),
'&amp;',
('planned_date_start', '=', False),
('date_deadline', '=', False),
]
</attribute>
</filter>
<filter name="my_tasks" position="after">
<filter
name="is_parent_task"
string="Parent Task"
domain="[('parent_id', '=', False)]"
/>
</filter>
</field>
</record>
</odoo>

View file

@ -0,0 +1,2 @@
from . import new_task_from_template
from . import res_config_settings

View file

@ -0,0 +1,57 @@
from odoo import models, fields
class NewTaskFromTemplateWizard(models.TransientModel):
_name = "project.task.from.template.wizard"
_description = "Create Task from Template Wizard"
project_id = fields.Many2one(
comodel_name="project.project",
string="Project",
help="The project the new task should be created in.",
required=True,
)
task_template_id = fields.Many2one(
comodel_name="project.task.template",
string="Task Template",
help="The template to use when creating the new task.",
required=True,
)
new_task_title = fields.Char(
help=(
"The title (name) for the newly created task. If left blank, the name of"
" the template will be used."
),
)
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self.env.context.get("active_id", False)
active_model = self.env.context.get("active_model", False)
if not active_model:
params = self.env.context.get("params", False)
active_model = params and params.get("model", False)
if (
active_model == "project.task.template"
and active_id
and "task_template_id" in fields_list
):
res.update({"task_template_id": active_id})
if active_model == "project.task" and "project_id" in fields_list:
res.update({"project_id": self.env.ref("industry_fsm.fsm_project").id})
return res
def action_create_task_from_template(self):
self.ensure_one()
task = self.task_template_id.create_task_from_self(
self.project_id, self.new_task_title
)
return {
"type": "ir.actions.act_window",
"res_model": "project.task",
"res_id": task.id,
"view_mode": "form",
"target": "current",
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="project_task_from_template_wizard_view_form" model="ir.ui.view">
<field name="name">project.task.from.template.wizard.view.form</field>
<field name="model">project.task.from.template.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="task_template_id"/>
<field name="new_task_title"/>
<field name="project_id"/>
</group>
</sheet>
<footer>
<button class='btn btn-primary' name="action_create_task_from_template" type="object" string="Create Task"/>
<button class='btn btn-secondary' special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="action_new_task_from_template">
<field name="name">New Task from Template</field>
<field name="res_model">project.task.from.template.wizard</field>
<field name="binding_model_id" ref="bemade_fsm.model_project_task_template"/>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View file

@ -0,0 +1,22 @@
from odoo import models, fields
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
company_id = fields.Many2one(
comodel_name="res.company",
default=lambda self: self.env.company or self.env.user.company_id,
)
separate_time_on_work_orders = fields.Boolean(
string="Separate Time from Materials on Work Order",
related="company_id.split_time_from_materials_on_service_work_orders",
readonly=False,
)
create_default_fsm_visit = fields.Boolean(
string="Create Default Visit for FSM Sales Orders",
related="company_id.create_default_fsm_visit",
readonly=False,
)

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.bemade.fsm</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="industry_fsm.res_config_settings_view_form"/>
<field name="arch" type="xml">
<setting name="module_industry_fsm_report" position="after">
<setting
name="separate_time_on_work_orders"
help="Separate blocks for materials and time on work order reports."
company_dependent="1">
<field name="separate_time_on_work_orders"/>
</setting>
<setting
name="create_default_fsm_visit"
help="Create a default FSM visit if a service SO doesn't already contain one at confirmation."
company_dependent="1">
<field name="create_default_fsm_visit"/>
</setting>
</setting>
</field>
</record>
</odoo>