¿Por qué este capítulo? Una sola llamada a
notification.add()es fácil; coordinar tres servicios en un mismo flujo, evitar que una caja de búsqueda golpee al servidor en cada tecla, y recuperarse de una llamada de red inestable son las cosas que realmente consumen un día de trabajo una vez que un addon crece más allá de su primera versión. ¿Qué trata de resolver Odoo con esto? La interfaz de Odoo tiene que seguir respondiendo y siendo resiliente bajo uso real — conexiones inestables, endpoints lentos, usuarios que hacen doble clic — así que el framework se apoya en patrones como debounce, caché y reintentos estructurados en vez de dejar que cada desarrollador los reinvente. Aplicación en la vida real: Búsqueda con debounce, reintento con backoff en una llamada inestable a una API externa, y flujos de trabajo de varios pasos que abarcan varios servicios son patrones a los que recurro constantemente una vez que un addon de cliente pasa de su primera prueba de concepto.
En la Parte 1 cubrimos los servicios que usarás todos los días — notification, action, dialog, y algunos esenciales — junto con los errores comunes que cometen los desarrolladores junior con ellos. Ahora abordaremos escenarios más exigentes: componer varios servicios en un solo flujo de trabajo, mantener rápidas las llamadas costosas a servicios, y recuperarnos con gracia cuando una llamada falla.
Patrones Avanzados de Servicios
Patrón 3: Composición de Servicios para Flujos de Trabajo Complejos
async procesarOrdenCliente(datosOrden) {
const pasos = [
{ nombre: "Validando datos de orden", accion: () => this.validarDatosOrden(datosOrden) },
{ nombre: "Creando cliente", accion: () => this.crearCliente(datosOrden.cliente) },
{ nombre: "Creando orden", accion: () => this.crearOrden(datosOrden) },
{ nombre: "Enviando confirmación", accion: () => this.enviarEmailConfirmacion(datosOrden) }
];
let pasosCompletados = 0;
try {
// Notificación inicial de progreso
this.notification.add(`Iniciando procesamiento de orden (${pasos.length} pasos)...`, {
type: "info"
});
for (const paso of pasos) {
console.log(`Procesando paso: ${paso.nombre}`);
// Ejecutar paso
await paso.accion();
pasosCompletados++;
// Notificación de progreso
this.notification.add(
`${paso.nombre} completado (${pasosCompletados}/${pasos.length})`,
{ type: "info" }
);
}
// Notificación de éxito y navegación
this.notification.add("¡Orden procesada exitosamente!", {
type: "success",
sticky: false
});
// Navegar a la orden
this.action.doAction({
type: "ir.actions.act_window",
res_model: "sale.order",
res_id: datosOrden.idOrden,
views: [[false, "form"]],
target: "current"
});
} catch (error) {
console.error(`Procesamiento de orden falló en paso ${pasosCompletados + 1}:`, error);
this.notification.add(
`Procesamiento de orden falló en paso: ${pasos[pasosCompletados]?.nombre || 'Desconocido'}. ${error.message}`,
{ type: "danger", sticky: true }
);
// Opcionalmente mostrar diálogo de rollback
this.dialog.add(ConfirmationDialog, {
title: "Procesamiento Falló",
body: "¿Te gustaría deshacer los cambios hechos hasta ahora?",
confirm: () => this.deshacerCambios(pasosCompletados),
cancel: () => console.log("Usuario eligió no deshacer")
});
}
}
Patrón 4: Gestión de Estado Basada en Servicios
Este patrón combina varios servicios dentro de un solo componente: useState para datos reactivos locales, y orm/action/notification/dialog/user coordinados en conjunto. Construiremos DashboardClientes en tres partes.
Primero, el setup() inicial: estado, referencias a servicios, y un pequeño objeto cargadorDatos que agrupa las consultas de solo lectura que necesita el dashboard:
export class DashboardClientes extends Component {
static template = "mi_modulo.DashboardClientes";
setup() {
this.state = useState({
clientes: [],
clienteSeleccionado: null,
cargando: false,
filtros: {
activo: true,
pais: null,
categoria: null
}
});
// Inicializar servicios
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
this.dialog = useService("dialog");
this.user = useService("user");
// Cargador de datos basado en servicios
this.cargadorDatos = {
clientes: () => this.orm.searchRead(
"res.partner",
this.construirDominioClientes(),
["name", "email", "phone", "country_id", "category_id"],
{ order: "name asc" }
),
paises: () => this.orm.searchRead(
"res.country",
[],
["name", "code"],
{ order: "name asc" }
),
categorias: () => this.orm.searchRead(
"res.partner.category",
[],
["name", "color"],
{ order: "name asc" }
)
};
// Inicializar dashboard
useEffect(
() => {
this.inicializarDashboard();
},
() => []
);
// Actualizaciones reactivas de filtros
useEffect(
() => {
this.refrescarClientes();
},
() => [this.state.filtros.activo, this.state.filtros.pais, this.state.filtros.categoria]
);
}
Después, el método que se ejecuta una vez al montar, cargando los tres conjuntos de datos en paralelo e informando éxito o fallo:
async inicializarDashboard() {
try {
this.state.cargando = true;
// Cargar todos los datos requeridos en paralelo
const [clientes, paises, categorias] = await Promise.all([
this.cargadorDatos.clientes(),
this.cargadorDatos.paises(),
this.cargadorDatos.categorias()
]);
this.state.clientes = clientes;
this.state.paises = paises;
this.state.categorias = categorias;
this.notification.add("¡Dashboard cargado exitosamente!", {
type: "success"
});
} catch (error) {
console.error("Inicialización de dashboard falló:", error);
this.notification.add("Error al cargar datos del dashboard.", {
type: "danger",
sticky: true
});
} finally {
this.state.cargando = false;
}
}
Finalmente, los métodos de acción masiva: una tabla de posibles acciones (archivar, eliminar, exportar), un paso de confirmación para las peligrosas, y el método que realmente ejecuta la acción elegida:
// Acciones orquestadas por servicios
async realizarAccionMasiva(accion, idsClientes) {
const mapaAcciones = {
archivar: {
titulo: "Archivar Clientes",
metodo: () => this.orm.write("res.partner", idsClientes, { active: false }),
mensajeExito: `${idsClientes.length} clientes archivados exitosamente`
},
eliminar: {
titulo: "Eliminar Clientes",
metodo: () => this.orm.unlink("res.partner", idsClientes),
mensajeExito: `${idsClientes.length} clientes eliminados exitosamente`,
peligrosa: true
},
exportar: {
titulo: "Exportar Clientes",
metodo: () => this.action.doAction({
type: "ir.actions.report",
report_name: "base.report_partner_list",
context: { active_ids: idsClientes }
}),
mensajeExito: "Exportación de clientes iniciada"
}
};
const configAccion = mapaAcciones[accion];
if (!configAccion) return;
// Mostrar confirmación para acciones peligrosas
if (configAccion.peligrosa) {
this.dialog.add(ConfirmationDialog, {
title: `Confirmar ${configAccion.titulo}`,
body: `Esto ${accion} permanentemente ${idsClientes.length} clientes. Esta acción no se puede deshacer.`,
confirm: () => this.ejecutarAccionMasiva(configAccion)
});
} else {
await this.ejecutarAccionMasiva(configAccion);
}
}
async ejecutarAccionMasiva(configAccion) {
try {
this.notification.add(`${configAccion.titulo} en progreso...`, {
type: "info"
});
await configAccion.metodo();
this.notification.add(configAccion.mensajeExito, {
type: "success"
});
// Refrescar datos después de acción masiva
await this.refrescarClientes();
} catch (error) {
console.error(`${configAccion.titulo} falló:`, error);
this.notification.add(`${configAccion.titulo} falló: ${error.message}`, {
type: "danger",
sticky: true
});
}
}
}
Optimización de Rendimiento de Servicios
Llamadas de Servicio con Debounce
Debounce significa retrasar una llamada a una función hasta que una ráfaga de eventos que la disparan se detenga durante un período determinado — por ejemplo, esperar a que el usuario deje de escribir antes de disparar una búsqueda, en lugar de enviar una solicitud por cada tecla presionada. Es una técnica común para evitar que llamadas costosas a servicios (como una búsqueda ORM) saturen el servidor.
setup() {
this.orm = useService("orm");
// Debounce para búsquedas costosas
this.busquedaConDebounce = this.debounce(async (terminoBusqueda) => {
if (!terminoBusqueda.trim()) return;
try {
const resultados = await this.orm.searchRead(
"res.partner",
[["name", "ilike", terminoBusqueda]],
["name", "email"],
{ limit: 10 }
);
this.state.resultadosBusqueda = resultados;
} catch (error) {
console.error("Búsqueda falló:", error);
}
}, 300); // Esperar 300ms después de que el usuario pare de escribir
}
debounce(func, espera) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), espera);
};
}
alCambiarBusqueda(event) {
const terminoBusqueda = event.target.value;
this.state.terminoBusqueda = terminoBusqueda;
this.busquedaConDebounce(terminoBusqueda);
}
Odoo ya incluye un helper de debounce listo para usar, así que en addons reales rara vez necesitas escribir el tuyo: import { debounce } from "@web/core/utils/timing";.
Caché de Respuestas de Servicio
setup() {
this.orm = useService("orm");
this.cache = new Map();
this.expiracionCache = new Map();
this.obtenerDatosEnCache = async (claveCache, obtenedor, ttl = 5 * 60 * 1000) => {
// Verificar si tenemos datos válidos en caché
if (this.cache.has(claveCache)) {
const expiracion = this.expiracionCache.get(claveCache);
if (Date.now() < expiracion) {
console.log(`Usando datos en caché para: ${claveCache}`);
return this.cache.get(claveCache);
}
}
// Obtener datos frescos
console.log(`Obteniendo datos frescos para: ${claveCache}`);
const datos = await obtenedor();
// Cachear el resultado
this.cache.set(claveCache, datos);
this.expiracionCache.set(claveCache, Date.now() + ttl);
return datos;
};
}
async cargarPaises() {
return this.obtenerDatosEnCache(
'paises',
() => this.orm.searchRead("res.country", [], ["name", "code"]),
10 * 60 * 1000 // Cachear por 10 minutos
);
}
Estrategias de Recuperación de Errores de Servicio
Reintento Automático con Backoff Exponencial
Backoff exponencial significa esperar cada vez más tiempo entre cada intento de reintento (1s, luego 2s, luego 4s, y así sucesivamente) en lugar de reintentar de inmediato — esto le da espacio a un servidor con problemas para recuperarse, en vez de bombardearlo con reintentos instantáneos.
async realizarOperacionResilientte(operacion, maxReintentos = 3) {
let ultimoError;
for (let intento = 1; intento <= maxReintentos; intento++) {
try {
const resultado = await operacion();
if (intento > 1) {
this.notification.add("Operación exitosa después de reintento.", {
type: "success"
});
}
return resultado;
} catch (error) {
ultimoError = error;
console.warn(`Intento ${intento} falló:`, error);
if (intento < maxReintentos) {
const retraso = Math.pow(2, intento - 1) * 1000; // Backoff exponencial
this.notification.add(`Operación falló, reintentando en ${retraso/1000}s... (${intento}/${maxReintentos})`, {
type: "warning"
});
await new Promise(resolve => setTimeout(resolve, retraso));
}
}
}
// Todos los reintentos fallaron
this.notification.add(`Operación falló después de ${maxReintentos} intentos: ${ultimoError.message}`, {
type: "danger",
sticky: true
});
throw ultimoError;
}
// Uso
async guardarDatos() {
await this.realizarOperacionResilientte(async () => {
return await this.orm.create("nombre.modelo", [this.state.datosFormulario]);
});
}
Ejemplo de Integración del Mundo Real
Aquí hay un ejemplo completo mostrando cómo múltiples servicios trabajan juntos en un escenario de producción. Lo veremos en dos partes: el setup y la carga de datos, y luego la acción que procesa las órdenes seleccionadas.
export class CentroProcesamiento extends Component {
static template = "mi_modulo.CentroProcesamiento";
setup() {
this.state = useState({
ordenes: [],
procesando: false,
ordenesSeleccionadas: new Set(),
estadisticas: {
total: 0,
pendientes: 0,
procesando: 0,
completadas: 0
}
});
// Inicialización de servicios
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
this.dialog = useService("dialog");
this.user = useService("user");
// Cargar datos al montar
useEffect(
() => {
this.cargarOrdenes();
this.cargarEstadisticas();
},
() => []
);
}
async cargarOrdenes() {
try {
const ordenes = await this.orm.searchRead(
"sale.order",
[["state", "in", ["draft", "sent", "sale"]]],
["name", "partner_id", "amount_total", "state", "date_order"],
{ order: "date_order desc", limit: 100 }
);
this.state.ordenes = ordenes;
} catch (error) {
this.notification.add("Error al cargar órdenes.", { type: "danger" });
}
}
async cargarEstadisticas() {
try {
const stats = await this.orm.call("sale.order", "get_order_statistics");
this.state.estadisticas = stats;
} catch (error) {
console.error("Error al cargar estadísticas:", error);
}
}
La parte interesante es procesarOrdenesSeleccionadas: valida la selección, confirma con el usuario, y luego coordina una llamada ORM por lotes, una notificación de éxito, un refresco de datos, y finalmente abre un reporte — los cinco servicios trabajando juntos alrededor de una sola acción del usuario:
async procesarOrdenesSeleccionadas() {
if (this.state.ordenesSeleccionadas.size === 0) {
this.notification.add("Por favor selecciona órdenes para procesar.", {
type: "warning"
});
return;
}
const idsOrdenes = Array.from(this.state.ordenesSeleccionadas);
// Diálogo de confirmación
this.dialog.add(ConfirmationDialog, {
title: "Procesar Órdenes",
body: `¿Procesar ${idsOrdenes.length} órdenes seleccionadas?`,
confirm: async () => {
try {
this.state.procesando = true;
// Notificación de progreso
this.notification.add(`Procesando ${idsOrdenes.length} órdenes...`, {
type: "info"
});
// Procesar órdenes por lotes
await this.orm.call("sale.order", "action_confirm", idsOrdenes);
// Retroalimentación de éxito
this.notification.add(`¡${idsOrdenes.length} órdenes procesadas exitosamente!`, {
type: "success"
});
// Limpiar selección y refrescar
this.state.ordenesSeleccionadas.clear();
await this.cargarOrdenes();
await this.cargarEstadisticas();
// Abrir reporte de procesamiento
this.action.doAction({
type: "ir.actions.report",
report_name: "sale.action_report_saleorder",
context: { active_ids: idsOrdenes }
});
} catch (error) {
console.error("Procesamiento de órdenes falló:", error);
this.notification.add(`Procesamiento falló: ${error.message}`, {
type: "danger",
sticky: true
});
} finally {
this.state.procesando = false;
}
}
});
}
}
Resumen
Los servicios integrados de Odoo proporcionan un kit de herramientas integral para crear aplicaciones profesionales e integradas:
notification: Retroalimentación del usuario y actualizaciones de estadoaction: Navegación e integración con Odoodialog: Interacciones del usuario y confirmacionesuser,company,router: Información del sistema y navegación
Al dominar estos servicios, tus componentes: - Se sentirán nativos a la experiencia de Odoo - Proporcionarán interacciones consistentes del usuario - Manejarán errores graciosamente - Se integrarán perfectamente con los flujos de trabajo de Odoo
La clave del éxito es entender cuándo y cómo usar cada servicio, combinarlos efectivamente para flujos de trabajo complejos, y siempre priorizar la experiencia del usuario con retroalimentación clara e interacciones intuitivas.
En el próximo capítulo, exploraremos patrones avanzados de componentes y consideraciones arquitectónicas para construir aplicaciones OWL a gran escala.
TL;DR: Compón servicios de forma deliberada (una notificación por cada paso de un flujo de varios pasos), aplica debounce a todo lo que se dispara en cada tecla con @web/core/utils/timing, y envuelve las llamadas poco confiables en una estrategia de reintento con backoff en vez de fallar en silencio.
Pruébalo tú mismo: El ejemplo de este capítulo está disponible como addon instalable de Odoo 19: simplifyit_owl_book_ch13_2_ex1. Las instrucciones de instalación están en el README del repositorio.
Ejercicios
- Flujo compuesto: Escribe un método que cree un partner, y luego cree inmediatamente un contacto relacionado para él, mostrando una notificación de progreso por cada paso (similar al Patrón 3). Asegúrate de que una falla en cualquiera de los pasos muestre cuál falló.
- Debounce por tu cuenta: Implementa un debounce de 300ms sobre un input de búsqueda en vivo respaldado por
orm.searchRead, sin usar el helperdebounceincorporado de Odoo — luego reemplázalo conimport { debounce } from "@web/core/utils/timing"y confirma que el comportamiento es el mismo. - Reintento con backoff: Toma
realizarOperacionResilienttey adáptalo para reintentar una llamadaorm.callhasta 3 veces, luego escribe una prueba rápida (real o en papel) describiendo qué notificación debería ver el usuario después de cada intento fallido.
¿Qué Sigue?
Ya cubriste toda la capa de servicios, desde la primera notificación hasta flujos de trabajo resilientes de varios pasos. El Capítulo 14 pasa a la composición — slots, scoped slots y t-portal — las herramientas para construir componentes reutilizables y flexibles en vez de componentes de un solo uso.