From 0207e1788a63fb773a9ee1728270d01b43cebb3a Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 4 Jan 2026 06:41:39 -0600 Subject: [PATCH] Initial commit - erp-mecanicas-diesel-database --- HERENCIA-ERP-CORE.md | 324 +++++++++++++++ README.md | 173 ++++++++ init/00-extensions.sql | 14 + init/00.5-workshop-core-tables.sql | 158 +++++++ init/01-create-schemas.sql | 29 ++ init/02-rls-functions.sql | 106 +++++ init/03-service-management-tables.sql | 566 ++++++++++++++++++++++++++ init/03.5-customers-table.sql | 91 +++++ init/04-parts-management-tables.sql | 397 ++++++++++++++++++ init/05-vehicle-management-tables.sql | 365 +++++++++++++++++ init/06-seed-data.sql | 81 ++++ init/07-notifications-schema.sql | 459 +++++++++++++++++++++ init/08-analytics-schema.sql | 387 ++++++++++++++++++ init/09-purchasing-schema.sql | 531 ++++++++++++++++++++++++ init/10-warranty-claims.sql | 469 +++++++++++++++++++++ init/11-quote-signature.sql | 426 +++++++++++++++++++ 16 files changed, 4576 insertions(+) create mode 100644 HERENCIA-ERP-CORE.md create mode 100644 README.md create mode 100644 init/00-extensions.sql create mode 100644 init/00.5-workshop-core-tables.sql create mode 100644 init/01-create-schemas.sql create mode 100644 init/02-rls-functions.sql create mode 100644 init/03-service-management-tables.sql create mode 100644 init/03.5-customers-table.sql create mode 100644 init/04-parts-management-tables.sql create mode 100644 init/05-vehicle-management-tables.sql create mode 100644 init/06-seed-data.sql create mode 100644 init/07-notifications-schema.sql create mode 100644 init/08-analytics-schema.sql create mode 100644 init/09-purchasing-schema.sql create mode 100644 init/10-warranty-claims.sql create mode 100644 init/11-quote-signature.sql diff --git a/HERENCIA-ERP-CORE.md b/HERENCIA-ERP-CORE.md new file mode 100644 index 0000000..274ea32 --- /dev/null +++ b/HERENCIA-ERP-CORE.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7eccbe9 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/init/00-extensions.sql b/init/00-extensions.sql new file mode 100644 index 0000000..12a2628 --- /dev/null +++ b/init/00-extensions.sql @@ -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"; diff --git a/init/00.5-workshop-core-tables.sql b/init/00.5-workshop-core-tables.sql new file mode 100644 index 0000000..356b436 --- /dev/null +++ b/init/00.5-workshop-core-tables.sql @@ -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)'; diff --git a/init/01-create-schemas.sql b/init/01-create-schemas.sql new file mode 100644 index 0000000..537e79f --- /dev/null +++ b/init/01-create-schemas.sql @@ -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; diff --git a/init/02-rls-functions.sql b/init/02-rls-functions.sql new file mode 100644 index 0000000..c40d26f --- /dev/null +++ b/init/02-rls-functions.sql @@ -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'; diff --git a/init/03-service-management-tables.sql b/init/03-service-management-tables.sql new file mode 100644 index 0000000..a5a4887 --- /dev/null +++ b/init/03-service-management-tables.sql @@ -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'); diff --git a/init/03.5-customers-table.sql b/init/03.5-customers-table.sql new file mode 100644 index 0000000..c2b671a --- /dev/null +++ b/init/03.5-customers-table.sql @@ -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'; diff --git a/init/04-parts-management-tables.sql b/init/04-parts-management-tables.sql new file mode 100644 index 0000000..d327ee1 --- /dev/null +++ b/init/04-parts-management-tables.sql @@ -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); diff --git a/init/05-vehicle-management-tables.sql b/init/05-vehicle-management-tables.sql new file mode 100644 index 0000000..ca3938d --- /dev/null +++ b/init/05-vehicle-management-tables.sql @@ -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(); diff --git a/init/06-seed-data.sql b/init/06-seed-data.sql new file mode 100644 index 0000000..a2d6e7e --- /dev/null +++ b/init/06-seed-data.sql @@ -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'; diff --git a/init/07-notifications-schema.sql b/init/07-notifications-schema.sql new file mode 100644 index 0000000..cf39943 --- /dev/null +++ b/init/07-notifications-schema.sql @@ -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; diff --git a/init/08-analytics-schema.sql b/init/08-analytics-schema.sql new file mode 100644 index 0000000..64bc1ab --- /dev/null +++ b/init/08-analytics-schema.sql @@ -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; diff --git a/init/09-purchasing-schema.sql b/init/09-purchasing-schema.sql new file mode 100644 index 0000000..c53d602 --- /dev/null +++ b/init/09-purchasing-schema.sql @@ -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; diff --git a/init/10-warranty-claims.sql b/init/10-warranty-claims.sql new file mode 100644 index 0000000..89fe535 --- /dev/null +++ b/init/10-warranty-claims.sql @@ -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; diff --git a/init/11-quote-signature.sql b/init/11-quote-signature.sql new file mode 100644 index 0000000..ab9ab4e --- /dev/null +++ b/init/11-quote-signature.sql @@ -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;