Compare commits

...

44 commits

Author SHA1 Message Date
Marc Durepos
452d0ad610 bemade_fsm: cleaning up template files. 2023-08-04 15:38:44 -04:00
Marc Durepos
cbeb80029a bemade_fsm: Got the "Sign Report" functionality working.
Found which template to extend to inject our new work order template
into the front-end signature page. Now need to add the timesheets info
with task tree into our template and we should be good to go.

Fixes Dur-Pro/bemade-addons#94
2023-08-04 15:31:10 -04:00
Marc Durepos
960fb405a0 bemade_fsm: Work order report printing relevant SO lines summary. Re Dur-Pro/bemade-addons#94 2023-08-03 15:15:22 -04:00
Marc Durepos
84e9ef9a41 bemade_fsm: Task naming modified to shorten child task names.
Fixes Dur-Pro/bemade-addons#92
2023-08-03 11:13:28 -04:00
Marc Durepos
f4c281f900 bemade_fsm: Marking visit task done doesn't create SO line.
Added a test to confirm that marking the task associated to a visit
does not create an additional sale order line like it did before.

Fixes Dur-Pro/bemade-addons#93
2023-08-03 10:17:05 -04:00
Marc Durepos
47e4429aa2 bemade_fsm: Marking visit task done completes its descendants.
Re: Dur-Pro/bemade-addons#93
2023-08-03 10:13:43 -04:00
Marc Durepos
9e5296a90d bemade_fsm: Explicitly set user_ids=False on visit task creation. Fixes Dur-Pro/bemade-addons#90 2023-08-03 09:51:02 -04:00
Marc Durepos
134e3ee38d bemade_fsm: Due date and initially planned hours on SO confirmation.
Fixes Dur-Pro/bemade-addons#86 and Dur-Pro/bemade-addons87
2023-08-02 20:28:37 -04:00
Marc Durepos
6bc1f60b8d bemade_fsm: updates to qweb for pdf of work orders. 2023-08-02 12:24:26 -04:00
Marc Durepos
f24bf62c5d bemade_fsm: updates to PDF for work orders. 2023-08-02 09:26:52 -04:00
Marc Durepos
8771530abd bemade_fsm: added is_complete to tasks for easier PDF generation. 2023-07-31 15:51:13 -04:00
Marc Durepos
4f75473db3 Merge branch '15.0' into bemade_fsm-15.0.0.5 2023-07-31 07:08:04 -04:00
Marc Durepos
4fc12e13ac bemade_fsm: Add the beginning of the PDF report for service tasks. 2023-07-31 07:04:46 -04:00
Marc Durepos
08114ad4f7 bemade_fsm: remove view override while Odoo get my JS fix onto servers. 2023-07-28 19:05:59 -04:00
Marc Durepos
e5394babc3 bemade_fsm: many upgrades including better calendar view 2023-07-28 19:05:59 -04:00
Marc Durepos
c5fb6b608e bemade_fsm: updated data to make new stages appear in the FSM project 2023-07-28 19:05:59 -04:00
Marc Durepos
0e0be67d62 bemade_planning_travel: new module begun for planning travel 2023-07-28 19:05:59 -04:00
Marc Durepos
a6f192d81e bemade_fsm: fix in res_partner.py for pad field in domain. 2023-07-28 19:05:59 -04:00
Marc Durepos
aa9b8bf21c bemade_fsm: data added for stages and equipment tags. 2023-07-28 19:05:59 -04:00
Marc Durepos
9248af2aaa bemade_fsm: make project.task.allow_billable store=True to un-break Odoo tests. 2023-07-28 19:05:59 -04:00
Marc Durepos
ca54e5739c bemade_fsm: Update to ir.model.access.csv to fix duplicate insert. 2023-07-28 19:05:59 -04:00
Marc Durepos
b211679a09 bemade_fsm: tie in with sale_planning to plan SO lines based on task templates. 2023-07-28 19:05:59 -04:00
Marc Durepos
3054f0d3d9 bemade_geo_distance moved to bemade_geo_routing. Spawned new module bemade_planning_travel. 2023-07-28 19:05:59 -04:00
Marc Durepos
4e74d7a557 bemade_geo_distance: fix to public method signature in base.geo_router 2023-07-28 19:05:59 -04:00
Marc Durepos
fd314d2429 bemade_geo_distance: new module for getting driving distance and time from one partner to another. 2023-07-28 19:05:59 -04:00
Marc Durepos
6bd52ae118 bemade_fsm: Added logic to use the planned hours from task templates on sale order lines (for planning purposes). Re #11. 2023-07-28 19:05:59 -04:00
Marc Durepos
59fc723026 bemade_fsm: Visits on sales orders now group created tasks under a common parent. Fixes #72. 2023-07-28 19:05:59 -04:00
Marc Durepos
bcdf2bf860 bemade_fsm: Equipment to sale order line connection made. Fixes #71. 2023-07-28 19:05:59 -04:00
Marc Durepos
5bb1023847 bemade_fsm: added many2many relationship to equipment from sale order lines. Set up summary and default equipment_ids from sale order. Ref #71. 2023-07-28 19:05:59 -04:00
Marc Durepos
8233f678cf Refactored tests. 2023-07-28 19:05:59 -04:00
Marc Durepos
933092771e [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-28 19:05:59 -04:00
Marc Durepos
238cde41e6 bemade_fsm: Convert task_template.equipment_id to a Many2many field. Fixes #58. 2023-07-28 19:05:59 -04:00
Marc Durepos
7ea3458ba4 bemade_fsm: test refactoring complete (all passing). 2023-07-28 19:05:59 -04:00
Marc Durepos
48abe06a51 bemade_fsm: major refactoring of unit tests completed. Integration tests need rework. 2023-07-28 19:05:59 -04:00
Marc Durepos
4010fd57a2 bemade_fsm: major refactoring of tests in progress (broken). 2023-07-28 19:05:59 -04:00
Marc Durepos
9b9e3a51db bemade_fsm: add visits to sale orders. Fixes #66. 2023-07-28 19:05:59 -04:00
Marc Durepos
146426e10b bemade_fsm: added a test for contact setting on SO shipping address change. Fixes #53. 2023-07-28 19:05:59 -04:00
Marc Durepos
8e9bc22b8e bemade_fsm: add equipment to Task Templates & flow down to tasks on SO. Fixes #50 2023-07-28 19:05:59 -04:00
Marc Durepos
22939a9fb5 bemade_fsm: added test for setting proper contacts on SO based on shipping address. 2023-07-28 19:05:59 -04:00
Marc Durepos
90153da600 bemade_fsm: Updating SO delivery address correctly changes site contacts, work order recipients and equipment. Fixes #53. 2023-07-28 19:05:59 -04:00
Marc Durepos
fc74046d32 Updated the naming of tasks for FSM. Fixes #54. 2023-07-28 19:05:59 -04:00
Marc Durepos
39467f96e2 bemade_fsm: Added default project stage data re #51. 2023-07-28 19:05:59 -04:00
Marc Durepos
73f53dead9 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-28 19:05:59 -04:00
Marc Durepos
c51d2f313c bemade_fsm: Added logic to prevent child FSM tasks from trying to create sales orders. Fixes #5. 2023-07-28 19:05:59 -04:00
41 changed files with 2111 additions and 456 deletions

View file

@ -20,38 +20,49 @@
########################################################################################
{
'name': 'Improved Field Service Management',
'version': '15.0.0.2.0',
'version': '15.0.0.5.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',
'author': 'Bemade Inc.',
'website': 'http://www.bemade.org',
'license': 'OPL-1',
'depends': ['project',
'depends': ['project_enterprise',
'project_forecast',
'stock',
'sale',
'sale_project',
'sale_stock',
'industry_fsm',
'industry_fsm_sale',
'sale_planning',
'worksheet',
'industry_fsm_stock',
'industry_fsm_report',
'industry_fsm_sale_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',
'reports/worksheet_custom_report_templates.xml',
'reports/worksheet_custom_reports.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',
],
'web.report_assets_common': [
'bemade_fsm/static/src/scss/bemade_fsm.scss'
]
},
'installable': True,
'auto_install': False

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record id="planning_project_stage_waiting_parts" model="project.task.type">
<field name="sequence">2</field>
<field name="name">Waiting on Parts</field>
<field name="legend_blocked">Blocked</field>
<field name="fold" eval="False"/>
<field name="is_closed" eval="False"/>
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]"/>
</record>
<record id="planning_project_stage_work_completed" model="project.task.type">
<field name="sequence">15</field>
<field name="name">Work Executed</field>
<field name="legend_blocked">Blocked</field>
<field name="fold" eval="False"/>
<field name="is_closed" eval="False"/>
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]"/>
</record>
<record id="planning_project_stage_exception" model="project.task.type">
<field name="sequence">19</field>
<field name="name">Exception</field>
<field name="legend_blocked">Blocked</field>
<field name="fold" eval="False"/>
<field name="is_closed" eval="False"/>
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]"/>
</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,8 @@ 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",)
description = fields.Text(string="Description",
tracking=True)
@ -53,24 +48,17 @@ 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):
for rec in self:
rec.partner_id = rec.partner_location_id and rec.partner_location_id.root_ancestor
@api.model
def _search_partner(self, operator, value):
return [('partner_location_id.root_ancestor', operator, value)]
@api.depends('pid_tag', 'name')
def _compute_complete_name(self):
for rec in self:
rec.complete_name = "[%s] %s" % (rec.pid_tag or ' ', rec.name)
@api.model
def name_search(self, name='', args=None, operator='ilike', limit=100):
args = args or []
@ -79,7 +67,6 @@ class Equipment(models.Model):
'|', '|', '|',
('pid_tag', operator, name),
('name', operator, name),
('partner_id.name', operator, name),
('partner_location_id.name', operator, name)],
limit=limit)
else:
@ -96,3 +83,8 @@ class Equipment(models.Model):
'res_model': 'bemade_fsm.equipment',
'type': 'ir.actions.act_window',
}
@api.depends('pid_tag', 'name')
def _compute_complete_name(self):
for rec in self:
rec.complete_name = "[%s] %s" % (rec.pid_tag or ' ', rec.name)

View file

@ -0,0 +1,58 @@
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",
ondelete="cascade")
sale_order_id = fields.Many2one(comodel_name="sale.order",
string="Sales Order",
required=True)
is_completed = fields.Boolean(string="Completed",
related="so_section_id.is_fully_delivered")
is_invoiced = fields.Boolean(string="Invoiced",
related="so_section_id.is_fully_delivered_and_invoiced")
summarized_equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment",
string="Equipment to Service",
compute="_compute_summarized_equipment_ids")
task_id = fields.Many2one(comodel_name="project.task",
compute="_compute_task_id",
string="Service Visit",)
task_ids = fields.One2many(comodel_name="project.task",
inverse_name="visit_id")
@api.depends('task_ids')
def _compute_task_id(self):
for rec in self:
rec.task_id = rec.task_ids and rec.task_ids[0]
@api.depends('so_section_id', 'sale_order_id.summary_equipment_ids')
def _compute_summarized_equipment_ids(self):
for rec in self:
lines = rec.so_section_id.get_section_line_ids()
equipment_ids = []
for line in lines:
for equipment in line.equipment_ids:
equipment_ids.append(equipment)
rec.summarized_equipment_ids = equipment_ids
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for i, rec in enumerate(recs):
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

@ -5,3 +5,6 @@ class ProductTemplate(models.Model):
_inherit = 'product.template'
task_template_id = fields.Many2one("project.task.template", string="Task Template", ondelete='restrict')
is_field_service = fields.Boolean("Plan as field service",
help="Products planned as field service will have travel time "
"considered in planning.")

View file

@ -1,5 +1,6 @@
from odoo import api, fields, models, Command
class Partner(models.Model):
_inherit = 'res.partner'
@ -7,17 +8,16 @@ class Partner(models.Model):
string='Equipment Count')
owned_equipment_ids = fields.One2many(comodel_name="bemade_fsm.equipment",
inverse_name="partner_id",
compute="_compute_owned_equipment_ids",
string="Owned Equipments")
equipment_ids = fields.One2many(comodel_name='bemade_fsm.equipment',
inverse_name='partner_location_id',
string='Site Equipment')
is_site_contact = fields.Boolean(string='Is a site contact',
compute="_compute_is_site_contact",
search="_search_is_site_contact",)
search="_search_is_site_contact", )
site_ids = fields.Many2many(string='Work Sites',
comodel_name='res.partner',
@ -42,6 +42,13 @@ class Partner(models.Model):
domain=[('is_company', '=', False)],
tracking=True)
@api.depends('equipment_ids', 'child_ids.company_type', 'child_ids.equipment_ids')
def _compute_owned_equipment_ids(self):
for rec in self:
ids = rec.equipment_ids | rec.child_ids.filtered(
lambda l: l.company_type == 'company').mapped('equipment_ids')
rec.owned_equipment_ids = ids or False
@api.depends('site_ids')
def _compute_is_site_contact(self):
for rec in self:
@ -50,7 +57,8 @@ class Partner(models.Model):
@api.depends('equipment_ids')
def _compute_equipment_count(self):
for rec in self:
all_equipment_ids = self.env['bemade_fsm.equipment'].search([('partner_id', '=', rec.id)])
all_equipment_ids = self.env['bemade_fsm.equipment'].search(
[('partner_location_id', '=', rec.id)])
rec.equipment_count = len(all_equipment_ids)
@api.model

View file

@ -1,12 +1,23 @@
from odoo import fields, models, api, _, Command
from odoo.exceptions import ValidationError
from odoo.tools import float_round
class SaleOrder(models.Model):
_inherit = 'sale.order'
equipment_id = fields.Many2one(comodel_name="bemade_fsm.equipment",
string="Equipment to Service",
tracking=True)
valid_equipment_ids = fields.One2many(comodel_name="bemade_fsm.equipment",
related="partner_id.owned_equipment_ids")
default_equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment",
string="Default Equipment to Service",
help="The default equipment to service for new sale order lines.",
compute="_compute_default_equipment",
inverse="_inverse_default_equipment",
store=True, )
summary_equipment_ids = fields.Many2many(comodel_name="bemade_fsm.equipment",
string="Equipment Being Serviced",
compute="_compute_summary_equipment_ids")
site_contacts = fields.Many2many(comodel_name='res.partner',
relation="sale_order_site_contacts_rel",
@ -21,8 +32,32 @@ 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.depends('order_line.task_id')
def get_relevant_order_lines(self, task_id):
self.ensure_one()
linked_lines = self.order_line.filtered(lambda l: l.task_id == task_id
or l == task_id.visit_id.so_section_id)
visit_lines = linked_lines.filtered(lambda l: l.visit_id)
for line in visit_lines:
linked_lines |= line.get_section_line_ids()
return linked_lines
@api.depends('order_line.equipment_ids')
def _compute_summary_equipment_ids(self):
for rec in self:
rec.summary_equipment_ids = rec.order_line.mapped('equipment_ids')
@api.onchange('partner_shipping_id')
def _onchange_partner_shipping_id(self):
super()._onchange_partner_shipping_id()
self._compute_default_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,18 +66,69 @@ class SaleOrder(models.Model):
def _inverse_default_contacts(self):
pass
@api.depends('partner_id')
def _compute_equipment(self):
@api.depends('partner_id', 'partner_shipping_id',
'partner_shipping_id.equipment_ids',
'partner_id.owned_equipment_ids')
def _compute_default_equipment(self):
for rec in self:
rec.equipment_ids = self.partner_shipping_id.equipment_ids if len(
self.partner_shipping_id.equipment_ids) <= 1 else False
if rec.partner_shipping_id.equipment_ids:
ids = rec.partner_shipping_id.equipment_ids
else:
ids = rec.partner_id.owned_equipment_ids
rec.default_equipment_ids = ids if len(ids) < 4 else False
def _inverse_equipment(self):
def _inverse_default_equipment(self):
pass
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
valid_equipment_ids = fields.One2many(comodel_name="bemade_fsm.equipment",
related="order_id.valid_equipment_ids")
visit_ids = fields.One2many(comodel_name="bemade_fsm.visit",
inverse_name="so_section_id", )
visit_id = fields.Many2one(comodel_name="bemade_fsm.visit",
compute="_compute_visit_id",
string="Visit",
ondelete='cascade',
store=True)
is_fully_delivered = fields.Boolean(string="Fully Delivered",
compute="_compute_is_fully_delivered",
help="Indicates whether a line or all the lines in a section have been"
"entirely delivered.")
is_fully_delivered_and_invoiced = fields.Boolean(string="Fully Invoiced",
compute="_compute_is_fully_invoiced",
help="Indicates whether a line or all the lines in a section have been"
"entirely delivered and invoiced.")
equipment_ids = fields.Many2many(string="Equipment to Service",
comodel_name="bemade_fsm.equipment",
relation="bemade_fsm_equipment_sale_order_line_rel",
column1="sale_order_line_id",
column2="equipment_id")
is_field_service = fields.Boolean(string="Is Field Service",
compute="_compute_is_field_service",
store=True)
task_duration = fields.Float(string="Estimated Duration",
compute="_compute_task_duration", )
@api.depends('visit_ids')
def _compute_visit_id(self):
for rec in self:
rec.visit_id = rec.visit_ids and rec.visit_ids[0]
@api.depends('product_id')
def _compute_is_field_service(self):
for rec in self:
rec.is_field_service = rec.product_id.is_field_service
@api.model_create_multi
def create(self, vals):
recs = super().create(vals)
for rec in recs:
if rec.order_id.default_equipment_ids and not rec.equipment_ids:
rec.equipment_ids = rec.order_id.default_equipment_ids
return recs
def _timesheet_create_task(self, project):
""" Generate task for the given so line, and link it.
@ -58,7 +144,9 @@ class SaleOrderLine(models.Model):
:param template: project.task.template to use to create the task.
:param parent: project.task to set as the parent to this task.
"""
values = _timesheet_create_task_prepare_values_from_template(project, template, parent)
values = _timesheet_create_task_prepare_values_from_template(project,
template,
parent)
task = self.env['project.task'].sudo().create(values)
subtasks = []
for t in template.subtasks:
@ -69,7 +157,8 @@ class SaleOrderLine(models.Model):
task.child_ids.write({'sale_order_id': None, 'sale_line_id': None, })
return task
def _timesheet_create_task_prepare_values_from_template(project, template, parent):
def _timesheet_create_task_prepare_values_from_template(project, template,
parent):
""" Copies the values from a project.task.template over to the set of values used to create a project.task.
:param project: project.project record to set on the task's project_id field.
@ -79,12 +168,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'] = 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'] = template.equipment_ids.ids
return vals
tmpl = self.product_id.task_template_id
@ -98,5 +189,132 @@ 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.equipment_ids:
task.equipment_ids = self.equipment_ids.ids
task.planned_hours = self.task_duration
return task
def _timesheet_service_generation(self):
super()._timesheet_service_generation()
visit_lines = self.filtered(lambda l: l.visit_id)
for line in visit_lines:
task_ids = line.get_section_line_ids().mapped('task_id')
if not task_ids:
continue
if len(set([task.project_id for task in task_ids])) > 1:
# Can't group up the tasks if they're part of different projects
return
project_id = task_ids[0].project_id
line.visit_id.task_id = line._generate_task_for_visit_line(project_id)
task_ids.write({'parent_id': line.visit_id.task_id.id})
self.mapped('task_id').synchronize_name_fsm()
def _generate_task_for_visit_line(self, project):
self.ensure_one()
task = self.env['project.task'].create({
'name': 'Temp Name',
'description': f"Parent task for {self.order_id.name}, visit {self.name}",
'project_id': project.id,
'equipment_ids': self.get_section_line_ids().mapped('equipment_ids').ids,
'sale_order_id': self.order_id.id,
'partner_id': self.order_id.partner_shipping_id.id,
'visit_id': self.visit_id.id,
'date_deadline': self.visit_id.approx_date,
'planned_hours': self.task_duration,
'user_ids': False, # Force to empty or it uses the current user
})
return task
@api.depends('order_id.order_line', 'display_type', 'qty_to_deliver',
'order_id.order_line.qty_to_deliver',
'order_id.order_line.display_type')
def _compute_is_fully_delivered(self):
for rec in self:
rec.is_fully_delivered = rec._iterate_items_compute_bool(
lambda l: l.qty_to_deliver == 0)
@api.depends('is_fully_delivered')
def _compute_is_fully_invoiced(self):
for rec in self:
if not rec.is_fully_delivered:
rec.is_fully_delivered_and_invoiced = False
return
rec.is_fully_delivered_and_invoiced = rec._iterate_items_compute_bool(
lambda l: l.qty_to_invoice == 0)
def get_section_line_ids(self):
self.ensure_one()
assert self.display_type == 'line_section', "Cannot get section lines for a non-section."
found = False
lines = []
for line in self.order_id.order_line.sorted(lambda l: l.sequence):
if line == self:
found = True
continue
if not found:
continue
if line.display_type == 'line_section': # Stop when we hit the next section
break
else:
lines.append(line)
return self.env['sale.order.line'].union(*lines)
def _iterate_items_compute_bool(self, single_line_func):
if not self.display_type:
return single_line_func(self)
elif self.display_type == 'line_note':
return True
else:
for line in self.order_id.order_line:
found = False
if line == self:
found = True
if not found:
continue
if found and line.display_type == 'line_section':
return True
val = single_line_func(self)
if not val:
return val
return True
@api.depends('task_duration', 'product_id', 'product_id.planning_enabled', 'state')
def _compute_planning_hours_to_plan(self):
# Override the method from sale_planning to use time estimates from the task
# template if appropriate. Also compute the value for visit lines.
planning_lines = self.filtered_domain([
('product_id.planning_enabled', '=', True),
('state', 'not in', ['draft', 'sent'])
])
for line in planning_lines:
line.planning_hours_to_plan = line.task_duration
for line in self - planning_lines:
line.planning_hours_to_plan = 0.0
@api.depends('product_id', 'visit_id')
def _compute_task_duration(self):
uom_hour = self.env.ref('uom.product_uom_hour')
uom_unit = self.env.ref('uom.product_uom_unit')
templated_lines = self.filtered(lambda l: l.product_id.task_template_id
and l.product_id.task_template_id.
planned_hours)
visit_lines = self.filtered(lambda l: l.visit_id)
regular_lines = self - templated_lines - visit_lines
for line in regular_lines:
if line.product_uom == uom_hour or line.product_uom == uom_unit:
line.task_duration = line.product_uom_qty
else:
line.task_duration = float_round(
line.product_uom._compute_quantity(line.product_uom_qty, uom_hour,
raise_if_failure=False),
precision_digits=2)
for line in templated_lines:
line.task_duration = line.product_id.task_template_id.planned_hours
if line.product_uom_category_id == self.env.ref(
'uom.product_uom_unit').category_id:
line.task_duration *= line.product_uom_qty
visit_lines = self.filtered(lambda l: l.visit_id)
for line in visit_lines:
line.task_duration = sum(line.get_section_line_ids().
mapped('task_duration'))

View file

@ -1,11 +1,18 @@
from odoo import fields, models, api, Command, _
from odoo.exceptions import ValidationError
from odoo.exceptions import ValidationError, UserError
from odoo.osv import expression
from collections import defaultdict, namedtuple
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,7 +30,87 @@ class Task(models.Model):
inverse="_inverse_contacts",
store=True)
@api.depends('sale_line_id.order_id.site_contacts', 'sale_line_id.order_id.work_order_contacts')
planned_date_begin = fields.Datetime("Start Date", tracking=True,
task_dependency_tracking=True,
compute="_compute_planned_dates",
inverse="_inverse_planned_dates",
store=True)
planned_date_end = fields.Datetime("End Date", tracking=True,
task_dependency_tracking=True,
compute="_compute_planned_dates",
inverse="_inverse_planned_dates",
store=True)
# Override related field to make it return false if this is an FSM subtask
allow_billable = fields.Boolean(string="Can be billed",
related=False,
compute="_compute_allow_billable",
store=True)
visit_id = fields.Many2one(comodel_name='bemade_fsm.visit')
relevant_order_lines = fields.Many2many(comodel_name='sale.order.line',
store=False,
compute='_compute_relevant_order_lines',)
@api.depends('sale_order_id')
def _compute_relevant_order_lines(self):
for rec in self:
rec.relevant_order_lines = rec.sale_order_id \
and rec.sale_order_id.get_relevant_order_lines(rec)
def _get_closed_stage_by_project(self):
""" Gets the stage representing completed tasks for each project in
self.project_id. Copied from industry_fsm/.../project.py:217-221
for consistency.
:returns: Dict of project.project -> project.task.type"""
return {
project:
project.type_ids.filtered(lambda stage: stage.is_closed)[:1]
or project.type_ids[-1:]
for project in self.project_id
}
def _get_related_planning_slots(self):
domain = expression.AND([
self._get_domain_compute_forecast_hours(),
[('task_id', 'in', self.ids + self._get_all_subtasks().ids)]
])
return self.env['planning.slot'].search(domain)
@api.depends('forecast_hours')
def _compute_planned_dates(self):
forecast_data = self._get_related_planning_slots()
mapped_data = {}
TimeSpan = namedtuple('timespan', ['start', 'end'])
for d in forecast_data:
if d not in mapped_data:
mapped_data.update({d: TimeSpan(d.start_datetime, d.end_datetime)})
continue
if mapped_data[d].start > d.start_datetime:
mapped_data[d].start = d.start_datetime
if mapped_data[d].end < d.end_datetime:
mapped_data[d].end = d.end_datetime
for rec in self:
if rec not in mapped_data:
if not rec.planned_date_end:
rec.planned_date_end = False
if not rec.planned_date_begin:
rec.planned_date_begin = False
continue
rec.planned_date_begin, rec.planned_date_end = mapped_data[rec]
def _inverse_planned_dates(self):
""" Modifying the planned dates for tasks with existing planning records
(planning.slot) has no defined safe behaviour, so we block it."""
if self._get_related_planning_slots():
raise UserError(_("Modifying the planned start or end time on a task is not "
"permitted when that task already has planning records "
"associated to it. Please modify or delete the planning "
"records instead."))
@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
is related to one, and from the task's customer if there is no sale order related to the task."""
@ -45,3 +132,53 @@ 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.visit_id', 'project_id.is_fsm', 'project_id.allow_billable')
def _compute_allow_billable(self):
for rec in self:
# If an FSM task has a parent that is linked to an SO line, then the parent is the billable one
if rec.parent_id and not rec.parent_id.visit_id and rec.project_id and rec.project_id.is_fsm:
rec.allow_billable = False
else:
rec.allow_billable = rec.project_id.allow_billable
def action_fsm_validate(self):
visits = self.filtered(lambda t: t.visit_id)
non_visits = self - visits
super(Task, non_visits).action_fsm_validate()
visits._stop_all_timers_and_create_timesheets()
closed_stage_by_project = visits._get_closed_stage_by_project()
super(Task, visits.child_ids).action_fsm_validate()
for visit in visits:
stage = closed_stage_by_project[visit.project_id]
visits.write({'stage_id': stage.id, 'fsm_done': True})
def synchronize_name_fsm(self):
""" Applies naming to the entire task tree for tasks that are part of this
recordset. Root tasks are named:
SOXXXXX: Partner Shipping Name - Sale Line Name (Template Name)
Child tasks with sale_line_id are named by their template if set, sale line name
if not.
Child tasks not linked to sale lines are left with their original names."""
all_tasks = self | self._get_all_subtasks()
for rec in all_tasks:
assert rec.is_fsm, "This method should only be called on FSM tasks."
template = rec.sale_line_id and rec.sale_line_id.product_id.task_template_id
if not rec.parent_id:
rec.name = f"{rec.sale_order_id.name}: " \
f"{rec.sale_order_id.partner_shipping_id.name} - " \
f"{rec.sale_line_id.name}"
if template:
rec.name += f" ({template.name})"
else:
rec.name = f"{template.name or rec.sale_line_id.name or rec.name}"
@property
def root_ancestor(self):
return self.parent_id and self.parent_id.root_ancestor or self

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

@ -0,0 +1,218 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<template id="work_order_page">
<div class="page">
<div class="row">
<div t-attf-class="{{'col-6' if report_type == 'pdf' else 'col-md-6 col-12'}}">
<div t-if="doc.user_ids"><strong>Technicians: </strong></div>
<t t-foreach="doc.user_ids" t-as="user">
<div class="mb-3">
<div t-esc="user" t-options='{
"widget": "contact",
"fields": ["name", "address", "phone", "email"]
}'/>
</div>
</t>
</div>
<div t-attf-class="{{('col-6' if report_type == 'pdf' else 'col-md-6 col-12') + ' mb-3'}}">
<t t-if="doc.partner_id">
<div><strong>Customer: </strong></div>
<div t-esc="doc.partner_id" t-options='{
"widget": "contact",
"fields": ["name", "address",]
}'/>
</t>
</div>
</div>
<t t-set="order_lines" t-value="doc.relevant_order_lines"/>
<t t-if="order_lines">
<t t-set="visit_lines" t-value="order_lines.mapped('visit_id')"/>
<t t-if="visit_lines" t-set="root_tasks"
t-value="visit_lines.mapped('task_id')"/>
<t t-else="" t-set="root_tasks"
t-value="order_lines.mapped('task_id').filtered(lambda l: not l.parent_id)"/>
<t t-set="final_subtotal" t-value="0"/>
<t t-set="final_tax" t-value="0"/>
<t t-set="final_total" t-value="0"/>
<t t-set="is_any_total_discount_line" t-value="False"/>
<t t-set="section_lines" t-value="order_lines.filtered(lambda l: l.display_type == 'line_section')"/>
<t t-foreach="order_lines" t-as="line">
<t t-set="final_subtotal"
t-value="final_subtotal + line.delivered_price_subtotal"/>
<t t-set="final_total"
t-value="final_total + line.delivered_price_total"/>
<t t-set="final_tax"
t-value="final_tax + line.delivered_price_tax"/>
<t t-set="is_any_total_discount_line"
t-value="is_any_total_discount_line or (line.discount and line.price_unit != 0 and line.delivered_price_total == 0)"/>
</t>
<t t-set="display_discount"
t-value="any(line.discount for line in order_lines)"/>
</t>
<t t-else="" t-set="root_tasks"
t-value="doc.root_ancestor"/>
<h2 t-if="order_lines">Time &amp; Material</h2>
<div t-if="order_lines" class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th class="text-left">Description</th>
<th class="text-right">Ordered</th>
<th class="text-right">Delivered</th>
<th class="text-right">Unit Price</th>
<th t-if="display_discount" class="text-right"
groups="product.group_discount_per_so_line">
<span>Disc.%</span>
</th>
<th class="text-right">
<span groups="account.group_show_line_subtotals_tax_excluded">
Amount</span>
<span groups="account.group_show_line_subtotals_tax_included">
Total Price</span>
</th>
</tr>
</thead>
<tbody class="sale_tbody">
<t t-set="current_subtotal" t-value="0"/>
<t t-foreach="order_lines" t-as="line">
<t t-set="is_task" t-value="line == doc.sale_line_id"/>
<t t-set="is_total_discount"
t-value="line.discount and line.price_unit != 0 and line.delivered_price_total == 0"/>
<t t-set="current_subtotal"
t-value="current_subtotal + line.delivered_price_subtotal"
groups="account.group_show_line_subtotals_tax_excluded"/>
<t t-set="current_total"
t-value="current_subtotal + line.delivered_price_total"
groups="account.group_show_line_subtotals_tax_included"/>
<tr t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''">
<t t-if="not line.display_type and not line.is_downpayment">
<td><span t-field="line.name"/></td>
<td class="text-right">
<span t-field="line.product_uom_qty"/>
<span t-field="line.product_uom"
groups="uom.group_uom"/>
</td>
<td class="text-right">
<span t-field="line.qty_delivered"/>
<span t-field="line.product_uom"
groups="uom.group_uom"/>
</td>
<td class="text-right">
<span t-field="line.price_unit"/>
</td>
<td t-if="display_discount"
class="text-right"
groups="product.group_discount_per_so_line">
<span t-field="line.discount"/>
</td>
<td class="text-right o_price_total">
<span t-field="line.delivered_price_subtotal"/>
</td>
</t>
<t t-if="line.display_type == 'line_section'">
<td colspan="99">
<span t-field="line.name"/>
</td>
<t t-set="current_section" t-value="line"/>
<t t-set="current_subtotal" t-value="0"/>
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
<span t-field="line.name"/>
</td>
</t>
</tr>
<t t-if="current_section and len(section_lines) > 1 and (line_last or order_lines[line_index+1].display_type == 'line_section')">
<tr class="is-subtotal text-right">
<td colspan="99">
<strong class="mr16">Section Subtotal</strong>
<span
t-esc="current_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'
/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
</div>
<div class="container_subtotal" name="so_total_summary">
<div id="total" class="row justify-content-end" name="total">
<div t-attf-class="#{'col-auto' if report_type != 'html' else 'col-sm-2'}">
<table class="table table-sm">
<tr t-if="final_tax" class="border-black o_subtotal" style="">
<td><strong>Untaxed amount</strong></td>
<td class="text-right">
<span t-esc="final_subtotal" t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'/>
</td>
</tr>
<tr t-if="final_tax">
<td><strong>Taxes</strong></td>
<td class="text-right">
<span t-esc="final_tax" t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'/>
</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td class="text-right">
<span t-esc="final_total" t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'/>
</td>
</tr>
</table>
</div>
</div>
</div>
<div t-if="doc.worksheet_signature">
<div t-if="report_type == html" class="ribbon" style="
position: absolute;
right: 0px; top: 0px;
z-index: 1;
overflow: hidden;
width: 75px; height: 75px;
text-align: right;">
<span style="
font-size: 10px;
color: #fff;
text-transform: uppercase;
text-align: center;
font-weight: bold; line-height: 20px;
transform: rotate(45deg);
width: 100px; height: auto; display: block;
background: green;
position: absolute;
top: 19px; right: -21px; left: auto;
padding: 0;">
Signed
</span>
</div>
<div t-attf-class="#{'col-12 col-lg-3' if report_type != 'html' else 'col-sm-7 col-md-4'} ml-auto text-right" style="page-break-inside: avoid">
<h5>Signature</h5>
<img t-att-src="image_data_uri(doc.worksheet_signature)" style="max-height: 6rem; max-width: 100%; color:black;"/><br/>
<span t-field="doc.worksheet_signed_by"/>
</div>
</div>
</div>
</template>
<template id="work_order">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-call="bemade_fsm.work_order_page"
t-lang="doc.partner_id.lang"/>
</t>
</t>
</t>
</template>
<!-- Make the front-end "Sign" page show our new template -->
<template id="portal_my_worksheet"
inherit_id="industry_fsm_report.portal_my_worksheet">
<xpath expr="//div[@t-call='industry_fsm_report.worksheet_custom_page']"
position="replace">
<div t-call="bemade_fsm.work_order_page"/>
</xpath>
</template>
</data>
</odoo>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<record id="industry_fsm_report.task_custom_report" model="ir.actions.report">
<field name="name">Work Order Report (PDF)</field>
<field name="report_name">bemade_fsm.work_order</field>
<field name="report_file">bemade_fsm.work_order</field>
<field name="print_report_name">'%s Work Order %s' % (
time.strftime('%Y-%m-%d'), object.name)</field>
<field name="report_type">qweb-pdf</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record id="default_worksheet_template" model="ir.actions.report">
<field name="name">Complete Worksheet Report (PDF)</field>
<field name="model">project.task</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">bemade_fsm.worksheet_complete</field>
<field name="print_report_name">
'%s Worksheet %s' % (time.strftime('%Y-%m-%d'), object.name)
</field>
<field name="binding_model_id" ref="model_project_task"/>
<field name="binding_type">report</field>
</record>
<template id="worksheet_complete">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="bemade_fsm.worksheet_complete_page"
t-lang="doc.partner_id.lang"/>
</t>
</t>
</template>
<template id="worksheet_complete_page">
<t t-call="web.external_layout">
<t t-set="address" t-if="doc.sale_order_id">
<strong class="big-red">Customer:</strong>
<div t-field="doc.sale_order_id.partner_id"
t-options='{"widget": "contact",
"fields": ["address", "name"], "no_marker": True}'/>
</t>
<t t-set="information_block">
<strong>Service Address:</strong>
<div t-field="doc.partner_id"
t-options='{"widget": "contact",
"fields": ["address", "name", "phone"],
"no_marker": True, "phone_icons": True}'/>
</t>
<div class="page">
<div class="oe_structure"/>
<h2 class="mt-3">
<span>Work Order </span>
<span t-if="doc.sale_order_id" t-field="doc.sale_order_id.name"/>
<span t-else="" t-field="doc.name"/>
</h2>
<div class="container">
<div class="row mb-3 border-dark">
<div t-if="doc.sale_order_id.client_order_ref" class="col-4">
<span class="font-weight-bolder">Your Reference:</span>
<span t-field="doc.sale_order_id.client_order_ref"/>
</div>
<div t-if="doc.site_contacts" class="col-4">
<span class="font-weight-bolder">Site Contacts:</span>
<t t-foreach="doc.site_contacts" t-as="contact">
<span t-field="contact.name"/>
<span t-out="contact"
t-options='{"widget": "contact",
"fields": ["phone", "mobile", "email"],
"no_marker": True, "phone_icons": True}'/>
</t>
</div>
<div t-if="doc.work_order_contacts" class="col-4">
<span class="font-weight-bolder">Work order attn:</span>
<t t-foreach="doc.work_order_contacts" t-as="contact">
<span t-field="contact.name"/>
<span t-out="contact"
t-options='{"widget": "contact",
"fields": ["phone", "mobile", "email"],
"no_marker": True, "phone_icons": True}'/>
</t>
</div>
</div>
<div class="row mb-3 border-dark">
<div class="col-4" t-if="doc.user_ids">
<span class="font-weight-bolder">Technician(s)</span>
<t t-foreach="doc.user_ids" t-as="technician">
<p class="m-0" t-field="technician.name"/>
</t>
</div>
<div class="col-4" t-if="doc.planned_date_begin">
<span class="font-weight-bolder">Planned Start</span>
<p t-field="doc.planned_date_begin"
t-options="{'widget': 'datetime'}"/>
</div>
<div class="col-4" t-if="doc.planned_date_end">
<span class="font-weight-bolder">Planned End</span>
<p t-field="doc.planned_date_end"
t-options="{'widget': 'datetime'}"/>
</div>
</div>
</div>
<div class="container">
<h3 class="mb32"><strong>Interventions</strong></h3>
<t t-foreach="doc.child_ids" t-as="intervention">
<div class="row">
<div class="col-6">
<h4><span t-field="intervention.name"/></h4>
<span t-if="intervention.description"
t-field="intervention.description"/>
</div>
<div t-if="intervention.equipment_ids"
class="col-6">
<h4>Equipment: </h4>
<t t-foreach="intervention.equipment_ids"
t-as="equipment">
<span t-field="equipment.complete_name"/>
<span t-if="equipment_index &lt; equipment_size -1"
t-out="', '"/>
</t>
</div>
</div>
<div class="row">
<table class="table table-borderless col-12">
<thead>
<th>Done</th>
<th>Task to do</th>
</thead>
<tbody>
<t t-foreach="intervention.child_ids"
t-as="task">
<tr class="wo-task">
<td style="width: 5%">
<input type="checkbox"
t-att-checked="task.is_complete"/>
</td>
<td style="width: 95%">
<p t-field="task.name"/>
<p t-field="task.description"/>
</td>
<!-- TODO: Figure out what to do with the old
concept of task comments -->
</tr>
</t>
</tbody>
</table>
</div>
</t>
</div>
</div>
</t>
</template>
</data>
</odoo>

View file

@ -1,6 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bemade_fsm_task_template,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,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_task_template_manager,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1
access_bemade_fsm_task_template_user,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_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
2 access_bemade_fsm_task_template access_bemade_fsm_task_template_manager bemade_fsm_task_template model_project_task_template project.group_project_manager 1 1 1 1
3 access_bemade_fsm_task_template access_bemade_fsm_task_template_user bemade_fsm_task_template model_project_task_template project.group_project_user 1 1 1 1
4 access_bemade_fsm_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

@ -0,0 +1,45 @@
.border-black {
border: 1px solid black;
}
.wo-information{
border: 1px solid black;
.row{
.col-auto{
border: 1px solid black;
}
}
page-break-inside: avoid;
}
.wo-intervention{
border: 1px solid black;
margin-bottom: 10px;
padding-bottom: 10px;
page-break-inside: avoid;
}
.wo-parts-used{
page-break-inside: avoid;
}
.wo-task{
border: 1px solid black;
margin-bottom: 10px;
padding-bottom: 10px;
page-break-inside: avoid;
}
.border-top-black{
border-top: 1px solid black;
}
.text-left {
text-align: left;
}
.manual_line_input {
border-bottom: 1px solid black;
margin: 30px 0px 0px 0px;
width: 100%;
}

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 () {
},
@ -100,13 +98,7 @@ tour.register('equipment_sale_order_tour', {
content: 'Navigate to the Field Service tab.',
trigger: 'a.nav-link[role="tab"]:contains(Field Service)'
}, {
content: 'Click Edit',
trigger: 'button.o_form_button_edit',
}, {
content: 'Click the equipment dropdown.',
trigger: '.o_field_widget[name="equipment_id"] input',
}, {
content: 'Check that the equipment is in the dropdown.',
content: 'Check that the equipment is listed in the tab.',
trigger: `li.ui-menu-item > a:contains(${TEST_EQPT1})`,
}
]);

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,192 @@ 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, shipping_location=None):
partner = partner or cls._generate_partner()
vals = {'partner_id': partner.id,
'client_order_ref': client_order_ref}
if equipment:
vals.update({'default_equipment_ids': [Command.set([equipment.id])]})
if shipping_location:
vals.update({'partner_shipping_id': shipping_location.id})
return cls.env['sale.order'].create(vals)
@classmethod
def _generate_sale_order_line(cls, sale_order, product=None, qty=1.0, uom=None, price=100.0, tax_id=False):
if not product:
product = cls._generate_product()
return cls.env['sale.order.line'].create({
'order_id': sale_order.id,
'product_id': product.id,
'product_uom_qty': qty,
'product_uom': uom and uom.id or cls.env.ref("uom.product_uom_hour").id,
'price_unit': price,
'tax_id': tax_id,
})
@classmethod
def _generate_equipment(cls, name='test equipment', partner=None):
return cls.env['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, customer=None):
""" Generates a task template with the specified structure and naming.
:param parent: The parent task template for the top-level task template being generated
:param structure: A list of integers describing the number of tasks for each level of descendants. An empty
list represents only one top-level task template. If no structure is given, an empty list
will be used in its place.
:param names: The name prefixes to be given to the task templates at each level. Each prefix will be followed
by a sequential integer for its level. Child 1, Child 2, Grandchild 1, etc. If no names argument
is passed, a default ['Task Template'] argument will be used.
:param planned_hours: The number of planned hours for the top-level task template being generated.
:param equipment: The equipment to add as linked equipment to the task template."""
if not names:
names = ['Task Template']
if not structure:
structure = []
if len(structure) != len(names) - 1:
raise ValueError("The length of the structure argument must contain exactly one element less than the "
"names argument.")
name = names.pop(0)
template = cls.env['project.task.template'].create({
'name': name,
'parent': parent and parent.id or False,
'planned_hours': planned_hours,
'equipment_ids': [Command.set(equipment and [equipment.id] or [])],
'customer': customer and customer.id or False,
})
parent = template
while structure:
subtasks = []
for i in range(0, structure[0]):
subtasks.append(cls.env['project.task.template'].create({
'parent': parent.id,
'name': names[0] + f" {i}",
}))
structure.pop(0)
names.pop(0)
parent = subtasks[0]
return template
def _invoice_sale_order(self, so):
wiz = self.env['sale.advance.payment.inv'].with_context(
{'active_ids': [so.id]}).create({})
wiz.create_invoices()
inv = so.invoice_ids[-1]
inv.action_post()
return inv
def _generate_visit(self, sale_order, label="Test Label"):
return self.env['bemade_fsm.visit'].create([{
'sale_order_id': sale_order.id,
'label': label,
}])
def _generate_so_with_one_visit_two_lines(self):
so = self._generate_sale_order()
visit = self._generate_visit(sale_order=so)
sol1 = self._generate_sale_order_line(sale_order=so)
sol2 = self._generate_sale_order_line(sale_order=so)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
return so, visit, sol1, sol2

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,124 @@
from odoo.tests import TransactionCase, tagged, Form
from .test_bemade_fsm_common import BemadeFSMBaseTest
from datetime import date, timedelta
@tagged('-at_install', 'post_install')
class FSMVisitTest(BemadeFSMBaseTest):
def test_create_visit_sets_name_on_section(self):
so = self._generate_sale_order()
self._generate_sale_order_line(sale_order=so)
visit = self._generate_visit(so)
self.assertTrue(visit.so_section_id)
self.assertEqual(visit.so_section_id.name, visit.label)
def test_change_visit_section_name(self):
so = self._generate_sale_order()
visit = self._generate_visit(so, label="First Label")
line = visit.so_section_id
line.name = "Second Label"
self.assertEqual(visit.label, "Second Label")
def test_change_visit_label_changes_section_name(self):
so = self._generate_sale_order()
visit = self._generate_visit(so, label="First Label")
line = visit.so_section_id
visit.label = "Second Label"
self.assertEqual(line.name, "Second Label")
def test_visit_completes_when_task_completes(self):
so = self._generate_sale_order()
visit = self._generate_visit(so)
self._generate_sale_order_line(so)
so.action_confirm()
task = so.order_line.filtered(lambda 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._generate_sale_order_line(so)
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 test_visit_groups_section_tasks_when_confirmed(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit_task = visit.task_id
self.assertTrue(visit_task)
visit_subtasks = visit_task.child_ids
self.assertTrue(
visit_subtasks and sol1.task_id in visit_subtasks and sol2.task_id in visit_subtasks)
def test_visit_task_gets_correct_due_date_on_confirmation(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit_task = visit.task_id
self.assertEqual(visit_task.date_deadline, visit.approx_date)
def test_visit_task_gets_correct_duration_on_confirmation(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
visit = self._generate_visit(sale_order=so)
sol1 = self._generate_sale_order_line(sale_order=so, qty=4.0)
sol2 = self._generate_sale_order_line(sale_order=so, qty=4.0)
visit.approx_date = date.today() + timedelta(days=7)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
so.action_confirm()
visit_task = visit.task_id
self.assertEqual(visit_task.planned_hours, 8.0)
def test_adding_visit_creates_one_sale_order_line(self):
partner = self._generate_partner()
so = self._generate_sale_order()
self._generate_sale_order_line(sale_order=so)
self._generate_sale_order_line(sale_order=so)
self._generate_visit(sale_order=so)
self.assertEqual(len(so.order_line), 3)
def test_marking_visit_task_done_completes_descendants(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
parent, child1, child2 = visit.task_id, sol1.task_id, sol2.task_id
parent.action_fsm_validate()
self.assertTrue(parent.is_closed)
self.assertTrue(child1.is_closed)
self.assertTrue(child2.is_closed)
self.assertEqual(sol1.qty_to_deliver, 0)
self.assertEqual(sol2.qty_to_deliver, 0)
self.assertTrue(visit.is_completed)
def test_marking_visit_task_done_does_not_create_sale_order_line(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit.task_id.action_fsm_validate()
self.assertEqual(len(so.order_line), 3)

View file

@ -1,97 +1,277 @@
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]
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_task_template_tree_order_confirmation(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
parent_template = self._generate_task_template(structure=[2, 1],
names=['Parent Template',
'Child Template',
'Grandchild Template'])
child_template_1= parent_template.subtasks[0]
child_template_2 = parent_template.subtasks[1]
grandchild_template = parent_template.subtasks[0].subtasks[0]
product = self._generate_product(task_template=parent_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
parent = sol.task_id
c1, c2 = parent.child_ids
gc = c1.child_ids[:1]
self.assertTrue(parent.child_ids and len(parent.child_ids) == 2)
self.assertTrue(parent_template.name in parent.name)
self.assertEqual(child_template_1.name, c1.name)
self.assertEqual(child_template_2.name, c2.name)
self.assertTrue(c1.child_ids and len(c1.child_ids) == 1)
self.assertEqual(grandchild_template.name, gc.name)
def test_order_confirmation_single_equipment(self):
""" The equipment selected on the SO should transfer to the task."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner, equipment=equipment)
task_template = self._generate_task_template(planned_hours=8)
product1 = self._generate_product(task_template=task_template)
product2 = self._generate_product()
sol1 = self._generate_sale_order_line(so, product=product1)
sol2 = self._generate_sale_order_line(so, product=product2)
so.action_confirm()
task1 = sol1.task_id
task2 = sol2.task_id
self.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)
self.assertEqual(task1.equipment_ids[0], equipment)
self.assertEqual(task2.equipment_ids[0], equipment)
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)
def test_order_confirmation_multiple_equipment(self):
""" All equipment items should flow from the sale order line to the final task """
partner = self._generate_partner()
for i in range(5):
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(
partner=partner) # No default equipment since more than 3 on partner
sol1, sol2, sol3 = [self._generate_sale_order_line(sale_order=sale_order) for i
in range(3)]
sol1.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2)])]
sol3.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2, 5)])]
sale_order.action_confirm()
self.assertEqual(sol1.equipment_ids, sol1.task_id.equipment_ids)
self.assertEqual(sol2.equipment_ids, sol2.task_id.equipment_ids)
self.assertEqual(sol3.equipment_ids, sol3.task_id.equipment_ids)
def test_task_template_with_equipment_flow(self):
""" The equipment selected on a task template should flow down to the task created on SO confirmation."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(equipment=equipment)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so = self.sale_order2
so.action_confirm()
sol1 = so.order_line[0]
sol2 = so.order_line[1]
assert_structure(sol1)
assert_structure(sol2)
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
self.assertEqual(sol.task_id.equipment_ids[0], equipment)
def test_sale_order_line_gets_default_equipment(self):
""" Sale order lines created on an SO with default equipment set should inherit that default equipment. """
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
sol = self._generate_sale_order_line(sale_order=sale_order)
self.assertEqual(sol.equipment_ids, partner.equipment_ids)
def test_sale_order_gets_correct_default_equipment_from_partner(self):
""" Should pick up equipment from the partner."""
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
self.assertEqual(sale_order.default_equipment_ids, partner.owned_equipment_ids)
def test_sale_order_default_equipment_maximum_number(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(3):
self._generate_equipment(child)
sale_order = self._generate_sale_order(partner=parent)
self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids)
def test_sale_order_no_default_equipment_with_more_than_three_owned_on_partner(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(4):
self._generate_equipment(child)
sale_order = self._generate_sale_order(partner=parent)
self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids)
def test_sale_order_resets_default_equipment_on_partner_change(self):
partner_1 = self._generate_partner()
partner_2 = self._generate_partner()
self._generate_equipment(partner=partner_1)
sale_order = self._generate_sale_order(partner_1)
form = Form(sale_order)
form.partner_id = partner_2
form.save()
self.assertFalse(sale_order.default_equipment_ids)
def test_sale_order_prioritize_shipping_location_equipments(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent, location_type='delivery')
self._generate_equipment(partner=parent)
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent, shipping_location=child)
self.assertEqual(sale_order.default_equipment_ids, child.equipment_ids)
def test_default_equipment_transfers_to_sale_order_line(self):
partner = self._generate_partner()
for i in range(3):
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
for i in range(3):
self._generate_sale_order_line(sale_order=sale_order)
for line in sale_order.order_line:
self.assertEqual(line.equipment_ids, partner.equipment_ids)
def test_task_mark_done(self):
""" Marking the task linked to 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()
task = so.order_line[0].task_id
self.assertTrue(task.equipment_id == equipment)
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)
def test_tasks_created_at_order_confirmation_have_no_assignees(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
user = self._generate_project_user(name="User", login='login')
# We test as a specific user since testing as root may not produce the error
so.with_user(user).action_confirm()
visit_task = visit.task_id
subtask1 = visit_task.child_ids[0]
subtask2 = visit_task.child_ids[1]
self.assertFalse(visit_task.user_ids)
self.assertFalse(subtask1.user_ids)
self.assertFalse(subtask2.user_ids)
@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,109 +1,91 @@
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 odoo.tools import mute_logger
from psycopg2.errors import ForeignKeyViolation
@tagged("-at_install", "post_install")
class TestTaskTemplateCommon(FSMManagerUserTransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.PLANNED_HOURS = 6
hours_uom = cls.env['uom.uom'].search([('name', '=', 'Hour')]) or False
# Test product to use with the various tests
cls.task1 = cls.env['project.task.template'].create({
'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,
})
# 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])]})
# 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,
})
@tagged('-at_install', 'post_install')
class TestTaskTemplate(TestTaskTemplateCommon):
class TestTaskTemplate(BemadeFSMBaseTest):
def test_delete_task_template(self):
"""User should never be able to delete a task template used on a product"""
task_template = self._generate_task_template(names=['Template 1'])
product = self._generate_product(name="Test Product 1", task_template=task_template)
with self.assertRaises(ForeignKeyViolation):
self.task1.unlink()
with mute_logger('odoo.sql_db'):
task_template.unlink()
def test_delete_subtask_template(self):
""" Deletion of a child task should be OK even if the parent is on a product. Children of the deleted
subtask should be deleted."""
self.child_task_2.unlink()
parent_task = self._generate_task_template(structure=[2, 1],
names=['Parent Template', 'Child Template',
'Grandchild Template'])
grandchild_task = parent_task.subtasks[0].subtasks[0]
parent_task.subtasks[0].unlink()
# Reading deleted child's name field should be impossible
with self.assertRaises(MissingError):
test = self.grandchild_task.name
test = grandchild_task.name
def test_dissociating_customer_resets_equipment_appropriately(self):
partner1 = self._generate_partner()
partner2 = self._generate_partner()
equipment1 = self._generate_equipment(partner=partner1)
task = self._generate_task_template(customer=partner1, equipment=equipment1)
form = Form(task)
# Switching the partner should trigger on_change that makes sure equipments are linked to the new partner
form.customer = partner2
form.save()
self.assertFalse(equipment1 in task.equipment_ids)
def test_hours_estimate_used_for_planning(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(planned_hours=8)
product = self._generate_product(uom=self.env.ref('uom.product_uom_unit'), task_template=task_template)
sol = self._generate_sale_order_line(sale_order=so, product=product)
self.assertEqual(sol.task_duration, 8)
def test_hours_estimate_multiplied_for_multiple_units_sold(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(planned_hours=8)
product = self._generate_product(uom=self.env.ref('uom.product_uom_unit'),
task_template=task_template)
sol = self._generate_sale_order_line(sale_order=so, product=product, qty=3.0)
self.assertEqual(sol.task_duration, 24)
def test_child_task_names_are_short_version(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
template = self._generate_task_template(names=['Task'])
product = self._generate_product(task_template=template)
sol1.name = "Short Name 1"
sol2.name = "Short Name 2"
sol3 = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
self.assertEqual(sol1.task_id.name, "Short Name 1")
self.assertEqual(sol2.task_id.name, "Short Name 2")
self.assertEqual(sol3.task_id.name, "Task")
@tagged('-at_install', 'post_install')
class TestTaskTemplateTour(HttpCase, TestTaskTemplateCommon):
@tagged('-at_install', 'post_install', 'slow')
class TestTaskTemplateTour(HttpCase, BemadeFSMBaseTest):
def test_task_template_tour(self):
user = self._generate_project_manager_user('Mister PM', 'misterpm')
task_template = self._generate_task_template(names=['Template 1'])
self._generate_product(name="Test Product 1", task_template=task_template)
self.start_tour('/web', 'task_template_tour',
login='misterpm', )
login=user.login, )

View file

@ -15,7 +15,6 @@
<field name="location_notes"/>
</group>
<group name="right">
<field name="partner_id"/>
<field name="partner_location_id"
groups="sale.group_delivery_invoice_address"
context="{'default_type': 'delivery', 'show_address': 1}"
@ -23,19 +22,6 @@
<field name="tag_ids" widget="many2many_tags" options="{'no_open': False}"/>
</group>
</group>
<!-- <notebook>-->
<!-- <page string="Interventions">-->
<!-- <field name="intervention_ids" mode="tree">-->
<!-- <tree string="Interventions" create="0" edit="0">-->
<!-- <field name="name"/>-->
<!-- <field name="description"/>-->
<!-- <field name="date_planned"/>-->
<!-- <field name="state"/>-->
<!-- </tree>-->
<!-- </field>-->
<!-- </page>-->
<!-- </notebook>-->
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers"/>
@ -50,36 +36,16 @@
<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>
<!-- BV: AN OTHER SEARCH VIEW TO BRING BACK
<record id="equipment_view_search" model="ir.ui.view">
<field name="name">bemade_fsm.equipment.search</field>
<field name="model">bemade_fsm.equipment</field>
<field name="arch" type="xml">
<search string="Equipments">
<field name="name"/>
<field name="partner_id"/>
<field name="pid_tag"/>
<field name="partner_location_id"/>
<group expand="0" string="Group By">
<filter string="Partner" context="{'group_by':'partner_id'}"/>
<filter string="Location" context="{'group_by':'partner_location_id'}"/>
</group>
</search>
</field>
</record>
-->
<record model="ir.actions.act_window" id="action_window_equipment">
<field name="name">Equipment</field>
<field name="res_model">bemade_fsm.equipment</field>

View file

@ -11,6 +11,9 @@
<field name="task_template_id"
attrs="{'invisible': [('service_tracking', 'not in', ('task_global_project', 'task_in_project'))]}"/>
</xpath>
<xpath expr="//field[@name='planning_enabled']" position="after">
<field name="is_field_service"/>
</xpath>
</field>
</record>
<record id="product_search_form_view_inherit_bemade_fsm" model="ir.ui.view">

View file

@ -8,17 +8,45 @@
<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">
<field name="equipment_id"
domain="[('partner_location_id', '=', partner_shipping_id)]"
context="{'default_partner_location_id': partner_shipping_id,}"/>
<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="valid_equipment_ids" invisible="1"/>
<field name="summary_equipment_ids"
context="{'default_partner_location_id': partner_shipping_id,}"
widget="many2many_tags"/>
<field name="site_contacts"
context="{'tree_view_ref': 'bemade_fsm.fsm_contacts_view_tree'}"/>
<field name="work_order_contacts"
context="{'tree_view_ref': 'bemade_fsm.fsm_contacts_view_tree'}"/>
<field name="default_equipment_ids"
context="{'default_partner_location_id': partner_shipping_id,}"
widget="many2many_tags"
domain="[('id', 'in', valid_equipment_ids)]"/>
</group>
</page>
</xpath>
<xpath expr="//tree//field[@name='name']" position="after">
<field name="valid_equipment_ids" invisible="1"/>
<field name="equipment_ids"
widget="many2many_tags"
domain="[('id', 'in', valid_equipment_ids)]"/>
</xpath>
</field>
</record>
<record id="bemade_fsm_visit_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>

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,15 +4,15 @@
<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)]"/>
</xpath>
<xpath expr="//page[@name='extra_info']" position="after">
<page string="Field Service" name="field_service">
<group name="contacts">
<group name="equipment_and_contacts">
<field name="equipment_ids"
domain="[('partner_location_id', '=', partner_id)]"
context="{'tree_view_ref': 'bemade_fsm.equipment_view_tree'}"/>
<field name="site_contacts"
context="{'tree_view_ref': 'bemade_fsm.fsm_contacts_view_tree'}"/>
<field name="work_order_contacts"
@ -20,6 +20,16 @@
</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-->
@ -29,48 +39,79 @@
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_fsm_map" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_fsm_map"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_to_schedule_fsm" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_to_schedule_fsm"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_all_fsm" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_all_fsm"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm_sale.project_task_action_to_invoice_fsm" model="ir.actions.act_window">
<record id="industry_fsm_sale.project_task_action_to_invoice_fsm"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<!-- Add parent_id = false to domain for planning actions as well -->
<record id="industry_fsm.project_task_action_fsm_planning_groupby_user" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_fsm_planning_groupby_user"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_fsm_planning_groupby_project" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_fsm_planning_groupby_project"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm_report.project_task_action_fsm_planning_groupby_worksheet" model="ir.actions.act_window">
<record id="industry_fsm_report.project_task_action_fsm_planning_groupby_worksheet"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<!-- <record id="project_task_view_calendar_fsm" model="ir.ui.view">-->
<!-- <field name="name">bemade_fsm.project_task_view_calendar_fsm</field>-->
<!-- <field name="inherit_id" ref="industry_fsm.project_task_view_calendar_fsm"/>-->
<!-- <field name="model">project.task</field>-->
<!-- <field name="arch" type="xml">-->
<!-- <field name="user_ids" position="attributes">-->
<!-- <attribute name="filters">1</attribute>-->
<!-- <attribute name="color">user_ids</attribute>-->
<!-- </field>-->
<!-- </field>-->
<!-- </record>-->
<record id="project_task_view_calendar_fsm_no_worksheet" model="ir.ui.view">
<field name="name">bemade_fsm.project_task_view_calendar_no_worksheet</field>
<field name="inherit_id"
ref="industry_fsm_report.project_task_view_calendar_fsm_worksheet"/>
<field name="model">project.task</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='worksheet_template_id']"
position="replace"></xpath>
<xpath expr="//calendar" position="attributes">
<attribute name="color">user_ids</attribute>
</xpath>
</field>
</record>
</data>
</odoo>

View file

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

View file

@ -0,0 +1,35 @@
#
# Bemade Inc.
#
# Copyright (C) July 2023 Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : marc@bemade.org)
#
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
# or modified copies of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
{
'name': 'Bemade Geo Distance',
'version': '15.0.1.0.0',
'summary': 'Utility module for calculating the distance driving between addresses.',
'description': """Module for calculating the driving distance between addresses using the Google Route API.
**IMPORTANT NOTE:** You must have an active Google Route API key configured in the Geo Localization
settings (under General Settings).""",
'category': 'Generic Modules/Others',
'author': 'Bemade Inc.',
'website': 'https://www.bemade.org',
'license': 'OPL-1',
'depends': ['base_geolocalize'],
'data': [],
'demo': [],
'installable': True,
'auto_install': False,
}

View file

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

View file

@ -0,0 +1,95 @@
from odoo import models, fields, tools, _, api
from odoo.exceptions import UserError
import requests
import logging
import datetime
from typing import Literal
_logger = logging.getLogger(__name__)
class GeoRouter(models.AbstractModel):
""" Abstract class used to call Google Route API and convert responses
into useful distance and route data.
"""
_name = 'base.geo_router'
_description = "Geographic Router"
units = Literal['metric', 'imperial']
@api.model
def _get_api_key(self):
apikey = self.env['ir.config_parameter'].sudo().get_param('base_geolocalize.google_map_api_key')
if not apikey:
raise UserError(_(
"API key for GeoCoding (Places) required.\n"
"Visit https://developers.google.com/maps/documentation/geocoding/get-api-key for more information."
))
return apikey
@api.model
def get_driving_distance_time(self, origin, destination, departure_time: datetime.datetime = None,
arrival_time: datetime.datetime = None, units: units = 'metric',
avoid_tolls=False, avoid_highways=False, avoid_ferries=False) -> (float, float):
""" Calculates the route between two addresses and returns the distance it either in Kilometers or Miles and
the time in minutes.
:param origin: The origin address (res.partner) from which to drive (origin).
:param destination: The destination address (res.partner).
:param departure_time: The departure time to use when planning the route (traffic aware).
Cannot be set simultaneously with arrival_time.
:param arrival_time: The arrival time to use when planning the route (traffic aware).
Cannot be set simultaneously with departure_time.
:param units: Type of units to return: 'imperial' for Miles, 'metric' for Kilometers.
:param avoid_tolls: Whether Google should calculate a route avoiding tolls.
:param avoid_highways: Whether Google should calculate a route avoiding highways.
:param avoid_ferries: Whether Google should calculate a route avoiding ferries.
:returns: tuple(distance: float, time: float)
"""
if departure_time and arrival_time:
raise ValueError("Route cannot be calculated with both an arrival time and departure time.")
apikey = self._get_api_key()
origin_addr = self.env['base.geocoder'].geo_query_address(street=origin.street, zip=origin.zip,
city=origin.city, state=origin.state_id.name,
country=origin.country_id.name)
destination_addr = self.env['base.geocoder'].geo_query_address(street=destination.street, zip=destination.zip,
city=destination.city,
state=destination.state_id.name,
country=destination.country_id.name)
params = {
'origin': {
'address': origin_addr,
},
'destination': {
'address': destination_addr,
},
'travelMode': 'DRIVE',
'computeAlternativeRoutes': False,
'routeModifiers': {
'avoidTolls': avoid_tolls,
'avoidHighways': avoid_highways,
'avoidFerries': avoid_ferries,
},
'languageCode': 'en-US',
'units': units,
}
if departure_time or arrival_time:
params.update({
'routingPreference': 'TRAFFIC_AWARE',
'departureTime' if departure_time else 'arrivalTime': departure_time or arrival_time
})
url = "https://routes.googleapis.com/directions/v2:computeRoutes"
distance_field = 'distanceMeters' if units == 'metric' else 'distanceMiles'
result = requests.post(url,
json=params,
headers={'X-Goog-Api-Key': apikey,
'Content-Type': 'application/json',
'X-Goog-FieldMask': f"routes.duration,routes.{distance_field}"}).json()
if 'routes' not in result:
_logger.error(f"Failed to retrieve route between {origin_addr} and {destination_addr}. "
f"Google response: {result}.")
route = result['routes'][0]
distance = (route[distance_field] / 1000) if units == 'metric' else route[distance_field]
time = int(route['duration'].strip('s')) / 60
return distance, time

View file

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

View file

@ -0,0 +1,53 @@
from odoo.tests import TransactionCase, tagged
import datetime
import pytz
@tagged('-at_install', 'post_install')
class TestGeoRouter(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.apikey = cls.env['base.geo_router']._get_api_key()
def test_distance_time_basic(self):
bemade, montreal = self._generate_partners()
distance, time = self.env['base.geo_router'].get_driving_distance_time(bemade, montreal)
self.assertTrue(20 < distance < 40)
self.assertTrue(12 < time < 60)
def test_time_traffic(self):
bemade, montreal = self._generate_partners()
est = pytz.timezone('US/Eastern')
today_naive = datetime.date.today()
today = datetime.datetime(today_naive.year, today_naive.month, today_naive.day, 8, 30, tzinfo=est)
arrival_time_local = today + datetime.timedelta(days=(2 - today.weekday()) % 7)
arrival_time_utc = arrival_time_local.astimezone(pytz.utc)
arrival_time = arrival_time_utc.strftime('%Y-%m-%dT%H:%M:%S.%fZ')
print(arrival_time)
distance, time = self.env['base.geo_router'].get_driving_distance_time(bemade, montreal,
arrival_time=arrival_time)
self.assertTrue(25 < time < 90)
@classmethod
def _generate_partners(self):
bemade = self.env['res.partner'].create({
'name': 'Bemade Inc.',
'street': '255 North Montcalm Blvd.',
'city': 'Candiac',
'zip': 'J5R 3L6',
'state_id': self.env.ref('base.state_ca_qc').id,
'country_id': self.env.ref('base.ca').id,
})
montreal = self.env['res.partner'].create({
'name': 'City of Montreal',
'street': '220 East René-Lévesque Blvd.',
'city': 'Montreal',
'zip': 'H2X 3A7',
'state_id': self.env.ref('base.state_ca_qc').id,
'country_id': self.env.ref('base.ca').id,
})
return bemade, montreal

View file

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

View file

@ -0,0 +1,40 @@
#
# Bemade Inc.
#
# Copyright (C) July 2023 Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : marc@bemade.org)
#
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
# or modified copies of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
{
'name': 'Planning Travel Time',
'version': '15.0.1.0.0',
'summary': "Plan travel time for services planned via Odoo's Planning module.",
'description': """
Adds the ability to add travel planning records to existing planning records
linked to sale order lines. Travel planning records book time in the scheduled
resource's calendar. Travel distance is based on the resource's work address, if
available. Otherwise, travel is assumed to be between the client location and
the company's address.""",
'category': 'Services/Field Service',
'author': 'Bemade Inc.',
'website': 'https://www.bemade.org',
'license': 'OPL-1',
'depends': ['sale_planning',
'industry_fsm',
'bemade_geo_routing'],
'data': [''],
'demo': [''],
'installable': True,
'auto_install': False,
}

View file

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

View file

@ -0,0 +1,42 @@
from odoo import models, fields, api
class PlanningSlot(models.Model):
_inherit = "planning.slot"
inbound_travel_slot = fields.Many2one(comodel_name="planning.slot",
string="Inbound Travel Plan", )
outbound_travel_slot = fields.Many2one(comodel_name="planning.slot",
string="Outbound Travel Plan")
# Fields for travel slots
travel_origin_slot = fields.One2many(comodel_name="planning.slot",
inverse_name="outbound_travel_slot")
travel_destination_slot = fields.One2many(comodel_name="planning.slot",
inverse_name="inbound_travel_slot")
is_travel_slot = fields.Boolean()
def action_plan_travel(self):
# Get previous and next planning records for the same day, same resource
for rec in self:
prev_slot = rec._get_previous_same_day_same_resource_slot()
next_slot = rec._get_next_same_day_same_resource_slot()
def _get_previous_same_day_same_resource_slot(self):
start = self.start_datetime.replace(hour=0, minute=0, second=0,
microsecond=0)
domain = [('is_travel_slot', '=', False),
('start_datetime', '>=', start),
('end_datetime', '<=', self.start_datetime),
('resource_id', '=', self.resource_id.id)]
return self.env['planning.slot'].search(domain)
def _get_next_same_day_same_resource_slot(self):
end = self.end_datetime.replace(hour=23, minute=59, second=59,
microsecond=999999)
domain = [('is_travel_slot', '=', False),
('start_datetime', '>=', self.end_datetime),
('end_datetime', '<=', end),
('resource_id', '=', self.resource_id.id)]
return self.env['planning.slot'].search(domain)