This commit is contained in:
Benoît Vézina 2024-12-14 06:53:23 -05:00
parent 5a97745d42
commit 9578594628
13 changed files with 361 additions and 102 deletions

View file

@ -9,6 +9,9 @@
'data': [
'views/itch_cycle_product_partner_view.xml',
'views/res_partner_view.xml',
'views/product_category_view.xml',
'security/ir.model.access.csv'
],
'installable': True,
'application': False,

View file

@ -1,3 +1,4 @@
from . import itch_cycle_product_partner
from . import sale_order
from . import sale_order_line
from . import product_category
from . import res_partner

View file

@ -1,33 +1,195 @@
from datetime import datetime, timedelta
import numpy as np
from odoo import models, fields, api
from datetime import timedelta, datetime
from odoo.exceptions import UserError
class ItchCycleProductPartner(models.Model):
_name = 'itch_cycle_product_partner'
_description = 'Itch Cycle by Product and Partner'
_name = "itch.cycle.product.partner"
_description = "Cycle Produit/Partenaire"
partner_id = fields.Many2one('res.partner', string="Client", required=True, ondelete='cascade')
product_id = fields.Many2one('product.product', string="Produit", required=True)
last_purchase_date = fields.Date(string="Dernière Date d'Achat")
itch_cycle_duration = fields.Integer(string="Durée du Itch-Cycle (jours)", default=0)
next_follow_up_date = fields.Date(string="Prochaine Date de Suivi", compute="_compute_next_follow_up_date", store=True)
partner_id = fields.Many2one(
comodel_name='res.partner',
string="Client",
required=True
)
@api.depends('last_purchase_date', 'itch_cycle_duration')
def _compute_next_follow_up_date(self):
product_id = fields.Many2one(
comodel_name='product.product',
string="Produit",
required=True
)
average_cycle = fields.Integer(
string="Cycle moyen (jours)",
compute="_compute_average_cycle",
store=True
)
next_expected_date = fields.Date(
string="Prochaine vente prévue",
compute="_compute_next_expected_date",
store=True
)
prediction_status = fields.Selection(
selection=[
('on_time', 'À lheure'),
('delayed', 'Retardée')
],
string="Statut de la prédiction",
compute="_compute_prediction_status"
)
sale_order_line_ids = fields.One2many(
comodel_name='sale.order.line',
inverse_name='itch_cycle_id',
string="Lignes de commande"
)
next_follow_up_date = fields.Date(
string="Prochaine date de suivi",
compute="_compute_next_follow_up_date",
store=True,
readonly=False
)
last_purchase_date = fields.Date(
string="Date du dernier achat",
compute="_compute_last_purchase_date",
store=True
)
itch_cycle_duration = fields.Integer(
string="Durée du cycle (jours)",
help="Représente la durée moyenne du cycle en jours",
compute="_compute_itch_cycle_duration",
store=True
)
@api.depends('average_cycle')
def _compute_itch_cycle_duration(self):
"""Calcule automatiquement la durée du cycle."""
for record in self:
if record.last_purchase_date and record.itch_cycle_duration > 0:
record.next_follow_up_date = record.last_purchase_date + timedelta(days=record.itch_cycle_duration)
record.itch_cycle_duration = record.average_cycle or 0
@api.depends('sale_order_line_ids')
def _compute_last_purchase_date(self):
"""Met automatiquement à jour la date du dernier achat."""
for record in self:
# Rechercher la commande la plus récente associée
last_order = record.sale_order_line_ids.mapped('order_id').sorted(key=lambda o: o.date_order, reverse=True)
record.last_purchase_date = last_order[0].date_order if last_order else None
@api.depends('next_expected_date')
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.
"""
for record in self:
if record.next_expected_date:
# Exemple : une alerte de suivi une semaine avant la prochaine vente prévue
record.next_follow_up_date = record.next_expected_date - timedelta(days=7)
else:
record.next_follow_up_date = False
record.next_follow_up_date = None
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.depends('sale_order_line_ids', 'product_id.categ_id')
def _compute_average_cycle(self):
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(',')))
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'))
itch_cycle_product_ids = fields.One2many('itch_cycle_product_partner', 'partner_id', string="Itch Cycles Produits")
itch_next_delay = fields.Date(string="Prochaine Date de Suivi (Itch-Cycle Min)", compute="_compute_itch_next_delay", store=True)
if len(dates) > 1:
intervals = [(dates[i + 1] - dates[i]).days for i in range(len(dates) - 1)]
record.average_cycle = int(np.mean(intervals)) if intervals else 0
else:
record.average_cycle = 0
@api.depends('itch_cycle_product_ids.next_follow_up_date')
def _compute_itch_next_delay(self):
for partner in self:
follow_up_dates = partner.itch_cycle_product_ids.mapped('next_follow_up_date')
partner.itch_next_delay = min(follow_up_dates) if follow_up_dates else False
@api.depends('average_cycle')
def _compute_next_expected_date(self):
for record in self:
if record.average_cycle and record.sale_order_line_ids:
last_date = max(record.sale_order_line_ids.mapped('order_id.date_order'))
record.next_expected_date = fields.Date.from_string(last_date) + timedelta(days=record.average_cycle)
else:
record.next_expected_date = None
def _compute_prediction_status(self):
today = fields.Date.context_today(self)
for record in self:
if record.next_expected_date and record.next_expected_date < today:
record.prediction_status = 'delayed'
else:
record.prediction_status = 'on_time'
@api.model
def populate_from_past_orders(self):
"""
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
])
# Carte pour regrouper par couple (client, produit)
cycle_data = {}
for line in sale_order_lines:
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
})
return True

View file

@ -0,0 +1,15 @@
from odoo import models, fields
class ProductCategory(models.Model):
_inherit = 'product.category'
seasonal_factor = fields.Boolean(
string="Catégorie saisonnière",
default=False
)
season_months = fields.Char(
string="Mois actifs",
help="Mois pendant lesquels cette catégorie est active (ex: '3,4,5' pour mars-mai)"
)

View file

@ -0,0 +1,33 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from datetime import datetime
class ResPartner(models.Model):
_inherit = 'res.partner'
itch_cycle_product_ids = fields.One2many(
comodel_name='itch.cycle.product.partner',
inverse_name='partner_id',
string="Itch Cycles Produits"
)
itch_next_delay = fields.Date(
string="Prochaine Date de Suivi (Itch-Cycle Min)",
compute="_compute_itch_next_delay",
store=True
)
@api.depends('itch_cycle_product_ids.next_follow_up_date')
def _compute_itch_next_delay(self):
"""
Calcule la date de suivi minimale parmi tous les cycles de produits associés.
"""
for partner in self:
follow_up_dates = partner.itch_cycle_product_ids.mapped('next_follow_up_date')
partner.itch_next_delay = min(follow_up_dates) if follow_up_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.
"""
self.env['itch.cycle.product.partner'].populate_from_past_orders()

View file

@ -1,44 +0,0 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from datetime import datetime
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
super(SaleOrder, self).action_confirm()
for line in self.order_line:
partner_id = self.partner_id
product_id = line.product_id
itch_cycle_record = self.env['itch_cycle_product_partner'].search([
('partner_id', '=', partner_id.id),
('product_id', '=', product_id.id)
], limit=1)
if itch_cycle_record:
if itch_cycle_record.last_purchase_date:
days_since_last_purchase = (datetime.now().date() - itch_cycle_record.last_purchase_date).days
if itch_cycle_record.itch_cycle_duration == 0:
raise UserError(_(
f"Veuillez définir la durée du itch-cycle pour le produit '{product_id.name}' "
f"pour le client '{partner_id.name}'.\n\n"
f"Il y a eu {days_since_last_purchase} jours depuis le dernier achat."
))
else:
itch_cycle_record.last_purchase_date = datetime.now().date()
itch_cycle_record.itch_cycle_duration = days_since_last_purchase
else:
itch_cycle_record.last_purchase_date = datetime.now().date()
else:
new_record = self.env['itch_cycle_product_partner'].create({
'partner_id': partner_id.id,
'product_id': product_id.id,
'last_purchase_date': datetime.now().date(),
'itch_cycle_duration': 0
})
raise UserError(_(
f"Il n'y a pas encore de itch-cycle défini pour le produit '{product_id.name}' "
f"avec le client '{partner_id.name}'.\n\nVeuillez entrer une durée pour ce cycle."
))

View file

@ -0,0 +1,27 @@
from odoo import models, fields, api
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
itch_cycle_id = fields.Many2one(
comodel_name='itch.cycle.product.partner',
string="Cycle Produit/Partenaire"
)
@api.model
def create(self, vals):
line = super(SaleOrderLine, self).create(vals)
# Associer au bon cycle produit/partenaire
if vals.get('product_id') and vals.get('order_id'):
partner_id = self.env['sale.order'].browse(vals['order_id']).partner_id.id
itch_cycle = self.env['itch.cycle.product.partner'].search([
('partner_id', '=', partner_id),
('product_id', '=', vals['product_id'])
], limit=1)
if not itch_cycle:
itch_cycle = self.env['itch.cycle.product.partner'].create({
'partner_id': partner_id,
'product_id': vals['product_id'],
})
line.itch_cycle_id = itch_cycle.id
return line

View file

@ -0,0 +1,2 @@
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_itch_cycle_product_partner itch.cycle.product.partner access model_itch_cycle_product_partner base.group_user 1 1 1 1

View file

@ -1,44 +1,71 @@
<odoo>
<record id="view_itch_cycle_product_partner_tree" model="ir.ui.view">
<field name="name">itch.cycle.product.partner.tree</field>
<field name="model">itch_cycle_product_partner</field>
<field name="model">itch.cycle.product.partner</field>
<field name="arch" type="xml">
<tree>
<field name="partner_id"/>
<field name="product_id"/>
<field name="last_purchase_date"/>
<field name="itch_cycle_duration"/>
<field name="average_cycle"/>
<field name="last_purchase_date"/>
<field name="next_expected_date"/>
<field name="next_follow_up_date"/>
<field name="prediction_status"/>
</tree>
</field>
</record>
<record id="view_itch_cycle_product_partner_form" model="ir.ui.view">
<field name="name">itch.cycle.product.partner.form</field>
<field name="model">itch_cycle_product_partner</field>
<field name="model">itch.cycle.product.partner</field>
<field name="arch" type="xml">
<form>
<header>
<field name="prediction_status" widget="statusbar" options="{'on_time': 'green', 'delayed': 'red'}"/>
</header>
<sheet>
<group>
<field name="partner_id"/>
<field name="product_id"/>
<field name="last_purchase_date"/>
<field name="itch_cycle_duration"/>
<field name="next_follow_up_date"/>
<group string="Informations principales">
<field name="partner_id"/>
<field name="product_id"/>
</group>
<group string="Statistiques">
<field name="itch_cycle_duration" readonly="1"/>
<field name="average_cycle" readonly="1"/>
<field name="last_purchase_date" readonly="1"/>
<field name="next_expected_date" readonly="1"/>
<field name="next_follow_up_date"/>
</group>
</group>
<notebook>
<page string="Lignes de commandes associées">
<field name="sale_order_line_ids">
<tree editable="bottom">
<field name="order_id"/>
<field name="product_id"/>
<field name="price_unit"/>
<field name="product_uom_qty"/>
<field name="discount"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<menuitem id="menu_itch_cycle_product_partner" name="Itch Cycles"
parent="sale.sale_order_menu"
action="action_itch_cycle_product_partner"/>
<record id="action_itch_cycle_product_partner" model="ir.actions.act_window">
<field name="name">Itch Cycles</field>
<field name="res_model">itch_cycle_product_partner</field>
<field name="name">Itch Cycles Produits</field>
<field name="res_model">itch.cycle.product.partner</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_itch_cycle_product_partner_tree"/>
</record>
</odoo>
<menuitem id="menu_itch_cycle_product_root"
name="Itch Cycle"
sequence="10"
parent="sale.menu_sale_report"
action="action_itch_cycle_product_partner"/>
</odoo>

View file

@ -0,0 +1,21 @@
<odoo>
<record id="view_product_category_form" model="ir.ui.view">
<field name="name">product.category.form</field>
<field name="model">product.category</field>
<field name="arch" type="xml">
<form string="Catégorie de produit">
<sheet>
<group>
<field name="name"/>
<field name="parent_id"/>
</group>
<group string="Saisonnalité">
<field name="seasonal_factor"/>
<field name="season_months"/>
</group>
</sheet>
</form>
</field>
</record>
</odoo>

View file

@ -5,19 +5,25 @@
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<sheet position="before">
<group string="Itch Cycle Information">
<field name="itch_next_delay" readonly="1"/>
<field name="itch_cycle_product_ids" readonly="1">
<tree>
<field name="product_id"/>
<field name="last_purchase_date"/>
<field name="itch_cycle_duration"/>
<field name="next_follow_up_date"/>
</tree>
</field>
</group>
</sheet>
<notebook position="inside">
<page string="Itch Cycle" name="itch_cycle">
<button name="action_populate_itch_cycles"
string="Traiter l'historique des cycles"
type="object"
class="btn-primary"/>
<group string="Itch Cycle Information">
<field name="itch_next_delay" readonly="1"/>
<field name="itch_cycle_product_ids" readonly="1">
<tree>
<field name="product_id"/>
<field name="last_purchase_date"/>
<field name="itch_cycle_duration"/>
<field name="next_follow_up_date"/>
</tree>
</field>
</group>
</page>
</notebook>
</field>
</record>
</odoo>

View file

@ -57,6 +57,8 @@ class PartnerPurchaseAnalysisWizard(models.TransientModel):
if self.date_end:
domain.append(('order_id.date_order', '<=', self.date_end))
domain.append(('order_id.state', 'in', ['sale', 'done']))
sale_order_lines = self.env['sale.order.line'].search(domain)
if not sale_order_lines:
@ -82,10 +84,14 @@ class PartnerPurchaseAnalysisWizard(models.TransientModel):
user_lang_name = self.env['res.lang'].search([('code', '=', user_lang)], limit=1).name or "English"
prompt = (
"Analyze the following customer purchase history. Identify trends, product "
"category preferences, and any significant deviations. Respond in "
f"{user_lang_name}. Produce graph and table of the analysis. You output all "
"in html format."
"Analyze all the sale order lines of the following customer. Identify trends, "
"and any significant deviations. Respond in "
f"{user_lang_name}. Produce the analysis adding next plan purchase or lost purchase. You output all "
"in html format. Focus on missing product order and deviation from the average. "
"Be sure to list all products and categories in the analysis. Try to identify "
"the next purchase date for all product and warn if the customer is not buying "
"and identify recurring sale and product not sale. Put in the analysis if the product is not bought "
"in the last 12 months and show those product as lost sale with a value of the lost sales"
)
# Construction de `purchase_details` pour le contenu du prompt