diff --git a/customer_itch_cycle/__manifest__.py b/customer_itch_cycle/__manifest__.py index 1f489f3..88f0256 100644 --- a/customer_itch_cycle/__manifest__.py +++ b/customer_itch_cycle/__manifest__.py @@ -1,10 +1,10 @@ - { 'name': 'Customer Itch Cycle Management', 'version': '1.0', 'depends': [ 'base', - 'sale_stock' + 'sale_stock', + 'web' ], 'author': 'Benoit Vézina', 'category': 'Sales Management', @@ -17,8 +17,15 @@ 'views/res_partner_view.xml', 'views/product_category_view.xml', 'security/ir.model.access.csv' - ], + 'assets': { + 'web.assets_backend': [ + 'customer_itch_cycle/static/src/js/cycle_product_partner_list_view.js', + ], + 'web.assets_backend_xml': [ + 'customer_itch_cycle/static/src/xml/process_history_button.xml', + ], + }, 'installable': True, 'application': False, } diff --git a/customer_itch_cycle/models/itch_cycle_product_partner.py b/customer_itch_cycle/models/itch_cycle_product_partner.py index d443659..0004478 100644 --- a/customer_itch_cycle/models/itch_cycle_product_partner.py +++ b/customer_itch_cycle/models/itch_cycle_product_partner.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta + import numpy as np from odoo import models, fields, api from odoo.exceptions import UserError @@ -11,167 +12,193 @@ class ItchCycleProductPartner(models.Model): partner_id = fields.Many2one( comodel_name='res.partner', string="Client", + help="Client associé à ce cycle", required=True ) product_id = fields.Many2one( comodel_name='product.product', string="Produit", + help="Produit associé à ce cycle", required=True ) - quantity_total_ordered = fields.Float( - string="Quantité totale commandée", - compute="_compute_quantity_total_ordered", - store=True - ) - - quantity_of_orders = fields.Integer( - string="Nombre de commandes", - compute="_compute_quantity_of_orders", - store=True - ) - - min_quantity_ordered = fields.Float( - string="Quantité minimale commandée", - compute="_compute_min_quantity_ordered", - store=True - ) - - max_quantity_ordered = fields.Float( - string="Quantité maximale commandée", - compute="_compute_max_quantity_ordered", - store=True - ) - - mean_quantity_ordered = fields.Float( - string="Quantité moyenne commandée", - compute="_compute_mean_quantity_ordered", - store=True - ) - - average_cycle = fields.Integer( - string="Cycle moyen (jours)", - compute="_compute_average_cycle", - store=True - ) - - manualy_set_cycle = fields.Integer( - string="Cycle moyen (jours) défini manuellement" - ) - - manualy_set_qty = fields.Float( - string="Quantité défini manuellement" - ) - - next_expected_date = fields.Date( - string="Prochaine vente prévue", - compute="_compute_next_expected_date", - store=True - ) - - manualy_set_next_date = fields.Date( - string="Prochaine vente prévue défini manuellement" - ) - - calc_next_date = fields.Date( - string="Prochaine vente prévue calculée", - compute="_compute_next_expected_date", - store=True - ) - - calc_average_cycle = fields.Integer( - string="Cycle moyen (jours) calculé", - compute="_compute_itch_cycle_duration", - store=True - ) - - calc_average_qty = fields.Float( - string="Quantité moyenne calculée", - compute="_compute_mean_quantity_ordered", - store=True - ) - - @api.depends('manualy_set_cycle','average_cycle') - def _compute_itch_cycle_duration(self): - for record in self: - if record.manualy_set_cycle: - record.calc_average_cycle = record.manualy_set_cycle - else: - record.calc_average_cycle = record.average_cycle - - @api.depends('manualy_set_qty','max_quantity_ordered') - def _compute_mean_quantity_ordered(self): - for record in self: - if record.manualy_set_qty: - record.calc_average_qty = record.manualy_set_qty - else: - record.calc_average_qty = record.max_quantity - - prediction_status = fields.Selection( - selection=[ - ('on_time', 'À l’heure'), - ('delayed', 'Retardée') - ], - string="Statut de la prédiction", - compute="_compute_prediction_status" - ) - sale_order_line_ids = fields.One2many( comodel_name='sale.order.line', inverse_name='itch_cycle_id', + help="Lignes de commande", string="Lignes de commande" ) - next_follow_up_date = fields.Date( + # Quantités: La plus part des champs sont informatifs et calculés automatiquement + + quantity_total_ordered = fields.Float( + string="Quantité totale commandée", + compute="_compute_sale_order_line_related_fields", + help="Quantité totale commandée par le client pour ce produit", + store=True + ) + + quantity_of_orders = fields.Integer( + string="Nombre de commandes", + compute="_compute_sale_order_line_related_fields", + help="Nombre de commandes passées par le client pour ce produit", + store=True + ) + + quantity_min_ordered = fields.Float( + string="Quantité minimale commandée", + compute="_compute_sale_order_line_related_fields", + help="Quantité minimale commandée par le client pour ce produit", + store=True + ) + + quantity_max_ordered = fields.Float( + string="Quantité maximale commandée", + compute="_compute_sale_order_line_related_fields", + help="Quantité maximale commandée par le client pour ce produit", + store=True + ) + + quantity_mean_ordered = fields.Float( + string="Quantité moyenne commandée", + compute="_compute_sale_order_line_related_fields", + help="Quantité moyenne commandée par le client pour ce produit", + store=True + ) + + quantity_manual_override = fields.Float( + string="Quantité défini manuellement", + help="Quantité défini manuellement" + ) + + quantity_planned = fields.Float( + string="Quantité prévue", + help="Quantité prévue pour la prochaine commande", + compute="_compute_quantity_planned", + store=True + ) + + # Cycles: La plupart des champs sont informatifs et calculés automatiquement + + cycle_duration_calculated = fields.Integer( + string="Cycle moyen (jours) calculé", + compute="_compute_average_cycle", + help="Cycle moyen calculé en jours", + store=True + ) + + cycle_duration_override = fields.Integer( + string="Cycle moyen (jours) défini manuellement", + help="Cycle moyen défini manuellement en jours" + ) + + cycle_duration = fields.Integer( + string="Cycle moyen (jours)", + compute="_compute_itch_cycle_duration", + help="Cycle moyen en jours", + store=True + ) + + # Dates: + + date_expected_evaluated = fields.Date( + string="Prochaine vente prévue par calcul", + compute="_compute_next_expected_date", + store=True + ) + + date_next_override = fields.Date( + string="Prochaine vente prévue défini manuellement", + help="Prochaine vente prévue défini manuellement" + ) + + date_expected = fields.Date( + string="Prochaine vente prévue", + compute="_compute_next_expected_date", + help="Prochaine vente prévue calculée", + store=True + ) + + date_next_follow_up = fields.Date( string="Prochaine date de suivi", compute="_compute_next_follow_up_date", + help="Prochaine date de suivi", store=True, readonly=False ) - last_purchase_date = fields.Date( + date_last_purchase = fields.Date( string="Date du dernier achat", compute="_compute_last_purchase_date", + help="Date du dernier achat", store=True ) - itch_cycle_duration = fields.Integer( - string="Durée du cycle (jours)", - help="Représente la durée moyenne du cycle en jours", - compute="_compute_itch_cycle_duration", - store=True + prediction_status = fields.Selection( + selection=[ + ('on_time', 'À l’heure'), + ('delayed', 'Retardée'), + ('archived', 'Archivée') + ], + string="Statut de la prédiction", + help="Statut de la prédiction", + compute="_compute_prediction_status" ) - @api.depends('average_cycle') + @api.depends('cycle_duration_override', 'cycle_duration_calculated') def _compute_itch_cycle_duration(self): - """Calcule automatiquement la durée du cycle.""" + """ + Calcule automatiquement la durée du cycle. + Si un cycle est défini manuellement, il est utilisé à la place. + Sinon, le cycle moyen calculé est utilisé. + """ for record in self: - record.itch_cycle_duration = record.average_cycle or 0 + if record.cycle_duration_override: + record.cycle_duration = record.cycle_duration_override + else: + record.cycle_duration = record.cycle_duration_calculated + + @api.depends('quantity_manual_override', 'quantity_max_ordered') + def _compute_mean_quantity_ordered(self): + """ + Calcule la quantité de la prochaine commande. + Si une quantité est définie manuellement, elle est utilisée sinon la quantité maximale est utilisée. + """ + for record in self: + if record.quantity_manual_override: + record.calc_average_qty = record.quantity_manual_override + else: + record.calc_average_qty = record.quantity_max_ordered @api.depends('sale_order_line_ids') def _compute_last_purchase_date(self): - """Met automatiquement à jour la date du dernier achat.""" + """ + Met automatiquement à jour la date du dernier achat. + """ for record in self: # Rechercher la commande la plus récente associée last_order = record.sale_order_line_ids.mapped('order_id').sorted(key=lambda o: o.date_order, reverse=True) - record.last_purchase_date = last_order[0].date_order if last_order else None + record.date_last_purchase = last_order[0].date_order if last_order else None - @api.depends('next_expected_date') + @api.depends('date_expected') def _compute_next_follow_up_date(self): """ - Définit la logique pour calculer automatiquement la date de suivi. - Peut être basée sur `next_expected_date`, mais modifiable par l'utilisateur. - """ + Définit la logique pour calculer automatiquement la date de suivi. + Peut être basée sur `next_expected_date`, mais modifiable par l'utilisateur. + """ for record in self: - if record.next_expected_date: + if record.date_expected: # Exemple : une alerte de suivi une semaine avant la prochaine vente prévue - record.next_follow_up_date = record.next_expected_date - timedelta(days=7) + record.date_next_follow_up = record.date_expected - timedelta(days=7) else: - record.next_follow_up_date = None + record.date_next_follow_up = None @api.depends('sale_order_line_ids', 'product_id.categ_id') def _compute_average_cycle(self): + """ + Calcule le cycle moyen en jours. + """ for record in self: product_category = record.product_id.categ_id if product_category.seasonal_factor and product_category.season_months: @@ -188,23 +215,31 @@ class ItchCycleProductPartner(models.Model): if len(dates) > 1: intervals = [(dates[i + 1] - dates[i]).days for i in range(len(dates) - 1)] - record.average_cycle = int(np.mean(intervals)) if intervals else 0 + record.cycle_duration_calculated = int(np.mean(intervals)) if intervals else 0 else: - record.average_cycle = 0 + record.cycle_duration_calculated = 0 - @api.depends('average_cycle') + @api.depends('cycle_duration') def _compute_next_expected_date(self): + """ + Calcule automatiquement la prochaine date de vente + """ for record in self: - if record.average_cycle and record.sale_order_line_ids: + if record.cycle_duration and record.sale_order_line_ids: last_date = max(record.sale_order_line_ids.mapped('order_id.date_order')) - record.next_expected_date = fields.Date.from_string(last_date) + timedelta(days=record.average_cycle) + record.date_expected_evaluated = fields.Date.from_string(last_date) + timedelta(days=record.cycle_duration) else: - record.next_expected_date = None + record.date_expected_evaluated = None def _compute_prediction_status(self): + """ + Calcule le statut de la prédiction. + """ today = fields.Date.context_today(self) for record in self: - if record.next_expected_date and record.next_expected_date < today: + if record.prediction_status == 'archived': + record.prediction_status = 'archived' + elif record.date_expected and record.date_expected < today: record.prediction_status = 'delayed' else: record.prediction_status = 'on_time' @@ -221,7 +256,6 @@ class ItchCycleProductPartner(models.Model): ('order_id.state', 'in', ['sale', 'done']), # Commandes confirmées ou terminées ('qty_delivered', '>', 0), # Quantité livrée supérieure à 0 ('product_id', '!=', False) # Exclure les lignes sans produit -# ('order_id.partner_id', '=', 5024659), # un client pour test ]) # Carte pour regrouper par couple (client, produit) @@ -262,8 +296,8 @@ class ItchCycleProductPartner(models.Model): 'sale_order_line_ids': [(6, 0, data['sale_order_line_ids'])] }) else: - #raise UserError("Impossible de créer un cycle sans client ou produit associé.") - print("Impossible de créer un cycle sans client ou produit associé.") + raise UserError('Impossible de créer un cycle sans client ou produit associé.') + print('Impossible de créer un cycle sans client ou produit associé.') print('valeur de product_id', product_id) print('valeur de partner_id', partner_id) else: @@ -274,26 +308,42 @@ class ItchCycleProductPartner(models.Model): return True @api.depends('sale_order_line_ids') - def _compute_quantity_total_ordered(self): + def _compute_sale_order_line_related_fields(self): + """ + Regroupe tous les calculs relatifs aux `sale.order.line`. + Calcule les statistiques principales : quantité totale, moyenne, minimum, maximum, etc. + """ for record in self: - record.quantity_total_ordered = sum(record.sale_order_line_ids.mapped('product_uom_qty')) + # Récupérer toutes les quantités des lignes de commande + quantities = record.sale_order_line_ids.mapped('product_uom_qty') - @api.depends('sale_order_line_ids') - def _compute_quantity_of_orders(self): - for record in self: + # Quantité totale + record.quantity_total_ordered = sum(quantities) + + # Nombre de commandes (lignes individuelles) record.quantity_of_orders = len(record.sale_order_line_ids) - @api.depends('sale_order_line_ids') - def _compute_min_quantity_ordered(self): - for record in self: - record.min_quantity_ordered = min(record.sale_order_line_ids.mapped('product_uom_qty')) + # Quantité minimale, maximale et moyenne + if quantities: + record.quantity_min_ordered = min(quantities) + record.quantity_max_ordered = max(quantities) + record.quantity_mean_ordered = np.mean(quantities) + else: + record.quantity_min_ordered = 0 + record.quantity_max_ordered = 0 + record.quantity_mean_ordered = 0 - @api.depends('sale_order_line_ids') - def _compute_max_quantity_ordered(self): - for record in self: - record.max_quantity_ordered = max(record.sale_order_line_ids.mapped('product_uom_qty')) + # Dernière date d'achat (commande la plus récente) + last_order = record.sale_order_line_ids.mapped('order_id').sorted(key=lambda o: o.date_order, reverse=True) + record.date_last_purchase = last_order[0].date_order if last_order else None - @api.depends('sale_order_line_ids') - def _compute_mean_quantity_ordered(self): + @api.depends('quantity_manual_override', 'quantity_mean_ordered') + def _compute_quantity_planned(self): + """ + Calcule la quantité prévue pour la prochaine commande. + """ for record in self: - record.mean_quantity_ordered = np.mean(record.sale_order_line_ids.mapped('product_uom_qty')) + if record.quantity_manual_override: + record.quantity_planned = record.quantity_manual_override + else: + record.quantity_planned = record.quantity_mean_ordered \ No newline at end of file diff --git a/customer_itch_cycle/static/src/js/cycle_product_partner_list_view.js b/customer_itch_cycle/static/src/js/cycle_product_partner_list_view.js new file mode 100644 index 0000000..eabe13c --- /dev/null +++ b/customer_itch_cycle/static/src/js/cycle_product_partner_list_view.js @@ -0,0 +1,35 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { listView } from "@web/views/list/list_view"; +import { ListController } from "@web/views/list/list_controller"; + +class CycleProductPartnerListController extends ListController { + setup() { + super.setup(); + this.orm = this.env.services.orm; // Service ORM pour effectuer des appels RPC + } + + async ProcessHistoryButton() { + try { + // Appel RPC via le service ORM + await this.orm.call("itch.cycle.product.partner", "populate_from_past_orders", []); + this.displayNotification({ + type: "success", + message: "L'action a été exécutée avec succès.", + }); + } catch (error) { + this.displayNotification({ + type: "danger", + message: `Une erreur s'est produite : ${error.message}`, + }); + } + } +} + +// Enregistrement de la vue personnalisée +registry.category("views").add("process_history_button_in_tree", { + ...listView, + Controller: CycleProductPartnerListController, + buttonTemplate: "customer_itch_cycle.button_in_tree", +}); \ No newline at end of file diff --git a/customer_itch_cycle/static/src/xml/process_history_button.xml b/customer_itch_cycle/static/src/xml/process_history_button.xml new file mode 100644 index 0000000..5768fdb --- /dev/null +++ b/customer_itch_cycle/static/src/xml/process_history_button.xml @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/customer_itch_cycle/views/itch_cycle_product_partner_view.xml b/customer_itch_cycle/views/itch_cycle_product_partner_view.xml index 6696f36..da80ddf 100644 --- a/customer_itch_cycle/views/itch_cycle_product_partner_view.xml +++ b/customer_itch_cycle/views/itch_cycle_product_partner_view.xml @@ -1,16 +1,17 @@ - + itch.cycle.product.partner.tree itch.cycle.product.partner - + - - - - - + + + + + + @@ -22,7 +23,7 @@
- +
@@ -30,22 +31,31 @@ - - - + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + @@ -67,16 +77,17 @@ - - Itch Cycles Produits + + Product Cycle Management itch.cycle.product.partner tree,form - + + action="action_cycle_product_partner"/> \ No newline at end of file diff --git a/customer_itch_cycle/views/res_partner_view.xml b/customer_itch_cycle/views/res_partner_view.xml index 5cd3564..cbac8b0 100644 --- a/customer_itch_cycle/views/res_partner_view.xml +++ b/customer_itch_cycle/views/res_partner_view.xml @@ -17,12 +17,10 @@ - - - - - - + + + +