for migration

This commit is contained in:
Benoît Vézina 2023-11-16 09:01:22 -05:00
parent 0b9e462831
commit fb301db2b3
206 changed files with 8229 additions and 613 deletions

View file

@ -1,2 +1,2 @@
# bemade-addons Branch 16.0
# bemade-addons
Odoo Addons made by Bemade

View file

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

View file

@ -0,0 +1,32 @@
#
# Bemade Inc.
#
# Copyright (C) September 2023 Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : marc@bemade.org)
#
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
# or modified copies of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
{
'name': 'Documents Portal Base',
'version': '15.0.1.0.0',
'summary': 'Adds documents to the front-end portal.',
'category': 'Document Management',
'author': 'Bemade Inc.',
'website': 'https://www.bemade.org',
'license': 'OPL-1',
'depends': ['documents', 'portal', 'mail_enterprise', 'im_livechat'],
'data': ['views/document_portal_templates.xml'],
'demo': [],
'installable': True,
'auto_install': False,
}

View file

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

View file

@ -0,0 +1,65 @@
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.http import request, route
from odoo.exceptions import AccessError, MissingError
from odoo import _
class DocumentCustomerPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
rtn = super()._prepare_home_portal_values(counters)
domain = self._prepare_documents_domain()
rtn['documents_count'] = request.env['documents.document'].search_count(domain)
return rtn
@route('/my/documents', type='http', auth='user', website=True)
def portal_my_documents(self, **kwargs):
values = self._prepare_portal_layout_values()
Documents = request.env['documents.document']
domain = self._prepare_documents_domain()
documents_count = Documents.search_count(domain)
documents = Documents.search(domain)
values.update({
'documents_count': documents_count,
'documents': documents.sudo(),
'default_url': '/my/documents',
'page_name': 'my_documents',
})
return request.render("bemade_documents_portal.portal_my_documents", values)
def _prepare_documents_domain(self):
partner = request.env.user.partner_id
user = request.env.user
"""Helper method intended to be overridden for future modules."""
return ['|',
('partner_id', '=', partner.id),
('owner_id', '=', user.id),
]
def _render_record_template(self, values):
""" Override this method to apply a different template for a single document
record on the portal. """
return request.render("bemade_documents_portal.document_portal_template", values)
@route('/my/documents/<int:document_id>', type='http', auth='user', website=True)
def portal_document_page(self, document_id, download=False, **kwargs):
document = request.env['documents.document'].browse(document_id)
if not document:
raise MissingError(_('This document does not exist.'))
if download:
return self._download_attachment(document)
values={
'document': document,
'page_name': 'my_documents',
'action': document._get_portal_return_action(),
}
return self._render_record_template(values)
def _download_attachment(self, document):
attachment = document.attachment_id
headers = [
('content-type', attachment.mimetype),
('content-length', attachment.file_size),
('content-disposition', f'attachment; filename="{document.name}"')
]
return request.make_response(attachment.raw, headers)

View file

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

View file

@ -0,0 +1,17 @@
from odoo import models, fields
class Document(models.Model):
_name = 'documents.document'
_inherit = ['documents.document', 'portal.mixin']
def _compute_access_url(self):
super()._compute_access_url()
for document in self:
document.access_url = f'/my/documents/{document.id}'
def _get_portal_return_action(self):
""" Return the action used to display documents when returning from customer
portal."""
self.ensure_one()
return self.env.ref('documents.document_action')

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<template id="portal_my_home" inherit_id="portal.portal_my_home">
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
<t t-call="portal.portal_docs_entry">
<t t-set="title">Documents</t>
<t t-set="url">/my/documents</t>
<t t-set="placeholder_count">documents_count</t>
</t>
</xpath>
</template>
<template id="portal_my_documents" name="My Documents">
<t t-call="portal.portal_layout">
<t t-call="portal.portal_table">
<thead>
<tr class="active">
<th>Name</th>
</tr>
</thead>
<tbody>
<t t-foreach="documents" t-as="document">
<tr>
<td>
<a t-att-href="document.get_portal_url()">
<t t-esc="document.name"/>
</a>
</td>
</tr>
</t>
</tbody>
</t>
</t>
</template>
<template id="document_portal_template" name="Document Portal Template">
<t t-call="portal.portal_layout">
<t t-set="o_portal_fullwidth_alert"
groups="documents.group_documents_user">
<t t-call="portal.portal_back_in_edit_mode">
<t t-set="backend_url"
t-value="'/web#model=%s&amp;id=%s&amp;action=%s&amp;view_type=form' % (document._name, document.id, action.id)"/>
</t>
</t>
<t t-call="portal.portal_record_layout">
<t t-set="card_header">
<div class="row no-gutters">
<h5 class="mb-1 mb-md-0">
<span t-field="document.name"/>
</h5>
</div>
</t>
<t t-set="card_body">
<!-- Main Document Contents -->
<div id="document_content"
class="col-12 col-lg justify-content-end w-100 h-100">
<div t-if="'image' in document.mimetype"
class="o_attachment_preview_img">
<img id="attachment_img"
class="img img-fluid d-block"
t-attf-src="/documents/content/{{document.id}}"/>
</div>
<iframe t-if="document.mimetype == 'application/pdf'"
class="mb48 w-100 min-vh-100"
t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/documents/content/{{document.id}}&amp;filename={{document.name}}"/>
<ul class="list-group list-group-flush flex-wrap flex-row flex-lg-column">
<li class="list-group-item flex-grow-1 b-0">
<a class="btn btn-secondary btn-block o_download_btn"
t-att-href="document.get_portal_url(download=True)">
Download</a>
</li>
<li class="list-group-item flex-grow-1 b-0">
<strong class="text-muted">File Size:
<t t-call="documents.format_file_size"/>
</strong>
</li>
<li class="list-group-item flex-grow-1 b-0">
<strong class="text-muted">File Type:
<t t-esc="document.mimetype"/>
</strong>
</li>
<li class="list-group-item flex-grow-1 b-0">
<strong class="text-muted">Attachment Type:
<t t-esc="document.attachment_type"/>
</strong>
</li>
</ul>
</div>
</t>
</t>
<!-- Chatter -->
<div id="document_communication" class="card-body">
<h2>History</h2>
<t t-call="portal.message_thread">
<t t-set="object" t-value="document"/>
</t>
</div>
</t>
</template>
<template id="portal_breadcrumbs" inherit_id="portal.portal_breadcrumbs">
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
<li t-if="page_name == 'my_documents'"
t-attf-class="breadcrumb-item #{'active ' if not document else ''}">
<a t-if="document"
t-attf-href="/my/documents?{{ keep_query() }}">Documents</a>
<t t-else="">Documents</t>
</li>
<li t-if="document" class="breadcrumb-item active" t-esc="document.name">
</li>
</xpath>
</template>
</data>
</odoo>

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
"name": "Fetchmail Only on production environment",
"version": "16.0.0.0.1",
"version": "15.0.0.0.1",
"category": "Extra Tools",
'summary': 'Fetchmail Only on production environment',
"description": """
@ -10,7 +10,7 @@
"author": "Bemade",
'website': 'https://www.bemade.org',
"depends": [
'mail',
'fetchmail',
],
"data": [
],

View file

@ -20,38 +20,49 @@
########################################################################################
{
'name': 'Improved Field Service Management',
'version': '15.0.0.2.0',
'version': '15.0.1.0.0',
'summary': 'Adds functionality necessary for managing field service operations at Durpro.',
'description': 'Adds functionality necessary for managing field service operations at Durpro.',
'category': 'Services/Field Service',
'author': 'Bemade Inc.',
'website': 'http://www.bemade.org',
'license': 'OPL-1',
'depends': ['project',
'depends': ['project_enterprise',
'project_forecast',
'stock',
'sale',
'sale_project',
'sale_stock',
'industry_fsm',
'industry_fsm_sale',
'sale_planning',
'worksheet',
'industry_fsm_stock',
'industry_fsm_report',
'industry_fsm_sale_report',
'bemade_partner_root_ancestor',
'mail',
],
'data': ['views/task_template_views.xml',
'views/equipment.xml',
'security/ir.model.access.csv',
'views/product_views.xml',
'views/res_partner.xml',
'views/menus.xml',
'views/task_views.xml',
'views/sale_order_views.xml',
],
'data': [
'data/fsm_data.xml',
'views/task_template_views.xml',
'views/equipment.xml',
'security/ir.model.access.csv',
'views/product_views.xml',
'views/res_partner.xml',
'views/menus.xml',
'views/task_views.xml',
'views/sale_order_views.xml',
'reports/worksheet_custom_report_templates.xml',
'reports/worksheet_custom_reports.xml',
],
'assets': {
'web.assets_tests': [
'bemade_fsm/static/tests/tours/task_template_tour.js',
'bemade_fsm/static/tests/tours/equipment_tour.js',
'bemade_fsm/static/tests/tours/sale_order_tour.js',
],
'web.report_assets_common': [
'bemade_fsm/static/src/scss/bemade_fsm.scss'
]
},
'installable': True,
'auto_install': False

View file

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

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

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

View file

@ -1,6 +1,10 @@
from . import task_template
from . import product_template
from . import sale_order_line
from . import sale_order
from . import equipment
from . import equipment_type
from . import equipment_tag
from . import task
from . import res_partner
from . import fsm_visit

View file

@ -1,26 +1,6 @@
from odoo import api, fields, models
class EquipmentTag(models.Model):
_name = "bemade_fsm.equipment.tag"
_description = 'Field service equipment category'
name = fields.Char('Name', required=True, translate=True)
color = fields.Integer('Color Index', default=10)
_sql_constraints = [
('name_uniq', 'unique (name)', "Tag name already exists !"),
]
class EquipmentType(models.Model):
_name = 'bemade_fsm.equipment.type'
_description = 'Field service equipment type'
_order = 'id'
name = fields.Char(string='Intervention Name', required=True, translate=True)
class Equipment(models.Model):
_name = 'bemade_fsm.equipment'
_rec_name = 'complete_name'
@ -33,44 +13,36 @@ class Equipment(models.Model):
complete_name = fields.Char(string="Equipment Name", compute="_compute_complete_name", store=True)
tag_ids = fields.Many2many('bemade_fsm.equipment.tag',
string='Application',
tracking=True,
help="Classify and analyze your equipment categories like: Boiler, Laboratory, "
"Waste water, Pure water")
partner_id = fields.Many2one('res.partner',
string="Owner",
compute="_compute_partner",
search="_search_partner",)
tag_ids = fields.Many2many(
comodel_name='bemade_fsm.equipment.tag',
string='Application',
help="Classify and analyze your equipment categories like: Boiler, Laboratory, Waste water, Pure water"
)
description = fields.Text(string="Description",
tracking=True)
description = fields.Text(string="Description", tracking=True)
partner_location_id = fields.Many2one('res.partner',
string="Physical Address",
tracking=True,
ondelete='cascade')
partner_location_id = fields.Many2one(
comodel_name='res.partner',
string="Physical Address",
tracking=True,
ondelete='cascade'
)
location_notes = fields.Text(string="Physical Location Notes",
tracking=True)
task_ids = fields.One2many(comodel_name='project.task',
inverse_name='equipment_id',
string='Interventions')
location_notes = fields.Text(string="Physical Location Notes", tracking=True)
task_ids = fields.Many2many(
comodel_name='project.task',
relation="bemade_fsm_task_equipment_rel",
column1="equipment_id",
column2="task_id",
string='Interventions'
)
@api.depends('partner_location_id')
def _compute_partner(self):
for rec in self:
rec.partner_id = rec.partner_location_id and rec.partner_location_id.root_ancestor
@api.model
def _search_partner(self, operator, value):
return [('partner_location_id.root_ancestor', operator, value)]
@api.depends('pid_tag', 'name')
def _compute_complete_name(self):
for rec in self:
rec.complete_name = "[%s] %s" % (rec.pid_tag or ' ', rec.name)
@api.model
def name_search(self, name='', args=None, operator='ilike', limit=100):
args = args or []
@ -79,7 +51,6 @@ class Equipment(models.Model):
'|', '|', '|',
('pid_tag', operator, name),
('name', operator, name),
('partner_id.name', operator, name),
('partner_location_id.name', operator, name)],
limit=limit)
else:
@ -96,3 +67,9 @@ class Equipment(models.Model):
'res_model': 'bemade_fsm.equipment',
'type': 'ir.actions.act_window',
}
@api.depends('pid_tag', 'name')
def _compute_complete_name(self):
for rec in self:
tag_part = "[%s] " % rec.pid_tag if rec.pid_tag else ""
rec.complete_name = tag_part + rec.name

View file

@ -0,0 +1,13 @@
from odoo import api, fields, models
class EquipmentTag(models.Model):
_name = "bemade_fsm.equipment.tag"
_description = 'Field service equipment category'
name = fields.Char('Name', required=True, translate=True)
color = fields.Integer('Color Index', default=10)
_sql_constraints = [
('name_uniq', 'unique (name)', "Tag name already exists !"),
]

View file

@ -0,0 +1,9 @@
from odoo import api, fields, models
class EquipmentType(models.Model):
_name = 'bemade_fsm.equipment.type'
_description = 'Field service equipment type'
_order = 'id'
name = fields.Char(string='Intervention Name', required=True, translate=True)

View file

@ -0,0 +1,70 @@
from odoo import models, fields, api, _
class FSMVisit(models.Model):
_name = "bemade_fsm.visit"
_description = 'Represents a single visit by assigned service personnel.'
label = fields.Text(string="Label", required=True, related='so_section_id.name', readonly=False)
approx_date = fields.Date(string='Approximate Date')
so_section_id = fields.Many2one(
comodel_name="sale.order.line",
string="Sale Order Section",
help="The section on the sale order that represents the labour and parts for this visit",
ondelete="cascade"
)
sale_order_id = fields.Many2one(
comodel_name="sale.order",
string="Sales Order",
required=True
)
is_completed = fields.Boolean(string="Completed", related="so_section_id.is_fully_delivered")
is_invoiced = fields.Boolean(string="Invoiced", related="so_section_id.is_fully_delivered_and_invoiced")
summarized_equipment_ids = fields.Many2many(
comodel_name="bemade_fsm.equipment",
string="Equipment to Service",
compute="_compute_summarized_equipment_ids"
)
task_id = fields.Many2one(
comodel_name="project.task",
compute="_compute_task_id",
string="Service Visit",
)
task_ids = fields.One2many(
comodel_name="project.task",
inverse_name="visit_id"
)
@api.depends('task_ids')
def _compute_task_id(self):
for rec in self:
rec.task_id = rec.task_ids and rec.task_ids[0]
@api.depends('so_section_id', 'sale_order_id.summary_equipment_ids')
def _compute_summarized_equipment_ids(self):
for rec in self:
lines = rec.so_section_id.get_section_line_ids()
equipment_ids = []
for line in lines:
for equipment in line.equipment_ids:
equipment_ids.append(equipment)
rec.summarized_equipment_ids = equipment_ids
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for i, rec in enumerate(recs):
rec.so_section_id = rec.env['sale.order.line'].create({
'order_id': rec.sale_order_id.id,
'display_type': 'line_section',
'name': vals_list[i]['label'],
})
return recs

View file

@ -4,4 +4,13 @@ from odoo import fields, models, api
class ProductTemplate(models.Model):
_inherit = 'product.template'
task_template_id = fields.Many2one("project.task.template", string="Task Template", ondelete='restrict')
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."
)

View file

@ -1,46 +1,68 @@
from odoo import api, fields, models, Command
class Partner(models.Model):
_inherit = 'res.partner'
equipment_count = fields.Integer(compute='_compute_equipment_count',
string='Equipment Count')
equipment_count = fields.Integer(compute='_compute_equipment_count', string='Equipment Count')
owned_equipment_ids = fields.One2many(comodel_name="bemade_fsm.equipment",
inverse_name="partner_id",
string="Owned Equipments")
owned_equipment_ids = fields.One2many(
comodel_name="bemade_fsm.equipment",
compute="_compute_owned_equipment_ids",
string="Owned Equipments"
)
equipment_ids = fields.One2many(
comodel_name='bemade_fsm.equipment',
inverse_name='partner_location_id',
string='Site Equipment'
)
equipment_ids = fields.One2many(comodel_name='bemade_fsm.equipment',
inverse_name='partner_location_id',
string='Site Equipment')
is_site_contact = fields.Boolean(
string='Is a site contact',
compute="_compute_is_site_contact",
search="_search_is_site_contact",
)
is_site_contact = fields.Boolean(string='Is a site contact',
compute="_compute_is_site_contact",
search="_search_is_site_contact",)
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
)
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)
site_contacts = fields.Many2many(
string='Site Contacts',
comodel_name='res.partner',
relation='res_partner_site_contact_rel',
column1='site_id',
column2='site_contact_id',
domain=[('is_company', '=', False)],
tracking=True
)
site_contacts = fields.Many2many(string='Site Contacts',
comodel_name='res.partner',
relation='res_partner_site_contact_rel',
column1='site_id',
column2='site_contact_id',
domain=[('is_company', '=', 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_company', '=', 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_company', '=', False)],
tracking=True)
@api.depends(
'equipment_ids',
'child_ids.company_type',
'child_ids.equipment_ids'
)
def _compute_owned_equipment_ids(self):
for rec in self:
ids = rec.equipment_ids | rec.child_ids.filtered(
lambda l: l.company_type == 'company').mapped('equipment_ids')
rec.owned_equipment_ids = ids or False
@api.depends('site_ids')
def _compute_is_site_contact(self):
@ -50,7 +72,8 @@ class Partner(models.Model):
@api.depends('equipment_ids')
def _compute_equipment_count(self):
for rec in self:
all_equipment_ids = self.env['bemade_fsm.equipment'].search([('partner_id', '=', rec.id)])
all_equipment_ids = self.env['bemade_fsm.equipment'].search(
[('partner_location_id', '=', rec.id)])
rec.equipment_count = len(all_equipment_ids)
@api.model

View file

@ -1,28 +1,77 @@
from odoo import fields, models, api, _, Command
from odoo.exceptions import ValidationError
from odoo.tools import float_round
import re
class SaleOrder(models.Model):
_inherit = 'sale.order'
equipment_id = fields.Many2one(comodel_name="bemade_fsm.equipment",
string="Equipment to Service",
tracking=True)
valid_equipment_ids = fields.One2many(
comodel_name="bemade_fsm.equipment",
related="partner_id.owned_equipment_ids"
)
site_contacts = fields.Many2many(comodel_name='res.partner',
relation="sale_order_site_contacts_rel",
compute="_compute_default_contacts",
inverse="_inverse_default_contacts",
string='Site Contacts',
store=True)
default_equipment_ids = fields.Many2many(
comodel_name="bemade_fsm.equipment",
string="Default Equipment to Service",
help="The default equipment to service for new sale order lines.",
compute="_compute_default_equipment",
inverse="_inverse_default_equipment",
store=True
)
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)
summary_equipment_ids = fields.Many2many(
comodel_name="bemade_fsm.equipment",
string="Equipment Being Serviced",
compute="_compute_summary_equipment_ids")
@api.depends('partner_id')
site_contacts = fields.Many2many(
comodel_name='res.partner',
relation="sale_order_site_contacts_rel",
compute="_compute_default_contacts",
inverse="_inverse_default_contacts",
string='Site 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
)
@api.depends('order_line.task_id')
def get_relevant_order_lines(self, task_id):
self.ensure_one()
linked_lines = self.order_line.filtered(lambda l: l.task_id == task_id
or l == task_id.visit_id.so_section_id)
visit_lines = linked_lines.filtered(lambda l: l.visit_id)
for line in visit_lines:
linked_lines |= line.get_section_line_ids()
return linked_lines
@api.depends('order_line.equipment_ids')
def _compute_summary_equipment_ids(self):
for rec in self:
rec.summary_equipment_ids = rec.order_line.mapped('equipment_ids')
@api.onchange('partner_shipping_id')
def _onchange_partner_shipping_id(self):
super()._onchange_partner_shipping_id()
self._compute_default_equipment()
self._compute_default_contacts()
@api.depends('partner_shipping_id')
def _compute_default_contacts(self):
for rec in self:
rec.site_contacts = rec.partner_shipping_id.site_contacts
@ -31,72 +80,21 @@ class SaleOrder(models.Model):
def _inverse_default_contacts(self):
pass
@api.depends('partner_id')
def _compute_equipment(self):
@api.depends(
'partner_id',
'partner_shipping_id',
'partner_shipping_id.equipment_ids',
'partner_id.owned_equipment_ids'
)
def _compute_default_equipment(self):
for rec in self:
rec.equipment_ids = self.partner_shipping_id.equipment_ids if len(
self.partner_shipping_id.equipment_ids) <= 1 else False
if rec.partner_shipping_id.equipment_ids:
ids = rec.partner_shipping_id.equipment_ids
else:
ids = rec.partner_id.owned_equipment_ids
rec.default_equipment_ids = ids if len(ids) < 4 else False
def _inverse_equipment(self):
def _inverse_default_equipment(self):
pass
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
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)
task.write({'child_ids': [Command.set([t.id for t in subtasks])]})
# We don't want to see the sub-tasks on the SO
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'] = f"{vals['name']} ({template.name})" if not parent else template.name
vals['description'] = template.description or vals['description']
vals['parent_id'] = parent and parent.id
vals['user_ids'] = template.assignees.ids
vals['tag_ids'] = template.tags.ids
vals['planned_hours'] = template.planned_hours
return vals
tmpl = self.product_id.task_template_id
if not tmpl:
task = super()._timesheet_create_task(project)
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: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%s)") % (
self.order_id.id, self.order_id.name, self.product_id.name)
task.message_post(body=task_msg)
task.equipment_id = self.order_id.equipment_id
return task

View file

@ -0,0 +1,267 @@
from odoo import fields, models, api, _, Command
from odoo.exceptions import ValidationError
from odoo.tools import float_round
import re
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
valid_equipment_ids = fields.One2many(
comodel_name="bemade_fsm.equipment",
related="order_id.valid_equipment_ids"
)
visit_ids = fields.One2many(
comodel_name="bemade_fsm.visit",
inverse_name="so_section_id",
string="Visits"
)
visit_id = fields.Many2one(
comodel_name="bemade_fsm.visit",
compute="_compute_visit_id",
string="Visit",
ondelete='cascade',
store=True
)
is_fully_delivered = fields.Boolean(
string="Fully Delivered",
compute="_compute_is_fully_delivered",
help="Indicates whether a line or all the lines in a section have been entirely delivered."
)
is_fully_delivered_and_invoiced = fields.Boolean(
string="Fully Invoiced",
compute="_compute_is_fully_invoiced",
help="Indicates whether a line or all the lines in a section have been entirely delivered and invoiced."
)
equipment_ids = fields.Many2many(
string="Equipment to Service",
comodel_name="bemade_fsm.equipment",
relation="bemade_fsm_equipment_sale_order_line_rel",
column1="sale_order_line_id",
column2="equipment_id"
)
is_field_service = fields.Boolean(
string="Is Field Service",
compute="_compute_is_field_service",
store=True
)
task_duration = fields.Float(
string="Estimated Duration",
compute="_compute_task_duration"
)
@api.depends('visit_ids')
def _compute_visit_id(self):
for rec in self:
rec.visit_id = rec.visit_ids and rec.visit_ids[0]
@api.depends('product_id')
def _compute_is_field_service(self):
for rec in self:
rec.is_field_service = rec.product_id.is_field_service
@api.model_create_multi
def create(self, vals):
recs = super().create(vals)
for rec in recs:
if rec.order_id.default_equipment_ids and not rec.equipment_ids:
rec.equipment_ids = rec.order_id.default_equipment_ids
return recs
def _timesheet_create_task(self, project):
""" Generate task for the given so line, and link it.
: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)
task.write({'child_ids': [Command.set([t.id for t in subtasks])]})
# We don't want to see the sub-tasks on the SO
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['planned_hours'] = template.planned_hours
vals['sequence'] = template.sequence
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)
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: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%s)") % (
self.order_id.id, self.order_id.name, self.product_id.name)
task.message_post(body=task_msg)
if not task.equipment_ids and self.equipment_ids:
task.equipment_ids = self.equipment_ids.ids
task.planned_hours = self.task_duration
return task
def _timesheet_service_generation(self):
super()._timesheet_service_generation()
visit_lines = self.filtered(lambda l: l.visit_id)
for line in visit_lines:
task_ids = line.get_section_line_ids().mapped('task_id')
if not task_ids:
continue
if len(set([task.project_id for task in task_ids])) > 1:
# Can't group up the tasks if they're part of different projects
return
project_id = task_ids[0].project_id
line.visit_id.task_id = line._generate_task_for_visit_line(project_id)
task_ids.write({'parent_id': line.visit_id.task_id.id})
self.mapped('task_id').synchronize_name_fsm()
def _generate_task_for_visit_line(self, project):
self.ensure_one()
task = self.env['project.task'].create({
'name': 'Temp Name',
'description': f"Parent task for {self.order_id.name}, visit {self.name}",
'project_id': project.id,
'equipment_ids': self.get_section_line_ids().mapped('equipment_ids').ids,
'sale_order_id': self.order_id.id,
'partner_id': self.order_id.partner_shipping_id.id,
'visit_id': self.visit_id.id,
'date_deadline': self.visit_id.approx_date,
'planned_hours': self.task_duration,
'user_ids': False, # Force to empty or it uses the current user
})
return task
@api.depends(
'order_id.order_line',
'display_type',
'qty_to_deliver',
'order_id.order_line.qty_to_deliver',
'order_id.order_line.display_type'
)
def _compute_is_fully_delivered(self):
for rec in self:
rec.is_fully_delivered = rec._iterate_items_compute_bool(
lambda l: l.qty_to_deliver == 0)
@api.depends('is_fully_delivered')
def _compute_is_fully_invoiced(self):
for rec in self:
if not rec.is_fully_delivered:
rec.is_fully_delivered_and_invoiced = False
return
rec.is_fully_delivered_and_invoiced = rec._iterate_items_compute_bool(
lambda l: l.qty_to_invoice == 0)
def get_section_line_ids(self):
self.ensure_one()
assert self.display_type == 'line_section', "Cannot get section lines for a non-section."
found = False
lines = []
for line in self.order_id.order_line.sorted(lambda l: l.sequence):
if line == self:
found = True
continue
if not found:
continue
if line.display_type == 'line_section': # Stop when we hit the next section
break
else:
lines.append(line)
return self.env['sale.order.line'].union(*lines)
def _iterate_items_compute_bool(self, single_line_func):
if not self.display_type:
return single_line_func(self)
elif self.display_type == 'line_note':
return True
else:
for line in self.order_id.order_line:
found = False
if line == self:
found = True
if not found:
continue
if found and line.display_type == 'line_section':
return True
val = single_line_func(self)
if not val:
return val
return True
@api.depends('task_duration', 'product_id', 'product_id.planning_enabled', 'state')
def _compute_planning_hours_to_plan(self):
# Override the method from sale_planning to use time estimates from the task
# template if appropriate. Also compute the value for visit lines.
planning_lines = self.filtered_domain([
('product_id.planning_enabled', '=', True),
('state', 'not in', ['draft', 'sent'])
])
for line in planning_lines:
line.planning_hours_to_plan = line.task_duration
for line in self - planning_lines:
line.planning_hours_to_plan = 0.0
@api.depends('product_id', 'visit_id')
def _compute_task_duration(self):
uom_hour = self.env.ref('uom.product_uom_hour')
uom_unit = self.env.ref('uom.product_uom_unit')
templated_lines = self.filtered(lambda l: l.product_id.task_template_id
and l.product_id.task_template_id.
planned_hours)
visit_lines = self.filtered(lambda l: l.visit_id)
regular_lines = self - templated_lines - visit_lines
for line in regular_lines:
if line.product_uom == uom_hour or line.product_uom == uom_unit:
line.task_duration = line.product_uom_qty
else:
line.task_duration = float_round(
line.product_uom._compute_quantity(line.product_uom_qty, uom_hour,
raise_if_failure=False),
precision_digits=2)
for line in templated_lines:
line.task_duration = line.product_id.task_template_id.planned_hours
if line.product_uom_category_id == self.env.ref(
'uom.product_uom_unit').category_id:
line.task_duration *= line.product_uom_qty
visit_lines = self.filtered(lambda l: l.visit_id)
for line in visit_lines:
line.task_duration = sum(line.get_section_line_ids().
mapped('task_duration'))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bemade_fsm_task_template,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1
access_bemade_fsm_task_template,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_task_template_manager,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1
access_bemade_fsm_task_template_user,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_equipment,bemade_fsm_equipment,model_bemade_fsm_equipment,base.group_user,1,1,1,1
access_bemade_fsm_equipment_tag,bemade_fsm_equipment_tag,model_bemade_fsm_equipment_tag,base.group_user,1,1,1,1
access_bemade_fsm_equipment_type,access_bemade_fsm_equipment_type,model_bemade_fsm_equipment_type,base.group_user,1,0,0,0
access_bemade_fsm_equipment_type,access_bemade_fsm_equipment_type,model_bemade_fsm_equipment_type,base.group_user,1,0,0,0
access_bemade_fsm_visit,access_bemade_fsm_visit,model_bemade_fsm_visit,base.group_user,1,1,1,1

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

View file

@ -0,0 +1,14 @@
/* This prevents repeating the header, which overlaps with the body due to a bug in
wkhtmltopdf rendering.
*/
thead {
display: table-row-group;
}
tfoot {
display: table-row-group;
}
tr {
page-break-inside: avoid;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -3,37 +3,192 @@ from odoo import Command
@tagged("-at_install", "post_install")
class FSMManagerUserTransactionCase(TransactionCase):
class BemadeFSMBaseTest(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
def _generate_project_manager_user(cls, name, login):
group_ids = cls.__get_user_groups()
user_group_project_manager = cls.env.ref('project.group_project_manager')
user_group_fsm_manager = cls.env.ref('industry_fsm.group_fsm_manager')
group_ids.append(user_group_fsm_manager.id)
group_ids.append(user_group_project_manager.id)
return cls.__generate_user(name, login, group_ids)
@classmethod
def _generate_project_user(cls, name, login):
group_ids = cls.__get_user_groups()
return cls.__generate_user(name, login, group_ids)
@classmethod
def __generate_user(cls, name, login, group_ids):
return cls.env['res.users'].with_context({'no_reset_password': True}).create({
'name': name,
'login': login,
'password': login,
'email': f"{login}@test.co",
'groups_id': [Command.set(group_ids)]
})
@classmethod
def __get_user_groups(cls):
user_group_employee = cls.env.ref('base.group_user')
user_group_project_user = cls.env.ref('project.group_project_user')
user_group_project_manager = cls.env.ref('project.group_project_manager')
user_group_fsm_user = cls.env.ref('industry_fsm.group_fsm_user')
user_group_fsm_manager = cls.env.ref('industry_fsm.group_fsm_manager')
user_group_sales_manager = cls.env.ref('sales_team.group_sale_manager')
user_group_sales_user = cls.env.ref('sales_team.group_sale_salesman')
user_group_sales_manager = cls.env.ref('sales_team.group_sale_manager')
user_product_customer = cls.env.ref('customer_product_code.group_product_customer_code_user')
group_ids = [user_group_employee.id,
user_group_project_user.id,
user_group_project_manager.id,
user_group_fsm_user.id,
user_group_fsm_manager.id,
user_group_sales_user.id,
user_group_sales_manager.id, ]
user_group_sales_manager.id,
user_group_sales_user.id, ]
if user_product_customer:
group_ids.append(user_product_customer.id)
return group_ids
# Test user with project access rights for the various tests
Users = cls.env['res.users'].with_context({'no_reset_password': True})
cls.user = Users.create({
'name': 'Project Manager',
'login': 'misterpm',
'password': 'misterpm',
'email': 'mrpm@testco.com',
'signature': 'Mr. PM',
'groups_id': [Command.set(group_ids)],
@classmethod
def _generate_partner(cls, name: str = 'Test Company', company_type: str = 'company', parent=None,
location_type='other'):
""" Generates a partner with basic address filled in.
:param name: The partner's name.
:param company_type: The type of partner, either 'company' or 'person' are accepted."""
return cls.env['res.partner'].create({
'name': name,
'company_type': company_type,
'street': '123 Street St.',
'city': 'Montreal',
'state_id': cls.env.ref('base.state_ca_qc').id,
'country_id': cls.env.ref('base.ca').id,
'parent_id': parent and parent.id or False,
'type': location_type,
})
@classmethod
def _generate_sale_order(cls, partner=None, client_order_ref='Test Order', equipment=None, shipping_location=None):
partner = partner or cls._generate_partner()
vals = {'partner_id': partner.id,
'client_order_ref': client_order_ref}
if equipment:
vals.update({'default_equipment_ids': [Command.set([equipment.id])]})
if shipping_location:
vals.update({'partner_shipping_id': shipping_location.id})
return cls.env['sale.order'].create(vals)
@classmethod
def _generate_sale_order_line(cls, sale_order, product=None, qty=1.0, uom=None, price=100.0, tax_id=False):
if not product:
product = cls._generate_product()
return cls.env['sale.order.line'].create({
'order_id': sale_order.id,
'product_id': product.id,
'product_uom_qty': qty,
'product_uom': uom and uom.id or cls.env.ref("uom.product_uom_hour").id,
'price_unit': price,
'tax_id': tax_id,
})
@classmethod
def _generate_equipment(cls, name='test equipment', partner=None):
return cls.env['bemade_fsm.equipment'].create({
'name': name,
'partner_location_id': partner and partner.id or False,
})
@classmethod
def _generate_product(cls, name='Test Product', product_type='service', service_tracking='task_global_project',
project=None, task_template=None, service_policy='delivered_manual', uom=None):
if 'project' in service_tracking and not project:
project = cls.env.ref("industry_fsm.fsm_project")
uom_id = uom and uom.id or cls.env.ref("uom.product_uom_hour").id or False
return cls.env['product.product'].create({
'name': name,
'type': product_type,
'service_tracking': service_tracking,
'project_id': service_tracking in ('task_global_project', 'project_only') and project.id or False,
'project_template_id': service_tracking == 'task_in_project' and project.id or False,
'task_template_id': task_template and task_template.id or False,
'service_policy': service_policy,
'uom_id': uom_id,
'uom_po_id': uom_id,
})
@classmethod
def _generate_fsm_project(cls, name='Test Project'):
return cls.env['project.project'].create({
'name': name,
'allow_material': True,
'allow_timesheets': True,
'allow_subtasks': True,
'allow_quotations': True,
'allow_worksheets': True,
'is_fsm': True,
})
@classmethod
def _generate_task_template(cls, parent=None, structure=None, names=None, planned_hours=1,
equipment=None, customer=None):
""" Generates a task template with the specified structure and naming.
:param parent: The parent task template for the top-level task template being generated
:param structure: A list of integers describing the number of tasks for each level of descendants. An empty
list represents only one top-level task template. If no structure is given, an empty list
will be used in its place.
:param names: The name prefixes to be given to the task templates at each level. Each prefix will be followed
by a sequential integer for its level. Child 1, Child 2, Grandchild 1, etc. If no names argument
is passed, a default ['Task Template'] argument will be used.
:param planned_hours: The number of planned hours for the top-level task template being generated.
:param equipment: The equipment to add as linked equipment to the task template."""
if not names:
names = ['Task Template']
if not structure:
structure = []
if len(structure) != len(names) - 1:
raise ValueError("The length of the structure argument must contain exactly one element less than the "
"names argument.")
name = names.pop(0)
template = cls.env['project.task.template'].create({
'name': name,
'parent': parent and parent.id or False,
'planned_hours': planned_hours,
'equipment_ids': [Command.set(equipment and [equipment.id] or [])],
'customer': customer and customer.id or False,
})
parent = template
while structure:
subtasks = []
for i in range(0, structure[0]):
subtasks.append(cls.env['project.task.template'].create({
'parent': parent.id,
'name': names[0] + f" {i}",
}))
structure.pop(0)
names.pop(0)
parent = subtasks[0]
return template
def _invoice_sale_order(self, so):
wiz = self.env['sale.advance.payment.inv'].with_context(
{'active_ids': [so.id]}).create({})
wiz.create_invoices()
inv = so.invoice_ids[-1]
inv.action_post()
return inv
def _generate_visit(self, sale_order, label="Test Label"):
return self.env['bemade_fsm.visit'].create([{
'sale_order_id': sale_order.id,
'label': label,
}])
def _generate_so_with_one_visit_two_lines(self):
so = self._generate_sale_order()
visit = self._generate_visit(sale_order=so)
sol1 = self._generate_sale_order_line(sale_order=so)
sol2 = self._generate_sale_order_line(sale_order=so)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
return so, visit, sol1, sol2

View file

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

View file

@ -1,56 +1,89 @@
from odoo.tests import TransactionCase, HttpCase, tagged
from odoo.tests import TransactionCase, HttpCase, tagged, Form
from odoo import Command
from .test_bemade_fsm_common import FSMManagerUserTransactionCase
from .test_bemade_fsm_common import BemadeFSMBaseTest
@tagged("-at_install", "post_install")
class SaleOrderFSMContactsCase(FSMManagerUserTransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
Partner = cls.env['res.partner']
cls.parent_co = Partner.create({
'name': 'Parent Co',
'company_type': 'company',
})
cls.contact_1 = Partner.create({
'name': 'Contact 1',
'company_type': 'person',
'parent_id': cls.parent_co.id,
})
cls.contact_2 = Partner.create({
'name': 'Contact 2',
'company_type': 'person',
'parent_id': cls.parent_co.id,
})
class SaleOrderFSMContactsCase(BemadeFSMBaseTest):
def test_site_contacts(self):
parent_co = self._generate_partner('Parent Co')
contact_1 = self._generate_partner('Contact 1', 'person', parent_co)
contact_2 = self._generate_partner('Contact 2', 'person', parent_co)
# Make sure the SO pulls the defaults from the partner correctly
self.parent_co.write({'site_contacts': [Command.set([self.contact_1.id, self.contact_2.id])]})
so = self.env['sale.order'].create({
'partner_id': self.parent_co.id,
})
self.assertTrue(so.site_contacts == self.parent_co.site_contacts)
parent_co.write({'site_contacts': [Command.set([contact_1.id, contact_2.id])]})
so = self._generate_sale_order(parent_co)
self.assertTrue(so.site_contacts == parent_co.site_contacts)
# Make sure updating the site contacts on the SO doesn't feed back to the partner
so.write({'site_contacts': [Command.set([self.contact_1.id])]})
self.assertTrue(self.contact_1 in so.site_contacts)
self.assertTrue(self.contact_2 not in so.site_contacts)
so.write({'site_contacts': [Command.set([contact_1.id])]})
self.assertTrue(contact_1 in so.site_contacts)
self.assertTrue(contact_2 not in so.site_contacts)
self.assertTrue(so.site_contacts != so.partner_id.site_contacts and len(so.partner_id.site_contacts) == 2)
def test_default_workorder_contacts(self):
parent_co = self._generate_partner('Parent Co')
contact_1 = self._generate_partner('Contact 1', 'person', parent_co)
contact_2 = self._generate_partner('Contact 2', 'person', parent_co)
# Make sure the SO pulls the defaults from the partner correctly
self.parent_co.write({'work_order_contacts': [Command.set([self.contact_1.id, self.contact_2.id])]})
so = self.env['sale.order'].create({
'partner_id': self.parent_co.id,
})
self.assertTrue(self.contact_1 in self.parent_co.work_order_contacts)
self.assertTrue(self.contact_2 in self.parent_co.work_order_contacts)
self.assertTrue(self.contact_1 in so.work_order_contacts)
self.assertTrue(self.contact_2 in so.work_order_contacts)
parent_co.write({'work_order_contacts': [Command.set([contact_1.id, contact_2.id])]})
so = self._generate_sale_order(parent_co)
self.assertTrue(contact_1 in parent_co.work_order_contacts)
self.assertTrue(contact_2 in parent_co.work_order_contacts)
self.assertTrue(contact_1 in so.work_order_contacts)
self.assertTrue(contact_2 in so.work_order_contacts)
# Make sure setting the work order contacts on the SO doesn't feed back to the partner
so.write({'work_order_contacts': [Command.set([self.contact_1.id])]})
self.assertTrue(self.contact_1 in so.work_order_contacts)
self.assertTrue(self.contact_2 not in so.work_order_contacts)
so.write({'work_order_contacts': [Command.set([contact_1.id])]})
self.assertTrue(contact_1 in so.work_order_contacts)
self.assertTrue(contact_2 not in so.work_order_contacts)
self.assertTrue(
so.work_order_contacts != so.partner_id.work_order_contacts and len(so.partner_id.work_order_contacts) == 2)
def test_multilayer_site_contacts(self):
parent_co = self._generate_partner('Parent Co')
shipping_location = self._generate_partner('Shipping Location', 'company', parent_co, 'delivery')
wo_contact_1 = self._generate_partner('WO Contact 1', 'person', shipping_location)
wo_contact_2 = self._generate_partner('WO Contact 2', 'person', shipping_location)
site_contact_1 = self._generate_partner('Site Contact 1', 'person', shipping_location)
site_contact_2 = self._generate_partner('Site Contact 2', 'person', shipping_location)
shipping_location.write({
'work_order_contacts': [Command.set([wo_contact_1.id, wo_contact_2.id])],
'site_contacts': [Command.set([site_contact_1.id, site_contact_2.id])]
})
so = self._generate_sale_order(parent_co)
so.write({'partner_shipping_id': shipping_location.id})
self.assertEqual(so.site_contacts, shipping_location.site_contacts)
self.assertEqual(so.work_order_contacts, shipping_location.work_order_contacts)
def test_onchange_shipping_address(self):
parent_co = self._generate_partner('Parent Co')
shipping_location = self._generate_partner('Shipping Location', 'company', parent_co, 'delivery')
wo_contact_1 = self._generate_partner('WO Contact 1', 'person', shipping_location)
wo_contact_2 = self._generate_partner('WO Contact 2', 'person', shipping_location)
site_contact_1 = self._generate_partner('Site Contact 1', 'person', shipping_location)
site_contact_2 = self._generate_partner('Site Contact 2', 'person', shipping_location)
shipping_location.write({
'work_order_contacts': [Command.set([wo_contact_1.id, wo_contact_2.id])],
'site_contacts': [Command.set([site_contact_1.id, site_contact_2.id])]
})
so = self._generate_sale_order(parent_co)
# Set back to a location without site or work order contacts
form = Form(so)
form.partner_shipping_id = parent_co
form.save()
# Make sure the contacts were reset on the SO
self.assertFalse(so.work_order_contacts)
self.assertFalse(so.site_contacts)
# Now set back to the location with the FSM contacts and make sure they get set on the SO
form.partner_shipping_id = shipping_location
form.save()
self.assertEqual(so.work_order_contacts, shipping_location.work_order_contacts)
self.assertEqual(so.site_contacts, shipping_location.site_contacts)

View file

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

View file

@ -1,97 +1,289 @@
from .test_task_template import TestTaskTemplateCommon
from .test_equipment import TestEquipmentCommon
from odoo.tests.common import tagged
from .test_task_template import BemadeFSMBaseTest
from odoo.tests.common import tagged, HttpCase, Form
from odoo import Command
@tagged("-at_install", "post_install")
class TestSalesOrder(TestTaskTemplateCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({
'name': 'Test Partner',
})
cls.sale_order1 = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'client_order_ref': 'TEST ORDER',
})
cls.sol_serv_order = cls.env['sale.order.line'].create({
'name': cls.product_task_global_project.name,
'product_id': cls.product_task_global_project.id,
'product_uom_qty': 1,
'product_uom': cls.product_task_global_project.uom_id.id,
'price_unit': 120.0,
'order_id': cls.sale_order1.id,
'tax_id': False,
})
cls.sol_serv_order_task_in_project = cls.env['sale.order.line'].create({
'name': cls.product_task_in_project.name,
'product_id': cls.product_task_in_project.id,
'product_uom_qty': 1,
'product_uom': cls.product_task_in_project.uom_id.id,
'price_unit': 150.0,
'order_id': cls.sale_order1.id,
'tax_id': False,
})
cls.sale_order2 = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'client_order_ref': 'TEST ORDER',
})
cls.sol_tree_order = cls.env['sale.order.line'].create({
'name': cls.product_task_tree_global_project.name,
'product_id': cls.product_task_tree_global_project.id,
'product_uom_qty': 1,
'product_uom': cls.product_task_tree_global_project.uom_id.id,
'price_unit': 120.0,
'order_id': cls.sale_order2.id,
'tax_id': False,
})
cls.sol_serv_order_task_in_project = cls.env['sale.order.line'].create({
'name': cls.product_task_tree_in_project.name,
'product_id': cls.product_task_tree_in_project.id,
'product_uom_qty': 1,
'product_uom': cls.product_task_tree_in_project.uom_id.id,
'price_unit': 150.0,
'order_id': cls.sale_order2.id,
'tax_id': False,
})
class TestSalesOrder(BemadeFSMBaseTest):
@tagged('-at_install', 'post_install')
def test_order_confirmation_simple_template(self):
""" Confirming the order should create a task in the global project. """
so = self.sale_order1
""" Confirming the order should create a task in the global project based on the task template. """
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(planned_hours=8)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
sol1 = so.order_line[0]
sol2 = so.order_line[1]
task = sol.task_id
self.assertTrue(task)
self.assertTrue(task_template.name in task.name)
self.assertTrue(task_template.planned_hours == task.planned_hours)
def test_task_template_tree_order_confirmation(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
parent_template = self._generate_task_template(structure=[2, 1],
names=['Parent Template',
'Child Template',
'Grandchild Template'])
child_template_1 = parent_template.subtasks[0]
child_template_2 = parent_template.subtasks[1]
grandchild_template = parent_template.subtasks[0].subtasks[0]
product = self._generate_product(task_template=parent_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
parent = sol.task_id
c1, c2 = parent.child_ids
gc = c1.child_ids[:1]
self.assertTrue(parent.child_ids and len(parent.child_ids) == 2)
self.assertTrue(parent_template.name in parent.name)
self.assertEqual(child_template_1.name, c1.name)
self.assertEqual(child_template_2.name, c2.name)
self.assertTrue(c1.child_ids and len(c1.child_ids) == 1)
self.assertEqual(grandchild_template.name, gc.name)
def test_order_confirmation_single_equipment(self):
""" The equipment selected on the SO should transfer to the task."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner, equipment=equipment)
task_template = self._generate_task_template(planned_hours=8)
product1 = self._generate_product(task_template=task_template)
product2 = self._generate_product()
sol1 = self._generate_sale_order_line(so, product=product1)
sol2 = self._generate_sale_order_line(so, product=product2)
so.action_confirm()
task1 = sol1.task_id
task2 = sol2.task_id
self.assertTrue(task1)
self.assertTrue(task2)
self.assertTrue(self.task1.name in task1.name)
self.assertTrue(self.task1.name in task2.name)
self.assertTrue(self.task1.planned_hours == task1.planned_hours)
self.assertEqual(task1.equipment_ids[0], equipment)
self.assertEqual(task2.equipment_ids[0], equipment)
def test_order_confirmation_tree_template(self):
def assert_structure(sol):
self.assertTrue(sol.task_id.child_ids and len(sol.task_id.child_ids) == 2)
self.assertTrue(self.parent_task.name in sol.task_id.name)
self.assertTrue(self.child_task_1.name in sol.task_id.child_ids[0].name)
self.assertTrue(self.child_task_2.name in sol.task_id.child_ids[1].name)
self.assertTrue(sol.task_id.child_ids[1].child_ids and len(sol.task_id.child_ids.child_ids) == 1)
self.assertTrue(self.grandchild_task.name in sol.task_id.child_ids.child_ids[0].name)
def test_order_confirmation_multiple_equipment(self):
""" All equipment items should flow from the sale order line to the final task """
partner = self._generate_partner()
for i in range(5):
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(
partner=partner) # No default equipment since more than 3 on partner
sol1, sol2, sol3 = [self._generate_sale_order_line(sale_order=sale_order) for i
in range(3)]
sol1.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2)])]
sol3.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2, 5)])]
sale_order.action_confirm()
self.assertEqual(sol1.equipment_ids, sol1.task_id.equipment_ids)
self.assertEqual(sol2.equipment_ids, sol2.task_id.equipment_ids)
self.assertEqual(sol3.equipment_ids, sol3.task_id.equipment_ids)
def test_task_template_with_equipment_flow(self):
""" The equipment selected on a task template should flow down to the task created on SO confirmation."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(equipment=equipment)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so = self.sale_order2
so.action_confirm()
sol1 = so.order_line[0]
sol2 = so.order_line[1]
assert_structure(sol1)
assert_structure(sol2)
def test_order_confirmation_equipment(self):
so = self.sale_order1
equipment = self.env['bemade_fsm.equipment'].create(
{'name': 'test equipment', 'partner_location_id': so.partner_shipping_id.id})
so.equipment_id = equipment.id
self.assertEqual(sol.task_id.equipment_ids[0], equipment)
def test_sale_order_line_gets_default_equipment(self):
""" Sale order lines created on an SO with default equipment set should inherit that default equipment. """
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
sol = self._generate_sale_order_line(sale_order=sale_order)
self.assertEqual(sol.equipment_ids, partner.equipment_ids)
def test_sale_order_gets_correct_default_equipment_from_partner(self):
""" Should pick up equipment from the partner."""
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
self.assertEqual(sale_order.default_equipment_ids, partner.owned_equipment_ids)
def test_sale_order_default_equipment_maximum_number(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(3):
self._generate_equipment(child)
sale_order = self._generate_sale_order(partner=parent)
self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids)
def test_sale_order_no_default_equipment_with_more_than_three_owned_on_partner(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(4):
self._generate_equipment(child)
sale_order = self._generate_sale_order(partner=parent)
self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids)
def test_sale_order_resets_default_equipment_on_partner_change(self):
partner_1 = self._generate_partner()
partner_2 = self._generate_partner()
self._generate_equipment(partner=partner_1)
sale_order = self._generate_sale_order(partner_1)
form = Form(sale_order)
form.partner_id = partner_2
form.save()
self.assertFalse(sale_order.default_equipment_ids)
def test_sale_order_prioritize_shipping_location_equipments(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent, location_type='delivery')
self._generate_equipment(partner=parent)
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent, shipping_location=child)
self.assertEqual(sale_order.default_equipment_ids, child.equipment_ids)
def test_default_equipment_transfers_to_sale_order_line(self):
partner = self._generate_partner()
for i in range(3):
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
for i in range(3):
self._generate_sale_order_line(sale_order=sale_order)
for line in sale_order.order_line:
self.assertEqual(line.equipment_ids, partner.equipment_ids)
def test_task_mark_done(self):
""" Marking the task linked to an SO line should mark the line delivered. Marking sub-tasks done should not."""
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(structure=[2],
names=["Parent Task", "Subtask"])
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
task = so.order_line[0].task_id
self.assertTrue(task.equipment_id == equipment)
parent_task = sol.task_id
subtasks = parent_task._get_all_subtasks()
# Marking the subtasks done should not increment delivered quantity
subtasks.action_fsm_validate()
self.assertEqual(sol.qty_delivered, 0)
# Marking the top-level tasks done should set the delivered quantity to some non-zero value based on the UOM
parent_task.action_fsm_validate()
self.assertTrue(sol.qty_delivered != 0)
def test_task_contacts_through_sale_order(self):
""" Make sure the site contacts and work order contacts transfer correctly from the SO to the task."""
partner = self._generate_partner()
contact1 = self._generate_partner('Site contact', 'person', partner)
contact2 = self._generate_partner('Work order contact', 'person', partner)
partner.write({
'site_contacts': [Command.set([contact1.id])],
'work_order_contacts': [Command.set([contact2.id])],
})
so = self._generate_sale_order(partner)
product = self._generate_product()
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
self.assertEqual(so.work_order_contacts, partner.work_order_contacts)
self.assertEqual(so.site_contacts, partner.site_contacts)
self.assertEqual(sol.task_id.work_order_contacts, partner.work_order_contacts)
self.assertEqual(sol.task_id.site_contacts, partner.site_contacts)
def test_changing_task_contacts_mirrors_with_sale_order(self):
partner = self._generate_partner()
contact = self._generate_partner("Contact", "person", partner)
so = self._generate_sale_order(partner)
product = self._generate_product()
sol = self._generate_sale_order_line(so, product)
so.action_confirm()
task = sol.task_id
task_form = Form(task)
# Now change the site/work order contact on the task and make sure it feeds back to the sales order
task_form.site_contacts.add(contact)
task_form.work_order_contacts.add(contact)
task_form.save()
self.assertEqual(task.site_contacts, so.site_contacts)
self.assertEqual(task.work_order_contacts, so.work_order_contacts)
# Test changing it on the SO feeds back to the task as well
f = Form(so)
f.work_order_contacts.remove(contact.id)
f.site_contacts.remove(contact.id)
f.save()
self.assertEqual(task.site_contacts, so.site_contacts)
self.assertEqual(task.work_order_contacts, so.work_order_contacts)
def test_tasks_created_at_order_confirmation_have_no_assignees(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
user = self._generate_project_user(name="User", login='login')
# We test as a specific user since testing as root may not produce the error
so.with_user(user).action_confirm()
visit_task = visit.task_id
subtask1 = visit_task.child_ids[0]
subtask2 = visit_task.child_ids[1]
self.assertFalse(visit_task.user_ids)
self.assertFalse(subtask1.user_ids)
self.assertFalse(subtask2.user_ids)
def test_long_line_name_overflows_to_task_description(self):
so = self._generate_sale_order()
product = self._generate_product()
product.description_sale = "This is a long product description.\n" \
"It even spans multiple lines.\n" \
"One could find this annoying in a task name."
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
task = sol.task_id
self.assertFalse("This is a long product description." in task.name)
self.assertFalse("It even spans multiple lines." in task.name)
self.assertFalse("One could find this annoying in a task name." in task.name)
self.assertTrue("It even spans multiple lines." in task.description)
self.assertTrue("One could find this annoying in a task name."
in task.description)
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)

View file

@ -1,109 +1,80 @@
from .test_bemade_fsm_common import FSMManagerUserTransactionCase
from odoo.tests.common import HttpCase, tagged
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests.common import HttpCase, tagged, Form
from odoo.exceptions import MissingError
from odoo import Command
from odoo.tools import mute_logger
from psycopg2.errors import ForeignKeyViolation
@tagged("-at_install", "post_install")
class TestTaskTemplateCommon(FSMManagerUserTransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.PLANNED_HOURS = 6
hours_uom = cls.env['uom.uom'].search([('name', '=', 'Hour')]) or False
# Test product to use with the various tests
cls.task1 = cls.env['project.task.template'].create({
'name': 'Template 1',
})
cls.project = cls.env['project.project'].create({
'name': 'Test Project',
})
cls.product_task_global_project = cls.env['product.product'].create({
'name': 'Test Product 1',
'type': 'service',
'service_tracking': 'task_global_project',
'project_id': cls.project.id,
'task_template_id': cls.task1.id,
'uom_id': hours_uom.id,
'uom_po_id': hours_uom.id,
})
cls.project_template = cls.env['project.project'].create({
'name': 'Test Project Template',
})
cls.product_task_in_project = cls.env['product.product'].create({
'name': 'Test Product 2',
'type': 'service',
'service_tracking': 'task_in_project',
'task_template_id': cls.task1.id,
'project_template_id': cls.project_template.id,
'uom_po_id': hours_uom.id,
'uom_id': hours_uom.id,
})
# Set up a task template tree with 2 children and 1 grandchild
cls.parent_task = cls.env['project.task.template'].create({
'name': 'Parent Template',
'planned_hours': cls.PLANNED_HOURS,
})
cls.child_task_1 = cls.env['project.task.template'].create({
'name': 'Child Template 1',
'parent': cls.parent_task.id,
})
cls.child_task_2 = cls.env['project.task.template'].create({
'name': 'Child Template 2',
'parent': cls.parent_task.id,
})
cls.parent_task.write({'subtasks': [Command.set([cls.child_task_1.id, cls.child_task_2.id])]})
cls.grandchild_task = cls.env['project.task.template'].create({
'name': 'Grandchild Template',
'parent': cls.child_task_2.id
})
cls.child_task_2.write({'subtasks': [Command.set([cls.grandchild_task.id])]})
# Create products using the task tree we just created
cls.product_task_tree_global_project = cls.env['product.product'].create({
'name': 'Test Product 3',
'type': 'service',
'service_tracking': 'task_global_project',
'project_id': cls.project.id,
'task_template_id': cls.parent_task.id,
'uom_id': hours_uom.id,
'uom_po_id': hours_uom.id,
})
cls.product_task_tree_in_project = cls.env['product.product'].create({
'name': 'Test Product 2',
'type': 'service',
'service_tracking': 'task_in_project',
'task_template_id': cls.parent_task.id,
'project_template_id': cls.project_template.id,
'uom_po_id': hours_uom.id,
'uom_id': hours_uom.id,
})
@tagged('-at_install', 'post_install')
class TestTaskTemplate(TestTaskTemplateCommon):
class TestTaskTemplate(BemadeFSMBaseTest):
def test_delete_task_template(self):
"""User should never be able to delete a task template used on a product"""
task_template = self._generate_task_template(names=['Template 1'])
product = self._generate_product(name="Test Product 1", task_template=task_template)
with self.assertRaises(ForeignKeyViolation):
self.task1.unlink()
with mute_logger('odoo.sql_db'):
task_template.unlink()
def test_delete_subtask_template(self):
""" Deletion of a child task should be OK even if the parent is on a product. Children of the deleted
subtask should be deleted."""
self.child_task_2.unlink()
parent_task = self._generate_task_template(structure=[2, 1],
names=['Parent Template', 'Child Template',
'Grandchild Template'])
grandchild_task = parent_task.subtasks[0].subtasks[0]
parent_task.subtasks[0].unlink()
# Reading deleted child's name field should be impossible
with self.assertRaises(MissingError):
test = self.grandchild_task.name
test = grandchild_task.name
def test_dissociating_customer_resets_equipment_appropriately(self):
partner1 = self._generate_partner()
partner2 = self._generate_partner()
equipment1 = self._generate_equipment(partner=partner1)
task = self._generate_task_template(customer=partner1, equipment=equipment1)
form = Form(task)
@tagged('-at_install', 'post_install')
class TestTaskTemplateTour(HttpCase, TestTaskTemplateCommon):
# Switching the partner should trigger on_change that makes sure equipments are linked to the new partner
form.customer = partner2
form.save()
def test_task_template_tour(self):
self.start_tour('/web', 'task_template_tour',
login='misterpm', )
self.assertFalse(equipment1 in task.equipment_ids)
def test_hours_estimate_used_for_planning(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(planned_hours=8)
product = self._generate_product(uom=self.env.ref('uom.product_uom_unit'), task_template=task_template)
sol = self._generate_sale_order_line(sale_order=so, product=product)
self.assertEqual(sol.task_duration, 8)
def test_hours_estimate_multiplied_for_multiple_units_sold(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(planned_hours=8)
product = self._generate_product(uom=self.env.ref('uom.product_uom_unit'),
task_template=task_template)
sol = self._generate_sale_order_line(sale_order=so, product=product, qty=3.0)
self.assertEqual(sol.task_duration, 24)
def test_child_task_names_are_short_version(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
template = self._generate_task_template(names=['Task'])
product = self._generate_product(task_template=template)
sol1.name = "Short Name 1"
sol2.name = "Short Name 2"
sol3 = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
self.assertEqual(sol1.task_id.name, "Short Name 1")
self.assertEqual(sol2.task_id.name, "Short Name 2")
self.assertEqual(sol3.task_id.name, "Task")

View file

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

View file

@ -9,7 +9,11 @@
<field name="arch" type="xml">
<xpath expr="//field[@name='project_id']" position="after">
<field name="task_template_id"
attrs="{'invisible': [('service_tracking', 'not in', ('task_global_project', 'task_in_project'))]}"/>
attrs="{'invisible': [('service_tracking', 'not in', ('task_global_project', 'task_in_project'))]}"
domain="[('parent', '=', False)]"/>
</xpath>
<xpath expr="//field[@name='planning_enabled']" position="after">
<field name="is_field_service"/>
</xpath>
</field>
</record>

View file

@ -8,17 +8,45 @@
<field name="arch" type="xml">
<xpath expr="//page[@name='other_information']" position="before">
<page name="field_service" string="Field Service">
<group name="field_service_info">
<field name="equipment_id"
domain="[('partner_location_id', '=', partner_shipping_id)]"
context="{'default_partner_location_id': partner_shipping_id,}"/>
<group name="fsm_visits" string="Service Visits">
<field name="visit_ids"
context="{'tree_view_ref': 'bemade_fsm.bemade_fsm_visit_tree'}"/>
</group>
<group name="field_service_info" string="Contacts and Equipment">
<field name="valid_equipment_ids" invisible="1"/>
<field name="summary_equipment_ids"
context="{'default_partner_location_id': partner_shipping_id,}"
widget="many2many_tags"/>
<field name="site_contacts"
context="{'tree_view_ref': 'bemade_fsm.fsm_contacts_view_tree'}"/>
<field name="work_order_contacts"
context="{'tree_view_ref': 'bemade_fsm.fsm_contacts_view_tree'}"/>
<field name="default_equipment_ids"
context="{'default_partner_location_id': partner_shipping_id,}"
widget="many2many_tags"
domain="[('id', 'in', valid_equipment_ids)]"/>
</group>
</page>
</xpath>
<xpath expr="//tree//field[@name='name']" position="after">
<field name="valid_equipment_ids" invisible="1"/>
<field name="equipment_ids"
widget="many2many_tags"
domain="[('id', 'in', valid_equipment_ids)]"/>
</xpath>
</field>
</record>
<record id="bemade_fsm_visit_tree" model="ir.ui.view">
<field name="name">bemade_fsm.visit.tree</field>
<field name="model">bemade_fsm.visit</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="label"/>
<field name="approx_date"/>
<field name="is_completed" widget="boolean"/>
<field name="is_invoiced" widget="boolean"/>
</tree>
</field>
</record>
</data>

View file

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

View file

@ -4,15 +4,21 @@
<record id="bemade_fsm_project_task_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.project_task.form</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_form2"/>
<field name="inherit_id" ref="industry_fsm.view_task_form2_inherit"/>
<field name="priority" eval="8"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="equipment_id" domain="[('partner_location_id', '=', partner_id)]"/>
<xpath expr="//field[@name='name']/.." position="before">
<h1 class="d-flex flex-row justify-content-between">
<field name="work_order_number"
attrs="{'invisible': [('work_order_number', '=', False)]}"/>
</h1>
</xpath>
<xpath expr="//page[@name='extra_info']" position="after">
<page string="Field Service" name="field_service">
<group name="contacts">
<page string="Field Service" name="field_service" translate="True">
<group name="equipment_and_contacts">
<field name="equipment_ids"
domain="[('partner_location_id', '=', partner_id)]"
context="{'tree_view_ref': 'bemade_fsm.equipment_view_tree'}"/>
<field name="site_contacts"
context="{'tree_view_ref': 'bemade_fsm.fsm_contacts_view_tree'}"/>
<field name="work_order_contacts"
@ -20,6 +26,26 @@
</group>
</page>
</xpath>
<button name="action_fsm_validate" class='btn-primary'
position="attributes">
<attribute name="string">Mark as Delivered</attribute>
<attribute name="groups">industry_fsm.group_fsm_manager</attribute>
</button>
<button name="action_fsm_validate" class='btn-secondary'
position="attributes">
<attribute name="string">Mark as Delivered</attribute>
<attribute name="groups">industry_fsm.group_fsm_manager</attribute>
</button>
</field>
</record>
<record id="view_task_form2_inherit" model="ir.ui.view">
<field name="inherit_id" ref="project.view_task_form2"/>
<field name="model">project.task</field>
<field name="name">bemade_fsm.project_task.form2</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='child_ids']/tree//field[@name='name']" position="after">
<field name="description" string="Description/Comments"/>
</xpath>
</field>
</record>
<!-- Add parent_id = false to domain for My Tasks, All Tasks: To Schedule, All Tasks and To Invoice-->
@ -29,48 +55,79 @@
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_fsm_map" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_fsm_map"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_to_schedule_fsm" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_to_schedule_fsm"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_all_fsm" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_all_fsm"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm_sale.project_task_action_to_invoice_fsm" model="ir.actions.act_window">
<record id="industry_fsm_sale.project_task_action_to_invoice_fsm"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<!-- Add parent_id = false to domain for planning actions as well -->
<record id="industry_fsm.project_task_action_fsm_planning_groupby_user" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_fsm_planning_groupby_user"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_fsm_planning_groupby_project" model="ir.actions.act_window">
<record id="industry_fsm.project_task_action_fsm_planning_groupby_project"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm_report.project_task_action_fsm_planning_groupby_worksheet" model="ir.actions.act_window">
<record id="industry_fsm_report.project_task_action_fsm_planning_groupby_worksheet"
model="ir.actions.act_window">
<field name="domain">[('is_fsm', '=', True),
('display_project_id', '!=', False),
('parent_id', '=', False)]
</field>
</record>
<!-- <record id="project_task_view_calendar_fsm" model="ir.ui.view">-->
<!-- <field name="name">bemade_fsm.project_task_view_calendar_fsm</field>-->
<!-- <field name="inherit_id" ref="industry_fsm.project_task_view_calendar_fsm"/>-->
<!-- <field name="model">project.task</field>-->
<!-- <field name="arch" type="xml">-->
<!-- <field name="user_ids" position="attributes">-->
<!-- <attribute name="filters">1</attribute>-->
<!-- <attribute name="color">user_ids</attribute>-->
<!-- </field>-->
<!-- </field>-->
<!-- </record>-->
<record id="project_task_view_calendar_fsm_no_worksheet" model="ir.ui.view">
<field name="name">bemade_fsm.project_task_view_calendar_no_worksheet</field>
<field name="inherit_id"
ref="industry_fsm_report.project_task_view_calendar_fsm_worksheet"/>
<field name="model">project.task</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='worksheet_template_id']"
position="replace"></xpath>
<xpath expr="//calendar" position="attributes">
<attribute name="color">user_ids</attribute>
</xpath>
</field>
</record>
</data>
</odoo>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,31 @@
#
# Bemade Inc.
#
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
# 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': 'Helpdesk One Ticket Per Email',
'version': '15.0.1.0.0',
'summary': 'Restrict ticket creation to a single ticket per email received.',
'category': 'Helpdesk',
'author': 'Bemade Inc.',
'website': 'http://www.bemade.org',
'license': 'OPL-1',
'depends': ['mail'],
'data': [],
'installable': True,
'auto_install': False
}

View file

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

View file

@ -0,0 +1,17 @@
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
@api.model
def _message_route_process(self, message, message_dict, routes):
helpdesk_routes = [r for r in routes if r[0] in ('helpdesk.ticket', 'helpdesk.team')]
if len(helpdesk_routes) > 1:
_logger.info("Messages contained multiple helpdesk routes. Only the first one will be used.")
helpdesk_routes.pop(0)
routes = routes - helpdesk_routes
return super()._message_route_process(message, message_dict, routes)

View file

@ -9,6 +9,8 @@ class MailAlias(models.Model):
@api.model
def create(self, vals):
alias = super(MailAlias, self).create(vals)
if not alias.alias_name:
return alias
alias_domain = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain"),
catchall_alias = self.env["ir.config_parameter"].sudo().get_param("mail.catchall.alias"),

View file

@ -17,4 +17,9 @@ class ResConfigSettings(models.TransientModel):
mailcow_sync_alias = fields.Boolean(
string='Sync Aliases with Odoo',
help='Auto create Aliases in Mailcow from Odoo',
config_parameter='mailcow.sync_alias')
config_parameter='mailcow.sync_alias')
mailcow_auto_create = fields.Boolean(
string='Create Mailboxes in Mailcow',
help='Auto create Mailboxes in Mailcow on creation in Odoo',
config_parameter='mailcow.create_mailbox')

View file

@ -3,13 +3,34 @@ from odoo import models, fields, api
class ResUsers(models.Model):
_inherit = 'res.users'
# def _compute_create_mailcow_mailbox(self):
# params = self.env['ir.config_parameter'].sudo()
# can_create = params.get_param('mailcow.create_mailbox')
# return can_create
mailcow_mailbox = fields.Boolean(string='Mailcow Mailbox', default=False)
# can_create_mailcow_mailbox = fields.Boolean(
# string='Create Mailcow Mailbox',
# compute=_compute_create_mailcow_mailbox
# )
mailcow_auto_create = fields.Boolean(compute='_compute_mailcow_auto_create', string='Create Mailbox in Mailcow')
def _compute_mailcow_auto_create(self):
for rec in self:
config = self.env['ir.config_parameter'].sudo()
get_mailcow_auto_create = config.get_param('mailcow_auto_create', False)
rec.mailcow_auto_create = get_mailcow_auto_create
@api.model
def create(self, vals):
res = super(ResUsers, self).create(vals)
if vals.get('mailcow_mailbox', false):
if res.mailcow_mailbox:
self.env['mail.mailcow.mailbox'].create_mailbox_for_user(res)
return res

View file

@ -8,29 +8,44 @@
<div id="emails" position='after'>
<div id="mailcow">
<h2>Mailcow Settings</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<label for="mailcow_base_url" string="Mailcow URL"/>
<div class="text-muted">
Base URL for Mailcow server
<div class="row mt16 o_settings_container" name="mailcow_setting">
<div class="col-xs-12 col-md-6 o_setting_box" id="mail_setting_creds">
<div class="o_setting_right_pane" id="mailcow_settings_creds_settings">
<label for="mailcow_base_url" string="Mailcow URL"/>
<div class="text-muted">
Base URL for Mailcow server
</div>
<div class="content-group">
<field name="mailcow_base_url"/>
</div>
<label for="mailcow_api_key" string="API Key"/>
<div class="text-muted">
Mailcow API Key
</div>
<div class="content-group">
<field name="mailcow_api_key"/>
</div>
</div>
<div class="content-group">
<field name="mailcow_base_url"/>
</div>
<label for="mailcow_api_key" string="API Key"/>
<div class="text-muted">
Mailcow API Key
</div>
<div class="content-group">
<field name="mailcow_api_key"/>
</div>
<label for="mailcow_sync_alias" string="Sync Aliases with Odoo'"/>
<div class="text-muted">
Auto create Aliases in Mailcow from Odoo
</div>
<div class="content-group">
</div>
<div class="col-xs-12 col-md-6 o_setting_box" name="mailcow_setting_options">
<div class="o_setting_left_pane">
<field name="mailcow_sync_alias"/>
</div>
<div class="o_setting_right_pane">
<label for="mailcow_sync_alias" string="Sync Aliases with Odoo"/>
<div class="text-muted">
Auto create Aliases in Mailcow from Odoo
</div>
</div>
<div class="o_setting_left_pane">
<field name="mailcow_auto_create"/>
</div>
<div class="o_setting_right_pane">
<label for="mailcow_auto_create" string="Create mailbox in Mailcow"/>
<div class="text-muted">
Auto create Mailbox in Mailcow on creation in Odoo
</div>
</div>
</div>
</div>
</div>

View file

@ -5,15 +5,12 @@
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<data>
<xpath expr="//notebook" position="inside">
<page string="Mailcow" name="mailcow">
<group>
<field name="mailcow_mailbox"/>
</group>
</page>
</xpath>
</data>
<field name="email" position="before">
<div id="mailcow_mailcow" attrs="{'invisible': [('id', '!=', False)]}">
<label for="mailcow_mailbox" string="Create mailbox on Mailcow"/>
<field name="mailcow_mailbox"/>
</div>
</field>
</field>
</record>
</odoo>

View file

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

View file

@ -0,0 +1,31 @@
#
# Bemade Inc.
#
# Copyright (C) September 2023 Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : marc@bemade.org)
#
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
# or modified copies of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
{
'name': 'Sales Margin on Vendor Price',
'version': '15.0.0.0.1',
'summary': 'Enables calculation of sales margins based on vendor pricelists.',
'description': """Adds a new method for calculating """,
'author': 'Bemade Inc.',
'website': 'https://www.bemade.org',
'license': 'OPL-1',
'depends': ['sale_stock_margin'],
'data': ['views/sale_order.xml'],
'installable': True,
'auto_install': False,
}

View file

@ -0,0 +1,58 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * bemade_margin_vendor_pricelist
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 15.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-10-19 15:08+0000\n"
"PO-Revision-Date: 2023-10-19 15:08+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_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_actual
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_actual
msgid "Margin"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_percent_actual
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_actual
msgid "Margin (%)"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_vendor
msgid "Margin (%) on Vendor Price"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_vendor
msgid "Margin on Vendor Price"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_actual
msgid "Purchase Price"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order
msgid "Sales Order"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order_line
msgid "Sales Order Line"
msgstr ""
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_vendor
msgid "Vendor Price"
msgstr ""

View file

@ -0,0 +1,58 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * bemade_margin_vendor_pricelist
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 15.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-10-19 15:08+0000\n"
"PO-Revision-Date: 2023-10-19 15:08+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_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_actual
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_actual
msgid "Margin"
msgstr "Marge"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order__margin_percent_actual
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_actual
msgid "Margin (%)"
msgstr "Marge (%)"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_percent_vendor
msgid "Margin (%) on Vendor Price"
msgstr "Marge (%) sur le prix du fournisseur"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__margin_vendor
msgid "Margin on Vendor Price"
msgstr "Marge sur le prix du fournisseur"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_actual
msgid "Purchase Price"
msgstr "Prix d'achat"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order
msgid "Sales Order"
msgstr "Commande de vente"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model,name:bemade_margin_vendor_pricelist.model_sale_order_line
msgid "Sales Order Line"
msgstr "Article de commande de vente"
#. module: bemade_margin_vendor_pricelist
#: model:ir.model.fields,field_description:bemade_margin_vendor_pricelist.field_sale_order_line__purchase_price_vendor
msgid "Vendor Price"
msgstr "Prix du fournisseur"

View file

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

View file

@ -0,0 +1,153 @@
from odoo import models, fields, api, _
from odoo.tools.float_utils import float_is_zero, float_compare
class SaleOrder(models.Model):
_inherit = 'sale.order'
margin_actual = fields.Monetary("Margin", compute='_compute_margin_actual',
store=False)
margin_percent_actual = fields.Float('Margin (%)', compute='_compute_margin_actual',
store=False,
group_operator='avg')
@api.depends('order_line.margin_actual', 'amount_untaxed')
def _compute_margin_actual(self):
for order in self:
order.margin_actual = sum(order.order_line.mapped('margin_actual'))
order.margin_percent_actual = order.amount_untaxed \
and order.margin_actual / order.amount_untaxed
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
purchase_price_vendor = fields.Float(compute='_compute_purchase_price_vendor',
string="Vendor Price", groups="base.group_user",
digits='Product Price')
margin_percent_vendor = fields.Float(string='Margin (%) on Vendor Price',
groups='base.group_user', group_operator='avg',
compute='_compute_margin_vendor')
margin_vendor = fields.Float(string='Margin on Vendor Price',
groups='base.group_user', digits='Product Price',
compute='_compute_margin_vendor')
purchase_price_actual = fields.Float(compute="_compute_actual_margins",
digits='Product Price',
groups="base.group_user",
string="Purchase Price")
margin_actual = fields.Float(compute="_compute_actual_margins",
digits='Product Price',
groups="base.group_user",
string="Margin")
margin_percent_actual = fields.Float(compute="_compute_actual_margins",
groups="base.group_user",
string="Margin (%)")
@api.depends('purchase_price', 'purchase_price_vendor', 'move_ids.product_id',
'move_ids.product_id.qty_available', 'move_ids.state',
'qty_to_deliver')
def _compute_actual_margins(self):
""" We want to use the margin based on average inventory valuation when the
sale order line will be completely fulfilled (or has been fulfilled) from stock.
For product not yet in stock we want to use the vendor price. We can also have
blended calculations (partly on vendor price, partly on stock valuation). This
occurs when an order has been or would be partially fulfilled from available
stock.
:return:
"""
non_product_lines = self.filtered(lambda r: not r.product_id)
non_product_lines.purchase_price_actual = 0.0
non_product_lines.margin_actual = 0.0
non_product_lines.margin_percent_actual = 0.0
for line in self - non_product_lines:
stock_missing = line._determine_missing_stock()
if float_is_zero(stock_missing,
precision_rounding=line.product_uom.rounding):
# everything is coming from stock, use inventory valuation
line.purchase_price_actual = line.purchase_price
elif float_compare(line.product_uom_qty, stock_missing,
precision_rounding=line.product_uom.rounding) == 0:
# everything is coming from the vendor, use vendor pricing
line.purchase_price_actual = line.purchase_price_vendor
else:
# we have a mix, use blended pricing
qty_from_stock = line.product_uom_qty - stock_missing
line.purchase_price_actual = \
(stock_missing * line.purchase_price_vendor
+ qty_from_stock * line.purchase_price) \
/ line.product_uom_qty
line.margin_actual = line.price_subtotal - (
line.purchase_price_actual * line.product_uom_qty)
line.margin_percent_actual = line.price_subtotal \
and line.margin_actual / line.price_subtotal
def _determine_missing_stock(self) -> float:
""" Compute how much stock is missing to meet an order line's demand. In the
case of a quotation line, available stock is checked as if the order were to be
placed immediately.
:return: The quantity missing from available stock to fulfill the line, in
the unit of measure matching self.product_uom.
"""
self.ensure_one()
is_order = self.order_id.state in ('sale', 'done')
if is_order and self.qty_to_deliver == 0:
return 0
elif is_order and self.qty_to_deliver > 0:
reserved = sum([m.reserved_availability for m in self.move_ids])
missing = self.qty_to_deliver - reserved
if float_compare(missing, 0.0,
precision_rounding=self.product_uom.rounding) == 1:
# Not enough reserved, check stock
missing = missing - self.product_id.qty_available
if float_compare(missing, 0.0,
precision_rounding=self.product_uom.rounding) == 1:
# Missing some stock to meet demand, return the quantity
return missing
else:
# Enough stock available to meet this line's demand
return 0
else:
# Already have stock reserved
return 0
else:
# This is a quotation, don't bother with stock reservations
missing = self.product_uom_qty - self.product_id.qty_available
if float_compare(missing, 0.0,
precision_rounding=self.product_uom.rounding) == 1:
return missing
else:
return 0
@api.depends('product_id', 'product_id.seller_ids',
'product_id.seller_ids.price')
def _compute_purchase_price_vendor(self):
for line in self:
product = line.product_id
suppinfos = product.seller_ids.sorted('sequence')
if not suppinfos:
line.purchase_price_vendor = 0.0
continue
suppinfo = suppinfos[0]
purch_currency = suppinfo.currency_id
to_cur = line.currency_id or line.order_id.currency_id
line.purchase_price_vendor = purch_currency._convert(
from_amount=suppinfo.price,
to_currency=to_cur,
company=line.company_id or self.env.company,
date=line.order_id.date_order or fields.Date.today(),
round=False,
) if to_cur and suppinfo.price else suppinfo.price
@api.depends('purchase_price_vendor')
def _compute_margin_vendor(self):
for line in self:
if not line.price_unit or float_is_zero(line.price_unit):
line.margin_vendor = 0
line.margin_percent_vendor = 0
continue
unit_margin = line.price_unit - line.purchase_price_vendor
line.margin_percent_vendor = unit_margin / line.price_unit
line.margin_vendor = unit_margin * line.product_uom_qty

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<record model="ir.ui.view" id="sale_margin_sale_order_inherit">
<field name="name">sale.order.margin.view.form.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale_margin.sale_margin_sale_order"/>
<field name="arch" type="xml">
<field name="margin" position="replace">
<field name="margin_actual" class="oe_inline"/>
</field>
<label for="margin" position="replace">
<label for="margin_actual" groups="base.group_user"/>
</label>
<field name="margin_percent" position="replace">
<field name="margin_percent_actual" nolabel="1" class="oe_inline"
widget="percentage" groups="base.group_user"/>
</field>
</field>
</record>
<record model="ir.ui.view" id="sale_margin_sale_order_line_inherit">
<field name="name">sale.order.line.margin.view.form.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale_margin.sale_margin_sale_order_line"/>
<field name="arch" type="xml">
<field name="purchase_price" position="replace">
<field name="purchase_price_actual" groups="base.group_user"/>
</field>
</field>
</record>
<record model="ir.ui.view" id="sale_margin_sale_order_line_form_inherit">
<field name="name">sale.order.view.form</field>
<field name="inherit_id" ref="sale_margin.sale_margin_sale_order_line_form"/>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<field name="purchase_price" position="replace">
<field name="purchase_price_actual" optional="hide"/>
</field>
<field name="margin" position="replace">
<field name="margin_actual" optional="hide"/>
</field>
<field name="margin_percent" position="replace">
<field name="margin_percent_actual" optional="hide"
widget="percentage" groups="base.group_user"/>
</field>
</field>
</record>
<!-- For now, we don't override the pivot and graph views because our fields
are not stored and this will cause performance issues. -->
</data>
</odoo>

View file

@ -0,0 +1,12 @@
from . import models
def post_init_hook(cr, registry):
# Look for .gitmodules file
gitmodules_file = os.path.join(os.getcwd(), '.gitmodules')
if os.path.exists(gitmodules_file):
# Read .gitmodules file
with open(gitmodules_file) as f:
content = f.read()
# Extract installed directory
installed_directory = re.search('[submodule "(.*)"]', content).group(1)
print(installed_directory)

View file

@ -0,0 +1,24 @@
{
'name': 'Module Linker',
'version': '15.0.1.0.0',
'category': 'Extra Tools',
'summary': 'Link modules from external repositories',
'description': """
This module allows users to link modules from external repositories.
""",
'depends': [
'base',
'base_setup'
],
'data': [
'views/res_config_settings_views.xml',
],
'license': 'AGPL-3',
'author': 'Bemade',
'website': 'https://bemade.org/',
'installable': True,
'auto_install': False,
'hooks': [
'post_init_hook',
],
}

View file

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

View file

@ -0,0 +1,8 @@
from odoo import models, fields, api, _
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
root_repos_directory = fields.Char(string="Root Repos Directory")
enabled_addons_directory = fields.Char(string="Enabled Addons Directory")

View file

@ -0,0 +1,28 @@
from odoo import fields, models, api
from odoo.exceptions import ValidationError
import os
class ResModuleLinks(models.Model):
_name = 'res.modules.links'
_description = 'Module Links'
name = fields.Char('Name', required=True)
module_id = fields.Many2one('ir.module.module', string='Module')
active = fields.Boolean(default=False)
repo_name = fields.Char('Repo Name', required=True)
@api.onchange('active')
def on_change_active(self):
params = self.env['ir.config_parameter'].sudo()
from_dir = params.get_param('root_repos_directory')[0],
to_dir = params.get_param('enabled_addons_directory')[0],
if self.active:
os.unlink(os.getcwd() + self.repo_name + '/' + self.name)
self.module_id.state = 'installed'
else:
if self.module_id.state == 'installed':
raise ValidationError('You must uninstall the module before deactivating it.')
else:
os.symlink(os.getcwd() + self.repo_name + '/' + self.name, os.getcwd() + self.repo_name + '/' + self.name)

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_settings_module_linker" model="ir.ui.view">
<field name="name">Settings Module Linker</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='about']" position="before">
<div id="module_linker">
<h2>Module Linker Settings</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<label for="root_repos_directory" string="Root Repos Directory"/>
<div class="text-muted">
Directory where repos are cloned
</div>
<div class="content-group">
<field name="root_repos_directory"/>
</div>
<label for="enabled_addons_directory" string="Enabled Addons Directory"/>
<div class="text-muted">
Enabled Addons Directory
</div>
<div class="content-group">
<field name="enabled_addons_directory"/>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View file

@ -31,7 +31,8 @@
'account',
'bemade_partner_root_ancestor',
],
'data': [],
'data': ['views/account_move_views.xml',
'views/res_partner_views.xml'],
'demo': [],
'installable': True,
'auto_install': False,

View file

@ -8,7 +8,7 @@ class AccountMove(models.Model):
string="Billing Contacts",
compute='_compute_billing_contacts',
inverse='_inverse_billing_contacts',
store=True)
store=True,)
@api.depends('line_ids.sale_line_ids.order_id', 'partner_id')
def _compute_billing_contacts(self):

View file

@ -8,11 +8,14 @@ class Partner(models.Model):
comodel_name='res.partner',
compute='_compute_billing_contacts',
inverse='_inverse_billing_contacts')
potential_billing_contacts = fields.Many2many(comodel_name='res.partner',
compute='_compute_billing_contacts')
@api.depends('child_ids.type')
def _compute_billing_contacts(self):
for rec in self:
rec.billing_contacts = rec.child_ids.filtered(lambda r: r.type == 'invoice')
rec.potential_billing_contacts = rec.child_ids | rec.parent_id.child_ids if rec.is_company else None
@api.depends('billing_contacts')
def _inverse_billing_contacts(self):

View file

@ -34,9 +34,11 @@ class TestBillingContacts(TransactionCase):
'parent_id': cls.parent_co.id,
'type': 'contact',
})
cls.product = cls.env['product.product'].with_company(cls.parent_co.company_id).create({
cls.product = cls.env['product.product'].with_company(
cls.parent_co.company_id).create({
'name': 'Product',
'categ_id': cls.env['product.category'].create({'name': 'Product Category'}).id,
'categ_id': cls.env['product.category'].create(
{'name': 'Product Category'}).id,
'list_price': 100.0,
'type': 'service',
'uom_id': cls.env.ref('uom.product_uom_unit').id,
@ -70,19 +72,23 @@ class TestBillingContacts(TransactionCase):
self.assertTrue(self.non_billing_contact not in self.parent_co.billing_contacts)
def test_sale_order_default_billing_contacts(self):
self.assertTrue(self.sale_order.billing_contacts == self.parent_co.billing_contacts)
self.assertTrue(
self.sale_order.billing_contacts == self.parent_co.billing_contacts)
def test_sale_order_change_contacts(self):
# Test that changing the billing contacts on an SO doesn't change them on the partner
self.sale_order.write({'billing_contacts': [Command.link(self.non_billing_contact.id)]})
self.assertTrue(all([c in self.sale_order.billing_contacts for c in self.parent_co.billing_contacts]))
self.sale_order.write(
{'billing_contacts': [Command.link(self.non_billing_contact.id)]})
self.assertTrue(all([c in self.sale_order.billing_contacts for c in
self.parent_co.billing_contacts]))
self.assertTrue(self.non_billing_contact not in self.parent_co.billing_contacts)
self.assertTrue(self.non_billing_contact in self.sale_order.billing_contacts)
def test_sale_order_to_invoice_contacts(self):
# Test that the invoices created from sales orders take the billing contacts configured on the SO
self.sale_order.write({'billing_contacts': [Command.link(self.non_billing_contact.id)]})
self.sale_order.write(
{'billing_contacts': [Command.link(self.non_billing_contact.id)]})
self.sale_order.action_confirm()
wiz = self.env['sale.advance.payment.inv'].create({})
@ -99,10 +105,12 @@ class TestBillingContacts(TransactionCase):
self.assertTrue(self.parent_co.billing_contacts == invoice.billing_contacts)
def test_invoice_followers_on_validate(self):
# Make sure all billing contacts get added as followers upon validating the invoice
self.sale_order.action_confirm()
wiz = self.env['sale.advance.payment.inv'].create({})
invoice = wiz._create_invoice(self.sale_order, self.sale_order.order_line[0],
self.sale_order.order_line.price_total)
invoice.write({'date': datetime.date.today()})
invoice.action_post()
self.assertTrue(all([r in invoice.message_partner_ids for r in self.parent_co.billing_contacts]))
self.assertTrue(all([r in invoice.message_partner_ids for r in
self.parent_co.billing_contacts]))

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<data>
<record id="invoice_view_form" model="ir.ui.view">
<field name="inherit_id" ref="account.view_move_form"/>
<field name="name">bemade_multiple_billing_contacts.invoice.view.form</field>
<field name="model">account.move</field>
<field name="arch" type="xml">
<field name="partner_id" position="after">
<field name="billing_contacts"
attrs="{'invisible': [('move_type', 'not in',
('out_invoice', 'out_refund'))]}"
widget="many2many_checkboxes"
domain="['|', '|', ('id', 'in', billing_contacts),
('parent_id', '=', partner_id),
('parent_id', 'parent_of', partner_id),
('is_company', '=',False)]"/>
</field>
</field>
</record>
</data>
</odoo>

View file

@ -10,7 +10,9 @@
<page name="default_contacts" string="Default Contacts"
help="Settings for default contacts to whom different correspondence should be sent.">
<group>
<field name="billing_contacts" attrs="{'invisible': [('company_type', '=', 'person')]}">
<field name="billing_contacts"
domain="[('parent_id', '=', id),
('is_company', '=', False)]">
<tree editable="bottom">
<field name="name" widget="res_partner_many2one" />
<field name="email" widget="email"/>

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import wizard

View file

@ -0,0 +1,25 @@
{
'name': 'Packing wizard',
'version': '15.0.1.0.0',
'category': 'Extra Tools',
'summary': 'Allow automated packing type creation',
'description': """
This module allows users simply enter all the package dimensions on every packing and it will create the packing
type automatically if it not exists, use it the create the package with the weight and related package type.
You need to activate the auto create package feature on the delivery carrier to use this feature.
""",
'depends': [
'delivery'
],
'data': [
'views/stock_package_views.xml',
'views/delivery_carrier_views.xml',
'wizard/choose_delivery_package_views.xml',
],
'license': 'AGPL-3',
'author': 'Bemade',
'website': 'https://bemade.org/',
'installable': True,
'auto_install': False
}

View file

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import stock_quant_package
from . import delivery_carrier

View file

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class DeliveryCarrier(models.Model):
_inherit = 'delivery.carrier'
# this allow us to enable the auto create package feature per delivery.carrier
# default is False to avoid impacting Odoo's default behavior on Test
auto_create_package = fields.Boolean('Auto Create Package', default=False)

View file

@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class StockQuantPackage(models.Model):
_inherit = 'stock.quant.package'
length = fields.Float('Length')
width = fields.Float('Width')
height = fields.Float('Height')
carrier_id = fields.Many2one( comodel_name='delivery.carrier',
string='Carrier',
help="Carrier use to create package.",
compute='_compute_package_carrier' )
provider = fields.Selection( selection=lambda self: self._get_provider(),
string='Provider',
help="Provider to create package.",
compute='_compute_package_carrier' )
auto_create_package = fields.Boolean( string='Auto Create Package',
compute='_compute_auto_create_package' )
@api.depends('carrier_id')
def _compute_auto_create_package(self):
# Look so much like a related field
for record in self:
record.auto_create_package = record.carrier_id.auto_create_package if record.carrier_id else False
def _compute_package_carrier(self):
move_line = self.env['stock.move.line'].search([('result_package_id', '=', self.id)])
# This also look like a related field and might not even be needed as we only need it to get the provider
self.carrier_id = move_line.carrier_id
delivery_types_available = [item[0] for item in self.env['stock.package.type']._fields['package_carrier_type'].selection]
# This is a bit of a hack to get the provider from the carrier_id, depending if carrier is integrated or not
if self.carrier_id.delivery_type not in delivery_types_available:
self.provider = False
else:
self.provider = move_line.carrier_id.delivery_type
def _get_provider(self):
# just cloning the selection from stock.package.type tru a lambda function
return self.env['stock.package.type']._fields['package_carrier_type'].selection
def write(self, vals):
if 'length' in vals and 'width' in vals and 'height' in vals:
# check if length, width, height are greater than 0
if vals['length'] <= 0 or vals['width'] <= 0 or vals['height'] <= 0:
raise ValidationError('Length, width, and height must be greater than 0.')
# Make sure length is always greater than width, otherwise swap them
if vals['length'] < vals['width']:
vals['length'], vals['width'] = vals['width'], vals['length']
# check if existing package_type exists
package_type = self.env['stock.package.type'].search([
('width', '=', vals['width']),
('height', '=', vals['height']),
('packaging_length', '=', vals['length']),
('package_carrier_type', '=', self.provider)
], limit=1)
# if not, create new package_type
if not package_type:
package_type = self.env['stock.package.type'].create({
'name': f'Box {vals["length"]}x{vals["width"]}x{vals["height"]}',
'width': vals['width'],
'height': vals['height'],
'packaging_length': vals['length'],
'package_carrier_type': self.provider
})
# update package_type_id
vals['package_type_id'] = package_type.id
res = super(StockQuantPackage, self).write(vals)
return res

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_delivery_carrier_form_auto_package" model="ir.ui.view">
<field name="name">Auto package configuration</field>
<field name="model">delivery.carrier</field>
<field name="inherit_id" ref="delivery.view_delivery_carrier_form"/>
<field name="arch" type="xml">
<field name="product_id" position="after">
<field name="auto_create_package"/>
</field>
</field>
</record>
</odoo>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_quant_package_form_size" model="ir.ui.view">
<field name="name">stock.view_quant_package_form_size</field>
<field name="model">stock.quant.package</field>
<field name="inherit_id" ref="stock.view_quant_package_form"/>
<field name="arch" type="xml">
<field name="package_type_id" position="replace">
<field name="auto_create_package" attrs="{'invisible': 1}"/>
<field name="package_type_id"
attrs="{'invisible': [('package_type_id', '=', False),('auto_create_package', '=', True)]}"/>
<field name="carrier_id" attrs="{'invisible': 1}"/>
<field name="provider" attrs="{'invisible': 1}"/>
<field name="length"
attrs="{'invisible': ['|', ('package_type_id', '!=', False), ('auto_create_package', '!=', True)]}"/>
<field name="width"
attrs="{'invisible': ['|', ('package_type_id', '!=', False), ('auto_create_package', '!=', True)]}"/>
<field name="height"
attrs="{'invisible': ['|', ('package_type_id', '!=', False), ('auto_create_package', '!=', True)]}"/>
</field>
</field>
</record>
</odoo>

View file

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import choose_delivery_package

View file

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from math import ceil
from odoo.exceptions import ValidationError
class ChooseDeliveryPackage(models.TransientModel):
_inherit = 'choose.delivery.package'
length = fields.Float('Length')
width = fields.Float('Width')
height = fields.Float('Height')
auto_create_package = fields.Boolean(string='Auto Create Package', default=False)
provider = fields.Selection(selection=lambda self: self._get_provider(), string='Provider')
@api.model
def default_get(self, fields_list):
# This is a bit of a hack to get the provider from the carrier_id, depending if carrier is integrated or not
defaults = super().default_get(fields_list)
if 'provider' in fields_list:
# get related picking
picking = self.env['stock.picking'].browse(defaults.get('picking_id'))
# generate list of available delivery types keeping only the first element of the tuple
delivery_types_available = \
[item[0] for item in self.env['stock.package.type']._fields['package_carrier_type'].selection]
# if carrier is not integrated, provider is False
if picking.carrier_id.delivery_type not in delivery_types_available:
defaults['provider'] = False
defaults['auto_create_package'] = False
else:
# if carrier is integrated, provider is the delivery_type and carry auto_create_package from carrier
defaults['provider'] = picking.carrier_id.delivery_type
defaults['auto_create_package'] = picking.carrier_id.auto_create_package
return defaults
def _get_provider(self):
# just cloning the selection from stock.package.type tru a lambda function
return self.env['stock.package.type']._fields['package_carrier_type'].selection
def action_put_in_pack(self):
# this override is call only for delivery.carrier with auto_create_package = True. Ideal for odoo test
if self.auto_create_package:
if self.width <= 0 or self.height <= 0 or self.length <= 0:
# Obviously, we need value for our wizard to work
raise ValidationError('Length, width, and height must be greater than 0.')
# Make sure length is always greater than width, otherwise swap them
if self.length < self.width:
self.length, self.width = self.width, self.length
# that search is carier.delivery integration agnostic
delivery_package_type = self.env['stock.package.type'].search([
('width', '=', self.width),
('height', '=', self.height),
('packaging_length', '=', self.length),
('package_carrier_type', '=', self.provider)
], limit=1)
# if no package type found, create one
if not delivery_package_type:
delivery_package_type = delivery_package_type.create({
'name': f'Box {vals["width"]}x{vals["height"]}x{vals["length"]}',
'width': self.width,
'height': self.height,
'packaging_length': self.length,
'package_carrier_type': self.provider
})
self.delivery_package_type_id = delivery_package_type.id
result = super().action_put_in_pack()
return result

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="choose_delivery_package_view_form2" model="ir.ui.view">
<field name="name">choose.delivery.package.form2</field>
<field name="model">choose.delivery.package</field>
<field name="inherit_id" ref="delivery.choose_delivery_package_view_form"/>
<field name="arch" type="xml">
<field name="delivery_package_type_id" position="replace">
<field name="auto_create_package" attrs="{'invisible': 1}"/>
<field name="delivery_package_type_id"
domain="[('package_carrier_type', '=', context.get('current_package_carrier_type', 'none'))]"
context="{'form_view_ref':'stock.stock_package_type_form'}"
attrs="{'invisible': [('auto_create_package', '=', True)]}" />
<field name="length" attrs="{'invisible': [('auto_create_package', '=', False)]}"/>
<field name="width" attrs="{'invisible': [('auto_create_package', '=', False)]}"/>
<field name="height" attrs="{'invisible': [('auto_create_package', '=', False)]}"/>
<field name="provider" attrs="{'invisible': 1}"/>
</field>
</field>
</record>
</odoo>

View file

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

View file

@ -0,0 +1,38 @@
{
'name': 'Automated Partner Association by Email Domain',
'version': '15.0.0.0.0',
'category': 'Extra Tools',
'summary': 'Automatically associates partners with companies using matching email domains',
'description': """
This advanced utility module empowers Odoo users with the capacity to automate partner-company associations
based directly on email domains. It helps in eliminating manual effort by establishing a rule of correlation
between the email domain of a partner and the corresponding division or a company.
The automation proceeds as follows:
- On creation of partner records, the system will cross-verify the email domain against the companies
in the records.
- If a match is detected, the partner will automatically be linked with that company.
This module also takes care of instances where there are multiple companies with the same email domain.
In such cases, an email is dispatched to the partner with a selection interface to finalize the association.
The module is a significant productivity enhancer for businesses that deal with a high number of partners
and require efficient management.
""",
'depends': [
'base',
'mail',
'website'
],
'data': [
'views/res_partner_views.xml',
'data/mail_template_data.xml',
'data/template_data.xml'
],
'license': 'AGPL-3',
'author': 'Bemade',
'website': 'https://bemade.org/',
'installable': True,
'auto_install': False
}

View file

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

View file

@ -0,0 +1,40 @@
# Import necessary libraries
from odoo import http, _
#from odoo.http import request
# Define a controller class that inherits from http.Controller
class DivisionCompanyController(http.Controller):
# Decorator to set the routing rules for the method 'select_division_company'.
# The URL for this method will be '/select_division_company'
# It has public access and should be used for HTTP requests of type GET.
@http.route('/select_division_company', website=True, type='http', auth='public')
def select_division_company(self, partner_id, access_token, division_id, **kwargs):
# Search the 'res.partner' model for a record with the given partner_id using Super User access rights
partner = http.request.env['res.partner'].sudo().search([
('id', '=', int(partner_id))
])
# Check if a partner was found with the given ID
if not partner:
# If no partner found, render an error page with a translatable error message
return http.request.render('bemade_partner_email_domain.error_page',
{'error_message': _('The requested partner is not found.')})
else:
# If the partner exists and the access token matches, proceed with updating the partner's division
if access_token and access_token == partner.access_token:
# Update the partner's parent with the division_id
partner.write({'parent_id': int(division_id)})
# Set a confirmation message signaling that the partner has been associated with the division
confirmation_message = _('Partner %s is associated with the division %s.' % (partner.name, division_id))
# Render the error page (which in this case acts as a status page) with the confirmation message.
return http.request.render('bemade_partner_email_domain.error_page',
{'confirmation_message': confirmation_message})
else:
# If the access token was not provided or does not match, render an error page with an error message
return http.request.render('bemade_partner_email_domain.error_page',
{'error_message': _('No Access Token found or not matching for this partner.')})

View file

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data noupdate="1">
<record id="email_template_select_parent" model="mail.template">
<field name="name">Division Selection: Choose Your Division</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="email_from">{{object.company_id.email_formatted or user.email_formatted}}</field>
<field name="partner_to">{{object.id}}</field>
<field name="subject">Select Your Division at {{object.company_id.name}}</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0"
style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial, sans-serif; color: #454748; width: 100%; border-collapse:separate;">
<tbody>
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<!-- Content here -->
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tbody>
<tr>
<td valign="top" style="font-size: 13px;">
<p>Hello <t t-out="object.name or ''">Brandon Freeman</t>,</p>
<p>We noticed that you have registered with our company by sending an email or using our website. To provide you with the best possible service, we need you to confirm the division of your company that you are working for.</p>
<p>Please click on the link below to select your division:</p>
<ul>
<t t-foreach="ctx.get('links', {}).items()" t-as="company_name">
<li>
<a t-attf-href="{{ company_name[1] }}" style="font-size:12px">
<t t-esc="company_name[0]"/>
</a>
</li>
</t>
</ul>
<p>If you have any questions or did not register with us, please disregard this email or contact us directly.</p>
<p>Best regards,</p>
<p>The Durpro team.</p>
</td>
</tr>
<!-- More content can be added here -->
</tbody>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590"
style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tbody>
<tr>
<td valign="middle" align="left">
<t t-out="object.company_id.name or ''"/>
</td>
</tr>
<tr>
<td valign="middle" align="left" style="opacity: 0.7;">
<t t-out="object.company_id.phone or ''"/>
<t t-if="object.company_id.email">
| <a t-att-href="'mailto:%s' % object.company_id.email" style="text-decoration:none; color: #454748;" t-out="object.company_id.email or ''"></a>
</t>
<t t-if="object.company_id.website">
| <a t-att-href="'%s' % object.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.company_id.website or ''"></a>
</t>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<!-- POWERED BY -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
<tbody>
<tr>
<td style="text-align: center; font-size: 13px;">
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=auth" style="color: #875A7B;">Odoo</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</field>
<field name="lang">{{ object.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,17 @@
<odoo>
<data>
<template id="error_page">
<base href="/" />
<t t-call="website.layout">
<div id="wrap">
<t t-if="error_message">
<div class="alert alert-danger"><t t-esc="error_message"/></div>
</t>
<t t-if="confirmation_message">
<div class="alert alert-success"><t t-esc="confirmation_message"/></div>
</t>
</div>
</t>
</template>
</data>
</odoo>

View file

@ -0,0 +1,41 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * bemade_partner_email_domain
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 15.0\n"
"Report-Msgid-Bugs-To: it@bemade.org\n"
"Last-Translator: chatgpt\n"
"Language-Team: French (http://www.transifex.com/odoo/odoo-14-0/language/fr_CA/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#: model:ir.model.fields,field_description:bemade_partner_email_domain.field_res_partner_access_token
msgid "Access Token"
msgstr "Jeton d'accès"
#: model:ir.model.fields,field_description:bemade_partner_email_domain.field_res_partner_email_domain
msgid "Email Domain"
msgstr "Domaine de courriel"
#: model:ir.model.fields,field_description:bemade_partner_email_domain.field_res_partner_is_subdivision
msgid "Subdivision"
msgstr "Subdivision"
#: code:addons/bemade_partner_email_domain/controllers/main.py:0
#, python-format
msgid "The requested partner is not found."
msgstr "Le partenaire demandé n'est pas trouvé."
#: code:addons/bemade_partner_email_domain/controllers/main.py:0
#, python-format
msgid "No Access Token found or not matching for this partner."
msgstr "Aucun jeton d'accès trouvé ou ne correspondant pas à ce partenaire."
#: code:addons/bemade_partner_email_domain/controllers/main.py:0
#, python-format
msgid "Partner %s is associated with the division %s."
msgstr "Le partenaire %s est associé à la division %s."

View file

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

View file

@ -0,0 +1,74 @@
from odoo import api, fields, models, Command
import uuid
from odoo.addons.website.controllers.main import Website
class Partner(models.Model):
_inherit = 'res.partner'
email_domain = fields.Char(string='Email Domain')
is_subdivision = fields.Boolean(string='Subdivision', default=False)
access_token = fields.Char(string='Access Token')
def _generate_access_token(self):
return uuid.uuid4().hex
def _send_selection_email(self, division_companies):
self.ensure_one()
# Generate a token and save it to the partner
access_token = self._generate_access_token()
print(f'partner id: {self.id}')
self.write({'access_token': access_token})
# Now include the token in the links
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
links = {
company.name: (f'{base_url}/select_division_company?partner_id={self.id}&access_token={access_token}&division_id={company.id}') for company in division_companies
}
print(f'links: {links}')
# Render the email template and send the email
template = self.env.ref('bemade_partner_email_domain.email_template_select_parent')
template.with_context(links=links).send_mail(self.id, force_send=True)
# @api.onchange('email')
def _check_parent_from_email_domain(self):
for rec in self:
if rec.parent_id:
continue
try:
# Split the email address on '@' and get the domain part
if rec.email:
email_domain = rec.email.split('@')[1].strip()
else:
continue
except IndexError:
# If there's no '@' symbol, the email address is invalid
return False
# Loop while there's more than one part in the domain (e.g., subdomain.domain.tld)
while '.' in email_domain:
# If the current email domain matches the main domain, return True
company_domain = self.env['res.partner'].search([('email_domain', 'ilike', email_domain)])
if company_domain:
if len(company_domain) > 1:
rec._send_selection_email(company_domain)
else:
rec.parent_id = company_domain.id
return
# If not, drop the part before the first '.' to check the next level
email_domain = email_domain.split('.', 1)[1]
@api.model_create_multi
def create(self, vals_list):
res = super(Partner, self).create(vals_list)
res._check_parent_from_email_domain()
return res
def write(self, vals):
res = super(Partner, self).write(vals)
if 'email' in vals:
self._check_parent_from_email_domain()
return res

View file

@ -0,0 +1,21 @@
<?xml version="1.0"?>
<odoo>
<record id="partner_email_domain_form" model="ir.ui.view">
<field name="name">bemade_partner_email_domain.partner.email.domain.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<field name="company_type" position="after">
<label for="is_subdivision" string="Division" attrs="{'invisible': [('is_company', '=', False)]}"/>
<field name="is_subdivision" attrs="{'invisible': [('is_company', '=', False)]}"/>
</field>
<field name="website" position="before">
<field name="email_domain" attrs="{'invisible': [('is_company', '=', False)]}"/>
</field>
<field name="parent_id" position="attributes">
<attribute name="attrs">{'invisible': [('is_company','=', True),('is_subdivision','=', False)]}
</attribute>
</field>
</field>
</record>
</odoo>

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more