erp-core/orchestration/directivas/DIRECTIVA-PATRONES-ODOO.md

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/partners con 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

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