Remove modules with 17.0 versions

This commit is contained in:
Marc Durepos 2025-09-02 12:58:15 -04:00
parent 8424815d53
commit ce69990df6
462 changed files with 0 additions and 19914 deletions

View file

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

View file

@ -1,20 +0,0 @@
{
"name": "Account Credit Hold",
"version": "17.0.1.1.1",
"summary": "Allows setting clients on credit hold, blocking the ability confirm a new sales order.",
"category": "Accounting/Accounting",
"author": "Bemade Inc.",
"maintainer": "Marc Durepos <marc@bemade.org>",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": ["sale", "account_followup", "stock"],
"data": [
"views/account_followup_views.xml",
"views/sale_order_views.xml",
"views/res_partner_views.xml",
"views/stock_picking_views.xml",
],
"demo": [],
"installable": True,
"auto_install": False,
}

View file

@ -1,246 +0,0 @@
# Migration vers Odoo 18.0 - Module account_credit_hold
## Fonctionnalités
- Ajoute un champ "Place on Credit Hold" sur les lignes de suivi de compte (account_followup.followup.line)
- Ajoute des champs et fonctionnalités sur les partenaires:
- postpone_hold_until: Date de report du blocage
- hold_bg: Champ technique pour le statut de blocage
- on_hold: État calculé du blocage de crédit
- Bloque la confirmation des commandes de vente si le client est en blocage de crédit
- Ajoute des indicateurs visuels (ruban rouge) sur:
- Commandes de vente
- Fiches partenaires
- Transferts de stock
- Ajoute des boutons pour mettre/lever le blocage de crédit dans la vue de suivi des comptes
## Analyse pour la Migration
### Dépendances
- sale
- account_followup
- stock
### Changements Techniques Requis
1. Mettre à jour la version dans __manifest__.py vers 18.0
2. Vérifier la compatibilité des vues XML avec Odoo 18.0
3. Vérifier si des changements dans l'API account_followup en 18.0
### Points d'Attention
1. Le module utilise l'héritage de vues et de modèles standard d'Odoo:
- account_followup.followup.line
- res.partner
- sale.order
- stock.picking
- account.followup.report
2. Fonctionnalités critiques à tester après migration:
- Calcul automatique du statut on_hold
- Blocage de la confirmation des commandes
- Nettoyage automatique des reports de blocage expirés (@api.autovacuum)
- Affichage correct des rubans d'avertissement
- Propagation du statut hold aux contacts liés (commercial_partner_id)
3. Implémentation Technique:
- Utilisation de champs computed avec store=True et compute_sudo=True
- Mécanisme de nettoyage automatique via @api.autovacuum
- Héritage de _execute_followup_partner pour automatisation du hold
- Messages de chatter automatiques lors des changements de statut
4. Points Spécifiques aux Vues:
- Utilisation du widget web_ribbon pour les indicateurs visuels
- Boutons conditionnels dans la vue de suivi des comptes
- Champs invisibles pour la logique d'affichage (hold_bg, on_hold)
- Groupes de sécurité sur le champ postpone_hold_until
## Questions et Considérations
1. Vérifier si Odoo 18.0 n'a pas introduit des fonctionnalités natives similaires dans account_followup:
- Système de blocage automatique des clients
- Gestion des périodes de grâce
- Indicateurs visuels de blocage
2. Points à valider:
- La structure des vues héritées est-elle identique en 18.0?
- Les champs related et computed fonctionnent-ils de la même manière?
- Le système de suivi des comptes (account_followup) a-t-il évolué?
- Le décorateur @api.autovacuum est-il toujours supporté?
- Le widget web_ribbon utilise-t-il toujours la même API?
3. Considérations d'Architecture:
- Le mécanisme de propagation du statut hold via commercial_partner_id est-il optimal?
- Possibilité de simplifier la logique de calcul du statut hold?
- Pertinence de stocker le champ hold_bg vs calcul à la demande
4. Alternatives Potentielles:
- Utiliser le système de credit limit natif d'Odoo avec des règles personnalisées?
- Intégrer avec le système de blocage des partenaires d'Odoo?
- Utiliser les étapes de facturation (invoice_status) plutôt qu'un champ séparé?
## Alternatives Natives Odoo 18.0
### Système de Crédit Natif
1. Odoo 18.0 inclut des fonctionnalités natives de gestion de crédit:
- Champ `credit_limit` sur res.partner
- Configuration du blocage au niveau de la société
- Règles de blocage basées sur:
- Montant de crédit maximum
- Factures échues
- Âge des factures
2. Possibilités d'utilisation des fonctionnalités natives:
- Utiliser `credit_limit` au lieu de `on_hold`
- Configurer les règles de blocage dans la configuration de la comptabilité
- Utiliser les notifications natives de dépassement de crédit
### Améliorations Possibles
1. Intégration avec le système natif:
- Synchroniser notre `on_hold` avec le système natif de blocage
- Utiliser les API natives de vérification de crédit
- Conserver uniquement les fonctionnalités non disponibles nativement
2. Simplification du code:
- Remplacer les champs custom par des champs natifs quand possible
- Utiliser le système d'alertes natif pour les rubans
- Intégrer avec le système de workflow natif
## Recommandations pour la Migration
### Approche "Vanilla First"
1. Évaluer chaque fonctionnalité custom:
- Est-elle disponible nativement dans Odoo 18.0?
- Peut-elle être remplacée par une configuration native?
- Le besoin business existe-t-il toujours?
2. Prioriser l'utilisation des fonctionnalités natives:
- Système de crédit natif
- Système de workflow natif
- API de notification standard
- Widgets standards de l'interface
### Modifications Techniques Recommandées
1. Remplacer les attributs obsolètes:
- Supprimer les `attrs` dans les vues (Odoo 16.0+)
- Utiliser `list` au lieu de `tree` (Odoo 17.0+)
- Adapter les widgets aux nouvelles conventions
2. Optimisation des performances:
- Utiliser les indexes de base de données appropriés
- Optimiser les recherches et calculs
- Implémenter le lazy loading quand possible
### Plan de Test Approfondi
1. Tests fonctionnels:
- Validation du comportement avec le système natif
- Tests de régression sur les fonctionnalités custom
- Vérification des performances
2. Tests d'intégration:
- Interaction avec le workflow de vente
- Synchronisation avec la comptabilité
- Comportement avec les autres modules
## État de la Migration
⚪ En analyse préliminaire
## Plan de Migration
### Étape 1: Analyse des Changements Odoo 18.0
- [ ] Examiner les changements dans account_followup
- [ ] Vérifier les nouvelles fonctionnalités de gestion de crédit
- [ ] Analyser les modifications des vues héritées
### Étape 2: Adaptation Technique
- [ ] Mise à jour du manifeste
- [ ] Vérification de la compatibilité des décorateurs
- [ ] Adaptation des vues XML si nécessaire
- [ ] Test des champs computed et related
### Étape 3: Tests Fonctionnels
- [ ] Validation du mécanisme de hold
- [ ] Test de la propagation aux contacts
- [ ] Vérification des nettoyages automatiques
- [ ] Test des indicateurs visuels
### Étape 4: Optimisation
- [ ] Évaluation des alternatives natives
- [ ] Simplification potentielle du code
- [ ] Amélioration des performances
## Notes de Version
- Version originale: 17.0.1.1.1
- Dernière analyse: 26/01/2025
## Fonctionnalités Natives dans Odoo 18.0
Odoo 18.0 inclut nativement plusieurs fonctionnalités de gestion du crédit :
1. **Gestion des Limites de Crédit**
- Champ `credit_limit` sur les partenaires
- Champ `use_partner_credit_limit` pour activer/désactiver par partenaire
- Configuration globale `account_use_credit_limit` au niveau de la société
- Champ `credit` pour le total des créances
- Champ `trust` pour le niveau de confiance du débiteur
2. **Visibilité et Contrôle**
- Champ `show_credit_limit` basé sur la configuration de la société
- Groupes de sécurité pour la gestion des limites de crédit
### Différences avec Notre Module
1. **Fonctionnalités à Migrer**
- [ ] Indicateurs visuels spécifiques pour les clients en dépassement
- [ ] Blocage automatique des commandes en dépassement
- [ ] Workflow d'approbation personnalisé
2. **Fonctionnalités à Adapter**
- [ ] Utiliser les champs natifs plutôt que nos champs customs
- [ ] Intégrer nos règles de blocage avec le système natif
- [ ] Adapter les rapports et vues pour utiliser les champs natifs
## Plan de Migration
### Phase 1 : Préparation
1. **Analyse des Données**
- [ ] Identifier les clients avec des limites de crédit
- [ ] Mapper les champs actuels vers les champs natifs
- [ ] Lister les règles de blocage personnalisées
2. **Configuration**
- [ ] Activer la gestion du crédit dans la configuration de la société
- [ ] Configurer les groupes de sécurité appropriés
- [ ] Préparer les scripts de migration des données
### Phase 2 : Migration
1. **Migration des Données**
- [ ] Transférer les limites de crédit vers le champ natif
- [ ] Migrer les configurations de blocage
- [ ] Mettre à jour les vues et rapports
2. **Développement**
- [ ] Adapter le code de blocage des commandes
- [ ] Implémenter les indicateurs visuels manquants
- [ ] Ajouter les fonctionnalités spécifiques non disponibles nativement
### Phase 3 : Tests
1. **Validation Fonctionnelle**
- [ ] Tester les limites de crédit
- [ ] Vérifier le blocage des commandes
- [ ] Valider les workflows d'approbation
2. **Tests d'Intégration**
- [ ] Tester avec les autres modules
- [ ] Vérifier la compatibilité avec les processus existants
## État de la Migration
🟡 En cours d'analyse - Utilisation partielle des fonctionnalités natives
## Notes Importantes
- La gestion du crédit est maintenant une fonctionnalité native d'Odoo
- Certaines fonctionnalités spécifiques devront être maintenues
- L'approche recommandée est d'utiliser au maximum les fonctionnalités natives et de ne conserver que les extensions nécessaires
## Prochaines Étapes
1. Valider l'approche avec l'équipe
2. Créer les scripts de migration des données
3. Développer les fonctionnalités manquantes
4. Planifier la formation des utilisateurs

View file

@ -1,5 +0,0 @@
from . import account_followup
from . import res_partner
from . import sale_order
from . import stock_picking
from . import account_followup_report

View file

@ -1,8 +0,0 @@
from odoo import fields, models, api
class FollowupLine(models.Model):
_inherit = 'account_followup.followup.line'
account_hold = fields.Boolean(string="Place on Credit Hold",
help="Place clients on account hold, restricting confirmation of new orders.")

View file

@ -1,12 +0,0 @@
from odoo import models, fields, api, _
class FollowUpReport(models.AbstractModel):
_inherit = 'account.followup.report'
def _get_line_info(self, followup_line):
res = super()._get_line_info(followup_line)
res.update({
'credit_hold': followup_line.account_hold
})
return res

View file

@ -1,80 +0,0 @@
from odoo import fields, models, api, _
from datetime import date
class Partner(models.Model):
_inherit = "res.partner"
postpone_hold_until = fields.Date(
string="Postpone Hold",
help="Grace period specific to this partner despite unpaid invoices.",
tracking=True,
)
hold_bg = fields.Boolean(
string="Hold (technical)",
compute="_compute_hold_bg",
store=True,
default=False,
compute_sudo=True,
tracking=True,
)
on_hold = fields.Boolean(
string="Account on Hold",
help="Client account is on hold for unpaid overdue invoices.",
compute="_compute_on_hold",
compute_sudo=True,
)
@api.depends("postpone_hold_until", "hold_bg")
def _compute_on_hold(self):
# manually re-compute hold_bg since followup_status doesn't get updated in Python but gets recalculated
# by an SQL query every time
self._compute_hold_bg()
for rec in self:
# If the parent company is on hold, so are all its sub-contacts and subsidiaries
if rec.commercial_partner_id and rec.commercial_partner_id.on_hold:
rec.on_hold = True
return
# If there is no parent company or the parent is not on hold, we compute for ourselves
if rec.hold_bg and not (
rec.postpone_hold_until and rec.postpone_hold_until > date.today()
):
rec.on_hold = True
else:
if rec.on_hold:
rec.message_post(_("Credit hold lifted."))
rec.on_hold = False
@api.autovacuum
def _cleanup_expired_hold_postponements(self):
expired_holds = self.search([("postpone_hold_until", "<=", date.today())])
expired_holds.write({"postpone_hold_until": False})
def action_credit_hold(self):
for rec in self:
rec.hold_bg = True
rec.message_post(body=_("Placed on credit hold."))
def action_lift_credit_hold(self):
for rec in self:
rec.hold_bg = False
rec.message_post(body=_("Credit hold lifted."))
def _execute_followup_partner(self, options=None):
res = super()._execute_followup_partner(options)
if self.followup_status == "in_need_of_action":
if self.followup_line_id.account_hold:
self.action_credit_hold()
return res
@api.depends("followup_status", "followup_line_id")
def _compute_hold_bg(self):
first_followup_level = self._get_first_followup_level()
for rec in self:
prev_hold_bg = rec.hold_bg
level = rec.followup_line_id
if rec.followup_status == "no_action_needed" and not level:
rec.hold_bg = False
else:
rec.hold_bg = prev_hold_bg

View file

@ -1,17 +0,0 @@
from odoo import fields, models, api, _
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = "sale.order"
client_on_hold = fields.Boolean(string='Client on Hold',
help="Whether or not a client has been put on hold due to unpaid invoices.",
related="partner_id.on_hold")
@api.depends('client_on_hold')
def action_confirm(self):
if any(self.mapped('client_on_hold')):
raise UserError(_("This client is on credit hold. No new orders can be confirmed until past-due invoices "
"are paid or the accounting team postpones the hold."))
super().action_confirm()

View file

@ -1,9 +0,0 @@
from odoo import fields, models, api
class ModelName(models.Model):
_inherit = "stock.picking"
client_on_hold = fields.Boolean(string='Client on Hold',
help="Whether or not a client has been put on hold due to unpaid invoices.",
related="partner_id.on_hold")

View file

@ -1,40 +0,0 @@
# Overview
This module adds the notion of placing clients on credit hold to the followup levels from the Odoo Enterprise
account_followup module. It adds an option to followup levels to mark clients matching the followup criteria as on
credit hold. This hold restricts the confirmation of new sales orders for these clients.
Accountant and admin users can set a date until which the account hold will be
postponed on a specific partner's form view. This effectively gives clients an extra
grace period, allowing orders to be confirmed until the period ends.
# Change Log
## 17.0.1.0.0 (2024-05-15)
Various modifications to adapt code to Odoo 17.0
## 15.0.2.0.0 (2023-05-04)
Complete remake of the module, making the "Credit Hold" an action that is either manually or
automatically triggered from the Accounting > Followup Reports section or by setting the automatic application field
on followup levels.
## 15.0.1.1.0 (2023-05-03)
Adds a ribbon to stock pickings for clients on hold, and therefore a dependency on stock.
## 15.0.1.0.2 (2023-05-03)
Fix to sale order view and sale order confirmation for clients not on hold.
## 15.0.1.0.1 (2023-05-03)
Fix clients on hold when status is "outstanding_invoices".
## 15.0.1.0.0 (2023-05-02) Initial Release
Initial release of the module, including a setting on follow-up levels to toggle placing on credit hold. Blocks
the confirmation of sales orders for clients on credit hold. Red "Credit Hold" banner appears on sales orders and
partner form view when a client is on credit hold. Credit hold can be postponed by setting the "Postpone Hold" field
on the partner form view.

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="account_followup_followup_line_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.account_followup_line.form</field>
<field name="model">account_followup.followup.line</field>
<field
name="inherit_id"
ref="account_followup.view_account_followup_followup_line_form"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='send_email']" position="before">
<field name="account_hold" />
</xpath></field>
</record>
<record id="customer_statements_form_view_inherit" model="ir.ui.view">
<field name="name">customer.statements.form.view.inherit</field>
<field name="model">res.partner</field>
<field
name="inherit_id"
ref="account_followup.customer_statements_form_view"
/>
<field name="arch" type="xml">
<xpath expr="//button[last()]" position="after">
<field invisible="1" name="hold_bg" />
<button
class="button btn-secondary"
invisible="hold_bg == True"
name="action_credit_hold"
string="Credit Hold"
type="object"
/>
<button
class="button btn-secondary"
invisible="hold_bg == False"
name="action_lift_credit_hold"
string="Lift Credit Hold"
type="object"
/>
</xpath></field>
</record>
<record id="action_credit_hold" model="ir.actions.server">
<field name="name">action_credit_hold</field>
<field name="model_id" ref="base.model_res_partner" />
<field name="binding_model_id" ref="base.model_res_partner" />
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">records.action_credit_hold()</field>
</record>
</odoo>

View file

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="res_partner_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.res_partner.form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="before">
<field invisible="True" name="hold_bg" />
<field invisible="True" name="on_hold" />
<widget
bg_color="bg-danger"
invisible="on_hold == False"
name="web_ribbon"
title="Credit Hold"
/>
</xpath></field>
</record>
<record id="view_partner_property_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.view_partner_property_form</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="account.view_partner_property_form" />
<field name="arch" type="xml">
<xpath expr="//group[@name='banks']" position="before">
<group string="Credit Hold">
<field
groups="account.group_account_manager,account.group_account_user"
name="postpone_hold_until"
readonly="hold_bg == False and postpone_hold_until == False"
/>
</group>
</xpath></field>
</record>
</odoo>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="sale_order_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.sale_order.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="before">
<field invisible="True" name="client_on_hold" />
<widget
bg_color="bg-danger"
invisible="client_on_hold == False"
name="web_ribbon"
title="Credit Hold"
/>
</xpath></field>
</record>
</odoo>

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="stock_picking_form_inherit" model="ir.ui.view">
<field name="name">account_credit_hold.stock_picking.form</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form" />
<field eval="8" name="priority" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="before">
<field invisible="True" name="client_on_hold" />
<widget
bg_color="bg-danger"
invisible="client_on_hold == False"
name="web_ribbon"
title="Credit Hold"
/>
</xpath></field>
</record>
</odoo>

View file

@ -1,33 +0,0 @@
from . import models
def post_init(env):
env['account.report'].search([('name', 'ilike', 'Aged % - North America')]).unlink()
new_receivable_report = env.ref('account_reports.aged_receivable_report').copy()
new_payable_report = env.ref('account_reports.aged_payable_report').copy()
new_receivable_report.line_ids.mapped('expression_ids').write({
'formula': '_report_custom_engine_aged_receivable_na'
})
new_payable_report.line_ids.mapped('expression_ids').write({'formula': '_report_custom_engine_aged_payable_na'})
new_receivable_report.write({
'name': 'Aged Receivable - North America',
'root_report_id': env.ref('account_reports.aged_receivable_report').id,
'availability_condition': 'always',
})
new_payable_report.write({
'name': 'Aged Payable - North America',
'root_report_id': env.ref('account_reports.aged_payable_report').id,
'availability_condition': 'always',
})
col_name_mapping = [
('At Date', 'Overdue'),
('1-30', '0-29'),
('31-60', '30-59'),
('61-90', '60-89'),
('91-120', '90-119'),
('Older', 'Later'),
]
for old_name, new_name in col_name_mapping:
((new_payable_report | new_receivable_report)
.mapped('column_ids')
.filtered(lambda col: col.name == old_name)).write({'name': new_name})

View file

@ -1,32 +0,0 @@
#
# Bemade Inc.
#
# Copyright (C) 2023-June 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': 'Aged Partner Balance (North American Style)',
'version': '17.0.1.0.0',
'summary': 'Present aged partner balance as predictive rather than past due.',
'category': 'Accounting',
'author': 'Bemade Inc.',
'website': 'http://www.bemade.org',
'license': 'LGPL-3',
'depends': ['account_reports'],
'assets': {},
'installable': True,
'auto_install': False,
'post_init_hook': 'post_init',
}

View file

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

View file

@ -1,245 +0,0 @@
from odoo import models, fields
from itertools import chain
from dateutil.relativedelta import relativedelta
class AgedPartnerBalanceCustomHandler(models.AbstractModel):
_inherit = "account.aged.partner.balance.report.handler"
def _report_custom_engine_aged_receivable_na(
self,
expressions,
options,
date_scope,
current_groupby,
next_groupby,
offset=0,
limit=None,
warnings=None
):
return self._aged_partner_report_custom_engine_common_na(
options,
'asset_receivable',
current_groupby,
next_groupby,
offset=offset,
limit=limit
)
def _report_custom_engine_aged_payable_na(
self,
expressions,
options,
date_scope,
current_groupby,
next_groupby,
offset=0,
limit=None,
warnings=None
):
return self._aged_partner_report_custom_engine_common_na(
options,
'liability_payable',
current_groupby,
next_groupby,
offset=offset,
limit=limit
)
def _aged_partner_report_custom_engine_common_na(self, options, internal_type, current_groupby, next_groupby, offset=0, limit=None):
report = self.env['account.report'].browse(options['report_id'])
report._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
def plus_days(date_obj, days):
return fields.Date.to_string(date_obj + relativedelta(days=days))
def minus_days(date_obj, days):
return fields.Date.to_string(date_obj - relativedelta(days=days))
date_to = fields.Date.from_string(options['date']['date_to'])
periods = [
(False, minus_days(date_to, 1)),
(date_to, plus_days(date_to, 29)),
(plus_days(date_to, 30), plus_days(date_to, 59)),
(plus_days(date_to, 60), plus_days(date_to, 89)),
(plus_days(date_to, 90), plus_days(date_to, 119)),
(plus_days(date_to, 120), False),
]
def build_result_dict(report, query_res_lines):
rslt = {f'period{i}': 0 for i in range(len(periods))}
for query_res in query_res_lines:
for i in range(len(periods)):
period_key = f'period{i}'
rslt[period_key] += query_res[period_key]
if current_groupby == 'id':
query_res = query_res_lines[0] # We're grouping by id, so there is only 1 element in query_res_lines anyway
currency = self.env['res.currency'].browse(query_res['currency_id'][0]) if len(query_res['currency_id']) == 1 else None
expected_date = len(query_res['expected_date']) == 1 and query_res['expected_date'][0] or len(query_res['due_date']) == 1 and query_res['due_date'][0]
rslt.update({
'invoice_date': query_res['invoice_date'][0] if len(query_res['invoice_date']) == 1 else None,
'due_date': query_res['due_date'][0] if len(query_res['due_date']) == 1 else None,
'amount_currency': query_res['amount_currency'],
'currency_id': query_res['currency_id'][0] if len(query_res['currency_id']) == 1 else None,
'currency': currency.display_name if currency else None,
'account_name': query_res['account_name'][0] if len(query_res['account_name']) == 1 else None,
'expected_date': expected_date or None,
'total': None,
'has_sublines': query_res['aml_count'] > 0,
# Needed by the custom_unfold_all_batch_data_generator, to speed-up unfold_all
'partner_id': query_res['partner_id'][0] if query_res['partner_id'] else None,
})
else:
rslt.update({
'invoice_date': None,
'due_date': None,
'amount_currency': None,
'currency_id': None,
'currency': None,
'account_name': None,
'expected_date': None,
'total': sum(rslt[f'period{i}'] for i in range(len(periods))),
'has_sublines': False,
})
return rslt
# Build period table
period_table_format = ('(VALUES %s)' % ','.join("(%s, %s, %s)" for period in periods))
params = list(chain.from_iterable(
(period[0] or None, period[1] or None, i)
for i, period in enumerate(periods)
))
period_table = self.env.cr.mogrify(period_table_format, params).decode(self.env.cr.connection.encoding)
# Build query
tables, where_clause, where_params = report._query_get(options, 'strict_range', domain=[('account_id.account_type', '=', internal_type)])
currency_table = report._get_query_currency_table(options)
always_present_groupby = "period_table.period_index, currency_table.rate, currency_table.precision"
if current_groupby:
select_from_groupby = f"account_move_line.{current_groupby} AS grouping_key,"
groupby_clause = f"account_move_line.{current_groupby}, {always_present_groupby}"
else:
select_from_groupby = ''
groupby_clause = always_present_groupby
select_period_query = ','.join(
f"""
CASE WHEN period_table.period_index = {i}
THEN %s * (
SUM(ROUND(account_move_line.balance * currency_table.rate, currency_table.precision))
- COALESCE(SUM(ROUND(part_debit.amount * currency_table.rate, currency_table.precision)), 0)
+ COALESCE(SUM(ROUND(part_credit.amount * currency_table.rate, currency_table.precision)), 0)
)
ELSE 0 END AS period{i}
"""
for i in range(len(periods))
)
tail_query, tail_params = report._get_engine_query_tail(offset, limit)
query = f"""
WITH period_table(date_start, date_stop, period_index) AS ({period_table})
SELECT
{select_from_groupby}
%s * (
SUM(account_move_line.amount_currency)
- COALESCE(SUM(part_debit.debit_amount_currency), 0)
+ COALESCE(SUM(part_credit.credit_amount_currency), 0)
) AS amount_currency,
ARRAY_AGG(DISTINCT account_move_line.partner_id) AS partner_id,
ARRAY_AGG(account_move_line.payment_id) AS payment_id,
ARRAY_AGG(DISTINCT move.invoice_date) AS invoice_date,
ARRAY_AGG(DISTINCT COALESCE(account_move_line.date_maturity, account_move_line.date)) AS report_date,
ARRAY_AGG(DISTINCT account_move_line.expected_pay_date) AS expected_date,
ARRAY_AGG(DISTINCT account.code) AS account_name,
ARRAY_AGG(DISTINCT COALESCE(account_move_line.date_maturity, account_move_line.date)) AS due_date,
ARRAY_AGG(DISTINCT account_move_line.currency_id) AS currency_id,
COUNT(account_move_line.id) AS aml_count,
ARRAY_AGG(account.code) AS account_code,
{select_period_query}
FROM {tables}
JOIN account_journal journal ON journal.id = account_move_line.journal_id
JOIN account_account account ON account.id = account_move_line.account_id
JOIN account_move move ON move.id = account_move_line.move_id
JOIN {currency_table} ON currency_table.company_id = account_move_line.company_id
LEFT JOIN LATERAL (
SELECT
SUM(part.amount) AS amount,
SUM(part.debit_amount_currency) AS debit_amount_currency,
part.debit_move_id
FROM account_partial_reconcile part
WHERE part.max_date <= %s AND part.debit_move_id = account_move_line.id
GROUP BY part.debit_move_id
) part_debit ON TRUE
LEFT JOIN LATERAL (
SELECT
SUM(part.amount) AS amount,
SUM(part.credit_amount_currency) AS credit_amount_currency,
part.credit_move_id
FROM account_partial_reconcile part
WHERE part.max_date <= %s AND part.credit_move_id = account_move_line.id
GROUP BY part.credit_move_id
) part_credit ON TRUE
JOIN period_table ON
(
period_table.date_start IS NULL
OR COALESCE(account_move_line.date_maturity, account_move_line.date) >= DATE(period_table.date_start)
)
AND
(
period_table.date_stop IS NULL
OR COALESCE(account_move_line.date_maturity, account_move_line.date) <= DATE(period_table.date_stop)
)
WHERE {where_clause}
GROUP BY {groupby_clause}
HAVING
(
SUM(ROUND(account_move_line.debit * currency_table.rate, currency_table.precision))
- COALESCE(SUM(ROUND(part_debit.amount * currency_table.rate, currency_table.precision)), 0)
) != 0
OR
(
SUM(ROUND(account_move_line.credit * currency_table.rate, currency_table.precision))
- COALESCE(SUM(ROUND(part_credit.amount * currency_table.rate, currency_table.precision)), 0)
) != 0
{tail_query}
"""
multiplicator = -1 if internal_type == 'liability_payable' else 1
params = [
multiplicator,
*([multiplicator] * len(periods)),
date_to,
date_to,
*where_params,
*tail_params,
]
self._cr.execute(query, params)
query_res_lines = self._cr.dictfetchall()
if not current_groupby:
return build_result_dict(report, query_res_lines)
else:
rslt = []
all_res_per_grouping_key = {}
for query_res in query_res_lines:
grouping_key = query_res['grouping_key']
all_res_per_grouping_key.setdefault(grouping_key, []).append(query_res)
for grouping_key, query_res_lines in all_res_per_grouping_key.items():
rslt.append((grouping_key, build_result_dict(report, query_res_lines)))
return rslt

View file

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

View file

@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
{
"name": "Change default when adding follower",
"version": "17.0.0.0.1",
"category": "Extra Tools",
'summary': 'Change default when adding follower',
"description": """
Change default when adding follower
Send mail false by default
""",
"author": "Bemade",
'website': 'https://www.bemade.org',
"depends": [
'mail',
],
"data": [
],
"auto_install": False,
"installable": True,
'license': 'OPL-1'
}

View file

@ -1,87 +0,0 @@
# Migration vers Odoo 18.0 - bemade_add_follower_no_sendmail_default
## Description du module
Ce module modifie le comportement par défaut du wizard d'ajout de followers pour que l'option "Envoyer un email" soit désactivée par défaut.
## Analyse technique
- Dépendances : mail
- Modèles modifiés :
- mail.wizard.invite : Modification de la valeur par défaut du champ send_mail à False
- Implémentation actuelle :
- Hérite de mail.wizard.invite
- Redéfinit uniquement le champ send_mail avec default=False
## Alternatives Natives
### Configuration Système
1. Vérifier dans Odoo 18.0 :
- Paramètres de configuration du module mail
- Paramètres système (ir.config_parameter)
- Préférences utilisateur
### Approches Alternatives
1. Configuration par utilisateur :
- Ajouter une préférence utilisateur dans res.users
- Utiliser cette préférence comme valeur par défaut
2. Configuration par type de document :
- Ajouter un paramètre dans les paramètres de notification par modèle
- Permettre une configuration plus granulaire
## Recommandations pour la Migration
### Approche "Vanilla First"
1. Évaluer les alternatives natives :
- [X] Vérifier si Odoo 18.0 a ajouté une configuration similaire
- [ ] Explorer les nouvelles fonctionnalités de notification
- [ ] Vérifier les paramètres de notification par défaut
2. Si aucune alternative native n'existe :
- [ ] Considérer l'ajout d'une configuration système
- [ ] Implémenter une solution plus flexible (par utilisateur ou par type de document)
### Modifications Techniques
1. Si le module est conservé :
- [ ] Mettre à jour la version dans __manifest__.py
- [ ] Vérifier la compatibilité de l'héritage du wizard
- [ ] Vérifier si le champ send_mail existe toujours et a le même comportement
- [ ] Adapter le code aux nouvelles conventions Odoo 18.0
2. Si migration vers une solution native :
- [ ] Créer un module de migration pour la transition
- [ ] Migrer les configurations existantes
- [ ] Prévoir un plan de désactivation du module
## Fonctionnalité Native dans Odoo 18.0
✅ La fonctionnalité existe nativement dans Odoo 18.0 !
Dans le modèle `mail.wizard.invite` (`mail/wizard/mail_wizard_invite.py`), le champ `notify` est déjà défini avec `default=False` :
```python
notify = fields.Boolean('Notify Recipients', default=False)
```
## Plan de Migration
### Actions Requises
1. **Désactivation du Module** :
- [ ] Désactiver le module avant la migration vers Odoo 18.0
- [ ] Vérifier qu'aucun autre module ne dépend de celui-ci
- [ ] Informer les utilisateurs que le comportement est maintenant natif
2. **Vérification** :
- [ ] Tester le comportement natif dans Odoo 18.0
- [ ] Confirmer que le comportement par défaut est identique
- [ ] Documenter tout changement d'interface utilisateur
## État de la Migration
🟢 Pas de migration nécessaire - Utiliser la fonctionnalité native
## Notes Importantes
- Le comportement souhaité (notification désactivée par défaut) est maintenant le comportement standard d'Odoo 18.0
- L'interface utilisateur est similaire, utilisant un widget boolean_toggle
- Aucune personnalisation supplémentaire n'est nécessaire
## Prochaines Étapes
1. Planifier la désactivation du module
2. Informer les utilisateurs du changement
3. Retirer le module de la liste des dépendances des autres modules si nécessaire

View file

@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
from . import mail_wizard_invite

View file

@ -1,11 +0,0 @@
# Copyright Bemade.org
from odoo import models, fields, api
class MailWizardInviteDefault(models.TransientModel):
_inherit = 'mail.wizard.invite'
send_mail = fields.Boolean(
default=False,
help="If true, an invitation email will be sent to the recipient"
)

View file

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

View file

@ -1,32 +0,0 @@
#
# 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': '17.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

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

View file

@ -1,65 +0,0 @@
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

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

View file

@ -1,17 +0,0 @@
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

@ -1,112 +0,0 @@
<?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,4 +0,0 @@
# Copyright (C) 2023 Bemade.org
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import models

View file

@ -1,20 +0,0 @@
# -*- coding: utf-8 -*-
{
"name": "Fetchmail Only on production environment",
"version": "17.0.0.0.1",
"category": "Extra Tools",
'summary': 'Fetchmail Only on production environment',
"description": """
Fetchmail Only on production environment
""",
"author": "Bemade",
'website': 'https://www.bemade.org',
"depends": [
'mail',
],
"data": [
],
"auto_install": True,
"installable": True,
'license': 'OPL-1'
}

View file

@ -1,31 +0,0 @@
# Migration vers Odoo 18.0 - bemade_fetchmail_only_production
## Description
Module restreignant la récupération des emails uniquement en environnement de production
## Fonctionnalités Ajoutées
- Désactivation de fetchmail dans les environnements de test et de développement
- Configuration par base de données
- Journalisation des tentatives de récupération
## Modèles et Champs Modifiés
- fetchmail.server
- Ajout du champ production_only (boolean)
- Ajout du champ last_attempt (datetime)
## Statut Migration
- [ ] A migrer
- [ ] En cours
- [ ] Migré
## Détails Migration
- Vérifier si la fonctionnalité existe déjà dans Odoo 18.0
- Analyser les impacts sur les workflows existants
## Actions Requises
- [ ] Vérifier la compatibilité avec Odoo 18.0
- [ ] Tester les fonctionnalités
- [ ] Mettre à jour la documentation
## Notes
- Ce module pourrait être remplacé par une configuration native dans Odoo 18.0

View file

@ -1,4 +0,0 @@
# Copyright (C) 2023 Bemade.org
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import fetchmail_server

View file

@ -1,23 +0,0 @@
# Copyright (C) 2023 Bemade.org
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
# Import the required classes and decorators from Odoo
from odoo import api, models
from urllib.parse import urlparse
_logger = logging.getLogger(__name__)
class fetchmail_server(models.Model):
_inherit = 'fetchmail.server'
@api.model
def fetch_mail(self):
if urlparse(self.env['ir.config_parameter'].sudo().get_param('web.base.url')).netloc == \
urlparse('https://erp.durpro.com/').netloc:
return super(fetchmail_server, self).fetch_mail()
else:
# Add log message
_logger.info("Trying to fetch email, current URL don't match with production URL, so we don't fetch email")
return True

View file

@ -1,10 +0,0 @@
15.0.0.0.1
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Initial release
16.0.0.0.1
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Modification for V16 compatibility

View file

@ -1,33 +0,0 @@
#
# Bemade Inc.
#
# Copyright (C) 2023-June 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': 'Fix Quality Worksheet',
'version': '17.0.1.0.0',
'summary': 'Fix Quality worksheet bug from Odoo Enterprise',
'description': '',
'category': 'Quality Control',
'author': 'Bemade Inc.',
'website': 'http://www.bemade.org',
'license': 'OPL-1',
'depends': ['quality_control'],
'data': ['reports/worksheet_custom_report_templates.xml'],
'assets': {},
'installable': True,
'auto_install': True,
}

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="worksheet_page" inherit_id="quality_control.worksheet_page">
<xpath expr="//span[@t-field='doc.result']" position="replace">
<span t-field="doc.measure"/>
</xpath>
</template>
</odoo>

View file

@ -1,3 +0,0 @@
from . import models
from . import reports
from . import wizard

View file

@ -1,70 +0,0 @@
######################################################################################
#
# 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": "Improved Field Service Management",
"version": "17.0.0.4.2",
"summary": (
"Adds functionality necessary for managing field service operations at Durpro."
),
"category": "Services/Field Service",
"author": "Bemade Inc.",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": [
"sale_stock",
"sale_project",
"account",
"project_enterprise",
"industry_fsm_stock",
"industry_fsm_report",
"industry_fsm_sale_report",
"fsm_equipment",
"bemade_partner_root_ancestor",
"mail",
],
"data": [
"data/fsm_data.xml",
"views/task_template_views.xml",
"security/ir.model.access.csv",
"views/product_views.xml",
"views/res_partner.xml",
"views/menus.xml",
"views/task_views.xml",
"views/sale_order_views.xml",
"reports/worksheet_custom_report_templates.xml",
"reports/worksheet_custom_reports.xml",
"wizard/new_task_from_template.xml",
"wizard/res_config_settings.xml",
],
"assets": {
"web.report_assets_common": ["bemade_fsm/static/src/scss/bemade_fsm.scss"],
"web.assets_backend": [
# BV: need to readd these files
# 'bemade_fsm/static/src/js/kanban_view.js',
# 'bemade_fsm/static/src/js/list_view.js',
],
"web.assets_qweb": [
"bemade_fsm/static/src/xml/project_view_buttons.xml",
],
},
"installable": True,
"auto_install": False,
}

View file

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="planning_project_stage_waiting_parts" model="project.task.type">
<field name="sequence">2</field>
<field name="name">Waiting on Parts</field>
<!-- BV: legend_blocked n'existe plus -->
<!-- BV: <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>
<!-- BV: <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>
<!-- BV: <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>
</odoo>

File diff suppressed because it is too large Load diff

View file

@ -1,369 +0,0 @@
# Improved Field Service Management - Migration vers Odoo 18.0
## Description
Ce module étend les fonctionnalités de gestion des services sur site (Field Service Management) avec des fonctionnalités spécifiques à Durpro.
## Fonctionnalités Ajoutées
### Gestion améliorée des services sur site
- **Existe dans Odoo 18.0 ?** : Partiellement
- **Différences avec la version native** :
- Gestion avancée des équipements et des contacts
- Système de modèles de tâches personnalisé
- Intégration approfondie avec les commandes de vente
- Gestion des visites FSM
- Propagation des affectations et des contacts
- **Alternatives** :
- Utiliser le module FSM standard d'Odoo
- Implémenter des fonctionnalités spécifiques via des modules personnalisés
## Modèles et Champs Modifiés
### Modèles impactés
- **project.task** :
- **Champs ajoutés** :
- work_order_contacts : Contacts liés au bon de travail
- site_contacts : Contacts sur site
- visit_id : Lien vers la visite FSM
- relevant_order_lines : Lignes de commande pertinentes
- work_order_number : Numéro de bon de travail
- propagate_assignment : Propagation des affectations
- is_closed : Indicateur de tâche fermée
- root_ancestor : Tâche racine de la hiérarchie
- **Méthodes modifiées** :
- create() : Gestion des contacts et numéros de bon de travail
- write() : Propagation des modifications aux sous-tâches
- _compute_allow_billable() : Calcul de la facturabilité
- _fsm_create_sale_order_line() : Création de lignes de commande
- action_fsm_validate() : Validation des tâches FSM
- synchronize_name_fsm() : Synchronisation des noms des tâches
- **Recommandations de migration** :
- Vérifier la compatibilité avec le nouveau système de tâches Odoo 18
- Tester la propagation des modifications
- Adapter les calculs de facturabilité
- Vérifier la gestion des noms des tâches
- **task.template** :
- **Nouveau modèle** : project.task.template
- **Champs principaux** :
- name : Nom du modèle
- description : Description HTML
- assignees : Utilisateurs assignés par défaut
- customer : Client par défaut
- project : Projet par défaut
- tags : Tags par défaut
- parent : Modèle parent
- subtasks : Sous-tâches
- sequence : Ordre d'affichage
- company_id : Société
- planned_hours : Heures planifiées
- equipment_ids : Équipements à entretenir
- **Méthodes principales** :
- _prepare_new_task_values_from_self() : Prépare les valeurs pour une nouvelle tâche
- create_task_from_self() : Crée une tâche à partir du modèle
- **Recommandations de migration** :
- Vérifier la compatibilité avec le nouveau système de modèles de tâches Odoo 18
- Tester la création de tâches à partir des modèles
- Adapter la gestion des équipements et des heures planifiées
- **product.template** :
- **Champs ajoutés** :
- task_template_id : Modèle de tâche associé
- is_field_service : Indicateur de service sur site
- **Recommandations de migration** :
- Vérifier la compatibilité avec le nouveau système de produits Odoo 18
- Tester la gestion des modèles de tâches
- Adapter l'indicateur de service sur site
- **res.partner** :
- **Champs ajoutés** :
- is_site_contact : Indicateur de contact sur site
- is_service_site : Indicateur de site de service
- site_ids : Sites de travail liés
- site_contacts : Contacts sur site
- work_order_contacts : Destinataires des bons de travail
- **Méthodes modifiées** :
- _compute_is_site_contact() : Calcul de l'état de contact sur site
- _search_is_site_contact() : Recherche des contacts sur site
- _compute_is_service_site() : Calcul de l'état de site de service
- **Recommandations de migration** :
- Vérifier la compatibilité avec le nouveau système de partenaires Odoo 18
- Tester les calculs des indicateurs
- Adapter la gestion des relations entre sites et contacts
- **sale.order** :
- **Champs ajoutés** :
- valid_equipment_ids : Équipements valides
- default_equipment_ids : Équipements par défaut à entretenir
- summary_equipment_ids : Équipements en cours de maintenance
- site_contacts : Contacts sur site
- work_order_contacts : Destinataires du bon de travail
- visit_ids : Visites FSM liées
- is_fsm : Indicateur de commande FSM
- **Méthodes modifiées** :
- get_relevant_order_lines() : Récupère les lignes pertinentes pour une tâche
- _compute_summary_equipment_ids() : Calcule les équipements en maintenance
- _onchange_partner_shipping_id() : Gère les changements de partenaire
- _compute_default_contacts() : Calcule les contacts par défaut
- _compute_default_equipment() : Calcule les équipements par défaut
- copy() : Gère la copie des visites
- _create_default_visit() : Crée une visite par défaut
- _create_or_organize_visits_if_needed() : Organise les visites FSM
- action_confirm() : Confirmation de commande avec gestion FSM
- write() : Gère les mises à jour des partenaires
- **Recommandations de migration** :
- Vérifier la compatibilité avec le nouveau système de commandes Odoo 18
- Tester la gestion des équipements
- Adapter la gestion des visites FSM
- Vérifier les calculs de contacts et d'équipements
## Vues à modifier
- **Vues existantes** :
- Formulaire de tâche :
- Ajout d'une page "Equipment and Contacts"
- Modification des boutons de validation
- Ajout du champ propagate_assignment
- Vue liste :
- Ajout du champ work_order_number
- Masquage de certains champs optionnels
- Vue calendrier :
- Personnalisation des couleurs par utilisateur
- Suppression du champ worksheet_template_id
- Vue de recherche :
- Modification des filtres de planification
- Ajout du filtre "Parent Task"
- **Nouvelles vues** :
- Aucune nouvelle vue créée, uniquement des modifications des vues existantes
- **Recommandations pour Odoo 18** :
- Vérifier la compatibilité avec les nouvelles vues Odoo 18
- Adapter les modifications de vues aux nouveaux designs
- Tester les fonctionnalités de recherche et de filtrage
- Vérifier la gestion des couleurs dans le calendrier
## Rapports
- **Rapports personnalisés** :
- **Nouveaux blocs de rapport** :
- Tableau des matériaux
- Tableau des temps et matériaux avec tarification
- Liste des sous-tâches
- Bloc d'informations sur la commande
- Résumé des équipements
- Entrées de feuille de temps
- Bloc de signature
- **Modifications principales** :
- Intégration des contacts et informations client
- Affichage des dates planifiées
- Gestion des signatures numériques
- Personnalisation de la mise en page
- **Recommandations pour Odoo 18** :
- Vérifier la compatibilité avec le nouveau système de rapports Odoo 18
- Adapter les modèles de rapport aux nouvelles fonctionnalités
- Tester l'affichage des différents blocs
- Vérifier la gestion des signatures numériques
## Assistants (Wizards)
- **Nouveaux assistants** : À documenter
## Analyse des Alternatives Natives Odoo 18.0
### Fonctionnalités Natives à Explorer
1. **Gestion des Services** :
- Module Industry FSM d'Odoo Enterprise
- Nouvelles fonctionnalités de planification
- Système de rapports amélioré
2. **Gestion des Équipements** :
- Module Maintenance d'Odoo
- Intégration avec FSM
- Système de suivi des équipements
3. **Gestion des Contacts** :
- Système de contacts hiérarchiques
- Gestion des rôles des contacts
- Système d'adresses de livraison
### Approche "Vanilla First"
1. **Fonctionnalités à Conserver en Custom** :
- Gestion spécifique des visites FSM
- Propagation des affectations
- Modèles de tâches personnalisés
- Gestion avancée des contacts sur site
2. **Fonctionnalités à Migrer vers Native** :
- Utiliser le système de planification natif
- Adopter le système de rapports standard
- Utiliser la gestion des équipements native
- Intégrer avec le système de contacts standard
## Plan de Migration
### Phase 1 : Analyse et Préparation
1. **Audit des Fonctionnalités** :
- [ ] Identifier les fonctionnalités disponibles nativement
- [ ] Lister les gaps fonctionnels
- [ ] Évaluer l'impact sur les processus existants
2. **Planification** :
- [ ] Définir la stratégie de migration
- [ ] Établir un calendrier
- [ ] Identifier les risques
### Phase 2 : Migration Technique
1. **Adaptation du Code** :
- [ ] Mettre à jour les vues (tree -> list)
- [ ] Supprimer les attrs obsolètes
- [ ] Adapter les méthodes aux nouvelles API
2. **Intégration Native** :
- [ ] Intégrer avec Industry FSM
- [ ] Connecter avec le module Maintenance
- [ ] Adapter le système de contacts
### Phase 3 : Tests et Validation
1. **Tests Fonctionnels** :
- [ ] Validation des workflows
- [ ] Tests des rapports
- [ ] Vérification des intégrations
2. **Tests de Performance** :
- [ ] Analyse des requêtes SQL
- [ ] Tests de charge
- [ ] Optimisation si nécessaire
## Recommandations Spécifiques
### Modèles et Champs
1. **project.task** :
- Utiliser les champs natifs quand possible
- Conserver uniquement les champs spécifiques
- Adapter les méthodes aux nouvelles API
2. **task.template** :
- Évaluer le système de modèles natif
- Simplifier la structure si possible
- Optimiser la création de tâches
3. **sale.order** :
- Utiliser les fonctionnalités FSM natives
- Optimiser la gestion des visites
- Simplifier les calculs
### Vues et Interface
1. **Modifications Prioritaires** :
- Remplacer tree par list
- Supprimer les attrs obsolètes
- Adapter aux nouveaux standards UI
2. **Améliorations Suggérées** :
- Utiliser les nouveaux widgets
- Simplifier les vues
- Améliorer l'expérience utilisateur
### Rapports
1. **Stratégie de Migration** :
- Utiliser le nouveau système de rapports
- Adapter les modèles existants
- Optimiser le rendu
## État de la Migration
⚪ En analyse préliminaire
## Notes Importantes
- Module complexe nécessitant une approche progressive
- Forte dépendance avec d'autres modules
- Impact important sur les processus métier
- Nécessité de formation des utilisateurs
## Prochaines Étapes
1. Valider l'approche avec les parties prenantes
2. Créer un environnement de test
3. Commencer par les fonctionnalités critiques
4. Planifier la formation des utilisateurs
## Analyse Technique
### Fonctionnalités Natives dans Odoo 18.0
Le module `industry_fsm` d'Odoo Enterprise 18.0 inclut déjà plusieurs fonctionnalités avancées :
1. **Gestion des Tâches FSM**
- Champ `is_fsm` sur les projets pour identifier les projets FSM
- Champ `fsm_done` sur les tâches pour marquer leur complétion
- Gestion des signatures sur les rapports de travail
- Gestion des coordonnées client (téléphone, adresse, etc.)
- Planification avec dates de début/fin
2. **Fonctionnalités de Base**
- Vue spécifique pour les travailleurs sur le terrain
- Rapports sur les tâches
- Intégration avec les feuilles de temps
- Géolocalisation des clients
- Gestion des produits sur les tâches
3. **Sécurité et Contraintes**
- Règles de sécurité spécifiques FSM
- Contraintes sur les projets FSM (company_id requis)
- Restrictions sur les dépendances de tâches et les jalons
### Différences avec Notre Module
1. **Fonctionnalités à Migrer**
- [ ] Fonctionnalités spécifiques de gestion d'équipement
- [ ] Workflows personnalisés
- [ ] Rapports et analyses spécifiques
- [ ] Intégrations avec d'autres modules custom
2. **Fonctionnalités à Adapter**
- [ ] Utiliser les champs natifs plutôt que nos champs customs
- [ ] Adapter nos vues aux nouvelles conventions Odoo 18.0
- [ ] Intégrer nos processus avec le système natif
## Plan de Migration
### Phase 1 : Préparation
1. **Analyse des Données**
- [ ] Identifier les données spécifiques à notre module
- [ ] Mapper les champs actuels vers les champs natifs
- [ ] Lister les fonctionnalités uniques à préserver
2. **Configuration**
- [ ] Activer et configurer le module `industry_fsm`
- [ ] Vérifier les dépendances et les conflits
- [ ] Préparer les scripts de migration des données
### Phase 2 : Migration
1. **Migration des Données**
- [ ] Transférer les données vers les structures natives
- [ ] Adapter les configurations existantes
- [ ] Mettre à jour les vues et rapports
2. **Développement**
- [ ] Adapter le code pour utiliser l'API Odoo 18.0
- [ ] Implémenter les fonctionnalités manquantes
- [ ] Mettre à jour les vues XML (plus d'attrs, list au lieu de tree)
### Phase 3 : Tests
1. **Validation Fonctionnelle**
- [ ] Tester les fonctionnalités de base FSM
- [ ] Vérifier nos fonctionnalités spécifiques
- [ ] Valider les workflows
2. **Tests d'Intégration**
- [ ] Tester avec les autres modules
- [ ] Vérifier la compatibilité mobile
- [ ] Valider les performances
## État de la Migration
🟡 En cours d'analyse - Utilisation maximale des fonctionnalités natives
## Notes Importantes
- Le module `industry_fsm` d'Odoo Enterprise offre une base solide
- Plusieurs de nos fonctionnalités peuvent être remplacées par des fonctionnalités natives
- Certaines personnalisations spécifiques devront être maintenues
- La nouvelle interface utilisateur nécessitera une formation des utilisateurs
## Prochaines Étapes
1. Valider l'approche avec l'équipe
2. Créer les scripts de migration des données
3. Développer les fonctionnalités manquantes
4. Planifier la formation des utilisateurs
## Notes de Version
- Version originale: 17.0.1.0.0
- Dernière analyse: 26/01/2025

View file

@ -1,81 +0,0 @@
from odoo import api, SUPERUSER_ID
import logging
from openupgradelib.openupgrade import update_module_moved_models
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""In this version, we separate the bemade_fsm.equipment and its associated models
out into a new module named fsm_equipment. We need to move those models and rename
some fields."""
env = api.Environment(cr, SUPERUSER_ID, {})
_logger.info("Moving FSM equipment...")
# Move the old equipment over to the new table
cr.execute(
"""
INSERT INTO fsm_equipment
(id, code, name, description, partner_id, location_notes, active)
SELECT id, pid_tag code, name, description, partner_location_id, location_notes,
active
FROM bemade_fsm_equipment
"""
)
_logger.info("Moving FSM equipment tags...")
# Move the tags
cr.execute(
"""
INSERT INTO fsm_equipment_tag (id, name, color)
SELECT id, name, color FROM bemade_fsm_equipment_tag
"""
)
_logger.info("Re-creating equipment to tag relations.")
# Add the relations
# Schema | Name | Type | Owner
# --------+---------------------------------------------------+-------+-------
# public | bemade_fsm_equipment_bemade_fsm_equipment_tag_rel | table | odoo
# public | bemade_fsm_equipment_sale_order_line_rel | table | odoo
# public | bemade_fsm_equipment_sale_order_rel | table | odoo
# public | bemade_fsm_task_equipment_rel | table | odoo
# public | bemade_fsm_task_template_equipment_rel | table | odoo
# public | fsm_equipment_fsm_equipment_tag_rel | table | odoo
# public | fsm_equipment_sale_order_rel | table | odoo
# public | fsm_task_equipment_rel | table | odoo
cr.execute(
"""
INSERT INTO fsm_task_equipment_rel (equipment_id, task_id)
SELECT equipment_id, task_id from bemade_fsm_task_equipment_rel
"""
)
cr.execute(
"""
INSERT INTO fsm_equipment_fsm_equipment_tag_rel (fsm_equipment_id, fsm_equipment_tag_id)
SELECT bemade_fsm_equipment_id, bemade_fsm_equipment_tag_id
FROM bemade_fsm_equipment_bemade_fsm_equipment_tag_rel
"""
)
# Clean up
_logger.info("Deleting menu items.")
cr.execute(
"""
DELETE FROM ir_ui_menu WHERE id in (
SELECT res_id from ir_model_data where model='ir.ui.menu'
and module='bemade_fsm'
)
"""
)
cr.execute(
"DELETE FROM ir_model_data where model='ir.ui.menu' and module='bemade_fsm'"
)
cr.execute("DELETE FROM ir_model_fields where model ilike 'bemade_fsm.equipment%'")
cr.execute(
"DELETE FROM ir_model WHERE name->>'en_US' ilike 'bemade_fsm.equipment%'"
)

View file

@ -1,9 +0,0 @@
from . import task_template
from . import product_template
from . import sale_order_line
from . import sale_order
from . import task
from . import res_partner
from . import fsm_visit
from . import res_company
from . import project

View file

@ -1,104 +0,0 @@
from odoo import models, fields, api
class FSMVisit(models.Model):
_name = "bemade_fsm.visit"
_description = "Represents a single visit by assigned service personnel."
label = fields.Text(
string="Label",
required=True,
related="so_section_id.name",
readonly=False,
copy=True,
)
approx_date = fields.Date(string="Approximate Date", copy=False)
so_section_id = fields.Many2one(
comodel_name="sale.order.line",
string="Sale Order Section",
help=(
"The section on the sale order that represents the labour and parts for"
" this visit"
),
ondelete="cascade",
)
sale_order_id = fields.Many2one(
comodel_name="sale.order",
string="Sales Order",
readonly=True,
)
is_completed = fields.Boolean(
string="Completed", related="so_section_id.is_fully_delivered"
)
is_invoiced = fields.Boolean(
string="Invoiced", related="so_section_id.is_fully_delivered_and_invoiced"
)
summarized_equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
string="Equipment to Service",
compute="_compute_summarized_equipment_ids",
)
task_id = fields.Many2one(
comodel_name="project.task",
compute="_compute_task_id",
string="Service Visit",
)
task_ids = fields.One2many(comodel_name="project.task", inverse_name="visit_id")
visit_no = fields.Integer(
compute="_compute_visit_no",
)
@api.depends("task_ids")
def _compute_task_id(self):
for rec in self:
rec.task_id = rec.task_ids and rec.task_ids[0]
@api.depends("so_section_id", "sale_order_id.summary_equipment_ids")
def _compute_summarized_equipment_ids(self):
for rec in self:
lines = rec.so_section_id.get_section_line_ids()
equipment_ids = []
for line in lines:
for equipment in line.equipment_ids:
equipment_ids.append(equipment)
rec.summarized_equipment_ids = equipment_ids
@api.model_create_multi
def create(self, vals_list):
recs = super().create(vals_list)
for i, rec in enumerate(recs.filtered(lambda visit: not visit.so_section_id)):
rec.so_section_id = rec.env["sale.order.line"].create(
{
"order_id": rec.sale_order_id.id,
"display_type": "line_section",
"name": vals_list[i].get("label", False),
}
)
return recs
@api.depends(
"so_section_id",
"sale_order_id",
"sale_order_id.visit_ids",
"sale_order_id.visit_ids.so_section_id",
"sale_order_id.visit_ids.so_section_id.sequence",
)
def _compute_visit_no(self):
for rec in self:
ordered_visit_lines = self.sale_order_id.visit_ids.so_section_id.sorted(
"sequence"
)
# Just a straight O(n) search here since n will always be relatively small
for index, line in enumerate(ordered_visit_lines):
if line == rec.so_section_id:
rec.visit_no = index + 1
return

View file

@ -1,19 +0,0 @@
from odoo import fields, models
class ProductTemplate(models.Model):
_inherit = "product.template"
task_template_id = fields.Many2one(
comodel_name="project.task.template",
string="Task Template",
ondelete="restrict",
)
is_field_service = fields.Boolean(
string="Plan as field service",
help=(
"Products planned as field service will have travel time considered in"
" planning."
),
)

View file

@ -1,13 +0,0 @@
from odoo import models
class Project(models.Model):
_inherit = "project.project"
def _fetch_sale_order_item_ids(
self, domain_per_model=None, limit=None, offset=None
):
# Override to flush the ORM cache to the database prior to running the query
# Temporary fix until Odoo fixes this method (PR #160067 submitted for this)
self.env.flush_all()
return super()._fetch_sale_order_item_ids(domain_per_model, limit, offset)

View file

@ -1,8 +0,0 @@
from odoo import models, fields
class Company(models.Model):
_inherit = "res.company"
split_time_from_materials_on_service_work_orders = fields.Boolean(default=False)
create_default_fsm_visit = fields.Boolean(default=False)

View file

@ -1,61 +0,0 @@
from odoo import api, fields, models
class Partner(models.Model):
_inherit = "res.partner"
is_site_contact = fields.Boolean(
string="Is a site contact",
compute="_compute_is_site_contact",
search="_search_is_site_contact",
)
is_service_site = fields.Boolean(
help="A partner is a service site if they have one or more equipments, "
"site contacts or work order contacts."
)
site_ids = fields.Many2many(
string="Work Sites",
comodel_name="res.partner",
relation="res_partner_site_contact_rel",
column1="site_contact_id",
column2="site_id",
tracking=True,
domain=[("is_service_site", "=", True)],
)
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="res_partner_site_contact_rel",
column1="site_id",
column2="site_contact_id",
domain=[("is_service_site", "=", False)],
tracking=True,
)
work_order_contacts = fields.Many2many(
string="Work Order Recipients",
comodel_name="res.partner",
relation="res_partner_work_order_contacts_rel",
column1="res_partner_id",
column2="work_order_contact_id",
domain=[("is_service_site", "=", False)],
tracking=True,
)
@api.depends("site_ids")
def _compute_is_site_contact(self):
for rec in self:
rec.is_site_contact = rec.site_ids
@api.model
def _search_is_site_contact(self, operator, value):
return [("site_ids", "!=", False)]
@api.depends("equipment_ids", "site_contacts", "work_order_contacts")
def _compute_is_service_site(self):
for rec in self:
rec.is_service_site = (
rec.equipment_ids or rec.site_contacts or rec.work_order_contacts
)

View file

@ -1,163 +0,0 @@
from odoo import fields, models, api, _, Command
class SaleOrder(models.Model):
_inherit = "sale.order"
valid_equipment_ids = fields.One2many(
comodel_name="fsm.equipment", related="partner_id.owned_equipment_ids"
)
default_equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
string="Default Equipment to Service",
help="The default equipment to service for new sale order lines.",
compute="_compute_default_equipment",
inverse="_inverse_default_equipment",
store=True,
)
summary_equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
string="Equipment Being Serviced",
compute="_compute_summary_equipment_ids",
)
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="sale_order_site_contacts_rel",
compute="_compute_default_contacts",
inverse="_inverse_default_contacts",
store=True,
)
work_order_contacts = fields.Many2many(
comodel_name="res.partner",
relation="sale_order_work_order_contacts_rel",
compute="_compute_default_contacts",
inverse="_inverse_default_contacts",
string="Work Order Recipients",
store=True,
)
visit_ids = fields.One2many(
comodel_name="bemade_fsm.visit", inverse_name="sale_order_id", readonly=False
)
is_fsm = fields.Boolean(
compute="_compute_is_fsm",
string="Is FSM",
store=True,
)
@api.depends("order_line.task_id")
def get_relevant_order_lines(self, task_id):
self.ensure_one()
linked_lines = self.order_line.filtered(
lambda line: line.task_id == task_id
or line == task_id.visit_id.so_section_id
)
visit_lines = linked_lines.filtered(lambda line: line.visit_id)
for line in visit_lines:
linked_lines |= line.get_section_line_ids()
return linked_lines
@api.depends("order_line.equipment_ids")
def _compute_summary_equipment_ids(self):
for rec in self:
rec.summary_equipment_ids = rec.order_line.mapped("equipment_ids")
@api.onchange("partner_shipping_id")
def _onchange_partner_shipping_id(self):
res = super()._onchange_partner_shipping_id()
self._compute_default_equipment()
self._compute_default_contacts()
return res
@api.depends("partner_shipping_id")
def _compute_default_contacts(self):
for rec in self:
rec.site_contacts = rec.partner_shipping_id.site_contacts
rec.work_order_contacts = rec.partner_shipping_id.work_order_contacts
def _inverse_default_contacts(self):
pass
@api.depends(
"partner_id",
"partner_shipping_id",
"partner_shipping_id.equipment_ids",
"partner_id.owned_equipment_ids",
)
def _compute_default_equipment(self):
for rec in self:
if rec.partner_shipping_id.equipment_ids:
ids = rec.partner_shipping_id.equipment_ids
else:
ids = rec.partner_id.owned_equipment_ids
rec.default_equipment_ids = ids if len(ids) < 4 else False
def _inverse_default_equipment(self):
pass
def copy(self, default=None):
rec = super().copy(default)
rec.visit_ids = [Command.set(rec.order_line.visit_ids.ids)]
return rec
def _create_default_visit(self):
"""Called when an order is confirmed with lines that will create an FSM task,
in order to make sure there is a visit line grouping all the service being done.
"""
self.ensure_one()
visit = self.env["bemade_fsm.visit"].create(
{
"label": _("Service Visit"),
"sale_order_id": self.id,
}
)
# Make sure it goes to the top of the list
visit.so_section_id.sequence = 0
def _create_or_organize_visits_if_needed(self):
"""Adds a visit line to the top of the order if there are not already visit
lines for an order with lines that will create an FSM task."""
for order in self.filtered("company_id.create_default_fsm_visit"):
if not order.visit_ids and order.is_fsm:
order._create_default_visit()
if order.is_fsm:
# Make sure that all the lines producing FSM tasks are under a visit
visit_line_ids = (
order.mapped("visit_ids")
.mapped("so_section_id")
.mapped("section_line_ids")
)
if any(
[
True
for line in order.order_line.filtered(
lambda line: not line.display_type
)
if line not in visit_line_ids
]
):
# If not, promote the first visit to the top of the order items list
for line in order.order_line:
line.sequence += 1
order.mapped("visit_ids").mapped("so_section_id")[0].sequence = 0
@api.depends("order_line.is_fsm")
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = any([line.is_fsm for line in rec.order_line])
def action_confirm(self):
self._create_or_organize_visits_if_needed()
return super().action_confirm()
def write(self, values):
res = super().write(values)
if "partner_shipping_id" in values:
for rec in self:
rec.tasks_ids.write({"partner_id": rec.partner_shipping_id.id})
return res

View file

@ -1,294 +0,0 @@
from odoo import fields, models, api, _, Command
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
valid_equipment_ids = fields.One2many(
comodel_name="fsm.equipment", related="order_id.valid_equipment_ids"
)
visit_ids = fields.One2many(
comodel_name="bemade_fsm.visit",
inverse_name="so_section_id",
string="Visits",
copy=True,
)
visit_id = fields.Many2one(
comodel_name="bemade_fsm.visit",
compute="_compute_visit_id",
string="Visit",
ondelete="cascade",
store=True,
)
is_fully_delivered = fields.Boolean(
string="Fully Delivered",
compute="_compute_is_fully_delivered",
help=(
"Indicates whether a line or all the lines in a section have been entirely"
" delivered."
),
)
is_fully_delivered_and_invoiced = fields.Boolean(
string="Fully Invoiced",
compute="_compute_is_fully_invoiced",
help=(
"Indicates whether a line or all the lines in a section have been entirely"
" delivered and invoiced."
),
)
equipment_ids = fields.Many2many(
string="Equipment to Service",
comodel_name="fsm.equipment",
relation="bemade_fsm_equipment_sale_order_line_rel",
column1="sale_order_line_id",
column2="equipment_id",
)
is_field_service = fields.Boolean(compute="_compute_is_field_service", store=True)
is_fsm = fields.Boolean(
string="Is FSM",
compute="_compute_is_fsm",
store=True,
)
section_line_ids = fields.One2many(
comodel_name="sale.order.line",
compute="_compute_section_line_ids",
)
@api.depends("visit_ids")
def _compute_visit_id(self):
for rec in self:
rec.visit_id = rec.visit_ids and rec.visit_ids[0]
@api.depends("product_id")
def _compute_is_field_service(self):
for rec in self:
rec.is_field_service = rec.product_id.is_field_service
@api.model_create_multi
def create(self, vals):
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 copy_data(self, default=None):
if default is None:
default = {}
if "visit_ids" not in default:
default["visit_ids"] = [
(0, 0, visit.copy_data()[0]) for visit in self.visit_ids
]
return super().copy_data(default)
def _timesheet_create_task(self, project):
"""Generate task for the given so line, and link it.
:param project: record of project.project in which the task should be created
:return task: record of the created task
Override to add the logic needed to implement task templates and equipment linkages.
"""
def _create_task_from_template(project, template, parent):
"""Recursively generates the task and any subtasks from a project.task.template.
:param project: project.project record to set on the task's project_id field.
:param template: project.task.template to use to create the task.
:param parent: project.task to set as the parent to this task.
"""
values = _timesheet_create_task_prepare_values_from_template(
project, template, parent
)
task = self.env["project.task"].sudo().create(values)
subtasks = []
for t in template.subtasks:
subtask = _create_task_from_template(project, t, task)
subtasks.append(subtask)
# 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["allocated_hours"] = template.planned_hours
vals["sequence"] = template.sequence
vals["partner_id"] = self.order_id.partner_id.id
if template.equipment_ids:
vals["equipment_ids"] = template.equipment_ids.ids
return vals
tmpl = self.product_id.task_template_id
if not tmpl:
task = super()._timesheet_create_task(project)
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=%(so_id)d>%(so_name)s</a> (%(product_name)s)"
) % {
"so_id": self.order_id.id,
"so_name": self.order_id.name,
"product_name": self.product_id.name,
}
task.message_post(body=task_msg)
if not task.equipment_ids and self.equipment_ids:
task.equipment_ids = self.equipment_ids.ids
return task
def _timesheet_service_generation(self):
super()._timesheet_service_generation()
visit_lines = self.filtered(lambda line: line.visit_id)
for index, line in enumerate(visit_lines):
task_ids = line.get_section_line_ids().mapped("task_id")
if not task_ids:
continue
if len(set([task.project_id for task in task_ids])) > 1:
# Can't group up the tasks if they're part of different projects
return
project_id = task_ids[0].project_id
line.visit_id.task_id = line._generate_task_for_visit_line(
project_id, index + 1, sum(task_ids.mapped("allocated_hours"))
)
task_ids.write({"parent_id": line.visit_id.task_id.id})
(self.mapped("task_id") | self.visit_ids.task_id).filtered(
"is_fsm"
).synchronize_name_fsm()
def _generate_task_for_visit_line(
self, project, visit_no: int, allocated_hours: int
):
self.ensure_one()
allocated_hours = sum(
self.get_section_line_ids().task_id.mapped("allocated_hours")
)
task = self.env["project.task"].create(
{
"name": (
f"{self.order_id.name} - "
+ _("Visit")
+ f" {visit_no} - {self.name}"
),
"project_id": project.id,
"equipment_ids": (
self.get_section_line_ids().mapped("equipment_ids").ids
),
"sale_order_id": self.order_id.id,
"partner_id": self.order_id.partner_shipping_id.id,
"visit_id": self.visit_id.id,
"allocated_hours": allocated_hours,
"date_deadline": self.visit_id.approx_date,
"user_ids": False, # Force to empty or it uses the current user
}
)
return task
@api.depends(
"order_id.order_line",
"display_type",
"qty_to_deliver",
"order_id.order_line.qty_to_deliver",
"order_id.order_line.display_type",
)
def _compute_is_fully_delivered(self):
for rec in self:
rec.is_fully_delivered = rec._iterate_items_compute_bool(
lambda line: line.qty_to_deliver == 0
)
@api.depends("is_fully_delivered")
def _compute_is_fully_invoiced(self):
for rec in self:
if not rec.is_fully_delivered:
rec.is_fully_delivered_and_invoiced = False
return
rec.is_fully_delivered_and_invoiced = rec._iterate_items_compute_bool(
lambda line: line.qty_to_invoice == 0
)
def get_section_line_ids(self):
"""Return a recordset of sale.order.line records that are in this sale order section."""
self.ensure_one()
assert (
self.display_type == "line_section"
), "Cannot get section lines for a non-section."
found = False
lines = []
for line in self.order_id.order_line.sorted(lambda line: line.sequence):
if line == self:
found = True
continue
if not found:
continue
if line.display_type == "line_section": # Stop when we hit the next section
break
else:
lines.append(line)
return self.env["sale.order.line"].union(*lines)
@api.depends("display_type", "order_id.order_line")
def _compute_section_line_ids(self):
for rec in self:
if rec.display_type == "line_section":
rec.section_line_ids = [Command.set(rec.get_section_line_ids().ids)]
else:
rec.section_line_ids = False
def _iterate_items_compute_bool(self, single_line_func):
if not self.display_type:
return single_line_func(self)
elif self.display_type == "line_note":
return True
else:
for line in self.order_id.order_line:
found = False
if line == self:
found = True
if not found:
continue
if found and line.display_type == "line_section":
return True
val = single_line_func(self)
if not val:
return val
return True
@api.depends("product_id.detailed_type", "product_id.service_tracking")
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = (
rec.product_id.detailed_type == "service"
and rec.product_id.service_tracking == "task_global_project"
)

View file

@ -1,222 +0,0 @@
from odoo import fields, models, api, Command
from odoo.addons.project.models.project_task import CLOSED_STATES
import re
class Task(models.Model):
_inherit = "project.task"
work_order_contacts = fields.Many2many(
comodel_name="res.partner",
relation="task_work_order_contact_rel",
column1="task_id",
column2="res_partner_id",
)
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="task_site_contact_rel",
column1="task_id",
column2="res_partner_id",
)
# Override related field to make it return false if this is an FSM subtask
allow_billable = fields.Boolean(
string="Can be billed",
related=False,
compute="_compute_allow_billable",
store=True,
)
visit_id = fields.Many2one(
comodel_name="bemade_fsm.visit",
string="Visit",
)
relevant_order_lines = fields.Many2many(
comodel_name="sale.order.line",
store=False,
compute="_compute_relevant_order_lines",
)
work_order_number = fields.Char(readonly=True)
propagate_assignment = fields.Boolean(
help="Propagate assignment of this task to all subtasks.",
default=False,
)
is_closed = fields.Boolean(
compute="_compute_is_closed",
)
root_ancestor = fields.Many2one(
comodel_name="project.task",
compute="_compute_root_ancestor",
recursive=True,
)
def _compute_is_closed(self):
for rec in self:
rec.is_closed = rec.state in CLOSED_STATES
@api.model_create_multi
def create(self, vals):
res = super().create(vals)
for rec in res:
if rec.parent_id and rec.is_fsm:
rec.partner_id = rec.parent_id.partner_id
if not rec.work_order_contacts and rec.parent_id:
rec.work_order_contacts = rec.parent_id.work_order_contacts
if not rec.site_contacts and rec.parent_id:
rec.site_contacts = rec.parent_id.site_contacts
if rec.sale_order_id:
seq = 1
prev_seqs = (
self.sale_order_id.tasks_ids
and self.sale_order_id.tasks_ids.mapped("work_order_number")
)
if prev_seqs:
pattern = re.compile(r"(\d+)$")
matches = map(lambda n: pattern.search(n), prev_seqs)
seq += max(map(lambda n: int(n.group(1)) if n else 0, matches))
rec.work_order_number = (
rec.sale_order_id.name.replace("SO", "SVR", 1) + f"-{seq}"
)
# If the task is linked to a sales order and has no parent, it should inherit SO work order contacts
if (
not rec.parent_id
and not rec.work_order_contacts
and rec.sale_order_id.work_order_contacts
):
rec.work_order_contacts = rec.sale_order_id.work_order_contacts
if (
not rec.parent_id
and not rec.site_contacts
and rec.sale_order_id.site_contacts
):
rec.site_contacts = rec.sale_order_id.site_contacts
return res
def write(self, vals):
res = super().write(vals)
if not self: # End recursion on empty RecordSet
return res
if "propagate_assignment" in vals:
# When a user sets propagate assignment, it should propagate that setting all the way down the chain
self.child_ids.write({"propagate_assignment": vals["propagate_assignment"]})
if "user_ids" in vals:
to_propagate = self.filtered(lambda task: task.propagate_assignment)
# Here we use child_ids instead of _get_all_subtasks() so as to allow for setting propagate_assignment
# to false on a child task.
to_propagate.child_ids.write({"user_ids": vals["user_ids"]})
for rec in self:
if rec.child_ids:
child_vals = {}
if "site_contacts" in vals:
child_vals.update(
site_contacts=[Command.set(rec.site_contacts.ids)]
)
if "work_order_contacts" in vals:
child_vals.update(
work_order_contacts=[Command.set(rec.work_order_contacts.ids)]
)
if "partner_id" in vals:
child_vals.update(partner_id=vals["partner_id"])
rec.child_ids.write(child_vals)
return res
@api.depends("sale_order_id")
def _compute_relevant_order_lines(self):
for rec in self:
rec.relevant_order_lines = (
rec.sale_order_id
and rec.sale_order_id.get_relevant_order_lines(rec)
or False
)
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
}
@api.depends("parent_id.visit_id", "project_id.is_fsm", "project_id.allow_billable")
def _compute_allow_billable(self):
for rec in self:
# If an FSM task has a parent that is linked to an SO line, then the parent is the billable one
if (
rec.parent_id
and not rec.parent_id.visit_id
and rec.project_id
and rec.project_id.is_fsm
):
rec.allow_billable = False
else:
rec.allow_billable = rec.project_id.allow_billable
def _fsm_create_sale_order_line(self):
# Override to not generate new lines for tasks that are just linked to a visit item
self.ensure_one()
if self.visit_id:
return
else:
super()._fsm_create_sale_order_line()
def action_fsm_validate(self, stop_running_timers=False):
# Override to close out subtasks as well
all_tasks = self | self._get_all_subtasks()
return super(Task, all_tasks).action_fsm_validate(stop_running_timers)
def _get_full_hierarchy(self):
if self.child_ids:
return self | self.child_ids._get_full_hierarchy()
return self
def synchronize_name_fsm(self):
"""Applies naming to the entire task tree for tasks that are part of this
recordset. Root tasks are named:
Partner Shipping Name - Sale Line Name (Template Name)
Child tasks with sale_line_id are named by their template if set, sale line name
if not.
Child tasks not linked to sale lines are left with their original names."""
all_tasks = self | self._get_all_subtasks()
for rec in all_tasks:
assert rec.is_fsm, "This method should only be called on FSM tasks."
template = rec.sale_line_id and rec.sale_line_id.product_id.task_template_id
if template:
title = template.name
elif rec.sale_line_id:
name_parts = rec.sale_line_id.name.split("\n")
title = name_parts and name_parts[0] or rec.sale_line_id.product_id.name
elif rec.visit_id:
title = rec.visit_id.label
else:
rec.name = rec.name
return
client_name = rec.sale_order_id.partner_shipping_id.name
if not rec.parent_id:
rec.name = f"{rec.work_order_number} - {client_name} - {title}"
else:
rec.name = title
@api.depends("parent_id", "parent_id.root_ancestor")
def _compute_root_ancestor(self):
for rec in self:
rec.root_ancestor = rec.parent_id and rec.parent_id.root_ancestor or self

View file

@ -1,122 +0,0 @@
from odoo import models, fields, api, Command
class TaskTemplate(models.Model):
_name = "project.task.template"
_description = "Template for new project tasks"
@api.model
def _current_company(self):
return self.env.company
name = fields.Char(string="Task Title", required=True)
description = fields.Html()
assignees = fields.Many2many(
comodel_name="res.users",
string="Default Assignees",
help="Employees assigned to tasks created from this template.",
)
customer = fields.Many2one(
comodel_name="res.partner",
string="Default Customer",
help="Default customer for tasks created from this template.",
default=lambda self: self.parent.customer if self.parent else False,
)
project = fields.Many2one(
comodel_name="project.project",
string="Default Project",
help="Default project for tasks created from this template.",
)
tags = fields.Many2many(
comodel_name="project.tags",
string="Default Tags",
help="Default tags for tasks created from this template.",
)
parent = fields.Many2one(
comodel_name="project.task.template",
string="Parent Task Template",
ondelete="cascade",
)
subtasks = fields.One2many(
comodel_name="project.task.template",
inverse_name="parent",
string="Subtask Templates",
)
sequence = fields.Integer()
company_id = fields.Many2one(
comodel_name="res.company", string="Company", index=1, default=_current_company
)
planned_hours = fields.Float("Initially Planned Hours")
equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
relation="bemade_fsm_task_template_equipment_rel",
column1="task_template_id",
column2="equipment_id",
string="Equipment to Service",
)
def action_open_task(self):
return {
"view_mode": "form",
"res_model": "project.task.template",
"res_id": self.id,
"type": "ir.actions.act_window",
"context": self._context,
}
@api.onchange("customer")
def _onchange_customer(self):
for rec in self:
new_equipment_ids = [
eq.id for eq in rec.equipment_ids if eq.partner_id == rec.customer
]
rec.write({"equipment_ids": [Command.set(new_equipment_ids)]})
def _prepare_new_task_values_from_self(self, project, name=False, parent_id=False):
partner_id = self.customer or self.parent.customer or False
if not partner_id and parent_id:
parent = self.env["project.task"].browse(parent_id)
partner_id = parent.partner_id or parent.sale_order_id.partner_shipping_id
vals = {
"project_id": project.id,
"name": name or self.name,
"description": self.description,
"parent_id": parent_id,
"user_ids": self.assignees.ids,
"tag_ids": self.tags.ids,
"allocated_hours": self.planned_hours,
"sequence": self.sequence,
"equipment_ids": (
[Command.set(self.equipment_ids.ids)] if self.equipment_ids else False
),
"partner_id": partner_id and partner_id.id,
"company_id": self.company_id.id,
}
return vals
def create_task_from_self(self, project, name=False, parent_id=False):
"""Create a project.task from this template and return it.
Can be called on a RecordSet of multiple templates.
:param project: project.project record the task should be added to
:param name: name for the new task (defaults to template name)
:param parent_id: parent task for the new task (none by default)
:return: project.task record created from this template
"""
tasks = self.env["project.task"]
for rec in self:
vals = rec._prepare_new_task_values_from_self(project, name, parent_id)
task = rec.env["project.task"].create(vals)
rec.subtasks.create_task_from_self(project, name=False, parent_id=task.id)
tasks |= task
return tasks

View file

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

View file

@ -1,588 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="workorder_page_materials_table">
<t
t-set="order_lines"
t-value="doc.relevant_order_lines.filtered(lambda l:
l.product_id.type != 'service' and not l.is_downpayment and not l.visit_id)"
/>
<t t-if="order_lines">
<t t-set="visit_lines" t-value="order_lines.mapped('visit_id')" />
<t
t-set="section_lines"
t-value="order_lines.filtered(lambda l: l.display_type == 'line_section')"
/>
</t>
<div
t-if="order_lines.filtered(lambda l: not l.display_type)"
style="page-break-inside: avoid;"
>
<h2 t-if="order_lines">Materials</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>
</tr>
</thead>
<tbody class="sale_tbody">
<t t-foreach="order_lines" t-as="line">
<t t-set="is_task" t-value="line == doc.sale_line_id" />
<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>
</t>
<t t-if="line.display_type == 'line_section'">
<td colspan="99">
<span t-field="line.name" />
</td>
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
<span t-field="line.name" />
</td>
</t>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</template>
<template id="workorder_page_sale_order_table_with_pricing_and_labour">
<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-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.display_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="39%">Task</th>
<th width="54%">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-out="task.description or ''" />
</tr>
<tr t-if="task.child_ids" class="mt-0 pt-0">
<td />
<td>
<t t-call="bemade_fsm.subtask_list" />
</td>
<td />
</tr>
</t>
</tbody>
</table>
</div>
</div>
</t>
</template>
<template id="workorder_page_info_block">
<h1>
<span name="work_order_number" t-out="doc.name" />
</h1>
<h4 t-if="doc.sale_order_id and doc.sale_order_id.client_order_ref">
Purchase Order: <span
name="po_number"
t-out="doc.sale_order_id.client_order_ref"
/>
</h4>
<hr />
<div class="row" name="address_and_time">
<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
t-attf-class="{{('col-6' if report_type == 'pdf' else 'col-md-6 col-12') + ' mb-3'}}"
t-if="doc.planned_date_begin or doc.date_deadline"
>
<div t-if="doc.planned_date_begin"><h6>Planned start: </h6></div>
<div class="mb-3">
<div t-out="doc.planned_date_begin.strftime('%Y-%m-%d %H:%M')" />
</div>
<div t-if="doc.date_deadline"><h6>Planned end: </h6></div>
<div class="mb-3">
<div t-out="doc.date_deadline.strftime('%Y-%m-%d %H:%M')" />
</div>
</div>
</div>
<div class="row" name="site_and_work_order_contacts">
<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>
<hr />
<div
t-if="not is_html_empty(doc.description)"
class="row"
name="visit_description"
>
<div class="col-12">
<span t-out="doc.description" />
</div>
</div>
<hr t-if="not is_html_empty(doc.description)" />
</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 overflow-hidden">
<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.code" /></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_id"
t-options='{
"widget": "contact",
"fields": ["name", "address"]}'
/>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</template>
<template id="workorder_page_timesheet_entries">
<t t-set="timesheets" t-value="doc.timesheet_ids" />
<div t-if="timesheets" style="page-break-inside: avoid;">
<h1>Time</h1>
<div class="table-responsive-sm">
<table class="table table-sm o_main_table">
<thead>
<tr>
<th width="14%">Date</th>
<th width="30%">Technician</th>
<th width="49%">Work Completed</th>
<th width="7%" class="text-right">Time</th>
</tr>
</thead>
<tbody>
<tr t-foreach="timesheets" t-as="timesheet" class="p-1 m-1">
<td>
<span t-out="timesheet.date.strftime('%Y-%m-%d')" />
</td>
<td>
<span t-field="timesheet.employee_id.name" />
</td>
<td>
<span t-field="timesheet.name" />
</td>
<td>
<span
t-esc="timesheet.unit_amount"
t-options="{
'widget': 'duration',
'digital': True,
'unit': 'hour',
'round': 'minute'
}"
/>
</td>
</tr>
</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_timesheet_entries" />
<t t-if="split_time_materials">
<t t-call="bemade_fsm.workorder_page_materials_table" />
</t>
<t t-else="">
<t
t-call="bemade_fsm.workorder_page_sale_order_table_with_pricing_and_labour"
/>
</t>
<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>
<template
id="worksheet_custom"
inherit_id="industry_fsm_report.worksheet_custom"
priority="100"
>
<xpath
expr="//t[@t-call='industry_fsm_report.worksheet_custom_page']"
position="replace"
>
<div t-call="bemade_fsm.work_order_page" />
</xpath>
</template>
</odoo>

View file

@ -1,13 +0,0 @@
from odoo import models
class TaskCustomReport(models.AbstractModel):
_inherit = "report.industry_fsm_report.worksheet_custom"
def _get_report_values(self, docids, data=None):
vals = super()._get_report_values(docids, data)
split_time_materials = (
self.env.company.split_time_from_materials_on_service_work_orders
)
vals.update({"split_time_materials": split_time_materials})
return vals

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="industry_fsm_report.task_custom_report" model="ir.actions.report">
<field name="print_report_name">'%s %s' % (
object.planned_date_begin.strftime(
'%Y-%m-%d') if object.planned_date_begin else time.strftime('%Y-%m-%d'),
object.name
)
</field>
</record>
</odoo>

View file

@ -1,5 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bemade_fsm_task_template_manager,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1
access_bemade_fsm_task_template_user,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_visit,access_bemade_fsm_visit,model_bemade_fsm_visit,base.group_user,1,1,1,1
access_bemade_fsm_task_wizard,access_bemade_fsm_task_wizard,model_project_task_from_template_wizard,project.group_project_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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_user bemade_fsm_task_template model_project_task_template project.group_project_user 1 1 1 1
4 access_bemade_fsm_visit access_bemade_fsm_visit model_bemade_fsm_visit base.group_user 1 1 1 1
5 access_bemade_fsm_task_wizard access_bemade_fsm_task_wizard model_project_task_from_template_wizard project.group_project_user 1 1 1 1

View file

@ -1,37 +0,0 @@
/** @odoo-module **/
import KanbanController from 'web.KanbanController';
import KanbanView from 'web.KanbanView';
const viewRegistry = require('web.view_registry')
const ProjectKanbanController = KanbanController.extend({
buttons_template: 'project.KanbanView.buttons',
custom_events: _.extend({}, KanbanController.prototype.custom_events, {
create_from_template: '_onCreateFromTemplate',
}),
_onCreateFromTemplate(ev) {
ev.stopPropagation();
const record = this.model.get(this.handle, {raw: true});
let context = record.context;
context.active_id = record.project_id;
this.do_action({
type: 'ir.actions.act_window',
res_model: 'project.task.from.template.wizard',
views: [[false, 'form']],
target: 'new',
context: {...record.context, res_model: 'project.task'},
});
},
renderButtons ($node) {
this._super.apply(this, arguments);
this.$buttons.on('click', 'button.o-kanban-button-new-from-template', this._onCreateFromTemplate.bind(this))
},
})
const ProjectKanbanView = KanbanView.extend({
config: _.extend({}, KanbanView.prototype.config, {
Controller: ProjectKanbanController,
})
});
viewRegistry.add('project_kanban', ProjectKanbanView)

View file

@ -1,36 +0,0 @@
/** @odoo-module **/
import ListController from 'web.ListController';
import ListView from 'web.ListView';
const viewRegistry = require('web.view_registry')
const ProjectListController = ListController.extend({
buttons_template: 'project.ListView.buttons',
custom_events: _.extend({}, ListController.prototype.custom_events, {
create_from_template: '_onCreateFromTemplate',
}),
_onCreateFromTemplate(ev) {
ev.stopPropagation();
const record = this.model.get(this.handle, {raw: true});
this.do_action({
type: 'ir.actions.act_window',
res_model: 'project.task.from.template.wizard',
views: [[false, 'form']],
target: 'new',
context: {...record.context, res_model: 'project.task'},
});
},
renderButtons ($node) {
self = this;
this._super.apply(this, arguments);
this.$buttons.on('click', 'button.o_list_button_add_from_template', this._onCreateFromTemplate.bind(this))
},
})
const ProjectListView = ListView.extend({
config: _.extend({}, ListView.prototype.config, {
Controller: ProjectListController,
})
});
viewRegistry.add('project_list', ProjectListView)

View file

@ -1,14 +0,0 @@
/* 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

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<!-- Add a button to create a task from a template to task (project) list and kanban views -->
<t t-name='project.KanbanView.buttons' t-inherit="web.KanbanView.buttons" t-inherit-mode="primary">
<xpath expr="//button[contains(@t-attf-class, 'o-kanban-button-new')]" position="after">
<button title="Create Task from Template" t-if="!noCreate" type="button"
t-attf-class="btn {{ btnClass }} o-kanban-button-new-from-template">
<t t-esc="create_text &amp;&amp; (create_text + ' from Template') || _t('Create from Template')"/>
</button>
</xpath>
</t>
<t t-name='project.ListView.buttons' t-inherit="web.ListView.buttons" t-inherit-mode="primary">
<xpath expr="//button[hasclass('o_list_button_add')]" position="after">
<!-- Create is enabled in the parent template at this point; check is done prior -->
<button type="button" class="btn ml-1 btn-primary o_list_button_add_from_template"
title="Create Task from Template">
Create from Template
</button>
</xpath>
</t>
</templates>

View file

@ -1,9 +0,0 @@
from . import test_bemade_fsm_common
from . import test_task_template
from . import test_sale_order
from . import test_fsm_contact_setting
from . import test_fsm_visit
from . import test_task
from . import test_task_report
from . import test_settings
from . import test_equipment

View file

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

View file

@ -1,32 +0,0 @@
from odoo.addons.bemade_fsm.tests.test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests import tagged, Form
@tagged("-at_install", "post_install")
class TestEquipment(BemadeFSMBaseTest):
def test_equipment_search_domain_on_sale_order(self):
"""Equipment from other clients was showing up in sale order line
equipment choices. Make sure this doesn't happen."""
partner = self._generate_partner()
partner_2 = self._generate_partner()
equipment_1 = self._generate_equipment(partner=partner)
equipment_2 = self._generate_equipment(partner_2)
sale_order = self._generate_sale_order(partner=partner)
product = self._generate_product()
self.assertEqual(sale_order.valid_equipment_ids, equipment_1)
name_search_results = self.env["fsm.equipment"].name_search(
args=[
"&",
["id", "in", sale_order.valid_equipment_ids.ids],
"!",
["id", "in", []],
],
limit=8,
name="test",
operator="ilike",
)
self.assertNotIn(
(equipment_2.id, equipment_2.display_name), name_search_results
)
self.assertIn((equipment_1.id, equipment_1.display_name), name_search_results)

View file

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

View file

@ -1,144 +0,0 @@
from odoo.tests import tagged
from .test_bemade_fsm_common import BemadeFSMBaseTest
from datetime import date, timedelta
@tagged("-at_install", "post_install")
class FSMVisitTest(BemadeFSMBaseTest):
def test_create_visit_sets_name_on_section(self):
so = self._generate_sale_order()
self._generate_sale_order_line(sale_order=so)
visit = self._generate_visit(so)
self.assertTrue(visit.so_section_id)
self.assertEqual(visit.so_section_id.name, visit.label)
def test_change_visit_section_name(self):
so = self._generate_sale_order()
visit = self._generate_visit(so, label="First Label")
line = visit.so_section_id
line.name = "Second Label"
self.assertEqual(visit.label, "Second Label")
def test_change_visit_label_changes_section_name(self):
so = self._generate_sale_order()
visit = self._generate_visit(so, label="First Label")
line = visit.so_section_id
visit.label = "Second Label"
self.assertEqual(line.name, "Second Label")
def test_visit_completes_when_task_completes(self):
so = self._generate_sale_order()
visit = self._generate_visit(so)
self._generate_sale_order_line(so)
so.action_confirm()
task = so.order_line.filtered(lambda line: line.task_id).task_id
task.action_fsm_validate()
self.assertTrue(visit.is_completed)
def test_visit_shows_invoiced_when_invoiced(self):
so = self._generate_sale_order()
visit = self._generate_visit(so)
self._generate_sale_order_line(so)
so.action_confirm()
task = so.order_line.filtered(lambda line: line.task_id).task_id
task.action_fsm_validate()
self._invoice_sale_order(so)
self.assertTrue(visit.is_invoiced)
def test_visit_groups_section_tasks_when_confirmed(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit_task = visit.task_id
self.assertTrue(visit_task)
visit_subtasks = visit_task.child_ids
self.assertTrue(
visit_subtasks
and sol1.task_id in visit_subtasks
and sol2.task_id in visit_subtasks
)
def test_visit_task_gets_correct_due_date_on_confirmation(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit_task = visit.task_id
self.assertEqual(visit_task.date_deadline, visit.approx_date)
def test_visit_task_gets_correct_duration_on_confirmation(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
visit = self._generate_visit(sale_order=so)
sol1 = self._generate_sale_order_line(sale_order=so, qty=4.0)
sol2 = self._generate_sale_order_line(sale_order=so, qty=4.0)
visit.approx_date = date.today() + timedelta(days=7)
visit.so_section_id.sequence = 1
sol1.sequence = 2
sol2.sequence = 3
so.action_confirm()
visit_task = visit.task_id
self.assertEqual(sol1.task_id.allocated_hours, 4.0)
self.assertEqual(sol2.task_id.allocated_hours, 4.0)
self.assertEqual(visit_task.allocated_hours, 8.0)
def test_adding_visit_creates_one_sale_order_line(self):
self._generate_partner()
so = self._generate_sale_order()
self._generate_sale_order_line(sale_order=so)
self._generate_sale_order_line(sale_order=so)
self._generate_visit(sale_order=so)
self.assertEqual(len(so.order_line), 3)
def test_marking_visit_task_done_completes_descendants(self):
(
so,
visit,
sol1,
sol2,
) = self._generate_so_with_one_visit_two_lines_and_descendants()
so.action_confirm()
parent = visit.task_id
parent.action_fsm_validate()
self._assert_is_done(parent)
def _assert_is_done(self, task):
"""Recursively assert all tasks in a hierarchy are complete"""
self.assertTrue(task.is_closed)
for child in task.child_ids:
self._assert_is_done(child)
def test_marking_visit_task_done_does_not_create_sale_order_line(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.action_confirm()
visit.task_id.action_fsm_validate()
self.assertEqual(len(so.order_line), 3)
def test_confirming_so_names_visit_properly(self):
"""Visits should be named <SO NUMBER> - Visit <visit #> - <visit label>"""
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
so.name = "SO12345"
so.action_confirm()
task = visit.task_id
supposed_name = "SVR12345-1 - Test Company - Test Label"
self.assertEqual(task.name, supposed_name)

View file

@ -1,363 +0,0 @@
from .test_task_template import BemadeFSMBaseTest
from odoo.tests.common import tagged, Form
from odoo import Command
@tagged("-at_install", "post_install")
class TestSalesOrder(BemadeFSMBaseTest):
@tagged("-at_install", "post_install")
def test_order_confirmation_simple_template(self):
"""Confirming the order should create a task in the global project based on the
task template."""
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(planned_hours=8)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
task = sol.task_id
self.assertTrue(task)
self.assertTrue(task_template.name in task.name)
self.assertTrue(task_template.planned_hours == task.allocated_hours)
def test_task_template_tree_order_confirmation(self):
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
parent_template = self._generate_task_template(
structure=[2, 1],
names=["Parent Template", "Child Template", "Grandchild Template"],
)
child_template_1 = parent_template.subtasks[0]
child_template_2 = parent_template.subtasks[1]
grandchild_template = parent_template.subtasks[0].subtasks[0]
product = self._generate_product(task_template=parent_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
parent = sol.task_id
c1, c2 = parent.child_ids
gc = c1.child_ids[:1]
self.assertTrue(parent.child_ids and len(parent.child_ids) == 2)
self.assertTrue(parent_template.name in parent.name)
self.assertEqual(child_template_1.name, c1.name)
self.assertEqual(child_template_2.name, c2.name)
self.assertTrue(c1.child_ids and len(c1.child_ids) == 1)
self.assertEqual(grandchild_template.name, gc.name)
def test_order_confirmation_single_equipment(self):
"""The equipment selected on the SO should transfer to the task."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner, equipment=equipment)
task_template = self._generate_task_template(planned_hours=8)
product1 = self._generate_product(task_template=task_template)
product2 = self._generate_product()
sol1 = self._generate_sale_order_line(so, product=product1)
sol2 = self._generate_sale_order_line(so, product=product2)
so.action_confirm()
task1 = sol1.task_id
task2 = sol2.task_id
self.assertEqual(task1.equipment_ids[0], equipment)
self.assertEqual(task2.equipment_ids[0], equipment)
def test_order_confirmation_multiple_equipment(self):
"""All equipment items should flow from the sale order line to the final task"""
partner = self._generate_partner()
for i in range(5):
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(
partner=partner
) # No default equipment since more than 3 on partner
sol1, sol2, sol3 = [
self._generate_sale_order_line(sale_order=sale_order) for _ in range(3)
]
sol1.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2)])
]
sol3.equipment_ids = [
Command.set([partner.equipment_ids[i].id for i in range(2, 5)])
]
sale_order.action_confirm()
self.assertEqual(sol1.equipment_ids, sol1.task_id.equipment_ids)
self.assertEqual(sol2.equipment_ids, sol2.task_id.equipment_ids)
self.assertEqual(sol3.equipment_ids, sol3.task_id.equipment_ids)
def test_task_template_with_equipment_flow(self):
"""The equipment selected on a task template should flow down to the task
created on SO confirmation."""
partner = self._generate_partner()
equipment = self._generate_equipment(partner=partner)
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(equipment=equipment)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
self.assertEqual(sol.task_id.equipment_ids[0], equipment)
def test_sale_order_line_gets_default_equipment(self):
"""Sale order lines created on a SO with default equipment set should inherit
that default equipment."""
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
sol = self._generate_sale_order_line(sale_order=sale_order)
self.assertEqual(sol.equipment_ids, partner.equipment_ids)
def test_sale_order_gets_correct_default_equipment_from_partner(self):
"""Should pick up equipment from the partner."""
partner = self._generate_partner()
self._generate_equipment(partner=partner)
sale_order = self._generate_sale_order(partner=partner)
self.assertEqual(sale_order.default_equipment_ids, partner.owned_equipment_ids)
def test_sale_order_default_equipment_maximum_number(self):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(3):
self._generate_equipment(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 a SO line should mark the line delivered.
Marking sub-tasks done should not."""
partner = self._generate_partner()
so = self._generate_sale_order(partner=partner)
task_template = self._generate_task_template(
structure=[2], names=["Parent Task", "Subtask"]
)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
parent_task = sol.task_id
subtasks = parent_task._get_all_subtasks()
# Marking the subtasks done should not increment delivered quantity
subtasks.action_fsm_validate(True)
self.assertEqual(sol.qty_delivered, 0)
# Marking the top-level tasks done should set the delivered quantity to some
# non-zero value based on the UOM
parent_task.action_fsm_validate(True)
self.assertTrue(sol.qty_delivered != 0)
def test_task_contacts_through_sale_order(self):
"""Make sure the site contacts and work order contacts transfer correctly
from the SO to the task."""
partner = self._generate_partner()
contact1 = self._generate_partner("Site contact", "person", partner)
contact2 = self._generate_partner("Work order contact", "person", partner)
partner.write(
{
"site_contacts": [Command.set([contact1.id])],
"work_order_contacts": [Command.set([contact2.id])],
}
)
so = self._generate_sale_order(partner)
product = self._generate_product()
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
self.assertEqual(so.work_order_contacts, partner.work_order_contacts)
self.assertEqual(so.site_contacts, partner.site_contacts)
self.assertEqual(sol.task_id.work_order_contacts, partner.work_order_contacts)
self.assertEqual(sol.task_id.site_contacts, partner.site_contacts)
def test_tasks_created_at_order_confirmation_have_no_assignees(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
user = self._generate_project_user(name="User", login="login")
# We test as a specific user since testing as root may not produce the error
so.with_user(user).action_confirm()
visit_task = visit.task_id
subtask1 = visit_task.child_ids[0]
subtask2 = visit_task.child_ids[1]
self.assertFalse(visit_task.user_ids)
self.assertFalse(subtask1.user_ids)
self.assertFalse(subtask2.user_ids)
def test_long_line_name_overflows_to_task_description(self):
so = self._generate_sale_order()
product = self._generate_product()
product.description_sale = (
"This is a long product description.\n"
"It even spans multiple lines.\n"
"One could find this annoying in a task name."
)
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
task = sol.task_id
self.assertFalse("This is a long product description." in task.name)
self.assertFalse("It even spans multiple lines." in task.name)
self.assertFalse("One could find this annoying in a task name." in task.name)
self.assertTrue("It even spans multiple lines." in task.description)
self.assertTrue(
"One could find this annoying in a task name." in task.description
)
def test_subtask_templates_no_description_if_blank_on_template(self):
so = self._generate_sale_order()
template = self._generate_task_template(
structure=[5], names=["Parent", "Child"]
)
template.description = ""
template.subtasks[0].description = "Some fixed description"
for t in template.subtasks[1:]:
t.description = ""
product = self._generate_product(task_template=template)
sol = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
task = sol.task_id
self.assertEqual(
task.child_ids[0].description, template.subtasks[0].description
)
for t in task.child_ids[1:]:
self.assertFalse(t.description)
def test_duplicate_sale_order_duplicates_visits(self):
"""Duplicated sales orders should have visits tied to their SO lines as in the
original. The copied visits should not have approximate dates set, however."""
so, visit, line1, line2 = self._generate_so_with_one_visit_two_lines()
so2 = so.copy()
self.assertTrue(so2.visit_ids)
visit2 = so2.visit_ids[0]
self.assertEqual(so2.order_line[0].visit_id, visit2)
self.assertEqual(visit2.label, visit.label)
self.assertFalse(visit2.approx_date)
def test_confirming_sale_order_creates_visit_if_none_created(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = True
product = self._generate_product()
self._generate_sale_order_line(so, product)
so.action_confirm()
visit_line = so.order_line.sorted("sequence")[0]
self.assertTrue(so.visit_ids)
self.assertEqual(visit_line.visit_id, so.visit_ids)
def test_confirming_sale_order_with_visit_creates_no_new_lines(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = True
self._generate_product()
self._generate_visit(so)
so.action_confirm()
self.assertEqual(len(so.visit_ids), 1)
def test_confirming_sale_order_creates_no_visit_if_setting_off(self):
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = False
product = self._generate_product()
self._generate_sale_order_line(so, product)
so.action_confirm()
self.assertFalse(so.visit_ids)
def test_changing_sale_order_customer_follows_to_tasks_and_subtasks(self):
so = self._generate_sale_order()
so = self._generate_sale_order()
so.company_id.create_default_fsm_visit = False
task_template = self._generate_task_template(
parent=None,
structure=[2, 2],
names=["Parent", "Child", "Grandchild"],
)
product = self._generate_product(task_template=task_template)
sol = self._generate_sale_order_line(so, product)
so.action_confirm()
parent_task = sol.task_id
for task in parent_task._get_all_subtasks() | parent_task:
self.assertEqual(so.partner_shipping_id, task.partner_id)
so.write(
{
"partner_shipping_id": self.env["res.partner"]
.create(
{
"name": "New shipping address",
"parent_id": so.partner_id.id,
"type": "delivery",
}
)
.id
}
)
for task in parent_task._get_all_subtasks() | parent_task:
self.assertEqual(so.partner_shipping_id, task.partner_id)

View file

@ -1,48 +0,0 @@
from odoo.tests import TransactionCase, Form, tagged
@tagged("-at_install", "post_install")
class TestSettings(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.test_partner_co = cls.env["res.partner"].create(
{
"name": "Test Co",
}
)
cls.test_co = cls.env["res.company"].create(
{
"name": "Test Co",
"country_id": cls.env.ref("base.ca").id,
}
)
cls.env.user.company_id = cls.test_co
def test_enabling_separate_time_on_work_orders(self):
wizard = self.env["res.config.settings"].create({})
self.assertFalse(self.test_co.split_time_from_materials_on_service_work_orders)
with Form(wizard) as form:
form.separate_time_on_work_orders = True
self.assertTrue(self.test_co.split_time_from_materials_on_service_work_orders)
def test_disabling_separate_time_on_work_orders(self):
wizard = self.env["res.config.settings"].create({})
self.test_co.split_time_from_materials_on_service_work_orders = True
with Form(wizard) as form:
form.separate_time_on_work_orders = False
self.assertFalse(self.test_co.split_time_from_materials_on_service_work_orders)
def test_enabling_create_default_fsm_visit(self):
wizard = self.env["res.config.settings"].create({})
self.test_co.create_default_fsm_visit = False
with Form(wizard) as form:
form.create_default_fsm_visit = True
self.assertTrue(self.test_co.create_default_fsm_visit)
def test_disabling_create_default_fsm_visit(self):
wizard = self.env["res.config.settings"].create({})
self.test_co.create_default_fsm_visit = True
with Form(wizard) as form:
form.create_default_fsm_visit = False
self.assertFalse(self.test_co.create_default_fsm_visit)

View file

@ -1,154 +0,0 @@
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests.common import tagged, Form
from odoo import Command
@tagged("post_install", "-at_install")
class TaskTest(BemadeFSMBaseTest):
@classmethod
def setUpClass(cls):
# Chose to set up all tests the same way since this code was becoming very
# redundant
super().setUpClass()
cls.user = cls._generate_project_manager_user("Bob", "Bob")
def _generate_so_with_multilevel_task_template(self):
so = self._generate_sale_order()
template = self._generate_task_template(
names=["Parent", "Child", "Grandchild"], structure=[2, 1]
)
product = self._generate_product(task_template=template)
sol = self._generate_sale_order_line(sale_order=so, product=product)
return so, sol
def test_reassigning_assignment_propagating_task_changes_subtasks(self):
so, sol = self._generate_so_with_multilevel_task_template()
so.action_confirm()
task = sol.task_id
task.propagate_assignment = True
task.write(
{
"user_ids": [Command.set([self.user.id])],
"propagate_assignment": True,
}
)
self.assertTrue(
all([t.user_ids == self.user for t in task | task._get_all_subtasks()])
)
def test_reassigning_task_doesnt_propagate_by_default(self):
so, sol = self._generate_so_with_multilevel_task_template()
so.action_confirm()
task = sol.task_id
task.write(
{
"user_ids": [Command.set([self.user.id])],
}
)
self.assertFalse(any([t.user_ids for t in task.child_ids.child_ids]))
def test_unset_propagate_assignment_unsets_for_all_children(self):
so, sol = self._generate_so_with_multilevel_task_template()
so.action_confirm()
task = sol.task_id
# First, set propagation and assign
task.propagate_assignment = True
task.write({"user_ids": [Command.set([self.user.id])]})
# Then, unset propagation for the children and re-set assignment
task.child_ids.write({"propagate_assignment": False})
self.assertFalse(
any([t.propagate_assignment for t in task._get_all_subtasks()])
)
# Then, test that assigning the parent only assigns its children, not its
# grandchildren
task.write({"user_ids": [Command.set([])]})
self.assertTrue(all([not t.user_ids for t in task | task.child_ids]))
self.assertTrue(
all([t.user_ids == self.user for t in task.child_ids.child_ids])
)
def test_task_gets_work_order_contacts_from_sale_order(self):
so, sol = self._generate_so_with_multilevel_task_template()
work_order_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"work_order_contacts": [(6, 0, work_order_contacts.ids)]})
so.action_confirm()
task = sol.task_id
self.assertEqual(task.work_order_contacts, so.work_order_contacts)
# Just a safeguard to make sure we set it properly on the SO
self.assertEqual(len(task.work_order_contacts), 2)
# Make sure all subtasks got the same
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.work_order_contacts, so.work_order_contacts)
def test_task_gets_site_contacts_from_sale_order(self):
so, sol = self._generate_so_with_multilevel_task_template()
site_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"site_contacts": [(6, 0, site_contacts.ids)]})
so.action_confirm()
task = sol.task_id
self.assertEqual(task.site_contacts, so.site_contacts)
# Just a safeguard to make sure we set it properly on the SO
self.assertEqual(len(task.site_contacts), 2)
# Make sure all subtasks got the same
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.site_contacts, so.site_contacts)
def test_task_gets_work_order_contacts_from_parent(self):
so, sol = self._generate_so_with_multilevel_task_template()
work_order_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"work_order_contacts": [(6, 0, work_order_contacts.ids)]})
so.action_confirm()
task = sol.task_id
task.write(
{
"work_order_contacts": [
Command.link(self._generate_partner(parent=so.partner_id).id)
]
}
)
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.work_order_contacts, task.work_order_contacts)
with Form(task) as task_form:
with task_form.child_ids.new() as subtask:
subtask.name = "Subtask 1"
subtask = task.child_ids[-1]
self.assertEqual(subtask.work_order_contacts, task.work_order_contacts)
def test_task_gets_site_contacts_from_parent(self):
so, sol = self._generate_so_with_multilevel_task_template()
site_contacts = self._generate_partner(
parent=so.partner_id
) | self._generate_partner(parent=so.partner_id)
so.write({"site_contacts": [(6, 0, site_contacts.ids)]})
so.action_confirm()
task = sol.task_id
task.write(
{
"site_contacts": [
Command.link(self._generate_partner(parent=so.partner_id).id)
]
}
)
for subtask in task._get_all_subtasks():
self.assertEqual(subtask.site_contacts, task.site_contacts)
with Form(task) as task_form:
with task_form.child_ids.new() as subtask:
subtask.name = "Subtask 1"
subtask = task.child_ids[-1]
self.assertEqual(subtask.site_contacts, task.site_contacts)

View file

@ -1,43 +0,0 @@
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests import Form
class TestTaskReport(BemadeFSMBaseTest):
def test_split_time_materials_setting(self):
with Form(self.env["res.config.settings"]) as settings:
settings.separate_time_on_work_orders = True
with Form(self.env["res.config.settings"]):
self.assertTrue(settings.separate_time_on_work_orders)
so = self._generate_sale_order()
service_product = self._generate_product()
material_product = self._generate_product(
name="Material Product",
product_type="product",
service_tracking="no",
)
visit = self._generate_visit(sale_order=so)
self._generate_sale_order_line(sale_order=so, product=service_product)
self._generate_sale_order_line(sale_order=so, product=material_product)
so.action_confirm()
task = visit.task_id
html_content = (
self.env["ir.actions.report"]
._render(
"industry_fsm_report.worksheet_custom",
[task.id],
)[0]
.decode("utf-8")
.split("\n")
)
strings_to_find = ["<h2>Materials</h2>", "<span>Material Product</span>"]
for line in strings_to_find:
line_found = False
for html_line in html_content:
if line in html_line:
line_found = True
self.assertTrue(line_found, f"{line} should be in file.")

View file

@ -1,80 +0,0 @@
from .test_bemade_fsm_common import BemadeFSMBaseTest
from odoo.tests.common import tagged, Form
from odoo.exceptions import MissingError
from odoo.tools import mute_logger
import psycopg2
@tagged("-at_install", "post_install")
class TestTaskTemplate(BemadeFSMBaseTest):
def test_delete_task_template(self):
"""User should never be able to delete a task template used on a product"""
task_template = self._generate_task_template(names=["Template 1"])
self._generate_product(name="Test Product 1", task_template=task_template)
with self.assertRaises(psycopg2.errors.ForeignKeyViolation):
with mute_logger("odoo.sql_db"):
task_template.unlink()
def test_delete_subtask_template(self):
"""Deletion of a child task should be OK even if the parent is on a product.
Children of the deleted subtask should be deleted."""
parent_task = self._generate_task_template(
structure=[2, 1],
names=["Parent Template", "Child Template", "Grandchild Template"],
)
grandchild_task = parent_task.subtasks[0].subtasks[0]
parent_task.subtasks[0].unlink()
# Reading deleted child's name field should be impossible
with self.assertRaises(MissingError):
_ = grandchild_task.name
def test_dissociating_customer_resets_equipment_appropriately(self):
partner1 = self._generate_partner()
partner2 = self._generate_partner()
equipment1 = self._generate_equipment(partner=partner1)
task = self._generate_task_template(customer=partner1, equipment=equipment1)
form = Form(task)
# Switching the partner should trigger on_change that makes sure equipments are
# linked to the new partner
form.customer = partner2
form.save()
self.assertFalse(equipment1 in task.equipment_ids)
def test_child_task_names_are_short_version(self):
so, visit, sol1, sol2 = self._generate_so_with_one_visit_two_lines()
template = self._generate_task_template(names=["Task"])
product = self._generate_product(task_template=template)
sol1.name = "Short Name 1"
sol2.name = "Short Name 2"
sol3 = self._generate_sale_order_line(sale_order=so, product=product)
so.action_confirm()
self.assertEqual(sol1.task_id.name, "Short Name 1")
self.assertEqual(sol2.task_id.name, "Short Name 2")
self.assertEqual(sol3.task_id.name, "Task")
def test_task_creation_directly_from_template(self):
project = self.env.ref("industry_fsm.fsm_project")
template = self._generate_task_template(
names=["Task", "Child", "Grandchild"], structure=[2, 1]
)
task = template.create_task_from_self(project, "My new task")
self.assertEqual(len(task.child_ids), len(template.subtasks))
self.assertEqual(
len(task.child_ids[0].child_ids), len(template.subtasks[0].subtasks)
)
self.assertEqual(
len(task.child_ids[1].child_ids), len(template.subtasks[1].subtasks)
)
self.assertEqual(task.name, "My new task")
self.assertEqual(task.child_ids[0].name, template.subtasks[0].name)
self.assertTrue(
all([t.project_id == project for t in task | task._get_all_subtasks()])
)

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<menuitem
id="project_task_template_menu"
name="Task Templates"
parent="project.menu_main_pm"
action="task_template_act_window"
groups="project.group_project_manager,project.group_project_user"
/>
<menuitem
id="service_task_template_meny"
name="Task Templates"
action="task_template_act_window"
parent="industry_fsm.fsm_menu_root"
groups="project.group_project_manager,industry_fsm.group_fsm_manager"
/>
</odoo>

View file

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="product_template_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.product.template.form</field>
<field name="model">product.template</field>
<field
name="inherit_id"
ref="sale_project.product_template_form_view_invoice_policy_inherit_sale_project"
/>
<field name="arch" type="xml">
<xpath expr="//field[@name='project_id']" position="after">
<field
name="task_template_id"
invisible="service_tracking not in ('task_global_project', 'task_in_project')"
domain="[('parent', '=', False)]"
/>
</xpath>
</field>
</record>
<!-- BV: Did comment that one cause can not match inherit_id and don't understand it use -->
<!-- <record id="product_search_form_view_inherit_bemade_fsm" model="ir.ui.view">-->
<!-- <field name="name">bemade_fsm.product_search_form_view_inherit_bemade_fsm</field>-->
<!-- <field name="model">product.product</field>-->
<!-- <field name="inherit_id" ref="industry_fsm_sale.product_search_form_view_inherit_fsm_sale"/>-->
<!-- <field name="arch" type="xml">-->
<!-- <xpath expr="//searchpanel//field[@name='categ_id']" position="attributes">-->
<!-- <attribute name="limit">0</attribute>-->
<!-- </xpath>-->
<!-- </field>-->
<!-- </record>-->
</odoo>

View file

@ -1,52 +0,0 @@
<?xml version="1.0" ?>
<odoo>
<record id="partner_equipment_location_view_form" model="ir.ui.view">
<field name="name">partner.equipment.location.view.form</field>
<field name="model">res.partner</field>
<field
name="inherit_id"
ref="fsm_equipment.partner_equipment_location_view_form"
/>
<field name="arch" type="xml">
<page name="equipment" position="attributes">
<attribute name="invisible">site_ids</attribute>
</page>
<page name="equipment" position="after">
<page
name="Service Contacts"
invisible="site_ids">
<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'}"
/>
</page>
<field name="is_service_site" invisible="True"/>
<page name="Service Sites" invisible="is_service_site">
<group>
<field name="site_ids">
<tree editable="bottom">
<field name="name" widget="res_partner_many2one" />
</tree>
</field>
</group>
</page>
</page>
</field>
</record>
<record id="fsm_contacts_view_tree" model="ir.ui.view">
<field name="name">bemade_fsm.contacts.tree</field>
<field name="model">res.partner</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="name" />
<field name="email" widget="email" />
<field name="phone" widget="phone" />
<field name="mobile" widget="phone" />
</tree>
</field>
</record>
</odoo>

View file

@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="sale_order_form_inherit" model="ir.ui.view">
<field name="name">bemade_fsm.sale_order.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//page[@name='other_information']" position="before">
<page name="field_service" string="Field Service">
<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_id': partner_shipping_id,}"
widget="many2many_tags"
groups="account.group_delivery_invoice_address"
/>
<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_id': partner_shipping_id,}"
widget="many2many_tags"
groups="account.group_delivery_invoice_address"
/>
</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>
</odoo>

View file

@ -1,127 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="task_template_form_view" model="ir.ui.view">
<field name="name">bemade_fsm.task_template.form</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<form string="Task Template">
<sheet>
<div class="oe_title">
<label for="name" />
<h1>
<field name="name" placeholder="Title" />
</h1>
</div>
<group>
<group>
<field name="project" />
<field name="assignees" widget="many2many_avatar_user" />
<field name="parent" />
<field name="planned_hours" />
</group>
<group>
<field name="customer" />
<field
name="equipment_ids"
domain="[('partner_id', '=', customer)]"
context="{'tree_view_ref': 'fsm_equipment.equipment_view_tree'}"
/>
<field name="tags" widget="many2many_tags" />
<field name="company_id" />
</group>
</group>
<notebook>
<page name="description_page" string="Description">
<field
name="description"
type="html"
options="{'collaborative': true}"
/>
</page>
<page name="subtasks_page" string="Subtasks">
<field name="subtasks">
<tree editable="bottom">
<field name="sequence" widget="handle" />
<field name="name" />
<field name="customer" />
<field
name="assignees"
widget="many2many_avatar_user"
/>
<button
name="action_open_task"
type="object"
title="View Task"
string="View Task"
class="btn btn-link pull-right"
/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="task_template_tree_view" model="ir.ui.view">
<field name="name">project.task_template.tree</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="assignees" widget="many2many_avatar_user" />
<field name="project" />
<field name="parent" />
<field name="planned_hours" />
</tree>
</field>
</record>
<record id="task_template_search_view" model="ir.ui.view">
<field name="name">project.task_template.search</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<search string="Task Template">
<field name="name" />
<field name="project" />
<field name="assignees" />
<field name="parent" />
<field name="subtasks" />
<field name="planned_hours" />
<group expand="1" string="Group By">
<filter
string="Project"
name="groupby_project"
domain="[]"
context="{'group_by':'project'}"
/>
<filter
string="Parent Task"
name="groupby_parent"
domain="[]"
context="{'group_by':'parent'}"
/>
<filter
string="Customer"
name="groupby_customer"
domain="[]"
context="{'group_by':'customer'}"
/>
</group>
</search>
</field>
</record>
<record id="task_template_act_window" model="ir.actions.act_window">
<field name="name">Task Template</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">project.task.template</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
There are no task templates, click above to create one.
</p>
</field>
</record>
</odoo>

View file

@ -1,229 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<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="industry_fsm.view_task_form2_inherit" />
<field name="priority" eval="8" />
<field name="arch" type="xml">
<xpath expr="//field[@name='name']/.." position="before">
<h1 class="d-flex flex-row justify-content-between">
<field
name="work_order_number"
invisible="work_order_number == False"
/>
</h1>
</xpath>
<xpath expr="//page[@name='extra_info']" position="after">
<page string="Equipment and Contacts" name="equipment_contacts"
translate="True">
<group name="equipment_and_contacts">
<field
name="equipment_ids"
domain="[('partner_id', '=', partner_id)]"
context="{'tree_view_ref': 'fsm_equipment.equipment_view_tree'}"
/>
<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'}"
/>
</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 name="user_ids" position="after">
<field name="propagate_assignment" />
</field>
</field>
</record>
<!-- Add parent_id = false to domain for My Tasks, All Tasks: To Schedule, All Tasks and To Invoice-->
<record id="industry_fsm.project_task_action_fsm" model="ir.actions.act_window">
<field name="context">{'search_default_is_parent_task': True}</field>
</record>
<record id="project_task_view_list_fsm_inherit" model="ir.ui.view">
<field name="name">project.task.view.list.fsm.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.project_task_view_list_fsm" />
<field name="arch" type="xml">
<tree position="attributes">
<attribute name="js_class">project_list</attribute>
</tree>
<field name="name" position="before">
<field name="work_order_number" optional="show" />
</field>
<field name="company_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="worksheet_template_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="project_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="progress" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="activity_ids" position="attributes">
<attribute name="optional">hide</attribute>
</field>
</field>
</record>
<record id="industry_fsm.project_task_action_fsm_map" model="ir.actions.act_window">
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm.project_task_action_to_schedule_fsm"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record id="industry_fsm.project_task_action_all_fsm" model="ir.actions.act_window">
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm_sale.project_task_action_to_invoice_fsm"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False),
('invoice_status', '=', 'to invoice')]
</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"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm.project_task_action_fsm_planning_groupby_project"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('parent_id', '=', False)]
</field>
</record>
<record
id="industry_fsm_report.project_task_action_fsm_planning_groupby_worksheet"
model="ir.actions.act_window"
>
<field name="context">{'search_default_is_parent_task': True}</field>
<field name="domain">[('is_fsm', '=', True),
('project_id', '!=', False),
('display_in_project', '=', True),
('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="priority" eval="100" />
<field name="arch" type="xml">
<xpath expr="//field[@name='worksheet_template_id']" position="replace" />
<xpath expr="//calendar" position="attributes">
<attribute name="color">user_ids</attribute>
</xpath>
</field>
</record>
<record id="project_task_view_search_fsm_inherit" model="ir.ui.view">
<field name="name">project.task.view.search.fsm.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="industry_fsm.project_task_view_search_fsm" />
<field name="arch" type="xml">
<filter name="schedule" position="attributes">
<attribute name="domain">
[
'&amp;',
('fsm_done', '=', False),
'|',
('user_ids', '=', False),
'&amp;',
('planned_date_start', '=', False),
('date_deadline', '=', False),
]
</attribute>
</filter>
<filter name="my_tasks" position="after">
<filter
name="is_parent_task"
string="Parent Task"
domain="[('parent_id', '=', False)]"
/>
</filter>
</field>
</record>
</odoo>

View file

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

View file

@ -1,57 +0,0 @@
from odoo import models, fields
class NewTaskFromTemplateWizard(models.TransientModel):
_name = "project.task.from.template.wizard"
_description = "Create Task from Template Wizard"
project_id = fields.Many2one(
comodel_name="project.project",
string="Project",
help="The project the new task should be created in.",
required=True,
)
task_template_id = fields.Many2one(
comodel_name="project.task.template",
string="Task Template",
help="The template to use when creating the new task.",
required=True,
)
new_task_title = fields.Char(
help=(
"The title (name) for the newly created task. If left blank, the name of"
" the template will be used."
),
)
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self.env.context.get("active_id", False)
active_model = self.env.context.get("active_model", False)
if not active_model:
params = self.env.context.get("params", False)
active_model = params and params.get("model", False)
if (
active_model == "project.task.template"
and active_id
and "task_template_id" in fields_list
):
res.update({"task_template_id": active_id})
if active_model == "project.task" and "project_id" in fields_list:
res.update({"project_id": self.env.ref("industry_fsm.fsm_project").id})
return res
def action_create_task_from_template(self):
self.ensure_one()
task = self.task_template_id.create_task_from_self(
self.project_id, self.new_task_title
)
return {
"type": "ir.actions.act_window",
"res_model": "project.task",
"res_id": task.id,
"view_mode": "form",
"target": "current",
}

View file

@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="project_task_from_template_wizard_view_form" model="ir.ui.view">
<field name="name">project.task.from.template.wizard.view.form</field>
<field name="model">project.task.from.template.wizard</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="task_template_id"/>
<field name="new_task_title"/>
<field name="project_id"/>
</group>
</sheet>
<footer>
<button class='btn btn-primary' name="action_create_task_from_template" type="object" string="Create Task"/>
<button class='btn btn-secondary' special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record model="ir.actions.act_window" id="action_new_task_from_template">
<field name="name">New Task from Template</field>
<field name="res_model">project.task.from.template.wizard</field>
<field name="binding_model_id" ref="bemade_fsm.model_project_task_template"/>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View file

@ -1,22 +0,0 @@
from odoo import models, fields
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
company_id = fields.Many2one(
comodel_name="res.company",
default=lambda self: self.env.company or self.env.user.company_id,
)
separate_time_on_work_orders = fields.Boolean(
string="Separate Time from Materials on Work Order",
related="company_id.split_time_from_materials_on_service_work_orders",
readonly=False,
)
create_default_fsm_visit = fields.Boolean(
string="Create Default Visit for FSM Sales Orders",
related="company_id.create_default_fsm_visit",
readonly=False,
)

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.bemade.fsm</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="industry_fsm.res_config_settings_view_form"/>
<field name="arch" type="xml">
<setting name="module_industry_fsm_report" position="after">
<setting
name="separate_time_on_work_orders"
help="Separate blocks for materials and time on work order reports."
company_dependent="1">
<field name="separate_time_on_work_orders"/>
</setting>
<setting
name="create_default_fsm_visit"
help="Create a default FSM visit if a service SO doesn't already contain one at confirmation."
company_dependent="1">
<field name="create_default_fsm_visit"/>
</setting>
</setting>
</field>
</record>
</odoo>

View file

@ -1,40 +0,0 @@
#
# 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': 'Full Form from Dialog',
'version': '17.0.1.0.0',
'summary': 'Allows opening the full form view from the dialog (modal) view.',
'description': 'Adds a button to open the full form view when viewing the form view for a record in a dialog.',
'category': 'Technical',
'author': 'Bemade Inc.',
'website': 'http://www.bemade.org',
'license': 'OPL-1',
'depends': ['base'], # For testing, install contacts module as well.
'data': [],
'assets': {
'web.assets_backend': [
'bemade_full_formview_from_modal/static/src/**/*',
],
'web.assets_tests': [
'bemade_full_formview_from_modal/static/tests/**/*',
]
},
'installable': True,
'auto_install': False
}

View file

@ -1,64 +0,0 @@
# Migration vers Odoo 18.0 - bemade_full_formview_from_modal
## Description
Module qui ajoute un bouton pour ouvrir la vue formulaire complète depuis une vue modale (dialog).
## Analyse Technique
### Fonctionnalité Native dans Odoo 18.0
✅ La fonctionnalité existe nativement dans Odoo 18.0 !
Le composant `FormViewDialog` dans `web/static/src/views/view_dialogs/form_view_dialog.js` inclut déjà la méthode `onExpand()` qui fournit exactement la même fonctionnalité :
```javascript
async onExpand() {
const beforeLeaveCallbacks = this.viewProps.__beforeLeave__.callbacks;
const res = await Promise.all(beforeLeaveCallbacks.map((callback) => callback()));
if (!res.includes(false)) {
this.actionService.doAction({
type: "ir.actions.act_window",
res_model: this.props.resModel,
res_id: this.currentResId,
views: [[false, "form"]],
});
}
}
```
Cette méthode :
- Gère les callbacks avant de quitter la vue
- Utilise le même service d'action
- Préserve le contexte et l'ID de l'enregistrement
- Ouvre la vue en mode plein écran
### Recommandation
Ce module n'est plus nécessaire dans Odoo 18.0 car la fonctionnalité est maintenant disponible nativement.
## Plan de Migration
### Actions Requises
1. **Désactivation du Module** :
- [ ] Désactiver le module avant la migration vers Odoo 18.0
- [ ] Vérifier qu'aucun autre module ne dépend de celui-ci
- [ ] Informer les utilisateurs que la fonctionnalité est maintenant native
2. **Vérification** :
- [ ] Tester la fonctionnalité native dans Odoo 18.0
- [ ] Confirmer que tous les cas d'utilisation sont couverts
- [ ] Documenter tout comportement différent pour les utilisateurs
## État de la Migration
🟢 Pas de migration nécessaire - Utiliser la fonctionnalité native
## Notes Importantes
- La fonctionnalité est maintenant intégrée nativement dans Odoo 18.0
- Le comportement natif est identique à notre implémentation custom
- Aucune personnalisation supplémentaire n'est nécessaire
## Prochaines Étapes
1. Planifier la désactivation du module
2. Documenter le changement pour les utilisateurs
3. Retirer le module de la liste des dépendances des autres modules si nécessaire
## Notes de Version
- Version originale: 17.0.1.0.0
- Dernière analyse: 26/01/2025

View file

@ -1,42 +0,0 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
import { X2ManyFieldDialog } from "@web/views/fields/relational_utils"
import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog"
patch(X2ManyFieldDialog.prototype, {
setup () {
super.setup();
this.action = useService('action')
this.env.dialogData.onOpenButtonClicked = this.onOpenButtonClicked.bind(this);
},
onOpenButtonClicked: function () {
this.action.doAction({
type: "ir.actions.act_window",
res_model: this.record.resModel,
res_id: this.record.resId,
views: [[false, "form"]],
target: "current",
context: this.props.context,
})
}
})
patch(FormViewDialog.prototype, {
setup () {
super.setup();
this.action = useService('action')
this.onOpenButtonClicked = this.onOpenButtonClicked.bind(this);
},
onOpenButtonClicked() {
this.action.doAction({
type: "ir.actions.act_window",
res_model: this.props.resModel,
res_id: this.props.resId,
views: [[false, "form"]],
target: "current",
context: this.props.context,
})
}
})

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-inherit="web.X2ManyFieldDialog" t-inherit-mode="extension">
<xpath expr="//t[@t-set-slot='footer']" position="inside">
<button class="btn btn-primary" t-on-click="() => this.onOpenButtonClicked()">Open</button>
</xpath>
</t>
<t t-inherit="web.FormViewDialog" t-inherit-mode="extension">
<t t-set-slot="footer" position="inside">
<button class="btn btn-secondary o_form_button_open" t-on-click="() => this.onOpenButtonClicked()">Open</button>
</t>
</t>
</templates>

View file

@ -1,45 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_service/tour_utils";
registry.category("web_tour.tours").add("full_formview_from_modal_tour", {
test: true,
url: '/web',
steps: () => [stepUtils.showAppsMenuItem(),
{
content: 'Go to contacts',
trigger: '.o_app[data-menu-xmlid="contacts.menu_contacts"]',
},
{
content: 'Click search',
trigger: '.o_searchview_input',
},
{
content: 'insert text in the search bar',
trigger: '.o_searchview_input',
run: 'text Test parent',
},
{
content: 'Validate search',
trigger: '.o_searchview_autocomplete .o_menu_item:contains("Name")',
},
{
content: 'Open the contact',
trigger: '.o_kanban_record .o_kanban_record_title span:contains("Test parent")',
},
{
content: 'Open the child',
trigger: 'div[name="child_ids"] .o_kanban_record:first-child',
},
{
"trigger": "button:contains('Open')",
"content": "Click the open button on the modal",
"run": "click",
},
{
content: 'Make sure the form view opens to Test Child',
trigger: 'div.o_last_breadcrumb_item span:contains("Test Child")',
}
]
});

View file

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

View file

@ -1,13 +0,0 @@
from odoo.tests import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestFullFormviewFromModal(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.parent = cls.env['res.partner'].create({'name': 'Test parent', })
cls.child = cls.env['res.partner'].create({'name': 'Test Child', 'parent_id': cls.parent.id})
def test_tour(self):
self.start_tour("/web", 'full_formview_from_modal_tour', login="demo")

View file

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

View file

@ -1,63 +0,0 @@
#
# 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 Addons from Git Repositories',
'version': '17.0.1.0.0',
'summary': 'A way to install addons from git repositories.',
'description': """
This module allows you to install addons from git repositories.
Configuration:
Set the directory where the repository will be cloned and the directory where the activated addons are located.
Usage:
You can add a git repository in the Apps application and then enabled the addons from it.
In the Apps application, you will see a new option to add git repositories.
This will allow you to select the repository and the branch you want to install.
You can then navigate to apps and in the menu, you will see a new option to enabled addons from git repositories.
""",
'category': 'Generic Modules/Others',
'author': 'Bemade Inc.',
'website': 'https://www.bemade.org',
'license': 'OPL-1',
'depends': [
'base_import_module'
],
'data': [
# 'data/default_directories_data.xml',
'security/ir.model.access.csv',
'views/git_repos_views.xml',
'views/res_settings_views.xml',
'views/action_and_menu.xml',
'wizard/directory_wizard_views.xml',
'wizard/git_repos_wizard_views.xml',
], 'demo': [],
'assets': {
'web.assets_backend': [
'/bemade_git_repos_addons/static/src/views/*/*',
],
},
'installable': True,
'auto_install': False,
}

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="0">
<!-- Server Action to set the default values -->
<record id="action_set_default_directories" model="ir.actions.server">
<field name="name">Set Default Directories</field>
<field name="model_id" ref="base.model_ir_config_parameter"/>
<field name="state">code</field>
<field name="code">
env['ir.config_parameter'].sudo().set_param('bemade_git_repos_addons.clone_dir', '.repos')
env['ir.config_parameter'].sudo().set_param('bemade_git_repos_addons.addons_dir', 'addons')
</field>
</record>
<!-- Automated Action -->
<record id="action_on_module_installation" model="base.automation">
<field name="name">On Module Installation</field>
<field name="model_id" ref="base.model_ir_module_module"/>
<field name="trigger">on_create</field>
<field name="filter_domain">[('name', '=', 'bemade_git_repos_addons'), ('state', '=', 'installed')]</field>
<field name="action_server_id" ref="action_set_default_directories"/>
</record>
</data>
</odoo>

View file

@ -1,4 +0,0 @@
from . import git_repos
from . import git_branch
from . import git_addons
from . import res_settings

View file

@ -1,28 +0,0 @@
from odoo import api, fields, models
import os
class GitAddons(models.Model):
_name = 'git.addons'
_description = 'Git Addons'
name = fields.Char(string='Addon Name')
branch_id = fields.Many2one('git.branch', string='Branch')
@api.model
def get_addons(self):
self.search([]).unlink() # Remove old records
branches = self.env['git.branch'].search([])
for branch in branches:
repos = branch.repos
addons_path = repos.addons_path
if os.path.isdir(addons_path):
addons = next(os.walk(addons_path))[1]
for addon in addons:
self.create({
'name': addon,
'branch_id': branch.id,
})
def action_update_addons(self):
self.get_addons()

View file

@ -1,18 +0,0 @@
from odoo import models, fields
class GitBranch(models.Model):
_name = 'git.branch'
_description = 'Git Branch'
name = fields.Char(string='Branch Name', required=True)
repo_id = fields.Many2one('git.repos', string='Repository')
active = fields.Boolean(string='Active', default=False)
branch_addons = fields.One2many(
comodel_name='git.addons',
inverse_name='branch_id',
string='Addons',
readonly=True)
# If there are additional fields or relations you need, please define them here

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