diff --git a/bemade_fsm/__init__.py b/bemade_fsm/__init__.py new file mode 100644 index 0000000..a27d6a2 --- /dev/null +++ b/bemade_fsm/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import reports +from . import wizard diff --git a/bemade_fsm/__manifest__.py b/bemade_fsm/__manifest__.py new file mode 100644 index 0000000..a429e38 --- /dev/null +++ b/bemade_fsm/__manifest__.py @@ -0,0 +1,70 @@ +###################################################################################### +# +# Bemade Inc. +# +# Copyright (C) 2023-June Bemade Inc. (). +# Author: Marc Durepos (Contact : mdurepos@durpro.com) +# +# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1) +# It is forbidden to publish, distribute, sublicense, or sell copies of the Software +# or modified copies of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +######################################################################################## +{ + "name": "Improved Field Service Management", + "version": "18.0.0.4.3", + "summary": ( + "Adds functionality necessary for managing field service operations at Durpro." + ), + "category": "Services/Field Service", + "author": "Bemade Inc.", + "website": "http://www.bemade.org", + "license": "LGPL-3", + "depends": [ + "sale_stock", + "sale_project", + "account", + "project_enterprise", + "industry_fsm_stock", + "industry_fsm_report", + "industry_fsm_sale_report", + "fsm_equipment", + "bemade_partner_root_ancestor", + "mail", + ], + "data": [ + "data/fsm_data.xml", + "views/task_template_views.xml", + "security/ir.model.access.csv", + "views/product_views.xml", + "views/res_partner.xml", + "views/menus.xml", + "views/task_views.xml", + "views/sale_order_views.xml", + "reports/worksheet_custom_report_templates.xml", + "reports/worksheet_custom_reports.xml", + "wizard/new_task_from_template.xml", + "wizard/res_config_settings.xml", + ], + "assets": { + "web.report_assets_common": ["bemade_fsm/static/src/scss/bemade_fsm.scss"], + "web.assets_backend": [ + # BV: need to readd these files + # 'bemade_fsm/static/src/js/kanban_view.js', + # 'bemade_fsm/static/src/js/list_view.js', + ], + "web.assets_qweb": [ + "bemade_fsm/static/src/xml/project_view_buttons.xml", + ], + }, + "installable": True, + "auto_install": False, +} diff --git a/bemade_fsm/data/fsm_data.xml b/bemade_fsm/data/fsm_data.xml new file mode 100644 index 0000000..63f15fd --- /dev/null +++ b/bemade_fsm/data/fsm_data.xml @@ -0,0 +1,21 @@ + + + + 2 + Waiting on Parts + + + + + 15 + Work Executed + + + + + 19 + Exception + + + + diff --git a/bemade_fsm/i18n/fr.po b/bemade_fsm/i18n/fr.po new file mode 100644 index 0000000..d8f79be --- /dev/null +++ b/bemade_fsm/i18n/fr.po @@ -0,0 +1,1098 @@ +# 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: 2024-02-13 17:09+0000\n" +"PO-Revision-Date: 2024-02-13 17:09+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_with_pricing_and_labour +msgid "" +"\n" +" Amount\n" +" \n" +" Total Price" +msgstr "" +"\n" +" Montant\n" +" \n" +" Prix Total" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_signature_block +msgid "" +"\n" +" Signed\n" +" " +msgstr "" +"\n" +" Signé\n" +" " + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour +msgid "Disc.%" +msgstr "% Escompte" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour +msgid "" +"Section\n" +" Subtotal" +msgstr "" +"Section\n" +" Sous-total" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour +msgid "Taxes" +msgstr "" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour +msgid "Total" +msgstr "" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour +msgid "Untaxed amount" +msgstr "Montant hors-taxes" + +#. 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_state +msgid "Activity State" +msgstr "État de l'activité" + +#. module: bemade_fsm +#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_equipment__activity_type_icon +msgid "Activity Type Icon" +msgstr "Icône du type d'activité" + +#. 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 "Peut être facturé" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.project_task_from_template_wizard_view_form +msgid "Cancel" +msgstr "Annuler" + +#. 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_terms:ir.ui.view,arch_db:bemade_fsm.project_task_from_template_wizard_view_form +msgid "Create Task" +msgstr "Créer une tâche" + +#. module: bemade_fsm +#. openerp-web +#: code:addons/bemade_fsm/static/src/xml/project_view_buttons.xml:0 +#: code:addons/bemade_fsm/static/src/xml/project_view_buttons.xml:0 +#, python-format +msgid "Create Task from Template" +msgstr "Créer une tâche à partir d'un gabarit" + +#. module: bemade_fsm +#: model:ir.model,name:bemade_fsm.model_project_task_from_template_wizard +msgid "Create Task from Template Wizard" +msgstr "Assistant pour création de tâche à partir d'un gabarit" + +#. module: bemade_fsm +#. openerp-web +#: code:addons/bemade_fsm/static/src/xml/project_view_buttons.xml:0 +#, python-format +msgid "Create from Template" +msgstr "Créer à partir d'un gabarit" + +#. 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_from_template_wizard__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_from_template_wizard__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_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_timesheet_entries +msgid "Date" +msgstr "" + +#. 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_materials_table +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour +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_materials_table +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour +msgid "Description" +msgstr "" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.view_task_form2_inherit +msgid "Description/Comments" +msgstr "Description/Commentaires" + +#. 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_from_template_wizard__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 +#: model:ir.model.fields,field_description:bemade_fsm.field_res_users__equipment_count +msgid "Equipment Count" +msgstr "Nombre d'équipemenets" + +#. 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(s) 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_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 "Icône Font Awesome p.ex. fa-tasks" + +#. 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_from_template_wizard__id +#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__id +msgid "ID" +msgstr "Identifiant" + +#. 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 been entirely " +"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__is_fsm +#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__is_fsm +msgid "Is FSM" +msgstr "Est FSM" + +#. 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 sur 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_from_template_wizard____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_from_template_wizard__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_from_template_wizard__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_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_materials_table +msgid "Materials" +msgstr "Matériaux" + +#. 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 "" + +#. 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_project_task_from_template_wizard__new_task_title +msgid "New Task Title" +msgstr "Nouveau titre de tâche" + +#. module: bemade_fsm +#: model:ir.actions.act_window,name:bemade_fsm.action_new_task_from_template +msgid "New Task from Template" +msgstr "Nouvelle tâche à partir d'un gabarit" + +#. 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_materials_table +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour +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__code +msgid "Code" +msgstr "Code" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.project_task_view_search_fsm_inherit +#: 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_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_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block +msgid "Planned end:" +msgstr "Fin planifiée :" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block +msgid "Purchase Order:" +msgstr "Commande :" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block +msgid "Planned start:" +msgstr "Début planifié :" + +#. module: bemade_fsm +#: model:ir.model,name:bemade_fsm.model_product_template +msgid "Product Template" +msgstr "Modèle d'article" + +#. 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:ir.model.fields,field_description:bemade_fsm.field_project_task_from_template_wizard__project_id +#: model_terms:ir.ui.view,arch_db:bemade_fsm.task_template_search_view +msgid "Project" +msgstr "Projet" + +#. module: bemade_fsm +#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__propagate_assignment +msgid "Propagate Assignment" +msgstr "Propager l'assignation" + +#. module: bemade_fsm +#: model:ir.model.fields,help:bemade_fsm.field_project_task__propagate_assignment +msgid "Propagate assignment of this task to all subtasks." +msgstr "Propager l'assignation de cette tâche à toutes les sous-tâches." + +#. 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 "Bon de commande" + +#. module: bemade_fsm +#: model:ir.model,name:bemade_fsm.model_sale_order_line +msgid "Sales Order Line" +msgstr "Ligne de bons de commande" + +#. module: bemade_fsm +#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__section_line_ids +msgid "Section Line" +msgstr "Section" + +#. module: bemade_fsm +#: model:ir.model.fields,field_description:bemade_fsm.field_project_task_template__sequence +msgid "Sequence" +msgstr "Séquence" + +#. module: bemade_fsm +#: code:addons/bemade_fsm/models/sale_order.py:0 +#: model:ir.model.fields,field_description:bemade_fsm.field_bemade_fsm_visit__task_id +#, python-format +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 +#: model:ir.model.fields,field_description:bemade_fsm.field_res_users__equipment_ids +msgid "Site Equipment" +msgstr "Équipement sur 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:ir.model.fields,field_description:bemade_fsm.field_project_task_from_template_wizard__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 +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_timesheet_entries +msgid "Technician" +msgstr "Technicien·ne" + +#. 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_project_task_from_template_wizard__project_id +msgid "The project the new task should be created in." +msgstr "" + +#. 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:ir.model.fields,help:bemade_fsm.field_project_task_from_template_wizard__task_template_id +msgid "The template to use when creating the new task." +msgstr "Le gabarit à utiliser en créant la nouvelle tâche." + +#. module: bemade_fsm +#: model:ir.model.fields,help:bemade_fsm.field_project_task_from_template_wizard__new_task_title +msgid "" +"The title (name) for the newly created task. If left blank, the name of the " +"template will be used." +msgstr "" +"Le titre (nom) de la nouvelle tâche. Si laissé vide, le nom du gabarit sera " +"utilisé." + +#. 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_line.py:0 +#, python-format +msgid "" +"This task has been created from: %s (%s)" +msgstr "" +"Cette tâche a été créée à partir de : %s (%s)" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_timesheet_entries +msgid "Time" +msgstr "Temps" + +#. module: bemade_fsm +#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour +msgid "Time & Material" +msgstr "Temps & 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_with_pricing_and_labour +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 +#: code:addons/bemade_fsm/models/sale_order_line.py:0 +#: 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 +#, python-format +msgid "Visit" +msgstr "Visite" + +#. module: bemade_fsm +#: model:ir.model.fields,field_description:bemade_fsm.field_sale_order_line__visit_ids +msgid "Visits" +msgstr "Visites" + +#. 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_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_timesheet_entries +msgid "Work Completed" +msgstr "Travaux complétés" + +#. 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\n" +" Contacts:" +msgstr "" +"Contacts recevant le bon de travail :" + +#. 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" diff --git a/bemade_fsm/models/__init__.py b/bemade_fsm/models/__init__.py new file mode 100644 index 0000000..5d52c07 --- /dev/null +++ b/bemade_fsm/models/__init__.py @@ -0,0 +1,9 @@ +from . import task_template +from . import product_template +from . import sale_order_line +from . import sale_order +from . import task +from . import res_partner +from . import fsm_visit +from . import res_company +from . import project diff --git a/bemade_fsm/models/fsm_visit.py b/bemade_fsm/models/fsm_visit.py new file mode 100644 index 0000000..8a92042 --- /dev/null +++ b/bemade_fsm/models/fsm_visit.py @@ -0,0 +1,104 @@ +from odoo import models, fields, api + + +class FSMVisit(models.Model): + _name = "bemade_fsm.visit" + _description = "Represents a single visit by assigned service personnel." + + label = fields.Text( + string="Label", + required=True, + related="so_section_id.name", + readonly=False, + copy=True, + ) + + approx_date = fields.Date(string="Approximate Date", copy=False) + + so_section_id = fields.Many2one( + comodel_name="sale.order.line", + string="Sale Order Section", + help=( + "The section on the sale order that represents the labour and parts for" + " this visit" + ), + ondelete="cascade", + ) + + sale_order_id = fields.Many2one( + comodel_name="sale.order", + string="Sales Order", + readonly=True, + ) + + is_completed = fields.Boolean( + string="Completed", related="so_section_id.is_fully_delivered" + ) + + is_invoiced = fields.Boolean( + string="Invoiced", related="so_section_id.is_fully_delivered_and_invoiced" + ) + + summarized_equipment_ids = fields.Many2many( + comodel_name="fsm.equipment", + string="Equipment to Service", + compute="_compute_summarized_equipment_ids", + ) + + task_id = fields.Many2one( + comodel_name="project.task", + compute="_compute_task_id", + string="Service Visit", + ) + + task_ids = fields.One2many(comodel_name="project.task", inverse_name="visit_id") + + visit_no = fields.Integer( + compute="_compute_visit_no", + ) + + @api.depends("task_ids") + def _compute_task_id(self): + for rec in self: + rec.task_id = rec.task_ids and rec.task_ids[0] + + @api.depends("so_section_id", "sale_order_id.summary_equipment_ids") + def _compute_summarized_equipment_ids(self): + for rec in self: + lines = rec.so_section_id.get_section_line_ids() + equipment_ids = [] + for line in lines: + for equipment in line.equipment_ids: + equipment_ids.append(equipment) + rec.summarized_equipment_ids = equipment_ids + + @api.model_create_multi + def create(self, vals_list): + recs = super().create(vals_list) + for i, rec in enumerate(recs.filtered(lambda visit: not visit.so_section_id)): + rec.so_section_id = rec.env["sale.order.line"].create( + { + "order_id": rec.sale_order_id.id, + "display_type": "line_section", + "name": vals_list[i].get("label", False), + } + ) + return recs + + @api.depends( + "so_section_id", + "sale_order_id", + "sale_order_id.visit_ids", + "sale_order_id.visit_ids.so_section_id", + "sale_order_id.visit_ids.so_section_id.sequence", + ) + def _compute_visit_no(self): + for rec in self: + ordered_visit_lines = self.sale_order_id.visit_ids.so_section_id.sorted( + "sequence" + ) + # Just a straight O(n) search here since n will always be relatively small + for index, line in enumerate(ordered_visit_lines): + if line == rec.so_section_id: + rec.visit_no = index + 1 + return diff --git a/bemade_fsm/models/product_template.py b/bemade_fsm/models/product_template.py new file mode 100644 index 0000000..9da9706 --- /dev/null +++ b/bemade_fsm/models/product_template.py @@ -0,0 +1,19 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + task_template_id = fields.Many2one( + comodel_name="project.task.template", + string="Task Template", + ondelete="restrict", + ) + + is_field_service = fields.Boolean( + string="Plan as field service", + help=( + "Products planned as field service will have travel time considered in" + " planning." + ), + ) diff --git a/bemade_fsm/models/project.py b/bemade_fsm/models/project.py new file mode 100644 index 0000000..79b539f --- /dev/null +++ b/bemade_fsm/models/project.py @@ -0,0 +1,13 @@ +from odoo import models + + +class Project(models.Model): + _inherit = "project.project" + + def _fetch_sale_order_item_ids( + self, domain_per_model=None, limit=None, offset=None + ): + # Override to flush the ORM cache to the database prior to running the query + # Temporary fix until Odoo fixes this method (PR #160067 submitted for this) + self.env.flush_all() + return super()._fetch_sale_order_item_ids(domain_per_model, limit, offset) diff --git a/bemade_fsm/models/res_company.py b/bemade_fsm/models/res_company.py new file mode 100644 index 0000000..0d2fd87 --- /dev/null +++ b/bemade_fsm/models/res_company.py @@ -0,0 +1,8 @@ +from odoo import models, fields + + +class Company(models.Model): + _inherit = "res.company" + + split_time_from_materials_on_service_work_orders = fields.Boolean(default=False) + create_default_fsm_visit = fields.Boolean(default=False) diff --git a/bemade_fsm/models/res_partner.py b/bemade_fsm/models/res_partner.py new file mode 100644 index 0000000..8d9c0ed --- /dev/null +++ b/bemade_fsm/models/res_partner.py @@ -0,0 +1,61 @@ +from odoo import api, fields, models + + +class Partner(models.Model): + _inherit = "res.partner" + + is_site_contact = fields.Boolean( + string="Is a site contact", + compute="_compute_is_site_contact", + search="_search_is_site_contact", + ) + + is_service_site = fields.Boolean( + help="A partner is a service site if they have one or more equipments, " + "site contacts or work order contacts." + ) + + site_ids = fields.Many2many( + string="Work Sites", + comodel_name="res.partner", + relation="res_partner_site_contact_rel", + column1="site_contact_id", + column2="site_id", + tracking=True, + domain=[("is_service_site", "=", True)], + ) + + site_contacts = fields.Many2many( + comodel_name="res.partner", + relation="res_partner_site_contact_rel", + column1="site_id", + column2="site_contact_id", + domain=[("is_service_site", "=", False)], + tracking=True, + ) + + work_order_contacts = fields.Many2many( + string="Work Order Recipients", + comodel_name="res.partner", + relation="res_partner_work_order_contacts_rel", + column1="res_partner_id", + column2="work_order_contact_id", + domain=[("is_service_site", "=", False)], + tracking=True, + ) + + @api.depends("site_ids") + def _compute_is_site_contact(self): + for rec in self: + rec.is_site_contact = rec.site_ids + + @api.model + def _search_is_site_contact(self, operator, value): + return [("site_ids", "!=", False)] + + @api.depends("equipment_ids", "site_contacts", "work_order_contacts") + def _compute_is_service_site(self): + for rec in self: + rec.is_service_site = ( + rec.equipment_ids or rec.site_contacts or rec.work_order_contacts + ) diff --git a/bemade_fsm/models/sale_order.py b/bemade_fsm/models/sale_order.py new file mode 100644 index 0000000..777c149 --- /dev/null +++ b/bemade_fsm/models/sale_order.py @@ -0,0 +1,163 @@ +from odoo import fields, models, api, _, Command + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + valid_equipment_ids = fields.One2many( + comodel_name="fsm.equipment", + related="partner_id.commercial_partner_id.owned_equipment_ids", + ) + + default_equipment_ids = fields.Many2many( + comodel_name="fsm.equipment", + string="Default Equipment to Service", + help="The default equipment to service for new sale order lines.", + compute="_compute_default_equipment", + inverse="_inverse_default_equipment", + store=True, + ) + + summary_equipment_ids = fields.Many2many( + comodel_name="fsm.equipment", + string="Equipment Being Serviced", + compute="_compute_summary_equipment_ids", + ) + + site_contacts = fields.Many2many( + comodel_name="res.partner", + relation="sale_order_site_contacts_rel", + compute="_compute_default_contacts", + inverse="_inverse_default_contacts", + store=True, + ) + + work_order_contacts = fields.Many2many( + comodel_name="res.partner", + relation="sale_order_work_order_contacts_rel", + compute="_compute_default_contacts", + inverse="_inverse_default_contacts", + string="Work Order Recipients", + store=True, + ) + + visit_ids = fields.One2many( + comodel_name="bemade_fsm.visit", inverse_name="sale_order_id", readonly=False + ) + + is_fsm = fields.Boolean( + compute="_compute_is_fsm", + string="Is FSM", + store=True, + ) + + def get_relevant_order_lines(self, task_id): + self.ensure_one() + linked_lines = self.order_line.filtered( + lambda line: line.task_id == task_id + or line == task_id.visit_id.so_section_id + ) + visit_lines = linked_lines.filtered(lambda line: line.visit_id) + for line in visit_lines: + linked_lines |= line.get_section_line_ids() + return linked_lines + + @api.depends("order_line.equipment_ids") + def _compute_summary_equipment_ids(self): + for rec in self: + rec.summary_equipment_ids = rec.order_line.mapped("equipment_ids") + + @api.onchange("partner_shipping_id") + def _onchange_partner_shipping_id(self): + res = super()._onchange_partner_shipping_id() + self._compute_default_equipment() + self._compute_default_contacts() + return res + + @api.depends("partner_shipping_id") + def _compute_default_contacts(self): + for rec in self: + rec.site_contacts = rec.partner_shipping_id.site_contacts + rec.work_order_contacts = rec.partner_shipping_id.work_order_contacts + + def _inverse_default_contacts(self): + pass + + @api.depends( + "partner_id", + "partner_shipping_id", + "partner_shipping_id.equipment_ids", + "partner_id.owned_equipment_ids", + ) + def _compute_default_equipment(self): + for rec in self: + if rec.partner_shipping_id.equipment_ids: + ids = rec.partner_shipping_id.equipment_ids + else: + ids = rec.partner_id.owned_equipment_ids + rec.default_equipment_ids = ids if len(ids) < 4 else False + + def _inverse_default_equipment(self): + pass + + def copy(self, default=None): + rec = super().copy(default) + rec.visit_ids = [Command.set(rec.order_line.visit_ids.ids)] + return rec + + def _create_default_visit(self): + """Called when an order is confirmed with lines that will create an FSM task, + in order to make sure there is a visit line grouping all the service being done. + """ + self.ensure_one() + visit = self.env["bemade_fsm.visit"].create( + { + "label": _("Service Visit"), + "sale_order_id": self.id, + } + ) + # Make sure it goes to the top of the list + visit.so_section_id.sequence = 0 + + def _create_or_organize_visits_if_needed(self): + """Adds a visit line to the top of the order if there are not already visit + lines for an order with lines that will create an FSM task.""" + for order in self.filtered("company_id.create_default_fsm_visit"): + if not order.visit_ids and order.is_fsm: + order._create_default_visit() + if order.is_fsm: + # Make sure that all the lines producing FSM tasks are under a visit + visit_line_ids = ( + order.mapped("visit_ids") + .mapped("so_section_id") + .mapped("section_line_ids") + ) + if any( + [ + True + for line in order.order_line.filtered( + lambda line: not line.display_type + ) + if line not in visit_line_ids + ] + ): + # If not, promote the first visit to the top of the order items list + for line in order.order_line: + line.sequence += 1 + order.mapped("visit_ids").mapped("so_section_id")[0].sequence = 0 + + @api.depends("order_line.is_fsm") + def _compute_is_fsm(self): + for rec in self: + rec.is_fsm = any([line.is_fsm for line in rec.order_line]) + + def action_confirm(self): + self._create_or_organize_visits_if_needed() + return super().action_confirm() + + def write(self, vals): + res = super().write(vals) + if "partner_shipping_id" in vals: + for rec in self: + rec.tasks_ids.write({"partner_id": rec.partner_shipping_id.id}) + return res diff --git a/bemade_fsm/models/sale_order_line.py b/bemade_fsm/models/sale_order_line.py new file mode 100644 index 0000000..5d34b46 --- /dev/null +++ b/bemade_fsm/models/sale_order_line.py @@ -0,0 +1,304 @@ +from odoo import fields, models, api, _, Command + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + valid_equipment_ids = fields.One2many( + comodel_name="fsm.equipment", related="order_id.valid_equipment_ids" + ) + + visit_ids = fields.One2many( + comodel_name="bemade_fsm.visit", + inverse_name="so_section_id", + string="Visits", + copy=True, + ) + + visit_id = fields.Many2one( + comodel_name="bemade_fsm.visit", + compute="_compute_visit_id", + string="Visit", + ondelete="cascade", + store=True, + ) + + is_fully_delivered = fields.Boolean( + string="Fully Delivered", + compute="_compute_is_fully_delivered", + help=( + "Indicates whether a line or all the lines in a section have been entirely" + " delivered." + ), + ) + + is_fully_delivered_and_invoiced = fields.Boolean( + string="Fully Invoiced", + compute="_compute_is_fully_invoiced", + help=( + "Indicates whether a line or all the lines in a section have been entirely" + " delivered and invoiced." + ), + ) + + equipment_ids = fields.Many2many( + string="Equipment to Service", + comodel_name="fsm.equipment", + relation="bemade_fsm_equipment_sale_order_line_rel", + column1="sale_order_line_id", + column2="equipment_id", + ) + + is_field_service = fields.Boolean(compute="_compute_is_field_service", store=True) + + is_fsm = fields.Boolean( + string="Is FSM", + compute="_compute_is_fsm", + store=True, + ) + + section_line_ids = fields.One2many( + comodel_name="sale.order.line", + compute="_compute_section_line_ids", + ) + + @api.depends("visit_ids") + def _compute_visit_id(self): + for rec in self: + rec.visit_id = rec.visit_ids and rec.visit_ids[0] + + @api.depends("product_id") + def _compute_is_field_service(self): + for rec in self: + rec.is_field_service = rec.product_id.is_field_service + + @api.model_create_multi + def create(self, vals_list): + recs = super().create(vals_list) + for rec in recs: + if rec.order_id.default_equipment_ids and not rec.equipment_ids: + rec.equipment_ids = rec.order_id.default_equipment_ids + return recs + + def copy_data(self, default=None): + if default is None: + default = {} + if "visit_ids" not in default: + default["visit_ids"] = [ + (0, 0, visit.copy_data()[0]) for visit in self.visit_ids + ] + return super().copy_data(default) + + def _timesheet_create_task(self, project): + """Generate task for the given so line, and link it. + :param project: record of project.project in which the task should be created + :return task: record of the created task + + Override to add the logic needed to implement task templates and equipment linkages. + """ + + def _create_task_from_template(project, template, parent): + """Recursively generates the task and any subtasks from a project.task.template. + + :param project: project.project record to set on the task's project_id field. + :param template: project.task.template to use to create the task. + :param parent: project.task to set as the parent to this task. + """ + values = _timesheet_create_task_prepare_values_from_template( + project, template, parent + ) + task = self.env["project.task"].sudo().create(values) + subtasks = [] + for t in template.subtasks: + subtask = _create_task_from_template(project, t, task) + subtasks.append(subtask) + + # We don't want to see the sub-tasks on the SO + if task.child_ids: + task.child_ids.write( + { + "sale_order_id": None, + "sale_line_id": None, + } + ) + + return task + + def _timesheet_create_task_prepare_values_from_template( + project, template, parent + ): + """Copies the values from a project.task.template over to the set of values used to create a project.task. + + :param project: project.project record to set on the task's project_id field. + Pass the project.project model or an empty recordset to leave task project_id blank. + DO NOT pass False or None as this will cause an error in _timesheet_create_task_prepare_values(project). + :param template: project.task.template to use to create the task. + :param parent: project.task to set as the parent to this task. + """ + vals = self._timesheet_create_task_prepare_values(project) + vals["name"] = template.name + vals["description"] = ( + template.description or "" if parent else vals["description"] + ) + vals["parent_id"] = parent and parent.id + vals["user_ids"] = template.assignees.ids + vals["tag_ids"] = template.tags.ids + vals["allocated_hours"] = template.planned_hours + vals["sequence"] = template.sequence + # Use shipping address for FSM tasks for consistency + if project and project.is_fsm: + vals["partner_id"] = self.order_id.partner_shipping_id.id + else: + vals["partner_id"] = self.order_id.partner_id.id + if template.equipment_ids: + vals["equipment_ids"] = template.equipment_ids.ids + return vals + + tmpl = self.product_id.task_template_id + if not tmpl: + task = super()._timesheet_create_task(project) + # For FSM tasks without a template, update partner_id to use shipping address + if project.is_fsm and task: + task.partner_id = self.order_id.partner_shipping_id.id + else: + task = _create_task_from_template(project, tmpl, None) + self.write({"task_id": task.id}) + # post message on task + task_msg = _( + "This task has been created from: %(so_name)s (%(product_name)s)" + ) % { + "so_id": self.order_id.id, + "so_name": self.order_id.name, + "product_name": self.product_id.name, + } + task.message_post(body=task_msg) + + if not task.equipment_ids and self.equipment_ids: + task.equipment_ids = self.equipment_ids.ids + return task + + def _timesheet_service_generation(self): + super()._timesheet_service_generation() + visit_lines = self.filtered(lambda line: line.visit_id) + for index, line in enumerate(visit_lines): + task_ids = line.get_section_line_ids().mapped("task_id") + if not task_ids: + continue + if len(set([task.project_id for task in task_ids])) > 1: + # Can't group up the tasks if they're part of different projects + return + project_id = task_ids[0].project_id + line.visit_id.task_id = line._generate_task_for_visit_line( + project_id, index + 1, sum(task_ids.mapped("allocated_hours")) + ) + task_ids.write({"parent_id": line.visit_id.task_id.id}) + (self.mapped("task_id") | self.visit_ids.task_id).filtered( + "is_fsm" + ).synchronize_name_fsm() + + def _generate_task_for_visit_line( + self, project, visit_no: int, allocated_hours: int + ): + self.ensure_one() + + allocated_hours = sum( + self.get_section_line_ids().task_id.mapped("allocated_hours") + ) + task = self.env["project.task"].create( + { + "name": ( + f"{self.order_id.name} - " + + _("Visit") + + f" {visit_no} - {self.name}" + ), + "project_id": project.id, + "equipment_ids": ( + self.get_section_line_ids().mapped("equipment_ids").ids + ), + "sale_order_id": self.order_id.id, + "partner_id": self.order_id.partner_shipping_id.id, + "visit_id": self.visit_id.id, + "allocated_hours": allocated_hours, + "date_deadline": self.visit_id.approx_date, + "user_ids": False, # Force to empty or it uses the current user + } + ) + return task + + @api.depends( + "order_id.order_line", + "display_type", + "qty_to_deliver", + "order_id.order_line.qty_to_deliver", + "order_id.order_line.display_type", + ) + def _compute_is_fully_delivered(self): + for rec in self: + rec.is_fully_delivered = rec._iterate_items_compute_bool( + lambda line: line.qty_to_deliver == 0 + ) + + @api.depends("is_fully_delivered") + def _compute_is_fully_invoiced(self): + for rec in self: + if not rec.is_fully_delivered: + rec.is_fully_delivered_and_invoiced = False + return + rec.is_fully_delivered_and_invoiced = rec._iterate_items_compute_bool( + lambda line: line.qty_to_invoice == 0 + ) + + def get_section_line_ids(self): + """Return a recordset of sale.order.line records that are in this sale order section.""" + self.ensure_one() + assert ( + self.display_type == "line_section" + ), "Cannot get section lines for a non-section." + found = False + lines = [] + for line in self.order_id.order_line.sorted(lambda line: line.sequence): + if line == self: + found = True + continue + if not found: + continue + if line.display_type == "line_section": # Stop when we hit the next section + break + else: + lines.append(line) + return self.env["sale.order.line"].union(*lines) + + @api.depends("display_type", "order_id.order_line") + def _compute_section_line_ids(self): + for rec in self: + if rec.display_type == "line_section": + rec.section_line_ids = [Command.set(rec.get_section_line_ids().ids)] + else: + rec.section_line_ids = False + + def _iterate_items_compute_bool(self, single_line_func): + if not self.display_type: + return single_line_func(self) + elif self.display_type == "line_note": + return True + else: + for line in self.order_id.order_line: + found = False + if line == self: + found = True + if not found: + continue + if found and line.display_type == "line_section": + return True + val = single_line_func(self) + if not val: + return val + return True + + @api.depends("product_id.type", "product_id.service_tracking") + def _compute_is_fsm(self): + for rec in self: + rec.is_fsm = ( + rec.product_id.type == "service" + and rec.product_id.service_tracking == "task_global_project" + ) diff --git a/bemade_fsm/models/task.py b/bemade_fsm/models/task.py new file mode 100644 index 0000000..6b28818 --- /dev/null +++ b/bemade_fsm/models/task.py @@ -0,0 +1,257 @@ +from odoo import fields, models, api, Command +from odoo.addons.project.models.project_task import CLOSED_STATES +import re +from typing import cast, List + + +class Task(models.Model): + _inherit = "project.task" + + work_order_contacts = fields.Many2many( + comodel_name="res.partner", + relation="task_work_order_contact_rel", + column1="task_id", + column2="res_partner_id", + ) + + site_contacts = fields.Many2many( + comodel_name="res.partner", + relation="task_site_contact_rel", + column1="task_id", + column2="res_partner_id", + ) + + # Override related field to make it return false if this is an FSM subtask + allow_billable = fields.Boolean( + string="Can be billed", + related=False, + compute="_compute_allow_billable", + store=True, + ) + + visit_id = fields.Many2one( + comodel_name="bemade_fsm.visit", + string="Visit", + ) + + relevant_order_lines = fields.Many2many( + comodel_name="sale.order.line", + store=False, + compute="_compute_relevant_order_lines", + ) + + work_order_number = fields.Char(readonly=True) + + propagate_assignment = fields.Boolean( + help="Propagate assignment of this task to all subtasks.", + default=False, + ) + + is_closed = fields.Boolean( + compute="_compute_is_closed", + ) + + root_ancestor = fields.Many2one( + comodel_name="project.task", + compute="_compute_root_ancestor", + recursive=True, + ) + + def _compute_is_closed(self): + for rec in self: + rec.is_closed = rec.state in CLOSED_STATES + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + for rec in res: + if rec.parent_id and rec.is_fsm: + # Always ensure FSM subtasks have a partner_id set from their parent + rec.partner_id = rec.parent_id.partner_id + if not rec.work_order_contacts and rec.parent_id: + rec.work_order_contacts = rec.parent_id.work_order_contacts + if not rec.site_contacts and rec.parent_id: + rec.site_contacts = rec.parent_id.site_contacts + if rec.sale_order_id: + seq = 1 + prev_seqs = ( + self.sale_order_id.tasks_ids + and self.sale_order_id.tasks_ids.mapped("work_order_number") + ) + if prev_seqs: + pattern = re.compile(r"(\d+)$") + matches = map( + lambda n: pattern.search(n), cast(List[str], prev_seqs) + ) + seq += max(map(lambda n: int(n.group(1)) if n else 0, matches)) + rec.work_order_number = ( + rec.sale_order_id.name.replace("SO", "SVR", 1) + f"-{seq}" + ) + # If the task is linked to a sales order and has no parent, it should inherit SO work order contacts + if ( + not rec.parent_id + and not rec.work_order_contacts + and rec.sale_order_id.work_order_contacts + ): + rec.work_order_contacts = rec.sale_order_id.work_order_contacts + if ( + not rec.parent_id + and not rec.site_contacts + and rec.sale_order_id.site_contacts + ): + rec.site_contacts = rec.sale_order_id.site_contacts + return res + + def write(self, vals): + res = super().write(vals) + if not self: # End recursion on empty RecordSet + return res + if "propagate_assignment" in vals: + # When a user sets propagate assignment, it should propagate that setting all the way down the chain + self.child_ids.write({"propagate_assignment": vals["propagate_assignment"]}) + if "user_ids" in vals: + to_propagate = self.filtered(lambda task: task.propagate_assignment) + # Here we use child_ids instead of _get_all_subtasks() so as to allow for setting propagate_assignment + # to false on a child task. + to_propagate.child_ids.write({"user_ids": vals["user_ids"]}) + for rec in self: + if rec.child_ids: + child_vals = {} + if "site_contacts" in vals: + child_vals.update( + site_contacts=[Command.set(rec.site_contacts.ids)] + ) + if "work_order_contacts" in vals: + child_vals.update( + work_order_contacts=[Command.set(rec.work_order_contacts.ids)] + ) + if "partner_id" in vals: + child_vals.update(partner_id=vals["partner_id"]) + if "state" in vals and rec.state in CLOSED_STATES: + # Propagate task completion or cancelling to subtasks + child_vals.update(state=rec.state) + if child_vals: + rec.child_ids.write(child_vals) + return res + + @api.depends("sale_order_id") + def _compute_relevant_order_lines(self): + for rec in self: + rec.relevant_order_lines = ( + rec.sale_order_id + and rec.sale_order_id.get_relevant_order_lines(rec) + or False + ) + + @api.depends("parent_id.visit_id", "project_id.is_fsm", "project_id.allow_billable") + def _compute_allow_billable(self): + for rec in self: + # If an FSM task has a parent that is linked to an SO line, then the parent is the billable one + if ( + rec.parent_id + and not rec.parent_id.visit_id + and rec.project_id + and rec.project_id.is_fsm + ): + rec.allow_billable = False + else: + rec.allow_billable = rec.project_id.allow_billable + + def _fsm_create_sale_order_line(self): + # Override to not generate new lines for tasks that are just linked to a visit item + self.ensure_one() + if self.visit_id: + return + else: + super()._fsm_create_sale_order_line() + + def action_fsm_validate(self, stop_running_timers=False): + # Override to close out subtasks as well + all_tasks = self | self._get_all_subtasks() + return super(Task, all_tasks).action_fsm_validate(stop_running_timers) + + def _get_full_hierarchy(self): + if self.child_ids: + return self | self.child_ids._get_full_hierarchy() + return self + + def synchronize_name_fsm(self): + """Applies naming to the entire task tree for tasks that are part of this + recordset. Root tasks are named: + + Partner Shipping Name - Sale Line Name (Template Name) + + Child tasks with sale_line_id are named by their template if set, sale line name + if not. + + Child tasks not linked to sale lines are left with their original names.""" + + all_tasks = self | self._get_all_subtasks() + for rec in all_tasks: + assert rec.is_fsm, "This method should only be called on FSM tasks." + + template = rec.sale_line_id and rec.sale_line_id.product_id.task_template_id + + if template: + title = template.name + elif rec.sale_line_id: + name_parts = rec.sale_line_id.name.split("\n") + title = name_parts and name_parts[0] or rec.sale_line_id.product_id.name + elif rec.visit_id: + title = rec.visit_id.label + else: + rec.name = rec.name + return + + client_name = rec.sale_order_id.partner_shipping_id.name + + if not rec.parent_id: + rec.name = f"{rec.work_order_number} - {client_name} - {title}" + else: + rec.name = title + + @api.depends("parent_id", "parent_id.root_ancestor") + def _compute_root_ancestor(self): + for rec in self: + rec.root_ancestor = rec.parent_id and rec.parent_id.root_ancestor or self + + @api.depends( + "partner_id", + "sale_line_id.order_partner_id", + "parent_id.sale_line_id", + "project_id.sale_line_id", + "milestone_id.sale_line_id", + "allow_billable", + ) + def _compute_sale_line(self): + """Override to prevent subtasks from inheriting parent's sale_line_id. + + In the base implementation, if a task and its parent share the same commercial partner, + the task will inherit the parent's sale_line_id. This causes issues with our FSM tasks + where we explicitly want subtasks to NOT have a sale_line_id set. + """ + + # Only run on root tasks + subtasks = self.filtered("parent_id") + (subtasks - subtasks.filtered("sale_line_id")).sale_line_id = False + super(Task, self - subtasks)._compute_sale_line() + + @api.depends("parent_id.partner_id", "project_id") + def _compute_partner_id(self): + """Override to prevent clearing partner_id for FSM tasks. + + In the base implementation, if a task has a partner_id but no project_id or parent_id, + the partner_id is cleared. This causes issues with our FSM tasks where we want to + preserve the partner_id even if project_id or parent_id is temporarily not set. + """ + # Only run the standard logic on non-FSM tasks + non_fsm_tasks = self.filtered(lambda t: not t.is_fsm) + super(Task, non_fsm_tasks)._compute_partner_id() + + # For FSM tasks, only set partner_id if it's not already set + fsm_tasks = self - non_fsm_tasks + for task in fsm_tasks: + if not task.partner_id: + task.partner_id = self._get_default_partner_id( + task.project_id, task.parent_id + ) diff --git a/bemade_fsm/models/task_template.py b/bemade_fsm/models/task_template.py new file mode 100644 index 0000000..8a9d453 --- /dev/null +++ b/bemade_fsm/models/task_template.py @@ -0,0 +1,122 @@ +from odoo import models, fields, api, Command + + +class TaskTemplate(models.Model): + _name = "project.task.template" + _description = "Template for new project tasks" + + @api.model + def _current_company(self): + return self.env.company + + name = fields.Char(string="Task Title", required=True) + + description = fields.Html() + + assignees = fields.Many2many( + comodel_name="res.users", + string="Default Assignees", + help="Employees assigned to tasks created from this template.", + ) + + customer = fields.Many2one( + comodel_name="res.partner", + string="Default Customer", + help="Default customer for tasks created from this template.", + default=lambda self: self.parent.customer if self.parent else False, + ) + + project = fields.Many2one( + comodel_name="project.project", + string="Default Project", + help="Default project for tasks created from this template.", + ) + + tags = fields.Many2many( + comodel_name="project.tags", + string="Default Tags", + help="Default tags for tasks created from this template.", + ) + + parent = fields.Many2one( + comodel_name="project.task.template", + string="Parent Task Template", + ondelete="cascade", + ) + + subtasks = fields.One2many( + comodel_name="project.task.template", + inverse_name="parent", + string="Subtask Templates", + ) + sequence = fields.Integer() + + company_id = fields.Many2one( + comodel_name="res.company", string="Company", index=1, default=_current_company + ) + + planned_hours = fields.Float("Initially Planned Hours") + + equipment_ids = fields.Many2many( + comodel_name="fsm.equipment", + relation="bemade_fsm_task_template_equipment_rel", + column1="task_template_id", + column2="equipment_id", + string="Equipment to Service", + ) + + def action_open_task(self): + return { + "view_mode": "form", + "res_model": "project.task.template", + "res_id": self.id, + "type": "ir.actions.act_window", + "context": self._context, + } + + @api.onchange("customer") + def _onchange_customer(self): + for rec in self: + new_equipment_ids = [ + eq.id for eq in rec.equipment_ids if eq.partner_id == rec.customer + ] + rec.write({"equipment_ids": [Command.set(new_equipment_ids)]}) + + def _prepare_new_task_values_from_self(self, project, name=False, parent_id=False): + partner_id = self.customer or self.parent.customer or False + if not partner_id and parent_id: + parent = self.env["project.task"].browse(parent_id) + partner_id = parent.partner_id or parent.sale_order_id.partner_shipping_id + vals = { + "project_id": project.id, + "name": name or self.name, + "description": self.description, + "parent_id": parent_id, + "user_ids": self.assignees.ids, + "tag_ids": self.tags.ids, + "allocated_hours": self.planned_hours, + "sequence": self.sequence, + "equipment_ids": ( + [Command.set(self.equipment_ids.ids)] if self.equipment_ids else False + ), + "partner_id": partner_id and partner_id.id, + "company_id": self.company_id.id, + } + return vals + + def create_task_from_self(self, project, name=False, parent_id=False): + """Create a project.task from this template and return it. + Can be called on a RecordSet of multiple templates. + + :param project: project.project record the task should be added to + :param name: name for the new task (defaults to template name) + :param parent_id: parent task for the new task (none by default) + :return: project.task record created from this template + """ + tasks = self.env["project.task"] + for rec in self: + vals = rec._prepare_new_task_values_from_self(project, name, parent_id) + task = rec.env["project.task"].create(vals) + rec.subtasks.create_task_from_self(project, name=False, parent_id=task.id) + tasks |= task + return tasks diff --git a/bemade_fsm/reports/__init__.py b/bemade_fsm/reports/__init__.py new file mode 100644 index 0000000..bb06496 --- /dev/null +++ b/bemade_fsm/reports/__init__.py @@ -0,0 +1 @@ +from . import worksheet_custom_reports diff --git a/bemade_fsm/reports/worksheet_custom_report_templates.xml b/bemade_fsm/reports/worksheet_custom_report_templates.xml new file mode 100644 index 0000000..3ed543e --- /dev/null +++ b/bemade_fsm/reports/worksheet_custom_report_templates.xml @@ -0,0 +1,581 @@ + + + + + + + + + + + + + + diff --git a/bemade_fsm/reports/worksheet_custom_reports.py b/bemade_fsm/reports/worksheet_custom_reports.py new file mode 100644 index 0000000..9fd6168 --- /dev/null +++ b/bemade_fsm/reports/worksheet_custom_reports.py @@ -0,0 +1,13 @@ +from odoo import models + + +class TaskCustomReport(models.AbstractModel): + _inherit = "report.industry_fsm.worksheet_custom" + + def _get_report_values(self, docids, data=None): + vals = super()._get_report_values(docids, data) + split_time_materials = ( + self.env.company.split_time_from_materials_on_service_work_orders + ) + vals.update({"split_time_materials": split_time_materials}) + return vals diff --git a/bemade_fsm/reports/worksheet_custom_reports.xml b/bemade_fsm/reports/worksheet_custom_reports.xml new file mode 100644 index 0000000..4ddbe16 --- /dev/null +++ b/bemade_fsm/reports/worksheet_custom_reports.xml @@ -0,0 +1,11 @@ + + + + '%s %s' % ( + object.planned_date_begin.strftime( + '%Y-%m-%d') if object.planned_date_begin else time.strftime('%Y-%m-%d'), + object.name + ) + + + diff --git a/bemade_fsm/security/ir.model.access.csv b/bemade_fsm/security/ir.model.access.csv new file mode 100644 index 0000000..12cb538 --- /dev/null +++ b/bemade_fsm/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_bemade_fsm_task_template_manager,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1 +access_bemade_fsm_task_template_user,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1 +access_bemade_fsm_visit,access_bemade_fsm_visit,model_bemade_fsm_visit,base.group_user,1,1,1,1 +access_bemade_fsm_task_wizard,access_bemade_fsm_task_wizard,model_project_task_from_template_wizard,project.group_project_user,1,1,1,1 diff --git a/bemade_fsm/static/src/js/kanban_view.js b/bemade_fsm/static/src/js/kanban_view.js new file mode 100644 index 0000000..7f99dcc --- /dev/null +++ b/bemade_fsm/static/src/js/kanban_view.js @@ -0,0 +1,37 @@ +/** @odoo-module **/ + +import KanbanController from 'web.KanbanController'; +import KanbanView from 'web.KanbanView'; +const viewRegistry = require('web.view_registry') + +const ProjectKanbanController = KanbanController.extend({ + buttons_template: 'project.KanbanView.buttons', + custom_events: _.extend({}, KanbanController.prototype.custom_events, { + create_from_template: '_onCreateFromTemplate', + }), + _onCreateFromTemplate(ev) { + ev.stopPropagation(); + const record = this.model.get(this.handle, {raw: true}); + let context = record.context; + context.active_id = record.project_id; + this.do_action({ + type: 'ir.actions.act_window', + res_model: 'project.task.from.template.wizard', + views: [[false, 'form']], + target: 'new', + context: {...record.context, res_model: 'project.task'}, + }); + }, + renderButtons ($node) { + this._super.apply(this, arguments); + this.$buttons.on('click', 'button.o-kanban-button-new-from-template', this._onCreateFromTemplate.bind(this)) + }, +}) + +const ProjectKanbanView = KanbanView.extend({ + config: _.extend({}, KanbanView.prototype.config, { + Controller: ProjectKanbanController, + }) +}); + +viewRegistry.add('project_kanban', ProjectKanbanView) \ No newline at end of file diff --git a/bemade_fsm/static/src/js/list_view.js b/bemade_fsm/static/src/js/list_view.js new file mode 100644 index 0000000..dc60c3a --- /dev/null +++ b/bemade_fsm/static/src/js/list_view.js @@ -0,0 +1,36 @@ +/** @odoo-module **/ + +import ListController from 'web.ListController'; +import ListView from 'web.ListView'; +const viewRegistry = require('web.view_registry') + +const ProjectListController = ListController.extend({ + buttons_template: 'project.ListView.buttons', + custom_events: _.extend({}, ListController.prototype.custom_events, { + create_from_template: '_onCreateFromTemplate', + }), + _onCreateFromTemplate(ev) { + ev.stopPropagation(); + const record = this.model.get(this.handle, {raw: true}); + this.do_action({ + type: 'ir.actions.act_window', + res_model: 'project.task.from.template.wizard', + views: [[false, 'form']], + target: 'new', + context: {...record.context, res_model: 'project.task'}, + }); + }, + renderButtons ($node) { + self = this; + this._super.apply(this, arguments); + this.$buttons.on('click', 'button.o_list_button_add_from_template', this._onCreateFromTemplate.bind(this)) + }, +}) + +const ProjectListView = ListView.extend({ + config: _.extend({}, ListView.prototype.config, { + Controller: ProjectListController, + }) +}); + +viewRegistry.add('project_list', ProjectListView) \ No newline at end of file diff --git a/bemade_fsm/static/src/scss/bemade_fsm.scss b/bemade_fsm/static/src/scss/bemade_fsm.scss new file mode 100644 index 0000000..b100402 --- /dev/null +++ b/bemade_fsm/static/src/scss/bemade_fsm.scss @@ -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; +} \ No newline at end of file diff --git a/bemade_fsm/static/src/xml/project_view_buttons.xml b/bemade_fsm/static/src/xml/project_view_buttons.xml new file mode 100644 index 0000000..5eb7c73 --- /dev/null +++ b/bemade_fsm/static/src/xml/project_view_buttons.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bemade_fsm/tests/__init__.py b/bemade_fsm/tests/__init__.py new file mode 100644 index 0000000..65b4ad7 --- /dev/null +++ b/bemade_fsm/tests/__init__.py @@ -0,0 +1,9 @@ +from . import test_bemade_fsm_common +from . import test_task_template +from . import test_sale_order +from . import test_fsm_contact_setting +from . import test_fsm_visit +from . import test_task +from . import test_task_report +from . import test_settings +from . import test_equipment diff --git a/bemade_fsm/tests/test_bemade_fsm_common.py b/bemade_fsm/tests/test_bemade_fsm_common.py new file mode 100644 index 0000000..14de4bd --- /dev/null +++ b/bemade_fsm/tests/test_bemade_fsm_common.py @@ -0,0 +1,292 @@ +from odoo.tests.common import TransactionCase, tagged +from odoo import Command + + +@tagged("-at_install", "post_install") +class BemadeFSMBaseTest(TransactionCase): + @classmethod + def _generate_project_manager_user(cls, name, login): + group_ids = cls.__get_user_groups() + user_group_project_manager = cls.env.ref("project.group_project_manager") + user_group_fsm_manager = cls.env.ref("industry_fsm.group_fsm_manager") + group_ids.append(user_group_fsm_manager.id) + group_ids.append(user_group_project_manager.id) + + return cls.__generate_user(name, login, group_ids) + + @classmethod + def _generate_project_user(cls, name, login): + group_ids = cls.__get_user_groups() + return cls.__generate_user(name, login, group_ids) + + @classmethod + def __generate_user(cls, name, login, group_ids): + return ( + cls.env["res.users"] + .with_context(no_reset_password=True) + .create( + { + "name": name, + "login": login, + "password": login, + "email": f"{login}@test.co", + "groups_id": [Command.set(group_ids)], + } + ) + ) + + @classmethod + def __get_user_groups(cls): + user_group_employee = cls.env.ref("base.group_user") + user_group_project_user = cls.env.ref("project.group_project_user") + user_group_fsm_user = cls.env.ref("industry_fsm.group_fsm_user") + user_group_sales_user = cls.env.ref("sales_team.group_sale_salesman") + user_group_sales_manager = cls.env.ref("sales_team.group_sale_manager") + user_group_delivery_address = cls.env.ref( + "account.group_delivery_invoice_address" + ) + user_product_customer = cls.env.ref( + "customer_product_code.group_product_customer_code_user", # pyright: ignore[reportGeneralTypeIssues] + raise_if_not_found=False, + ) + + group_ids = [ + user_group_employee.id, + user_group_project_user.id, + user_group_fsm_user.id, + user_group_sales_manager.id, + user_group_sales_user.id, + user_group_delivery_address.id, + ] + if user_product_customer: + group_ids.append(user_product_customer.id) + return group_ids + + @classmethod + def _generate_partner( + cls, + name: str = "Test Company", + company_type: str = "company", + parent=None, + location_type="other", + ): + """Generates a partner with basic address filled in. + + :param name: The partner's name. + :param company_type: The type of partner, either 'company' or 'person' are + accepted. + """ + return cls.env["res.partner"].create( + { + "name": name, + "company_type": company_type, + "street": "123 Street St.", + "city": "Montreal", + "state_id": cls.env.ref("base.state_ca_qc").id, + "country_id": cls.env.ref("base.ca").id, + "parent_id": parent and parent.id or False, + "type": location_type, + } + ) + + @classmethod + def _generate_sale_order( + cls, + partner=None, + client_order_ref="Test Order", + equipment=None, + shipping_location=None, + ): + partner = partner or cls._generate_partner() + vals = {"partner_id": partner.id, "client_order_ref": client_order_ref} + if equipment: + vals.update({"default_equipment_ids": [Command.set([equipment.id])]}) + if shipping_location: + vals.update({"partner_shipping_id": shipping_location.id}) + return cls.env["sale.order"].create(vals) + + @classmethod + def _generate_sale_order_line( + cls, sale_order, product=None, qty=1.0, uom=None, price=100.0, tax_id=False + ): + if not product: + product = cls._generate_product() + return cls.env["sale.order.line"].create( + { + "order_id": sale_order.id, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": uom and uom.id or cls.env.ref("uom.product_uom_hour").id, + "price_unit": price, + "tax_id": tax_id, + } + ) + + @classmethod + def _generate_equipment(cls, name="test equipment", partner=None): + return cls.env["fsm.equipment"].create( + { + "name": name, + "partner_id": partner and partner.id or False, + } + ) + + @classmethod + def _generate_product( + cls, + name="Test Product", + product_type="service", + service_tracking="task_global_project", + project=None, + task_template=None, + service_policy="delivered_manual", + uom=None, + ): + if "project" in service_tracking and not project: + project = cls.env.ref("industry_fsm.fsm_project") + uom_id = uom and uom.id or cls.env.ref("uom.product_uom_hour").id or False + return cls.env["product.product"].create( + { + "name": name, + "type": product_type, + "service_tracking": service_tracking, + "service_type": "timesheet", + "project_id": ( + service_tracking in ("task_global_project", "project_only") + and project + and project.id + or False + ), + "project_template_id": ( + service_tracking == "task_in_project" + and project + and project.id + or False + ), + "task_template_id": task_template and task_template.id or False, + "service_policy": service_policy, + "uom_id": uom_id, + "uom_po_id": uom_id, + } + ) + + @classmethod + def _generate_fsm_project(cls, name="Test Project"): + return cls.env["project.project"].create( + { + "name": name, + "allow_material": True, + "allow_timesheets": True, + "allow_quotations": True, + "allow_worksheets": True, + "is_fsm": True, + } + ) + + @classmethod + def _generate_task_template( + cls, + parent=None, + structure=None, + names=None, + planned_hours=1, + equipment=None, + customer=None, + ): + """Generates a task template with the specified structure and naming. + + :param parent: The parent task template for the top-level task template being + generated + :param structure: A list of integers describing the number of tasks for each + level of descendants. An empty list represents only one + top-level task template. If no structure is given, an empty + list will be used in its place. + :param names: The name prefixes to be given to the task templates at each level. + Each prefix will be followed by a sequential integer for its + level. Child 1, Child 2, Grandchild 1, etc. If no names argument + is passed, a default ['Task Template'] argument will be used. + :param planned_hours: The number of planned hours for the top-level task + template being generated. + :param equipment: The equipment to add as linked equipment to the task template. + """ + if not names: + names = ["Task Template"] + if not structure: + structure = [] + if len(structure) != len(names) - 1: + raise ValueError( + "The length of the structure argument must contain exactly one element" + " less than the names argument." + ) + name = names.pop(0) + template = cls.env["project.task.template"].create( + { + "name": name, + "parent": parent and parent.id or False, + "planned_hours": planned_hours, + "equipment_ids": [Command.set(equipment and [equipment.id] or [])], + "customer": customer and customer.id or False, + } + ) + parent = template + while structure: + subtasks = [] + for i in range(0, structure[0]): + subtasks.append( + cls.env["project.task.template"].create( + { + "parent": parent.id, + "name": names[0] + f" {i}", + } + ) + ) + structure.pop(0) + names.pop(0) + parent = subtasks[0] + return template + + def _invoice_sale_order(self, so): + wiz = ( + self.env["sale.advance.payment.inv"] + .with_context(active_ids=[so.id]) + .create({}) + ) + wiz.create_invoices() + inv = so.invoice_ids[-1] + inv.action_post() + return inv + + def _generate_visit(self, sale_order, label="Test Label"): + return self.env["bemade_fsm.visit"].create( + [ + { + "sale_order_id": sale_order.id, + "label": label, + } + ] + ) + + def _generate_so_with_one_visit_two_lines(self): + so = self._generate_sale_order() + visit = self._generate_visit(sale_order=so) + sol1 = self._generate_sale_order_line(sale_order=so) + sol2 = self._generate_sale_order_line(sale_order=so) + visit.so_section_id.sequence = 1 + sol1.sequence = 2 + sol2.sequence = 3 + return so, visit, sol1, sol2 + + def _generate_so_with_one_visit_two_lines_and_descendants(self): + so = self._generate_sale_order() + visit = self._generate_visit(sale_order=so) + task_template = self._generate_task_template( + structure=[2, 2, 2], + names=["Parent", "Child", "Grandchild", "Great-grandchild"], + ) + product = self._generate_product(task_template=task_template) + sol1 = self._generate_sale_order_line(sale_order=so, product=product) + sol2 = self._generate_sale_order_line(sale_order=so, product=product) + visit.so_section_id.sequence = 1 + sol1.sequence = 2 + sol2.sequence = 3 + return so, visit, sol1, sol2 diff --git a/bemade_fsm/tests/test_equipment.py b/bemade_fsm/tests/test_equipment.py new file mode 100644 index 0000000..ce0ad2b --- /dev/null +++ b/bemade_fsm/tests/test_equipment.py @@ -0,0 +1,32 @@ +from odoo.addons.bemade_fsm.tests.test_bemade_fsm_common import BemadeFSMBaseTest +from odoo.tests import tagged, Form + + +@tagged("-at_install", "post_install") +class TestEquipment(BemadeFSMBaseTest): + def test_equipment_search_domain_on_sale_order(self): + """Equipment from other clients was showing up in sale order line + equipment choices. Make sure this doesn't happen.""" + partner = self._generate_partner() + partner_2 = self._generate_partner() + equipment_1 = self._generate_equipment(partner=partner) + equipment_2 = self._generate_equipment(partner=partner_2) + sale_order = self._generate_sale_order(partner=partner) + product = self._generate_product() + self.assertEqual(sale_order.valid_equipment_ids, equipment_1) + + name_search_results = self.env["fsm.equipment"].name_search( + args=[ + "&", + ["id", "in", sale_order.valid_equipment_ids.ids], + "!", + ["id", "in", []], + ], + limit=8, + name="test", + operator="ilike", + ) + self.assertNotIn( + (equipment_2.id, equipment_2.display_name), name_search_results + ) + self.assertIn((equipment_1.id, equipment_1.display_name), name_search_results) diff --git a/bemade_fsm/tests/test_fsm_contact_setting.py b/bemade_fsm/tests/test_fsm_contact_setting.py new file mode 100644 index 0000000..670b90e --- /dev/null +++ b/bemade_fsm/tests/test_fsm_contact_setting.py @@ -0,0 +1,129 @@ +from odoo.tests import tagged, Form +from odoo import Command +from .test_bemade_fsm_common import BemadeFSMBaseTest + + +@tagged("-at_install", "post_install") +class SaleOrderFSMContactsCase(BemadeFSMBaseTest): + def test_site_contacts(self): + parent_co = self._generate_partner("Parent Co") + contact_1 = self._generate_partner("Contact 1", "person", parent_co) + contact_2 = self._generate_partner("Contact 2", "person", parent_co) + + # Make sure the SO pulls the defaults from the partner correctly + parent_co.write({"site_contacts": [Command.set([contact_1.id, contact_2.id])]}) + so = self._generate_sale_order(parent_co) + self.assertTrue(so.site_contacts == parent_co.site_contacts) + + # Make sure updating the site contacts on the SO doesn't feed back to the + # partner + so.write({"site_contacts": [Command.set([contact_1.id])]}) + self.assertTrue(contact_1 in so.site_contacts) + self.assertTrue(contact_2 not in so.site_contacts) + self.assertTrue( + so.site_contacts != so.partner_id.site_contacts + and len(so.partner_id.site_contacts) == 2 + ) + + def test_default_workorder_contacts(self): + parent_co = self._generate_partner("Parent Co") + contact_1 = self._generate_partner("Contact 1", "person", parent_co) + contact_2 = self._generate_partner("Contact 2", "person", parent_co) + + # Make sure the SO pulls the defaults from the partner correctly + parent_co.write( + {"work_order_contacts": [Command.set([contact_1.id, contact_2.id])]} + ) + so = self._generate_sale_order(parent_co) + self.assertTrue(contact_1 in parent_co.work_order_contacts) + self.assertTrue(contact_2 in parent_co.work_order_contacts) + self.assertTrue(contact_1 in so.work_order_contacts) + self.assertTrue(contact_2 in so.work_order_contacts) + + # Make sure setting the work order contacts on the SO doesn't feed back to the + # partner + so.write({"work_order_contacts": [Command.set([contact_1.id])]}) + self.assertTrue(contact_1 in so.work_order_contacts) + self.assertTrue(contact_2 not in so.work_order_contacts) + self.assertTrue( + so.work_order_contacts != so.partner_id.work_order_contacts + and len(so.partner_id.work_order_contacts) == 2 + ) + + def test_multilayer_site_contacts(self): + parent_co = self._generate_partner("Parent Co") + shipping_location = self._generate_partner( + "Shipping Location", "company", parent_co, "delivery" + ) + wo_contact_1 = self._generate_partner( + "WO Contact 1", "person", shipping_location + ) + wo_contact_2 = self._generate_partner( + "WO Contact 2", "person", shipping_location + ) + site_contact_1 = self._generate_partner( + "Site Contact 1", "person", shipping_location + ) + site_contact_2 = self._generate_partner( + "Site Contact 2", "person", shipping_location + ) + shipping_location.write( + { + "work_order_contacts": [ + Command.set([wo_contact_1.id, wo_contact_2.id]) + ], + "site_contacts": [Command.set([site_contact_1.id, site_contact_2.id])], + } + ) + + so = self._generate_sale_order(parent_co) + so.write({"partner_shipping_id": shipping_location.id}) + + self.assertEqual(so.site_contacts, shipping_location.site_contacts) + self.assertEqual(so.work_order_contacts, shipping_location.work_order_contacts) + + def test_onchange_shipping_address(self): + self.env.user.groups_id += self.env.ref( + "account.group_delivery_invoice_address" + ) + parent_co = self._generate_partner("Parent Co") + shipping_location = self._generate_partner( + "Shipping Location", "company", parent_co, "delivery" + ) + wo_contact_1 = self._generate_partner( + "WO Contact 1", "person", shipping_location + ) + wo_contact_2 = self._generate_partner( + "WO Contact 2", "person", shipping_location + ) + site_contact_1 = self._generate_partner( + "Site Contact 1", "person", shipping_location + ) + site_contact_2 = self._generate_partner( + "Site Contact 2", "person", shipping_location + ) + shipping_location.write( + { + "work_order_contacts": [ + Command.set([wo_contact_1.id, wo_contact_2.id]) + ], + "site_contacts": [Command.set([site_contact_1.id, site_contact_2.id])], + } + ) + + so = self._generate_sale_order(parent_co) + + # Set back to a location without site or work order contacts + form = Form(so) + form.partner_shipping_id = parent_co + form.save() + # Make sure the contacts were reset on the SO + self.assertFalse(so.work_order_contacts) + self.assertFalse(so.site_contacts) + + # Now set back to the location with the FSM contacts and make sure they get set + # on the SO + form.partner_shipping_id = shipping_location + form.save() + self.assertEqual(so.work_order_contacts, shipping_location.work_order_contacts) + self.assertEqual(so.site_contacts, shipping_location.site_contacts) diff --git a/bemade_fsm/tests/test_fsm_visit.py b/bemade_fsm/tests/test_fsm_visit.py new file mode 100644 index 0000000..45f97fc --- /dev/null +++ b/bemade_fsm/tests/test_fsm_visit.py @@ -0,0 +1,196 @@ +from odoo.tests import tagged +from .test_bemade_fsm_common import BemadeFSMBaseTest +from datetime import date, timedelta + + +@tagged("-at_install", "post_install") +class FSMVisitTest(BemadeFSMBaseTest): + def test_create_visit_sets_name_on_section(self): + so = self._generate_sale_order() + self._generate_sale_order_line(sale_order=so) + + visit = self._generate_visit(so) + + self.assertTrue(visit.so_section_id) + self.assertEqual(visit.so_section_id.name, visit.label) + + def test_change_visit_section_name(self): + so = self._generate_sale_order() + visit = self._generate_visit(so, label="First Label") + line = visit.so_section_id + + line.name = "Second Label" + + self.assertEqual(visit.label, "Second Label") + + def test_change_visit_label_changes_section_name(self): + so = self._generate_sale_order() + visit = self._generate_visit(so, label="First Label") + line = visit.so_section_id + + visit.label = "Second Label" + + self.assertEqual(line.name, "Second Label") + + def test_visit_completes_when_task_completes(self): + so = self._generate_sale_order() + visit = self._generate_visit(so) + self._generate_sale_order_line(so) + so.action_confirm() + task = so.order_line.filtered(lambda line: line.task_id).task_id + + task.action_fsm_validate() + + self.assertTrue(visit.is_completed) + + def test_visit_shows_invoiced_when_invoiced(self): + so = self._generate_sale_order() + visit = self._generate_visit(so) + self._generate_sale_order_line(so) + so.action_confirm() + task = so.order_line.filtered(lambda line: line.task_id).task_id + task.action_fsm_validate() + + self._invoice_sale_order(so) + + self.assertTrue(visit.is_invoiced) + + def test_visit_groups_section_tasks_when_confirmed(self): + so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines() + + so.action_confirm() + + visit_task = visit.task_id + self.assertTrue(visit_task) + visit_subtasks = visit_task.child_ids + self.assertTrue( + visit_subtasks + and sol1.task_id in visit_subtasks + and sol2.task_id in visit_subtasks + ) + + def test_visit_task_gets_correct_due_date_on_confirmation(self): + so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines() + + so.action_confirm() + + visit_task = visit.task_id + self.assertEqual(visit_task.date_deadline, visit.approx_date) + + def test_visit_task_gets_correct_duration_on_confirmation(self): + partner = self._generate_partner() + so = self._generate_sale_order(partner=partner) + visit = self._generate_visit(sale_order=so) + sol1 = self._generate_sale_order_line(sale_order=so, qty=4.0) + sol2 = self._generate_sale_order_line(sale_order=so, qty=4.0) + visit.approx_date = date.today() + timedelta(days=7) + visit.so_section_id.sequence = 1 + sol1.sequence = 2 + sol2.sequence = 3 + + so.action_confirm() + + visit_task = visit.task_id + self.assertEqual(sol1.task_id.allocated_hours, 4.0) + self.assertEqual(sol2.task_id.allocated_hours, 4.0) + self.assertEqual(visit_task.allocated_hours, 8.0) + + def test_adding_visit_creates_one_sale_order_line(self): + self._generate_partner() + so = self._generate_sale_order() + self._generate_sale_order_line(sale_order=so) + self._generate_sale_order_line(sale_order=so) + + self._generate_visit(sale_order=so) + + self.assertEqual(len(so.order_line), 3) + + def test_marking_visit_task_done_completes_descendants(self): + ( + so, + visit, + sol1, + sol2, + ) = self._generate_so_with_one_visit_two_lines_and_descendants() + so.action_confirm() + parent = visit.task_id + + parent.action_fsm_validate() + + self._assert_is_done(parent) + + def _assert_is_done(self, task): + """Recursively assert all tasks in a hierarchy are complete""" + self.assertTrue(task.is_closed) + for child in task.child_ids: + self._assert_is_done(child) + + def test_marking_visit_task_done_does_not_create_sale_order_line(self): + so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines() + so.action_confirm() + + visit.task_id.action_fsm_validate() + + self.assertEqual(len(so.order_line), 3) + + def test_confirming_so_names_visit_properly(self): + """Visits should be named - Visit - """ + so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines() + so.name = "SO12345" + so.action_confirm() + task = visit.task_id + + supposed_name = "SVR12345-1 - Test Company - Test Label" + self.assertEqual(task.name, supposed_name) + + def test_subtasks_inherit_partner_from_parent_task(self): + """Test that subtasks of tasks created from FSM sales orders have their partner_id set correctly.""" + # Create a sale order with a shipping address different from the billing address + partner = self._generate_partner(name="Customer") + shipping_partner = self.env['res.partner'].create({ + 'name': 'Shipping Address', + 'parent_id': partner.id, + 'type': 'delivery', + }) + + # Create a sale order with the customer and shipping address + so = self._generate_sale_order(partner=partner) + so.partner_shipping_id = shipping_partner + + # Create a visit + visit = self._generate_visit(sale_order=so) + + # Create a product with a task template that has subtasks + parent_template = self._generate_task_template( + structure=[1], + names=["Parent Task", "Child Task"], + ) + product = self._generate_product(task_template=parent_template) + + # Add the product to the sale order + sol = self._generate_sale_order_line(sale_order=so, product=product) + + # Set the sequence to ensure proper ordering + visit.so_section_id.sequence = 1 + sol.sequence = 2 + + # Confirm the sale order to create tasks + so.action_confirm() + + # Get the created tasks + parent_task = sol.task_id + self.assertTrue(parent_task, "Parent task should be created") + + # Check that the parent task has a partner_id set + self.assertTrue(parent_task.partner_id, "Parent task should have a partner set") + + # Check that the subtask exists + self.assertTrue(parent_task.child_ids, "Parent task should have subtasks") + child_task = parent_task.child_ids[0] + + # The key test: Check that the subtask has a partner_id set + self.assertTrue(child_task.partner_id, "Subtask should have a partner_id set") + + # Check that the subtask has the same partner as the parent task + self.assertEqual(child_task.partner_id, parent_task.partner_id, + "Subtask should have the same partner as its parent task") diff --git a/bemade_fsm/tests/test_sale_order.py b/bemade_fsm/tests/test_sale_order.py new file mode 100644 index 0000000..517d909 --- /dev/null +++ b/bemade_fsm/tests/test_sale_order.py @@ -0,0 +1,474 @@ +from .test_task_template import BemadeFSMBaseTest +from odoo.tests.common import tagged, Form +from odoo import Command + + +@tagged("-at_install", "post_install") +class TestSalesOrder(BemadeFSMBaseTest): + @tagged("-at_install", "post_install") + def test_order_confirmation_simple_template(self): + """Confirming the order should create a task in the global project based on the + task template.""" + partner = self._generate_partner() + so = self._generate_sale_order(partner=partner) + task_template = self._generate_task_template(planned_hours=8) + product = self._generate_product(task_template=task_template) + sol = self._generate_sale_order_line(so, product=product) + + so.action_confirm() + + task = sol.task_id + self.assertTrue(task) + self.assertTrue(task_template.name in task.name) + self.assertTrue(task_template.planned_hours == task.allocated_hours) + + def test_task_template_tree_order_confirmation(self): + partner = self._generate_partner() + so = self._generate_sale_order(partner=partner) + parent_template = self._generate_task_template( + structure=[2, 1], + names=["Parent Template", "Child Template", "Grandchild Template"], + ) + child_template_1 = parent_template.subtasks[0] + child_template_2 = parent_template.subtasks[1] + grandchild_template = parent_template.subtasks[0].subtasks[0] + product = self._generate_product(task_template=parent_template) + sol = self._generate_sale_order_line(so, product=product) + + so.action_confirm() + + parent = sol.task_id + c1, c2 = parent.child_ids + gc = c1.child_ids[:1] + self.assertTrue(parent.child_ids and len(parent.child_ids) == 2) + self.assertTrue(parent_template.name in parent.name) + self.assertEqual(child_template_1.name, c1.name) + self.assertEqual(child_template_2.name, c2.name) + self.assertTrue(c1.child_ids and len(c1.child_ids) == 1) + self.assertEqual(grandchild_template.name, gc.name) + + def test_order_confirmation_single_equipment(self): + """The equipment selected on the SO should transfer to the task.""" + partner = self._generate_partner() + equipment = self._generate_equipment(partner=partner) + so = self._generate_sale_order(partner=partner, equipment=equipment) + task_template = self._generate_task_template(planned_hours=8) + product1 = self._generate_product(task_template=task_template) + product2 = self._generate_product() + sol1 = self._generate_sale_order_line(so, product=product1) + sol2 = self._generate_sale_order_line(so, product=product2) + + so.action_confirm() + + task1 = sol1.task_id + task2 = sol2.task_id + self.assertEqual(task1.equipment_ids[0], equipment) + self.assertEqual(task2.equipment_ids[0], equipment) + + def test_order_confirmation_multiple_equipment(self): + """All equipment items should flow from the sale order line to the final task""" + partner = self._generate_partner() + for i in range(5): + self._generate_equipment(partner=partner) + sale_order = self._generate_sale_order( + partner=partner + ) # No default equipment since more than 3 on partner + sol1, sol2, sol3 = [ + self._generate_sale_order_line(sale_order=sale_order) for _ in range(3) + ] + sol1.equipment_ids = [ + Command.set([partner.equipment_ids[i].id for i in range(2)]) + ] + sol3.equipment_ids = [ + Command.set([partner.equipment_ids[i].id for i in range(2, 5)]) + ] + + sale_order.action_confirm() + + self.assertEqual(sol1.equipment_ids, sol1.task_id.equipment_ids) + self.assertEqual(sol2.equipment_ids, sol2.task_id.equipment_ids) + self.assertEqual(sol3.equipment_ids, sol3.task_id.equipment_ids) + + def test_task_template_with_equipment_flow(self): + """The equipment selected on a task template should flow down to the task + created on SO confirmation.""" + partner = self._generate_partner() + equipment = self._generate_equipment(partner=partner) + so = self._generate_sale_order(partner=partner) + task_template = self._generate_task_template(equipment=equipment) + product = self._generate_product(task_template=task_template) + sol = self._generate_sale_order_line(so, product=product) + + so.action_confirm() + + self.assertEqual(sol.task_id.equipment_ids[0], equipment) + + def test_sale_order_line_gets_default_equipment(self): + """Sale order lines created on a SO with default equipment set should inherit + that default equipment.""" + partner = self._generate_partner() + self._generate_equipment(partner=partner) + sale_order = self._generate_sale_order(partner=partner) + + sol = self._generate_sale_order_line(sale_order=sale_order) + + self.assertEqual(sol.equipment_ids, partner.equipment_ids) + + def test_sale_order_gets_correct_default_equipment_from_partner(self): + """Should pick up equipment from the partner.""" + partner = self._generate_partner() + self._generate_equipment(partner=partner) + + sale_order = self._generate_sale_order(partner=partner) + + self.assertEqual(sale_order.default_equipment_ids, partner.owned_equipment_ids) + + def test_sale_order_default_equipment_maximum_number(self): + parent = self._generate_partner() + child = self._generate_partner(parent=parent) + for i in range(3): + self._generate_equipment(partner=child) + + sale_order = self._generate_sale_order(partner=parent) + + self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids) + + def test_sale_order_no_default_equipment_with_more_than_three_owned_on_partner( + self, + ): + parent = self._generate_partner() + child = self._generate_partner(parent=parent) + for i in range(4): + self._generate_equipment(partner=child) + + sale_order = self._generate_sale_order(partner=parent) + + self.assertEqual(sale_order.default_equipment_ids, self.env["fsm.equipment"]) + + def test_sale_order_resets_default_equipment_on_partner_change(self): + partner_1 = self._generate_partner() + partner_2 = self._generate_partner() + self._generate_equipment(partner=partner_1) + sale_order = self._generate_sale_order(partner_1) + form = Form(sale_order) + + form.partner_id = partner_2 + form.save() + + self.assertFalse(sale_order.default_equipment_ids) + + def test_sale_order_prioritize_shipping_location_equipments(self): + parent = self._generate_partner() + child = self._generate_partner(parent=parent, location_type="delivery") + self._generate_equipment(partner=parent) + self._generate_equipment(partner=child) + + sale_order = self._generate_sale_order(partner=parent, shipping_location=child) + + self.assertEqual(sale_order.default_equipment_ids, child.equipment_ids) + + def test_default_equipment_transfers_to_sale_order_line(self): + partner = self._generate_partner() + for i in range(3): + self._generate_equipment(partner=partner) + sale_order = self._generate_sale_order(partner=partner) + + for i in range(3): + self._generate_sale_order_line(sale_order=sale_order) + + for line in sale_order.order_line: + self.assertEqual(line.equipment_ids, partner.equipment_ids) + + def test_task_mark_done(self): + """Marking the task linked to a SO line should mark the line delivered. + Marking sub-tasks done should not.""" + partner = self._generate_partner() + so = self._generate_sale_order(partner=partner) + task_template = self._generate_task_template( + structure=[2], names=["Parent Task", "Subtask"] + ) + product = self._generate_product(task_template=task_template) + sol = self._generate_sale_order_line(so, product=product) + so.action_confirm() + parent_task = sol.task_id + subtasks = parent_task._get_all_subtasks() + + # Marking the subtasks done should not increment delivered quantity + subtasks.action_fsm_validate(True) + self.assertEqual(sol.qty_delivered, 0) + + # Marking the top-level tasks done should set the delivered quantity to some + # non-zero value based on the UOM + parent_task.action_fsm_validate(True) + self.assertTrue(sol.qty_delivered != 0) + + def test_task_contacts_through_sale_order(self): + """Make sure the site contacts and work order contacts transfer correctly + from the SO to the task.""" + + partner = self._generate_partner() + contact1 = self._generate_partner("Site contact", "person", partner) + contact2 = self._generate_partner("Work order contact", "person", partner) + partner.write( + { + "site_contacts": [Command.set([contact1.id])], + "work_order_contacts": [Command.set([contact2.id])], + } + ) + so = self._generate_sale_order(partner) + product = self._generate_product() + sol = self._generate_sale_order_line(sale_order=so, product=product) + + so.action_confirm() + + self.assertEqual(so.work_order_contacts, partner.work_order_contacts) + self.assertEqual(so.site_contacts, partner.site_contacts) + self.assertEqual(sol.task_id.work_order_contacts, partner.work_order_contacts) + self.assertEqual(sol.task_id.site_contacts, partner.site_contacts) + + def test_tasks_created_at_order_confirmation_have_no_assignees(self): + so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines() + user = self._generate_project_user(name="User", login="login") + + # We test as a specific user since testing as root may not produce the error + so.with_user(user).action_confirm() + + visit_task = visit.task_id + subtask1 = visit_task.child_ids[0] + subtask2 = visit_task.child_ids[1] + self.assertFalse(visit_task.user_ids) + self.assertFalse(subtask1.user_ids) + self.assertFalse(subtask2.user_ids) + + def test_long_line_name_overflows_to_task_description(self): + so = self._generate_sale_order() + product = self._generate_product() + product.description_sale = ( + "This is a long product description.\n" + "It even spans multiple lines.\n" + "One could find this annoying in a task name." + ) + + sol = self._generate_sale_order_line(sale_order=so, product=product) + + so.action_confirm() + task = sol.task_id + + self.assertFalse("This is a long product description." in task.name) + self.assertFalse("It even spans multiple lines." in task.name) + self.assertFalse("One could find this annoying in a task name." in task.name) + self.assertTrue("It even spans multiple lines." in task.description) + self.assertTrue( + "One could find this annoying in a task name." in task.description + ) + + def test_subtask_templates_no_description_if_blank_on_template(self): + so = self._generate_sale_order() + template = self._generate_task_template( + structure=[5], names=["Parent", "Child"] + ) + template.description = "" + template.subtasks[0].description = "Some fixed description" + for t in template.subtasks[1:]: + t.description = "" + product = self._generate_product(task_template=template) + sol = self._generate_sale_order_line(sale_order=so, product=product) + + so.action_confirm() + + task = sol.task_id + self.assertEqual( + task.child_ids[0].description, template.subtasks[0].description + ) + for t in task.child_ids[1:]: + self.assertFalse(t.description) + + def test_duplicate_sale_order_duplicates_visits(self): + """Duplicated sales orders should have visits tied to their SO lines as in the + original. The copied visits should not have approximate dates set, however.""" + so, visit, line1, line2 = self._generate_so_with_one_visit_two_lines() + + so2 = so.copy() + + self.assertTrue(so2.visit_ids) + visit2 = so2.visit_ids[0] + self.assertEqual(so2.order_line[0].visit_id, visit2) + self.assertEqual(visit2.label, visit.label) + self.assertFalse(visit2.approx_date) + + def test_confirming_sale_order_creates_visit_if_none_created(self): + so = self._generate_sale_order() + so.company_id.create_default_fsm_visit = True + product = self._generate_product() + self._generate_sale_order_line(so, product) + + so.action_confirm() + + visit_line = so.order_line.sorted("sequence")[0] + self.assertTrue(so.visit_ids) + self.assertEqual(visit_line.visit_id, so.visit_ids) + + def test_confirming_sale_order_with_visit_creates_no_new_lines(self): + so = self._generate_sale_order() + so.company_id.create_default_fsm_visit = True + self._generate_product() + self._generate_visit(so) + + so.action_confirm() + + self.assertEqual(len(so.visit_ids), 1) + + def test_confirming_sale_order_creates_no_visit_if_setting_off(self): + so = self._generate_sale_order() + so.company_id.create_default_fsm_visit = False + product = self._generate_product() + self._generate_sale_order_line(so, product) + + so.action_confirm() + + self.assertFalse(so.visit_ids) + + def test_changing_sale_order_customer_follows_to_tasks_and_subtasks(self): + so = self._generate_sale_order() + so = self._generate_sale_order() + so.company_id.create_default_fsm_visit = False + task_template = self._generate_task_template( + parent=None, + structure=[2, 2], + names=["Parent", "Child", "Grandchild"], + ) + product = self._generate_product(task_template=task_template) + sol = self._generate_sale_order_line(so, product) + + so.action_confirm() + + parent_task = sol.task_id + for task in parent_task._get_all_subtasks() | parent_task: + self.assertEqual(so.partner_shipping_id, task.partner_id) + + so.write( + { + "partner_shipping_id": self.env["res.partner"] + .create( + { + "name": "New shipping address", + "parent_id": so.partner_id.id, + "type": "delivery", + } + ) + .id + } + ) + for task in parent_task._get_all_subtasks() | parent_task: + self.assertEqual( + so.partner_shipping_id, + task.partner_id, + f"{task.name} has a different partner than the SO", + ) + + def test_task_hierarchy_maintained_after_cancel_reconfirm(self): + """Test that task hierarchy and project assignments are maintained when canceling + and reconfirming a sale order with a templated FSM product.""" + self.env.user.groups_id |= self.env.ref( + "account.group_delivery_invoice_address" + ) + # Create a task template with subtasks + parent_template = self._generate_task_template( + structure=[2], # Two subtasks + names=["Main Service", "Subtask"], + planned_hours=8, + ) + + # Create FSM product with template + product = self._generate_product(task_template=parent_template) + + # Create and confirm sale order + partner = self._generate_partner() + partner_2 = self._generate_partner(parent=partner, company_type="person") + self.assertEqual(partner_2.commercial_partner_id, partner) + so = self._generate_sale_order(partner=partner) + sol = self._generate_sale_order_line(so, product=product) + so.action_confirm() + + # Get initial tasks and verify setup + main_task = sol.task_id + self.assertTrue(main_task, "Main task should be created") + self.assertTrue(main_task.project_id, "Main task should have a project") + + subtasks = main_task.child_ids + self.assertEqual(len(subtasks), 2, "Should have created 2 subtasks") + self.assertEqual(so.tasks_count, 1, "Should have only 1 task on confirmation") + + # Verify initial task hierarchy + initial_project = main_task.project_id + for subtask in subtasks: + self.assertEqual( + subtask.project_id, + initial_project, + "Subtask should have same project as main task", + ) + self.assertFalse( + subtask.sale_order_id, "Subtask should not be linked to sale order" + ) + self.assertFalse( + subtask.sale_line_id, "Subtask should not be linked to sale order line" + ) + + # Store initial names for comparison + initial_subtask_names = subtasks.mapped("name") + + original_task_names = (main_task | main_task._get_all_subtasks()).mapped("name") + # Cancel and reconfirm the sale order + so.with_context(disable_cancel_warning=True).action_cancel() + + so.action_draft() + so.write({"partner_shipping_id": partner_2.id}) + # Get new tasks + new_main_task = sol.task_id + self.assertEqual( + new_main_task, main_task, "New main task should be same as old" + ) + + new_subtasks = new_main_task.child_ids + new_subtasks._compute_sale_line() + self.assertEqual( + len(new_subtasks), 2, "Should still have 2 subtasks after reconfirmation" + ) + self.assertFalse( + new_subtasks.sale_line_id, + "Subtasks should not be linked to Sale Order Line", + ) + new_task_names = (new_main_task | new_main_task._get_all_subtasks()).mapped( + "name" + ) + self.assertEqual( + new_task_names, original_task_names, "New task names should be the same" + ) + + # Verify task hierarchy is maintained + self.assertEqual( + new_main_task.project_id, + initial_project, + "New main task should have same project", + ) + + for subtask in new_subtasks: + self.assertEqual( + subtask.project_id, + initial_project, + "New subtask should maintain same project as main task", + ) + self.assertFalse( + subtask.sale_order_id, "New subtask should not be linked to sale order" + ) + self.assertFalse( + subtask.sale_line_id, + "New subtask should not be linked to sale order line", + ) + + # Verify subtask names are maintained + self.assertEqual( + sorted(new_subtasks.mapped("name")), + sorted(initial_subtask_names), + "Subtask names should be maintained after reconfirmation", + ) diff --git a/bemade_fsm/tests/test_settings.py b/bemade_fsm/tests/test_settings.py new file mode 100644 index 0000000..0ee4ba8 --- /dev/null +++ b/bemade_fsm/tests/test_settings.py @@ -0,0 +1,48 @@ +from odoo.tests import TransactionCase, Form, tagged + + +@tagged("-at_install", "post_install") +class TestSettings(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.test_partner_co = cls.env["res.partner"].create( + { + "name": "Test Co", + } + ) + cls.test_co = cls.env["res.company"].create( + { + "name": "Test Co", + "country_id": cls.env.ref("base.ca").id, + } + ) + cls.env.user.company_id = cls.test_co + + def test_enabling_separate_time_on_work_orders(self): + wizard = self.env["res.config.settings"].create({}) + self.assertFalse(self.test_co.split_time_from_materials_on_service_work_orders) + with Form(wizard) as form: + form.separate_time_on_work_orders = True + self.assertTrue(self.test_co.split_time_from_materials_on_service_work_orders) + + def test_disabling_separate_time_on_work_orders(self): + wizard = self.env["res.config.settings"].create({}) + self.test_co.split_time_from_materials_on_service_work_orders = True + with Form(wizard) as form: + form.separate_time_on_work_orders = False + self.assertFalse(self.test_co.split_time_from_materials_on_service_work_orders) + + def test_enabling_create_default_fsm_visit(self): + wizard = self.env["res.config.settings"].create({}) + self.test_co.create_default_fsm_visit = False + with Form(wizard) as form: + form.create_default_fsm_visit = True + self.assertTrue(self.test_co.create_default_fsm_visit) + + def test_disabling_create_default_fsm_visit(self): + wizard = self.env["res.config.settings"].create({}) + self.test_co.create_default_fsm_visit = True + with Form(wizard) as form: + form.create_default_fsm_visit = False + self.assertFalse(self.test_co.create_default_fsm_visit) diff --git a/bemade_fsm/tests/test_task.py b/bemade_fsm/tests/test_task.py new file mode 100644 index 0000000..79f4a89 --- /dev/null +++ b/bemade_fsm/tests/test_task.py @@ -0,0 +1,154 @@ +from .test_bemade_fsm_common import BemadeFSMBaseTest +from odoo.tests.common import tagged, Form +from odoo import Command + + +@tagged("post_install", "-at_install") +class TaskTest(BemadeFSMBaseTest): + @classmethod + def setUpClass(cls): + # Chose to set up all tests the same way since this code was becoming very + # redundant + super().setUpClass() + cls.user = cls._generate_project_manager_user("Bob", "Bob") + + def _generate_so_with_multilevel_task_template(self): + so = self._generate_sale_order() + template = self._generate_task_template( + names=["Parent", "Child", "Grandchild"], structure=[2, 1] + ) + product = self._generate_product(task_template=template) + sol = self._generate_sale_order_line(sale_order=so, product=product) + return so, sol + + def test_reassigning_assignment_propagating_task_changes_subtasks(self): + so, sol = self._generate_so_with_multilevel_task_template() + so.action_confirm() + task = sol.task_id + + task.propagate_assignment = True + task.write( + { + "user_ids": [Command.set([self.user.id])], + "propagate_assignment": True, + } + ) + + self.assertTrue( + all([t.user_ids == self.user for t in task | task._get_all_subtasks()]) + ) + + def test_reassigning_task_doesnt_propagate_by_default(self): + so, sol = self._generate_so_with_multilevel_task_template() + so.action_confirm() + task = sol.task_id + + task.write( + { + "user_ids": [Command.set([self.user.id])], + } + ) + + self.assertFalse(any([t.user_ids for t in task.child_ids.child_ids])) + + def test_unset_propagate_assignment_unsets_for_all_children(self): + so, sol = self._generate_so_with_multilevel_task_template() + so.action_confirm() + task = sol.task_id + # First, set propagation and assign + task.propagate_assignment = True + task.write({"user_ids": [Command.set([self.user.id])]}) + # Then, unset propagation for the children and re-set assignment + task.child_ids.write({"propagate_assignment": False}) + self.assertFalse( + any([t.propagate_assignment for t in task._get_all_subtasks()]) + ) + # Then, test that assigning the parent only assigns its children, not its + # grandchildren + task.write({"user_ids": [Command.set([])]}) + self.assertTrue(all([not t.user_ids for t in task | task.child_ids])) + self.assertTrue( + all([t.user_ids == self.user for t in task.child_ids.child_ids]) + ) + + def test_task_gets_work_order_contacts_from_sale_order(self): + so, sol = self._generate_so_with_multilevel_task_template() + work_order_contacts = self._generate_partner( + parent=so.partner_id + ) | self._generate_partner(parent=so.partner_id) + so.write({"work_order_contacts": [(6, 0, work_order_contacts.ids)]}) + + so.action_confirm() + task = sol.task_id + + self.assertEqual(task.work_order_contacts, so.work_order_contacts) + # Just a safeguard to make sure we set it properly on the SO + self.assertEqual(len(task.work_order_contacts), 2) + # Make sure all subtasks got the same + for subtask in task._get_all_subtasks(): + self.assertEqual(subtask.work_order_contacts, so.work_order_contacts) + + def test_task_gets_site_contacts_from_sale_order(self): + so, sol = self._generate_so_with_multilevel_task_template() + site_contacts = self._generate_partner( + parent=so.partner_id + ) | self._generate_partner(parent=so.partner_id) + so.write({"site_contacts": [(6, 0, site_contacts.ids)]}) + + so.action_confirm() + task = sol.task_id + + self.assertEqual(task.site_contacts, so.site_contacts) + # Just a safeguard to make sure we set it properly on the SO + self.assertEqual(len(task.site_contacts), 2) + # Make sure all subtasks got the same + for subtask in task._get_all_subtasks(): + self.assertEqual(subtask.site_contacts, so.site_contacts) + + def test_task_gets_work_order_contacts_from_parent(self): + so, sol = self._generate_so_with_multilevel_task_template() + work_order_contacts = self._generate_partner( + parent=so.partner_id + ) | self._generate_partner(parent=so.partner_id) + so.write({"work_order_contacts": [(6, 0, work_order_contacts.ids)]}) + + so.action_confirm() + task = sol.task_id + task.write( + { + "work_order_contacts": [ + Command.link(self._generate_partner(parent=so.partner_id).id) + ] + } + ) + for subtask in task._get_all_subtasks(): + self.assertEqual(subtask.work_order_contacts, task.work_order_contacts) + with Form(task) as task_form: + with task_form.child_ids.new() as subtask: + subtask.name = "Subtask 1" + subtask = task.child_ids[-1] + self.assertEqual(subtask.work_order_contacts, task.work_order_contacts) + + def test_task_gets_site_contacts_from_parent(self): + so, sol = self._generate_so_with_multilevel_task_template() + site_contacts = self._generate_partner( + parent=so.partner_id + ) | self._generate_partner(parent=so.partner_id) + so.write({"site_contacts": [(6, 0, site_contacts.ids)]}) + + so.action_confirm() + task = sol.task_id + task.write( + { + "site_contacts": [ + Command.link(self._generate_partner(parent=so.partner_id).id) + ] + } + ) + for subtask in task._get_all_subtasks(): + self.assertEqual(subtask.site_contacts, task.site_contacts) + with Form(task) as task_form: + with task_form.child_ids.new() as subtask: + subtask.name = "Subtask 1" + subtask = task.child_ids[-1] + self.assertEqual(subtask.site_contacts, task.site_contacts) diff --git a/bemade_fsm/tests/test_task_report.py b/bemade_fsm/tests/test_task_report.py new file mode 100644 index 0000000..330445e --- /dev/null +++ b/bemade_fsm/tests/test_task_report.py @@ -0,0 +1,62 @@ +from .test_bemade_fsm_common import BemadeFSMBaseTest +from odoo.tests import Form + + +class TestTaskReport(BemadeFSMBaseTest): + def test_split_time_materials_setting(self): + with Form(self.env["res.config.settings"]) as settings: + settings.separate_time_on_work_orders = True + + with Form(self.env["res.config.settings"]): + self.assertTrue(settings.separate_time_on_work_orders) + + so = self._generate_sale_order() + service_product = self._generate_product() + material_product = self._generate_product( + name="Material Product", + product_type="consu", + service_tracking="no", + ) + visit = self._generate_visit(sale_order=so) + self._generate_sale_order_line(sale_order=so, product=service_product) + self._generate_sale_order_line(sale_order=so, product=material_product) + so.action_confirm() + task = visit.task_id + + # Add timesheet entry to make the report renderable + self.env["account.analytic.line"].create( + { + "name": "Test timesheet entry", + "task_id": task.id, + "project_id": task.project_id.id, + "unit_amount": 2.0, + "employee_id": self.env["hr.employee"] + .create( + { + "name": "Test Employee", + "user_id": self.env.user.id, + } + ) + .id, + } + ) + + html_content = ( + self.env["ir.actions.report"] + ._render( + "industry_fsm.worksheet_custom", [task.id] + )[ # pyright: ignore[reportOptionalSubscript] + 0 + ] + .decode("utf-8") + .split("\n") + ) + + strings_to_find = ["

Materials

", "Material Product"] + + for line in strings_to_find: + line_found = False + for html_line in html_content: + if line in html_line: + line_found = True + self.assertTrue(line_found, f"{line} should be in file.") diff --git a/bemade_fsm/tests/test_task_template.py b/bemade_fsm/tests/test_task_template.py new file mode 100644 index 0000000..ba6bd70 --- /dev/null +++ b/bemade_fsm/tests/test_task_template.py @@ -0,0 +1,80 @@ +from .test_bemade_fsm_common import BemadeFSMBaseTest +from odoo.tests.common import tagged, Form +from odoo.exceptions import MissingError +from odoo.tools import mute_logger +import psycopg2 + + +@tagged("-at_install", "post_install") +class TestTaskTemplate(BemadeFSMBaseTest): + def test_delete_task_template(self): + """User should never be able to delete a task template used on a product""" + task_template = self._generate_task_template(names=["Template 1"]) + self._generate_product(name="Test Product 1", task_template=task_template) + with self.assertRaises(psycopg2.errors.ForeignKeyViolation): + with mute_logger("odoo.sql_db"): + task_template.unlink() + + def test_delete_subtask_template(self): + """Deletion of a child task should be OK even if the parent is on a product. + Children of the deleted subtask should be deleted.""" + parent_task = self._generate_task_template( + structure=[2, 1], + names=["Parent Template", "Child Template", "Grandchild Template"], + ) + grandchild_task = parent_task.subtasks[0].subtasks[0] + + parent_task.subtasks[0].unlink() + + # Reading deleted child's name field should be impossible + with self.assertRaises(MissingError): + _ = grandchild_task.name + + def test_dissociating_customer_resets_equipment_appropriately(self): + partner1 = self._generate_partner() + partner2 = self._generate_partner() + equipment1 = self._generate_equipment(partner=partner1) + task = self._generate_task_template(customer=partner1, equipment=equipment1) + form = Form(task) + + # Switching the partner should trigger on_change that makes sure equipments are + # linked to the new partner + form.customer = partner2 + form.save() + + self.assertFalse(equipment1 in task.equipment_ids) + + def test_child_task_names_are_short_version(self): + so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines() + template = self._generate_task_template(names=["Task"]) + product = self._generate_product(task_template=template) + sol1.name = "Short Name 1" + sol2.name = "Short Name 2" + sol3 = self._generate_sale_order_line(sale_order=so, product=product) + + so.action_confirm() + + self.assertEqual(sol1.task_id.name, "Short Name 1") + self.assertEqual(sol2.task_id.name, "Short Name 2") + self.assertEqual(sol3.task_id.name, "Task") + + def test_task_creation_directly_from_template(self): + project = self.env.ref("industry_fsm.fsm_project") + template = self._generate_task_template( + names=["Task", "Child", "Grandchild"], structure=[2, 1] + ) + + task = template.create_task_from_self(project, "My new task") + + self.assertEqual(len(task.child_ids), len(template.subtasks)) + self.assertEqual( + len(task.child_ids[0].child_ids), len(template.subtasks[0].subtasks) + ) + self.assertEqual( + len(task.child_ids[1].child_ids), len(template.subtasks[1].subtasks) + ) + self.assertEqual(task.name, "My new task") + self.assertEqual(task.child_ids[0].name, template.subtasks[0].name) + self.assertTrue( + all([t.project_id == project for t in task | task._get_all_subtasks()]) + ) diff --git a/bemade_fsm/views/menus.xml b/bemade_fsm/views/menus.xml new file mode 100644 index 0000000..e9406c3 --- /dev/null +++ b/bemade_fsm/views/menus.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/bemade_fsm/views/product_views.xml b/bemade_fsm/views/product_views.xml new file mode 100644 index 0000000..5efd9f7 --- /dev/null +++ b/bemade_fsm/views/product_views.xml @@ -0,0 +1,32 @@ + + + + bemade_fsm.product.template.form + product.template + + + + + + + + + + + + + + + + + + + + diff --git a/bemade_fsm/views/res_partner.xml b/bemade_fsm/views/res_partner.xml new file mode 100644 index 0000000..489dc35 --- /dev/null +++ b/bemade_fsm/views/res_partner.xml @@ -0,0 +1,52 @@ + + + + partner.equipment.location.view.form + res.partner + + + + site_ids + + + + + + + + + + + + + + + + + + + + + bemade_fsm.contacts.list + res.partner + + + + + + + + + + diff --git a/bemade_fsm/views/sale_order_views.xml b/bemade_fsm/views/sale_order_views.xml new file mode 100644 index 0000000..86482f9 --- /dev/null +++ b/bemade_fsm/views/sale_order_views.xml @@ -0,0 +1,64 @@ + + + + bemade_fsm.sale_order.form + sale.order + + + + + + + + + + + + + + + + + + + + + + + + + bemade_fsm.visit.list + bemade_fsm.visit + + + + + + + + + + diff --git a/bemade_fsm/views/task_template_views.xml b/bemade_fsm/views/task_template_views.xml new file mode 100644 index 0000000..ca808d8 --- /dev/null +++ b/bemade_fsm/views/task_template_views.xml @@ -0,0 +1,127 @@ + + + + bemade_fsm.task_template.form + project.task.template + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + project.task + bemade_fsm.project_task.form2 + + + + + + + + + + + + {'search_default_is_parent_task': True} + + + project.task.view.list.fsm.inherit + project.task + + + + project_list + + + + + + hide + + + hide + + + hide + + + hide + + + hide + + + + + {'search_default_is_parent_task': True} + [('is_fsm', '=', True), + ('project_id', '!=', False), + ('display_in_project', '=', True), + ('parent_id', '=', False)] + + + + {'search_default_is_parent_task': True} + [('is_fsm', '=', True), + ('project_id', '!=', False), + ('display_in_project', '=', True), + ('parent_id', '=', False)] + + + + {'search_default_is_parent_task': True} + [('is_fsm', '=', True), + ('project_id', '!=', False), + ('display_in_project', '=', True), + ('parent_id', '=', False)] + + + + {'search_default_is_parent_task': True} + [('is_fsm', '=', True), + ('project_id', '!=', False), + ('display_in_project', '=', True), + ('parent_id', '=', False), + ('invoice_status', '=', 'to invoice')] + + + + + {'search_default_is_parent_task': True} + [('is_fsm', '=', True), + ('project_id', '!=', False), + ('display_in_project', '=', True), + ('parent_id', '=', False)] + + + + {'search_default_is_parent_task': True} + [('is_fsm', '=', True), + ('project_id', '!=', False), + ('display_in_project', '=', True), + ('parent_id', '=', False)] + + + + {'search_default_is_parent_task': True} + [('is_fsm', '=', True), + ('project_id', '!=', False), + ('display_in_project', '=', True), + ('parent_id', '=', False)] + + + + + + + + + + + + + + + bemade_fsm.project_task_view_calendar_no_worksheet + + project.task + + + + + user_ids + + + + + project.task.view.search.fsm.inherit + project.task + + + + + [ + '&', + ('fsm_done', '=', False), + '|', + ('user_ids', '=', False), + '&', + ('planned_date_start', '=', False), + ('date_deadline', '=', False), + ] + + + + + + + +
diff --git a/bemade_fsm/wizard/__init__.py b/bemade_fsm/wizard/__init__.py new file mode 100644 index 0000000..d7ab68a --- /dev/null +++ b/bemade_fsm/wizard/__init__.py @@ -0,0 +1,2 @@ +from . import new_task_from_template +from . import res_config_settings diff --git a/bemade_fsm/wizard/new_task_from_template.py b/bemade_fsm/wizard/new_task_from_template.py new file mode 100644 index 0000000..519e1c7 --- /dev/null +++ b/bemade_fsm/wizard/new_task_from_template.py @@ -0,0 +1,57 @@ +from odoo import models, fields + + +class NewTaskFromTemplateWizard(models.TransientModel): + _name = "project.task.from.template.wizard" + _description = "Create Task from Template Wizard" + + project_id = fields.Many2one( + comodel_name="project.project", + string="Project", + help="The project the new task should be created in.", + required=True, + ) + + task_template_id = fields.Many2one( + comodel_name="project.task.template", + string="Task Template", + help="The template to use when creating the new task.", + required=True, + ) + + new_task_title = fields.Char( + help=( + "The title (name) for the newly created task. If left blank, the name of" + " the template will be used." + ), + ) + + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_id = self.env.context.get("active_id", False) + active_model = self.env.context.get("active_model", False) + if not active_model: + params = self.env.context.get("params", False) + active_model = params and params.get("model", False) + if ( + active_model == "project.task.template" + and active_id + and "task_template_id" in fields_list + ): + res.update({"task_template_id": active_id}) + if active_model == "project.task" and "project_id" in fields_list: + res.update({"project_id": self.env.ref("industry_fsm.fsm_project").id}) + return res + + def action_create_task_from_template(self): + self.ensure_one() + task = self.task_template_id.create_task_from_self( + self.project_id, self.new_task_title + ) + return { + "type": "ir.actions.act_window", + "res_model": "project.task", + "res_id": task.id, + "view_mode": "form", + "target": "current", + } diff --git a/bemade_fsm/wizard/new_task_from_template.xml b/bemade_fsm/wizard/new_task_from_template.xml new file mode 100644 index 0000000..1290b8f --- /dev/null +++ b/bemade_fsm/wizard/new_task_from_template.xml @@ -0,0 +1,29 @@ + + + + project.task.from.template.wizard.view.form + project.task.from.template.wizard + +
+ + + + + + + +
+
+
+
+
+ + New Task from Template + project.task.from.template.wizard + + form + new + +
\ No newline at end of file diff --git a/bemade_fsm/wizard/res_config_settings.py b/bemade_fsm/wizard/res_config_settings.py new file mode 100644 index 0000000..9ac4f73 --- /dev/null +++ b/bemade_fsm/wizard/res_config_settings.py @@ -0,0 +1,22 @@ +from odoo import models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + company_id = fields.Many2one( + comodel_name="res.company", + default=lambda self: self.env.company or self.env.user.company_id, + ) + + separate_time_on_work_orders = fields.Boolean( + string="Separate Time from Materials on Work Order", + related="company_id.split_time_from_materials_on_service_work_orders", + readonly=False, + ) + + create_default_fsm_visit = fields.Boolean( + string="Create Default Visit for FSM Sales Orders", + related="company_id.create_default_fsm_visit", + readonly=False, + ) diff --git a/bemade_fsm/wizard/res_config_settings.xml b/bemade_fsm/wizard/res_config_settings.xml new file mode 100644 index 0000000..08d4216 --- /dev/null +++ b/bemade_fsm/wizard/res_config_settings.xml @@ -0,0 +1,24 @@ + + + + res.config.settings.view.form.inherit.bemade.fsm + res.config.settings + + + + + + + + + + + + + \ No newline at end of file