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 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 ### Para trabajar en un proyecto existente
```bash ```bash
cd ~/workspace/projects/<proyecto> cd ~/workspace/projects/<proyecto>
@ -82,6 +105,21 @@ cat orchestration/00-guidelines/CONTEXTO-PROYECTO.md
./devtools/scripts/bootstrap-project.sh <nombre> <tipo> ./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 ## Documentación
- **Sistema de Agentes:** `core/orchestration/README.md` - **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 - Módulos de código reutilizables
- Estándares técnicos y de negocio - Estándares técnicos y de negocio
- Directivas globales para agentes/subagentes - Directivas globales para agentes/subagentes
- Constantes y tipos universales
## Estructura ## Estructura
``` ```
core/ core/
├── orchestration/ # Sistema de agentes unificado ├── modules/ # Código compartido ejecutable
│ ├── agents/ # Prompts de agentes NEXUS │ ├── utils/ # Utilidades universales ✅
│ ├── directivas/ # Directivas globales (33+) │ │ ├── date.util.ts # Manipulación de fechas
│ ├── templates/ # Templates para subagentes │ │ ├── string.util.ts # Manipulación de strings
│ ├── referencias/ # Contextos y paths globales │ │ ├── validation.util.ts # Validaciones
│ └── claude/ # Configuración Claude Code │ │ └── index.ts
│ ├── auth/ # Autenticación (por implementar)
│ ├── billing/ # Facturación
│ ├── notifications/ # Notificaciones
│ ├── payments/ # Pagos
│ └── multitenant/ # Multi-tenancy
├── modules/ # Módulos de código reutilizables ├── constants/ # Constantes globales ✅
│ ├── auth/ # Autenticación/autorización │ ├── enums.constants.ts # Enums universales
│ ├── billing/ # Facturación y billing │ ├── regex.constants.ts # Patrones regex
│ ├── payments/ # Integración de pagos │ └── index.ts
│ ├── notifications/ # Sistema de notificaciones
│ └── multitenant/ # Soporte multi-tenant
└── standards/ # Estándares técnicos globales ├── 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 ├── CODING-STANDARDS.md
├── TESTING-STANDARDS.md ├── TESTING-STANDARDS.md
├── API-STANDARDS.md └── ...
└── DATABASE-STANDARDS.md
``` ```
## Uso ## Uso
### Sistema de Orquestación ### Importar Utilidades
Los agentes cargan automáticamente las directivas de `core/orchestration/directivas/` al inicializar. Cada proyecto puede extender (pero no reducir) estas directivas.
### Módulos Reutilizables ```typescript
Los módulos en `core/modules/` son dependencias compartidas que pueden ser importadas por cualquier proyecto. // En cualquier proyecto del workspace
import { formatDate, slugify, isEmail } from '@core/modules/utils';
### Estándares // O importar específico
Los estándares en `core/standards/` definen los mínimos de calidad para todos los proyectos. 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 ## Ver También
- [Sistema de Orquestación](orchestration/README.md) - [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 # DevTools - Herramientas de Desarrollo
## Descripción Development tools, configurations, and scripts for the ISEM workspace.
Este directorio contiene scripts, templates y configuraciones para automatizar tareas comunes del workspace.
## Estructura ## Estructura
``` ```
devtools/ devtools/
├── scripts/ # Scripts de automatización ├── configs/ # Shared configurations
│ ├── bootstrap-project.sh # Crear nuevo proyecto │ ├── eslint.config.base.js # ESLint base configuration
│ ├── validate-structure.sh # Validar estructura │ ├── prettier.config.js # Prettier configuration
│ └── ... │ ├── tsconfig.base.json # TypeScript base configuration
├── templates/ # Templates reutilizables │ └── jest.config.base.js # Jest base configuration
│ ├── project-template/ # Template de proyecto ├── docker/ # Docker utilities
│ └── customer-template/ # Template de cliente │ └── postgres-init/ # PostgreSQL initialization scripts
└── docker/ # Configuración Docker ├── scripts/ # Development scripts
├── docker-compose.dev.yml │ ├── dev.sh # Main development helper
└── Dockerfiles/ │ ├── bootstrap-project.sh # Crear nuevo proyecto
│ └── validate-structure.sh # Validar estructura
└── templates/ # Project templates
``` ```
## Scripts Disponibles ## 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 ### bootstrap-project.sh
Crea un nuevo proyecto con estructura estándar. Crea un nuevo proyecto con estructura estándar.
@ -70,22 +97,64 @@ Template para implementaciones de clientes. Contiene:
- Archivos de personalización - Archivos de personalización
- Documentación del cliente - Documentación del cliente
## Docker ## Configuraciones Compartidas
### docker-compose.dev.yml ### ESLint (eslint.config.js)
```javascript
Configuración Docker Compose para desarrollo local: import baseConfig from '../../../devtools/configs/eslint.config.base.js';
- PostgreSQL export default [...baseConfig];
- 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
``` ```
### 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 # INVENTARIO COMPLETO DE OBJETOS DE BASE DE DATOS
# ERP GENERIC - PostgreSQL 15+ # 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 # 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: prerequisites:
@ -1711,16 +1712,16 @@ system:
views: [] views: []
# ============================================================================ # ============================================================================
# RESUMEN DE INVENTARIO # RESUMEN DE INVENTARIO (ACTUALIZADO 2025-12-09)
# ============================================================================ # ============================================================================
summary: summary:
total_schemas: 9 total_schemas: 12
total_enums: 35 total_enums: 49
total_tables: 97 total_tables: 144
total_functions: 43 total_functions: 52
total_triggers: 78 total_triggers: 95
total_indexes: 200+ total_indexes: 350+
total_rls_policies: 50+ total_rls_policies: 80+
total_views: 8 total_views: 8
schemas: schemas:
@ -1729,11 +1730,11 @@ summary:
functions: 9 functions: 9
types: 2 types: 2
- name: auth - name: auth
tables: 10 tables: 26 # 10 (auth.sql) + 16 (auth-extensions.sql)
functions: 7 functions: 10
enums: 4 enums: 4
- name: core - name: core
tables: 11 tables: 12
functions: 3 functions: 3
enums: 4 enums: 4
- name: analytics - name: analytics
@ -1741,12 +1742,12 @@ summary:
functions: 4 functions: 4
enums: 3 enums: 3
- name: financial - name: financial
tables: 14 tables: 15
functions: 4 functions: 4
enums: 10 enums: 10
- name: inventory - name: inventory
tables: 10 tables: 20 # 10 (inventory.sql) + 10 (inventory-extensions.sql)
functions: 5 functions: 8
enums: 6 enums: 6
- name: purchase - name: purchase
tables: 8 tables: 8
@ -1764,7 +1765,405 @@ summary:
tables: 13 tables: 13
functions: 4 functions: 4
enums: 7 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 # 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: herencia_core:
base_de_datos: erp-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: schemas_heredados:
- auth - nombre: auth
- core tablas: 26 # Autenticación, MFA, OAuth, API Keys
- inventory - nombre: core
- sales tablas: 12 # Partners (pacientes), catálogos, UoM
- financial - nombre: financial
tablas_heredadas: 120+ tablas: 15 # Contabilidad, facturas, pagos
referencia: "apps/erp-core/database/" - 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: schemas_especificos:
- nombre: clinica - nombre: clinica

View File

@ -11,19 +11,21 @@ proyecto:
path: /home/isem/workspace/projects/erp-suite/apps/verticales/clinicas path: /home/isem/workspace/projects/erp-suite/apps/verticales/clinicas
herencia: herencia:
core_version: "0.6.0" core_version: "0.6.0"
tablas_heredadas: 97 tablas_heredadas: 144
schemas_heredados: 12
specs_aplicables: 22 specs_aplicables: 22
specs_implementadas: 0 specs_implementadas: 0
resumen_general: resumen_general:
total_modulos: 12 total_modulos: 12
total_schemas_planificados: 4 total_schemas_planificados: 1
total_tablas_planificadas: 45 total_tablas_planificadas: 13
total_tablas_implementadas: 13
total_servicios_backend: 0 total_servicios_backend: 0
total_componentes_frontend: 0 total_componentes_frontend: 0
story_points_estimados: 451 story_points_estimados: 451
test_coverage: N/A test_coverage: N/A
ultima_actualizacion: 2025-12-08 ultima_actualizacion: 2025-12-09
modulos: modulos:
total: 12 total: 12
@ -161,9 +163,15 @@ specs_core:
capas: capas:
database: database:
inventario: DATABASE_INVENTORY.yml inventario: DATABASE_INVENTORY.yml
schemas_planificados: [clinical, pharmacy, laboratory, imaging] schemas_implementados: [clinical]
tablas_planificadas: 45 tablas_implementadas: 13
estado: PLANIFICADO 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: backend:
inventario: BACKEND_INVENTORY.yml inventario: BACKEND_INVENTORY.yml

View File

@ -1,7 +1,7 @@
# Referencia de Base de Datos - ERP Construcción # Referencia de Base de Datos - ERP Construcción
**Fecha:** 2025-12-08 **Fecha:** 2025-12-09
**Versión:** 1.1 **Versión:** 1.2
**Proyecto:** ERP Construcción **Proyecto:** ERP Construcción
**Nivel:** 2B.2 (Proyecto Independiente) **Nivel:** 2B.2 (Proyecto Independiente)
@ -40,12 +40,21 @@ ERP Construcción es un **proyecto independiente** que implementa y adapta patro
│ │ │ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │construction │ │ hr │ │ hse │ │ │ │construction │ │ hr │ │ hse │ │
│ │ 2 tbl │ │ 3 tbl │ │ 28 tbl │ │ │ │ 24 tbl │ │ 8 tbl │ │ 58 tbl │ │
│ │ (proyectos) │ │ (empleados) │ │ (seguridad) │ │ │ │ (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 ## 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 ```sql
-- Extiende: projects schema del core -- DDL: 01-construction-schema-ddl.sql
-- Relaciones: -- Estructura de proyecto (8 tablas):
-- proyectos -> core.partners (cliente) -- fraccionamientos, etapas, manzanas, lotes, torres, niveles, departamentos, prototipos
-- fraccionamientos -> proyectos -- Presupuestos y Conceptos (3 tablas):
-- fraccionamientos usa PostGIS para ubicación -- conceptos, presupuestos, presupuesto_partidas
-- Programación y Avances (5 tablas):
construccion.proyectos -- programa_obra, programa_actividades, avances_obra, fotos_avance, bitacora_obra
construccion.fraccionamientos -- 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 ```sql
-- DDL: 02-hr-schema-ddl.sql
-- Extiende: hr schema del core -- Extiende: hr schema del core
-- Adiciona campos específicos de construcción:
-- CURP, NSS, nivel_riesgo, capacitaciones
hr.employees (extendido) hr.employee_construction -- Extensión empleados construcción
hr.puestos hr.asistencias -- Registro con GPS/biométrico
hr.employee_fraccionamientos 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 **Propósito:** Health, Safety & Environment
```sql ```sql
-- Nuevo schema específico de construcción -- DDL: 03-hse-schema-ddl.sql
-- Implementa 8 requerimientos funcionales (RF-MAA017-001 a 008) -- Implementa 8 requerimientos funcionales (RF-MAA017-001 a 008)
Grupos de tablas: Grupos de tablas:
@ -111,12 +127,77 @@ Grupos de tablas:
- Control de Capacitaciones (6 tablas) - Control de Capacitaciones (6 tablas)
- Inspecciones de Seguridad (7 tablas) - Inspecciones de Seguridad (7 tablas)
- Control de EPP (7 tablas) - Control de EPP (7 tablas)
- Cumplimiento STPS (10 tablas) - Cumplimiento STPS (11 tablas)
- Gestión Ambiental (8 tablas) - Gestión Ambiental (9 tablas)
- Permisos de Trabajo (8 tablas) - Permisos de Trabajo (8 tablas)
- Indicadores HSE (7 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 ## ORDEN DE EJECUCIÓN DDL
@ -128,13 +209,19 @@ Para recrear la base de datos completa:
cd apps/erp-core/database cd apps/erp-core/database
./scripts/reset-database.sh --force ./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 cd apps/verticales/construccion/database
psql $DATABASE_URL -f schemas/01-construction-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 psql $DATABASE_URL -f schemas/02-hr-schema-ddl.sql # 8 tablas
psql $DATABASE_URL -f schemas/03-hse-schema-ddl.sql 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 ## DEPENDENCIAS CRUZADAS
@ -271,4 +358,5 @@ Según el [MAPEO-SPECS-VERTICALES.md](../../../../erp-core/docs/04-modelado/MAPE
--- ---
**Documento de herencia oficial** **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 -- CONSTRUCTION Schema DDL - Gestión de Obras (COMPLETO)
-- Modulo: MAA-001 a MAA-006 (Fundamentos de Construccion) -- Modulos: MAI-002, MAI-003, MAI-005, MAI-009, MAI-012
-- Version: 1.0.0 -- Version: 2.0.0
-- Fecha: 2025-12-06 -- Fecha: 2025-12-08
-- ============================================================================ -- ============================================================================
-- POLITICA: CARGA LIMPIA (ver DIRECTIVA-POLITICA-CARGA-LIMPIA.md) -- POLITICA: CARGA LIMPIA (ver DIRECTIVA-POLITICA-CARGA-LIMPIA.md)
-- Este archivo es parte de la fuente de verdad DDL. -- Este archivo es parte de la fuente de verdad DDL.
-- ============================================================================ -- ============================================================================
-- Verificar que ERP-Core esta instalado -- Verificar que ERP-Core está instalado
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
@ -25,95 +25,879 @@ END $$;
-- Crear schema si no existe -- Crear schema si no existe
CREATE SCHEMA IF NOT EXISTS construction; 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) DO $$ BEGIN
CREATE TABLE IF NOT EXISTS construction.proyectos ( CREATE TYPE construction.project_status AS ENUM (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 'draft', 'planning', 'in_progress', 'paused', 'completed', 'cancelled'
tenant_id UUID NOT NULL REFERENCES auth.tenants(id), );
codigo VARCHAR(20) NOT NULL, EXCEPTION WHEN duplicate_object THEN NULL; END $$;
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.lot_status AS ENUM (
'available', 'reserved', 'sold', 'under_construction', 'delivered', 'warranty'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Tabla: Fraccionamientos (obras dentro de un proyecto) 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 ( CREATE TABLE IF NOT EXISTS construction.fraccionamientos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
proyecto_id UUID NOT NULL REFERENCES construction.proyectos(id), code VARCHAR(20) NOT NULL,
codigo VARCHAR(20) NOT NULL, name VARCHAR(255) NOT NULL,
nombre VARCHAR(200) NOT NULL, description TEXT,
descripcion TEXT, address TEXT,
direccion TEXT, city VARCHAR(100),
ubicacion_geo GEOMETRY(Point, 4326), state VARCHAR(100),
fecha_inicio DATE, zip_code VARCHAR(10),
fecha_fin_estimada DATE, location GEOMETRY(POINT, 4326),
estado VARCHAR(20) NOT NULL DEFAULT 'activo', 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id), 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 -- INDICES
-- ============================================================================ -- ============================================================================
CREATE INDEX IF NOT EXISTS idx_proyectos_tenant ON construction.proyectos(tenant_id); -- Fraccionamientos
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_tenant ON construction.fraccionamientos(tenant_id); CREATE INDEX IF NOT EXISTS idx_fraccionamientos_tenant_id ON construction.fraccionamientos(tenant_id);
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_proyecto ON construction.fraccionamientos(proyecto_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.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 -- Policies de tenant isolation usando current_setting
FOR ALL DO $$ BEGIN
USING (tenant_id = current_setting('app.current_tenant', true)::UUID); 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_id', true)::UUID);
EXCEPTION WHEN undefined_object THEN NULL; END $$;
CREATE POLICY tenant_isolation_fraccionamientos ON construction.fraccionamientos DO $$ BEGIN
FOR ALL DROP POLICY IF EXISTS tenant_isolation_etapas ON construction.etapas;
USING (tenant_id = current_setting('app.current_tenant', true)::UUID); 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 $$;
-- ============================================================================ DO $$ BEGIN
-- TRIGGERS 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_proyectos_updated_at DO $$ BEGIN
BEFORE UPDATE ON construction.proyectos DROP POLICY IF EXISTS tenant_isolation_lotes ON construction.lotes;
FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at(); 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 $$;
CREATE TRIGGER trg_fraccionamientos_updated_at DO $$ BEGIN
BEFORE UPDATE ON construction.fraccionamientos DROP POLICY IF EXISTS tenant_isolation_torres ON construction.torres;
FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at(); 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 -- COMENTARIOS
-- ============================================================================ -- ============================================================================
COMMENT ON TABLE construction.proyectos IS 'Proyectos de desarrollo inmobiliario'; COMMENT ON SCHEMA construction IS 'Schema de construcción: obras, lotes, avances, calidad, contratos';
COMMENT ON TABLE construction.fraccionamientos IS 'Fraccionamientos/obras dentro de un proyecto'; 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 # DATABASE INVENTORY - ERP CONSTRUCCION
# ============================================================================= # =============================================================================
# Version: 1.1.0 # Version: 1.3.0
# Ultima actualizacion: 2025-12-06 # Ultima actualizacion: 2025-12-09
# Proposito: Inventario canonico de objetos de base de datos # Proposito: Inventario canonico de objetos de base de datos
# Nomenclatura: Ver NAMING-CONVENTIONS.md # Nomenclatura: Ver NAMING-CONVENTIONS.md
# ============================================================================= # =============================================================================
metadata: metadata:
proyecto: ERP Construccion proyecto: ERP Construccion
version: 2.0.0 version: 2.1.0
fecha_actualizacion: 2025-12-08 fecha_actualizacion: 2025-12-09
motor: PostgreSQL 15+ motor: PostgreSQL 15+
extensiones: [uuid-ossp, pg_trgm, btree_gist, pgcrypto, postgis] extensiones: [uuid-ossp, pg_trgm, btree_gist, pgcrypto, postgis]
@ -18,41 +18,41 @@ metadata:
# HERENCIA DE ERP CORE # HERENCIA DE ERP CORE
# ============================================================================= # =============================================================================
herencia_core: herencia_core:
version_core: "1.1.0" version_core: "1.2.0"
tablas_heredadas: 124 # Total de tablas del core tablas_heredadas: 144 # Total de tablas del core (actualizado 2025-12-09)
schemas_heredados: schemas_heredados:
- auth: 26 # Autenticación, MFA, OAuth, API Keys - auth: 26 # 10 (auth.sql) + 16 (auth-extensions.sql)
- core: 12 # Partners, catálogos, monedas - core: 12 # Partners, catálogos, monedas, UoM
- financial: 15 # Contabilidad, facturas - financial: 15 # Contabilidad, facturas, pagos
- inventory: 15 # Productos, stock, valoración - inventory: 20 # 10 (inventory.sql) + 10 (inventory-extensions.sql)
- purchase: 8 # Compras - purchase: 8 # Órdenes de compra, proveedores
- sales: 6 # Ventas - sales: 10 # Órdenes de venta, clientes
- projects: 5 # Base proyectos - projects: 10 # Proyectos, tareas, dependencias
- hr: 6 # RRHH base - hr: 6 # RRHH base, empleados
- analytics: 5 # Centros de costo - analytics: 7 # Centros de costo, cuentas analíticas
- system: 10 # Mensajes, notificaciones - system: 13 # Mensajes, notificaciones, logs
- billing: 11 # SaaS (opcional) - billing: 11 # SaaS multi-tenant (opcional)
- crm: 5 # CRM (opcional) - crm: 6 # CRM leads, opportunities (opcional)
referencia: "apps/erp-core/database/ddl/" referencia: "apps/erp-core/database/ddl/"
documento_herencia: "../database/HERENCIA-ERP-CORE.md" documento_herencia: "../database/HERENCIA-ERP-CORE.md"
# ============================================================================= # =============================================================================
# RESUMEN DE OBJETOS (ACTUALIZADO 2025-12-08) # RESUMEN DE OBJETOS (ACTUALIZADO 2025-12-09)
# ============================================================================= # =============================================================================
resumen: resumen:
schemas_core: 12 # Heredados de erp-core schemas_core: 12 # Heredados de erp-core
schemas_especificos: 3 # construccion, hr (ext), hse schemas_especificos: 7 # construction, hr, hse, estimates, infonavit, inventory-ext, purchase-ext
tablas_heredadas: 124 # Del core tablas_heredadas: 144 # Del core (actualizado 2025-12-09)
tablas_especificas: 33 # 2 construccion + 3 hr + 28 hse tablas_especificas: 110 # 24 construction + 8 hr + 58 hse + 8 estimates + 8 infonavit + 4 inventory + 5 purchase
tablas_total: 157 # 124 + 33 tablas_total: 254 # 144 + 110
enums: 89 # 22 base + 67 HSE enums: 89 # 22 base + 67 HSE
funciones: 13 funciones: 13
triggers: 15 triggers: 15
rls_policies: 157 # 1 policy por tabla con tenant_id rls_policies: 254 # 1 policy por tabla con tenant_id (144 core + 110 construcción)
indices: 250+ indices: 350+
estado_implementacion: estado_implementacion:
database_core: "100%" # ERP Core validado con carga limpia 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 backend: "5%" # Solo entidades base
frontend: "2%" # Solo estructura frontend: "2%" # Solo estructura
ddl_files_core: ddl_files_core:
@ -72,9 +72,13 @@ resumen:
- "erp-core/database/ddl/11-crm.sql" - "erp-core/database/ddl/11-crm.sql"
- "erp-core/database/ddl/12-hr.sql" - "erp-core/database/ddl/12-hr.sql"
ddl_files_extension: ddl_files_extension:
- schemas/01-construction-schema-ddl.sql - schemas/01-construction-schema-ddl.sql # 24 tablas
- schemas/02-hr-schema-ddl.sql - schemas/02-hr-schema-ddl.sql # 8 tablas
- schemas/03-hse-schema-ddl.sql - 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 # SCHEMAS - NOMENCLATURA UNIFICADA
@ -1422,25 +1426,43 @@ schemas_deprecados:
- documents_management: "usar 'documents' (pendiente)" - documents_management: "usar 'documents' (pendiente)"
# ============================================================================= # =============================================================================
# VALIDACION DDL (2025-12-08) # VALIDACION DDL (2025-12-09)
# ============================================================================= # =============================================================================
validacion_ddl: validacion_ddl:
fecha: "2025-12-08" fecha: "2025-12-09"
estado: "✅ CORREGIDO" estado: "✅ COMPLETO - 7 schemas, 110 tablas"
total_correcciones: 50 total_archivos_ddl: 7
archivos_corregidos: archivos_ddl:
- archivo: "schemas/01-construction-schema-ddl.sql" - archivo: "schemas/01-construction-schema-ddl.sql"
correcciones: 4 tablas: 24
detalle: "core.tenants → auth.tenants, core.users → auth.users" estado: "implementado"
- archivo: "schemas/02-hr-schema-ddl.sql" - archivo: "schemas/02-hr-schema-ddl.sql"
correcciones: 4 tablas: 8
detalle: "core.tenants → auth.tenants, core.users → auth.users" estado: "implementado"
- archivo: "schemas/03-hse-schema-ddl.sql" - archivo: "schemas/03-hse-schema-ddl.sql"
correcciones: 42 tablas: 58
detalle: "Todas las FK corregidas a auth.*" 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: verificaciones_prerequisitos:
- "DDL verifica existencia de auth.tenants" - "DDL verifica existencia de auth.tenants"
- "DDL verifica existencia de auth.users" - "DDL verifica existencia de auth.users"
- "DDL verifica existencia de schemas dependientes"
- "ERP-Core debe estar instalado antes de ejecutar DDL" - "ERP-Core debe estar instalado antes de ejecutar DDL"
compatible_erp_core: true compatible_erp_core: true
@ -1450,11 +1472,11 @@ validacion_ddl:
metadata: metadata:
creado_por: Requirements-Analyst creado_por: Requirements-Analyst
fecha_creacion: 2025-12-06 fecha_creacion: 2025-12-06
ultima_actualizacion: 2025-12-08 ultima_actualizacion: 2025-12-09
version_documento: 1.2.0 version_documento: 1.3.0
cambios_version: 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: 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: 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 # MASTER INVENTORY - ERP CONSTRUCCION
# ============================================================================= # =============================================================================
# Ultima actualizacion: 2025-12-06 # Ultima actualizacion: 2025-12-09
# SSOT: Single Source of Truth para metricas del proyecto vertical # SSOT: Single Source of Truth para metricas del proyecto vertical
# Base: Extiende erp-core (61% reutilizacion) # Base: Extiende erp-core (61% reutilizacion)
# Nomenclatura: Ver NAMING-CONVENTIONS.md # Nomenclatura: Ver NAMING-CONVENTIONS.md
@ -57,7 +57,7 @@ metricas:
fase_2_mae: 3 fase_2_mae: 3
fase_3_maa: 1 fase_3_maa: 1
documentados: 18 # Todos documentados incluyendo MAA-017 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) backend_parcial: 4 # construction, hr, hse, core (entidades básicas)
requerimientos: requerimientos:
@ -77,15 +77,15 @@ metricas:
story_points: 692 # +42 de MAA-017 story_points: 692 # +42 de MAA-017
database: database:
# Conteo real basado en DDL files (actualizado 2025-12-08) # Conteo real basado en DDL files (actualizado 2025-12-09)
schemas_implementados: 3 # construction, hr, hse schemas_implementados: 7 # construction, hr, hse, estimates, infonavit, inventory, purchase
schemas_pendientes: 4 # estimates, infonavit, inventory-ext, purchase-ext schemas_pendientes: 0 # Todos los schemas de Fase 1 implementados
tablas_implementadas: 33 # 2 construction + 3 hr + 28 hse tablas_implementadas: 110 # 24 construction + 8 hr + 58 hse + 8 estimates + 8 infonavit + 4 inventory + 5 purchase
tablas_documentadas: 65 # Total en documentación tablas_documentadas: 110 # Total alineado con DDL
enums: 89 # 22 base + 67 HSE enums: 89 # 22 base + 67 HSE
funciones: 13 funciones: 13
triggers: 15 triggers: 15
rls_policies: 33 # 1 por tabla implementada rls_policies: 110 # 1 por tabla implementada
backend: backend:
# Estado actual del código TypeScript # Estado actual del código TypeScript
@ -767,25 +767,41 @@ proxima_accion:
- Configuracion multi-tenant - Configuracion multi-tenant
# ============================================================================= # =============================================================================
# VALIDACION DDL (2025-12-08) # VALIDACION DDL (2025-12-09)
# ============================================================================= # =============================================================================
validacion_ddl: validacion_ddl:
fecha: "2025-12-08" fecha: "2025-12-09"
estado: "✅ CORREGIDO" estado: "✅ COMPLETO - Alineado con erp-core"
compatible_erp_core: true compatible_erp_core: true
total_correcciones: 50 total_archivos_ddl: 5
archivos_corregidos: ddl_files:
- archivo: "01-construction-schema-ddl.sql" - archivo: "01-construction-schema-ddl.sql"
correcciones: 4 tablas: 24
estado: "implementado"
- archivo: "02-hr-schema-ddl.sql" - archivo: "02-hr-schema-ddl.sql"
correcciones: 4 tablas: 8
estado: "implementado"
- archivo: "03-hse-schema-ddl.sql" - archivo: "03-hse-schema-ddl.sql"
correcciones: 42 tablas: 58
correcciones_aplicadas: estado: "implementado"
- "core.tenants → auth.tenants" - archivo: "04-estimates-schema-ddl.sql"
- "core.users → auth.users" tablas: 8
- "Verificaciones de prerequisitos actualizadas" estado: "implementado"
nota: "DDL ahora compatible con ERP-Core. Requiere ERP-Core instalado." - 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 # METADATA
@ -793,11 +809,12 @@ validacion_ddl:
metadata: metadata:
creado_por: Requirements-Analyst creado_por: Requirements-Analyst
fecha_creacion: 2025-12-06 fecha_creacion: 2025-12-06
ultima_actualizacion: 2025-12-08 ultima_actualizacion: 2025-12-09
version_documento: 1.2.0 version_documento: 1.3.0
cambios_version: 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: Validacion DDL completada - 50 FK corregidas (2025-12-08)"
- "1.2.0: Prerequisitos DDL actualizados para ERP-Core" - "1.2.0: Prerequisitos DDL actualizados para ERP-Core"
- "1.1.0: Nomenclatura de schemas unificada segun NAMING-CONVENTIONS.md" - "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