# DDL Specification: core_catalogs Schema

## Identificacion

| Campo | Valor |
|-------|-------|
| **Schema** | core_catalogs |
| **Modulo** | MGN-005 Catalogs |
| **Version** | 1.0 |
| **Fecha** | 2025-12-05 |
| **Estado** | Ready |

---

## Diagrama ER

```mermaid
erDiagram
    %% Catalogos Globales (sin tenant_id)
    countries ||--o{ states : has
    countries ||--o| currencies : default_currency

    %% Catalogos por Tenant
    tenants ||--o{ contacts : owns
    tenants ||--o{ currency_rates : owns
    tenants ||--o{ uom_categories : owns
    tenants ||--o{ uom : owns
    tenants ||--o{ contact_tags : owns
    tenants ||--o{ categories : owns

    contacts ||--o{ contacts : has_children
    contacts }o--o{ contact_tags : tagged_with
    contacts }o--|| countries : located_in
    contacts }o--|| states : located_in
    contacts }o--|| users : linked_to

    uom_categories ||--o{ uom : contains
    categories ||--o{ categories : has_children

    countries {
        uuid id PK
        string name
        char code UK
        char code3
        int phone_code
        uuid currency_id FK
        text address_format
        boolean is_active
    }

    states {
        uuid id PK
        uuid country_id FK
        string name
        string code UK
        boolean is_active
    }

    currencies {
        uuid id PK
        char name UK
        string full_name
        string symbol
        int decimal_places
        decimal rounding
        enum position
        boolean is_active
    }

    currency_rates {
        uuid id PK
        uuid tenant_id FK
        uuid currency_id FK
        decimal rate
        date name
        timestamp created_at
    }

    contacts {
        uuid id PK
        uuid tenant_id FK
        uuid parent_id FK
        enum contact_type
        string name
        string display_name
        string ref
        string vat
        string email
        string phone
        string whatsapp_number
        boolean whatsapp_opt_in
        uuid country_id FK
        uuid state_id FK
        uuid user_id FK
        boolean is_active
    }

    uom_categories {
        uuid id PK
        uuid tenant_id FK
        string name
        boolean is_active
    }

    uom {
        uuid id PK
        uuid tenant_id FK
        uuid category_id FK
        string name
        enum uom_type
        decimal factor
        decimal rounding
        boolean is_active
    }

    contact_tags {
        uuid id PK
        uuid tenant_id FK
        uuid parent_id FK
        string name
        int color
        boolean is_active
    }

    categories {
        uuid id PK
        uuid tenant_id FK
        uuid parent_id FK
        string entity_type
        string name
        string code
        string parent_path
        int sort_order
        boolean is_active
    }
```

---

## Tablas Globales (Sin tenant_id)

### 1. countries

Catalogo de paises ISO 3166-1.

```sql
CREATE TABLE core_catalogs.countries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL,
    code CHAR(2) NOT NULL UNIQUE,
    code3 CHAR(3),
    numeric_code INT,
    phone_code INT,
    currency_id UUID,  -- FK a currencies (sin constraint para evitar ciclos)
    address_format TEXT DEFAULT '%(street)s\n%(street2)s\n%(city)s %(state_code)s %(zip)s\n%(country_name)s',
    name_position VARCHAR(10) DEFAULT 'before',
    state_required BOOLEAN DEFAULT false,
    zip_required BOOLEAN DEFAULT true,
    vat_label VARCHAR(50) DEFAULT 'VAT',
    is_active BOOLEAN NOT NULL DEFAULT true,

    CONSTRAINT chk_countries_code CHECK (code ~ '^[A-Z]{2}$'),
    CONSTRAINT chk_countries_code3 CHECK (code3 IS NULL OR code3 ~ '^[A-Z]{3}$'),
    CONSTRAINT chk_countries_name_position CHECK (name_position IN ('before', 'after'))
);

-- Indices
CREATE INDEX idx_countries_code ON core_catalogs.countries(code);
CREATE INDEX idx_countries_active ON core_catalogs.countries(is_active) WHERE is_active = true;

-- Comentarios
COMMENT ON TABLE core_catalogs.countries IS 'Catalogo global de paises ISO 3166-1';
COMMENT ON COLUMN core_catalogs.countries.address_format IS 'Template para formato de direccion';
COMMENT ON COLUMN core_catalogs.countries.vat_label IS 'Etiqueta local para VAT (RFC, NIT, RUT, etc.)';
```

---

### 2. states

Subdivisiones de paises ISO 3166-2.

```sql
CREATE TABLE core_catalogs.states (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    country_id UUID NOT NULL REFERENCES core_catalogs.countries(id),
    name VARCHAR(100) NOT NULL,
    code VARCHAR(10) NOT NULL,
    is_active BOOLEAN NOT NULL DEFAULT true,

    CONSTRAINT uq_states_country_code UNIQUE (country_id, code)
);

-- Indices
CREATE INDEX idx_states_country ON core_catalogs.states(country_id);
CREATE INDEX idx_states_code ON core_catalogs.states(code);

-- Comentarios
COMMENT ON TABLE core_catalogs.states IS 'Subdivisiones de paises (estados, provincias, departamentos)';
```

---

### 3. currencies

Catalogo de monedas ISO 4217.

```sql
CREATE TABLE core_catalogs.currencies (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name CHAR(3) NOT NULL UNIQUE,  -- ISO 4217 code
    full_name VARCHAR(100),
    symbol VARCHAR(10) NOT NULL,
    iso_numeric INT,
    decimal_places INT NOT NULL DEFAULT 2,
    rounding DECIMAL(12, 6) NOT NULL DEFAULT 0.01,
    position VARCHAR(10) NOT NULL DEFAULT 'before',
    currency_unit_label VARCHAR(50),    -- "Dollar", "Peso"
    currency_subunit_label VARCHAR(50), -- "Cent", "Centavo"
    is_active BOOLEAN NOT NULL DEFAULT true,

    CONSTRAINT chk_currencies_name CHECK (name ~ '^[A-Z]{3}$'),
    CONSTRAINT chk_currencies_position CHECK (position IN ('before', 'after')),
    CONSTRAINT chk_currencies_decimal CHECK (decimal_places >= 0 AND decimal_places <= 6),
    CONSTRAINT chk_currencies_rounding CHECK (rounding > 0)
);

-- Indices
CREATE INDEX idx_currencies_name ON core_catalogs.currencies(name);
CREATE INDEX idx_currencies_active ON core_catalogs.currencies(is_active) WHERE is_active = true;

-- Comentarios
COMMENT ON TABLE core_catalogs.currencies IS 'Catalogo global de monedas ISO 4217';
COMMENT ON COLUMN core_catalogs.currencies.position IS 'Posicion del simbolo: before ($100) o after (100$)';
```

---

### 4. country_groups

Agrupaciones regionales de paises.

```sql
CREATE TABLE core_catalogs.country_groups (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL UNIQUE
);

CREATE TABLE core_catalogs.country_group_rel (
    country_id UUID NOT NULL REFERENCES core_catalogs.countries(id) ON DELETE CASCADE,
    group_id UUID NOT NULL REFERENCES core_catalogs.country_groups(id) ON DELETE CASCADE,
    PRIMARY KEY (country_id, group_id)
);

-- Comentarios
COMMENT ON TABLE core_catalogs.country_groups IS 'Grupos regionales (EU, LATAM, etc.)';
```

---

## Tablas por Tenant (Con tenant_id y RLS)

### 5. currency_rates

Tasas de cambio historicas por tenant.

```sql
CREATE TABLE core_catalogs.currency_rates (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
    currency_id UUID NOT NULL REFERENCES core_catalogs.currencies(id),
    rate DECIMAL(20, 10) NOT NULL,
    name DATE NOT NULL,  -- Fecha de la tasa (Odoo naming convention)
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    created_by UUID,

    CONSTRAINT uq_currency_rates UNIQUE (tenant_id, currency_id, name),
    CONSTRAINT chk_currency_rates_positive CHECK (rate > 0)
);

-- Indices
CREATE INDEX idx_currency_rates_tenant ON core_catalogs.currency_rates(tenant_id);
CREATE INDEX idx_currency_rates_currency ON core_catalogs.currency_rates(currency_id);
CREATE INDEX idx_currency_rates_date ON core_catalogs.currency_rates(name DESC);

-- RLS
ALTER TABLE core_catalogs.currency_rates ENABLE ROW LEVEL SECURITY;

CREATE POLICY currency_rates_tenant_isolation ON core_catalogs.currency_rates
    FOR ALL
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Comentarios
COMMENT ON TABLE core_catalogs.currency_rates IS 'Tasas de cambio historicas por tenant';
COMMENT ON COLUMN core_catalogs.currency_rates.rate IS '1 moneda_base = rate moneda_extranjera';
```

---

### 6. contacts

Contactos (clientes, proveedores, empleados). Equivalente a res.partner de Odoo.

```sql
CREATE TABLE core_catalogs.contacts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
    parent_id UUID REFERENCES core_catalogs.contacts(id),

    -- Tipo y clasificacion
    contact_type VARCHAR(20) NOT NULL DEFAULT 'company',
    roles TEXT[] NOT NULL DEFAULT '{}',  -- {'customer', 'vendor', 'employee'}

    -- Datos basicos
    name VARCHAR(200) NOT NULL,
    display_name VARCHAR(300),  -- Computed: incluye parent
    ref VARCHAR(50),  -- Codigo interno
    vat VARCHAR(50),  -- RFC/NIT/RUT
    company_registry VARCHAR(100),

    -- Contacto
    email VARCHAR(200),
    phone VARCHAR(50),
    mobile VARCHAR(50),
    website VARCHAR(200),
    function VARCHAR(100),  -- Cargo/Puesto (para contact type)

    -- WhatsApp (MGN-017)
    whatsapp_number VARCHAR(20),      -- Numero de WhatsApp (formato E.164: +521234567890)
    whatsapp_opt_in BOOLEAN DEFAULT false,  -- Consentimiento para recibir mensajes
    whatsapp_opt_in_date TIMESTAMPTZ, -- Fecha de consentimiento

    -- Direccion
    street VARCHAR(200),
    street2 VARCHAR(200),
    city VARCHAR(100),
    zip VARCHAR(20),
    state_id UUID REFERENCES core_catalogs.states(id),
    country_id UUID REFERENCES core_catalogs.countries(id),

    -- Comercial
    currency_id UUID REFERENCES core_catalogs.currencies(id),
    payment_term_days INT DEFAULT 0,
    credit_limit DECIMAL(15, 2) DEFAULT 0,
    bank_accounts JSONB DEFAULT '[]'::jsonb,

    -- Vinculacion
    user_id UUID,  -- FK a core_users.users (agregado despues por dependencia circular)

    -- Imagen
    image_url VARCHAR(500),

    -- Metadata
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    created_by UUID,
    updated_by UUID,
    deleted_at TIMESTAMPTZ,
    deleted_by UUID,

    CONSTRAINT chk_contacts_type CHECK (contact_type IN ('company', 'individual', 'contact')),
    CONSTRAINT chk_contacts_parent CHECK (
        (contact_type = 'contact' AND parent_id IS NOT NULL) OR
        (contact_type != 'contact')
    ),
    CONSTRAINT chk_contacts_email CHECK (email IS NULL OR email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
    CONSTRAINT chk_contacts_credit_limit CHECK (credit_limit >= 0),
    CONSTRAINT chk_contacts_whatsapp CHECK (whatsapp_number IS NULL OR whatsapp_number ~ '^\+[1-9]\d{6,14}$')
);

-- Indices
CREATE INDEX idx_contacts_tenant ON core_catalogs.contacts(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_parent ON core_catalogs.contacts(parent_id) WHERE parent_id IS NOT NULL;
CREATE INDEX idx_contacts_type ON core_catalogs.contacts(contact_type) WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_roles ON core_catalogs.contacts USING gin(roles) WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_vat ON core_catalogs.contacts(tenant_id, vat) WHERE vat IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_contacts_ref ON core_catalogs.contacts(tenant_id, ref) WHERE ref IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_contacts_whatsapp ON core_catalogs.contacts(tenant_id, whatsapp_number) WHERE whatsapp_number IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_contacts_whatsapp_optin ON core_catalogs.contacts(tenant_id, whatsapp_opt_in) WHERE whatsapp_opt_in = true AND deleted_at IS NULL;
CREATE INDEX idx_contacts_search ON core_catalogs.contacts
    USING gin(to_tsvector('spanish', name || ' ' || COALESCE(email, '') || ' ' || COALESCE(vat, '')));

-- Trigger para updated_at
CREATE TRIGGER trg_contacts_updated_at
    BEFORE UPDATE ON core_catalogs.contacts
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- Trigger para display_name
CREATE OR REPLACE FUNCTION core_catalogs.compute_contact_display_name()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.parent_id IS NOT NULL THEN
        SELECT name || ', ' || NEW.name INTO NEW.display_name
        FROM core_catalogs.contacts WHERE id = NEW.parent_id;
    ELSE
        NEW.display_name := NEW.name;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_contacts_display_name
    BEFORE INSERT OR UPDATE OF name, parent_id ON core_catalogs.contacts
    FOR EACH ROW
    EXECUTE FUNCTION core_catalogs.compute_contact_display_name();

-- RLS
ALTER TABLE core_catalogs.contacts ENABLE ROW LEVEL SECURITY;

CREATE POLICY contacts_tenant_isolation ON core_catalogs.contacts
    FOR ALL
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Comentarios
COMMENT ON TABLE core_catalogs.contacts IS 'Contactos: clientes, proveedores, empleados (Ref: Odoo res.partner)';
COMMENT ON COLUMN core_catalogs.contacts.contact_type IS 'company: empresa, individual: persona, contact: contacto de empresa';
COMMENT ON COLUMN core_catalogs.contacts.roles IS 'Array de roles: customer, vendor, employee, other';
COMMENT ON COLUMN core_catalogs.contacts.whatsapp_number IS 'Numero WhatsApp formato E.164 (MGN-017)';
COMMENT ON COLUMN core_catalogs.contacts.whatsapp_opt_in IS 'Consentimiento para marketing WhatsApp (GDPR/LFPDPPP)';
```

---

### 7. contact_tags

Tags para categorizar contactos.

```sql
CREATE TABLE core_catalogs.contact_tags (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
    parent_id UUID REFERENCES core_catalogs.contact_tags(id),
    name VARCHAR(100) NOT NULL,
    color INT NOT NULL DEFAULT 1,
    is_active BOOLEAN NOT NULL DEFAULT true,

    CONSTRAINT uq_contact_tags_name UNIQUE (tenant_id, parent_id, name),
    CONSTRAINT chk_contact_tags_color CHECK (color >= 1 AND color <= 11)
);

-- Tabla de relacion M:N
CREATE TABLE core_catalogs.contact_tag_rel (
    contact_id UUID NOT NULL REFERENCES core_catalogs.contacts(id) ON DELETE CASCADE,
    tag_id UUID NOT NULL REFERENCES core_catalogs.contact_tags(id) ON DELETE CASCADE,
    PRIMARY KEY (contact_id, tag_id)
);

-- Indices
CREATE INDEX idx_contact_tags_tenant ON core_catalogs.contact_tags(tenant_id);
CREATE INDEX idx_contact_tag_rel_contact ON core_catalogs.contact_tag_rel(contact_id);
CREATE INDEX idx_contact_tag_rel_tag ON core_catalogs.contact_tag_rel(tag_id);

-- RLS
ALTER TABLE core_catalogs.contact_tags ENABLE ROW LEVEL SECURITY;

CREATE POLICY contact_tags_tenant_isolation ON core_catalogs.contact_tags
    FOR ALL
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Comentarios
COMMENT ON TABLE core_catalogs.contact_tags IS 'Tags para categorizar contactos (Ref: Odoo res.partner.category)';
COMMENT ON COLUMN core_catalogs.contact_tags.color IS 'Color 1-11 para visualizacion';
```

---

### 8. uom_categories

Categorias de unidades de medida.

```sql
CREATE TABLE core_catalogs.uom_categories (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    is_active BOOLEAN NOT NULL DEFAULT true,

    CONSTRAINT uq_uom_categories_name UNIQUE (tenant_id, name)
);

-- Indices
CREATE INDEX idx_uom_categories_tenant ON core_catalogs.uom_categories(tenant_id);

-- RLS
ALTER TABLE core_catalogs.uom_categories ENABLE ROW LEVEL SECURITY;

CREATE POLICY uom_categories_tenant_isolation ON core_catalogs.uom_categories
    FOR ALL
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Comentarios
COMMENT ON TABLE core_catalogs.uom_categories IS 'Categorias de unidades de medida (Ref: Odoo uom.category)';
```

---

### 9. uom

Unidades de medida.

```sql
CREATE TABLE core_catalogs.uom (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
    category_id UUID NOT NULL REFERENCES core_catalogs.uom_categories(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    uom_type VARCHAR(20) NOT NULL DEFAULT 'reference',
    factor DECIMAL(20, 10) NOT NULL DEFAULT 1.0,
    rounding DECIMAL(12, 6) NOT NULL DEFAULT 0.01,
    is_active BOOLEAN NOT NULL DEFAULT true,
    is_protected BOOLEAN NOT NULL DEFAULT false,

    CONSTRAINT uq_uom_name UNIQUE (tenant_id, name),
    CONSTRAINT chk_uom_type CHECK (uom_type IN ('reference', 'bigger', 'smaller')),
    CONSTRAINT chk_uom_factor CHECK (factor > 0),
    CONSTRAINT chk_uom_rounding CHECK (rounding > 0),
    CONSTRAINT chk_uom_reference_factor CHECK (
        (uom_type = 'reference' AND factor = 1.0) OR
        (uom_type != 'reference')
    )
);

-- Indices
CREATE INDEX idx_uom_tenant ON core_catalogs.uom(tenant_id);
CREATE INDEX idx_uom_category ON core_catalogs.uom(category_id);

-- RLS
ALTER TABLE core_catalogs.uom ENABLE ROW LEVEL SECURITY;

CREATE POLICY uom_tenant_isolation ON core_catalogs.uom
    FOR ALL
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Comentarios
COMMENT ON TABLE core_catalogs.uom IS 'Unidades de medida (Ref: Odoo uom.uom)';
COMMENT ON COLUMN core_catalogs.uom.factor IS 'Factor de conversion vs referencia de la categoria';
COMMENT ON COLUMN core_catalogs.uom.is_protected IS 'Unidades del sistema que no se pueden modificar';
```

---

### 10. categories

Categorias genericas para diferentes entidades.

```sql
CREATE TABLE core_catalogs.categories (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
    parent_id UUID REFERENCES core_catalogs.categories(id),
    entity_type VARCHAR(50) NOT NULL,  -- contact, product, expense, etc.
    name VARCHAR(100) NOT NULL,
    code VARCHAR(50),
    parent_path VARCHAR(255),  -- Materialized path: "1/5/12/"
    sort_order INT NOT NULL DEFAULT 0,
    is_active BOOLEAN NOT NULL DEFAULT true,

    CONSTRAINT uq_categories_code UNIQUE (tenant_id, entity_type, code),
    CONSTRAINT chk_categories_entity CHECK (entity_type IN ('contact', 'product', 'expense', 'document'))
);

-- Indices
CREATE INDEX idx_categories_tenant ON core_catalogs.categories(tenant_id);
CREATE INDEX idx_categories_parent ON core_catalogs.categories(parent_id);
CREATE INDEX idx_categories_type ON core_catalogs.categories(tenant_id, entity_type);
CREATE INDEX idx_categories_path ON core_catalogs.categories(parent_path);

-- Trigger para parent_path
CREATE OR REPLACE FUNCTION core_catalogs.compute_category_parent_path()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.parent_id IS NULL THEN
        NEW.parent_path := NEW.id::text || '/';
    ELSE
        SELECT parent_path || NEW.id::text || '/' INTO NEW.parent_path
        FROM core_catalogs.categories WHERE id = NEW.parent_id;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_categories_parent_path
    BEFORE INSERT OR UPDATE OF parent_id ON core_catalogs.categories
    FOR EACH ROW
    EXECUTE FUNCTION core_catalogs.compute_category_parent_path();

-- RLS
ALTER TABLE core_catalogs.categories ENABLE ROW LEVEL SECURITY;

CREATE POLICY categories_tenant_isolation ON core_catalogs.categories
    FOR ALL
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Comentarios
COMMENT ON TABLE core_catalogs.categories IS 'Categorias jerarquicas para diferentes entidades';
COMMENT ON COLUMN core_catalogs.categories.parent_path IS 'Path materializado para consultas eficientes';
```

---

## Funciones de Utilidad

### Conversion de Moneda

```sql
CREATE OR REPLACE FUNCTION core_catalogs.convert_currency(
    p_amount DECIMAL,
    p_from_currency UUID,
    p_to_currency UUID,
    p_tenant_id UUID,
    p_date DATE DEFAULT CURRENT_DATE
) RETURNS DECIMAL AS $$
DECLARE
    v_from_rate DECIMAL;
    v_to_rate DECIMAL;
BEGIN
    IF p_from_currency = p_to_currency THEN
        RETURN p_amount;
    END IF;

    -- Obtener tasa de origen (ultima disponible hasta la fecha)
    SELECT rate INTO v_from_rate
    FROM core_catalogs.currency_rates
    WHERE tenant_id = p_tenant_id
      AND currency_id = p_from_currency
      AND name <= p_date
    ORDER BY name DESC LIMIT 1;

    -- Obtener tasa de destino
    SELECT rate INTO v_to_rate
    FROM core_catalogs.currency_rates
    WHERE tenant_id = p_tenant_id
      AND currency_id = p_to_currency
      AND name <= p_date
    ORDER BY name DESC LIMIT 1;

    -- Convertir (via moneda base del tenant)
    RETURN p_amount * (COALESCE(v_to_rate, 1) / COALESCE(v_from_rate, 1));
END;
$$ LANGUAGE plpgsql STABLE;
```

### Conversion de UoM

```sql
CREATE OR REPLACE FUNCTION core_catalogs.convert_uom(
    p_quantity DECIMAL,
    p_from_uom UUID,
    p_to_uom UUID
) RETURNS DECIMAL AS $$
DECLARE
    v_from_factor DECIMAL;
    v_to_factor DECIMAL;
    v_from_category UUID;
    v_to_category UUID;
BEGIN
    IF p_from_uom = p_to_uom THEN
        RETURN p_quantity;
    END IF;

    SELECT factor, category_id INTO v_from_factor, v_from_category
    FROM core_catalogs.uom WHERE id = p_from_uom;

    SELECT factor, category_id INTO v_to_factor, v_to_category
    FROM core_catalogs.uom WHERE id = p_to_uom;

    IF v_from_category != v_to_category THEN
        RAISE EXCEPTION 'Cannot convert between different UoM categories';
    END IF;

    RETURN p_quantity * (v_from_factor / v_to_factor);
END;
$$ LANGUAGE plpgsql STABLE;
```

### Obtener Descendientes de Categoria

```sql
CREATE OR REPLACE FUNCTION core_catalogs.get_category_descendants(
    p_category_id UUID,
    p_include_self BOOLEAN DEFAULT true
) RETURNS TABLE (id UUID, name VARCHAR, level INT) AS $$
    SELECT c.id, c.name,
           (LENGTH(c.parent_path) - LENGTH(REPLACE(c.parent_path, '/', ''))) -
           (LENGTH(p.parent_path) - LENGTH(REPLACE(p.parent_path, '/', ''))) AS level
    FROM core_catalogs.categories c
    CROSS JOIN core_catalogs.categories p
    WHERE p.id = p_category_id
      AND c.parent_path LIKE p.parent_path || '%'
      AND (p_include_self OR c.id != p_category_id)
    ORDER BY c.parent_path;
$$ LANGUAGE SQL STABLE;
```

---

## Data Seed

### Paises Principales

```sql
INSERT INTO core_catalogs.countries (name, code, code3, numeric_code, phone_code, vat_label) VALUES
('Mexico', 'MX', 'MEX', 484, 52, 'RFC'),
('United States', 'US', 'USA', 840, 1, 'Tax ID'),
('Spain', 'ES', 'ESP', 724, 34, 'NIF'),
('Colombia', 'CO', 'COL', 170, 57, 'NIT'),
('Argentina', 'AR', 'ARG', 32, 54, 'CUIT'),
('Chile', 'CL', 'CHL', 152, 56, 'RUT'),
('Peru', 'PE', 'PER', 604, 51, 'RUC'),
('Brazil', 'BR', 'BRA', 76, 55, 'CNPJ/CPF'),
('Canada', 'CA', 'CAN', 124, 1, 'BN'),
('United Kingdom', 'GB', 'GBR', 826, 44, 'VAT No');
```

### Monedas Principales

```sql
INSERT INTO core_catalogs.currencies (name, full_name, symbol, decimal_places, position) VALUES
('USD', 'US Dollar', '$', 2, 'before'),
('MXN', 'Mexican Peso', '$', 2, 'before'),
('EUR', 'Euro', '€', 2, 'before'),
('GBP', 'British Pound', '£', 2, 'before'),
('CAD', 'Canadian Dollar', 'C$', 2, 'before'),
('ARS', 'Argentine Peso', 'AR$', 2, 'before'),
('COP', 'Colombian Peso', 'COP$', 0, 'before'),
('CLP', 'Chilean Peso', 'CLP$', 0, 'before'),
('PEN', 'Peruvian Sol', 'S/', 2, 'before'),
('BRL', 'Brazilian Real', 'R$', 2, 'before');
```

### UoM por Defecto (por tenant)

```sql
-- Se ejecuta al crear tenant
CREATE OR REPLACE FUNCTION core_catalogs.create_default_uom(p_tenant_id UUID)
RETURNS void AS $$
DECLARE
    v_cat_unit UUID;
    v_cat_weight UUID;
    v_cat_volume UUID;
    v_cat_length UUID;
    v_cat_time UUID;
BEGIN
    -- Categorias
    INSERT INTO core_catalogs.uom_categories (tenant_id, name) VALUES
    (p_tenant_id, 'Unit') RETURNING id INTO v_cat_unit;
    INSERT INTO core_catalogs.uom_categories (tenant_id, name) VALUES
    (p_tenant_id, 'Weight') RETURNING id INTO v_cat_weight;
    INSERT INTO core_catalogs.uom_categories (tenant_id, name) VALUES
    (p_tenant_id, 'Volume') RETURNING id INTO v_cat_volume;
    INSERT INTO core_catalogs.uom_categories (tenant_id, name) VALUES
    (p_tenant_id, 'Length') RETURNING id INTO v_cat_length;
    INSERT INTO core_catalogs.uom_categories (tenant_id, name) VALUES
    (p_tenant_id, 'Time') RETURNING id INTO v_cat_time;

    -- Unit
    INSERT INTO core_catalogs.uom (tenant_id, category_id, name, uom_type, factor, rounding, is_protected) VALUES
    (p_tenant_id, v_cat_unit, 'pcs', 'reference', 1, 1, true),
    (p_tenant_id, v_cat_unit, 'dozen', 'bigger', 0.0833333, 1, false);

    -- Weight
    INSERT INTO core_catalogs.uom (tenant_id, category_id, name, uom_type, factor, rounding, is_protected) VALUES
    (p_tenant_id, v_cat_weight, 'kg', 'reference', 1, 0.001, true),
    (p_tenant_id, v_cat_weight, 'g', 'smaller', 1000, 1, false),
    (p_tenant_id, v_cat_weight, 'lb', 'bigger', 0.453592, 0.01, false);

    -- Volume
    INSERT INTO core_catalogs.uom (tenant_id, category_id, name, uom_type, factor, rounding, is_protected) VALUES
    (p_tenant_id, v_cat_volume, 'L', 'reference', 1, 0.001, true),
    (p_tenant_id, v_cat_volume, 'mL', 'smaller', 1000, 1, false);

    -- Length
    INSERT INTO core_catalogs.uom (tenant_id, category_id, name, uom_type, factor, rounding, is_protected) VALUES
    (p_tenant_id, v_cat_length, 'm', 'reference', 1, 0.001, true),
    (p_tenant_id, v_cat_length, 'cm', 'smaller', 100, 1, false);

    -- Time
    INSERT INTO core_catalogs.uom (tenant_id, category_id, name, uom_type, factor, rounding, is_protected) VALUES
    (p_tenant_id, v_cat_time, 'hour', 'reference', 1, 0.01, true),
    (p_tenant_id, v_cat_time, 'day', 'bigger', 0.0417, 0.01, false);
END;
$$ LANGUAGE plpgsql;
```

---

## Resumen de Tablas

| Tabla | Scope | Columnas | Descripcion |
|-------|-------|----------|-------------|
| countries | Global | 13 | Paises ISO 3166-1 |
| states | Global | 5 | Estados/Provincias |
| currencies | Global | 11 | Monedas ISO 4217 |
| country_groups | Global | 2 | Grupos regionales |
| country_group_rel | Global | 2 | Relacion paises-grupos |
| currency_rates | Tenant | 6 | Tasas de cambio |
| contacts | Tenant | 31 | Contactos (partners) con WhatsApp |
| contact_tags | Tenant | 6 | Tags de contactos |
| contact_tag_rel | Tenant | 2 | Relacion contactos-tags |
| uom_categories | Tenant | 4 | Categorias UoM |
| uom | Tenant | 9 | Unidades de medida |
| categories | Tenant | 9 | Categorias genericas |

**Total: 12 tablas, ~103 columnas**

---

## Historial

| Version | Fecha | Autor | Cambios |
|---------|-------|-------|---------|
| 1.0 | 2025-12-05 | System | Creacion inicial basada en Odoo |
| 1.1 | 2025-12-05 | System | Campos WhatsApp en contacts: whatsapp_number, whatsapp_opt_in, whatsapp_opt_in_date (MGN-017) |
