changes on workspace
Some checks failed
CI Pipeline / changes (push) Has been cancelled
CI Pipeline / core (push) Has been cancelled
CI Pipeline / trading-backend (push) Has been cancelled
CI Pipeline / trading-data-service (push) Has been cancelled
CI Pipeline / trading-frontend (push) Has been cancelled
CI Pipeline / erp-core (push) Has been cancelled
CI Pipeline / erp-mecanicas (push) Has been cancelled
CI Pipeline / gamilit-backend (push) Has been cancelled
CI Pipeline / gamilit-frontend (push) Has been cancelled

This commit is contained in:
rckrdmrd 2025-12-09 14:46:20 -06:00
parent 49155822ae
commit 789d1ab46b
418 changed files with 77338 additions and 19680 deletions

290
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,290 @@
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
env:
NODE_VERSION: '20'
PYTHON_VERSION: '3.11'
jobs:
# =============================================================================
# Detect Changes
# =============================================================================
changes:
runs-on: ubuntu-latest
outputs:
core: ${{ steps.changes.outputs.core }}
trading: ${{ steps.changes.outputs.trading }}
erp: ${{ steps.changes.outputs.erp }}
gamilit: ${{ steps.changes.outputs.gamilit }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: changes
with:
filters: |
core:
- 'core/**'
trading:
- 'projects/trading-platform/**'
erp:
- 'projects/erp-suite/**'
gamilit:
- 'projects/gamilit/**'
# =============================================================================
# Core Modules
# =============================================================================
core:
needs: changes
if: ${{ needs.changes.outputs.core == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: core/modules/package-lock.json
- name: Install dependencies
working-directory: core/modules
run: npm ci
- name: Lint
working-directory: core/modules
run: npm run lint --if-present
- name: Type check
working-directory: core/modules
run: npm run typecheck --if-present
- name: Test
working-directory: core/modules
run: npm test --if-present
# =============================================================================
# Trading Platform - Backend
# =============================================================================
trading-backend:
needs: changes
if: ${{ needs.changes.outputs.trading == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: projects/trading-platform/apps/backend/package-lock.json
- name: Install dependencies
working-directory: projects/trading-platform/apps/backend
run: npm ci
- name: Lint
working-directory: projects/trading-platform/apps/backend
run: npm run lint --if-present
- name: Type check
working-directory: projects/trading-platform/apps/backend
run: npm run typecheck --if-present
- name: Build
working-directory: projects/trading-platform/apps/backend
run: npm run build --if-present
- name: Test
working-directory: projects/trading-platform/apps/backend
run: npm test --if-present
# =============================================================================
# Trading Platform - Data Service (Python)
# =============================================================================
trading-data-service:
needs: changes
if: ${{ needs.changes.outputs.trading == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
cache-dependency-path: projects/trading-platform/apps/data-service/requirements.txt
- name: Install dependencies
working-directory: projects/trading-platform/apps/data-service
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint with ruff
working-directory: projects/trading-platform/apps/data-service
run: |
pip install ruff
ruff check src/
- name: Type check with mypy
working-directory: projects/trading-platform/apps/data-service
run: |
pip install mypy
mypy src/ --ignore-missing-imports || true
- name: Test
working-directory: projects/trading-platform/apps/data-service
run: pytest --if-present || true
# =============================================================================
# Trading Platform - Frontend
# =============================================================================
trading-frontend:
needs: changes
if: ${{ needs.changes.outputs.trading == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: projects/trading-platform/apps/frontend/package-lock.json
- name: Install dependencies
working-directory: projects/trading-platform/apps/frontend
run: npm ci
- name: Lint
working-directory: projects/trading-platform/apps/frontend
run: npm run lint --if-present
- name: Type check
working-directory: projects/trading-platform/apps/frontend
run: npm run typecheck --if-present
- name: Build
working-directory: projects/trading-platform/apps/frontend
run: npm run build
# =============================================================================
# ERP Suite - Core
# =============================================================================
erp-core:
needs: changes
if: ${{ needs.changes.outputs.erp == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: projects/erp-suite/apps/erp-core/backend/package-lock.json
- name: Install backend dependencies
working-directory: projects/erp-suite/apps/erp-core/backend
run: npm ci --if-present || true
- name: Lint backend
working-directory: projects/erp-suite/apps/erp-core/backend
run: npm run lint --if-present || true
- name: Type check backend
working-directory: projects/erp-suite/apps/erp-core/backend
run: npm run typecheck --if-present || true
# =============================================================================
# ERP Suite - Mecánicas Diesel
# =============================================================================
erp-mecanicas:
needs: changes
if: ${{ needs.changes.outputs.erp == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install dependencies
working-directory: projects/erp-suite/apps/verticales/mecanicas-diesel/backend
run: npm ci --if-present || npm install
- name: Type check
working-directory: projects/erp-suite/apps/verticales/mecanicas-diesel/backend
run: npm run typecheck --if-present || npx tsc --noEmit
# =============================================================================
# Gamilit
# =============================================================================
gamilit-backend:
needs: changes
if: ${{ needs.changes.outputs.gamilit == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: projects/gamilit/apps/backend/package-lock.json
- name: Install dependencies
working-directory: projects/gamilit/apps/backend
run: npm ci
- name: Lint
working-directory: projects/gamilit/apps/backend
run: npm run lint
- name: Build
working-directory: projects/gamilit/apps/backend
run: npm run build
- name: Test
working-directory: projects/gamilit/apps/backend
run: npm test --if-present
gamilit-frontend:
needs: changes
if: ${{ needs.changes.outputs.gamilit == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: projects/gamilit/apps/frontend/package-lock.json
- name: Install dependencies
working-directory: projects/gamilit/apps/frontend
run: npm ci
- name: Lint
working-directory: projects/gamilit/apps/frontend
run: npm run lint
- name: Build
working-directory: projects/gamilit/apps/frontend
run: npm run build

194
.github/workflows/docker-build.yml vendored Normal file
View File

@ -0,0 +1,194 @@
name: Docker Build
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
inputs:
project:
description: 'Project to build'
required: true
type: choice
options:
- all
- trading-platform
- erp-suite
- gamilit
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ${{ github.repository_owner }}
jobs:
# =============================================================================
# Trading Platform Images
# =============================================================================
trading-platform:
if: ${{ github.event.inputs.project == 'all' || github.event.inputs.project == 'trading-platform' || github.event_name == 'push' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
service:
- name: backend
context: projects/trading-platform/apps/backend
dockerfile: Dockerfile
- name: frontend
context: projects/trading-platform/apps/frontend
dockerfile: Dockerfile
- name: data-service
context: projects/trading-platform/apps/data-service
dockerfile: Dockerfile
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/trading-${{ matrix.service.name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ${{ matrix.service.context }}
file: ${{ matrix.service.context }}/${{ matrix.service.dockerfile }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# =============================================================================
# ERP Suite Images
# =============================================================================
erp-suite:
if: ${{ github.event.inputs.project == 'all' || github.event.inputs.project == 'erp-suite' || github.event_name == 'push' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
service:
- name: erp-core-backend
context: projects/erp-suite/apps/erp-core/backend
- name: mecanicas-backend
context: projects/erp-suite/apps/verticales/mecanicas-diesel/backend
steps:
- uses: actions/checkout@v4
- name: Check if Dockerfile exists
id: check
run: |
if [ -f "${{ matrix.service.context }}/Dockerfile" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
if: steps.check.outputs.exists == 'true'
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: steps.check.outputs.exists == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
if: steps.check.outputs.exists == 'true'
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ matrix.service.name }}
- name: Build and push
if: steps.check.outputs.exists == 'true'
uses: docker/build-push-action@v5
with:
context: ${{ matrix.service.context }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# =============================================================================
# Gamilit Images
# =============================================================================
gamilit:
if: ${{ github.event.inputs.project == 'all' || github.event.inputs.project == 'gamilit' || github.event_name == 'push' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
service:
- name: backend
context: projects/gamilit/apps/backend
- name: frontend
context: projects/gamilit/apps/frontend
steps:
- uses: actions/checkout@v4
- name: Check if Dockerfile exists
id: check
run: |
if [ -f "${{ matrix.service.context }}/Dockerfile" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
if: steps.check.outputs.exists == 'true'
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: steps.check.outputs.exists == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
if: steps.check.outputs.exists == 'true'
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/gamilit-${{ matrix.service.name }}
- name: Build and push
if: steps.check.outputs.exists == 'true'
uses: docker/build-push-action@v5
with:
context: ${{ matrix.service.context }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -0,0 +1,276 @@
# Plan de Actualizaciones Mayores - Gamilit & Trading-Platform
## Resumen Ejecutivo
Este plan detalla la migración de dependencias con breaking changes significativos para los proyectos **gamilit** y **trading-platform**.
---
## 1. ZOD 3.x → 4.x (GAMILIT Frontend)
### Estado Actual
- **Versión actual**: 4.1.12 (ya está en v4 en package.json)
- **Archivos afectados**: ~30+ archivos con schemas de validación
- **Uso principal**: `@hookform/resolvers/zod` para validación de formularios
### Breaking Changes Relevantes
| Cambio | Impacto | Acción Requerida |
|--------|---------|------------------|
| `message``error` param | Alto | Buscar y reemplazar |
| `.default()` behavior change | Medio | Verificar schemas con defaults |
| `ZodError.errors``ZodError.issues` | Bajo | Verificar manejo de errores |
| Import paths (`zod/v4`) | Bajo | Mantener `zod` (es v4 default) |
### Análisis del Código
```typescript
// Patrones encontrados que necesitan revisión:
z.string().min(1, 'El correo electrónico es requerido') // OK - sin cambios
z.enum(['student', 'admin_teacher'], { message: '...' }) // CAMBIO: message → error
.refine((val) => val === true, { message: '...' }) // OK - refine sigue igual
```
### Tareas
1. Ejecutar codemod `npx zod-v3-to-v4`
2. Buscar `{ message:` en schemas y cambiar a `{ error:`
3. Verificar schemas con `.default()` o `.catch()`
4. Ejecutar tests y build
### Riesgo: BAJO (ya está en v4, solo ajustes menores)
---
## 2. STRIPE 14.x → 20.x (Trading-Platform Backend)
### Estado Actual
- **Versión actual**: ^14.7.0
- **Archivos afectados**: 3 archivos en `/modules/payments/`
- **API Version usada**: `2023-10-16`
### Breaking Changes Relevantes
| Cambio | Impacto | Acción Requerida |
|--------|---------|------------------|
| API Version update | Alto | Actualizar apiVersion |
| Checkout Session behavior | Medio | Verificar subscription flow |
| `total_count` deprecated on lists | Bajo | No usado en código |
### Análisis del Código
```typescript
const stripe = new Stripe(config.stripe?.secretKey, {
apiVersion: '2023-10-16', // CAMBIO: Actualizar a '2024-12-18.acacia' o latest
});
// Patrones usados:
stripe.customers.create() // OK
stripe.checkout.sessions.create() // REVISAR: subscription behavior
stripe.subscriptions.update() // OK
stripe.billingPortal.sessions.create() // OK
```
### Tareas
1. Actualizar `stripe` a ^20.0.0
2. Actualizar `apiVersion` a versión compatible
3. Verificar webhook signature handling
4. Testear flujo de checkout completo
5. Verificar `stripe.promotionCodes.list()` sigue funcionando
### Riesgo: MEDIO (requiere testing de flujo de pagos)
---
## 3. REDIS 4.x → 5.x (Trading-Platform Backend)
### Estado Actual
- **Versión actual**: ^4.6.10
- **Archivos afectados**: 0 (no se encontró uso activo)
- **Nota**: La dependencia está declarada pero no hay imports de `redis`
### Análisis
```bash
grep -rn "from ['\"']redis['\"']" src/ # Sin resultados
grep -rn "createClient" src/ # Sin resultados
```
### Breaking Changes Relevantes (si se usara)
| Cambio | Impacto |
|--------|---------|
| `client.QUIT()``client.close()` | N/A |
| `client.disconnect()``client.destroy()` | N/A |
| Iterator changes (SCAN, etc.) | N/A |
### Recomendación
**OPCIÓN A**: Remover la dependencia (no está en uso)
**OPCIÓN B**: Actualizar a v5 para cuando se necesite
### Riesgo: NINGUNO (no hay código que usar redis)
---
## 4. JEST 29.x → 30.x (Ambos Proyectos)
### Estado Actual
- **Versión actual**: ^29.7.0 (gamilit backend, trading-platform backend)
- **Archivos afectados**: Archivos de configuración jest y todos los tests
### Breaking Changes Relevantes
| Cambio | Impacto | Acción Requerida |
|--------|---------|------------------|
| Node 14/16 dropped | Ninguno | Ya usamos Node 18+ |
| jsdom 21→26 | Medio | Revisar mocks de window.location |
| `--testPathPattern``--testPathPatterns` | Bajo | Actualizar scripts |
| `genMockFromModule` removed | Bajo | Buscar y reemplazar |
| Snapshot changes (Error.cause) | Bajo | Actualizar snapshots |
### Análisis del Código
```bash
# Buscar uso de APIs deprecadas
grep -rn "genMockFromModule" __tests__/ # Verificar
grep -rn "toBeCalled()" __tests__/ # Cambiar a toHaveBeenCalled()
```
### Tareas
1. Actualizar `jest` a ^30.0.0
2. Actualizar `ts-jest` a versión compatible
3. Actualizar `@types/jest` a ^30.0.0
4. Buscar y reemplazar `genMockFromModule``createMockFromModule`
5. Actualizar snapshots: `npm test -- -u`
6. Verificar mocks de `window.location` en frontend tests
### Riesgo: BAJO-MEDIO (principalmente snapshots)
---
## 5. VITEST 3.x → 4.x (Gamilit Frontend)
### Estado Actual
- **Versión actual**: ^3.2.4
- **Archivos afectados**: Configuración vitest y tests de frontend
### Análisis
Vitest 4 tiene cambios menores. La mayoría son mejoras de performance.
### Riesgo: BAJO
---
## Plan de Ejecución por Fases
### FASE 1: Bajo Riesgo (Ejecutar Primero)
1. **Redis**: Remover dependencia no usada
2. **Zod**: Ajustes menores de sintaxis (message → error)
### FASE 2: Riesgo Medio
3. **Jest/Vitest**: Actualizar testing frameworks
4. **Stripe**: Actualizar con testing exhaustivo
### FASE 3: Opcional
5. Otras dependencias menores identificadas
---
## Prompts para Subagentes
### Subagente 1: Migración Zod
```
TAREA: Migrar schemas de Zod en gamilit/apps/frontend
1. Ejecutar: npx zod-v3-to-v4 --dry-run primero
2. Buscar patrones: { message: en archivos *Schema*.ts
3. Reemplazar { message: por { error: en z.enum() y similares
4. NO cambiar .refine() - esos siguen usando message
5. Verificar: npm run build && npm test
Archivos clave:
- src/shared/schemas/auth.schemas.ts
- src/features/mechanics/**/\*Schemas.ts
- src/services/api/schemas/*.ts
```
### Subagente 2: Limpieza Redis
```
TAREA: Remover dependencia redis no usada en trading-platform/apps/backend
1. Verificar que no hay imports de 'redis' en src/
2. Remover "redis": "^4.6.10" de package.json
3. Ejecutar npm install
4. Verificar build: npm run build
```
### Subagente 3: Migración Jest
```
TAREA: Migrar Jest 29→30 en trading-platform/apps/backend
1. Actualizar package.json:
- "jest": "^30.0.0"
- "ts-jest": "^29.3.0" (compatible con jest 30)
- "@types/jest": "^30.0.0"
2. Buscar y reemplazar en __tests__/:
- genMockFromModule → createMockFromModule
- toBeCalled() → toHaveBeenCalled()
3. Actualizar jest.config.js si necesario
4. Ejecutar: npm test -- -u (actualizar snapshots)
5. Verificar todos los tests pasan
```
### Subagente 4: Migración Stripe
```
TAREA: Migrar Stripe 14→20 en trading-platform/apps/backend
1. Actualizar package.json: "stripe": "^20.0.0"
2. npm install
3. Actualizar src/modules/payments/services/stripe.service.ts:
- Cambiar apiVersion: '2023-10-16' → '2024-12-18.acacia'
4. Verificar que estos métodos siguen funcionando:
- stripe.customers.create()
- stripe.checkout.sessions.create()
- stripe.subscriptions.update()
- stripe.billingPortal.sessions.create()
- stripe.webhooks.constructEvent()
5. npm run build
6. Documentar cualquier cambio de API necesario
```
---
## Validación Post-Migración
Para cada proyecto:
```bash
# 1. Limpiar node_modules
rm -rf node_modules package-lock.json
npm install
# 2. Verificar vulnerabilidades
npm audit
# 3. Build
npm run build
# 4. Lint
npm run lint
# 5. Tests
npm test
# 6. Dev server (verificación manual)
npm run dev
```
---
## Decisión Final
| Dependencia | Acción | Prioridad | Subagente |
|-------------|--------|-----------|-----------|
| redis | REMOVER | Alta | #2 |
| zod | AJUSTAR | Media | #1 |
| jest | ACTUALIZAR | Media | #3 |
| stripe | ACTUALIZAR | Baja | #4 |
| vitest | POSTERGAR | Baja | - |
**Nota**: Stripe se posterga porque el riesgo de romper pagos en producción es alto y requiere testing exhaustivo con sandbox.

View File

@ -67,6 +67,29 @@ cd workspace
cat SETUP.md
```
### Usar el script de desarrollo
```bash
# Ver comandos disponibles
./devtools/scripts/dev.sh help
# Ver estado del workspace
./devtools/scripts/dev.sh status
# Iniciar servicios Docker (PostgreSQL, Redis, etc.)
./devtools/scripts/dev.sh docker-up
# Iniciar un proyecto
./devtools/scripts/dev.sh start gamilit
./devtools/scripts/dev.sh start trading
./devtools/scripts/dev.sh start mecanicas
# Instalar todas las dependencias
./devtools/scripts/dev.sh install
# Ver asignación de puertos
./devtools/scripts/dev.sh ports
```
### Para trabajar en un proyecto existente
```bash
cd ~/workspace/projects/<proyecto>
@ -82,6 +105,21 @@ cat orchestration/00-guidelines/CONTEXTO-PROYECTO.md
./devtools/scripts/bootstrap-project.sh <nombre> <tipo>
```
## Proyectos Activos
| Proyecto | Estado | Backend | Frontend |
|----------|--------|---------|----------|
| **Gamilit** | MVP 60% | NestJS :3000 | React :5173 |
| **Trading Platform** | 50% | Express :3001 | React :5174 |
| **ERP Suite** | 35% | Express :3010+ | React :5175 |
| **Mecánicas Diesel** | MVP 95% | Express :3011 | - |
## CI/CD
GitHub Actions configurados en `.github/workflows/`:
- **ci.yml** - Lint, test, build por proyecto
- **docker-build.yml** - Construcción de imágenes Docker
## Documentación
- **Sistema de Agentes:** `core/orchestration/README.md`

View File

@ -8,44 +8,112 @@ El directorio `core/` contiene todo lo que se comparte a nivel de **fábrica**,
- Módulos de código reutilizables
- Estándares técnicos y de negocio
- Directivas globales para agentes/subagentes
- Constantes y tipos universales
## Estructura
```
core/
├── orchestration/ # Sistema de agentes unificado
│ ├── agents/ # Prompts de agentes NEXUS
│ ├── directivas/ # Directivas globales (33+)
│ ├── templates/ # Templates para subagentes
│ ├── referencias/ # Contextos y paths globales
│ └── claude/ # Configuración Claude Code
├── modules/ # Código compartido ejecutable
│ ├── utils/ # Utilidades universales ✅
│ │ ├── date.util.ts # Manipulación de fechas
│ │ ├── string.util.ts # Manipulación de strings
│ │ ├── validation.util.ts # Validaciones
│ │ └── index.ts
│ ├── auth/ # Autenticación (por implementar)
│ ├── billing/ # Facturación
│ ├── notifications/ # Notificaciones
│ ├── payments/ # Pagos
│ └── multitenant/ # Multi-tenancy
├── modules/ # Módulos de código reutilizables
│ ├── auth/ # Autenticación/autorización
│ ├── billing/ # Facturación y billing
│ ├── payments/ # Integración de pagos
│ ├── notifications/ # Sistema de notificaciones
│ └── multitenant/ # Soporte multi-tenant
├── constants/ # Constantes globales ✅
│ ├── enums.constants.ts # Enums universales
│ ├── regex.constants.ts # Patrones regex
│ └── index.ts
├── types/ # Tipos TypeScript compartidos ✅
│ ├── api.types.ts # Tipos de API
│ ├── common.types.ts # Tipos comunes
│ └── index.ts
├── catalog/ # Documentación de funcionalidades
│ ├── auth/
│ ├── notifications/
│ └── ...
├── orchestration/ # Sistema de agentes NEXUS
│ ├── agents/
│ ├── directivas/
│ ├── templates/
│ └── referencias/
└── standards/ # Estándares técnicos globales
├── CODING-STANDARDS.md
├── TESTING-STANDARDS.md
├── API-STANDARDS.md
└── DATABASE-STANDARDS.md
└── ...
```
## Uso
### Sistema de Orquestación
Los agentes cargan automáticamente las directivas de `core/orchestration/directivas/` al inicializar. Cada proyecto puede extender (pero no reducir) estas directivas.
### Importar Utilidades
### Módulos Reutilizables
Los módulos en `core/modules/` son dependencias compartidas que pueden ser importadas por cualquier proyecto.
```typescript
// En cualquier proyecto del workspace
import { formatDate, slugify, isEmail } from '@core/modules/utils';
### Estándares
Los estándares en `core/standards/` definen los mínimos de calidad para todos los proyectos.
// O importar específico
import { formatToISO, addDays } from '@core/modules/utils/date.util';
```
### Importar Constantes
```typescript
import { UserStatus, PaymentStatus } from '@core/constants';
import { EMAIL_REGEX, UUID_REGEX } from '@core/constants/regex.constants';
```
### Importar Tipos
```typescript
import { ApiResponse, PaginatedResponse } from '@core/types';
import { BaseEntity, Address } from '@core/types/common.types';
```
## Módulos Disponibles
### Utils (`@core/modules/utils`)
| Archivo | Funciones | Descripción |
|---------|-----------|-------------|
| `date.util.ts` | formatDate, addDays, diffInDays, etc. | Manipulación de fechas |
| `string.util.ts` | slugify, capitalize, truncate, etc. | Manipulación de strings |
| `validation.util.ts` | isEmail, isUUID, isStrongPassword, etc. | Validaciones |
### Constants (`@core/constants`)
| Archivo | Contenido |
|---------|-----------|
| `enums.constants.ts` | UserStatus, PaymentStatus, NotificationType, etc. |
| `regex.constants.ts` | EMAIL_REGEX, UUID_REGEX, PHONE_REGEX, etc. |
### Types (`@core/types`)
| Archivo | Tipos |
|---------|-------|
| `api.types.ts` | ApiResponse, PaginatedResponse, ErrorCodes |
| `common.types.ts` | BaseEntity, Address, Money, Result |
## Proyectos que Usan Core
- **Gamilit** - Plataforma educativa de gamificación
- **Trading Platform** - OrbiQuant IA trading
- **ERP Suite** - Sistema ERP multi-vertical
## Sistema de Orquestación
Los agentes cargan automáticamente las directivas de `core/orchestration/directivas/` al inicializar.
## Ver También
- [Sistema de Orquestación](orchestration/README.md)
- [Directivas Principales](orchestration/directivas/DIRECTIVAS-PRINCIPALES.md)
- [Catálogo de Funcionalidades](catalog/README.md)
- [Plan de Organización](../PLAN-ORGANIZACION-WORKSPACE.md)

29
core/modules/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "@core/modules",
"version": "1.0.0",
"description": "Core modules compartidos para todos los proyectos del workspace",
"main": "index.ts",
"types": "index.ts",
"scripts": {
"build": "tsc",
"lint": "eslint . --ext .ts",
"test": "jest"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
},
"exports": {
"./utils": "./utils/index.ts",
"./utils/*": "./utils/*.ts"
},
"keywords": [
"core",
"utils",
"shared",
"workspace"
],
"author": "ISEM Team",
"license": "PROPRIETARY"
}

317
core/types/api.types.ts Normal file
View File

@ -0,0 +1,317 @@
/**
* API Types - Core Module
*
* Tipos compartidos para APIs REST en todos los proyectos.
*
* @module @core/types/api
* @version 1.0.0
*/
import { SortDirection } from '../constants/enums.constants';
// ============================================================================
// API RESPONSE TYPES
// ============================================================================
/**
* Standard API response wrapper
*/
export interface ApiResponse<T = unknown> {
success: boolean;
data: T;
message?: string;
timestamp: string;
path?: string;
requestId?: string;
}
/**
* API error response
*/
export interface ApiError {
success: false;
error: {
code: string;
message: string;
details?: Record<string, unknown>;
stack?: string; // Only in development
};
timestamp: string;
path?: string;
requestId?: string;
}
/**
* Paginated response
*/
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
pagination: PaginationMeta;
}
/**
* Pagination metadata
*/
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
// ============================================================================
// REQUEST TYPES
// ============================================================================
/**
* Pagination query parameters
*/
export interface PaginationParams {
page?: number;
limit?: number;
}
/**
* Sort query parameters
*/
export interface SortParams {
sortBy?: string;
sortOrder?: SortDirection;
}
/**
* Combined query parameters
*/
export interface QueryParams extends PaginationParams, SortParams {
search?: string;
filter?: Record<string, unknown>;
}
/**
* Date range filter
*/
export interface DateRangeFilter {
startDate?: string;
endDate?: string;
}
// ============================================================================
// CRUD OPERATIONS
// ============================================================================
/**
* Create operation result
*/
export interface CreateResult<T> {
created: T;
message?: string;
}
/**
* Update operation result
*/
export interface UpdateResult<T> {
updated: T;
message?: string;
}
/**
* Delete operation result
*/
export interface DeleteResult {
deleted: boolean;
id: string;
message?: string;
}
/**
* Bulk operation result
*/
export interface BulkResult {
success: number;
failed: number;
errors?: Array<{
id: string;
error: string;
}>;
}
// ============================================================================
// HEALTH CHECK
// ============================================================================
/**
* Health check response
*/
export interface HealthCheckResponse {
status: 'healthy' | 'degraded' | 'unhealthy';
timestamp: string;
version: string;
uptime: number;
services?: Record<string, ServiceHealth>;
}
/**
* Individual service health
*/
export interface ServiceHealth {
status: 'up' | 'down' | 'degraded';
latency?: number;
message?: string;
}
// ============================================================================
// ERROR CODES
// ============================================================================
/**
* Standard error codes
*/
export const ErrorCodes = {
// Client errors (4xx)
BAD_REQUEST: 'BAD_REQUEST',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
METHOD_NOT_ALLOWED: 'METHOD_NOT_ALLOWED',
CONFLICT: 'CONFLICT',
UNPROCESSABLE_ENTITY: 'UNPROCESSABLE_ENTITY',
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
// Server errors (5xx)
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
GATEWAY_TIMEOUT: 'GATEWAY_TIMEOUT',
// Validation errors
VALIDATION_ERROR: 'VALIDATION_ERROR',
INVALID_INPUT: 'INVALID_INPUT',
MISSING_FIELD: 'MISSING_FIELD',
// Auth errors
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
TOKEN_INVALID: 'TOKEN_INVALID',
SESSION_EXPIRED: 'SESSION_EXPIRED',
// Business logic errors
RESOURCE_EXISTS: 'RESOURCE_EXISTS',
RESOURCE_LOCKED: 'RESOURCE_LOCKED',
OPERATION_FAILED: 'OPERATION_FAILED',
LIMIT_EXCEEDED: 'LIMIT_EXCEEDED',
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
// ============================================================================
// HTTP STATUS
// ============================================================================
/**
* HTTP status codes mapping
*/
export const HttpStatus = {
OK: 200,
CREATED: 201,
ACCEPTED: 202,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
} as const;
export type HttpStatusCode = (typeof HttpStatus)[keyof typeof HttpStatus];
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Create success response
*/
export const createSuccessResponse = <T>(
data: T,
message?: string,
): ApiResponse<T> => ({
success: true,
data,
message,
timestamp: new Date().toISOString(),
});
/**
* Create error response
*/
export const createErrorResponse = (
code: ErrorCode,
message: string,
details?: Record<string, unknown>,
): ApiError => ({
success: false,
error: {
code,
message,
details,
},
timestamp: new Date().toISOString(),
});
/**
* Create paginated response
*/
export const createPaginatedResponse = <T>(
data: T[],
page: number,
limit: number,
total: number,
): PaginatedResponse<T> => {
const totalPages = Math.ceil(total / limit);
return {
success: true,
data,
timestamp: new Date().toISOString(),
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
};
};
/**
* Calculate pagination offset
*/
export const calculateOffset = (page: number, limit: number): number => {
return (page - 1) * limit;
};
/**
* Normalize pagination params
*/
export const normalizePagination = (
params: PaginationParams,
defaults: { page: number; limit: number; maxLimit: number } = {
page: 1,
limit: 20,
maxLimit: 100,
},
): Required<PaginationParams> => ({
page: Math.max(1, params.page || defaults.page),
limit: Math.min(
defaults.maxLimit,
Math.max(1, params.limit || defaults.limit),
),
});

374
core/types/common.types.ts Normal file
View File

@ -0,0 +1,374 @@
/**
* Common Types - Core Module
*
* Tipos comunes compartidos entre todos los proyectos.
*
* @module @core/types/common
* @version 1.0.0
*/
// ============================================================================
// BASE ENTITY TYPES
// ============================================================================
/**
* Base entity with standard fields
*/
export interface BaseEntity {
id: string;
createdAt: Date | string;
updatedAt: Date | string;
}
/**
* Soft-deletable entity
*/
export interface SoftDeletableEntity extends BaseEntity {
deletedAt?: Date | string | null;
isDeleted: boolean;
}
/**
* Auditable entity
*/
export interface AuditableEntity extends BaseEntity {
createdBy?: string;
updatedBy?: string;
}
/**
* Multi-tenant entity
*/
export interface TenantEntity extends BaseEntity {
tenantId: string;
}
/**
* Full featured entity (all mixins)
*/
export interface FullEntity
extends SoftDeletableEntity,
AuditableEntity,
TenantEntity {}
// ============================================================================
// USER TYPES
// ============================================================================
/**
* Base user information
*/
export interface BaseUser {
id: string;
email: string;
name: string;
avatar?: string;
}
/**
* User with authentication info
*/
export interface AuthUser extends BaseUser {
role: string;
permissions?: string[];
isEmailVerified: boolean;
lastLoginAt?: Date | string;
}
/**
* User profile
*/
export interface UserProfile extends BaseUser {
firstName?: string;
lastName?: string;
phone?: string;
timezone?: string;
locale?: string;
bio?: string;
}
// ============================================================================
// ADDRESS & LOCATION
// ============================================================================
/**
* Physical address
*/
export interface Address {
street?: string;
number?: string;
interior?: string;
neighborhood?: string;
city: string;
state: string;
country: string;
postalCode: string;
latitude?: number;
longitude?: number;
}
/**
* Geographic coordinates
*/
export interface GeoLocation {
latitude: number;
longitude: number;
accuracy?: number;
}
// ============================================================================
// CONTACT INFORMATION
// ============================================================================
/**
* Contact information
*/
export interface ContactInfo {
email?: string;
phone?: string;
mobile?: string;
fax?: string;
website?: string;
}
/**
* Social media links
*/
export interface SocialLinks {
facebook?: string;
twitter?: string;
instagram?: string;
linkedin?: string;
youtube?: string;
tiktok?: string;
}
// ============================================================================
// FILE & MEDIA
// ============================================================================
/**
* File information
*/
export interface FileInfo {
id: string;
name: string;
originalName: string;
mimeType: string;
size: number;
url: string;
path?: string;
}
/**
* Image with dimensions
*/
export interface ImageInfo extends FileInfo {
width: number;
height: number;
thumbnailUrl?: string;
}
/**
* Upload result
*/
export interface UploadResult {
success: boolean;
file?: FileInfo;
error?: string;
}
// ============================================================================
// MONEY & CURRENCY
// ============================================================================
/**
* Monetary amount
*/
export interface Money {
amount: number;
currency: string;
}
/**
* Price with optional tax
*/
export interface Price extends Money {
taxAmount?: number;
taxRate?: number;
totalAmount: number;
}
// ============================================================================
// DATE & TIME
// ============================================================================
/**
* Date range
*/
export interface DateRange {
start: Date | string;
end: Date | string;
}
/**
* Time slot
*/
export interface TimeSlot {
startTime: string; // HH:mm
endTime: string; // HH:mm
}
/**
* Schedule (day with time slots)
*/
export interface DaySchedule {
dayOfWeek: number; // 0-6, where 0 is Sunday
isOpen: boolean;
slots: TimeSlot[];
}
// ============================================================================
// METADATA
// ============================================================================
/**
* Generic metadata
*/
export type Metadata = Record<string, unknown>;
/**
* SEO metadata
*/
export interface SEOMetadata {
title?: string;
description?: string;
keywords?: string[];
ogImage?: string;
canonicalUrl?: string;
}
// ============================================================================
// KEY-VALUE PAIRS
// ============================================================================
/**
* Simple key-value pair
*/
export interface KeyValue<T = string> {
key: string;
value: T;
}
/**
* Option for select/dropdown
*/
export interface SelectOption<T = string> {
label: string;
value: T;
disabled?: boolean;
description?: string;
}
/**
* Tree node (hierarchical data)
*/
export interface TreeNode<T = unknown> {
id: string;
label: string;
data?: T;
children?: TreeNode<T>[];
parentId?: string;
}
// ============================================================================
// UTILITY TYPES
// ============================================================================
/**
* Make all properties optional except specified keys
*/
export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
Pick<T, K>;
/**
* Make specified properties required
*/
export type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
/**
* Make all properties nullable
*/
export type Nullable<T> = { [K in keyof T]: T[K] | null };
/**
* Deep partial (all nested properties optional)
*/
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
/**
* Remove readonly from all properties
*/
export type Mutable<T> = { -readonly [P in keyof T]: T[P] };
/**
* Extract non-function properties
*/
export type DataOnly<T> = {
[K in keyof T as T[K] extends Function ? never : K]: T[K];
};
/**
* Async function type
*/
export type AsyncFunction<T = void, Args extends unknown[] = []> = (
...args: Args
) => Promise<T>;
/**
* Constructor type
*/
export type Constructor<T = object> = new (...args: unknown[]) => T;
// ============================================================================
// RESULT TYPES
// ============================================================================
/**
* Result type for operations that can fail
*/
export type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
/**
* Create success result
*/
export const success = <T>(data: T): Result<T, never> => ({
success: true,
data,
});
/**
* Create failure result
*/
export const failure = <E>(error: E): Result<never, E> => ({
success: false,
error,
});
/**
* Check if result is success
*/
export const isSuccess = <T, E>(
result: Result<T, E>,
): result is { success: true; data: T } => result.success;
/**
* Check if result is failure
*/
export const isFailure = <T, E>(
result: Result<T, E>,
): result is { success: false; error: E } => !result.success;

11
core/types/index.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Core Types Module
*
* Type definitions shared across all projects in the workspace.
*
* @module @core/types
* @version 1.0.0
*/
export * from './api.types';
export * from './common.types';

View File

@ -1,27 +1,54 @@
# DevTools - Herramientas de Desarrollo
## Descripción
Este directorio contiene scripts, templates y configuraciones para automatizar tareas comunes del workspace.
Development tools, configurations, and scripts for the ISEM workspace.
## Estructura
```
devtools/
├── scripts/ # Scripts de automatización
├── configs/ # Shared configurations
│ ├── eslint.config.base.js # ESLint base configuration
│ ├── prettier.config.js # Prettier configuration
│ ├── tsconfig.base.json # TypeScript base configuration
│ └── jest.config.base.js # Jest base configuration
├── docker/ # Docker utilities
│ └── postgres-init/ # PostgreSQL initialization scripts
├── scripts/ # Development scripts
│ ├── dev.sh # Main development helper
│ ├── bootstrap-project.sh # Crear nuevo proyecto
│ ├── validate-structure.sh # Validar estructura
│ └── ...
├── templates/ # Templates reutilizables
│ ├── project-template/ # Template de proyecto
│ └── customer-template/ # Template de cliente
└── docker/ # Configuración Docker
├── docker-compose.dev.yml
└── Dockerfiles/
│ └── validate-structure.sh # Validar estructura
└── templates/ # Project templates
```
## Scripts Disponibles
### dev.sh - Script principal de desarrollo
```bash
# Ver comandos disponibles
./scripts/dev.sh help
# Ver estado del workspace
./scripts/dev.sh status
# Iniciar servicios Docker
./scripts/dev.sh docker-up
# Iniciar un proyecto
./scripts/dev.sh start gamilit
./scripts/dev.sh start trading
./scripts/dev.sh start mecanicas
# Instalar dependencias
./scripts/dev.sh install
# Ver puertos asignados
./scripts/dev.sh ports
# Lint de proyectos
./scripts/dev.sh lint gamilit
```
### bootstrap-project.sh
Crea un nuevo proyecto con estructura estándar.
@ -70,22 +97,64 @@ Template para implementaciones de clientes. Contiene:
- Archivos de personalización
- Documentación del cliente
## Docker
## Configuraciones Compartidas
### docker-compose.dev.yml
Configuración Docker Compose para desarrollo local:
- PostgreSQL
- Redis (opcional)
- ChromaDB (para RAG)
```bash
# Levantar servicios
cd docker && docker-compose -f docker-compose.dev.yml up -d
# Detener servicios
docker-compose -f docker-compose.dev.yml down
### ESLint (eslint.config.js)
```javascript
import baseConfig from '../../../devtools/configs/eslint.config.base.js';
export default [...baseConfig];
```
### Prettier (.prettierrc.js)
```javascript
module.exports = require('../../../devtools/configs/prettier.config.js');
```
### TypeScript (tsconfig.json)
```json
{
"extends": "../../../devtools/configs/tsconfig.base.json",
"compilerOptions": { "outDir": "./dist", "rootDir": "./src" }
}
```
## Docker
Docker Compose principal en la raíz del workspace (`/docker-compose.yml`):
```bash
# Levantar todos los servicios
docker-compose up -d
# Ver logs
docker-compose logs -f
# Detener servicios
docker-compose down
```
Servicios incluidos:
- **PostgreSQL 15** - Base de datos principal (multi-database)
- **TimescaleDB** - Series temporales para Trading Platform
- **Redis 7** - Cache y sesiones
- **MinIO** - Almacenamiento S3-compatible
- **Mailhog** - Testing de emails
- **Adminer** - UI de administración de BD
## Asignación de Puertos
| Proyecto | Servicio | Puerto |
|----------|----------|--------|
| Gamilit | Backend | 3000 |
| Gamilit | Frontend | 5173 |
| Trading | Backend | 3001 |
| Trading | Frontend | 5174 |
| Trading | Data Service | 8001 |
| ERP Core | Backend | 3010 |
| Mecánicas | Backend | 3011 |
| Shared | PostgreSQL | 5432 |
| Shared | TimescaleDB | 5433 |
| Shared | Redis | 6379 |
---
*DevTools del Workspace de Fábrica de Software*
*DevTools del Workspace ISEM*

View File

@ -0,0 +1,60 @@
/**
* ESLint Base Configuration
* Shared across all TypeScript projects in the workspace
*
* Usage in project:
* ```js
* // eslint.config.js
* import baseConfig from '../../../devtools/configs/eslint.config.base.js';
* export default [...baseConfig];
* ```
*/
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettierConfig from 'eslint-config-prettier';
export default [
js.configs.recommended,
...tseslint.configs.recommended,
prettierConfig,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
},
rules: {
// TypeScript specific
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
// General
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
'no-debugger': 'error',
'prefer-const': 'error',
'no-var': 'error',
'eqeqeq': ['error', 'always'],
// Import ordering (if using import plugin)
// 'import/order': ['error', { 'newlines-between': 'always' }],
},
},
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/.next/**',
'**/coverage/**',
'**/*.js',
'**/*.d.ts',
],
},
];

View File

@ -0,0 +1,88 @@
/**
* Jest Base Configuration
* Shared across all TypeScript projects
*
* Usage in project:
* ```js
* // jest.config.js
* const baseConfig = require('../../../devtools/configs/jest.config.base.js');
* module.exports = {
* ...baseConfig,
* roots: ['<rootDir>/src'],
* };
* ```
*/
module.exports = {
// TypeScript support
preset: 'ts-jest',
testEnvironment: 'node',
// Test patterns
testMatch: [
'**/__tests__/**/*.ts',
'**/*.spec.ts',
'**/*.test.ts',
],
// Module resolution
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
// Path aliases (override in project config)
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@modules/(.*)$': '<rootDir>/src/modules/$1',
'^@shared/(.*)$': '<rootDir>/src/shared/$1',
'^@config/(.*)$': '<rootDir>/src/config/$1',
},
// Coverage
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.spec.ts',
'!src/**/*.test.ts',
'!src/**/index.ts',
'!src/main.ts',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
// Transform
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'tsconfig.json',
}],
},
// Setup
setupFilesAfterEnv: [],
// Timeouts
testTimeout: 10000,
// Clear mocks between tests
clearMocks: true,
restoreMocks: true,
// Verbose output
verbose: true,
// Ignore patterns
testPathIgnorePatterns: [
'/node_modules/',
'/dist/',
'/build/',
],
transformIgnorePatterns: [
'/node_modules/',
],
};

View File

@ -0,0 +1,68 @@
/**
* Prettier Configuration
* Shared across all projects in the workspace
*
* Usage in project:
* Create .prettierrc.js with:
* module.exports = require('../../../devtools/configs/prettier.config.js');
*/
module.exports = {
// Line width
printWidth: 100,
tabWidth: 2,
useTabs: false,
// Quotes
singleQuote: true,
jsxSingleQuote: false,
// Semicolons
semi: true,
// Trailing commas
trailingComma: 'es5',
// Brackets
bracketSpacing: true,
bracketSameLine: false,
// Arrow functions
arrowParens: 'avoid',
// End of line
endOfLine: 'lf',
// Prose wrap (for markdown)
proseWrap: 'preserve',
// HTML whitespace
htmlWhitespaceSensitivity: 'css',
// Embedded language formatting
embeddedLanguageFormatting: 'auto',
// Overrides for specific file types
overrides: [
{
files: '*.json',
options: {
printWidth: 80,
},
},
{
files: '*.md',
options: {
proseWrap: 'always',
printWidth: 80,
},
},
{
files: ['*.yml', '*.yaml'],
options: {
tabWidth: 2,
singleQuote: false,
},
},
],
};

View File

@ -0,0 +1,55 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
// Language and Environment
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
// Strictness
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": false,
"noImplicitThis": true,
"alwaysStrict": true,
// Code Quality
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Interop
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
// Emit
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": false,
// Decorators (for TypeORM, NestJS)
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
// Skip lib check for faster builds
"skipLibCheck": true
},
"exclude": [
"node_modules",
"dist",
"build",
"coverage",
"**/*.spec.ts",
"**/*.test.ts"
]
}

View File

@ -0,0 +1,25 @@
#!/bin/bash
# =============================================================================
# Create multiple databases for development
# =============================================================================
set -e
set -u
function create_database() {
local database=$1
echo "Creating database: $database"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
CREATE DATABASE $database;
GRANT ALL PRIVILEGES ON DATABASE $database TO $POSTGRES_USER;
EOSQL
}
# Create databases if POSTGRES_MULTIPLE_DATABASES is set
if [ -n "${POSTGRES_MULTIPLE_DATABASES:-}" ]; then
echo "Creating multiple databases: $POSTGRES_MULTIPLE_DATABASES"
for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do
create_database $db
done
echo "Multiple databases created"
fi

335
devtools/scripts/dev.sh Executable file
View File

@ -0,0 +1,335 @@
#!/bin/bash
# =============================================================================
# Development Helper Script
# ISEM Workspace - Multi-project development tool
# =============================================================================
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Workspace root
WORKSPACE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# =============================================================================
# Helper Functions
# =============================================================================
print_header() {
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BLUE} $1${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}$1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
print_info() {
echo -e "${BLUE} $1${NC}"
}
# =============================================================================
# Commands
# =============================================================================
cmd_help() {
print_header "ISEM Workspace Developer Tools"
echo ""
echo "Usage: ./dev.sh <command> [options]"
echo ""
echo "Commands:"
echo " status Show status of all projects"
echo " start <project> Start development server for a project"
echo " stop <project> Stop development server"
echo " build <project> Build a project"
echo " test <project> Run tests for a project"
echo " lint <project> Lint a project"
echo " install Install all dependencies"
echo " docker-up Start all Docker services"
echo " docker-down Stop all Docker services"
echo " db-reset Reset development databases"
echo " ports Show port usage"
echo ""
echo "Projects:"
echo " gamilit Gamilit platform (backend + frontend)"
echo " trading Trading Platform (all services)"
echo " erp-core ERP Core"
echo " mecanicas Mecánicas Diesel vertical"
echo " all All projects"
echo ""
echo "Examples:"
echo " ./dev.sh start gamilit"
echo " ./dev.sh build trading"
echo " ./dev.sh docker-up"
}
cmd_status() {
print_header "Workspace Status"
echo ""
echo "📦 Projects:"
echo ""
# Gamilit
if [ -d "$WORKSPACE_ROOT/projects/gamilit" ]; then
echo -e " ${GREEN}${NC} Gamilit"
if [ -f "$WORKSPACE_ROOT/projects/gamilit/apps/backend/package.json" ]; then
echo " Backend: $(cd $WORKSPACE_ROOT/projects/gamilit/apps/backend && node -p "require('./package.json').version" 2>/dev/null || echo 'N/A')"
fi
fi
# Trading Platform
if [ -d "$WORKSPACE_ROOT/projects/trading-platform" ]; then
echo -e " ${GREEN}${NC} Trading Platform"
echo " Apps: backend, frontend, data-service, trading-agents, llm-agent"
fi
# ERP Suite
if [ -d "$WORKSPACE_ROOT/projects/erp-suite" ]; then
echo -e " ${GREEN}${NC} ERP Suite"
echo " Verticales: mecanicas-diesel, construccion, clinicas, retail, vidrio-templado"
fi
echo ""
echo "🔌 Port Assignments:"
cmd_ports_internal
}
cmd_ports() {
print_header "Port Usage"
cmd_ports_internal
}
cmd_ports_internal() {
echo ""
echo " Gamilit:"
echo " Backend: 3000"
echo " Frontend: 5173"
echo " Database: 5432"
echo ""
echo " Trading Platform:"
echo " Backend: 3001"
echo " Frontend: 5174"
echo " Data Service: 8001"
echo " Trading Agents: 8002"
echo " LLM Agent: 8003"
echo " Database: 5433"
echo ""
echo " ERP Suite:"
echo " ERP Core Backend: 3010"
echo " ERP Core Frontend: 5175"
echo " Mecánicas Backend: 3011"
echo " Construcción Backend: 3012"
echo " Database: 5434"
}
cmd_start() {
local project=$1
case $project in
gamilit)
print_header "Starting Gamilit"
cd "$WORKSPACE_ROOT/projects/gamilit"
npm run dev
;;
trading)
print_header "Starting Trading Platform"
cd "$WORKSPACE_ROOT/projects/trading-platform"
docker-compose up -d
;;
erp-core)
print_header "Starting ERP Core"
cd "$WORKSPACE_ROOT/projects/erp-suite/apps/erp-core/backend"
npm run dev
;;
mecanicas)
print_header "Starting Mecánicas Diesel"
cd "$WORKSPACE_ROOT/projects/erp-suite/apps/verticales/mecanicas-diesel/backend"
npm run dev
;;
*)
print_error "Unknown project: $project"
echo "Available: gamilit, trading, erp-core, mecanicas"
exit 1
;;
esac
}
cmd_build() {
local project=$1
case $project in
gamilit)
print_header "Building Gamilit"
cd "$WORKSPACE_ROOT/projects/gamilit/apps/backend"
npm run build
cd "$WORKSPACE_ROOT/projects/gamilit/apps/frontend"
npm run build
print_success "Gamilit built successfully"
;;
trading)
print_header "Building Trading Platform"
cd "$WORKSPACE_ROOT/projects/trading-platform/apps/backend"
npm run build
cd "$WORKSPACE_ROOT/projects/trading-platform/apps/frontend"
npm run build
print_success "Trading Platform built successfully"
;;
all)
cmd_build gamilit
cmd_build trading
;;
*)
print_error "Unknown project: $project"
exit 1
;;
esac
}
cmd_install() {
print_header "Installing Dependencies"
# Core modules
if [ -f "$WORKSPACE_ROOT/core/modules/package.json" ]; then
print_info "Installing core/modules..."
cd "$WORKSPACE_ROOT/core/modules"
npm install
fi
# Gamilit
if [ -f "$WORKSPACE_ROOT/projects/gamilit/package.json" ]; then
print_info "Installing Gamilit..."
cd "$WORKSPACE_ROOT/projects/gamilit"
npm install
fi
# Trading Platform
for app in backend frontend; do
if [ -f "$WORKSPACE_ROOT/projects/trading-platform/apps/$app/package.json" ]; then
print_info "Installing Trading Platform $app..."
cd "$WORKSPACE_ROOT/projects/trading-platform/apps/$app"
npm install
fi
done
# Data service (Python)
if [ -f "$WORKSPACE_ROOT/projects/trading-platform/apps/data-service/requirements.txt" ]; then
print_info "Installing Trading Platform data-service..."
cd "$WORKSPACE_ROOT/projects/trading-platform/apps/data-service"
pip install -r requirements.txt
fi
print_success "All dependencies installed"
}
cmd_docker_up() {
print_header "Starting Docker Services"
cd "$WORKSPACE_ROOT"
# Start databases
docker-compose -f docker-compose.yml up -d
print_success "Docker services started"
print_info "Run 'docker-compose logs -f' to see logs"
}
cmd_docker_down() {
print_header "Stopping Docker Services"
cd "$WORKSPACE_ROOT"
docker-compose down
print_success "Docker services stopped"
}
cmd_lint() {
local project=$1
case $project in
gamilit)
print_header "Linting Gamilit"
cd "$WORKSPACE_ROOT/projects/gamilit/apps/backend"
npm run lint
cd "$WORKSPACE_ROOT/projects/gamilit/apps/frontend"
npm run lint
;;
trading)
print_header "Linting Trading Platform"
cd "$WORKSPACE_ROOT/projects/trading-platform/apps/backend"
npm run lint
cd "$WORKSPACE_ROOT/projects/trading-platform/apps/frontend"
npm run lint
;;
all)
cmd_lint gamilit
cmd_lint trading
;;
*)
print_error "Unknown project: $project"
exit 1
;;
esac
print_success "Linting completed"
}
# =============================================================================
# Main
# =============================================================================
main() {
local command=$1
shift || true
case $command in
help|--help|-h|"")
cmd_help
;;
status)
cmd_status
;;
start)
cmd_start "$@"
;;
build)
cmd_build "$@"
;;
install)
cmd_install
;;
docker-up)
cmd_docker_up
;;
docker-down)
cmd_docker_down
;;
ports)
cmd_ports
;;
lint)
cmd_lint "$@"
;;
*)
print_error "Unknown command: $command"
cmd_help
exit 1
;;
esac
}
main "$@"

145
docker-compose.yml Normal file
View File

@ -0,0 +1,145 @@
# =============================================================================
# ISEM Workspace - Development Docker Compose
# =============================================================================
# Shared infrastructure services for all projects
#
# Usage:
# docker-compose up -d # Start all services
# docker-compose up -d postgres # Start only PostgreSQL
# docker-compose logs -f # View logs
# docker-compose down # Stop all services
# =============================================================================
version: '3.8'
services:
# ===========================================================================
# PostgreSQL - Multi-database instance
# ===========================================================================
postgres:
image: postgres:15-alpine
container_name: isem-postgres
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres_dev_2024
POSTGRES_MULTIPLE_DATABASES: gamilit_dev,trading_dev,erp_dev,mecanicas_dev
volumes:
- postgres_data:/var/lib/postgresql/data
- ./devtools/docker/postgres-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- isem-network
# ===========================================================================
# Redis - Cache and sessions
# ===========================================================================
redis:
image: redis:7-alpine
container_name: isem-redis
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- isem-network
# ===========================================================================
# TimescaleDB - Time-series data for Trading Platform
# ===========================================================================
timescaledb:
image: timescale/timescaledb:latest-pg15
container_name: isem-timescaledb
restart: unless-stopped
ports:
- "5433:5432"
environment:
POSTGRES_USER: trading_user
POSTGRES_PASSWORD: trading_dev_2024
POSTGRES_DB: orbiquant_trading
volumes:
- timescale_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U trading_user -d orbiquant_trading"]
interval: 10s
timeout: 5s
retries: 5
networks:
- isem-network
# ===========================================================================
# MinIO - S3-compatible object storage
# ===========================================================================
minio:
image: minio/minio:latest
container_name: isem-minio
restart: unless-stopped
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
networks:
- isem-network
# ===========================================================================
# Mailhog - Email testing
# ===========================================================================
mailhog:
image: mailhog/mailhog:latest
container_name: isem-mailhog
restart: unless-stopped
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- isem-network
# ===========================================================================
# Adminer - Database management UI
# ===========================================================================
adminer:
image: adminer:latest
container_name: isem-adminer
restart: unless-stopped
ports:
- "8080:8080"
environment:
ADMINER_DEFAULT_SERVER: postgres
networks:
- isem-network
depends_on:
- postgres
networks:
isem-network:
driver: bridge
name: isem-network
volumes:
postgres_data:
redis_data:
timescale_data:
minio_data:

View File

@ -2,9 +2,10 @@
# INVENTARIO COMPLETO DE OBJETOS DE BASE DE DATOS
# ERP GENERIC - PostgreSQL 15+
# ============================================================================
# Fecha: 2025-11-24
# Fecha: 2025-12-09 (Actualizado)
# Propósito: Inventario exhaustivo de todos los objetos de BD extraídos de DDL
# Schemas: auth, core, analytics, financial, inventory, purchase, sales, projects, system
# Schemas: auth, core, analytics, financial, inventory, purchase, sales, projects, system, billing, crm, hr
# Total: 12 schemas, 144 tablas
# ============================================================================
prerequisites:
@ -1711,16 +1712,16 @@ system:
views: []
# ============================================================================
# RESUMEN DE INVENTARIO
# RESUMEN DE INVENTARIO (ACTUALIZADO 2025-12-09)
# ============================================================================
summary:
total_schemas: 9
total_enums: 35
total_tables: 97
total_functions: 43
total_triggers: 78
total_indexes: 200+
total_rls_policies: 50+
total_schemas: 12
total_enums: 49
total_tables: 144
total_functions: 52
total_triggers: 95
total_indexes: 350+
total_rls_policies: 80+
total_views: 8
schemas:
@ -1729,11 +1730,11 @@ summary:
functions: 9
types: 2
- name: auth
tables: 10
functions: 7
tables: 26 # 10 (auth.sql) + 16 (auth-extensions.sql)
functions: 10
enums: 4
- name: core
tables: 11
tables: 12
functions: 3
enums: 4
- name: analytics
@ -1741,12 +1742,12 @@ summary:
functions: 4
enums: 3
- name: financial
tables: 14
tables: 15
functions: 4
enums: 10
- name: inventory
tables: 10
functions: 5
tables: 20 # 10 (inventory.sql) + 10 (inventory-extensions.sql)
functions: 8
enums: 6
- name: purchase
tables: 8
@ -1764,7 +1765,405 @@ summary:
tables: 13
functions: 4
enums: 7
- name: billing
tables: 11
functions: 3
enums: 5
- name: crm
tables: 6
functions: 0
enums: 4
- name: hr
tables: 6
functions: 0
enums: 5
# ============================================================================
# SCHEMA: billing (NUEVO - SaaS/Multi-tenant)
# DDL: 10-billing.sql - 11 tablas
# ============================================================================
billing:
enums:
- name: subscription_status
values: [trialing, active, past_due, paused, cancelled, suspended, expired]
- name: billing_cycle
values: [monthly, quarterly, semi_annual, annual]
- name: payment_method_type
values: [card, bank_transfer, paypal, oxxo, spei, other]
- name: invoice_status
values: [draft, open, paid, void, uncollectible]
- name: payment_status
values: [pending, processing, succeeded, failed, cancelled, refunded]
tables:
- name: subscription_plans
columns: [id, code, name, description, price_monthly, price_yearly, currency_code, max_users, max_companies, max_storage_gb, max_api_calls_month, features, is_active, is_public, is_default, trial_days, sort_order, created_at, created_by, updated_at, updated_by]
foreign_keys: []
note: "Planes globales (no por tenant)"
- name: tenant_owners
columns: [id, tenant_id, user_id, ownership_type, billing_email, billing_phone, billing_name, created_at, created_by]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: user_id
references: auth.users(id)
- name: subscriptions
columns: [id, tenant_id, plan_id, status, billing_cycle, trial_start_at, trial_end_at, current_period_start, current_period_end, cancelled_at, cancel_at_period_end, paused_at, discount_percent, coupon_code, stripe_subscription_id, stripe_customer_id, created_at, created_by, updated_at, updated_by]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: plan_id
references: billing.subscription_plans(id)
- name: payment_methods
columns: [id, tenant_id, type, is_default, card_last_four, card_brand, card_exp_month, card_exp_year, billing_name, billing_email, billing_address_line1, billing_address_line2, billing_city, billing_state, billing_postal_code, billing_country, stripe_payment_method_id, created_at, created_by, updated_at, deleted_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- name: invoices
columns: [id, tenant_id, subscription_id, invoice_number, status, period_start, period_end, due_date, paid_at, voided_at, subtotal, tax_amount, discount_amount, total, amount_paid, amount_due, currency_code, customer_name, customer_tax_id, customer_email, customer_address, pdf_url, cfdi_uuid, cfdi_xml_url, stripe_invoice_id, notes, created_at, created_by, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: subscription_id
references: billing.subscriptions(id)
- name: invoice_lines
columns: [id, invoice_id, description, quantity, unit_price, amount, period_start, period_end, created_at]
foreign_keys:
- column: invoice_id
references: billing.invoices(id)
- name: payments
columns: [id, tenant_id, invoice_id, payment_method_id, amount, currency_code, status, paid_at, failed_at, refunded_at, failure_reason, failure_code, transaction_id, stripe_payment_intent_id, created_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: invoice_id
references: billing.invoices(id)
- column: payment_method_id
references: billing.payment_methods(id)
- name: usage_records
columns: [id, tenant_id, subscription_id, metric_type, quantity, billing_period, recorded_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: subscription_id
references: billing.subscriptions(id)
- name: coupons
columns: [id, code, name, description, discount_type, discount_value, currency_code, max_redemptions, max_redemptions_per_tenant, redemptions_count, valid_from, valid_until, applicable_plans, is_active, created_at, created_by]
foreign_keys: []
- name: coupon_redemptions
columns: [id, coupon_id, tenant_id, subscription_id, redeemed_at, redeemed_by]
foreign_keys:
- column: coupon_id
references: billing.coupons(id)
- column: tenant_id
references: auth.tenants(id)
- column: subscription_id
references: billing.subscriptions(id)
- name: subscription_history
columns: [id, subscription_id, event_type, previous_plan_id, new_plan_id, previous_status, new_status, metadata, notes, created_at, created_by]
foreign_keys:
- column: subscription_id
references: billing.subscriptions(id)
- column: previous_plan_id
references: billing.subscription_plans(id)
- column: new_plan_id
references: billing.subscription_plans(id)
functions:
- name: get_tenant_plan
purpose: "Obtiene información del plan actual de un tenant"
- name: can_add_user
purpose: "Verifica si el tenant puede agregar más usuarios según su plan"
- name: has_feature
purpose: "Verifica si una feature está habilitada para el tenant"
rls_policies: []
note: "Sin RLS - gestionado a nivel aplicación por razones de seguridad"
# ============================================================================
# SCHEMA: crm (NUEVO - Customer Relationship Management)
# DDL: 11-crm.sql - 6 tablas
# ============================================================================
crm:
enums:
- name: lead_status
values: [new, contacted, qualified, converted, lost]
- name: opportunity_status
values: [open, won, lost]
- name: activity_type
values: [call, email, meeting, task, note]
- name: lead_source
values: [website, phone, email, referral, social_media, advertising, event, other]
tables:
- name: lead_stages
columns: [id, tenant_id, name, sequence, is_won, probability, requirements, active, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- name: opportunity_stages
columns: [id, tenant_id, name, sequence, is_won, probability, requirements, active, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- name: lost_reasons
columns: [id, tenant_id, name, description, active, created_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- name: leads
columns: [id, tenant_id, company_id, name, ref, contact_name, email, phone, mobile, website, company_name, job_position, industry, employee_count, annual_revenue, street, city, state, zip, country, stage_id, status, user_id, sales_team_id, source, campaign_id, medium, priority, probability, expected_revenue, date_open, date_closed, date_deadline, date_last_activity, partner_id, opportunity_id, lost_reason_id, lost_notes, description, notes, tags, created_by, updated_by, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: company_id
references: auth.companies(id)
- column: stage_id
references: crm.lead_stages(id)
- column: user_id
references: auth.users(id)
- column: sales_team_id
references: sales.sales_teams(id)
- column: partner_id
references: core.partners(id)
- column: lost_reason_id
references: crm.lost_reasons(id)
- name: opportunities
columns: [id, tenant_id, company_id, name, ref, partner_id, contact_name, email, phone, stage_id, status, user_id, sales_team_id, priority, probability, expected_revenue, recurring_revenue, recurring_plan, date_deadline, date_closed, date_last_activity, lead_id, source, campaign_id, medium, lost_reason_id, lost_notes, quotation_id, order_id, description, notes, tags, created_by, updated_by, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: company_id
references: auth.companies(id)
- column: partner_id
references: core.partners(id)
- column: stage_id
references: crm.opportunity_stages(id)
- column: user_id
references: auth.users(id)
- column: sales_team_id
references: sales.sales_teams(id)
- column: lead_id
references: crm.leads(id)
- column: lost_reason_id
references: crm.lost_reasons(id)
- column: quotation_id
references: sales.quotations(id)
- column: order_id
references: sales.sales_orders(id)
- name: activities
columns: [id, tenant_id, res_model, res_id, activity_type, summary, description, date_deadline, date_done, user_id, assigned_to, done, created_by, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: user_id
references: auth.users(id)
- column: assigned_to
references: auth.users(id)
rls_policies:
- name: tenant_isolation_lead_stages
table: lead_stages
- name: tenant_isolation_opportunity_stages
table: opportunity_stages
- name: tenant_isolation_lost_reasons
table: lost_reasons
- name: tenant_isolation_leads
table: leads
- name: tenant_isolation_opportunities
table: opportunities
- name: tenant_isolation_crm_activities
table: activities
# ============================================================================
# SCHEMA: hr (NUEVO - Human Resources)
# DDL: 12-hr.sql - 6 tablas
# ============================================================================
hr:
enums:
- name: contract_status
values: [draft, active, expired, terminated, cancelled]
- name: contract_type
values: [permanent, temporary, contractor, internship, part_time]
- name: leave_status
values: [draft, submitted, approved, rejected, cancelled]
- name: leave_type
values: [vacation, sick, personal, maternity, paternity, bereavement, unpaid, other]
- name: employee_status
values: [active, inactive, on_leave, terminated]
tables:
- name: departments
columns: [id, tenant_id, company_id, name, code, parent_id, manager_id, description, color, active, created_by, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: company_id
references: auth.companies(id)
- column: parent_id
references: hr.departments(id)
- column: manager_id
references: hr.employees(id)
- name: job_positions
columns: [id, tenant_id, name, department_id, description, requirements, responsibilities, min_salary, max_salary, active, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: department_id
references: hr.departments(id)
- name: employees
columns: [id, tenant_id, company_id, employee_number, first_name, last_name, middle_name, user_id, birth_date, gender, marital_status, nationality, identification_id, identification_type, social_security_number, tax_id, email, work_email, phone, work_phone, mobile, emergency_contact, emergency_phone, street, city, state, zip, country, department_id, job_position_id, manager_id, hire_date, termination_date, status, bank_name, bank_account, bank_clabe, photo_url, notes, created_by, updated_by, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: company_id
references: auth.companies(id)
- column: user_id
references: auth.users(id)
- column: department_id
references: hr.departments(id)
- column: job_position_id
references: hr.job_positions(id)
- column: manager_id
references: hr.employees(id)
- name: contracts
columns: [id, tenant_id, company_id, employee_id, name, reference, contract_type, status, job_position_id, department_id, date_start, date_end, trial_date_end, wage, wage_type, currency_id, resource_calendar_id, hours_per_week, vacation_days, christmas_bonus_days, document_url, notes, created_by, updated_by, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: company_id
references: auth.companies(id)
- column: employee_id
references: hr.employees(id)
- column: job_position_id
references: hr.job_positions(id)
- column: department_id
references: hr.departments(id)
- column: currency_id
references: core.currencies(id)
- name: leave_types
columns: [id, tenant_id, name, code, leave_type, requires_approval, max_days, is_paid, color, active, created_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- name: leaves
columns: [id, tenant_id, company_id, employee_id, leave_type_id, name, date_from, date_to, number_of_days, status, description, approved_by, approved_at, rejection_reason, created_by, updated_by, created_at, updated_at]
foreign_keys:
- column: tenant_id
references: auth.tenants(id)
- column: company_id
references: auth.companies(id)
- column: employee_id
references: hr.employees(id)
- column: leave_type_id
references: hr.leave_types(id)
- column: approved_by
references: auth.users(id)
rls_policies:
- name: tenant_isolation_departments
table: departments
- name: tenant_isolation_job_positions
table: job_positions
- name: tenant_isolation_employees
table: employees
- name: tenant_isolation_contracts
table: contracts
- name: tenant_isolation_leave_types
table: leave_types
- name: tenant_isolation_leaves
table: leaves
# ============================================================================
# SCHEMA: auth (EXTENSIONES - auth-extensions.sql)
# DDL: 01-auth-extensions.sql - 16 tablas adicionales
# ============================================================================
auth_extensions:
note: "Estas tablas complementan el schema auth base (01-auth.sql)"
tables:
- name: groups
purpose: "Grupos de usuarios para permisos"
- name: group_implied
purpose: "Herencia entre grupos"
- name: user_groups
purpose: "Asignación usuarios a grupos"
- name: models
purpose: "Registro de modelos del sistema"
- name: model_access
purpose: "Permisos CRUD por modelo y grupo"
- name: record_rules
purpose: "Reglas de acceso a nivel registro (domain filters)"
- name: rule_groups
purpose: "Asignación reglas a grupos"
- name: model_fields
purpose: "Campos de modelos"
- name: field_permissions
purpose: "Permisos a nivel campo"
- name: api_keys
purpose: "API Keys para autenticación"
- name: trusted_devices
purpose: "Dispositivos de confianza para 2FA"
- name: verification_codes
purpose: "Códigos de verificación (2FA, email)"
- name: mfa_audit_log
purpose: "Auditoría de operaciones MFA"
- name: oauth_providers
purpose: "Proveedores OAuth2 configurados"
- name: oauth_user_links
purpose: "Vinculación usuarios con cuentas OAuth"
- name: oauth_states
purpose: "Estados temporales para flow OAuth"
# ============================================================================
# SCHEMA: inventory (EXTENSIONES - inventory-extensions.sql)
# DDL: 05-inventory-extensions.sql - 10 tablas adicionales
# ============================================================================
inventory_extensions:
note: "Estas tablas complementan el schema inventory base (05-inventory.sql)"
tables:
- name: stock_valuation_layers
purpose: "Capas de valoración FIFO/AVCO"
- name: category_stock_accounts
purpose: "Cuentas contables por categoría de producto"
- name: valuation_settings
purpose: "Configuración de valoración por empresa"
- name: lots
purpose: "Lotes de productos (trazabilidad)"
- name: stock_move_consume_rel
purpose: "Relación movimientos produce/consume"
- name: removal_strategies
purpose: "Estrategias de remoción (FIFO/LIFO/AVCO)"
- name: inventory_count_sessions
purpose: "Sesiones de conteo cíclico"
- name: inventory_count_lines
purpose: "Líneas de conteo"
- name: abc_classification_rules
purpose: "Reglas de clasificación ABC"
- name: product_abc_classification
purpose: "Clasificación ABC de productos"
# ============================================================================
# FIN DEL INVENTARIO
# Última actualización: 2025-12-09
# Total: 12 schemas, 144 tablas
# ============================================================================

View File

@ -0,0 +1,208 @@
# Guía de Alineación de Verticales con ERP-Core
**Versión:** 1.0.0
**Fecha:** 2025-12-09
**Propósito:** Estándar para mantener verticales alineadas con erp-core
---
## 1. Arquitectura Base
### ERP-Core como Fundación
Todas las verticales heredan de **erp-core** que provee:
| Schema | Tablas | Propósito |
|--------|--------|-----------|
| auth | 26 | Autenticación, MFA, OAuth, API Keys, roles, permisos |
| core | 12 | Partners, catálogos, UoM, monedas, secuencias |
| financial | 15 | Contabilidad, facturas, pagos, asientos |
| inventory | 20 | Productos, stock, valoración FIFO/AVCO, lotes |
| purchase | 8 | Órdenes de compra, proveedores |
| sales | 10 | Ventas, cotizaciones, equipos de venta |
| projects | 10 | Proyectos, tareas, dependencias |
| analytics | 7 | Contabilidad analítica, centros de costo |
| system | 13 | Mensajes, notificaciones, logs, auditoría |
| billing | 11 | SaaS/Suscripciones (opcional) |
| crm | 6 | Leads, oportunidades (opcional) |
| hr | 6 | Empleados, contratos, ausencias |
| **TOTAL** | **144** | |
### Variable RLS Estándar
```sql
current_setting('app.current_tenant_id', true)::UUID
```
**IMPORTANTE:** Todas las verticales DEBEN usar esta variable exacta para RLS.
---
## 2. Estructura de Archivos Requerida
### Estructura Mínima por Vertical
```
apps/verticales/{vertical}/
├── backend/ # Código backend (NestJS/Express)
├── frontend/ # Código frontend (React/Vue)
├── database/
│ ├── HERENCIA-ERP-CORE.md # REQUERIDO: Documento de herencia
│ ├── README.md # Descripción de BD
│ ├── init/ # DDL files (si implementado)
│ │ ├── 00-extensions.sql
│ │ ├── 01-create-schemas.sql
│ │ ├── 02-rls-functions.sql
│ │ └── XX-{schema}-tables.sql
│ └── seeds/ # Datos iniciales
├── docs/ # Documentación del proyecto
└── orchestration/
└── inventarios/ # REQUERIDO: Inventarios YAML
├── MASTER_INVENTORY.yml
├── DATABASE_INVENTORY.yml
├── BACKEND_INVENTORY.yml
├── FRONTEND_INVENTORY.yml
├── DEPENDENCY_GRAPH.yml
└── TRACEABILITY_MATRIX.yml
```
---
## 3. Formato Estándar de DATABASE_INVENTORY.yml
### Sección herencia_core (OBLIGATORIA)
```yaml
herencia_core:
base_de_datos: erp-core
version_core: "1.2.0"
tablas_heredadas: 144 # NO MODIFICAR - valor fijo
schemas_heredados:
- nombre: auth
tablas: 26
uso: "Descripción contextualizada a la vertical"
- nombre: core
tablas: 12
uso: "..."
# ... todos los 12 schemas
referencia_ddl: "apps/erp-core/database/ddl/"
documento_herencia: "../database/HERENCIA-ERP-CORE.md"
variable_rls: "app.current_tenant_id"
```
### Sección schemas_especificos
```yaml
schemas_especificos:
- nombre: {schema_vertical}
descripcion: "Propósito del schema"
estado: PLANIFICADO | EN_DESARROLLO | IMPLEMENTADO
tablas_estimadas: N
modulos_relacionados: [MOD-001, MOD-002]
tablas:
- nombre_tabla_1
- nombre_tabla_2
```
---
## 4. Reglas de Nomenclatura DDL
### Archivos SQL
```
00-extensions.sql # Extensiones PostgreSQL
01-create-schemas.sql # CREATE SCHEMA IF NOT EXISTS
02-rls-functions.sql # Funciones de contexto RLS
03-{dominio}-tables.sql # Tablas por dominio
04-{dominio}-tables.sql
...
99-seed-data.sql # Datos iniciales
```
### Tablas
- Usar snake_case: `service_orders`, `order_items`
- Prefijo de schema obligatorio en FK: `auth.users`, `core.partners`
- Columnas de auditoría estándar:
```sql
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
```
### RLS Policy
```sql
CREATE POLICY tenant_isolation_{tabla} ON {schema}.{tabla}
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
```
---
## 5. Estado de Verticales Actuales
| Vertical | Estado | DDL | Backend | Frontend | Próximo Paso |
|----------|--------|-----|---------|----------|--------------|
| **Construcción** | 35% | ✅ 7 archivos | Parcial | Estructura | Completar backend |
| **Mecánicas Diesel** | 95% | ✅ 6 archivos | Estructura | Estructura | Iniciar Sprint 1 |
| **Clínicas** | 25% | ❌ Pendiente | No iniciado | No iniciado | Crear DDL |
| **Retail** | 25% | ❌ Pendiente | No iniciado | No iniciado | Crear DDL |
| **Vidrio Templado** | 25% | ❌ Pendiente | No iniciado | No iniciado | Crear DDL |
---
## 6. Checklist de Alineación
### Antes de iniciar desarrollo:
- [ ] `herencia_core.tablas_heredadas` = 144
- [ ] `herencia_core.version_core` = "1.2.0"
- [ ] `herencia_core.variable_rls` = "app.current_tenant_id"
- [ ] Los 12 schemas heredados están documentados
- [ ] `HERENCIA-ERP-CORE.md` existe en database/
- [ ] DDL usa `current_setting('app.current_tenant_id', true)::UUID`
- [ ] FK a auth.tenants y auth.users (NO core.*)
### Validación de DDL:
```bash
# Verificar variable RLS correcta
grep -r "current_tenant_id" database/init/*.sql
# Verificar NO usar variable incorrecta
grep -r "current_tenant'" database/init/*.sql # Debe retornar vacío
# Verificar FK correctas
grep -r "auth.tenants" database/init/*.sql
grep -r "auth.users" database/init/*.sql
```
---
## 7. Proceso de Actualización
Cuando erp-core se actualice:
1. Verificar cambios en `INVENTARIO-OBJETOS-BD.yml`
2. Actualizar `tablas_heredadas` si cambió
3. Actualizar `version_core`
4. Revisar si hay nuevos schemas
5. Actualizar `DATABASE_INVENTORY.yml` de cada vertical
6. Verificar compatibilidad de DDL existente
---
## 8. Contacto y Soporte
- **Documentación Core:** `/apps/erp-core/docs/`
- **Inventario Core:** `/apps/erp-core/docs/04-modelado/trazabilidad/INVENTARIO-OBJETOS-BD.yml`
- **DDL Core:** `/apps/erp-core/database/ddl/`
---
**Última actualización:** 2025-12-09
**Mantenido por:** Architecture Analyst Agent

View File

@ -0,0 +1,83 @@
# Base de Datos - ERP Clínicas
## Resumen
| Aspecto | Valor |
|---------|-------|
| **Schema principal** | `clinica` |
| **Tablas específicas** | 13 |
| **ENUMs** | 4 |
| **Hereda de ERP-Core** | 144 tablas (12 schemas) |
## Prerequisitos
1. **ERP-Core instalado** con todos sus schemas:
- auth, core, financial, inventory, purchase, sales, projects, analytics, system, billing, crm, hr
2. **Extensiones PostgreSQL**:
- pgcrypto (encriptación)
- pg_trgm (búsqueda de texto)
## Orden de Ejecución DDL
```bash
# 1. Instalar ERP-Core primero
cd apps/erp-core/database
./scripts/reset-database.sh
# 2. Instalar extensión Clínicas
cd apps/verticales/clinicas/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-clinical-tables.sql
psql $DATABASE_URL -f init/04-seed-data.sql
```
## Tablas Implementadas
### Schema: clinica (13 tablas)
| Tabla | Módulo | Descripción |
|-------|--------|-------------|
| specialties | CL-002 | Catálogo de especialidades médicas |
| doctors | CL-002 | Médicos (extiende hr.employees) |
| patients | CL-001 | Pacientes (extiende core.partners) |
| patient_contacts | CL-001 | Contactos de emergencia |
| patient_insurance | CL-001 | Información de seguros |
| appointment_slots | CL-002 | Horarios disponibles |
| appointments | CL-002 | Citas médicas |
| medical_records | CL-003 | Expediente clínico electrónico |
| consultations | CL-003 | Consultas realizadas |
| vital_signs | CL-003 | Signos vitales |
| diagnoses | CL-003 | Diagnósticos (CIE-10) |
| prescriptions | CL-003 | Recetas médicas |
| prescription_items | CL-003 | Medicamentos en receta |
## ENUMs
| Enum | Valores |
|------|---------|
| appointment_status | scheduled, confirmed, in_progress, completed, cancelled, no_show |
| patient_gender | male, female, other, prefer_not_to_say |
| blood_type | A+, A-, B+, B-, AB+, AB-, O+, O-, unknown |
| consultation_status | draft, in_progress, completed, cancelled |
## Row Level Security
Todas las tablas tienen RLS habilitado con aislamiento por tenant:
```sql
tenant_id = current_setting('app.current_tenant_id', true)::UUID
```
## Consideraciones de Seguridad
- **NOM-024-SSA3-2012**: Expediente clínico electrónico
- **Datos sensibles**: medical_records, consultations requieren encriptación
- **Auditoría completa**: Todas las tablas tienen campos de auditoría
## Referencias
- [HERENCIA-ERP-CORE.md](./HERENCIA-ERP-CORE.md)
- [DATABASE_INVENTORY.yml](../orchestration/inventarios/DATABASE_INVENTORY.yml)

View File

@ -0,0 +1,25 @@
-- ============================================================================
-- EXTENSIONES PostgreSQL - ERP Clínicas
-- ============================================================================
-- Versión: 1.0.0
-- Fecha: 2025-12-09
-- Prerequisito: ERP-Core debe estar instalado
-- ============================================================================
-- Verificar que ERP-Core esté instalado
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
RAISE EXCEPTION 'ERP-Core no instalado. Ejecutar primero DDL de erp-core.';
END IF;
END $$;
-- Extensión para encriptación de datos sensibles (expedientes médicos)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Extensión para búsqueda de texto (diagnósticos CIE-10)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ============================================================================
-- FIN EXTENSIONES
-- ============================================================================

View File

@ -0,0 +1,15 @@
-- ============================================================================
-- SCHEMAS - ERP Clínicas
-- ============================================================================
-- Versión: 1.0.0
-- Fecha: 2025-12-09
-- ============================================================================
-- Schema principal para operaciones clínicas
CREATE SCHEMA IF NOT EXISTS clinica;
COMMENT ON SCHEMA clinica IS 'Schema para operaciones de clínica/consultorio médico';
-- ============================================================================
-- FIN SCHEMAS
-- ============================================================================

View File

@ -0,0 +1,37 @@
-- ============================================================================
-- FUNCIONES RLS - ERP Clínicas
-- ============================================================================
-- Versión: 1.0.0
-- Fecha: 2025-12-09
-- Nota: Usa las funciones de contexto de ERP-Core (auth schema)
-- ============================================================================
-- Las funciones principales están en ERP-Core:
-- auth.get_current_tenant_id()
-- auth.get_current_user_id()
-- auth.get_current_company_id()
-- Función auxiliar para verificar acceso a expediente médico
CREATE OR REPLACE FUNCTION clinica.can_access_medical_record(
p_patient_id UUID,
p_user_id UUID DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_user_id UUID;
v_has_access BOOLEAN := FALSE;
BEGIN
v_user_id := COALESCE(p_user_id, current_setting('app.current_user_id', true)::UUID);
-- TODO: Implementar lógica de permisos específicos
-- Por ahora, cualquier usuario del tenant puede acceder
RETURN TRUE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
COMMENT ON FUNCTION clinica.can_access_medical_record IS
'Verifica si el usuario tiene permiso para acceder al expediente médico del paciente';
-- ============================================================================
-- FIN FUNCIONES RLS
-- ============================================================================

View File

@ -0,0 +1,628 @@
-- ============================================================================
-- TABLAS CLÍNICAS - ERP Clínicas
-- ============================================================================
-- Módulos: CL-001 (Pacientes), CL-002 (Citas), CL-003 (Expediente)
-- Versión: 1.0.0
-- Fecha: 2025-12-09
-- ============================================================================
-- PREREQUISITOS:
-- 1. ERP-Core instalado (auth.tenants, auth.users, core.partners)
-- 2. Schema clinica creado
-- ============================================================================
-- ============================================================================
-- TYPES (ENUMs)
-- ============================================================================
DO $$ BEGIN
CREATE TYPE clinica.appointment_status AS ENUM (
'scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE clinica.patient_gender AS ENUM (
'male', 'female', 'other', 'prefer_not_to_say'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE clinica.blood_type AS ENUM (
'A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-', 'unknown'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE clinica.consultation_status AS ENUM (
'draft', 'in_progress', 'completed', 'cancelled'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ============================================================================
-- CATÁLOGOS BASE
-- ============================================================================
-- Tabla: specialties (Especialidades médicas)
CREATE TABLE IF NOT EXISTS clinica.specialties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
consultation_duration INTEGER DEFAULT 30, -- minutos
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_specialties_code UNIQUE (tenant_id, code)
);
-- Tabla: doctors (Médicos - extiende hr.employees)
CREATE TABLE IF NOT EXISTS clinica.doctors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
employee_id UUID, -- FK a hr.employees (ERP Core)
user_id UUID REFERENCES auth.users(id),
specialty_id UUID NOT NULL REFERENCES clinica.specialties(id),
license_number VARCHAR(50) NOT NULL, -- Cédula profesional
license_expiry DATE,
secondary_specialties UUID[], -- Array de specialty_ids
consultation_fee DECIMAL(12,2),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_doctors_license UNIQUE (tenant_id, license_number)
);
-- ============================================================================
-- PACIENTES (CL-001)
-- ============================================================================
-- Tabla: patients (Pacientes - extiende core.partners)
CREATE TABLE IF NOT EXISTS clinica.patients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
partner_id UUID REFERENCES core.partners(id), -- Vinculo a partner
-- Identificación
patient_number VARCHAR(30) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
middle_name VARCHAR(100),
-- Datos personales
birth_date DATE,
gender clinica.patient_gender,
curp VARCHAR(18),
-- Contacto
email VARCHAR(255),
phone VARCHAR(20),
mobile VARCHAR(20),
-- Dirección
street VARCHAR(255),
city VARCHAR(100),
state VARCHAR(100),
zip_code VARCHAR(10),
country VARCHAR(100) DEFAULT 'México',
-- Datos médicos básicos
blood_type clinica.blood_type DEFAULT 'unknown',
allergies TEXT[],
chronic_conditions TEXT[],
-- Seguro médico
has_insurance BOOLEAN DEFAULT FALSE,
insurance_provider VARCHAR(100),
insurance_policy VARCHAR(50),
-- Control
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_visit_date DATE,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_patients_number UNIQUE (tenant_id, patient_number)
);
-- Tabla: patient_contacts (Contactos de emergencia)
CREATE TABLE IF NOT EXISTS clinica.patient_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
patient_id UUID NOT NULL REFERENCES clinica.patients(id) ON DELETE CASCADE,
contact_name VARCHAR(200) NOT NULL,
relationship VARCHAR(50), -- Parentesco
phone VARCHAR(20),
mobile VARCHAR(20),
email VARCHAR(255),
is_primary BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id)
);
-- Tabla: patient_insurance (Información de seguros)
CREATE TABLE IF NOT EXISTS clinica.patient_insurance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
patient_id UUID NOT NULL REFERENCES clinica.patients(id) ON DELETE CASCADE,
insurance_provider VARCHAR(100) NOT NULL,
policy_number VARCHAR(50) NOT NULL,
group_number VARCHAR(50),
holder_name VARCHAR(200),
holder_relationship VARCHAR(50),
coverage_type VARCHAR(50),
valid_from DATE,
valid_until DATE,
is_primary BOOLEAN DEFAULT TRUE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id)
);
-- ============================================================================
-- CITAS (CL-002)
-- ============================================================================
-- Tabla: appointment_slots (Horarios disponibles)
CREATE TABLE IF NOT EXISTS clinica.appointment_slots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
doctor_id UUID NOT NULL REFERENCES clinica.doctors(id),
day_of_week INTEGER NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=Domingo
start_time TIME NOT NULL,
end_time TIME NOT NULL,
slot_duration INTEGER DEFAULT 30, -- minutos
max_appointments INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
CONSTRAINT chk_slot_times CHECK (end_time > start_time)
);
-- Tabla: appointments (Citas médicas)
CREATE TABLE IF NOT EXISTS clinica.appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Referencias
patient_id UUID NOT NULL REFERENCES clinica.patients(id),
doctor_id UUID NOT NULL REFERENCES clinica.doctors(id),
specialty_id UUID REFERENCES clinica.specialties(id),
-- Programación
appointment_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
duration INTEGER DEFAULT 30, -- minutos
-- Estado
status clinica.appointment_status NOT NULL DEFAULT 'scheduled',
-- Detalles
reason TEXT, -- Motivo de consulta
notes TEXT,
is_first_visit BOOLEAN DEFAULT FALSE,
is_follow_up BOOLEAN DEFAULT FALSE,
follow_up_to UUID REFERENCES clinica.appointments(id),
-- Recordatorios
reminder_sent BOOLEAN DEFAULT FALSE,
reminder_sent_at TIMESTAMPTZ,
-- Confirmación
confirmed_at TIMESTAMPTZ,
confirmed_by UUID REFERENCES auth.users(id),
-- Cancelación
cancelled_at TIMESTAMPTZ,
cancelled_by UUID REFERENCES auth.users(id),
cancellation_reason TEXT,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
CONSTRAINT chk_appointment_times CHECK (end_time > start_time)
);
-- ============================================================================
-- EXPEDIENTE CLÍNICO (CL-003)
-- ============================================================================
-- Tabla: medical_records (Expediente clínico electrónico)
-- NOTA: Datos sensibles según NOM-024-SSA3-2012
CREATE TABLE IF NOT EXISTS clinica.medical_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
patient_id UUID NOT NULL REFERENCES clinica.patients(id),
-- Número de expediente
record_number VARCHAR(30) NOT NULL,
-- Antecedentes
family_history TEXT,
personal_history TEXT,
surgical_history TEXT,
-- Hábitos
smoking_status VARCHAR(50),
alcohol_status VARCHAR(50),
exercise_status VARCHAR(50),
diet_notes TEXT,
-- Gineco-obstétricos (si aplica)
obstetric_history JSONB,
-- Notas generales
notes TEXT,
-- Control de acceso
is_confidential BOOLEAN DEFAULT TRUE,
access_restricted BOOLEAN DEFAULT FALSE,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_medical_records_number UNIQUE (tenant_id, record_number),
CONSTRAINT uq_medical_records_patient UNIQUE (patient_id)
);
-- Tabla: consultations (Consultas realizadas)
CREATE TABLE IF NOT EXISTS clinica.consultations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Referencias
medical_record_id UUID NOT NULL REFERENCES clinica.medical_records(id),
appointment_id UUID REFERENCES clinica.appointments(id),
doctor_id UUID NOT NULL REFERENCES clinica.doctors(id),
-- Fecha/hora
consultation_date DATE NOT NULL,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
-- Estado
status clinica.consultation_status DEFAULT 'draft',
-- Motivo de consulta
chief_complaint TEXT NOT NULL, -- Motivo principal
present_illness TEXT, -- Padecimiento actual
-- Exploración física
physical_exam JSONB, -- Estructurado por sistemas
-- Plan
treatment_plan TEXT,
follow_up_instructions TEXT,
next_appointment_days INTEGER,
-- Notas
notes TEXT,
private_notes TEXT, -- Solo visible para el médico
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id)
);
-- Tabla: vital_signs (Signos vitales)
CREATE TABLE IF NOT EXISTS clinica.vital_signs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
consultation_id UUID NOT NULL REFERENCES clinica.consultations(id) ON DELETE CASCADE,
-- Signos vitales
weight_kg DECIMAL(5,2),
height_cm DECIMAL(5,2),
bmi DECIMAL(4,2) GENERATED ALWAYS AS (
CASE WHEN height_cm > 0 THEN weight_kg / ((height_cm/100) * (height_cm/100)) END
) STORED,
temperature_c DECIMAL(4,2),
blood_pressure_systolic INTEGER,
blood_pressure_diastolic INTEGER,
heart_rate INTEGER, -- latidos por minuto
respiratory_rate INTEGER, -- respiraciones por minuto
oxygen_saturation INTEGER, -- porcentaje
-- Fecha/hora de medición
measured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
measured_by UUID REFERENCES auth.users(id),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id)
);
-- Tabla: diagnoses (Diagnósticos - CIE-10)
CREATE TABLE IF NOT EXISTS clinica.diagnoses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
consultation_id UUID NOT NULL REFERENCES clinica.consultations(id) ON DELETE CASCADE,
-- Código CIE-10
icd10_code VARCHAR(10) NOT NULL,
icd10_description VARCHAR(255),
-- Tipo
diagnosis_type VARCHAR(20) NOT NULL DEFAULT 'primary', -- primary, secondary, differential
-- Detalles
notes TEXT,
is_chronic BOOLEAN DEFAULT FALSE,
onset_date DATE,
-- Orden
sequence INTEGER DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id)
);
-- Tabla: prescriptions (Recetas médicas)
CREATE TABLE IF NOT EXISTS clinica.prescriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
consultation_id UUID NOT NULL REFERENCES clinica.consultations(id),
-- Número de receta
prescription_number VARCHAR(30) NOT NULL,
prescription_date DATE NOT NULL DEFAULT CURRENT_DATE,
-- Médico
doctor_id UUID NOT NULL REFERENCES clinica.doctors(id),
-- Instrucciones generales
general_instructions TEXT,
-- Vigencia
valid_until DATE,
-- Estado
is_printed BOOLEAN DEFAULT FALSE,
printed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_prescriptions_number UNIQUE (tenant_id, prescription_number)
);
-- Tabla: prescription_items (Líneas de receta)
CREATE TABLE IF NOT EXISTS clinica.prescription_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
prescription_id UUID NOT NULL REFERENCES clinica.prescriptions(id) ON DELETE CASCADE,
-- Medicamento
product_id UUID, -- FK a inventory.products (ERP Core)
medication_name VARCHAR(255) NOT NULL,
presentation VARCHAR(100), -- Tabletas, jarabe, etc.
-- Dosificación
dosage VARCHAR(100) NOT NULL, -- "1 tableta"
frequency VARCHAR(100) NOT NULL, -- "cada 8 horas"
duration VARCHAR(100), -- "por 7 días"
quantity INTEGER, -- Cantidad a surtir
-- Instrucciones
instructions TEXT,
-- Orden
sequence INTEGER DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id)
);
-- ============================================================================
-- ÍNDICES
-- ============================================================================
-- Specialties
CREATE INDEX IF NOT EXISTS idx_specialties_tenant ON clinica.specialties(tenant_id);
-- Doctors
CREATE INDEX IF NOT EXISTS idx_doctors_tenant ON clinica.doctors(tenant_id);
CREATE INDEX IF NOT EXISTS idx_doctors_specialty ON clinica.doctors(specialty_id);
CREATE INDEX IF NOT EXISTS idx_doctors_user ON clinica.doctors(user_id);
-- Patients
CREATE INDEX IF NOT EXISTS idx_patients_tenant ON clinica.patients(tenant_id);
CREATE INDEX IF NOT EXISTS idx_patients_partner ON clinica.patients(partner_id);
CREATE INDEX IF NOT EXISTS idx_patients_name ON clinica.patients(last_name, first_name);
CREATE INDEX IF NOT EXISTS idx_patients_curp ON clinica.patients(curp);
-- Patient contacts
CREATE INDEX IF NOT EXISTS idx_patient_contacts_tenant ON clinica.patient_contacts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_patient_contacts_patient ON clinica.patient_contacts(patient_id);
-- Patient insurance
CREATE INDEX IF NOT EXISTS idx_patient_insurance_tenant ON clinica.patient_insurance(tenant_id);
CREATE INDEX IF NOT EXISTS idx_patient_insurance_patient ON clinica.patient_insurance(patient_id);
-- Appointment slots
CREATE INDEX IF NOT EXISTS idx_appointment_slots_tenant ON clinica.appointment_slots(tenant_id);
CREATE INDEX IF NOT EXISTS idx_appointment_slots_doctor ON clinica.appointment_slots(doctor_id);
-- Appointments
CREATE INDEX IF NOT EXISTS idx_appointments_tenant ON clinica.appointments(tenant_id);
CREATE INDEX IF NOT EXISTS idx_appointments_patient ON clinica.appointments(patient_id);
CREATE INDEX IF NOT EXISTS idx_appointments_doctor ON clinica.appointments(doctor_id);
CREATE INDEX IF NOT EXISTS idx_appointments_date ON clinica.appointments(appointment_date);
CREATE INDEX IF NOT EXISTS idx_appointments_status ON clinica.appointments(status);
-- Medical records
CREATE INDEX IF NOT EXISTS idx_medical_records_tenant ON clinica.medical_records(tenant_id);
CREATE INDEX IF NOT EXISTS idx_medical_records_patient ON clinica.medical_records(patient_id);
-- Consultations
CREATE INDEX IF NOT EXISTS idx_consultations_tenant ON clinica.consultations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_consultations_record ON clinica.consultations(medical_record_id);
CREATE INDEX IF NOT EXISTS idx_consultations_doctor ON clinica.consultations(doctor_id);
CREATE INDEX IF NOT EXISTS idx_consultations_date ON clinica.consultations(consultation_date);
-- Vital signs
CREATE INDEX IF NOT EXISTS idx_vital_signs_tenant ON clinica.vital_signs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_vital_signs_consultation ON clinica.vital_signs(consultation_id);
-- Diagnoses
CREATE INDEX IF NOT EXISTS idx_diagnoses_tenant ON clinica.diagnoses(tenant_id);
CREATE INDEX IF NOT EXISTS idx_diagnoses_consultation ON clinica.diagnoses(consultation_id);
CREATE INDEX IF NOT EXISTS idx_diagnoses_icd10 ON clinica.diagnoses(icd10_code);
-- Prescriptions
CREATE INDEX IF NOT EXISTS idx_prescriptions_tenant ON clinica.prescriptions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_prescriptions_consultation ON clinica.prescriptions(consultation_id);
-- Prescription items
CREATE INDEX IF NOT EXISTS idx_prescription_items_tenant ON clinica.prescription_items(tenant_id);
CREATE INDEX IF NOT EXISTS idx_prescription_items_prescription ON clinica.prescription_items(prescription_id);
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
ALTER TABLE clinica.specialties ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.doctors ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.patients ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.patient_contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.patient_insurance ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.appointment_slots ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.appointments ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.medical_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.consultations ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.vital_signs ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.diagnoses ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.prescriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE clinica.prescription_items ENABLE ROW LEVEL SECURITY;
-- Políticas de aislamiento por tenant
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_specialties ON clinica.specialties;
CREATE POLICY tenant_isolation_specialties ON clinica.specialties
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_doctors ON clinica.doctors;
CREATE POLICY tenant_isolation_doctors ON clinica.doctors
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_patients ON clinica.patients;
CREATE POLICY tenant_isolation_patients ON clinica.patients
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_patient_contacts ON clinica.patient_contacts;
CREATE POLICY tenant_isolation_patient_contacts ON clinica.patient_contacts
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_patient_insurance ON clinica.patient_insurance;
CREATE POLICY tenant_isolation_patient_insurance ON clinica.patient_insurance
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_appointment_slots ON clinica.appointment_slots;
CREATE POLICY tenant_isolation_appointment_slots ON clinica.appointment_slots
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_appointments ON clinica.appointments;
CREATE POLICY tenant_isolation_appointments ON clinica.appointments
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_medical_records ON clinica.medical_records;
CREATE POLICY tenant_isolation_medical_records ON clinica.medical_records
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_consultations ON clinica.consultations;
CREATE POLICY tenant_isolation_consultations ON clinica.consultations
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_vital_signs ON clinica.vital_signs;
CREATE POLICY tenant_isolation_vital_signs ON clinica.vital_signs
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_diagnoses ON clinica.diagnoses;
CREATE POLICY tenant_isolation_diagnoses ON clinica.diagnoses
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_prescriptions ON clinica.prescriptions;
CREATE POLICY tenant_isolation_prescriptions ON clinica.prescriptions
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_prescription_items ON clinica.prescription_items;
CREATE POLICY tenant_isolation_prescription_items ON clinica.prescription_items
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
-- ============================================================================
-- COMENTARIOS
-- ============================================================================
COMMENT ON TABLE clinica.specialties IS 'Catálogo de especialidades médicas';
COMMENT ON TABLE clinica.doctors IS 'Médicos y especialistas - extiende hr.employees';
COMMENT ON TABLE clinica.patients IS 'Registro de pacientes - extiende core.partners';
COMMENT ON TABLE clinica.patient_contacts IS 'Contactos de emergencia del paciente';
COMMENT ON TABLE clinica.patient_insurance IS 'Información de seguros médicos';
COMMENT ON TABLE clinica.appointment_slots IS 'Horarios disponibles por médico';
COMMENT ON TABLE clinica.appointments IS 'Citas médicas programadas';
COMMENT ON TABLE clinica.medical_records IS 'Expediente clínico electrónico (NOM-024-SSA3)';
COMMENT ON TABLE clinica.consultations IS 'Consultas médicas realizadas';
COMMENT ON TABLE clinica.vital_signs IS 'Signos vitales del paciente';
COMMENT ON TABLE clinica.diagnoses IS 'Diagnósticos según CIE-10';
COMMENT ON TABLE clinica.prescriptions IS 'Recetas médicas';
COMMENT ON TABLE clinica.prescription_items IS 'Medicamentos en receta';
-- ============================================================================
-- FIN TABLAS CLÍNICAS
-- Total: 13 tablas, 4 ENUMs
-- ============================================================================

View File

@ -0,0 +1,34 @@
-- ============================================================================
-- DATOS INICIALES - ERP Clínicas
-- ============================================================================
-- Versión: 1.0.0
-- Fecha: 2025-12-09
-- ============================================================================
-- Especialidades médicas comunes
-- NOTA: Se insertan solo si el tenant existe (usar en script de inicialización)
/*
-- Ejemplo de inserción (ejecutar con tenant_id específico):
INSERT INTO clinica.specialties (tenant_id, code, name, description, consultation_duration) VALUES
('TENANT_UUID', 'MG', 'Medicina General', 'Atención médica primaria', 30),
('TENANT_UUID', 'PED', 'Pediatría', 'Atención médica infantil', 30),
('TENANT_UUID', 'GIN', 'Ginecología', 'Salud de la mujer', 30),
('TENANT_UUID', 'CARD', 'Cardiología', 'Enfermedades del corazón', 45),
('TENANT_UUID', 'DERM', 'Dermatología', 'Enfermedades de la piel', 30),
('TENANT_UUID', 'OFT', 'Oftalmología', 'Salud visual', 30),
('TENANT_UUID', 'ORL', 'Otorrinolaringología', 'Oído, nariz y garganta', 30),
('TENANT_UUID', 'TRAU', 'Traumatología', 'Sistema músculo-esquelético', 30),
('TENANT_UUID', 'NEUR', 'Neurología', 'Sistema nervioso', 45),
('TENANT_UUID', 'PSIQ', 'Psiquiatría', 'Salud mental', 60),
('TENANT_UUID', 'ENDO', 'Endocrinología', 'Sistema endocrino', 45),
('TENANT_UUID', 'GAST', 'Gastroenterología', 'Sistema digestivo', 45),
('TENANT_UUID', 'NEFR', 'Nefrología', 'Enfermedades renales', 45),
('TENANT_UUID', 'UROL', 'Urología', 'Sistema urinario', 30),
('TENANT_UUID', 'ONCO', 'Oncología', 'Tratamiento del cáncer', 60);
*/
-- ============================================================================
-- FIN SEED DATA
-- ============================================================================

View File

@ -10,14 +10,36 @@ proyecto:
herencia_core:
base_de_datos: erp-core
version_core: "1.2.0"
tablas_heredadas: 144 # Actualizado 2025-12-09 según conteo real DDL
schemas_heredados:
- auth
- core
- inventory
- sales
- financial
tablas_heredadas: 120+
referencia: "apps/erp-core/database/"
- nombre: auth
tablas: 26 # Autenticación, MFA, OAuth, API Keys
- nombre: core
tablas: 12 # Partners (pacientes), catálogos, UoM
- nombre: financial
tablas: 15 # Contabilidad, facturas, pagos
- nombre: inventory
tablas: 20 # Medicamentos, insumos médicos
- nombre: purchase
tablas: 8 # Compras de insumos
- nombre: sales
tablas: 10 # Servicios médicos, facturación
- nombre: projects
tablas: 10 # Tratamientos (como proyectos)
- nombre: analytics
tablas: 7 # Centros de costo por consultorio
- nombre: system
tablas: 13 # Mensajes, notificaciones, logs
- nombre: billing
tablas: 11 # SaaS (opcional)
- nombre: crm
tablas: 6 # Pacientes potenciales (opcional)
- nombre: hr
tablas: 6 # Personal médico, contratos
referencia_ddl: "apps/erp-core/database/ddl/"
documento_herencia: "../database/HERENCIA-ERP-CORE.md"
variable_rls: "app.current_tenant_id"
schemas_especificos:
- nombre: clinica

View File

@ -11,19 +11,21 @@ proyecto:
path: /home/isem/workspace/projects/erp-suite/apps/verticales/clinicas
herencia:
core_version: "0.6.0"
tablas_heredadas: 97
tablas_heredadas: 144
schemas_heredados: 12
specs_aplicables: 22
specs_implementadas: 0
resumen_general:
total_modulos: 12
total_schemas_planificados: 4
total_tablas_planificadas: 45
total_schemas_planificados: 1
total_tablas_planificadas: 13
total_tablas_implementadas: 13
total_servicios_backend: 0
total_componentes_frontend: 0
story_points_estimados: 451
test_coverage: N/A
ultima_actualizacion: 2025-12-08
ultima_actualizacion: 2025-12-09
modulos:
total: 12
@ -161,9 +163,15 @@ specs_core:
capas:
database:
inventario: DATABASE_INVENTORY.yml
schemas_planificados: [clinical, pharmacy, laboratory, imaging]
tablas_planificadas: 45
estado: PLANIFICADO
schemas_implementados: [clinical]
tablas_implementadas: 13
enums_implementados: 4
ddl_files:
- init/00-extensions.sql
- init/01-create-schemas.sql
- init/02-rls-functions.sql
- init/03-clinical-tables.sql
estado: DDL_COMPLETO
backend:
inventario: BACKEND_INVENTORY.yml

View File

@ -1,7 +1,7 @@
# Referencia de Base de Datos - ERP Construcción
**Fecha:** 2025-12-08
**Versión:** 1.1
**Fecha:** 2025-12-09
**Versión:** 1.2
**Proyecto:** ERP Construcción
**Nivel:** 2B.2 (Proyecto Independiente)
@ -40,12 +40,21 @@ ERP Construcción es un **proyecto independiente** que implementa y adapta patro
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │construction │ │ hr │ │ hse │ │
│ │ 2 tbl │ │ 3 tbl │ │ 28 tbl │ │
│ │ 24 tbl │ │ 8 tbl │ │ 58 tbl │ │
│ │ (proyectos) │ │ (empleados) │ │ (seguridad) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Schemas propios: 3 | Tablas propias: 33 │
│ Opera de forma INDEPENDIENTE │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ estimates │ │ infonavit │ │ inventory │ │
│ │ 8 tbl │ │ 8 tbl │ │ 4 tbl │ │
│ │(estimación) │ │ (ruv) │ │ (ext) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ purchase │ Schemas propios: 7 │
│ │ 5 tbl │ Tablas propias: 110 │
│ │ (ext) │ Opera de forma INDEPENDIENTE │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
@ -69,41 +78,48 @@ Los siguientes patrones del ERP-Core fueron **adaptados e implementados** en est
## SCHEMAS ESPECÍFICOS DE CONSTRUCCIÓN
### 1. Schema `construccion` (2 tablas)
### 1. Schema `construction` (24 tablas)
**Propósito:** Gestión de proyectos de obra y fraccionamientos
**Propósito:** Gestión de proyectos de obra, estructura y avances
```sql
-- Extiende: projects schema del core
-- Relaciones:
-- proyectos -> core.partners (cliente)
-- fraccionamientos -> proyectos
-- fraccionamientos usa PostGIS para ubicación
construccion.proyectos
construccion.fraccionamientos
-- DDL: 01-construction-schema-ddl.sql
-- Estructura de proyecto (8 tablas):
-- fraccionamientos, etapas, manzanas, lotes, torres, niveles, departamentos, prototipos
-- Presupuestos y Conceptos (3 tablas):
-- conceptos, presupuestos, presupuesto_partidas
-- Programación y Avances (5 tablas):
-- programa_obra, programa_actividades, avances_obra, fotos_avance, bitacora_obra
-- Calidad (5 tablas):
-- checklists, checklist_items, inspecciones, inspeccion_resultados, tickets_postventa
-- Contratos (3 tablas):
-- subcontratistas, contratos, contrato_partidas
```
### 2. Schema `hr` extendido (3 tablas)
### 2. Schema `hr` extendido (8 tablas)
**Propósito:** Gestión de personal de obra
**Propósito:** Gestión de personal de obra, asistencias, destajo
```sql
-- DDL: 02-hr-schema-ddl.sql
-- Extiende: hr schema del core
-- Adiciona campos específicos de construcción:
-- CURP, NSS, nivel_riesgo, capacitaciones
hr.employees (extendido)
hr.puestos
hr.employee_fraccionamientos
hr.employee_construction -- Extensión empleados construcción
hr.asistencias -- Registro con GPS/biométrico
hr.asistencia_biometrico -- Datos biométricos
hr.geocercas -- Validación GPS (PostGIS)
hr.destajo -- Trabajo a destajo
hr.destajo_detalle -- Mediciones destajo
hr.cuadrillas -- Equipos de trabajo
hr.cuadrilla_miembros -- Miembros cuadrillas
```
### 3. Schema `hse` (28 tablas)
### 3. Schema `hse` (58 tablas)
**Propósito:** Health, Safety & Environment
```sql
-- Nuevo schema específico de construcción
-- DDL: 03-hse-schema-ddl.sql
-- Implementa 8 requerimientos funcionales (RF-MAA017-001 a 008)
Grupos de tablas:
@ -111,12 +127,77 @@ Grupos de tablas:
- Control de Capacitaciones (6 tablas)
- Inspecciones de Seguridad (7 tablas)
- Control de EPP (7 tablas)
- Cumplimiento STPS (10 tablas)
- Gestión Ambiental (8 tablas)
- Cumplimiento STPS (11 tablas)
- Gestión Ambiental (9 tablas)
- Permisos de Trabajo (8 tablas)
- Indicadores HSE (7 tablas)
```
### 4. Schema `estimates` (8 tablas)
**Propósito:** Estimaciones, anticipos, retenciones
```sql
-- DDL: 04-estimates-schema-ddl.sql
-- Módulo: MAI-008 (Estimaciones y Facturación)
estimates.estimaciones -- Estimaciones de obra
estimates.estimacion_conceptos -- Conceptos estimados
estimates.generadores -- Números generadores
estimates.anticipos -- Anticipos de obra
estimates.amortizaciones -- Amortización de anticipos
estimates.retenciones -- Retenciones (garantía, IMSS, ISR)
estimates.fondo_garantia -- Fondo de garantía
estimates.estimacion_workflow -- Workflow de aprobación
```
### 5. Schema `infonavit` (8 tablas)
**Propósito:** Integración INFONAVIT, RUV, derechohabientes
```sql
-- DDL: 05-infonavit-schema-ddl.sql
-- Módulos: MAI-010/011 (CRM Derechohabientes, Integración INFONAVIT)
infonavit.registro_infonavit -- Registro RUV
infonavit.oferta_vivienda -- Oferta registrada
infonavit.derechohabientes -- Derechohabientes
infonavit.asignacion_vivienda -- Asignaciones
infonavit.actas -- Actas de entrega
infonavit.acta_viviendas -- Viviendas en acta
infonavit.reportes_infonavit -- Reportes RUV
infonavit.historico_puntos -- Histórico puntos ecológicos
```
### 6. Schema `inventory` extensión (4 tablas)
**Propósito:** Almacenes de proyecto, requisiciones de obra
```sql
-- DDL: 06-inventory-ext-schema-ddl.sql
-- Extiende: inventory schema del core
inventory.almacenes_proyecto -- Almacenes por obra
inventory.requisiciones_obra -- Requisiciones desde obra
inventory.requisicion_lineas -- Líneas de requisición
inventory.consumos_obra -- Consumos por lote/concepto
```
### 7. Schema `purchase` extensión (5 tablas)
**Propósito:** Órdenes de compra construcción, comparativos
```sql
-- DDL: 07-purchase-ext-schema-ddl.sql
-- Extiende: purchase schema del core
purchase.purchase_order_construction -- Extensión OC
purchase.supplier_construction -- Extensión proveedores
purchase.comparativo_cotizaciones -- Cuadro comparativo
purchase.comparativo_proveedores -- Proveedores en comparativo
purchase.comparativo_productos -- Productos cotizados
```
---
## ORDEN DE EJECUCIÓN DDL
@ -128,13 +209,19 @@ Para recrear la base de datos completa:
cd apps/erp-core/database
./scripts/reset-database.sh --force
# PASO 2: Cargar extensiones de Construcción
# PASO 2: Cargar extensiones de Construcción (orden importante)
cd apps/verticales/construccion/database
psql $DATABASE_URL -f schemas/01-construction-schema-ddl.sql
psql $DATABASE_URL -f schemas/02-hr-schema-ddl.sql
psql $DATABASE_URL -f schemas/03-hse-schema-ddl.sql
psql $DATABASE_URL -f schemas/01-construction-schema-ddl.sql # 24 tablas
psql $DATABASE_URL -f schemas/02-hr-schema-ddl.sql # 8 tablas
psql $DATABASE_URL -f schemas/03-hse-schema-ddl.sql # 58 tablas
psql $DATABASE_URL -f schemas/04-estimates-schema-ddl.sql # 8 tablas
psql $DATABASE_URL -f schemas/05-infonavit-schema-ddl.sql # 8 tablas
psql $DATABASE_URL -f schemas/06-inventory-ext-schema-ddl.sql # 4 tablas
psql $DATABASE_URL -f schemas/07-purchase-ext-schema-ddl.sql # 5 tablas
```
**Nota:** Los archivos 06 y 07 dependen de que 01-construction esté instalado.
---
## DEPENDENCIAS CRUZADAS
@ -271,4 +358,5 @@ Según el [MAPEO-SPECS-VERTICALES.md](../../../../erp-core/docs/04-modelado/MAPE
---
**Documento de herencia oficial**
**Última actualización:** 2025-12-08
**Última actualización:** 2025-12-09
**Total schemas:** 7 | **Total tablas:** 110

View File

@ -1,14 +1,14 @@
-- ============================================================================
-- CONSTRUCTION Schema DDL - Gestion de Obras
-- Modulo: MAA-001 a MAA-006 (Fundamentos de Construccion)
-- Version: 1.0.0
-- Fecha: 2025-12-06
-- CONSTRUCTION Schema DDL - Gestión de Obras (COMPLETO)
-- Modulos: MAI-002, MAI-003, MAI-005, MAI-009, MAI-012
-- Version: 2.0.0
-- Fecha: 2025-12-08
-- ============================================================================
-- POLITICA: CARGA LIMPIA (ver DIRECTIVA-POLITICA-CARGA-LIMPIA.md)
-- Este archivo es parte de la fuente de verdad DDL.
-- ============================================================================
-- Verificar que ERP-Core esta instalado
-- Verificar que ERP-Core está instalado
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
@ -25,95 +25,879 @@ END $$;
-- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS construction;
-- Configurar search_path
SET search_path TO construction, core, core_shared, public;
-- ============================================================================
-- TABLAS BASE MINIMAS (requeridas por otros modulos como HSE)
-- TYPES (ENUMs)
-- ============================================================================
-- Tabla: Proyectos (desarrollo inmobiliario)
CREATE TABLE IF NOT EXISTS construction.proyectos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
codigo VARCHAR(20) NOT NULL,
nombre VARCHAR(200) NOT NULL,
descripcion TEXT,
direccion TEXT,
ciudad VARCHAR(100),
estado VARCHAR(100),
fecha_inicio DATE,
fecha_fin_estimada DATE,
estado_proyecto VARCHAR(20) NOT NULL DEFAULT 'activo',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_proyectos_codigo UNIQUE (tenant_id, codigo)
DO $$ BEGIN
CREATE TYPE construction.project_status AS ENUM (
'draft', 'planning', 'in_progress', 'paused', 'completed', 'cancelled'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Tabla: Fraccionamientos (obras dentro de un proyecto)
DO $$ BEGIN
CREATE TYPE construction.lot_status AS ENUM (
'available', 'reserved', 'sold', 'under_construction', 'delivered', 'warranty'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE construction.prototype_type AS ENUM (
'horizontal', 'vertical', 'commercial', 'mixed'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE construction.advance_status AS ENUM (
'pending', 'captured', 'reviewed', 'approved', 'rejected'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE construction.quality_status AS ENUM (
'pending', 'in_review', 'approved', 'rejected', 'rework'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE construction.contract_type AS ENUM (
'fixed_price', 'unit_price', 'cost_plus', 'mixed'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE construction.contract_status AS ENUM (
'draft', 'pending_approval', 'active', 'suspended', 'terminated', 'closed'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ============================================================================
-- TABLES - ESTRUCTURA DE PROYECTO
-- ============================================================================
-- Tabla: fraccionamientos (desarrollo inmobiliario)
CREATE TABLE IF NOT EXISTS construction.fraccionamientos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
proyecto_id UUID NOT NULL REFERENCES construction.proyectos(id),
codigo VARCHAR(20) NOT NULL,
nombre VARCHAR(200) NOT NULL,
descripcion TEXT,
direccion TEXT,
ubicacion_geo GEOMETRY(Point, 4326),
fecha_inicio DATE,
fecha_fin_estimada DATE,
estado VARCHAR(20) NOT NULL DEFAULT 'activo',
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
code VARCHAR(20) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
address TEXT,
city VARCHAR(100),
state VARCHAR(100),
zip_code VARCHAR(10),
location GEOMETRY(POINT, 4326),
total_area_m2 DECIMAL(12,2),
buildable_area_m2 DECIMAL(12,2),
total_lots INTEGER DEFAULT 0,
status construction.project_status NOT NULL DEFAULT 'draft',
start_date DATE,
expected_end_date DATE,
actual_end_date DATE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_fraccionamientos_code_tenant UNIQUE (tenant_id, code)
);
CONSTRAINT uq_fraccionamientos_codigo UNIQUE (tenant_id, codigo)
-- Tabla: etapas (fases del fraccionamiento)
CREATE TABLE IF NOT EXISTS construction.etapas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id) ON DELETE CASCADE,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
sequence INTEGER NOT NULL DEFAULT 1,
total_lots INTEGER DEFAULT 0,
status construction.project_status NOT NULL DEFAULT 'draft',
start_date DATE,
expected_end_date DATE,
actual_end_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_etapas_code_fracc UNIQUE (fraccionamiento_id, code)
);
-- Tabla: manzanas (agrupación de lotes)
CREATE TABLE IF NOT EXISTS construction.manzanas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
etapa_id UUID NOT NULL REFERENCES construction.etapas(id) ON DELETE CASCADE,
code VARCHAR(20) NOT NULL,
name VARCHAR(100),
total_lots INTEGER DEFAULT 0,
polygon GEOMETRY(POLYGON, 4326),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_manzanas_code_etapa UNIQUE (etapa_id, code)
);
-- Tabla: prototipos (tipos de vivienda) - definida antes de lotes
CREATE TABLE IF NOT EXISTS construction.prototipos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
type construction.prototype_type NOT NULL DEFAULT 'horizontal',
area_construction_m2 DECIMAL(10,2),
area_terrain_m2 DECIMAL(10,2),
bedrooms INTEGER DEFAULT 0,
bathrooms DECIMAL(3,1) DEFAULT 0,
parking_spaces INTEGER DEFAULT 0,
floors INTEGER DEFAULT 1,
base_price DECIMAL(14,2),
blueprint_url VARCHAR(500),
render_url VARCHAR(500),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_prototipos_code_tenant UNIQUE (tenant_id, code)
);
-- Tabla: lotes (unidades vendibles horizontal)
CREATE TABLE IF NOT EXISTS construction.lotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
manzana_id UUID NOT NULL REFERENCES construction.manzanas(id) ON DELETE CASCADE,
prototipo_id UUID REFERENCES construction.prototipos(id),
code VARCHAR(30) NOT NULL,
official_number VARCHAR(50),
area_m2 DECIMAL(10,2),
front_m DECIMAL(8,2),
depth_m DECIMAL(8,2),
status construction.lot_status NOT NULL DEFAULT 'available',
location GEOMETRY(POINT, 4326),
polygon GEOMETRY(POLYGON, 4326),
price_base DECIMAL(14,2),
price_final DECIMAL(14,2),
buyer_id UUID,
sale_date DATE,
delivery_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_lotes_code_manzana UNIQUE (manzana_id, code)
);
-- ============================================================================
-- TABLES - ESTRUCTURA VERTICAL (TORRES)
-- ============================================================================
-- Tabla: torres (edificios verticales)
CREATE TABLE IF NOT EXISTS construction.torres (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
etapa_id UUID NOT NULL REFERENCES construction.etapas(id) ON DELETE CASCADE,
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
total_floors INTEGER NOT NULL DEFAULT 1,
total_units INTEGER DEFAULT 0,
status construction.project_status NOT NULL DEFAULT 'draft',
location GEOMETRY(POINT, 4326),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_torres_code_etapa UNIQUE (etapa_id, code)
);
-- Tabla: niveles (pisos de torre)
CREATE TABLE IF NOT EXISTS construction.niveles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
torre_id UUID NOT NULL REFERENCES construction.torres(id) ON DELETE CASCADE,
floor_number INTEGER NOT NULL,
name VARCHAR(50),
total_units INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_niveles_floor_torre UNIQUE (torre_id, floor_number)
);
-- Tabla: departamentos (unidades en torre)
CREATE TABLE IF NOT EXISTS construction.departamentos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
nivel_id UUID NOT NULL REFERENCES construction.niveles(id) ON DELETE CASCADE,
prototipo_id UUID REFERENCES construction.prototipos(id),
code VARCHAR(30) NOT NULL,
unit_number VARCHAR(20) NOT NULL,
area_m2 DECIMAL(10,2),
status construction.lot_status NOT NULL DEFAULT 'available',
price_base DECIMAL(14,2),
price_final DECIMAL(14,2),
buyer_id UUID,
sale_date DATE,
delivery_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_departamentos_code_nivel UNIQUE (nivel_id, code)
);
-- ============================================================================
-- TABLES - CONCEPTOS Y PRESUPUESTOS
-- ============================================================================
-- Tabla: conceptos (catálogo de conceptos de obra)
CREATE TABLE IF NOT EXISTS construction.conceptos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
parent_id UUID REFERENCES construction.conceptos(id),
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
unit_id UUID,
unit_price DECIMAL(12,4),
is_composite BOOLEAN NOT NULL DEFAULT FALSE,
level INTEGER NOT NULL DEFAULT 0,
path VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_conceptos_code_tenant UNIQUE (tenant_id, code)
);
-- Tabla: presupuestos (presupuesto por prototipo/obra)
CREATE TABLE IF NOT EXISTS construction.presupuestos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
fraccionamiento_id UUID REFERENCES construction.fraccionamientos(id),
prototipo_id UUID REFERENCES construction.prototipos(id),
code VARCHAR(30) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
total_amount DECIMAL(16,2) DEFAULT 0,
currency_id UUID,
approved_at TIMESTAMPTZ,
approved_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_presupuestos_code_version UNIQUE (tenant_id, code, version)
);
-- Tabla: presupuesto_partidas (líneas del presupuesto)
CREATE TABLE IF NOT EXISTS construction.presupuesto_partidas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
presupuesto_id UUID NOT NULL REFERENCES construction.presupuestos(id) ON DELETE CASCADE,
concepto_id UUID NOT NULL REFERENCES construction.conceptos(id),
sequence INTEGER NOT NULL DEFAULT 0,
quantity DECIMAL(12,4) NOT NULL DEFAULT 0,
unit_price DECIMAL(12,4) NOT NULL DEFAULT 0,
total_amount DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_partidas_presupuesto_concepto UNIQUE (presupuesto_id, concepto_id)
);
-- ============================================================================
-- TABLES - AVANCES Y CONTROL DE OBRA
-- ============================================================================
-- Tabla: programa_obra (programa maestro)
CREATE TABLE IF NOT EXISTS construction.programa_obra (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
code VARCHAR(30) NOT NULL,
name VARCHAR(255) NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_programa_code_version UNIQUE (tenant_id, code, version)
);
-- Tabla: programa_actividades (actividades del programa)
CREATE TABLE IF NOT EXISTS construction.programa_actividades (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
programa_id UUID NOT NULL REFERENCES construction.programa_obra(id) ON DELETE CASCADE,
concepto_id UUID REFERENCES construction.conceptos(id),
parent_id UUID REFERENCES construction.programa_actividades(id),
name VARCHAR(255) NOT NULL,
sequence INTEGER NOT NULL DEFAULT 0,
planned_start DATE,
planned_end DATE,
planned_quantity DECIMAL(12,4) DEFAULT 0,
planned_weight DECIMAL(8,4) DEFAULT 0,
wbs_code VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
);
-- Tabla: avances_obra (captura de avances)
CREATE TABLE IF NOT EXISTS construction.avances_obra (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
lote_id UUID REFERENCES construction.lotes(id),
departamento_id UUID REFERENCES construction.departamentos(id),
concepto_id UUID NOT NULL REFERENCES construction.conceptos(id),
capture_date DATE NOT NULL,
quantity_executed DECIMAL(12,4) NOT NULL DEFAULT 0,
percentage_executed DECIMAL(5,2) DEFAULT 0,
status construction.advance_status NOT NULL DEFAULT 'pending',
notes TEXT,
captured_by UUID NOT NULL REFERENCES auth.users(id),
reviewed_by UUID REFERENCES auth.users(id),
reviewed_at TIMESTAMPTZ,
approved_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT chk_avances_lote_or_depto CHECK (
(lote_id IS NOT NULL AND departamento_id IS NULL) OR
(lote_id IS NULL AND departamento_id IS NOT NULL)
)
);
-- Tabla: fotos_avance (evidencia fotográfica)
CREATE TABLE IF NOT EXISTS construction.fotos_avance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
avance_id UUID NOT NULL REFERENCES construction.avances_obra(id) ON DELETE CASCADE,
file_url VARCHAR(500) NOT NULL,
file_name VARCHAR(255),
file_size INTEGER,
mime_type VARCHAR(50),
description TEXT,
location GEOMETRY(POINT, 4326),
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
);
-- Tabla: bitacora_obra (registro de bitácora)
CREATE TABLE IF NOT EXISTS construction.bitacora_obra (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
entry_date DATE NOT NULL,
entry_number INTEGER NOT NULL,
weather VARCHAR(50),
temperature_max DECIMAL(4,1),
temperature_min DECIMAL(4,1),
workers_count INTEGER DEFAULT 0,
description TEXT NOT NULL,
observations TEXT,
incidents TEXT,
registered_by UUID NOT NULL REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_bitacora_fracc_number UNIQUE (fraccionamiento_id, entry_number)
);
-- ============================================================================
-- TABLES - CALIDAD Y POSTVENTA (MAI-009)
-- ============================================================================
-- Tabla: checklists (plantillas de verificación)
CREATE TABLE IF NOT EXISTS construction.checklists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
code VARCHAR(30) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
prototipo_id UUID REFERENCES construction.prototipos(id),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_checklists_code_tenant UNIQUE (tenant_id, code)
);
-- Tabla: checklist_items (items del checklist)
CREATE TABLE IF NOT EXISTS construction.checklist_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
checklist_id UUID NOT NULL REFERENCES construction.checklists(id) ON DELETE CASCADE,
sequence INTEGER NOT NULL DEFAULT 0,
name VARCHAR(255) NOT NULL,
description TEXT,
is_required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
);
-- Tabla: inspecciones (inspecciones de calidad)
CREATE TABLE IF NOT EXISTS construction.inspecciones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
checklist_id UUID NOT NULL REFERENCES construction.checklists(id),
lote_id UUID REFERENCES construction.lotes(id),
departamento_id UUID REFERENCES construction.departamentos(id),
inspection_date DATE NOT NULL,
status construction.quality_status NOT NULL DEFAULT 'pending',
inspector_id UUID NOT NULL REFERENCES auth.users(id),
notes TEXT,
approved_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
);
-- Tabla: inspeccion_resultados (resultados por item)
CREATE TABLE IF NOT EXISTS construction.inspeccion_resultados (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
inspeccion_id UUID NOT NULL REFERENCES construction.inspecciones(id) ON DELETE CASCADE,
checklist_item_id UUID NOT NULL REFERENCES construction.checklist_items(id),
is_passed BOOLEAN,
notes TEXT,
photo_url VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id)
);
-- Tabla: tickets_postventa (tickets de garantía)
CREATE TABLE IF NOT EXISTS construction.tickets_postventa (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
lote_id UUID REFERENCES construction.lotes(id),
departamento_id UUID REFERENCES construction.departamentos(id),
ticket_number VARCHAR(30) NOT NULL,
reported_date DATE NOT NULL,
category VARCHAR(50),
description TEXT NOT NULL,
priority VARCHAR(20) DEFAULT 'medium',
status VARCHAR(20) NOT NULL DEFAULT 'open',
assigned_to UUID REFERENCES auth.users(id),
resolution TEXT,
resolved_at TIMESTAMPTZ,
resolved_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_tickets_number_tenant UNIQUE (tenant_id, ticket_number)
);
-- ============================================================================
-- TABLES - CONTRATOS Y SUBCONTRATOS (MAI-012)
-- ============================================================================
-- Tabla: subcontratistas
CREATE TABLE IF NOT EXISTS construction.subcontratistas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
partner_id UUID,
code VARCHAR(20) NOT NULL,
name VARCHAR(255) NOT NULL,
legal_name VARCHAR(255),
tax_id VARCHAR(20),
specialty VARCHAR(100),
contact_name VARCHAR(100),
contact_phone VARCHAR(20),
contact_email VARCHAR(100),
address TEXT,
rating DECIMAL(3,2),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_subcontratistas_code_tenant UNIQUE (tenant_id, code)
);
-- Tabla: contratos (contratos con subcontratistas)
CREATE TABLE IF NOT EXISTS construction.contratos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
subcontratista_id UUID NOT NULL REFERENCES construction.subcontratistas(id),
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
contract_number VARCHAR(30) NOT NULL,
contract_type construction.contract_type NOT NULL DEFAULT 'unit_price',
name VARCHAR(255) NOT NULL,
description TEXT,
start_date DATE NOT NULL,
end_date DATE,
total_amount DECIMAL(16,2),
advance_percentage DECIMAL(5,2) DEFAULT 0,
retention_percentage DECIMAL(5,2) DEFAULT 5,
status construction.contract_status NOT NULL DEFAULT 'draft',
signed_at TIMESTAMPTZ,
signed_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_contratos_number_tenant UNIQUE (tenant_id, contract_number)
);
-- Tabla: contrato_partidas (líneas del contrato)
CREATE TABLE IF NOT EXISTS construction.contrato_partidas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
contrato_id UUID NOT NULL REFERENCES construction.contratos(id) ON DELETE CASCADE,
concepto_id UUID NOT NULL REFERENCES construction.conceptos(id),
quantity DECIMAL(12,4) NOT NULL DEFAULT 0,
unit_price DECIMAL(12,4) NOT NULL DEFAULT 0,
total_amount DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
);
-- ============================================================================
-- INDICES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_proyectos_tenant ON construction.proyectos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_tenant ON construction.fraccionamientos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_proyecto ON construction.fraccionamientos(proyecto_id);
-- Fraccionamientos
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_tenant_id ON construction.fraccionamientos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_status ON construction.fraccionamientos(status);
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_code ON construction.fraccionamientos(code);
-- Etapas
CREATE INDEX IF NOT EXISTS idx_etapas_tenant_id ON construction.etapas(tenant_id);
CREATE INDEX IF NOT EXISTS idx_etapas_fraccionamiento_id ON construction.etapas(fraccionamiento_id);
-- Manzanas
CREATE INDEX IF NOT EXISTS idx_manzanas_tenant_id ON construction.manzanas(tenant_id);
CREATE INDEX IF NOT EXISTS idx_manzanas_etapa_id ON construction.manzanas(etapa_id);
-- Lotes
CREATE INDEX IF NOT EXISTS idx_lotes_tenant_id ON construction.lotes(tenant_id);
CREATE INDEX IF NOT EXISTS idx_lotes_manzana_id ON construction.lotes(manzana_id);
CREATE INDEX IF NOT EXISTS idx_lotes_prototipo_id ON construction.lotes(prototipo_id);
CREATE INDEX IF NOT EXISTS idx_lotes_status ON construction.lotes(status);
-- Torres
CREATE INDEX IF NOT EXISTS idx_torres_tenant_id ON construction.torres(tenant_id);
CREATE INDEX IF NOT EXISTS idx_torres_etapa_id ON construction.torres(etapa_id);
-- Niveles
CREATE INDEX IF NOT EXISTS idx_niveles_tenant_id ON construction.niveles(tenant_id);
CREATE INDEX IF NOT EXISTS idx_niveles_torre_id ON construction.niveles(torre_id);
-- Departamentos
CREATE INDEX IF NOT EXISTS idx_departamentos_tenant_id ON construction.departamentos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_departamentos_nivel_id ON construction.departamentos(nivel_id);
CREATE INDEX IF NOT EXISTS idx_departamentos_status ON construction.departamentos(status);
-- Prototipos
CREATE INDEX IF NOT EXISTS idx_prototipos_tenant_id ON construction.prototipos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_prototipos_type ON construction.prototipos(type);
-- Conceptos
CREATE INDEX IF NOT EXISTS idx_conceptos_tenant_id ON construction.conceptos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_conceptos_parent_id ON construction.conceptos(parent_id);
CREATE INDEX IF NOT EXISTS idx_conceptos_code ON construction.conceptos(code);
-- Presupuestos
CREATE INDEX IF NOT EXISTS idx_presupuestos_tenant_id ON construction.presupuestos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_presupuestos_fraccionamiento_id ON construction.presupuestos(fraccionamiento_id);
-- Avances
CREATE INDEX IF NOT EXISTS idx_avances_tenant_id ON construction.avances_obra(tenant_id);
CREATE INDEX IF NOT EXISTS idx_avances_lote_id ON construction.avances_obra(lote_id);
CREATE INDEX IF NOT EXISTS idx_avances_concepto_id ON construction.avances_obra(concepto_id);
CREATE INDEX IF NOT EXISTS idx_avances_capture_date ON construction.avances_obra(capture_date);
-- Bitacora
CREATE INDEX IF NOT EXISTS idx_bitacora_tenant_id ON construction.bitacora_obra(tenant_id);
CREATE INDEX IF NOT EXISTS idx_bitacora_fraccionamiento_id ON construction.bitacora_obra(fraccionamiento_id);
-- Inspecciones
CREATE INDEX IF NOT EXISTS idx_inspecciones_tenant_id ON construction.inspecciones(tenant_id);
CREATE INDEX IF NOT EXISTS idx_inspecciones_status ON construction.inspecciones(status);
-- Tickets
CREATE INDEX IF NOT EXISTS idx_tickets_tenant_id ON construction.tickets_postventa(tenant_id);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON construction.tickets_postventa(status);
-- Subcontratistas
CREATE INDEX IF NOT EXISTS idx_subcontratistas_tenant_id ON construction.subcontratistas(tenant_id);
-- Contratos
CREATE INDEX IF NOT EXISTS idx_contratos_tenant_id ON construction.contratos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_contratos_subcontratista_id ON construction.contratos(subcontratista_id);
CREATE INDEX IF NOT EXISTS idx_contratos_fraccionamiento_id ON construction.contratos(fraccionamiento_id);
-- ============================================================================
-- ROW LEVEL SECURITY
-- ROW LEVEL SECURITY (RLS)
-- ============================================================================
ALTER TABLE construction.proyectos ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.fraccionamientos ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.etapas ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.manzanas ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.lotes ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.torres ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.niveles ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.departamentos ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.prototipos ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.conceptos ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.presupuestos ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.presupuesto_partidas ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.programa_obra ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.programa_actividades ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.avances_obra ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.fotos_avance ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.bitacora_obra ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.checklists ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.checklist_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.inspecciones ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.inspeccion_resultados ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.tickets_postventa ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.subcontratistas ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.contratos ENABLE ROW LEVEL SECURITY;
ALTER TABLE construction.contrato_partidas ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_proyectos ON construction.proyectos
FOR ALL
USING (tenant_id = current_setting('app.current_tenant', true)::UUID);
-- Policies de tenant isolation usando current_setting
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_fraccionamientos ON construction.fraccionamientos;
CREATE POLICY tenant_isolation_fraccionamientos ON construction.fraccionamientos
FOR ALL
USING (tenant_id = current_setting('app.current_tenant', true)::UUID);
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
-- ============================================================================
-- TRIGGERS
-- ============================================================================
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_etapas ON construction.etapas;
CREATE POLICY tenant_isolation_etapas ON construction.etapas
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
CREATE TRIGGER trg_proyectos_updated_at
BEFORE UPDATE ON construction.proyectos
FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at();
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_manzanas ON construction.manzanas;
CREATE POLICY tenant_isolation_manzanas ON construction.manzanas
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
CREATE TRIGGER trg_fraccionamientos_updated_at
BEFORE UPDATE ON construction.fraccionamientos
FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at();
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_lotes ON construction.lotes;
CREATE POLICY tenant_isolation_lotes ON construction.lotes
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_torres ON construction.torres;
CREATE POLICY tenant_isolation_torres ON construction.torres
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_niveles ON construction.niveles;
CREATE POLICY tenant_isolation_niveles ON construction.niveles
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_departamentos ON construction.departamentos;
CREATE POLICY tenant_isolation_departamentos ON construction.departamentos
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_prototipos ON construction.prototipos;
CREATE POLICY tenant_isolation_prototipos ON construction.prototipos
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_conceptos ON construction.conceptos;
CREATE POLICY tenant_isolation_conceptos ON construction.conceptos
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_presupuestos ON construction.presupuestos;
CREATE POLICY tenant_isolation_presupuestos ON construction.presupuestos
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_presupuesto_partidas ON construction.presupuesto_partidas;
CREATE POLICY tenant_isolation_presupuesto_partidas ON construction.presupuesto_partidas
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_programa_obra ON construction.programa_obra;
CREATE POLICY tenant_isolation_programa_obra ON construction.programa_obra
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_programa_actividades ON construction.programa_actividades;
CREATE POLICY tenant_isolation_programa_actividades ON construction.programa_actividades
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_avances_obra ON construction.avances_obra;
CREATE POLICY tenant_isolation_avances_obra ON construction.avances_obra
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_fotos_avance ON construction.fotos_avance;
CREATE POLICY tenant_isolation_fotos_avance ON construction.fotos_avance
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_bitacora_obra ON construction.bitacora_obra;
CREATE POLICY tenant_isolation_bitacora_obra ON construction.bitacora_obra
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_checklists ON construction.checklists;
CREATE POLICY tenant_isolation_checklists ON construction.checklists
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_checklist_items ON construction.checklist_items;
CREATE POLICY tenant_isolation_checklist_items ON construction.checklist_items
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_inspecciones ON construction.inspecciones;
CREATE POLICY tenant_isolation_inspecciones ON construction.inspecciones
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_inspeccion_resultados ON construction.inspeccion_resultados;
CREATE POLICY tenant_isolation_inspeccion_resultados ON construction.inspeccion_resultados
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_tickets_postventa ON construction.tickets_postventa;
CREATE POLICY tenant_isolation_tickets_postventa ON construction.tickets_postventa
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_subcontratistas ON construction.subcontratistas;
CREATE POLICY tenant_isolation_subcontratistas ON construction.subcontratistas
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_contratos ON construction.contratos;
CREATE POLICY tenant_isolation_contratos ON construction.contratos
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_contrato_partidas ON construction.contrato_partidas;
CREATE POLICY tenant_isolation_contrato_partidas ON construction.contrato_partidas
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
-- ============================================================================
-- COMENTARIOS
-- ============================================================================
COMMENT ON TABLE construction.proyectos IS 'Proyectos de desarrollo inmobiliario';
COMMENT ON TABLE construction.fraccionamientos IS 'Fraccionamientos/obras dentro de un proyecto';
COMMENT ON SCHEMA construction IS 'Schema de construcción: obras, lotes, avances, calidad, contratos';
COMMENT ON TABLE construction.fraccionamientos IS 'Desarrollos inmobiliarios/fraccionamientos';
COMMENT ON TABLE construction.etapas IS 'Etapas/fases de un fraccionamiento';
COMMENT ON TABLE construction.manzanas IS 'Manzanas dentro de una etapa';
COMMENT ON TABLE construction.lotes IS 'Lotes/terrenos vendibles (horizontal)';
COMMENT ON TABLE construction.torres IS 'Torres/edificios (vertical)';
COMMENT ON TABLE construction.niveles IS 'Pisos de una torre';
COMMENT ON TABLE construction.departamentos IS 'Departamentos/unidades en torre';
COMMENT ON TABLE construction.prototipos IS 'Tipos de vivienda/prototipos';
COMMENT ON TABLE construction.conceptos IS 'Catálogo de conceptos de obra';
COMMENT ON TABLE construction.presupuestos IS 'Presupuestos por prototipo u obra';
COMMENT ON TABLE construction.avances_obra IS 'Captura de avances físicos';
COMMENT ON TABLE construction.bitacora_obra IS 'Bitácora diaria de obra';
COMMENT ON TABLE construction.checklists IS 'Plantillas de verificación';
COMMENT ON TABLE construction.inspecciones IS 'Inspecciones de calidad';
COMMENT ON TABLE construction.tickets_postventa IS 'Tickets de garantía';
COMMENT ON TABLE construction.subcontratistas IS 'Catálogo de subcontratistas';
COMMENT ON TABLE construction.contratos IS 'Contratos con subcontratistas';
-- ============================================================================
-- FIN
-- FIN DEL SCHEMA CONSTRUCTION
-- Total tablas: 24
-- ============================================================================

View File

@ -0,0 +1,415 @@
-- ============================================================================
-- ESTIMATES Schema DDL - Estimaciones, Anticipos y Retenciones
-- Modulos: MAI-008 (Estimaciones y Facturación)
-- Version: 1.0.0
-- Fecha: 2025-12-08
-- ============================================================================
-- PREREQUISITOS:
-- 1. ERP-Core instalado (auth.tenants, auth.users)
-- 2. Schema construction instalado (fraccionamientos, contratos, conceptos, lotes, departamentos)
-- ============================================================================
-- Verificar prerequisitos
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
RAISE EXCEPTION 'Schema auth no existe. Ejecutar primero ERP-Core DDL';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'construction') THEN
RAISE EXCEPTION 'Schema construction no existe. Ejecutar primero construction DDL';
END IF;
END $$;
-- Crear schema
CREATE SCHEMA IF NOT EXISTS estimates;
-- ============================================================================
-- TYPES (ENUMs)
-- ============================================================================
DO $$ BEGIN
CREATE TYPE estimates.estimate_status AS ENUM (
'draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE estimates.advance_type AS ENUM (
'initial', 'progress', 'materials'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE estimates.retention_type AS ENUM (
'guarantee', 'tax', 'penalty', 'other'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE estimates.generator_status AS ENUM (
'draft', 'in_progress', 'completed', 'approved'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ============================================================================
-- TABLES - ESTIMACIONES
-- ============================================================================
-- Tabla: estimaciones (estimaciones de obra)
CREATE TABLE IF NOT EXISTS estimates.estimaciones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
contrato_id UUID NOT NULL REFERENCES construction.contratos(id),
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
estimate_number VARCHAR(30) NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
sequence_number INTEGER NOT NULL,
status estimates.estimate_status NOT NULL DEFAULT 'draft',
subtotal DECIMAL(16,2) DEFAULT 0,
advance_amount DECIMAL(16,2) DEFAULT 0,
retention_amount DECIMAL(16,2) DEFAULT 0,
tax_amount DECIMAL(16,2) DEFAULT 0,
total_amount DECIMAL(16,2) DEFAULT 0,
submitted_at TIMESTAMPTZ,
submitted_by UUID REFERENCES auth.users(id),
reviewed_at TIMESTAMPTZ,
reviewed_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMPTZ,
approved_by UUID REFERENCES auth.users(id),
invoice_id UUID,
invoiced_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_estimaciones_number_tenant UNIQUE (tenant_id, estimate_number),
CONSTRAINT uq_estimaciones_sequence_contrato UNIQUE (contrato_id, sequence_number),
CONSTRAINT chk_estimaciones_period CHECK (period_end >= period_start)
);
-- Tabla: estimacion_conceptos (líneas de estimación)
CREATE TABLE IF NOT EXISTS estimates.estimacion_conceptos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
estimacion_id UUID NOT NULL REFERENCES estimates.estimaciones(id) ON DELETE CASCADE,
concepto_id UUID NOT NULL REFERENCES construction.conceptos(id),
contrato_partida_id UUID REFERENCES construction.contrato_partidas(id),
quantity_contract DECIMAL(12,4) DEFAULT 0,
quantity_previous DECIMAL(12,4) DEFAULT 0,
quantity_current DECIMAL(12,4) DEFAULT 0,
quantity_accumulated DECIMAL(12,4) GENERATED ALWAYS AS (quantity_previous + quantity_current) STORED,
unit_price DECIMAL(12,4) NOT NULL DEFAULT 0,
amount_current DECIMAL(14,2) GENERATED ALWAYS AS (quantity_current * unit_price) STORED,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_est_conceptos_estimacion_concepto UNIQUE (estimacion_id, concepto_id)
);
-- Tabla: generadores (soporte de cantidades)
CREATE TABLE IF NOT EXISTS estimates.generadores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
estimacion_concepto_id UUID NOT NULL REFERENCES estimates.estimacion_conceptos(id) ON DELETE CASCADE,
generator_number VARCHAR(30) NOT NULL,
description TEXT,
status estimates.generator_status NOT NULL DEFAULT 'draft',
lote_id UUID REFERENCES construction.lotes(id),
departamento_id UUID REFERENCES construction.departamentos(id),
location_description VARCHAR(255),
quantity DECIMAL(12,4) NOT NULL DEFAULT 0,
formula TEXT,
photo_url VARCHAR(500),
sketch_url VARCHAR(500),
captured_by UUID NOT NULL REFERENCES auth.users(id),
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
approved_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
);
-- ============================================================================
-- TABLES - ANTICIPOS
-- ============================================================================
-- Tabla: anticipos (anticipos otorgados)
CREATE TABLE IF NOT EXISTS estimates.anticipos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
contrato_id UUID NOT NULL REFERENCES construction.contratos(id),
advance_type estimates.advance_type NOT NULL DEFAULT 'initial',
advance_number VARCHAR(30) NOT NULL,
advance_date DATE NOT NULL,
gross_amount DECIMAL(16,2) NOT NULL,
tax_amount DECIMAL(16,2) DEFAULT 0,
net_amount DECIMAL(16,2) NOT NULL,
amortization_percentage DECIMAL(5,2) DEFAULT 0,
amortized_amount DECIMAL(16,2) DEFAULT 0,
pending_amount DECIMAL(16,2) GENERATED ALWAYS AS (net_amount - amortized_amount) STORED,
is_fully_amortized BOOLEAN DEFAULT FALSE,
approved_at TIMESTAMPTZ,
approved_by UUID REFERENCES auth.users(id),
paid_at TIMESTAMPTZ,
payment_reference VARCHAR(100),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_anticipos_number_tenant UNIQUE (tenant_id, advance_number)
);
-- Tabla: amortizaciones (amortizaciones de anticipos)
CREATE TABLE IF NOT EXISTS estimates.amortizaciones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
anticipo_id UUID NOT NULL REFERENCES estimates.anticipos(id),
estimacion_id UUID NOT NULL REFERENCES estimates.estimaciones(id),
amount DECIMAL(16,2) NOT NULL,
amortization_date DATE NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_amortizaciones_anticipo_estimacion UNIQUE (anticipo_id, estimacion_id)
);
-- ============================================================================
-- TABLES - RETENCIONES
-- ============================================================================
-- Tabla: retenciones (retenciones aplicadas)
CREATE TABLE IF NOT EXISTS estimates.retenciones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
estimacion_id UUID NOT NULL REFERENCES estimates.estimaciones(id),
retention_type estimates.retention_type NOT NULL,
description VARCHAR(255) NOT NULL,
percentage DECIMAL(5,2),
amount DECIMAL(16,2) NOT NULL,
release_date DATE,
released_at TIMESTAMPTZ,
released_amount DECIMAL(16,2),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
);
-- Tabla: fondo_garantia (acumulado de fondo de garantía)
CREATE TABLE IF NOT EXISTS estimates.fondo_garantia (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
contrato_id UUID NOT NULL REFERENCES construction.contratos(id),
accumulated_amount DECIMAL(16,2) DEFAULT 0,
released_amount DECIMAL(16,2) DEFAULT 0,
pending_amount DECIMAL(16,2) GENERATED ALWAYS AS (accumulated_amount - released_amount) STORED,
release_date DATE,
released_at TIMESTAMPTZ,
released_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_fondo_garantia_contrato UNIQUE (contrato_id)
);
-- ============================================================================
-- TABLES - WORKFLOW
-- ============================================================================
-- Tabla: estimacion_workflow (historial de workflow)
CREATE TABLE IF NOT EXISTS estimates.estimacion_workflow (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
estimacion_id UUID NOT NULL REFERENCES estimates.estimaciones(id) ON DELETE CASCADE,
from_status estimates.estimate_status,
to_status estimates.estimate_status NOT NULL,
action VARCHAR(50) NOT NULL,
comments TEXT,
performed_by UUID NOT NULL REFERENCES auth.users(id),
performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id)
);
-- ============================================================================
-- INDICES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_estimaciones_tenant_id ON estimates.estimaciones(tenant_id);
CREATE INDEX IF NOT EXISTS idx_estimaciones_contrato_id ON estimates.estimaciones(contrato_id);
CREATE INDEX IF NOT EXISTS idx_estimaciones_fraccionamiento_id ON estimates.estimaciones(fraccionamiento_id);
CREATE INDEX IF NOT EXISTS idx_estimaciones_status ON estimates.estimaciones(status);
CREATE INDEX IF NOT EXISTS idx_estimaciones_period ON estimates.estimaciones(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_est_conceptos_tenant_id ON estimates.estimacion_conceptos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_est_conceptos_estimacion_id ON estimates.estimacion_conceptos(estimacion_id);
CREATE INDEX IF NOT EXISTS idx_est_conceptos_concepto_id ON estimates.estimacion_conceptos(concepto_id);
CREATE INDEX IF NOT EXISTS idx_generadores_tenant_id ON estimates.generadores(tenant_id);
CREATE INDEX IF NOT EXISTS idx_generadores_est_concepto_id ON estimates.generadores(estimacion_concepto_id);
CREATE INDEX IF NOT EXISTS idx_generadores_status ON estimates.generadores(status);
CREATE INDEX IF NOT EXISTS idx_anticipos_tenant_id ON estimates.anticipos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_anticipos_contrato_id ON estimates.anticipos(contrato_id);
CREATE INDEX IF NOT EXISTS idx_anticipos_type ON estimates.anticipos(advance_type);
CREATE INDEX IF NOT EXISTS idx_amortizaciones_tenant_id ON estimates.amortizaciones(tenant_id);
CREATE INDEX IF NOT EXISTS idx_amortizaciones_anticipo_id ON estimates.amortizaciones(anticipo_id);
CREATE INDEX IF NOT EXISTS idx_amortizaciones_estimacion_id ON estimates.amortizaciones(estimacion_id);
CREATE INDEX IF NOT EXISTS idx_retenciones_tenant_id ON estimates.retenciones(tenant_id);
CREATE INDEX IF NOT EXISTS idx_retenciones_estimacion_id ON estimates.retenciones(estimacion_id);
CREATE INDEX IF NOT EXISTS idx_retenciones_type ON estimates.retenciones(retention_type);
CREATE INDEX IF NOT EXISTS idx_fondo_garantia_tenant_id ON estimates.fondo_garantia(tenant_id);
CREATE INDEX IF NOT EXISTS idx_fondo_garantia_contrato_id ON estimates.fondo_garantia(contrato_id);
CREATE INDEX IF NOT EXISTS idx_est_workflow_tenant_id ON estimates.estimacion_workflow(tenant_id);
CREATE INDEX IF NOT EXISTS idx_est_workflow_estimacion_id ON estimates.estimacion_workflow(estimacion_id);
-- ============================================================================
-- ROW LEVEL SECURITY (RLS)
-- ============================================================================
ALTER TABLE estimates.estimaciones ENABLE ROW LEVEL SECURITY;
ALTER TABLE estimates.estimacion_conceptos ENABLE ROW LEVEL SECURITY;
ALTER TABLE estimates.generadores ENABLE ROW LEVEL SECURITY;
ALTER TABLE estimates.anticipos ENABLE ROW LEVEL SECURITY;
ALTER TABLE estimates.amortizaciones ENABLE ROW LEVEL SECURITY;
ALTER TABLE estimates.retenciones ENABLE ROW LEVEL SECURITY;
ALTER TABLE estimates.fondo_garantia ENABLE ROW LEVEL SECURITY;
ALTER TABLE estimates.estimacion_workflow ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_estimaciones ON estimates.estimaciones;
CREATE POLICY tenant_isolation_estimaciones ON estimates.estimaciones
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_est_conceptos ON estimates.estimacion_conceptos;
CREATE POLICY tenant_isolation_est_conceptos ON estimates.estimacion_conceptos
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_generadores ON estimates.generadores;
CREATE POLICY tenant_isolation_generadores ON estimates.generadores
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_anticipos ON estimates.anticipos;
CREATE POLICY tenant_isolation_anticipos ON estimates.anticipos
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_amortizaciones ON estimates.amortizaciones;
CREATE POLICY tenant_isolation_amortizaciones ON estimates.amortizaciones
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_retenciones ON estimates.retenciones;
CREATE POLICY tenant_isolation_retenciones ON estimates.retenciones
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_fondo_garantia ON estimates.fondo_garantia;
CREATE POLICY tenant_isolation_fondo_garantia ON estimates.fondo_garantia
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_est_workflow ON estimates.estimacion_workflow;
CREATE POLICY tenant_isolation_est_workflow ON estimates.estimacion_workflow
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
-- ============================================================================
-- FUNCIONES
-- ============================================================================
-- Función: calcular totales de estimación
CREATE OR REPLACE FUNCTION estimates.calculate_estimate_totals(p_estimacion_id UUID)
RETURNS VOID AS $$
DECLARE
v_subtotal DECIMAL(16,2);
v_advance DECIMAL(16,2);
v_retention DECIMAL(16,2);
v_tax_rate DECIMAL(5,2) := 0.16;
v_tax DECIMAL(16,2);
v_total DECIMAL(16,2);
BEGIN
SELECT COALESCE(SUM(amount_current), 0) INTO v_subtotal
FROM estimates.estimacion_conceptos
WHERE estimacion_id = p_estimacion_id AND deleted_at IS NULL;
SELECT COALESCE(SUM(amount), 0) INTO v_advance
FROM estimates.amortizaciones
WHERE estimacion_id = p_estimacion_id AND deleted_at IS NULL;
SELECT COALESCE(SUM(amount), 0) INTO v_retention
FROM estimates.retenciones
WHERE estimacion_id = p_estimacion_id AND deleted_at IS NULL;
v_tax := v_subtotal * v_tax_rate;
v_total := v_subtotal + v_tax - v_advance - v_retention;
UPDATE estimates.estimaciones
SET subtotal = v_subtotal,
advance_amount = v_advance,
retention_amount = v_retention,
tax_amount = v_tax,
total_amount = v_total,
updated_at = NOW()
WHERE id = p_estimacion_id;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- COMENTARIOS
-- ============================================================================
COMMENT ON SCHEMA estimates IS 'Schema de estimaciones, anticipos y retenciones de obra';
COMMENT ON TABLE estimates.estimaciones IS 'Estimaciones de obra periódicas';
COMMENT ON TABLE estimates.estimacion_conceptos IS 'Líneas de concepto por estimación';
COMMENT ON TABLE estimates.generadores IS 'Generadores de cantidades para estimaciones';
COMMENT ON TABLE estimates.anticipos IS 'Anticipos otorgados a subcontratistas';
COMMENT ON TABLE estimates.amortizaciones IS 'Amortizaciones de anticipos por estimación';
COMMENT ON TABLE estimates.retenciones IS 'Retenciones aplicadas a estimaciones';
COMMENT ON TABLE estimates.fondo_garantia IS 'Fondo de garantía acumulado por contrato';
COMMENT ON TABLE estimates.estimacion_workflow IS 'Historial de workflow de estimaciones';
-- ============================================================================
-- FIN DEL SCHEMA ESTIMATES
-- Total tablas: 8
-- ============================================================================

View File

@ -0,0 +1,413 @@
-- ============================================================================
-- INFONAVIT Schema DDL - Cumplimiento INFONAVIT y Derechohabientes
-- Modulos: MAI-010 (CRM Derechohabientes), MAI-011 (Integración INFONAVIT)
-- Version: 1.0.0
-- Fecha: 2025-12-08
-- ============================================================================
-- PREREQUISITOS:
-- 1. ERP-Core instalado (auth.tenants, auth.users, auth.companies)
-- 2. Schema construction instalado (fraccionamientos, lotes, departamentos)
-- ============================================================================
-- Verificar prerequisitos
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
RAISE EXCEPTION 'Schema auth no existe. Ejecutar primero ERP-Core DDL';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'construction') THEN
RAISE EXCEPTION 'Schema construction no existe. Ejecutar primero construction DDL';
END IF;
END $$;
-- Crear schema
CREATE SCHEMA IF NOT EXISTS infonavit;
-- ============================================================================
-- TYPES (ENUMs)
-- ============================================================================
DO $$ BEGIN
CREATE TYPE infonavit.derechohabiente_status AS ENUM (
'prospect', 'pre_qualified', 'qualified', 'assigned', 'in_process', 'owner', 'cancelled'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE infonavit.credit_type AS ENUM (
'infonavit_tradicional', 'infonavit_total', 'cofinavit', 'mejoravit',
'fovissste', 'fovissste_infonavit', 'bank_credit', 'cash'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE infonavit.acta_type AS ENUM (
'inicio_obra', 'verificacion_avance', 'entrega_recepcion', 'conclusion_obra', 'liberacion_vivienda'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE infonavit.acta_status AS ENUM (
'draft', 'pending', 'signed', 'submitted', 'approved', 'rejected', 'cancelled'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE infonavit.report_type AS ENUM (
'avance_fisico', 'avance_financiero', 'inventario_viviendas', 'asignaciones', 'escrituraciones', 'cartera_vencida'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ============================================================================
-- TABLES - REGISTRO INFONAVIT
-- ============================================================================
-- Tabla: registro_infonavit (registro del constructor ante INFONAVIT)
CREATE TABLE IF NOT EXISTS infonavit.registro_infonavit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
company_id UUID NOT NULL,
registro_number VARCHAR(50) NOT NULL,
registro_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
vigencia_start DATE,
vigencia_end DATE,
responsable_tecnico VARCHAR(255),
cedula_profesional VARCHAR(50),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_registro_infonavit_tenant UNIQUE (tenant_id, registro_number)
);
-- Tabla: oferta_vivienda (oferta de viviendas ante INFONAVIT)
CREATE TABLE IF NOT EXISTS infonavit.oferta_vivienda (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
registro_id UUID NOT NULL REFERENCES infonavit.registro_infonavit(id),
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
oferta_number VARCHAR(50) NOT NULL,
submission_date DATE NOT NULL,
approval_date DATE,
total_units INTEGER NOT NULL DEFAULT 0,
approved_units INTEGER DEFAULT 0,
price_range_min DECIMAL(14,2),
price_range_max DECIMAL(14,2),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
rejection_reason TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_oferta_vivienda_tenant UNIQUE (tenant_id, oferta_number)
);
-- ============================================================================
-- TABLES - DERECHOHABIENTES
-- ============================================================================
-- Tabla: derechohabientes (compradores con crédito INFONAVIT)
CREATE TABLE IF NOT EXISTS infonavit.derechohabientes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
partner_id UUID,
nss VARCHAR(15) NOT NULL,
curp VARCHAR(18),
rfc VARCHAR(13),
full_name VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
second_last_name VARCHAR(100),
birth_date DATE,
gender VARCHAR(10),
marital_status VARCHAR(20),
nationality VARCHAR(50) DEFAULT 'Mexicana',
email VARCHAR(255),
phone VARCHAR(20),
mobile VARCHAR(20),
address TEXT,
city VARCHAR(100),
state VARCHAR(100),
zip_code VARCHAR(10),
employer_name VARCHAR(255),
employer_rfc VARCHAR(13),
employment_start_date DATE,
salary DECIMAL(12,2),
cotization_weeks INTEGER,
credit_type infonavit.credit_type,
credit_number VARCHAR(50),
credit_amount DECIMAL(14,2),
puntos_infonavit DECIMAL(10,2),
subcuenta_vivienda DECIMAL(14,2),
precalificacion_date DATE,
precalificacion_amount DECIMAL(14,2),
status infonavit.derechohabiente_status NOT NULL DEFAULT 'prospect',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_derechohabientes_nss_tenant UNIQUE (tenant_id, nss)
);
-- Tabla: asignacion_vivienda (asignación de vivienda a derechohabiente)
CREATE TABLE IF NOT EXISTS infonavit.asignacion_vivienda (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
derechohabiente_id UUID NOT NULL REFERENCES infonavit.derechohabientes(id),
lote_id UUID REFERENCES construction.lotes(id),
departamento_id UUID REFERENCES construction.departamentos(id),
oferta_id UUID REFERENCES infonavit.oferta_vivienda(id),
assignment_date DATE NOT NULL,
assignment_number VARCHAR(50),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
sale_price DECIMAL(14,2) NOT NULL,
credit_amount DECIMAL(14,2),
down_payment DECIMAL(14,2),
subsidy_amount DECIMAL(14,2),
notary_name VARCHAR(255),
notary_number VARCHAR(50),
deed_date DATE,
deed_number VARCHAR(50),
public_registry_number VARCHAR(50),
public_registry_date DATE,
scheduled_delivery_date DATE,
actual_delivery_date DATE,
delivery_act_id UUID,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT chk_asignacion_lote_or_depto CHECK (
(lote_id IS NOT NULL AND departamento_id IS NULL) OR
(lote_id IS NULL AND departamento_id IS NOT NULL)
)
);
-- ============================================================================
-- TABLES - ACTAS
-- ============================================================================
-- Tabla: actas (actas INFONAVIT)
CREATE TABLE IF NOT EXISTS infonavit.actas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
acta_type infonavit.acta_type NOT NULL,
acta_number VARCHAR(50) NOT NULL,
acta_date DATE NOT NULL,
status infonavit.acta_status NOT NULL DEFAULT 'draft',
infonavit_representative VARCHAR(255),
constructor_representative VARCHAR(255),
perito_name VARCHAR(255),
perito_cedula VARCHAR(50),
description TEXT,
observations TEXT,
agreements TEXT,
physical_advance_percentage DECIMAL(5,2),
financial_advance_percentage DECIMAL(5,2),
signed_at TIMESTAMPTZ,
submitted_to_infonavit_at TIMESTAMPTZ,
infonavit_response_at TIMESTAMPTZ,
infonavit_folio VARCHAR(50),
document_url VARCHAR(500),
signed_document_url VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_actas_number_tenant UNIQUE (tenant_id, acta_number)
);
-- Tabla: acta_viviendas (viviendas incluidas en acta)
CREATE TABLE IF NOT EXISTS infonavit.acta_viviendas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
acta_id UUID NOT NULL REFERENCES infonavit.actas(id) ON DELETE CASCADE,
lote_id UUID REFERENCES construction.lotes(id),
departamento_id UUID REFERENCES construction.departamentos(id),
advance_percentage DECIMAL(5,2),
observations TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id)
);
-- ============================================================================
-- TABLES - REPORTES INFONAVIT
-- ============================================================================
-- Tabla: reportes_infonavit (reportes enviados a INFONAVIT)
CREATE TABLE IF NOT EXISTS infonavit.reportes_infonavit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
report_type infonavit.report_type NOT NULL,
report_number VARCHAR(50) NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
submission_date DATE,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
infonavit_folio VARCHAR(50),
total_units INTEGER,
units_in_progress INTEGER,
units_completed INTEGER,
units_delivered INTEGER,
physical_advance_percentage DECIMAL(5,2),
financial_advance_percentage DECIMAL(5,2),
document_url VARCHAR(500),
acknowledgment_url VARCHAR(500),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_reportes_number_tenant UNIQUE (tenant_id, report_number)
);
-- Tabla: historico_puntos (histórico de puntos INFONAVIT)
CREATE TABLE IF NOT EXISTS infonavit.historico_puntos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
derechohabiente_id UUID NOT NULL REFERENCES infonavit.derechohabientes(id),
query_date DATE NOT NULL,
puntos DECIMAL(10,2),
subcuenta_vivienda DECIMAL(14,2),
cotization_weeks INTEGER,
credit_capacity DECIMAL(14,2),
source VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id)
);
-- ============================================================================
-- INDICES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_registro_infonavit_tenant_id ON infonavit.registro_infonavit(tenant_id);
CREATE INDEX IF NOT EXISTS idx_registro_infonavit_company_id ON infonavit.registro_infonavit(company_id);
CREATE INDEX IF NOT EXISTS idx_oferta_vivienda_tenant_id ON infonavit.oferta_vivienda(tenant_id);
CREATE INDEX IF NOT EXISTS idx_oferta_vivienda_registro_id ON infonavit.oferta_vivienda(registro_id);
CREATE INDEX IF NOT EXISTS idx_oferta_vivienda_fraccionamiento_id ON infonavit.oferta_vivienda(fraccionamiento_id);
CREATE INDEX IF NOT EXISTS idx_oferta_vivienda_status ON infonavit.oferta_vivienda(status);
CREATE INDEX IF NOT EXISTS idx_derechohabientes_tenant_id ON infonavit.derechohabientes(tenant_id);
CREATE INDEX IF NOT EXISTS idx_derechohabientes_nss ON infonavit.derechohabientes(nss);
CREATE INDEX IF NOT EXISTS idx_derechohabientes_curp ON infonavit.derechohabientes(curp);
CREATE INDEX IF NOT EXISTS idx_derechohabientes_status ON infonavit.derechohabientes(status);
CREATE INDEX IF NOT EXISTS idx_derechohabientes_credit_type ON infonavit.derechohabientes(credit_type);
CREATE INDEX IF NOT EXISTS idx_asignacion_tenant_id ON infonavit.asignacion_vivienda(tenant_id);
CREATE INDEX IF NOT EXISTS idx_asignacion_derechohabiente_id ON infonavit.asignacion_vivienda(derechohabiente_id);
CREATE INDEX IF NOT EXISTS idx_asignacion_lote_id ON infonavit.asignacion_vivienda(lote_id);
CREATE INDEX IF NOT EXISTS idx_asignacion_status ON infonavit.asignacion_vivienda(status);
CREATE INDEX IF NOT EXISTS idx_actas_tenant_id ON infonavit.actas(tenant_id);
CREATE INDEX IF NOT EXISTS idx_actas_fraccionamiento_id ON infonavit.actas(fraccionamiento_id);
CREATE INDEX IF NOT EXISTS idx_actas_type ON infonavit.actas(acta_type);
CREATE INDEX IF NOT EXISTS idx_actas_status ON infonavit.actas(status);
CREATE INDEX IF NOT EXISTS idx_acta_viviendas_tenant_id ON infonavit.acta_viviendas(tenant_id);
CREATE INDEX IF NOT EXISTS idx_acta_viviendas_acta_id ON infonavit.acta_viviendas(acta_id);
CREATE INDEX IF NOT EXISTS idx_reportes_tenant_id ON infonavit.reportes_infonavit(tenant_id);
CREATE INDEX IF NOT EXISTS idx_reportes_fraccionamiento_id ON infonavit.reportes_infonavit(fraccionamiento_id);
CREATE INDEX IF NOT EXISTS idx_reportes_type ON infonavit.reportes_infonavit(report_type);
CREATE INDEX IF NOT EXISTS idx_historico_puntos_tenant_id ON infonavit.historico_puntos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_historico_puntos_derechohabiente_id ON infonavit.historico_puntos(derechohabiente_id);
-- ============================================================================
-- ROW LEVEL SECURITY (RLS)
-- ============================================================================
ALTER TABLE infonavit.registro_infonavit ENABLE ROW LEVEL SECURITY;
ALTER TABLE infonavit.oferta_vivienda ENABLE ROW LEVEL SECURITY;
ALTER TABLE infonavit.derechohabientes ENABLE ROW LEVEL SECURITY;
ALTER TABLE infonavit.asignacion_vivienda ENABLE ROW LEVEL SECURITY;
ALTER TABLE infonavit.actas ENABLE ROW LEVEL SECURITY;
ALTER TABLE infonavit.acta_viviendas ENABLE ROW LEVEL SECURITY;
ALTER TABLE infonavit.reportes_infonavit ENABLE ROW LEVEL SECURITY;
ALTER TABLE infonavit.historico_puntos ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_registro_infonavit ON infonavit.registro_infonavit;
CREATE POLICY tenant_isolation_registro_infonavit ON infonavit.registro_infonavit
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_oferta_vivienda ON infonavit.oferta_vivienda;
CREATE POLICY tenant_isolation_oferta_vivienda ON infonavit.oferta_vivienda
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_derechohabientes ON infonavit.derechohabientes;
CREATE POLICY tenant_isolation_derechohabientes ON infonavit.derechohabientes
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_asignacion_vivienda ON infonavit.asignacion_vivienda;
CREATE POLICY tenant_isolation_asignacion_vivienda ON infonavit.asignacion_vivienda
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_actas ON infonavit.actas;
CREATE POLICY tenant_isolation_actas ON infonavit.actas
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_acta_viviendas ON infonavit.acta_viviendas;
CREATE POLICY tenant_isolation_acta_viviendas ON infonavit.acta_viviendas
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_reportes_infonavit ON infonavit.reportes_infonavit;
CREATE POLICY tenant_isolation_reportes_infonavit ON infonavit.reportes_infonavit
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_historico_puntos ON infonavit.historico_puntos;
CREATE POLICY tenant_isolation_historico_puntos ON infonavit.historico_puntos
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
-- ============================================================================
-- COMENTARIOS
-- ============================================================================
COMMENT ON SCHEMA infonavit IS 'Schema de cumplimiento INFONAVIT y gestión de derechohabientes';
COMMENT ON TABLE infonavit.registro_infonavit IS 'Registro del constructor ante INFONAVIT';
COMMENT ON TABLE infonavit.oferta_vivienda IS 'Oferta de viviendas registrada ante INFONAVIT';
COMMENT ON TABLE infonavit.derechohabientes IS 'Derechohabientes INFONAVIT/compradores';
COMMENT ON TABLE infonavit.asignacion_vivienda IS 'Asignación de vivienda a derechohabiente';
COMMENT ON TABLE infonavit.actas IS 'Actas oficiales INFONAVIT';
COMMENT ON TABLE infonavit.acta_viviendas IS 'Viviendas incluidas en cada acta';
COMMENT ON TABLE infonavit.reportes_infonavit IS 'Reportes periódicos enviados a INFONAVIT';
COMMENT ON TABLE infonavit.historico_puntos IS 'Histórico de consulta de puntos INFONAVIT';
-- ============================================================================
-- FIN DEL SCHEMA INFONAVIT
-- Total tablas: 8
-- ============================================================================

View File

@ -0,0 +1,213 @@
-- ============================================================================
-- INVENTORY EXTENSION Schema DDL - Extensiones de Inventario para Construcción
-- Modulos: MAI-004 (Compras e Inventarios)
-- Version: 1.0.0
-- Fecha: 2025-12-08
-- ============================================================================
-- TIPO: Extensión del ERP Core (MGN-005 Inventory)
-- NOTA: Contiene SOLO extensiones específicas de construcción.
-- Las tablas base están en el ERP Core.
-- ============================================================================
-- PREREQUISITOS:
-- 1. ERP-Core instalado (auth.tenants, auth.users)
-- 2. Schema construction instalado (fraccionamientos, conceptos, lotes, departamentos)
-- 3. Schema inventory de ERP-Core instalado (opcional, para FKs)
-- ============================================================================
-- Verificar prerequisitos
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
RAISE EXCEPTION 'Schema auth no existe. Ejecutar primero ERP-Core DDL';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'construction') THEN
RAISE EXCEPTION 'Schema construction no existe. Ejecutar primero construction DDL';
END IF;
END $$;
-- Crear schema si no existe (puede ya existir desde ERP-Core)
CREATE SCHEMA IF NOT EXISTS inventory;
-- ============================================================================
-- TYPES (ENUMs) ADICIONALES
-- ============================================================================
DO $$ BEGIN
CREATE TYPE inventory.warehouse_type_construction AS ENUM (
'central', 'obra', 'temporal', 'transito'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE inventory.requisition_status AS ENUM (
'draft', 'submitted', 'approved', 'partially_served', 'served', 'cancelled'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ============================================================================
-- TABLES - EXTENSIONES CONSTRUCCIÓN
-- ============================================================================
-- Tabla: almacenes_proyecto (almacén por proyecto/obra)
-- Extiende: inventory.warehouses (ERP Core)
CREATE TABLE IF NOT EXISTS inventory.almacenes_proyecto (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
warehouse_id UUID NOT NULL, -- FK a inventory.warehouses (ERP Core)
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
warehouse_type inventory.warehouse_type_construction NOT NULL DEFAULT 'obra',
location_description TEXT,
location GEOMETRY(POINT, 4326),
responsible_id UUID REFERENCES auth.users(id),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_almacenes_proyecto_warehouse UNIQUE (warehouse_id)
);
-- Tabla: requisiciones_obra (requisiciones desde obra)
CREATE TABLE IF NOT EXISTS inventory.requisiciones_obra (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
requisition_number VARCHAR(30) NOT NULL,
requisition_date DATE NOT NULL,
required_date DATE NOT NULL,
status inventory.requisition_status NOT NULL DEFAULT 'draft',
priority VARCHAR(20) DEFAULT 'medium',
requested_by UUID NOT NULL REFERENCES auth.users(id),
destination_warehouse_id UUID, -- FK a inventory.warehouses (ERP Core)
approved_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMPTZ,
rejection_reason TEXT,
purchase_order_id UUID, -- FK a purchase.purchase_orders (ERP Core)
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_requisiciones_obra_number UNIQUE (tenant_id, requisition_number)
);
-- Tabla: requisicion_lineas (líneas de requisición)
CREATE TABLE IF NOT EXISTS inventory.requisicion_lineas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
requisicion_id UUID NOT NULL REFERENCES inventory.requisiciones_obra(id) ON DELETE CASCADE,
product_id UUID NOT NULL, -- FK a inventory.products (ERP Core)
concepto_id UUID REFERENCES construction.conceptos(id),
lote_id UUID REFERENCES construction.lotes(id),
quantity_requested DECIMAL(12,4) NOT NULL,
quantity_approved DECIMAL(12,4),
quantity_served DECIMAL(12,4) DEFAULT 0,
unit_id UUID, -- FK a core.uom (ERP Core)
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
);
-- Tabla: consumos_obra (consumos de materiales por obra/lote)
CREATE TABLE IF NOT EXISTS inventory.consumos_obra (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
stock_move_id UUID, -- FK a inventory.stock_moves (ERP Core)
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
lote_id UUID REFERENCES construction.lotes(id),
departamento_id UUID REFERENCES construction.departamentos(id),
concepto_id UUID REFERENCES construction.conceptos(id),
product_id UUID NOT NULL, -- FK a inventory.products (ERP Core)
quantity DECIMAL(12,4) NOT NULL,
unit_cost DECIMAL(12,4),
total_cost DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_cost) STORED,
consumption_date DATE NOT NULL,
registered_by UUID NOT NULL REFERENCES auth.users(id),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id)
);
-- ============================================================================
-- INDICES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_almacenes_proyecto_tenant_id ON inventory.almacenes_proyecto(tenant_id);
CREATE INDEX IF NOT EXISTS idx_almacenes_proyecto_warehouse_id ON inventory.almacenes_proyecto(warehouse_id);
CREATE INDEX IF NOT EXISTS idx_almacenes_proyecto_fraccionamiento_id ON inventory.almacenes_proyecto(fraccionamiento_id);
CREATE INDEX IF NOT EXISTS idx_requisiciones_obra_tenant_id ON inventory.requisiciones_obra(tenant_id);
CREATE INDEX IF NOT EXISTS idx_requisiciones_obra_fraccionamiento_id ON inventory.requisiciones_obra(fraccionamiento_id);
CREATE INDEX IF NOT EXISTS idx_requisiciones_obra_status ON inventory.requisiciones_obra(status);
CREATE INDEX IF NOT EXISTS idx_requisiciones_obra_date ON inventory.requisiciones_obra(requisition_date);
CREATE INDEX IF NOT EXISTS idx_requisiciones_obra_required_date ON inventory.requisiciones_obra(required_date);
CREATE INDEX IF NOT EXISTS idx_requisicion_lineas_tenant_id ON inventory.requisicion_lineas(tenant_id);
CREATE INDEX IF NOT EXISTS idx_requisicion_lineas_requisicion_id ON inventory.requisicion_lineas(requisicion_id);
CREATE INDEX IF NOT EXISTS idx_requisicion_lineas_product_id ON inventory.requisicion_lineas(product_id);
CREATE INDEX IF NOT EXISTS idx_consumos_obra_tenant_id ON inventory.consumos_obra(tenant_id);
CREATE INDEX IF NOT EXISTS idx_consumos_obra_fraccionamiento_id ON inventory.consumos_obra(fraccionamiento_id);
CREATE INDEX IF NOT EXISTS idx_consumos_obra_lote_id ON inventory.consumos_obra(lote_id);
CREATE INDEX IF NOT EXISTS idx_consumos_obra_concepto_id ON inventory.consumos_obra(concepto_id);
CREATE INDEX IF NOT EXISTS idx_consumos_obra_product_id ON inventory.consumos_obra(product_id);
CREATE INDEX IF NOT EXISTS idx_consumos_obra_date ON inventory.consumos_obra(consumption_date);
-- ============================================================================
-- ROW LEVEL SECURITY (RLS)
-- ============================================================================
ALTER TABLE inventory.almacenes_proyecto ENABLE ROW LEVEL SECURITY;
ALTER TABLE inventory.requisiciones_obra ENABLE ROW LEVEL SECURITY;
ALTER TABLE inventory.requisicion_lineas ENABLE ROW LEVEL SECURITY;
ALTER TABLE inventory.consumos_obra ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_almacenes_proyecto ON inventory.almacenes_proyecto;
CREATE POLICY tenant_isolation_almacenes_proyecto ON inventory.almacenes_proyecto
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_requisiciones_obra ON inventory.requisiciones_obra;
CREATE POLICY tenant_isolation_requisiciones_obra ON inventory.requisiciones_obra
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_requisicion_lineas ON inventory.requisicion_lineas;
CREATE POLICY tenant_isolation_requisicion_lineas ON inventory.requisicion_lineas
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_consumos_obra ON inventory.consumos_obra;
CREATE POLICY tenant_isolation_consumos_obra ON inventory.consumos_obra
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
-- ============================================================================
-- COMENTARIOS
-- ============================================================================
COMMENT ON TABLE inventory.almacenes_proyecto IS 'Extensión: almacenes por proyecto de construcción';
COMMENT ON TABLE inventory.requisiciones_obra IS 'Extensión: requisiciones de material desde obra';
COMMENT ON TABLE inventory.requisicion_lineas IS 'Extensión: líneas de requisición de obra';
COMMENT ON TABLE inventory.consumos_obra IS 'Extensión: consumos de materiales por obra/lote';
-- ============================================================================
-- FIN DE EXTENSIONES INVENTORY
-- Total tablas: 4
-- ============================================================================

View File

@ -0,0 +1,227 @@
-- ============================================================================
-- PURCHASE EXTENSION Schema DDL - Extensiones de Compras para Construcción
-- Modulos: MAI-004 (Compras e Inventarios)
-- Version: 1.0.0
-- Fecha: 2025-12-08
-- ============================================================================
-- TIPO: Extensión del ERP Core (MGN-006 Purchase)
-- NOTA: Contiene SOLO extensiones específicas de construcción.
-- Las tablas base están en el ERP Core.
-- ============================================================================
-- PREREQUISITOS:
-- 1. ERP-Core instalado (auth.tenants, auth.users)
-- 2. Schema construction instalado (fraccionamientos)
-- 3. Schema inventory extension instalado (requisiciones_obra)
-- 4. Schema purchase de ERP-Core instalado (opcional, para FKs)
-- ============================================================================
-- Verificar prerequisitos
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
RAISE EXCEPTION 'Schema auth no existe. Ejecutar primero ERP-Core DDL';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'construction') THEN
RAISE EXCEPTION 'Schema construction no existe. Ejecutar primero construction DDL';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'inventory') THEN
RAISE EXCEPTION 'Schema inventory no existe. Ejecutar primero inventory extension DDL';
END IF;
END $$;
-- Crear schema si no existe (puede ya existir desde ERP-Core)
CREATE SCHEMA IF NOT EXISTS purchase;
-- ============================================================================
-- TABLES - EXTENSIONES CONSTRUCCIÓN
-- ============================================================================
-- Tabla: purchase_order_construction (extensión de órdenes de compra)
-- Extiende: purchase.purchase_orders (ERP Core)
CREATE TABLE IF NOT EXISTS purchase.purchase_order_construction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
purchase_order_id UUID NOT NULL, -- FK a purchase.purchase_orders (ERP Core)
fraccionamiento_id UUID REFERENCES construction.fraccionamientos(id),
requisicion_id UUID REFERENCES inventory.requisiciones_obra(id),
delivery_location VARCHAR(255),
delivery_contact VARCHAR(100),
delivery_phone VARCHAR(20),
received_by UUID REFERENCES auth.users(id),
received_at TIMESTAMPTZ,
quality_approved BOOLEAN,
quality_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_po_construction_po_id UNIQUE (purchase_order_id)
);
-- Tabla: supplier_construction (extensión de proveedores)
-- Extiende: purchase.suppliers (ERP Core)
CREATE TABLE IF NOT EXISTS purchase.supplier_construction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
supplier_id UUID NOT NULL, -- FK a purchase.suppliers (ERP Core)
is_materials_supplier BOOLEAN DEFAULT FALSE,
is_services_supplier BOOLEAN DEFAULT FALSE,
is_equipment_supplier BOOLEAN DEFAULT FALSE,
specialties TEXT[],
quality_rating DECIMAL(3,2),
delivery_rating DECIMAL(3,2),
price_rating DECIMAL(3,2),
overall_rating DECIMAL(3,2) GENERATED ALWAYS AS (
(COALESCE(quality_rating, 0) + COALESCE(delivery_rating, 0) + COALESCE(price_rating, 0)) / 3
) STORED,
last_evaluation_date DATE,
credit_limit DECIMAL(14,2),
payment_days INTEGER DEFAULT 30,
has_valid_documents BOOLEAN DEFAULT FALSE,
documents_expiry_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_supplier_construction_supplier_id UNIQUE (supplier_id)
);
-- Tabla: comparativo_cotizaciones (cuadro comparativo)
CREATE TABLE IF NOT EXISTS purchase.comparativo_cotizaciones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
requisicion_id UUID REFERENCES inventory.requisiciones_obra(id),
code VARCHAR(30) NOT NULL,
name VARCHAR(255) NOT NULL,
comparison_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
winner_supplier_id UUID, -- FK a purchase.suppliers (ERP Core)
approved_by UUID REFERENCES auth.users(id),
approved_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_comparativo_code_tenant UNIQUE (tenant_id, code)
);
-- Tabla: comparativo_proveedores (proveedores en comparativo)
CREATE TABLE IF NOT EXISTS purchase.comparativo_proveedores (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
comparativo_id UUID NOT NULL REFERENCES purchase.comparativo_cotizaciones(id) ON DELETE CASCADE,
supplier_id UUID NOT NULL, -- FK a purchase.suppliers (ERP Core)
quotation_number VARCHAR(50),
quotation_date DATE,
delivery_days INTEGER,
payment_conditions VARCHAR(100),
total_amount DECIMAL(16,2),
is_selected BOOLEAN DEFAULT FALSE,
evaluation_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id)
);
-- Tabla: comparativo_productos (productos en comparativo)
CREATE TABLE IF NOT EXISTS purchase.comparativo_productos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
comparativo_proveedor_id UUID NOT NULL REFERENCES purchase.comparativo_proveedores(id) ON DELETE CASCADE,
product_id UUID NOT NULL, -- FK a inventory.products (ERP Core)
quantity DECIMAL(12,4) NOT NULL,
unit_price DECIMAL(12,4) NOT NULL,
total_price DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ,
updated_by UUID REFERENCES auth.users(id)
);
-- ============================================================================
-- INDICES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_po_construction_tenant_id ON purchase.purchase_order_construction(tenant_id);
CREATE INDEX IF NOT EXISTS idx_po_construction_po_id ON purchase.purchase_order_construction(purchase_order_id);
CREATE INDEX IF NOT EXISTS idx_po_construction_fraccionamiento_id ON purchase.purchase_order_construction(fraccionamiento_id);
CREATE INDEX IF NOT EXISTS idx_po_construction_requisicion_id ON purchase.purchase_order_construction(requisicion_id);
CREATE INDEX IF NOT EXISTS idx_supplier_construction_tenant_id ON purchase.supplier_construction(tenant_id);
CREATE INDEX IF NOT EXISTS idx_supplier_construction_supplier_id ON purchase.supplier_construction(supplier_id);
CREATE INDEX IF NOT EXISTS idx_supplier_construction_rating ON purchase.supplier_construction(overall_rating);
CREATE INDEX IF NOT EXISTS idx_comparativo_tenant_id ON purchase.comparativo_cotizaciones(tenant_id);
CREATE INDEX IF NOT EXISTS idx_comparativo_requisicion_id ON purchase.comparativo_cotizaciones(requisicion_id);
CREATE INDEX IF NOT EXISTS idx_comparativo_status ON purchase.comparativo_cotizaciones(status);
CREATE INDEX IF NOT EXISTS idx_comparativo_prov_tenant_id ON purchase.comparativo_proveedores(tenant_id);
CREATE INDEX IF NOT EXISTS idx_comparativo_prov_comparativo_id ON purchase.comparativo_proveedores(comparativo_id);
CREATE INDEX IF NOT EXISTS idx_comparativo_prov_supplier_id ON purchase.comparativo_proveedores(supplier_id);
CREATE INDEX IF NOT EXISTS idx_comparativo_prod_tenant_id ON purchase.comparativo_productos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_comparativo_prod_proveedor_id ON purchase.comparativo_productos(comparativo_proveedor_id);
-- ============================================================================
-- ROW LEVEL SECURITY (RLS)
-- ============================================================================
ALTER TABLE purchase.purchase_order_construction ENABLE ROW LEVEL SECURITY;
ALTER TABLE purchase.supplier_construction ENABLE ROW LEVEL SECURITY;
ALTER TABLE purchase.comparativo_cotizaciones ENABLE ROW LEVEL SECURITY;
ALTER TABLE purchase.comparativo_proveedores ENABLE ROW LEVEL SECURITY;
ALTER TABLE purchase.comparativo_productos ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_po_construction ON purchase.purchase_order_construction;
CREATE POLICY tenant_isolation_po_construction ON purchase.purchase_order_construction
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_supplier_construction ON purchase.supplier_construction;
CREATE POLICY tenant_isolation_supplier_construction ON purchase.supplier_construction
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_comparativo ON purchase.comparativo_cotizaciones;
CREATE POLICY tenant_isolation_comparativo ON purchase.comparativo_cotizaciones
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_comparativo_prov ON purchase.comparativo_proveedores;
CREATE POLICY tenant_isolation_comparativo_prov ON purchase.comparativo_proveedores
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
DO $$ BEGIN
DROP POLICY IF EXISTS tenant_isolation_comparativo_prod ON purchase.comparativo_productos;
CREATE POLICY tenant_isolation_comparativo_prod ON purchase.comparativo_productos
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
-- ============================================================================
-- COMENTARIOS
-- ============================================================================
COMMENT ON TABLE purchase.purchase_order_construction IS 'Extensión: datos adicionales de OC para construcción';
COMMENT ON TABLE purchase.supplier_construction IS 'Extensión: datos adicionales de proveedores para construcción';
COMMENT ON TABLE purchase.comparativo_cotizaciones IS 'Extensión: cuadro comparativo de cotizaciones';
COMMENT ON TABLE purchase.comparativo_proveedores IS 'Extensión: proveedores participantes en comparativo';
COMMENT ON TABLE purchase.comparativo_productos IS 'Extensión: productos cotizados por proveedor';
-- ============================================================================
-- FIN DE EXTENSIONES PURCHASE
-- Total tablas: 5
-- ============================================================================

View File

@ -1,16 +1,16 @@
# =============================================================================
# DATABASE INVENTORY - ERP CONSTRUCCION
# =============================================================================
# Version: 1.1.0
# Ultima actualizacion: 2025-12-06
# Version: 1.3.0
# Ultima actualizacion: 2025-12-09
# Proposito: Inventario canonico de objetos de base de datos
# Nomenclatura: Ver NAMING-CONVENTIONS.md
# =============================================================================
metadata:
proyecto: ERP Construccion
version: 2.0.0
fecha_actualizacion: 2025-12-08
version: 2.1.0
fecha_actualizacion: 2025-12-09
motor: PostgreSQL 15+
extensiones: [uuid-ossp, pg_trgm, btree_gist, pgcrypto, postgis]
@ -18,41 +18,41 @@ metadata:
# HERENCIA DE ERP CORE
# =============================================================================
herencia_core:
version_core: "1.1.0"
tablas_heredadas: 124 # Total de tablas del core
version_core: "1.2.0"
tablas_heredadas: 144 # Total de tablas del core (actualizado 2025-12-09)
schemas_heredados:
- auth: 26 # Autenticación, MFA, OAuth, API Keys
- core: 12 # Partners, catálogos, monedas
- financial: 15 # Contabilidad, facturas
- inventory: 15 # Productos, stock, valoración
- purchase: 8 # Compras
- sales: 6 # Ventas
- projects: 5 # Base proyectos
- hr: 6 # RRHH base
- analytics: 5 # Centros de costo
- system: 10 # Mensajes, notificaciones
- billing: 11 # SaaS (opcional)
- crm: 5 # CRM (opcional)
- auth: 26 # 10 (auth.sql) + 16 (auth-extensions.sql)
- core: 12 # Partners, catálogos, monedas, UoM
- financial: 15 # Contabilidad, facturas, pagos
- inventory: 20 # 10 (inventory.sql) + 10 (inventory-extensions.sql)
- purchase: 8 # Órdenes de compra, proveedores
- sales: 10 # Órdenes de venta, clientes
- projects: 10 # Proyectos, tareas, dependencias
- hr: 6 # RRHH base, empleados
- analytics: 7 # Centros de costo, cuentas analíticas
- system: 13 # Mensajes, notificaciones, logs
- billing: 11 # SaaS multi-tenant (opcional)
- crm: 6 # CRM leads, opportunities (opcional)
referencia: "apps/erp-core/database/ddl/"
documento_herencia: "../database/HERENCIA-ERP-CORE.md"
# =============================================================================
# RESUMEN DE OBJETOS (ACTUALIZADO 2025-12-08)
# RESUMEN DE OBJETOS (ACTUALIZADO 2025-12-09)
# =============================================================================
resumen:
schemas_core: 12 # Heredados de erp-core
schemas_especificos: 3 # construccion, hr (ext), hse
tablas_heredadas: 124 # Del core
tablas_especificas: 33 # 2 construccion + 3 hr + 28 hse
tablas_total: 157 # 124 + 33
schemas_especificos: 7 # construction, hr, hse, estimates, infonavit, inventory-ext, purchase-ext
tablas_heredadas: 144 # Del core (actualizado 2025-12-09)
tablas_especificas: 110 # 24 construction + 8 hr + 58 hse + 8 estimates + 8 infonavit + 4 inventory + 5 purchase
tablas_total: 254 # 144 + 110
enums: 89 # 22 base + 67 HSE
funciones: 13
triggers: 15
rls_policies: 157 # 1 policy por tabla con tenant_id
indices: 250+
rls_policies: 254 # 1 policy por tabla con tenant_id (144 core + 110 construcción)
indices: 350+
estado_implementacion:
database_core: "100%" # ERP Core validado con carga limpia
database_construccion: "100%" # DDL de construccion implementado
database_construccion: "100%" # DDL completo - 7 schemas, 110 tablas
backend: "5%" # Solo entidades base
frontend: "2%" # Solo estructura
ddl_files_core:
@ -72,9 +72,13 @@ resumen:
- "erp-core/database/ddl/11-crm.sql"
- "erp-core/database/ddl/12-hr.sql"
ddl_files_extension:
- schemas/01-construction-schema-ddl.sql
- schemas/02-hr-schema-ddl.sql
- schemas/03-hse-schema-ddl.sql
- schemas/01-construction-schema-ddl.sql # 24 tablas
- schemas/02-hr-schema-ddl.sql # 8 tablas
- schemas/03-hse-schema-ddl.sql # 58 tablas
- schemas/04-estimates-schema-ddl.sql # 8 tablas
- schemas/05-infonavit-schema-ddl.sql # 8 tablas
- schemas/06-inventory-ext-schema-ddl.sql # 4 tablas
- schemas/07-purchase-ext-schema-ddl.sql # 5 tablas
# =============================================================================
# SCHEMAS - NOMENCLATURA UNIFICADA
@ -1422,25 +1426,43 @@ schemas_deprecados:
- documents_management: "usar 'documents' (pendiente)"
# =============================================================================
# VALIDACION DDL (2025-12-08)
# VALIDACION DDL (2025-12-09)
# =============================================================================
validacion_ddl:
fecha: "2025-12-08"
estado: "✅ CORREGIDO"
total_correcciones: 50
archivos_corregidos:
fecha: "2025-12-09"
estado: "✅ COMPLETO - 7 schemas, 110 tablas"
total_archivos_ddl: 7
archivos_ddl:
- archivo: "schemas/01-construction-schema-ddl.sql"
correcciones: 4
detalle: "core.tenants → auth.tenants, core.users → auth.users"
tablas: 24
estado: "implementado"
- archivo: "schemas/02-hr-schema-ddl.sql"
correcciones: 4
detalle: "core.tenants → auth.tenants, core.users → auth.users"
tablas: 8
estado: "implementado"
- archivo: "schemas/03-hse-schema-ddl.sql"
correcciones: 42
detalle: "Todas las FK corregidas a auth.*"
tablas: 58
estado: "implementado"
- archivo: "schemas/04-estimates-schema-ddl.sql"
tablas: 8
estado: "implementado"
- archivo: "schemas/05-infonavit-schema-ddl.sql"
tablas: 8
estado: "implementado"
- archivo: "schemas/06-inventory-ext-schema-ddl.sql"
tablas: 4
estado: "implementado"
- archivo: "schemas/07-purchase-ext-schema-ddl.sql"
tablas: 5
estado: "implementado"
alineacion_erp_core:
rls_variable: "app.current_tenant_id"
fk_tenants: "auth.tenants"
fk_users: "auth.users"
prerequisitos_verificados: true
verificaciones_prerequisitos:
- "DDL verifica existencia de auth.tenants"
- "DDL verifica existencia de auth.users"
- "DDL verifica existencia de schemas dependientes"
- "ERP-Core debe estar instalado antes de ejecutar DDL"
compatible_erp_core: true
@ -1450,11 +1472,11 @@ validacion_ddl:
metadata:
creado_por: Requirements-Analyst
fecha_creacion: 2025-12-06
ultima_actualizacion: 2025-12-08
version_documento: 1.2.0
ultima_actualizacion: 2025-12-09
version_documento: 1.3.0
cambios_version:
- "1.3.0: DDL completo - 7 schemas, 110 tablas (2025-12-09)"
- "1.3.0: Nuevos DDL: estimates, infonavit, inventory-ext, purchase-ext"
- "1.3.0: Variable RLS corregida a app.current_tenant_id"
- "1.2.0: Validacion DDL - 50 FK corregidas a auth.* (2025-12-08)"
- "1.2.0: Verificaciones de prerequisitos actualizadas"
- "1.1.0: Nomenclatura unificada segun NAMING-CONVENTIONS.md"
- "1.1.0: Alineacion con DDL files reales"
- "1.1.0: Schemas deprecados documentados"

View File

@ -1,7 +1,7 @@
# =============================================================================
# MASTER INVENTORY - ERP CONSTRUCCION
# =============================================================================
# Ultima actualizacion: 2025-12-06
# Ultima actualizacion: 2025-12-09
# SSOT: Single Source of Truth para metricas del proyecto vertical
# Base: Extiende erp-core (61% reutilizacion)
# Nomenclatura: Ver NAMING-CONVENTIONS.md
@ -57,7 +57,7 @@ metricas:
fase_2_mae: 3
fase_3_maa: 1
documentados: 18 # Todos documentados incluyendo MAA-017
ddl_implementado: 3 # construction, hr, hse
ddl_implementado: 7 # construction, hr, hse, estimates, infonavit, inventory-ext, purchase-ext
backend_parcial: 4 # construction, hr, hse, core (entidades básicas)
requerimientos:
@ -77,15 +77,15 @@ metricas:
story_points: 692 # +42 de MAA-017
database:
# Conteo real basado en DDL files (actualizado 2025-12-08)
schemas_implementados: 3 # construction, hr, hse
schemas_pendientes: 4 # estimates, infonavit, inventory-ext, purchase-ext
tablas_implementadas: 33 # 2 construction + 3 hr + 28 hse
tablas_documentadas: 65 # Total en documentación
# Conteo real basado en DDL files (actualizado 2025-12-09)
schemas_implementados: 7 # construction, hr, hse, estimates, infonavit, inventory, purchase
schemas_pendientes: 0 # Todos los schemas de Fase 1 implementados
tablas_implementadas: 110 # 24 construction + 8 hr + 58 hse + 8 estimates + 8 infonavit + 4 inventory + 5 purchase
tablas_documentadas: 110 # Total alineado con DDL
enums: 89 # 22 base + 67 HSE
funciones: 13
triggers: 15
rls_policies: 33 # 1 por tabla implementada
rls_policies: 110 # 1 por tabla implementada
backend:
# Estado actual del código TypeScript
@ -767,25 +767,41 @@ proxima_accion:
- Configuracion multi-tenant
# =============================================================================
# VALIDACION DDL (2025-12-08)
# VALIDACION DDL (2025-12-09)
# =============================================================================
validacion_ddl:
fecha: "2025-12-08"
estado: "✅ CORREGIDO"
fecha: "2025-12-09"
estado: "✅ COMPLETO - Alineado con erp-core"
compatible_erp_core: true
total_correcciones: 50
archivos_corregidos:
total_archivos_ddl: 5
ddl_files:
- archivo: "01-construction-schema-ddl.sql"
correcciones: 4
tablas: 24
estado: "implementado"
- archivo: "02-hr-schema-ddl.sql"
correcciones: 4
tablas: 8
estado: "implementado"
- archivo: "03-hse-schema-ddl.sql"
correcciones: 42
correcciones_aplicadas:
- "core.tenants → auth.tenants"
- "core.users → auth.users"
- "Verificaciones de prerequisitos actualizadas"
nota: "DDL ahora compatible con ERP-Core. Requiere ERP-Core instalado."
tablas: 58
estado: "implementado"
- archivo: "04-estimates-schema-ddl.sql"
tablas: 8
estado: "implementado"
- archivo: "05-infonavit-schema-ddl.sql"
tablas: 8
estado: "implementado"
- archivo: "06-inventory-ext-schema-ddl.sql"
tablas: 4
estado: "implementado"
- archivo: "07-purchase-ext-schema-ddl.sql"
tablas: 5
estado: "implementado"
alineacion_erp_core:
rls_variable: "app.current_tenant_id"
fk_tenants: "auth.tenants"
fk_users: "auth.users"
prerequisitos_verificados: true
nota: "Todos los DDL verificados y alineados con erp-core. Variable RLS corregida a app.current_tenant_id"
# =============================================================================
# METADATA
@ -793,11 +809,12 @@ validacion_ddl:
metadata:
creado_por: Requirements-Analyst
fecha_creacion: 2025-12-06
ultima_actualizacion: 2025-12-08
version_documento: 1.2.0
ultima_actualizacion: 2025-12-09
version_documento: 1.3.0
cambios_version:
- "1.3.0: DDL completo - 7 schemas, 110 tablas implementadas (2025-12-09)"
- "1.3.0: Nuevos DDL: estimates, infonavit, inventory-ext, purchase-ext"
- "1.3.0: Variable RLS corregida a app.current_tenant_id (alineado erp-core)"
- "1.2.0: Validacion DDL completada - 50 FK corregidas (2025-12-08)"
- "1.2.0: Prerequisitos DDL actualizados para ERP-Core"
- "1.1.0: Nomenclatura de schemas unificada segun NAMING-CONVENTIONS.md"
- "1.1.0: Conteos corregidos segun DDL files reales"
- "1.1.0: Tablas mapeadas por modulo"

View File

@ -0,0 +1,22 @@
# Mecánicas Diesel Backend - Environment Variables
# Server
NODE_ENV=development
PORT=3010
# Database (PostgreSQL)
DB_HOST=localhost
DB_PORT=5434
DB_NAME=mecanicas_diesel_dev
DB_USER=mecanicas_user
DB_PASSWORD=mecanicas_secret_2024
# JWT
JWT_SECRET=your-jwt-secret-change-in-production
JWT_EXPIRES_IN=24h
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Logging
LOG_LEVEL=debug

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
{
"name": "@erp-suite/mecanicas-diesel-backend",
"version": "0.1.0",
"description": "Backend for Mecánicas Diesel vertical - ERP Suite",
"main": "dist/main.js",
"scripts": {
"build": "tsc",
"dev": "ts-node-dev --respawn --transpile-only src/main.ts",
"start": "node dist/main.js",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"test": "jest",
"test:cov": "jest --coverage",
"db:migrate": "typeorm migration:run",
"db:migrate:revert": "typeorm migration:revert"
},
"dependencies": {
"express": "^4.18.2",
"typeorm": "^0.3.17",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.1",
"dotenv": "^16.3.1",
"zod": "^3.22.4",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"uuid": "^9.0.1",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"compression": "^1.7.4",
"morgan": "^1.10.0",
"winston": "^3.11.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.5",
"@types/uuid": "^9.0.7",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@types/morgan": "^1.9.9",
"typescript": "^5.3.3",
"ts-node-dev": "^2.0.0",
"eslint": "^8.55.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.11",
"ts-jest": "^29.1.1"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"erp",
"mecanicas",
"diesel",
"taller",
"workshop"
],
"author": "ISEM Team",
"license": "PROPRIETARY"
}

View File

@ -0,0 +1,163 @@
/**
* Main Entry Point
* Mecánicas Diesel Backend - ERP Suite
*/
import 'reflect-metadata';
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import morgan from 'morgan';
import { config } from 'dotenv';
import { DataSource } from 'typeorm';
// Controllers
import { createServiceOrderController } from './modules/service-management/controllers/service-order.controller';
import { createQuoteController } from './modules/service-management/controllers/quote.controller';
import { createDiagnosticController } from './modules/service-management/controllers/diagnostic.controller';
import { createVehicleController } from './modules/vehicle-management/controllers/vehicle.controller';
import { createFleetController } from './modules/vehicle-management/controllers/fleet.controller';
import { createPartController } from './modules/parts-management/controllers/part.controller';
import { createSupplierController } from './modules/parts-management/controllers/supplier.controller';
// Entities
import { ServiceOrder } from './modules/service-management/entities/service-order.entity';
import { OrderItem } from './modules/service-management/entities/order-item.entity';
import { Diagnostic } from './modules/service-management/entities/diagnostic.entity';
import { Quote } from './modules/service-management/entities/quote.entity';
import { WorkBay } from './modules/service-management/entities/work-bay.entity';
import { Service } from './modules/service-management/entities/service.entity';
import { Vehicle } from './modules/vehicle-management/entities/vehicle.entity';
import { Fleet } from './modules/vehicle-management/entities/fleet.entity';
import { VehicleEngine } from './modules/vehicle-management/entities/vehicle-engine.entity';
import { EngineCatalog } from './modules/vehicle-management/entities/engine-catalog.entity';
import { MaintenanceReminder } from './modules/vehicle-management/entities/maintenance-reminder.entity';
import { Part } from './modules/parts-management/entities/part.entity';
import { PartCategory } from './modules/parts-management/entities/part-category.entity';
import { Supplier } from './modules/parts-management/entities/supplier.entity';
import { WarehouseLocation } from './modules/parts-management/entities/warehouse-location.entity';
// Load environment variables
config();
const app = express();
const PORT = process.env.PORT || 3011;
// Database configuration
const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'mecanicas_diesel',
schema: process.env.DB_SCHEMA || 'public',
entities: [
// Service Management
ServiceOrder,
OrderItem,
Diagnostic,
Quote,
WorkBay,
Service,
// Vehicle Management
Vehicle,
Fleet,
VehicleEngine,
EngineCatalog,
MaintenanceReminder,
// Parts Management
Part,
PartCategory,
Supplier,
WarehouseLocation,
],
synchronize: process.env.NODE_ENV === 'development',
logging: process.env.NODE_ENV === 'development',
});
// Middleware
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000', 'http://localhost:5175'],
credentials: true,
}));
app.use(compression());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Health check
app.get('/health', (_req, res) => {
res.json({
status: 'healthy',
service: 'mecanicas-diesel-backend',
version: '0.1.0',
timestamp: new Date().toISOString(),
database: AppDataSource.isInitialized ? 'connected' : 'disconnected',
});
});
// Initialize database and routes
async function bootstrap() {
try {
// Initialize database connection
await AppDataSource.initialize();
console.log('📦 Database connection established');
// Register API routes
app.use('/api/v1/service-orders', createServiceOrderController(AppDataSource));
app.use('/api/v1/quotes', createQuoteController(AppDataSource));
app.use('/api/v1/diagnostics', createDiagnosticController(AppDataSource));
app.use('/api/v1/vehicles', createVehicleController(AppDataSource));
app.use('/api/v1/fleets', createFleetController(AppDataSource));
app.use('/api/v1/parts', createPartController(AppDataSource));
app.use('/api/v1/suppliers', createSupplierController(AppDataSource));
// API documentation endpoint
app.get('/api/v1', (_req, res) => {
res.json({
name: 'Mecánicas Diesel API',
version: '1.0.0',
endpoints: {
serviceOrders: '/api/v1/service-orders',
quotes: '/api/v1/quotes',
diagnostics: '/api/v1/diagnostics',
vehicles: '/api/v1/vehicles',
fleets: '/api/v1/fleets',
parts: '/api/v1/parts',
suppliers: '/api/v1/suppliers',
},
documentation: '/api/v1/docs',
});
});
// 404 handler
app.use((_req, res) => {
res.status(404).json({ error: 'Not Found' });
});
// Error handler
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
});
// Start server
app.listen(PORT, () => {
console.log(`🔧 Mecánicas Diesel Backend running on port ${PORT}`);
console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`🏥 Health check: http://localhost:${PORT}/health`);
console.log(`📚 API Root: http://localhost:${PORT}/api/v1`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
bootstrap();

View File

@ -0,0 +1,259 @@
/**
* Part Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for parts/inventory management.
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { PartService, PartFilters } from '../services/part.service';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createPartController(dataSource: DataSource): Router {
const router = Router();
const service = new PartService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Create a new part
* POST /api/parts
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const part = await service.create(req.tenantId!, req.body);
res.status(201).json(part);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List parts with filters
* GET /api/parts
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const filters: PartFilters = {
categoryId: req.query.categoryId as string,
preferredSupplierId: req.query.supplierId as string,
brand: req.query.brand as string,
search: req.query.search as string,
lowStock: req.query.lowStock === 'true',
isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined,
};
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get inventory statistics
* GET /api/parts/stats
*/
router.get('/stats', async (req: TenantRequest, res: Response) => {
try {
const stats = await service.getInventoryValue(req.tenantId!);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get parts with low stock
* GET /api/parts/low-stock
*/
router.get('/low-stock', async (req: TenantRequest, res: Response) => {
try {
const parts = await service.getLowStockParts(req.tenantId!);
res.json(parts);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Search parts (for autocomplete)
* GET /api/parts/search
*/
router.get('/search', async (req: TenantRequest, res: Response) => {
try {
const query = req.query.q as string || '';
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
const parts = await service.search(req.tenantId!, query, limit);
res.json(parts);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get a single part
* GET /api/parts/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const part = await service.findById(req.tenantId!, req.params.id);
if (!part) {
return res.status(404).json({ error: 'Part not found' });
}
res.json(part);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get by SKU
* GET /api/parts/sku/:sku
*/
router.get('/sku/:sku', async (req: TenantRequest, res: Response) => {
try {
const part = await service.findBySku(req.tenantId!, req.params.sku);
if (!part) {
return res.status(404).json({ error: 'Part not found' });
}
res.json(part);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get by barcode
* GET /api/parts/barcode/:barcode
*/
router.get('/barcode/:barcode', async (req: TenantRequest, res: Response) => {
try {
const part = await service.findByBarcode(req.tenantId!, req.params.barcode);
if (!part) {
return res.status(404).json({ error: 'Part not found' });
}
res.json(part);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update part
* PATCH /api/parts/:id
*/
router.patch('/:id', async (req: TenantRequest, res: Response) => {
try {
const part = await service.update(req.tenantId!, req.params.id, req.body);
if (!part) {
return res.status(404).json({ error: 'Part not found' });
}
res.json(part);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Adjust stock
* POST /api/parts/:id/stock-adjustment
*/
router.post('/:id/stock-adjustment', async (req: TenantRequest, res: Response) => {
try {
const part = await service.adjustStock(req.tenantId!, req.params.id, req.body);
if (!part) {
return res.status(404).json({ error: 'Part not found' });
}
res.json(part);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Reserve stock
* POST /api/parts/:id/reserve
*/
router.post('/:id/reserve', async (req: TenantRequest, res: Response) => {
try {
const success = await service.reserveStock(req.tenantId!, req.params.id, req.body.quantity);
if (!success) {
return res.status(404).json({ error: 'Part not found' });
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Release reserved stock
* POST /api/parts/:id/release
*/
router.post('/:id/release', async (req: TenantRequest, res: Response) => {
try {
const success = await service.releaseStock(req.tenantId!, req.params.id, req.body.quantity);
if (!success) {
return res.status(404).json({ error: 'Part not found' });
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Consume stock (order completed)
* POST /api/parts/:id/consume
*/
router.post('/:id/consume', async (req: TenantRequest, res: Response) => {
try {
const success = await service.consumeStock(req.tenantId!, req.params.id, req.body.quantity);
if (!success) {
return res.status(404).json({ error: 'Part not found' });
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Deactivate part
* DELETE /api/parts/:id
*/
router.delete('/:id', async (req: TenantRequest, res: Response) => {
try {
const success = await service.deactivate(req.tenantId!, req.params.id);
if (!success) {
return res.status(404).json({ error: 'Part not found' });
}
res.status(204).send();
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,149 @@
/**
* Supplier Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for supplier management.
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { SupplierService } from '../services/supplier.service';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createSupplierController(dataSource: DataSource): Router {
const router = Router();
const service = new SupplierService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Create a new supplier
* POST /api/suppliers
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const supplier = await service.create(req.tenantId!, req.body);
res.status(201).json(supplier);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List suppliers
* GET /api/suppliers
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const filters = {
search: req.query.search as string,
isActive: req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined,
};
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Search suppliers (for autocomplete)
* GET /api/suppliers/search
*/
router.get('/search', async (req: TenantRequest, res: Response) => {
try {
const query = req.query.q as string || '';
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
const suppliers = await service.search(req.tenantId!, query, limit);
res.json(suppliers);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get a single supplier
* GET /api/suppliers/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const supplier = await service.findById(req.tenantId!, req.params.id);
if (!supplier) {
return res.status(404).json({ error: 'Supplier not found' });
}
res.json(supplier);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get supplier with statistics
* GET /api/suppliers/:id/stats
*/
router.get('/:id/stats', async (req: TenantRequest, res: Response) => {
try {
const result = await service.getSupplierWithStats(req.tenantId!, req.params.id);
if (!result) {
return res.status(404).json({ error: 'Supplier not found' });
}
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update supplier
* PATCH /api/suppliers/:id
*/
router.patch('/:id', async (req: TenantRequest, res: Response) => {
try {
const supplier = await service.update(req.tenantId!, req.params.id, req.body);
if (!supplier) {
return res.status(404).json({ error: 'Supplier not found' });
}
res.json(supplier);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Deactivate supplier
* DELETE /api/suppliers/:id
*/
router.delete('/:id', async (req: TenantRequest, res: Response) => {
try {
const success = await service.deactivate(req.tenantId!, req.params.id);
if (!success) {
return res.status(404).json({ error: 'Supplier not found' });
}
res.status(204).send();
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,9 @@
/**
* Parts Management Entities Index
* Mecánicas Diesel - ERP Suite
*/
export * from './part.entity';
export * from './part-category.entity';
export * from './supplier.entity';
export * from './warehouse-location.entity';

View File

@ -0,0 +1,55 @@
/**
* Part Category Entity
* Mecánicas Diesel - ERP Suite
*
* Represents part categories with hierarchical structure.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
@Entity({ name: 'part_categories', schema: 'parts_management' })
@Index('idx_part_categories_tenant', ['tenantId'])
@Index('idx_part_categories_parent', ['parentId'])
export class PartCategory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 300, nullable: true })
description?: string;
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId?: string;
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => PartCategory, category => category.children, { nullable: true })
@JoinColumn({ name: 'parent_id' })
parent?: PartCategory;
@OneToMany(() => PartCategory, category => category.parent)
children: PartCategory[];
}

View File

@ -0,0 +1,127 @@
/**
* Part Entity
* Mecánicas Diesel - ERP Suite
*
* Represents parts/spare parts inventory.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
Check,
} from 'typeorm';
import { PartCategory } from './part-category.entity';
import { Supplier } from './supplier.entity';
import { WarehouseLocation } from './warehouse-location.entity';
@Entity({ name: 'parts', schema: 'parts_management' })
@Index('idx_parts_tenant', ['tenantId'])
@Index('idx_parts_sku', ['sku'])
@Index('idx_parts_barcode', ['barcode'])
@Index('idx_parts_category', ['categoryId'])
@Index('idx_parts_supplier', ['preferredSupplierId'])
@Check('chk_min_max_stock', '"max_stock" IS NULL OR "max_stock" >= "min_stock"')
export class Part {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 50 })
sku: string;
@Column({ type: 'varchar', length: 300 })
name: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'category_id', type: 'uuid', nullable: true })
categoryId?: string;
@Column({ type: 'varchar', length: 100, nullable: true })
brand?: string;
@Column({ type: 'varchar', length: 100, nullable: true })
manufacturer?: string;
@Column({ name: 'compatible_engines', type: 'text', array: true, nullable: true })
compatibleEngines?: string[];
// Pricing
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
cost?: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
price: number;
// Inventory
@Column({ name: 'current_stock', type: 'decimal', precision: 10, scale: 3, default: 0 })
currentStock: number;
@Column({ name: 'reserved_stock', type: 'decimal', precision: 10, scale: 3, default: 0 })
reservedStock: number;
@Column({ name: 'min_stock', type: 'decimal', precision: 10, scale: 3, default: 0 })
minStock: number;
@Column({ name: 'max_stock', type: 'decimal', precision: 10, scale: 3, nullable: true })
maxStock?: number;
@Column({ name: 'reorder_point', type: 'decimal', precision: 10, scale: 3, nullable: true })
reorderPoint?: number;
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId?: string;
@Column({ type: 'varchar', length: 20, default: 'pza' })
unit: string;
@Column({ type: 'varchar', length: 50, nullable: true })
barcode?: string;
@Column({ name: 'preferred_supplier_id', type: 'uuid', nullable: true })
preferredSupplierId?: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Computed
get availableStock(): number {
return this.currentStock - this.reservedStock;
}
get isLowStock(): boolean {
return this.currentStock <= this.minStock;
}
// Relations
@ManyToOne(() => PartCategory, { nullable: true })
@JoinColumn({ name: 'category_id' })
category?: PartCategory;
@ManyToOne(() => Supplier, { nullable: true })
@JoinColumn({ name: 'preferred_supplier_id' })
preferredSupplier?: Supplier;
@ManyToOne(() => WarehouseLocation, { nullable: true })
@JoinColumn({ name: 'location_id' })
location?: WarehouseLocation;
// @OneToMany(() => PartAlternate, alt => alt.part)
// alternates: PartAlternate[];
}

View File

@ -0,0 +1,70 @@
/**
* Supplier Entity
* Mecánicas Diesel - ERP Suite
*
* Represents parts suppliers.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity({ name: 'suppliers', schema: 'parts_management' })
@Index('idx_suppliers_tenant', ['tenantId'])
@Index('idx_suppliers_name', ['name'])
export class Supplier {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ name: 'legal_name', type: 'varchar', length: 300, nullable: true })
legalName?: string;
@Column({ type: 'varchar', length: 13, nullable: true })
rfc?: string;
// Contact
@Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true })
contactName?: string;
@Column({ type: 'varchar', length: 200, nullable: true })
email?: string;
@Column({ type: 'varchar', length: 20, nullable: true })
phone?: string;
@Column({ type: 'text', nullable: true })
address?: string;
// Terms
@Column({ name: 'credit_days', type: 'integer', default: 0 })
creditDays: number;
@Column({ name: 'discount_pct', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPct: number;
@Column({ type: 'decimal', precision: 3, scale: 2, nullable: true })
rating?: number;
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,59 @@
/**
* Warehouse Location Entity
* Mecánicas Diesel - ERP Suite
*
* Represents storage locations in the warehouse.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity({ name: 'warehouse_locations', schema: 'parts_management' })
@Index('idx_locations_tenant', ['tenantId'])
@Index('idx_locations_zone', ['zone'])
export class WarehouseLocation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'varchar', length: 100, nullable: true })
name?: string;
@Column({ type: 'varchar', length: 200, nullable: true })
description?: string;
@Column({ type: 'varchar', length: 10, nullable: true })
zone?: string;
@Column({ type: 'varchar', length: 10, nullable: true })
aisle?: string;
@Column({ type: 'varchar', length: 10, nullable: true })
level?: string;
@Column({ name: 'max_weight', type: 'decimal', precision: 10, scale: 2, nullable: true })
maxWeight?: number;
@Column({ name: 'max_volume', type: 'decimal', precision: 10, scale: 2, nullable: true })
maxVolume?: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,18 @@
/**
* Parts Management Module
* Mecánicas Diesel - ERP Suite
*/
// Entities
export { Part } from './entities/part.entity';
export { PartCategory } from './entities/part-category.entity';
export { Supplier } from './entities/supplier.entity';
export { WarehouseLocation } from './entities/warehouse-location.entity';
// Services
export { PartService, CreatePartDto, UpdatePartDto, PartFilters, StockAdjustmentDto } from './services/part.service';
export { SupplierService, CreateSupplierDto, UpdateSupplierDto } from './services/supplier.service';
// Controllers
export { createPartController } from './controllers/part.controller';
export { createSupplierController } from './controllers/supplier.controller';

View File

@ -0,0 +1,341 @@
/**
* Part Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for parts/inventory management.
*/
import { Repository, DataSource } from 'typeorm';
import { Part } from '../entities/part.entity';
// DTOs
export interface CreatePartDto {
sku: string;
name: string;
description?: string;
categoryId?: string;
preferredSupplierId?: string;
brand?: string;
manufacturer?: string;
compatibleEngines?: string[];
unit?: string;
cost?: number;
price: number;
minStock?: number;
maxStock?: number;
reorderPoint?: number;
locationId?: string;
barcode?: string;
notes?: string;
}
export interface UpdatePartDto {
name?: string;
description?: string;
categoryId?: string;
preferredSupplierId?: string;
brand?: string;
manufacturer?: string;
compatibleEngines?: string[];
unit?: string;
cost?: number;
price?: number;
minStock?: number;
maxStock?: number;
reorderPoint?: number;
locationId?: string;
barcode?: string;
isActive?: boolean;
}
export interface PartFilters {
categoryId?: string;
preferredSupplierId?: string;
brand?: string;
search?: string;
lowStock?: boolean;
isActive?: boolean;
}
export interface StockAdjustmentDto {
quantity: number;
reason: string;
reference?: string;
}
export class PartService {
private partRepository: Repository<Part>;
constructor(dataSource: DataSource) {
this.partRepository = dataSource.getRepository(Part);
}
/**
* Create a new part
*/
async create(tenantId: string, dto: CreatePartDto): Promise<Part> {
// Check SKU uniqueness
const existing = await this.partRepository.findOne({
where: { tenantId, sku: dto.sku },
});
if (existing) {
throw new Error(`Part with SKU ${dto.sku} already exists`);
}
const part = this.partRepository.create({
tenantId,
sku: dto.sku,
name: dto.name,
description: dto.description,
categoryId: dto.categoryId,
preferredSupplierId: dto.preferredSupplierId,
brand: dto.brand,
manufacturer: dto.manufacturer,
compatibleEngines: dto.compatibleEngines,
unit: dto.unit || 'pza',
cost: dto.cost,
price: dto.price,
minStock: dto.minStock || 0,
maxStock: dto.maxStock,
reorderPoint: dto.reorderPoint,
locationId: dto.locationId,
barcode: dto.barcode,
currentStock: 0,
reservedStock: 0,
isActive: true,
});
return this.partRepository.save(part);
}
/**
* Find part by ID
*/
async findById(tenantId: string, id: string): Promise<Part | null> {
return this.partRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find part by SKU
*/
async findBySku(tenantId: string, sku: string): Promise<Part | null> {
return this.partRepository.findOne({
where: { tenantId, sku },
});
}
/**
* Find part by barcode
*/
async findByBarcode(tenantId: string, barcode: string): Promise<Part | null> {
return this.partRepository.findOne({
where: { tenantId, barcode },
});
}
/**
* List parts with filters
*/
async findAll(
tenantId: string,
filters: PartFilters = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.partRepository.createQueryBuilder('part')
.where('part.tenant_id = :tenantId', { tenantId });
if (filters.categoryId) {
queryBuilder.andWhere('part.category_id = :categoryId', { categoryId: filters.categoryId });
}
if (filters.preferredSupplierId) {
queryBuilder.andWhere('part.preferred_supplier_id = :supplierId', { supplierId: filters.preferredSupplierId });
}
if (filters.brand) {
queryBuilder.andWhere('part.brand = :brand', { brand: filters.brand });
}
if (filters.isActive !== undefined) {
queryBuilder.andWhere('part.is_active = :isActive', { isActive: filters.isActive });
}
if (filters.lowStock) {
queryBuilder.andWhere('part.current_stock <= part.min_stock');
}
if (filters.search) {
queryBuilder.andWhere(
'(part.sku ILIKE :search OR part.name ILIKE :search OR part.barcode ILIKE :search OR part.description ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('part.name', 'ASC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Update part
*/
async update(tenantId: string, id: string, dto: UpdatePartDto): Promise<Part | null> {
const part = await this.findById(tenantId, id);
if (!part) return null;
Object.assign(part, dto);
return this.partRepository.save(part);
}
/**
* Adjust stock (increase or decrease)
*/
async adjustStock(
tenantId: string,
id: string,
dto: StockAdjustmentDto
): Promise<Part | null> {
const part = await this.findById(tenantId, id);
if (!part) return null;
const newStock = part.currentStock + dto.quantity;
if (newStock < 0) {
throw new Error('Stock cannot be negative');
}
part.currentStock = newStock;
// TODO: Create stock movement record for audit trail
return this.partRepository.save(part);
}
/**
* Reserve stock for an order
*/
async reserveStock(tenantId: string, id: string, quantity: number): Promise<boolean> {
const part = await this.findById(tenantId, id);
if (!part) return false;
const availableStock = part.currentStock - part.reservedStock;
if (quantity > availableStock) {
throw new Error(`Insufficient stock. Available: ${availableStock}, Requested: ${quantity}`);
}
part.reservedStock += quantity;
await this.partRepository.save(part);
return true;
}
/**
* Release reserved stock
*/
async releaseStock(tenantId: string, id: string, quantity: number): Promise<boolean> {
const part = await this.findById(tenantId, id);
if (!part) return false;
part.reservedStock = Math.max(0, part.reservedStock - quantity);
await this.partRepository.save(part);
return true;
}
/**
* Consume reserved stock (when order is completed)
*/
async consumeStock(tenantId: string, id: string, quantity: number): Promise<boolean> {
const part = await this.findById(tenantId, id);
if (!part) return false;
part.reservedStock = Math.max(0, part.reservedStock - quantity);
part.currentStock = Math.max(0, part.currentStock - quantity);
await this.partRepository.save(part);
return true;
}
/**
* Get parts with low stock
*/
async getLowStockParts(tenantId: string): Promise<Part[]> {
return this.partRepository
.createQueryBuilder('part')
.where('part.tenant_id = :tenantId', { tenantId })
.andWhere('part.is_active = true')
.andWhere('part.current_stock <= part.min_stock')
.orderBy('part.current_stock', 'ASC')
.getMany();
}
/**
* Get inventory value
*/
async getInventoryValue(tenantId: string): Promise<{
totalCostValue: number;
totalSaleValue: number;
totalItems: number;
lowStockCount: number;
}> {
const result = await this.partRepository
.createQueryBuilder('part')
.select('SUM(part.current_stock * COALESCE(part.cost, 0))', 'costValue')
.addSelect('SUM(part.current_stock * part.price)', 'saleValue')
.addSelect('SUM(part.current_stock)', 'totalItems')
.where('part.tenant_id = :tenantId', { tenantId })
.andWhere('part.is_active = true')
.getRawOne();
const lowStockCount = await this.partRepository
.createQueryBuilder('part')
.where('part.tenant_id = :tenantId', { tenantId })
.andWhere('part.is_active = true')
.andWhere('part.current_stock <= part.min_stock')
.getCount();
return {
totalCostValue: parseFloat(result?.costValue) || 0,
totalSaleValue: parseFloat(result?.saleValue) || 0,
totalItems: parseInt(result?.totalItems, 10) || 0,
lowStockCount,
};
}
/**
* Search parts for autocomplete
*/
async search(tenantId: string, query: string, limit = 10): Promise<Part[]> {
return this.partRepository
.createQueryBuilder('part')
.where('part.tenant_id = :tenantId', { tenantId })
.andWhere('part.is_active = true')
.andWhere(
'(part.sku ILIKE :query OR part.name ILIKE :query OR part.barcode ILIKE :query)',
{ query: `%${query}%` }
)
.orderBy('part.name', 'ASC')
.take(limit)
.getMany();
}
/**
* Deactivate part
*/
async deactivate(tenantId: string, id: string): Promise<boolean> {
const part = await this.findById(tenantId, id);
if (!part) return false;
part.isActive = false;
await this.partRepository.save(part);
return true;
}
}

View File

@ -0,0 +1,189 @@
/**
* Supplier Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for supplier management.
*/
import { Repository, DataSource } from 'typeorm';
import { Supplier } from '../entities/supplier.entity';
import { Part } from '../entities/part.entity';
// DTOs
export interface CreateSupplierDto {
name: string;
legalName?: string;
rfc?: string;
contactName?: string;
phone?: string;
email?: string;
address?: string;
creditDays?: number;
discountPct?: number;
notes?: string;
}
export interface UpdateSupplierDto {
name?: string;
legalName?: string;
rfc?: string;
contactName?: string;
phone?: string;
email?: string;
address?: string;
creditDays?: number;
discountPct?: number;
notes?: string;
isActive?: boolean;
}
export class SupplierService {
private supplierRepository: Repository<Supplier>;
private partRepository: Repository<Part>;
constructor(dataSource: DataSource) {
this.supplierRepository = dataSource.getRepository(Supplier);
this.partRepository = dataSource.getRepository(Part);
}
/**
* Create a new supplier
*/
async create(tenantId: string, dto: CreateSupplierDto): Promise<Supplier> {
const supplier = this.supplierRepository.create({
tenantId,
name: dto.name,
legalName: dto.legalName,
rfc: dto.rfc,
contactName: dto.contactName,
phone: dto.phone,
email: dto.email,
address: dto.address,
creditDays: dto.creditDays || 0,
discountPct: dto.discountPct || 0,
notes: dto.notes,
isActive: true,
});
return this.supplierRepository.save(supplier);
}
/**
* Find supplier by ID
*/
async findById(tenantId: string, id: string): Promise<Supplier | null> {
return this.supplierRepository.findOne({
where: { id, tenantId },
});
}
/**
* List suppliers
*/
async findAll(
tenantId: string,
filters: { search?: string; isActive?: boolean } = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.supplierRepository.createQueryBuilder('supplier')
.where('supplier.tenant_id = :tenantId', { tenantId });
if (filters.isActive !== undefined) {
queryBuilder.andWhere('supplier.is_active = :isActive', { isActive: filters.isActive });
}
if (filters.search) {
queryBuilder.andWhere(
'(supplier.name ILIKE :search OR supplier.contact_name ILIKE :search OR supplier.rfc ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('supplier.name', 'ASC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Update supplier
*/
async update(tenantId: string, id: string, dto: UpdateSupplierDto): Promise<Supplier | null> {
const supplier = await this.findById(tenantId, id);
if (!supplier) return null;
Object.assign(supplier, dto);
return this.supplierRepository.save(supplier);
}
/**
* Deactivate supplier
*/
async deactivate(tenantId: string, id: string): Promise<boolean> {
const supplier = await this.findById(tenantId, id);
if (!supplier) return false;
supplier.isActive = false;
await this.supplierRepository.save(supplier);
return true;
}
/**
* Get supplier with part count
*/
async getSupplierWithStats(tenantId: string, id: string): Promise<{
supplier: Supplier;
partCount: number;
totalInventoryValue: number;
} | null> {
const supplier = await this.findById(tenantId, id);
if (!supplier) return null;
const [partCount, inventoryResult] = await Promise.all([
this.partRepository
.createQueryBuilder('part')
.where('part.tenant_id = :tenantId', { tenantId })
.andWhere('part.supplier_id = :supplierId', { supplierId: id })
.getCount(),
this.partRepository
.createQueryBuilder('part')
.select('SUM(part.current_stock * part.cost_price)', 'value')
.where('part.tenant_id = :tenantId', { tenantId })
.andWhere('part.supplier_id = :supplierId', { supplierId: id })
.getRawOne(),
]);
return {
supplier,
partCount,
totalInventoryValue: parseFloat(inventoryResult?.value) || 0,
};
}
/**
* Search suppliers for autocomplete
*/
async search(tenantId: string, query: string, limit = 10): Promise<Supplier[]> {
return this.supplierRepository
.createQueryBuilder('supplier')
.where('supplier.tenant_id = :tenantId', { tenantId })
.andWhere('supplier.is_active = true')
.andWhere(
'(supplier.name ILIKE :query OR supplier.rfc ILIKE :query)',
{ query: `%${query}%` }
)
.orderBy('supplier.name', 'ASC')
.take(limit)
.getMany();
}
}

View File

@ -0,0 +1,151 @@
/**
* Diagnostic Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for vehicle diagnostics.
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { DiagnosticService } from '../services/diagnostic.service';
import { DiagnosticType, DiagnosticResult } from '../entities/diagnostic.entity';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createDiagnosticController(dataSource: DataSource): Router {
const router = Router();
const service = new DiagnosticService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Create a new diagnostic
* POST /api/diagnostics
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const diagnostic = await service.create(req.tenantId!, req.body);
res.status(201).json(diagnostic);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Get a single diagnostic
* GET /api/diagnostics/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const diagnostic = await service.findById(req.tenantId!, req.params.id);
if (!diagnostic) {
return res.status(404).json({ error: 'Diagnostic not found' });
}
res.json(diagnostic);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get diagnostics by vehicle
* GET /api/diagnostics/vehicle/:vehicleId
*/
router.get('/vehicle/:vehicleId', async (req: TenantRequest, res: Response) => {
try {
const diagnostics = await service.findByVehicle(req.tenantId!, req.params.vehicleId);
res.json(diagnostics);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get diagnostics by order
* GET /api/diagnostics/order/:orderId
*/
router.get('/order/:orderId', async (req: TenantRequest, res: Response) => {
try {
const diagnostics = await service.findByOrder(req.tenantId!, req.params.orderId);
res.json(diagnostics);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get vehicle diagnostic statistics
* GET /api/diagnostics/vehicle/:vehicleId/stats
*/
router.get('/vehicle/:vehicleId/stats', async (req: TenantRequest, res: Response) => {
try {
const stats = await service.getVehicleStats(req.tenantId!, req.params.vehicleId);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update diagnostic result
* PATCH /api/diagnostics/:id/result
*/
router.patch('/:id/result', async (req: TenantRequest, res: Response) => {
try {
const { result, summary } = req.body;
const diagnostic = await service.updateResult(
req.tenantId!,
req.params.id,
result as DiagnosticResult,
summary
);
if (!diagnostic) {
return res.status(404).json({ error: 'Diagnostic not found' });
}
res.json(diagnostic);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Parse DTC codes from raw data
* POST /api/diagnostics/parse-dtc
*/
router.post('/parse-dtc', async (req: TenantRequest, res: Response) => {
try {
const items = service.parseDTCCodes(req.body.rawData || {});
res.json(items);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Analyze injector test results
* POST /api/diagnostics/analyze-injectors
*/
router.post('/analyze-injectors', async (req: TenantRequest, res: Response) => {
try {
const items = service.analyzeInjectorTest(req.body.rawData || {});
res.json(items);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,234 @@
/**
* Quote Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for quotations.
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { QuoteService } from '../services/quote.service';
import { QuoteStatus } from '../entities/quote.entity';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createQuoteController(dataSource: DataSource): Router {
const router = Router();
const service = new QuoteService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Create a new quote
* POST /api/quotes
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const quote = await service.create(req.tenantId!, req.body, req.userId);
res.status(201).json(quote);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List quotes with filters
* GET /api/quotes
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const filters = {
status: req.query.status as QuoteStatus,
customerId: req.query.customerId as string,
vehicleId: req.query.vehicleId as string,
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined,
};
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get quote statistics
* GET /api/quotes/stats
*/
router.get('/stats', async (req: TenantRequest, res: Response) => {
try {
const stats = await service.getStats(req.tenantId!);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update expired quotes
* POST /api/quotes/update-expired
*/
router.post('/update-expired', async (req: TenantRequest, res: Response) => {
try {
const count = await service.updateExpiredQuotes(req.tenantId!);
res.json({ updated: count });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get a single quote
* GET /api/quotes/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const quote = await service.findById(req.tenantId!, req.params.id);
if (!quote) {
return res.status(404).json({ error: 'Quote not found' });
}
res.json(quote);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get by quote number
* GET /api/quotes/number/:quoteNumber
*/
router.get('/number/:quoteNumber', async (req: TenantRequest, res: Response) => {
try {
const quote = await service.findByNumber(req.tenantId!, req.params.quoteNumber);
if (!quote) {
return res.status(404).json({ error: 'Quote not found' });
}
res.json(quote);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Send quote to customer
* POST /api/quotes/:id/send
*/
router.post('/:id/send', async (req: TenantRequest, res: Response) => {
try {
const channel = req.body.channel || 'email';
const quote = await service.send(req.tenantId!, req.params.id, channel);
if (!quote) {
return res.status(404).json({ error: 'Quote not found' });
}
res.json(quote);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Mark quote as viewed
* POST /api/quotes/:id/view
*/
router.post('/:id/view', async (req: TenantRequest, res: Response) => {
try {
const quote = await service.markViewed(req.tenantId!, req.params.id);
if (!quote) {
return res.status(404).json({ error: 'Quote not found' });
}
res.json(quote);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Approve quote (customer action)
* POST /api/quotes/:id/approve
*/
router.post('/:id/approve', async (req: TenantRequest, res: Response) => {
try {
const approvalData = {
approvedByName: req.body.approvedByName,
approvalSignature: req.body.approvalSignature,
approvalIp: req.ip,
};
const quote = await service.approve(req.tenantId!, req.params.id, approvalData);
if (!quote) {
return res.status(404).json({ error: 'Quote not found' });
}
res.json(quote);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Reject quote
* POST /api/quotes/:id/reject
*/
router.post('/:id/reject', async (req: TenantRequest, res: Response) => {
try {
const quote = await service.reject(req.tenantId!, req.params.id, req.body.reason);
if (!quote) {
return res.status(404).json({ error: 'Quote not found' });
}
res.json(quote);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Convert quote to service order
* POST /api/quotes/:id/convert
*/
router.post('/:id/convert', async (req: TenantRequest, res: Response) => {
try {
const order = await service.convertToOrder(req.tenantId!, req.params.id, req.userId);
if (!order) {
return res.status(404).json({ error: 'Quote not found' });
}
res.status(201).json(order);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Apply discount to quote
* POST /api/quotes/:id/discount
*/
router.post('/:id/discount', async (req: TenantRequest, res: Response) => {
try {
const quote = await service.applyDiscount(req.tenantId!, req.params.id, req.body);
if (!quote) {
return res.status(404).json({ error: 'Quote not found' });
}
res.json(quote);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,216 @@
/**
* Service Order Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for service orders.
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { ServiceOrderService, ServiceOrderFilters } from '../services/service-order.service';
import { ServiceOrderStatus, ServiceOrderPriority } from '../entities/service-order.entity';
import { OrderItemType } from '../entities/order-item.entity';
// Middleware type for tenant extraction
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createServiceOrderController(dataSource: DataSource): Router {
const router = Router();
const service = new ServiceOrderService(dataSource);
// Middleware to extract tenant from request
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Create a new service order
* POST /api/service-orders
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const order = await service.create(req.tenantId!, req.body, req.userId);
res.status(201).json(order);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List service orders with filters
* GET /api/service-orders
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const filters: ServiceOrderFilters = {
status: req.query.status as ServiceOrderStatus,
priority: req.query.priority as ServiceOrderPriority,
customerId: req.query.customerId as string,
vehicleId: req.query.vehicleId as string,
assignedTo: req.query.assignedTo as string,
bayId: req.query.bayId as string,
search: req.query.search as string,
fromDate: req.query.fromDate ? new Date(req.query.fromDate as string) : undefined,
toDate: req.query.toDate ? new Date(req.query.toDate as string) : undefined,
};
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get orders grouped by status (Kanban)
* GET /api/service-orders/kanban
*/
router.get('/kanban', async (req: TenantRequest, res: Response) => {
try {
const result = await service.getOrdersByStatus(req.tenantId!);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get dashboard statistics
* GET /api/service-orders/stats
*/
router.get('/stats', async (req: TenantRequest, res: Response) => {
try {
const stats = await service.getDashboardStats(req.tenantId!);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get a single service order
* GET /api/service-orders/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const order = await service.findById(req.tenantId!, req.params.id);
if (!order) {
return res.status(404).json({ error: 'Service order not found' });
}
res.json(order);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get by order number
* GET /api/service-orders/number/:orderNumber
*/
router.get('/number/:orderNumber', async (req: TenantRequest, res: Response) => {
try {
const order = await service.findByOrderNumber(req.tenantId!, req.params.orderNumber);
if (!order) {
return res.status(404).json({ error: 'Service order not found' });
}
res.json(order);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update service order
* PATCH /api/service-orders/:id
*/
router.patch('/:id', async (req: TenantRequest, res: Response) => {
try {
const order = await service.update(req.tenantId!, req.params.id, req.body);
if (!order) {
return res.status(404).json({ error: 'Service order not found' });
}
res.json(order);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Add item to order
* POST /api/service-orders/:id/items
*/
router.post('/:id/items', async (req: TenantRequest, res: Response) => {
try {
const item = await service.addItem(req.tenantId!, req.params.id, req.body);
if (!item) {
return res.status(404).json({ error: 'Service order not found' });
}
res.status(201).json(item);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Get order items
* GET /api/service-orders/:id/items
*/
router.get('/:id/items', async (req: TenantRequest, res: Response) => {
try {
const items = await service.getItems(req.params.id);
res.json(items);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update order item
* PATCH /api/service-orders/:id/items/:itemId
*/
router.patch('/:id/items/:itemId', async (req: TenantRequest, res: Response) => {
try {
const item = await service.updateItem(req.params.itemId, req.body);
if (!item) {
return res.status(404).json({ error: 'Item not found' });
}
res.json(item);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Remove order item
* DELETE /api/service-orders/:id/items/:itemId
*/
router.delete('/:id/items/:itemId', async (req: TenantRequest, res: Response) => {
try {
const success = await service.removeItem(req.params.itemId);
if (!success) {
return res.status(404).json({ error: 'Item not found' });
}
res.status(204).send();
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,93 @@
/**
* Diagnostic Entity
* Mecánicas Diesel - ERP Suite
*
* Represents diagnostic tests performed on vehicles.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { ServiceOrder } from './service-order.entity';
export enum DiagnosticType {
SCANNER = 'scanner',
INJECTOR_TEST = 'injector_test',
PUMP_TEST = 'pump_test',
COMPRESSION = 'compression',
TURBO_TEST = 'turbo_test',
OTHER = 'other',
}
export enum DiagnosticResult {
PASS = 'pass',
FAIL = 'fail',
NEEDS_ATTENTION = 'needs_attention',
}
@Entity({ name: 'diagnostics', schema: 'service_management' })
@Index('idx_diagnostics_tenant', ['tenantId'])
@Index('idx_diagnostics_vehicle', ['vehicleId'])
@Index('idx_diagnostics_order', ['orderId'])
export class Diagnostic {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'order_id', type: 'uuid', nullable: true })
orderId?: string;
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId: string;
@Column({ name: 'diagnostic_type', type: 'varchar', length: 50 })
diagnosticType: DiagnosticType;
@Column({ type: 'varchar', length: 200, nullable: true })
equipment?: string;
@Column({ name: 'performed_at', type: 'timestamptz', default: () => 'NOW()' })
performedAt: Date;
@Column({ name: 'performed_by', type: 'uuid', nullable: true })
performedBy?: string;
@Column({ type: 'varchar', length: 20, nullable: true })
result?: DiagnosticResult;
@Column({ type: 'text', nullable: true })
summary?: string;
@Column({ name: 'raw_data', type: 'jsonb', nullable: true })
rawData?: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => ServiceOrder, { nullable: true })
@JoinColumn({ name: 'order_id' })
order?: ServiceOrder;
// @OneToMany(() => DiagnosticItem, item => item.diagnostic)
// items: DiagnosticItem[];
// @OneToMany(() => DiagnosticPhoto, photo => photo.diagnostic)
// photos: DiagnosticPhoto[];
// @OneToMany(() => DiagnosticRecommendation, rec => rec.diagnostic)
// recommendations: DiagnosticRecommendation[];
}

View File

@ -0,0 +1,11 @@
/**
* Service Management Entities Index
* Mecánicas Diesel - ERP Suite
*/
export * from './service-order.entity';
export * from './order-item.entity';
export * from './diagnostic.entity';
export * from './quote.entity';
export * from './work-bay.entity';
export * from './service.entity';

View File

@ -0,0 +1,102 @@
/**
* Order Item Entity
* Mecánicas Diesel - ERP Suite
*
* Represents line items (services or parts) in a service order.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { ServiceOrder } from './service-order.entity';
export enum OrderItemType {
SERVICE = 'service',
PART = 'part',
}
export enum OrderItemStatus {
PENDING = 'pending',
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
}
@Entity({ name: 'order_items', schema: 'service_management' })
@Index('idx_order_items_order', ['orderId'])
export class OrderItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'order_id', type: 'uuid' })
orderId: string;
// Type
@Column({ name: 'item_type', type: 'varchar', length: 20 })
itemType: OrderItemType;
// Optional references
@Column({ name: 'service_id', type: 'uuid', nullable: true })
serviceId?: string;
@Column({ name: 'part_id', type: 'uuid', nullable: true })
partId?: string;
// Description
@Column({ type: 'varchar', length: 500 })
description: string;
// Quantities and prices
@Column({ type: 'decimal', precision: 10, scale: 3, default: 1 })
quantity: number;
@Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 })
unitPrice: number;
@Column({ name: 'discount_pct', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPct: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
subtotal: number;
// Status
@Column({
type: 'varchar',
length: 20,
default: OrderItemStatus.PENDING,
})
status: OrderItemStatus;
// For labor items
@Column({ name: 'estimated_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
estimatedHours?: number;
@Column({ name: 'actual_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
actualHours?: number;
// Mechanic
@Column({ name: 'performed_by', type: 'uuid', nullable: true })
performedBy?: string;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt?: Date;
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ name: 'sort_order', type: 'integer', default: 0 })
sortOrder: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relations
@ManyToOne(() => ServiceOrder, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'order_id' })
order: ServiceOrder;
}

View File

@ -0,0 +1,140 @@
/**
* Quote Entity
* Mecánicas Diesel - ERP Suite
*
* Represents service quotations for customers.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { Diagnostic } from './diagnostic.entity';
import { ServiceOrder } from './service-order.entity';
export enum QuoteStatus {
DRAFT = 'draft',
SENT = 'sent',
VIEWED = 'viewed',
APPROVED = 'approved',
REJECTED = 'rejected',
EXPIRED = 'expired',
CONVERTED = 'converted',
}
@Entity({ name: 'quotes', schema: 'service_management' })
@Index('idx_quotes_tenant', ['tenantId'])
@Index('idx_quotes_status', ['tenantId', 'status'])
@Index('idx_quotes_customer', ['customerId'])
export class Quote {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'quote_number', type: 'varchar', length: 20 })
quoteNumber: string;
@Column({ name: 'customer_id', type: 'uuid' })
customerId: string;
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId: string;
@Column({ name: 'diagnostic_id', type: 'uuid', nullable: true })
diagnosticId?: string;
@Column({
type: 'varchar',
length: 20,
default: QuoteStatus.DRAFT,
})
status: QuoteStatus;
// Dates
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'sent_at', type: 'timestamptz', nullable: true })
sentAt?: Date;
@Column({ name: 'viewed_at', type: 'timestamptz', nullable: true })
viewedAt?: Date;
@Column({ name: 'responded_at', type: 'timestamptz', nullable: true })
respondedAt?: Date;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date;
// Totals
@Column({ name: 'labor_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
laborTotal: number;
@Column({ name: 'parts_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
partsTotal: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
discountAmount: number;
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPercent: number;
@Column({ name: 'discount_reason', type: 'varchar', length: 200, nullable: true })
discountReason?: string;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
tax: number;
@Column({ name: 'grand_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
grandTotal: number;
@Column({ name: 'validity_days', type: 'integer', default: 15 })
validityDays: number;
@Column({ type: 'text', nullable: true })
terms?: string;
@Column({ type: 'text', nullable: true })
notes?: string;
// Conversion to order
@Column({ name: 'converted_order_id', type: 'uuid', nullable: true })
convertedOrderId?: string;
// Digital approval
@Column({ name: 'approved_by_name', type: 'varchar', length: 200, nullable: true })
approvedByName?: string;
@Column({ name: 'approval_signature', type: 'text', nullable: true })
approvalSignature?: string;
@Column({ name: 'approval_ip', type: 'inet', nullable: true })
approvalIp?: string;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => Diagnostic, { nullable: true })
@JoinColumn({ name: 'diagnostic_id' })
diagnostic?: Diagnostic;
@ManyToOne(() => ServiceOrder, { nullable: true })
@JoinColumn({ name: 'converted_order_id' })
convertedOrder?: ServiceOrder;
// @OneToMany(() => QuoteItem, item => item.quote)
// items: QuoteItem[];
}

View File

@ -0,0 +1,161 @@
/**
* Service Order Entity
* Mecánicas Diesel - ERP Suite
*
* Represents a vehicle service order in the workshop.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
JoinColumn,
Index,
Check,
} from 'typeorm';
// Status values for service orders
export enum ServiceOrderStatus {
RECEIVED = 'received',
DIAGNOSED = 'diagnosed',
QUOTED = 'quoted',
APPROVED = 'approved',
IN_PROGRESS = 'in_progress',
WAITING_PARTS = 'waiting_parts',
COMPLETED = 'completed',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
}
export enum ServiceOrderPriority {
LOW = 'low',
NORMAL = 'normal',
HIGH = 'high',
URGENT = 'urgent',
}
@Entity({ name: 'service_orders', schema: 'service_management' })
@Index('idx_orders_tenant', ['tenantId'])
@Index('idx_orders_status', ['tenantId', 'status'])
@Index('idx_orders_vehicle', ['vehicleId'])
@Index('idx_orders_customer', ['customerId'])
@Index('idx_orders_assigned', ['assignedTo'])
@Check('chk_odometer', '"odometer_out" IS NULL OR "odometer_out" >= "odometer_in"')
export class ServiceOrder {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
// Identification
@Column({ name: 'order_number', type: 'varchar', length: 20 })
orderNumber: string;
// Relations
@Column({ name: 'customer_id', type: 'uuid' })
customerId: string;
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId: string;
@Column({ name: 'quote_id', type: 'uuid', nullable: true })
quoteId?: string;
// Assignment
@Column({ name: 'assigned_to', type: 'uuid', nullable: true })
assignedTo?: string;
@Column({ name: 'bay_id', type: 'uuid', nullable: true })
bayId?: string;
// Status
@Column({
type: 'varchar',
length: 30,
default: ServiceOrderStatus.RECEIVED,
})
status: ServiceOrderStatus;
@Column({
type: 'varchar',
length: 20,
default: ServiceOrderPriority.NORMAL,
})
priority: ServiceOrderPriority;
// Dates
@Column({ name: 'received_at', type: 'timestamptz', default: () => 'NOW()' })
receivedAt: Date;
@Column({ name: 'promised_at', type: 'timestamptz', nullable: true })
promisedAt?: Date;
@Column({ name: 'started_at', type: 'timestamptz', nullable: true })
startedAt?: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt?: Date;
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date;
// Odometer
@Column({ name: 'odometer_in', type: 'integer', nullable: true })
odometerIn?: number;
@Column({ name: 'odometer_out', type: 'integer', nullable: true })
odometerOut?: number;
// Symptoms
@Column({ name: 'customer_symptoms', type: 'text', nullable: true })
customerSymptoms?: string;
// Totals
@Column({ name: 'labor_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
laborTotal: number;
@Column({ name: 'parts_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
partsTotal: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
discountAmount: number;
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPercent: number;
@Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
tax: number;
@Column({ name: 'grand_total', type: 'decimal', precision: 12, scale: 2, default: 0 })
grandTotal: number;
// Notes
@Column({ name: 'internal_notes', type: 'text', nullable: true })
internalNotes?: string;
@Column({ name: 'customer_notes', type: 'text', nullable: true })
customerNotes?: string;
// Audit
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations (to be added when other entities are defined)
// @ManyToOne(() => Vehicle, vehicle => vehicle.serviceOrders)
// @JoinColumn({ name: 'vehicle_id' })
// vehicle: Vehicle;
// @OneToMany(() => OrderItem, item => item.order)
// items: OrderItem[];
}

View File

@ -0,0 +1,63 @@
/**
* Service Entity
* Mecánicas Diesel - ERP Suite
*
* Represents service catalog items.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
@Entity({ name: 'services', schema: 'service_management' })
@Index('idx_services_tenant', ['tenantId'])
@Index('idx_services_category', ['categoryId'])
@Index('idx_services_code', ['code'])
export class Service {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ name: 'category_id', type: 'uuid', nullable: true })
categoryId?: string;
@Column({ type: 'decimal', precision: 12, scale: 2 })
price: number;
@Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
cost?: number;
@Column({ name: 'estimated_hours', type: 'decimal', precision: 5, scale: 2, nullable: true })
estimatedHours?: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// @ManyToOne(() => ServiceCategory)
// @JoinColumn({ name: 'category_id' })
// category?: ServiceCategory;
}

View File

@ -0,0 +1,77 @@
/**
* Work Bay Entity
* Mecánicas Diesel - ERP Suite
*
* Represents work bays in the workshop.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum BayType {
GENERAL = 'general',
DIAGNOSTIC = 'diagnostic',
HEAVY_DUTY = 'heavy_duty',
QUICK_SERVICE = 'quick_service',
}
export enum BayStatus {
AVAILABLE = 'available',
OCCUPIED = 'occupied',
MAINTENANCE = 'maintenance',
}
@Entity({ name: 'work_bays', schema: 'service_management' })
@Index('idx_bays_tenant', ['tenantId'])
@Index('idx_bays_status', ['tenantId', 'status'])
export class WorkBay {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 50 })
name: string;
@Column({ type: 'varchar', length: 200, nullable: true })
description?: string;
@Column({ name: 'bay_type', type: 'varchar', length: 50, nullable: true })
bayType?: BayType;
@Column({
type: 'varchar',
length: 20,
default: BayStatus.AVAILABLE,
})
status: BayStatus;
@Column({ name: 'current_order_id', type: 'uuid', nullable: true })
currentOrderId?: string;
// Capacity
@Column({ name: 'max_weight', type: 'decimal', precision: 10, scale: 2, nullable: true })
maxWeight?: number;
@Column({ name: 'has_lift', type: 'boolean', default: false })
hasLift: boolean;
@Column({ name: 'has_pit', type: 'boolean', default: false })
hasPit: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,22 @@
/**
* Service Management Module
* Mecánicas Diesel - ERP Suite
*/
// Entities
export { ServiceOrder, ServiceOrderStatus, ServiceOrderPriority } from './entities/service-order.entity';
export { OrderItem, OrderItemType, OrderItemStatus } from './entities/order-item.entity';
export { Diagnostic, DiagnosticType, DiagnosticResult } from './entities/diagnostic.entity';
export { Quote, QuoteStatus } from './entities/quote.entity';
export { WorkBay, BayStatus, BayType } from './entities/work-bay.entity';
export { Service } from './entities/service.entity';
// Services
export { ServiceOrderService, CreateServiceOrderDto, UpdateServiceOrderDto, ServiceOrderFilters } from './services/service-order.service';
export { DiagnosticService, CreateDiagnosticDto, DiagnosticItemDto, DiagnosticRecommendationDto } from './services/diagnostic.service';
export { QuoteService, CreateQuoteDto, QuoteItemDto, ApplyDiscountDto } from './services/quote.service';
// Controllers
export { createServiceOrderController } from './controllers/service-order.controller';
export { createDiagnosticController } from './controllers/diagnostic.controller';
export { createQuoteController } from './controllers/quote.controller';

View File

@ -0,0 +1,290 @@
/**
* Diagnostic Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for vehicle diagnostics.
*/
import { Repository, DataSource } from 'typeorm';
import {
Diagnostic,
DiagnosticType,
DiagnosticResult,
} from '../entities/diagnostic.entity';
// DTOs
export interface CreateDiagnosticDto {
vehicleId: string;
orderId?: string;
diagnosticType: DiagnosticType;
equipment?: string;
performedBy?: string;
summary?: string;
rawData?: Record<string, unknown>;
}
export interface DiagnosticItemDto {
itemType: 'dtc_code' | 'test_result' | 'measurement' | 'observation';
code?: string;
description?: string;
severity?: 'critical' | 'warning' | 'info';
parameter?: string;
value?: number;
unit?: string;
minRef?: number;
maxRef?: number;
status?: 'ok' | 'warning' | 'fail' | 'no_reference';
component?: string;
cylinder?: number;
notes?: string;
}
export interface DiagnosticRecommendationDto {
description: string;
priority: 'critical' | 'high' | 'medium' | 'low';
urgency: 'immediate' | 'soon' | 'scheduled' | 'preventive';
suggestedServiceId?: string;
estimatedCost?: number;
notes?: string;
}
export class DiagnosticService {
private diagnosticRepository: Repository<Diagnostic>;
constructor(private dataSource: DataSource) {
this.diagnosticRepository = dataSource.getRepository(Diagnostic);
}
/**
* Create a new diagnostic
*/
async create(tenantId: string, dto: CreateDiagnosticDto): Promise<Diagnostic> {
const diagnostic = this.diagnosticRepository.create({
tenantId,
vehicleId: dto.vehicleId,
orderId: dto.orderId,
diagnosticType: dto.diagnosticType,
equipment: dto.equipment,
performedBy: dto.performedBy,
summary: dto.summary,
rawData: dto.rawData,
performedAt: new Date(),
});
return this.diagnosticRepository.save(diagnostic);
}
/**
* Find diagnostic by ID
*/
async findById(tenantId: string, id: string): Promise<Diagnostic | null> {
return this.diagnosticRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find diagnostics by vehicle
*/
async findByVehicle(tenantId: string, vehicleId: string): Promise<Diagnostic[]> {
return this.diagnosticRepository.find({
where: { tenantId, vehicleId },
order: { performedAt: 'DESC' },
});
}
/**
* Find diagnostics by order
*/
async findByOrder(tenantId: string, orderId: string): Promise<Diagnostic[]> {
return this.diagnosticRepository.find({
where: { tenantId, orderId },
order: { performedAt: 'DESC' },
});
}
/**
* Update diagnostic result
*/
async updateResult(
tenantId: string,
id: string,
result: DiagnosticResult,
summary?: string
): Promise<Diagnostic | null> {
const diagnostic = await this.findById(tenantId, id);
if (!diagnostic) return null;
diagnostic.result = result;
if (summary) diagnostic.summary = summary;
return this.diagnosticRepository.save(diagnostic);
}
/**
* Get diagnostic statistics for a vehicle
*/
async getVehicleStats(tenantId: string, vehicleId: string): Promise<{
totalDiagnostics: number;
lastDiagnosticDate: Date | null;
diagnosticsByType: Record<DiagnosticType, number>;
issuesFound: number;
}> {
const diagnostics = await this.findByVehicle(tenantId, vehicleId);
const diagnosticsByType: Record<DiagnosticType, number> = {
[DiagnosticType.SCANNER]: 0,
[DiagnosticType.INJECTOR_TEST]: 0,
[DiagnosticType.PUMP_TEST]: 0,
[DiagnosticType.COMPRESSION]: 0,
[DiagnosticType.TURBO_TEST]: 0,
[DiagnosticType.OTHER]: 0,
};
let issuesFound = 0;
for (const diag of diagnostics) {
diagnosticsByType[diag.diagnosticType]++;
if (diag.result === DiagnosticResult.FAIL || diag.result === DiagnosticResult.NEEDS_ATTENTION) {
issuesFound++;
}
}
return {
totalDiagnostics: diagnostics.length,
lastDiagnosticDate: diagnostics.length > 0 ? diagnostics[0].performedAt : null,
diagnosticsByType,
issuesFound,
};
}
/**
* Parse DTC codes from scanner data
*/
parseDTCCodes(rawData: Record<string, unknown>): DiagnosticItemDto[] {
const items: DiagnosticItemDto[] = [];
// Handle common scanner data formats
const dtcCodes = rawData.dtc_codes || rawData.codes || rawData.faults || [];
if (Array.isArray(dtcCodes)) {
for (const code of dtcCodes) {
if (typeof code === 'string') {
items.push({
itemType: 'dtc_code',
code,
description: this.getDTCDescription(code),
severity: this.getDTCSeverity(code),
});
} else if (typeof code === 'object' && code !== null) {
items.push({
itemType: 'dtc_code',
code: code.code || code.id,
description: code.description || code.message || this.getDTCDescription(code.code),
severity: code.severity || this.getDTCSeverity(code.code),
});
}
}
}
return items;
}
/**
* Get DTC code description (simplified lookup)
*/
private getDTCDescription(code: string): string {
// Common diesel DTC codes
const descriptions: Record<string, string> = {
'P0087': 'Fuel Rail/System Pressure - Too Low',
'P0088': 'Fuel Rail/System Pressure - Too High',
'P0093': 'Fuel System Leak Detected - Large Leak',
'P0100': 'Mass Air Flow Circuit Malfunction',
'P0101': 'Mass Air Flow Circuit Range/Performance',
'P0102': 'Mass Air Flow Circuit Low',
'P0103': 'Mass Air Flow Circuit High',
'P0201': 'Injector Circuit/Open - Cylinder 1',
'P0202': 'Injector Circuit/Open - Cylinder 2',
'P0203': 'Injector Circuit/Open - Cylinder 3',
'P0204': 'Injector Circuit/Open - Cylinder 4',
'P0205': 'Injector Circuit/Open - Cylinder 5',
'P0206': 'Injector Circuit/Open - Cylinder 6',
'P0234': 'Turbocharger/Supercharger Overboost Condition',
'P0299': 'Turbocharger/Supercharger Underboost',
'P0401': 'Exhaust Gas Recirculation Flow Insufficient',
'P0402': 'Exhaust Gas Recirculation Flow Excessive',
'P0404': 'Exhaust Gas Recirculation Circuit Range/Performance',
'P0405': 'Exhaust Gas Recirculation Sensor A Circuit Low',
'P2002': 'Diesel Particulate Filter Efficiency Below Threshold',
'P2003': 'Diesel Particulate Filter Efficiency Below Threshold Bank 2',
'P242F': 'Diesel Particulate Filter Restriction - Ash Accumulation',
};
return descriptions[code] || `Unknown code: ${code}`;
}
/**
* Determine DTC severity
*/
private getDTCSeverity(code: string): 'critical' | 'warning' | 'info' {
// P0xxx codes starting with certain numbers are more critical
if (code.startsWith('P0087') || code.startsWith('P0088') || code.startsWith('P0093')) {
return 'critical'; // Fuel system issues
}
if (code.startsWith('P02')) {
return 'critical'; // Injector issues
}
if (code.startsWith('P0234') || code.startsWith('P0299')) {
return 'warning'; // Turbo issues
}
if (code.startsWith('P04')) {
return 'warning'; // EGR issues
}
if (code.startsWith('P2')) {
return 'warning'; // DPF issues
}
return 'info';
}
/**
* Analyze injector test results
*/
analyzeInjectorTest(rawData: Record<string, unknown>): DiagnosticItemDto[] {
const items: DiagnosticItemDto[] = [];
const injectors = rawData.injectors || rawData.cylinders || [];
if (Array.isArray(injectors)) {
for (let i = 0; i < injectors.length; i++) {
const injector = injectors[i];
if (typeof injector === 'object' && injector !== null) {
// Return quantity test
if (injector.return_qty !== undefined) {
items.push({
itemType: 'measurement',
parameter: 'Return Quantity',
value: injector.return_qty,
unit: 'ml/min',
minRef: 0,
maxRef: 50, // Typical max for healthy injector
status: injector.return_qty > 50 ? 'fail' : 'ok',
cylinder: i + 1,
});
}
// Spray pattern
if (injector.spray_pattern !== undefined) {
items.push({
itemType: 'observation',
description: `Spray pattern: ${injector.spray_pattern}`,
status: injector.spray_pattern === 'good' ? 'ok' : 'warning',
cylinder: i + 1,
});
}
}
}
}
return items;
}
}

View File

@ -0,0 +1,401 @@
/**
* Quote Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for quotations management.
*/
import { Repository, DataSource } from 'typeorm';
import { Quote, QuoteStatus } from '../entities/quote.entity';
import { ServiceOrder, ServiceOrderStatus } from '../entities/service-order.entity';
// DTOs
export interface CreateQuoteDto {
customerId: string;
vehicleId: string;
diagnosticId?: string;
validityDays?: number;
terms?: string;
notes?: string;
}
export interface QuoteItemDto {
itemType: 'service' | 'part';
description: string;
quantity: number;
unitPrice: number;
discountPct?: number;
serviceId?: string;
partId?: string;
}
export interface ApplyDiscountDto {
discountPercent?: number;
discountAmount?: number;
discountReason?: string;
}
export class QuoteService {
private quoteRepository: Repository<Quote>;
private orderRepository: Repository<ServiceOrder>;
constructor(private dataSource: DataSource) {
this.quoteRepository = dataSource.getRepository(Quote);
this.orderRepository = dataSource.getRepository(ServiceOrder);
}
/**
* Generate next quote number for tenant
*/
private async generateQuoteNumber(tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `COT-${year}-`;
const lastQuote = await this.quoteRepository.findOne({
where: { tenantId },
order: { createdAt: 'DESC' },
});
let sequence = 1;
if (lastQuote?.quoteNumber?.startsWith(prefix)) {
const lastSeq = parseInt(lastQuote.quoteNumber.replace(prefix, ''), 10);
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
}
return `${prefix}${sequence.toString().padStart(5, '0')}`;
}
/**
* Create a new quote
*/
async create(tenantId: string, dto: CreateQuoteDto, userId?: string): Promise<Quote> {
const quoteNumber = await this.generateQuoteNumber(tenantId);
const validityDays = dto.validityDays || 15;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + validityDays);
const quote = this.quoteRepository.create({
tenantId,
quoteNumber,
customerId: dto.customerId,
vehicleId: dto.vehicleId,
diagnosticId: dto.diagnosticId,
status: QuoteStatus.DRAFT,
validityDays,
expiresAt,
terms: dto.terms,
notes: dto.notes,
createdBy: userId,
});
return this.quoteRepository.save(quote);
}
/**
* Find quote by ID
*/
async findById(tenantId: string, id: string): Promise<Quote | null> {
return this.quoteRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find quote by number
*/
async findByNumber(tenantId: string, quoteNumber: string): Promise<Quote | null> {
return this.quoteRepository.findOne({
where: { tenantId, quoteNumber },
});
}
/**
* List quotes with filters
*/
async findAll(
tenantId: string,
filters: {
status?: QuoteStatus;
customerId?: string;
vehicleId?: string;
fromDate?: Date;
toDate?: Date;
} = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.quoteRepository.createQueryBuilder('quote')
.where('quote.tenant_id = :tenantId', { tenantId });
if (filters.status) {
queryBuilder.andWhere('quote.status = :status', { status: filters.status });
}
if (filters.customerId) {
queryBuilder.andWhere('quote.customer_id = :customerId', { customerId: filters.customerId });
}
if (filters.vehicleId) {
queryBuilder.andWhere('quote.vehicle_id = :vehicleId', { vehicleId: filters.vehicleId });
}
if (filters.fromDate) {
queryBuilder.andWhere('quote.created_at >= :fromDate', { fromDate: filters.fromDate });
}
if (filters.toDate) {
queryBuilder.andWhere('quote.created_at <= :toDate', { toDate: filters.toDate });
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('quote.created_at', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Send quote to customer
*/
async send(tenantId: string, id: string, channel: 'email' | 'whatsapp'): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (quote.status !== QuoteStatus.DRAFT) {
throw new Error('Quote has already been sent');
}
quote.status = QuoteStatus.SENT;
quote.sentAt = new Date();
// TODO: Integrate with notification service
// await notificationService.sendQuote(quote, channel);
return this.quoteRepository.save(quote);
}
/**
* Mark quote as viewed
*/
async markViewed(tenantId: string, id: string): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (!quote.viewedAt) {
quote.viewedAt = new Date();
if (quote.status === QuoteStatus.SENT) {
quote.status = QuoteStatus.VIEWED;
}
return this.quoteRepository.save(quote);
}
return quote;
}
/**
* Approve quote (customer action)
*/
async approve(
tenantId: string,
id: string,
approvalData: {
approvedByName: string;
approvalSignature?: string;
approvalIp?: string;
}
): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (quote.status === QuoteStatus.EXPIRED) {
throw new Error('Quote has expired');
}
if (quote.status === QuoteStatus.REJECTED) {
throw new Error('Quote was rejected');
}
if (quote.status === QuoteStatus.APPROVED || quote.status === QuoteStatus.CONVERTED) {
throw new Error('Quote has already been approved');
}
quote.status = QuoteStatus.APPROVED;
quote.respondedAt = new Date();
quote.approvedByName = approvalData.approvedByName;
quote.approvalSignature = approvalData.approvalSignature;
quote.approvalIp = approvalData.approvalIp;
return this.quoteRepository.save(quote);
}
/**
* Reject quote
*/
async reject(tenantId: string, id: string, reason?: string): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
quote.status = QuoteStatus.REJECTED;
quote.respondedAt = new Date();
if (reason) {
quote.notes = `${quote.notes || ''}\n\nRejection reason: ${reason}`.trim();
}
return this.quoteRepository.save(quote);
}
/**
* Convert quote to service order
*/
async convertToOrder(tenantId: string, id: string, userId?: string): Promise<ServiceOrder | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (quote.status !== QuoteStatus.APPROVED) {
throw new Error('Quote must be approved before conversion');
}
// Generate order number
const year = new Date().getFullYear();
const prefix = `OS-${year}-`;
const lastOrder = await this.orderRepository.findOne({
where: { tenantId },
order: { createdAt: 'DESC' },
});
let sequence = 1;
if (lastOrder?.orderNumber?.startsWith(prefix)) {
const lastSeq = parseInt(lastOrder.orderNumber.replace(prefix, ''), 10);
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
}
const orderNumber = `${prefix}${sequence.toString().padStart(5, '0')}`;
// Create service order
const order = this.orderRepository.create({
tenantId,
orderNumber,
customerId: quote.customerId,
vehicleId: quote.vehicleId,
quoteId: quote.id,
status: ServiceOrderStatus.APPROVED,
laborTotal: quote.laborTotal,
partsTotal: quote.partsTotal,
discountAmount: quote.discountAmount,
discountPercent: quote.discountPercent,
tax: quote.tax,
grandTotal: quote.grandTotal,
customerNotes: quote.notes,
createdBy: userId,
receivedAt: new Date(),
});
const savedOrder = await this.orderRepository.save(order);
// Update quote
quote.status = QuoteStatus.CONVERTED;
quote.convertedOrderId = savedOrder.id;
await this.quoteRepository.save(quote);
return savedOrder;
}
/**
* Apply discount to quote
*/
async applyDiscount(tenantId: string, id: string, dto: ApplyDiscountDto): Promise<Quote | null> {
const quote = await this.findById(tenantId, id);
if (!quote) return null;
if (dto.discountPercent !== undefined) {
quote.discountPercent = dto.discountPercent;
const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal);
quote.discountAmount = subtotal * (dto.discountPercent / 100);
} else if (dto.discountAmount !== undefined) {
quote.discountAmount = dto.discountAmount;
const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal);
quote.discountPercent = subtotal > 0 ? (dto.discountAmount / subtotal) * 100 : 0;
}
if (dto.discountReason) {
quote.discountReason = dto.discountReason;
}
// Recalculate totals
const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal);
const taxableAmount = subtotal - Number(quote.discountAmount);
quote.tax = taxableAmount * 0.16; // 16% IVA
quote.grandTotal = taxableAmount + quote.tax;
return this.quoteRepository.save(quote);
}
/**
* Check and update expired quotes
*/
async updateExpiredQuotes(tenantId: string): Promise<number> {
const result = await this.quoteRepository
.createQueryBuilder()
.update(Quote)
.set({ status: QuoteStatus.EXPIRED })
.where('tenant_id = :tenantId', { tenantId })
.andWhere('status IN (:...statuses)', {
statuses: [QuoteStatus.DRAFT, QuoteStatus.SENT, QuoteStatus.VIEWED],
})
.andWhere('expires_at < :now', { now: new Date() })
.execute();
return result.affected || 0;
}
/**
* Get quote statistics
*/
async getStats(tenantId: string): Promise<{
total: number;
pending: number;
approved: number;
rejected: number;
converted: number;
conversionRate: number;
averageValue: number;
}> {
const [total, pending, approved, rejected, converted, valueResult] = await Promise.all([
this.quoteRepository.count({ where: { tenantId } }),
this.quoteRepository.count({
where: { tenantId, status: QuoteStatus.SENT },
}),
this.quoteRepository.count({
where: { tenantId, status: QuoteStatus.APPROVED },
}),
this.quoteRepository.count({
where: { tenantId, status: QuoteStatus.REJECTED },
}),
this.quoteRepository.count({
where: { tenantId, status: QuoteStatus.CONVERTED },
}),
this.quoteRepository
.createQueryBuilder('quote')
.select('AVG(quote.grand_total)', 'avg')
.where('quote.tenant_id = :tenantId', { tenantId })
.getRawOne(),
]);
const totalResponded = approved + rejected + converted;
const conversionRate = totalResponded > 0 ? ((approved + converted) / totalResponded) * 100 : 0;
return {
total,
pending,
approved,
rejected,
converted,
conversionRate,
averageValue: parseFloat(valueResult?.avg) || 0,
};
}
}

View File

@ -0,0 +1,484 @@
/**
* Service Order Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for service orders management.
*/
import { Repository, DataSource, FindOptionsWhere, ILike } from 'typeorm';
import {
ServiceOrder,
ServiceOrderStatus,
ServiceOrderPriority,
} from '../entities/service-order.entity';
import { OrderItem, OrderItemType, OrderItemStatus } from '../entities/order-item.entity';
// DTOs
export interface CreateServiceOrderDto {
customerId: string;
vehicleId: string;
customerSymptoms?: string;
priority?: ServiceOrderPriority;
promisedAt?: Date;
assignedTo?: string;
bayId?: string;
odometerIn?: number;
internalNotes?: string;
}
export interface UpdateServiceOrderDto {
status?: ServiceOrderStatus;
priority?: ServiceOrderPriority;
assignedTo?: string;
bayId?: string;
promisedAt?: Date;
odometerOut?: number;
customerSymptoms?: string;
internalNotes?: string;
customerNotes?: string;
}
export interface AddOrderItemDto {
itemType: OrderItemType;
description: string;
quantity: number;
unitPrice: number;
discountPct?: number;
serviceId?: string;
partId?: string;
estimatedHours?: number;
notes?: string;
}
export interface ServiceOrderFilters {
status?: ServiceOrderStatus;
priority?: ServiceOrderPriority;
customerId?: string;
vehicleId?: string;
assignedTo?: string;
bayId?: string;
search?: string;
fromDate?: Date;
toDate?: Date;
}
export interface PaginationOptions {
page: number;
limit: number;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class ServiceOrderService {
private orderRepository: Repository<ServiceOrder>;
private itemRepository: Repository<OrderItem>;
constructor(private dataSource: DataSource) {
this.orderRepository = dataSource.getRepository(ServiceOrder);
this.itemRepository = dataSource.getRepository(OrderItem);
}
/**
* Generate next order number for tenant
*/
private async generateOrderNumber(tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `OS-${year}-`;
const lastOrder = await this.orderRepository.findOne({
where: { tenantId },
order: { createdAt: 'DESC' },
});
let sequence = 1;
if (lastOrder?.orderNumber?.startsWith(prefix)) {
const lastSeq = parseInt(lastOrder.orderNumber.replace(prefix, ''), 10);
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
}
return `${prefix}${sequence.toString().padStart(5, '0')}`;
}
/**
* Create a new service order
*/
async create(tenantId: string, dto: CreateServiceOrderDto, userId?: string): Promise<ServiceOrder> {
const orderNumber = await this.generateOrderNumber(tenantId);
const order = this.orderRepository.create({
tenantId,
orderNumber,
customerId: dto.customerId,
vehicleId: dto.vehicleId,
customerSymptoms: dto.customerSymptoms,
priority: dto.priority || ServiceOrderPriority.NORMAL,
status: ServiceOrderStatus.RECEIVED,
promisedAt: dto.promisedAt,
assignedTo: dto.assignedTo,
bayId: dto.bayId,
odometerIn: dto.odometerIn,
internalNotes: dto.internalNotes,
createdBy: userId,
receivedAt: new Date(),
});
return this.orderRepository.save(order);
}
/**
* Find order by ID
*/
async findById(tenantId: string, id: string): Promise<ServiceOrder | null> {
return this.orderRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find order by order number
*/
async findByOrderNumber(tenantId: string, orderNumber: string): Promise<ServiceOrder | null> {
return this.orderRepository.findOne({
where: { tenantId, orderNumber },
});
}
/**
* List orders with filters and pagination
*/
async findAll(
tenantId: string,
filters: ServiceOrderFilters = {},
pagination: PaginationOptions = { page: 1, limit: 20 }
): Promise<PaginatedResult<ServiceOrder>> {
const where: FindOptionsWhere<ServiceOrder> = { tenantId };
if (filters.status) where.status = filters.status;
if (filters.priority) where.priority = filters.priority;
if (filters.customerId) where.customerId = filters.customerId;
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
if (filters.assignedTo) where.assignedTo = filters.assignedTo;
if (filters.bayId) where.bayId = filters.bayId;
const queryBuilder = this.orderRepository.createQueryBuilder('order')
.where('order.tenant_id = :tenantId', { tenantId });
if (filters.status) {
queryBuilder.andWhere('order.status = :status', { status: filters.status });
}
if (filters.priority) {
queryBuilder.andWhere('order.priority = :priority', { priority: filters.priority });
}
if (filters.customerId) {
queryBuilder.andWhere('order.customer_id = :customerId', { customerId: filters.customerId });
}
if (filters.vehicleId) {
queryBuilder.andWhere('order.vehicle_id = :vehicleId', { vehicleId: filters.vehicleId });
}
if (filters.assignedTo) {
queryBuilder.andWhere('order.assigned_to = :assignedTo', { assignedTo: filters.assignedTo });
}
if (filters.fromDate) {
queryBuilder.andWhere('order.received_at >= :fromDate', { fromDate: filters.fromDate });
}
if (filters.toDate) {
queryBuilder.andWhere('order.received_at <= :toDate', { toDate: filters.toDate });
}
if (filters.search) {
queryBuilder.andWhere(
'(order.order_number ILIKE :search OR order.customer_symptoms ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('order.received_at', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Update service order
*/
async update(
tenantId: string,
id: string,
dto: UpdateServiceOrderDto
): Promise<ServiceOrder | null> {
const order = await this.findById(tenantId, id);
if (!order) return null;
// Handle status transitions
if (dto.status && dto.status !== order.status) {
this.validateStatusTransition(order.status, dto.status);
this.applyStatusSideEffects(order, dto.status);
}
Object.assign(order, dto);
return this.orderRepository.save(order);
}
/**
* Validate status transition
*/
private validateStatusTransition(from: ServiceOrderStatus, to: ServiceOrderStatus): void {
const validTransitions: Record<ServiceOrderStatus, ServiceOrderStatus[]> = {
[ServiceOrderStatus.RECEIVED]: [ServiceOrderStatus.DIAGNOSED, ServiceOrderStatus.CANCELLED],
[ServiceOrderStatus.DIAGNOSED]: [ServiceOrderStatus.QUOTED, ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.CANCELLED],
[ServiceOrderStatus.QUOTED]: [ServiceOrderStatus.APPROVED, ServiceOrderStatus.CANCELLED],
[ServiceOrderStatus.APPROVED]: [ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.CANCELLED],
[ServiceOrderStatus.IN_PROGRESS]: [ServiceOrderStatus.WAITING_PARTS, ServiceOrderStatus.COMPLETED, ServiceOrderStatus.CANCELLED],
[ServiceOrderStatus.WAITING_PARTS]: [ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.CANCELLED],
[ServiceOrderStatus.COMPLETED]: [ServiceOrderStatus.DELIVERED],
[ServiceOrderStatus.DELIVERED]: [],
[ServiceOrderStatus.CANCELLED]: [],
};
if (!validTransitions[from].includes(to)) {
throw new Error(`Invalid status transition from ${from} to ${to}`);
}
}
/**
* Apply side effects when status changes
*/
private applyStatusSideEffects(order: ServiceOrder, newStatus: ServiceOrderStatus): void {
const now = new Date();
switch (newStatus) {
case ServiceOrderStatus.IN_PROGRESS:
if (!order.startedAt) order.startedAt = now;
break;
case ServiceOrderStatus.COMPLETED:
order.completedAt = now;
break;
case ServiceOrderStatus.DELIVERED:
order.deliveredAt = now;
break;
}
}
/**
* Add item to order
*/
async addItem(tenantId: string, orderId: string, dto: AddOrderItemDto): Promise<OrderItem | null> {
const order = await this.findById(tenantId, orderId);
if (!order) return null;
const subtotal = dto.quantity * dto.unitPrice * (1 - (dto.discountPct || 0) / 100);
const item = this.itemRepository.create({
orderId,
itemType: dto.itemType,
description: dto.description,
quantity: dto.quantity,
unitPrice: dto.unitPrice,
discountPct: dto.discountPct || 0,
subtotal,
serviceId: dto.serviceId,
partId: dto.partId,
estimatedHours: dto.estimatedHours,
notes: dto.notes,
status: OrderItemStatus.PENDING,
});
const savedItem = await this.itemRepository.save(item);
// Recalculate totals
await this.recalculateTotals(orderId);
return savedItem;
}
/**
* Get order items
*/
async getItems(orderId: string): Promise<OrderItem[]> {
return this.itemRepository.find({
where: { orderId },
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
}
/**
* Update order item
*/
async updateItem(
itemId: string,
dto: Partial<AddOrderItemDto>
): Promise<OrderItem | null> {
const item = await this.itemRepository.findOne({ where: { id: itemId } });
if (!item) return null;
if (dto.quantity !== undefined || dto.unitPrice !== undefined || dto.discountPct !== undefined) {
const quantity = dto.quantity ?? item.quantity;
const unitPrice = dto.unitPrice ?? item.unitPrice;
const discountPct = dto.discountPct ?? item.discountPct;
item.subtotal = quantity * unitPrice * (1 - discountPct / 100);
}
Object.assign(item, dto);
const savedItem = await this.itemRepository.save(item);
// Recalculate totals
await this.recalculateTotals(item.orderId);
return savedItem;
}
/**
* Remove order item
*/
async removeItem(itemId: string): Promise<boolean> {
const item = await this.itemRepository.findOne({ where: { id: itemId } });
if (!item) return false;
const orderId = item.orderId;
await this.itemRepository.remove(item);
// Recalculate totals
await this.recalculateTotals(orderId);
return true;
}
/**
* Recalculate order totals
*/
private async recalculateTotals(orderId: string): Promise<void> {
const items = await this.getItems(orderId);
let laborTotal = 0;
let partsTotal = 0;
for (const item of items) {
if (item.itemType === OrderItemType.SERVICE) {
laborTotal += Number(item.subtotal);
} else {
partsTotal += Number(item.subtotal);
}
}
const order = await this.orderRepository.findOne({ where: { id: orderId } });
if (!order) return;
order.laborTotal = laborTotal;
order.partsTotal = partsTotal;
const subtotal = laborTotal + partsTotal;
const discountAmount = subtotal * (Number(order.discountPercent) / 100);
order.discountAmount = discountAmount;
const taxableAmount = subtotal - discountAmount;
order.tax = taxableAmount * 0.16; // 16% IVA México
order.grandTotal = taxableAmount + order.tax;
await this.orderRepository.save(order);
}
/**
* Get orders by status (for Kanban board)
*/
async getOrdersByStatus(tenantId: string): Promise<Record<ServiceOrderStatus, ServiceOrder[]>> {
const orders = await this.orderRepository.find({
where: { tenantId },
order: { receivedAt: 'DESC' },
});
const grouped: Record<ServiceOrderStatus, ServiceOrder[]> = {
[ServiceOrderStatus.RECEIVED]: [],
[ServiceOrderStatus.DIAGNOSED]: [],
[ServiceOrderStatus.QUOTED]: [],
[ServiceOrderStatus.APPROVED]: [],
[ServiceOrderStatus.IN_PROGRESS]: [],
[ServiceOrderStatus.WAITING_PARTS]: [],
[ServiceOrderStatus.COMPLETED]: [],
[ServiceOrderStatus.DELIVERED]: [],
[ServiceOrderStatus.CANCELLED]: [],
};
for (const order of orders) {
grouped[order.status].push(order);
}
return grouped;
}
/**
* Get dashboard statistics
*/
async getDashboardStats(tenantId: string): Promise<{
totalOrders: number;
pendingOrders: number;
inProgressOrders: number;
completedToday: number;
totalRevenue: number;
averageTicket: number;
}> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [
totalOrders,
pendingOrders,
inProgressOrders,
completedToday,
revenueResult,
] = await Promise.all([
this.orderRepository.count({ where: { tenantId } }),
this.orderRepository.count({
where: { tenantId, status: ServiceOrderStatus.RECEIVED },
}),
this.orderRepository.count({
where: { tenantId, status: ServiceOrderStatus.IN_PROGRESS },
}),
this.orderRepository.createQueryBuilder('order')
.where('order.tenant_id = :tenantId', { tenantId })
.andWhere('order.status = :status', { status: ServiceOrderStatus.COMPLETED })
.andWhere('order.completed_at >= :today', { today })
.getCount(),
this.orderRepository.createQueryBuilder('order')
.select('SUM(order.grand_total)', 'total')
.where('order.tenant_id = :tenantId', { tenantId })
.andWhere('order.status IN (:...statuses)', {
statuses: [ServiceOrderStatus.COMPLETED, ServiceOrderStatus.DELIVERED],
})
.getRawOne(),
]);
const totalRevenue = parseFloat(revenueResult?.total) || 0;
const completedCount = await this.orderRepository.count({
where: {
tenantId,
status: ServiceOrderStatus.COMPLETED,
},
});
return {
totalOrders,
pendingOrders,
inProgressOrders,
completedToday,
totalRevenue,
averageTicket: completedCount > 0 ? totalRevenue / completedCount : 0,
};
}
}

View File

@ -0,0 +1,174 @@
/**
* Fleet Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for fleet management.
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { FleetService } from '../services/fleet.service';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createFleetController(dataSource: DataSource): Router {
const router = Router();
const service = new FleetService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Create a new fleet
* POST /api/fleets
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const fleet = await service.create(req.tenantId!, req.body);
res.status(201).json(fleet);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List fleets
* GET /api/fleets
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get active fleets
* GET /api/fleets/active
*/
router.get('/active', async (req: TenantRequest, res: Response) => {
try {
const fleets = await service.findActive(req.tenantId!);
res.json(fleets);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get a single fleet
* GET /api/fleets/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const fleet = await service.findById(req.tenantId!, req.params.id);
if (!fleet) {
return res.status(404).json({ error: 'Fleet not found' });
}
res.json(fleet);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get fleet with statistics
* GET /api/fleets/:id/stats
*/
router.get('/:id/stats', async (req: TenantRequest, res: Response) => {
try {
const result = await service.getFleetWithStats(req.tenantId!, req.params.id);
if (!result) {
return res.status(404).json({ error: 'Fleet not found' });
}
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update fleet
* PATCH /api/fleets/:id
*/
router.patch('/:id', async (req: TenantRequest, res: Response) => {
try {
const fleet = await service.update(req.tenantId!, req.params.id, req.body);
if (!fleet) {
return res.status(404).json({ error: 'Fleet not found' });
}
res.json(fleet);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Deactivate fleet
* DELETE /api/fleets/:id
*/
router.delete('/:id', async (req: TenantRequest, res: Response) => {
try {
const success = await service.deactivate(req.tenantId!, req.params.id);
if (!success) {
return res.status(404).json({ error: 'Fleet not found' });
}
res.status(204).send();
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Add vehicle to fleet
* POST /api/fleets/:id/vehicles/:vehicleId
*/
router.post('/:id/vehicles/:vehicleId', async (req: TenantRequest, res: Response) => {
try {
const success = await service.addVehicle(req.tenantId!, req.params.id, req.params.vehicleId);
if (!success) {
return res.status(404).json({ error: 'Fleet or vehicle not found' });
}
res.status(204).send();
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Remove vehicle from fleet
* DELETE /api/fleets/:id/vehicles/:vehicleId
*/
router.delete('/:id/vehicles/:vehicleId', async (req: TenantRequest, res: Response) => {
try {
const success = await service.removeVehicle(req.tenantId!, req.params.id, req.params.vehicleId);
if (!success) {
return res.status(404).json({ error: 'Vehicle not found in fleet' });
}
res.status(204).send();
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,238 @@
/**
* Vehicle Controller
* Mecánicas Diesel - ERP Suite
*
* REST API endpoints for vehicle management.
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { VehicleService, VehicleFilters } from '../services/vehicle.service';
import { VehicleType, VehicleStatus } from '../entities/vehicle.entity';
interface TenantRequest extends Request {
tenantId?: string;
userId?: string;
}
export function createVehicleController(dataSource: DataSource): Router {
const router = Router();
const service = new VehicleService(dataSource);
const extractTenant = (req: TenantRequest, res: Response, next: NextFunction) => {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
return res.status(400).json({ error: 'Tenant ID is required' });
}
req.tenantId = tenantId;
req.userId = req.headers['x-user-id'] as string;
next();
};
router.use(extractTenant);
/**
* Create a new vehicle
* POST /api/vehicles
*/
router.post('/', async (req: TenantRequest, res: Response) => {
try {
const vehicle = await service.create(req.tenantId!, req.body);
res.status(201).json(vehicle);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* List vehicles with filters
* GET /api/vehicles
*/
router.get('/', async (req: TenantRequest, res: Response) => {
try {
const filters: VehicleFilters = {
customerId: req.query.customerId as string,
fleetId: req.query.fleetId as string,
make: req.query.make as string,
model: req.query.model as string,
year: req.query.year ? parseInt(req.query.year as string, 10) : undefined,
vehicleType: req.query.vehicleType as VehicleType,
search: req.query.search as string,
status: req.query.status as VehicleStatus,
};
const pagination = {
page: parseInt(req.query.page as string, 10) || 1,
limit: Math.min(parseInt(req.query.limit as string, 10) || 20, 100),
};
const result = await service.findAll(req.tenantId!, filters, pagination);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get vehicle statistics
* GET /api/vehicles/stats
*/
router.get('/stats', async (req: TenantRequest, res: Response) => {
try {
const stats = await service.getStats(req.tenantId!);
res.json(stats);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get unique makes
* GET /api/vehicles/makes
*/
router.get('/makes', async (req: TenantRequest, res: Response) => {
try {
const makes = await service.getMakes(req.tenantId!);
res.json(makes);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get models for a make
* GET /api/vehicles/makes/:make/models
*/
router.get('/makes/:make/models', async (req: TenantRequest, res: Response) => {
try {
const models = await service.getModels(req.tenantId!, req.params.make);
res.json(models);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get a single vehicle
* GET /api/vehicles/:id
*/
router.get('/:id', async (req: TenantRequest, res: Response) => {
try {
const vehicle = await service.findById(req.tenantId!, req.params.id);
if (!vehicle) {
return res.status(404).json({ error: 'Vehicle not found' });
}
res.json(vehicle);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get by plate number
* GET /api/vehicles/plate/:licensePlate
*/
router.get('/plate/:licensePlate', async (req: TenantRequest, res: Response) => {
try {
const vehicle = await service.findByPlate(req.tenantId!, req.params.licensePlate);
if (!vehicle) {
return res.status(404).json({ error: 'Vehicle not found' });
}
res.json(vehicle);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get by VIN
* GET /api/vehicles/vin/:vin
*/
router.get('/vin/:vin', async (req: TenantRequest, res: Response) => {
try {
const vehicle = await service.findByVin(req.tenantId!, req.params.vin);
if (!vehicle) {
return res.status(404).json({ error: 'Vehicle not found' });
}
res.json(vehicle);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get customer's vehicles
* GET /api/vehicles/customer/:customerId
*/
router.get('/customer/:customerId', async (req: TenantRequest, res: Response) => {
try {
const vehicles = await service.findByCustomer(req.tenantId!, req.params.customerId);
res.json(vehicles);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Get fleet's vehicles
* GET /api/vehicles/fleet/:fleetId
*/
router.get('/fleet/:fleetId', async (req: TenantRequest, res: Response) => {
try {
const vehicles = await service.findByFleet(req.tenantId!, req.params.fleetId);
res.json(vehicles);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Update vehicle
* PATCH /api/vehicles/:id
*/
router.patch('/:id', async (req: TenantRequest, res: Response) => {
try {
const vehicle = await service.update(req.tenantId!, req.params.id, req.body);
if (!vehicle) {
return res.status(404).json({ error: 'Vehicle not found' });
}
res.json(vehicle);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Update odometer
* PATCH /api/vehicles/:id/odometer
*/
router.patch('/:id/odometer', async (req: TenantRequest, res: Response) => {
try {
const vehicle = await service.updateOdometer(req.tenantId!, req.params.id, req.body.odometer);
if (!vehicle) {
return res.status(404).json({ error: 'Vehicle not found' });
}
res.json(vehicle);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
});
/**
* Deactivate vehicle
* DELETE /api/vehicles/:id
*/
router.delete('/:id', async (req: TenantRequest, res: Response) => {
try {
const success = await service.deactivate(req.tenantId!, req.params.id);
if (!success) {
return res.status(404).json({ error: 'Vehicle not found' });
}
res.status(204).send();
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
return router;
}

View File

@ -0,0 +1,62 @@
/**
* Engine Catalog Entity
* Mecánicas Diesel - ERP Suite
*
* Global catalog of diesel engine models.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
Check,
} from 'typeorm';
@Entity({ name: 'engine_catalog', schema: 'vehicle_management' })
@Check('chk_horsepower', '"horsepower_max" >= "horsepower_min"')
@Check('chk_years', '"year_end" IS NULL OR "year_end" >= "year_start"')
export class EngineCatalog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 50 })
make: string;
@Column({ type: 'varchar', length: 50 })
model: string;
@Column({ type: 'integer', nullable: true })
cylinders?: number;
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
displacement?: number;
@Column({ name: 'fuel_type', type: 'varchar', length: 20, default: 'diesel' })
fuelType: string;
@Column({ name: 'horsepower_min', type: 'integer', nullable: true })
horsepowerMin?: number;
@Column({ name: 'horsepower_max', type: 'integer', nullable: true })
horsepowerMax?: number;
@Column({ name: 'torque_max', type: 'integer', nullable: true })
torqueMax?: number;
@Column({ name: 'injection_system', type: 'varchar', length: 50, nullable: true })
injectionSystem?: string;
@Column({ name: 'year_start', type: 'integer', nullable: true })
yearStart?: number;
@Column({ name: 'year_end', type: 'integer', nullable: true })
yearEnd?: number;
@Column({ type: 'text', nullable: true })
notes?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,76 @@
/**
* Fleet Entity
* Mecánicas Diesel - ERP Suite
*
* Represents vehicle fleets for commercial customers.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { Vehicle } from './vehicle.entity';
@Entity({ name: 'fleets', schema: 'vehicle_management' })
@Index('idx_fleets_tenant', ['tenantId'])
@Index('idx_fleets_name', ['name'])
export class Fleet {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ type: 'varchar', length: 200 })
name: string;
@Column({ type: 'varchar', length: 20, nullable: true })
code?: string;
// Contact
@Column({ name: 'contact_name', type: 'varchar', length: 200, nullable: true })
contactName?: string;
@Column({ name: 'contact_email', type: 'varchar', length: 200, nullable: true })
contactEmail?: string;
@Column({ name: 'contact_phone', type: 'varchar', length: 20, nullable: true })
contactPhone?: string;
// Commercial terms
@Column({ name: 'discount_labor_pct', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountLaborPct: number;
@Column({ name: 'discount_parts_pct', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPartsPct: number;
@Column({ name: 'credit_days', type: 'integer', default: 0 })
creditDays: number;
@Column({ name: 'credit_limit', type: 'decimal', precision: 12, scale: 2, default: 0 })
creditLimit: number;
@Column({ name: 'vehicle_count', type: 'integer', default: 0 })
vehicleCount: number;
@Column({ type: 'text', nullable: true })
notes?: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@OneToMany(() => Vehicle, vehicle => vehicle.fleet)
vehicles: Vehicle[];
}

View File

@ -0,0 +1,10 @@
/**
* Vehicle Management Entities Index
* Mecánicas Diesel - ERP Suite
*/
export * from './vehicle.entity';
export * from './fleet.entity';
export * from './vehicle-engine.entity';
export * from './engine-catalog.entity';
export * from './maintenance-reminder.entity';

View File

@ -0,0 +1,103 @@
/**
* Maintenance Reminder Entity
* Mecánicas Diesel - ERP Suite
*
* Represents scheduled maintenance reminders for vehicles.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Vehicle } from './vehicle.entity';
export enum FrequencyType {
TIME = 'time',
ODOMETER = 'odometer',
BOTH = 'both',
}
export enum ReminderStatus {
ACTIVE = 'active',
PAUSED = 'paused',
COMPLETED = 'completed',
}
@Entity({ name: 'maintenance_reminders', schema: 'vehicle_management' })
@Index('idx_reminders_tenant', ['tenantId'])
@Index('idx_reminders_vehicle', ['vehicleId'])
@Index('idx_reminders_due_date', ['nextDueDate'])
export class MaintenanceReminder {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId: string;
@Column({ name: 'service_type', type: 'varchar', length: 100 })
serviceType: string;
@Column({ name: 'service_id', type: 'uuid', nullable: true })
serviceId?: string;
@Column({ name: 'frequency_type', type: 'varchar', length: 20 })
frequencyType: FrequencyType;
// Intervals
@Column({ name: 'interval_days', type: 'integer', nullable: true })
intervalDays?: number;
@Column({ name: 'interval_km', type: 'integer', nullable: true })
intervalKm?: number;
// Last service
@Column({ name: 'last_service_date', type: 'date', nullable: true })
lastServiceDate?: Date;
@Column({ name: 'last_service_km', type: 'integer', nullable: true })
lastServiceKm?: number;
// Next due
@Column({ name: 'next_due_date', type: 'date', nullable: true })
nextDueDate?: Date;
@Column({ name: 'next_due_km', type: 'integer', nullable: true })
nextDueKm?: number;
// Notifications
@Column({ name: 'notify_days_before', type: 'integer', default: 7 })
notifyDaysBefore: number;
@Column({ name: 'notify_km_before', type: 'integer', default: 1000 })
notifyKmBefore: number;
@Column({
type: 'varchar',
length: 20,
default: ReminderStatus.ACTIVE,
})
status: ReminderStatus;
@Column({ type: 'text', nullable: true })
notes?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => Vehicle, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'vehicle_id' })
vehicle: Vehicle;
}

View File

@ -0,0 +1,107 @@
/**
* Vehicle Engine Entity
* Mecánicas Diesel - ERP Suite
*
* Represents engine specifications for a vehicle.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Vehicle } from './vehicle.entity';
import { EngineCatalog } from './engine-catalog.entity';
export enum TurboType {
VGT = 'VGT',
WASTEGATE = 'wastegate',
TWIN = 'twin',
COMPOUND = 'compound',
}
@Entity({ name: 'vehicle_engines', schema: 'vehicle_management' })
@Index('idx_vehicle_engines_vehicle', ['vehicleId'])
@Index('idx_vehicle_engines_serial', ['serialNumber'])
@Index('idx_vehicle_engines_catalog', ['engineCatalogId'])
export class VehicleEngine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId: string;
@Column({ name: 'engine_catalog_id', type: 'uuid', nullable: true })
engineCatalogId?: string;
@Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true })
serialNumber?: string;
// Performance specs
@Column({ type: 'integer', nullable: true })
horsepower?: number;
@Column({ type: 'integer', nullable: true })
torque?: number;
// ECM
@Column({ name: 'ecm_model', type: 'varchar', length: 50, nullable: true })
ecmModel?: string;
@Column({ name: 'ecm_software', type: 'varchar', length: 50, nullable: true })
ecmSoftware?: string;
// Injection system
@Column({ name: 'injection_system', type: 'varchar', length: 50, nullable: true })
injectionSystem?: string;
@Column({ name: 'rail_pressure_max', type: 'decimal', precision: 10, scale: 2, nullable: true })
railPressureMax?: number;
@Column({ name: 'injector_count', type: 'integer', nullable: true })
injectorCount?: number;
// Turbo
@Column({ name: 'turbo_type', type: 'varchar', length: 50, nullable: true })
turboType?: TurboType;
@Column({ name: 'turbo_make', type: 'varchar', length: 50, nullable: true })
turboMake?: string;
@Column({ name: 'turbo_model', type: 'varchar', length: 50, nullable: true })
turboModel?: string;
// Dates
@Column({ name: 'manufacture_date', type: 'date', nullable: true })
manufactureDate?: Date;
@Column({ name: 'rebuild_date', type: 'date', nullable: true })
rebuildDate?: Date;
@Column({ name: 'rebuild_odometer', type: 'integer', nullable: true })
rebuildOdometer?: number;
@Column({ type: 'text', nullable: true })
notes?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@OneToOne(() => Vehicle, vehicle => vehicle.engine, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'vehicle_id' })
vehicle: Vehicle;
@ManyToOne(() => EngineCatalog, { nullable: true })
@JoinColumn({ name: 'engine_catalog_id' })
engineCatalog?: EngineCatalog;
}

View File

@ -0,0 +1,129 @@
/**
* Vehicle Entity
* Mecánicas Diesel - ERP Suite
*
* Represents vehicles registered in the workshop.
*/
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
OneToMany,
OneToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Fleet } from './fleet.entity';
import { VehicleEngine } from './vehicle-engine.entity';
export enum VehicleType {
TRUCK = 'truck',
TRAILER = 'trailer',
BUS = 'bus',
PICKUP = 'pickup',
OTHER = 'other',
}
export enum VehicleStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SOLD = 'sold',
}
@Entity({ name: 'vehicles', schema: 'vehicle_management' })
@Index('idx_vehicles_tenant', ['tenantId'])
@Index('idx_vehicles_customer', ['customerId'])
@Index('idx_vehicles_fleet', ['fleetId'])
@Index('idx_vehicles_vin', ['vin'])
@Index('idx_vehicles_plate', ['licensePlate'])
export class Vehicle {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'customer_id', type: 'uuid' })
customerId: string;
@Column({ name: 'fleet_id', type: 'uuid', nullable: true })
fleetId?: string;
// Identification
@Column({ type: 'varchar', length: 17, nullable: true })
vin?: string;
@Column({ name: 'license_plate', type: 'varchar', length: 15 })
licensePlate: string;
@Column({ name: 'economic_number', type: 'varchar', length: 20, nullable: true })
economicNumber?: string;
// Vehicle info
@Column({ type: 'varchar', length: 50 })
make: string;
@Column({ type: 'varchar', length: 100 })
model: string;
@Column({ type: 'integer' })
year: number;
@Column({ type: 'varchar', length: 30, nullable: true })
color?: string;
@Column({
name: 'vehicle_type',
type: 'varchar',
length: 30,
default: VehicleType.TRUCK,
})
vehicleType: VehicleType;
// Odometer
@Column({ name: 'current_odometer', type: 'integer', nullable: true })
currentOdometer?: number;
@Column({ name: 'odometer_updated_at', type: 'timestamptz', nullable: true })
odometerUpdatedAt?: Date;
@Column({ name: 'photo_url', type: 'varchar', length: 500, nullable: true })
photoUrl?: string;
@Column({
type: 'varchar',
length: 20,
default: VehicleStatus.ACTIVE,
})
status: VehicleStatus;
@Column({ type: 'text', nullable: true })
notes?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relations
@ManyToOne(() => Fleet, fleet => fleet.vehicles, { nullable: true })
@JoinColumn({ name: 'fleet_id' })
fleet?: Fleet;
@OneToOne(() => VehicleEngine, engine => engine.vehicle)
engine?: VehicleEngine;
// @OneToMany(() => ServiceOrder, order => order.vehicle)
// serviceOrders: ServiceOrder[];
// @OneToMany(() => MaintenanceReminder, reminder => reminder.vehicle)
// reminders: MaintenanceReminder[];
// @OneToMany(() => VehicleDocument, doc => doc.vehicle)
// documents: VehicleDocument[];
}

View File

@ -0,0 +1,19 @@
/**
* Vehicle Management Module
* Mecánicas Diesel - ERP Suite
*/
// Entities
export { Vehicle, VehicleType, VehicleStatus } from './entities/vehicle.entity';
export { Fleet } from './entities/fleet.entity';
export { VehicleEngine } from './entities/vehicle-engine.entity';
export { EngineCatalog } from './entities/engine-catalog.entity';
export { MaintenanceReminder } from './entities/maintenance-reminder.entity';
// Services
export { VehicleService, CreateVehicleDto, UpdateVehicleDto, VehicleFilters } from './services/vehicle.service';
export { FleetService, CreateFleetDto, UpdateFleetDto } from './services/fleet.service';
// Controllers
export { createVehicleController } from './controllers/vehicle.controller';
export { createFleetController } from './controllers/fleet.controller';

View File

@ -0,0 +1,207 @@
/**
* Fleet Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for fleet management.
*/
import { Repository, DataSource } from 'typeorm';
import { Fleet } from '../entities/fleet.entity';
import { Vehicle, VehicleStatus } from '../entities/vehicle.entity';
// DTOs
export interface CreateFleetDto {
name: string;
code?: string;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
discountLaborPct?: number;
discountPartsPct?: number;
creditDays?: number;
creditLimit?: number;
notes?: string;
}
export interface UpdateFleetDto {
name?: string;
code?: string;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
discountLaborPct?: number;
discountPartsPct?: number;
creditDays?: number;
creditLimit?: number;
notes?: string;
isActive?: boolean;
}
export class FleetService {
private fleetRepository: Repository<Fleet>;
private vehicleRepository: Repository<Vehicle>;
constructor(dataSource: DataSource) {
this.fleetRepository = dataSource.getRepository(Fleet);
this.vehicleRepository = dataSource.getRepository(Vehicle);
}
/**
* Create a new fleet
*/
async create(tenantId: string, dto: CreateFleetDto): Promise<Fleet> {
const fleet = this.fleetRepository.create({
tenantId,
name: dto.name,
code: dto.code,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
contactEmail: dto.contactEmail,
discountLaborPct: dto.discountLaborPct || 0,
discountPartsPct: dto.discountPartsPct || 0,
creditDays: dto.creditDays || 0,
creditLimit: dto.creditLimit || 0,
notes: dto.notes,
isActive: true,
vehicleCount: 0,
});
return this.fleetRepository.save(fleet);
}
/**
* Find fleet by ID
*/
async findById(tenantId: string, id: string): Promise<Fleet | null> {
return this.fleetRepository.findOne({
where: { id, tenantId },
});
}
/**
* List fleets
*/
async findAll(
tenantId: string,
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.fleetRepository.createQueryBuilder('fleet')
.where('fleet.tenant_id = :tenantId', { tenantId });
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('fleet.name', 'ASC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Update fleet
*/
async update(tenantId: string, id: string, dto: UpdateFleetDto): Promise<Fleet | null> {
const fleet = await this.findById(tenantId, id);
if (!fleet) return null;
Object.assign(fleet, dto);
return this.fleetRepository.save(fleet);
}
/**
* Deactivate fleet
*/
async deactivate(tenantId: string, id: string): Promise<boolean> {
const fleet = await this.findById(tenantId, id);
if (!fleet) return false;
fleet.isActive = false;
await this.fleetRepository.save(fleet);
return true;
}
/**
* Get fleet with vehicle count
*/
async getFleetWithStats(tenantId: string, id: string): Promise<{
fleet: Fleet;
vehicleCount: number;
activeVehicles: number;
} | null> {
const fleet = await this.findById(tenantId, id);
if (!fleet) return null;
const [vehicleCount, activeVehicles] = await Promise.all([
this.vehicleRepository.count({ where: { tenantId, fleetId: id } }),
this.vehicleRepository.count({ where: { tenantId, fleetId: id, status: VehicleStatus.ACTIVE } }),
]);
return {
fleet,
vehicleCount,
activeVehicles,
};
}
/**
* Get active fleets
*/
async findActive(tenantId: string): Promise<Fleet[]> {
return this.fleetRepository.find({
where: { tenantId, isActive: true },
order: { name: 'ASC' },
});
}
/**
* Add vehicle to fleet
*/
async addVehicle(tenantId: string, fleetId: string, vehicleId: string): Promise<boolean> {
const fleet = await this.findById(tenantId, fleetId);
if (!fleet) return false;
const vehicle = await this.vehicleRepository.findOne({
where: { id: vehicleId, tenantId },
});
if (!vehicle) return false;
vehicle.fleetId = fleetId;
await this.vehicleRepository.save(vehicle);
// Update vehicle count
fleet.vehicleCount = await this.vehicleRepository.count({ where: { tenantId, fleetId } });
await this.fleetRepository.save(fleet);
return true;
}
/**
* Remove vehicle from fleet
*/
async removeVehicle(tenantId: string, fleetId: string, vehicleId: string): Promise<boolean> {
const vehicle = await this.vehicleRepository.findOne({
where: { id: vehicleId, tenantId, fleetId },
});
if (!vehicle) return false;
const fleet = await this.findById(tenantId, fleetId);
if (!fleet) return false;
vehicle.fleetId = undefined;
await this.vehicleRepository.save(vehicle);
// Update vehicle count
fleet.vehicleCount = await this.vehicleRepository.count({ where: { tenantId, fleetId } });
await this.fleetRepository.save(fleet);
return true;
}
}

View File

@ -0,0 +1,319 @@
/**
* Vehicle Service
* Mecánicas Diesel - ERP Suite
*
* Business logic for vehicle management.
*/
import { Repository, DataSource } from 'typeorm';
import { Vehicle, VehicleType, VehicleStatus } from '../entities/vehicle.entity';
// DTOs
export interface CreateVehicleDto {
customerId: string;
fleetId?: string;
licensePlate: string;
vin?: string;
make: string;
model: string;
year: number;
color?: string;
vehicleType?: VehicleType;
economicNumber?: string;
currentOdometer?: number;
notes?: string;
}
export interface UpdateVehicleDto {
licensePlate?: string;
vin?: string;
color?: string;
vehicleType?: VehicleType;
economicNumber?: string;
currentOdometer?: number;
notes?: string;
status?: VehicleStatus;
}
export interface VehicleFilters {
customerId?: string;
fleetId?: string;
make?: string;
model?: string;
year?: number;
vehicleType?: VehicleType;
search?: string;
status?: VehicleStatus;
}
export class VehicleService {
private vehicleRepository: Repository<Vehicle>;
constructor(dataSource: DataSource) {
this.vehicleRepository = dataSource.getRepository(Vehicle);
}
/**
* Create a new vehicle
*/
async create(tenantId: string, dto: CreateVehicleDto): Promise<Vehicle> {
// Check for duplicate plate number
const existing = await this.vehicleRepository.findOne({
where: { tenantId, licensePlate: dto.licensePlate },
});
if (existing) {
throw new Error(`Vehicle with plate ${dto.licensePlate} already exists`);
}
const vehicle = this.vehicleRepository.create({
tenantId,
customerId: dto.customerId,
fleetId: dto.fleetId,
licensePlate: dto.licensePlate,
vin: dto.vin,
make: dto.make,
model: dto.model,
year: dto.year,
color: dto.color,
vehicleType: dto.vehicleType || VehicleType.TRUCK,
economicNumber: dto.economicNumber,
currentOdometer: dto.currentOdometer,
notes: dto.notes,
status: VehicleStatus.ACTIVE,
});
return this.vehicleRepository.save(vehicle);
}
/**
* Find vehicle by ID
*/
async findById(tenantId: string, id: string): Promise<Vehicle | null> {
return this.vehicleRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find vehicle by plate number
*/
async findByPlate(tenantId: string, licensePlate: string): Promise<Vehicle | null> {
return this.vehicleRepository.findOne({
where: { tenantId, licensePlate },
});
}
/**
* Find vehicle by VIN
*/
async findByVin(tenantId: string, vin: string): Promise<Vehicle | null> {
return this.vehicleRepository.findOne({
where: { tenantId, vin },
});
}
/**
* List vehicles with filters
*/
async findAll(
tenantId: string,
filters: VehicleFilters = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.vehicleRepository.createQueryBuilder('vehicle')
.where('vehicle.tenant_id = :tenantId', { tenantId });
if (filters.customerId) {
queryBuilder.andWhere('vehicle.customer_id = :customerId', { customerId: filters.customerId });
}
if (filters.fleetId) {
queryBuilder.andWhere('vehicle.fleet_id = :fleetId', { fleetId: filters.fleetId });
}
if (filters.make) {
queryBuilder.andWhere('vehicle.make = :make', { make: filters.make });
}
if (filters.model) {
queryBuilder.andWhere('vehicle.model = :model', { model: filters.model });
}
if (filters.year) {
queryBuilder.andWhere('vehicle.year = :year', { year: filters.year });
}
if (filters.vehicleType) {
queryBuilder.andWhere('vehicle.vehicle_type = :vehicleType', { vehicleType: filters.vehicleType });
}
if (filters.status) {
queryBuilder.andWhere('vehicle.status = :status', { status: filters.status });
}
if (filters.search) {
queryBuilder.andWhere(
'(vehicle.license_plate ILIKE :search OR vehicle.make ILIKE :search OR vehicle.model ILIKE :search OR vehicle.vin ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('vehicle.created_at', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Update vehicle
*/
async update(tenantId: string, id: string, dto: UpdateVehicleDto): Promise<Vehicle | null> {
const vehicle = await this.findById(tenantId, id);
if (!vehicle) return null;
// Check plate number uniqueness if changing
if (dto.licensePlate && dto.licensePlate !== vehicle.licensePlate) {
const existing = await this.findByPlate(tenantId, dto.licensePlate);
if (existing) {
throw new Error(`Vehicle with plate ${dto.licensePlate} already exists`);
}
}
Object.assign(vehicle, dto);
return this.vehicleRepository.save(vehicle);
}
/**
* Update odometer
*/
async updateOdometer(tenantId: string, id: string, odometer: number): Promise<Vehicle | null> {
const vehicle = await this.findById(tenantId, id);
if (!vehicle) return null;
if (odometer < (vehicle.currentOdometer || 0)) {
throw new Error('New odometer reading cannot be less than current');
}
vehicle.currentOdometer = odometer;
vehicle.odometerUpdatedAt = new Date();
return this.vehicleRepository.save(vehicle);
}
/**
* Deactivate vehicle
*/
async deactivate(tenantId: string, id: string): Promise<boolean> {
const vehicle = await this.findById(tenantId, id);
if (!vehicle) return false;
vehicle.status = VehicleStatus.INACTIVE;
await this.vehicleRepository.save(vehicle);
return true;
}
/**
* Get customer's vehicles
*/
async findByCustomer(tenantId: string, customerId: string): Promise<Vehicle[]> {
return this.vehicleRepository.find({
where: { tenantId, customerId, status: VehicleStatus.ACTIVE },
order: { createdAt: 'DESC' },
});
}
/**
* Get fleet's vehicles
*/
async findByFleet(tenantId: string, fleetId: string): Promise<Vehicle[]> {
return this.vehicleRepository.find({
where: { tenantId, fleetId, status: VehicleStatus.ACTIVE },
order: { createdAt: 'DESC' },
});
}
/**
* Get unique makes for filters
*/
async getMakes(tenantId: string): Promise<string[]> {
const result = await this.vehicleRepository
.createQueryBuilder('vehicle')
.select('DISTINCT vehicle.make', 'make')
.where('vehicle.tenant_id = :tenantId', { tenantId })
.orderBy('vehicle.make', 'ASC')
.getRawMany();
return result.map(r => r.make);
}
/**
* Get models for a make
*/
async getModels(tenantId: string, make: string): Promise<string[]> {
const result = await this.vehicleRepository
.createQueryBuilder('vehicle')
.select('DISTINCT vehicle.model', 'model')
.where('vehicle.tenant_id = :tenantId', { tenantId })
.andWhere('vehicle.make = :make', { make })
.orderBy('vehicle.model', 'ASC')
.getRawMany();
return result.map(r => r.model);
}
/**
* Get vehicle statistics
*/
async getStats(tenantId: string): Promise<{
total: number;
active: number;
byVehicleType: Record<VehicleType, number>;
byMake: { make: string; count: number }[];
}> {
const [total, active, vehicleTypeCounts, makeCounts] = await Promise.all([
this.vehicleRepository.count({ where: { tenantId } }),
this.vehicleRepository.count({ where: { tenantId, status: VehicleStatus.ACTIVE } }),
this.vehicleRepository
.createQueryBuilder('vehicle')
.select('vehicle.vehicle_type', 'vehicleType')
.addSelect('COUNT(*)', 'count')
.where('vehicle.tenant_id = :tenantId', { tenantId })
.groupBy('vehicle.vehicle_type')
.getRawMany(),
this.vehicleRepository
.createQueryBuilder('vehicle')
.select('vehicle.make', 'make')
.addSelect('COUNT(*)', 'count')
.where('vehicle.tenant_id = :tenantId', { tenantId })
.groupBy('vehicle.make')
.orderBy('count', 'DESC')
.limit(10)
.getRawMany(),
]);
const byVehicleType: Record<VehicleType, number> = {
[VehicleType.TRUCK]: 0,
[VehicleType.TRAILER]: 0,
[VehicleType.BUS]: 0,
[VehicleType.PICKUP]: 0,
[VehicleType.OTHER]: 0,
};
for (const row of vehicleTypeCounts) {
if (row.vehicleType) {
byVehicleType[row.vehicleType as VehicleType] = parseInt(row.count, 10);
}
}
return {
total,
active,
byVehicleType,
byMake: makeCounts.map(r => ({ make: r.make, count: parseInt(r.count, 10) })),
};
}
}

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false,
"noImplicitAny": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"paths": {
"@modules/*": ["./src/modules/*"],
"@shared/*": ["./src/shared/*"],
"@config/*": ["./src/config/*"]
},
"baseUrl": "."
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
}

View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,152 @@
# Frontend - ERP Mecanicas Diesel
## Stack Tecnologico
| Tecnologia | Version | Proposito |
|------------|---------|-----------|
| React | 18.x | Framework UI |
| Vite | 6.x | Build tool |
| TypeScript | 5.x | Lenguaje |
| React Router | 6.x | Routing |
| Zustand | 5.x | State management |
| React Query | 5.x | Data fetching/caching |
| Tailwind CSS | 3.x | Styling |
| React Hook Form | 7.x | Formularios |
| Zod | 3.x | Validacion |
| Axios | 1.x | HTTP client |
| Lucide React | - | Iconos |
## Estructura del Proyecto
```
src/
├── components/
│ ├── common/ # Componentes reutilizables (Button, Input, etc)
│ ├── layout/ # Layout principal (Sidebar, Header, MainLayout)
│ └── features/ # Componentes especificos por modulo
├── features/ # Logica por modulo/epic
│ ├── auth/ # Autenticacion
│ ├── service-orders/ # MMD-002: Ordenes de servicio
│ ├── diagnostics/ # MMD-003: Diagnosticos
│ ├── inventory/ # MMD-004: Inventario
│ ├── vehicles/ # MMD-005: Vehiculos
│ ├── quotes/ # MMD-006: Cotizaciones
│ └── settings/ # MMD-001: Configuracion
├── store/ # Zustand stores
│ ├── authStore.ts # Estado de autenticacion
│ └── tallerStore.ts # Estado del taller
├── services/
│ └── api/ # Clientes API
│ ├── client.ts # Axios instance con interceptors
│ ├── auth.ts # Endpoints de auth
│ └── serviceOrders.ts # Endpoints de ordenes
├── pages/ # Paginas/Vistas
│ ├── Login.tsx
│ └── Dashboard.tsx
├── hooks/ # Custom React hooks
├── types/ # TypeScript types
│ └── index.ts # Tipos base
├── utils/ # Utilidades
├── App.tsx # Router principal
├── main.tsx # Entry point
└── index.css # Tailwind imports
```
## Comandos
```bash
# Instalar dependencias
npm install
# Desarrollo
npm run dev
# Build produccion
npm run build
# Preview build
npm run preview
# Lint
npm run lint
```
## Variables de Entorno
Crear archivo `.env.local`:
```env
VITE_API_URL=http://localhost:3041/api/v1
```
## Modulos por Implementar
### MMD-001: Fundamentos (Sprint 1-2)
- [ ] Configuracion de taller (wizard)
- [ ] Gestion de roles
- [ ] Catalogo de servicios
- [ ] Gestion de bahias
### MMD-002: Ordenes de Servicio (Sprint 2-5)
- [ ] Lista de ordenes con filtros
- [ ] Detalle de orden
- [ ] Crear orden (wizard 4 pasos)
- [ ] Tablero Kanban
- [ ] Registro de trabajos
### MMD-003: Diagnosticos (Sprint 2-4)
- [ ] Lista de diagnosticos
- [ ] Scanner OBD (DTC codes)
- [ ] Pruebas de banco
- [ ] Galeria de fotos
### MMD-004: Inventario (Sprint 4-6)
- [ ] Catalogo de refacciones
- [ ] Kardex de movimientos
- [ ] Alertas de stock
- [ ] Recepcion de mercancia
### MMD-005: Vehiculos (Sprint 4-6)
- [ ] Lista de vehiculos
- [ ] Ficha tecnica
- [ ] Especificaciones de motor
- [ ] Historial de servicios
### MMD-006: Cotizaciones (Sprint 6)
- [ ] Lista de cotizaciones
- [ ] Crear cotizacion
- [ ] Generar PDF
- [ ] Envio por email/WhatsApp
## Convenciones
### Nombres de Archivos
- Componentes: `PascalCase.tsx`
- Hooks: `useCamelCase.ts`
- Stores: `camelCaseStore.ts`
- Types: `camelCase.types.ts`
- Services: `camelCase.ts`
### Estructura de Feature
```
features/{feature}/
├── components/ # Componentes UI
├── hooks/ # Custom hooks
├── types/ # TypeScript types
└── index.ts # Exports publicos
```
## Dependencias del Backend
Este frontend requiere el backend de mecanicas-diesel corriendo en el puerto 3041.
```bash
# Desde la raiz del proyecto
cd ../backend
npm run dev
```
---
*ERP Mecanicas Diesel - Sistema NEXUS*
*Creado: 2025-12-08*

View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.12",
"axios": "^1.13.2",
"lucide-react": "^0.556.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"react-router-dom": "^6.30.2",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,134 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MainLayout } from './components/layout';
import { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard';
import { useAuthStore } from './store/authStore';
// Create React Query client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
},
},
});
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// Placeholder pages
function ServiceOrdersPage() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Ordenes de Servicio</h1>
<p className="text-gray-500">Modulo en desarrollo...</p>
</div>
);
}
function DiagnosticsPage() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Diagnosticos</h1>
<p className="text-gray-500">Modulo en desarrollo...</p>
</div>
);
}
function InventoryPage() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
<p className="text-gray-500">Modulo en desarrollo...</p>
</div>
);
}
function VehiclesPage() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Vehiculos</h1>
<p className="text-gray-500">Modulo en desarrollo...</p>
</div>
);
}
function QuotesPage() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Cotizaciones</h1>
<p className="text-gray-500">Modulo en desarrollo...</p>
</div>
);
}
function SettingsPage() {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Configuracion</h1>
<p className="text-gray-500">Modulo en desarrollo...</p>
</div>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/login" element={<Login />} />
{/* Protected routes */}
<Route
path="/"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="orders" element={<ServiceOrdersPage />} />
<Route path="orders/:id" element={<ServiceOrdersPage />} />
<Route path="diagnostics" element={<DiagnosticsPage />} />
<Route path="diagnostics/:id" element={<DiagnosticsPage />} />
<Route path="inventory" element={<InventoryPage />} />
<Route path="inventory/:id" element={<InventoryPage />} />
<Route path="vehicles" element={<VehiclesPage />} />
<Route path="vehicles/:id" element={<VehiclesPage />} />
<Route path="quotes" element={<QuotesPage />} />
<Route path="quotes/:id" element={<QuotesPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
{/* 404 */}
<Route
path="*"
element={
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900">404</h1>
<p className="text-gray-500">Pagina no encontrada</p>
</div>
</div>
}
/>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}
export default App;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,50 @@
import { Bell, Search } from 'lucide-react';
import { useTallerStore } from '../../store/tallerStore';
export function Header() {
const { selectedBay, workBays, setSelectedBay } = useTallerStore();
return (
<header className="flex h-16 shrink-0 items-center gap-4 border-b border-gray-200 bg-white px-6">
{/* Search */}
<div className="flex flex-1 items-center gap-2">
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar ordenes, vehiculos, clientes..."
className="w-full rounded-lg border border-gray-300 bg-gray-50 py-2 pl-10 pr-4 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
/>
</div>
</div>
{/* Bay selector */}
{workBays.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Bahia:</span>
<select
value={selectedBay?.id || ''}
onChange={(e) => {
const bay = workBays.find((b) => b.id === e.target.value);
setSelectedBay(bay || null);
}}
className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
>
<option value="">Sin bahia</option>
{workBays.map((bay) => (
<option key={bay.id} value={bay.id}>
{bay.name} ({bay.status === 'available' ? 'Disponible' : 'Ocupada'})
</option>
))}
</select>
</div>
)}
{/* Notifications */}
<button className="relative rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
<Bell className="h-5 w-5" />
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-red-500" />
</button>
</header>
);
}

View File

@ -0,0 +1,23 @@
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
export function MainLayout() {
return (
<div className="flex h-screen bg-gray-100">
{/* Sidebar */}
<Sidebar />
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<Header />
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
Wrench,
Stethoscope,
Package,
Truck,
FileText,
Settings,
LogOut,
} from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { useTallerStore } from '../../store/tallerStore';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'Ordenes de Servicio', href: '/orders', icon: Wrench },
{ name: 'Diagnosticos', href: '/diagnostics', icon: Stethoscope },
{ name: 'Inventario', href: '/inventory', icon: Package },
{ name: 'Vehiculos', href: '/vehicles', icon: Truck },
{ name: 'Cotizaciones', href: '/quotes', icon: FileText },
];
const secondaryNavigation = [
{ name: 'Configuracion', href: '/settings', icon: Settings },
];
export function Sidebar() {
const { logout, user } = useAuthStore();
const { currentTaller } = useTallerStore();
return (
<div className="flex h-full w-64 flex-col bg-gray-900">
{/* Logo */}
<div className="flex h-16 shrink-0 items-center px-6">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-diesel-500 flex items-center justify-center">
<Wrench className="h-5 w-5 text-white" />
</div>
<span className="text-xl font-bold text-white">Mecanicas</span>
</div>
</div>
{/* Taller info */}
{currentTaller && (
<div className="px-4 py-2">
<div className="rounded-lg bg-gray-800 px-3 py-2">
<p className="text-xs text-gray-400">Taller</p>
<p className="text-sm font-medium text-white truncate">
{currentTaller.name}
</p>
</div>
</div>
)}
{/* Navigation */}
<nav className="flex flex-1 flex-col px-4 py-4">
<ul role="list" className="flex flex-1 flex-col gap-1">
{navigation.map((item) => (
<li key={item.name}>
<NavLink
to={item.href}
className={({ isActive }) =>
`group flex gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-diesel-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`
}
>
<item.icon className="h-5 w-5 shrink-0" />
{item.name}
</NavLink>
</li>
))}
</ul>
{/* Secondary navigation */}
<ul role="list" className="mt-auto space-y-1">
{secondaryNavigation.map((item) => (
<li key={item.name}>
<NavLink
to={item.href}
className={({ isActive }) =>
`group flex gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive
? 'bg-gray-800 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`
}
>
<item.icon className="h-5 w-5 shrink-0" />
{item.name}
</NavLink>
</li>
))}
{/* Logout */}
<li>
<button
onClick={() => logout()}
className="group flex w-full gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
>
<LogOut className="h-5 w-5 shrink-0" />
Cerrar Sesion
</button>
</li>
</ul>
{/* User info */}
{user && (
<div className="mt-4 border-t border-gray-800 pt-4">
<div className="flex items-center gap-3 px-3">
<div className="h-8 w-8 rounded-full bg-gray-700 flex items-center justify-center">
<span className="text-sm font-medium text-white">
{user.full_name.charAt(0).toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{user.full_name}
</p>
<p className="text-xs text-gray-400 truncate">{user.role}</p>
</div>
</div>
</div>
)}
</nav>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { MainLayout } from './MainLayout';
export { Sidebar } from './Sidebar';
export { Header } from './Header';

View File

@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,137 @@
import { Wrench, Truck, Package, FileText, Clock, CheckCircle } from 'lucide-react';
const stats = [
{ name: 'Ordenes Activas', value: '12', icon: Wrench, color: 'bg-blue-500' },
{ name: 'Vehiculos Atendidos (Mes)', value: '48', icon: Truck, color: 'bg-green-500' },
{ name: 'Alertas de Stock', value: '5', icon: Package, color: 'bg-yellow-500' },
{ name: 'Cotizaciones Pendientes', value: '8', icon: FileText, color: 'bg-purple-500' },
];
const recentOrders = [
{ id: 'OS-2025-0142', vehicle: 'Kenworth T800', status: 'En Reparacion', customer: 'Transportes del Norte' },
{ id: 'OS-2025-0141', vehicle: 'Freightliner Cascadia', status: 'Diagnostico', customer: 'Carga Pesada SA' },
{ id: 'OS-2025-0140', vehicle: 'International LT', status: 'Esperando Refacciones', customer: 'Logistica Express' },
{ id: 'OS-2025-0139', vehicle: 'Peterbilt 579', status: 'Listo', customer: 'Fletes Rapidos' },
];
export function Dashboard() {
return (
<div className="space-y-6">
{/* Page header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500">Resumen de operaciones del taller</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<div
key={stat.name}
className="flex items-center gap-4 rounded-lg bg-white p-4 shadow-sm"
>
<div className={`rounded-lg ${stat.color} p-3`}>
<stat.icon className="h-6 w-6 text-white" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
<p className="text-sm text-gray-500">{stat.name}</p>
</div>
</div>
))}
</div>
{/* Content grid */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Recent orders */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Ordenes Recientes
</h2>
<div className="space-y-3">
{recentOrders.map((order) => (
<div
key={order.id}
className="flex items-center justify-between rounded-lg border border-gray-100 p-3 hover:bg-gray-50"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-gray-100 flex items-center justify-center">
<Truck className="h-5 w-5 text-gray-600" />
</div>
<div>
<p className="font-medium text-gray-900">{order.id}</p>
<p className="text-sm text-gray-500">{order.vehicle}</p>
</div>
</div>
<div className="text-right">
<span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
{order.status}
</span>
<p className="text-sm text-gray-500 mt-1">{order.customer}</p>
</div>
</div>
))}
</div>
</div>
{/* Quick actions */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Acciones Rapidas
</h2>
<div className="grid grid-cols-2 gap-3">
<button className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors">
<Wrench className="h-8 w-8 text-diesel-600" />
<span className="text-sm font-medium text-gray-700">Nueva Orden</span>
</button>
<button className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors">
<FileText className="h-8 w-8 text-diesel-600" />
<span className="text-sm font-medium text-gray-700">Nueva Cotizacion</span>
</button>
<button className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors">
<Truck className="h-8 w-8 text-diesel-600" />
<span className="text-sm font-medium text-gray-700">Registrar Vehiculo</span>
</button>
<button className="flex flex-col items-center gap-2 rounded-lg border border-gray-200 p-4 hover:border-diesel-500 hover:bg-diesel-50 transition-colors">
<Package className="h-8 w-8 text-diesel-600" />
<span className="text-sm font-medium text-gray-700">Recibir Mercancia</span>
</button>
</div>
</div>
{/* Today's schedule */}
<div className="rounded-lg bg-white p-6 shadow-sm lg:col-span-2">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Programacion del Dia
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="h-5 w-5 text-yellow-500" />
<span className="font-medium text-gray-900">Pendientes</span>
</div>
<p className="text-3xl font-bold text-gray-900">4</p>
<p className="text-sm text-gray-500">ordenes por iniciar</p>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-2 mb-3">
<Wrench className="h-5 w-5 text-blue-500" />
<span className="font-medium text-gray-900">En Proceso</span>
</div>
<p className="text-3xl font-bold text-gray-900">6</p>
<p className="text-sm text-gray-500">ordenes en reparacion</p>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-2 mb-3">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="font-medium text-gray-900">Completadas Hoy</span>
</div>
<p className="text-3xl font-bold text-gray-900">3</p>
<p className="text-sm text-gray-500">ordenes entregadas</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,165 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Wrench, Eye, EyeOff } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
const loginSchema = z.object({
email: z.string().email('Email invalido'),
password: z.string().min(6, 'Minimo 6 caracteres'),
});
type LoginForm = z.infer<typeof loginSchema>;
export function Login() {
const navigate = useNavigate();
const { login } = useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginForm) => {
setIsLoading(true);
setError(null);
try {
// TODO: Replace with actual API call
// const response = await authApi.login(data);
// Mock login for development
const mockUser = {
id: '1',
email: data.email,
full_name: 'Usuario Demo',
role: 'admin',
permissions: ['*'],
};
login(mockUser, 'mock-token', 'mock-refresh-token');
navigate('/dashboard');
} catch (err) {
setError('Credenciales invalidas');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100 px-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-diesel-500">
<Wrench className="h-8 w-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Mecanicas Diesel</h1>
<p className="text-sm text-gray-500">Sistema de gestion de taller</p>
</div>
{/* Form */}
<div className="rounded-xl bg-white p-8 shadow-lg">
<h2 className="mb-6 text-xl font-semibold text-gray-900">
Iniciar Sesion
</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Email */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Correo electronico
</label>
<input
{...register('email')}
type="email"
id="email"
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
placeholder="usuario@taller.com"
/>
{errors.email && (
<p className="mt-1 text-xs text-red-600">{errors.email.message}</p>
)}
</div>
{/* Password */}
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Contrasena
</label>
<div className="relative mt-1">
<input
{...register('password')}
type={showPassword ? 'text' : 'password'}
id="password"
className="block w-full rounded-lg border border-gray-300 px-4 py-2.5 pr-10 text-sm focus:border-diesel-500 focus:outline-none focus:ring-1 focus:ring-diesel-500"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-xs text-red-600">
{errors.password.message}
</p>
)}
</div>
{/* Forgot password */}
<div className="flex items-center justify-end">
<a
href="#"
className="text-sm text-diesel-600 hover:text-diesel-700"
>
Olvidaste tu contrasena?
</a>
</div>
{/* Submit */}
<button
type="submit"
disabled={isLoading}
className="w-full rounded-lg bg-diesel-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-diesel-700 focus:outline-none focus:ring-2 focus:ring-diesel-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? 'Ingresando...' : 'Ingresar'}
</button>
</form>
</div>
{/* Footer */}
<p className="mt-6 text-center text-xs text-gray-500">
ERP Mecanicas Diesel - Sistema NEXUS
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
import { api } from './client';
import type { User, ApiResponse } from '../../types';
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
user: User;
token: string;
refreshToken: string;
}
export interface RegisterRequest {
email: string;
password: string;
full_name: string;
taller_name?: string;
}
export const authApi = {
login: (data: LoginRequest) =>
api.post<ApiResponse<LoginResponse>>('/auth/login', data),
register: (data: RegisterRequest) =>
api.post<ApiResponse<LoginResponse>>('/auth/register', data),
logout: () =>
api.post<ApiResponse<null>>('/auth/logout'),
refreshToken: (refreshToken: string) =>
api.post<ApiResponse<{ token: string }>>('/auth/refresh', { refreshToken }),
getProfile: () =>
api.get<ApiResponse<User>>('/auth/profile'),
changePassword: (currentPassword: string, newPassword: string) =>
api.post<ApiResponse<null>>('/auth/change-password', {
currentPassword,
newPassword,
}),
};

View File

@ -0,0 +1,84 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios';
import { useAuthStore } from '../../store/authStore';
// API Base URL
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3041/api/v1';
// Create axios instance
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - add auth token
apiClient.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - handle errors
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
// Handle 401 - Unauthorized
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = useAuthStore.getState().refreshToken;
if (refreshToken) {
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
refreshToken,
});
const { token } = response.data.data;
useAuthStore.getState().setToken(token);
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return apiClient(originalRequest);
}
} catch (refreshError) {
// Refresh failed, logout user
useAuthStore.getState().logout();
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default apiClient;
// Generic API methods
export const api = {
get: <T>(url: string, config?: AxiosRequestConfig) =>
apiClient.get<T>(url, config).then((res) => res.data),
post: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.post<T>(url, data, config).then((res) => res.data),
put: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.put<T>(url, data, config).then((res) => res.data),
patch: <T>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
apiClient.patch<T>(url, data, config).then((res) => res.data),
delete: <T>(url: string, config?: AxiosRequestConfig) =>
apiClient.delete<T>(url, config).then((res) => res.data),
};

View File

@ -0,0 +1,124 @@
import { api } from './client';
import type { ApiResponse, PaginatedResult, BaseFilters, ServiceOrderStatus } from '../../types';
// Types
export interface ServiceOrder {
id: string;
tenant_id: string;
order_number: string;
customer_id: string;
customer_name: string;
vehicle_id: string;
vehicle_info: string;
status: ServiceOrderStatus;
priority: 'low' | 'medium' | 'high' | 'urgent';
received_at: string;
promised_at: string | null;
mechanic_id: string | null;
mechanic_name: string | null;
bay_id: string | null;
bay_name: string | null;
symptoms: string;
notes: string | null;
labor_total: number;
parts_total: number;
tax: number;
grand_total: number;
created_at: string;
updated_at: string | null;
}
export interface ServiceOrderItem {
id: string;
order_id: string;
item_type: 'service' | 'part';
service_id: string | null;
part_id: string | null;
description: string;
quantity: number;
unit_price: number;
discount: number;
subtotal: number;
actual_hours: number | null;
performed_by: string | null;
}
export interface CreateServiceOrderRequest {
customer_id: string;
vehicle_id: string;
symptoms: string;
priority?: 'low' | 'medium' | 'high' | 'urgent';
promised_at?: string;
mechanic_id?: string;
bay_id?: string;
}
export interface ServiceOrderFilters extends BaseFilters {
status?: ServiceOrderStatus;
priority?: string;
mechanic_id?: string;
bay_id?: string;
customer_id?: string;
vehicle_id?: string;
}
// API
export const serviceOrdersApi = {
// List orders with filters
list: (filters?: ServiceOrderFilters) =>
api.get<ApiResponse<PaginatedResult<ServiceOrder>>>('/service-orders', {
params: filters,
}),
// Get single order
getById: (id: string) =>
api.get<ApiResponse<ServiceOrder>>(`/service-orders/${id}`),
// Create new order
create: (data: CreateServiceOrderRequest) =>
api.post<ApiResponse<ServiceOrder>>('/service-orders', data),
// Update order
update: (id: string, data: Partial<ServiceOrder>) =>
api.patch<ApiResponse<ServiceOrder>>(`/service-orders/${id}`, data),
// Change status
changeStatus: (id: string, status: ServiceOrderStatus, notes?: string) =>
api.post<ApiResponse<ServiceOrder>>(`/service-orders/${id}/status`, {
status,
notes,
}),
// Assign mechanic and bay
assign: (id: string, mechanicId: string, bayId: string) =>
api.post<ApiResponse<ServiceOrder>>(`/service-orders/${id}/assign`, {
mechanic_id: mechanicId,
bay_id: bayId,
}),
// Get order items
getItems: (orderId: string) =>
api.get<ApiResponse<ServiceOrderItem[]>>(`/service-orders/${orderId}/items`),
// Add item to order
addItem: (orderId: string, item: Partial<ServiceOrderItem>) =>
api.post<ApiResponse<ServiceOrderItem>>(`/service-orders/${orderId}/items`, item),
// Remove item from order
removeItem: (orderId: string, itemId: string) =>
api.delete<ApiResponse<null>>(`/service-orders/${orderId}/items/${itemId}`),
// Close order
close: (id: string, finalOdometer: number) =>
api.post<ApiResponse<ServiceOrder>>(`/service-orders/${id}/close`, {
final_odometer: finalOdometer,
}),
// Get kanban view data
getKanbanView: () =>
api.get<ApiResponse<Record<ServiceOrderStatus, ServiceOrder[]>>>('/service-orders/kanban'),
// Get order history
getHistory: (vehicleId: string) =>
api.get<ApiResponse<ServiceOrder[]>>(`/vehicles/${vehicleId}/service-history`),
};

View File

@ -0,0 +1,56 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User, AuthState } from '../types';
interface AuthStore extends AuthState {
// Actions
login: (user: User, token: string, refreshToken: string) => void;
logout: () => void;
updateUser: (user: Partial<User>) => void;
setToken: (token: string) => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
// Initial state
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
// Actions
login: (user, token, refreshToken) =>
set({
user,
token,
refreshToken,
isAuthenticated: true,
}),
logout: () =>
set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
}),
updateUser: (userData) =>
set((state) => ({
user: state.user ? { ...state.user, ...userData } : null,
})),
setToken: (token) => set({ token }),
}),
{
name: 'mecanicas-auth-storage',
partialize: (state) => ({
token: state.token,
refreshToken: state.refreshToken,
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
}
)
);

View File

@ -0,0 +1,70 @@
import { create } from 'zustand';
// Types
export interface Taller {
id: string;
name: string;
legal_name: string;
rfc: string;
address: string;
phone: string;
email: string;
logo_url?: string;
}
export interface WorkBay {
id: string;
name: string;
bay_type: 'general' | 'diesel' | 'heavy_duty';
status: 'available' | 'occupied' | 'maintenance';
current_order_id?: string;
}
export interface TallerState {
currentTaller: Taller | null;
selectedBay: WorkBay | null;
workBays: WorkBay[];
isLoading: boolean;
error: string | null;
}
interface TallerStore extends TallerState {
setTaller: (taller: Taller) => void;
setSelectedBay: (bay: WorkBay | null) => void;
setWorkBays: (bays: WorkBay[]) => void;
updateBayStatus: (bayId: string, status: WorkBay['status']) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
reset: () => void;
}
const initialState: TallerState = {
currentTaller: null,
selectedBay: null,
workBays: [],
isLoading: false,
error: null,
};
export const useTallerStore = create<TallerStore>((set) => ({
...initialState,
setTaller: (taller) => set({ currentTaller: taller }),
setSelectedBay: (bay) => set({ selectedBay: bay }),
setWorkBays: (bays) => set({ workBays: bays }),
updateBayStatus: (bayId, status) =>
set((state) => ({
workBays: state.workBays.map((bay) =>
bay.id === bayId ? { ...bay, status } : bay
),
})),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
reset: () => set(initialState),
}));

View File

@ -0,0 +1,114 @@
// =============================================================================
// TIPOS BASE - ERP MECANICAS DIESEL
// =============================================================================
// Tipos de paginacion
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface PaginationParams {
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
// Tipos de filtros
export interface BaseFilters extends PaginationParams {
search?: string;
status?: string;
dateFrom?: string;
dateTo?: string;
}
// Tipos de auditoria
export interface AuditFields {
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
deleted_at?: string | null;
deleted_by?: string | null;
}
// Tipo base para entidades
export interface BaseEntity extends AuditFields {
id: string;
tenant_id: string;
}
// Tipos de usuario y auth
export interface User {
id: string;
email: string;
full_name: string;
avatar_url?: string;
role: string;
permissions: string[];
}
export interface AuthState {
user: User | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
}
// Tipos de API response
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface ApiError {
success: false;
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
}
// Status de ordenes de servicio
export type ServiceOrderStatus =
| 'received'
| 'diagnosing'
| 'quoted'
| 'approved'
| 'in_repair'
| 'waiting_parts'
| 'ready'
| 'delivered'
| 'cancelled';
// Tipos de diagnostico
export type DiagnosticType =
| 'obd_scanner'
| 'injector_bench'
| 'pump_bench'
| 'measurements';
// Tipos de movimiento de inventario
export type MovementType =
| 'purchase'
| 'sale'
| 'transfer'
| 'adjustment'
| 'return'
| 'production';
// Status de cotizacion
export type QuoteStatus =
| 'draft'
| 'sent'
| 'viewed'
| 'approved'
| 'rejected'
| 'expired'
| 'converted';

View File

@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
diesel: {
50: '#fef3c7',
100: '#fde68a',
200: '#fcd34d',
300: '#fbbf24',
400: '#f59e0b',
500: '#d97706',
600: '#b45309',
700: '#92400e',
800: '#78350f',
900: '#451a03',
}
}
},
},
plugins: [],
}

Some files were not shown because too many files have changed in this diff Show More