Compare commits

...

51 commits

Author SHA1 Message Date
Marc Durepos
8f21f26dbf bemade_fsm: Release 15.0.1 (beta)
* Added translations for French
 * Removed broken test tours (low value tests, high maintenance cost)
 * Updated version in manifest
2023-09-06 09:45:58 -04:00
Marc Durepos
741171a210 bemade_fsm: Printing from a subtask prints the root ancestor.
Fixes Dur-Pro/bemade-addons#83

Also added some styling to prevent table headers overlapping with table
content when tables run multiple pages. This is due to a rendering bug
in wkhtmltopdf.
2023-09-06 09:45:58 -04:00
Marc Durepos
f12583275b bemade_fsm: Improved task naming. Fixes Dur-Pro/bemade-addons#89. 2023-09-06 09:45:58 -04:00
Marc Durepos
22cf91c67c bemade_fsm: PDF improvements
* Equipment summary added after the sale line summary.
   Fixes Dur-Pro/bemade-addons#60.
 * Different blocks of the document made conditional to content
 * Improved how tasks and subtasks are rendered
2023-09-06 09:45:58 -04:00
Marc Durepos
05ac09014c bemade_fsm: remove square brackets from equipment complete name if no tag. 2023-09-06 09:45:58 -04:00
Marc Durepos
aef2d80c88 bemade_fsm: added work order number, significant mods to work order PDF. 2023-09-06 09:45:58 -04:00
Marc Durepos
7d79d21045 bemade_fsm: further progress on work order PDF. 2023-09-06 09:45:58 -04:00
Marc Durepos
b1ca61ce89 bemade_fsm: refactor work order report templates to break them into sub-templates for easier modification. 2023-09-06 09:45:58 -04:00
Marc Durepos
7b5a7b63c2 bemade_fsm: cleaning up template files. 2023-09-06 09:45:58 -04:00
Marc Durepos
e845f84ab5 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-09-06 09:45:58 -04:00
Marc Durepos
f4652057f1 bemade_fsm: Work order report printing relevant SO lines summary. Re Dur-Pro/bemade-addons#94 2023-09-06 09:45:58 -04:00
Marc Durepos
dd94e2a1cf bemade_fsm: Task naming modified to shorten child task names.
Fixes Dur-Pro/bemade-addons#92
2023-09-06 09:45:58 -04:00
Marc Durepos
40b362185d 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-09-06 09:45:58 -04:00
Marc Durepos
26603d5701 bemade_fsm: Marking visit task done completes its descendants.
Re: Dur-Pro/bemade-addons#93
2023-09-06 09:45:58 -04:00
Marc Durepos
2e1161c6ad bemade_fsm: Explicitly set user_ids=False on visit task creation. Fixes Dur-Pro/bemade-addons#90 2023-09-06 09:45:58 -04:00
Marc Durepos
e1bf6e18b6 bemade_fsm: Due date and initially planned hours on SO confirmation.
Fixes Dur-Pro/bemade-addons#86 and Dur-Pro/bemade-addons87
2023-09-06 09:45:58 -04:00
Marc Durepos
e604757617 bemade_fsm: updates to qweb for pdf of work orders. 2023-09-06 09:45:58 -04:00
Marc Durepos
4806b245c8 bemade_fsm: updates to PDF for work orders. 2023-09-06 09:45:58 -04:00
Marc Durepos
4c7ba22f87 bemade_fsm: added is_complete to tasks for easier PDF generation. 2023-09-06 09:45:58 -04:00
Marc Durepos
28a9e7a8d1 bemade_fsm: Add the beginning of the PDF report for service tasks. 2023-09-06 09:45:58 -04:00
Marc Durepos
a8472ec00d bemade_fsm: remove view override while Odoo get my JS fix onto servers. 2023-09-06 09:45:58 -04:00
Marc Durepos
31a2cae37c bemade_fsm: many upgrades including better calendar view 2023-09-06 09:45:58 -04:00
Marc Durepos
f4b8770d5d bemade_fsm: updated data to make new stages appear in the FSM project 2023-09-06 09:45:58 -04:00
Marc Durepos
caadbdc342 bemade_planning_travel: new module begun for planning travel 2023-09-06 09:45:58 -04:00
Marc Durepos
034e3b0cbb bemade_fsm: fix in res_partner.py for pad field in domain. 2023-09-06 09:45:58 -04:00
Marc Durepos
ce53137871 bemade_fsm: data added for stages and equipment tags. 2023-09-06 09:45:58 -04:00
Marc Durepos
66923c14bf bemade_fsm: make project.task.allow_billable store=True to un-break Odoo tests. 2023-09-06 09:45:58 -04:00
Marc Durepos
2303753f3d bemade_fsm: Update to ir.model.access.csv to fix duplicate insert. 2023-09-06 09:45:58 -04:00
Marc Durepos
66cac13616 bemade_fsm: tie in with sale_planning to plan SO lines based on task templates. 2023-09-06 09:45:58 -04:00
Marc Durepos
7cdcd2995e bemade_geo_distance moved to bemade_geo_routing. Spawned new module bemade_planning_travel. 2023-09-06 09:45:57 -04:00
Marc Durepos
1f47523515 bemade_geo_distance: fix to public method signature in base.geo_router 2023-09-06 09:45:57 -04:00
Marc Durepos
21ba9e640f bemade_geo_distance: new module for getting driving distance and time from one partner to another. 2023-09-06 09:45:57 -04:00
Marc Durepos
04fb5699ac bemade_fsm: Added logic to use the planned hours from task templates on sale order lines (for planning purposes). Re #11. 2023-09-06 09:45:57 -04:00
Marc Durepos
b3aa92afe1 bemade_fsm: Visits on sales orders now group created tasks under a common parent. Fixes #72. 2023-09-06 09:45:57 -04:00
Marc Durepos
4ca7dcd73d bemade_fsm: Equipment to sale order line connection made. Fixes #71. 2023-09-06 09:45:57 -04:00
Marc Durepos
515246860f bemade_fsm: added many2many relationship to equipment from sale order lines. Set up summary and default equipment_ids from sale order. Ref #71. 2023-09-06 09:45:57 -04:00
Marc Durepos
7541480bfd Refactored tests. 2023-09-06 09:45:57 -04:00
Marc Durepos
9d3769ae10 [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-09-06 09:45:57 -04:00
Marc Durepos
963fa748c6 bemade_fsm: Convert task_template.equipment_id to a Many2many field. Fixes #58. 2023-09-06 09:45:57 -04:00
Marc Durepos
82b51907a5 bemade_fsm: test refactoring complete (all passing). 2023-09-06 09:45:57 -04:00
Marc Durepos
3e311ead94 bemade_fsm: major refactoring of unit tests completed. Integration tests need rework. 2023-09-06 09:45:57 -04:00
Marc Durepos
24a5e6e2b9 bemade_fsm: major refactoring of tests in progress (broken). 2023-09-06 09:45:57 -04:00
Marc Durepos
6159eae798 bemade_fsm: add visits to sale orders. Fixes #66. 2023-09-06 09:45:57 -04:00
Marc Durepos
878ccf849b bemade_fsm: added a test for contact setting on SO shipping address change. Fixes #53. 2023-09-06 09:45:57 -04:00
Marc Durepos
565446ee9d bemade_fsm: add equipment to Task Templates & flow down to tasks on SO. Fixes #50 2023-09-06 09:45:57 -04:00
Marc Durepos
a4a998e012 bemade_fsm: added test for setting proper contacts on SO based on shipping address. 2023-09-06 09:45:57 -04:00
Marc Durepos
218e721261 bemade_fsm: Updating SO delivery address correctly changes site contacts, work order recipients and equipment. Fixes #53. 2023-09-06 09:45:57 -04:00
Marc Durepos
05aae321be Updated the naming of tasks for FSM. Fixes #54. 2023-09-06 09:45:57 -04:00
Marc Durepos
395bd999a0 bemade_fsm: Added default project stage data re #51. 2023-09-06 09:45:57 -04:00
Marc Durepos
f7520c50b8 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-09-06 09:45:57 -04:00
Marc Durepos
07b0f992a4 bemade_fsm: Added logic to prevent child FSM tasks from trying to create sales orders. Fixes #5. 2023-09-06 09:45:57 -04:00
42 changed files with 3224 additions and 465 deletions

View file

@ -20,38 +20,49 @@
########################################################################################
{
'name': 'Improved Field Service Management',
'version': '15.0.0.2.0',
'version': '15.0.1.0.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>

987
bemade_fsm/i18n/fr.po Normal file
View file

@ -0,0 +1,987 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * bemade_fsm
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 15.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-08-29 15:04+0000\n"
"PO-Revision-Date: 2023-08-29 15:04+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid ""
"<span groups=\"account.group_show_line_subtotals_tax_excluded\">\n"
" Amount</span>\n"
" <span groups=\"account.group_show_line_subtotals_tax_included\">\n"
" Total Price</span>"
msgstr ""
"<span groups=\"account.group_show_line_subtotals_tax_excluded\">\n"
" Prix</span>\n"
" <span groups=\"account.group_show_line_subtotals_tax_included\">\n"
" Prix</span>"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_signature_block
msgid ""
"<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;\">\n"
" Signed\n"
" </span>"
msgstr ""
"<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;\">\n"
" Signé\n"
" </span>"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid "<span>Disc.%</span>"
msgstr "<span>Escompte %</span>"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid ""
"<strong class=\"mr16\">Section\n"
" Subtotal</strong>"
msgstr ""
"<strong class=\"mr16\">Section\n"
" Sous-total</strong>"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid "<strong>Taxes</strong>"
msgstr "<strong>Taxes</strong>"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid "<strong>Total</strong>"
msgstr ""
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid "<strong>Untaxed amount</strong>"
msgstr "<strong>Montant hors taxes</strong>"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_needaction
msgid "Action Needed"
msgstr "Action requise"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_ids
msgid "Activities"
msgstr "Activités"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_exception_decoration
msgid "Activity Exception Decoration"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_state
msgid "Activity State"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_type_icon
msgid "Activity Type Icon"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__tag_ids
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_equipment_summary
msgid "Application"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__approx_date
msgid "Approximate Date"
msgstr "Date approximative"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_attachment_count
msgid "Attachment Count"
msgstr "Nombre de pièces jointes"
#. module: bemade_fsm
#: model:project.task.type,legend_blocked:bemade_fsm.planning_project_stage_exception
#: model:project.task.type,legend_blocked:bemade_fsm.planning_project_stage_waiting_parts
#: model:project.task.type,legend_blocked:bemade_fsm.planning_project_stage_work_completed
msgid "Blocked"
msgstr "Bloqué"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__allow_billable
msgid "Can be billed"
msgstr "Facturable"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_res_partner__is_site_contact
#: model:ir.model.fields,help:bemade_fsm.field_res_users__is_site_contact
msgid ""
"Check if the contact is a site contact, otherwise it is a not in that list"
msgstr ""
"Vérifier si le contact est un contact du site, sinon ne pas l'inclure dans la liste"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__tag_ids
msgid ""
"Classify and analyze your equipment categories like: Boiler, Laboratory, "
"Waste water, Pure water"
msgstr ""
"Classifier et analyser vos catégories d'équipements comme : Chaudière, Laboratoire, "
"Eaux usées, Eau pure"
#. module: bemade_fsm
#: model:ir.ui.menu,name:bemade_fsm.menu_service_client_equipment
msgid "Client Equipment"
msgstr "Équipement client"
#. module: bemade_fsm
#: model:ir.ui.menu,name:bemade_fsm.menu_service_client
#: model:ir.ui.menu,name:bemade_fsm.menu_service_client_clients
msgid "Clients"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_tag__color
msgid "Color Index"
msgstr "Index de couleur"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_tasks_table
msgid "Comments"
msgstr "Commentaires"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__company_id
msgid "Company"
msgstr "Société"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__is_completed
msgid "Completed"
msgstr "Complété"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_res_partner
msgid "Contact"
msgstr ""
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.sale_order_form_inherit
msgid "Contacts and Equipment"
msgstr "Contacts et équipements"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__create_uid
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_tag__create_uid
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_type__create_uid
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__create_uid
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__create_uid
msgid "Created by"
msgstr "Créé par"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__create_date
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_tag__create_date
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_type__create_date
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__create_date
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__create_date
msgid "Created on"
msgstr "Date de création"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_search_view
msgid "Customer"
msgstr "Client"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block
msgid "Customer:"
msgstr "Client :"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__assignees
msgid "Default Assignees"
msgstr "Assigné par défaut"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__customer
msgid "Default Customer"
msgstr "Client par défaut"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order__default_equipment_ids
msgid "Default Equipment to Service"
msgstr "Équipement à désservir par défaut"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__project
msgid "Default Project"
msgstr "Projet par défaut"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__tags
msgid "Default Tags"
msgstr "Étiquettes par défaut"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_project_task_template__customer
msgid "Default customer for tasks created from this template."
msgstr "Client par défaut pour tâches créées à partir de ce gabarit."
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_project_task_template__project
msgid "Default project for tasks created from this template."
msgstr "Projet par défaut pour tâches créées à partir de ce gabarit."
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_project_task_template__tags
msgid "Default tags for tasks created from this template."
msgstr "Étiquettes par défaut pour tâches créées à partir de ce gabarit."
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid "Delivered"
msgstr "Livré"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__description
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__description
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_form_view
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid "Description"
msgstr ""
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.fsm_equipment_view_tree
msgid "Details"
msgstr "Détails"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__display_name
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_tag__display_name
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_type__display_name
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__display_name
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__display_name
msgid "Display Name"
msgstr "Nom pour affichage"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_project_task_template__assignees
msgid "Employees assigned to tasks created from this template."
msgstr "Employés assignés aux tâches créées à partir de ce gabarit."
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__planned_date_end
msgid "End Date"
msgstr "Date de fin"
#. module: bemade_fsm
#: model:ir.actions.act_window,name:bemade_fsm.action_window_equipment
#: model_terms:ir.ui.view,arch_db:bemade_fsm.equipment_view_form
#: model_terms:ir.ui.view,arch_db:bemade_fsm.equipment_view_tree
msgid "Equipment"
msgstr "Équipement"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order__summary_equipment_ids
msgid "Equipment Being Serviced"
msgstr "Équipement désservi"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_res_partner__equipment_count
msgid "Equipment Count"
msgstr "Nombre d'équipements"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__complete_name
msgid "Equipment Name"
msgstr "Nom d'équipement"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_equipment_summary
msgid "Equipment Serviced"
msgstr "Équipement désservi"
#. module: bemade_fsm
#: model:ir.actions.act_window,name:bemade_fsm.action_window_equipment_tags
msgid "Equipment Tag"
msgstr "Étiquette d'équipement"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__summarized_equipment_ids
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__equipment_ids
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__equipment_ids
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__equipment_ids
msgid "Equipment to Service"
msgstr "Équipement à désservir"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_tasks_table
msgid "Equipment:"
msgstr "Équipement : "
#. module: bemade_fsm
#: model:ir.actions.act_window,name:bemade_fsm.act_res_partner_2_equipment
#: model_terms:ir.ui.view,arch_db:bemade_fsm.partner_equipment_location_view_form
msgid "Equipments"
msgstr "Équipements"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__task_duration
msgid "Estimated Duration"
msgstr "Durée estimée"
#. module: bemade_fsm
#: model:project.task.type,name:bemade_fsm.planning_project_stage_exception
msgid "Exception"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__failed_message_ids
msgid "Failed Messages"
msgstr "Messages échoués"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.bemade_fsm_project_task_form_inherit
#: model_terms:ir.ui.view,arch_db:bemade_fsm.partner_equipment_location_view_form
#: model_terms:ir.ui.view,arch_db:bemade_fsm.sale_order_form_inherit
msgid "Field Service"
msgstr "Service terrain"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_bemade_fsm_equipment
msgid "Field service equipment"
msgstr "Équipement de service terrain"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_bemade_fsm_equipment_tag
msgid "Field service equipment category"
msgstr "Catégorie d'équipement de service terrain"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_bemade_fsm_equipment_type
msgid "Field service equipment type"
msgstr "Type d'équipement de service terrain"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_follower_ids
msgid "Followers"
msgstr "Abonnés"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_partner_ids
msgid "Followers (Partners)"
msgstr "Abonnés (partenaires)"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__activity_type_icon
msgid "Font awesome icon e.g. fa-tasks"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__is_fully_delivered
msgid "Fully Delivered"
msgstr "Complètement livré"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__is_fully_delivered_and_invoiced
msgid "Fully Invoiced"
msgstr "Complètement facturé"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_search_view
msgid "Group By"
msgstr "Regrouper par"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__has_message
msgid "Has Message"
msgstr "A un message"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__id
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_tag__id
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_type__id
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__id
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__id
msgid "ID"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_exception_icon
msgid "Icon"
msgstr "Icône"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__activity_exception_icon
msgid "Icon to indicate an exception activity."
msgstr "Icône pour indiquer une activité d'exceiption."
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__message_needaction
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__message_unread
msgid "If checked, new messages require your attention."
msgstr "Si coché, de nouveaux messages nécessitent votre attention."
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__message_has_error
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__message_has_sms_error
msgid "If checked, some messages have a delivery error."
msgstr "Si coché, des messages ont une erreur de livraison."
#. module: bemade_fsm
#: model:project.task.type,legend_normal:bemade_fsm.planning_project_stage_exception
#: model:project.task.type,legend_normal:bemade_fsm.planning_project_stage_waiting_parts
#: model:project.task.type,legend_normal:bemade_fsm.planning_project_stage_work_completed
msgid "In Progress"
msgstr "En cours"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_visit__is_invoiced
#: model:ir.model.fields,help:bemade_fsm.field_sale_order_line__is_fully_delivered_and_invoiced
msgid ""
"Indicates whether a line or all the lines in a section have be enentirely"
" delivered and invoiced."
msgstr ""
"Indique si une ligne ou toutes les lignes dans une section ont été entièrement livrées"
"et facturées."
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_visit__is_completed
#: model:ir.model.fields,help:bemade_fsm.field_sale_order_line__is_fully_delivered
msgid ""
"Indicates whether a line or all the lines in a section have been entirely "
"delivered."
msgstr ""
"Indique si une ligne ou toutes les lignes dans une section ont été entièrement livrées."
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__planned_hours
msgid "Initially Planned Hours"
msgstr "Heures planifiées initialement"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_type__name
msgid "Intervention Name"
msgstr "Nom d'intervention"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__task_ids
msgid "Interventions"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__is_invoiced
msgid "Invoiced"
msgstr "Facturé"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__is_field_service
msgid "Is Field Service"
msgstr "Est un service terrain"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_is_follower
msgid "Is Follower"
msgstr "Est un abonné"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_res_partner__is_site_contact
#: model:ir.model.fields,field_description:bemade_fsm.field_res_users__is_site_contact
msgid "Is a site contact"
msgstr "Est un contact du site"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__label
msgid "Label"
msgstr "Nom"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment____last_update
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_tag____last_update
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_type____last_update
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit____last_update
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template____last_update
msgid "Last Modified on"
msgstr "Dernière modification"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__write_uid
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_tag__write_uid
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_type__write_uid
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__write_uid
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__write_uid
msgid "Last Updated by"
msgstr "Modifié par"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__write_date
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_tag__write_date
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_type__write_date
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__write_date
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__write_date
msgid "Last Updated on"
msgstr "Dernière mise à jour"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_equipment_summary
msgid "Location"
msgstr "Emplacement"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_main_attachment_id
msgid "Main Attachment"
msgstr "Pièce jointe principale"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.bemade_fsm_project_task_form_inherit
msgid "Mark as Delivered"
msgstr "Marquer comme livré"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_has_error
msgid "Message Delivery error"
msgstr "Erreur de livraison de message"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_ids
msgid "Messages"
msgstr ""
#. module: bemade_fsm
#: code:addons/bemade_fsm/models/task.py:0
#, python-format
msgid ""
"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."
msgstr ""
"Modifier la date planifiée pour le début ou la fin d'une tâche nest pas permis quand"
" cette tâche est déjà associée à des enregistrements de planification. Veuillez"
" plutôt modifier ou supprimer les planifications associées."
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__my_activity_date_deadline
msgid "My Activity Deadline"
msgstr "Date d'échéance de mon activité"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__name
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment_tag__name
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_equipment_summary
msgid "Name"
msgstr "Nom"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_calendar_event_id
msgid "Next Activity Calendar Event"
msgstr "Évènement au calendrier de la prochaine activité"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_date_deadline
msgid "Next Activity Deadline"
msgstr "Échéance de la prochaine activité"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_summary
msgid "Next Activity Summary"
msgstr "Résumé de la prochaine activité"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_type_id
msgid "Next Activity Type"
msgstr "Type de la prochaine activité"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_needaction_counter
msgid "Number of Actions"
msgstr "Nombre d'actions"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_has_error_counter
msgid "Number of errors"
msgstr "Nombre d'erreurs"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__message_needaction_counter
msgid "Number of messages which requires an action"
msgstr "Nombre de messages nécessitant une action"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__message_has_error_counter
msgid "Number of messages with delivery error"
msgstr "Nombre de messages avec une erreur de livraison"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__message_unread_counter
msgid "Number of unread messages"
msgstr "Nombre de messages non-lus"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid "Ordered"
msgstr "Commandé"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_res_partner__owned_equipment_ids
#: model:ir.model.fields,field_description:bemade_fsm.field_res_users__owned_equipment_ids
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order__valid_equipment_ids
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__valid_equipment_ids
msgid "Owned Equipments"
msgstr "Équipements possédés"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__pid_tag
msgid "P&ID Tag"
msgstr "Tag P&ID"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_search_view
msgid "Parent Task"
msgstr "Tâche parent"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__parent
msgid "Parent Task Template"
msgstr "Gabarit de tâche parent"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__partner_location_id
msgid "Physical Address"
msgstr "Adresse physique"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__location_notes
msgid "Physical Location Notes"
msgstr "Notes sur l'emplacement physique"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_product_product__is_field_service
#: model:ir.model.fields,field_description:bemade_fsm.field_product_template__is_field_service
msgid "Plan as field service"
msgstr "Planifier comme service terrain"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_product_template
msgid "Product Template"
msgstr "Modèle de produit"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_product_product__is_field_service
#: model:ir.model.fields,help:bemade_fsm.field_product_template__is_field_service
msgid ""
"Products planned as field service will have travel time considered in "
"planning."
msgstr ""
"Les produits planifiés comme service terrain auront leur temps de voyagement considéré"
" lors de la planification."
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_search_view
msgid "Project"
msgstr "Projet"
#. module: bemade_fsm
#: model:project.task.type,legend_done:bemade_fsm.planning_project_stage_exception
#: model:project.task.type,legend_done:bemade_fsm.planning_project_stage_waiting_parts
#: model:project.task.type,legend_done:bemade_fsm.planning_project_stage_work_completed
msgid "Ready"
msgstr "Prêt"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__relevant_order_lines
msgid "Relevant Order Lines"
msgstr "Articles commandés pertinents"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_bemade_fsm_visit
msgid "Represents a single visit by assigned service personnel."
msgstr "Représente une seule visite par le personnel de service assigné."
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_user_id
msgid "Responsible User"
msgstr "Utilisateur responsable"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_has_sms_error
msgid "SMS Delivery error"
msgstr "Erreur de livraison SMS"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__so_section_id
msgid "Sale Order Section"
msgstr "Section de commande de vente"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_sale_order
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__sale_order_id
msgid "Sales Order"
msgstr "Commande de vente"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_sale_order_line
msgid "Sales Order Line"
msgstr "Ligne de commandes de vente"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__sequence
msgid "Sequence"
msgstr "Séquence"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__task_id
msgid "Service Visit"
msgstr "Visite de service"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.sale_order_form_inherit
msgid "Service Visits"
msgstr "Visites de service"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_signature_block
msgid "Signature"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__site_contacts
#: model:ir.model.fields,field_description:bemade_fsm.field_res_partner__site_contacts
#: model:ir.model.fields,field_description:bemade_fsm.field_res_users__site_contacts
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order__site_contacts
msgid "Site Contacts"
msgstr "Contacts sur site"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block
msgid "Site Contacts:"
msgstr "Contacts sur site :"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_res_partner__equipment_ids
msgid "Site Equipment"
msgstr "Équipements du site"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__planned_date_begin
msgid "Start Date"
msgstr "Date de début"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_tasks_table
msgid "Status"
msgstr ""
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__activity_state
msgid ""
"Status based on activities\n"
"Overdue: Due date is already passed\n"
"Today: Activity date is today\n"
"Planned: Future activities."
msgstr ""
"Status basé sur les activités\n"
"En retard: La date est déjà passée\n"
"Aujourd'hui: L'activité est dûe aujourd'hui\n"
"Planifié: Activités futures."
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__subtasks
msgid "Subtask Templates"
msgstr "Gabarits des sous-tâches"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_form_view
msgid "Subtasks"
msgstr "Sous-tâches"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_equipment_summary
msgid "Tag"
msgstr "Étiquette"
#. module: bemade_fsm
#: model:ir.model.constraint,message:bemade_fsm.constraint_bemade_fsm_equipment_tag_name_uniq
msgid "Tag name already exists !"
msgstr "Le nom d'étiquette existe déjà !"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_project_task
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__task_ids
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_tasks_table
msgid "Task"
msgstr "Tâche"
#. module: bemade_fsm
#: model:ir.actions.act_window,name:bemade_fsm.task_template_act_window
#: model:ir.model.fields,field_description:bemade_fsm.field_product_product__task_template_id
#: model:ir.model.fields,field_description:bemade_fsm.field_product_template__task_template_id
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_form_view
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_search_view
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_tree_view
msgid "Task Template"
msgstr "Gabarit de tâche"
#. module: bemade_fsm
#: model:ir.ui.menu,name:bemade_fsm.project_task_template_menu
#: model:ir.ui.menu,name:bemade_fsm.service_task_template_meny
msgid "Task Templates"
msgstr "Gabarits de tâches"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__name
msgid "Task Title"
msgstr "Nom de tâche"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_tasks_table
msgid "Technician"
msgstr "Technicien·ne"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block
msgid "Technicians:"
msgstr "Technicien·ne·s:"
#. module: bemade_fsm
#: model:ir.model,name:bemade_fsm.model_project_task_template
msgid "Template for new project tasks"
msgstr "Gabarits pour nouvelles tâches"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_sale_order__default_equipment_ids
msgid "The default equipment to service for new sale order lines."
msgstr "L'équipement à désservir par défaut pour les nouveaux articles de commande de"
" ventes."
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_visit__so_section_id
msgid ""
"The section on the sale order that represents the labour and parts for this "
"visit"
msgstr ""
"La section sur la commande de vente représentant la main d'oeuvre et les pièces pour"
" cette visite"
#. module: bemade_fsm
#: model_terms:ir.actions.act_window,help:bemade_fsm.task_template_act_window
msgid "There are no task templates, click above to create one."
msgstr "Il n'existe aucun gabarit de tâche, cliquez ci-dessus pour en créer une."
#. module: bemade_fsm
#: code:addons/bemade_fsm/models/sale_order.py:0
#, python-format
msgid ""
"This task has been created from: <a href=# data-oe-model=sale.order data-oe-"
"id=%d>%s</a> (%s)"
msgstr ""
"Cette tâche a été créée à partir de : <a href=# data-oe-model=sale.order data-oe-"
"id=%d>%s</a> (%s)"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid "Time &amp; Material"
msgstr "Temps &amp; matériaux"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_form_view
msgid "Title"
msgstr "Titre"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__activity_exception_decoration
msgid "Type of the exception activity on record."
msgstr "Type d'activité d'exception sur l'enregistrement."
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table
msgid "Unit Price"
msgstr "Prix unitaire"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_unread
msgid "Unread Messages"
msgstr "Messages non-lus"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__message_unread_counter
msgid "Unread Messages Counter"
msgstr "Décompte de messages non-lus"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_form_view
msgid "View Task"
msgstr "Voir tâche"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__visit_id
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order__visit_ids
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__visit_id
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__visit_ids
msgid "Visit"
msgstr "Visite"
#. module: bemade_fsm
#: model:project.task.type,name:bemade_fsm.planning_project_stage_waiting_parts
msgid "Waiting on Parts"
msgstr "En attente de pièces"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__website_message_ids
msgid "Website Messages"
msgstr "Messages du site web"
#. module: bemade_fsm
#: model:ir.model.fields,help:bemade_fsm.field_bemade_fsm_equipment__website_message_ids
msgid "Website communication history"
msgstr "Historique de communication du site web"
#. module: bemade_fsm
#: model:project.task.type,name:bemade_fsm.planning_project_stage_work_completed
msgid "Work Executed"
msgstr "Travaux exécutés"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block
msgid "Work Order"
msgstr "Bon de travail"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block
msgid ""
"Work Order\n"
" Contacts:"
msgstr ""
"Bon de travail\n"
" Contacts :"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__work_order_contacts
msgid "Work Order Contacts"
msgstr "Contacts recevant le bon de travail"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__work_order_number
msgid "Work Order Number"
msgstr "Numéro de bon de travail"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_res_partner__work_order_contacts
#: model:ir.model.fields,field_description:bemade_fsm.field_res_users__work_order_contacts
#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order__work_order_contacts
msgid "Work Order Recipients"
msgstr "Destinataires du bon de travail"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_res_partner__site_ids
#: model:ir.model.fields,field_description:bemade_fsm.field_res_users__site_ids
msgid "Work Sites"
msgstr "Emplacements pour service terrain"

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,9 @@ 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:
tag_part = "[%s] " % rec.pid_tag if rec.pid_tag else ""
rec.complete_name = tag_part + 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,24 @@
from odoo import fields, models, api, _, Command
from odoo.exceptions import ValidationError
from odoo.tools import float_round
import re
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 +33,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 +67,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 +145,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 +158,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 +169,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 +190,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,19 @@
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
import re
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 +31,107 @@ 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', )
work_order_number = fields.Char(readonly=True)
@api.model_create_multi
def create(self, vals):
res = super().create(vals)
for rec in res:
if rec.sale_order_id:
seq = 1
prev_seqs = self.sale_order_id.tasks_ids and \
self.sale_order_id.tasks_ids.mapped('work_order_number')
if prev_seqs:
pattern = re.compile(r"\d+$")
matches = map(lambda n: pattern.search(n), prev_seqs)
seq += max(map(lambda n: int(n.group(1)) if n else 0), matches)
rec.work_order_number = rec.sale_order_id.name.replace('SO', 'WO', 1) \
+ f"-{seq}"
return res
@api.depends('sale_order_id')
def _compute_relevant_order_lines(self):
for rec in self:
rec.relevant_order_lines = (
rec.sale_order_id and rec.sale_order_id.get_relevant_order_lines(
rec) or False)
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 +153,54 @@ 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:
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
name_parts = rec.sale_line_id and rec.sale_line_id.name.split('\n')
title = name_parts and name_parts[0] or rec.sale_line_id.product_id.name
if not rec.parent_id:
rec.name = f"{rec.sale_order_id.partner_shipping_id.name} - " \
f"{title}"
if template:
rec.name += f" ({template.name})"
else:
rec.name = template.name or title 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,371 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<template id="workorder_page_sale_order_table">
<t t-set="order_lines" t-value="doc.relevant_order_lines"/>
<t t-if="order_lines">
<t t-set="visit_lines" t-value="order_lines.mapped('visit_id')"/>
<t t-if="visit_lines" t-set="root_tasks"
t-value="visit_lines.mapped('task_id')"/>
<t t-else="" t-set="root_tasks"
t-value="order_lines.mapped('task_id').filtered(lambda l: not l.parent_id)"/>
<t t-set="final_subtotal" t-value="0"/>
<t t-set="final_tax" t-value="0"/>
<t t-set="final_total" t-value="0"/>
<t t-set="is_any_total_discount_line" t-value="False"/>
<t t-set="section_lines"
t-value="order_lines.filtered(lambda l: l.display_type == 'line_section')"/>
<t t-foreach="order_lines" t-as="line">
<t t-set="final_subtotal"
t-value="final_subtotal + line.delivered_price_subtotal"/>
<t t-set="final_total"
t-value="final_total + line.delivered_price_total"/>
<t t-set="final_tax"
t-value="final_tax + line.delivered_price_tax"/>
<t t-set="is_any_total_discount_line"
t-value="is_any_total_discount_line or (line.discount and line.price_unit != 0 and line.delivered_price_total == 0)"/>
</t>
<t t-set="display_discount"
t-value="any(line.discount for line in order_lines)"/>
</t>
<t t-else="" t-set="root_tasks"
t-value="doc.root_ancestor"/>
<div t-if="order_lines.filtered(lambda l: not l.display_type)">
<h2 t-if="order_lines">Time &amp; Material</h2>
<div t-if="order_lines" class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th class="text-left">Description</th>
<th class="text-right">Ordered</th>
<th class="text-right">Delivered</th>
<th class="text-right">Unit Price</th>
<th t-if="display_discount" class="text-right"
groups="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" class="border-black o_subtotal">
<td><strong>Taxes</strong></td>
<td class="text-right">
<span t-esc="final_tax"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'/>
</td>
</tr>
<tr t-if="final_total" class="border-black o_total">
<td><strong>Total</strong></td>
<td class="text-right">
<span t-esc="final_total"
t-options='{"widget": "monetary", "display_currency": doc.sale_order_id.pricelist_id.currency_id}'/>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</template>
<template id="subtask_list">
<ul t-if="task.child_ids" class="ml-1 pl-1" style="list-style-type: none;">
<t t-foreach="task.child_ids" t-as="subtask">
<li class="ml-1">
<span t-out="subtask.name"/>
<t t-call="bemade_fsm.subtask_list">
<t t-set="task" t-value="subtask"/>
</t>
</li>
</t>
</ul>
</template>
<template id="workorder_page_tasks_table">
<t t-set="interventions" t-value="doc.child_ids"/>
<t t-set="timesheets" t-value="doc.timesheet_ids"/>
<t t-foreach="interventions" t-as="intervention">
<div style="page-break-inside: avoid;">
<h2 t-out="str(intervention_index + 1) + '. ' + intervention.name"/>
<h3 t-if="intervention.equipment_ids">Equipment:
<t t-foreach="intervention.equipment_ids" t-as="equipment_id">
<span t-out="equipment_id.complete_name + (', ' if not equipment_id_last else '')"/>
</t>
</h3>
<div>
<span t-esc="intervention.description"/>
</div>
<t t-set="tasks" t-value="intervention.child_ids"/>
<div t-if="tasks" class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th width="7%">Status</th>
<th width="30%">Task</th>
<th width="18%">Technician</th>
<th width="45%">Comments</th>
</tr>
</thead>
<tbody>
<t t-foreach="tasks" t-as="task">
<tr class="p-1 m-1">
<td t-out="task.stage_id.name"/>
<td>
<span t-out="task.name"/>
</td>
<td>
<t t-foreach="task.user_ids" t-as="user">
<span t-out="user.name + (', ' if not user_last else '')"/>
</t>
</td>
<td t-out="task.description or ''"/>
</tr>
<tr class="mt-0 pt-0">
<td/>
<td>
<t t-call="bemade_fsm.subtask_list"/>
</td>
<td/>
<td/>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</t>
</template>
<template id="workorder_page_info_block">
<h1>Work Order <span t-out="doc.work_order_number"></span></h1>
<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"><h6>Technicians: </h6></div>
<t t-foreach="doc.user_ids" t-as="user">
<div class="mb-3">
<div t-esc="user" t-options='{
"widget": "contact",
"fields": ["name", "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><h6>Customer: </h6></div>
<div t-esc="doc.partner_id" t-options='{
"widget": "contact",
"fields": ["name", "address",]
}'/>
</t>
</div>
</div>
<div class="row">
<div t-attf-class="{{'col-6' if report_type == 'pdf' else 'col-md-6 col-12'}}">
<div t-if="doc.site_contacts"><h6>Site Contacts: </h6></div>
<t t-foreach="doc.site_contacts" t-as="contact">
<div class="mb-3">
<div t-esc="contact" t-options='{
"widget": "contact",
"fields": ["name", "phone", "email"]
}'/>
</div>
</t>
</div>
<div t-attf-class="{{'col-6' if report_type == 'pdf' else 'col-md-6 col-12'}}">
<div t-if="doc.work_order_contacts"><h6>Work Order
Contacts: </h6></div>
<t t-foreach="doc.work_order_contacts" t-as="contact">
<div class="mb-3">
<div t-esc="contact" t-options='{
"widget": "contact",
"fields": ["name", "phone", "email"]
}'/>
</div>
</t>
</div>
</div>
</template>
<template id="workorder_equipment_summary">
<div t-if="doc.equipment_ids and len(doc.child_ids) > 1"
style="page-break-inside: avoid;">
<h3>Equipment Serviced</h3>
<div class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th>Tag</th>
<th>Name</th>
<th>Application</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.equipment_ids" t-as="equipment">
<tr>
<td><span t-esc="equipment.pid_tag"/></td>
<td><span t-esc="equipment.name"/></td>
<td>
<t t-foreach="equipment.tag_ids"
t-as="application">
<span t-esc="application.name + (', ' if not application_last else '')"/>
</t>
</td>
<td t-esc="equipment.partner_location_id"
t-options='{
"widget": "contact",
"fields": ["name", "address"]}'
/>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</template>
<template id="workorder_page_signature_block">
<div t-if="doc.worksheet_signature">
<div t-if="report_type == html" class="ribbon" style="
position: absolute;
right: 0px; top: 0px;
z-index: 1;
overflow: hidden;
width: 75px; height: 75px;
text-align: right;">
<span style="
font-size: 10px;
color: #fff;
text-transform: uppercase;
text-align: center;
font-weight: bold; line-height: 20px;
transform: rotate(45deg);
width: 100px; height: auto; display: block;
background: green;
position: absolute;
top: 19px; right: -21px; left: auto;
padding: 0;">
Signed
</span>
</div>
<div t-attf-class="#{' col-12 col-lg-3' if report_type != 'html' else '
col-sm-7 col-md-4'} ml-auto text-right"
style="page-break-inside: avoid">
<h5>Signature</h5>
<img t-att-src="image_data_uri(doc.worksheet_signature)"
style="max-height: 6rem; max-width: 100%; color:black;"/><br/>
<span t-field="doc.worksheet_signed_by"/>
</div>
</div>
</template>
<template id="work_order_page">
<div class="page">
<t t-call="bemade_fsm.workorder_page_info_block"/>
<t t-call="bemade_fsm.workorder_page_sale_order_table"/>
<t t-call="bemade_fsm.workorder_equipment_summary"/>
<t t-call="bemade_fsm.workorder_page_signature_block"/>
<t t-call="bemade_fsm.workorder_page_tasks_table"/>
</div>
</template>
<template id="work_order">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-set="doc" t-value="doc.root_ancestor" t-if="doc.parent_id"/>
<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.work_order_number)</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,14 @@
/* This prevents repeating the header, which overlaps with the body due to a bug in
wkhtmltopdf rendering.
*/
thead {
display: table-row-group;
}
tfoot {
display: table-row-group;
}
tr {
page-break-inside: avoid;
}

View file

@ -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,57 +1,21 @@
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)
@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([])]})
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):
self.equipment.name
@tagged('-at_install', 'post_install')
class TestEquipmentTours(HttpCase, TestEquipmentCommon):
def test_equipment_base_tour(self):
self.start_tour('/web', 'equipment_base_tour',
login=self.user.login, )
def test_equipment_sale_order_tour(self):
self.start_tour('/web', 'equipment_sale_order_tour', login=self.user.login)
equipment.name

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,272 @@
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)
def test_long_line_name_overflows_to_task_description(self):
so = self._generate_sale_order()
product = self._generate_product()
product.description_sale = "This is a long product description.\n" \
"It even spans multiple lines.\n" \
"One could find this annoying in a task name."
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
task = sol.task_id
self.assertFalse("This is a long product description." in task.name)
self.assertFalse("It even spans multiple lines." in task.name)
self.assertFalse("One could find this annoying in a task name." in task.name)
self.assertTrue("It even spans multiple lines." in task.description)
self.assertTrue("One could find this annoying in a task name."
in task.description)

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,80 @@
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)
@tagged('-at_install', 'post_install')
class TestTaskTemplateTour(HttpCase, TestTaskTemplateCommon):
# Switching the partner should trigger on_change that makes sure equipments are linked to the new partner
form.customer = partner2
form.save()
def test_task_template_tour(self):
self.start_tour('/web', 'task_template_tour',
login='misterpm', )
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")

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,21 @@
<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 expr="//field[@name='name']/.." position="before">
<h1 class="d-flex flex-row justify-content-between">
<field name="work_order_number"
attrs="{'invisible': [('work_order_number', '=', False)]}"/>
</h1>
</xpath>
<xpath expr="//page[@name='extra_info']" position="after">
<page string="Field Service" name="field_service">
<group name="contacts">
<page string="Field Service" name="field_service" translate="True">
<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 +26,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 +45,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)