This commit is contained in:
MicheleFerri 2025-07-31 15:58:49 +02:00
parent 5c6da0e243
commit 3b082bb3d9
61 changed files with 2292 additions and 0 deletions

View File

@ -0,0 +1,16 @@
{
'name': 'Hide Send Message Button',
'version': '1.0',
'category': 'Hidden',
'summary': 'Nasconde il pulsante Invia messaggio nel chatter',
'depends': ['mail'],
'assets': {
'web.assets_backend': [
'hide_send_message_button/static/src/css/hide_send_button.css',
'hide_send_message_button/static/src/js/hide_send_button.js',
# 'web/static/src/js/core/dom_ready.js', # <-- aggiungi questa riga
],
},
'installable': True,
'application': False,
}

View File

@ -0,0 +1,3 @@
.o-mail-Chatter-sendMessage {
display: none !important;
}

View File

@ -0,0 +1,36 @@
odoo.define('hide_send_message_button.hide_send_button', [], function () {
"use strict";
document.addEventListener('DOMContentLoaded', function () {
function hideButton() {
const btn = document.querySelector('.o-mail-Chatter-sendMessage');
if (btn) {
btn.style.display = 'none';
}
}
hideButton();
const observer = new MutationObserver(hideButton);
observer.observe(document.body, { childList: true, subtree: true });
});
});
// odoo.define('hide_send_message_button.hide_send_button', function (require) {
// "use strict";
// const { patch } = require('web.utils');
// const Chatter = require('mail.Chatter');
// patch(Chatter.prototype, {
// mounted() {
// this._super(...arguments);
// const btn = this.el.querySelector('.o-mail-Chatter-sendMessage');
// if (btn) {
// btn.style.display = 'none';
// }
// }
// });
// });

View File

@ -0,0 +1,8 @@
<odoo>
<template id="assets_backend" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="/hide_send_message_button/static/src/css/hide_send_button.css"/>
<script type="module" src="/hide_send_message_button/static/src/js/hide_send_button.js"/>
</xpath>
</template>
</odoo>

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

View File

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name': 'Partner Info', # Se il tuo modulo è 'morpheus_contacts', sostituisci qui
'version': '1.0',
'summary': 'Aggiunge campi nel "FORM Contatto" e nella scheda contatti.',
'description': """
Questo modulo estende il form del contatto (res.partner) per includere
una nuova scheda "FORM Contatto" con altri campi.
""",
'author': 'Il Tuo Nome/Azienda',
'website': 'Il Tuo Sito Web (opzionale)',
'category': 'Customer Relationship Management',
'depends': ['base'],
'data': [
'views/res_partner_view.xml',
'data/settori.xml',
'data/fornitore_attuale_data.xml',
'security/ir.model.access.csv',
'views/tag_contatto.xml'
],
'installable': True,
'application': False,
'auto_install': False,
}

Binary file not shown.

View File

@ -0,0 +1,13 @@
<odoo>
<data noupdate="0">
<record id="fornitore_option_marchi_diretti" model="fornitore.attuale.option">
<field name="name">Marchi Diretti</field>
</record>
<record id="fornitore_option_grossista" model="fornitore.attuale.option">
<field name="name">Grossista - Distributore</field>
</record>
<record id="fornitore_option_dettaglianti" model="fornitore.attuale.option">
<field name="name">Dettaglianti</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,16 @@
<odoo>
<data noupdate="0">
<record id="settore_abbigliamento" model="res.partner.sector">
<field name="name">Abbigliamento - Borse - Scarpe</field>
</record>
<record id="settore_cosmetici" model="res.partner.sector">
<field name="name">Cosmetici &amp; Profumi</field>
</record>
<record id="settore_occhialeria" model="res.partner.sector">
<field name="name">Occhialeria</field>
</record>
<record id="settore_altre_categorie" model="res.partner.sector">
<field name="name">Altre Categorie</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import res_partner

View File

@ -0,0 +1,85 @@
from odoo import models, fields
class ResPartner(models.Model):
_inherit = 'res.partner'
is_favorite_contact = fields.Boolean(
string="Contatto Preferito",
help="Spunta questa casella se è un contatto prioritario per questa azienda."
)
percent_provvigioni = fields.Float(
string="% Provvigioni",
digits=(5, 2),
help="Inserisci la percentuale"
)
cliente_fornitore_tipo = fields.Selection([
('cliente', 'Cliente'),
('fornitore', 'Fornitore'),
('entrambi', 'Entrambi')
], string='Cliente/Fornitore')
agente_id = fields.Many2one(
'res.partner',
string="Agente",
domain="[('is_company', '=', False)]", # Filtra per i contatti che sono persone
help="Seleziona l'agente di riferimento"
)
ceo_name = fields.Char(string="CEO Name")
data_inizio_attivita = fields.Date(string="Data inizio attività")
fatturato_annuo = fields.Monetary(string="Fatturato annuo / Ricavi", currency_field='currency_id')
currency_id = fields.Many2one(
'res.currency',
string='Valuta',
default=lambda self: self.env.company.currency_id.id,
)
# Sezione "FORM Contatto"
settori = fields.Many2many('res.partner.sector', string="Settori")
origine_contatto = fields.Selection([
('motore_ricerca', 'Motore di ricerca'),
('linkedin_social', 'LinkedIn / Altri social media'),
('stampa_media', 'Financial Times / Altri media'),
('passaparola', 'Passaparola'),
('email', 'E-mail'),
('sitointernet', 'Sito Internet'),
('organico', 'Organico')
], string="Origine Contatto")
identita_cliente = fields.Selection([
('grossista_commerciante', 'Grossista - Commerciante'),
('distributore', 'Distributore'),
('e_commerce', 'E-commerce'),
('catena_negozi', 'Catena di negozi'),
('centro_commerciale_outlet', 'Centro Commerciale - Outlet'),
('agente', 'Agente'),
], string="Identità Cliente")
acquisti_da_italia = fields.Selection([
('si', ''),
('no', 'No')
], string="State acquistando dall'Italia?")
fornitori_attuali_ids = fields.Many2many(
'fornitore.attuale.option',
string="Fornitori Attuali dei Clienti"
)
class ResPartnerSector(models.Model):
_name = 'res.partner.sector' # Nome tecnico corretto
_description = 'Settore Partner'
name = fields.Char(string="Nome Settore")
class FornitoreAttualeOption(models.Model):
_name = 'fornitore.attuale.option'
_description = 'Opzioni Fornitore Attuale'
name = fields.Char(string="Tipo di Fornitore", required=True)

View File

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_res_partner_sector_user,res.partner.sector.user,model_res_partner_sector,base.group_user,1,1,1,1
access_fornitore_attuale_option_user,Fornitore Attuale Option access,model_fornitore_attuale_option,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_res_partner_sector_user res.partner.sector.user model_res_partner_sector base.group_user 1 1 1 1
3 access_fornitore_attuale_option_user Fornitore Attuale Option access model_fornitore_attuale_option base.group_user 1 0 0 0

View File

@ -0,0 +1,42 @@
<odoo>
<record id="view_partner_form_custom_full" model="ir.ui.view">
<field name="name">res.partner.form.custom.full</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='sales_purchases']//field[@name='user_id']" position="after">
<field name="agente_id" context="{'default_is_company': False, 'default_company_type': 'person'}" string="Agente"/>
<field name="percent_provvigioni"/>
</xpath>
<!-- Campi extra nella vista principale -->
<xpath expr="//sheet/group" position="inside">
<group string="Informazioni Aziendali">
<field name="cliente_fornitore_tipo"/>
<field name="ceo_name"/>
<field name="data_inizio_attivita"/>
<field name="fatturato_annuo"/>
<field name="currency_id" invisible="1"/>
</group>
</xpath>
<!-- Nuova pagina nel notebook -->
<xpath expr="//notebook" position="inside">
<page string="Customer Form">
<group string="Informazioni Generali">
<field name="settori" widget="many2many_checkboxes"/>
<field name="origine_contatto"/>
</group>
<group string="Informazioni Merceologiche">
<field name="identita_cliente"/>
<field name="acquisti_da_italia"/>
<field name="fornitori_attuali_ids" widget="many2many_checkboxes"/>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_partner_form_inherit_child_quick_create" model="ir.ui.view">
<field name="name">res.partner.form.inherit.child.quick.create</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='child_ids']/form//field[@name='name']" position="after">
<field name="category_id" widget="many2many_tags" placeholder="Etichette..."/>
<field name="is_favorite_contact" widget="boolean_favorite"/>
</xpath>
</field>
</record>
<record id="view_partner_form_inherit_child_kanban_create" model="ir.ui.view">
<field name="name">res.partner.form.inherit.child.kanban.create</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='child_ids']" position="inside">
<kanban>
<field name="name"/>
<field name="title"/>
<field name="email"/>
<field name="category_id"/>
<field name="is_favorite_contact" widget="boolean_favorite"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<div class="oe_kanban_details">
<strong><field name="name"/></strong>
<div t-if="record.title.raw_value">
<t t-esc="record.title.value"/>
</div>
<div t-if="record.email.raw_value" class="o_text_overflow">
<t t-esc="record.email.value"/>
</div>
<field name="category_id" widget="many2many_tags"/>
<field name="is_favorite_contact" widget="boolean_favorite"/>
</div>
</div>
</t>
</templates>
</kanban>
</xpath>
</field>
</record>
</data>
</odoo>

2
morpheus_crm/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

View File

@ -0,0 +1,17 @@
{
'name': 'Morpheus CRM',
'version': '1.0',
'category': 'CRM',
'summary': 'Estensioni CRM con wishlist cliente',
'depends': ['crm'],
'data': [
'security/ir.model.access.csv',
'views/crm_wishlist_view.xml',
'data/crm_wishlist_categoria_data.xml',
'data/crm_wishlist_sottocategoria_data.xml',
'data/crm_lead_genere_data.xml',
'views/crm_kanban_view.xml'
],
'installable': True,
'application': False,
}

Binary file not shown.

View File

@ -0,0 +1,18 @@
<odoo>
<data noupdate="1"> <record id="genere_uomo" model="crm.lead.genere">
<field name="name">Uomo</field>
</record>
<record id="genere_donna" model="crm.lead.genere">
<field name="name">Donna</field>
</record>
<record id="genere_bambino" model="crm.lead.genere">
<field name="name">Bambino</field>
</record>
<record id="genere_pets" model="crm.lead.genere">
<field name="name">Pets</field>
</record>
<record id="genere_altro" model="crm.lead.genere">
<field name="name">Altro</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,25 @@
<odoo>
<data noupdate="0">
<record id="categoria_fashion" model="crm.wishlist.categoria">
<field name="name">Fashion</field>
</record>
<record id="categoria_beauty" model="crm.wishlist.categoria">
<field name="name">Beauty</field>
</record>
<record id="categoria_eyewear" model="crm.wishlist.categoria">
<field name="name">Eyewear</field>
</record>
<record id="categoria_home" model="crm.wishlist.categoria">
<field name="name">Home</field>
</record>
<record id="categoria_gift" model="crm.wishlist.categoria">
<field name="name">Gift</field>
</record>
<record id="categoria_food_beverage" model="crm.wishlist.categoria">
<field name="name">Food &amp; Beverage</field>
</record>
<record id="categoria_other" model="crm.wishlist.categoria">
<field name="name">Other</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,95 @@
<odoo>
<data noupdate="0">
<!-- Fashion -->
<record id="sottocat_fashion_scarpe" model="crm.wishlist.sottocategoria">
<field name="name">Scarpe</field>
<field name="categoria_id" ref="morpheus_crm.categoria_fashion"/>
</record>
<record id="sottocat_fashion_abbigliamento" model="crm.wishlist.sottocategoria">
<field name="name">Abbigliamento</field>
<field name="categoria_id" ref="morpheus_crm.categoria_fashion"/>
</record>
<record id="sottocat_fashion_borse" model="crm.wishlist.sottocategoria">
<field name="name">Borse</field>
<field name="categoria_id" ref="morpheus_crm.categoria_fashion"/>
</record>
<record id="sottocat_fashion_accessori" model="crm.wishlist.sottocategoria">
<field name="name">Accessori</field>
<field name="categoria_id" ref="morpheus_crm.categoria_fashion"/>
</record>
<!-- Beauty -->
<record id="sottocat_beauty_skincare" model="crm.wishlist.sottocategoria">
<field name="name">Skincare</field>
<field name="categoria_id" ref="morpheus_crm.categoria_beauty"/>
</record>
<record id="sottocat_beauty_makeup" model="crm.wishlist.sottocategoria">
<field name="name">Make-up</field>
<field name="categoria_id" ref="morpheus_crm.categoria_beauty"/>
</record>
<record id="sottocat_beauty_profumi" model="crm.wishlist.sottocategoria">
<field name="name">Profumi</field>
<field name="categoria_id" ref="morpheus_crm.categoria_beauty"/>
</record>
<!-- Eyewear -->
<record id="sottocat_eyewear_occhiali_da_sole" model="crm.wishlist.sottocategoria">
<field name="name">Occhiali da sole</field>
<field name="categoria_id" ref="morpheus_crm.categoria_eyewear"/>
</record>
<record id="sottocat_eyewear_occhiali_da_vista" model="crm.wishlist.sottocategoria">
<field name="name">Occhiali da vista</field>
<field name="categoria_id" ref="morpheus_crm.categoria_eyewear"/>
</record>
<!-- Home -->
<record id="sottocat_home_arredamento" model="crm.wishlist.sottocategoria">
<field name="name">Arredamento</field>
<field name="categoria_id" ref="morpheus_crm.categoria_home"/>
</record>
<record id="sottocat_home_decorazioni" model="crm.wishlist.sottocategoria">
<field name="name">Decorazioni</field>
<field name="categoria_id" ref="morpheus_crm.categoria_home"/>
</record>
<record id="sottocat_home_illuminazione" model="crm.wishlist.sottocategoria">
<field name="name">Illuminazione</field>
<field name="categoria_id" ref="morpheus_crm.categoria_home"/>
</record>
<!-- Gift -->
<record id="sottocat_gift_gadget" model="crm.wishlist.sottocategoria">
<field name="name">Gadget</field>
<field name="categoria_id" ref="morpheus_crm.categoria_gift"/>
</record>
<record id="sottocat_gift_regali_aziendali" model="crm.wishlist.sottocategoria">
<field name="name">Regali aziendali</field>
<field name="categoria_id" ref="morpheus_crm.categoria_gift"/>
</record>
<!-- <record id="sottocat_gift_confezioni" model="crm.wishlist.sottocategoria">
<field name="name">Confezioni regalo</field>
<field name="categoria_id" ref="morpheus_crm.categoria_gift"/>
</record> -->
<!-- Food & Beverage -->
<record id="sottocat_food_beverage_vini" model="crm.wishlist.sottocategoria">
<field name="name">Vini</field>
<field name="categoria_id" ref="morpheus_crm.categoria_food_beverage"/>
</record>
<record id="sottocat_food_beverage_dolci" model="crm.wishlist.sottocategoria">
<field name="name">Dolci</field>
<field name="categoria_id" ref="morpheus_crm.categoria_food_beverage"/>
</record>
<record id="sottocat_food_beverage_prodotti_tipici" model="crm.wishlist.sottocategoria">
<field name="name">Prodotti tipici</field>
<field name="categoria_id" ref="morpheus_crm.categoria_food_beverage"/>
</record>
<!-- Other -->
<record id="sottocat_other_varie" model="crm.wishlist.sottocategoria">
<field name="name">Varie</field>
<field name="categoria_id" ref="morpheus_crm.categoria_other"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import crm_lead

View File

@ -0,0 +1,203 @@
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError, UserError
class CrmLead(models.Model):
_inherit = 'crm.lead'
addetto_acquisti = fields.Many2one(
'res.users',
string='Addetto acquisti'
)
addetto_acquisti_2 = fields.Many2one(
'res.users',
string='Addetto acquisti 2'
)
addetto_vendite_2 = fields.Many2one(
'res.users',
string='Addetto vendite 2'
)
def action_set_stage_wishlist_verificata(self):
self.ensure_one() # Assicura che il metodo sia chiamato su un singolo record, tipico per un click da form.
# Trova lo stadio di destinazione "Wishlist verificata"
# Per maggiore robustezza, se conosci l'XML ID dello stadio, usa:
# target_stage = self.env.ref('nome_tuo_modulo.xml_id_dello_stadio_wishlist_verificata', raise_if_not_found=False)
target_stage = self.env['crm.stage'].search([('name', '=', 'Wishlist verificata')], limit=1)
if not target_stage:
raise UserError(_("Lo stadio 'Wishlist verificata' non è stato trovato. Assicurati che esista."))
# Verifica se l'opportunità è effettivamente nello stadio "Nuova" prima di cambiarlo
# (questa è una doppia verifica, dato che l'XML dovrebbe già nascondere il bottone)
if self.stage_id.name == 'Nuova':
if self.stage_id.id == target_stage.id:
# Già nello stadio desiderato (improbabile se il bottone è visibile solo in "Nuova")
return True
self.write({'stage_id': target_stage.id})
else:
# Questo messaggio potrebbe apparire se la condizione 'invisible' nell'XML non fosse perfetta
# o se il metodo fosse chiamato da un'altra parte.
raise UserError(_("L'opportunità deve essere nello stadio 'Nuova' per poterla inviare a 'Wishlist verificata'."))
return True
verifica_target = fields.Selection([
('yes', ''),
('no', 'No')
], string="Verificato target ITA/EEUU")
motivo_verifica_negata = fields.Text(string="Motivo mancata verifica")
percent_provvigioni = fields.Float(
string="% Provvigioni",
digits=(5, 2),
help="Inserisci la percentuale"
)
agente_id = fields.Many2one(
'res.partner',
string="Agente",
domain="[('is_company', '=', False)]",
help="Seleziona l'agente di riferimento")
@api.onchange('partner_id')
def _onchange_partner_id_set_agente(self):
if self.partner_id:
if self.partner_id.agente_id:
self.agente_id = self.partner_id.agente_id
self.percent_provvigioni = self.partner_id.percent_provvigioni
@api.model
def create(self, vals):
if vals.get('partner_id'):
partner = self.env['res.partner'].browse(vals['partner_id'])
if not vals.get('agente_id') and partner.agente_id:
vals['agente_id'] = partner.agente_id.id
if not vals.get('percent_provvigioni'):
vals['percent_provvigioni'] = partner.percent_provvigioni
return super(CrmLead, self).create(vals)
# Campi per la Richiesta Offerta
fornitore_id = fields.Many2one('res.partner', string="Fornitore")
buyer = fields.Char(string="Buyer")
prodotti_richiesti = fields.Text(string="Prodotti Richiesti")
minimo_ordine = fields.Char(string="Minimo Ordine")
# Modifica del campo categoria_ids (Many2many)
categoria_ids = fields.Many2many(
'crm.wishlist.categoria',
string="Categorie"
)
sottocategoria_ids = fields.Many2many(
'crm.wishlist.sottocategoria',
string="Sottocategorie",
domain="[('categoria_id', 'in', categoria_ids)]"
)
stagione = fields.Selection([
('close_out', 'Close Out'),
('attuale', 'Attuale'),
('preordine', 'Pre-ordine'),
], string="Stagione")
budget = fields.Monetary(string="Budget", currency_field='currency_id')
currency_id = fields.Many2one('res.currency', string='Valuta', default=lambda self: self.env.company.currency_id.id)
tipo_richiesta = fields.Selection([
('quotazione', 'Quotazione'),
('generica', 'Generica'),
], string="Tipo Richiesta")
brand = fields.Char(string="Brand")
target = fields.Char(string="Target")
genere_ids = fields.Many2many(
'crm.lead.genere', # Riferimento al nuovo modello
string="Genere"
)
note = fields.Text(string="Note")
shelf_life = fields.Char(string="Shelf life")
allegato_ids = fields.Many2many('ir.attachment', string="Allegati")
# Computazione del completamento della wishlist
wishlist_complete_percent = fields.Float(
string="Completamento Wishlist (%)",
compute="_compute_wishlist_complete_percent",
store=True
)
@api.depends(
'categoria_ids',
'sottocategoria_ids',
'stagione',
'budget',
'tipo_richiesta',
'brand',
'target',
'genere_ids',
'verifica_target',
)
def _compute_wishlist_complete_percent(self):
for lead in self:
fields_to_check = [
'categoria_ids', 'sottocategoria_ids', 'stagione', 'budget',
'tipo_richiesta', 'brand', 'target',
'genere_ids', 'verifica_target',
]
total = len(fields_to_check)
completed = 0
for field_name in fields_to_check:
value = getattr(lead, field_name)
if value: # Per i Many2many, questo verifica se la relazione non è vuota
completed += 1
lead.wishlist_complete_percent = round((completed / total) * 100.0, 2) if total else 0.0
@api.onchange('tipo_richiesta')
def _onchange_tipo_richiesta(self):
if self.tipo_richiesta == 'quotazione':
self.priority = '3'
elif self.tipo_richiesta == 'generica':
self.priority = '2'
else:
self.priority = '1'
class WishlistCategoria(models.Model):
_name = 'crm.wishlist.categoria'
_description = 'Categoria Wishlist'
name = fields.Char(string="Nome Categoria")
class WishlistSottocategoria(models.Model):
_name = 'crm.wishlist.sottocategoria'
_description = 'Sottocategoria Wishlist'
name = fields.Char(required=True)
categoria_id = fields.Many2one(
'crm.wishlist.categoria',
string='Categoria di appartenenza',
required=True,
ondelete='cascade',
)
# Nuovo modello per le opzioni del Genere
class CrmLeadGenere(models.Model):
_name = 'crm.lead.genere'
_description = 'Genere per Lead CRM'
name = fields.Char(string="Nome Genere", required=True, translate=True)

View File

@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_crm_wishlist_categoria_user,access.crm.wishlist.categoria.user,model_crm_wishlist_categoria,base.group_user,1,1,1,1
access_crm_wishlist_categoria_manager,access.crm.wishlist.categoria.manager,model_crm_wishlist_categoria,base.group_system,1,1,1,1
access_crm_wishlist_sottocategoria_user,access.crm.wishlist.sottocategoria.user,model_crm_wishlist_sottocategoria,base.group_user,1,1,1,1
access_crm_wishlist_sottocategoria_manager,access.crm.wishlist.sottocategoria.manager,model_crm_wishlist_sottocategoria,base.group_system,1,1,1,1
access_crm_lead_genere_user,crm.lead.genere.user,model_crm_lead_genere,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_crm_wishlist_categoria_user access.crm.wishlist.categoria.user model_crm_wishlist_categoria base.group_user 1 1 1 1
3 access_crm_wishlist_categoria_manager access.crm.wishlist.categoria.manager model_crm_wishlist_categoria base.group_system 1 1 1 1
4 access_crm_wishlist_sottocategoria_user access.crm.wishlist.sottocategoria.user model_crm_wishlist_sottocategoria base.group_user 1 1 1 1
5 access_crm_wishlist_sottocategoria_manager access.crm.wishlist.sottocategoria.manager model_crm_wishlist_sottocategoria base.group_system 1 1 1 1
6 access_crm_lead_genere_user crm.lead.genere.user model_crm_lead_genere base.group_user 1 1 1 1

View File

@ -0,0 +1,18 @@
<odoo>
<record id="view_crm_lead_kanban_inherit_data_inserimento" model="ir.ui.view">
<field name="name">crm.lead.kanban.inherit.data.inserimento</field>
<field name="model">crm.lead</field>
<field name="inherit_id" ref="crm.crm_case_kanban_view_leads"/>
<field name="arch" type="xml">
<!-- Inserisce dopo il partner -->
<xpath expr="//field[@name='partner_id']" position="after">
<div class="text-muted small">
🕓 Data inserimento:
<field name="create_date" readonly="1"/>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,77 @@
<odoo>
<record id="view_crm_lead_form_custom_wishlist" model="ir.ui.view">
<field name="name">crm.lead.form.wishlist</field>
<field name="model">crm.lead</field>
<field name="inherit_id" ref="crm.crm_lead_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='user_id']" position="before">
<field name="create_date" readonly="1"/>
<field name="agente_id" context="{'default_is_company': False, 'default_company_type': 'person'}" string="Agente"/>
<field name="percent_provvigioni"/>
</xpath>
<xpath expr="//field[@name='user_id']" position="after">
<field name="addetto_vendite_2"/>
<field name="addetto_acquisti"/>
<field name="addetto_acquisti_2"/>
</xpath>
<field name="day_open" position="attributes">
<attribute name="invisible">1</attribute>
</field>
<field name="day_close" position="attributes">
<attribute name="invisible">1</attribute>
</field>
<xpath expr="//sheet/notebook" position="inside">
<page string="Wishlist Cliente">
<group string="Completamento">
<field name="wishlist_complete_percent" widget="progressbar"/>
<button name="action_set_stage_wishlist_verificata"
string="Invio Wishlist Verificata"
type="object"
help="Passa l'opportunità allo stadio Wishlist verificata"/>
</group>
<group string="Informazioni Wishlist" col="2">
<field name="categoria_ids" widget="many2many_checkboxes"/>
<field name="sottocategoria_ids" widget="many2many_checkboxes" domain="[('categoria_id', 'in', categoria_ids)]"/>
<field name="stagione"/>
<field name="budget"/>
<field name="currency_id" invisible="1"/>
<field name="tipo_richiesta"/>
</group>
<group string="Dettagli Aggiuntivi" col="2">
<field name="brand"/>
<field name="target"/>
<field name="verifica_target"/>
<field name="motivo_verifica_negata" invisible="verifica_target != 'no'" required="verifica_target == 'no'" />
<field name="genere_ids" widget="many2many_checkboxes"/>
<field name="shelf_life"/>
<field name="note"/>
<field name="allegato_ids" widget="many2many_binary"/>
</group>
</page>
<page string="Richiesta Offerta">
<group string="Informazioni Richiesta Offerta" col="2">
<field name="fornitore_id"/>
<field name="categoria_ids" widget="many2many_checkboxes"/>
<field name="sottocategoria_ids" widget="many2many_checkboxes" domain="[('categoria_id', 'in', categoria_ids)]"/>
<field name="stagione"/>
<field name="buyer"/>
<field name="prodotti_richiesti"/>
</group>
<group string="Dettagli Aggiuntivi" col="2">
<field name="brand"/>
<field name="minimo_ordine"/>
<field name="genere_ids" widget="many2many_checkboxes"/>
<field name="note"/>
<field name="allegato_ids" widget="many2many_binary"/>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,26 @@
{
'name': 'Custom PDF Report - ReportLab',
'version': '18.0.1.0.0',
'category': 'Sales',
'summary': 'Genera PDF personalizzati da preventivi e ordini usando ReportLab',
'description': """
Modulo per creare PDF personalizzati partendo dalle informazioni
di preventivi e ordini di vendita utilizzando la libreria ReportLab.
Compatibile con Odoo 18.0
""",
'author': 'Il Tuo Nome',
'website': 'https://www.example.com',
'depends': ['sale', 'base', 'mail'],
'external_dependencies': {
'python': ['reportlab'],
},
'data': [
'security/ir.model.access.csv',
'views/letterhead_wizard_views.xml',
],
'installable': True,
'auto_install': False,
'application': False,
'license': 'LGPL-3',
}

View File

@ -0,0 +1 @@
from . import sale_order

View File

@ -0,0 +1,897 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
import base64
import io
import logging
from datetime import datetime, date
import re
# Import ReportLab
try:
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image, Flowable, Indenter
from reportlab.lib.units import inch, cm
from reportlab.lib.enums import TA_CENTER, TA_RIGHT, TA_LEFT
from reportlab.lib.utils import ImageReader
except ImportError:
raise ImportError("ReportLab non è installato. Esegui: pip install reportlab")
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _clean_html_text(self, html_text):
if not html_text:
return ""
text = html_text
text = re.sub(r'<br\s*/?>', '\n', text)
text = re.sub(r'<p[^>]*>', '\n\n', text)
text = re.sub(r'</p>', '', text)
text = re.sub(r'<div[^>]*>', '\n', text)
text = re.sub(r'</div>', '', text)
text = re.sub(r'<strong[^>]*>(.*?)</strong>', r'<b>\1</b>', text, flags=re.DOTALL)
text = re.sub(r'<b[^>]*>(.*?)</b>', r'<b>\1</b>', text, flags=re.DOTALL)
text = re.sub(r'<[^>]+>', '', text)
text = text.replace('&nbsp;', ' ').replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>').replace('&quot;', '"').replace('&#39;', "'")
lines = text.split('\n')
cleaned_lines = [' '.join(line.split()) for line in lines if line.strip()]
result = '<br/>'.join(cleaned_lines)
return result
def _format_currency(self, amount):
"""Formatta gli importi con separatore delle migliaia in formato italiano"""
if not isinstance(amount, (int, float)):
return ""
formatted = f"{amount:,.2f}".replace(',', 'TEMP').replace('.', ',').replace('TEMP', '.')
return f"{formatted}"
def action_generate_custom_pdf(self, letterhead_type=None, admin_fields=None):
self.ensure_one()
import os
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import SimpleDocTemplate, Image, Spacer, Table, TableStyle, Paragraph
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
import io
import base64
from datetime import datetime
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=1*cm,
leftMargin=1*cm,
topMargin=0.2*cm,
bottomMargin=3*cm
)
story = []
styles = getSampleStyleSheet()
partner = self.partner_id
# Logo in alto a sinistra
img = None
module_path = os.path.dirname(os.path.abspath(__file__))
logo_path = os.path.join(module_path, '../static/src/img/ap_logo_info.png')
logo_path = os.path.normpath(logo_path)
if os.path.exists(logo_path):
img = Image(logo_path, width=300, height=170)
img.hAlign = 'LEFT'
# Dati cliente in alto a destra
client_lines = []
client_lines.append(f"<b>Customer Code No. </b> {partner.id}")
if partner.name:
client_lines.append(f"<b>{partner.name}</b>")
if partner.street:
client_lines.append(partner.street)
if partner.street2:
client_lines.append(partner.street2)
city_line = ' '.join(filter(None, [partner.zip, partner.city]))
if city_line:
client_lines.append(city_line)
if partner.country_id:
client_lines.append(partner.country_id.name)
client_style = ParagraphStyle('ClientStyle', parent=styles['Normal'], fontSize=14, leading=26, alignment=2)
client_info = Paragraph('<br/>'.join(client_lines), client_style)
# Tabella logo sx, cliente dx
table_data = [[img if img else '', client_info]]
table = Table(table_data, colWidths=[doc.width/2, doc.width/2])
table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('ALIGN', (0, 0), (0, 0), 'LEFT'),
('ALIGN', (1, 0), (1, 0), 'RIGHT'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 0),
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
]))
story.append(table)
story.append(Spacer(1, 30))
# Barra PROFORMA
class ProformaHeader(Flowable):
def __init__(self, width, proforma_text, page_text):
super().__init__()
self.width = width
self.proforma_text = proforma_text
self.page_text = page_text
self.height = 24
def draw(self):
self.canv.saveState()
# Gradiente da nero (#000000) a fucsia (#f30ad0) come ThankYouBar
steps = int(self.width * 2)
rect_width = self.width / steps
for i in range(steps):
ratio = i / (steps - 1)
r = int(0 + (243 - 0) * ratio)
g = int(0 + (10 - 0) * ratio)
b = int(0 + (208 - 0) * ratio)
color = colors.Color(r/255, g/255, b/255)
self.canv.setFillColor(color)
self.canv.rect(i * rect_width, 0, rect_width + 0.5, self.height, stroke=0, fill=1)
self.canv.setFont("Helvetica-Bold", 16)
self.canv.setFillColor(colors.white)
self.canv.drawCentredString(self.width/2, 6, self.proforma_text)
self.canv.setFont("Helvetica", 10)
self.canv.drawRightString(self.width-10, 6, self.page_text)
self.canv.restoreState()
def wrap(self, availWidth, availHeight):
return self.width, self.height
proforma_header = ProformaHeader(doc.width, "PROFORMA", "Pagina 1")
story.append(proforma_header)
story.append(Spacer(1, 20))
# Colonna 3: PO Number e PO Date affiancati
po_number_label = Paragraph('<b>PO Number</b>', ParagraphStyle('po_label', parent=styles['Normal'], alignment=2))
po_number_value = Paragraph(self.name or '', ParagraphStyle('po_value', parent=styles['Normal'], alignment=2))
po_date_label = Paragraph('<b>PO Date</b>', ParagraphStyle('po_label', parent=styles['Normal'], alignment=2))
po_date_value = Paragraph(self.date_order.strftime('%d/%m/%Y') if self.date_order else '', ParagraphStyle('po_value', parent=styles['Normal'], alignment=2))
col3_table = Table(
[
[po_number_label, po_date_label],
[po_number_value, po_date_value]
],
colWidths=[(doc.width/3)/2, (doc.width/3)/2]
)
col3_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 2),
('RIGHTPADDING', (0, 0), (-1, -1), 2),
('TOPPADDING', (0, 0), (-1, -1), 0),
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
('BOX', (0, 0), (-1, -1), 0, colors.white),
('INNERGRID', (0, 0), (-1, -1), 0, colors.white),
]))
col3 = col3_table
# Colonna 2: Fiscal Code sopra, sotto Currency e Payment Terms affiancati, con valori
fiscal_code_label = Paragraph('<b>Fiscal Code</b>', ParagraphStyle('fiscal_label', parent=styles['Normal'], alignment=1))
currency_label = Paragraph('<b>Currency</b>', ParagraphStyle('currency_label', parent=styles['Normal'], alignment=1))
payment_terms_label = Paragraph('<b>Payment Terms</b>', ParagraphStyle('payment_label', parent=styles['Normal'], alignment=1))
fiscal_code_value = Paragraph(getattr(partner, 'l10n_it_codice_fiscale', '') or '', ParagraphStyle('fiscal_value', parent=styles['Normal'], alignment=1))
currency_value = Paragraph(self.currency_id.name or '', ParagraphStyle('currency_value', parent=styles['Normal'], alignment=1))
payment_terms_value = Paragraph(self.payment_term_id.name or '', ParagraphStyle('payment_value', parent=styles['Normal'], alignment=1))
col2_table = Table(
[
[fiscal_code_label, ''],
[currency_label, payment_terms_label],
[currency_value, payment_terms_value]
],
colWidths=[(doc.width/3)/2, (doc.width/3)/2]
)
col2_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 2),
('RIGHTPADDING', (0, 0), (-1, -1), 2),
('TOPPADDING', (0, 0), (-1, -1), 0),
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
('BOX', (0, 0), (-1, -1), 0, colors.white),
('INNERGRID', (0, 0), (-1, -1), 0, colors.white),
]))
col2 = col2_table
# Colonna 1: Customer VAT Number, Customer Purchase Order, NOTES con valore (label allineata)
vat_label = Paragraph('<b>Customer VAT Number</b>', ParagraphStyle('vat_label', parent=styles['Normal'], alignment=0))
vat_value = Paragraph(partner.vat or '', ParagraphStyle('vat_value', parent=styles['Normal'], alignment=0))
po_label = Paragraph('<b>Customer Purchase Order</b>', ParagraphStyle('po_label', parent=styles['Normal'], alignment=0))
notes_label = Paragraph('<b>NOTES</b>', ParagraphStyle('notes_label', parent=styles['Normal'], alignment=0))
notes_value = Paragraph(self.note or '', ParagraphStyle('notes_value', parent=styles['Normal'], alignment=0))
col1 = [vat_label, vat_value, Spacer(1, 4), po_label, Spacer(1, 4), notes_label, notes_value]
second_row_table = Table(
[[col1, col2, col3]],
colWidths=[doc.width/3, doc.width/3, doc.width/3]
)
second_row_table.setStyle(TableStyle([
('VALIGN', (0, 0), (0, 0), 'TOP'),
('VALIGN', (1, 0), (1, 0), 'TOP'),
('VALIGN', (2, 0), (2, 0), 'TOP'),
('ALIGN', (0, 0), (1, 0), 'LEFT'),
('ALIGN', (2, 0), (2, 0), 'RIGHT'),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 6),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('BOX', (0, 0), (-1, -1), 0, colors.white),
('INNERGRID', (0, 0), (-1, -1), 0, colors.white),
]))
story.append(second_row_table)
story.append(Spacer(1, 20))
# Tabella delle righe del preventivo (8 colonne)
header_style = ParagraphStyle('HeaderStyle', parent=styles['Normal'], textColor=colors.white, alignment=0, fontName='Helvetica-Bold', fontSize=7)
order_line_headers = [
Paragraph('<b>Code</b>', header_style),
Paragraph('<b>Description</b>', header_style),
Paragraph('<b>Pcs</b>', header_style),
Paragraph('<b>Unit Price</b>', header_style),
Paragraph('<b>Disc.%</b>', header_style),
Paragraph('<b>Disc.%</b>', header_style),
Paragraph('<b>Total</b>', header_style),
Paragraph('<b>VAT Code</b>', header_style),
]
# Preparo le righe dati con wordwrap per le colonne di testo
wrapped_order_line_rows = []
for line in self.order_line:
disc1 = getattr(line, 'discount', 0)
disc2 = getattr(line, 'discount', 0)
disc1_str = f"{disc1:.2f}" if disc1 != 0 else ''
disc2_str = f"{disc2:.2f}" if disc2 != 0 else ''
wrapped_order_line_rows.append([
Paragraph(line.product_id.display_name or '', ParagraphStyle('wrap', parent=styles['Normal'], fontSize=9, wordWrap='CJK')),
Paragraph(line.name or '', ParagraphStyle('wrap', parent=styles['Normal'], fontSize=9, wordWrap='CJK')),
f"{line.product_uom_qty:.2f}",
f"{line.price_unit:.2f}",
disc1_str,
disc2_str,
f"{line.price_subtotal:.2f}",
', '.join([tax.name for tax in line.tax_id]) if line.tax_id else '',
])
# Barra gradiente per header tabella dettaglio
class DetailHeaderBar(Flowable):
def __init__(self, width, height=24):
super().__init__()
self.width = width
self.height = height
def draw(self):
self.canv.saveState()
steps = int(self.width * 2)
rect_width = self.width / steps
for i in range(steps):
ratio = i / (steps - 1)
r = int(0 + (243 - 0) * ratio)
g = int(0 + (10 - 0) * ratio)
b = int(0 + (208 - 0) * ratio)
color = colors.Color(r/255, g/255, b/255)
self.canv.setFillColor(color)
self.canv.rect(i * rect_width, 0, rect_width + 0.5, self.height, stroke=0, fill=1)
self.canv.restoreState()
def wrap(self, availWidth, availHeight):
return self.width, self.height
# RIMUOVO la barra gradiente tra la tabella proforma e la tabella dei dettagli
# story.append(DetailHeaderBar(doc.width, 24))
# Aggiungo la riga delle intestazioni sopra la barra gradiente
header_table = Table([
order_line_headers
], colWidths=[
doc.width * 0.17, # Code
doc.width * 0.38, # Description
doc.width * 0.06, # Pcs
doc.width * 0.10, # Unit Price
doc.width * 0.06, # Disc.%
doc.width * 0.06, # Disc.%
doc.width * 0.15, # Total (ingrandita)
doc.width * 0.08, # VAT Code
])
header_table.setStyle(TableStyle([
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (1, 0), 'LEFT'), # Code, Description
('ALIGN', (2, 0), (7, 0), 'RIGHT'), # tutte le altre colonne
('VALIGN', (0, 0), (-1, 0), 'MIDDLE'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 20),
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
('TOPPADDING', (0, 0), (-1, 0), 6),
('LEFTPADDING', (0, 0), (-1, 0), 4),
('RIGHTPADDING', (0, 0), (-1, 0), 4),
]))
story.append(header_table)
def make_gradient_colors(start_hex, end_hex, n):
from reportlab.lib.colors import HexColor, Color
s = HexColor(start_hex)
e = HexColor(end_hex)
colors_list = []
for i in range(n):
ratio = i / max(n-1, 1)
r = s.red + (e.red - s.red) * ratio
g = s.green + (e.green - s.green) * ratio
b = s.blue + (e.blue - s.blue) * ratio
colors_list.append(Color(r, g, b))
return colors_list
# Tabella delle righe del preventivo (8 colonne) con header gradiente
order_table_data = [order_line_headers] + wrapped_order_line_rows
order_table = Table(order_table_data, colWidths=[
doc.width * 0.17, # Code
doc.width * 0.38, # Description
doc.width * 0.06, # Pcs
doc.width * 0.10, # Unit Price
doc.width * 0.06, # Disc.%
doc.width * 0.06, # Disc.%
doc.width * 0.10, # Total (ingrandita)
doc.width * 0.08, # VAT Code
])
gradient_colors = make_gradient_colors('#000000', '#f30ad0', 8)
order_table_style = TableStyle([
*[
('BACKGROUND', (col, 0), (col, 0), color)
for col, color in enumerate(gradient_colors)
],
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (1, -1), 'LEFT'), # Code, Description
('ALIGN', (2, 0), (7, -1), 'RIGHT'), # tutte le altre colonne
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 20), # intestazione a 20pt
('FONTSIZE', (0, 1), (-1, -1), 9), # righe dati a 10pt (ingrandito)
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
('TOPPADDING', (0, 0), (-1, 0), 6),
('LEFTPADDING', (0, 0), (-1, -1), 4),
('RIGHTPADDING', (0, 0), (-1, -1), 4),
# Nascondo completamente i bordi tra le colonne nella riga header
('LINEBELOW', (0, 0), (-1, 0), 0, colors.transparent),
('LINEABOVE', (0, 0), (-1, 0), 0, colors.transparent),
('LINEBEFORE', (0, 0), (0, 0), 0, colors.transparent),
('LINEAFTER', (-1, 0), (-1, 0), 0, colors.transparent),
('INNERGRID', (0, 0), (-1, 0), 0, colors.transparent),
('BOX', (0, 0), (-1, 0), 0, colors.transparent),
('GRID', (0, 0), (-1, 0), 0, colors.transparent),
# Linee orizzontali tra le righe dati
('LINEBELOW', (0, 1), (-1, -1), 0.25, colors.HexColor('#cccccc')), # sotto ogni riga dati
('WORDWRAP', (0, 0), (-1, -1), 'CJK'), # Abilita il word wrap su tutte le celle
])
order_table.setStyle(order_table_style)
story.append(order_table)
story.append(Spacer(1, 10)) # Interlinea tra tabella di dettaglio e tabella sottostante
# Replica della barra di intestazione sotto la tabella di dettaglio
gradient_colors = make_gradient_colors('#000000', '#f30ad0', 8)
header_bar_table = Table([
order_line_headers
], colWidths=[
doc.width * 0.17, # Code
doc.width * 0.38, # Description
doc.width * 0.06, # Pcs
doc.width * 0.10, # Unit Price
doc.width * 0.06, # Disc.%
doc.width * 0.06, # Disc.%
doc.width * 0.10, # Total (ingrandita)
doc.width * 0.08, # VAT Code
])
header_bar_table.setStyle(TableStyle([
*[
('BACKGROUND', (col, 0), (col, 0), color)
for col, color in enumerate(gradient_colors)
],
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (1, 0), 'LEFT'), # Code, Description
('ALIGN', (2, 0), (7, 0), 'RIGHT'), # tutte le altre colonne
('VALIGN', (0, 0), (-1, 0), 'MIDDLE'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 20),
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
('TOPPADDING', (0, 0), (-1, 0), 6),
('LEFTPADDING', (0, 0), (-1, 0), 4),
('RIGHTPADDING', (0, 0), (-1, 0), 4),
]))
story.append(header_bar_table)
# Tabella Totali sotto e a destra della barra header
# Calcolo larghezza delle ultime due colonne
col_widths = [
doc.width * 0.17, # Code
doc.width * 0.38, # Description
doc.width * 0.06, # Pcs
doc.width * 0.10, # Unit Price
doc.width * 0.06, # Disc.%
doc.width * 0.06, # Disc.%
doc.width * 0.10, # Total (ingrandita)
doc.width * 0.08, # VAT Code
]
# Calcolo i valori richiesti
imponibile = sum([line.price_subtotal for line in self.order_line])
total_vat = sum([(line.price_total - line.price_subtotal) for line in self.order_line])
currency = self.currency_id and self.currency_id.name or ''
# Definisco ora le label e i valori per la tabella Totali (senza le tre label iniziali)
totals_labels = [
'Total Taxable Vat',
'Total Vat',
f'Total Value Goods <b>{self.currency_id.name}</b>',
'Deposit',
'Additional Discount',
'Additional Discount',
'Total Fees',
'<b>TOTAL PROFORMA</b>',
]
values_col = [
f"{imponibile:,.2f}".replace(",", "TEMP").replace(".", ",").replace("TEMP", "."), # Total Taxable Vat senza valuta
f"{total_vat:,.2f}".replace(",", "TEMP").replace(".", ",").replace("TEMP", "."), # Total Vat
f"{imponibile:,.2f}".replace(",", "TEMP").replace(".", ",").replace("TEMP", "."), # Total Value Goods solo numero
'', # Deposit
'', # Additional Discount
'', # Additional Discount
'', # Total Fees
'', # TOTAL PROFORMA (placeholder, verrà aggiornato dopo)
]
try:
additional_discount_1 = float(values_col[4]) if values_col[4] else 0.0
except Exception:
additional_discount_1 = 0.0
try:
additional_discount_2 = float(values_col[5]) if values_col[5] else 0.0
except Exception:
additional_discount_2 = 0.0
proforma_total = imponibile + total_vat - (additional_discount_1 + additional_discount_2)
values_col[-1] = f"{proforma_total:,.2f}".replace(",", "TEMP").replace(".", ",").replace("TEMP", ".")
# Larghezza identica alla barra header
header_bar_width = sum(col_widths)
shrink_factor = 0.32 # 32% della larghezza originale
new_table_width = header_bar_width * shrink_factor
label_col_width = new_table_width * 0.7
value_col_width = new_table_width * 0.3
indent = doc.width - new_table_width - 11
# Ricostruisco totals_rows con la colonna valori
new_totals_rows = []
for i, label in enumerate(totals_labels):
# Grassetto per Total Taxable Vat e Total Value Goods
is_bold = 'Total Taxable Vat' in label or 'Total Value Goods' in label or 'TOTAL PROFORMA' in label
style_label = ParagraphStyle('totals_label', parent=styles['Normal'], fontSize=10, fontName='Helvetica-Bold' if 'TOTAL PROFORMA' in label else 'Helvetica')
style_value = ParagraphStyle('totals_value', parent=styles['Normal'], fontSize=10, alignment=2, fontName='Helvetica-Bold' if is_bold else 'Helvetica')
new_totals_rows.append([
Paragraph(label, style_label),
Paragraph(values_col[i], style_value) if values_col[i] else ''
])
# Adatto la tabella Totali alla larghezza della colonna 3 della tabella esterna
col1_width = doc.width - new_table_width - 11 - 5
col2_width = 0
col3_width = new_table_width
label_col_width = col3_width * 0.7
value_col_width = col3_width * 0.3
class TotalsHeaderBar(Flowable):
def __init__(self, width, height=24, text="Totale"):
super().__init__()
self.width = width
self.height = height
self.text = text
def draw(self):
self.canv.saveState()
steps = int(self.width * 2)
rect_width = self.width / steps
for i in range(steps):
ratio = i / (steps - 1)
r = int(0 + (243 - 0) * ratio)
g = int(0 + (10 - 0) * ratio)
b = int(0 + (208 - 0) * ratio)
color = colors.Color(r/255, g/255, b/255)
self.canv.setFillColor(color)
self.canv.rect(i * rect_width, 0, rect_width + 0.5, self.height, stroke=0, fill=1)
self.canv.setFont("Helvetica-Bold", 14)
self.canv.setFillColor(colors.white)
self.canv.drawCentredString(self.width/2, 6, self.text)
self.canv.restoreState()
def wrap(self, availWidth, availHeight):
return self.width, self.height
# Uso la barra gradiente come header della tabella dei totali
totals_table = Table(
[[TotalsHeaderBar(new_table_width, 24), '']] + new_totals_rows,
colWidths=[label_col_width, value_col_width]
)
totals_table.setStyle(TableStyle([
('SPAN', (0, 0), (1, 0)),
# Rimuovo il background fucsia, la barra gradiente è ora il Flowable TotalsHeaderBar
#('BACKGROUND', (0, 0), (1, 0), colors.HexColor('#f30ad0')),
('BACKGROUND', (0, 1), (-1, -1), colors.lightgrey), # sfondo grigio a tutta la tabella dei totali
('TEXTCOLOR', (0, 0), (1, 0), colors.white),
('ALIGN', (0, 0), (1, 0), 'CENTER'),
('VALIGN', (0, 0), (1, 0), 'MIDDLE'),
('FONTNAME', (0, 0), (1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (1, 0), 14),
('FONTSIZE', (0, 1), (-1, -1), 10), # Applica 10pt a tutte le righe tranne header
('BOTTOMPADDING', (0, 0), (1, 0), 0), # padding a 0 per la barra
('TOPPADDING', (0, 0), (1, 0), 0),
('LEFTPADDING', (0, 0), (1, 0), 0),
('RIGHTPADDING', (0, 0), (1, 0), 0),
('LINEABOVE', (0, 0), (1, 0), 0.5, colors.HexColor('#f30ad0')),
('ALIGN', (0, 1), (0, -1), 'LEFT'),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
('FONTNAME', (0, 8), (0, 8), 'Helvetica-Bold'),
#('BOX', (0, 0), (-1, -1), 1, colors.black),
#('INNERGRID', (0, 0), (-1, -1), 1, colors.black),
]))
# Recupero i valori richiesti per la mini-tabella
# Total Taxable Vat dalla tabella Totali
total_taxable_vat_value = values_col[0]
# Codici VAT dalla colonna 7 della tabella dettaglio (solo uno, non ripetuto)
vat_codes_list = [row[7] for row in wrapped_order_line_rows if row[7]] if wrapped_order_line_rows else []
vat_codes_unique = ', '.join(sorted(set(vat_codes_list)))
# Creo la mini-tabella 2x3
labels_row = ['Taxable Vat', 'VAT Code', 'VAT']
values_row = [total_taxable_vat_value, vat_codes_unique, '']
# Nuovo blocco verticale per la colonna 1, riga 2
# Ricostruisco la mini-tabella con la nuova colonna
labels_table = Table([
labels_row,
values_row,
# La terza riga con il valore duplicato è stata rimossa
], colWidths=[col1_width*0.18, col1_width*0.18, col1_width*0.18])
labels_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('ALIGN', (0, 1), (2, 1), 'CENTER'),
('FONTNAME', (0, 1), (2, 1), 'Helvetica'),
('FONTSIZE', (0, 1), (2, 1), 10),
]))
# Creo una nuova tabella a 3 colonne: la prima riga contiene le label richieste, la seconda e terza sono vuote, la quarta contiene la tabella Totali con rowspan=3
# Modifica: aggiungo le label richieste nella seconda riga, prima colonna
# Ora la tabella ha due colonne: label e valore (solo per Total Goods)
courier_labels = Table([
[Paragraph('Total Goods', styles['Normal']), Paragraph(f'<b>{total_taxable_vat_value}</b>', ParagraphStyle('bold_total', parent=styles['Normal'], fontName='Helvetica-Bold', fontSize=7))],
[Paragraph('Stamp Duty', styles['Normal']), ''],
[Paragraph('Total Pieces', styles['Normal']), ''],
], colWidths=[col1_width*0.27, col1_width*0.27])
courier_labels.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica'),
('FONTNAME', (1, 0), (1, 0), 'Helvetica-Bold'), # grassetto solo per il valore imponibile
('FONTSIZE', (0, 0), (-1, -1), 7),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
('LEFTPADDING', (0, 0), (1, -1), 30),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('BACKGROUND', (0, 0), (0, 0), colors.lightgrey), # sfondo grigio alla cella colonna 1 riga 1
('BACKGROUND', (1, 0), (1, 0), colors.lightgrey), # sfondo grigio all'importo accanto a Total Goods
('BACKGROUND', (0, 1), (1, 1), colors.lightgrey), # sfondo grigio a Stamp Duty e valore
('BACKGROUND', (0, 2), (1, 2), colors.lightgrey), # sfondo grigio a Total Pieces e valore
]))
outer_table = Table([
[labels_table, '', totals_table],
[courier_labels, '', ''],
], colWidths=[col1_width, col2_width, col3_width])
outer_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'TOP'),
#('BOX', (0, 0), (-1, -1), 1, colors.black),
#('INNERGRID', (0, 0), (-1, -1), 1, colors.black),
('SPAN', (2, 0), (2, 1)), # rowspan della colonna 3 sulle 2 righe
]))
story.append(outer_table)
story.append(Indenter(0, 0))
story.append(Spacer(1, 10))
# Label Bank Details in grassetto
bank_details_label = Paragraph('<b>Bank Details</b>', styles['Normal'])
story.append(bank_details_label)
story.append(Spacer(1, 10))
# Nuova tabella 4 colonne, 1 riga, stessa larghezza della outer_table
# Calcolo larghezza totale della tabella superiore
bottom_table_total_width = col1_width + col2_width + col3_width
bottom_table_col_width = bottom_table_total_width / 4
bottom_table_col_widths = [bottom_table_col_width] * 4
bottom_table = Table([
['Account Name', 'Bank Name', 'BIC/SWIFT', 'IBAN']
], colWidths=bottom_table_col_widths)
bottom_table.setStyle(TableStyle([
#('BOX', (0, 0), (-1, -1), 1, colors.black),
#('INNERGRID', (0, 0), (-1, -1), 1, colors.black),
('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey), # sfondo grigio a tutta la riga
]))
small_footer_style = ParagraphStyle('small_footer', parent=styles['Normal'], fontSize=7)
remarks_text = (
'REMARKS :<br/>'
'1. Bank commissions and currency exchange fees are at customers charge.<br/>'
'2. Payments have to be processed in Euro currency.<br/>'
'3. Deposit and Balance payments must be processed as per net amounts confirmed in the Proforma Invoice or Invoice. In case of short amount for the deposit, the remaining amount will be charged in the balance payment.<br/>'
'Balance payments with short amounts will not be accepted.<br/>'
'<br/>' # Interlinea sopra la scritta MANDATORY CLAIM SUBMISSION
'<b>MANDATORY CLAIM SUBMISSION :</b><br/>'
'In case of non-conformity goods, claims must be submitted within 15 days from the goods pick-up date. Claims received after this time frame will not be processed.'
)
footer_table_data = [
['', ''],
[
Paragraph('COURIER / FORWARDER NAME<br/><br/><br/><b>WAREHOUSE PICKUP ADDRESS</b>', styles['Normal']),
Paragraph(remarks_text, small_footer_style)
],
]
story.append(bottom_table)
story.append(Spacer(1, 10)) # Interlinea tra la tabella sopra e la tabella footer
# Inserisco la barra gradiente sopra la tabella footer
story.append(DetailHeaderBar(doc.width, 24))
footer_table = Table(footer_table_data, colWidths=[bottom_table_total_width/2]*2)
footer_table.setStyle(TableStyle([
# RIMUOVO il background fucsia, la barra gradiente è sopra
#('BACKGROUND', (0, 0), (1, 0), colors.HexColor('#f30ad0')),
('TEXTCOLOR', (0, 0), (1, 0), colors.white),
('ALIGN', (0, 0), (1, 0), 'CENTER'),
('VALIGN', (0, 1), (0, 1), 'TOP'), # COURIER / FORWARDER NAME in alto
('FONTNAME', (0, 0), (1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (1, 0), 12),
]))
story.append(footer_table)
# Dopo la tabella footer, aggiungo il blocco privacy notes
privacy_notes_text = (
'<b>PRIVACY NOTES:</b><br/>'
'Pursuant to art. 13 of the EU Regulation n. 2016/679, as the Data Controller of your personal data, MORPHEUS ADVISOR SRL informs you that the acquired data will be processed in computerized and non-computerized mode, for the execution of legal or contractual obligations, to guarantee the correct administrative and accounting management and that your data will not be disseminated. For the exercise of your rights, as provided for by the the Regulations, you can contact us:<br/>'
'Tel. +39 0577 686678 - info@apitalianluxury.com. The complete information on the processing of personal data is available at the headquarters of the Data Controller.'
)
privacy_notes_para = Paragraph(privacy_notes_text, ParagraphStyle('privacy_notes', parent=styles['Normal'], fontSize=7, spaceBefore=8, spaceAfter=0))
privacy_notes_table = Table(
[[privacy_notes_para]],
colWidths=[bottom_table_total_width]
)
privacy_notes_table.setStyle(TableStyle([
#('BOX', (0, 0), (-1, -1), 1, colors.black),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('RIGHTPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
]))
story.append(privacy_notes_table)
# Barra fuchsia con messaggio "Thank You for Your Business"
class ThankYouBar(Flowable):
def __init__(self, width):
super().__init__()
self.width = width
self.height = 30
def draw(self):
self.canv.saveState()
# Gradiente da nero (#000000) a fucsia (#f30ad0) molto uniforme
steps = int(self.width * 2) # Rettangoli larghi 0.5 punti
rect_width = self.width / steps
for i in range(steps):
ratio = i / (steps - 1)
r = int(0 + (243 - 0) * ratio)
g = int(0 + (10 - 0) * ratio)
b = int(0 + (208 - 0) * ratio)
color = colors.Color(r/255, g/255, b/255)
self.canv.setFillColor(color)
self.canv.rect(i * rect_width, 0, rect_width + 0.5, self.height, stroke=0, fill=1)
# Testo centrato
self.canv.setFont("Helvetica-Bold", 14)
self.canv.setFillColor(colors.white)
self.canv.drawCentredString(self.width/2, 8, "Thank You for Your Business")
self.canv.restoreState()
def wrap(self, availWidth, availHeight):
return self.width, self.height
story.append(Spacer(1, 10)) # Spazio prima della barra
thank_you_bar = ThankYouBar(doc.width)
story.append(thank_you_bar)
# Alla fine:
doc.build(story)
pdf_data = buffer.getvalue()
buffer.close()
filename = f'Preventivo_{self.name.replace("/", "_")}_{datetime.now().strftime("%Y%m%d_%H%M")}.pdf'
attachment = self.env['ir.attachment'].create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_data),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
'description': f'PDF con logo ap_logo_info.png e dati cliente'
})
return {'type': 'ir.actions.act_url', 'url': f'/web/content/{attachment.id}?download=true', 'target': 'new'}
class SaleOrderLetterheadAdminWizard(models.TransientModel):
_name = 'sale.order.letterhead.admin.wizard'
_description = 'Wizard Amministrazione: sceglie carta intestata e recupera dati'
sale_order_id = fields.Many2one('sale.order', string='Ordine di Vendita', required=True, ondelete='cascade')
letterhead_type = fields.Selection([
('polo_informatico', 'Polo Informatico'),
('metriks', 'Metriks'),
('fconn', 'Fconn')
], string='Carta Intestata', required=True, default='metriks')
responsabile_bu = fields.Many2many(
'res.users',
'sale_order_letterhead_admin_wizard_responsabile_bu_rel',
'wizard_id', 'user_id',
string='Responsabile BU',
required=True
)
segreteria_commerciale = fields.Many2many(
'res.users',
'sale_order_letterhead_admin_wizard_segreteria_commerciale_rel',
'wizard_id', 'user_id',
string='Segreteria Commerciale',
required=True
)
amministrazione = fields.Many2many(
'res.users',
'sale_order_letterhead_admin_wizard_amministrazione_rel',
'wizard_id', 'user_id',
string='Amministrazione',
required=True
)
responsabile_bu_email = fields.Char(string='Email Responsabile BU', compute='_compute_emails', store=False)
segreteria_commerciale_email = fields.Char(string='Email Segreteria Commerciale', compute='_compute_emails', store=False)
amministrazione_email = fields.Char(string='Email Amministrazione', compute='_compute_emails', store=False)
@api.depends('responsabile_bu', 'segreteria_commerciale', 'amministrazione')
def _compute_emails(self):
for record in self:
record.responsabile_bu_email = ', '.join([u.partner_id.email for u in record.responsabile_bu if u.partner_id.email])
record.segreteria_commerciale_email = ', '.join([u.partner_id.email for u in record.segreteria_commerciale if u.partner_id.email])
record.amministrazione_email = ', '.join([u.partner_id.email for u in record.amministrazione if u.partner_id.email])
def _validate_user_emails(self):
"""Verifica che tutti gli utenti selezionati abbiano un'email valida"""
for field, users in [
('Responsabile BU', self.responsabile_bu),
('Segreteria Commerciale', self.segreteria_commerciale),
('Amministrazione', self.amministrazione)
]:
for user in users:
if not user.partner_id.email:
raise UserError(f"L'utente selezionato come {field} ({user.name}) non ha un indirizzo email configurato.")
def action_generate_and_send_pdf(self):
self.ensure_one()
self._validate_user_emails()
pdf_action = self.sale_order_id.action_generate_custom_pdf(
letterhead_type=self.letterhead_type,
admin_fields=self._prepare_admin_fields()
)
attachment_id = self.env['ir.attachment'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.sale_order_id.id)
], order='create_date desc', limit=1)
if not attachment_id:
raise UserError("Errore nella generazione del PDF")
recipients = []
for user in self.responsabile_bu:
if user.partner_id.email:
recipients.append(user.partner_id.email)
for user in self.segreteria_commerciale:
if user.partner_id.email:
recipients.append(user.partner_id.email)
for user in self.amministrazione:
if user.partner_id.email:
recipients.append(user.partner_id.email)
recipients = list(set(recipients))
if not recipients:
raise UserError("Nessun destinatario valido trovato. Compilare i campi Responsabile BU, Segreteria Commerciale e Amministrazione.")
current_user = self.env.user
current_user_name = current_user.name or 'Utente Odoo'
current_user_email = current_user.partner_id.email or 'internal@metriks.ai'
date_str = self.sale_order_id.date_order.strftime('%d/%m/%Y') if self.sale_order_id.date_order else 'N/A'
total_formatted = f"{self.sale_order_id.amount_total:,.2f}".replace(',', 'TEMP').replace('.', ',').replace('TEMP', '.')
body_html = f"""
<div style=\"margin: 0px; padding: 0px; font-family: Arial, sans-serif;\">
<p style=\"margin: 0px; padding: 0px; font-size: 13px;\">
Buongiorno,<br/><br/>
In allegato l'ordine di vendita:<br/><br/>
<strong>Riferimento ordine:</strong> {self.sale_order_id.name}<br/>
<strong>Cliente:</strong> {self.sale_order_id.partner_id.name}<br/>
<strong>Data ordine:</strong> {date_str}<br/>
<strong>Totale:</strong> {total_formatted}<br/><br/>
<div style=\"background-color: #f8f9fa; padding: 15px; border-left: 4px solid #1f4e79; margin: 20px 0;\">
<p style=\"margin: 0; font-weight: bold; color: #1f4e79;\">📋 Accesso rapido all'ordine:</p>
<p style=\"margin: 5px 0 0 0;\">
<a href=\"{self.sale_order_id.get_base_url()}/web#id={self.sale_order_id.id}&model=sale.order&view_type=form\"
style=\"background-color: #1f4e79; color: white; padding: 8px 16px; text-decoration: none; border-radius: 4px; display: inline-block;\">
🔗 Visualizza Ordine in Odoo
</a>
</p>
</div>
<hr style=\"border: none; border-top: 1px solid #e9ecef; margin: 20px 0;\"/>
<p style=\"font-size: 12px; color: #6c757d;\">
<em>Questo messaggio è stato generato automaticamente dal sistema Odoo.</em>
</p>
Cordiali saluti,<br/>
<strong>Metriks AI S.r.l. Società Benefit</strong>
</p>
</div>
"""
mail_values = {
'subject': f'Riferimento ordine - {self.sale_order_id.name}',
'body_html': body_html,
'email_to': ','.join(recipients),
'email_from': f"{current_user_name} <internal@metriks.ai>",
'reply_to': current_user_email,
'attachment_ids': [(4, attachment_id.id)],
'auto_delete': True,
}
mail = self.env['mail.mail'].create(mail_values)
mail.send()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Successo',
'message': f'Il PDF è stato inviato con successo a {len(recipients)} destinatari',
'type': 'success',
'sticky': False,
}
}
def _prepare_admin_fields(self):
self.ensure_one()
if not self.sale_order_id:
raise UserError("Ordine di vendita non trovato.")
so = self.sale_order_id
tipo_opp_label = so.tipo_opportunita
if 'tipo_opportunita' in so._fields and so._fields['tipo_opportunita'].type == 'selection':
tipo_opp_label = dict(so._fields['tipo_opportunita'].selection).get(so.tipo_opportunita, so.tipo_opportunita)
# 2. Gestione per 'prodotto' (stessa identica logica)
prodotto_label = so.prodotto
if 'prodotto' in so._fields and so._fields['prodotto'].type == 'selection':
prodotto_label = dict(so._fields['prodotto'].selection).get(so.prodotto, so.prodotto)
admin_fields = {
'opportunity_id': so.opportunity_id.name or '',
'prodotto': prodotto_label,
'tipo_opportunita': tipo_opp_label,
# --- CORREZIONE: Aggiunto .name per ottenere la label ---
'azienda_segnalata': so.azienda_segnalata.name or '',
'azienda_segnalata_1': so.azienda_segnalata_1.name or '',
'user_id': so.user_id.name or '',
'advisor_1': so.advisor_1.name or '',
'advisor_2': so.advisor_2.name or '',
'specialist_commerciale': so.specialist_commerciale.name or '',
'azienda_fatturatrice': so.azienda_fatturatrice.name or '',
'iban': so.iban,
'sdi': so.sdi,
'email': getattr(so, 'email', ''),
'email_pec': getattr(so, 'email_pec', ''),
'signed_on': so.signed_on or '',
'canone_tre_anni': so.canone_tre_anni or 0.0,
'canone_anno_1': so.canone_anno_1 or 0.0,
'canone_anno_2': so.canone_anno_2 or 0.0,
'canone_anno_3': so.canone_anno_3 or 0.0,
'payment_term_id': so.payment_term_id.name or '',
'termini_pagamento_canone': so.termini_pagamento_canone.name or '',
'decorrenza_canone': so.decorrenza_canone or '',
}
return admin_fields

View File

@ -0,0 +1,3 @@
# ... existing code ...
# (Rimuovere tutte le righe che fanno riferimento a model_sale_order_letterhead_wizard)
# ... existing code ...
1 # ... existing code ...
2 # (Rimuovere tutte le righe che fanno riferimento a model_sale_order_letterhead_wizard)
3 # ... existing code ...

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_order_form_letterhead" model="ir.ui.view">
<field name="name">sale.order.form.letterhead</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_generate_custom_pdf"
string="Genera PDF"
type="object"
class="btn-primary"
icon="fa-file-pdf-o"
help="Genera un PDF personalizzato"/>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,2 @@
from . import models
from . import wizards

View File

@ -0,0 +1,16 @@
{
"name": "Importa righe e prodotti da file in preventivo",
"version": "1.0",
"author": "ChatGPT",
"category": "Sales",
"depends": ["sale", "product"],
"data": [
# "security/import_sale_order_wizard_model.xml",
"security/ir.model.access.csv",
"views/sale_order_view.xml",
"views/import_wizard_view.xml",
],
"installable": True,
"application": False
}

View File

@ -0,0 +1 @@
from . import sale_order

View File

@ -0,0 +1,24 @@
from odoo import models, fields
class SaleOrder(models.Model):
_inherit = "sale.order"
def action_open_import_wizard(self):
return {
"name": "Importa Articoli",
"type": "ir.actions.act_window",
"res_model": "import.sale.order.wizard",
"view_mode": "form",
"target": "new",
"context": {"default_order_id": self.id},
}
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
product_barcode = fields.Char(
string='Barcode',
related='product_id.barcode',
store=True,
readonly=True
)

View File

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_import_sale_order_wizard,access.import.sale.order.wizard,model_import_sale_order_wizard,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_import_sale_order_wizard access.import.sale.order.wizard model_import_sale_order_wizard 1 1 1 1

View File

@ -0,0 +1,12 @@
<odoo>
<record id="view_order_form_import" model="ir.ui.view">
<field name="name">sale.order.form.import</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<header position="inside">
<button name="action_open_import_wizard" type="object" string="Importa Articoli" class="btn-primary"/>
</header>
</field>
</record>
</odoo>

View File

@ -0,0 +1,21 @@
<odoo>
<record id="view_import_sale_order_wizard_form" model="ir.ui.view">
<field name="name">import.sale.order.wizard.form</field>
<field name="model">import.sale.order.wizard</field>
<field name="arch" type="xml">
<form string="Importa Articoli nel Preventivo">
<group>
<field name="order_id" readonly="1"/>
<field name="import_file"/>
</group>
<group>
<field name="log_message" modifiers="{'invisible': true}" />
</group>
<footer>
<button string="Importa" type="object" name="action_import_lines" class="btn-primary"/>
<button string="Annulla" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,35 @@
<odoo>
<record id="view_order_form_import" model="ir.ui.view">
<field name="name">sale.order.form.import</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<header position="inside">
<button name="action_open_import_wizard" type="object"
string="Importa Articoli" class="btn-primary"/>
</header>
</field>
</record>
<!-- Estensione della sottovista delle righe ordine per mostrare il barcode -->
<record id="view_order_form_inherit_barcode" model="ir.ui.view">
<field name="name">sale.order.form.inherit.barcode</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Inserisce il campo barcode dopo il campo product_id nella sottovista delle righe -->
<xpath expr="//field[@name='order_line']/form//field[@name='product_id']" position="after">
<field name="product_barcode" readonly="1"/>
</xpath>
</field>
</record>
<!-- Azione per aprire il wizard -->
<record id="action_open_import_sale_order_wizard" model="ir.actions.act_window">
<field name="name">Importa da Excel</field>
<field name="res_model">import.sale.order.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_order_id': active_id}</field>
</record>
</odoo>

View File

@ -0,0 +1 @@
from . import import_sale_order_wizard

View File

@ -0,0 +1,465 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
import base64
import io
from openpyxl import load_workbook
class ImportSaleOrderWizard(models.TransientModel):
_name = "import.sale.order.wizard"
_description = "Importa Articoli nel Preventivo"
import_file = fields.Binary("File Excel (.xlsx)", required=True)
order_id = fields.Many2one("sale.order", string="Preventivo", required=True)
log_message = fields.Text("Log Importazione", readonly=True)
def action_import_lines(self):
if not self.import_file:
raise UserError("Nessun file caricato.")
file_data = base64.b64decode(self.import_file)
try:
wb = load_workbook(filename=io.BytesIO(file_data), data_only=True)
except Exception as e:
raise UserError(f"Errore nell'aprire il file Excel: {str(e)}")
sheet = wb.active
headers = [str(cell.value).strip() if cell.value else "" for cell in next(sheet.iter_rows(max_row=1))]
header_map = {
"Codice EAN": "ean",
"Nome Prodotto": "name",
"Quantità": "qty",
"Prezzo Unitario": "price",
"Taglia": "size",
"Colore": "color"
}
for required_col in header_map:
if required_col not in headers:
raise UserError(f"Colonna obbligatoria mancante: '{required_col}'")
col_index = {v: headers.index(k) for k, v in header_map.items()}
Attribute = self.env["product.attribute"]
AttributeValue = self.env["product.attribute.value"]
ProductTemplate = self.env["product.template"]
ProductProduct = self.env["product.product"]
SaleOrderLine = self.env["sale.order.line"]
log_lines = []
attr_size = Attribute.search([("name", "=", "Taglia")], limit=1)
if not attr_size:
attr_size = Attribute.create({"name": "Taglia"})
attr_color = Attribute.search([("name", "=", "Colore")], limit=1)
if not attr_color:
attr_color = Attribute.create({"name": "Colore"})
for row_idx, row in enumerate(sheet.iter_rows(min_row=2), start=2):
try:
name = str(row[col_index["name"]].value or "").strip()
qty = float(row[col_index["qty"]].value or 0)
price = float(row[col_index["price"]].value or 0)
size = str(row[col_index["size"]].value or "").strip()
color = str(row[col_index["color"]].value or "").strip()
ean = str(row[col_index["ean"]].value or "").strip()
if not name or not size or not color:
log_lines.append(f"Riga {row_idx}: Dati mancanti (Codice Prodotto, Taglia o Colore)")
continue
val_size = AttributeValue.search([("name", "=", size), ("attribute_id", "=", attr_size.id)], limit=1)
if not val_size:
val_size = AttributeValue.create({"name": size, "attribute_id": attr_size.id})
val_color = AttributeValue.search([("name", "=", color), ("attribute_id", "=", attr_color.id)], limit=1)
if not val_color:
val_color = AttributeValue.create({"name": color, "attribute_id": attr_color.id})
tmpl = ProductTemplate.search([("name", "=", name)], limit=1)
if not tmpl:
tmpl = ProductTemplate.create({
"name": name,
"type": "consu",
})
tmpl.write({
"attribute_line_ids": [(0, 0, {
"attribute_id": attr_size.id,
"value_ids": [(6, 0, [val_size.id])],
}), (0, 0, {
"attribute_id": attr_color.id,
"value_ids": [(6, 0, [val_color.id])],
})]
})
else:
size_line = tmpl.attribute_line_ids.filtered(lambda l: l.attribute_id.id == attr_size.id)
if size_line and val_size.id not in size_line.value_ids.ids:
size_line.write({"value_ids": [(4, val_size.id)]})
elif not size_line:
tmpl.write({"attribute_line_ids": [(0, 0, {
"attribute_id": attr_size.id,
"value_ids": [(6, 0, [val_size.id])]
})]})
color_line = tmpl.attribute_line_ids.filtered(lambda l: l.attribute_id.id == attr_color.id)
if color_line and val_color.id not in color_line.value_ids.ids:
color_line.write({"value_ids": [(4, val_color.id)]})
elif not color_line:
tmpl.write({"attribute_line_ids": [(0, 0, {
"attribute_id": attr_color.id,
"value_ids": [(6, 0, [val_color.id])]
})]})
tmpl._create_variant_ids()
variants = ProductProduct.search([("product_tmpl_id", "=", tmpl.id)])
variant = None
for v in variants:
values = v.product_template_attribute_value_ids
if all([
any(val.name == size and val.attribute_id.id == attr_size.id for val in values),
any(val.name == color and val.attribute_id.id == attr_color.id for val in values),
]):
variant = v
break
if not variant:
log_lines.append(f"Riga {row_idx}: Variante non trovata per {name} - {size}/{color}")
continue
if ean and ProductProduct.search([("barcode", "=", ean), ("id", "!=", variant.id)], limit=1):
log_lines.append(f"Riga {row_idx}: Barcode {ean} già usato da altro prodotto.")
continue
if ean:
variant.barcode = ean
SaleOrderLine.create({
"order_id": self.order_id.id,
"product_id": variant.id,
"product_uom_qty": qty,
"price_unit": price,
"name": name,
})
except Exception as e:
log_lines.append(f"Riga {row_idx}: Errore durante importazione - {str(e)}")
self.log_message = "\n".join(log_lines) if log_lines else "Importazione completata senza errori."
return {
"type": "ir.actions.act_window",
"res_model": "import.sale.order.wizard",
"view_mode": "form",
"res_id": self.id,
"target": "new",
"context": self.env.context,
}
# from odoo import models, fields, api
# from odoo.exceptions import UserError
# import base64
# import io
# import logging
# from openpyxl import load_workbook
# # _logger = logging.getLogger(__name__)
# class ImportSaleOrderWizard(models.TransientModel):
# _name = "import.sale.order.wizard"
# _description = "Importa Articoli nel Preventivo"
# import_file = fields.Binary("File Excel (.xlsx)", required=True)
# order_id = fields.Many2one("sale.order", string="Preventivo", required=True)
# # log_message = fields.Text("Log Importazione", readonly=True)
# # def action_import_lines(self):
# # _logger.info("⚙️ Metodo action_import_lines chiamato correttamente")
# # raise UserError("✅ Il metodo è stato eseguito correttamente.")
# def action_import_lines(self):
# if not self.import_file:
# raise UserError("Nessun file caricato.")
# file_data = base64.b64decode(self.import_file)
# try:
# wb = load_workbook(filename=io.BytesIO(file_data), data_only=True)
# except Exception as e:
# raise UserError(f"Errore nell'aprire il file Excel: {str(e)}")
# sheet = wb.active
# # Leggi intestazioni reali
# headers = [str(cell.value).strip() if cell.value else "" for cell in next(sheet.iter_rows(max_row=1))]
# header_map = {
# "Codice EAN": "ean",
# "Codice Prodotto": "name",
# "Quantità": "qty",
# "Prezzo Unitario": "price",
# "Taglia": "size",
# "Colore": "color"
# }
# for required_col in header_map:
# if required_col not in headers:
# raise UserError(f"Colonna obbligatoria mancante: '{required_col}'")
# col_index = {v: headers.index(k) for k, v in header_map.items()}
# Attribute = self.env["product.attribute"]
# AttributeValue = self.env["product.attribute.value"]
# ProductTemplate = self.env["product.template"]
# ProductProduct = self.env["product.product"]
# SaleOrderLine = self.env["sale.order.line"]
# log_lines = []
# # Recupera o crea attributi
# attr_size = Attribute.search([("name", "=", "Taglia")], limit=1)
# if not attr_size:
# attr_size = Attribute.create({"name": "Taglia"})
# attr_color = Attribute.search([("name", "=", "Colore")], limit=1)
# if not attr_color:
# attr_color = Attribute.create({"name": "Colore"})
# for row_idx, row in enumerate(sheet.iter_rows(min_row=2), start=2):
# try:
# name = str(row[col_index["name"]].value or "").strip()
# qty = float(row[col_index["qty"]].value or 0)
# price = float(row[col_index["price"]].value or 0)
# size = str(row[col_index["size"]].value or "").strip()
# color = str(row[col_index["color"]].value or "").strip()
# ean = str(row[col_index["ean"]].value or "").strip()
# if not name or not size or not color:
# log_lines.append(f"Riga {row_idx}: Dati mancanti (Codice Prodotto, Taglia o Colore)")
# continue
# # Crea valori attributo se non esistono
# val_size = AttributeValue.search([("name", "=", size), ("attribute_id", "=", attr_size.id)], limit=1)
# if not val_size:
# val_size = AttributeValue.create({"name": size, "attribute_id": attr_size.id})
# val_color = AttributeValue.search([("name", "=", color), ("attribute_id", "=", attr_color.id)], limit=1)
# if not val_color:
# val_color = AttributeValue.create({"name": color, "attribute_id": attr_color.id})
# # Cerca o crea template
# tmpl = ProductTemplate.search([("name", "=", name)], limit=1)
# if not tmpl:
# tmpl = ProductTemplate.create({
# "name": name,
# "type": "product",
# "attribute_line_ids": [(0, 0, {
# "attribute_id": attr_size.id,
# "value_ids": [(6, 0, [val_size.id])],
# }), (0, 0, {
# "attribute_id": attr_color.id,
# "value_ids": [(6, 0, [val_color.id])],
# })]
# })
# else:
# # Aggiungi attributi mancanti
# size_line = tmpl.attribute_line_ids.filtered(lambda l: l.attribute_id.id == attr_size.id)
# if size_line and val_size.id not in size_line.value_ids.ids:
# size_line.write({"value_ids": [(4, val_size.id)]})
# color_line = tmpl.attribute_line_ids.filtered(lambda l: l.attribute_id.id == attr_color.id)
# if color_line and val_color.id not in color_line.value_ids.ids:
# color_line.write({"value_ids": [(4, val_color.id)]})
# tmpl._create_variant_ids()
# # Cerca variante
# variant = ProductProduct.search([
# ("product_tmpl_id", "=", tmpl.id),
# ("product_template_attribute_value_ids.attribute_id", "in", [attr_size.id, attr_color.id]),
# ("product_template_attribute_value_ids.name", "in", [size, color])
# ], limit=1)
# if not variant:
# log_lines.append(f"Riga {row_idx}: Variante non trovata per {name} - {size}/{color}")
# continue
# # Verifica se EAN è già usato da altri
# if ean and ProductProduct.search([("barcode", "=", ean), ("id", "!=", variant.id)], limit=1):
# log_lines.append(f"Riga {row_idx}: Barcode {ean} già usato da altro prodotto.")
# continue
# # Imposta barcode
# variant.barcode = ean
# # Crea riga ordine
# SaleOrderLine.create({
# "order_id": self.order_id.id,
# "product_id": variant.id,
# "product_uom_qty": qty,
# "price_unit": price,
# "name": name,
# })
# except Exception as e:
# log_lines.append(f"Riga {row_idx}: Errore durante importazione - {str(e)}")
# self.log_message = "\n".join(log_lines) if log_lines else "Importazione completata senza errori."
# return {
# "type": "ir.actions.act_window",
# "res_model": "import.sale.order.wizard",
# "view_mode": "form",
# "res_id": self.id,
# "target": "new",
# "context": self.env.context,
# }
##########################################################################################
# from odoo import models, fields, api
# from odoo.exceptions import UserError
# import base64
# import io
# from openpyxl import load_workbook
# class ImportSaleOrderWizard(models.TransientModel):
# _name = "import.sale.order.wizard"
# _description = "Importa Articoli nel Preventivo"
# import_file = fields.Binary("File Excel (.xlsx)", required=True)
# order_id = fields.Many2one("sale.order", string="Preventivo", required=True)
# log_message = fields.Text("Log Importazione", readonly=True)
# def action_import_lines(self):
# if not self.import_file:
# raise UserError("Nessun file caricato.")
# file_data = base64.b64decode(self.import_file)
# try:
# wb = load_workbook(filename=io.BytesIO(file_data), data_only=True)
# except Exception as e:
# raise UserError(f"Errore nell'aprire il file Excel: {str(e)}")
# sheet = wb.active
# # Leggi intestazioni in minuscolo e senza spazi iniziali/finali
# headers = [str(cell.value).strip().lower() if cell.value else "" for cell in next(sheet.iter_rows(max_row=1))]
# # required_headers = ["nome", "descrizione", "prezzo", "quantità", "taglia", "colore", "ean13"]
# required_headers = ["codice ean", "codice prodotto", "quantità", "prezzo unitario", "taglia", "colore"]
# for rh in required_headers:
# if rh not in headers:
# raise UserError(f"Colonna obbligatoria mancante: '{rh}'")
# col_index = {h: headers.index(h) for h in required_headers}
# Attribute = self.env["product.attribute"]
# AttributeValue = self.env["product.attribute.value"]
# ProductTemplate = self.env["product.template"]
# ProductProduct = self.env["product.product"]
# SaleOrderLine = self.env["sale.order.line"]
# log_lines = []
# attr_size = Attribute.search([("name", "=", "Taglia")], limit=1) or Attribute.create({"name": "Taglia"})
# attr_color = Attribute.search([("name", "=", "Colore")], limit=1) or Attribute.create({"name": "Colore"})
# for row_idx, row in enumerate(sheet.iter_rows(min_row=2), start=2):
# try:
# values = {}
# for h in required_headers:
# cell = row[col_index[h]]
# values[h.lower()] = str(cell.value).strip() if cell.value is not None else ""
# name = values["nome"]
# descrizione = values["descrizione"]
# try:
# price = float(values["prezzo"])
# qty = float(values["quantità"])
# except ValueError:
# raise UserError(f"Prezzo o quantità non numerici alla riga {row_idx}")
# size = values["taglia"]
# color = values["colore"]
# ean = values["ean13"]
# # Cerca o crea valori attributi taglia e colore
# val_size = AttributeValue.search([("name", "=", size), ("attribute_id", "=", attr_size.id)], limit=1)
# if not val_size:
# val_size = AttributeValue.create({"name": size, "attribute_id": attr_size.id})
# val_color = AttributeValue.search([("name", "=", color), ("attribute_id", "=", attr_color.id)], limit=1)
# if not val_color:
# val_color = AttributeValue.create({"name": color, "attribute_id": attr_color.id})
# # Cerca o crea template prodotto
# tmpl = ProductTemplate.search([("name", "=", name)], limit=1)
# if not tmpl:
# tmpl = ProductTemplate.create({
# "name": name,
# "type": "product",
# "description_sale": descrizione,
# "attribute_line_ids": [(0, 0, {
# "attribute_id": attr_size.id,
# "value_ids": [(6, 0, [val_size.id])]
# }), (0, 0, {
# "attribute_id": attr_color.id,
# "value_ids": [(6, 0, [val_color.id])]
# })]
# })
# else:
# # Aggiorna attribute_line_ids per includere i valori se non presenti
# size_line = tmpl.attribute_line_ids.filtered(lambda l: l.attribute_id == attr_size)
# if size_line and val_size.id not in size_line.value_ids.ids:
# size_line.write({"value_ids": [(4, val_size.id)]})
# color_line = tmpl.attribute_line_ids.filtered(lambda l: l.attribute_id == attr_color)
# if color_line and val_color.id not in color_line.value_ids.ids:
# color_line.write({"value_ids": [(4, val_color.id)]})
# # Forza la creazione varianti
# tmpl._create_variant_ids()
# # Cerca variante per taglia e colore
# variant = ProductProduct.search([
# ("product_tmpl_id", "=", tmpl.id),
# ("product_template_attribute_value_ids.attribute_id", "in", [attr_size.id, attr_color.id]),
# ("product_template_attribute_value_ids.name", "in", [size, color])
# ], limit=1)
# if not variant:
# log_lines.append(f"Riga {row_idx}: Variante non trovata per {name} - {size}/{color}")
# continue
# # Se barcode esiste già per un altro prodotto diverso, skip
# if ProductProduct.search([("barcode", "=", ean), ("id", "!=", variant.id)], limit=1):
# log_lines.append(f"Riga {row_idx}: Barcode {ean} già associato ad un altro prodotto")
# continue
# # Imposta barcode
# variant.barcode = ean
# # Crea riga ordine vendita
# SaleOrderLine.create({
# "order_id": self.order_id.id,
# "product_id": variant.id,
# "product_uom_qty": qty,
# "price_unit": price,
# "name": descrizione or name,
# })
# except Exception as e:
# log_lines.append(f"Riga {row_idx}: Errore durante l'importazione - {str(e)}")
# if log_lines:
# self.log_message = "\n".join(log_lines)
# else:
# self.log_message = "Importazione completata con successo senza errori."
# return {
# "type": "ir.actions.act_window",
# "res_model": "import.sale.order.wizard",
# "view_mode": "form",
# "res_id": self.id,
# "target": "new",
# "context": self.env.context,
# }