Initial commit - erp-mecanicas-diesel-database

This commit is contained in:
rckrdmrd 2026-01-04 06:41:39 -06:00
commit 0207e1788a
16 changed files with 4576 additions and 0 deletions

324
HERENCIA-ERP-CORE.md Normal file
View File

@ -0,0 +1,324 @@
# Referencia de Base de Datos - ERP Mecánicas Diesel
**Fecha:** 2025-12-08
**Versión:** 1.1
**Proyecto:** ERP Mecánicas Diesel
**Nivel:** 2B.2 (Proyecto Independiente)
---
## RESUMEN
ERP Mecánicas Diesel es un **proyecto independiente** que implementa y adapta patrones del ERP-Core para el dominio de talleres de reparación de motores diesel. No es una extensión del core, sino un sistema autónomo que:
1. **Implementa** schemas propios para gestión de órdenes de servicio
2. **Adapta** estructuras de inventario para refacciones especializadas
3. **Reutiliza** patrones de autenticación y multi-tenancy
4. **Opera independientemente** como sistema completo
**DDL de Referencia (Core):** `apps/erp-core/database/ddl/`
**DDL Propio:** `database/init/`
---
## ARQUITECTURA DEL PROYECTO
```
┌─────────────────────────────────────────────────────────────────┐
│ ERP CORE (Referencia) │
│ Patrones, specs y estructuras reutilizables │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ auth │ │ core │ │inventory│ │ sales │ │
│ │ patrones│ │ patrones│ │ patrones│ │ patrones │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ REFERENCIA / FORK
┌─────────────────────────────────────────────────────────────────┐
│ ERP MECÁNICAS DIESEL (Proyecto Independiente) │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ service_ │ │ parts_ │ │ vehicle_ │ │
│ │ management │ │ management │ │ management │ │
│ │ 18 tbl │ │ 12 tbl │ │ 8 tbl │ │
│ │ (órdenes) │ │ (refacciones) │ │ (vehículos) │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ │
│ Schemas propios: 3 | Tablas propias: 38 │
│ Opera de forma INDEPENDIENTE │
└─────────────────────────────────────────────────────────────────┘
```
---
## PATRONES REUTILIZADOS DEL CORE
Los siguientes patrones del ERP-Core fueron **adaptados e implementados** en este proyecto:
| Patrón del Core | Adaptación en Mecánicas Diesel |
|-----------------|-------------------------------|
| `auth.*` | Multi-tenancy con RLS propio |
| `core.partners` | Clientes, flotas de vehículos |
| `inventory.*` | Refacciones, partes OEM, stock |
| `sales.*` | Cotizaciones, órdenes de servicio |
**Nota:** Este proyecto NO depende del ERP-Core para ejecutarse. Implementa sus propios schemas y puede operar de forma completamente standalone.
---
## SCHEMAS ESPECÍFICOS DE MECÁNICAS DIESEL
### 1. Schema `service_management` (10+ tablas)
**Propósito:** Gestión de órdenes de servicio y diagnósticos
```sql
-- Tablas principales:
service_management.service_orders -- Órdenes de trabajo
service_management.order_items -- Líneas (servicios/refacciones)
service_management.work_bays -- Bahías de trabajo
service_management.diagnostics -- Diagnósticos
service_management.diagnostic_items -- Hallazgos
service_management.quotes -- Cotizaciones
service_management.services -- Catálogo de servicios
```
**Relaciones con Core:**
- `service_orders.customer_id` -> `core.partners`
- `service_orders.vehicle_id` -> `vehicle_management.vehicles`
- `order_items.part_id` -> `parts_management.parts`
### 2. Schema `parts_management` (12+ tablas)
**Propósito:** Inventario de refacciones especializado
```sql
-- Extiende: inventory schema del core
-- Adiciona campos específicos:
-- OEM numbers, compatibilidad vehicular, garantías
parts_management.parts -- Refacciones (extiende inventory.products)
parts_management.part_categories -- Categorías
parts_management.suppliers -- Proveedores especializados
parts_management.warehouse_locations -- Ubicaciones en almacén
parts_management.inventory_movements -- Kardex
parts_management.inventory_adjustments -- Ajustes
parts_management.part_compatibility -- Compatibilidad con vehículos
```
**Relaciones con Core:**
- `parts.base_product_id` -> `inventory.products` (herencia)
- `suppliers.partner_id` -> `core.partners`
### 3. Schema `vehicle_management` (8+ tablas)
**Propósito:** Gestión de vehículos diesel y flotas
```sql
vehicle_management.vehicles -- Vehículos registrados
vehicle_management.vehicle_engines -- Especificaciones del motor
vehicle_management.fleets -- Flotas de clientes
vehicle_management.engine_catalog -- Catálogo de motores diesel
vehicle_management.maintenance_reminders -- Recordatorios de servicio
```
**Relaciones con Core:**
- `vehicles.customer_id` -> `core.partners`
- `fleets.customer_id` -> `core.partners`
---
## ORDEN DE EJECUCIÓN DDL
Para recrear la base de datos completa:
```bash
# PASO 1: Cargar ERP Core (base)
cd apps/erp-core/database
./scripts/reset-database.sh --force
# PASO 2: Cargar extensiones de Mecánicas Diesel
cd apps/verticales/mecanicas-diesel/database
psql $DATABASE_URL -f init/00-extensions.sql
psql $DATABASE_URL -f init/01-create-schemas.sql
psql $DATABASE_URL -f init/02-rls-functions.sql
psql $DATABASE_URL -f init/03-service-management-tables.sql
psql $DATABASE_URL -f init/04-parts-management-tables.sql
psql $DATABASE_URL -f init/05-vehicle-management-tables.sql
psql $DATABASE_URL -f init/06-seed-data.sql
```
---
## DEPENDENCIAS CRUZADAS
### Tablas de Mecánicas que dependen del Core
| Tabla Mecánicas | Depende de (Core) |
|-----------------|-------------------|
| `service_management.service_orders` | `core.partners` (customer) |
| `service_management.service_orders` | `auth.users` (assigned_to) |
| `parts_management.parts` | `inventory.products` (herencia) |
| `parts_management.suppliers` | `core.partners` |
| `vehicle_management.vehicles` | `core.partners` (owner) |
| `vehicle_management.fleets` | `core.partners` |
| Todas las tablas | `auth.users` (audit) |
---
## SPECS DEL CORE IMPLEMENTADAS
| Spec Core | Aplicación en Mecánicas | Estado |
|-----------|------------------------|--------|
| SPEC-VALORACION-INVENTARIO | Costeo de refacciones | ✅ DDL LISTO |
| SPEC-TRAZABILIDAD-LOTES-SERIES | Garantías de partes | ✅ DDL LISTO |
| SPEC-INVENTARIOS-CICLICOS | Conteos de refacciones | ✅ DDL LISTO |
| SPEC-MAIL-THREAD-TRACKING | Historial de órdenes | PENDIENTE |
| SPEC-TAREAS-RECURRENTES | Mantenimientos preventivos | PENDIENTE |
### Correcciones de DDL Core (2025-12-08)
El DDL del ERP-Core fue corregido para resolver FK inválidas:
1. **stock_valuation_layers**: Campos `journal_entry_id` y `journal_entry_line_id` (antes `account_move_*`)
2. **stock_move_consume_rel**: Nueva tabla de trazabilidad (antes `move_line_consume_rel`)
3. **category_stock_accounts**: FK corregida a `core.product_categories`
4. **product_categories**: ALTERs ahora apuntan a schema `core`
Estas correcciones permiten que el DDL de inventory se ejecute correctamente y habilitan:
- Valoración FIFO/AVCO de refacciones
- Trazabilidad de lotes y números de serie (garantías)
- Conteos cíclicos de inventario con clasificación ABC
### Validación DDL Mecánicas-Diesel (2025-12-08)
**Estado:** ✅ VÁLIDO - Compatible con ERP-Core
| Archivo | Líneas | Tablas | Estado |
|---------|--------|--------|--------|
| `init/00-extensions.sql` | 14 | 0 | ✅ Válido |
| `init/01-create-schemas.sql` | 30 | 0 | ✅ Válido |
| `init/02-rls-functions.sql` | 106 | 0 | ✅ Válido |
| `init/03-service-management-tables.sql` | 567 | ~18 | ✅ Válido |
| `init/04-parts-management-tables.sql` | 398 | ~12 | ✅ Válido |
| `init/05-vehicle-management-tables.sql` | 365 | ~8 | ✅ Válido |
**Enfoque de FK:** Este proyecto usa **referencias comentadas** en lugar de FK explícitas:
```sql
-- Usa columnas sin FK explícitas:
tenant_id UUID NOT NULL, -- Referencia conceptual: auth.tenants
customer_id UUID NOT NULL, -- Referencia conceptual: core.partners
assigned_to UUID, -- Referencia conceptual: auth.users
```
**Ventajas:**
- ✅ No depende del schema específico de ERP-Core
- ✅ Puede operar standalone o integrado
- ✅ Sin discrepancias con cambios en auth/core
**Desventajas:**
- ⚠️ No hay integridad referencial a nivel de BD
- ⚠️ La integridad debe garantizarse a nivel de aplicación
**Recomendación para integración completa:**
Si se requiere integridad referencial estricta, agregar FK explícitas a `auth.tenants` y `auth.users` (no `core.*`).
---
## MAPEO DE NOMENCLATURA
| Core | Mecánicas Diesel |
|------|------------------|
| `core.partners` | Clientes, Flotas |
| `inventory.products` | Refacciones base |
| `inventory.locations` | Warehouse locations |
| `sales.sale_orders` | Base para cotizaciones |
| `purchase.purchase_orders` | Compras de refacciones |
---
## CATÁLOGO DE MOTORES DIESEL
El schema `vehicle_management` incluye un catálogo de motores diesel preconfigurado:
| Marca | Modelos |
|-------|---------|
| Cummins | ISX15, ISB6.7, X15 |
| Detroit | DD15, DD13 |
| Paccar | MX-13, MX-11 |
| International | A26 |
| Volvo | D13, D11 |
| Navistar | N13 |
---
## VALIDACIÓN DE HERENCIA
### Verificar schemas heredados
```sql
-- Verificar que existen los schemas del core
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name IN ('auth', 'core', 'financial', 'inventory',
'purchase', 'sales', 'analytics', 'system');
```
### Verificar extensiones de mecánicas
```sql
-- Verificar schemas específicos
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name IN ('service_management', 'parts_management', 'vehicle_management');
-- Contar tablas por schema
SELECT schemaname, COUNT(*) as tables
FROM pg_tables
WHERE schemaname LIKE '%_management'
GROUP BY schemaname;
```
---
## SPECS DEL CORE APLICABLES
Según el [MAPEO-SPECS-VERTICALES.md](../../../../erp-core/docs/04-modelado/MAPEO-SPECS-VERTICALES.md):
| Categoría | Total | Obligatorias | Opcionales | No Aplican |
|-----------|-------|--------------|------------|------------|
| **Mecánicas-Diesel** | 30 | 23 | 2 | 5 |
### SPECS Críticas para Mecánicas-Diesel
| SPEC | Aplicación | Estado DDL |
|------|------------|------------|
| SPEC-VALORACION-INVENTARIO | Costeo de refacciones | ✅ DDL LISTO |
| SPEC-TRAZABILIDAD-LOTES-SERIES | Garantías de partes | ✅ DDL LISTO |
| SPEC-INVENTARIOS-CICLICOS | Conteos de refacciones | ✅ DDL LISTO |
| SPEC-PRICING-RULES | Precios por tipo de servicio | PENDIENTE |
| SPEC-MAIL-THREAD-TRACKING | Historial de órdenes | PENDIENTE |
### SPECS No Aplicables
- `SPEC-INTEGRACION-CALENDAR` - No requiere calendario externo
- `SPEC-PROYECTOS-DEPENDENCIAS-BURNDOWN` - No aplica a taller mecánico
- `SPEC-FIRMA-ELECTRONICA-NOM151` - Opcional
- `SPEC-OAUTH2-SOCIAL-LOGIN` - Opcional
- `SPEC-CONSOLIDACION-FINANCIERA` - Opcional
---
## REFERENCIAS
- ERP Core DDL: `apps/erp-core/database/ddl/`
- ERP Core README: `apps/erp-core/database/README.md`
- MAPEO-SPECS-VERTICALES: `apps/erp-core/docs/04-modelado/MAPEO-SPECS-VERTICALES.md`
- DATABASE_INVENTORY.yml: `orchestration/inventarios/`
---
**Documento de herencia oficial**
**Última actualización:** 2025-12-08

173
README.md Normal file
View File

@ -0,0 +1,173 @@
# Base de Datos - Mecánicas Diesel
## Descripción
Base de datos PostgreSQL para el sistema de gestión de talleres de mecánica diesel.
## Schemas
| Schema | Descripción |
|--------|-------------|
| `service_management` | Órdenes de servicio, diagnósticos, cotizaciones |
| `parts_management` | Inventario de refacciones específico del taller |
| `vehicle_management` | Vehículos diesel, flotas, motores |
**NOTA:** Los schemas `auth`, `core`, `inventory` se heredan de erp-core.
## Configuración
### 1. Variables de Entorno
Copiar `.env.example` a `.env` y configurar:
```bash
cp .env.example .env
# Editar valores en .env
```
### 2. Iniciar con Docker
```bash
# Solo base de datos y Redis
docker-compose up -d postgres redis
# Verificar salud
docker-compose ps
# Ver logs
docker-compose logs -f postgres
```
### 3. Ejecutar Scripts Manualmente
Si no usas Docker, ejecutar en orden:
```bash
psql -U mecanicas_user -d mecanicas_diesel -f database/init/00-extensions.sql
psql -U mecanicas_user -d mecanicas_diesel -f database/init/01-create-schemas.sql
psql -U mecanicas_user -d mecanicas_diesel -f database/init/02-rls-functions.sql
psql -U mecanicas_user -d mecanicas_diesel -f database/init/03-service-management-tables.sql
psql -U mecanicas_user -d mecanicas_diesel -f database/init/04-parts-management-tables.sql
psql -U mecanicas_user -d mecanicas_diesel -f database/init/05-vehicle-management-tables.sql
psql -U mecanicas_user -d mecanicas_diesel -f database/init/06-seed-data.sql
```
## Arquitectura Multi-Tenant
### Row-Level Security (RLS)
Todas las tablas principales implementan RLS con políticas completas:
```sql
-- Establecer tenant en sesión
SELECT set_current_tenant_id('uuid-del-tenant');
-- Las queries se filtran automáticamente
SELECT * FROM service_management.service_orders;
-- Solo retorna órdenes del tenant actual
```
### Crear Políticas RLS
Para nuevas tablas:
```sql
-- Automático: crea políticas SELECT, INSERT, UPDATE, DELETE
SELECT create_tenant_rls_policies('schema_name', 'table_name');
```
## Tablas Principales
### service_management
- `service_orders` - Órdenes de servicio
- `order_items` - Líneas de trabajo/refacciones
- `work_bays` - Bahías de trabajo
- `diagnostics` - Diagnósticos realizados
- `diagnostic_items` - Hallazgos del diagnóstico
- `quotes` - Cotizaciones
- `services` - Catálogo de servicios
### parts_management
- `parts` - Refacciones
- `part_categories` - Categorías
- `suppliers` - Proveedores
- `warehouse_locations` - Ubicaciones
- `inventory_movements` - Kardex
- `inventory_adjustments` - Ajustes
### vehicle_management
- `vehicles` - Vehículos registrados
- `vehicle_engines` - Especificaciones del motor
- `fleets` - Flotas
- `engine_catalog` - Catálogo de motores (global)
- `maintenance_reminders` - Recordatorios
## Convenciones
### Nombres
- Tablas: plural en inglés, snake_case
- Columnas: snake_case
- PKs: `id UUID`
- FKs: `entity_id UUID`
- Tenant: `tenant_id UUID`
### Campos Estándar
```sql
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
tenant_id UUID NOT NULL
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
created_by UUID
```
### Soft Delete (opcional)
```sql
deleted_at TIMESTAMP WITH TIME ZONE
deleted_by UUID
```
## Triggers Automáticos
- `trigger_set_updated_at()` - Actualiza `updated_at` en cada UPDATE
- `update_fleet_vehicle_count()` - Mantiene conteo de vehículos en flotas
## Herramientas
### Adminer (UI de BD)
```bash
docker-compose --profile tools up -d adminer
# Acceder en http://localhost:8080
```
### Conexión Directa
```bash
docker-compose exec postgres psql -U mecanicas_user -d mecanicas_diesel
```
## Migraciones
Para cambios futuros, usar el sistema de migraciones de tu ORM (Prisma, TypeORM, etc.).
Ejemplo con Prisma:
```bash
npx prisma migrate dev --name add_new_field
```
## Backup
```bash
# Backup
docker-compose exec postgres pg_dump -U mecanicas_user mecanicas_diesel > backup.sql
# Restore
docker-compose exec -T postgres psql -U mecanicas_user -d mecanicas_diesel < backup.sql
```

14
init/00-extensions.sql Normal file
View File

@ -0,0 +1,14 @@
-- ===========================================
-- MECANICAS DIESEL - Extensiones PostgreSQL
-- ===========================================
-- Ejecutar primero
-- UUID generation
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Full text search en español
CREATE EXTENSION IF NOT EXISTS "unaccent";
-- Para auditoría y triggers
CREATE EXTENSION IF NOT EXISTS "hstore";

View File

@ -0,0 +1,158 @@
-- ===========================================
-- MECANICAS DIESEL - Schema workshop_core
-- ===========================================
-- Autenticación y gestión de usuarios
-- Ejecutar después de 00-extensions.sql
-- Crear schema
CREATE SCHEMA IF NOT EXISTS workshop_core;
COMMENT ON SCHEMA workshop_core IS 'Autenticación, usuarios y gestión de talleres multi-tenant';
-- Grants básicos
GRANT USAGE ON SCHEMA workshop_core TO mecanicas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA workshop_core TO mecanicas_user;
-- Default privileges para tablas futuras
ALTER DEFAULT PRIVILEGES IN SCHEMA workshop_core GRANT ALL ON TABLES TO mecanicas_user;
SET search_path TO workshop_core, public;
-- -------------------------------------------
-- WORKSHOPS - Talleres (tenants)
-- -------------------------------------------
CREATE TABLE workshop_core.workshops (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Información general
name VARCHAR(100) NOT NULL,
-- Datos fiscales
rfc VARCHAR(13),
-- Contacto
email VARCHAR(255),
phone VARCHAR(20),
-- Dirección
address TEXT,
city VARCHAR(100),
state VARCHAR(50),
postal_code VARCHAR(10),
-- Branding
logo_url TEXT,
-- Estado
is_active BOOLEAN DEFAULT true,
-- Audit
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_workshops_is_active ON workshop_core.workshops(is_active);
CREATE INDEX idx_workshops_rfc ON workshop_core.workshops(rfc) WHERE rfc IS NOT NULL;
-- Trigger updated_at
CREATE TRIGGER trg_workshops_updated_at
BEFORE UPDATE ON workshop_core.workshops
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
COMMENT ON TABLE workshop_core.workshops IS 'Talleres mecánicos (tenants del sistema multi-tenant)';
COMMENT ON COLUMN workshop_core.workshops.id IS 'Identificador único del taller (tenant_id)';
COMMENT ON COLUMN workshop_core.workshops.name IS 'Nombre comercial del taller';
COMMENT ON COLUMN workshop_core.workshops.rfc IS 'RFC (Registro Federal de Contribuyentes) para facturación';
COMMENT ON COLUMN workshop_core.workshops.is_active IS 'Indica si el taller está activo y puede operar en el sistema';
-- -------------------------------------------
-- USERS - Usuarios del sistema
-- -------------------------------------------
CREATE TABLE workshop_core.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Multi-tenant
tenant_id UUID NOT NULL REFERENCES workshop_core.workshops(id) ON DELETE CASCADE,
-- Credenciales
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
-- Información personal
full_name VARCHAR(150) NOT NULL,
avatar_url TEXT,
-- Rol y permisos
role VARCHAR(30) NOT NULL DEFAULT 'mecanico'
CHECK (role IN ('admin', 'jefe_taller', 'mecanico', 'recepcion', 'almacen')),
-- Estado
is_active BOOLEAN DEFAULT true,
email_verified BOOLEAN DEFAULT false,
-- Sesión
last_login_at TIMESTAMP WITH TIME ZONE,
-- Audit
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraints
CONSTRAINT uq_user_email_global UNIQUE (email),
CONSTRAINT uq_user_email_tenant UNIQUE (tenant_id, email)
);
-- Índices
CREATE INDEX idx_users_tenant ON workshop_core.users(tenant_id);
CREATE INDEX idx_users_email ON workshop_core.users(email);
CREATE INDEX idx_users_role ON workshop_core.users(tenant_id, role);
CREATE INDEX idx_users_is_active ON workshop_core.users(tenant_id, is_active);
CREATE INDEX idx_users_last_login ON workshop_core.users(last_login_at DESC);
-- Trigger updated_at
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON workshop_core.users
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- RLS
SELECT create_tenant_rls_policies('workshop_core', 'users');
COMMENT ON TABLE workshop_core.users IS 'Usuarios del sistema con autenticación y roles';
COMMENT ON COLUMN workshop_core.users.tenant_id IS 'Taller al que pertenece el usuario';
COMMENT ON COLUMN workshop_core.users.email IS 'Correo electrónico único para login';
COMMENT ON COLUMN workshop_core.users.password_hash IS 'Hash bcrypt de la contraseña';
COMMENT ON COLUMN workshop_core.users.role IS 'Rol del usuario: admin, jefe_taller, mecanico, recepcion, almacen';
COMMENT ON COLUMN workshop_core.users.is_active IS 'Indica si el usuario puede acceder al sistema';
COMMENT ON COLUMN workshop_core.users.email_verified IS 'Indica si el email ha sido verificado';
-- -------------------------------------------
-- REFRESH_TOKENS - Tokens de refresco JWT
-- -------------------------------------------
CREATE TABLE workshop_core.refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Usuario
user_id UUID NOT NULL REFERENCES workshop_core.users(id) ON DELETE CASCADE,
-- Token
token VARCHAR(500) NOT NULL,
-- Validez
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked_at TIMESTAMP WITH TIME ZONE,
-- Audit
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Índices
CREATE INDEX idx_refresh_tokens_user ON workshop_core.refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token ON workshop_core.refresh_tokens(token);
CREATE INDEX idx_refresh_tokens_expires ON workshop_core.refresh_tokens(expires_at);
CREATE INDEX idx_refresh_tokens_active ON workshop_core.refresh_tokens(user_id, expires_at)
WHERE revoked_at IS NULL;
COMMENT ON TABLE workshop_core.refresh_tokens IS 'Tokens de refresco para autenticación JWT';
COMMENT ON COLUMN workshop_core.refresh_tokens.token IS 'Token de refresco único para renovar access tokens';
COMMENT ON COLUMN workshop_core.refresh_tokens.expires_at IS 'Fecha de expiración del token';
COMMENT ON COLUMN workshop_core.refresh_tokens.revoked_at IS 'Fecha de revocación (si el token fue invalidado)';

View File

@ -0,0 +1,29 @@
-- ===========================================
-- MECANICAS DIESEL - Creacion de Schemas
-- ===========================================
-- Ejecutar despues de extensiones
-- Schemas propios de mecanicas-diesel
CREATE SCHEMA IF NOT EXISTS service_management;
COMMENT ON SCHEMA service_management IS 'Órdenes de servicio, diagnósticos, cotizaciones';
CREATE SCHEMA IF NOT EXISTS parts_management;
COMMENT ON SCHEMA parts_management IS 'Inventario de refacciones específico del taller';
CREATE SCHEMA IF NOT EXISTS vehicle_management;
COMMENT ON SCHEMA vehicle_management IS 'Vehículos diesel, flotas, motores';
-- Grants básicos
GRANT USAGE ON SCHEMA service_management TO mecanicas_user;
GRANT USAGE ON SCHEMA parts_management TO mecanicas_user;
GRANT USAGE ON SCHEMA vehicle_management TO mecanicas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA service_management TO mecanicas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA parts_management TO mecanicas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA vehicle_management TO mecanicas_user;
-- Default privileges para tablas futuras
ALTER DEFAULT PRIVILEGES IN SCHEMA service_management GRANT ALL ON TABLES TO mecanicas_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA parts_management GRANT ALL ON TABLES TO mecanicas_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA vehicle_management GRANT ALL ON TABLES TO mecanicas_user;

106
init/02-rls-functions.sql Normal file
View File

@ -0,0 +1,106 @@
-- ===========================================
-- MECANICAS DIESEL - Funciones RLS Multi-Tenant
-- ===========================================
-- Funciones para Row-Level Security
-- Función para obtener el tenant_id actual de la sesión
CREATE OR REPLACE FUNCTION get_current_tenant_id()
RETURNS UUID AS $$
BEGIN
RETURN NULLIF(current_setting('app.current_tenant_id', true), '')::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
COMMENT ON FUNCTION get_current_tenant_id() IS 'Obtiene el tenant_id de la sesión actual para RLS';
-- Función para establecer el tenant_id en la sesión
CREATE OR REPLACE FUNCTION set_current_tenant_id(p_tenant_id UUID)
RETURNS VOID AS $$
BEGIN
PERFORM set_config('app.current_tenant_id', p_tenant_id::TEXT, false);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
COMMENT ON FUNCTION set_current_tenant_id(UUID) IS 'Establece el tenant_id para la sesión actual';
-- Función para obtener el user_id actual
CREATE OR REPLACE FUNCTION get_current_user_id()
RETURNS UUID AS $$
BEGIN
RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
-- Trigger function para updated_at automático
CREATE OR REPLACE FUNCTION trigger_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION trigger_set_updated_at() IS 'Actualiza automáticamente updated_at';
-- Trigger function para created_by automático
CREATE OR REPLACE FUNCTION trigger_set_created_by()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.created_by IS NULL THEN
NEW.created_by = get_current_user_id();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Macro para crear políticas RLS completas
-- Uso: SELECT create_tenant_rls_policies('schema_name', 'table_name');
CREATE OR REPLACE FUNCTION create_tenant_rls_policies(
p_schema TEXT,
p_table TEXT,
p_tenant_column TEXT DEFAULT 'tenant_id'
)
RETURNS VOID AS $$
DECLARE
v_full_table TEXT;
BEGIN
v_full_table := p_schema || '.' || p_table;
-- Habilitar RLS
EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', p_schema, p_table);
-- Política SELECT
EXECUTE format(
'CREATE POLICY %I ON %I.%I FOR SELECT USING (%I = get_current_tenant_id())',
p_table || '_select_policy', p_schema, p_table, p_tenant_column
);
-- Política INSERT
EXECUTE format(
'CREATE POLICY %I ON %I.%I FOR INSERT WITH CHECK (%I = get_current_tenant_id())',
p_table || '_insert_policy', p_schema, p_table, p_tenant_column
);
-- Política UPDATE
EXECUTE format(
'CREATE POLICY %I ON %I.%I FOR UPDATE USING (%I = get_current_tenant_id()) WITH CHECK (%I = get_current_tenant_id())',
p_table || '_update_policy', p_schema, p_table, p_tenant_column, p_tenant_column
);
-- Política DELETE
EXECUTE format(
'CREATE POLICY %I ON %I.%I FOR DELETE USING (%I = get_current_tenant_id())',
p_table || '_delete_policy', p_schema, p_table, p_tenant_column
);
RAISE NOTICE 'RLS policies created for %.%', p_schema, p_table;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION create_tenant_rls_policies(TEXT, TEXT, TEXT) IS 'Crea políticas RLS completas (SELECT, INSERT, UPDATE, DELETE) para una tabla';

View File

@ -0,0 +1,566 @@
-- ===========================================
-- MECANICAS DIESEL - Schema service_management
-- ===========================================
-- Ordenes de servicio, diagnosticos, cotizaciones
SET search_path TO service_management, public;
-- -------------------------------------------
-- SERVICE_ORDERS - Ordenes de servicio
-- -------------------------------------------
CREATE TABLE service_management.service_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL, -- Identificador del taller (multi-tenant)
-- Identificación
order_number VARCHAR(20) NOT NULL,
-- Relaciones (referencias a tablas de otros schemas)
customer_id UUID NOT NULL, -- core.partners
vehicle_id UUID NOT NULL, -- vehicle_management.vehicles
quote_id UUID, -- service_management.quotes
-- Asignación
assigned_to UUID, -- auth.users
bay_id UUID, -- Bahía de trabajo
-- Estado con CHECK constraint
status VARCHAR(30) DEFAULT 'received'
CHECK (status IN ('received', 'diagnosed', 'quoted', 'approved',
'in_progress', 'waiting_parts', 'completed', 'delivered', 'cancelled')),
priority VARCHAR(20) DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
-- Fechas
received_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
promised_at TIMESTAMP WITH TIME ZONE,
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
delivered_at TIMESTAMP WITH TIME ZONE,
-- Kilometraje
odometer_in INTEGER CHECK (odometer_in >= 0),
odometer_out INTEGER CHECK (odometer_out >= 0),
-- Síntomas reportados
customer_symptoms TEXT,
-- Totales
labor_total DECIMAL(12,2) DEFAULT 0 CHECK (labor_total >= 0),
parts_total DECIMAL(12,2) DEFAULT 0 CHECK (parts_total >= 0),
discount_amount DECIMAL(12,2) DEFAULT 0 CHECK (discount_amount >= 0),
discount_percent DECIMAL(5,2) DEFAULT 0 CHECK (discount_percent >= 0 AND discount_percent <= 100),
tax DECIMAL(12,2) DEFAULT 0 CHECK (tax >= 0),
grand_total DECIMAL(12,2) DEFAULT 0 CHECK (grand_total >= 0),
-- Notas
internal_notes TEXT,
customer_notes TEXT,
-- Audit
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_order_number UNIQUE (tenant_id, order_number),
CONSTRAINT chk_odometer CHECK (odometer_out IS NULL OR odometer_out >= odometer_in)
);
-- Índices
CREATE INDEX idx_orders_tenant ON service_management.service_orders(tenant_id);
CREATE INDEX idx_orders_status ON service_management.service_orders(tenant_id, status);
CREATE INDEX idx_orders_vehicle ON service_management.service_orders(vehicle_id);
CREATE INDEX idx_orders_customer ON service_management.service_orders(customer_id);
CREATE INDEX idx_orders_assigned ON service_management.service_orders(assigned_to);
CREATE INDEX idx_orders_received ON service_management.service_orders(received_at DESC);
-- Trigger updated_at
CREATE TRIGGER trg_service_orders_updated_at
BEFORE UPDATE ON service_management.service_orders
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- RLS
SELECT create_tenant_rls_policies('service_management', 'service_orders');
-- -------------------------------------------
-- ORDER_ITEMS - Líneas de trabajo/refacciones
-- -------------------------------------------
CREATE TABLE service_management.order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES service_management.service_orders(id) ON DELETE CASCADE,
-- Tipo
item_type VARCHAR(20) NOT NULL CHECK (item_type IN ('service', 'part')),
-- Referencias opcionales
service_id UUID, -- core.services
part_id UUID, -- parts_management.parts
-- Descripción
description VARCHAR(500) NOT NULL,
-- Cantidades y precios
quantity DECIMAL(10,3) DEFAULT 1 CHECK (quantity > 0),
unit_price DECIMAL(12,2) NOT NULL CHECK (unit_price >= 0),
discount_pct DECIMAL(5,2) DEFAULT 0 CHECK (discount_pct >= 0 AND discount_pct <= 100),
subtotal DECIMAL(12,2) NOT NULL CHECK (subtotal >= 0),
-- Estado
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed')),
-- Para mano de obra
estimated_hours DECIMAL(5,2) CHECK (estimated_hours >= 0),
actual_hours DECIMAL(5,2) CHECK (actual_hours >= 0),
-- Mecánico
performed_by UUID,
completed_at TIMESTAMP WITH TIME ZONE,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_order_items_order ON service_management.order_items(order_id);
CREATE INDEX idx_order_items_type ON service_management.order_items(order_id, item_type);
-- -------------------------------------------
-- ORDER_STATUS_HISTORY - Historial de estados
-- -------------------------------------------
CREATE TABLE service_management.order_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES service_management.service_orders(id) ON DELETE CASCADE,
from_status VARCHAR(30),
to_status VARCHAR(30) NOT NULL,
changed_by UUID,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_status_history_order ON service_management.order_status_history(order_id);
-- -------------------------------------------
-- WORK_BAYS - Bahías de trabajo
-- -------------------------------------------
CREATE TABLE service_management.work_bays (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(50) NOT NULL,
description VARCHAR(200),
bay_type VARCHAR(50) CHECK (bay_type IN ('general', 'diagnostic', 'heavy_duty', 'quick_service')),
-- Estado (current_order_id es NULLABLE para evitar referencia circular)
status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'occupied', 'maintenance')),
current_order_id UUID, -- NULLABLE - se actualiza después
-- Capacidad
max_weight DECIMAL(10,2) CHECK (max_weight > 0),
has_lift BOOLEAN DEFAULT FALSE,
has_pit BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_bay_name UNIQUE (tenant_id, name)
);
CREATE INDEX idx_bays_tenant ON service_management.work_bays(tenant_id);
CREATE INDEX idx_bays_status ON service_management.work_bays(tenant_id, status);
CREATE TRIGGER trg_work_bays_updated_at
BEFORE UPDATE ON service_management.work_bays
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('service_management', 'work_bays');
-- FK de service_orders.bay_id (se agrega después de crear work_bays)
ALTER TABLE service_management.service_orders
ADD CONSTRAINT fk_orders_bay
FOREIGN KEY (bay_id) REFERENCES service_management.work_bays(id);
-- -------------------------------------------
-- DIAGNOSTICS - Diagnósticos realizados
-- -------------------------------------------
CREATE TABLE service_management.diagnostics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
order_id UUID REFERENCES service_management.service_orders(id),
vehicle_id UUID NOT NULL,
diagnostic_type VARCHAR(50) NOT NULL
CHECK (diagnostic_type IN ('scanner', 'injector_test', 'pump_test', 'compression', 'turbo_test', 'other')),
equipment VARCHAR(200),
performed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
performed_by UUID,
result VARCHAR(20) CHECK (result IN ('pass', 'fail', 'needs_attention')),
summary TEXT,
raw_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_diagnostics_tenant ON service_management.diagnostics(tenant_id);
CREATE INDEX idx_diagnostics_vehicle ON service_management.diagnostics(vehicle_id);
CREATE INDEX idx_diagnostics_order ON service_management.diagnostics(order_id);
CREATE INDEX idx_diagnostics_date ON service_management.diagnostics(performed_at DESC);
CREATE TRIGGER trg_diagnostics_updated_at
BEFORE UPDATE ON service_management.diagnostics
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('service_management', 'diagnostics');
-- -------------------------------------------
-- DIAGNOSTIC_ITEMS - Hallazgos del diagnóstico
-- -------------------------------------------
CREATE TABLE service_management.diagnostic_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
diagnostic_id UUID NOT NULL REFERENCES service_management.diagnostics(id) ON DELETE CASCADE,
item_type VARCHAR(50) NOT NULL
CHECK (item_type IN ('dtc_code', 'test_result', 'measurement', 'observation')),
-- Para códigos DTC
code VARCHAR(20),
description VARCHAR(500),
severity VARCHAR(20) CHECK (severity IN ('critical', 'warning', 'info')),
-- Para mediciones
parameter VARCHAR(100),
value DECIMAL(12,4),
unit VARCHAR(20),
min_ref DECIMAL(12,4),
max_ref DECIMAL(12,4),
status VARCHAR(20) CHECK (status IN ('ok', 'warning', 'fail', 'no_reference')),
-- Componente
component VARCHAR(100),
cylinder INTEGER CHECK (cylinder >= 1 AND cylinder <= 12),
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_diag_items_diagnostic ON service_management.diagnostic_items(diagnostic_id);
-- -------------------------------------------
-- DIAGNOSTIC_PHOTOS - Fotos de evidencia
-- -------------------------------------------
CREATE TABLE service_management.diagnostic_photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
diagnostic_id UUID NOT NULL REFERENCES service_management.diagnostics(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
thumbnail_url VARCHAR(500),
description VARCHAR(300),
category VARCHAR(50) CHECK (category IN ('before', 'damage', 'process', 'after', 'other')),
file_size INTEGER,
mime_type VARCHAR(50),
sort_order INTEGER DEFAULT 0,
uploaded_by UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_diag_photos_diagnostic ON service_management.diagnostic_photos(diagnostic_id);
-- -------------------------------------------
-- DIAGNOSTIC_RECOMMENDATIONS - Recomendaciones
-- -------------------------------------------
CREATE TABLE service_management.diagnostic_recommendations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
diagnostic_id UUID NOT NULL REFERENCES service_management.diagnostics(id) ON DELETE CASCADE,
diagnostic_item_id UUID REFERENCES service_management.diagnostic_items(id),
description TEXT NOT NULL,
priority VARCHAR(20) DEFAULT 'medium'
CHECK (priority IN ('critical', 'high', 'medium', 'low')),
urgency VARCHAR(20) DEFAULT 'soon'
CHECK (urgency IN ('immediate', 'soon', 'scheduled', 'preventive')),
suggested_service_id UUID,
estimated_cost DECIMAL(12,2) CHECK (estimated_cost >= 0),
status VARCHAR(20) DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'declined', 'completed')),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_recommendations_diagnostic ON service_management.diagnostic_recommendations(diagnostic_id);
-- -------------------------------------------
-- QUOTES - Cotizaciones
-- -------------------------------------------
CREATE TABLE service_management.quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
quote_number VARCHAR(20) NOT NULL,
customer_id UUID NOT NULL,
vehicle_id UUID NOT NULL,
diagnostic_id UUID REFERENCES service_management.diagnostics(id),
status VARCHAR(20) DEFAULT 'draft'
CHECK (status IN ('draft', 'sent', 'viewed', 'approved', 'rejected', 'expired', 'converted')),
-- Fechas
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
sent_at TIMESTAMP WITH TIME ZONE,
viewed_at TIMESTAMP WITH TIME ZONE,
responded_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE,
-- Totales
labor_total DECIMAL(12,2) DEFAULT 0 CHECK (labor_total >= 0),
parts_total DECIMAL(12,2) DEFAULT 0 CHECK (parts_total >= 0),
discount_amount DECIMAL(12,2) DEFAULT 0 CHECK (discount_amount >= 0),
discount_percent DECIMAL(5,2) DEFAULT 0 CHECK (discount_percent >= 0 AND discount_percent <= 100),
discount_reason VARCHAR(200),
tax DECIMAL(12,2) DEFAULT 0 CHECK (tax >= 0),
grand_total DECIMAL(12,2) DEFAULT 0 CHECK (grand_total >= 0),
validity_days INTEGER DEFAULT 15 CHECK (validity_days > 0),
terms TEXT,
notes TEXT,
-- Conversión a orden
converted_order_id UUID REFERENCES service_management.service_orders(id),
-- Aprobación digital
approved_by_name VARCHAR(200),
approval_signature TEXT,
approval_ip INET,
created_by UUID,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_quote_number UNIQUE (tenant_id, quote_number)
);
CREATE INDEX idx_quotes_tenant ON service_management.quotes(tenant_id);
CREATE INDEX idx_quotes_status ON service_management.quotes(tenant_id, status);
CREATE INDEX idx_quotes_customer ON service_management.quotes(customer_id);
CREATE INDEX idx_quotes_created ON service_management.quotes(created_at DESC);
CREATE TRIGGER trg_quotes_updated_at
BEFORE UPDATE ON service_management.quotes
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('service_management', 'quotes');
-- -------------------------------------------
-- QUOTE_ITEMS - Líneas de cotización
-- -------------------------------------------
CREATE TABLE service_management.quote_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quote_id UUID NOT NULL REFERENCES service_management.quotes(id) ON DELETE CASCADE,
item_type VARCHAR(20) NOT NULL CHECK (item_type IN ('service', 'part')),
service_id UUID,
part_id UUID,
description VARCHAR(500) NOT NULL,
quantity DECIMAL(10,3) DEFAULT 1 CHECK (quantity > 0),
unit_price DECIMAL(12,2) NOT NULL CHECK (unit_price >= 0),
discount_pct DECIMAL(5,2) DEFAULT 0 CHECK (discount_pct >= 0 AND discount_pct <= 100),
subtotal DECIMAL(12,2) NOT NULL CHECK (subtotal >= 0),
is_approved BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_quote_items_quote ON service_management.quote_items(quote_id);
-- -------------------------------------------
-- QUOTE_TRACKING - Tracking de cotizaciones
-- -------------------------------------------
CREATE TABLE service_management.quote_tracking (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quote_id UUID NOT NULL REFERENCES service_management.quotes(id) ON DELETE CASCADE,
event_type VARCHAR(30) NOT NULL
CHECK (event_type IN ('sent_email', 'sent_whatsapp', 'opened', 'link_clicked', 'approved', 'rejected')),
channel VARCHAR(20) CHECK (channel IN ('email', 'whatsapp', 'link')),
ip_address INET,
user_agent TEXT,
device_type VARCHAR(20),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_quote_tracking_quote ON service_management.quote_tracking(quote_id);
-- -------------------------------------------
-- QUOTE_FOLLOWUPS - Seguimiento de cotizaciones
-- -------------------------------------------
CREATE TABLE service_management.quote_followups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quote_id UUID NOT NULL REFERENCES service_management.quotes(id) ON DELETE CASCADE,
action VARCHAR(100) NOT NULL,
notes TEXT,
next_action VARCHAR(100),
next_action_at TIMESTAMP WITH TIME ZONE,
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_quote_followups_quote ON service_management.quote_followups(quote_id);
-- -------------------------------------------
-- TEST_TYPES - Tipos de prueba configurables
-- -------------------------------------------
CREATE TABLE service_management.test_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID, -- NULL = global
name VARCHAR(100) NOT NULL,
description TEXT,
component_type VARCHAR(50) CHECK (component_type IN ('injector', 'pump', 'turbo', 'engine', 'other')),
is_system BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_test_types_tenant ON service_management.test_types(tenant_id);
CREATE TRIGGER trg_test_types_updated_at
BEFORE UPDATE ON service_management.test_types
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- -------------------------------------------
-- TEST_PARAMETERS - Parámetros de prueba
-- -------------------------------------------
CREATE TABLE service_management.test_parameters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
test_type_id UUID NOT NULL REFERENCES service_management.test_types(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
unit VARCHAR(20),
data_type VARCHAR(20) DEFAULT 'numeric' CHECK (data_type IN ('numeric', 'boolean', 'text')),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_test_params_type ON service_management.test_parameters(test_type_id);
-- -------------------------------------------
-- PARAMETER_REFERENCES - Valores de referencia por motor
-- -------------------------------------------
CREATE TABLE service_management.parameter_references (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parameter_id UUID NOT NULL REFERENCES service_management.test_parameters(id) ON DELETE CASCADE,
engine_model_id UUID, -- vehicle_management.engine_catalog
min_value DECIMAL(12,4),
max_value DECIMAL(12,4),
nominal_value DECIMAL(12,4),
tolerance_pct DECIMAL(5,2) CHECK (tolerance_pct >= 0),
source VARCHAR(200),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_param_engine UNIQUE (parameter_id, engine_model_id)
);
CREATE INDEX idx_param_refs_param ON service_management.parameter_references(parameter_id);
CREATE INDEX idx_param_refs_engine ON service_management.parameter_references(engine_model_id);
-- -------------------------------------------
-- SERVICE_CATEGORIES - Categorías de servicios
-- -------------------------------------------
CREATE TABLE service_management.service_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(300),
color VARCHAR(7),
icon VARCHAR(50),
parent_id UUID REFERENCES service_management.service_categories(id),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_svc_categories_tenant ON service_management.service_categories(tenant_id);
CREATE INDEX idx_svc_categories_parent ON service_management.service_categories(parent_id);
CREATE TRIGGER trg_service_categories_updated_at
BEFORE UPDATE ON service_management.service_categories
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('service_management', 'service_categories');
-- -------------------------------------------
-- SERVICES - Catálogo de servicios
-- -------------------------------------------
CREATE TABLE service_management.services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
category_id UUID REFERENCES service_management.service_categories(id),
price DECIMAL(12,2) NOT NULL CHECK (price >= 0),
cost DECIMAL(12,2) CHECK (cost >= 0),
estimated_hours DECIMAL(5,2) CHECK (estimated_hours >= 0),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_service_code UNIQUE (tenant_id, code)
);
CREATE INDEX idx_services_tenant ON service_management.services(tenant_id);
CREATE INDEX idx_services_category ON service_management.services(category_id);
CREATE INDEX idx_services_code ON service_management.services(code);
CREATE TRIGGER trg_services_updated_at
BEFORE UPDATE ON service_management.services
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('service_management', 'services');

View File

@ -0,0 +1,91 @@
-- ===========================================
-- MECANICAS DIESEL - Customers Table
-- ===========================================
-- Tabla de clientes para el taller
-- Nota: Esta tabla se crea en service_management para mantener
-- consistencia con las referencias de service_orders
SET search_path TO service_management, public;
-- -------------------------------------------
-- CUSTOMERS - Clientes del taller
-- -------------------------------------------
CREATE TABLE IF NOT EXISTS service_management.customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL, -- Identificador del taller (multi-tenant)
-- Tipo de cliente
customer_type VARCHAR(20) DEFAULT 'individual'
CHECK (customer_type IN ('individual', 'company', 'fleet')),
-- Identificación
name VARCHAR(200) NOT NULL,
legal_name VARCHAR(300),
rfc VARCHAR(13),
-- Contacto
email VARCHAR(200),
phone VARCHAR(20),
phone_secondary VARCHAR(20),
-- Dirección
address TEXT,
city VARCHAR(100),
state VARCHAR(100),
postal_code VARCHAR(10),
-- Términos comerciales (para empresas/flotas)
credit_days INTEGER DEFAULT 0 CHECK (credit_days >= 0),
credit_limit DECIMAL(12,2) DEFAULT 0 CHECK (credit_limit >= 0),
discount_labor_pct DECIMAL(5,2) DEFAULT 0 CHECK (discount_labor_pct >= 0 AND discount_labor_pct <= 100),
discount_parts_pct DECIMAL(5,2) DEFAULT 0 CHECK (discount_parts_pct >= 0 AND discount_parts_pct <= 100),
-- Estadísticas
total_orders INTEGER DEFAULT 0 CHECK (total_orders >= 0),
total_spent DECIMAL(14,2) DEFAULT 0 CHECK (total_spent >= 0),
last_visit_at TIMESTAMP WITH TIME ZONE,
-- Notas y preferencias
notes TEXT,
preferred_contact VARCHAR(20) DEFAULT 'phone'
CHECK (preferred_contact IN ('phone', 'email', 'whatsapp')),
-- Estado
is_active BOOLEAN DEFAULT true,
-- Audit
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Constraints
CONSTRAINT uq_customer_email UNIQUE (tenant_id, email),
CONSTRAINT uq_customer_rfc UNIQUE (tenant_id, rfc)
);
-- Índices
CREATE INDEX idx_customers_tenant ON service_management.customers(tenant_id);
CREATE INDEX idx_customers_email ON service_management.customers(tenant_id, email);
CREATE INDEX idx_customers_phone ON service_management.customers(phone);
CREATE INDEX idx_customers_rfc ON service_management.customers(rfc);
CREATE INDEX idx_customers_type ON service_management.customers(tenant_id, customer_type);
CREATE INDEX idx_customers_name ON service_management.customers(tenant_id, name);
CREATE INDEX idx_customers_active ON service_management.customers(tenant_id, is_active);
-- Trigger updated_at
CREATE TRIGGER trg_customers_updated_at
BEFORE UPDATE ON service_management.customers
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- RLS
SELECT create_tenant_rls_policies('service_management', 'customers');
-- Comentarios
COMMENT ON TABLE service_management.customers IS 'Clientes del taller mecánico';
COMMENT ON COLUMN service_management.customers.customer_type IS 'Tipo: individual (persona física), company (empresa), fleet (flota)';
COMMENT ON COLUMN service_management.customers.credit_days IS 'Días de crédito otorgados al cliente';
COMMENT ON COLUMN service_management.customers.credit_limit IS 'Límite de crédito en pesos';
COMMENT ON COLUMN service_management.customers.discount_labor_pct IS 'Descuento en mano de obra (%)';
COMMENT ON COLUMN service_management.customers.discount_parts_pct IS 'Descuento en refacciones (%)';
COMMENT ON COLUMN service_management.customers.total_orders IS 'Total de órdenes de servicio acumuladas';
COMMENT ON COLUMN service_management.customers.total_spent IS 'Total gastado por el cliente';

View File

@ -0,0 +1,397 @@
-- ===========================================
-- MECANICAS DIESEL - Schema parts_management
-- ===========================================
-- Inventario de refacciones especifico del taller
SET search_path TO parts_management, public;
-- -------------------------------------------
-- PART_CATEGORIES - Categorías de refacciones
-- -------------------------------------------
CREATE TABLE parts_management.part_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(300),
parent_id UUID REFERENCES parts_management.part_categories(id),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_part_categories_tenant ON parts_management.part_categories(tenant_id);
CREATE INDEX idx_part_categories_parent ON parts_management.part_categories(parent_id);
CREATE TRIGGER trg_part_categories_updated_at
BEFORE UPDATE ON parts_management.part_categories
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('parts_management', 'part_categories');
-- -------------------------------------------
-- SUPPLIERS - Proveedores
-- -------------------------------------------
CREATE TABLE parts_management.suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
legal_name VARCHAR(300),
rfc VARCHAR(13),
contact_name VARCHAR(200),
email VARCHAR(200),
phone VARCHAR(20),
address TEXT,
credit_days INTEGER DEFAULT 0 CHECK (credit_days >= 0),
discount_pct DECIMAL(5,2) DEFAULT 0 CHECK (discount_pct >= 0 AND discount_pct <= 100),
rating DECIMAL(3,2) CHECK (rating >= 0 AND rating <= 5),
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_suppliers_tenant ON parts_management.suppliers(tenant_id);
CREATE INDEX idx_suppliers_name ON parts_management.suppliers(name);
CREATE TRIGGER trg_suppliers_updated_at
BEFORE UPDATE ON parts_management.suppliers
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('parts_management', 'suppliers');
-- -------------------------------------------
-- WAREHOUSE_LOCATIONS - Ubicaciones de almacén
-- -------------------------------------------
CREATE TABLE parts_management.warehouse_locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
code VARCHAR(20) NOT NULL,
name VARCHAR(100),
description VARCHAR(200),
zone VARCHAR(10),
aisle VARCHAR(10),
level VARCHAR(10),
max_weight DECIMAL(10,2) CHECK (max_weight > 0),
max_volume DECIMAL(10,2) CHECK (max_volume > 0),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_location_code UNIQUE (tenant_id, code)
);
CREATE INDEX idx_locations_tenant ON parts_management.warehouse_locations(tenant_id);
CREATE INDEX idx_locations_zone ON parts_management.warehouse_locations(zone);
CREATE TRIGGER trg_warehouse_locations_updated_at
BEFORE UPDATE ON parts_management.warehouse_locations
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('parts_management', 'warehouse_locations');
-- -------------------------------------------
-- PARTS - Refacciones del taller
-- -------------------------------------------
CREATE TABLE parts_management.parts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
sku VARCHAR(50) NOT NULL,
name VARCHAR(300) NOT NULL,
description TEXT,
category_id UUID REFERENCES parts_management.part_categories(id),
brand VARCHAR(100),
manufacturer VARCHAR(100),
compatible_engines TEXT[],
cost DECIMAL(12,2) CHECK (cost >= 0),
price DECIMAL(12,2) NOT NULL CHECK (price >= 0),
-- Inventario
current_stock DECIMAL(10,3) DEFAULT 0 CHECK (current_stock >= 0),
reserved_stock DECIMAL(10,3) DEFAULT 0 CHECK (reserved_stock >= 0),
min_stock DECIMAL(10,3) DEFAULT 0 CHECK (min_stock >= 0),
max_stock DECIMAL(10,3) CHECK (max_stock > 0),
reorder_point DECIMAL(10,3) CHECK (reorder_point >= 0),
location_id UUID REFERENCES parts_management.warehouse_locations(id),
unit VARCHAR(20) DEFAULT 'pza',
barcode VARCHAR(50),
preferred_supplier_id UUID REFERENCES parts_management.suppliers(id),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_part_sku UNIQUE (tenant_id, sku),
CONSTRAINT chk_min_max_stock CHECK (max_stock IS NULL OR max_stock >= min_stock)
);
CREATE INDEX idx_parts_tenant ON parts_management.parts(tenant_id);
CREATE INDEX idx_parts_sku ON parts_management.parts(sku);
CREATE INDEX idx_parts_barcode ON parts_management.parts(barcode);
CREATE INDEX idx_parts_category ON parts_management.parts(category_id);
CREATE INDEX idx_parts_supplier ON parts_management.parts(preferred_supplier_id);
CREATE INDEX idx_parts_location ON parts_management.parts(location_id);
CREATE TRIGGER trg_parts_updated_at
BEFORE UPDATE ON parts_management.parts
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('parts_management', 'parts');
-- -------------------------------------------
-- PART_ALTERNATES - Códigos alternos
-- -------------------------------------------
CREATE TABLE parts_management.part_alternates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part_id UUID NOT NULL REFERENCES parts_management.parts(id) ON DELETE CASCADE,
alternate_code VARCHAR(50) NOT NULL,
manufacturer VARCHAR(100),
is_preferred BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_alternates_code ON parts_management.part_alternates(alternate_code);
CREATE INDEX idx_alternates_part ON parts_management.part_alternates(part_id);
-- -------------------------------------------
-- PART_LOCATIONS - Multi-ubicación por refacción
-- -------------------------------------------
CREATE TABLE parts_management.part_locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part_id UUID NOT NULL REFERENCES parts_management.parts(id) ON DELETE CASCADE,
location_id UUID NOT NULL REFERENCES parts_management.warehouse_locations(id),
quantity DECIMAL(10,3) DEFAULT 0 CHECK (quantity >= 0),
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_part_location UNIQUE (part_id, location_id)
);
CREATE INDEX idx_part_locations_part ON parts_management.part_locations(part_id);
CREATE INDEX idx_part_locations_location ON parts_management.part_locations(location_id);
-- -------------------------------------------
-- INVENTORY_MOVEMENTS - Kardex
-- -------------------------------------------
CREATE TABLE parts_management.inventory_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
part_id UUID NOT NULL REFERENCES parts_management.parts(id),
movement_type VARCHAR(30) NOT NULL
CHECK (movement_type IN ('purchase', 'consumption', 'adjustment_in', 'adjustment_out', 'return', 'transfer')),
reference_type VARCHAR(30) CHECK (reference_type IN ('service_order', 'purchase_order', 'adjustment', 'return')),
reference_id UUID,
reference_number VARCHAR(50),
quantity DECIMAL(10,3) NOT NULL,
previous_stock DECIMAL(10,3) NOT NULL,
new_stock DECIMAL(10,3) NOT NULL,
unit_cost DECIMAL(12,2) CHECK (unit_cost >= 0),
total_cost DECIMAL(12,2) CHECK (total_cost >= 0),
location_id UUID REFERENCES parts_management.warehouse_locations(id),
notes TEXT,
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_movements_tenant ON parts_management.inventory_movements(tenant_id);
CREATE INDEX idx_movements_part ON parts_management.inventory_movements(part_id);
CREATE INDEX idx_movements_date ON parts_management.inventory_movements(created_at DESC);
CREATE INDEX idx_movements_reference ON parts_management.inventory_movements(reference_type, reference_id);
SELECT create_tenant_rls_policies('parts_management', 'inventory_movements');
-- -------------------------------------------
-- INVENTORY_ADJUSTMENTS - Ajustes de inventario
-- -------------------------------------------
CREATE TABLE parts_management.inventory_adjustments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
adjustment_number VARCHAR(20) NOT NULL,
adjustment_type VARCHAR(30) NOT NULL
CHECK (adjustment_type IN ('count', 'damage', 'expiry', 'correction', 'other')),
status VARCHAR(20) DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'applied', 'rejected')),
total_items INTEGER DEFAULT 0,
total_value DECIMAL(12,2) DEFAULT 0,
reason TEXT NOT NULL,
notes TEXT,
approved_by UUID,
approved_at TIMESTAMP WITH TIME ZONE,
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_adjustment_number UNIQUE (tenant_id, adjustment_number)
);
CREATE INDEX idx_adjustments_tenant ON parts_management.inventory_adjustments(tenant_id);
CREATE INDEX idx_adjustments_status ON parts_management.inventory_adjustments(status);
CREATE TRIGGER trg_adjustments_updated_at
BEFORE UPDATE ON parts_management.inventory_adjustments
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('parts_management', 'inventory_adjustments');
-- -------------------------------------------
-- ADJUSTMENT_ITEMS - Ítems del ajuste
-- -------------------------------------------
CREATE TABLE parts_management.adjustment_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
adjustment_id UUID NOT NULL REFERENCES parts_management.inventory_adjustments(id) ON DELETE CASCADE,
part_id UUID NOT NULL REFERENCES parts_management.parts(id),
system_qty DECIMAL(10,3) NOT NULL,
physical_qty DECIMAL(10,3) NOT NULL,
difference DECIMAL(10,3) NOT NULL,
unit_cost DECIMAL(12,2) CHECK (unit_cost >= 0),
value_impact DECIMAL(12,2),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_adjustment_items_adjustment ON parts_management.adjustment_items(adjustment_id);
CREATE INDEX idx_adjustment_items_part ON parts_management.adjustment_items(part_id);
-- -------------------------------------------
-- STOCK_ALERTS - Alertas de stock
-- -------------------------------------------
CREATE TABLE parts_management.stock_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
part_id UUID NOT NULL REFERENCES parts_management.parts(id),
alert_type VARCHAR(30) NOT NULL
CHECK (alert_type IN ('low_stock', 'out_of_stock', 'overstock')),
current_stock DECIMAL(10,3),
threshold DECIMAL(10,3),
status VARCHAR(20) DEFAULT 'active'
CHECK (status IN ('active', 'acknowledged', 'resolved')),
acknowledged_by UUID,
acknowledged_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_alerts_tenant ON parts_management.stock_alerts(tenant_id);
CREATE INDEX idx_alerts_status ON parts_management.stock_alerts(status);
CREATE INDEX idx_alerts_part ON parts_management.stock_alerts(part_id);
SELECT create_tenant_rls_policies('parts_management', 'stock_alerts');
-- -------------------------------------------
-- PHYSICAL_INVENTORY - Sesiones de inventario físico
-- -------------------------------------------
CREATE TABLE parts_management.physical_inventory (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
inventory_number VARCHAR(20) NOT NULL,
inventory_type VARCHAR(30) NOT NULL
CHECK (inventory_type IN ('full', 'zone', 'category', 'cyclic')),
status VARCHAR(20) DEFAULT 'in_progress'
CHECK (status IN ('in_progress', 'completed', 'cancelled')),
zones TEXT[],
categories UUID[],
total_items INTEGER DEFAULT 0,
counted_items INTEGER DEFAULT 0,
with_difference INTEGER DEFAULT 0,
started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE,
adjustment_id UUID REFERENCES parts_management.inventory_adjustments(id),
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_inventory_number UNIQUE (tenant_id, inventory_number)
);
CREATE INDEX idx_physical_inv_tenant ON parts_management.physical_inventory(tenant_id);
CREATE INDEX idx_physical_inv_status ON parts_management.physical_inventory(status);
SELECT create_tenant_rls_policies('parts_management', 'physical_inventory');
-- -------------------------------------------
-- INVENTORY_COUNTS - Conteos del inventario físico
-- -------------------------------------------
CREATE TABLE parts_management.inventory_counts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id UUID NOT NULL REFERENCES parts_management.physical_inventory(id) ON DELETE CASCADE,
part_id UUID NOT NULL REFERENCES parts_management.parts(id),
location_id UUID REFERENCES parts_management.warehouse_locations(id),
system_qty DECIMAL(10,3) NOT NULL,
counted_qty DECIMAL(10,3),
difference DECIMAL(10,3),
counted_by UUID,
counted_at TIMESTAMP WITH TIME ZONE,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_count_part_location UNIQUE (inventory_id, part_id, location_id)
);
CREATE INDEX idx_inventory_counts_inventory ON parts_management.inventory_counts(inventory_id);
CREATE INDEX idx_inventory_counts_part ON parts_management.inventory_counts(part_id);

View File

@ -0,0 +1,365 @@
-- ===========================================
-- MECANICAS DIESEL - Schema vehicle_management
-- ===========================================
-- Vehículos diesel, flotas, motores
SET search_path TO vehicle_management, public;
-- -------------------------------------------
-- ENGINE_CATALOG - Catálogo de motores (global)
-- -------------------------------------------
CREATE TABLE vehicle_management.engine_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
make VARCHAR(50) NOT NULL,
model VARCHAR(50) NOT NULL,
cylinders INTEGER CHECK (cylinders > 0),
displacement DECIMAL(5,2) CHECK (displacement > 0),
fuel_type VARCHAR(20) DEFAULT 'diesel',
horsepower_min INTEGER CHECK (horsepower_min > 0),
horsepower_max INTEGER CHECK (horsepower_max > 0),
torque_max INTEGER CHECK (torque_max > 0),
injection_system VARCHAR(50),
year_start INTEGER CHECK (year_start >= 1950),
year_end INTEGER CHECK (year_end >= 1950),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_engine_model UNIQUE (make, model),
CONSTRAINT chk_horsepower CHECK (horsepower_max >= horsepower_min),
CONSTRAINT chk_years CHECK (year_end IS NULL OR year_end >= year_start)
);
-- Datos iniciales de motores comunes
INSERT INTO vehicle_management.engine_catalog (make, model, cylinders, displacement, horsepower_min, horsepower_max, torque_max, injection_system) VALUES
('Cummins', 'ISX15', 6, 14.9, 400, 600, 2050, 'common_rail'),
('Cummins', 'ISL9', 6, 8.9, 260, 380, 1250, 'common_rail'),
('Cummins', 'X15', 6, 14.9, 400, 605, 2050, 'common_rail'),
('Cummins', 'ISB6.7', 6, 6.7, 200, 325, 750, 'common_rail'),
('Detroit', 'DD15', 6, 14.8, 400, 505, 1850, 'common_rail'),
('Detroit', 'DD13', 6, 12.8, 350, 470, 1650, 'common_rail'),
('Paccar', 'MX-13', 6, 12.9, 380, 510, 1850, 'common_rail'),
('Paccar', 'MX-11', 6, 10.8, 355, 430, 1550, 'common_rail'),
('Navistar', 'MaxxForce 13', 6, 12.4, 410, 475, 1700, 'common_rail'),
('Volvo', 'D13', 6, 12.8, 375, 500, 1850, 'unit_injector'),
('Caterpillar', 'C15', 6, 15.2, 435, 625, 2050, 'unit_injector'),
('Caterpillar', 'C13', 6, 12.5, 380, 520, 1750, 'unit_injector');
-- -------------------------------------------
-- FLEETS - Flotas de vehículos
-- -------------------------------------------
CREATE TABLE vehicle_management.fleets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
code VARCHAR(20),
contact_name VARCHAR(200),
contact_email VARCHAR(200),
contact_phone VARCHAR(20),
-- Condiciones comerciales
discount_labor_pct DECIMAL(5,2) DEFAULT 0 CHECK (discount_labor_pct >= 0 AND discount_labor_pct <= 100),
discount_parts_pct DECIMAL(5,2) DEFAULT 0 CHECK (discount_parts_pct >= 0 AND discount_parts_pct <= 100),
credit_days INTEGER DEFAULT 0 CHECK (credit_days >= 0),
credit_limit DECIMAL(12,2) DEFAULT 0 CHECK (credit_limit >= 0),
vehicle_count INTEGER DEFAULT 0 CHECK (vehicle_count >= 0),
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_fleets_tenant ON vehicle_management.fleets(tenant_id);
CREATE INDEX idx_fleets_name ON vehicle_management.fleets(name);
CREATE TRIGGER trg_fleets_updated_at
BEFORE UPDATE ON vehicle_management.fleets
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('vehicle_management', 'fleets');
-- -------------------------------------------
-- VEHICLES - Vehículos registrados
-- -------------------------------------------
CREATE TABLE vehicle_management.vehicles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
customer_id UUID NOT NULL, -- core.partners
fleet_id UUID REFERENCES vehicle_management.fleets(id),
vin VARCHAR(17),
license_plate VARCHAR(15) NOT NULL,
economic_number VARCHAR(20),
make VARCHAR(50) NOT NULL,
model VARCHAR(100) NOT NULL,
year INTEGER NOT NULL CHECK (year >= 1950 AND year <= 2100),
color VARCHAR(30),
vehicle_type VARCHAR(30) DEFAULT 'truck'
CHECK (vehicle_type IN ('truck', 'trailer', 'bus', 'pickup', 'other')),
current_odometer INTEGER CHECK (current_odometer >= 0),
odometer_updated_at TIMESTAMP WITH TIME ZONE,
photo_url VARCHAR(500),
status VARCHAR(20) DEFAULT 'active'
CHECK (status IN ('active', 'inactive', 'sold')),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_vehicle_plate UNIQUE (tenant_id, license_plate)
);
CREATE INDEX idx_vehicles_tenant ON vehicle_management.vehicles(tenant_id);
CREATE INDEX idx_vehicles_customer ON vehicle_management.vehicles(customer_id);
CREATE INDEX idx_vehicles_fleet ON vehicle_management.vehicles(fleet_id);
CREATE INDEX idx_vehicles_vin ON vehicle_management.vehicles(vin);
CREATE INDEX idx_vehicles_plate ON vehicle_management.vehicles(license_plate);
CREATE TRIGGER trg_vehicles_updated_at
BEFORE UPDATE ON vehicle_management.vehicles
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('vehicle_management', 'vehicles');
-- -------------------------------------------
-- VEHICLE_ENGINES - Especificaciones del motor
-- -------------------------------------------
CREATE TABLE vehicle_management.vehicle_engines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL REFERENCES vehicle_management.vehicles(id) ON DELETE CASCADE,
engine_catalog_id UUID REFERENCES vehicle_management.engine_catalog(id),
serial_number VARCHAR(50),
horsepower INTEGER CHECK (horsepower > 0),
torque INTEGER CHECK (torque > 0),
ecm_model VARCHAR(50),
ecm_software VARCHAR(50),
injection_system VARCHAR(50),
rail_pressure_max DECIMAL(10,2) CHECK (rail_pressure_max > 0),
injector_count INTEGER CHECK (injector_count > 0),
turbo_type VARCHAR(50) CHECK (turbo_type IN ('VGT', 'wastegate', 'twin', 'compound')),
turbo_make VARCHAR(50),
turbo_model VARCHAR(50),
manufacture_date DATE,
rebuild_date DATE,
rebuild_odometer INTEGER CHECK (rebuild_odometer >= 0),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_vehicle_engines_vehicle ON vehicle_management.vehicle_engines(vehicle_id);
CREATE INDEX idx_vehicle_engines_serial ON vehicle_management.vehicle_engines(serial_number);
CREATE INDEX idx_vehicle_engines_catalog ON vehicle_management.vehicle_engines(engine_catalog_id);
CREATE TRIGGER trg_vehicle_engines_updated_at
BEFORE UPDATE ON vehicle_management.vehicle_engines
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- -------------------------------------------
-- VEHICLE_HISTORY - Historial de cambios
-- -------------------------------------------
CREATE TABLE vehicle_management.vehicle_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL REFERENCES vehicle_management.vehicles(id) ON DELETE CASCADE,
field_name VARCHAR(50) NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by UUID,
changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_vehicle_history_vehicle ON vehicle_management.vehicle_history(vehicle_id);
CREATE INDEX idx_vehicle_history_date ON vehicle_management.vehicle_history(changed_at DESC);
-- -------------------------------------------
-- MAINTENANCE_REMINDERS - Recordatorios
-- -------------------------------------------
CREATE TABLE vehicle_management.maintenance_reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
vehicle_id UUID NOT NULL REFERENCES vehicle_management.vehicles(id) ON DELETE CASCADE,
service_type VARCHAR(100) NOT NULL,
service_id UUID,
frequency_type VARCHAR(20) NOT NULL CHECK (frequency_type IN ('time', 'odometer', 'both')),
interval_days INTEGER CHECK (interval_days > 0),
interval_km INTEGER CHECK (interval_km > 0),
last_service_date DATE,
last_service_km INTEGER,
next_due_date DATE,
next_due_km INTEGER,
notify_days_before INTEGER DEFAULT 7 CHECK (notify_days_before >= 0),
notify_km_before INTEGER DEFAULT 1000 CHECK (notify_km_before >= 0),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'paused', 'completed')),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_reminders_tenant ON vehicle_management.maintenance_reminders(tenant_id);
CREATE INDEX idx_reminders_vehicle ON vehicle_management.maintenance_reminders(vehicle_id);
CREATE INDEX idx_reminders_due_date ON vehicle_management.maintenance_reminders(next_due_date);
CREATE TRIGGER trg_reminders_updated_at
BEFORE UPDATE ON vehicle_management.maintenance_reminders
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
SELECT create_tenant_rls_policies('vehicle_management', 'maintenance_reminders');
-- -------------------------------------------
-- REMINDER_NOTIFICATIONS - Notificaciones enviadas
-- -------------------------------------------
CREATE TABLE vehicle_management.reminder_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reminder_id UUID NOT NULL REFERENCES vehicle_management.maintenance_reminders(id) ON DELETE CASCADE,
notification_type VARCHAR(20) NOT NULL CHECK (notification_type IN ('email', 'sms', 'push', 'whatsapp')),
sent_to VARCHAR(200),
sent_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'sent' CHECK (status IN ('sent', 'delivered', 'failed')),
error_message TEXT
);
CREATE INDEX idx_reminder_notif_reminder ON vehicle_management.reminder_notifications(reminder_id);
-- -------------------------------------------
-- VEHICLE_DOCUMENTS - Documentos del vehículo
-- -------------------------------------------
CREATE TABLE vehicle_management.vehicle_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID NOT NULL REFERENCES vehicle_management.vehicles(id) ON DELETE CASCADE,
document_type VARCHAR(50) NOT NULL
CHECK (document_type IN ('registration', 'insurance', 'permit', 'verification', 'other')),
document_number VARCHAR(100),
issue_date DATE,
expiry_date DATE,
file_url VARCHAR(500),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_vehicle_docs_vehicle ON vehicle_management.vehicle_documents(vehicle_id);
CREATE INDEX idx_vehicle_docs_expiry ON vehicle_management.vehicle_documents(expiry_date);
-- -------------------------------------------
-- VISTA: Vehicle Summary
-- -------------------------------------------
CREATE OR REPLACE VIEW vehicle_management.vw_vehicle_summary AS
SELECT
v.id,
v.tenant_id,
v.license_plate,
v.economic_number,
v.make,
v.model,
v.year,
v.current_odometer,
v.status,
v.customer_id,
f.name as fleet_name,
f.id as fleet_id,
ec.make as engine_make,
ec.model as engine_model,
ve.serial_number as engine_serial,
ve.horsepower,
(SELECT COUNT(*) FROM service_management.service_orders so
WHERE so.vehicle_id = v.id) as total_orders,
(SELECT MAX(so.completed_at) FROM service_management.service_orders so
WHERE so.vehicle_id = v.id AND so.status = 'completed') as last_service_date
FROM vehicle_management.vehicles v
LEFT JOIN vehicle_management.fleets f ON v.fleet_id = f.id
LEFT JOIN vehicle_management.vehicle_engines ve ON ve.vehicle_id = v.id
LEFT JOIN vehicle_management.engine_catalog ec ON ve.engine_catalog_id = ec.id
WHERE v.tenant_id = get_current_tenant_id();
COMMENT ON VIEW vehicle_management.vw_vehicle_summary IS 'Vista resumida de vehículos con filtro RLS';
-- -------------------------------------------
-- Trigger para actualizar vehicle_count en fleets
-- -------------------------------------------
CREATE OR REPLACE FUNCTION vehicle_management.update_fleet_vehicle_count()
RETURNS TRIGGER AS $$
BEGIN
-- Actualizar conteo de la flota anterior (si existe)
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.fleet_id IS DISTINCT FROM NEW.fleet_id) THEN
IF OLD.fleet_id IS NOT NULL THEN
UPDATE vehicle_management.fleets
SET vehicle_count = (
SELECT COUNT(*) FROM vehicle_management.vehicles
WHERE fleet_id = OLD.fleet_id AND status = 'active'
)
WHERE id = OLD.fleet_id;
END IF;
END IF;
-- Actualizar conteo de la nueva flota
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND OLD.fleet_id IS DISTINCT FROM NEW.fleet_id) THEN
IF NEW.fleet_id IS NOT NULL THEN
UPDATE vehicle_management.fleets
SET vehicle_count = (
SELECT COUNT(*) FROM vehicle_management.vehicles
WHERE fleet_id = NEW.fleet_id AND status = 'active'
)
WHERE id = NEW.fleet_id;
END IF;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_fleet_count
AFTER INSERT OR UPDATE OR DELETE ON vehicle_management.vehicles
FOR EACH ROW EXECUTE FUNCTION vehicle_management.update_fleet_vehicle_count();

81
init/06-seed-data.sql Normal file
View File

@ -0,0 +1,81 @@
-- ===========================================
-- MECANICAS DIESEL - Datos Semilla
-- ===========================================
-- Datos iniciales para desarrollo y testing
-- -------------------------------------------
-- Tipos de prueba predefinidos (globales)
-- -------------------------------------------
INSERT INTO service_management.test_types (id, tenant_id, name, description, component_type, is_system, is_active)
VALUES
(gen_random_uuid(), NULL, 'Diagnóstico Scanner', 'Lectura de códigos DTC con scanner OBD-II/J1939', 'engine', true, true),
(gen_random_uuid(), NULL, 'Prueba de Inyector', 'Prueba de banco para inyectores diesel', 'injector', true, true),
(gen_random_uuid(), NULL, 'Prueba de Bomba de Alta Presión', 'Prueba de banco para bomba de inyección', 'pump', true, true),
(gen_random_uuid(), NULL, 'Prueba de Compresión', 'Medición de compresión por cilindro', 'engine', true, true),
(gen_random_uuid(), NULL, 'Prueba de Turbo VGT', 'Prueba de actuador y geometría variable', 'turbo', true, true),
(gen_random_uuid(), NULL, 'Prueba de Presión de Riel', 'Medición de presión del sistema common rail', 'pump', true, true);
-- Parámetros para prueba de inyector
WITH test AS (
SELECT id FROM service_management.test_types WHERE name = 'Prueba de Inyector' LIMIT 1
)
INSERT INTO service_management.test_parameters (test_type_id, name, unit, data_type, sort_order)
SELECT test.id, params.name, params.unit, params.data_type, params.sort_order
FROM test, (VALUES
('Presión de apertura', 'PSI', 'numeric', 1),
('Caudal de inyección', 'ml/100', 'numeric', 2),
('Retorno', 'ml/min', 'numeric', 3),
('Patrón de spray', NULL, 'text', 4),
('Tiempo de respuesta', 'ms', 'numeric', 5)
) AS params(name, unit, data_type, sort_order);
-- Parámetros para prueba de bomba
WITH test AS (
SELECT id FROM service_management.test_types WHERE name = 'Prueba de Bomba de Alta Presión' LIMIT 1
)
INSERT INTO service_management.test_parameters (test_type_id, name, unit, data_type, sort_order)
SELECT test.id, params.name, params.unit, params.data_type, params.sort_order
FROM test, (VALUES
('Presión máxima', 'PSI', 'numeric', 1),
('Presión a RPM idle', 'PSI', 'numeric', 2),
('Caudal volumétrico', 'lt/min', 'numeric', 3),
('Tiempo de cebado', 'seg', 'numeric', 4)
) AS params(name, unit, data_type, sort_order);
-- Parámetros para compresión
WITH test AS (
SELECT id FROM service_management.test_types WHERE name = 'Prueba de Compresión' LIMIT 1
)
INSERT INTO service_management.test_parameters (test_type_id, name, unit, data_type, sort_order)
SELECT test.id, params.name, params.unit, params.data_type, params.sort_order
FROM test, (VALUES
('Compresión Cilindro 1', 'PSI', 'numeric', 1),
('Compresión Cilindro 2', 'PSI', 'numeric', 2),
('Compresión Cilindro 3', 'PSI', 'numeric', 3),
('Compresión Cilindro 4', 'PSI', 'numeric', 4),
('Compresión Cilindro 5', 'PSI', 'numeric', 5),
('Compresión Cilindro 6', 'PSI', 'numeric', 6)
) AS params(name, unit, data_type, sort_order);
-- Valores de referencia para Cummins ISX15
WITH
engine AS (SELECT id FROM vehicle_management.engine_catalog WHERE make = 'Cummins' AND model = 'ISX15' LIMIT 1),
param_presion AS (SELECT id FROM service_management.test_parameters WHERE name = 'Presión de apertura' LIMIT 1),
param_retorno AS (SELECT id FROM service_management.test_parameters WHERE name = 'Retorno' LIMIT 1),
param_caudal AS (SELECT id FROM service_management.test_parameters WHERE name = 'Caudal de inyección' LIMIT 1)
INSERT INTO service_management.parameter_references (parameter_id, engine_model_id, min_value, max_value, nominal_value, source)
SELECT * FROM (
SELECT param_presion.id, engine.id, 2800, 3200, 3000, 'Manual Cummins ISX15' FROM param_presion, engine
UNION ALL
SELECT param_retorno.id, engine.id, 0, 20, 10, 'Manual Cummins ISX15' FROM param_retorno, engine
UNION ALL
SELECT param_caudal.id, engine.id, 45, 55, 50, 'Manual Cummins ISX15' FROM param_caudal, engine
) refs
WHERE EXISTS (SELECT 1 FROM param_presion) AND EXISTS (SELECT 1 FROM engine);
-- -------------------------------------------
-- Notas de uso
-- -------------------------------------------
COMMENT ON TABLE service_management.test_types IS 'Datos semilla: 6 tipos de prueba predefinidos';
COMMENT ON TABLE service_management.test_parameters IS 'Datos semilla: Parámetros para pruebas de inyector, bomba y compresión';
COMMENT ON TABLE service_management.parameter_references IS 'Datos semilla: Referencias para Cummins ISX15';

View File

@ -0,0 +1,459 @@
-- ===========================================
-- MECANICAS DIESEL - Schema de Notificaciones
-- ===========================================
-- Sistema de tracking, mensajes, followers y actividades
-- Permite historial de cambios y notificaciones automaticas
-- ============================================
-- SCHEMA: notifications
-- ============================================
CREATE SCHEMA IF NOT EXISTS notifications;
COMMENT ON SCHEMA notifications IS 'Sistema de tracking, mensajes, followers y actividades (patron mail.thread)';
-- Grants
GRANT USAGE ON SCHEMA notifications TO mecanicas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA notifications TO mecanicas_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA notifications GRANT ALL ON TABLES TO mecanicas_user;
-- ============================================
-- GAP-01: Sistema de Tracking de Cambios
-- ============================================
-- Subtipos de mensaje (clasificacion)
CREATE TABLE notifications.message_subtypes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
res_model VARCHAR(100), -- NULL = aplica a todos los modelos
is_internal BOOLEAN NOT NULL DEFAULT false,
is_default BOOLEAN NOT NULL DEFAULT false,
sequence INTEGER NOT NULL DEFAULT 10,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE notifications.message_subtypes IS 'Clasificacion de tipos de mensaje (creacion, edicion, nota, etc.)';
-- Seed de subtipos predeterminados
INSERT INTO notifications.message_subtypes (code, name, description, is_internal, is_default, sequence) VALUES
('mt_note', 'Nota', 'Nota interna', true, true, 1),
('mt_comment', 'Comentario', 'Comentario publico', false, true, 2),
('mt_tracking', 'Cambio de valor', 'Cambio en campo trackeado', true, false, 10),
('mt_creation', 'Creacion', 'Documento creado', false, false, 5),
('mt_status_change', 'Cambio de estado', 'Estado del documento modificado', false, false, 6),
('mt_assignment', 'Asignacion', 'Documento asignado a usuario', false, false, 7);
-- Mensajes (chatter/historial)
CREATE TABLE notifications.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Referencia al documento
res_model VARCHAR(100) NOT NULL, -- ej: 'service_management.service_orders'
res_id UUID NOT NULL, -- ID del documento
-- Tipo y subtipo
message_type VARCHAR(20) NOT NULL DEFAULT 'notification',
subtype_id UUID REFERENCES notifications.message_subtypes(id),
-- Autor
author_id UUID, -- Usuario que escribio el mensaje
author_name VARCHAR(256), -- Nombre del autor (desnormalizado)
-- Contenido
subject VARCHAR(500),
body TEXT,
-- Tracking de cambios (JSON array)
-- Formato: [{"field": "status", "old_value": "draft", "new_value": "confirmed", "field_label": "Estado"}]
tracking_values JSONB DEFAULT '[]'::jsonb,
-- Metadatos
is_internal BOOLEAN NOT NULL DEFAULT false,
parent_id UUID REFERENCES notifications.messages(id),
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_message_type CHECK (message_type IN ('comment', 'notification', 'note', 'email', 'system'))
);
COMMENT ON TABLE notifications.messages IS 'Historial de mensajes y cambios en documentos (chatter)';
COMMENT ON COLUMN notifications.messages.res_model IS 'Nombre completo del modelo (schema.table)';
COMMENT ON COLUMN notifications.messages.res_id IS 'ID del registro referenciado';
COMMENT ON COLUMN notifications.messages.tracking_values IS 'Array JSON con cambios de campos trackeados';
-- Indices para messages
CREATE INDEX idx_messages_resource ON notifications.messages(res_model, res_id);
CREATE INDEX idx_messages_tenant ON notifications.messages(tenant_id);
CREATE INDEX idx_messages_created ON notifications.messages(created_at DESC);
CREATE INDEX idx_messages_author ON notifications.messages(author_id);
CREATE INDEX idx_messages_parent ON notifications.messages(parent_id) WHERE parent_id IS NOT NULL;
-- RLS para messages
SELECT create_tenant_rls_policies('notifications', 'messages');
-- ============================================
-- GAP-02: Sistema de Followers/Suscriptores
-- ============================================
-- Seguidores de documentos
CREATE TABLE notifications.followers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Referencia al documento
res_model VARCHAR(100) NOT NULL,
res_id UUID NOT NULL,
-- Seguidor (puede ser usuario o partner/cliente)
partner_id UUID NOT NULL, -- ID del contacto/usuario
partner_type VARCHAR(20) NOT NULL DEFAULT 'user',
-- Metadatos
reason VARCHAR(100), -- Por que sigue (manual, asignacion, creador, etc.)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_follower UNIQUE(tenant_id, res_model, res_id, partner_id),
CONSTRAINT chk_partner_type CHECK (partner_type IN ('user', 'customer', 'supplier'))
);
COMMENT ON TABLE notifications.followers IS 'Suscriptores a documentos para notificaciones automaticas';
-- Subtipos a los que esta suscrito cada follower
CREATE TABLE notifications.follower_subtypes (
follower_id UUID NOT NULL REFERENCES notifications.followers(id) ON DELETE CASCADE,
subtype_id UUID NOT NULL REFERENCES notifications.message_subtypes(id) ON DELETE CASCADE,
PRIMARY KEY (follower_id, subtype_id)
);
COMMENT ON TABLE notifications.follower_subtypes IS 'Tipos de mensaje a los que esta suscrito cada follower';
-- Indices para followers
CREATE INDEX idx_followers_resource ON notifications.followers(res_model, res_id);
CREATE INDEX idx_followers_partner ON notifications.followers(partner_id);
CREATE INDEX idx_followers_tenant ON notifications.followers(tenant_id);
-- RLS para followers
SELECT create_tenant_rls_policies('notifications', 'followers');
-- ============================================
-- GAP-03: Actividades Programadas
-- ============================================
-- Tipos de actividad
CREATE TABLE notifications.activity_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
-- Configuracion
icon VARCHAR(50) DEFAULT 'fa-tasks',
color VARCHAR(20) DEFAULT 'primary',
default_days INTEGER DEFAULT 0, -- Dias por defecto para deadline
-- Restriccion por modelo (NULL = todos)
res_model VARCHAR(100),
-- Metadatos
sequence INTEGER NOT NULL DEFAULT 10,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE notifications.activity_types IS 'Tipos de actividad disponibles (llamar, reunion, tarea, etc.)';
-- Seed de tipos predeterminados para taller mecanico
INSERT INTO notifications.activity_types (code, name, description, icon, color, default_days, sequence) VALUES
('call', 'Llamar cliente', 'Llamada telefonica al cliente', 'fa-phone', 'info', 0, 1),
('meeting', 'Cita de entrega', 'Cita para entregar vehiculo', 'fa-calendar-check', 'success', 0, 2),
('todo', 'Tarea pendiente', 'Tarea generica por completar', 'fa-tasks', 'warning', 1, 3),
('reminder', 'Recordatorio mantenimiento', 'Recordar cliente sobre proximo mantenimiento', 'fa-bell', 'secondary', 30, 4),
('followup', 'Seguimiento cotizacion', 'Dar seguimiento a cotizacion enviada', 'fa-envelope', 'primary', 3, 5),
('approval', 'Pendiente aprobacion', 'Esperar aprobacion de cliente o supervisor', 'fa-check-circle', 'danger', 1, 6),
('parts_arrival', 'Llegada de refacciones', 'Refacciones pendientes de llegar', 'fa-truck', 'info', 2, 7),
('quality_check', 'Revision de calidad', 'Inspeccion de trabajo terminado', 'fa-search', 'warning', 0, 8);
-- Actividades programadas
CREATE TABLE notifications.activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Referencia al documento
res_model VARCHAR(100) NOT NULL,
res_id UUID NOT NULL,
-- Tipo y asignacion
activity_type_id UUID NOT NULL REFERENCES notifications.activity_types(id),
user_id UUID NOT NULL, -- Usuario asignado
-- Programacion
date_deadline DATE NOT NULL,
-- Contenido
summary VARCHAR(500),
note TEXT,
-- Estado
state VARCHAR(20) NOT NULL DEFAULT 'planned',
date_done TIMESTAMPTZ,
feedback TEXT, -- Comentario al completar
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
CONSTRAINT chk_activity_state CHECK (state IN ('planned', 'today', 'overdue', 'done', 'canceled'))
);
COMMENT ON TABLE notifications.activities IS 'Actividades y recordatorios programados asociados a documentos';
COMMENT ON COLUMN notifications.activities.state IS 'Estado: planned (futuro), today (hoy), overdue (vencida), done (completada), canceled';
-- Indices para activities
CREATE INDEX idx_activities_resource ON notifications.activities(res_model, res_id);
CREATE INDEX idx_activities_user ON notifications.activities(user_id);
CREATE INDEX idx_activities_deadline ON notifications.activities(date_deadline);
CREATE INDEX idx_activities_tenant ON notifications.activities(tenant_id);
CREATE INDEX idx_activities_pending ON notifications.activities(user_id, date_deadline)
WHERE state NOT IN ('done', 'canceled');
-- RLS para activities
SELECT create_tenant_rls_policies('notifications', 'activities');
-- ============================================
-- FUNCIONES AUXILIARES
-- ============================================
-- Funcion para actualizar estado de actividades (planned -> today -> overdue)
CREATE OR REPLACE FUNCTION notifications.update_activity_states()
RETURNS INTEGER AS $$
DECLARE
v_updated INTEGER;
BEGIN
-- Actualizar a 'today' las que vencen hoy
UPDATE notifications.activities
SET state = 'today'
WHERE state = 'planned'
AND date_deadline = CURRENT_DATE;
GET DIAGNOSTICS v_updated = ROW_COUNT;
-- Actualizar a 'overdue' las vencidas
UPDATE notifications.activities
SET state = 'overdue'
WHERE state IN ('planned', 'today')
AND date_deadline < CURRENT_DATE;
RETURN v_updated;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.update_activity_states() IS 'Actualiza estados de actividades segun fecha (ejecutar diariamente)';
-- Funcion para agregar follower automaticamente
CREATE OR REPLACE FUNCTION notifications.add_follower(
p_tenant_id UUID,
p_res_model VARCHAR(100),
p_res_id UUID,
p_partner_id UUID,
p_partner_type VARCHAR(20) DEFAULT 'user',
p_reason VARCHAR(100) DEFAULT 'manual'
)
RETURNS UUID AS $$
DECLARE
v_follower_id UUID;
BEGIN
INSERT INTO notifications.followers (tenant_id, res_model, res_id, partner_id, partner_type, reason)
VALUES (p_tenant_id, p_res_model, p_res_id, p_partner_id, p_partner_type, p_reason)
ON CONFLICT (tenant_id, res_model, res_id, partner_id) DO NOTHING
RETURNING id INTO v_follower_id;
-- Si ya existia, obtener el ID
IF v_follower_id IS NULL THEN
SELECT id INTO v_follower_id
FROM notifications.followers
WHERE tenant_id = p_tenant_id
AND res_model = p_res_model
AND res_id = p_res_id
AND partner_id = p_partner_id;
END IF;
RETURN v_follower_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.add_follower IS 'Agrega un follower a un documento (idempotente)';
-- Funcion para registrar mensaje de tracking
CREATE OR REPLACE FUNCTION notifications.log_tracking_message(
p_tenant_id UUID,
p_res_model VARCHAR(100),
p_res_id UUID,
p_author_id UUID,
p_tracking_values JSONB,
p_body TEXT DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_message_id UUID;
v_subtype_id UUID;
BEGIN
-- Obtener subtipo de tracking
SELECT id INTO v_subtype_id
FROM notifications.message_subtypes
WHERE code = 'mt_tracking';
INSERT INTO notifications.messages (
tenant_id, res_model, res_id, message_type,
subtype_id, author_id, body, tracking_values, is_internal
)
VALUES (
p_tenant_id, p_res_model, p_res_id, 'notification',
v_subtype_id, p_author_id, p_body, p_tracking_values, true
)
RETURNING id INTO v_message_id;
RETURN v_message_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.log_tracking_message IS 'Registra un mensaje de tracking de cambios';
-- Funcion para crear actividad
CREATE OR REPLACE FUNCTION notifications.create_activity(
p_tenant_id UUID,
p_res_model VARCHAR(100),
p_res_id UUID,
p_activity_type_code VARCHAR(50),
p_user_id UUID,
p_date_deadline DATE DEFAULT NULL,
p_summary VARCHAR(500) DEFAULT NULL,
p_note TEXT DEFAULT NULL,
p_created_by UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_activity_id UUID;
v_activity_type_id UUID;
v_default_days INTEGER;
BEGIN
-- Obtener tipo de actividad
SELECT id, default_days INTO v_activity_type_id, v_default_days
FROM notifications.activity_types
WHERE code = p_activity_type_code AND is_active = true;
IF v_activity_type_id IS NULL THEN
RAISE EXCEPTION 'Activity type % not found', p_activity_type_code;
END IF;
-- Calcular deadline si no se proporciono
IF p_date_deadline IS NULL THEN
p_date_deadline := CURRENT_DATE + v_default_days;
END IF;
-- Determinar estado inicial
INSERT INTO notifications.activities (
tenant_id, res_model, res_id, activity_type_id, user_id,
date_deadline, summary, note, state, created_by
)
VALUES (
p_tenant_id, p_res_model, p_res_id, v_activity_type_id, p_user_id,
p_date_deadline, p_summary, p_note,
CASE
WHEN p_date_deadline < CURRENT_DATE THEN 'overdue'
WHEN p_date_deadline = CURRENT_DATE THEN 'today'
ELSE 'planned'
END,
COALESCE(p_created_by, get_current_user_id())
)
RETURNING id INTO v_activity_id;
RETURN v_activity_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.create_activity IS 'Crea una nueva actividad programada';
-- Funcion para completar actividad
CREATE OR REPLACE FUNCTION notifications.complete_activity(
p_activity_id UUID,
p_feedback TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
BEGIN
UPDATE notifications.activities
SET state = 'done',
date_done = NOW(),
feedback = p_feedback
WHERE id = p_activity_id
AND state NOT IN ('done', 'canceled');
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.complete_activity IS 'Marca una actividad como completada';
-- ============================================
-- VISTAS UTILES
-- ============================================
-- Vista de actividades pendientes por usuario
CREATE VIEW notifications.v_pending_activities AS
SELECT
a.id,
a.tenant_id,
a.res_model,
a.res_id,
at.code as activity_type_code,
at.name as activity_type_name,
at.icon,
at.color,
a.user_id,
a.date_deadline,
a.summary,
a.note,
a.state,
CASE
WHEN a.date_deadline < CURRENT_DATE THEN a.date_deadline - CURRENT_DATE
ELSE 0
END as days_overdue,
a.created_at
FROM notifications.activities a
JOIN notifications.activity_types at ON at.id = a.activity_type_id
WHERE a.state NOT IN ('done', 'canceled')
ORDER BY a.date_deadline ASC;
COMMENT ON VIEW notifications.v_pending_activities IS 'Actividades pendientes con informacion de tipo';
-- Vista de historial de mensajes por documento
CREATE VIEW notifications.v_message_history AS
SELECT
m.id,
m.tenant_id,
m.res_model,
m.res_id,
m.message_type,
ms.code as subtype_code,
ms.name as subtype_name,
m.author_id,
m.author_name,
m.subject,
m.body,
m.tracking_values,
m.is_internal,
m.parent_id,
m.created_at
FROM notifications.messages m
LEFT JOIN notifications.message_subtypes ms ON ms.id = m.subtype_id
ORDER BY m.created_at DESC;
COMMENT ON VIEW notifications.v_message_history IS 'Historial de mensajes formateado con subtipos';
-- ============================================
-- GRANTS ADICIONALES
-- ============================================
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA notifications TO mecanicas_user;
GRANT SELECT ON ALL TABLES IN SCHEMA notifications TO mecanicas_user;

View File

@ -0,0 +1,387 @@
-- ===========================================
-- MECANICAS DIESEL - Schema de Contabilidad Analitica
-- ===========================================
-- Resuelve: GAP-05
-- Permite P&L por orden de servicio
-- Referencia: SPEC-CONTABILIDAD-ANALITICA-MULTIDIMENSIONAL.md
-- ============================================
-- SCHEMA: analytics
-- ============================================
CREATE SCHEMA IF NOT EXISTS analytics;
COMMENT ON SCHEMA analytics IS 'Contabilidad analitica simplificada - costos e ingresos por orden';
-- Grants
GRANT USAGE ON SCHEMA analytics TO mecanicas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA analytics TO mecanicas_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA analytics GRANT ALL ON TABLES TO mecanicas_user;
-- ============================================
-- CUENTAS ANALITICAS
-- ============================================
-- Tipos de cuenta analitica
CREATE TABLE analytics.account_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
sequence INTEGER NOT NULL DEFAULT 10,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE analytics.account_types IS 'Clasificacion de cuentas analiticas';
-- Seed de tipos predeterminados para taller
INSERT INTO analytics.account_types (code, name, description, sequence) VALUES
('service_order', 'Orden de Servicio', 'Cuenta por orden de servicio individual', 1),
('project', 'Proyecto', 'Agrupacion de multiples ordenes', 2),
('vehicle', 'Vehiculo', 'Costos historicos por vehiculo', 3),
('customer', 'Cliente', 'Rentabilidad por cliente', 4),
('department', 'Departamento', 'Costos por area del taller', 5);
-- Cuentas analiticas
CREATE TABLE analytics.accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Identificacion
code VARCHAR(30) NOT NULL,
name VARCHAR(150) NOT NULL,
-- Clasificacion
account_type_id UUID NOT NULL REFERENCES analytics.account_types(id),
-- Referencia al documento origen (opcional)
res_model VARCHAR(100), -- ej: 'service_management.service_orders'
res_id UUID, -- ID del documento
-- Jerarquia (para agrupaciones)
parent_id UUID REFERENCES analytics.accounts(id),
-- Presupuesto (opcional)
budget_amount DECIMAL(20,6) DEFAULT 0,
-- Metadatos
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
CONSTRAINT uq_analytics_account UNIQUE(tenant_id, code)
);
COMMENT ON TABLE analytics.accounts IS 'Cuentas analiticas para tracking de costos/ingresos';
COMMENT ON COLUMN analytics.accounts.res_model IS 'Modelo relacionado (ej: service_orders)';
COMMENT ON COLUMN analytics.accounts.res_id IS 'ID del documento relacionado';
-- Indices para accounts
CREATE INDEX idx_analytics_accounts_tenant ON analytics.accounts(tenant_id);
CREATE INDEX idx_analytics_accounts_type ON analytics.accounts(account_type_id);
CREATE INDEX idx_analytics_accounts_parent ON analytics.accounts(parent_id) WHERE parent_id IS NOT NULL;
CREATE INDEX idx_analytics_accounts_resource ON analytics.accounts(res_model, res_id) WHERE res_model IS NOT NULL;
-- RLS para accounts
SELECT create_tenant_rls_policies('analytics', 'accounts');
-- Trigger para updated_at
CREATE TRIGGER set_updated_at_analytics_accounts
BEFORE UPDATE ON analytics.accounts
FOR EACH ROW
EXECUTE FUNCTION trigger_set_updated_at();
-- ============================================
-- LINEAS ANALITICAS (Movimientos)
-- ============================================
-- Categorias de linea (costo vs ingreso)
CREATE TYPE analytics.line_category AS ENUM ('cost', 'revenue', 'adjustment');
-- Lineas analiticas
CREATE TABLE analytics.lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Cuenta analitica
account_id UUID NOT NULL REFERENCES analytics.accounts(id),
-- Fecha y descripcion
date DATE NOT NULL,
name VARCHAR(256) NOT NULL,
ref VARCHAR(100), -- Referencia externa (factura, orden, etc.)
-- Importes
amount DECIMAL(20,6) NOT NULL, -- Positivo = ingreso, negativo = costo
category analytics.line_category NOT NULL,
unit_amount DECIMAL(20,6), -- Cantidad de unidades (horas, piezas)
unit_cost DECIMAL(20,6), -- Costo unitario
-- Origen del movimiento
source_model VARCHAR(100), -- Modelo que genero la linea
source_id UUID, -- ID del registro origen
-- Detalle adicional
product_id UUID, -- Producto/refaccion si aplica
employee_id UUID, -- Empleado si es mano de obra
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID
);
COMMENT ON TABLE analytics.lines IS 'Movimientos de costos/ingresos en cuentas analiticas';
COMMENT ON COLUMN analytics.lines.amount IS 'Monto: positivo=ingreso, negativo=costo';
COMMENT ON COLUMN analytics.lines.unit_amount IS 'Cantidad (horas de mano de obra, unidades de refaccion)';
-- Indices para lines
CREATE INDEX idx_analytics_lines_tenant ON analytics.lines(tenant_id);
CREATE INDEX idx_analytics_lines_account ON analytics.lines(account_id);
CREATE INDEX idx_analytics_lines_date ON analytics.lines(date);
CREATE INDEX idx_analytics_lines_category ON analytics.lines(category);
CREATE INDEX idx_analytics_lines_source ON analytics.lines(source_model, source_id) WHERE source_model IS NOT NULL;
CREATE INDEX idx_analytics_lines_product ON analytics.lines(product_id) WHERE product_id IS NOT NULL;
-- RLS para lines
SELECT create_tenant_rls_policies('analytics', 'lines');
-- ============================================
-- FUNCIONES PARA GESTION ANALITICA
-- ============================================
-- Funcion para crear cuenta analitica automatica para orden de servicio
CREATE OR REPLACE FUNCTION analytics.create_service_order_account(
p_tenant_id UUID,
p_service_order_id UUID,
p_order_number VARCHAR(50),
p_customer_name VARCHAR(256) DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_account_id UUID;
v_type_id UUID;
v_code VARCHAR(30);
v_name VARCHAR(150);
BEGIN
-- Obtener tipo 'service_order'
SELECT id INTO v_type_id
FROM analytics.account_types
WHERE code = 'service_order';
-- Generar codigo y nombre
v_code := 'OS-' || p_order_number;
v_name := 'Orden ' || p_order_number;
IF p_customer_name IS NOT NULL THEN
v_name := v_name || ' - ' || LEFT(p_customer_name, 50);
END IF;
-- Insertar cuenta
INSERT INTO analytics.accounts (
tenant_id, code, name, account_type_id,
res_model, res_id
)
VALUES (
p_tenant_id, v_code, v_name, v_type_id,
'service_management.service_orders', p_service_order_id
)
ON CONFLICT (tenant_id, code) DO UPDATE SET
name = EXCLUDED.name,
updated_at = NOW()
RETURNING id INTO v_account_id;
RETURN v_account_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION analytics.create_service_order_account IS 'Crea cuenta analitica automatica para orden de servicio';
-- Funcion para registrar costo de refaccion
CREATE OR REPLACE FUNCTION analytics.log_parts_cost(
p_tenant_id UUID,
p_account_id UUID,
p_part_id UUID,
p_part_name VARCHAR(256),
p_quantity DECIMAL(20,6),
p_unit_cost DECIMAL(20,6),
p_source_model VARCHAR(100) DEFAULT NULL,
p_source_id UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_line_id UUID;
v_total_cost DECIMAL(20,6);
BEGIN
v_total_cost := p_quantity * p_unit_cost * -1; -- Negativo porque es costo
INSERT INTO analytics.lines (
tenant_id, account_id, date, name, ref,
amount, category, unit_amount, unit_cost,
source_model, source_id, product_id, created_by
)
VALUES (
p_tenant_id, p_account_id, CURRENT_DATE,
'Refaccion: ' || p_part_name, NULL,
v_total_cost, 'cost', p_quantity, p_unit_cost,
p_source_model, p_source_id, p_part_id, get_current_user_id()
)
RETURNING id INTO v_line_id;
RETURN v_line_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION analytics.log_parts_cost IS 'Registra costo de refacciones usadas';
-- Funcion para registrar costo de mano de obra
CREATE OR REPLACE FUNCTION analytics.log_labor_cost(
p_tenant_id UUID,
p_account_id UUID,
p_employee_id UUID,
p_employee_name VARCHAR(256),
p_hours DECIMAL(20,6),
p_hourly_rate DECIMAL(20,6),
p_description VARCHAR(256) DEFAULT NULL,
p_source_model VARCHAR(100) DEFAULT NULL,
p_source_id UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_line_id UUID;
v_total_cost DECIMAL(20,6);
BEGIN
v_total_cost := p_hours * p_hourly_rate * -1; -- Negativo porque es costo
INSERT INTO analytics.lines (
tenant_id, account_id, date, name, ref,
amount, category, unit_amount, unit_cost,
source_model, source_id, employee_id, created_by
)
VALUES (
p_tenant_id, p_account_id, CURRENT_DATE,
COALESCE(p_description, 'Mano de obra: ' || p_employee_name), NULL,
v_total_cost, 'cost', p_hours, p_hourly_rate,
p_source_model, p_source_id, p_employee_id, get_current_user_id()
)
RETURNING id INTO v_line_id;
RETURN v_line_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION analytics.log_labor_cost IS 'Registra costo de mano de obra';
-- Funcion para registrar ingreso (facturacion)
CREATE OR REPLACE FUNCTION analytics.log_revenue(
p_tenant_id UUID,
p_account_id UUID,
p_amount DECIMAL(20,6),
p_description VARCHAR(256),
p_ref VARCHAR(100) DEFAULT NULL,
p_source_model VARCHAR(100) DEFAULT NULL,
p_source_id UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_line_id UUID;
BEGIN
INSERT INTO analytics.lines (
tenant_id, account_id, date, name, ref,
amount, category, unit_amount, unit_cost,
source_model, source_id, created_by
)
VALUES (
p_tenant_id, p_account_id, CURRENT_DATE,
p_description, p_ref,
ABS(p_amount), 'revenue', NULL, NULL,
p_source_model, p_source_id, get_current_user_id()
)
RETURNING id INTO v_line_id;
RETURN v_line_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION analytics.log_revenue IS 'Registra ingreso en cuenta analitica';
-- ============================================
-- VISTAS PARA REPORTES
-- ============================================
-- Vista de P&L por cuenta analitica
CREATE VIEW analytics.v_account_pnl AS
SELECT
a.id as account_id,
a.tenant_id,
a.code,
a.name,
at.code as account_type,
a.res_model,
a.res_id,
a.budget_amount,
COALESCE(SUM(CASE WHEN l.category = 'revenue' THEN l.amount ELSE 0 END), 0) as total_revenue,
COALESCE(SUM(CASE WHEN l.category = 'cost' THEN ABS(l.amount) ELSE 0 END), 0) as total_cost,
COALESCE(SUM(l.amount), 0) as net_profit,
CASE
WHEN COALESCE(SUM(CASE WHEN l.category = 'revenue' THEN l.amount ELSE 0 END), 0) = 0 THEN 0
ELSE ROUND(
(COALESCE(SUM(l.amount), 0) /
COALESCE(SUM(CASE WHEN l.category = 'revenue' THEN l.amount ELSE 0 END), 1)) * 100,
2
)
END as margin_percent,
COUNT(DISTINCT l.id) as line_count
FROM analytics.accounts a
JOIN analytics.account_types at ON at.id = a.account_type_id
LEFT JOIN analytics.lines l ON l.account_id = a.id
WHERE a.is_active = true
GROUP BY a.id, a.tenant_id, a.code, a.name, at.code, a.res_model, a.res_id, a.budget_amount;
COMMENT ON VIEW analytics.v_account_pnl IS 'Estado de resultados por cuenta analitica';
-- Vista de detalle de costos por orden
CREATE VIEW analytics.v_service_order_costs AS
SELECT
a.res_id as service_order_id,
a.tenant_id,
a.code as account_code,
l.date,
l.name,
l.category,
l.amount,
l.unit_amount,
l.unit_cost,
l.product_id,
l.employee_id,
l.source_model,
l.source_id,
l.created_at
FROM analytics.accounts a
JOIN analytics.account_types at ON at.id = a.account_type_id AND at.code = 'service_order'
JOIN analytics.lines l ON l.account_id = a.id
WHERE a.res_model = 'service_management.service_orders'
ORDER BY l.created_at DESC;
COMMENT ON VIEW analytics.v_service_order_costs IS 'Detalle de costos e ingresos por orden de servicio';
-- Vista resumen mensual
CREATE VIEW analytics.v_monthly_summary AS
SELECT
a.tenant_id,
DATE_TRUNC('month', l.date) as month,
at.code as account_type,
COUNT(DISTINCT a.id) as account_count,
SUM(CASE WHEN l.category = 'revenue' THEN l.amount ELSE 0 END) as total_revenue,
SUM(CASE WHEN l.category = 'cost' THEN ABS(l.amount) ELSE 0 END) as total_cost,
SUM(l.amount) as net_profit
FROM analytics.lines l
JOIN analytics.accounts a ON a.id = l.account_id
JOIN analytics.account_types at ON at.id = a.account_type_id
GROUP BY a.tenant_id, DATE_TRUNC('month', l.date), at.code
ORDER BY month DESC, account_type;
COMMENT ON VIEW analytics.v_monthly_summary IS 'Resumen de rentabilidad mensual por tipo de cuenta';
-- ============================================
-- GRANTS ADICIONALES
-- ============================================
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA analytics TO mecanicas_user;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO mecanicas_user;

View File

@ -0,0 +1,531 @@
-- ===========================================
-- MECANICAS DIESEL - Schema de Compras
-- ===========================================
-- Sistema de ordenes de compra, proveedores y recepciones
-- Gestion completa del ciclo de compras del taller
-- ============================================
-- SCHEMA: purchasing
-- ============================================
CREATE SCHEMA IF NOT EXISTS purchasing;
COMMENT ON SCHEMA purchasing IS 'Gestion de compras: ordenes de compra, recepciones, proveedores';
-- Grants
GRANT USAGE ON SCHEMA purchasing TO mecanicas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA purchasing TO mecanicas_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA purchasing GRANT ALL ON TABLES TO mecanicas_user;
-- ============================================
-- PROVEEDORES (complementa partners existentes)
-- ============================================
-- Extension de datos de proveedor
CREATE TABLE purchasing.suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Datos basicos
code VARCHAR(20) NOT NULL,
name VARCHAR(256) NOT NULL,
trade_name VARCHAR(256), -- Nombre comercial
rfc VARCHAR(13),
-- Contacto
contact_name VARCHAR(256),
email VARCHAR(256),
phone VARCHAR(50),
mobile VARCHAR(50),
-- Direccion
street VARCHAR(256),
city VARCHAR(100),
state VARCHAR(100),
zip_code VARCHAR(10),
country VARCHAR(100) DEFAULT 'Mexico',
-- Datos comerciales
payment_term_days INTEGER DEFAULT 30,
credit_limit DECIMAL(20,6) DEFAULT 0,
currency_code VARCHAR(3) DEFAULT 'MXN',
-- Clasificacion
category VARCHAR(50), -- refacciones, lubricantes, herramientas, etc.
is_preferred BOOLEAN NOT NULL DEFAULT false,
rating INTEGER CHECK (rating BETWEEN 1 AND 5),
-- Notas
notes TEXT,
-- Metadatos
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
created_by UUID,
CONSTRAINT uq_supplier_code UNIQUE(tenant_id, code)
);
COMMENT ON TABLE purchasing.suppliers IS 'Proveedores del taller';
-- Indices para suppliers
CREATE INDEX idx_suppliers_tenant ON purchasing.suppliers(tenant_id);
CREATE INDEX idx_suppliers_name ON purchasing.suppliers(name);
CREATE INDEX idx_suppliers_category ON purchasing.suppliers(category);
CREATE INDEX idx_suppliers_preferred ON purchasing.suppliers(is_preferred) WHERE is_preferred = true;
-- RLS para suppliers
SELECT create_tenant_rls_policies('purchasing', 'suppliers');
-- Trigger para updated_at
CREATE TRIGGER set_updated_at_suppliers
BEFORE UPDATE ON purchasing.suppliers
FOR EACH ROW
EXECUTE FUNCTION trigger_set_updated_at();
-- ============================================
-- ORDENES DE COMPRA
-- ============================================
-- Estados de orden de compra
CREATE TYPE purchasing.po_status AS ENUM (
'draft', -- Borrador
'sent', -- Enviada a proveedor
'confirmed', -- Confirmada por proveedor
'partial', -- Parcialmente recibida
'received', -- Completamente recibida
'cancelled' -- Cancelada
);
-- Ordenes de compra
CREATE TABLE purchasing.purchase_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Identificacion
order_number VARCHAR(50) NOT NULL,
reference VARCHAR(100), -- Referencia del proveedor
-- Proveedor
supplier_id UUID NOT NULL REFERENCES purchasing.suppliers(id),
-- Estado
status purchasing.po_status NOT NULL DEFAULT 'draft',
-- Fechas
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
expected_date DATE, -- Fecha esperada de entrega
received_date DATE, -- Fecha de recepcion completa
-- Importes
subtotal DECIMAL(20,6) NOT NULL DEFAULT 0,
discount_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
tax_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
total DECIMAL(20,6) NOT NULL DEFAULT 0,
currency_code VARCHAR(3) DEFAULT 'MXN',
-- Urgencia (para taller)
priority VARCHAR(20) DEFAULT 'normal',
service_order_id UUID, -- Orden de servicio relacionada (si aplica)
-- Notas
notes TEXT,
internal_notes TEXT,
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
created_by UUID NOT NULL,
confirmed_by UUID,
confirmed_at TIMESTAMPTZ,
CONSTRAINT uq_po_number UNIQUE(tenant_id, order_number),
CONSTRAINT chk_po_priority CHECK (priority IN ('low', 'normal', 'high', 'urgent'))
);
COMMENT ON TABLE purchasing.purchase_orders IS 'Ordenes de compra a proveedores';
COMMENT ON COLUMN purchasing.purchase_orders.service_order_id IS 'Orden de servicio que origino la compra (para urgencias)';
-- Indices para purchase_orders
CREATE INDEX idx_po_tenant ON purchasing.purchase_orders(tenant_id);
CREATE INDEX idx_po_supplier ON purchasing.purchase_orders(supplier_id);
CREATE INDEX idx_po_status ON purchasing.purchase_orders(status);
CREATE INDEX idx_po_date ON purchasing.purchase_orders(order_date DESC);
CREATE INDEX idx_po_expected ON purchasing.purchase_orders(expected_date) WHERE status NOT IN ('received', 'cancelled');
CREATE INDEX idx_po_service_order ON purchasing.purchase_orders(service_order_id) WHERE service_order_id IS NOT NULL;
-- RLS para purchase_orders
SELECT create_tenant_rls_policies('purchasing', 'purchase_orders');
-- Triggers
CREATE TRIGGER set_updated_at_purchase_orders
BEFORE UPDATE ON purchasing.purchase_orders
FOR EACH ROW
EXECUTE FUNCTION trigger_set_updated_at();
-- ============================================
-- LINEAS DE ORDEN DE COMPRA
-- ============================================
CREATE TABLE purchasing.purchase_order_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purchase_order_id UUID NOT NULL REFERENCES purchasing.purchase_orders(id) ON DELETE CASCADE,
-- Linea
line_number INTEGER NOT NULL DEFAULT 1,
-- Producto
part_id UUID, -- Referencia a parts_management.parts
product_code VARCHAR(50), -- Codigo del producto (desnormalizado)
description VARCHAR(500) NOT NULL,
-- Cantidades
quantity DECIMAL(20,6) NOT NULL,
unit_of_measure VARCHAR(20) DEFAULT 'PZA',
received_quantity DECIMAL(20,6) NOT NULL DEFAULT 0,
-- Precios
unit_price DECIMAL(20,6) NOT NULL,
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
subtotal DECIMAL(20,6) NOT NULL,
tax_percent DECIMAL(5,2) NOT NULL DEFAULT 16.00, -- IVA Mexico
tax_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
total DECIMAL(20,6) NOT NULL,
-- Fechas
expected_date DATE,
-- Estado de linea
is_closed BOOLEAN NOT NULL DEFAULT false,
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE purchasing.purchase_order_lines IS 'Lineas de detalle de ordenes de compra';
-- Indices para lines
CREATE INDEX idx_pol_order ON purchasing.purchase_order_lines(purchase_order_id);
CREATE INDEX idx_pol_part ON purchasing.purchase_order_lines(part_id) WHERE part_id IS NOT NULL;
CREATE INDEX idx_pol_pending ON purchasing.purchase_order_lines(purchase_order_id)
WHERE received_quantity < quantity AND is_closed = false;
-- ============================================
-- RECEPCIONES DE COMPRA
-- ============================================
CREATE TABLE purchasing.purchase_receipts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Identificacion
receipt_number VARCHAR(50) NOT NULL,
purchase_order_id UUID NOT NULL REFERENCES purchasing.purchase_orders(id),
-- Fecha y proveedor
receipt_date DATE NOT NULL DEFAULT CURRENT_DATE,
supplier_id UUID NOT NULL REFERENCES purchasing.suppliers(id),
-- Documentos del proveedor
supplier_invoice VARCHAR(50), -- Numero de factura proveedor
supplier_delivery_note VARCHAR(50), -- Remision del proveedor
-- Notas
notes TEXT,
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL,
CONSTRAINT uq_receipt_number UNIQUE(tenant_id, receipt_number)
);
COMMENT ON TABLE purchasing.purchase_receipts IS 'Recepciones de mercancia de ordenes de compra';
-- Indices para receipts
CREATE INDEX idx_pr_tenant ON purchasing.purchase_receipts(tenant_id);
CREATE INDEX idx_pr_order ON purchasing.purchase_receipts(purchase_order_id);
CREATE INDEX idx_pr_date ON purchasing.purchase_receipts(receipt_date DESC);
-- RLS para receipts
SELECT create_tenant_rls_policies('purchasing', 'purchase_receipts');
-- Lineas de recepcion
CREATE TABLE purchasing.purchase_receipt_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
receipt_id UUID NOT NULL REFERENCES purchasing.purchase_receipts(id) ON DELETE CASCADE,
order_line_id UUID NOT NULL REFERENCES purchasing.purchase_order_lines(id),
-- Cantidades
quantity_received DECIMAL(20,6) NOT NULL,
quantity_rejected DECIMAL(20,6) NOT NULL DEFAULT 0,
rejection_reason VARCHAR(256),
-- Lote/Serie (si aplica)
lot_number VARCHAR(100),
serial_numbers TEXT[],
-- Ubicacion destino
location_id UUID, -- Referencia a inventory location
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE purchasing.purchase_receipt_lines IS 'Detalle de productos recibidos';
CREATE INDEX idx_prl_receipt ON purchasing.purchase_receipt_lines(receipt_id);
CREATE INDEX idx_prl_order_line ON purchasing.purchase_receipt_lines(order_line_id);
-- ============================================
-- FUNCIONES AUXILIARES
-- ============================================
-- Funcion para generar numero de orden de compra
CREATE OR REPLACE FUNCTION purchasing.generate_po_number(p_tenant_id UUID)
RETURNS VARCHAR(50) AS $$
DECLARE
v_year TEXT;
v_sequence INTEGER;
v_number VARCHAR(50);
BEGIN
v_year := TO_CHAR(CURRENT_DATE, 'YYYY');
-- Obtener siguiente secuencia del año
SELECT COALESCE(MAX(
CAST(SUBSTRING(order_number FROM 'OC-' || v_year || '-(\d+)') AS INTEGER)
), 0) + 1
INTO v_sequence
FROM purchasing.purchase_orders
WHERE tenant_id = p_tenant_id
AND order_number LIKE 'OC-' || v_year || '-%';
v_number := 'OC-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0');
RETURN v_number;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION purchasing.generate_po_number IS 'Genera numero secuencial de orden de compra (OC-YYYY-NNNNN)';
-- Funcion para generar numero de recepcion
CREATE OR REPLACE FUNCTION purchasing.generate_receipt_number(p_tenant_id UUID)
RETURNS VARCHAR(50) AS $$
DECLARE
v_year TEXT;
v_sequence INTEGER;
v_number VARCHAR(50);
BEGIN
v_year := TO_CHAR(CURRENT_DATE, 'YYYY');
SELECT COALESCE(MAX(
CAST(SUBSTRING(receipt_number FROM 'REC-' || v_year || '-(\d+)') AS INTEGER)
), 0) + 1
INTO v_sequence
FROM purchasing.purchase_receipts
WHERE tenant_id = p_tenant_id
AND receipt_number LIKE 'REC-' || v_year || '-%';
v_number := 'REC-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0');
RETURN v_number;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION purchasing.generate_receipt_number IS 'Genera numero secuencial de recepcion (REC-YYYY-NNNNN)';
-- Funcion para calcular totales de linea
CREATE OR REPLACE FUNCTION purchasing.calculate_line_totals()
RETURNS TRIGGER AS $$
BEGIN
-- Calcular subtotal (con descuento)
NEW.subtotal := NEW.quantity * NEW.unit_price * (1 - NEW.discount_percent / 100);
-- Calcular impuesto
NEW.tax_amount := NEW.subtotal * NEW.tax_percent / 100;
-- Calcular total
NEW.total := NEW.subtotal + NEW.tax_amount;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER calculate_pol_totals
BEFORE INSERT OR UPDATE OF quantity, unit_price, discount_percent, tax_percent
ON purchasing.purchase_order_lines
FOR EACH ROW
EXECUTE FUNCTION purchasing.calculate_line_totals();
-- Funcion para actualizar totales de orden
CREATE OR REPLACE FUNCTION purchasing.update_order_totals()
RETURNS TRIGGER AS $$
BEGIN
UPDATE purchasing.purchase_orders po
SET
subtotal = COALESCE((
SELECT SUM(subtotal) FROM purchasing.purchase_order_lines WHERE purchase_order_id = po.id
), 0),
tax_amount = COALESCE((
SELECT SUM(tax_amount) FROM purchasing.purchase_order_lines WHERE purchase_order_id = po.id
), 0),
total = COALESCE((
SELECT SUM(total) FROM purchasing.purchase_order_lines WHERE purchase_order_id = po.id
), 0),
updated_at = NOW()
WHERE id = COALESCE(NEW.purchase_order_id, OLD.purchase_order_id);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_po_totals
AFTER INSERT OR UPDATE OR DELETE
ON purchasing.purchase_order_lines
FOR EACH ROW
EXECUTE FUNCTION purchasing.update_order_totals();
-- Funcion para actualizar cantidades recibidas
CREATE OR REPLACE FUNCTION purchasing.update_received_quantities()
RETURNS TRIGGER AS $$
DECLARE
v_order_id UUID;
v_total_lines INTEGER;
v_received_lines INTEGER;
BEGIN
-- Actualizar cantidad recibida en linea de orden
UPDATE purchasing.purchase_order_lines pol
SET received_quantity = COALESCE((
SELECT SUM(prl.quantity_received)
FROM purchasing.purchase_receipt_lines prl
WHERE prl.order_line_id = pol.id
), 0)
WHERE id = NEW.order_line_id;
-- Obtener orden de compra
SELECT purchase_order_id INTO v_order_id
FROM purchasing.purchase_order_lines
WHERE id = NEW.order_line_id;
-- Verificar si toda la orden fue recibida
SELECT
COUNT(*),
COUNT(*) FILTER (WHERE received_quantity >= quantity)
INTO v_total_lines, v_received_lines
FROM purchasing.purchase_order_lines
WHERE purchase_order_id = v_order_id;
-- Actualizar estado de la orden
UPDATE purchasing.purchase_orders
SET status = CASE
WHEN v_received_lines = v_total_lines THEN 'received'::purchasing.po_status
WHEN v_received_lines > 0 THEN 'partial'::purchasing.po_status
ELSE status
END,
received_date = CASE
WHEN v_received_lines = v_total_lines THEN CURRENT_DATE
ELSE received_date
END,
updated_at = NOW()
WHERE id = v_order_id;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_po_received
AFTER INSERT
ON purchasing.purchase_receipt_lines
FOR EACH ROW
EXECUTE FUNCTION purchasing.update_received_quantities();
-- ============================================
-- VISTAS UTILES
-- ============================================
-- Vista de ordenes de compra pendientes
CREATE VIEW purchasing.v_pending_orders AS
SELECT
po.id,
po.tenant_id,
po.order_number,
po.status,
po.order_date,
po.expected_date,
s.name as supplier_name,
s.contact_name,
s.phone as supplier_phone,
po.total,
po.priority,
po.service_order_id,
CASE
WHEN po.expected_date < CURRENT_DATE THEN 'overdue'
WHEN po.expected_date = CURRENT_DATE THEN 'today'
WHEN po.expected_date <= CURRENT_DATE + 3 THEN 'soon'
ELSE 'normal'
END as urgency,
COUNT(pol.id) as line_count,
SUM(CASE WHEN pol.received_quantity < pol.quantity THEN 1 ELSE 0 END) as pending_lines
FROM purchasing.purchase_orders po
JOIN purchasing.suppliers s ON s.id = po.supplier_id
LEFT JOIN purchasing.purchase_order_lines pol ON pol.purchase_order_id = po.id
WHERE po.status NOT IN ('received', 'cancelled')
GROUP BY po.id, po.tenant_id, po.order_number, po.status, po.order_date,
po.expected_date, s.name, s.contact_name, s.phone, po.total,
po.priority, po.service_order_id
ORDER BY po.expected_date ASC NULLS LAST, po.priority DESC;
COMMENT ON VIEW purchasing.v_pending_orders IS 'Ordenes de compra pendientes de recibir';
-- Vista de productos pendientes de recibir
CREATE VIEW purchasing.v_pending_products AS
SELECT
po.tenant_id,
pol.part_id,
pol.product_code,
pol.description,
po.order_number,
po.supplier_id,
s.name as supplier_name,
pol.quantity,
pol.received_quantity,
pol.quantity - pol.received_quantity as pending_quantity,
pol.unit_price,
po.expected_date,
po.service_order_id
FROM purchasing.purchase_order_lines pol
JOIN purchasing.purchase_orders po ON po.id = pol.purchase_order_id
JOIN purchasing.suppliers s ON s.id = po.supplier_id
WHERE pol.received_quantity < pol.quantity
AND pol.is_closed = false
AND po.status NOT IN ('cancelled')
ORDER BY po.expected_date ASC NULLS LAST;
COMMENT ON VIEW purchasing.v_pending_products IS 'Productos pendientes de recibir por orden';
-- Vista de historial de compras por proveedor
CREATE VIEW purchasing.v_supplier_history AS
SELECT
s.id as supplier_id,
s.tenant_id,
s.code as supplier_code,
s.name as supplier_name,
s.category,
s.rating,
COUNT(DISTINCT po.id) as total_orders,
COUNT(DISTINCT po.id) FILTER (WHERE po.status = 'received') as completed_orders,
SUM(po.total) FILTER (WHERE po.status = 'received') as total_purchased,
AVG(po.received_date - po.expected_date) FILTER (WHERE po.status = 'received' AND po.expected_date IS NOT NULL) as avg_delivery_days,
MAX(po.order_date) as last_order_date
FROM purchasing.suppliers s
LEFT JOIN purchasing.purchase_orders po ON po.supplier_id = s.id
WHERE s.is_active = true
GROUP BY s.id, s.tenant_id, s.code, s.name, s.category, s.rating
ORDER BY total_purchased DESC NULLS LAST;
COMMENT ON VIEW purchasing.v_supplier_history IS 'Historial y estadisticas de compras por proveedor';
-- ============================================
-- GRANTS ADICIONALES
-- ============================================
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA purchasing TO mecanicas_user;
GRANT SELECT ON ALL TABLES IN SCHEMA purchasing TO mecanicas_user;

469
init/10-warranty-claims.sql Normal file
View File

@ -0,0 +1,469 @@
-- ===========================================
-- MECANICAS DIESEL - Tracking de Garantias
-- ===========================================
-- Resuelve: GAP-10
-- Sistema de seguimiento de garantias de refacciones
-- Permite reclamar garantias a proveedores
-- ============================================
-- EXTENSION DE PARTS PARA GARANTIAS
-- ============================================
-- Agregar campos de garantia a parts si no existen
DO $$
BEGIN
-- warranty_months
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'parts_management'
AND table_name = 'parts'
AND column_name = 'warranty_months'
) THEN
ALTER TABLE parts_management.parts
ADD COLUMN warranty_months INTEGER DEFAULT 0;
COMMENT ON COLUMN parts_management.parts.warranty_months IS 'Meses de garantia del fabricante';
END IF;
-- warranty_policy
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'parts_management'
AND table_name = 'parts'
AND column_name = 'warranty_policy'
) THEN
ALTER TABLE parts_management.parts
ADD COLUMN warranty_policy TEXT;
COMMENT ON COLUMN parts_management.parts.warranty_policy IS 'Descripcion de politica de garantia';
END IF;
END $$;
-- ============================================
-- TABLA DE GARANTIAS DE PARTES INSTALADAS
-- ============================================
-- Estados de garantia
CREATE TYPE parts_management.warranty_status AS ENUM (
'active', -- Garantia vigente
'expired', -- Garantia expirada
'claimed', -- Reclamo en proceso
'approved', -- Reclamo aprobado
'rejected', -- Reclamo rechazado
'replaced' -- Pieza reemplazada por garantia
);
-- Garantias de piezas instaladas
CREATE TABLE parts_management.warranty_claims (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Referencia a la pieza
part_id UUID NOT NULL,
part_name VARCHAR(256) NOT NULL, -- Desnormalizado para historial
part_sku VARCHAR(50),
-- Referencia al proveedor/fabricante
supplier_id UUID,
supplier_name VARCHAR(256),
manufacturer VARCHAR(256),
-- Datos de instalacion
service_order_id UUID,
service_order_number VARCHAR(50),
installation_date DATE NOT NULL,
installation_notes TEXT,
-- Datos de garantia
warranty_months INTEGER NOT NULL DEFAULT 12,
expiration_date DATE NOT NULL,
serial_number VARCHAR(100),
lot_number VARCHAR(100),
-- Vehiculo (contexto)
vehicle_id UUID,
vehicle_plate VARCHAR(20),
vehicle_description VARCHAR(256),
-- Cliente
customer_id UUID,
customer_name VARCHAR(256),
-- Estado y reclamo
status parts_management.warranty_status NOT NULL DEFAULT 'active',
-- Datos del reclamo (si aplica)
claim_date DATE,
claim_reason TEXT,
claim_description TEXT,
defect_photos TEXT[], -- URLs de fotos del defecto
-- Resolucion
resolution_date DATE,
resolution_type VARCHAR(50), -- replacement, refund, repair, rejected
resolution_notes TEXT,
replacement_part_id UUID, -- Nueva pieza si fue reemplazo
-- Costos
original_cost DECIMAL(20,6),
claim_amount DECIMAL(20,6),
approved_amount DECIMAL(20,6),
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
created_by UUID,
claimed_by UUID,
resolved_by UUID
);
COMMENT ON TABLE parts_management.warranty_claims IS 'Registro de garantias de piezas instaladas en vehiculos';
COMMENT ON COLUMN parts_management.warranty_claims.defect_photos IS 'Array de URLs a fotos del defecto';
-- Indices para warranty_claims
CREATE INDEX idx_wc_tenant ON parts_management.warranty_claims(tenant_id);
CREATE INDEX idx_wc_part ON parts_management.warranty_claims(part_id);
CREATE INDEX idx_wc_service_order ON parts_management.warranty_claims(service_order_id) WHERE service_order_id IS NOT NULL;
CREATE INDEX idx_wc_vehicle ON parts_management.warranty_claims(vehicle_id) WHERE vehicle_id IS NOT NULL;
CREATE INDEX idx_wc_customer ON parts_management.warranty_claims(customer_id);
CREATE INDEX idx_wc_status ON parts_management.warranty_claims(status);
CREATE INDEX idx_wc_expiration ON parts_management.warranty_claims(expiration_date)
WHERE status = 'active';
CREATE INDEX idx_wc_supplier ON parts_management.warranty_claims(supplier_id) WHERE supplier_id IS NOT NULL;
-- RLS para warranty_claims
SELECT create_tenant_rls_policies('parts_management', 'warranty_claims');
-- Trigger para updated_at
CREATE TRIGGER set_updated_at_warranty_claims
BEFORE UPDATE ON parts_management.warranty_claims
FOR EACH ROW
EXECUTE FUNCTION trigger_set_updated_at();
-- ============================================
-- FUNCIONES AUXILIARES
-- ============================================
-- Funcion para crear registro de garantia al instalar pieza
CREATE OR REPLACE FUNCTION parts_management.create_warranty_record(
p_tenant_id UUID,
p_part_id UUID,
p_service_order_id UUID,
p_vehicle_id UUID DEFAULT NULL,
p_customer_id UUID DEFAULT NULL,
p_serial_number VARCHAR(100) DEFAULT NULL,
p_supplier_id UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_warranty_id UUID;
v_part RECORD;
v_service_order RECORD;
v_vehicle RECORD;
v_customer RECORD;
v_supplier RECORD;
BEGIN
-- Obtener datos de la pieza
SELECT id, sku, name, warranty_months, warranty_policy, cost
INTO v_part
FROM parts_management.parts
WHERE id = p_part_id;
IF v_part.id IS NULL THEN
RAISE EXCEPTION 'Part % not found', p_part_id;
END IF;
-- Si no tiene garantia, no crear registro
IF COALESCE(v_part.warranty_months, 0) <= 0 THEN
RETURN NULL;
END IF;
-- Obtener datos de orden de servicio
SELECT id, order_number
INTO v_service_order
FROM service_management.service_orders
WHERE id = p_service_order_id;
-- Obtener datos de vehiculo (si aplica)
IF p_vehicle_id IS NOT NULL THEN
SELECT id, plate_number,
CONCAT(brand, ' ', model, ' ', COALESCE(year::TEXT, '')) as description
INTO v_vehicle
FROM vehicle_management.vehicles
WHERE id = p_vehicle_id;
END IF;
-- Obtener datos de cliente (si aplica)
IF p_customer_id IS NOT NULL THEN
SELECT id, name
INTO v_customer
FROM workshop_core.customers
WHERE id = p_customer_id;
END IF;
-- Obtener datos de proveedor (si aplica)
IF p_supplier_id IS NOT NULL THEN
SELECT id, name
INTO v_supplier
FROM purchasing.suppliers
WHERE id = p_supplier_id;
END IF;
-- Crear registro de garantia
INSERT INTO parts_management.warranty_claims (
tenant_id,
part_id, part_name, part_sku,
supplier_id, supplier_name,
service_order_id, service_order_number,
installation_date, warranty_months, expiration_date,
serial_number,
vehicle_id, vehicle_plate, vehicle_description,
customer_id, customer_name,
original_cost,
created_by
)
VALUES (
p_tenant_id,
v_part.id, v_part.name, v_part.sku,
p_supplier_id, v_supplier.name,
p_service_order_id, v_service_order.order_number,
CURRENT_DATE, v_part.warranty_months,
CURRENT_DATE + (v_part.warranty_months || ' months')::INTERVAL,
p_serial_number,
p_vehicle_id, v_vehicle.plate_number, v_vehicle.description,
p_customer_id, v_customer.name,
v_part.cost,
get_current_user_id()
)
RETURNING id INTO v_warranty_id;
RETURN v_warranty_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION parts_management.create_warranty_record IS 'Crea registro de garantia al instalar una pieza';
-- Funcion para iniciar reclamo de garantia
CREATE OR REPLACE FUNCTION parts_management.initiate_warranty_claim(
p_warranty_id UUID,
p_claim_reason TEXT,
p_claim_description TEXT DEFAULT NULL,
p_defect_photos TEXT[] DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_warranty RECORD;
BEGIN
-- Obtener garantia
SELECT * INTO v_warranty
FROM parts_management.warranty_claims
WHERE id = p_warranty_id;
IF v_warranty.id IS NULL THEN
RAISE EXCEPTION 'Warranty record % not found', p_warranty_id;
END IF;
-- Verificar que este activa
IF v_warranty.status != 'active' THEN
RAISE EXCEPTION 'Warranty is not active (current status: %)', v_warranty.status;
END IF;
-- Verificar que no este expirada
IF v_warranty.expiration_date < CURRENT_DATE THEN
-- Actualizar a expirada primero
UPDATE parts_management.warranty_claims
SET status = 'expired', updated_at = NOW()
WHERE id = p_warranty_id;
RAISE EXCEPTION 'Warranty expired on %', v_warranty.expiration_date;
END IF;
-- Actualizar con datos del reclamo
UPDATE parts_management.warranty_claims
SET
status = 'claimed',
claim_date = CURRENT_DATE,
claim_reason = p_claim_reason,
claim_description = p_claim_description,
defect_photos = p_defect_photos,
claim_amount = original_cost,
claimed_by = get_current_user_id(),
updated_at = NOW()
WHERE id = p_warranty_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION parts_management.initiate_warranty_claim IS 'Inicia un reclamo de garantia';
-- Funcion para resolver reclamo
CREATE OR REPLACE FUNCTION parts_management.resolve_warranty_claim(
p_warranty_id UUID,
p_resolution_type VARCHAR(50),
p_approved_amount DECIMAL(20,6) DEFAULT NULL,
p_resolution_notes TEXT DEFAULT NULL,
p_replacement_part_id UUID DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_new_status parts_management.warranty_status;
BEGIN
-- Determinar nuevo estado segun resolucion
v_new_status := CASE p_resolution_type
WHEN 'replacement' THEN 'replaced'::parts_management.warranty_status
WHEN 'refund' THEN 'approved'::parts_management.warranty_status
WHEN 'repair' THEN 'approved'::parts_management.warranty_status
WHEN 'rejected' THEN 'rejected'::parts_management.warranty_status
ELSE 'approved'::parts_management.warranty_status
END;
UPDATE parts_management.warranty_claims
SET
status = v_new_status,
resolution_date = CURRENT_DATE,
resolution_type = p_resolution_type,
resolution_notes = p_resolution_notes,
approved_amount = CASE
WHEN p_resolution_type = 'rejected' THEN 0
ELSE COALESCE(p_approved_amount, claim_amount)
END,
replacement_part_id = p_replacement_part_id,
resolved_by = get_current_user_id(),
updated_at = NOW()
WHERE id = p_warranty_id
AND status = 'claimed';
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION parts_management.resolve_warranty_claim IS 'Resuelve un reclamo de garantia';
-- Funcion para actualizar garantias expiradas (ejecutar diariamente)
CREATE OR REPLACE FUNCTION parts_management.expire_warranties()
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
UPDATE parts_management.warranty_claims
SET
status = 'expired',
updated_at = NOW()
WHERE status = 'active'
AND expiration_date < CURRENT_DATE;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION parts_management.expire_warranties IS 'Marca como expiradas las garantias vencidas';
-- ============================================
-- VISTAS DE REPORTES
-- ============================================
-- Vista de garantias activas
CREATE VIEW parts_management.v_active_warranties AS
SELECT
wc.id,
wc.tenant_id,
wc.part_id,
wc.part_name,
wc.part_sku,
wc.supplier_name,
wc.manufacturer,
wc.service_order_number,
wc.installation_date,
wc.expiration_date,
wc.serial_number,
wc.vehicle_plate,
wc.vehicle_description,
wc.customer_name,
wc.original_cost,
-- Dias restantes
wc.expiration_date - CURRENT_DATE as days_remaining,
-- Urgencia de expiracion
CASE
WHEN wc.expiration_date - CURRENT_DATE <= 7 THEN 'critical'
WHEN wc.expiration_date - CURRENT_DATE <= 30 THEN 'warning'
WHEN wc.expiration_date - CURRENT_DATE <= 90 THEN 'notice'
ELSE 'ok'
END as expiration_urgency
FROM parts_management.warranty_claims wc
WHERE wc.status = 'active'
AND wc.expiration_date >= CURRENT_DATE
ORDER BY wc.expiration_date ASC;
COMMENT ON VIEW parts_management.v_active_warranties IS 'Garantias vigentes con dias restantes';
-- Vista de reclamos pendientes
CREATE VIEW parts_management.v_pending_claims AS
SELECT
wc.id,
wc.tenant_id,
wc.part_name,
wc.part_sku,
wc.supplier_id,
wc.supplier_name,
wc.claim_date,
wc.claim_reason,
wc.claim_amount,
wc.vehicle_plate,
wc.customer_name,
CURRENT_DATE - wc.claim_date as days_pending
FROM parts_management.warranty_claims wc
WHERE wc.status = 'claimed'
ORDER BY wc.claim_date ASC;
COMMENT ON VIEW parts_management.v_pending_claims IS 'Reclamos de garantia pendientes de resolucion';
-- Vista resumen de garantias por proveedor
CREATE VIEW parts_management.v_warranty_summary_by_supplier AS
SELECT
wc.tenant_id,
wc.supplier_id,
wc.supplier_name,
COUNT(*) as total_warranties,
COUNT(*) FILTER (WHERE status = 'active') as active_warranties,
COUNT(*) FILTER (WHERE status = 'claimed') as pending_claims,
COUNT(*) FILTER (WHERE status IN ('approved', 'replaced')) as approved_claims,
COUNT(*) FILTER (WHERE status = 'rejected') as rejected_claims,
COALESCE(SUM(approved_amount) FILTER (WHERE status IN ('approved', 'replaced')), 0) as total_approved_amount,
ROUND(
COUNT(*) FILTER (WHERE status IN ('approved', 'replaced'))::DECIMAL /
NULLIF(COUNT(*) FILTER (WHERE status IN ('approved', 'replaced', 'rejected')), 0) * 100,
2
) as approval_rate
FROM parts_management.warranty_claims wc
WHERE wc.supplier_id IS NOT NULL
GROUP BY wc.tenant_id, wc.supplier_id, wc.supplier_name
ORDER BY total_warranties DESC;
COMMENT ON VIEW parts_management.v_warranty_summary_by_supplier IS 'Resumen de garantias agrupado por proveedor';
-- Vista de garantias por vehiculo
CREATE VIEW parts_management.v_vehicle_warranties AS
SELECT
wc.tenant_id,
wc.vehicle_id,
wc.vehicle_plate,
wc.vehicle_description,
wc.customer_id,
wc.customer_name,
COUNT(*) as total_parts_with_warranty,
COUNT(*) FILTER (WHERE status = 'active' AND expiration_date >= CURRENT_DATE) as active_warranties,
COUNT(*) FILTER (WHERE status = 'active' AND expiration_date >= CURRENT_DATE AND expiration_date - CURRENT_DATE <= 30) as expiring_soon,
COALESCE(SUM(original_cost), 0) as total_warranty_value
FROM parts_management.warranty_claims wc
WHERE wc.vehicle_id IS NOT NULL
GROUP BY wc.tenant_id, wc.vehicle_id, wc.vehicle_plate, wc.vehicle_description,
wc.customer_id, wc.customer_name
ORDER BY active_warranties DESC;
COMMENT ON VIEW parts_management.v_vehicle_warranties IS 'Resumen de garantias por vehiculo';
-- ============================================
-- GRANTS
-- ============================================
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA parts_management TO mecanicas_user;
GRANT SELECT ON ALL TABLES IN SCHEMA parts_management TO mecanicas_user;

426
init/11-quote-signature.sql Normal file
View File

@ -0,0 +1,426 @@
-- ===========================================
-- MECANICAS DIESEL - Firma Electronica Basica
-- ===========================================
-- Resuelve: GAP-12
-- Sistema de firma canvas para aprobacion de cotizaciones
-- Nota: Para NOM-151 completa ver SPEC-FIRMA-ELECTRONICA-NOM151.md
-- ============================================
-- EXTENSION DE QUOTES PARA FIRMA
-- ============================================
-- Agregar campos de firma a cotizaciones
DO $$
BEGIN
-- signature_data (canvas base64)
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'service_management'
AND table_name = 'quotes'
AND column_name = 'signature_data'
) THEN
ALTER TABLE service_management.quotes
ADD COLUMN signature_data TEXT;
COMMENT ON COLUMN service_management.quotes.signature_data IS 'Firma canvas en formato base64 PNG';
END IF;
-- signed_at
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'service_management'
AND table_name = 'quotes'
AND column_name = 'signed_at'
) THEN
ALTER TABLE service_management.quotes
ADD COLUMN signed_at TIMESTAMPTZ;
COMMENT ON COLUMN service_management.quotes.signed_at IS 'Fecha y hora de firma';
END IF;
-- signed_by_name
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'service_management'
AND table_name = 'quotes'
AND column_name = 'signed_by_name'
) THEN
ALTER TABLE service_management.quotes
ADD COLUMN signed_by_name VARCHAR(256);
COMMENT ON COLUMN service_management.quotes.signed_by_name IS 'Nombre de quien firmo';
END IF;
-- signed_by_ip
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'service_management'
AND table_name = 'quotes'
AND column_name = 'signed_by_ip'
) THEN
ALTER TABLE service_management.quotes
ADD COLUMN signed_by_ip VARCHAR(45);
COMMENT ON COLUMN service_management.quotes.signed_by_ip IS 'IP desde donde se firmo';
END IF;
-- signed_by_email
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'service_management'
AND table_name = 'quotes'
AND column_name = 'signed_by_email'
) THEN
ALTER TABLE service_management.quotes
ADD COLUMN signed_by_email VARCHAR(256);
COMMENT ON COLUMN service_management.quotes.signed_by_email IS 'Email de quien firmo';
END IF;
-- signature_hash
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'service_management'
AND table_name = 'quotes'
AND column_name = 'signature_hash'
) THEN
ALTER TABLE service_management.quotes
ADD COLUMN signature_hash VARCHAR(128);
COMMENT ON COLUMN service_management.quotes.signature_hash IS 'Hash SHA-256 del documento al momento de firmar';
END IF;
-- approval_token
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'service_management'
AND table_name = 'quotes'
AND column_name = 'approval_token'
) THEN
ALTER TABLE service_management.quotes
ADD COLUMN approval_token VARCHAR(64);
COMMENT ON COLUMN service_management.quotes.approval_token IS 'Token unico para link de aprobacion';
END IF;
-- token_expires_at
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'service_management'
AND table_name = 'quotes'
AND column_name = 'token_expires_at'
) THEN
ALTER TABLE service_management.quotes
ADD COLUMN token_expires_at TIMESTAMPTZ;
COMMENT ON COLUMN service_management.quotes.token_expires_at IS 'Expiracion del token de aprobacion';
END IF;
END $$;
-- Indice para busqueda por token
CREATE INDEX IF NOT EXISTS idx_quotes_approval_token
ON service_management.quotes(approval_token)
WHERE approval_token IS NOT NULL;
-- ============================================
-- TABLA DE HISTORIAL DE FIRMAS
-- ============================================
-- Historial de todas las firmas (para auditoria)
CREATE TABLE IF NOT EXISTS service_management.signature_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Documento firmado
document_type VARCHAR(50) NOT NULL, -- 'quote', 'service_order', etc.
document_id UUID NOT NULL,
document_number VARCHAR(50),
-- Datos del firmante
signer_name VARCHAR(256) NOT NULL,
signer_email VARCHAR(256),
signer_phone VARCHAR(50),
signer_ip VARCHAR(45),
signer_user_agent TEXT,
-- Firma
signature_data TEXT NOT NULL, -- Base64 de la imagen de firma
signature_method VARCHAR(50) NOT NULL DEFAULT 'canvas', -- canvas, typed, upload
-- Integridad
document_hash VARCHAR(128) NOT NULL, -- Hash del documento al firmar
signature_hash VARCHAR(128), -- Hash de la firma
document_snapshot JSONB, -- Snapshot del documento
-- Contexto
action VARCHAR(50) NOT NULL, -- 'approve', 'reject', 'acknowledge'
comments TEXT,
-- Auditoria
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Geolocation (opcional, si el cliente lo permite)
geo_latitude DECIMAL(10, 8),
geo_longitude DECIMAL(11, 8),
geo_accuracy DECIMAL(10, 2)
);
COMMENT ON TABLE service_management.signature_audit IS 'Registro de auditoria de todas las firmas electronicas';
-- Indices
CREATE INDEX idx_sig_audit_tenant ON service_management.signature_audit(tenant_id);
CREATE INDEX idx_sig_audit_document ON service_management.signature_audit(document_type, document_id);
CREATE INDEX idx_sig_audit_signer ON service_management.signature_audit(signer_email);
CREATE INDEX idx_sig_audit_created ON service_management.signature_audit(created_at DESC);
-- RLS
SELECT create_tenant_rls_policies('service_management', 'signature_audit');
-- ============================================
-- FUNCIONES AUXILIARES
-- ============================================
-- Funcion para generar token de aprobacion
CREATE OR REPLACE FUNCTION service_management.generate_approval_token(
p_quote_id UUID,
p_expires_hours INTEGER DEFAULT 72
)
RETURNS VARCHAR(64) AS $$
DECLARE
v_token VARCHAR(64);
BEGIN
-- Generar token aleatorio
v_token := encode(gen_random_bytes(32), 'hex');
-- Actualizar cotizacion con token
UPDATE service_management.quotes
SET
approval_token = v_token,
token_expires_at = NOW() + (p_expires_hours || ' hours')::INTERVAL
WHERE id = p_quote_id;
RETURN v_token;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION service_management.generate_approval_token IS 'Genera token de aprobacion para cotizacion';
-- Funcion para validar token
CREATE OR REPLACE FUNCTION service_management.validate_approval_token(
p_token VARCHAR(64)
)
RETURNS TABLE (
quote_id UUID,
is_valid BOOLEAN,
error_message TEXT
) AS $$
DECLARE
v_quote RECORD;
BEGIN
-- Buscar cotizacion con token
SELECT q.id, q.status, q.token_expires_at, q.signed_at
INTO v_quote
FROM service_management.quotes q
WHERE q.approval_token = p_token;
-- Token no encontrado
IF v_quote.id IS NULL THEN
RETURN QUERY SELECT
NULL::UUID,
false,
'Token invalido o no encontrado'::TEXT;
RETURN;
END IF;
-- Token expirado
IF v_quote.token_expires_at < NOW() THEN
RETURN QUERY SELECT
v_quote.id,
false,
'El token ha expirado'::TEXT;
RETURN;
END IF;
-- Ya firmada
IF v_quote.signed_at IS NOT NULL THEN
RETURN QUERY SELECT
v_quote.id,
false,
'La cotizacion ya fue firmada'::TEXT;
RETURN;
END IF;
-- Cotizacion no esta en estado correcto
IF v_quote.status NOT IN ('sent', 'pending') THEN
RETURN QUERY SELECT
v_quote.id,
false,
'La cotizacion no esta disponible para aprobacion'::TEXT;
RETURN;
END IF;
-- Token valido
RETURN QUERY SELECT
v_quote.id,
true,
NULL::TEXT;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION service_management.validate_approval_token IS 'Valida token de aprobacion y estado de cotizacion';
-- Funcion para firmar cotizacion
CREATE OR REPLACE FUNCTION service_management.sign_quote(
p_quote_id UUID,
p_signature_data TEXT,
p_signer_name VARCHAR(256),
p_signer_email VARCHAR(256) DEFAULT NULL,
p_signer_ip VARCHAR(45) DEFAULT NULL,
p_user_agent TEXT DEFAULT NULL,
p_comments TEXT DEFAULT NULL,
p_action VARCHAR(50) DEFAULT 'approve'
)
RETURNS UUID AS $$
DECLARE
v_quote RECORD;
v_audit_id UUID;
v_document_hash VARCHAR(128);
v_signature_hash VARCHAR(128);
v_new_status VARCHAR(20);
BEGIN
-- Obtener cotizacion
SELECT q.*, c.name as customer_name
INTO v_quote
FROM service_management.quotes q
LEFT JOIN workshop_core.customers c ON c.id = q.customer_id
WHERE q.id = p_quote_id;
IF v_quote.id IS NULL THEN
RAISE EXCEPTION 'Quote % not found', p_quote_id;
END IF;
-- Verificar que no este ya firmada
IF v_quote.signed_at IS NOT NULL THEN
RAISE EXCEPTION 'Quote already signed on %', v_quote.signed_at;
END IF;
-- Generar hash del documento (simplificado - en produccion usar contenido completo)
v_document_hash := encode(
sha256(
(v_quote.id::TEXT || v_quote.total::TEXT || v_quote.created_at::TEXT)::bytea
),
'hex'
);
-- Generar hash de la firma
v_signature_hash := encode(sha256(p_signature_data::bytea), 'hex');
-- Determinar nuevo estado
v_new_status := CASE p_action
WHEN 'approve' THEN 'approved'
WHEN 'reject' THEN 'rejected'
ELSE 'pending'
END;
-- Actualizar cotizacion
UPDATE service_management.quotes
SET
status = v_new_status,
signature_data = p_signature_data,
signed_at = NOW(),
signed_by_name = p_signer_name,
signed_by_email = p_signer_email,
signed_by_ip = p_signer_ip,
signature_hash = v_document_hash,
approval_token = NULL, -- Invalidar token
token_expires_at = NULL,
updated_at = NOW()
WHERE id = p_quote_id;
-- Crear registro de auditoria
INSERT INTO service_management.signature_audit (
tenant_id,
document_type, document_id, document_number,
signer_name, signer_email, signer_ip, signer_user_agent,
signature_data, signature_method,
document_hash, signature_hash,
document_snapshot,
action, comments
)
VALUES (
v_quote.tenant_id,
'quote', p_quote_id, v_quote.quote_number,
p_signer_name, p_signer_email, p_signer_ip, p_user_agent,
p_signature_data, 'canvas',
v_document_hash, v_signature_hash,
jsonb_build_object(
'quote_number', v_quote.quote_number,
'customer_name', v_quote.customer_name,
'total', v_quote.total,
'created_at', v_quote.created_at,
'items_count', (SELECT COUNT(*) FROM service_management.quote_lines WHERE quote_id = p_quote_id)
),
p_action, p_comments
)
RETURNING id INTO v_audit_id;
RETURN v_audit_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION service_management.sign_quote IS 'Firma una cotizacion con firma canvas y crea registro de auditoria';
-- ============================================
-- VISTAS
-- ============================================
-- Vista de cotizaciones pendientes de firma
CREATE VIEW service_management.v_quotes_pending_signature AS
SELECT
q.id,
q.tenant_id,
q.quote_number,
q.status,
q.total,
c.name as customer_name,
c.email as customer_email,
c.phone as customer_phone,
q.approval_token IS NOT NULL as has_token,
q.token_expires_at,
CASE
WHEN q.token_expires_at IS NULL THEN 'no_token'
WHEN q.token_expires_at < NOW() THEN 'expired'
WHEN q.token_expires_at < NOW() + INTERVAL '24 hours' THEN 'expiring_soon'
ELSE 'valid'
END as token_status,
q.created_at,
q.updated_at
FROM service_management.quotes q
LEFT JOIN workshop_core.customers c ON c.id = q.customer_id
WHERE q.status IN ('sent', 'pending')
AND q.signed_at IS NULL
ORDER BY q.created_at DESC;
COMMENT ON VIEW service_management.v_quotes_pending_signature IS 'Cotizaciones pendientes de firma del cliente';
-- Vista de historial de firmas
CREATE VIEW service_management.v_signature_history AS
SELECT
sa.id,
sa.tenant_id,
sa.document_type,
sa.document_number,
sa.signer_name,
sa.signer_email,
sa.action,
sa.created_at as signed_at,
sa.signer_ip,
sa.comments,
-- Info adicional del documento
CASE sa.document_type
WHEN 'quote' THEN (SELECT total FROM service_management.quotes WHERE id = sa.document_id)
ELSE NULL
END as document_total
FROM service_management.signature_audit sa
ORDER BY sa.created_at DESC;
COMMENT ON VIEW service_management.v_signature_history IS 'Historial de firmas electronicas';
-- ============================================
-- GRANTS
-- ============================================
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA service_management TO mecanicas_user;
GRANT SELECT ON service_management.signature_audit TO mecanicas_user;
GRANT INSERT ON service_management.signature_audit TO mecanicas_user;