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',
'version': '15.0.0.2.0',
'version': '15.0.0.3.0',
'summary': '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',
@ -32,25 +32,27 @@
'sale',
'sale_project',
'sale_stock',
'industry_fsm',
'industry_fsm_sale',
'industry_fsm_stock',
'industry_fsm_report',
'bemade_partner_root_ancestor',
'mail',
],
'data': ['views/task_template_views.xml',
'views/equipment.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',
],
'data': [
'data/fsm_data.xml',
'views/task_template_views.xml',
'views/equipment.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',
],
'assets': {
'web.assets_tests': [
'bemade_fsm/static/tests/tours/task_template_tour.js',
'bemade_fsm/static/tests/tours/equipment_tour.js',
'bemade_fsm/static/tests/tours/sale_order_tour.js',
],
},
'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 task
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',
string='Application',
tracking=True,
help="Classify and analyze your equipment categories like: Boiler, Laboratory, "
"Waste water, Pure water")
partner_id = fields.Many2one('res.partner',
string="Owner",
compute="_compute_partner",
search="_search_partner",)
search="_search_partner", )
description = fields.Text(string="Description",
tracking=True)
@ -53,9 +52,11 @@ class Equipment(models.Model):
location_notes = fields.Text(string="Physical Location Notes",
tracking=True)
task_ids = fields.One2many(comodel_name='project.task',
inverse_name='equipment_id',
string='Interventions')
task_ids = fields.Many2many(comodel_name='project.task',
relation="bemade_fsm_task_equipment_rel",
column1="equipment_id",
column2="task_id",
string='Interventions')
@api.depends('partner_location_id')
def _compute_partner(self):

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.exceptions import ValidationError
class SaleOrder(models.Model):
@ -6,7 +7,10 @@ class SaleOrder(models.Model):
equipment_id = fields.Many2one(comodel_name="bemade_fsm.equipment",
string="Equipment to Service",
tracking=True)
tracking=True,
compute="_compute_equipment",
inverse="_inverse_equipment",
store=True)
site_contacts = fields.Many2many(comodel_name='res.partner',
relation="sale_order_site_contacts_rel",
@ -21,8 +25,17 @@ class SaleOrder(models.Model):
inverse='_inverse_default_contacts',
string='Work Order Recipients',
store=True)
visit_ids = fields.One2many(comodel_name='bemade_fsm.visit',
inverse_name="sale_order_id",
readonly=False)
@api.depends('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):
for rec in self:
rec.site_contacts = rec.partner_shipping_id.site_contacts
@ -31,10 +44,10 @@ class SaleOrder(models.Model):
def _inverse_default_contacts(self):
pass
@api.depends('partner_id')
@api.depends('partner_shipping_id')
def _compute_equipment(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
def _inverse_equipment(self):
@ -44,6 +57,18 @@ class SaleOrder(models.Model):
class SaleOrderLine(models.Model):
_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):
""" Generate task for the given so line, and link it.
: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, })
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):
""" 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.
"""
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['parent_id'] = parent and parent.id
vals['user_ids'] = template.assignees.ids
vals['tag_ids'] = template.tags.ids
vals['planned_hours'] = template.planned_hours
if template.equipment_ids:
vals['equipment_ids'] = [Command.set(template.equipment_ids.ids)]
return vals
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)") % (
self.order_id.id, self.order_id.name, self.product_id.name)
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
@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):
_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",
relation="task_work_order_contact_rel",
@ -23,6 +28,10 @@ class Task(models.Model):
inverse="_inverse_contacts",
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')
def _compute_contacts(self):
""" 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)],
'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):
_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(string="Description")
assignees = fields.Many2many("res.users", string="Default Assignees", help="Employees assigned to tasks created from this template.")
customer = fields.Many2one("res.partner", string="Default Customer", help="Default customer for tasks created from this template.")
project = fields.Many2one("project.project", string="Default Project", help="Default project for tasks created from this template.")
tags = fields.Many2many("project.tags", string="Default Tags", help="Default tags for tasks created from this template.")
assignees = fields.Many2many("res.users", string="Default Assignees",
help="Employees assigned to tasks created from this template.")
customer = fields.Many2one("res.partner", string="Default Customer",
help="Default customer for tasks created from this template.")
project = fields.Many2one("project.project", string="Default Project",
help="Default project for tasks created from this template.")
tags = fields.Many2many("project.tags", string="Default Tags",
help="Default tags for tasks created from this template.")
parent = fields.Many2one("project.task.template", string="Parent Task Template", ondelete='cascade')
subtasks = fields.One2many("project.task.template", inverse_name="parent", string="Subtask Templates")
sequence = fields.Integer(string="Sequence")
company_id = fields.Many2one("res.company", string="Company", index=1, default=_current_company)
planned_hours = fields.Float("Initially Planned Hours")
equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment",
relation="bemade_fsm_task_template_equipment_rel",
column1="task_template_id",
column2="equipment_id",
string="Equipment to Service",)
def action_open_task(self):
return {
@ -29,4 +39,8 @@ class TaskTemplate(models.Model):
'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_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_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';
const TEST_COMPANY = "Test Partner Company";
const TEST_COMPANY = "Test Partner";
const TEST_EQPT1 = "Test Equipment 1";
const TEST_EQPT2 = "Test Equipment 2";
tour.register('equipment_base_tour', {
@ -21,7 +21,6 @@ tour.register('equipment_base_tour', {
}, {
content: 'Click the create button',
trigger: '.o_list_button_add',
extra_trigger: 'li.breadcrumb-item.active:has(span:contains(Equipment))',
}, {
content: 'Add a tag',
trigger: 'input[name="pid_tag"]',
@ -33,13 +32,13 @@ tour.register('equipment_base_tour', {
}, {
content: 'Set the partner',
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',
trigger: `li a.dropdown-item:contains(${TEST_COMPANY})`,
}, {
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) */
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})`,
}, {
content: 'Make sure we have a first test equipment',
/*trigger: `div[name="equipment_ids"]:has(td:contains(${TEST_EQPT1}))`,*/
trigger: `td:contains(${TEST_EQPT1})`,
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})`,
}, {
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) */
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_equipment
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")
class FSMManagerUserTransactionCase(TransactionCase):
class BemadeFSMBaseTest(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
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_project_manager = cls.env.ref('project.group_project_manager')
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_manager = cls.env.ref('sales_team.group_sale_manager')
user_product_customer = cls.env.ref('customer_product_code.group_product_customer_code_user')
group_ids = [user_group_employee.id,
user_group_project_user.id,
user_group_project_manager.id,
user_group_fsm_user.id,
user_group_fsm_manager.id,
user_group_sales_user.id,
user_group_sales_manager.id, ]
user_group_sales_manager.id,
user_group_sales_user.id, ]
if user_product_customer:
group_ids.append(user_product_customer.id)
return group_ids
# Test user with project access rights for the various tests
Users = cls.env['res.users'].with_context({'no_reset_password': True})
cls.user = Users.create({
'name': 'Project Manager',
'login': 'misterpm',
'password': 'misterpm',
'email': 'mrpm@testco.com',
'signature': 'Mr. PM',
'groups_id': [Command.set(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):
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 .test_bemade_fsm_common import FSMManagerUserTransactionCase
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo import Command
from odoo.exceptions import MissingError
@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
def setUpClass(cls):
super().setUpClass()
# Set up the test partner
cls.partner_company = cls.env['res.partner'].create({
'name': 'Test Partner Company',
'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):
partner = cls._generate_partner("Test Partner")
cls._generate_partner('Site Contact', 'person', parent=partner)
cls._generate_equipment(name='Test Equipment 1', partner=partner)
cls.user = cls._generate_project_manager_user("Mister PM", 'misterpm')
def test_equipment_base_tour(self):
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 .test_bemade_fsm_common import FSMManagerUserTransactionCase
from .test_bemade_fsm_common import BemadeFSMBaseTest
@tagged("-at_install", "post_install")
class SaleOrderFSMContactsCase(FSMManagerUserTransactionCase):
@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,
})
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
self.parent_co.write({'site_contacts': [Command.set([self.contact_1.id, self.contact_2.id])]})
so = self.env['sale.order'].create({
'partner_id': self.parent_co.id,
})
self.assertTrue(so.site_contacts == self.parent_co.site_contacts)
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([self.contact_1.id])]})
self.assertTrue(self.contact_1 in so.site_contacts)
self.assertTrue(self.contact_2 not in so.site_contacts)
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
self.parent_co.write({'work_order_contacts': [Command.set([self.contact_1.id, self.contact_2.id])]})
so = self.env['sale.order'].create({
'partner_id': self.parent_co.id,
})
self.assertTrue(self.contact_1 in self.parent_co.work_order_contacts)
self.assertTrue(self.contact_2 in self.parent_co.work_order_contacts)
self.assertTrue(self.contact_1 in so.work_order_contacts)
self.assertTrue(self.contact_2 in so.work_order_contacts)
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([self.contact_1.id])]})
self.assertTrue(self.contact_1 in so.work_order_contacts)
self.assertTrue(self.contact_2 not in so.work_order_contacts)
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):
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_equipment import TestEquipmentCommon
from odoo.tests.common import tagged
from .test_task_template import BemadeFSMBaseTest
from odoo.tests.common import tagged, HttpCase, Form
from odoo import Command
@tagged("-at_install", "post_install")
class TestSalesOrder(TestTaskTemplateCommon):
@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,
})
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. """
so = self.sale_order1
""" 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()
sol1 = so.order_line[0]
sol2 = so.order_line[1]
task1 = sol1.task_id
task2 = sol2.task_id
self.assertTrue(task1)
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)
task = sol.task_id
self.assertTrue(task)
self.assertTrue(task_template.name in task.name)
self.assertTrue(task_template.planned_hours == task.planned_hours)
def test_order_confirmation_tree_template(self):
def assert_structure(sol):
self.assertTrue(sol.task_id.child_ids and len(sol.task_id.child_ids) == 2)
self.assertTrue(self.parent_task.name in sol.task_id.name)
self.assertTrue(self.child_task_1.name in sol.task_id.child_ids[0].name)
self.assertTrue(self.child_task_2.name in sol.task_id.child_ids[1].name)
self.assertTrue(sol.task_id.child_ids[1].child_ids and len(sol.task_id.child_ids.child_ids) == 1)
self.assertTrue(self.grandchild_task.name in sol.task_id.child_ids.child_ids[0].name)
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
parent_task = self._generate_task_template(structure=[2, 1],
names=['Parent Template', 'Child Template',
'Grandchild Template'])
child_task_1 = parent_task.subtasks[0]
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()
sol1 = so.order_line[0]
sol2 = so.order_line[1]
assert_structure(sol1)
assert_structure(sol2)
self.assertTrue(sol.task_id.child_ids and len(sol.task_id.child_ids) == 2)
self.assertTrue(parent_task.name in sol.task_id.name)
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):
so = self.sale_order1
equipment = self.env['bemade_fsm.equipment'].create(
{'name': 'test equipment', 'partner_location_id': so.partner_shipping_id.id})
so.equipment_id = equipment.id
""" 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()
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 odoo.tests.common import HttpCase, tagged
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests.common import HttpCase, tagged, Form
from odoo.exceptions import MissingError
from odoo import Command
from psycopg2.errors import ForeignKeyViolation
@tagged("-at_install", "post_install")
class TestTaskTemplateCommon(FSMManagerUserTransactionCase):
class TestTaskTemplateCommon(BemadeFSMBaseTest):
@classmethod
def setUpClass(cls):
@ -18,70 +18,27 @@ class TestTaskTemplateCommon(FSMManagerUserTransactionCase):
'name': 'Template 1',
})
cls.project = cls.env['project.project'].create({
'name': 'Test Project',
})
cls.product_task_global_project = cls.env['product.product'].create({
'name': 'Test Product 1',
'type': 'service',
'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,
})
cls.project = cls.env.ref('industry_fsm.fsm_project')
cls.product_task_global_project = cls._generate_product(name='Test Product 1', task_template=cls.task1)
cls.project_template = cls._generate_fsm_project('Test Project Template')
cls.product_task_in_project = cls._generate_product(name='Test Product 2', project=cls.project_template,
service_tracking='task_in_project', task_template=cls.task1)
# Set up a task template tree with 2 children and 1 grandchild
cls.parent_task = cls.env['project.task.template'].create({
'name': 'Parent Template',
'planned_hours': cls.PLANNED_HOURS,
})
cls.child_task_1 = cls.env['project.task.template'].create({
'name': 'Child Template 1',
'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])]})
cls.parent_task = cls._generate_task_template(structure=[2, 1],
names=['Parent Template', 'Child Template',
'Grandchild Template'])
cls.child_task_1 = cls.parent_task.subtasks[0]
cls.child_task_2 = cls.parent_task.subtasks[1]
cls.grandchild_task = cls.child_task_1.subtasks[0]
# Create products using the task tree we just created
cls.product_task_tree_global_project = cls.env['product.product'].create({
'name': 'Test Product 3',
'type': 'service',
'service_tracking': 'task_global_project',
'project_id': cls.project.id,
'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,
})
cls.product_task_tree_global_project = cls._generate_product(name='Test Product 3',
task_template=cls.parent_task)
cls.product_task_tree_in_project = cls._generate_product(name="Test Product 2", project=cls.project_template,
service_tracking='task_in_project',
task_template=cls.parent_task)
@tagged('-at_install', 'post_install')
@ -95,14 +52,31 @@ class TestTaskTemplate(TestTaskTemplateCommon):
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."""
self.child_task_2.unlink()
self.child_task_1.unlink()
# Reading deleted child's name field should be impossible
with self.assertRaises(MissingError):
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):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls._generate_project_manager_user('Mister PM', 'misterpm')
def test_task_template_tour(self):
self.start_tour('/web', 'task_template_tour',

View file

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

View file

@ -8,7 +8,11 @@
<field name="arch" type="xml">
<xpath expr="//page[@name='other_information']" position="before">
<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"
domain="[('partner_location_id', '=', partner_shipping_id)]"
context="{'default_partner_location_id': partner_shipping_id,}"/>
@ -21,5 +25,17 @@
</xpath>
</field>
</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>
</odoo>

View file

@ -23,6 +23,9 @@
</group>
<group>
<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="company_id" />
</group>

View file

@ -4,11 +4,13 @@
<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="project.view_task_form2"/>
<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='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 expr="//page[@name='extra_info']" position="after">
<page string="Field Service" name="field_service">
@ -20,6 +22,14 @@
</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>
<!-- Add parent_id = false to domain for My Tasks, All Tasks: To Schedule, All Tasks and To Invoice-->