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
+
+ - Product: {self.product_id.name}
+ - Expected Quantity: {self.quantity_mean_ordered}
+ - Cycle Duration: {self.cycle_duration} days
+ - Previous Order Date: {self.date_last_purchase}
+
+ """,
+ }
+
+ 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 @@
-
\ 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/views/res_partner_view.xml b/customer_itch_cycle/views/res_partner_view.xml
index cbac8b0..b8f575f 100644
--- a/customer_itch_cycle/views/res_partner_view.xml
+++ b/customer_itch_cycle/views/res_partner_view.xml
@@ -1,4 +1,4 @@
-
+
res.partner.form.itch.cycle
@@ -6,27 +6,84 @@
-
-
-
-
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+ itch.cycle.product.partner.form
+ itch.cycle.product.partner
+
+
+
+
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
+
+
+
+
+