601 lines
16 KiB
Markdown
601 lines
16 KiB
Markdown
# 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<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
|
|
```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<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
|
|
```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, 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
|
|
```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<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
|