16 KiB
16 KiB
DIRECTIVA: Patrones de Diseno Basados en Odoo
Proyecto: ERP Core - Base Generica Reutilizable Version: 1.0.0 Fecha: 2025-12-05 Aplicable a: ERP Core y todas las verticales Referencia: Odoo Enterprise 17.0 Estado: OBLIGATORIO
PRINCIPIO FUNDAMENTAL
El ERP Core sigue los patrones arquitectonicos de Odoo adaptados a stack TypeScript/Node.js
Esta directiva define como adaptar los patrones probados de Odoo a nuestra arquitectura.
1. PATRON: Modelo Jerarquico Multi-Company
En Odoo
class BaseModel(models.AbstractModel):
_name = 'base.model'
company_id = fields.Many2one('res.company')
En ERP Core (Adaptacion)
// Base entity con multi-tenant
@Entity()
export abstract class BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'tenant_id' })
@Index('idx_tenant')
tenantId: string;
@ManyToOne(() => TenantEntity)
@JoinColumn({ name: 'tenant_id' })
tenant: TenantEntity;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ type: 'uuid', name: 'created_by', nullable: true })
createdBy: string;
@Column({ type: 'uuid', name: 'updated_by', nullable: true })
updatedBy: string;
@Column({ name: 'is_active', default: true })
isActive: boolean;
}
Implementacion DDL
-- Columnas obligatorias en TODA tabla
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES core_system.tenants(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES core_auth.users(id),
updated_by UUID REFERENCES core_auth.users(id),
is_active BOOLEAN DEFAULT TRUE
2. PATRON: Partner Unificado (res.partner)
En Odoo
class Partner(models.Model):
_name = 'res.partner'
# Un solo modelo para clientes, proveedores, empleados, contactos
is_company = fields.Boolean()
customer_rank = fields.Integer()
supplier_rank = fields.Integer()
En ERP Core (Adaptacion)
// Schema: core_partners
@Entity({ schema: 'core_partners', name: 'partners' })
export class PartnerEntity extends BaseEntity {
@Column({ length: 200 })
name: string;
@Column({ name: 'is_company', default: false })
isCompany: boolean;
@Column({ name: 'partner_type', type: 'enum', enum: PartnerType })
partnerType: PartnerType; // 'customer' | 'supplier' | 'employee' | 'contact'
@Column({ name: 'customer_rank', default: 0 })
customerRank: number;
@Column({ name: 'supplier_rank', default: 0 })
supplierRank: number;
// Relacion jerarquica (contactos de empresa)
@ManyToOne(() => PartnerEntity, { nullable: true })
@JoinColumn({ name: 'parent_id' })
parent: PartnerEntity;
@OneToMany(() => PartnerEntity, p => p.parent)
contacts: PartnerEntity[];
}
Beneficios
- Un solo endpoint
/api/partnerscon filtros - Relaciones unificadas (facturas, ordenes, etc.)
- Direcciones y contactos en cascada
3. PATRON: Productos Configurables (product.template/product.product)
En Odoo
class ProductTemplate(models.Model):
_name = 'product.template'
# Template: atributos comunes
name = fields.Char()
list_price = fields.Float()
class ProductProduct(models.Model):
_name = 'product.product'
# Variante: combinacion especifica
product_tmpl_id = fields.Many2one('product.template')
attribute_value_ids = fields.Many2many('product.attribute.value')
En ERP Core (Adaptacion)
// Schema: core_products
// Template (producto base)
@Entity({ schema: 'core_products', name: 'product_templates' })
export class ProductTemplateEntity extends BaseEntity {
@Column({ length: 200 })
name: string;
@Column({ name: 'internal_ref', length: 50, nullable: true })
internalRef: string;
@Column({ name: 'list_price', type: 'decimal', precision: 10, scale: 2 })
listPrice: number;
@Column({ name: 'product_type', type: 'enum', enum: ProductType })
productType: ProductType; // 'consu' | 'service' | 'storable'
@OneToMany(() => ProductVariantEntity, v => v.template)
variants: ProductVariantEntity[];
}
// Variante (producto especifico)
@Entity({ schema: 'core_products', name: 'product_variants' })
export class ProductVariantEntity extends BaseEntity {
@ManyToOne(() => ProductTemplateEntity)
@JoinColumn({ name: 'template_id' })
template: ProductTemplateEntity;
@Column({ type: 'jsonb', name: 'attribute_values', default: {} })
attributeValues: Record<string, string>; // { "color": "rojo", "talla": "M" }
@Column({ name: 'sku', length: 50, unique: true })
sku: string;
@Column({ name: 'barcode', length: 50, nullable: true })
barcode: string;
}
4. PATRON: Secuencias (ir.sequence)
En Odoo
class Sequence(models.Model):
_name = 'ir.sequence'
prefix = fields.Char() # 'INV/'
padding = fields.Integer() # 5 -> 00001
number_next = fields.Integer()
En ERP Core (Adaptacion)
-- Schema: core_system
CREATE TABLE core_system.sequences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES core_system.tenants(id),
code VARCHAR(50) NOT NULL, -- 'sale.order', 'purchase.order'
name VARCHAR(100) NOT NULL,
prefix VARCHAR(20), -- 'SO-', 'PO-'
suffix VARCHAR(20), -- '-2025'
padding INTEGER DEFAULT 5, -- numero de digitos
number_next INTEGER DEFAULT 1,
use_date_range BOOLEAN DEFAULT FALSE,
UNIQUE(tenant_id, code)
);
-- Funcion para obtener siguiente numero
CREATE OR REPLACE FUNCTION core_system.next_sequence(
p_tenant_id UUID,
p_code VARCHAR
) RETURNS VARCHAR AS $$
DECLARE
v_seq RECORD;
v_number INTEGER;
v_result VARCHAR;
BEGIN
SELECT * INTO v_seq
FROM core_system.sequences
WHERE tenant_id = p_tenant_id AND code = p_code
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Secuencia no encontrada: %', p_code;
END IF;
v_number := v_seq.number_next;
UPDATE core_system.sequences
SET number_next = number_next + 1
WHERE id = v_seq.id;
v_result := COALESCE(v_seq.prefix, '') ||
LPAD(v_number::TEXT, v_seq.padding, '0') ||
COALESCE(v_seq.suffix, '');
RETURN v_result;
END;
$$ LANGUAGE plpgsql;
Uso en Backend
@Injectable()
export class SequenceService {
async getNext(tenantId: string, code: string): Promise<string> {
const result = await this.dataSource.query(
'SELECT core_system.next_sequence($1, $2) as sequence',
[tenantId, code]
);
return result[0].sequence;
}
}
// Uso en SaleOrderService
const orderNumber = await this.sequenceService.getNext(tenantId, 'sale.order');
// Resultado: "SO-00001"
5. PATRON: Estados y Flujos (State Machine)
En Odoo
class SaleOrder(models.Model):
state = fields.Selection([
('draft', 'Quotation'),
('sent', 'Quotation Sent'),
('sale', 'Sales Order'),
('done', 'Locked'),
('cancel', 'Cancelled'),
], default='draft')
def action_confirm(self):
self.state = 'sale'
En ERP Core (Adaptacion)
// Enum de estados
export enum SaleOrderState {
DRAFT = 'draft',
SENT = 'sent',
CONFIRMED = 'confirmed',
DONE = 'done',
CANCELLED = 'cancelled'
}
// Transiciones validas
const STATE_TRANSITIONS: Record<SaleOrderState, SaleOrderState[]> = {
[SaleOrderState.DRAFT]: [SaleOrderState.SENT, SaleOrderState.CANCELLED],
[SaleOrderState.SENT]: [SaleOrderState.CONFIRMED, SaleOrderState.CANCELLED],
[SaleOrderState.CONFIRMED]: [SaleOrderState.DONE, SaleOrderState.CANCELLED],
[SaleOrderState.DONE]: [],
[SaleOrderState.CANCELLED]: [SaleOrderState.DRAFT], // Reabrir
};
// Service con validacion de transicion
@Injectable()
export class SaleOrderService {
async changeState(id: string, newState: SaleOrderState): Promise<void> {
const order = await this.findOne(id);
if (!STATE_TRANSITIONS[order.state].includes(newState)) {
throw new BadRequestException(
`Transicion no permitida: ${order.state} -> ${newState}`
);
}
// Ejecutar acciones segun transicion
await this.executeStateActions(order, newState);
order.state = newState;
await this.repository.save(order);
}
private async executeStateActions(order: SaleOrder, newState: SaleOrderState) {
switch (newState) {
case SaleOrderState.CONFIRMED:
await this.createStockMoves(order);
await this.createInvoice(order);
break;
case SaleOrderState.CANCELLED:
await this.cancelStockMoves(order);
break;
}
}
}
6. PATRON: Lineas de Documento (One2Many)
En Odoo
class SaleOrder(models.Model):
order_line = fields.One2many('sale.order.line', 'order_id')
class SaleOrderLine(models.Model):
order_id = fields.Many2one('sale.order')
product_id = fields.Many2one('product.product')
quantity = fields.Float()
price_unit = fields.Float()
price_subtotal = fields.Float(compute='_compute_subtotal')
En ERP Core (Adaptacion)
// Header
@Entity({ schema: 'core_sales', name: 'sale_orders' })
export class SaleOrderEntity extends BaseEntity {
@Column({ length: 50, unique: true })
name: string; // SO-00001
@ManyToOne(() => PartnerEntity)
@JoinColumn({ name: 'partner_id' })
partner: PartnerEntity;
@Column({ type: 'enum', enum: SaleOrderState, default: SaleOrderState.DRAFT })
state: SaleOrderState;
@OneToMany(() => SaleOrderLineEntity, line => line.order, { cascade: true })
lines: SaleOrderLineEntity[];
// Campos calculados (actualizados por trigger o computed)
@Column({ name: 'amount_untaxed', type: 'decimal', precision: 10, scale: 2 })
amountUntaxed: number;
@Column({ name: 'amount_tax', type: 'decimal', precision: 10, scale: 2 })
amountTax: number;
@Column({ name: 'amount_total', type: 'decimal', precision: 10, scale: 2 })
amountTotal: number;
}
// Lines
@Entity({ schema: 'core_sales', name: 'sale_order_lines' })
export class SaleOrderLineEntity extends BaseEntity {
@ManyToOne(() => SaleOrderEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'order_id' })
order: SaleOrderEntity;
@Column()
sequence: number; // Orden de lineas
@ManyToOne(() => ProductVariantEntity)
@JoinColumn({ name: 'product_id' })
product: ProductVariantEntity;
@Column({ type: 'decimal', precision: 10, scale: 2 })
quantity: number;
@Column({ name: 'price_unit', type: 'decimal', precision: 10, scale: 2 })
priceUnit: number;
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0 })
discount: number; // Porcentaje
// Calculado: quantity * price_unit * (1 - discount/100)
@Column({ name: 'price_subtotal', type: 'decimal', precision: 10, scale: 2 })
priceSubtotal: number;
}
Trigger para calculos
CREATE OR REPLACE FUNCTION core_sales.compute_order_totals()
RETURNS TRIGGER AS $$
BEGIN
UPDATE core_sales.sale_orders
SET
amount_untaxed = (
SELECT COALESCE(SUM(price_subtotal), 0)
FROM core_sales.sale_order_lines
WHERE order_id = NEW.order_id
),
amount_total = amount_untaxed + amount_tax,
updated_at = NOW()
WHERE id = NEW.order_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_compute_order_totals
AFTER INSERT OR UPDATE OR DELETE ON core_sales.sale_order_lines
FOR EACH ROW EXECUTE FUNCTION core_sales.compute_order_totals();
7. PATRON: Herencia de Modulos (Verticales)
En Odoo
# Modulo base
class SaleOrder(models.Model):
_name = 'sale.order'
# Extension en otro modulo
class SaleOrderConstruction(models.Model):
_inherit = 'sale.order'
project_id = fields.Many2one('construction.project')
En ERP Core (Adaptacion)
Core (base):
// erp-core/backend/src/modules/sales/entities/sale-order.entity.ts
@Entity({ schema: 'core_sales', name: 'sale_orders' })
export class SaleOrderEntity extends BaseEntity {
@Column({ length: 50 })
name: string;
// ... campos base
}
Vertical (extension):
// verticales/construccion/backend/src/modules/sales/entities/sale-order-construction.entity.ts
// Opcion 1: Herencia de clase (si no se necesitan columnas adicionales en BD)
export class SaleOrderConstructionEntity extends SaleOrderEntity {
// Metodos adicionales, no columnas
getProjectReference(): string {
return `${this.name}-${this.project?.code}`;
}
}
// Opcion 2: Tabla separada con FK (si se necesitan columnas adicionales)
@Entity({ schema: 'vertical_construccion', name: 'sale_order_extensions' })
export class SaleOrderConstructionExtEntity {
@PrimaryColumn('uuid')
id: string; // Mismo ID que sale_order
@OneToOne(() => SaleOrderEntity)
@JoinColumn({ name: 'id' })
saleOrder: SaleOrderEntity;
@ManyToOne(() => ProjectEntity)
@JoinColumn({ name: 'project_id' })
project: ProjectEntity;
@Column({ name: 'phase_id', nullable: true })
phaseId: string;
}
DDL Extension
-- Schema de la vertical
CREATE SCHEMA IF NOT EXISTS vertical_construccion;
-- Tabla de extension
CREATE TABLE vertical_construccion.sale_order_extensions (
id UUID PRIMARY KEY REFERENCES core_sales.sale_orders(id) ON DELETE CASCADE,
project_id UUID REFERENCES vertical_construccion.projects(id),
phase_id UUID REFERENCES vertical_construccion.project_phases(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Vista unificada (opcional)
CREATE VIEW vertical_construccion.v_sale_orders_full AS
SELECT
so.*,
ext.project_id,
ext.phase_id,
p.name as project_name
FROM core_sales.sale_orders so
LEFT JOIN vertical_construccion.sale_order_extensions ext ON so.id = ext.id
LEFT JOIN vertical_construccion.projects p ON ext.project_id = p.id;
8. PATRON: Contexto Global (ir.config_parameter)
En Odoo
self.env['ir.config_parameter'].sudo().get_param('web.base.url')
En ERP Core (Adaptacion)
-- Configuracion por tenant
CREATE TABLE core_system.config_parameters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES core_system.tenants(id),
key VARCHAR(100) NOT NULL,
value TEXT,
UNIQUE(tenant_id, key)
);
-- Valores globales (tenant_id = NULL)
INSERT INTO core_system.config_parameters (tenant_id, key, value) VALUES
(NULL, 'system.default_currency', 'MXN'),
(NULL, 'system.date_format', 'YYYY-MM-DD');
@Injectable()
export class ConfigService {
async getParam(tenantId: string | null, key: string): Promise<string | null> {
// Buscar primero en tenant, luego global
const result = await this.repository.findOne({
where: [
{ tenantId, key },
{ tenantId: IsNull(), key }
],
order: { tenantId: 'DESC' } // Prioriza tenant sobre global
});
return result?.value ?? null;
}
}
MAPEO MODULOS ODOO -> ERP CORE
| Modulo Odoo | Schema ERP Core | Descripcion |
|---|---|---|
res.company |
core_system.tenants |
Multi-tenancy |
res.users |
core_auth.users |
Usuarios |
res.partner |
core_partners.partners |
Clientes/Proveedores |
product.* |
core_products.* |
Productos |
sale.* |
core_sales.* |
Ventas |
purchase.* |
core_purchases.* |
Compras |
stock.* |
core_inventory.* |
Inventario |
account.* |
core_financial.* |
Contabilidad |
hr.* |
core_hr.* |
Recursos Humanos |
crm.* |
core_crm.* |
CRM |
project.* |
core_projects.* |
Proyectos |
REFERENCIAS
Documentacion Odoo
- https://www.odoo.com/documentation/17.0/developer.html
- Patrones en:
/home/isem/workspace/knowledge-base/patterns/odoo/
Codigo de Referencia
[RUTA-LEGACY-ELIMINADA]/shared/patterns/[RUTA-LEGACY-ELIMINADA]/shared/reference/
Version: 1.0.0 Ultima actualizacion: 2025-12-05 Estado: ACTIVA Y OBLIGATORIA