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': [ 'data': [
'views/itch_cycle_product_partner_view.xml', 'views/itch_cycle_product_partner_view.xml',
'views/res_partner_view.xml', 'views/res_partner_view.xml',
'views/product_category_view.xml',
'security/ir.model.access.csv'
], ],
'installable': True, 'installable': True,
'application': False, 'application': False,

View file

@ -1,3 +1,4 @@
from . import itch_cycle_product_partner 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 odoo import models, fields, api
from datetime import timedelta, datetime from odoo.exceptions import UserError
class ItchCycleProductPartner(models.Model): class ItchCycleProductPartner(models.Model):
_name = 'itch_cycle_product_partner' _name = "itch.cycle.product.partner"
_description = 'Itch Cycle by Product and Partner' _description = "Cycle Produit/Partenaire"
partner_id = fields.Many2one('res.partner', string="Client", required=True, ondelete='cascade') partner_id = fields.Many2one(
product_id = fields.Many2one('product.product', string="Produit", required=True) comodel_name='res.partner',
last_purchase_date = fields.Date(string="Dernière Date d'Achat") string="Client",
itch_cycle_duration = fields.Integer(string="Durée du Itch-Cycle (jours)", default=0) required=True
next_follow_up_date = fields.Date(string="Prochaine Date de Suivi", compute="_compute_next_follow_up_date", store=True) )
@api.depends('last_purchase_date', 'itch_cycle_duration') product_id = fields.Many2one(
def _compute_next_follow_up_date(self): 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: for record in self:
if record.last_purchase_date and record.itch_cycle_duration > 0: record.itch_cycle_duration = record.average_cycle or 0
record.next_follow_up_date = record.last_purchase_date + timedelta(days=record.itch_cycle_duration)
@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: else:
record.next_follow_up_date = False record.next_follow_up_date = None
class ResPartner(models.Model): @api.depends('sale_order_line_ids', 'product_id.categ_id')
_inherit = 'res.partner' 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") if len(dates) > 1:
itch_next_delay = fields.Date(string="Prochaine Date de Suivi (Itch-Cycle Min)", compute="_compute_itch_next_delay", store=True) 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') @api.depends('average_cycle')
def _compute_itch_next_delay(self): def _compute_next_expected_date(self):
for partner in self: for record in self:
follow_up_dates = partner.itch_cycle_product_ids.mapped('next_follow_up_date') if record.average_cycle and record.sale_order_line_ids:
partner.itch_next_delay = min(follow_up_dates) if follow_up_dates else False 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> <odoo>
<record id="view_itch_cycle_product_partner_tree" model="ir.ui.view"> <record id="view_itch_cycle_product_partner_tree" model="ir.ui.view">
<field name="name">itch.cycle.product.partner.tree</field> <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"> <field name="arch" type="xml">
<tree> <tree>
<field name="partner_id"/> <field name="partner_id"/>
<field name="product_id"/> <field name="product_id"/>
<field name="last_purchase_date"/>
<field name="itch_cycle_duration"/> <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="next_follow_up_date"/>
<field name="prediction_status"/>
</tree> </tree>
</field> </field>
</record> </record>
<record id="view_itch_cycle_product_partner_form" model="ir.ui.view"> <record id="view_itch_cycle_product_partner_form" model="ir.ui.view">
<field name="name">itch.cycle.product.partner.form</field> <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"> <field name="arch" type="xml">
<form> <form>
<header>
<field name="prediction_status" widget="statusbar" options="{'on_time': 'green', 'delayed': 'red'}"/>
</header>
<sheet> <sheet>
<group> <group>
<field name="partner_id"/> <group string="Informations principales">
<field name="product_id"/> <field name="partner_id"/>
<field name="last_purchase_date"/> <field name="product_id"/>
<field name="itch_cycle_duration"/> </group>
<field name="next_follow_up_date"/> <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> </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> </sheet>
</form> </form>
</field> </field>
</record> </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"> <record id="action_itch_cycle_product_partner" model="ir.actions.act_window">
<field name="name">Itch Cycles</field> <field name="name">Itch Cycles Produits</field>
<field name="res_model">itch_cycle_product_partner</field> <field name="res_model">itch.cycle.product.partner</field>
<field name="view_mode">tree,form</field> <field name="view_mode">tree,form</field>
<field name="view_id" ref="view_itch_cycle_product_partner_tree"/>
</record> </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="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/> <field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<sheet position="before"> <notebook position="inside">
<group string="Itch Cycle Information"> <page string="Itch Cycle" name="itch_cycle">
<field name="itch_next_delay" readonly="1"/> <button name="action_populate_itch_cycles"
<field name="itch_cycle_product_ids" readonly="1"> string="Traiter l'historique des cycles"
<tree> type="object"
<field name="product_id"/> class="btn-primary"/>
<field name="last_purchase_date"/> <group string="Itch Cycle Information">
<field name="itch_cycle_duration"/> <field name="itch_next_delay" readonly="1"/>
<field name="next_follow_up_date"/> <field name="itch_cycle_product_ids" readonly="1">
</tree> <tree>
</field> <field name="product_id"/>
</group> <field name="last_purchase_date"/>
</sheet> <field name="itch_cycle_duration"/>
<field name="next_follow_up_date"/>
</tree>
</field>
</group>
</page>
</notebook>
</field> </field>
</record> </record>
</odoo> </odoo>

View file

@ -57,6 +57,8 @@ class PartnerPurchaseAnalysisWizard(models.TransientModel):
if self.date_end: if self.date_end:
domain.append(('order_id.date_order', '<=', 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) sale_order_lines = self.env['sale.order.line'].search(domain)
if not sale_order_lines: 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" user_lang_name = self.env['res.lang'].search([('code', '=', user_lang)], limit=1).name or "English"
prompt = ( prompt = (
"Analyze the following customer purchase history. Identify trends, product " "Analyze all the sale order lines of the following customer. Identify trends, "
"category preferences, and any significant deviations. Respond in " "and any significant deviations. Respond in "
f"{user_lang_name}. Produce graph and table of the analysis. You output all " f"{user_lang_name}. Produce the analysis adding next plan purchase or lost purchase. You output all "
"in html format." "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 # Construction de `purchase_details` pour le contenu du prompt