Compare commits

...

14 commits

Author SHA1 Message Date
Marc Durepos
48410382b2 [REL] bemade_fsm 15.0.0.3
\# New Features

     - #67 - Add the ability to specify visit information on service SOs
     - #55 - Technician marking a task done no longer moves it to a
       "delivered" state.
     - #51 - Configure FSM project stages via the module data
     - #54 - Add SO number, customer name and SO line name to the calendar
       view
     - #50 - Add equipment field to task templates

    \# Bug Fixes

     - #5 - Marking subtasks done tried to create a new SO and confirm it.
       This no longer tries to create a new SO.
     - #53 - Site contacts not being auto-filled on sales order after
       selecting the right delivery address. Now made automatic as intended.
2023-07-17 09:54:16 -04:00
Marc Durepos
a923b3c892 bemade_fsm: Convert task_template.equipment_id to a Many2many field. Fixes #58. 2023-07-17 09:35:11 -04:00
Marc Durepos
cd144953c5 bemade_fsm: test refactoring complete (all passing). 2023-07-14 21:26:59 -04:00
Marc Durepos
7a573d58ed bemade_fsm: major refactoring of unit tests completed. Integration tests need rework. 2023-07-14 13:39:30 -04:00
Marc Durepos
804ce5b599 bemade_fsm: major refactoring of tests in progress (broken). 2023-07-13 21:42:16 -04:00
Marc Durepos
cf3fccfa32 bemade_fsm: add visits to sale orders. Fixes #66. 2023-07-13 19:30:27 -04:00
Marc Durepos
e1a923466c bemade_fsm: added a test for contact setting on SO shipping address change. Fixes #53. 2023-07-13 19:28:28 -04:00
Marc Durepos
b0c87b81d1 bemade_fsm: add equipment to Task Templates & flow down to tasks on SO. Fixes #50 2023-07-11 13:15:37 -04:00
Marc Durepos
b90b98e2b2 bemade_fsm: added test for setting proper contacts on SO based on shipping address. 2023-07-11 12:31:05 -04:00
Marc Durepos
fb83435c89 bemade_fsm: Updating SO delivery address correctly changes site contacts, work order recipients and equipment. Fixes #53. 2023-07-10 14:54:03 -04:00
Marc Durepos
8bca6a6150 Updated the naming of tasks for FSM. Fixes #54. 2023-07-05 16:16:29 -04:00
Marc Durepos
0eada63857 bemade_fsm: Added default project stage data re #51. 2023-07-05 14:46:38 -04:00
Marc Durepos
403d6c0ee7 bemade_fsm: Renamed button Mark Done on FSM tasks to Mark as Delivered. Wrote tests to address manager vs user cases for the button. Re #55. 2023-07-05 13:48:57 -04:00
Marc Durepos
a216cc95d6 bemade_fsm: Added logic to prevent child FSM tasks from trying to create sales orders. Fixes #5. 2023-07-05 09:57:04 -04:00
24 changed files with 789 additions and 350 deletions

View file

@ -20,7 +20,7 @@
######################################################################################## ########################################################################################
{ {
'name': 'Improved Field Service Management', 'name': 'Improved Field Service Management',
'version': '15.0.0.2.0', 'version': '15.0.0.3.0',
'summary': 'Adds functionality necessary for managing field service operations at Durpro.', 'summary': 'Adds functionality necessary for managing field service operations at Durpro.',
'description': 'Adds functionality necessary for managing field service operations at Durpro.', 'description': 'Adds functionality necessary for managing field service operations at Durpro.',
'category': 'Services/Field Service', 'category': 'Services/Field Service',
@ -32,25 +32,27 @@
'sale', 'sale',
'sale_project', 'sale_project',
'sale_stock', 'sale_stock',
'industry_fsm', 'industry_fsm_stock',
'industry_fsm_sale',
'industry_fsm_report', 'industry_fsm_report',
'bemade_partner_root_ancestor', 'bemade_partner_root_ancestor',
'mail', 'mail',
], ],
'data': ['views/task_template_views.xml', 'data': [
'views/equipment.xml', 'data/fsm_data.xml',
'security/ir.model.access.csv', 'views/task_template_views.xml',
'views/product_views.xml', 'views/equipment.xml',
'views/res_partner.xml', 'security/ir.model.access.csv',
'views/menus.xml', 'views/product_views.xml',
'views/task_views.xml', 'views/res_partner.xml',
'views/sale_order_views.xml', 'views/menus.xml',
], 'views/task_views.xml',
'views/sale_order_views.xml',
],
'assets': { 'assets': {
'web.assets_tests': [ 'web.assets_tests': [
'bemade_fsm/static/tests/tours/task_template_tour.js', 'bemade_fsm/static/tests/tours/task_template_tour.js',
'bemade_fsm/static/tests/tours/equipment_tour.js', 'bemade_fsm/static/tests/tours/equipment_tour.js',
'bemade_fsm/static/tests/tours/sale_order_tour.js',
], ],
}, },
'installable': True, 'installable': True,

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record id="planning_project_stage_work_completed" model="project.task.type">
<field name="sequence">15</field>
<field name="name">Work Executed</field>
<field name="legend_blocked">Blocked</field>
<field name="fold" eval="False"/>
<field name="is_closed" eval="False"/>
</record>
<!-- Since the Field Service project has no_update="1" we use a workaround here -->
<function model="ir.model.data" name="write">
<function name="search" model="ir.model.data">
<value eval="[('name', '=', 'fsm_project'), ('module', '=', 'industry_fsm'), ('model', '=', 'project.project')]"/>
</function>
<value eval="{'noupdate': False}"/>
</function>
<record id="industry_fsm.fsm_project" model="project.project">
<field name="type_ids"
eval="[(4, ref('industry_fsm.planning_project_stage_0')), (4, ref('industry_fsm.planning_project_stage_1')), (4, ref('industry_fsm.planning_project_stage_2')), (4, ref('planning_project_stage_work_completed')), (4, ref('industry_fsm.planning_project_stage_3')), (4, ref('industry_fsm.planning_project_stage_4'))]"/>
<field name="allow_subtasks"
eval="True"/>
</record>
<function model="ir.model.data" name="write">
<function name="search" model="ir.model.data">
<value eval="[('name', '=', 'fsm_project'), ('module', '=', 'industry_fsm'), ('model', '=', 'project.project')]"/>
</function>
<value eval="{'noupdate': True}"/>
</function>
</data>
</odoo>

View file

@ -4,3 +4,4 @@ from . import sale_order
from . import equipment from . import equipment
from . import task from . import task
from . import res_partner from . import res_partner
from . import fsm_visit

View file

@ -35,13 +35,12 @@ class Equipment(models.Model):
tag_ids = fields.Many2many('bemade_fsm.equipment.tag', tag_ids = fields.Many2many('bemade_fsm.equipment.tag',
string='Application', string='Application',
tracking=True,
help="Classify and analyze your equipment categories like: Boiler, Laboratory, " help="Classify and analyze your equipment categories like: Boiler, Laboratory, "
"Waste water, Pure water") "Waste water, Pure water")
partner_id = fields.Many2one('res.partner', partner_id = fields.Many2one('res.partner',
string="Owner", string="Owner",
compute="_compute_partner", compute="_compute_partner",
search="_search_partner",) search="_search_partner", )
description = fields.Text(string="Description", description = fields.Text(string="Description",
tracking=True) tracking=True)
@ -53,9 +52,11 @@ class Equipment(models.Model):
location_notes = fields.Text(string="Physical Location Notes", location_notes = fields.Text(string="Physical Location Notes",
tracking=True) tracking=True)
task_ids = fields.One2many(comodel_name='project.task', task_ids = fields.Many2many(comodel_name='project.task',
inverse_name='equipment_id', relation="bemade_fsm_task_equipment_rel",
string='Interventions') column1="equipment_id",
column2="task_id",
string='Interventions')
@api.depends('partner_location_id') @api.depends('partner_location_id')
def _compute_partner(self): def _compute_partner(self):

View file

@ -0,0 +1,37 @@
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)
approx_date = fields.Date(string='Approximate Date')
so_section_id = fields.Many2one(comodel_name="sale.order.line",
string="Sale Order Section",
help="The section on the sale order that represents the labour and parts for "
"this visit")
sale_order_id = fields.Many2one(comodel_name="sale.order",
string="Sales Order",
required=True)
is_completed = fields.Boolean(string="Completed",
related="so_section_id.is_fully_delivered")
is_invoiced = fields.Boolean(string="Invoiced",
related="so_section_id.is_fully_delivered_and_invoiced")
def _compute_is_invoiced(self):
self.is_invoiced = False
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for i, rec in enumerate(recs):
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]['label'],
})
return recs

View file

@ -1,4 +1,5 @@
from odoo import fields, models, api, _, Command from odoo import fields, models, api, _, Command
from odoo.exceptions import ValidationError
class SaleOrder(models.Model): class SaleOrder(models.Model):
@ -6,7 +7,10 @@ class SaleOrder(models.Model):
equipment_id = fields.Many2one(comodel_name="bemade_fsm.equipment", equipment_id = fields.Many2one(comodel_name="bemade_fsm.equipment",
string="Equipment to Service", string="Equipment to Service",
tracking=True) tracking=True,
compute="_compute_equipment",
inverse="_inverse_equipment",
store=True)
site_contacts = fields.Many2many(comodel_name='res.partner', site_contacts = fields.Many2many(comodel_name='res.partner',
relation="sale_order_site_contacts_rel", relation="sale_order_site_contacts_rel",
@ -21,8 +25,17 @@ class SaleOrder(models.Model):
inverse='_inverse_default_contacts', inverse='_inverse_default_contacts',
string='Work Order Recipients', string='Work Order Recipients',
store=True) store=True)
visit_ids = fields.One2many(comodel_name='bemade_fsm.visit',
inverse_name="sale_order_id",
readonly=False)
@api.depends('partner_id') @api.onchange('partner_shipping_id')
def _onchange_partner_shipping_id(self):
super()._onchange_partner_shipping_id()
self._compute_equipment()
self._compute_default_contacts()
@api.depends('partner_shipping_id')
def _compute_default_contacts(self): def _compute_default_contacts(self):
for rec in self: for rec in self:
rec.site_contacts = rec.partner_shipping_id.site_contacts rec.site_contacts = rec.partner_shipping_id.site_contacts
@ -31,10 +44,10 @@ class SaleOrder(models.Model):
def _inverse_default_contacts(self): def _inverse_default_contacts(self):
pass pass
@api.depends('partner_id') @api.depends('partner_shipping_id')
def _compute_equipment(self): def _compute_equipment(self):
for rec in self: for rec in self:
rec.equipment_ids = self.partner_shipping_id.equipment_ids if len( rec.equipment_id = self.partner_shipping_id.equipment_ids if len(
self.partner_shipping_id.equipment_ids) <= 1 else False self.partner_shipping_id.equipment_ids) <= 1 else False
def _inverse_equipment(self): def _inverse_equipment(self):
@ -44,6 +57,18 @@ class SaleOrder(models.Model):
class SaleOrderLine(models.Model): class SaleOrderLine(models.Model):
_inherit = 'sale.order.line' _inherit = 'sale.order.line'
visit_id = fields.One2many(comodel_name="bemade_fsm.visit",
inverse_name="so_section_id",
string="Visit")
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.")
def _timesheet_create_task(self, project): def _timesheet_create_task(self, project):
""" Generate task for the given so line, and link it. """ Generate task for the given so line, and link it.
:param project: record of project.project in which the task should be created :param project: record of project.project in which the task should be created
@ -69,6 +94,10 @@ class SaleOrderLine(models.Model):
task.child_ids.write({'sale_order_id': None, 'sale_line_id': None, }) task.child_ids.write({'sale_order_id': None, 'sale_line_id': None, })
return task return task
def _generate_task_name(template=None):
template_name = template and template.name
return f"{self.order_id.name}: {self.order_id.partner_shipping_id.name} - {self.name} ({template_name})"
def _timesheet_create_task_prepare_values_from_template(project, template, parent): 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. """ Copies the values from a project.task.template over to the set of values used to create a project.task.
@ -79,12 +108,14 @@ class SaleOrderLine(models.Model):
:param parent: project.task to set as the parent to this task. :param parent: project.task to set as the parent to this task.
""" """
vals = self._timesheet_create_task_prepare_values(project) vals = self._timesheet_create_task_prepare_values(project)
vals['name'] = f"{vals['name']} ({template.name})" if not parent else template.name vals['name'] = _generate_task_name(template) if not parent else template.name
vals['description'] = template.description or vals['description'] vals['description'] = template.description or vals['description']
vals['parent_id'] = parent and parent.id vals['parent_id'] = parent and parent.id
vals['user_ids'] = template.assignees.ids vals['user_ids'] = template.assignees.ids
vals['tag_ids'] = template.tags.ids vals['tag_ids'] = template.tags.ids
vals['planned_hours'] = template.planned_hours vals['planned_hours'] = template.planned_hours
if template.equipment_ids:
vals['equipment_ids'] = [Command.set(template.equipment_ids.ids)]
return vals return vals
tmpl = self.product_id.task_template_id tmpl = self.product_id.task_template_id
@ -98,5 +129,38 @@ class SaleOrderLine(models.Model):
"This task has been created from: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%s)") % ( "This task has been created from: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%s)") % (
self.order_id.id, self.order_id.name, self.product_id.name) self.order_id.id, self.order_id.name, self.product_id.name)
task.message_post(body=task_msg) task.message_post(body=task_msg)
task.equipment_id = self.order_id.equipment_id if not task.equipment_ids and self.order_id.equipment_id:
task.write({'equipment_ids': [Command.set([self.order_id.equipment_id.id])]})
task.name = _generate_task_name(tmpl)
return task 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):
self.is_fully_delivered = self._iterate_items_compute_bool(lambda l: l.qty_to_deliver == 0)
@api.depends('is_fully_delivered')
def _compute_is_fully_invoiced(self):
if not self.is_fully_delivered:
self.is_fully_delivered_and_invoiced = False
return
self.is_fully_delivered_and_invoiced = self._iterate_items_compute_bool(lambda l: l.qty_to_invoice == 0)
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

View file

@ -5,7 +5,12 @@ from odoo.exceptions import ValidationError
class Task(models.Model): class Task(models.Model):
_inherit = "project.task" _inherit = "project.task"
equipment_id = fields.Many2one("bemade_fsm.equipment", string="Equipment to Service", tracking=True) equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment",
relation="bemade_fsm_task_equipment_rel",
column1="task_id",
column2="equipment_id",
string="Equipment to Service",
tracking=True,)
work_order_contacts = fields.Many2many(comodel_name="res.partner", work_order_contacts = fields.Many2many(comodel_name="res.partner",
relation="task_work_order_contact_rel", relation="task_work_order_contact_rel",
@ -23,6 +28,10 @@ class Task(models.Model):
inverse="_inverse_contacts", inverse="_inverse_contacts",
store=True) store=True)
# Override related field to make it return false if this is an FSM subtask
allow_billable = fields.Boolean(string="Can be billed",
related=False,
compute="_compute_allow_billable",)
@api.depends('sale_line_id.order_id.site_contacts', 'sale_line_id.order_id.work_order_contacts') @api.depends('sale_line_id.order_id.site_contacts', 'sale_line_id.order_id.work_order_contacts')
def _compute_contacts(self): def _compute_contacts(self):
""" The work order contacts and site contacts for a given task are taken from the sale order if the task """ The work order contacts and site contacts for a given task are taken from the sale order if the task
@ -45,3 +54,11 @@ class Task(models.Model):
'work_order_contacts': [Command.set(rec.work_order_contacts.ids)], 'work_order_contacts': [Command.set(rec.work_order_contacts.ids)],
'site_contacts': [Command.set(rec.site_contacts.ids)], 'site_contacts': [Command.set(rec.site_contacts.ids)],
}) })
@api.depends('parent_id', 'project_id')
def _compute_allow_billable(self):
for rec in self:
if rec.parent_id and rec.project_id and rec.project_id.is_fsm:
rec.allow_billable = False
else:
rec.allow_billable = rec.project_id.allow_billable

View file

@ -1,24 +1,34 @@
from odoo import models, fields, api, _ from odoo import models, fields, api, _, Command
class TaskTemplate(models.Model): class TaskTemplate(models.Model):
_name = 'project.task.template' _name = 'project.task.template'
_description = "Template for new project tasks" _description = "Template for new project tasks"
@api.model @api.model
def _current_company(self): def _current_company(self):
return self.env.company return self.env.company
name = fields.Char(string="Task Title", required=True) name = fields.Char(string="Task Title", required=True)
description = fields.Html(string="Description") description = fields.Html(string="Description")
assignees = fields.Many2many("res.users", string="Default Assignees", help="Employees assigned to tasks created from this template.") assignees = fields.Many2many("res.users", string="Default Assignees",
customer = fields.Many2one("res.partner", string="Default Customer", help="Default customer for tasks created from this template.") help="Employees assigned to tasks created from this template.")
project = fields.Many2one("project.project", string="Default Project", help="Default project for tasks created from this template.") customer = fields.Many2one("res.partner", string="Default Customer",
tags = fields.Many2many("project.tags", string="Default Tags", help="Default tags for tasks created from this template.") help="Default customer for tasks created from this template.")
project = fields.Many2one("project.project", string="Default Project",
help="Default project for tasks created from this template.")
tags = fields.Many2many("project.tags", string="Default Tags",
help="Default tags for tasks created from this template.")
parent = fields.Many2one("project.task.template", string="Parent Task Template", ondelete='cascade') parent = fields.Many2one("project.task.template", string="Parent Task Template", ondelete='cascade')
subtasks = fields.One2many("project.task.template", inverse_name="parent", string="Subtask Templates") subtasks = fields.One2many("project.task.template", inverse_name="parent", string="Subtask Templates")
sequence = fields.Integer(string="Sequence") sequence = fields.Integer(string="Sequence")
company_id = fields.Many2one("res.company", string="Company", index=1, default=_current_company) company_id = fields.Many2one("res.company", string="Company", index=1, default=_current_company)
planned_hours = fields.Float("Initially Planned Hours") planned_hours = fields.Float("Initially Planned Hours")
equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment",
relation="bemade_fsm_task_template_equipment_rel",
column1="task_template_id",
column2="equipment_id",
string="Equipment to Service",)
def action_open_task(self): def action_open_task(self):
return { return {
@ -29,4 +39,8 @@ class TaskTemplate(models.Model):
'context': self._context '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_location_id == rec.customer]
rec.write({'equipment_ids': [Command.set(new_equipment_ids)]})

View file

@ -3,4 +3,5 @@ access_bemade_fsm_task_template,bemade_fsm_task_template,model_project_task_temp
access_bemade_fsm_task_template,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1 access_bemade_fsm_task_template,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_equipment,bemade_fsm_equipment,model_bemade_fsm_equipment,base.group_user,1,1,1,1 access_bemade_fsm_equipment,bemade_fsm_equipment,model_bemade_fsm_equipment,base.group_user,1,1,1,1
access_bemade_fsm_equipment_tag,bemade_fsm_equipment_tag,model_bemade_fsm_equipment_tag,base.group_user,1,1,1,1 access_bemade_fsm_equipment_tag,bemade_fsm_equipment_tag,model_bemade_fsm_equipment_tag,base.group_user,1,1,1,1
access_bemade_fsm_equipment_type,access_bemade_fsm_equipment_type,model_bemade_fsm_equipment_type,base.group_user,1,0,0,0 access_bemade_fsm_equipment_type,access_bemade_fsm_equipment_type,model_bemade_fsm_equipment_type,base.group_user,1,0,0,0
access_bemade_fsm_visit,access_bemade_fsm_visit,model_bemade_fsm_visit,base.group_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_bemade_fsm_task_template bemade_fsm_task_template model_project_task_template project.group_project_user 1 1 1 1
4 access_bemade_fsm_equipment bemade_fsm_equipment model_bemade_fsm_equipment base.group_user 1 1 1 1
5 access_bemade_fsm_equipment_tag bemade_fsm_equipment_tag model_bemade_fsm_equipment_tag base.group_user 1 1 1 1
6 access_bemade_fsm_equipment_type access_bemade_fsm_equipment_type model_bemade_fsm_equipment_type base.group_user 1 0 0 0
7 access_bemade_fsm_visit access_bemade_fsm_visit model_bemade_fsm_visit base.group_user 1 1 1 1

View file

@ -2,7 +2,7 @@
import tour from 'web_tour.tour'; import tour from 'web_tour.tour';
const TEST_COMPANY = "Test Partner Company"; const TEST_COMPANY = "Test Partner";
const TEST_EQPT1 = "Test Equipment 1"; const TEST_EQPT1 = "Test Equipment 1";
const TEST_EQPT2 = "Test Equipment 2"; const TEST_EQPT2 = "Test Equipment 2";
tour.register('equipment_base_tour', { tour.register('equipment_base_tour', {
@ -21,7 +21,6 @@ tour.register('equipment_base_tour', {
}, { }, {
content: 'Click the create button', content: 'Click the create button',
trigger: '.o_list_button_add', trigger: '.o_list_button_add',
extra_trigger: 'li.breadcrumb-item.active:has(span:contains(Equipment))',
}, { }, {
content: 'Add a tag', content: 'Add a tag',
trigger: 'input[name="pid_tag"]', trigger: 'input[name="pid_tag"]',
@ -33,13 +32,13 @@ tour.register('equipment_base_tour', {
}, { }, {
content: 'Set the partner', content: 'Set the partner',
trigger: 'div[name="partner_location_id"] div div input', trigger: 'div[name="partner_location_id"] div div input',
run: 'text Test Partner Company', run: `text ${TEST_COMPANY}`,
}, { }, {
content: 'Click the partner in the dropdown', content: 'Click the partner in the dropdown',
trigger: `li a.dropdown-item:contains(${TEST_COMPANY})`, trigger: `li a.dropdown-item:contains(${TEST_COMPANY})`,
}, { }, {
content: 'Save equipment', content: 'Save equipment',
trigger: 'button.o_form_button_save', trigger: 'button.o_list_button_save',
}, { }, {
/* Navigate to the client and make sure that there are two equipments saved (one from the Python test case) */ /* Navigate to the client and make sure that there are two equipments saved (one from the Python test case) */
content: 'Navigate to the Clients submenu', content: 'Navigate to the Clients submenu',
@ -65,7 +64,6 @@ tour.register('equipment_base_tour', {
extra_trigger: `h1 span.o_field_partner_autocomplete[name="name"]:contains(${TEST_COMPANY})`, extra_trigger: `h1 span.o_field_partner_autocomplete[name="name"]:contains(${TEST_COMPANY})`,
}, { }, {
content: 'Make sure we have a first test equipment', content: 'Make sure we have a first test equipment',
/*trigger: `div[name="equipment_ids"]:has(td:contains(${TEST_EQPT1}))`,*/
trigger: `td:contains(${TEST_EQPT1})`, trigger: `td:contains(${TEST_EQPT1})`,
run: function () { run: function () {
}, },

View file

@ -0,0 +1,40 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
const SO_NAME = "TEST ORDER 2"
const PRODUCT_NAME = "Test Product 3"
tour.register('sale_order_tour', {
test: true,
url: '/web',
},
[tour.stepUtils.showAppsMenuItem(), {
content: 'Navigate to the Service menu',
trigger: '.o_app[data-menu-xmlid="sale.sale_menu_root"]',
}, {
content: 'Search for the sales order',
trigger: 'input.o_searchview_input',
run: `text ${SO_NAME}`,
}, {
content: 'Validate Search',
trigger: '.o_menu_item.o_selection_focus',
run: 'click',
}, {
content: 'Open the test order',
trigger: `.o_data_cell[name="client_order_ref"]:contains(${SO_NAME})`,
}, {
content: 'Click the view tasks button',
trigger: 'button[name="action_view_task"]',
}, /*{
content: 'Click the first task',
trigger: `div.o_kanban_record:has(span:contains(${PRODUCT_NAME}))`,
},*/ {
content: 'Click on the ready to invoice button',
trigger: 'button[name="action_fsm_validate"]',
extra_trigger: `li.breadcrumb-item.active:has(span:contains(${PRODUCT_NAME}))`
}, {
content: 'View the SO',
trigger: 'button[name="action_view_so"]',
// extra_trigger: 'button[title="Current state"]:contains(Done)',
}
]);

View file

@ -38,7 +38,7 @@ tour.register('task_equipment_tour', {
trigger: `li a.dropdown-item:contains(${TEST_COMPANY})`, trigger: `li a.dropdown-item:contains(${TEST_COMPANY})`,
}, { }, {
content: 'Save equipment', content: 'Save equipment',
trigger: 'button.o_form_button_save', trigger: 'button.o_list_button_save',
}, { }, {
/* Navigate to the client and make sure that there are two equipments saved (one from the Python test case) */ /* Navigate to the client and make sure that there are two equipments saved (one from the Python test case) */
content: 'Navigate to the Clients submenu', content: 'Navigate to the Clients submenu',

View file

@ -3,4 +3,4 @@ from . import test_task_template
from . import test_sale_order from . import test_sale_order
from . import test_equipment from . import test_equipment
from . import test_fsm_contact_setting from . import test_fsm_contact_setting
from . import test_so_task_contacts from . import test_fsm_visit

View file

@ -3,37 +3,163 @@ from odoo import Command
@tagged("-at_install", "post_install") @tagged("-at_install", "post_install")
class FSMManagerUserTransactionCase(TransactionCase): class BemadeFSMBaseTest(TransactionCase):
@classmethod @classmethod
def setUpClass(cls): def _generate_project_manager_user(cls, name, login):
super().setUpClass() 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_employee = cls.env.ref('base.group_user')
user_group_project_user = cls.env.ref('project.group_project_user') user_group_project_user = cls.env.ref('project.group_project_user')
user_group_project_manager = cls.env.ref('project.group_project_manager')
user_group_fsm_user = cls.env.ref('industry_fsm.group_fsm_user') user_group_fsm_user = cls.env.ref('industry_fsm.group_fsm_user')
user_group_fsm_manager = cls.env.ref('industry_fsm.group_fsm_manager')
user_group_sales_manager = cls.env.ref('sales_team.group_sale_manager')
user_group_sales_user = cls.env.ref('sales_team.group_sale_salesman') 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_product_customer = cls.env.ref('customer_product_code.group_product_customer_code_user') user_product_customer = cls.env.ref('customer_product_code.group_product_customer_code_user')
group_ids = [user_group_employee.id, group_ids = [user_group_employee.id,
user_group_project_user.id, user_group_project_user.id,
user_group_project_manager.id,
user_group_fsm_user.id, user_group_fsm_user.id,
user_group_fsm_manager.id, user_group_sales_manager.id,
user_group_sales_user.id, user_group_sales_user.id, ]
user_group_sales_manager.id, ]
if user_product_customer: if user_product_customer:
group_ids.append(user_product_customer.id) group_ids.append(user_product_customer.id)
return group_ids
# Test user with project access rights for the various tests @classmethod
Users = cls.env['res.users'].with_context({'no_reset_password': True}) def _generate_partner(cls, name: str = 'Test Company', company_type: str = 'company', parent=None,
cls.user = Users.create({ location_type='other'):
'name': 'Project Manager', """ Generates a partner with basic address filled in.
'login': 'misterpm',
'password': 'misterpm', :param name: The partner's name.
'email': 'mrpm@testco.com', :param company_type: The type of partner, either 'company' or 'person' are accepted."""
'signature': 'Mr. PM', return cls.env['res.partner'].create({
'groups_id': [Command.set(group_ids)], '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):
partner = partner or cls._generate_partner()
return cls.env['sale.order'].create({
'partner_id': partner.id,
'client_order_ref': client_order_ref,
'equipment_id': equipment and equipment.id or False,
})
@classmethod
def _generate_sale_order_line(cls, sale_order, product, qty=1.0, uom=None, price=100.0, tax_id=False):
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['bemade_fsm.equipment'].create({
'name': name,
'partner_location_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,
'project_id': service_tracking in ('task_global_project', 'project_only') and project.id or False,
'project_template_id': service_tracking == 'task_in_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_subtasks': 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):
""" 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 [])],
})
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

View file

@ -1,53 +1,36 @@
from odoo.tests.common import HttpCase, tagged from odoo.tests.common import HttpCase, tagged
from .test_bemade_fsm_common import FSMManagerUserTransactionCase from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo import Command from odoo import Command
from odoo.exceptions import MissingError from odoo.exceptions import MissingError
@tagged("-at_install", "post_install") @tagged("-at_install", "post_install")
class TestEquipmentCommon(FSMManagerUserTransactionCase): class TestEquipment(BemadeFSMBaseTest):
def test_crud(self):
partner_company = self._generate_partner()
partner_contact = self._generate_partner('Site Contact', 'person', partner_company)
equipment = self._generate_equipment('Test Equipment 1', partner_company)
# Just make sure the basic ORM stuff is OK
self.assertTrue(equipment in partner_company.equipment_ids)
self.assertTrue(len(partner_company.equipment_ids) == 1)
# Delete should cascade
partner_company.write({'equipment_ids': [Command.set([])]})
with self.assertRaises(MissingError):
equipment.name
@tagged('-at_install', 'post_install', 'slow')
class TestEquipmentTours(HttpCase, BemadeFSMBaseTest):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
partner = cls._generate_partner("Test Partner")
# Set up the test partner cls._generate_partner('Site Contact', 'person', parent=partner)
cls.partner_company = cls.env['res.partner'].create({ cls._generate_equipment(name='Test Equipment 1', partner=partner)
'name': 'Test Partner Company', cls.user = cls._generate_project_manager_user("Mister PM", 'misterpm')
'company_type': 'company',
'street': '123 Street St.',
'city': 'Montreal',
'state_id': cls.env['res.country.state'].search([('name', 'ilike', 'Quebec%')]).id,
'country_id': cls.env['res.country'].search([('name', '=', 'Canada')]).id
})
cls.partner_contact = cls.env['res.partner'].create({
'name': 'Site Contact',
'company_type': 'person',
'parent_id': cls.partner_company.id,
})
cls.equipment = cls.env['bemade_fsm.equipment'].create({
'name': 'Test Equipment 1',
'partner_location_id': cls.partner_company.id,
})
@tagged('-at_install', 'post_install')
class TestEquipmentBase(TestEquipmentCommon):
def test_crd(self):
# Just make sure the basic ORM stuff is OK
self.assertTrue(self.equipment in self.partner_company.equipment_ids)
self.assertTrue(len(self.partner_company.equipment_ids) == 1)
self.partner_company.write({'equipment_ids': [Command.set([])]})
# Delete should cascade
with self.assertRaises(MissingError):
self.equipment.name
@tagged('-at_install', 'post_install')
class TestEquipmentTours(HttpCase, TestEquipmentCommon):
def test_equipment_base_tour(self): def test_equipment_base_tour(self):
self.start_tour('/web', 'equipment_base_tour', self.start_tour('/web', 'equipment_base_tour',

View file

@ -1,56 +1,89 @@
from odoo.tests import TransactionCase, HttpCase, tagged from odoo.tests import TransactionCase, HttpCase, tagged, Form
from odoo import Command from odoo import Command
from .test_bemade_fsm_common import FSMManagerUserTransactionCase from .test_bemade_fsm_common import BemadeFSMBaseTest
@tagged("-at_install", "post_install") @tagged("-at_install", "post_install")
class SaleOrderFSMContactsCase(FSMManagerUserTransactionCase): class SaleOrderFSMContactsCase(BemadeFSMBaseTest):
@classmethod
def setUpClass(cls):
super().setUpClass()
Partner = cls.env['res.partner']
cls.parent_co = Partner.create({
'name': 'Parent Co',
'company_type': 'company',
})
cls.contact_1 = Partner.create({
'name': 'Contact 1',
'company_type': 'person',
'parent_id': cls.parent_co.id,
})
cls.contact_2 = Partner.create({
'name': 'Contact 2',
'company_type': 'person',
'parent_id': cls.parent_co.id,
})
def test_site_contacts(self): 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 # Make sure the SO pulls the defaults from the partner correctly
self.parent_co.write({'site_contacts': [Command.set([self.contact_1.id, self.contact_2.id])]}) parent_co.write({'site_contacts': [Command.set([contact_1.id, contact_2.id])]})
so = self.env['sale.order'].create({ so = self._generate_sale_order(parent_co)
'partner_id': self.parent_co.id, self.assertTrue(so.site_contacts == parent_co.site_contacts)
})
self.assertTrue(so.site_contacts == self.parent_co.site_contacts)
# Make sure updating the site contacts on the SO doesn't feed back to the partner # Make sure updating the site contacts on the SO doesn't feed back to the partner
so.write({'site_contacts': [Command.set([self.contact_1.id])]}) so.write({'site_contacts': [Command.set([contact_1.id])]})
self.assertTrue(self.contact_1 in so.site_contacts) self.assertTrue(contact_1 in so.site_contacts)
self.assertTrue(self.contact_2 not 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) self.assertTrue(so.site_contacts != so.partner_id.site_contacts and len(so.partner_id.site_contacts) == 2)
def test_default_workorder_contacts(self): 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 # Make sure the SO pulls the defaults from the partner correctly
self.parent_co.write({'work_order_contacts': [Command.set([self.contact_1.id, self.contact_2.id])]}) parent_co.write({'work_order_contacts': [Command.set([contact_1.id, contact_2.id])]})
so = self.env['sale.order'].create({ so = self._generate_sale_order(parent_co)
'partner_id': self.parent_co.id, self.assertTrue(contact_1 in parent_co.work_order_contacts)
}) self.assertTrue(contact_2 in parent_co.work_order_contacts)
self.assertTrue(self.contact_1 in self.parent_co.work_order_contacts) self.assertTrue(contact_1 in so.work_order_contacts)
self.assertTrue(self.contact_2 in self.parent_co.work_order_contacts) self.assertTrue(contact_2 in so.work_order_contacts)
self.assertTrue(self.contact_1 in so.work_order_contacts)
self.assertTrue(self.contact_2 in so.work_order_contacts)
# Make sure setting the work order contacts on the SO doesn't feed back to the partner # Make sure setting the work order contacts on the SO doesn't feed back to the partner
so.write({'work_order_contacts': [Command.set([self.contact_1.id])]}) so.write({'work_order_contacts': [Command.set([contact_1.id])]})
self.assertTrue(self.contact_1 in so.work_order_contacts) self.assertTrue(contact_1 in so.work_order_contacts)
self.assertTrue(self.contact_2 not in so.work_order_contacts) self.assertTrue(contact_2 not in so.work_order_contacts)
self.assertTrue( self.assertTrue(
so.work_order_contacts != so.partner_id.work_order_contacts and len(so.partner_id.work_order_contacts) == 2) 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):
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,79 @@
from odoo.tests import TransactionCase, tagged, Form
from .test_bemade_fsm_common import BemadeFSMBaseTest
@tagged('-at_install', 'post_install')
class FSMVisitTest(BemadeFSMBaseTest):
def test_create_visit_sets_name_on_section(self):
so = self._generate_sale_order()
self._add_service_so_line(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._add_service_so_line(so, task=True)
so.action_confirm()
task = so.order_line.filtered(lambda l: l.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._add_service_so_line(so, task=True)
so.action_confirm()
task = so.order_line.filtered(lambda l: l.task_id).task_id
task.action_fsm_validate()
self._invoice_sale_order(so)
self.assertTrue(visit.is_invoiced)
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 _add_service_so_line(self, sale_order, task: bool = False):
""" Generates a sales order line for a service product.
:param sale_order: The sales order to which the new line is to be added
:param task: If true, the created line will be for a product with service_tracking=task_global_project
"""
service_tracking = 'task_global_project' if task else 'no'
product = self._generate_product(service_tracking=service_tracking)
return self._generate_sale_order_line(sale_order, product)

View file

@ -1,97 +1,164 @@
from .test_task_template import TestTaskTemplateCommon from .test_task_template import BemadeFSMBaseTest
from .test_equipment import TestEquipmentCommon from odoo.tests.common import tagged, HttpCase, Form
from odoo.tests.common import tagged from odoo import Command
@tagged("-at_install", "post_install") @tagged("-at_install", "post_install")
class TestSalesOrder(TestTaskTemplateCommon): class TestSalesOrder(BemadeFSMBaseTest):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({
'name': 'Test Partner',
})
cls.sale_order1 = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'client_order_ref': 'TEST ORDER',
})
cls.sol_serv_order = cls.env['sale.order.line'].create({
'name': cls.product_task_global_project.name,
'product_id': cls.product_task_global_project.id,
'product_uom_qty': 1,
'product_uom': cls.product_task_global_project.uom_id.id,
'price_unit': 120.0,
'order_id': cls.sale_order1.id,
'tax_id': False,
})
cls.sol_serv_order_task_in_project = cls.env['sale.order.line'].create({
'name': cls.product_task_in_project.name,
'product_id': cls.product_task_in_project.id,
'product_uom_qty': 1,
'product_uom': cls.product_task_in_project.uom_id.id,
'price_unit': 150.0,
'order_id': cls.sale_order1.id,
'tax_id': False,
})
cls.sale_order2 = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'client_order_ref': 'TEST ORDER',
})
cls.sol_tree_order = cls.env['sale.order.line'].create({
'name': cls.product_task_tree_global_project.name,
'product_id': cls.product_task_tree_global_project.id,
'product_uom_qty': 1,
'product_uom': cls.product_task_tree_global_project.uom_id.id,
'price_unit': 120.0,
'order_id': cls.sale_order2.id,
'tax_id': False,
})
cls.sol_serv_order_task_in_project = cls.env['sale.order.line'].create({
'name': cls.product_task_tree_in_project.name,
'product_id': cls.product_task_tree_in_project.id,
'product_uom_qty': 1,
'product_uom': cls.product_task_tree_in_project.uom_id.id,
'price_unit': 150.0,
'order_id': cls.sale_order2.id,
'tax_id': False,
})
@tagged('-at_install', 'post_install') @tagged('-at_install', 'post_install')
def test_order_confirmation_simple_template(self): def test_order_confirmation_simple_template(self):
""" Confirming the order should create a task in the global project. """ """ Confirming the order should create a task in the global project based on the task template. """
so = self.sale_order1 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() so.action_confirm()
sol1 = so.order_line[0]
sol2 = so.order_line[1] task = sol.task_id
task1 = sol1.task_id self.assertTrue(task)
task2 = sol2.task_id self.assertTrue(task_template.name in task.name)
self.assertTrue(task1) self.assertTrue(task_template.planned_hours == task.planned_hours)
self.assertTrue(task2)
self.assertTrue(self.task1.name in task1.name)
self.assertTrue(self.task1.name in task2.name)
self.assertTrue(self.task1.planned_hours == task1.planned_hours)
def test_order_confirmation_tree_template(self): def test_order_confirmation_tree_template(self):
def assert_structure(sol): partner = self._generate_partner()
self.assertTrue(sol.task_id.child_ids and len(sol.task_id.child_ids) == 2) so = self._generate_sale_order(partner=partner)
self.assertTrue(self.parent_task.name in sol.task_id.name) parent_task = self._generate_task_template(structure=[2, 1],
self.assertTrue(self.child_task_1.name in sol.task_id.child_ids[0].name) names=['Parent Template', 'Child Template',
self.assertTrue(self.child_task_2.name in sol.task_id.child_ids[1].name) 'Grandchild Template'])
self.assertTrue(sol.task_id.child_ids[1].child_ids and len(sol.task_id.child_ids.child_ids) == 1) child_task_1 = parent_task.subtasks[0]
self.assertTrue(self.grandchild_task.name in sol.task_id.child_ids.child_ids[0].name) child_task_2 = parent_task.subtasks[1]
grandchild_task = parent_task.subtasks[0].subtasks[0]
product = self._generate_product(task_template=parent_task)
sol = self._generate_sale_order_line(so, product=product)
so = self.sale_order2
so.action_confirm() so.action_confirm()
sol1 = so.order_line[0]
sol2 = so.order_line[1] self.assertTrue(sol.task_id.child_ids and len(sol.task_id.child_ids) == 2)
assert_structure(sol1) self.assertTrue(parent_task.name in sol.task_id.name)
assert_structure(sol2) self.assertTrue(child_task_1.name in sol.task_id.child_ids[0].name)
self.assertTrue(child_task_2.name in sol.task_id.child_ids[1].name)
self.assertTrue(sol.task_id.child_ids[0].child_ids and len(sol.task_id.child_ids[0].child_ids) == 1)
self.assertTrue(grandchild_task.name in sol.task_id.child_ids.child_ids[0].name)
def test_order_confirmation_equipment(self): def test_order_confirmation_equipment(self):
so = self.sale_order1 """ The equipment selected on the SO should transfer to the task."""
equipment = self.env['bemade_fsm.equipment'].create( partner = self._generate_partner()
{'name': 'test equipment', 'partner_location_id': so.partner_shipping_id.id}) equipment = self._generate_equipment(partner=partner)
so.equipment_id = equipment.id 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() so.action_confirm()
task = so.order_line[0].task_id
self.assertTrue(task.equipment_id == equipment) task1 = sol1.task_id
task2 = sol2.task_id
self.assertEqual(task1.equipment_ids[0], equipment)
self.assertEqual(task2.equipment_ids[0], equipment)
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_task_mark_done(self):
""" Marking the task linked to an 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()
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()
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_changing_task_contacts_mirrors_with_sale_order(self):
partner = self._generate_partner()
contact = self._generate_partner("Contact", "person", partner)
so = self._generate_sale_order(partner)
product = self._generate_product()
sol = self._generate_sale_order_line(so, product)
so.action_confirm()
task = sol.task_id
task_form = Form(task)
# Now change the site/work order contact on the task and make sure it feeds back to the sales order
task_form.site_contacts.add(contact)
task_form.work_order_contacts.add(contact)
task_form.save()
self.assertEqual(task.site_contacts, so.site_contacts)
self.assertEqual(task.work_order_contacts, so.work_order_contacts)
# Test changing it on the SO feeds back to the task as well
f = Form(so)
f.work_order_contacts.remove(contact.id)
f.site_contacts.remove(contact.id)
f.save()
self.assertEqual(task.site_contacts, so.site_contacts)
self.assertEqual(task.work_order_contacts, so.work_order_contacts)
@tagged("-at_install", "post_install", "slow")
class TestSaleOrderTour(HttpCase, TestSalesOrder):
def test_sale_order_tour_no_invoice_button_for_non_manager(self):
so = self._generate_sale_order(client_order_ref='TEST ORDER 2')
self._generate_sale_order_line(so, self._generate_product(name='Test Product 3'))
self._generate_project_user('Mr. User', 'mruser')
so.action_confirm()
# Make sure a non-manager cannot mark a task as ready to invoice
with self.assertRaises(AssertionError) as e:
self.start_tour('/web', 'sale_order_tour',
login='mruser', )
self.assertTrue("Click on the ready to invoice button" in str(e.exception))
def test_task_mark_to_invoice(self):
# Make sure that when a manager clicks the ready to invoice button, the qty delivered is updated on the SO
so = self._generate_sale_order(client_order_ref='TEST ORDER 2')
sol = self._generate_sale_order_line(so, self._generate_product(name='Test Product 3'))
so.action_confirm()
user = self._generate_project_manager_user('Mr. User', 'mruser')
self.start_tour('/web', 'sale_order_tour', login=user.login)
self.assertTrue(sol.qty_delivered != 0)

View file

@ -1,58 +0,0 @@
from .test_sale_order import TestSalesOrder
from odoo import Command
from odoo.tests.common import Form
class TestSaleOrderTaskContacts(TestSalesOrder):
@classmethod
def setUpClass(cls):
super().setUpClass()
Partner = cls.env['res.partner']
cls.partner2 = Partner.create({
'name': 'New Partner',
'company_type': 'company',
})
cls.contact = Partner.create({
'name': 'Contact',
'company_type': 'person',
'parent_id': cls.partner2.id,
})
cls.contact2 = cls.env['res.partner'].create({
'name': 'Contact 2',
'company_type': 'person',
'parent_id': cls.partner2.id,
})
def _test_task_contacts_from_so(self, field):
""" Shorthand function for testing both work_order_contacts and site_contacts fields on SOs and tasks."""
def ga(obj):
return getattr(obj, field)
# Add the default site/work order contact to the partner, create an SO with the partner
self.partner2.write({field: [Command.set([self.contact.id])]})
self.sale_order1.write(
{'partner_id': self.partner2.id, field: [Command.set(ga(self.partner2).ids)]})
so = self.sale_order1
self.assertTrue(ga(so))
# Confirm the SO and check that the task got the default carried over
so.action_confirm()
task = so.order_line[0].task_id
self.assertTrue(ga(task))
self.assertTrue(ga(task) == ga(so))
# Now change the site/work order contact on the task and make sure it feeds back to the sales order
f = Form(task)
ga(f).add(self.contact2)
f.save()
self.assertTrue(ga(so) == ga(task))
# Test changing it on the SO feeds back to the task as well
f = Form(so)
ga(f).remove(self.contact.id)
f.save()
self.assertTrue(ga(so) == ga(task))
def test_work_order_contacts_on_task(self):
# Make sure work order contacts on the sales order transfer to the task on order confirmation
self._test_task_contacts_from_so('work_order_contacts')
def test_site_contacts_on_task(self):
# Make sure site contacts from the sales order transfer to the task on order confirmation
self._test_task_contacts_from_so('site_contacts')

View file

@ -1,12 +1,12 @@
from .test_bemade_fsm_common import FSMManagerUserTransactionCase from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests.common import HttpCase, tagged from odoo.tests.common import HttpCase, tagged, Form
from odoo.exceptions import MissingError from odoo.exceptions import MissingError
from odoo import Command from odoo import Command
from psycopg2.errors import ForeignKeyViolation from psycopg2.errors import ForeignKeyViolation
@tagged("-at_install", "post_install") @tagged("-at_install", "post_install")
class TestTaskTemplateCommon(FSMManagerUserTransactionCase): class TestTaskTemplateCommon(BemadeFSMBaseTest):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@ -18,70 +18,27 @@ class TestTaskTemplateCommon(FSMManagerUserTransactionCase):
'name': 'Template 1', 'name': 'Template 1',
}) })
cls.project = cls.env['project.project'].create({ cls.project = cls.env.ref('industry_fsm.fsm_project')
'name': 'Test Project',
}) cls.product_task_global_project = cls._generate_product(name='Test Product 1', task_template=cls.task1)
cls.product_task_global_project = cls.env['product.product'].create({ cls.project_template = cls._generate_fsm_project('Test Project Template')
'name': 'Test Product 1', cls.product_task_in_project = cls._generate_product(name='Test Product 2', project=cls.project_template,
'type': 'service', service_tracking='task_in_project', task_template=cls.task1)
'service_tracking': 'task_global_project',
'project_id': cls.project.id,
'task_template_id': cls.task1.id,
'uom_id': hours_uom.id,
'uom_po_id': hours_uom.id,
})
cls.project_template = cls.env['project.project'].create({
'name': 'Test Project Template',
})
cls.product_task_in_project = cls.env['product.product'].create({
'name': 'Test Product 2',
'type': 'service',
'service_tracking': 'task_in_project',
'task_template_id': cls.task1.id,
'project_template_id': cls.project_template.id,
'uom_po_id': hours_uom.id,
'uom_id': hours_uom.id,
})
# Set up a task template tree with 2 children and 1 grandchild # Set up a task template tree with 2 children and 1 grandchild
cls.parent_task = cls.env['project.task.template'].create({ cls.parent_task = cls._generate_task_template(structure=[2, 1],
'name': 'Parent Template', names=['Parent Template', 'Child Template',
'planned_hours': cls.PLANNED_HOURS, 'Grandchild Template'])
}) cls.child_task_1 = cls.parent_task.subtasks[0]
cls.child_task_1 = cls.env['project.task.template'].create({ cls.child_task_2 = cls.parent_task.subtasks[1]
'name': 'Child Template 1', cls.grandchild_task = cls.child_task_1.subtasks[0]
'parent': cls.parent_task.id,
})
cls.child_task_2 = cls.env['project.task.template'].create({
'name': 'Child Template 2',
'parent': cls.parent_task.id,
})
cls.parent_task.write({'subtasks': [Command.set([cls.child_task_1.id, cls.child_task_2.id])]})
cls.grandchild_task = cls.env['project.task.template'].create({
'name': 'Grandchild Template',
'parent': cls.child_task_2.id
})
cls.child_task_2.write({'subtasks': [Command.set([cls.grandchild_task.id])]})
# Create products using the task tree we just created # Create products using the task tree we just created
cls.product_task_tree_global_project = cls.env['product.product'].create({ cls.product_task_tree_global_project = cls._generate_product(name='Test Product 3',
'name': 'Test Product 3', task_template=cls.parent_task)
'type': 'service', cls.product_task_tree_in_project = cls._generate_product(name="Test Product 2", project=cls.project_template,
'service_tracking': 'task_global_project', service_tracking='task_in_project',
'project_id': cls.project.id, task_template=cls.parent_task)
'task_template_id': cls.parent_task.id,
'uom_id': hours_uom.id,
'uom_po_id': hours_uom.id,
})
cls.product_task_tree_in_project = cls.env['product.product'].create({
'name': 'Test Product 2',
'type': 'service',
'service_tracking': 'task_in_project',
'task_template_id': cls.parent_task.id,
'project_template_id': cls.project_template.id,
'uom_po_id': hours_uom.id,
'uom_id': hours_uom.id,
})
@tagged('-at_install', 'post_install') @tagged('-at_install', 'post_install')
@ -95,14 +52,31 @@ class TestTaskTemplate(TestTaskTemplateCommon):
def test_delete_subtask_template(self): 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 """ Deletion of a child task should be OK even if the parent is on a product. Children of the deleted
subtask should be deleted.""" subtask should be deleted."""
self.child_task_2.unlink() self.child_task_1.unlink()
# Reading deleted child's name field should be impossible # Reading deleted child's name field should be impossible
with self.assertRaises(MissingError): with self.assertRaises(MissingError):
test = self.grandchild_task.name test = self.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)
form = Form(self.task1)
form.customer = partner1
form.equipment_ids.add(equipment1)
@tagged('-at_install', 'post_install') # Switching the partner should trigger on_change that makes sure equipments are linked to the new partner
form.customer = partner2
self.assertFalse(equipment1 in self.task1.equipment_ids)
@tagged('-at_install', 'post_install', 'slow')
class TestTaskTemplateTour(HttpCase, TestTaskTemplateCommon): class TestTaskTemplateTour(HttpCase, TestTaskTemplateCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._generate_project_manager_user('Mister PM', 'misterpm')
def test_task_template_tour(self): def test_task_template_tour(self):
self.start_tour('/web', 'task_template_tour', self.start_tour('/web', 'task_template_tour',

View file

@ -50,12 +50,12 @@
<field name="name">bemade_fsm.equipment.tree</field> <field name="name">bemade_fsm.equipment.tree</field>
<field name="model">bemade_fsm.equipment</field> <field name="model">bemade_fsm.equipment</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<tree string="Equipment"> <tree string="Equipment" editable="bottom">
<field name="pid_tag"/> <field name="pid_tag"/>
<field name="name"/> <field name="name"/>
<field name="description"/> <field name="description"/>
<field name="tag_ids" widget="many2many_tags" options="{'no_open': False}"/> <field name="tag_ids" widget="many2many_tags" options="{'no_open': False}"/>
<field name="partner_id"/> <field name="partner_location_id"/>
</tree> </tree>
</field> </field>
</record> </record>

View file

@ -8,7 +8,11 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//page[@name='other_information']" position="before"> <xpath expr="//page[@name='other_information']" position="before">
<page name="field_service" string="Field Service"> <page name="field_service" string="Field Service">
<group name="field_service_info"> <group name="fsm_visits" string="Service Visits">
<field name="visit_ids"
context="{'tree_view_ref': 'bemade_fsm.bemade_fsm_visit_tree'}"/>
</group>
<group name="field_service_info" string="Contacts and Equipment">
<field name="equipment_id" <field name="equipment_id"
domain="[('partner_location_id', '=', partner_shipping_id)]" domain="[('partner_location_id', '=', partner_shipping_id)]"
context="{'default_partner_location_id': partner_shipping_id,}"/> context="{'default_partner_location_id': partner_shipping_id,}"/>
@ -21,5 +25,17 @@
</xpath> </xpath>
</field> </field>
</record> </record>
<record id="bemade_fsm_visit_tree" model="ir.ui.view">
<field name="name">bemade_fsm.visit.tree</field>
<field name="model">bemade_fsm.visit</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="label"/>
<field name="approx_date"/>
<field name="is_completed" widget="boolean"/>
<field name="is_invoiced" widget="boolean"/>
</tree>
</field>
</record>
</data> </data>
</odoo> </odoo>

View file

@ -23,6 +23,9 @@
</group> </group>
<group> <group>
<field name="customer"/> <field name="customer"/>
<field name="equipment_ids"
domain="[('partner_location_id', '=', customer)]"
context="{'tree_view_ref': 'bemade_fsm.equipment_view_tree'}"/>
<field name="tags" widget="many2many_tags"/> <field name="tags" widget="many2many_tags"/>
<field name="company_id" /> <field name="company_id" />
</group> </group>

View file

@ -4,11 +4,13 @@
<record id="bemade_fsm_project_task_form_inherit" model="ir.ui.view"> <record id="bemade_fsm_project_task_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.project_task.form</field> <field name="name">bemade_fsm.project_task.form</field>
<field name="model">project.task</field> <field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_form2"/> <field name="inherit_id" ref="industry_fsm.view_task_form2_inherit"/>
<field name="priority" eval="8"/> <field name="priority" eval="8"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after"> <xpath expr="//field[@name='partner_id']" position="after">
<field name="equipment_id" domain="[('partner_location_id', '=', partner_id)]"/> <field name="equipment_ids"
domain="[('partner_location_id', '=', partner_id)]"
context="{'tree_view_ref': 'bemade_fsm.equipment_view_tree'}"/>
</xpath> </xpath>
<xpath expr="//page[@name='extra_info']" position="after"> <xpath expr="//page[@name='extra_info']" position="after">
<page string="Field Service" name="field_service"> <page string="Field Service" name="field_service">
@ -20,6 +22,14 @@
</group> </group>
</page> </page>
</xpath> </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> </field>
</record> </record>
<!-- Add parent_id = false to domain for My Tasks, All Tasks: To Schedule, All Tasks and To Invoice--> <!-- Add parent_id = false to domain for My Tasks, All Tasks: To Schedule, All Tasks and To Invoice-->