# 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 ```python class BaseModel(models.AbstractModel): _name = 'base.model' company_id = fields.Many2one('res.company') ``` ### En ERP Core (Adaptacion) ```typescript // 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 ```sql -- 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 ```python 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) ```typescript // 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/partners` con filtros - Relaciones unificadas (facturas, ordenes, etc.) - Direcciones y contactos en cascada --- ## 3. PATRON: Productos Configurables (product.template/product.product) ### En Odoo ```python 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) ```typescript // 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; // { "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 ```python class Sequence(models.Model): _name = 'ir.sequence' prefix = fields.Char() # 'INV/' padding = fields.Integer() # 5 -> 00001 number_next = fields.Integer() ``` ### En ERP Core (Adaptacion) ```sql -- 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 ```typescript @Injectable() export class SequenceService { async getNext(tenantId: string, code: string): Promise { 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 ```python 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) ```typescript // Enum de estados export enum SaleOrderState { DRAFT = 'draft', SENT = 'sent', CONFIRMED = 'confirmed', DONE = 'done', CANCELLED = 'cancelled' } // Transiciones validas const STATE_TRANSITIONS: Record = { [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 { 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 ```python 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) ```typescript // 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 ```sql 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 ```python # 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):** ```typescript // 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):** ```typescript // 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 ```sql -- 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 ```python self.env['ir.config_parameter'].sudo().get_param('web.base.url') ``` ### En ERP Core (Adaptacion) ```sql -- 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'); ``` ```typescript @Injectable() export class ConfigService { async getParam(tenantId: string | null, key: string): Promise { // 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