¿Por qué este capítulo? Todo addon de Odoo no trivial eventualmente necesita leer o escribir registros reales — un componente que solo gestiona estado local es un juguete. Este capítulo es donde tus componentes empiezan a hablar con la base de datos real. ¿Qué trata de resolver Odoo con esto? Odoo necesita una forma consistente y segura de que el código frontend llegue al ORM y a controladores personalizados sin que cada desarrollador arme a mano sus propias llamadas AJAX, manejo de errores y validaciones de seguridad. Aplicación en la vida real: Cada widget de dashboard, acción de kanban personalizada y formulario de portal que he construido para un cliente eventualmente se reduce a llamadas
orm.searchRead/create/writeexactamente como las de este capítulo.
En los capítulos anteriores, hemos construido componentes que pueden gestionar estado, responder a eventos y manejar escenarios complejos de ciclo de vida. Pero una aplicación web moderna no está completa sin la capacidad de comunicarse con el servidor. Tus hermosos componentes OWL necesitan obtener datos reales, guardar cambios de usuario e integrarse perfectamente con el backend de Odoo.
Aquí es donde entra el sistema de servicios. La arquitectura de servicios de Odoo proporciona una forma limpia y consistente para que tus componentes frontend se comuniquen con el backend de Python, muestren notificaciones, activen acciones y mucho más.
La puerta de entrada a este poderoso sistema es el hook useService—el puente de tus componentes hacia todo el ecosistema de Odoo.
Entendiendo la Arquitectura de Servicios de Odoo
Los servicios en Odoo son objetos singleton que proporcionan funcionalidad específica a través de toda tu aplicación. Están diseñados para ser:
- Centralizados: Una instancia por tipo de servicio
- Consistentes: La misma API en todas partes de la app
- Integrados: Conectados perfectamente al backend de Odoo
- Comprobables: Fáciles de simular y probar
Piensa en los servicios como las "utilidades" de tu aplicación—herramientas especializadas que manejan responsabilidades específicas para que tus componentes puedan enfocarse en su trabajo principal: renderizar UI y manejar interacciones de usuario.
El Hook useService
El hook useService es notablemente simple:
import { useService } from "@web/core/utils/hooks";
// Dentro del setup() de tu componente
const serviceName = useService("service_name");
¡Eso es todo! Obtienes una instancia de servicio completamente configurada lista para usar. Exploremos los servicios más importantes que usarás diariamente.
El Servicio orm: Tu Puerta de Entrada a la Base de Datos
El servicio orm es tu herramienta principal para operaciones de base de datos. Proporciona una API limpia basada en Promises que refleja los métodos ORM de Python de Odoo, haciendo que la comunicación con el servidor se sienta natural e intuitiva.
Operaciones CRUD Básicas
Construyamos un componente completo de gestión de productos que demuestre todas las operaciones básicas del ORM:
JavaScript (product_manager.js):
import { Component, useEffect, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class ProductManager extends Component {
static template = "my_module.ProductManager";
setup() {
this.state = useState({
products: [],
categories: [],
loading: true,
error: null,
selectedProduct: null,
formData: {
name: "",
list_price: 0,
categ_id: null,
description: ""
}
});
// Obtener el servicio ORM
this.orm = useService("orm");
// Cargar datos iniciales cuando el componente se monte
useEffect(
() => {
this.loadInitialData();
},
() => []
);
}
async loadInitialData() {
try {
console.log("ProductManager: Cargando datos iniciales");
this.state.loading = true;
this.state.error = null;
// Cargar productos y categorías en paralelo para mejor rendimiento
const [products, categories] = await Promise.all([
this.loadProducts(),
this.loadCategories()
]);
this.state.products = products;
this.state.categories = categories;
console.log(`ProductManager: Cargados ${products.length} productos y ${categories.length} categorías`);
} catch (error) {
console.error("ProductManager: Falló la carga de datos iniciales:", error);
this.state.error = "Falló la carga de datos. Por favor, actualiza la página.";
} finally {
this.state.loading = false;
}
}
async loadProducts() {
// searchRead: Buscar registros y leer sus campos en una sola llamada
return await this.orm.searchRead(
"product.product", // Nombre del modelo
[["sale_ok", "=", true]], // Dominio (filtros de búsqueda)
["id", "name", "list_price", "categ_id", "qty_available"], // Campos a leer
{
order: "name asc", // Orden de clasificación
limit: 100 // Máximo de registros
}
);
}
async loadCategories() {
return await this.orm.searchRead(
"product.category",
[], // Dominio vacío = todos los registros
["id", "name"],
{ order: "name asc" }
);
}
async createProduct() {
if (!this.state.formData.name.trim()) {
this.state.error = "El nombre del producto es requerido";
return;
}
try {
console.log("ProductManager: Creando nuevo producto", this.state.formData);
// create: Crear nuevos registros
const newProductIds = await this.orm.create(
"product.product",
[{
name: this.state.formData.name,
list_price: this.state.formData.list_price,
categ_id: this.state.formData.categ_id,
sale_ok: true,
purchase_ok: true
}]
);
console.log(`ProductManager: Producto creado con ID ${newProductIds[0]}`);
// Recargar productos para mostrar el nuevo
await this.refreshProducts();
// Reiniciar formulario
this.resetForm();
this.state.error = null;
} catch (error) {
console.error("ProductManager: Falló la creación del producto:", error);
this.state.error = `Falló la creación del producto: ${error.message}`;
}
}
async updateProduct(productId, updates) {
try {
console.log(`ProductManager: Actualizando producto ${productId}`, updates);
// write: Actualizar registros existentes
await this.orm.write(
"product.product",
[productId], // Lista de IDs de registros a actualizar
updates // Diccionario de actualizaciones de campos
);
console.log(`ProductManager: Producto ${productId} actualizado exitosamente`);
// Actualizar la lista de productos
await this.refreshProducts();
} catch (error) {
console.error(`ProductManager: Falló la actualización del producto ${productId}:`, error);
this.state.error = `Falló la actualización del producto: ${error.message}`;
}
}
async deleteProduct(productId) {
try {
console.log(`ProductManager: Eliminando producto ${productId}`);
// unlink: Eliminar registros
await this.orm.unlink("product.product", [productId]);
console.log(`ProductManager: Producto ${productId} eliminado exitosamente`);
// Remover del estado local inmediatamente para mejor UX
this.state.products = this.state.products.filter(p => p.id !== productId);
// Limpiar selección si el producto eliminado estaba seleccionado
if (this.state.selectedProduct?.id === productId) {
this.state.selectedProduct = null;
}
} catch (error) {
console.error(`ProductManager: Falló la eliminación del producto ${productId}:`, error);
this.state.error = `Falló la eliminación del producto: ${error.message}`;
}
}
async refreshProducts() {
try {
const products = await this.loadProducts();
this.state.products = products;
console.log("ProductManager: Productos actualizados exitosamente");
} catch (error) {
console.error("ProductManager: Falló la actualización de productos:", error);
}
}
// Métodos auxiliares para manejo de formularios
selectProduct(product) {
this.state.selectedProduct = product;
this.state.formData = {
name: product.name,
list_price: product.list_price,
categ_id: product.categ_id[0],
description: ""
};
}
resetForm() {
this.state.formData = {
name: "",
list_price: 0,
categ_id: null,
description: ""
};
this.state.selectedProduct = null;
}
onFormChange(field, value) {
this.state.formData[field] = value;
}
// Acciones rápidas para gestión de productos
async quickUpdatePrice(productId, newPrice) {
await this.updateProduct(productId, { list_price: newPrice });
}
async toggleProductAvailability(productId, currentStatus) {
await this.updateProduct(productId, { sale_ok: !currentStatus });
}
// Propiedades computadas para el template
get formattedProducts() {
return this.state.products.map(product => ({
...product,
formattedPrice: `$${product.list_price.toFixed(2)}`,
categoryName: product.categ_id ? product.categ_id[1] : "Sin Categoría",
stockStatus: product.qty_available > 0 ? "En Stock" : "Agotado"
}));
}
get isFormValid() {
return this.state.formData.name.trim().length > 0;
}
}
Template (product_manager.xml):
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="my_module.ProductManager" owl="1">
<div class="product-manager">
<!-- Encabezado -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Gestor de Productos</h2>
<button
class="btn btn-outline-secondary btn-sm"
t-on-click="refreshProducts"
t-att-disabled="state.loading">
<i class="fa fa-refresh"></i> Actualizar
</button>
</div>
<!-- Mostrar Errores -->
<t t-if="state.error">
<div class="alert alert-danger alert-dismissible">
<t t-esc="state.error"/>
<button
type="button"
class="btn-close"
t-on-click="() => this.state.error = null">
</button>
</div>
</t>
<!-- Estado de Carga -->
<t t-if="state.loading">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando productos...</span>
</div>
<p class="mt-2 text-muted">Cargando productos y categorías...</p>
</div>
</t>
<!-- Contenido Principal -->
<t t-else="">
<div class="row">
<!-- Formulario de Producto -->
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<t t-if="state.selectedProduct">Editar Producto</t>
<t t-else="">Nuevo Producto</t>
</h5>
</div>
<div class="card-body">
<form t-on-submit.prevent="createProduct">
<!-- Nombre del Producto -->
<div class="mb-3">
<label class="form-label">Nombre del Producto *</label>
<input
type="text"
class="form-control"
t-model="state.formData.name"
placeholder="Ingresa el nombre del producto"
required
/>
</div>
<!-- Precio -->
<div class="mb-3">
<label class="form-label">Precio</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input
type="number"
class="form-control"
t-model="state.formData.list_price"
min="0"
step="0.01"
/>
</div>
</div>
<!-- Categoría -->
<div class="mb-3">
<label class="form-label">Categoría</label>
<select
class="form-select"
t-model="state.formData.categ_id">
<option value="">Seleccionar Categoría</option>
<t t-foreach="state.categories" t-as="category" t-key="category.id">
<option t-att-value="category.id" t-esc="category.name"/>
</t>
</select>
</div>
<!-- Botones de Acción -->
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary flex-fill"
t-att-disabled="!isFormValid">
<t t-if="state.selectedProduct">Actualizar Producto</t>
<t t-else="">Crear Producto</t>
</button>
<button
type="button"
class="btn btn-outline-secondary"
t-on-click="resetForm">
Limpiar
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Lista de Productos -->
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
Productos (<t t-esc="state.products.length"/>)
</h5>
</div>
<div class="card-body p-0">
<t t-if="state.products.length === 0">
<div class="text-center py-4 text-muted">
<i class="fa fa-box-open fa-2x mb-2"></i>
<p>No se encontraron productos. ¡Crea tu primer producto!</p>
</div>
</t>
<t t-else="">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Nombre</th>
<th>Categoría</th>
<th>Precio</th>
<th>Stock</th>
<th class="text-end">Acciones</th>
</tr>
</thead>
<tbody>
<t t-foreach="formattedProducts" t-as="product" t-key="product.id">
<tr t-att-class="state.selectedProduct?.id === product.id ? 'table-active' : ''">
<td>
<strong t-esc="product.name"/>
</td>
<td>
<small class="text-muted" t-esc="product.categoryName"/>
</td>
<td>
<span t-esc="product.formattedPrice"/>
</td>
<td>
<span
class="badge"
t-att-class="product.qty_available > 0 ? 'bg-success' : 'bg-warning'">
<t t-esc="product.stockStatus"/>
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button
class="btn btn-outline-primary"
t-on-click="() => this.selectProduct(product)"
title="Editar">
<i class="fa fa-edit"></i>
</button>
<button
class="btn btn-outline-danger"
t-on-click="() => this.deleteProduct(product.id)"
title="Eliminar">
<i class="fa fa-trash"></i>
</button>
</div>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</div>
</t>
</templates>
Este ejemplo demuestra todas las operaciones clave del ORM:
searchRead: Buscar y leer registros en una operacióncreate: Crear nuevos registroswrite: Actualizar registros existentesunlink: Eliminar registros
Operaciones Avanzadas del ORM
El servicio orm proporciona métodos más avanzados para escenarios complejos:
Usando read para Registros Específicos:
// Leer registros específicos por ID
const products = await this.orm.read(
"product.product",
[1, 2, 3], // IDs de registros específicos
["name", "list_price", "description"] // Campos a leer
);
Usando search y searchCount:
// Obtener solo IDs de registros que coincidan con criterios
const productIds = await this.orm.search(
"product.product",
[["sale_ok", "=", true]],
{ limit: 5, offset: 10 }
);
// Contar registros sin obtenerlos
const totalProducts = await this.orm.searchCount(
"product.product",
[["sale_ok", "=", true]]
);
Llamando Métodos Personalizados del Modelo:
// Llamar cualquier método en tu modelo Python
const result = await this.orm.call(
"product.product", // Nombre del modelo
"my_custom_method", // Nombre del método
[recordId], // Args (generalmente IDs de registros)
{ // Kwargs
param1: "value1",
param2: "value2"
}
);
El Servicio rpc: Comunicación Directa con Controladores
Mientras que el servicio orm es perfecto para operaciones de modelo, a veces necesitas llamar rutas de controlador directamente. El servicio rpc (abreviatura de Remote Procedure Call, "llamada a procedimiento remoto": invocar una función que en realidad se ejecuta en el servidor, como si fuera local) proporciona esta capacidad:
Ejemplo JavaScript:
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class CustomReportGenerator extends Component {
static template = "my_module.CustomReportGenerator";
setup() {
this.state = useState({
reportData: null,
loading: false,
filters: {
start_date: "",
end_date: "",
partner_ids: []
}
});
this.rpc = useService("rpc");
}
async generateReport() {
try {
this.state.loading = true;
console.log("CustomReportGenerator: Generando reporte personalizado");
// Llamar tu ruta de controlador personalizada
const reportData = await this.rpc("/my_module/generate_report", {
filters: this.state.filters,
format: "json",
include_details: true
});
this.state.reportData = reportData;
console.log("CustomReportGenerator: Reporte generado exitosamente");
} catch (error) {
console.error("CustomReportGenerator: Falló la generación del reporte:", error);
// Manejar error apropiadamente
} finally {
this.state.loading = false;
}
}
async downloadReportPDF() {
try {
console.log("CustomReportGenerator: Descargando reporte PDF");
// Esto podría retornar un blob o URL para descarga
const pdfData = await this.rpc("/my_module/generate_report", {
filters: this.state.filters,
format: "pdf"
});
// Manejar lógica de descarga PDF aquí
} catch (error) {
console.error("CustomReportGenerator: Falló la descarga PDF:", error);
}
}
}
Manejo de Errores y Retroalimentación de Usuario
Las aplicaciones profesionales necesitan un manejo robusto de errores. Aquí tienes cómo manejar escenarios comunes:
Patrón de Manejo Completo de Errores:
async performDatabaseOperation() {
try {
this.state.loading = true;
this.state.error = null;
const result = await this.orm.searchRead(
"some.model",
this.buildDomain(),
this.getRequiredFields()
);
this.state.data = result;
} catch (error) {
console.error("Falló la operación de base de datos:", error);
// Diferentes tipos de error necesitan diferente manejo
if (error.code === 403) {
this.state.error = "No tienes permisos para acceder a estos datos.";
} else if (error.code === 404) {
this.state.error = "Los datos solicitados no fueron encontrados.";
} else if (error.message.includes("network")) {
this.state.error = "Error de red. Por favor verifica tu conexión.";
} else {
this.state.error = `Falló la operación: ${error.message}`;
}
} finally {
this.state.loading = false;
}
}
Patrón de Actualizaciones Optimistas:
async quickUpdate(recordId, field, value) {
// Almacenar valor original para rollback
const originalData = [...this.state.records];
try {
// Actualizar UI inmediatamente (optimista)
const record = this.state.records.find(r => r.id === recordId);
if (record) {
record[field] = value;
}
// Luego actualizar servidor
await this.orm.write("model.name", [recordId], { [field]: value });
console.log("Actualización rápida exitosa");
} catch (error) {
// Rollback UI en error
this.state.records = originalData;
console.error("Falló la actualización rápida, rollback realizado:", error);
// Mostrar mensaje de error amigable
this.showError(`Falló la actualización de ${field}. Por favor intenta de nuevo.`);
}
}
Patrones de Integración de Servicios
Patrón 1: Composición de Servicios
setup() {
// Múltiples servicios trabajando juntos
this.orm = useService("orm");
this.rpc = useService("rpc");
this.notification = useService("notification");
this.action = useService("action");
// Operación compuesta usando múltiples servicios
this.performComplexOperation = async () => {
try {
// 1. Guardar datos vía ORM
const [recordId] = await this.orm.create("model.name", [this.state.formData]);
// 2. Activar procesamiento del lado del servidor vía RPC
await this.rpc("/custom/process_record", { record_id: recordId });
// 3. Mostrar notificación de éxito
this.notification.add("¡Registro creado y procesado exitosamente!", {
type: "success"
});
// 4. Navegar al nuevo registro
this.action.doAction({
type: "ir.actions.act_window",
res_model: "model.name",
res_id: recordId,
views: [[false, "form"]],
target: "current"
});
} catch (error) {
this.notification.add("Falló la operación. Por favor intenta de nuevo.", {
type: "danger"
});
}
};
}
Patrón 2: Abstracción de Servicios
setup() {
this.orm = useService("orm");
// Crear capa de abstracción para tu dominio específico
this.customerService = {
async getCustomers(filters = {}) {
return await this.orm.searchRead(
"res.partner",
[["is_company", "=", true], ...this.buildDomain(filters)],
["name", "email", "phone", "city"],
{ order: "name asc" }
);
},
async createCustomer(customerData) {
return await this.orm.create("res.partner", [customerData]);
},
async updateCustomer(customerId, updates) {
return await this.orm.write("res.partner", [customerId], updates);
}
};
}
Consideraciones de Rendimiento
Operaciones en Lote
// MALO: Múltiples llamadas individuales
for (const productId of productIds) {
await this.orm.write("product.product", [productId], { active: false });
}
// BUENO: Una sola llamada en lote
await this.orm.write("product.product", productIds, { active: false });
Selección Eficiente de Campos
// MALO: Cargar datos innecesarios
const products = await this.orm.searchRead("product.product", [], []);
// BUENO: Solo cargar lo que necesitas
const products = await this.orm.searchRead(
"product.product",
[],
["id", "name", "list_price"] // Solo los campos que realmente usas
);
Patrón de Cache Inteligente
setup() {
this.orm = useService("orm");
this.cache = new Map();
this.getCachedData = async (cacheKey, fetcher) => {
if (this.cache.has(cacheKey)) {
console.log(`Usando datos en cache para ${cacheKey}`);
return this.cache.get(cacheKey);
}
const data = await fetcher();
this.cache.set(cacheKey, data);
return data;
};
this.getCategories = () => {
return this.getCachedData('categories', () =>
this.orm.searchRead("product.category", [], ["id", "name"])
);
};
}
Errores Comunes y Soluciones
Error 1: Asumir que searchRead Devuelve un Objeto Envoltorio
// Malo - algunos ORMs envuelven los resultados en un objeto; el de Odoo no
const { records } = await this.orm.searchRead("res.partner", [], ["name"]);
// Correcto - searchRead resuelve directamente a un array de registros
const records = await this.orm.searchRead("res.partner", [], ["name"]);
Error 2: Olvidar await
// Malo - todo método de orm devuelve una Promise; sin await guardas la Promise misma
loadProducts() {
this.state.products = this.orm.searchRead("product.product", [], ["name"]);
}
// Correcto
async loadProducts() {
this.state.products = await this.orm.searchRead("product.product", [], ["name"]);
}
Error 3: No Manejar Errores del Servidor
// Malo - un rechazo no manejado puede romper toda la acción
async saveRecord() {
await this.orm.create("res.partner", [this.state.formData]);
}
// Correcto - envuelve en try/catch y da retroalimentación al usuario
async saveRecord() {
try {
await this.orm.create("res.partner", [this.state.formData]);
} catch (error) {
this.state.error = "No se pudo guardar el registro. Intenta de nuevo.";
}
}
Error 4: Olvidar que create Devuelve un Array de IDs
// Malo - asumir que create resuelve a un solo id
const id = await this.orm.create("res.partner", [{ name: "Test" }]);
// Correcto - create siempre resuelve a un array, incluso para un solo registro
const [id] = await this.orm.create("res.partner", [{ name: "Test" }]);
Probando la Integración de Servicios
Como los componentes reciben los servicios a través de useService, las pruebas pueden sustituirlos por mocks. Los helpers exactos dependen de tu versión de Odoo (cubriremos el framework de pruebas real de Odoo en el Capítulo 16 Parte 1), pero la idea siempre se ve así:
// Pseudo-código: la forma de una prueba con servicios simulados
describe("ProductManager", () => {
test("debería cargar productos al montar", async () => {
const mockOrm = {
searchRead: jest.fn().mockResolvedValue([
{ id: 1, name: "Producto de Prueba", list_price: 10.0 }
])
};
const component = await makeTestComponent(ProductManager, {
services: {
orm: mockOrm
}
});
await nextTick(); // Esperar a que los efectos se completen
expect(mockOrm.searchRead).toHaveBeenCalledWith(
"product.product",
[["sale_ok", "=", true]],
expect.any(Array),
expect.any(Object)
);
expect(component.state.products).toHaveLength(1);
});
});
Resumen
El hook useService y el sistema de servicios de Odoo proporcionan:
- Servicio
orm: Operaciones CRUD completas para modelos de Odoo - Servicio
rpc: Comunicación directa con controladores personalizados - API consistente: Los mismos patrones a través de toda tu aplicación
- Manejo de errores: Gestión y recuperación de errores incorporada
- Rendimiento: Comunicación optimizada con el backend
Principios clave para el uso efectivo de servicios:
- Usar el servicio correcto para cada tarea (ORM para modelos, RPC para controladores)
- Manejar errores graciosamente con mensajes amigables para el usuario
- Operaciones en lote cuando sea posible para mejor rendimiento
- Cache estratégico para reducir solicitudes al servidor
- Probar con simulaciones para asegurar confiabilidad
En el próximo capítulo, exploraremos más servicios incorporados de Odoo como notificaciones y acciones, que te ayudarán a crear experiencias de usuario verdaderamente integradas.
TL;DR: Usa useService("orm") para CRUD contra modelos de Odoo (searchRead, create, write, unlink) y useService("rpc") para controladores personalizados — siempre con await, siempre envolviendo las llamadas en try/catch, y recordando que create devuelve un array de ids, no un id suelto.
Pruébalo tú mismo: El ejemplo de este capítulo está disponible como addon instalable de Odoo 19: simplifyit_owl_book_ch12_ex1. Las instrucciones de instalación están en el README del repositorio.
Ejercicios
- CRUD básico: Construye un componente pequeño que liste registros de
res.partner(searchReadcon solonameyemail), y agrega un botón que cree un nuevo contacto llamado "Contacto de Prueba" usandoorm.create. Recuerda quecreatedevuelve un array de ids. - Maneja el error: Envuelve una llamada a
orm.writeen untry/catchy muestra una alerta de Bootstrap con un mensaje amigable si falla. Pruébalo pasando un nombre de modelo inválido y observa qué pasa sin el try/catch primero. - Cuenta antes de traer: Usa
orm.searchCountpara mostrar "X contactos encontrados" antes de cargar la lista completa consearchRead, para que el usuario vea un número de inmediato mientras los datos se cargan.
¿Qué Sigue?
Con datos fluyendo entre tus componentes y el servidor, el Capítulo 13 pasa al resto de los servicios integrados de Odoo — notificaciones, diálogos, acciones y permisos — las piezas que hacen que un componente se sienta parte nativa de Odoo en vez de un widget pegado con cinta adhesiva.