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
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:
parent
49155822ae
commit
789d1ab46b
290
.github/workflows/ci.yml
vendored
Normal file
290
.github/workflows/ci.yml
vendored
Normal 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
194
.github/workflows/docker-build.yml
vendored
Normal 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
|
||||
276
PLAN-ACTUALIZACIONES-MAYORES.md
Normal file
276
PLAN-ACTUALIZACIONES-MAYORES.md
Normal 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.
|
||||
38
README.md
38
README.md
@ -67,6 +67,29 @@ cd workspace
|
||||
cat SETUP.md
|
||||
```
|
||||
|
||||
### Usar el script de desarrollo
|
||||
```bash
|
||||
# Ver comandos disponibles
|
||||
./devtools/scripts/dev.sh help
|
||||
|
||||
# Ver estado del workspace
|
||||
./devtools/scripts/dev.sh status
|
||||
|
||||
# Iniciar servicios Docker (PostgreSQL, Redis, etc.)
|
||||
./devtools/scripts/dev.sh docker-up
|
||||
|
||||
# Iniciar un proyecto
|
||||
./devtools/scripts/dev.sh start gamilit
|
||||
./devtools/scripts/dev.sh start trading
|
||||
./devtools/scripts/dev.sh start mecanicas
|
||||
|
||||
# Instalar todas las dependencias
|
||||
./devtools/scripts/dev.sh install
|
||||
|
||||
# Ver asignación de puertos
|
||||
./devtools/scripts/dev.sh ports
|
||||
```
|
||||
|
||||
### Para trabajar en un proyecto existente
|
||||
```bash
|
||||
cd ~/workspace/projects/<proyecto>
|
||||
@ -82,6 +105,21 @@ cat orchestration/00-guidelines/CONTEXTO-PROYECTO.md
|
||||
./devtools/scripts/bootstrap-project.sh <nombre> <tipo>
|
||||
```
|
||||
|
||||
## Proyectos Activos
|
||||
|
||||
| Proyecto | Estado | Backend | Frontend |
|
||||
|----------|--------|---------|----------|
|
||||
| **Gamilit** | MVP 60% | NestJS :3000 | React :5173 |
|
||||
| **Trading Platform** | 50% | Express :3001 | React :5174 |
|
||||
| **ERP Suite** | 35% | Express :3010+ | React :5175 |
|
||||
| **Mecánicas Diesel** | MVP 95% | Express :3011 | - |
|
||||
|
||||
## CI/CD
|
||||
|
||||
GitHub Actions configurados en `.github/workflows/`:
|
||||
- **ci.yml** - Lint, test, build por proyecto
|
||||
- **docker-build.yml** - Construcción de imágenes Docker
|
||||
|
||||
## Documentación
|
||||
|
||||
- **Sistema de Agentes:** `core/orchestration/README.md`
|
||||
|
||||
110
core/README.md
110
core/README.md
@ -8,44 +8,112 @@ El directorio `core/` contiene todo lo que se comparte a nivel de **fábrica**,
|
||||
- Módulos de código reutilizables
|
||||
- Estándares técnicos y de negocio
|
||||
- Directivas globales para agentes/subagentes
|
||||
- Constantes y tipos universales
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
core/
|
||||
├── orchestration/ # Sistema de agentes unificado
|
||||
│ ├── agents/ # Prompts de agentes NEXUS
|
||||
│ ├── directivas/ # Directivas globales (33+)
|
||||
│ ├── templates/ # Templates para subagentes
|
||||
│ ├── referencias/ # Contextos y paths globales
|
||||
│ └── claude/ # Configuración Claude Code
|
||||
├── modules/ # Código compartido ejecutable
|
||||
│ ├── utils/ # Utilidades universales ✅
|
||||
│ │ ├── date.util.ts # Manipulación de fechas
|
||||
│ │ ├── string.util.ts # Manipulación de strings
|
||||
│ │ ├── validation.util.ts # Validaciones
|
||||
│ │ └── index.ts
|
||||
│ ├── auth/ # Autenticación (por implementar)
|
||||
│ ├── billing/ # Facturación
|
||||
│ ├── notifications/ # Notificaciones
|
||||
│ ├── payments/ # Pagos
|
||||
│ └── multitenant/ # Multi-tenancy
|
||||
│
|
||||
├── modules/ # Módulos de código reutilizables
|
||||
│ ├── auth/ # Autenticación/autorización
|
||||
│ ├── billing/ # Facturación y billing
|
||||
│ ├── payments/ # Integración de pagos
|
||||
│ ├── notifications/ # Sistema de notificaciones
|
||||
│ └── multitenant/ # Soporte multi-tenant
|
||||
├── constants/ # Constantes globales ✅
|
||||
│ ├── enums.constants.ts # Enums universales
|
||||
│ ├── regex.constants.ts # Patrones regex
|
||||
│ └── index.ts
|
||||
│
|
||||
├── types/ # Tipos TypeScript compartidos ✅
|
||||
│ ├── api.types.ts # Tipos de API
|
||||
│ ├── common.types.ts # Tipos comunes
|
||||
│ └── index.ts
|
||||
│
|
||||
├── catalog/ # Documentación de funcionalidades
|
||||
│ ├── auth/
|
||||
│ ├── notifications/
|
||||
│ └── ...
|
||||
│
|
||||
├── orchestration/ # Sistema de agentes NEXUS
|
||||
│ ├── agents/
|
||||
│ ├── directivas/
|
||||
│ ├── templates/
|
||||
│ └── referencias/
|
||||
│
|
||||
└── standards/ # Estándares técnicos globales
|
||||
├── CODING-STANDARDS.md
|
||||
├── TESTING-STANDARDS.md
|
||||
├── API-STANDARDS.md
|
||||
└── DATABASE-STANDARDS.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
### Sistema de Orquestación
|
||||
Los agentes cargan automáticamente las directivas de `core/orchestration/directivas/` al inicializar. Cada proyecto puede extender (pero no reducir) estas directivas.
|
||||
### Importar Utilidades
|
||||
|
||||
### Módulos Reutilizables
|
||||
Los módulos en `core/modules/` son dependencias compartidas que pueden ser importadas por cualquier proyecto.
|
||||
```typescript
|
||||
// En cualquier proyecto del workspace
|
||||
import { formatDate, slugify, isEmail } from '@core/modules/utils';
|
||||
|
||||
### Estándares
|
||||
Los estándares en `core/standards/` definen los mínimos de calidad para todos los proyectos.
|
||||
// O importar específico
|
||||
import { formatToISO, addDays } from '@core/modules/utils/date.util';
|
||||
```
|
||||
|
||||
### Importar Constantes
|
||||
|
||||
```typescript
|
||||
import { UserStatus, PaymentStatus } from '@core/constants';
|
||||
import { EMAIL_REGEX, UUID_REGEX } from '@core/constants/regex.constants';
|
||||
```
|
||||
|
||||
### Importar Tipos
|
||||
|
||||
```typescript
|
||||
import { ApiResponse, PaginatedResponse } from '@core/types';
|
||||
import { BaseEntity, Address } from '@core/types/common.types';
|
||||
```
|
||||
|
||||
## Módulos Disponibles
|
||||
|
||||
### Utils (`@core/modules/utils`)
|
||||
|
||||
| Archivo | Funciones | Descripción |
|
||||
|---------|-----------|-------------|
|
||||
| `date.util.ts` | formatDate, addDays, diffInDays, etc. | Manipulación de fechas |
|
||||
| `string.util.ts` | slugify, capitalize, truncate, etc. | Manipulación de strings |
|
||||
| `validation.util.ts` | isEmail, isUUID, isStrongPassword, etc. | Validaciones |
|
||||
|
||||
### Constants (`@core/constants`)
|
||||
|
||||
| Archivo | Contenido |
|
||||
|---------|-----------|
|
||||
| `enums.constants.ts` | UserStatus, PaymentStatus, NotificationType, etc. |
|
||||
| `regex.constants.ts` | EMAIL_REGEX, UUID_REGEX, PHONE_REGEX, etc. |
|
||||
|
||||
### Types (`@core/types`)
|
||||
|
||||
| Archivo | Tipos |
|
||||
|---------|-------|
|
||||
| `api.types.ts` | ApiResponse, PaginatedResponse, ErrorCodes |
|
||||
| `common.types.ts` | BaseEntity, Address, Money, Result |
|
||||
|
||||
## Proyectos que Usan Core
|
||||
|
||||
- **Gamilit** - Plataforma educativa de gamificación
|
||||
- **Trading Platform** - OrbiQuant IA trading
|
||||
- **ERP Suite** - Sistema ERP multi-vertical
|
||||
|
||||
## Sistema de Orquestación
|
||||
Los agentes cargan automáticamente las directivas de `core/orchestration/directivas/` al inicializar.
|
||||
|
||||
## Ver También
|
||||
|
||||
- [Sistema de Orquestación](orchestration/README.md)
|
||||
- [Directivas Principales](orchestration/directivas/DIRECTIVAS-PRINCIPALES.md)
|
||||
- [Catálogo de Funcionalidades](catalog/README.md)
|
||||
- [Plan de Organización](../PLAN-ORGANIZACION-WORKSPACE.md)
|
||||
|
||||
29
core/modules/package.json
Normal file
29
core/modules/package.json
Normal 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
317
core/types/api.types.ts
Normal 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
374
core/types/common.types.ts
Normal 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
11
core/types/index.ts
Normal 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';
|
||||
@ -1,27 +1,54 @@
|
||||
# DevTools - Herramientas de Desarrollo
|
||||
|
||||
## Descripción
|
||||
|
||||
Este directorio contiene scripts, templates y configuraciones para automatizar tareas comunes del workspace.
|
||||
Development tools, configurations, and scripts for the ISEM workspace.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
devtools/
|
||||
├── scripts/ # Scripts de automatización
|
||||
├── configs/ # Shared configurations
|
||||
│ ├── eslint.config.base.js # ESLint base configuration
|
||||
│ ├── prettier.config.js # Prettier configuration
|
||||
│ ├── tsconfig.base.json # TypeScript base configuration
|
||||
│ └── jest.config.base.js # Jest base configuration
|
||||
├── docker/ # Docker utilities
|
||||
│ └── postgres-init/ # PostgreSQL initialization scripts
|
||||
├── scripts/ # Development scripts
|
||||
│ ├── dev.sh # Main development helper
|
||||
│ ├── bootstrap-project.sh # Crear nuevo proyecto
|
||||
│ ├── validate-structure.sh # Validar estructura
|
||||
│ └── ...
|
||||
├── templates/ # Templates reutilizables
|
||||
│ ├── project-template/ # Template de proyecto
|
||||
│ └── customer-template/ # Template de cliente
|
||||
└── docker/ # Configuración Docker
|
||||
├── docker-compose.dev.yml
|
||||
└── Dockerfiles/
|
||||
│ └── validate-structure.sh # Validar estructura
|
||||
└── templates/ # Project templates
|
||||
```
|
||||
|
||||
## Scripts Disponibles
|
||||
|
||||
### dev.sh - Script principal de desarrollo
|
||||
|
||||
```bash
|
||||
# Ver comandos disponibles
|
||||
./scripts/dev.sh help
|
||||
|
||||
# Ver estado del workspace
|
||||
./scripts/dev.sh status
|
||||
|
||||
# Iniciar servicios Docker
|
||||
./scripts/dev.sh docker-up
|
||||
|
||||
# Iniciar un proyecto
|
||||
./scripts/dev.sh start gamilit
|
||||
./scripts/dev.sh start trading
|
||||
./scripts/dev.sh start mecanicas
|
||||
|
||||
# Instalar dependencias
|
||||
./scripts/dev.sh install
|
||||
|
||||
# Ver puertos asignados
|
||||
./scripts/dev.sh ports
|
||||
|
||||
# Lint de proyectos
|
||||
./scripts/dev.sh lint gamilit
|
||||
```
|
||||
|
||||
### bootstrap-project.sh
|
||||
|
||||
Crea un nuevo proyecto con estructura estándar.
|
||||
@ -70,22 +97,64 @@ Template para implementaciones de clientes. Contiene:
|
||||
- Archivos de personalización
|
||||
- Documentación del cliente
|
||||
|
||||
## Docker
|
||||
## Configuraciones Compartidas
|
||||
|
||||
### docker-compose.dev.yml
|
||||
|
||||
Configuración Docker Compose para desarrollo local:
|
||||
- PostgreSQL
|
||||
- Redis (opcional)
|
||||
- ChromaDB (para RAG)
|
||||
|
||||
```bash
|
||||
# Levantar servicios
|
||||
cd docker && docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Detener servicios
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
### ESLint (eslint.config.js)
|
||||
```javascript
|
||||
import baseConfig from '../../../devtools/configs/eslint.config.base.js';
|
||||
export default [...baseConfig];
|
||||
```
|
||||
|
||||
### Prettier (.prettierrc.js)
|
||||
```javascript
|
||||
module.exports = require('../../../devtools/configs/prettier.config.js');
|
||||
```
|
||||
|
||||
### TypeScript (tsconfig.json)
|
||||
```json
|
||||
{
|
||||
"extends": "../../../devtools/configs/tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "./dist", "rootDir": "./src" }
|
||||
}
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Docker Compose principal en la raíz del workspace (`/docker-compose.yml`):
|
||||
|
||||
```bash
|
||||
# Levantar todos los servicios
|
||||
docker-compose up -d
|
||||
|
||||
# Ver logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Detener servicios
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
Servicios incluidos:
|
||||
- **PostgreSQL 15** - Base de datos principal (multi-database)
|
||||
- **TimescaleDB** - Series temporales para Trading Platform
|
||||
- **Redis 7** - Cache y sesiones
|
||||
- **MinIO** - Almacenamiento S3-compatible
|
||||
- **Mailhog** - Testing de emails
|
||||
- **Adminer** - UI de administración de BD
|
||||
|
||||
## Asignación de Puertos
|
||||
|
||||
| Proyecto | Servicio | Puerto |
|
||||
|----------|----------|--------|
|
||||
| Gamilit | Backend | 3000 |
|
||||
| Gamilit | Frontend | 5173 |
|
||||
| Trading | Backend | 3001 |
|
||||
| Trading | Frontend | 5174 |
|
||||
| Trading | Data Service | 8001 |
|
||||
| ERP Core | Backend | 3010 |
|
||||
| Mecánicas | Backend | 3011 |
|
||||
| Shared | PostgreSQL | 5432 |
|
||||
| Shared | TimescaleDB | 5433 |
|
||||
| Shared | Redis | 6379 |
|
||||
|
||||
---
|
||||
*DevTools del Workspace de Fábrica de Software*
|
||||
*DevTools del Workspace ISEM*
|
||||
|
||||
60
devtools/configs/eslint.config.base.js
Normal file
60
devtools/configs/eslint.config.base.js
Normal 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',
|
||||
],
|
||||
},
|
||||
];
|
||||
88
devtools/configs/jest.config.base.js
Normal file
88
devtools/configs/jest.config.base.js
Normal 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/',
|
||||
],
|
||||
};
|
||||
68
devtools/configs/prettier.config.js
Normal file
68
devtools/configs/prettier.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
55
devtools/configs/tsconfig.base.json
Normal file
55
devtools/configs/tsconfig.base.json
Normal 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"
|
||||
]
|
||||
}
|
||||
25
devtools/docker/postgres-init/01-create-databases.sh
Executable file
25
devtools/docker/postgres-init/01-create-databases.sh
Executable 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
335
devtools/scripts/dev.sh
Executable 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
145
docker-compose.yml
Normal 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:
|
||||
@ -2,9 +2,10 @@
|
||||
# INVENTARIO COMPLETO DE OBJETOS DE BASE DE DATOS
|
||||
# ERP GENERIC - PostgreSQL 15+
|
||||
# ============================================================================
|
||||
# Fecha: 2025-11-24
|
||||
# Fecha: 2025-12-09 (Actualizado)
|
||||
# Propósito: Inventario exhaustivo de todos los objetos de BD extraídos de DDL
|
||||
# Schemas: auth, core, analytics, financial, inventory, purchase, sales, projects, system
|
||||
# Schemas: auth, core, analytics, financial, inventory, purchase, sales, projects, system, billing, crm, hr
|
||||
# Total: 12 schemas, 144 tablas
|
||||
# ============================================================================
|
||||
|
||||
prerequisites:
|
||||
@ -1711,16 +1712,16 @@ system:
|
||||
views: []
|
||||
|
||||
# ============================================================================
|
||||
# RESUMEN DE INVENTARIO
|
||||
# RESUMEN DE INVENTARIO (ACTUALIZADO 2025-12-09)
|
||||
# ============================================================================
|
||||
summary:
|
||||
total_schemas: 9
|
||||
total_enums: 35
|
||||
total_tables: 97
|
||||
total_functions: 43
|
||||
total_triggers: 78
|
||||
total_indexes: 200+
|
||||
total_rls_policies: 50+
|
||||
total_schemas: 12
|
||||
total_enums: 49
|
||||
total_tables: 144
|
||||
total_functions: 52
|
||||
total_triggers: 95
|
||||
total_indexes: 350+
|
||||
total_rls_policies: 80+
|
||||
total_views: 8
|
||||
|
||||
schemas:
|
||||
@ -1729,11 +1730,11 @@ summary:
|
||||
functions: 9
|
||||
types: 2
|
||||
- name: auth
|
||||
tables: 10
|
||||
functions: 7
|
||||
tables: 26 # 10 (auth.sql) + 16 (auth-extensions.sql)
|
||||
functions: 10
|
||||
enums: 4
|
||||
- name: core
|
||||
tables: 11
|
||||
tables: 12
|
||||
functions: 3
|
||||
enums: 4
|
||||
- name: analytics
|
||||
@ -1741,12 +1742,12 @@ summary:
|
||||
functions: 4
|
||||
enums: 3
|
||||
- name: financial
|
||||
tables: 14
|
||||
tables: 15
|
||||
functions: 4
|
||||
enums: 10
|
||||
- name: inventory
|
||||
tables: 10
|
||||
functions: 5
|
||||
tables: 20 # 10 (inventory.sql) + 10 (inventory-extensions.sql)
|
||||
functions: 8
|
||||
enums: 6
|
||||
- name: purchase
|
||||
tables: 8
|
||||
@ -1764,7 +1765,405 @@ summary:
|
||||
tables: 13
|
||||
functions: 4
|
||||
enums: 7
|
||||
- name: billing
|
||||
tables: 11
|
||||
functions: 3
|
||||
enums: 5
|
||||
- name: crm
|
||||
tables: 6
|
||||
functions: 0
|
||||
enums: 4
|
||||
- name: hr
|
||||
tables: 6
|
||||
functions: 0
|
||||
enums: 5
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMA: billing (NUEVO - SaaS/Multi-tenant)
|
||||
# DDL: 10-billing.sql - 11 tablas
|
||||
# ============================================================================
|
||||
billing:
|
||||
enums:
|
||||
- name: subscription_status
|
||||
values: [trialing, active, past_due, paused, cancelled, suspended, expired]
|
||||
- name: billing_cycle
|
||||
values: [monthly, quarterly, semi_annual, annual]
|
||||
- name: payment_method_type
|
||||
values: [card, bank_transfer, paypal, oxxo, spei, other]
|
||||
- name: invoice_status
|
||||
values: [draft, open, paid, void, uncollectible]
|
||||
- name: payment_status
|
||||
values: [pending, processing, succeeded, failed, cancelled, refunded]
|
||||
|
||||
tables:
|
||||
- name: subscription_plans
|
||||
columns: [id, code, name, description, price_monthly, price_yearly, currency_code, max_users, max_companies, max_storage_gb, max_api_calls_month, features, is_active, is_public, is_default, trial_days, sort_order, created_at, created_by, updated_at, updated_by]
|
||||
foreign_keys: []
|
||||
note: "Planes globales (no por tenant)"
|
||||
|
||||
- name: tenant_owners
|
||||
columns: [id, tenant_id, user_id, ownership_type, billing_email, billing_phone, billing_name, created_at, created_by]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: user_id
|
||||
references: auth.users(id)
|
||||
|
||||
- name: subscriptions
|
||||
columns: [id, tenant_id, plan_id, status, billing_cycle, trial_start_at, trial_end_at, current_period_start, current_period_end, cancelled_at, cancel_at_period_end, paused_at, discount_percent, coupon_code, stripe_subscription_id, stripe_customer_id, created_at, created_by, updated_at, updated_by]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: plan_id
|
||||
references: billing.subscription_plans(id)
|
||||
|
||||
- name: payment_methods
|
||||
columns: [id, tenant_id, type, is_default, card_last_four, card_brand, card_exp_month, card_exp_year, billing_name, billing_email, billing_address_line1, billing_address_line2, billing_city, billing_state, billing_postal_code, billing_country, stripe_payment_method_id, created_at, created_by, updated_at, deleted_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
|
||||
- name: invoices
|
||||
columns: [id, tenant_id, subscription_id, invoice_number, status, period_start, period_end, due_date, paid_at, voided_at, subtotal, tax_amount, discount_amount, total, amount_paid, amount_due, currency_code, customer_name, customer_tax_id, customer_email, customer_address, pdf_url, cfdi_uuid, cfdi_xml_url, stripe_invoice_id, notes, created_at, created_by, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: subscription_id
|
||||
references: billing.subscriptions(id)
|
||||
|
||||
- name: invoice_lines
|
||||
columns: [id, invoice_id, description, quantity, unit_price, amount, period_start, period_end, created_at]
|
||||
foreign_keys:
|
||||
- column: invoice_id
|
||||
references: billing.invoices(id)
|
||||
|
||||
- name: payments
|
||||
columns: [id, tenant_id, invoice_id, payment_method_id, amount, currency_code, status, paid_at, failed_at, refunded_at, failure_reason, failure_code, transaction_id, stripe_payment_intent_id, created_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: invoice_id
|
||||
references: billing.invoices(id)
|
||||
- column: payment_method_id
|
||||
references: billing.payment_methods(id)
|
||||
|
||||
- name: usage_records
|
||||
columns: [id, tenant_id, subscription_id, metric_type, quantity, billing_period, recorded_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: subscription_id
|
||||
references: billing.subscriptions(id)
|
||||
|
||||
- name: coupons
|
||||
columns: [id, code, name, description, discount_type, discount_value, currency_code, max_redemptions, max_redemptions_per_tenant, redemptions_count, valid_from, valid_until, applicable_plans, is_active, created_at, created_by]
|
||||
foreign_keys: []
|
||||
|
||||
- name: coupon_redemptions
|
||||
columns: [id, coupon_id, tenant_id, subscription_id, redeemed_at, redeemed_by]
|
||||
foreign_keys:
|
||||
- column: coupon_id
|
||||
references: billing.coupons(id)
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: subscription_id
|
||||
references: billing.subscriptions(id)
|
||||
|
||||
- name: subscription_history
|
||||
columns: [id, subscription_id, event_type, previous_plan_id, new_plan_id, previous_status, new_status, metadata, notes, created_at, created_by]
|
||||
foreign_keys:
|
||||
- column: subscription_id
|
||||
references: billing.subscriptions(id)
|
||||
- column: previous_plan_id
|
||||
references: billing.subscription_plans(id)
|
||||
- column: new_plan_id
|
||||
references: billing.subscription_plans(id)
|
||||
|
||||
functions:
|
||||
- name: get_tenant_plan
|
||||
purpose: "Obtiene información del plan actual de un tenant"
|
||||
- name: can_add_user
|
||||
purpose: "Verifica si el tenant puede agregar más usuarios según su plan"
|
||||
- name: has_feature
|
||||
purpose: "Verifica si una feature está habilitada para el tenant"
|
||||
|
||||
rls_policies: []
|
||||
note: "Sin RLS - gestionado a nivel aplicación por razones de seguridad"
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMA: crm (NUEVO - Customer Relationship Management)
|
||||
# DDL: 11-crm.sql - 6 tablas
|
||||
# ============================================================================
|
||||
crm:
|
||||
enums:
|
||||
- name: lead_status
|
||||
values: [new, contacted, qualified, converted, lost]
|
||||
- name: opportunity_status
|
||||
values: [open, won, lost]
|
||||
- name: activity_type
|
||||
values: [call, email, meeting, task, note]
|
||||
- name: lead_source
|
||||
values: [website, phone, email, referral, social_media, advertising, event, other]
|
||||
|
||||
tables:
|
||||
- name: lead_stages
|
||||
columns: [id, tenant_id, name, sequence, is_won, probability, requirements, active, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
|
||||
- name: opportunity_stages
|
||||
columns: [id, tenant_id, name, sequence, is_won, probability, requirements, active, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
|
||||
- name: lost_reasons
|
||||
columns: [id, tenant_id, name, description, active, created_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
|
||||
- name: leads
|
||||
columns: [id, tenant_id, company_id, name, ref, contact_name, email, phone, mobile, website, company_name, job_position, industry, employee_count, annual_revenue, street, city, state, zip, country, stage_id, status, user_id, sales_team_id, source, campaign_id, medium, priority, probability, expected_revenue, date_open, date_closed, date_deadline, date_last_activity, partner_id, opportunity_id, lost_reason_id, lost_notes, description, notes, tags, created_by, updated_by, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: company_id
|
||||
references: auth.companies(id)
|
||||
- column: stage_id
|
||||
references: crm.lead_stages(id)
|
||||
- column: user_id
|
||||
references: auth.users(id)
|
||||
- column: sales_team_id
|
||||
references: sales.sales_teams(id)
|
||||
- column: partner_id
|
||||
references: core.partners(id)
|
||||
- column: lost_reason_id
|
||||
references: crm.lost_reasons(id)
|
||||
|
||||
- name: opportunities
|
||||
columns: [id, tenant_id, company_id, name, ref, partner_id, contact_name, email, phone, stage_id, status, user_id, sales_team_id, priority, probability, expected_revenue, recurring_revenue, recurring_plan, date_deadline, date_closed, date_last_activity, lead_id, source, campaign_id, medium, lost_reason_id, lost_notes, quotation_id, order_id, description, notes, tags, created_by, updated_by, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: company_id
|
||||
references: auth.companies(id)
|
||||
- column: partner_id
|
||||
references: core.partners(id)
|
||||
- column: stage_id
|
||||
references: crm.opportunity_stages(id)
|
||||
- column: user_id
|
||||
references: auth.users(id)
|
||||
- column: sales_team_id
|
||||
references: sales.sales_teams(id)
|
||||
- column: lead_id
|
||||
references: crm.leads(id)
|
||||
- column: lost_reason_id
|
||||
references: crm.lost_reasons(id)
|
||||
- column: quotation_id
|
||||
references: sales.quotations(id)
|
||||
- column: order_id
|
||||
references: sales.sales_orders(id)
|
||||
|
||||
- name: activities
|
||||
columns: [id, tenant_id, res_model, res_id, activity_type, summary, description, date_deadline, date_done, user_id, assigned_to, done, created_by, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: user_id
|
||||
references: auth.users(id)
|
||||
- column: assigned_to
|
||||
references: auth.users(id)
|
||||
|
||||
rls_policies:
|
||||
- name: tenant_isolation_lead_stages
|
||||
table: lead_stages
|
||||
- name: tenant_isolation_opportunity_stages
|
||||
table: opportunity_stages
|
||||
- name: tenant_isolation_lost_reasons
|
||||
table: lost_reasons
|
||||
- name: tenant_isolation_leads
|
||||
table: leads
|
||||
- name: tenant_isolation_opportunities
|
||||
table: opportunities
|
||||
- name: tenant_isolation_crm_activities
|
||||
table: activities
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMA: hr (NUEVO - Human Resources)
|
||||
# DDL: 12-hr.sql - 6 tablas
|
||||
# ============================================================================
|
||||
hr:
|
||||
enums:
|
||||
- name: contract_status
|
||||
values: [draft, active, expired, terminated, cancelled]
|
||||
- name: contract_type
|
||||
values: [permanent, temporary, contractor, internship, part_time]
|
||||
- name: leave_status
|
||||
values: [draft, submitted, approved, rejected, cancelled]
|
||||
- name: leave_type
|
||||
values: [vacation, sick, personal, maternity, paternity, bereavement, unpaid, other]
|
||||
- name: employee_status
|
||||
values: [active, inactive, on_leave, terminated]
|
||||
|
||||
tables:
|
||||
- name: departments
|
||||
columns: [id, tenant_id, company_id, name, code, parent_id, manager_id, description, color, active, created_by, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: company_id
|
||||
references: auth.companies(id)
|
||||
- column: parent_id
|
||||
references: hr.departments(id)
|
||||
- column: manager_id
|
||||
references: hr.employees(id)
|
||||
|
||||
- name: job_positions
|
||||
columns: [id, tenant_id, name, department_id, description, requirements, responsibilities, min_salary, max_salary, active, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: department_id
|
||||
references: hr.departments(id)
|
||||
|
||||
- name: employees
|
||||
columns: [id, tenant_id, company_id, employee_number, first_name, last_name, middle_name, user_id, birth_date, gender, marital_status, nationality, identification_id, identification_type, social_security_number, tax_id, email, work_email, phone, work_phone, mobile, emergency_contact, emergency_phone, street, city, state, zip, country, department_id, job_position_id, manager_id, hire_date, termination_date, status, bank_name, bank_account, bank_clabe, photo_url, notes, created_by, updated_by, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: company_id
|
||||
references: auth.companies(id)
|
||||
- column: user_id
|
||||
references: auth.users(id)
|
||||
- column: department_id
|
||||
references: hr.departments(id)
|
||||
- column: job_position_id
|
||||
references: hr.job_positions(id)
|
||||
- column: manager_id
|
||||
references: hr.employees(id)
|
||||
|
||||
- name: contracts
|
||||
columns: [id, tenant_id, company_id, employee_id, name, reference, contract_type, status, job_position_id, department_id, date_start, date_end, trial_date_end, wage, wage_type, currency_id, resource_calendar_id, hours_per_week, vacation_days, christmas_bonus_days, document_url, notes, created_by, updated_by, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: company_id
|
||||
references: auth.companies(id)
|
||||
- column: employee_id
|
||||
references: hr.employees(id)
|
||||
- column: job_position_id
|
||||
references: hr.job_positions(id)
|
||||
- column: department_id
|
||||
references: hr.departments(id)
|
||||
- column: currency_id
|
||||
references: core.currencies(id)
|
||||
|
||||
- name: leave_types
|
||||
columns: [id, tenant_id, name, code, leave_type, requires_approval, max_days, is_paid, color, active, created_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
|
||||
- name: leaves
|
||||
columns: [id, tenant_id, company_id, employee_id, leave_type_id, name, date_from, date_to, number_of_days, status, description, approved_by, approved_at, rejection_reason, created_by, updated_by, created_at, updated_at]
|
||||
foreign_keys:
|
||||
- column: tenant_id
|
||||
references: auth.tenants(id)
|
||||
- column: company_id
|
||||
references: auth.companies(id)
|
||||
- column: employee_id
|
||||
references: hr.employees(id)
|
||||
- column: leave_type_id
|
||||
references: hr.leave_types(id)
|
||||
- column: approved_by
|
||||
references: auth.users(id)
|
||||
|
||||
rls_policies:
|
||||
- name: tenant_isolation_departments
|
||||
table: departments
|
||||
- name: tenant_isolation_job_positions
|
||||
table: job_positions
|
||||
- name: tenant_isolation_employees
|
||||
table: employees
|
||||
- name: tenant_isolation_contracts
|
||||
table: contracts
|
||||
- name: tenant_isolation_leave_types
|
||||
table: leave_types
|
||||
- name: tenant_isolation_leaves
|
||||
table: leaves
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMA: auth (EXTENSIONES - auth-extensions.sql)
|
||||
# DDL: 01-auth-extensions.sql - 16 tablas adicionales
|
||||
# ============================================================================
|
||||
auth_extensions:
|
||||
note: "Estas tablas complementan el schema auth base (01-auth.sql)"
|
||||
tables:
|
||||
- name: groups
|
||||
purpose: "Grupos de usuarios para permisos"
|
||||
- name: group_implied
|
||||
purpose: "Herencia entre grupos"
|
||||
- name: user_groups
|
||||
purpose: "Asignación usuarios a grupos"
|
||||
- name: models
|
||||
purpose: "Registro de modelos del sistema"
|
||||
- name: model_access
|
||||
purpose: "Permisos CRUD por modelo y grupo"
|
||||
- name: record_rules
|
||||
purpose: "Reglas de acceso a nivel registro (domain filters)"
|
||||
- name: rule_groups
|
||||
purpose: "Asignación reglas a grupos"
|
||||
- name: model_fields
|
||||
purpose: "Campos de modelos"
|
||||
- name: field_permissions
|
||||
purpose: "Permisos a nivel campo"
|
||||
- name: api_keys
|
||||
purpose: "API Keys para autenticación"
|
||||
- name: trusted_devices
|
||||
purpose: "Dispositivos de confianza para 2FA"
|
||||
- name: verification_codes
|
||||
purpose: "Códigos de verificación (2FA, email)"
|
||||
- name: mfa_audit_log
|
||||
purpose: "Auditoría de operaciones MFA"
|
||||
- name: oauth_providers
|
||||
purpose: "Proveedores OAuth2 configurados"
|
||||
- name: oauth_user_links
|
||||
purpose: "Vinculación usuarios con cuentas OAuth"
|
||||
- name: oauth_states
|
||||
purpose: "Estados temporales para flow OAuth"
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMA: inventory (EXTENSIONES - inventory-extensions.sql)
|
||||
# DDL: 05-inventory-extensions.sql - 10 tablas adicionales
|
||||
# ============================================================================
|
||||
inventory_extensions:
|
||||
note: "Estas tablas complementan el schema inventory base (05-inventory.sql)"
|
||||
tables:
|
||||
- name: stock_valuation_layers
|
||||
purpose: "Capas de valoración FIFO/AVCO"
|
||||
- name: category_stock_accounts
|
||||
purpose: "Cuentas contables por categoría de producto"
|
||||
- name: valuation_settings
|
||||
purpose: "Configuración de valoración por empresa"
|
||||
- name: lots
|
||||
purpose: "Lotes de productos (trazabilidad)"
|
||||
- name: stock_move_consume_rel
|
||||
purpose: "Relación movimientos produce/consume"
|
||||
- name: removal_strategies
|
||||
purpose: "Estrategias de remoción (FIFO/LIFO/AVCO)"
|
||||
- name: inventory_count_sessions
|
||||
purpose: "Sesiones de conteo cíclico"
|
||||
- name: inventory_count_lines
|
||||
purpose: "Líneas de conteo"
|
||||
- name: abc_classification_rules
|
||||
purpose: "Reglas de clasificación ABC"
|
||||
- name: product_abc_classification
|
||||
purpose: "Clasificación ABC de productos"
|
||||
|
||||
# ============================================================================
|
||||
# FIN DEL INVENTARIO
|
||||
# Última actualización: 2025-12-09
|
||||
# Total: 12 schemas, 144 tablas
|
||||
# ============================================================================
|
||||
|
||||
208
projects/erp-suite/apps/verticales/GUIA-ALINEACION-ERP-CORE.md
Normal file
208
projects/erp-suite/apps/verticales/GUIA-ALINEACION-ERP-CORE.md
Normal 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
|
||||
@ -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)
|
||||
@ -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
|
||||
-- ============================================================================
|
||||
@ -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
|
||||
-- ============================================================================
|
||||
@ -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
|
||||
-- ============================================================================
|
||||
@ -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
|
||||
-- ============================================================================
|
||||
@ -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
|
||||
-- ============================================================================
|
||||
@ -10,14 +10,36 @@ proyecto:
|
||||
|
||||
herencia_core:
|
||||
base_de_datos: erp-core
|
||||
version_core: "1.2.0"
|
||||
tablas_heredadas: 144 # Actualizado 2025-12-09 según conteo real DDL
|
||||
schemas_heredados:
|
||||
- auth
|
||||
- core
|
||||
- inventory
|
||||
- sales
|
||||
- financial
|
||||
tablas_heredadas: 120+
|
||||
referencia: "apps/erp-core/database/"
|
||||
- nombre: auth
|
||||
tablas: 26 # Autenticación, MFA, OAuth, API Keys
|
||||
- nombre: core
|
||||
tablas: 12 # Partners (pacientes), catálogos, UoM
|
||||
- nombre: financial
|
||||
tablas: 15 # Contabilidad, facturas, pagos
|
||||
- nombre: inventory
|
||||
tablas: 20 # Medicamentos, insumos médicos
|
||||
- nombre: purchase
|
||||
tablas: 8 # Compras de insumos
|
||||
- nombre: sales
|
||||
tablas: 10 # Servicios médicos, facturación
|
||||
- nombre: projects
|
||||
tablas: 10 # Tratamientos (como proyectos)
|
||||
- nombre: analytics
|
||||
tablas: 7 # Centros de costo por consultorio
|
||||
- nombre: system
|
||||
tablas: 13 # Mensajes, notificaciones, logs
|
||||
- nombre: billing
|
||||
tablas: 11 # SaaS (opcional)
|
||||
- nombre: crm
|
||||
tablas: 6 # Pacientes potenciales (opcional)
|
||||
- nombre: hr
|
||||
tablas: 6 # Personal médico, contratos
|
||||
referencia_ddl: "apps/erp-core/database/ddl/"
|
||||
documento_herencia: "../database/HERENCIA-ERP-CORE.md"
|
||||
variable_rls: "app.current_tenant_id"
|
||||
|
||||
schemas_especificos:
|
||||
- nombre: clinica
|
||||
|
||||
@ -11,19 +11,21 @@ proyecto:
|
||||
path: /home/isem/workspace/projects/erp-suite/apps/verticales/clinicas
|
||||
herencia:
|
||||
core_version: "0.6.0"
|
||||
tablas_heredadas: 97
|
||||
tablas_heredadas: 144
|
||||
schemas_heredados: 12
|
||||
specs_aplicables: 22
|
||||
specs_implementadas: 0
|
||||
|
||||
resumen_general:
|
||||
total_modulos: 12
|
||||
total_schemas_planificados: 4
|
||||
total_tablas_planificadas: 45
|
||||
total_schemas_planificados: 1
|
||||
total_tablas_planificadas: 13
|
||||
total_tablas_implementadas: 13
|
||||
total_servicios_backend: 0
|
||||
total_componentes_frontend: 0
|
||||
story_points_estimados: 451
|
||||
test_coverage: N/A
|
||||
ultima_actualizacion: 2025-12-08
|
||||
ultima_actualizacion: 2025-12-09
|
||||
|
||||
modulos:
|
||||
total: 12
|
||||
@ -161,9 +163,15 @@ specs_core:
|
||||
capas:
|
||||
database:
|
||||
inventario: DATABASE_INVENTORY.yml
|
||||
schemas_planificados: [clinical, pharmacy, laboratory, imaging]
|
||||
tablas_planificadas: 45
|
||||
estado: PLANIFICADO
|
||||
schemas_implementados: [clinical]
|
||||
tablas_implementadas: 13
|
||||
enums_implementados: 4
|
||||
ddl_files:
|
||||
- init/00-extensions.sql
|
||||
- init/01-create-schemas.sql
|
||||
- init/02-rls-functions.sql
|
||||
- init/03-clinical-tables.sql
|
||||
estado: DDL_COMPLETO
|
||||
|
||||
backend:
|
||||
inventario: BACKEND_INVENTORY.yml
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Referencia de Base de Datos - ERP Construcción
|
||||
|
||||
**Fecha:** 2025-12-08
|
||||
**Versión:** 1.1
|
||||
**Fecha:** 2025-12-09
|
||||
**Versión:** 1.2
|
||||
**Proyecto:** ERP Construcción
|
||||
**Nivel:** 2B.2 (Proyecto Independiente)
|
||||
|
||||
@ -40,12 +40,21 @@ ERP Construcción es un **proyecto independiente** que implementa y adapta patro
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │construction │ │ hr │ │ hse │ │
|
||||
│ │ 2 tbl │ │ 3 tbl │ │ 28 tbl │ │
|
||||
│ │ 24 tbl │ │ 8 tbl │ │ 58 tbl │ │
|
||||
│ │ (proyectos) │ │ (empleados) │ │ (seguridad) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ Schemas propios: 3 | Tablas propias: 33 │
|
||||
│ Opera de forma INDEPENDIENTE │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ estimates │ │ infonavit │ │ inventory │ │
|
||||
│ │ 8 tbl │ │ 8 tbl │ │ 4 tbl │ │
|
||||
│ │(estimación) │ │ (ruv) │ │ (ext) │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ purchase │ Schemas propios: 7 │
|
||||
│ │ 5 tbl │ Tablas propias: 110 │
|
||||
│ │ (ext) │ Opera de forma INDEPENDIENTE │
|
||||
│ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@ -69,41 +78,48 @@ Los siguientes patrones del ERP-Core fueron **adaptados e implementados** en est
|
||||
|
||||
## SCHEMAS ESPECÍFICOS DE CONSTRUCCIÓN
|
||||
|
||||
### 1. Schema `construccion` (2 tablas)
|
||||
### 1. Schema `construction` (24 tablas)
|
||||
|
||||
**Propósito:** Gestión de proyectos de obra y fraccionamientos
|
||||
**Propósito:** Gestión de proyectos de obra, estructura y avances
|
||||
|
||||
```sql
|
||||
-- Extiende: projects schema del core
|
||||
-- Relaciones:
|
||||
-- proyectos -> core.partners (cliente)
|
||||
-- fraccionamientos -> proyectos
|
||||
-- fraccionamientos usa PostGIS para ubicación
|
||||
|
||||
construccion.proyectos
|
||||
construccion.fraccionamientos
|
||||
-- DDL: 01-construction-schema-ddl.sql
|
||||
-- Estructura de proyecto (8 tablas):
|
||||
-- fraccionamientos, etapas, manzanas, lotes, torres, niveles, departamentos, prototipos
|
||||
-- Presupuestos y Conceptos (3 tablas):
|
||||
-- conceptos, presupuestos, presupuesto_partidas
|
||||
-- Programación y Avances (5 tablas):
|
||||
-- programa_obra, programa_actividades, avances_obra, fotos_avance, bitacora_obra
|
||||
-- Calidad (5 tablas):
|
||||
-- checklists, checklist_items, inspecciones, inspeccion_resultados, tickets_postventa
|
||||
-- Contratos (3 tablas):
|
||||
-- subcontratistas, contratos, contrato_partidas
|
||||
```
|
||||
|
||||
### 2. Schema `hr` extendido (3 tablas)
|
||||
### 2. Schema `hr` extendido (8 tablas)
|
||||
|
||||
**Propósito:** Gestión de personal de obra
|
||||
**Propósito:** Gestión de personal de obra, asistencias, destajo
|
||||
|
||||
```sql
|
||||
-- DDL: 02-hr-schema-ddl.sql
|
||||
-- Extiende: hr schema del core
|
||||
-- Adiciona campos específicos de construcción:
|
||||
-- CURP, NSS, nivel_riesgo, capacitaciones
|
||||
|
||||
hr.employees (extendido)
|
||||
hr.puestos
|
||||
hr.employee_fraccionamientos
|
||||
hr.employee_construction -- Extensión empleados construcción
|
||||
hr.asistencias -- Registro con GPS/biométrico
|
||||
hr.asistencia_biometrico -- Datos biométricos
|
||||
hr.geocercas -- Validación GPS (PostGIS)
|
||||
hr.destajo -- Trabajo a destajo
|
||||
hr.destajo_detalle -- Mediciones destajo
|
||||
hr.cuadrillas -- Equipos de trabajo
|
||||
hr.cuadrilla_miembros -- Miembros cuadrillas
|
||||
```
|
||||
|
||||
### 3. Schema `hse` (28 tablas)
|
||||
### 3. Schema `hse` (58 tablas)
|
||||
|
||||
**Propósito:** Health, Safety & Environment
|
||||
|
||||
```sql
|
||||
-- Nuevo schema específico de construcción
|
||||
-- DDL: 03-hse-schema-ddl.sql
|
||||
-- Implementa 8 requerimientos funcionales (RF-MAA017-001 a 008)
|
||||
|
||||
Grupos de tablas:
|
||||
@ -111,12 +127,77 @@ Grupos de tablas:
|
||||
- Control de Capacitaciones (6 tablas)
|
||||
- Inspecciones de Seguridad (7 tablas)
|
||||
- Control de EPP (7 tablas)
|
||||
- Cumplimiento STPS (10 tablas)
|
||||
- Gestión Ambiental (8 tablas)
|
||||
- Cumplimiento STPS (11 tablas)
|
||||
- Gestión Ambiental (9 tablas)
|
||||
- Permisos de Trabajo (8 tablas)
|
||||
- Indicadores HSE (7 tablas)
|
||||
```
|
||||
|
||||
### 4. Schema `estimates` (8 tablas)
|
||||
|
||||
**Propósito:** Estimaciones, anticipos, retenciones
|
||||
|
||||
```sql
|
||||
-- DDL: 04-estimates-schema-ddl.sql
|
||||
-- Módulo: MAI-008 (Estimaciones y Facturación)
|
||||
|
||||
estimates.estimaciones -- Estimaciones de obra
|
||||
estimates.estimacion_conceptos -- Conceptos estimados
|
||||
estimates.generadores -- Números generadores
|
||||
estimates.anticipos -- Anticipos de obra
|
||||
estimates.amortizaciones -- Amortización de anticipos
|
||||
estimates.retenciones -- Retenciones (garantía, IMSS, ISR)
|
||||
estimates.fondo_garantia -- Fondo de garantía
|
||||
estimates.estimacion_workflow -- Workflow de aprobación
|
||||
```
|
||||
|
||||
### 5. Schema `infonavit` (8 tablas)
|
||||
|
||||
**Propósito:** Integración INFONAVIT, RUV, derechohabientes
|
||||
|
||||
```sql
|
||||
-- DDL: 05-infonavit-schema-ddl.sql
|
||||
-- Módulos: MAI-010/011 (CRM Derechohabientes, Integración INFONAVIT)
|
||||
|
||||
infonavit.registro_infonavit -- Registro RUV
|
||||
infonavit.oferta_vivienda -- Oferta registrada
|
||||
infonavit.derechohabientes -- Derechohabientes
|
||||
infonavit.asignacion_vivienda -- Asignaciones
|
||||
infonavit.actas -- Actas de entrega
|
||||
infonavit.acta_viviendas -- Viviendas en acta
|
||||
infonavit.reportes_infonavit -- Reportes RUV
|
||||
infonavit.historico_puntos -- Histórico puntos ecológicos
|
||||
```
|
||||
|
||||
### 6. Schema `inventory` extensión (4 tablas)
|
||||
|
||||
**Propósito:** Almacenes de proyecto, requisiciones de obra
|
||||
|
||||
```sql
|
||||
-- DDL: 06-inventory-ext-schema-ddl.sql
|
||||
-- Extiende: inventory schema del core
|
||||
|
||||
inventory.almacenes_proyecto -- Almacenes por obra
|
||||
inventory.requisiciones_obra -- Requisiciones desde obra
|
||||
inventory.requisicion_lineas -- Líneas de requisición
|
||||
inventory.consumos_obra -- Consumos por lote/concepto
|
||||
```
|
||||
|
||||
### 7. Schema `purchase` extensión (5 tablas)
|
||||
|
||||
**Propósito:** Órdenes de compra construcción, comparativos
|
||||
|
||||
```sql
|
||||
-- DDL: 07-purchase-ext-schema-ddl.sql
|
||||
-- Extiende: purchase schema del core
|
||||
|
||||
purchase.purchase_order_construction -- Extensión OC
|
||||
purchase.supplier_construction -- Extensión proveedores
|
||||
purchase.comparativo_cotizaciones -- Cuadro comparativo
|
||||
purchase.comparativo_proveedores -- Proveedores en comparativo
|
||||
purchase.comparativo_productos -- Productos cotizados
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ORDEN DE EJECUCIÓN DDL
|
||||
@ -128,13 +209,19 @@ Para recrear la base de datos completa:
|
||||
cd apps/erp-core/database
|
||||
./scripts/reset-database.sh --force
|
||||
|
||||
# PASO 2: Cargar extensiones de Construcción
|
||||
# PASO 2: Cargar extensiones de Construcción (orden importante)
|
||||
cd apps/verticales/construccion/database
|
||||
psql $DATABASE_URL -f schemas/01-construction-schema-ddl.sql
|
||||
psql $DATABASE_URL -f schemas/02-hr-schema-ddl.sql
|
||||
psql $DATABASE_URL -f schemas/03-hse-schema-ddl.sql
|
||||
psql $DATABASE_URL -f schemas/01-construction-schema-ddl.sql # 24 tablas
|
||||
psql $DATABASE_URL -f schemas/02-hr-schema-ddl.sql # 8 tablas
|
||||
psql $DATABASE_URL -f schemas/03-hse-schema-ddl.sql # 58 tablas
|
||||
psql $DATABASE_URL -f schemas/04-estimates-schema-ddl.sql # 8 tablas
|
||||
psql $DATABASE_URL -f schemas/05-infonavit-schema-ddl.sql # 8 tablas
|
||||
psql $DATABASE_URL -f schemas/06-inventory-ext-schema-ddl.sql # 4 tablas
|
||||
psql $DATABASE_URL -f schemas/07-purchase-ext-schema-ddl.sql # 5 tablas
|
||||
```
|
||||
|
||||
**Nota:** Los archivos 06 y 07 dependen de que 01-construction esté instalado.
|
||||
|
||||
---
|
||||
|
||||
## DEPENDENCIAS CRUZADAS
|
||||
@ -271,4 +358,5 @@ Según el [MAPEO-SPECS-VERTICALES.md](../../../../erp-core/docs/04-modelado/MAPE
|
||||
---
|
||||
|
||||
**Documento de herencia oficial**
|
||||
**Última actualización:** 2025-12-08
|
||||
**Última actualización:** 2025-12-09
|
||||
**Total schemas:** 7 | **Total tablas:** 110
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
-- ============================================================================
|
||||
-- CONSTRUCTION Schema DDL - Gestion de Obras
|
||||
-- Modulo: MAA-001 a MAA-006 (Fundamentos de Construccion)
|
||||
-- Version: 1.0.0
|
||||
-- Fecha: 2025-12-06
|
||||
-- CONSTRUCTION Schema DDL - Gestión de Obras (COMPLETO)
|
||||
-- Modulos: MAI-002, MAI-003, MAI-005, MAI-009, MAI-012
|
||||
-- Version: 2.0.0
|
||||
-- Fecha: 2025-12-08
|
||||
-- ============================================================================
|
||||
-- POLITICA: CARGA LIMPIA (ver DIRECTIVA-POLITICA-CARGA-LIMPIA.md)
|
||||
-- Este archivo es parte de la fuente de verdad DDL.
|
||||
-- ============================================================================
|
||||
|
||||
-- Verificar que ERP-Core esta instalado
|
||||
-- Verificar que ERP-Core está instalado
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
|
||||
@ -25,95 +25,879 @@ END $$;
|
||||
-- Crear schema si no existe
|
||||
CREATE SCHEMA IF NOT EXISTS construction;
|
||||
|
||||
-- Configurar search_path
|
||||
SET search_path TO construction, core, core_shared, public;
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLAS BASE MINIMAS (requeridas por otros modulos como HSE)
|
||||
-- TYPES (ENUMs)
|
||||
-- ============================================================================
|
||||
|
||||
-- Tabla: Proyectos (desarrollo inmobiliario)
|
||||
CREATE TABLE IF NOT EXISTS construction.proyectos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
||||
codigo VARCHAR(20) NOT NULL,
|
||||
nombre VARCHAR(200) NOT NULL,
|
||||
descripcion TEXT,
|
||||
direccion TEXT,
|
||||
ciudad VARCHAR(100),
|
||||
estado VARCHAR(100),
|
||||
fecha_inicio DATE,
|
||||
fecha_fin_estimada DATE,
|
||||
estado_proyecto VARCHAR(20) NOT NULL DEFAULT 'activo',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
|
||||
CONSTRAINT uq_proyectos_codigo UNIQUE (tenant_id, codigo)
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE construction.project_status AS ENUM (
|
||||
'draft', 'planning', 'in_progress', 'paused', 'completed', 'cancelled'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- Tabla: Fraccionamientos (obras dentro de un proyecto)
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE construction.lot_status AS ENUM (
|
||||
'available', 'reserved', 'sold', 'under_construction', 'delivered', 'warranty'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE construction.prototype_type AS ENUM (
|
||||
'horizontal', 'vertical', 'commercial', 'mixed'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE construction.advance_status AS ENUM (
|
||||
'pending', 'captured', 'reviewed', 'approved', 'rejected'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE construction.quality_status AS ENUM (
|
||||
'pending', 'in_review', 'approved', 'rejected', 'rework'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE construction.contract_type AS ENUM (
|
||||
'fixed_price', 'unit_price', 'cost_plus', 'mixed'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE construction.contract_status AS ENUM (
|
||||
'draft', 'pending_approval', 'active', 'suspended', 'terminated', 'closed'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLES - ESTRUCTURA DE PROYECTO
|
||||
-- ============================================================================
|
||||
|
||||
-- Tabla: fraccionamientos (desarrollo inmobiliario)
|
||||
CREATE TABLE IF NOT EXISTS construction.fraccionamientos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
||||
proyecto_id UUID NOT NULL REFERENCES construction.proyectos(id),
|
||||
codigo VARCHAR(20) NOT NULL,
|
||||
nombre VARCHAR(200) NOT NULL,
|
||||
descripcion TEXT,
|
||||
direccion TEXT,
|
||||
ubicacion_geo GEOMETRY(Point, 4326),
|
||||
fecha_inicio DATE,
|
||||
fecha_fin_estimada DATE,
|
||||
estado VARCHAR(20) NOT NULL DEFAULT 'activo',
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
code VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
address TEXT,
|
||||
city VARCHAR(100),
|
||||
state VARCHAR(100),
|
||||
zip_code VARCHAR(10),
|
||||
location GEOMETRY(POINT, 4326),
|
||||
total_area_m2 DECIMAL(12,2),
|
||||
buildable_area_m2 DECIMAL(12,2),
|
||||
total_lots INTEGER DEFAULT 0,
|
||||
status construction.project_status NOT NULL DEFAULT 'draft',
|
||||
start_date DATE,
|
||||
expected_end_date DATE,
|
||||
actual_end_date DATE,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_fraccionamientos_code_tenant UNIQUE (tenant_id, code)
|
||||
);
|
||||
|
||||
CONSTRAINT uq_fraccionamientos_codigo UNIQUE (tenant_id, codigo)
|
||||
-- Tabla: etapas (fases del fraccionamiento)
|
||||
CREATE TABLE IF NOT EXISTS construction.etapas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id) ON DELETE CASCADE,
|
||||
code VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
sequence INTEGER NOT NULL DEFAULT 1,
|
||||
total_lots INTEGER DEFAULT 0,
|
||||
status construction.project_status NOT NULL DEFAULT 'draft',
|
||||
start_date DATE,
|
||||
expected_end_date DATE,
|
||||
actual_end_date DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_etapas_code_fracc UNIQUE (fraccionamiento_id, code)
|
||||
);
|
||||
|
||||
-- Tabla: manzanas (agrupación de lotes)
|
||||
CREATE TABLE IF NOT EXISTS construction.manzanas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
etapa_id UUID NOT NULL REFERENCES construction.etapas(id) ON DELETE CASCADE,
|
||||
code VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(100),
|
||||
total_lots INTEGER DEFAULT 0,
|
||||
polygon GEOMETRY(POLYGON, 4326),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_manzanas_code_etapa UNIQUE (etapa_id, code)
|
||||
);
|
||||
|
||||
-- Tabla: prototipos (tipos de vivienda) - definida antes de lotes
|
||||
CREATE TABLE IF NOT EXISTS construction.prototipos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
code VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
type construction.prototype_type NOT NULL DEFAULT 'horizontal',
|
||||
area_construction_m2 DECIMAL(10,2),
|
||||
area_terrain_m2 DECIMAL(10,2),
|
||||
bedrooms INTEGER DEFAULT 0,
|
||||
bathrooms DECIMAL(3,1) DEFAULT 0,
|
||||
parking_spaces INTEGER DEFAULT 0,
|
||||
floors INTEGER DEFAULT 1,
|
||||
base_price DECIMAL(14,2),
|
||||
blueprint_url VARCHAR(500),
|
||||
render_url VARCHAR(500),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_prototipos_code_tenant UNIQUE (tenant_id, code)
|
||||
);
|
||||
|
||||
-- Tabla: lotes (unidades vendibles horizontal)
|
||||
CREATE TABLE IF NOT EXISTS construction.lotes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
manzana_id UUID NOT NULL REFERENCES construction.manzanas(id) ON DELETE CASCADE,
|
||||
prototipo_id UUID REFERENCES construction.prototipos(id),
|
||||
code VARCHAR(30) NOT NULL,
|
||||
official_number VARCHAR(50),
|
||||
area_m2 DECIMAL(10,2),
|
||||
front_m DECIMAL(8,2),
|
||||
depth_m DECIMAL(8,2),
|
||||
status construction.lot_status NOT NULL DEFAULT 'available',
|
||||
location GEOMETRY(POINT, 4326),
|
||||
polygon GEOMETRY(POLYGON, 4326),
|
||||
price_base DECIMAL(14,2),
|
||||
price_final DECIMAL(14,2),
|
||||
buyer_id UUID,
|
||||
sale_date DATE,
|
||||
delivery_date DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_lotes_code_manzana UNIQUE (manzana_id, code)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLES - ESTRUCTURA VERTICAL (TORRES)
|
||||
-- ============================================================================
|
||||
|
||||
-- Tabla: torres (edificios verticales)
|
||||
CREATE TABLE IF NOT EXISTS construction.torres (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
etapa_id UUID NOT NULL REFERENCES construction.etapas(id) ON DELETE CASCADE,
|
||||
code VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
total_floors INTEGER NOT NULL DEFAULT 1,
|
||||
total_units INTEGER DEFAULT 0,
|
||||
status construction.project_status NOT NULL DEFAULT 'draft',
|
||||
location GEOMETRY(POINT, 4326),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_torres_code_etapa UNIQUE (etapa_id, code)
|
||||
);
|
||||
|
||||
-- Tabla: niveles (pisos de torre)
|
||||
CREATE TABLE IF NOT EXISTS construction.niveles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
torre_id UUID NOT NULL REFERENCES construction.torres(id) ON DELETE CASCADE,
|
||||
floor_number INTEGER NOT NULL,
|
||||
name VARCHAR(50),
|
||||
total_units INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_niveles_floor_torre UNIQUE (torre_id, floor_number)
|
||||
);
|
||||
|
||||
-- Tabla: departamentos (unidades en torre)
|
||||
CREATE TABLE IF NOT EXISTS construction.departamentos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
nivel_id UUID NOT NULL REFERENCES construction.niveles(id) ON DELETE CASCADE,
|
||||
prototipo_id UUID REFERENCES construction.prototipos(id),
|
||||
code VARCHAR(30) NOT NULL,
|
||||
unit_number VARCHAR(20) NOT NULL,
|
||||
area_m2 DECIMAL(10,2),
|
||||
status construction.lot_status NOT NULL DEFAULT 'available',
|
||||
price_base DECIMAL(14,2),
|
||||
price_final DECIMAL(14,2),
|
||||
buyer_id UUID,
|
||||
sale_date DATE,
|
||||
delivery_date DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_departamentos_code_nivel UNIQUE (nivel_id, code)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLES - CONCEPTOS Y PRESUPUESTOS
|
||||
-- ============================================================================
|
||||
|
||||
-- Tabla: conceptos (catálogo de conceptos de obra)
|
||||
CREATE TABLE IF NOT EXISTS construction.conceptos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES construction.conceptos(id),
|
||||
code VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
unit_id UUID,
|
||||
unit_price DECIMAL(12,4),
|
||||
is_composite BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
path VARCHAR(500),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_conceptos_code_tenant UNIQUE (tenant_id, code)
|
||||
);
|
||||
|
||||
-- Tabla: presupuestos (presupuesto por prototipo/obra)
|
||||
CREATE TABLE IF NOT EXISTS construction.presupuestos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
fraccionamiento_id UUID REFERENCES construction.fraccionamientos(id),
|
||||
prototipo_id UUID REFERENCES construction.prototipos(id),
|
||||
code VARCHAR(30) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
total_amount DECIMAL(16,2) DEFAULT 0,
|
||||
currency_id UUID,
|
||||
approved_at TIMESTAMPTZ,
|
||||
approved_by UUID REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_presupuestos_code_version UNIQUE (tenant_id, code, version)
|
||||
);
|
||||
|
||||
-- Tabla: presupuesto_partidas (líneas del presupuesto)
|
||||
CREATE TABLE IF NOT EXISTS construction.presupuesto_partidas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
presupuesto_id UUID NOT NULL REFERENCES construction.presupuestos(id) ON DELETE CASCADE,
|
||||
concepto_id UUID NOT NULL REFERENCES construction.conceptos(id),
|
||||
sequence INTEGER NOT NULL DEFAULT 0,
|
||||
quantity DECIMAL(12,4) NOT NULL DEFAULT 0,
|
||||
unit_price DECIMAL(12,4) NOT NULL DEFAULT 0,
|
||||
total_amount DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_partidas_presupuesto_concepto UNIQUE (presupuesto_id, concepto_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLES - AVANCES Y CONTROL DE OBRA
|
||||
-- ============================================================================
|
||||
|
||||
-- Tabla: programa_obra (programa maestro)
|
||||
CREATE TABLE IF NOT EXISTS construction.programa_obra (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
|
||||
code VARCHAR(30) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_programa_code_version UNIQUE (tenant_id, code, version)
|
||||
);
|
||||
|
||||
-- Tabla: programa_actividades (actividades del programa)
|
||||
CREATE TABLE IF NOT EXISTS construction.programa_actividades (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
programa_id UUID NOT NULL REFERENCES construction.programa_obra(id) ON DELETE CASCADE,
|
||||
concepto_id UUID REFERENCES construction.conceptos(id),
|
||||
parent_id UUID REFERENCES construction.programa_actividades(id),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
sequence INTEGER NOT NULL DEFAULT 0,
|
||||
planned_start DATE,
|
||||
planned_end DATE,
|
||||
planned_quantity DECIMAL(12,4) DEFAULT 0,
|
||||
planned_weight DECIMAL(8,4) DEFAULT 0,
|
||||
wbs_code VARCHAR(50),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Tabla: avances_obra (captura de avances)
|
||||
CREATE TABLE IF NOT EXISTS construction.avances_obra (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
lote_id UUID REFERENCES construction.lotes(id),
|
||||
departamento_id UUID REFERENCES construction.departamentos(id),
|
||||
concepto_id UUID NOT NULL REFERENCES construction.conceptos(id),
|
||||
capture_date DATE NOT NULL,
|
||||
quantity_executed DECIMAL(12,4) NOT NULL DEFAULT 0,
|
||||
percentage_executed DECIMAL(5,2) DEFAULT 0,
|
||||
status construction.advance_status NOT NULL DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
captured_by UUID NOT NULL REFERENCES auth.users(id),
|
||||
reviewed_by UUID REFERENCES auth.users(id),
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
approved_by UUID REFERENCES auth.users(id),
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT chk_avances_lote_or_depto CHECK (
|
||||
(lote_id IS NOT NULL AND departamento_id IS NULL) OR
|
||||
(lote_id IS NULL AND departamento_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Tabla: fotos_avance (evidencia fotográfica)
|
||||
CREATE TABLE IF NOT EXISTS construction.fotos_avance (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
avance_id UUID NOT NULL REFERENCES construction.avances_obra(id) ON DELETE CASCADE,
|
||||
file_url VARCHAR(500) NOT NULL,
|
||||
file_name VARCHAR(255),
|
||||
file_size INTEGER,
|
||||
mime_type VARCHAR(50),
|
||||
description TEXT,
|
||||
location GEOMETRY(POINT, 4326),
|
||||
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Tabla: bitacora_obra (registro de bitácora)
|
||||
CREATE TABLE IF NOT EXISTS construction.bitacora_obra (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
|
||||
entry_date DATE NOT NULL,
|
||||
entry_number INTEGER NOT NULL,
|
||||
weather VARCHAR(50),
|
||||
temperature_max DECIMAL(4,1),
|
||||
temperature_min DECIMAL(4,1),
|
||||
workers_count INTEGER DEFAULT 0,
|
||||
description TEXT NOT NULL,
|
||||
observations TEXT,
|
||||
incidents TEXT,
|
||||
registered_by UUID NOT NULL REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_bitacora_fracc_number UNIQUE (fraccionamiento_id, entry_number)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLES - CALIDAD Y POSTVENTA (MAI-009)
|
||||
-- ============================================================================
|
||||
|
||||
-- Tabla: checklists (plantillas de verificación)
|
||||
CREATE TABLE IF NOT EXISTS construction.checklists (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
code VARCHAR(30) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
prototipo_id UUID REFERENCES construction.prototipos(id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_checklists_code_tenant UNIQUE (tenant_id, code)
|
||||
);
|
||||
|
||||
-- Tabla: checklist_items (items del checklist)
|
||||
CREATE TABLE IF NOT EXISTS construction.checklist_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
checklist_id UUID NOT NULL REFERENCES construction.checklists(id) ON DELETE CASCADE,
|
||||
sequence INTEGER NOT NULL DEFAULT 0,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
is_required BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Tabla: inspecciones (inspecciones de calidad)
|
||||
CREATE TABLE IF NOT EXISTS construction.inspecciones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
checklist_id UUID NOT NULL REFERENCES construction.checklists(id),
|
||||
lote_id UUID REFERENCES construction.lotes(id),
|
||||
departamento_id UUID REFERENCES construction.departamentos(id),
|
||||
inspection_date DATE NOT NULL,
|
||||
status construction.quality_status NOT NULL DEFAULT 'pending',
|
||||
inspector_id UUID NOT NULL REFERENCES auth.users(id),
|
||||
notes TEXT,
|
||||
approved_by UUID REFERENCES auth.users(id),
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Tabla: inspeccion_resultados (resultados por item)
|
||||
CREATE TABLE IF NOT EXISTS construction.inspeccion_resultados (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
inspeccion_id UUID NOT NULL REFERENCES construction.inspecciones(id) ON DELETE CASCADE,
|
||||
checklist_item_id UUID NOT NULL REFERENCES construction.checklist_items(id),
|
||||
is_passed BOOLEAN,
|
||||
notes TEXT,
|
||||
photo_url VARCHAR(500),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Tabla: tickets_postventa (tickets de garantía)
|
||||
CREATE TABLE IF NOT EXISTS construction.tickets_postventa (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
lote_id UUID REFERENCES construction.lotes(id),
|
||||
departamento_id UUID REFERENCES construction.departamentos(id),
|
||||
ticket_number VARCHAR(30) NOT NULL,
|
||||
reported_date DATE NOT NULL,
|
||||
category VARCHAR(50),
|
||||
description TEXT NOT NULL,
|
||||
priority VARCHAR(20) DEFAULT 'medium',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
assigned_to UUID REFERENCES auth.users(id),
|
||||
resolution TEXT,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by UUID REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_tickets_number_tenant UNIQUE (tenant_id, ticket_number)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLES - CONTRATOS Y SUBCONTRATOS (MAI-012)
|
||||
-- ============================================================================
|
||||
|
||||
-- Tabla: subcontratistas
|
||||
CREATE TABLE IF NOT EXISTS construction.subcontratistas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
partner_id UUID,
|
||||
code VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
legal_name VARCHAR(255),
|
||||
tax_id VARCHAR(20),
|
||||
specialty VARCHAR(100),
|
||||
contact_name VARCHAR(100),
|
||||
contact_phone VARCHAR(20),
|
||||
contact_email VARCHAR(100),
|
||||
address TEXT,
|
||||
rating DECIMAL(3,2),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_subcontratistas_code_tenant UNIQUE (tenant_id, code)
|
||||
);
|
||||
|
||||
-- Tabla: contratos (contratos con subcontratistas)
|
||||
CREATE TABLE IF NOT EXISTS construction.contratos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
subcontratista_id UUID NOT NULL REFERENCES construction.subcontratistas(id),
|
||||
fraccionamiento_id UUID NOT NULL REFERENCES construction.fraccionamientos(id),
|
||||
contract_number VARCHAR(30) NOT NULL,
|
||||
contract_type construction.contract_type NOT NULL DEFAULT 'unit_price',
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
total_amount DECIMAL(16,2),
|
||||
advance_percentage DECIMAL(5,2) DEFAULT 0,
|
||||
retention_percentage DECIMAL(5,2) DEFAULT 5,
|
||||
status construction.contract_status NOT NULL DEFAULT 'draft',
|
||||
signed_at TIMESTAMPTZ,
|
||||
signed_by UUID REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id),
|
||||
CONSTRAINT uq_contratos_number_tenant UNIQUE (tenant_id, contract_number)
|
||||
);
|
||||
|
||||
-- Tabla: contrato_partidas (líneas del contrato)
|
||||
CREATE TABLE IF NOT EXISTS construction.contrato_partidas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||
contrato_id UUID NOT NULL REFERENCES construction.contratos(id) ON DELETE CASCADE,
|
||||
concepto_id UUID NOT NULL REFERENCES construction.conceptos(id),
|
||||
quantity DECIMAL(12,4) NOT NULL DEFAULT 0,
|
||||
unit_price DECIMAL(12,4) NOT NULL DEFAULT 0,
|
||||
total_amount DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ,
|
||||
updated_by UUID REFERENCES auth.users(id),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
deleted_by UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- INDICES
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_proyectos_tenant ON construction.proyectos(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_tenant ON construction.fraccionamientos(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_proyecto ON construction.fraccionamientos(proyecto_id);
|
||||
-- Fraccionamientos
|
||||
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_tenant_id ON construction.fraccionamientos(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_status ON construction.fraccionamientos(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_fraccionamientos_code ON construction.fraccionamientos(code);
|
||||
|
||||
-- Etapas
|
||||
CREATE INDEX IF NOT EXISTS idx_etapas_tenant_id ON construction.etapas(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_etapas_fraccionamiento_id ON construction.etapas(fraccionamiento_id);
|
||||
|
||||
-- Manzanas
|
||||
CREATE INDEX IF NOT EXISTS idx_manzanas_tenant_id ON construction.manzanas(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manzanas_etapa_id ON construction.manzanas(etapa_id);
|
||||
|
||||
-- Lotes
|
||||
CREATE INDEX IF NOT EXISTS idx_lotes_tenant_id ON construction.lotes(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lotes_manzana_id ON construction.lotes(manzana_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lotes_prototipo_id ON construction.lotes(prototipo_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lotes_status ON construction.lotes(status);
|
||||
|
||||
-- Torres
|
||||
CREATE INDEX IF NOT EXISTS idx_torres_tenant_id ON construction.torres(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_torres_etapa_id ON construction.torres(etapa_id);
|
||||
|
||||
-- Niveles
|
||||
CREATE INDEX IF NOT EXISTS idx_niveles_tenant_id ON construction.niveles(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_niveles_torre_id ON construction.niveles(torre_id);
|
||||
|
||||
-- Departamentos
|
||||
CREATE INDEX IF NOT EXISTS idx_departamentos_tenant_id ON construction.departamentos(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_departamentos_nivel_id ON construction.departamentos(nivel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_departamentos_status ON construction.departamentos(status);
|
||||
|
||||
-- Prototipos
|
||||
CREATE INDEX IF NOT EXISTS idx_prototipos_tenant_id ON construction.prototipos(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_prototipos_type ON construction.prototipos(type);
|
||||
|
||||
-- Conceptos
|
||||
CREATE INDEX IF NOT EXISTS idx_conceptos_tenant_id ON construction.conceptos(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conceptos_parent_id ON construction.conceptos(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conceptos_code ON construction.conceptos(code);
|
||||
|
||||
-- Presupuestos
|
||||
CREATE INDEX IF NOT EXISTS idx_presupuestos_tenant_id ON construction.presupuestos(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_presupuestos_fraccionamiento_id ON construction.presupuestos(fraccionamiento_id);
|
||||
|
||||
-- Avances
|
||||
CREATE INDEX IF NOT EXISTS idx_avances_tenant_id ON construction.avances_obra(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_avances_lote_id ON construction.avances_obra(lote_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_avances_concepto_id ON construction.avances_obra(concepto_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_avances_capture_date ON construction.avances_obra(capture_date);
|
||||
|
||||
-- Bitacora
|
||||
CREATE INDEX IF NOT EXISTS idx_bitacora_tenant_id ON construction.bitacora_obra(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bitacora_fraccionamiento_id ON construction.bitacora_obra(fraccionamiento_id);
|
||||
|
||||
-- Inspecciones
|
||||
CREATE INDEX IF NOT EXISTS idx_inspecciones_tenant_id ON construction.inspecciones(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_inspecciones_status ON construction.inspecciones(status);
|
||||
|
||||
-- Tickets
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_tenant_id ON construction.tickets_postventa(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON construction.tickets_postventa(status);
|
||||
|
||||
-- Subcontratistas
|
||||
CREATE INDEX IF NOT EXISTS idx_subcontratistas_tenant_id ON construction.subcontratistas(tenant_id);
|
||||
|
||||
-- Contratos
|
||||
CREATE INDEX IF NOT EXISTS idx_contratos_tenant_id ON construction.contratos(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_contratos_subcontratista_id ON construction.contratos(subcontratista_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_contratos_fraccionamiento_id ON construction.contratos(fraccionamiento_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- ROW LEVEL SECURITY
|
||||
-- ROW LEVEL SECURITY (RLS)
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE construction.proyectos ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.fraccionamientos ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.etapas ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.manzanas ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.lotes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.torres ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.niveles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.departamentos ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.prototipos ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.conceptos ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.presupuestos ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.presupuesto_partidas ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.programa_obra ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.programa_actividades ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.avances_obra ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.fotos_avance ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.bitacora_obra ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.checklists ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.checklist_items ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.inspecciones ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.inspeccion_resultados ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.tickets_postventa ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.subcontratistas ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.contratos ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE construction.contrato_partidas ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation_proyectos ON construction.proyectos
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.current_tenant', true)::UUID);
|
||||
|
||||
-- Policies de tenant isolation usando current_setting
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_fraccionamientos ON construction.fraccionamientos;
|
||||
CREATE POLICY tenant_isolation_fraccionamientos ON construction.fraccionamientos
|
||||
FOR ALL
|
||||
USING (tenant_id = current_setting('app.current_tenant', true)::UUID);
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGERS
|
||||
-- ============================================================================
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_etapas ON construction.etapas;
|
||||
CREATE POLICY tenant_isolation_etapas ON construction.etapas
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
CREATE TRIGGER trg_proyectos_updated_at
|
||||
BEFORE UPDATE ON construction.proyectos
|
||||
FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at();
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_manzanas ON construction.manzanas;
|
||||
CREATE POLICY tenant_isolation_manzanas ON construction.manzanas
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
CREATE TRIGGER trg_fraccionamientos_updated_at
|
||||
BEFORE UPDATE ON construction.fraccionamientos
|
||||
FOR EACH ROW EXECUTE FUNCTION core_shared.set_updated_at();
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_lotes ON construction.lotes;
|
||||
CREATE POLICY tenant_isolation_lotes ON construction.lotes
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_torres ON construction.torres;
|
||||
CREATE POLICY tenant_isolation_torres ON construction.torres
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_niveles ON construction.niveles;
|
||||
CREATE POLICY tenant_isolation_niveles ON construction.niveles
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_departamentos ON construction.departamentos;
|
||||
CREATE POLICY tenant_isolation_departamentos ON construction.departamentos
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_prototipos ON construction.prototipos;
|
||||
CREATE POLICY tenant_isolation_prototipos ON construction.prototipos
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_conceptos ON construction.conceptos;
|
||||
CREATE POLICY tenant_isolation_conceptos ON construction.conceptos
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_presupuestos ON construction.presupuestos;
|
||||
CREATE POLICY tenant_isolation_presupuestos ON construction.presupuestos
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_presupuesto_partidas ON construction.presupuesto_partidas;
|
||||
CREATE POLICY tenant_isolation_presupuesto_partidas ON construction.presupuesto_partidas
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_programa_obra ON construction.programa_obra;
|
||||
CREATE POLICY tenant_isolation_programa_obra ON construction.programa_obra
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_programa_actividades ON construction.programa_actividades;
|
||||
CREATE POLICY tenant_isolation_programa_actividades ON construction.programa_actividades
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_avances_obra ON construction.avances_obra;
|
||||
CREATE POLICY tenant_isolation_avances_obra ON construction.avances_obra
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_fotos_avance ON construction.fotos_avance;
|
||||
CREATE POLICY tenant_isolation_fotos_avance ON construction.fotos_avance
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_bitacora_obra ON construction.bitacora_obra;
|
||||
CREATE POLICY tenant_isolation_bitacora_obra ON construction.bitacora_obra
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_checklists ON construction.checklists;
|
||||
CREATE POLICY tenant_isolation_checklists ON construction.checklists
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_checklist_items ON construction.checklist_items;
|
||||
CREATE POLICY tenant_isolation_checklist_items ON construction.checklist_items
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_inspecciones ON construction.inspecciones;
|
||||
CREATE POLICY tenant_isolation_inspecciones ON construction.inspecciones
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_inspeccion_resultados ON construction.inspeccion_resultados;
|
||||
CREATE POLICY tenant_isolation_inspeccion_resultados ON construction.inspeccion_resultados
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_tickets_postventa ON construction.tickets_postventa;
|
||||
CREATE POLICY tenant_isolation_tickets_postventa ON construction.tickets_postventa
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_subcontratistas ON construction.subcontratistas;
|
||||
CREATE POLICY tenant_isolation_subcontratistas ON construction.subcontratistas
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_contratos ON construction.contratos;
|
||||
CREATE POLICY tenant_isolation_contratos ON construction.contratos
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
DROP POLICY IF EXISTS tenant_isolation_contrato_partidas ON construction.contrato_partidas;
|
||||
CREATE POLICY tenant_isolation_contrato_partidas ON construction.contrato_partidas
|
||||
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||
EXCEPTION WHEN undefined_object THEN NULL; END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- COMENTARIOS
|
||||
-- ============================================================================
|
||||
|
||||
COMMENT ON TABLE construction.proyectos IS 'Proyectos de desarrollo inmobiliario';
|
||||
COMMENT ON TABLE construction.fraccionamientos IS 'Fraccionamientos/obras dentro de un proyecto';
|
||||
COMMENT ON SCHEMA construction IS 'Schema de construcción: obras, lotes, avances, calidad, contratos';
|
||||
COMMENT ON TABLE construction.fraccionamientos IS 'Desarrollos inmobiliarios/fraccionamientos';
|
||||
COMMENT ON TABLE construction.etapas IS 'Etapas/fases de un fraccionamiento';
|
||||
COMMENT ON TABLE construction.manzanas IS 'Manzanas dentro de una etapa';
|
||||
COMMENT ON TABLE construction.lotes IS 'Lotes/terrenos vendibles (horizontal)';
|
||||
COMMENT ON TABLE construction.torres IS 'Torres/edificios (vertical)';
|
||||
COMMENT ON TABLE construction.niveles IS 'Pisos de una torre';
|
||||
COMMENT ON TABLE construction.departamentos IS 'Departamentos/unidades en torre';
|
||||
COMMENT ON TABLE construction.prototipos IS 'Tipos de vivienda/prototipos';
|
||||
COMMENT ON TABLE construction.conceptos IS 'Catálogo de conceptos de obra';
|
||||
COMMENT ON TABLE construction.presupuestos IS 'Presupuestos por prototipo u obra';
|
||||
COMMENT ON TABLE construction.avances_obra IS 'Captura de avances físicos';
|
||||
COMMENT ON TABLE construction.bitacora_obra IS 'Bitácora diaria de obra';
|
||||
COMMENT ON TABLE construction.checklists IS 'Plantillas de verificación';
|
||||
COMMENT ON TABLE construction.inspecciones IS 'Inspecciones de calidad';
|
||||
COMMENT ON TABLE construction.tickets_postventa IS 'Tickets de garantía';
|
||||
COMMENT ON TABLE construction.subcontratistas IS 'Catálogo de subcontratistas';
|
||||
COMMENT ON TABLE construction.contratos IS 'Contratos con subcontratistas';
|
||||
|
||||
-- ============================================================================
|
||||
-- FIN
|
||||
-- FIN DEL SCHEMA CONSTRUCTION
|
||||
-- Total tablas: 24
|
||||
-- ============================================================================
|
||||
|
||||
@ -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
|
||||
-- ============================================================================
|
||||
@ -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
|
||||
-- ============================================================================
|
||||
@ -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
|
||||
-- ============================================================================
|
||||
@ -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
|
||||
-- ============================================================================
|
||||
@ -1,16 +1,16 @@
|
||||
# =============================================================================
|
||||
# DATABASE INVENTORY - ERP CONSTRUCCION
|
||||
# =============================================================================
|
||||
# Version: 1.1.0
|
||||
# Ultima actualizacion: 2025-12-06
|
||||
# Version: 1.3.0
|
||||
# Ultima actualizacion: 2025-12-09
|
||||
# Proposito: Inventario canonico de objetos de base de datos
|
||||
# Nomenclatura: Ver NAMING-CONVENTIONS.md
|
||||
# =============================================================================
|
||||
|
||||
metadata:
|
||||
proyecto: ERP Construccion
|
||||
version: 2.0.0
|
||||
fecha_actualizacion: 2025-12-08
|
||||
version: 2.1.0
|
||||
fecha_actualizacion: 2025-12-09
|
||||
motor: PostgreSQL 15+
|
||||
extensiones: [uuid-ossp, pg_trgm, btree_gist, pgcrypto, postgis]
|
||||
|
||||
@ -18,41 +18,41 @@ metadata:
|
||||
# HERENCIA DE ERP CORE
|
||||
# =============================================================================
|
||||
herencia_core:
|
||||
version_core: "1.1.0"
|
||||
tablas_heredadas: 124 # Total de tablas del core
|
||||
version_core: "1.2.0"
|
||||
tablas_heredadas: 144 # Total de tablas del core (actualizado 2025-12-09)
|
||||
schemas_heredados:
|
||||
- auth: 26 # Autenticación, MFA, OAuth, API Keys
|
||||
- core: 12 # Partners, catálogos, monedas
|
||||
- financial: 15 # Contabilidad, facturas
|
||||
- inventory: 15 # Productos, stock, valoración
|
||||
- purchase: 8 # Compras
|
||||
- sales: 6 # Ventas
|
||||
- projects: 5 # Base proyectos
|
||||
- hr: 6 # RRHH base
|
||||
- analytics: 5 # Centros de costo
|
||||
- system: 10 # Mensajes, notificaciones
|
||||
- billing: 11 # SaaS (opcional)
|
||||
- crm: 5 # CRM (opcional)
|
||||
- auth: 26 # 10 (auth.sql) + 16 (auth-extensions.sql)
|
||||
- core: 12 # Partners, catálogos, monedas, UoM
|
||||
- financial: 15 # Contabilidad, facturas, pagos
|
||||
- inventory: 20 # 10 (inventory.sql) + 10 (inventory-extensions.sql)
|
||||
- purchase: 8 # Órdenes de compra, proveedores
|
||||
- sales: 10 # Órdenes de venta, clientes
|
||||
- projects: 10 # Proyectos, tareas, dependencias
|
||||
- hr: 6 # RRHH base, empleados
|
||||
- analytics: 7 # Centros de costo, cuentas analíticas
|
||||
- system: 13 # Mensajes, notificaciones, logs
|
||||
- billing: 11 # SaaS multi-tenant (opcional)
|
||||
- crm: 6 # CRM leads, opportunities (opcional)
|
||||
referencia: "apps/erp-core/database/ddl/"
|
||||
documento_herencia: "../database/HERENCIA-ERP-CORE.md"
|
||||
|
||||
# =============================================================================
|
||||
# RESUMEN DE OBJETOS (ACTUALIZADO 2025-12-08)
|
||||
# RESUMEN DE OBJETOS (ACTUALIZADO 2025-12-09)
|
||||
# =============================================================================
|
||||
resumen:
|
||||
schemas_core: 12 # Heredados de erp-core
|
||||
schemas_especificos: 3 # construccion, hr (ext), hse
|
||||
tablas_heredadas: 124 # Del core
|
||||
tablas_especificas: 33 # 2 construccion + 3 hr + 28 hse
|
||||
tablas_total: 157 # 124 + 33
|
||||
schemas_especificos: 7 # construction, hr, hse, estimates, infonavit, inventory-ext, purchase-ext
|
||||
tablas_heredadas: 144 # Del core (actualizado 2025-12-09)
|
||||
tablas_especificas: 110 # 24 construction + 8 hr + 58 hse + 8 estimates + 8 infonavit + 4 inventory + 5 purchase
|
||||
tablas_total: 254 # 144 + 110
|
||||
enums: 89 # 22 base + 67 HSE
|
||||
funciones: 13
|
||||
triggers: 15
|
||||
rls_policies: 157 # 1 policy por tabla con tenant_id
|
||||
indices: 250+
|
||||
rls_policies: 254 # 1 policy por tabla con tenant_id (144 core + 110 construcción)
|
||||
indices: 350+
|
||||
estado_implementacion:
|
||||
database_core: "100%" # ERP Core validado con carga limpia
|
||||
database_construccion: "100%" # DDL de construccion implementado
|
||||
database_construccion: "100%" # DDL completo - 7 schemas, 110 tablas
|
||||
backend: "5%" # Solo entidades base
|
||||
frontend: "2%" # Solo estructura
|
||||
ddl_files_core:
|
||||
@ -72,9 +72,13 @@ resumen:
|
||||
- "erp-core/database/ddl/11-crm.sql"
|
||||
- "erp-core/database/ddl/12-hr.sql"
|
||||
ddl_files_extension:
|
||||
- schemas/01-construction-schema-ddl.sql
|
||||
- schemas/02-hr-schema-ddl.sql
|
||||
- schemas/03-hse-schema-ddl.sql
|
||||
- schemas/01-construction-schema-ddl.sql # 24 tablas
|
||||
- schemas/02-hr-schema-ddl.sql # 8 tablas
|
||||
- schemas/03-hse-schema-ddl.sql # 58 tablas
|
||||
- schemas/04-estimates-schema-ddl.sql # 8 tablas
|
||||
- schemas/05-infonavit-schema-ddl.sql # 8 tablas
|
||||
- schemas/06-inventory-ext-schema-ddl.sql # 4 tablas
|
||||
- schemas/07-purchase-ext-schema-ddl.sql # 5 tablas
|
||||
|
||||
# =============================================================================
|
||||
# SCHEMAS - NOMENCLATURA UNIFICADA
|
||||
@ -1422,25 +1426,43 @@ schemas_deprecados:
|
||||
- documents_management: "usar 'documents' (pendiente)"
|
||||
|
||||
# =============================================================================
|
||||
# VALIDACION DDL (2025-12-08)
|
||||
# VALIDACION DDL (2025-12-09)
|
||||
# =============================================================================
|
||||
validacion_ddl:
|
||||
fecha: "2025-12-08"
|
||||
estado: "✅ CORREGIDO"
|
||||
total_correcciones: 50
|
||||
archivos_corregidos:
|
||||
fecha: "2025-12-09"
|
||||
estado: "✅ COMPLETO - 7 schemas, 110 tablas"
|
||||
total_archivos_ddl: 7
|
||||
archivos_ddl:
|
||||
- archivo: "schemas/01-construction-schema-ddl.sql"
|
||||
correcciones: 4
|
||||
detalle: "core.tenants → auth.tenants, core.users → auth.users"
|
||||
tablas: 24
|
||||
estado: "implementado"
|
||||
- archivo: "schemas/02-hr-schema-ddl.sql"
|
||||
correcciones: 4
|
||||
detalle: "core.tenants → auth.tenants, core.users → auth.users"
|
||||
tablas: 8
|
||||
estado: "implementado"
|
||||
- archivo: "schemas/03-hse-schema-ddl.sql"
|
||||
correcciones: 42
|
||||
detalle: "Todas las FK corregidas a auth.*"
|
||||
tablas: 58
|
||||
estado: "implementado"
|
||||
- archivo: "schemas/04-estimates-schema-ddl.sql"
|
||||
tablas: 8
|
||||
estado: "implementado"
|
||||
- archivo: "schemas/05-infonavit-schema-ddl.sql"
|
||||
tablas: 8
|
||||
estado: "implementado"
|
||||
- archivo: "schemas/06-inventory-ext-schema-ddl.sql"
|
||||
tablas: 4
|
||||
estado: "implementado"
|
||||
- archivo: "schemas/07-purchase-ext-schema-ddl.sql"
|
||||
tablas: 5
|
||||
estado: "implementado"
|
||||
alineacion_erp_core:
|
||||
rls_variable: "app.current_tenant_id"
|
||||
fk_tenants: "auth.tenants"
|
||||
fk_users: "auth.users"
|
||||
prerequisitos_verificados: true
|
||||
verificaciones_prerequisitos:
|
||||
- "DDL verifica existencia de auth.tenants"
|
||||
- "DDL verifica existencia de auth.users"
|
||||
- "DDL verifica existencia de schemas dependientes"
|
||||
- "ERP-Core debe estar instalado antes de ejecutar DDL"
|
||||
compatible_erp_core: true
|
||||
|
||||
@ -1450,11 +1472,11 @@ validacion_ddl:
|
||||
metadata:
|
||||
creado_por: Requirements-Analyst
|
||||
fecha_creacion: 2025-12-06
|
||||
ultima_actualizacion: 2025-12-08
|
||||
version_documento: 1.2.0
|
||||
ultima_actualizacion: 2025-12-09
|
||||
version_documento: 1.3.0
|
||||
cambios_version:
|
||||
- "1.3.0: DDL completo - 7 schemas, 110 tablas (2025-12-09)"
|
||||
- "1.3.0: Nuevos DDL: estimates, infonavit, inventory-ext, purchase-ext"
|
||||
- "1.3.0: Variable RLS corregida a app.current_tenant_id"
|
||||
- "1.2.0: Validacion DDL - 50 FK corregidas a auth.* (2025-12-08)"
|
||||
- "1.2.0: Verificaciones de prerequisitos actualizadas"
|
||||
- "1.1.0: Nomenclatura unificada segun NAMING-CONVENTIONS.md"
|
||||
- "1.1.0: Alineacion con DDL files reales"
|
||||
- "1.1.0: Schemas deprecados documentados"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# =============================================================================
|
||||
# MASTER INVENTORY - ERP CONSTRUCCION
|
||||
# =============================================================================
|
||||
# Ultima actualizacion: 2025-12-06
|
||||
# Ultima actualizacion: 2025-12-09
|
||||
# SSOT: Single Source of Truth para metricas del proyecto vertical
|
||||
# Base: Extiende erp-core (61% reutilizacion)
|
||||
# Nomenclatura: Ver NAMING-CONVENTIONS.md
|
||||
@ -57,7 +57,7 @@ metricas:
|
||||
fase_2_mae: 3
|
||||
fase_3_maa: 1
|
||||
documentados: 18 # Todos documentados incluyendo MAA-017
|
||||
ddl_implementado: 3 # construction, hr, hse
|
||||
ddl_implementado: 7 # construction, hr, hse, estimates, infonavit, inventory-ext, purchase-ext
|
||||
backend_parcial: 4 # construction, hr, hse, core (entidades básicas)
|
||||
|
||||
requerimientos:
|
||||
@ -77,15 +77,15 @@ metricas:
|
||||
story_points: 692 # +42 de MAA-017
|
||||
|
||||
database:
|
||||
# Conteo real basado en DDL files (actualizado 2025-12-08)
|
||||
schemas_implementados: 3 # construction, hr, hse
|
||||
schemas_pendientes: 4 # estimates, infonavit, inventory-ext, purchase-ext
|
||||
tablas_implementadas: 33 # 2 construction + 3 hr + 28 hse
|
||||
tablas_documentadas: 65 # Total en documentación
|
||||
# Conteo real basado en DDL files (actualizado 2025-12-09)
|
||||
schemas_implementados: 7 # construction, hr, hse, estimates, infonavit, inventory, purchase
|
||||
schemas_pendientes: 0 # Todos los schemas de Fase 1 implementados
|
||||
tablas_implementadas: 110 # 24 construction + 8 hr + 58 hse + 8 estimates + 8 infonavit + 4 inventory + 5 purchase
|
||||
tablas_documentadas: 110 # Total alineado con DDL
|
||||
enums: 89 # 22 base + 67 HSE
|
||||
funciones: 13
|
||||
triggers: 15
|
||||
rls_policies: 33 # 1 por tabla implementada
|
||||
rls_policies: 110 # 1 por tabla implementada
|
||||
|
||||
backend:
|
||||
# Estado actual del código TypeScript
|
||||
@ -767,25 +767,41 @@ proxima_accion:
|
||||
- Configuracion multi-tenant
|
||||
|
||||
# =============================================================================
|
||||
# VALIDACION DDL (2025-12-08)
|
||||
# VALIDACION DDL (2025-12-09)
|
||||
# =============================================================================
|
||||
validacion_ddl:
|
||||
fecha: "2025-12-08"
|
||||
estado: "✅ CORREGIDO"
|
||||
fecha: "2025-12-09"
|
||||
estado: "✅ COMPLETO - Alineado con erp-core"
|
||||
compatible_erp_core: true
|
||||
total_correcciones: 50
|
||||
archivos_corregidos:
|
||||
total_archivos_ddl: 5
|
||||
ddl_files:
|
||||
- archivo: "01-construction-schema-ddl.sql"
|
||||
correcciones: 4
|
||||
tablas: 24
|
||||
estado: "implementado"
|
||||
- archivo: "02-hr-schema-ddl.sql"
|
||||
correcciones: 4
|
||||
tablas: 8
|
||||
estado: "implementado"
|
||||
- archivo: "03-hse-schema-ddl.sql"
|
||||
correcciones: 42
|
||||
correcciones_aplicadas:
|
||||
- "core.tenants → auth.tenants"
|
||||
- "core.users → auth.users"
|
||||
- "Verificaciones de prerequisitos actualizadas"
|
||||
nota: "DDL ahora compatible con ERP-Core. Requiere ERP-Core instalado."
|
||||
tablas: 58
|
||||
estado: "implementado"
|
||||
- archivo: "04-estimates-schema-ddl.sql"
|
||||
tablas: 8
|
||||
estado: "implementado"
|
||||
- archivo: "05-infonavit-schema-ddl.sql"
|
||||
tablas: 8
|
||||
estado: "implementado"
|
||||
- archivo: "06-inventory-ext-schema-ddl.sql"
|
||||
tablas: 4
|
||||
estado: "implementado"
|
||||
- archivo: "07-purchase-ext-schema-ddl.sql"
|
||||
tablas: 5
|
||||
estado: "implementado"
|
||||
alineacion_erp_core:
|
||||
rls_variable: "app.current_tenant_id"
|
||||
fk_tenants: "auth.tenants"
|
||||
fk_users: "auth.users"
|
||||
prerequisitos_verificados: true
|
||||
nota: "Todos los DDL verificados y alineados con erp-core. Variable RLS corregida a app.current_tenant_id"
|
||||
|
||||
# =============================================================================
|
||||
# METADATA
|
||||
@ -793,11 +809,12 @@ validacion_ddl:
|
||||
metadata:
|
||||
creado_por: Requirements-Analyst
|
||||
fecha_creacion: 2025-12-06
|
||||
ultima_actualizacion: 2025-12-08
|
||||
version_documento: 1.2.0
|
||||
ultima_actualizacion: 2025-12-09
|
||||
version_documento: 1.3.0
|
||||
cambios_version:
|
||||
- "1.3.0: DDL completo - 7 schemas, 110 tablas implementadas (2025-12-09)"
|
||||
- "1.3.0: Nuevos DDL: estimates, infonavit, inventory-ext, purchase-ext"
|
||||
- "1.3.0: Variable RLS corregida a app.current_tenant_id (alineado erp-core)"
|
||||
- "1.2.0: Validacion DDL completada - 50 FK corregidas (2025-12-08)"
|
||||
- "1.2.0: Prerequisitos DDL actualizados para ERP-Core"
|
||||
- "1.1.0: Nomenclatura de schemas unificada segun NAMING-CONVENTIONS.md"
|
||||
- "1.1.0: Conteos corregidos segun DDL files reales"
|
||||
- "1.1.0: Tablas mapeadas por modulo"
|
||||
|
||||
@ -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
|
||||
8044
projects/erp-suite/apps/verticales/mecanicas-diesel/backend/package-lock.json
generated
Normal file
8044
projects/erp-suite/apps/verticales/mecanicas-diesel/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
@ -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();
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
@ -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[];
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) })),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
24
projects/erp-suite/apps/verticales/mecanicas-diesel/frontend/.gitignore
vendored
Normal file
24
projects/erp-suite/apps/verticales/mecanicas-diesel/frontend/.gitignore
vendored
Normal 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?
|
||||
@ -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*
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@ -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>
|
||||
3731
projects/erp-suite/apps/verticales/mecanicas-diesel/frontend/package-lock.json
generated
Normal file
3731
projects/erp-suite/apps/verticales/mecanicas-diesel/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@ -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 |
@ -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;
|
||||
@ -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 |
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export { MainLayout } from './MainLayout';
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { Header } from './Header';
|
||||
@ -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;
|
||||
}
|
||||
@ -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>,
|
||||
)
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
@ -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),
|
||||
};
|
||||
@ -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`),
|
||||
};
|
||||
@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -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),
|
||||
}));
|
||||
@ -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';
|
||||
@ -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
Loading…
Reference in New Issue
Block a user