diff --git a/customer_itch_cycle/__init__.py b/customer_itch_cycle/__init__.py index a9e3372..6ed2c21 100644 --- a/customer_itch_cycle/__init__.py +++ b/customer_itch_cycle/__init__.py @@ -1,2 +1,2 @@ - from . import models +from . import wizards \ No newline at end of file diff --git a/customer_itch_cycle/__manifest__.py b/customer_itch_cycle/__manifest__.py index 27d1fe4..ce70008 100644 --- a/customer_itch_cycle/__manifest__.py +++ b/customer_itch_cycle/__manifest__.py @@ -1,24 +1,37 @@ { - 'name': 'Customer Itch Cycle Management', - 'version': '1.0', + 'name': 'Customer Itch Cycle', + 'version': '17.0.1.0.0', + 'category': 'Sales', + 'summary': 'Manage customer product cycles and predict future sales', + 'description': """ + This module helps track and manage customer purchasing cycles: + * Track product purchase cycles per customer + * Predict next purchase dates + * Generate opportunities based on predictions + * Monitor delayed purchases + """, + 'author': 'DurPro', + 'website': 'https://www.durpro.com', 'depends': [ 'base', - 'sale_stock', - 'web' + 'sale', + 'sale_management', + 'crm', + 'sale_crm', + 'base_automation', ], - 'author': 'Benoit Vézina', - 'category': 'Sales Management', - 'summary': 'Manage customer itch cycles by product for proactive sales engagement.', - 'website': 'https://www.bemade.org', - 'description': "Manage customer itch cycles by product for proactive sales engagement.", - 'license': 'AGPL-3', 'data': [ + 'security/ir.model.access.csv', + 'data/month_data.xml', + 'wizards/apply_to_child_categories.xml', 'views/itch_cycle_product_partner_view.xml', 'views/res_partner_view.xml', - 'views/product_category_view.xml', 'views/itch_cycle_actions.xml', 'views/itch_cycle_menu.xml', - 'security/ir.model.access.csv' + 'views/crm_lead_view.xml', + 'views/product_category_view.xml', + 'data/ir_cron_data.xml', +# 'data/crm_automation_data.xml', ], 'assets': { 'web.assets_backend': [ @@ -27,5 +40,6 @@ ], }, 'installable': True, - 'application': False, + 'application': True, + 'license': 'LGPL-3', } diff --git a/customer_itch_cycle/data/ir_actions_server.xml b/customer_itch_cycle/data/ir_actions_server.xml new file mode 100644 index 0000000..7ae73e1 --- /dev/null +++ b/customer_itch_cycle/data/ir_actions_server.xml @@ -0,0 +1,13 @@ + + + + Reprocess All Cycles + + + list,form + code + + env['itch.cycle.product.partner'].populate_from_past_orders() + + + diff --git a/customer_itch_cycle/data/ir_cron_data.xml b/customer_itch_cycle/data/ir_cron_data.xml new file mode 100644 index 0000000..d59ddfe --- /dev/null +++ b/customer_itch_cycle/data/ir_cron_data.xml @@ -0,0 +1,34 @@ + + + + + + Sales Cycles: Create Opportunities + + code + model._cron_create_opportunities() + + 1 + days + -1 + + 5 + + + + + + Sales Cycles: Process History + + code + model.populate_from_past_orders() + + 1 + days + -1 + + 10 + + + + diff --git a/customer_itch_cycle/data/month_data.xml b/customer_itch_cycle/data/month_data.xml new file mode 100644 index 0000000..ef9552f --- /dev/null +++ b/customer_itch_cycle/data/month_data.xml @@ -0,0 +1,66 @@ + + + + + + January + 1 + 1 + + + February + 2 + 2 + + + March + 3 + 3 + + + April + 4 + 4 + + + May + 5 + 5 + + + June + 6 + 6 + + + July + 7 + 7 + + + August + 8 + 8 + + + September + 9 + 9 + + + October + 10 + 10 + + + November + 11 + 11 + + + December + 12 + 12 + + + diff --git a/customer_itch_cycle/models/__init__.py b/customer_itch_cycle/models/__init__.py index 5bc4a0e..5875670 100644 --- a/customer_itch_cycle/models/__init__.py +++ b/customer_itch_cycle/models/__init__.py @@ -2,3 +2,4 @@ from . import itch_cycle_product_partner from . import sale_order_line from . import product_category from . import res_partner +from . import crm_lead diff --git a/customer_itch_cycle/models/crm_lead.py b/customer_itch_cycle/models/crm_lead.py new file mode 100644 index 0000000..6827a80 --- /dev/null +++ b/customer_itch_cycle/models/crm_lead.py @@ -0,0 +1,42 @@ +from odoo import models, fields + + +class CrmLead(models.Model): + """ + Extend CRM Lead model to add purchase cycle information. + + Adds a link to the purchase cycle associated with the opportunity. + This allows tracking customer purchasing patterns and forecasting + future opportunities based on historical data. + """ + + _inherit = 'crm.lead' + + itch_cycle_id = fields.Many2one( + comodel_name='itch.cycle.product.partner', + string='Purchase Cycle', + ondelete='set null', + help=""" + Linked purchase cycle for this opportunity. + Used to track the customer's purchasing patterns. + """ + ) + + date_last_purchase = fields.Date( + related='itch_cycle_id.date_last_purchase', + string='Last Purchase Date', + readonly=True + ) + + cycle_duration = fields.Integer( + related='itch_cycle_id.cycle_duration', + string='Cycle Duration (days)', + readonly=True + ) + + cycle_sales = fields.One2many( + comodel_name='sale.order.line', + related='itch_cycle_id.sale_order_line_ids', + string='Cycle Sales', + readonly=True + ) diff --git a/customer_itch_cycle/models/itch_cycle_product_partner.py b/customer_itch_cycle/models/itch_cycle_product_partner.py index 0004478..5727fdb 100644 --- a/customer_itch_cycle/models/itch_cycle_product_partner.py +++ b/customer_itch_cycle/models/itch_cycle_product_partner.py @@ -1,349 +1,903 @@ -from datetime import datetime, timedelta - +from datetime import timedelta, date +import logging import numpy as np from odoo import models, fields, api -from odoo.exceptions import UserError +from odoo.exceptions import ValidationError, UserError + +_logger = logging.getLogger(__name__) + class ItchCycleProductPartner(models.Model): + """ + Model representing the purchase cycle between a product and a partner. + + Tracks purchase patterns, predicts future orders, and manages related + opportunities. + + Attributes: + _name (str): Model technical name + _description (str): Model description + _inherit (list): Inherited models + """ _name = "itch.cycle.product.partner" - _description = "Cycle Produit/Partenaire" + _description = "Product/Partner Purchase Cycle" + _inherit = ["mail.thread", "mail.activity.mixin"] + + _sql_constraints = [ + ( + "positive_cycle_duration", + "CHECK(cycle_duration_override >= 0)", + "The forced cycle duration must be positive, zero or FALSE." + ) + ] + + active = fields.Boolean( + string="Active", + default=True, + help="If unchecked, this cycle will be considered archived", + tracking=True + ) partner_id = fields.Many2one( - comodel_name='res.partner', - string="Client", - help="Client associé à ce cycle", - required=True + comodel_name="res.partner", + string="Customer", + help="Customer associated with this cycle", + required=True, + tracking=True + ) + + partner_email = fields.Char( + related="partner_id.email", + string="Partner Email" + ) + + partner_phone = fields.Char( + related="partner_id.phone", + string="Partner Phone" + ) + + partner_mobile = fields.Char( + related="partner_id.mobile", + string="Partner Mobile" + ) + + partner_city = fields.Char( + related="partner_id.city", + string="Partner City" + ) + + partner_country_id = fields.Many2one( + related="partner_id.country_id", + string="Partner Country" ) product_id = fields.Many2one( - comodel_name='product.product', - string="Produit", - help="Produit associé à ce cycle", - required=True + comodel_name="product.product", + string="Product", + help="Product associated with this cycle", + required=True, + tracking=True + ) + + product_categ_id = fields.Many2one( + related="product_id.categ_id", + string="Product Category" + ) + + product_type = fields.Selection( + related="product_id.type", + string="Product Type" + ) + + product_lst_price = fields.Float( + related="product_id.lst_price", + string="Product List Price" + ) + + product_qty_available = fields.Float( + related="product_id.qty_available", + string="Product Quantity Available" ) sale_order_line_ids = fields.One2many( - comodel_name='sale.order.line', - inverse_name='itch_cycle_id', - help="Lignes de commande", - string="Lignes de commande" + comodel_name="sale.order.line", + inverse_name="itch_cycle_id", + string="Sale Order Lines", + help="Historical sales order lines for this product/partner combination" ) - # Quantités: La plus part des champs sont informatifs et calculés automatiquement + opportunity_ids = fields.Many2many( + comodel_name="crm.lead", + string="Related Opportunities", + domain=[("type", "=", "opportunity")], + help="Opportunities automatically generated from cycle predictions" + ) + + opportunity_count = fields.Integer( + string="Number of Opportunities", + compute="_compute_opportunity_count", + help="Count of related opportunities" + ) quantity_total_ordered = fields.Float( - string="Quantité totale commandée", + string="Total Quantity Ordered", compute="_compute_sale_order_line_related_fields", - help="Quantité totale commandée par le client pour ce produit", + help="Total quantity ordered by the customer for this product", store=True ) quantity_of_orders = fields.Integer( - string="Nombre de commandes", + string="Number of Orders", compute="_compute_sale_order_line_related_fields", - help="Nombre de commandes passées par le client pour ce produit", + help="Number of orders placed by the customer for this product", store=True ) quantity_min_ordered = fields.Float( - string="Quantité minimale commandée", + string="Minimum Quantity Ordered", compute="_compute_sale_order_line_related_fields", - help="Quantité minimale commandée par le client pour ce produit", + help="Minimum quantity ordered by the customer for this product", store=True ) quantity_max_ordered = fields.Float( - string="Quantité maximale commandée", + string="Maximum Quantity Ordered", compute="_compute_sale_order_line_related_fields", - help="Quantité maximale commandée par le client pour ce produit", + help="Maximum quantity ordered by the customer for this product", store=True ) quantity_mean_ordered = fields.Float( - string="Quantité moyenne commandée", + string="Average Quantity Ordered", compute="_compute_sale_order_line_related_fields", - help="Quantité moyenne commandée par le client pour ce produit", + help="Average quantity ordered by the customer for this product", store=True ) quantity_manual_override = fields.Float( - string="Quantité défini manuellement", - help="Quantité défini manuellement" + string="Manual Quantity Override", + help="Manually defined quantity", + tracking=True ) quantity_planned = fields.Float( - string="Quantité prévue", - help="Quantité prévue pour la prochaine commande", + string="Planned Quantity", + help="Planned quantity for the next order", compute="_compute_quantity_planned", - store=True + store=True, + tracking=True ) - # Cycles: La plupart des champs sont informatifs et calculés automatiquement - cycle_duration_calculated = fields.Integer( - string="Cycle moyen (jours) calculé", + string="Calculated Average Cycle (days)", compute="_compute_average_cycle", - help="Cycle moyen calculé en jours", + help="Calculated average cycle in days", store=True ) cycle_duration_override = fields.Integer( - string="Cycle moyen (jours) défini manuellement", - help="Cycle moyen défini manuellement en jours" + string="Cycle Duration (forced)", + help="Cycle duration in days (forced value)", + default=0, + tracking=True ) cycle_duration = fields.Integer( - string="Cycle moyen (jours)", + string="Average Cycle (days)", compute="_compute_itch_cycle_duration", - help="Cycle moyen en jours", + help="Average cycle in days", store=True ) - # Dates: - date_expected_evaluated = fields.Date( - string="Prochaine vente prévue par calcul", - compute="_compute_next_expected_date", + string="Next Expected Sale by Calculation", + compute="_compute_date_expected_evaluated", 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_override = fields.Date( + string="Next Expected Sale Manual Override", + help="Manually defined next expected sale date", + tracking=True ) date_expected = fields.Date( - string="Prochaine vente prévue", - compute="_compute_next_expected_date", - help="Prochaine vente prévue calculée", - store=True + string="Expected Date", + help="Expected date for the next order", + compute="_compute_date_expected", + store=True, + tracking=True ) date_next_follow_up = fields.Date( - string="Prochaine date de suivi", + string="Follow-up Date", + help="Planned follow-up date", compute="_compute_next_follow_up_date", - help="Prochaine date de suivi", store=True, - readonly=False + readonly=False, + tracking=True ) date_last_purchase = fields.Date( - string="Date du dernier achat", + string="Last Purchase Date", compute="_compute_last_purchase_date", - help="Date du dernier achat", + help="Date of last purchase", store=True ) - prediction_status = fields.Selection( + state = fields.Selection( selection=[ - ('on_time', 'À l’heure'), - ('delayed', 'Retardée'), - ('archived', 'Archivée') + ("new", "New"), + ("pending", "Pending"), + ("on_time", "On Time"), + ("upcoming", "Upcoming"), + ("delayed", "Delayed"), + ("critical", "Critical"), + ("archived", "Archived"), ], - string="Statut de la prédiction", - help="Statut de la prédiction", - compute="_compute_prediction_status" + string="State", + help="Current cycle state", + compute="_compute_state", + store=True ) - @api.depends('cycle_duration_override', 'cycle_duration_calculated') + notes = fields.Text( + string="Notes", + help="Additional notes about this cycle", + tracking=True + ) + + deviation_percent = fields.Float( + string="Average Deviation (%)", + compute="_compute_deviation", + help="Percentage deviation from average orders", + store=True + ) + + name = fields.Char( + string='Name', + compute='_compute_name', + store=True, + ) + + @api.depends('partner_id.name', 'product_id.name') + def _compute_name(self): + for record in self: + record.name = f"{record.partner_id.name}/{record.product_id.name}" + + @api.depends("cycle_duration_override", "cycle_duration_calculated") def _compute_itch_cycle_duration(self): - """ - 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é. + """Compute the cycle duration. + + If a cycle is manually defined, it is used instead. + Otherwise, the calculated average cycle is used. """ for record in self: - if record.cycle_duration_override: - record.cycle_duration = record.cycle_duration_override - else: - record.cycle_duration = record.cycle_duration_calculated + record.cycle_duration = ( + record.cycle_duration_override or + 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. + @api.depends("quantity_manual_override", "quantity_mean_ordered") + def _compute_quantity_planned(self): + """Compute the planned quantity for the next order. + + If a quantity is manually defined, it is used. + Otherwise, the average quantity is used. """ 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 + record.quantity_planned = ( + record.quantity_manual_override or + record.quantity_mean_ordered + ) - @api.depends('sale_order_line_ids') + @api.depends("sale_order_line_ids") def _compute_last_purchase_date(self): - """ - Met automatiquement à jour la date du dernier achat. - """ + """Update the last purchase date.""" 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.date_last_purchase = last_order[0].date_order if last_order else None + orders = record.sale_order_line_ids.mapped('order_id') + last_order = orders.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('date_expected') + @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. + """Compute the follow-up date. + + Can be based on `next_expected_date`, + but modifiable by the user. """ for record in self: - if record.date_expected: - # Exemple : une alerte de suivi une semaine avant la prochaine vente prévue - record.date_next_follow_up = record.date_expected - timedelta(days=7) - else: - record.date_next_follow_up = None + record.date_next_follow_up = ( + record.date_expected - timedelta(days=7) + if record.date_expected + else None + ) - @api.depends('sale_order_line_ids', 'product_id.categ_id') + @api.depends("sale_order_line_ids", "product_id.categ_id") def _compute_average_cycle(self): - """ - Calcule le cycle moyen en jours. + """Calculate the average cycle in days. + + Takes into account seasonal factors + if defined in product category. """ for record in self: product_category = record.product_id.categ_id - if product_category.seasonal_factor and product_category.season_months: - # Filtrer les commandes par mois saisonniers de la catégorie - active_months = list(map(int, product_category.season_months.split(','))) + if (product_category.seasonal_factor and + product_category.season_months): + active_months = list(map( + int, + product_category.season_months.split(',') + )) dates = sorted([ line.order_id.date_order for line in record.sale_order_line_ids if line.order_id.date_order.month in active_months ]) else: - # Cycle habituel (non saisonnier) - dates = sorted(record.sale_order_line_ids.mapped('order_id.date_order')) + dates = sorted( + record.sale_order_line_ids.mapped('order_id.date_order') + ) if len(dates) > 1: - intervals = [(dates[i + 1] - dates[i]).days for i in range(len(dates) - 1)] - record.cycle_duration_calculated = int(np.mean(intervals)) if intervals else 0 + intervals = [ + (dates[i + 1] - dates[i]).days + for i in range(len(dates) - 1) + ] + record.cycle_duration_calculated = ( + int(np.mean(intervals)) if intervals else 0 + ) else: record.cycle_duration_calculated = 0 - @api.depends('cycle_duration') - def _compute_next_expected_date(self): - """ - Calcule automatiquement la prochaine date de vente + @api.constrains("cycle_duration_override", "date_expected_override") + def _check_cycle_constraints(self): + """Validate cycle constraints. + + Ensures forced cycle duration is positive + and expected date is not in past. """ for record in self: - if record.cycle_duration and record.sale_order_line_ids: - last_date = max(record.sale_order_line_ids.mapped('order_id.date_order')) - record.date_expected_evaluated = fields.Date.from_string(last_date) + timedelta(days=record.cycle_duration) - else: - record.date_expected_evaluated = None + if record.cycle_duration_override and record.cycle_duration_override < 0: + raise ValidationError('The forced cycle duration must be positive.') + if record.date_expected_override and record.date_expected_override < fields.Date.today(): + raise ValidationError('The forced expected date cannot be in the past.') - def _compute_prediction_status(self): + @api.constrains('product_id') + def _check_product_category_tracked(self): + """Ensure cycles can only be created for products in tracked categories. + + Raises ValidationError if product category + is not configured for cycle tracking. """ - Calcule le statut de la prédiction. - """ - today = fields.Date.context_today(self) for record in self: - if record.prediction_status == 'archived': - record.prediction_status = 'archived' - elif record.date_expected and record.date_expected < today: - record.prediction_status = 'delayed' + if not record.product_id.categ_id.is_cycle_tracked: + msg = ( + f"Cannot create cycle for product '{record.product_id.name}' " + f"because its category '{record.product_id.categ_id.name}' " + "is not configured for cycle tracking. Enable 'Track Sales Cycle' " + "in the product category settings first." + ) + raise ValidationError(msg) + + @api.depends( + 'cycle_duration', + 'date_last_purchase' + ) + def _compute_date_expected_evaluated(self): + """Calculate next sale date based on average cycle and last purchase date. + + Handles edge cases and logs warnings for invalid data. + """ + for record in self: + try: + # Check if cycle_duration is valid + if not isinstance(record.cycle_duration, int): + record.date_expected_evaluated = None + _logger.warning( + "Invalid cycle duration type for record %s: " + "Expected int, got %s", + record.id, type(record.cycle_duration) + ) + continue + + if record.cycle_duration < 0: + record.date_expected_evaluated = None + _logger.warning( + "Invalid cycle duration value for record %s: " + "Must be positive, got %s", + record.id, record.cycle_duration + ) + continue + + # Check if date_last_purchase is valid + if not isinstance(record.date_last_purchase, date): + record.date_expected_evaluated = None + _logger.warning( + "Invalid last purchase date type for record %s: " + "Expected date, got %s", + record.id, type(record.date_last_purchase) + ) + continue + + # Calculate expected date + record.date_expected_evaluated = ( + record.date_last_purchase + + timedelta(days=record.cycle_duration) + ) + + _logger.info( + "Successfully computed date_expected_evaluated for record %s: " + "Last purchase: %s, Cycle duration: %s days, " + "Expected date: %s", + record.id, record.date_last_purchase, + record.cycle_duration, record.date_expected_evaluated + ) + + except Exception as e: + record.date_expected_evaluated = None + _logger.error( + "Error computing date_expected_evaluated for record %s: %s", + record.id, str(e) + ) + + @api.depends( + 'date_expected_evaluated', + 'date_expected_override', + 'cycle_duration', + 'date_last_purchase' + ) + def _compute_date_expected(self): + """Calculate expected date considering: + + - The override date if defined + - The evaluated date if future + - The last order date + cycle if a cycle is defined + - Otherwise None + """ + for record in self: + if (record.date_expected_override and + record.date_expected_override > fields.Date.today()): + record.date_expected = record.date_expected_override + elif record.date_expected_evaluated: + record.date_expected = record.date_expected_evaluated else: - record.prediction_status = 'on_time' + record.date_expected = None + + @api.depends('date_expected', 'active', 'quantity_of_orders') + def _compute_state(self): + """Determine current state based on various conditions. + + Possible states: new, pending, on_time, + upcoming, delayed, critical, archived. + """ + today = fields.Date.today() + for record in self: + if not record.active: + record.state = 'archived' + continue + + if record.quantity_of_orders < 2: + record.state = 'new' + elif not record.date_expected: + record.state = 'pending' + elif record.date_expected < today: + days_late = (today - record.date_expected).days + record.state = 'critical' if days_late > 30 else 'delayed' + elif (record.date_expected - today).days <= 7: + record.state = 'upcoming' + else: + record.state = 'on_time' @api.model def populate_from_past_orders(self): + """Process historical sales data to create or update product cycles. + + Analyzes past orders to calculate + cycle durations and expected dates. """ - Méthode pour traiter le passé : - Crée des enregistrements 'itch.cycle.product.partner' - basés sur les commandes de vente existantes. - """ - # Récupérer toutes les lignes de commandes confirmées - sale_order_lines = self.env['sale.order.line'].search([ - ('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 + _logger.info("Starting historical sales data processing...") + + sale_lines = self.env['sale.order.line'].search([ + ('state', 'in', ['sale', 'done']), + ('order_id.state', 'in', ['sale', 'done']), + ('order_id.date_order', '!=', False), + ('product_id', '!=', False), + ('order_id.partner_id', '!=', False), + ('qty_delivered', '!=', 0) ]) - - # Carte pour regrouper par couple (client, produit) - cycle_data = {} - - for line in sale_order_lines: + + partner_product_lines = {} + total_lines = len(sale_lines) + + for i, line in enumerate(sale_lines, 1): + if i % (total_lines // 10 or 1) == 0: + progress = (i / total_lines) * 100 + _logger.info(f"Progress: {progress:.0f}%") + + if not (line.product_id and line.order_id.partner_id and line.order_id.date_order): + continue + key = (line.order_id.partner_id.id, line.product_id.id) - if key not in cycle_data: - cycle_data[key] = { - 'partner_id': line.order_id.partner_id.id, - 'product_id': line.product_id.id, - 'sale_order_line_ids': [] - } - cycle_data[key]['sale_order_line_ids'].append(line.id) # Ajouter l'ID de la ligne - - # Créer les enregistrements manquants dans Itch Cycle - for key, data in cycle_data.items(): - partner_id, product_id = key - - # Vérifier si l'enregistrement existe déjà - itch_cycle = self.search([('partner_id', '=', partner_id), ('product_id', '=', product_id)], limit=1) - - if not itch_cycle: - # Calculer le cycle moyen basé sur les dates de commandes - order_dates = sorted([self.env['sale.order.line'].browse(line_id).order_id.date_order - for line_id in data['sale_order_line_ids']]) - if len(order_dates) > 1: - intervals = [(order_dates[i + 1] - order_dates[i]).days for i in range(len(order_dates) - 1)] - average_cycle = sum(intervals) // len(intervals) if intervals else 0 - else: - average_cycle = 0 - - # Créer l'enregistrement dans la table Itch Cycle - if product_id and partner_id: - self.create({ - 'partner_id': partner_id, - 'product_id': product_id, - '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é.') - print('valeur de product_id', product_id) - print('valeur de partner_id', partner_id) - else: - # Mettre à jour les lignes de commande si nécessaire - itch_cycle.write({ - 'sale_order_line_ids': [(6, 0, data['sale_order_line_ids'])], # Lier ou actualiser les lignes + if key not in partner_product_lines: + partner_product_lines[key] = [] + partner_product_lines[key].append(line) + + notifications = [] + processed_count = 0 + error_count = 0 + total_combinations = len(partner_product_lines) + + for (partner_id, product_id), lines in partner_product_lines.items(): + cr = self.env.cr + try: + with cr.savepoint(): + if not partner_id or not product_id: + continue + + sorted_lines = sorted( + lines, + key=lambda line: line.order_id.date_order or fields.Datetime.now() + ) + + total_quantity = sum(line.product_uom_qty for line in sorted_lines) + mean_quantity = total_quantity / len(sorted_lines) + last_order_date = sorted_lines[-1].order_id.date_order if sorted_lines else False + + if len(sorted_lines) > 1: + time_diffs = [ + (sorted_lines[i].order_id.date_order - + sorted_lines[i-1].order_id.date_order).days + for i in range(1, len(sorted_lines)) + if sorted_lines[i].order_id.date_order and + sorted_lines[i-1].order_id.date_order + ] + + if time_diffs: + cycle_duration = sum(time_diffs) / len(time_diffs) + if cycle_duration < 1: + cycle_duration = 1 + else: + cycle_duration = 30 + else: + cycle_duration = 30 + + date_expected = last_order_date + timedelta(days=cycle_duration) if last_order_date else False + + cycle = self.with_context(active_test=False).search([ + ('partner_id', '=', partner_id), + ('product_id', '=', product_id) + ]) + + values = { + 'quantity_mean_ordered': mean_quantity, + 'quantity_total_ordered': total_quantity, + 'cycle_duration': cycle_duration, + 'sale_order_line_ids': [(6, 0, [line.id for line in sorted_lines])], + 'date_expected': date_expected, + 'date_last_purchase': last_order_date, + 'active': True, + } + + if cycle: + cycle.write(values) + else: + values.update({ + 'partner_id': partner_id, + 'product_id': product_id, + }) + self.create(values) + + processed_count += 1 + if processed_count % (total_combinations // 10 or 1) == 0: + progress = (processed_count / total_combinations) * 100 + _logger.info(f"Progress: {progress:.0f}%") + + except Exception as e: + error_count += 1 + _logger.error(f"Error processing partner {partner_id} and product {product_id}: {str(e)}") + notifications.append({ + 'params': { + 'message': f"Error processing partner {partner_id} and product {product_id}: {str(e)}", + 'type': 'warning', + 'sticky': False + } }) - return True + continue + + # Add final notification + status = 'warning' if error_count > 0 else 'success' + message = f"Processed {processed_count} combinations" + if error_count > 0: + message += f" with {error_count} errors" + + notifications.append({ + 'params': { + 'message': message, + 'type': status, + 'sticky': False + } + }) + + return { + 'tag': 'display_notifications', + 'params': {'notifications': notifications} + } - @api.depends('sale_order_line_ids') + @api.depends( + 'sale_order_line_ids' + ) 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. + """Compute statistics from related sale order lines. + + Calculates: + - Total quantity ordered + - Number of orders + - Minimum quantity ordered + - Maximum quantity ordered + - Average quantity ordered """ for record in self: - # Récupérer toutes les quantités des lignes de commande quantities = record.sale_order_line_ids.mapped('product_uom_qty') - - # Quantité totale - record.quantity_total_ordered = sum(quantities) - - # Nombre de commandes (lignes individuelles) - record.quantity_of_orders = len(record.sale_order_line_ids) - - # Quantité minimale, maximale et moyenne + record.quantity_of_orders = len(quantities) if quantities: + record.quantity_total_ordered = sum(quantities) record.quantity_min_ordered = min(quantities) record.quantity_max_ordered = max(quantities) - record.quantity_mean_ordered = np.mean(quantities) + record.quantity_mean_ordered = sum(quantities) / len(quantities) else: + record.quantity_total_ordered = 0 record.quantity_min_ordered = 0 record.quantity_max_ordered = 0 record.quantity_mean_ordered = 0 - # 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('quantity_manual_override', 'quantity_mean_ordered') - def _compute_quantity_planned(self): - """ - Calcule la quantité prévue pour la prochaine commande. + @api.constrains( + 'quantity_manual_override' + ) + def _check_quantity_manual_override(self): + """Validate manual quantity override. + + Ensures manual quantity override is positive. + Raises ValidationError if negative value + is provided. """ for record in self: - if record.quantity_manual_override: - record.quantity_planned = record.quantity_manual_override + if record.quantity_manual_override < 0: + raise ValidationError('The manual quantity override must be positive.') + + @api.depends( + 'quantity_planned', + 'quantity_mean_ordered' + ) + def _compute_deviation(self): + """Calculate deviation between planned and average quantity. + + Returns percentage difference between + planned quantity and historical average. + """ + for record in self: + if record.quantity_mean_ordered and record.quantity_planned: + record.deviation_percent = ((record.quantity_planned - record.quantity_mean_ordered) / record.quantity_mean_ordered) * 100 else: - record.quantity_planned = record.quantity_mean_ordered \ No newline at end of file + record.deviation_percent = 0 + + @api.depends( + 'opportunity_ids' + ) + def _compute_opportunity_count(self): + """Count related opportunities. + + Returns number of opportunities + linked to this cycle. + """ + for record in self: + record.opportunity_count = len(record.opportunity_ids) + + def action_view_opportunities(self): + """Open CRM opportunities view. + + Displays all opportunities related + to this cycle in pipeline view. + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("crm.crm_lead_action_pipeline") + action['domain'] = [('id', 'in', self.opportunity_ids.ids)] + action['context'] = { + 'default_partner_id': self.partner_id.id, + 'default_expected_revenue': self.product_lst_price * self.quantity_mean_ordered, + } + return action + + def _create_opportunity_data(self): + """Prepare data for opportunity creation. + + Returns: + dict: Dictionary containing opportunity data + """ + self.ensure_one() + expected_date = self.date_expected or fields.Date.today() + expected_revenue = self.product_lst_price * self.quantity_mean_ordered + + return { + 'name': f"Predicted Sale: {self.product_id.name}", + 'partner_id': self.partner_id.id, + 'type': 'opportunity', + 'expected_revenue': expected_revenue, + 'date_deadline': expected_date, + 'itch_cycle_id': self.id, + 'description': f""" +

Automatically generated from sales cycle prediction

+ + """, + } + + def create_opportunity(self): + """Create new opportunity from cycle prediction. + + Generates opportunity with expected revenue + and deadline based on cycle data. + """ + self.ensure_one() + + # Create opportunity using prepared data + opportunity = self.env['crm.lead'].create( + self._create_opportunity_data() + ) + + # Link the opportunity to this cycle + self.write({ + 'opportunity_ids': [(4, opportunity.id)] + }) + + return { + 'type': 'ir.actions.act_window', + 'res_model': 'crm.lead', + 'res_id': opportunity.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_create_opportunity(self): + """Create opportunity from button click. + + Wrapper method for create_opportunity() + to handle button actions. + """ + return self.create_opportunity() + + def action_view_latest_opportunity(self): + """View most recent opportunity. + + Opens form view of the latest created + opportunity for this cycle. + """ + self.ensure_one() + if not self.opportunity_ids: + raise UserError("Aucune opportunité associée à ce cycle.") + + latest_opportunity = self.opportunity_ids.sorted(key=lambda o: o.create_date, reverse=True)[0] + return { + 'type': 'ir.actions.act_window', + 'res_model': 'crm.lead', + 'res_id': latest_opportunity.id, + 'view_mode': 'form', + 'target': 'current', + } + + def action_create_batch_opportunities(self): + """ + Create opportunities in batch for selected cycles. + + Returns: + dict: Action result to display notification + """ + created_count = 0 + error_count = 0 + + for cycle in self: + try: + # Check if there's already an open opportunity for this cycle + existing_opportunities = cycle.opportunity_ids.filtered( + lambda o: not o.stage_id.is_won + ) + + if existing_opportunities: + continue + + # Create new opportunity using prepared data + opportunity = self.env['crm.lead'].create( + cycle._create_opportunity_data() + ) + cycle.write({ + 'opportunity_ids': [(4, opportunity.id)] + }) + created_count += 1 + + except Exception as e: + error_count += 1 + _logger.error( + f"Error creating opportunity for cycle {cycle.id}: {str(e)}" + ) + continue + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Batch Opportunity Creation', + 'message': f"Created {created_count} opportunities, {error_count} errors", + 'sticky': False, + 'type': 'success' if error_count == 0 else 'warning', + } + } + + def _cron_create_opportunities(self): + """Automatically create opportunities for upcoming predicted sales. + + This cron job will: + - Find all active cycles with expected dates in the next 30 days + - Create new opportunities for cycles without existing open opportunities + - Log the results of the operation + """ + _logger.info("Starting automatic opportunity creation...") + + # Find upcoming cycles in the next 30 days + today = fields.Date.today() + upcoming_cycles = self.search([ + ('date_expected', '!=', False), + ('date_expected', '>=', today), + ('date_expected', '<=', today + timedelta(days=30)), + ('active', '=', True), + ]) + + created_count = 0 + skipped_count = 0 + error_count = 0 + + for cycle in upcoming_cycles: + try: + # Check if there's already an open opportunity for this cycle + existing_opportunities = cycle.opportunity_ids.filtered( + lambda o: not o.stage_id.is_won and + o.date_deadline >= today + ) + + if existing_opportunities: + skipped_count += 1 + continue + + # Create new opportunity using prepared data + opportunity = self.env['crm.lead'].create( + cycle._create_opportunity_data() + ) + cycle.write({ + 'opportunity_ids': [(4, opportunity.id)] + }) + created_count += 1 + + except Exception as e: + error_count += 1 + _logger.error( + f"Error creating opportunity for cycle {cycle.id}: {str(e)}" + ) + continue + + # Log final results + _logger.info( + f"Opportunity creation completed: " + f"{created_count} created, " + f"{skipped_count} skipped, " + f"{error_count} errors" + ) + + return { + 'created_count': created_count, + 'skipped_count': skipped_count, + 'error_count': error_count, + } diff --git a/customer_itch_cycle/models/product_category.py b/customer_itch_cycle/models/product_category.py index 0602138..36c914d 100644 --- a/customer_itch_cycle/models/product_category.py +++ b/customer_itch_cycle/models/product_category.py @@ -1,15 +1,124 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError +import logging + + +_logger = logging.getLogger(__name__) + +class Month(models.Model): + """ + Model to represent months for seasonal tracking. + This allows for better organization and selection of active months + for seasonal product categories. + """ + _name = 'itch.month' + _description = 'Month for Seasonal Tracking' + _order = 'sequence' + + name = fields.Char(string='Name', required=True, translate=True) + sequence = fields.Integer(string='Sequence', required=True) + number = fields.Integer(string='Month Number', required=True) + + _sql_constraints = [ + ('unique_number', 'unique(number)', 'Month number must be unique!'), + ('number_range', 'CHECK(number >= 1 AND number <= 12)', 'Month number must be between 1 and 12!') + ] -from odoo import models, fields class ProductCategory(models.Model): + """ + Extends the product category model to add seasonal behavior functionality. + + This is used in the sales cycle calculation to better predict next sales + by taking into account seasonal patterns. + """ _inherit = 'product.category' - seasonal_factor = fields.Boolean( - string="Catégorie saisonnière", - default=False + is_cycle_tracked = fields.Boolean( + string="Track Sales Cycle", + help="If enabled, products in this category will be tracked by the sales cycle system", + default=True, ) - season_months = fields.Char( - string="Mois actifs", - help="Mois pendant lesquels cette catégorie est active (ex: '3,4,5' pour mars-mai)" + temp_is_cycle_tracked = fields.Boolean( + string="Temporary Track Sales Cycle", + help="Technical field to track changes in is_cycle_tracked", + default=True, ) + + @api.onchange('is_cycle_tracked') + def _onchange_is_cycle_tracked(self): + """Store the new value temporarily and restore the original value""" + if self.id and self.is_cycle_tracked != self.temp_is_cycle_tracked: + self.temp_is_cycle_tracked = self.is_cycle_tracked + self.is_cycle_tracked = not self.is_cycle_tracked + return { + 'warning': { + 'title': 'Confirmation Required', + 'message': 'Please use the "Apply Changes" button to change tracking status.' + } + } + + def action_apply_cycle_tracked(self): + """Open wizard to apply cycle tracked changes""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Apply to Child Categories', + 'res_model': 'itch.apply.to.child.categories', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_category_id': self.id, + 'default_new_value': self.temp_is_cycle_tracked, + } + } + + def write(self, vals): + """ + Override write to handle changes in is_cycle_tracked field. + + If a category is no longer tracked, we archive all related cycles. + """ + if 'is_cycle_tracked' in vals and not vals['is_cycle_tracked']: + # Find all cycles for products in this category + cycles = self.env['itch.cycle.product.partner'].search([ + ('product_id.categ_id', 'in', self.ids) + ]) + if cycles: + # Archive the cycles + cycles.write({ + 'active': False, + 'notes': f'{fields.Date.today()}: Archived automatically - Category no longer tracked' + }) + # Log the action + _logger.info( + f"Archived {len(cycles)} cycles due to category {self.name} " + f"being marked as not tracked" + ) + + return super().write(vals) + + seasonal_factor = fields.Boolean( + string="Seasonal Category", + help="Enable this if the products in this category have seasonal sales patterns", + default=False, + ) + + season_months = fields.Many2many( + 'itch.month', + string="Active Months", + help="Select the months when this category is typically active", + ) + + @api.constrains('season_months') + def _check_season_months(self): + """ + Validate the selection of season_months field. + + Rules: + - Cannot be empty if seasonal_factor is True + """ + for record in self: + if record.seasonal_factor and not record.season_months: + raise ValidationError("Active months must be specified for seasonal categories") diff --git a/customer_itch_cycle/models/res_partner.py b/customer_itch_cycle/models/res_partner.py index a0e9f56..21fd4d8 100644 --- a/customer_itch_cycle/models/res_partner.py +++ b/customer_itch_cycle/models/res_partner.py @@ -1,43 +1,53 @@ -from odoo import models, fields, api, _ -from odoo.exceptions import UserError -from datetime import datetime +from odoo import api, fields, models + class ResPartner(models.Model): + """ + Extends the partner model to add sales cycle tracking functionality. + """ _inherit = 'res.partner' itch_cycle_product_ids = fields.One2many( comodel_name='itch.cycle.product.partner', inverse_name='partner_id', - string="Itch Cycles Products" + string="Product Sales Cycles", + help="List of all product sales cycles associated with this customer", ) reorder_cycle_product_ids = fields.One2many( comodel_name='itch.cycle.product.partner', inverse_name='partner_id', - string="Reorder Cycles Products", - domain=[('quantity_of_orders', '>', 1)] + string="Active Sales Cycles", + domain=[('quantity_of_orders', '>', 1)], + help="List of product sales cycles with established patterns", ) itch_next_delay = fields.Date( - string="Prochaine Date de Suivi (Itch-Cycle Min)", - # compute="_compute_itch_next_delay", - store=True + string="Next Follow-up Date", + compute="_compute_itch_next_delay", + store=True, + help="Earliest follow-up date", ) - @api.depends('itch_cycle_product_ids.next_follow_up_date') + @api.depends( + 'itch_cycle_product_ids', + 'itch_cycle_product_ids.date_next_follow_up' + ) def _compute_itch_next_delay(self): """ - Calcule la date de suivi minimale parmi tous les cycles de produits associés. - """ + Compute the earliest follow-up date. + + The date is calculated from all associated product cycles. + """ for partner in self: - follow_up_dates = partner.reorder_cycle_product_ids.mapped('next_follow_up_date') - if follow_up_dates: - partner.itch_next_delay = min(follow_up_dates) - else: - partner.itch_next_delay = False + cycles = partner.reorder_cycle_product_ids + dates = cycles.mapped('date_next_follow_up') + partner.itch_next_delay = min(dates) if dates else False def action_populate_itch_cycles(self): """ - Action bouton qui appelle la méthode pour peupler les cycles depuis les données passées. + Initialize or update product cycles. + + The cycles are created from historical order data. """ - self.env['itch.cycle.product.partner'].populate_from_past_orders() \ No newline at end of file + self.env['itch.cycle.product.partner'].populate_from_past_orders() diff --git a/customer_itch_cycle/models/sale_order_line.py b/customer_itch_cycle/models/sale_order_line.py index 04db55d..0bcf217 100644 --- a/customer_itch_cycle/models/sale_order_line.py +++ b/customer_itch_cycle/models/sale_order_line.py @@ -1,43 +1,51 @@ from odoo import models, fields, api + class SaleOrderLine(models.Model): + """ + Extends sale order line to add itch cycle tracking. + """ _inherit = 'sale.order.line' itch_cycle_id = fields.Many2one( comodel_name='itch.cycle.product.partner', - string="Cycle Produit/Partenaire" - ) + string="Cycle") stock_move_ids = fields.One2many( comodel_name='stock.move', inverse_name='sale_line_id', - string="Mouvements de stock" - ) + string="Movements") sale_date = fields.Datetime( - string="Date de vente", + string="Date", related='order_id.date_order', - store=True - ) + store=True) @api.model_create_multi def create(self, vals_list): - # Appeler le super pour créer les lignes + """ + Create sale order lines and associate them with their itch cycle. + + If the product is cycle tracked and no existing cycle exists for the + product/partner combination, a new cycle is created. + """ lines = super().create(vals_list) - # Associer les lignes nouvellement créées avec leur cycle for line in lines: - if line.product_id and line.order_id: + if (line.product_id and line.order_id and + line.product_id.categ_id.is_cycle_tracked): partner_id = line.order_id.partner_id.id itch_cycle = self.env['itch.cycle.product.partner'].search([ ('partner_id', '=', partner_id), ('product_id', '=', line.product_id.id) ], limit=1) if not itch_cycle: - itch_cycle = self.env['itch.cycle.product.partner'].create({ + cycle_vals = { 'partner_id': partner_id, 'product_id': line.product_id.id, - }) + } + itch_cycle = self.env['itch.cycle.product.partner'].create( + cycle_vals) line.itch_cycle_id = itch_cycle.id - return lines \ No newline at end of file + return lines diff --git a/customer_itch_cycle/security/ir.model.access.csv b/customer_itch_cycle/security/ir.model.access.csv index 8d88f59..6444bd1 100644 --- a/customer_itch_cycle/security/ir.model.access.csv +++ b/customer_itch_cycle/security/ir.model.access.csv @@ -1,2 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_itch_cycle_product_partner,itch.cycle.product.partner access,model_itch_cycle_product_partner,base.group_user,1,1,1,1 \ No newline at end of file +access_itch_cycle_product_partner,itch.cycle.product.partner access,model_itch_cycle_product_partner,base.group_user,1,1,1,1 +access_itch_month_user,itch.month access user,model_itch_month,base.group_user,1,0,0,0 +access_itch_month_manager,itch.month access manager,model_itch_month,sales_team.group_sale_manager,1,1,1,1 +access_itch_apply_to_child_categories,itch.apply.to.child.categories access,model_itch_apply_to_child_categories,base.group_user,1,1,1,1 \ 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 index 55efb80..407e74c 100644 --- 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 @@ -5,6 +5,10 @@ import { listView } from "@web/views/list/list_view"; import { ListController } from "@web/views/list/list_controller"; import { useService } from "@web/core/utils/hooks"; +/** + * Custom List Controller for Product/Partner Cycle Management + * Extends standard list view to add history processing functionality + */ class CycleProductPartnerListController extends ListController { setup() { super.setup(); @@ -12,22 +16,41 @@ class CycleProductPartnerListController extends ListController { this.notification = useService("notification"); } + /** + * Process historical sales data to update cycles + * Shows notifications for processing status + */ async ProcessHistoryButton() { try { - await this.orm.call("itch.cycle.product.partner", "populate_from_past_orders", []); - this.notification.add("L'action a été exécutée avec succès.", { - type: "success", + this.notification.add("Starting history processing...", { + type: "info", + sticky: false, }); + + const result = await this.orm.call("itch.cycle.product.partner", "populate_from_past_orders", []); + + if (result && result.tag === 'display_notifications' && result.params.notifications) { + for (const notif of result.params.notifications) { + this.notification.add(notif.params.message, { + type: notif.params.type, + sticky: notif.params.sticky, + }); + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + + await this.model.load(); } catch (error) { - this.notification.add(`Une erreur s'est produite : ${error.message}`, { + this.notification.add(`An error occurred: ${error.message}`, { type: "danger", }); } } } +// Register the custom list view for cycle product partner registry.category("views").add("cycle_product_partner_list", { ...listView, Controller: CycleProductPartnerListController, - buttonTemplate: "customer_itch_cycle.process_history_button_in_tree", -}); \ No newline at end of file + buttonTemplate: "customer_itch_cycle.ListButtons", +}); diff --git a/customer_itch_cycle/static/src/xml/process_history_button.xml b/customer_itch_cycle/static/src/xml/process_history_button.xml index 88a9e59..f6ef38b 100644 --- a/customer_itch_cycle/static/src/xml/process_history_button.xml +++ b/customer_itch_cycle/static/src/xml/process_history_button.xml @@ -1,8 +1,10 @@ - - + +
+ +
\ No newline at end of file diff --git a/customer_itch_cycle/views/crm_lead_view.xml b/customer_itch_cycle/views/crm_lead_view.xml new file mode 100644 index 0000000..8e06715 --- /dev/null +++ b/customer_itch_cycle/views/crm_lead_view.xml @@ -0,0 +1,51 @@ + + + + + crm.lead.form.inherit.itch.cycle + crm.lead + + + + + + + + + + + crm.lead.form.inherit.itch.cycle.tab + crm.lead + + + + + + + + + + + + + + + + + + + + + + + crm.lead.search.inherit.itch.cycle + crm.lead + + + + + + + + diff --git a/customer_itch_cycle/views/itch_cycle_actions.xml b/customer_itch_cycle/views/itch_cycle_actions.xml index 688aede..2109722 100644 --- a/customer_itch_cycle/views/itch_cycle_actions.xml +++ b/customer_itch_cycle/views/itch_cycle_actions.xml @@ -6,6 +6,8 @@ itch.cycle.product.partner tree,form + [('state', '!=', 'new'), ('active', '=', True)] + \ 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 c2433bb..55c4992 100644 --- a/customer_itch_cycle/views/itch_cycle_product_partner_view.xml +++ b/customer_itch_cycle/views/itch_cycle_product_partner_view.xml @@ -1,19 +1,72 @@ + + + Cycles Produit/Partenaire + itch.cycle.product.partner + tree,form + name_get + + + + Create Opportunities + + code + records.action_create_batch_opportunities() + + action + tree,form + + itch.cycle.product.partner.tree itch.cycle.product.partner - + + + + + + + + + + + + + + + + + + + + itch.cycle.product.partner.search + itch.cycle.product.partner + + - - - - - - - - + + + + + + + + + + + + + + + + + @@ -23,44 +76,86 @@
- +
+
+ + +
- - - - - - - - - - - - - - + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + @@ -71,9 +166,26 @@ + + + + + + + + + + + +
+
+ + + +
-
\ No newline at end of file + diff --git a/customer_itch_cycle/views/product_category_view.xml b/customer_itch_cycle/views/product_category_view.xml index a903bf3..060f129 100644 --- a/customer_itch_cycle/views/product_category_view.xml +++ b/customer_itch_cycle/views/product_category_view.xml @@ -1,21 +1,30 @@ - product.category.form product.category + -
- + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
diff --git a/customer_itch_cycle/wizards/__init__.py b/customer_itch_cycle/wizards/__init__.py new file mode 100644 index 0000000..7c1d61c --- /dev/null +++ b/customer_itch_cycle/wizards/__init__.py @@ -0,0 +1 @@ +from . import apply_to_child_categories diff --git a/customer_itch_cycle/wizards/apply_to_child_categories.py b/customer_itch_cycle/wizards/apply_to_child_categories.py new file mode 100644 index 0000000..4875220 --- /dev/null +++ b/customer_itch_cycle/wizards/apply_to_child_categories.py @@ -0,0 +1,48 @@ +from odoo import models, fields, api + +class ApplyToChildCategories(models.TransientModel): + """ + Wizard to apply cycle tracking settings to child categories + This wizard is shown when changing is_cycle_tracked on a product category + """ + _name = 'itch.apply.to.child.categories' + _description = 'Apply Settings to Child Categories' + + category_id = fields.Many2one('product.category', string='Category', required=True) + new_value = fields.Boolean(string='New Tracking Value') + apply_to_children = fields.Boolean( + string='Apply to Child Categories', + help='If checked, the cycle tracking settings will be applied to all child categories', + default=True + ) + child_count = fields.Integer( + string='Number of Child Categories', + compute='_compute_child_count' + ) + + @api.depends('category_id') + def _compute_child_count(self): + """Compute the number of child categories that would be affected""" + for wizard in self: + wizard.child_count = self.env['product.category'].search_count([ + ('id', 'child_of', wizard.category_id.id), + ('id', '!=', wizard.category_id.id) + ]) + + def apply_settings(self): + """Apply the settings to the selected categories""" + self.ensure_one() + if self.apply_to_children: + categories = self.env['product.category'].search([ + ('id', 'child_of', self.category_id.id) + ]) + else: + categories = self.category_id + + # Apply the settings + values = { + 'is_cycle_tracked': self.new_value, + 'temp_is_cycle_tracked': self.new_value, + } + categories.write(values) + return {'type': 'ir.actions.act_window_close'} diff --git a/customer_itch_cycle/wizards/apply_to_child_categories.xml b/customer_itch_cycle/wizards/apply_to_child_categories.xml new file mode 100644 index 0000000..e2be729 --- /dev/null +++ b/customer_itch_cycle/wizards/apply_to_child_categories.xml @@ -0,0 +1,20 @@ + + + + itch.apply.to.child.categories.form + itch.apply.to.child.categories + +
+ + + + + + +
+
+
+