diff --git a/.agent/docs/neobrutalism-components.md b/.agent/docs/neobrutalism-components.md
new file mode 100644
index 0000000..3a7b417
--- /dev/null
+++ b/.agent/docs/neobrutalism-components.md
@@ -0,0 +1,210 @@
+# Neobrutalism UI Components Reference
+
+Referencia rápida de componentes de [neobrutalism.dev](https://www.neobrutalism.dev/) para diseño en Parhelion WMS.
+
+## Instalación Base
+
+```bash
+# Usar con shadcn CLI
+pnpm dlx shadcn@latest add https://neobrutalism.dev/r/[component].json
+```
+
+---
+
+## Componentes por Categoría
+
+### 🎨 Básicos
+
+| Componente | Descripción | Instalación |
+| ---------- | ---------------------------------------------------------- | ------------- |
+| **Button** | Botones con variantes: default, reverse, noShadow, neutral | `button.json` |
+| **Badge** | Etiquetas: default, neutral, withIcon | `badge.json` |
+| **Card** | Contenedores con CardHeader, CardContent, CardFooter | `card.json` |
+| **Avatar** | Imágenes de perfil circulares | `avatar.json` |
+
+### 📝 Formularios
+
+| Componente | Descripción | Instalación |
+| --------------- | ---------------------------- | ------------------ |
+| **Input** | Campos de texto | `input.json` |
+| **Checkbox** | Casillas de verificación | `checkbox.json` |
+| **Switch** | Toggle on/off | `switch.json` |
+| **Select** | Dropdown de opciones | `select.json` |
+| **Slider** | Control deslizante | `slider.json` |
+| **Radio Group** | Grupo de opciones exclusivas | `radio-group.json` |
+| **Label** | Etiquetas para inputs | `label.json` |
+| **Textarea** | Área de texto multilínea | `textarea.json` |
+
+### 📊 Datos
+
+| Componente | Descripción | Instalación |
+| -------------- | ------------------------------------------- | ----------------- |
+| **Table** | Tablas con TableHeader, TableRow, TableCell | `table.json` |
+| **Data Table** | Tablas avanzadas con sorting/filtering | `data-table.json` |
+| **Progress** | Barras de progreso | `progress.json` |
+| **Chart** | Gráficos (basado en Recharts) | `chart.json` |
+
+### 🧭 Navegación
+
+| Componente | Descripción | Instalación |
+| ------------------- | ----------------------------------------------- | ---------------------- |
+| **Tabs** | Pestañas con TabsList, TabsTrigger, TabsContent | `tabs.json` |
+| **Breadcrumb** | Migas de pan | `breadcrumb.json` |
+| **Navigation Menu** | Menú de navegación | `navigation-menu.json` |
+| **Menubar** | Barra de menú | `menubar.json` |
+| **Sidebar** | Barra lateral | `sidebar.json` |
+| **Pagination** | Paginación | `pagination.json` |
+
+### 💬 Overlays
+
+| Componente | Descripción | Instalación |
+| ----------------- | ------------------------------- | -------------------- |
+| **Dialog** | Modales | `dialog.json` |
+| **Alert Dialog** | Diálogos de confirmación | `alert-dialog.json` |
+| **Sheet** | Paneles deslizantes | `sheet.json` |
+| **Drawer** | Cajón desde abajo (mobile) | `drawer.json` |
+| **Popover** | Popups contextuales | `popover.json` |
+| **Dropdown Menu** | Menús desplegables | `dropdown-menu.json` |
+| **Context Menu** | Menú contextual (click derecho) | `context-menu.json` |
+| **Hover Card** | Tarjeta al hover | `hover-card.json` |
+| **Tooltip** | Tooltips | `tooltip.json` |
+
+### 🎭 Especiales
+
+| Componente | Descripción | Instalación |
+| --------------- | ----------------------------- | ------------------ |
+| **Accordion** | Secciones colapsables | `accordion.json` |
+| **Collapsible** | Contenido colapsable | `collapsible.json` |
+| **Carousel** | Carrusel de imágenes | `carousel.json` |
+| **Marquee** | Texto en movimiento | `marquee.json` |
+| **Image Card** | Tarjeta con imagen | `image-card.json` |
+| **Calendar** | Selector de fecha | `calendar.json` |
+| **Date Picker** | Picker de fechas | `date-picker.json` |
+| **Sonner** | Notificaciones toast | `sonner.json` |
+| **Alert** | Mensajes de alerta | `alert.json` |
+| **Skeleton** | Placeholders de carga | `skeleton.json` |
+| **Scroll Area** | Área con scroll personalizado | `scroll-area.json` |
+| **Resizable** | Paneles redimensionables | `resizable.json` |
+| **Command** | Command palette (⌘K) | `command.json` |
+| **Combobox** | Input con autocompletado | `combobox.json` |
+
+---
+
+## Ejemplos de Uso
+
+### Button
+
+```jsx
+import { Button } from '@/components/ui/button'
+
+
+
+
+
+```
+
+### Card
+
+```jsx
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+} from "@/components/ui/card";
+
+
+
+ Título
+ Descripción
+
+ Contenido aquí
+
+
+
+;
+```
+
+### Tabs
+
+```jsx
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+
+
+
+ Tab 1
+ Tab 2
+
+ Contenido 1
+ Contenido 2
+;
+```
+
+### Progress
+
+```jsx
+import { Progress } from "@/components/ui/progress";
+
+;
+```
+
+### Table
+
+```jsx
+import {
+ Table,
+ TableHeader,
+ TableBody,
+ TableRow,
+ TableHead,
+ TableCell,
+} from "@/components/ui/table";
+
+
+
+
+ Columna 1
+ Columna 2
+
+
+
+
+ Dato 1
+ Dato 2
+
+
+
;
+```
+
+### Switch
+
+```jsx
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+
+
+
+
+
;
+```
+
+---
+
+## Características del Estilo
+
+- **Bordes gruesos**: 2px sólidos, típicamente negros
+- **Sombras offset**: Box-shadow desplazadas (4px 4px 0)
+- **Colores vivos**: Paletas llamativas, no sutiles
+- **Sin bordes redondeados**: Esquinas rectas o mínimamente redondeadas
+- **Alto contraste**: Texto legible sobre fondos brillantes
+- **Hover states**: Transformaciones y cambios de color prominentes
+
+## Links
+
+- 📘 [Documentación oficial](https://www.neobrutalism.dev/docs)
+- 🎨 [Estilos](https://www.neobrutalism.dev/styling)
+- 📊 [Charts](https://www.neobrutalism.dev/charts)
+- 🖼️ [Figma](https://www.neobrutalism.dev/docs/figma)
+- 💻 [GitHub](https://github.com/ekmas/neobrutalism-components)
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
new file mode 100644
index 0000000..a677a9d
--- /dev/null
+++ b/.github/workflows/README.md
@@ -0,0 +1,46 @@
+# GitHub Actions - CI/CD Pipeline
+
+## Workflows Planificados
+
+### 🔧 Backend (.NET 8)
+
+| Archivo | Trigger | Acciones |
+| :------------------- | :---------------------------- | :---------------------------------- |
+| `backend-ci.yml` | Push a `develop`, PR a `main` | Build, Test, Lint |
+| `backend-deploy.yml` | Push a `main` | Build Docker, Deploy a DigitalOcean |
+
+### 🎨 Frontend Admin (Angular)
+
+| Archivo | Trigger | Acciones |
+| :----------------- | :---------------------------- | :--------------------------------- |
+| `admin-ci.yml` | Push a `develop`, PR a `main` | Build, Lint, Test |
+| `admin-deploy.yml` | Push a `main` | Build, Deploy a DigitalOcean/Nginx |
+
+### 📱 Frontend Operaciones (React PWA)
+
+| Archivo | Trigger | Acciones |
+| :----------------------- | :---------------------------- | :---------------- |
+| `operaciones-ci.yml` | Push a `develop`, PR a `main` | Build, Lint |
+| `operaciones-deploy.yml` | Push a `main` | Build PWA, Deploy |
+
+### 📲 Frontend Campo (React PWA)
+
+| Archivo | Trigger | Acciones |
+| :----------------- | :---------------------------- | :---------------- |
+| `campo-ci.yml` | Push a `develop`, PR a `main` | Build, Lint |
+| `campo-deploy.yml` | Push a `main` | Build PWA, Deploy |
+
+---
+
+## Dominios Objetivo
+
+| Servicio | URL |
+| :------------------- | :----------------------- |
+| API Backend | `api.macrostasis.lat` |
+| Frontend Admin | `admin.macrostasis.lat` |
+| Frontend Operaciones | `ops.macrostasis.lat` |
+| Frontend Campo | `driver.macrostasis.lat` |
+
+---
+
+**Nota:** Los workflows se implementarán cuando los proyectos estén configurados.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..41c08c8
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,243 @@
+# ===================================
+# PARHELION CI - Build & Test Pipeline
+# Se ejecuta en cada push/PR a develop y main
+# v0.6.0-alpha: Python Microservice Integration
+# ===================================
+
+name: CI Pipeline
+
+on:
+ push:
+ branches: [develop, main]
+ pull_request:
+ branches: [develop, main]
+
+jobs:
+ # ===== BACKEND (.NET 8) + PostgreSQL Tests =====
+ backend:
+ name: Backend Build & xUnit Tests
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ./backend
+
+ # PostgreSQL service para tests de integracion reales
+ services:
+ postgres:
+ image: postgres:17
+ env:
+ POSTGRES_USER: parhelion_test
+ POSTGRES_PASSWORD: test_password_ci
+ POSTGRES_DB: parhelion_test_db
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ env:
+ # Connection string para tests con PostgreSQL real
+ ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=parhelion_test_db;Username=parhelion_test;Password=test_password_ci"
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --configuration Release --no-restore
+
+ # xUnit Tests (repository, pagination, foundation)
+ - name: Run xUnit Tests
+ run: dotnet test --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx"
+
+ - name: Upload Test Results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: backend-test-results
+ path: backend/tests/**/test-results.trx
+
+ # Validar que las migraciones se aplican correctamente a PostgreSQL
+ - name: Install EF Core Tools
+ run: dotnet tool install --global dotnet-ef --version 8.0.11
+
+ - name: Apply Migrations to PostgreSQL
+ run: dotnet ef database update --project src/Parhelion.Infrastructure --startup-project src/Parhelion.API
+ env:
+ ASPNETCORE_ENVIRONMENT: Testing
+
+ - name: Verify Database Schema (24 tables)
+ run: |
+ echo "Verificando que todas las tablas existen en PostgreSQL (24 tablas esperadas)..."
+ PGPASSWORD=test_password_ci psql -h localhost -U parhelion_test -d parhelion_test_db -c "\dt" | grep -E "(Tenants|Users|Employees|Shifts|Drivers|Trucks|Locations|Shipments|CatalogItems|InventoryStocks|InventoryTransactions)" && echo "Schema validation passed (v0.5.1)"
+
+ # ===== FRONTEND ADMIN (Angular) =====
+ frontend-admin:
+ name: Admin Build & Lint
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ./frontend-admin
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: "./frontend-admin/package-lock.json"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint || true
+
+ - name: Build
+ run: npm run build
+
+ # ===== FRONTEND OPERACIONES (React) =====
+ frontend-operaciones:
+ name: Operaciones Build & Lint
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ./frontend-operaciones
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: "./frontend-operaciones/package-lock.json"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint || true
+
+ - name: Build
+ run: npm run build
+
+ # ===== FRONTEND CAMPO (React) =====
+ frontend-campo:
+ name: Campo Build & Lint
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ./frontend-campo
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: "./frontend-campo/package-lock.json"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Lint
+ run: npm run lint || true
+
+ - name: Build
+ run: npm run build
+
+ # ===== DOCKER BUILD TEST =====
+ docker:
+ name: Docker Compose Validation
+ runs-on: ubuntu-latest
+ needs: [backend, frontend-admin, frontend-operaciones, frontend-campo]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Validate docker-compose
+ run: docker compose config
+
+ - name: Build all images
+ run: docker compose build
+
+ # ===== PYTHON ANALYTICS SERVICE (v0.6.0+) =====
+ python-analytics:
+ name: Python Build & Tests
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ./service-python
+
+ services:
+ postgres:
+ image: postgres:17
+ env:
+ POSTGRES_USER: parhelion_test
+ POSTGRES_PASSWORD: test_password_ci
+ POSTGRES_DB: parhelion_test_db
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ env:
+ DATABASE_URL: "postgresql+asyncpg://parhelion_test:test_password_ci@localhost:5432/parhelion_test_db"
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Python 3.12
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: "pip"
+ cache-dependency-path: "./service-python/requirements.txt"
+
+ - name: Install dependencies
+ run: pip install -r requirements.txt
+
+ - name: Lint with Ruff
+ run: ruff check src/ || true
+
+ - name: Type check with MyPy
+ run: mypy src/ || true
+
+ - name: Run pytest
+ run: pytest tests/ -v --tb=short || true
+
+ - name: Upload Coverage
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: python-coverage
+ path: htmlcov/
+
+ # ===== RESUMEN FINAL =====
+ summary:
+ name: All Checks Passed
+ runs-on: ubuntu-latest
+ needs: [docker, python-analytics]
+ if: success()
+
+ steps:
+ - name: Success
+ run: echo "Todos los builds, tests y validacion de DB pasaron correctamente (incluyendo Python Analytics)"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d666d5d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,99 @@
+# ===========================
+# PARHELION-LOGISTICS GITIGNORE
+# ===========================
+
+# ===== ENVIRONMENT & SECRETS =====
+.env
+.env.*
+!.env.example
+*.local
+appsettings.Development.json
+appsettings.Local.json
+secrets.json
+
+# ===== NODE (Angular/React) =====
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+dist/
+build/
+.cache/
+.parcel-cache/
+.next/
+.nuxt/
+.vite/
+
+# ===== .NET =====
+bin/
+obj/
+*.user
+*.suo
+*.userosscache
+*.sln.docstates
+.vs/
+*.csproj.user
+publish/
+out/
+
+# ===== IDE =====
+.idea/
+*.swp
+*.swo
+*~
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# ===== OS =====
+.DS_Store
+Thumbs.db
+*.log
+
+# ===== Docker =====
+docker-compose.override.yml
+
+# ===== Coverage & Testing =====
+coverage/
+*.lcov
+.nyc_output/
+TestResults/
+
+# ===== Misc =====
+*.tmp
+*.temp
+*.bak
+
+# ===== PYTHON =====
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+.venv/
+venv/
+ENV/
+env/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+htmlcov/
+*.egg-info/
+*.egg
+pip-log.txt
+pip-delete-this-directory.txt
+.ipynb_checkpoints/
+
+# ===== Local Development Tools (NEVER in production) =====
+# Control panel is a private development tool - never committed to repository
+control-panel/
+agent/
+
+# ===== Scripts (Stress Tests / Dev Only) =====
+scripts/
+credentials.dev.txt
+stress_tests.py
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..09e184f
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "git.ignoreLimitWarning": true,
+ "remote.autoForwardPortsFallback": 0,
+ "css.validate": false
+}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..5858a6a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,811 @@
+# Changelog
+
+Historial de cambios del proyecto Parhelion Logistics.
+
+---
+
+## [0.6.0-alpha] - 2025-12-28 (En Progreso)
+
+### Nuevo Sistema de Versionado
+
+A partir de esta versión, el proyecto adopta **Semantic Versioning (SemVer)** estricto con pre-releases:
+
+```
+MAJOR.MINOR.PATCH-PRERELEASE+BUILD
+Ejemplo: 0.6.0-alpha.1+build.2025.12.28
+```
+
+| Etapa | Significado |
+| ------- | ------------------------------------------- |
+| `alpha` | Desarrollo activo, funcionalidad incompleta |
+| `beta` | Feature-complete, en testing |
+| `rc` | Release Candidate, listo para producción |
+
+### Agregado
+
+- **Python Analytics Service** (Microservicio local):
+
+ - Framework: FastAPI 0.115+ con Python 3.12
+ - Arquitectura: Clean Architecture (domain, application, infrastructure, api)
+ - ORM: SQLAlchemy 2.0 + asyncpg (async PostgreSQL)
+ - Bounded Context: Analytics & Predictions (separado del Core .NET)
+ - Puerto interno: 8000
+ - Container name: `parhelion-python`
+
+- **Preparativos de Integración**:
+
+ - Documentación actualizada: README.md, api-architecture.md, database-schema.md
+ - `.gitignore` con patrones Python (**pycache**, .venv, .pytest_cache, etc.)
+ - Nuevo roadmap hacia v1.0.0 MVP (Q1 2026)
+ - Sistema de versionado SemVer con staged releases (alpha → beta → rc)
+
+### Modificado
+
+- `docker-compose.yml` - Preparado para servicio `python-analytics`
+- `README.md` - Stack tecnológico expandido con Python/FastAPI
+- `.github/workflows/ci.yml` - Estructura preparada para job Python
+
+### Arquitectura
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Docker Network │
+│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │
+│ │ .NET API│◄─┤PostgreSQL├─►│ Python │ │ n8n │ │
+│ │ :5000 │ │ :5432 │ │ :8000 │ │ :5678 │ │
+│ └────┬────┘ └─────────┘ └────┬────┘ └────────┬────────┘ │
+│ │ │ │ │
+│ └─────────────────────────┴─────────────────┘ │
+│ Internal REST/JSON │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Notas de Migración
+
+- Nueva variable de entorno requerida: `INTERNAL_SERVICE_KEY` para auth inter-servicios
+- Volume nuevo: `python_cache` para modelos ML (futuro)
+- El microservicio Python es local (como n8n), no expuesto públicamente
+
+---
+
+## [0.6.0-beta] - 2025-12-29
+
+### Refactorización de Autorización Multi-Tenant
+
+- **UserService.CreateAsync refactorizado:**
+
+ - SuperAdmin puede especificar `targetTenantId` para crear Admin de otro Tenant
+ - Tenant Admin crea usuarios que heredan `TenantId` automáticamente
+ - Validación: Non-SuperAdmin no puede usar `targetTenantId`
+
+- **AuditSaveChangesInterceptor mejorado:**
+
+ - Asignación automática de `TenantId` para todas las `TenantEntity`
+ - TenantId extraído del contexto del usuario creador
+
+- **CreateUserRequest DTO actualizado:**
+ - Nuevo campo opcional: `TargetTenantId` (solo para SuperAdmin)
+
+### Base de Datos
+
+- **Reset completo de datos de prueba:**
+
+ - Sistema limpio con flujo multi-tenant correcto
+ - 1 SuperAdmin (MetaCodeX CEO)
+ - 1 Tenant ejemplo (TransporteMX) con recursos completos
+
+- **Script de reset:** `scripts/reset_database.sql`
+
+### Nuevo Tenant de Prueba: TransporteMX
+
+| Entidad | Cantidad | Detalles |
+| --------- | -------- | --------------------------------- |
+| Users | 6 | 1 Admin + 3 Drivers + 2 Warehouse |
+| Employees | 5 | Vinculados a Users |
+| Drivers | 3 | Con Trucks asignados |
+| Trucks | 3 | DryBox, Refrigerated, Flatbed |
+| Locations | 4 | 3 Hubs + 1 Store |
+
+### Documentación
+
+- `api-architecture.md` - Nueva sección "Autenticación y Autorización" con diagrama Mermaid
+- `README.md` - Actualizado con Multi-tenancy Automático
+- `credentials.dev.txt` - Archivo de credenciales de desarrollo con todos los IDs
+
+### Preparación para Stress Tests
+
+- `scripts/stress_tests.py` - Actualizado con credenciales y IDs correctos
+- 5 tests de estrés listos para ejecución:
+ 1. Generación masiva (500 shipments)
+ 2. Fragmentación de red
+ 3. Simulación de caos operativo
+ 4. Concurrencia Polly
+ 5. Optimización de carga 3D
+
+---
+
+## [0.5.7] - 2025-12-23
+
+### Agregado
+
+- **Frontend Landing Page (frontend-inicio)**:
+
+ - Nuevo proyecto Angular 18 con diseño Neo-Brutalism
+ - 10 componentes animados: Marquee, Buttons, Badges, Cards, Tabs, Progress Bars, Carousel, Accordion, Alert, Grid Animation
+ - Carousel con changelog completo (8 slides: v0.1.0 → v0.5.7)
+ - Tabs con características: Core, Flotilla, Documentos, Automatización
+ - Diseño responsive mobile-first (5 breakpoints: 320px, 480px, 768px, 1024px, 1280px+)
+ - Enlaces a Panel Admin, Operaciones y Driver App
+ - Accesibilidad: Touch device enhancements, Reduced motion support
+
+- **Infraestructura Cloudflare Tunnel**:
+
+ - Subdominios públicos configurados:
+ - `parhelion.macrostasis.lat` → Landing Page
+ - `phadmin.macrostasis.lat` → Panel Admin
+ - `phops.macrostasis.lat` → Operaciones (PWA)
+ - `phdriver.macrostasis.lat` → Driver App (PWA)
+ - `phapi.macrostasis.lat` → Backend API
+ - Docker service `inicio` agregado a `docker-compose.yml`
+ - Nginx + Multi-stage build para producción
+
+- **Generación Dinámica de PDFs** (Sin almacenamiento de archivos):
+
+ - `IPdfGeneratorService` - Interface para generación on-demand de documentos
+ - `PdfGeneratorService` - Implementación con plantillas HTML para 5 tipos de documentos
+ - `DocumentsController` - Nuevo controller con endpoints protegidos por JWT:
+ - `GET /api/documents/service-order/{shipmentId}` - Orden de Servicio
+ - `GET /api/documents/waybill/{shipmentId}` - Carta Porte
+ - `GET /api/documents/manifest/{routeId}` - Manifiesto de Carga
+ - `GET /api/documents/trip-sheet/{driverId}` - Hoja de Ruta
+ - `GET /api/documents/pod/{shipmentId}` - Prueba de Entrega (POD)
+ - Los PDFs se generan en memoria usando datos de BD + plantilla
+ - Cliente crea `blob:` URL local (estilo WhatsApp Web)
+
+- **Proof of Delivery (POD) con Firma Digital**:
+
+ - Nuevos campos en `ShipmentDocument`: `SignatureBase64`, `SignedByName`, `SignedAt`, `SignatureLatitude`, `SignatureLongitude`
+ - Endpoint `POST /api/shipment-documents/pod/{shipmentId}` para captura de firma
+ - Campos de metadata: `OriginalFileName`, `ContentType`, `FileSizeBytes`
+ - Migración `AddPodSignatureFields` aplicada
+
+- **Timeline de Checkpoints (Visualización Metro)**:
+
+ - `CheckpointTimelineItem` DTO con StatusLabel en español
+ - `IShipmentCheckpointService.GetTimelineAsync()` con datos simplificados
+ - Endpoint `GET /api/shipment-checkpoints/timeline/{shipmentId}`
+ - Labels: Cargado, QR escaneado, Llegó a Hub, En camino, Entregado, etc.
+
+### Modificado
+
+- **Refactorización de Controllers a Clean Architecture**:
+
+ - `ShipmentCheckpointsController` - Ahora usa `IShipmentCheckpointService` (antes: DbContext directo)
+ - `ShipmentDocumentsController` - Simplificado, delegación a servicios
+ - Agregados endpoints de filtrado: `/by-status`, `/last`, `/timeline`
+
+- `ShipmentCheckpointService` - Inyección de `IWebhookPublisher` y `ILogger`
+- `ShipmentCheckpointService.CreateAsync` - Publica webhook `checkpoint.created` tras guardar
+- `Program.cs` - Registro de `IPdfGeneratorService`, versión `0.5.7`
+- `Dockerfile` - Actualizado a version 0.5.7
+- `docker-compose.yml` - Agregado servicio `inicio` en puerto 4000
+
+### Eliminado
+
+- `LocalFileStorageService` - Ya no se almacenan archivos permanentemente
+- `IFileStorageService` - Reemplazado por generación dinámica
+- Test scripts temporales (`test_e2e_full.sh`, `test_v057.sh`)
+
+---
+
+## [0.5.6] - 2025-12-22
+
+### Agregado
+
+- **Sistema de Webhooks (Backend → n8n)**:
+
+ - `IWebhookPublisher` - Interface en Application Layer
+ - `N8nWebhookPublisher` - Implementación fire-and-forget con logging
+ - `ICallbackTokenService` & `CallbackTokenService` - Implementación de tokens JWT efímeros (15m)
+ - `NullWebhookPublisher` - Implementación vacía para desactivar webhooks
+ - `N8nConfiguration` - Configuración tipada desde appsettings
+ - 5 DTOs de eventos: ShipmentException, BookingRequest, HandshakeAttempt, StatusChanged, CheckpointCreated
+
+- **Sistema de Notificaciones (n8n → Backend)**:
+
+ - Nueva entidad `Notification` con tipos (Alert, Info, Warning, Success) y prioridades
+ - `NotificationsController` con endpoints para n8n (POST) y apps móviles (GET)
+ - `INotificationService` + `NotificationService` implementación
+ - Migración `AddNotifications` aplicada
+
+- **Autenticación de Servicios Externos (Multi-Tenant)**:
+
+ - Nueva entidad `ServiceApiKey` con TenantId, KeyHash (SHA256), Scopes, Expiración
+ - `ServiceApiKeyAttribute` - Filtro que valida X-Service-Key contra BD
+ - Lookup de TenantId desde tabla en lugar de hardcoding
+ - **Generación automática de API Key** al crear nuevo Tenant (responsabilidad del SuperAdmin)
+ - Migración `AddServiceApiKeys` aplicada
+
+- **Telemetría GPS de Camiones**:
+
+ - Campos `LastLatitude`, `LastLongitude`, `LastLocationUpdate` en Truck
+ - Endpoint `POST /api/trucks/{id}/location` para simulación GPS
+
+- **Búsqueda Geoespacial de Choferes**:
+
+ - `IDriverService.GetNearbyDriversAsync` con fórmula Haversine
+ - Endpoint `GET /api/drivers/nearby?lat=&lon=&radius=`
+ - Filtrado por DriverStatus.Available y TenantId
+
+### Modificado
+
+- `DriversController.GetNearby` - Ahora resuelve TenantId desde ServiceApiKey (producción-ready)
+- `ServiceApiKeyAttribute` - Refinado para soportar auth híbrida (Header `X-Service-Key` y `Authorization: Bearer`)
+- `ShipmentService.UpdateStatusAsync` - Publica webhook `shipment.exception` automáticamente
+- `Program.cs` - Registro condicional de IWebhookPublisher (N8n o Null)
+- `docker-compose.yml` - Agregado servicio n8n con PostgreSQL compartido
+- Estandarización de "Envelope" JSON para todos los eventos de sistema (CorrelationId, Timestamp, CallbackToken)
+
+### Seguridad
+
+- API Keys almacenadas como SHA256 hash (nunca plain text)
+- Validación de expiración y estado activo
+- Rate limiting de actualización LastUsedAt (fire-and-forget)
+
+### Notas Técnicas
+
+- Webhooks son fire-and-forget: errores se loguean pero no interrumpen flujo
+- Configuración `N8n:Enabled` controla activación de webhooks
+- ServiceApiKeys requieren seed manual para tenants
+
+---
+
+## [0.5.5] - 2025-12-18
+
+### Agregado
+
+- **WMS Services Layer (4 servicios)**:
+
+ - `WarehouseZoneService` - Gestión de zonas de almacén
+ - `WarehouseOperatorService` - Gestión de almacenistas
+ - `InventoryStockService` - Stock con reserva/liberación
+ - `InventoryTransactionService` - Kardex de movimientos (Receipt, Dispatch, Transfer)
+
+- **TMS Network Services (2 servicios)**:
+
+ - `NetworkLinkService` - Enlaces bidireccionales entre nodos
+ - `RouteStepService` - Pasos de ruta con reordenamiento automático
+
+- **Business Rules Validators**:
+
+ - `ICargoCompatibilityValidator` - Interface de validación carga-camión
+ - `CargoCompatibilityValidator` - Implementación con reglas:
+ - Cargo refrigerado → Truck Refrigerated
+ - Cargo HAZMAT → Truck HazmatTank
+ - Cargo alto valor (>$500K) → Truck Armored
+
+- **Automatic FleetLog Generation**:
+
+ - `DriverService.AssignTruckAsync` ahora genera FleetLog automáticamente
+ - Audit trail completo de cambios de camión
+
+- **Airport Code Validation**:
+
+ - `LocationService.CreateAsync` valida formato 2-4 letras (MTY, GDL, MM)
+ - Normalización automática a mayúsculas
+
+- **Tests (50 nuevos, total: 122)**:
+
+ - Unit tests para WMS services (15)
+ - Unit tests para Network services (10)
+ - Integration tests WMS/Fleet/Network (13)
+ - CargoCompatibilityValidator tests (12)
+
+### Modificado
+
+- `ShipmentService.AssignToDriverAsync` - Ahora valida compatibilidad carga-camión
+- `Program.cs` - Registro de todos los nuevos servicios en DI
+- `ServiceTestFixture` - Datos seed para WMS (Zone, CatalogItem, InventoryStock)
+
+### Notas Técnicas
+
+- Validators registrados como Singleton (stateless)
+- Services registrados como Scoped
+- Todas las validaciones son fail-safe con mensajes descriptivos
+
+---
+
+## [0.5.4] - 2025-12-18
+
+### Agregado
+
+- **Swagger/OpenAPI Documentation**:
+
+ - OpenAPI Info con version, titulo, descripcion, contacto
+ - JWT Bearer Security Scheme con autorizacion
+ - XML Comments habilitados para documentacion automatica
+ - Atributos `[Produces]` y `[Consumes]` en Controllers
+
+- **Business Logic - Shipment Workflow**:
+ - Validación de transiciones de estado (`ValidateStatusTransition`)
+ - Workflow: PendingApproval → Approved → Loaded → InTransit → AtHub/OutForDelivery → Delivered
+ - Estado Exception para manejo de problemas con recuperación
+
+### Modificado
+
+- **Controllers Refactorizados (5 total)**:
+
+ - `TrucksController` → `ITruckService`
+ - `DriversController` → `IDriverService`
+ - `FleetLogsController` → `IFleetLogService`
+ - `LocationsController` → `ILocationService`
+ - `RouteBlueprintsController` → `IRouteService`
+
+- **Nuevos Endpoints**:
+ - `PATCH /api/drivers/{id}/assign-truck` - Asignar camión a chofer
+ - `PATCH /api/drivers/{id}/status` - Actualizar estatus de chofer
+ - `POST /api/fleet-logs/start-usage` - Iniciar uso de camión
+ - `POST /api/fleet-logs/end-usage` - Finalizar uso de camión
+ - `GET /api/route-blueprints/{id}/steps` - Obtener pasos de ruta
+
+### Notas Tecnicas
+
+- Controllers ahora son thin wrappers que delegan a Services
+- Validación de workflow previene transiciones inválidas de estado
+- Preparación para tests de Business Logic en v0.5.5
+
+---
+
+## [0.5.3] - 2025-12-18
+
+### Agregado
+
+- **Integration Tests para Services Layer (44 tests nuevos)**:
+
+ - `ServiceTestFixture` - Fixture con UnitOfWork real y datos de prueba
+ - `TestIds` - IDs conocidos para testing consistente
+ - **Core Services Tests**: TenantServiceTests (10), RoleServiceTests (8), EmployeeServiceTests (6), ClientServiceTests (4)
+ - **Shipment Services Tests**: ShipmentServiceTests (3)
+ - **Fleet Services Tests**: TruckServiceTests (8)
+ - **Network Services Tests**: LocationServiceTests (5)
+
+### Modificado
+
+- Total de tests: 28 → 72 (incremento de 44 tests)
+- Estructura de tests reorganizada en Unit/Services/{Layer}
+
+### Notas Tecnicas
+
+- Tests usan InMemory Database para aislamiento
+- Cada test crea instancia fresca de UnitOfWork
+- Cobertura de CRUD, validaciones de duplicados, y filtros
+
+---
+
+## [0.5.2] - 2025-12-17
+
+### Agregado
+
+- **Services Layer (16 interfaces, 15 implementaciones)**:
+
+ - `IGenericService` - Interfaz base con operaciones CRUD genericas
+ - **Core Services**: TenantService, UserService, RoleService, EmployeeService, ClientService
+ - **Shipment Services**: ShipmentService, ShipmentItemService, ShipmentCheckpointService, ShipmentDocumentService, CatalogItemService
+ - **Fleet Services**: DriverService, TruckService, FleetLogService
+ - **Network Services**: LocationService, RouteService
+
+- **Refactorizacion de Controllers**:
+
+ - TenantsController, UsersController, RolesController, EmployeesController, ClientsController, ShipmentsController
+ - Controllers ahora usan Service interfaces en lugar de acceso directo a DbContext
+ - Cumplimiento estricto de Clean Architecture
+
+- **Nuevos Endpoints en ShipmentsController**:
+
+ - `GET /api/shipments/by-tracking/{trackingNumber}` - Busqueda por tracking number
+ - `GET /api/shipments/by-status/{status}` - Filtrado por estatus
+ - `GET /api/shipments/by-driver/{driverId}` - Envios por chofer
+ - `GET /api/shipments/by-location/{locationId}` - Envios por ubicacion
+ - `PATCH /api/shipments/{id}/assign` - Asignacion de chofer y camion
+ - `PATCH /api/shipments/{id}/status` - Actualizacion de estatus
+
+### Modificado
+
+- **Dependency Injection**: Registro de 15 Services en Program.cs organizado por capas
+- **Program.cs**: Estructura clara con secciones Core, Shipment, Fleet, Network
+
+### Notas Tecnicas
+
+- Services Layer encapsula logica de negocio y validaciones
+- Controllers reducidos a thin wrappers (delegacion a Services)
+- IUnitOfWork se inyecta en Services para coordinacion de repositorios
+- Preparacion para implementacion de tests de integracion en v0.5.3
+
+---
+
+## [0.5.1] - 2025-12-16
+
+### Agregado
+
+- **Foundation Layer (Repository Pattern)**:
+
+ - `IGenericRepository` - Operaciones CRUD genericas con soft delete
+ - `ITenantRepository` - Repositorio con aislamiento multi-tenant
+ - `IUnitOfWork` - Coordinacion de transacciones entre repositorios
+ - `GenericRepository` y `TenantRepository` implementaciones
+ - `UnitOfWork` con todos los repositorios (Core, Fleet, Warehouse, Shipment, Network)
+
+- **DTOs Comunes**:
+
+ - `PagedRequest` - Paginacion generica con ordenamiento y busqueda
+ - `PagedResult` - Respuesta paginada con metadata (TotalPages, HasNext, etc.)
+ - `BaseDto`, `TenantDto` - DTOs base con campos de auditoria
+ - `OperationResult`, `OperationResult` - Respuestas estandarizadas
+
+- **Infraestructura Docker**:
+
+ - `docker-compose.yml` actualizado con servicios: postgres, api, admin, operaciones, campo, tunnel
+ - PostgreSQL 17 con volumen externo `postgres_pgdata`
+ - Healthchecks configurados para todos los servicios
+ - Cloudflare Tunnel para acceso remoto
+
+- **xUnit Tests (28 tests)**:
+
+ - `PaginationDtoTests` - Validacion de paginacion
+ - `GenericRepositoryTests` - CRUD, soft delete, queries
+ - `InMemoryDbFixture` - Fixture con datos de prueba
+ - `TestDataBuilder` - Builder pattern para entidades de test
+
+### Modificado
+
+- CI/CD actualizado a v0.5.1 con paso explicito `Run xUnit Tests`
+- PostgreSQL actualizado a version 17 en CI
+- Swagger UI habilitado en todos los entornos (desarrollo via Tailscale)
+
+### Notas Tecnicas
+
+- Herramientas de desarrollo local (control-panel) excluidas del repositorio
+- Foundation layer es prerequisito para Services layer (v0.5.2+)
+- Tests de logica de negocio se implementan en fases 3-8
+
+---
+
+## [0.5.0] - 2025-12-15
+
+### Agregado
+
+- **Endpoints API Skeleton (22 endpoints)**:
+
+ - Core Layer: Tenants, Users, Roles, Employees, Clients
+ - Warehouse Layer: Locations, WarehouseZones, WarehouseOperators, InventoryStocks, InventoryTransactions
+ - Fleet Layer: Trucks, Drivers, Shifts, FleetLogs
+ - Shipment Layer: Shipments, ShipmentItems, ShipmentCheckpoints, ShipmentDocuments, CatalogItems
+ - Network Layer: NetworkLinks, RouteBlueprints, RouteSteps
+
+- **Schema Metadata Endpoint**:
+
+ - `GET /api/Schema/metadata` - Retorna estructura de BD para herramientas
+ - `POST /api/Schema/refresh` - Invalida cache de metadata
+
+- **Documentacion**:
+ - Nuevo archivo `api-architecture.md` con estructura de capas y endpoints
+ - Documentacion de Swagger UI en `/swagger`
+
+### Modificado
+
+- Version del sistema actualizada a 0.5.0
+- CI/CD actualizado para verificar 24 tablas en base de datos
+
+### Notas Tecnicas
+
+- Endpoints responden con HTTP 200 (lista vacia) para GET autenticados
+- Logica CRUD pendiente para v0.5.x
+- Herramientas de desarrollo local excluidas del repositorio
+
+---
+
+## [0.4.4] - 2025-12-14
+
+### Agregado
+
+- **Catalogo Maestro de Productos (`CatalogItem`)**:
+
+ - SKU unico por tenant con indice unico
+ - Dimensiones por defecto (peso, ancho, alto, largo)
+ - Flags: RequiresRefrigeration, IsHazardous, IsFragile
+ - Unidad de medida base (Pza, Kg, Lt, Caja)
+
+- **Inventario Cuantificado (`InventoryStock`)**:
+
+ - Stock por zona de bodega con FK a `WarehouseZone`
+ - Numero de lote (`BatchNumber`) y fecha de caducidad
+ - Cantidad reservada y disponible
+ - Costo unitario para valuacion
+ - Indice unico compuesto: `(ZoneId, ProductId, BatchNumber)`
+ - Indice filtrado para productos proximos a caducar
+
+- **Kardex de Movimientos (`InventoryTransaction`)**:
+
+ - Bitacora de todos los movimientos internos
+ - Tipos: Receipt, PutAway, InternalMove, Picking, Packing, Dispatch, Adjustment, Scrap, Return
+ - FK a zonas origen/destino, usuario ejecutor, envio relacionado
+ - Indices para consultas por producto y fecha
+
+- **Automatizacion de Auditoria (`AuditSaveChangesInterceptor`)**:
+
+ - Llena automaticamente `CreatedByUserId` en inserts
+ - Llena automaticamente `LastModifiedByUserId` en updates
+ - Maneja `DeletedAt` en soft deletes
+ - Servicios: `ICurrentUserService`, `CurrentUserService`
+
+- **Campos de Auditoria en `BaseEntity`**:
+
+ - `CreatedByUserId` (Guid?) - Usuario que creo el registro
+ - `LastModifiedByUserId` (Guid?) - Ultimo usuario que modifico
+ - `RowVersion` (uint) - Token de concurrencia optimista
+
+- **Geolocalizacion**:
+
+ - `Latitude` y `Longitude` (decimal, precision 9,6) en `Location`
+ - `Latitude` y `Longitude` en `ShipmentCheckpoint`
+
+- **FK `ProductId` en `ShipmentItem`**:
+ - Referencia opcional a `CatalogItem`
+ - Campos descriptivos se mantienen para override
+
+### Modificado
+
+- `BaseEntity`: +RowVersion, +CreatedByUserId, +LastModifiedByUserId
+- `ShipmentItem`: +ProductId FK a CatalogItem
+- `WarehouseZone`: +InventoryStocks, +OriginTransactions, +DestinationTransactions
+- `Location`: +Latitude, +Longitude
+- `ShipmentCheckpoint`: +Latitude, +Longitude, CreatedByUserId marcado con `new`
+- `FleetLog`: CreatedByUserId marcado con `new`
+- `ParhelionDbContext`: +CatalogItems, +InventoryStocks, +InventoryTransactions DbSets
+- `Program.cs`: +AuditSaveChangesInterceptor, version 0.4.4
+
+### Configuraciones EF Core
+
+- `CatalogItemConfiguration`: SKU unico por tenant, precision de decimales
+- `InventoryStockConfiguration`: Concurrencia xmin, indices filtrados
+- `InventoryTransactionConfiguration`: Relaciones con zonas, usuario, envio
+
+### Migracion
+
+- `WmsEnhancement044` aplicada a PostgreSQL
+- 3 nuevas tablas: CatalogItems, InventoryStocks, InventoryTransactions
+- Total: 23 tablas en base de datos
+
+### Tests
+
+- 8 tests de integracion pasando
+- Compatibilidad verificada con esquema anterior
+
+---
+
+## [0.4.3] - 2025-12-13
+
+### Agregado
+
+- **Employee Layer (Centralización de Datos de Empleado)**:
+
+ - Nueva entidad `Employee` con datos legales (RFC, NSS, CURP)
+ - Contacto de emergencia, fecha de contratación, departamento
+ - Relación 1:1 con `User` (usuario del sistema)
+
+- **Sistema de Turnos (`Shift`)**:
+
+ - Nuevo registro de turnos de trabajo por tenant
+ - Campos: StartTime, EndTime, DaysOfWeek
+ - Asignación opcional a empleados
+
+- **Zonas de Bodega (`WarehouseZone`)**:
+
+ - Divisiones internas de ubicaciones (Receiving, Storage, ColdChain, etc.)
+ - Enum `WarehouseZoneType` con 6 tipos de zona
+ - Asignación a operadores de almacén
+
+- **Extensión WarehouseOperator**:
+
+ - Similar a Driver pero para almacenistas
+ - Ubicación asignada, zona primaria
+ - FK en `ShipmentCheckpoint.HandledByWarehouseOperatorId`
+
+- **Super Admin (IsSuperAdmin)**:
+
+ - Flag en `User` para administradores del sistema
+ - Correo format: `nombre@parhelion.com`
+ - Nuevo rol `SystemAdmin` en SeedData
+
+- **20 Nuevos Permisos**:
+
+ - Employees: Read, Create, Update, Delete
+ - Shifts: Read, Create, Update, Delete
+ - WarehouseZones: Read, Create, Update, Delete
+ - WarehouseOperators: Read, Create, Update, Delete
+ - Tenants: Read, Create, Update, Deactivate
+
+### Modificado
+
+- `Driver`: Refactorizado de `TenantEntity` a `BaseEntity`
+ - `UserId` → `EmployeeId` (datos legales movidos a Employee)
+- `User`: Agregado `IsSuperAdmin`, `Employee` navigation
+- `Location`: Agregado `Zones` y `AssignedWarehouseOperators`
+- `ShipmentCheckpoint`: Agregado `HandledByWarehouseOperatorId`
+
+### Tests
+
+- 7 tests de integración E2E para Employee Layer
+- Cobertura: Tenant, User, Employee, Driver, WarehouseOperator, Shift, Checkpoint
+
+---
+
+## [0.4.2] - 2025-12-13
+
+### Agregado
+
+- **Sistema de Autenticación JWT**:
+
+ - `AuthController`: `/login`, `/refresh`, `/logout`, `/me`
+ - Access token (2h) + Refresh token (7 días)
+ - Revocación de tokens, tracking de IP
+
+- **Autorización por Roles (Inmutable)**:
+
+ - `RolePermissions.cs` con 60+ permisos en código
+ - Roles: Admin, Driver, Warehouse, DemoUser
+ - Permisos NO modificables en runtime
+
+- **Nueva Tabla `Clients`** (Remitentes/Destinatarios):
+
+ - Datos: CompanyName, ContactName, Email, Phone
+ - Fiscales: TaxId (RFC), LegalName, BillingAddress
+ - Prioridad: Normal, Low, High, Urgent
+ - FK en Shipment: SenderId, RecipientClientId
+
+- **Nueva Tabla `RefreshTokens`**:
+
+ - Token hasheado, expiración, revocación, IP, UserAgent
+
+- **Campos Legales en `Drivers`**:
+
+ - RFC, NSS, CURP, LicenseType, LicenseExpiration
+ - EmergencyContact, EmergencyPhone, HireDate
+
+- **Campos Legales en `Trucks`**:
+
+ - VIN, EngineNumber, Year, Color, seguro, verificación
+
+- **Trazabilidad en `ShipmentCheckpoints`**:
+ - HandledByDriverId, LoadedOntoTruckId, ActionType
+
+### Migración
+
+- `AddAuthAndClients` aplicada a PostgreSQL
+
+---
+
+## [0.4.0] - 2025-12-12
+
+### Agregado
+
+- **Domain Layer Completo**: 14 entidades según `database-schema.md`
+ - Core: Tenant, User, Role
+ - Flotilla: Driver, Truck, FleetLog
+ - Red Logística: Location, NetworkLink, RouteBlueprint, RouteStep
+ - Envíos: Shipment, ShipmentItem, ShipmentCheckpoint, ShipmentDocument
+- **11 Enums**: ShipmentStatus, TruckType, LocationType, CheckpointStatus, etc.
+- **Infrastructure Layer con EF Core**:
+ - DbContext con Query Filters globales (multi-tenancy + soft delete)
+ - 14 configuraciones Fluent API con índices y constraints
+ - Audit Trail automático (CreatedAt, UpdatedAt, DeletedAt)
+- **Migración Inicial**: `InitialCreate` aplicada a PostgreSQL
+- **Seed Data**: Roles del sistema (Admin, Driver, Warehouse, DemoUser)
+- **Endpoint `/health/db`**: Verificación de estado de base de datos
+
+### Metodología de Implementación
+
+| Aspecto | Implementación |
+| --------------------- | ----------------------------------------------- |
+| **Approach** | Code First con Entity Framework Core 8.0.10 |
+| **Database** | PostgreSQL 17 (Docker) |
+| **Naming Convention** | PascalCase en C#, preservado en PostgreSQL |
+| **Architecture** | Clean Architecture + Domain-Driven Design (DDD) |
+| **Multi-Tenancy** | Query Filters globales por TenantId |
+| **Soft Delete** | IsDeleted flag en todas las entidades |
+| **Audit Trail** | CreatedAt, UpdatedAt, DeletedAt automáticos |
+
+### Seguridad
+
+- **Anti SQL Injection**: Queries parameterizadas automáticas de EF Core
+- **Tenant Isolation**: Query Filters globales por TenantId
+- **Soft Delete**: Todas las entidades soportan borrado lógico
+- **Password Strategy**: BCrypt (usuarios) + Argon2id (admins)
+
+### Configurado
+
+- **Connection Strings**: Separación develop/production
+- **Paquetes NuGet**:
+ - Npgsql.EntityFrameworkCore.PostgreSQL 8.0.10
+ - Microsoft.EntityFrameworkCore.Design 8.0.10
+
+---
+
+## [0.3.0] - 2025-12-12
+
+### Agregado
+
+- **Sistema de Diseño Neo-Brutalism**: Estilo visual moderno con bordes sólidos y sombras
+ - Paleta "Industrial Solar": Oxide (#C85A17), Sand (#E8E6E1), Black (#000000)
+ - Tipografía: New Rocker (logo), Merriweather (títulos), Inter (body)
+ - Componentes: Buttons, Cards, Inputs con estilo brutalist
+- **Grid Animado**: Fondo con grid cuadriculado naranja y movimiento aleatorio
+ - Dirección random en cada carga de página
+ - 8 direcciones posibles (cardinales + diagonales)
+- **Remote Development**: Frontends configurados para acceso via Tailscale
+ - Vite servers escuchando en `0.0.0.0`
+ - Backend API accesible remotamente
+
+### Configurado
+
+- **Puertos dedicados** via `.env`:
+ - Backend: 5100
+ - Admin: 4100
+ - Operaciones: 5101
+ - Campo: 5102
+- **Endpoint `/health`** en backend API para verificación de estado
+
+---
+
+## [0.2.0] - 2025-12-11
+
+### Agregado
+
+- **Docker**: Configuración completa de docker-compose con 6 servicios
+ - PostgreSQL 16 con healthcheck
+ - Backend API (.NET 8)
+ - Frontend Admin (Angular 18)
+ - Frontend Operaciones (React + Vite)
+ - Frontend Campo (React + Vite)
+ - Cloudflare Tunnel para exposición pública
+- **Healthchecks**: Todos los servicios tienen verificación de salud
+- **CI/CD**: Pipeline de GitHub Actions para build y test automático
+- **Red Docker**: Todos los servicios en `parhelion-network`
+
+### Configurado
+
+- Variables de entorno via `.env` (no versionado)
+- Cloudflared espera a que todos los servicios estén healthy
+
+---
+
+## [0.1.0] - 2025-12-11
+
+### Agregado
+
+- **Estructura del proyecto**: 4 carpetas principales
+ - `backend/`: .NET 8 Web API con Clean Architecture
+ - `frontend-admin/`: Angular 18 con routing
+ - `frontend-operaciones/`: React + Vite + TypeScript
+ - `frontend-campo/`: React + Vite + TypeScript
+- **Documentación**:
+ - `database-schema.md`: Esquema completo de BD
+ - `requirments.md`: Requerimientos funcionales
+ - `BRANCHING.md`: Estrategia de ramas Git Flow
+- **Git Flow**: Ramas `main` y `develop` configuradas
+
+### Notas
+
+- Las 4 feature branches vacías fueron eliminadas
+- Solo se crean branches cuando hay trabajo real
+
+---
+
+## Próximos Pasos
+
+1. ~~Implementar Domain Layer (entidades)~~
+2. ~~Configurar Infrastructure Layer (EF Core)~~
+3. Crear API endpoints básicos (CRUD)
+4. Implementar autenticación JWT
+5. Diseñar UI del Admin
+6. Probar Docker con PostgreSQL
diff --git a/README.md b/README.md
index 1059038..0280dda 100644
--- a/README.md
+++ b/README.md
@@ -3,15 +3,18 @@


+
+



+


-Plataforma Unificada de Logística B2B (WMS + TMS) nivel Enterprise. Gestiona inventarios, flotas tipificadas, redes Hub & Spoke y documentación legal (Carta Porte) en un entorno Multi-tenant.
+Plataforma Unificada de Logística B2B (WMS + TMS) nivel Enterprise. Gestiona inventarios, flotas tipificadas, redes Hub & Spoke y documentación legal (Carta Porte) en un entorno Multi-tenant con **agentes de IA automatizados** y **análisis predictivo con Python**.
-> **Estado del Proyecto:** Diseño Finalizado (v2.3) - Listo para Implementación
+> **Estado:** Development Preview v0.6.0-alpha - Python Microservice Integration
---
@@ -19,7 +22,7 @@ Plataforma Unificada de Logística B2B (WMS + TMS) nivel Enterprise. Gestiona in
**Parhelion-Logistics** es una plataforma SaaS multi-tenant de nivel Enterprise que unifica las capacidades de un WMS (Warehouse Management System) y un TMS (Transportation Management System). Diseñada para empresas de transporte B2B que requieren gestión integral: inventarios estáticos en almacén, flotas tipificadas (refrigerado, HAZMAT, blindado), redes de distribución Hub & Spoke, trazabilidad por checkpoints y documentación legal mexicana (Carta Porte, POD).
-**Objetivo Técnico:** Implementación de **Clean Architecture** y **Domain-Driven Design (DDD)** en un entorno de producción utilizando .NET 8, Angular, React, Docker y PostgreSQL.
+**Objetivo Técnico:** Implementación de **Clean Architecture** y **Domain-Driven Design (DDD)** en un entorno de producción utilizando .NET 8, **Python (FastAPI)**, Angular, React, Docker, PostgreSQL y **n8n** para automatización inteligente.
---
@@ -27,57 +30,146 @@ Plataforma Unificada de Logística B2B (WMS + TMS) nivel Enterprise. Gestiona in
### Core
-- [x] Documentación de requerimientos y esquema de base de datos
-- [ ] **Arquitectura Base:** Configuración de Clean Architecture y estructura de proyecto
-- [ ] **Multi-tenancy:** Aislamiento de datos por cliente/empresa
+- [x] **Documentación Completa:** Requerimientos, esquema de BD, guías de API
+- [x] **Clean Architecture:** Domain, Application, Infrastructure, API layers
+- [x] **Multi-tenancy:** Query Filters globales + herencia automática de TenantId
+- [x] **Domain Layer:** 25 entidades + 17 enumeraciones
+- [x] **Infrastructure Layer:** EF Core + PostgreSQL + Migrations
+- [x] **API REST:** 22 endpoints CRUD para todas las entidades
+- [x] **Autorización Jerárquica:** SuperAdmin → Admin → Driver/Warehouse
+- [x] **Repository Pattern:** GenericRepository + UnitOfWork + Soft Delete
+- [x] **xUnit Tests:** 122 tests (foundation + services + business rules)
+- [x] **Services Layer:** 22 servicios (Core, Shipment, Fleet, Network, Warehouse)
+
+### Python Analytics Service (v0.6.0)
+
+- [x] **Microservicio Python 3.12:** FastAPI + SQLAlchemy 2.0 + asyncpg
+- [x] **10 Módulos de Análisis:**
+ - Route Optimizer (NetworkX)
+ - Truck Recommender (ML)
+ - Demand Forecaster (Prophet)
+ - Anomaly Detector (Isolation Forest)
+ - Loading Optimizer (3D Bin Packing)
+ - Network Analyzer (Graph Analytics)
+ - Shipment Clusterer (K-Means)
+ - ETA Predictor (Gradient Boosting)
+ - Driver Performance (KPI Analytics)
+ - Dashboard Engine (Real-time Metrics)
+- [x] **Comunicación Inter-Servicios:** Polly resilience + internal JWT
+- [x] **Stress Tests:** 5 tests de rendimiento validados
### Gestión de Flotilla
-- [ ] **Camiones Tipificados:** DryBox, Refrigerado, HAZMAT, Plataforma, Blindado
-- [ ] **Choferes:** Asignación fija (default_truck) y dinámica (current_truck)
-- [ ] **Bitácora de Flotilla:** Historial de cambios de vehículo (FleetLog)
+- [x] **Camiones Tipificados:** DryBox, Refrigerado, HAZMAT, Plataforma, Blindado
+- [x] **Choferes:** Asignación fija (default_truck) y dinámica (current_truck)
+- [x] **Bitácora de Flotilla:** Historial de cambios de vehículo (FleetLog automático)
+- [x] **Telemetría GPS:** Campos de latitud/longitud en Trucks
+- [x] **Búsqueda Geoespacial:** Endpoint nearby con algoritmo Haversine
-### Red Logística (Hub & Spoke)
+### Automatización e Inteligencia (n8n)
-- [ ] **Nodos de Red:** RegionalHub, CrossDock, Warehouse, Store, SupplierPlant
-- [ ] **Códigos Aeroportuarios:** Identificadores únicos por ubicación (MTY, GDL, MM)
-- [ ] **Enlaces de Red:** Conexiones FirstMile, LineHaul, LastMile
-- [ ] **Rutas Predefinidas:** RouteBlueprint con paradas y tiempos de tránsito
+- [x] **Webhooks:** 5 tipos de eventos para integración externa
+- [x] **Notificaciones:** Push notifications persistidas para apps móviles
+- [x] **ServiceApiKey:** Autenticación multi-tenant para agentes IA
+- [x] **Agente Crisis Management:** Búsqueda automática de chofer cercano
+
+### Red Logística (Hub and Spoke)
+
+- [x] **Nodos de Red:** RegionalHub, CrossDock, Warehouse, Store, SupplierPlant
+- [x] **Códigos Únicos:** Identificadores estilo aeropuerto (MTY, GDL, CDMX)
+- [x] **Enlaces de Red:** Conexiones FirstMile, LineHaul, LastMile
+- [x] **Rutas Predefinidas:** RouteBlueprint con paradas y tiempos de tránsito
### Envíos y Trazabilidad
-- [ ] **Manifiesto de Carga:** Items con peso volumétrico y valor declarado
-- [ ] **Restricciones de Compatibilidad:** Cadena de frío, HAZMAT, Alto valor
-- [ ] **Checkpoints:** Bitácora de eventos (Loaded, QrScanned, ArrivedHub, Delivered)
+- [x] **Manifiesto de Carga:** Items con peso volumétrico y valor declarado
+- [x] **Restricciones de Compatibilidad:** Cadena de frío, HAZMAT, Alto valor
+- [x] **Checkpoints:** Bitácora de eventos con timeline visual
- [ ] **QR Handshake:** Transferencia de custodia digital mediante escaneo
### Documentación B2B
-- [ ] **Orden de Servicio:** Petición inicial del cliente
-- [ ] **Carta Porte (Waybill):** Documento legal SAT para transporte
-- [ ] **Manifiesto de Carga:** Checklist de estiba para almacenista
-- [ ] **Hoja de Ruta:** Itinerario con ventanas de entrega
-- [ ] **POD (Proof of Delivery):** Firma digital del receptor
+- [x] **5 Tipos de Documentos PDF:** Orden de Servicio, Carta Porte, Manifiesto, Hoja de Ruta, POD
+- [x] **Generación On-Demand:** Sin almacenamiento de archivos
+- [x] **Firma Digital:** Captura de firma en POD
### Operación
-- [ ] **Seguridad:** Autenticación JWT con roles (Admin/Chofer/Almacenista)
+- [x] **Seguridad JWT:** Autenticación con tokens seguros
- [ ] **Dashboard:** KPIs operativos en tiempo real
-- [ ] **Modo Demo:** Acceso para reclutadores sin registro previo
+- [ ] **Modo Demo:** Acceso para reclutadores sin registro
+
+---
+
+## Demo (Development Preview)
+
+| Aplicación | URL Pública | Descripción |
+| :--------------- | :------------------------------------------------------------- | :------------------------------------------ |
+| **Landing Page** | [parhelion.macrostasis.lat](https://parhelion.macrostasis.lat) | Página principal con changelog y navegación |
+| **Panel Admin** | [phadmin.macrostasis.lat](https://phadmin.macrostasis.lat) | Gestión administrativa (Angular) |
+| **Operaciones** | [phops.macrostasis.lat](https://phops.macrostasis.lat) | App para almacenistas (React PWA) |
+| **Driver App** | [phdriver.macrostasis.lat](https://phdriver.macrostasis.lat) | App para choferes (React PWA) |
+
+> Infraestructura: Cloudflare Tunnel (Zero Trust) + Docker Compose + Digital Ocean
+
+---
+
+## Python Analytics Service (v0.6.0)
+
+Microservicio dedicado para analisis avanzado, predicciones ML y reportes. Implementado con Clean Architecture en Python.
+
+Para documentacion completa del servicio, ver [python-analytics.md](./python-analytics.md).
+
+| Componente | Tecnologia | Estado |
+| ---------- | ------------------------ | ------- |
+| Framework | FastAPI 0.115+ | Activo |
+| Runtime | Python 3.12 | Activo |
+| ORM | SQLAlchemy 2.0 + asyncpg | Activo |
+| Testing | pytest + pytest-asyncio | 4 tests |
+| Container | parhelion-python:8000 | Healthy |
---
## Stack Tecnológico
-| Capa | Tecnología | Usuario |
-| :----------------------- | :------------------------------------ | :------------- |
-| **Backend** | C# / .NET 8 Web API | - |
-| **Base de Datos** | PostgreSQL 16 | - |
-| **ORM** | Entity Framework Core (Code First) | - |
-| **Frontend (Admin)** | Angular 18+ (Material Design) | Admin |
-| **Frontend (Operación)** | React (PWA) | Chofer/Almacén |
-| **Infraestructura** | Docker Compose, Nginx (Reverse Proxy) | - |
-| **Hosting** | Digital Ocean Droplet (Linux) | - |
+| Capa | Tecnología | Usuario |
+| :----------------------- | :------------------------------------ | :---------- |
+| **Backend** | C# / .NET 8 Web API | - |
+| **Analytics Service** | Python 3.12 / FastAPI | - |
+| **Base de Datos** | PostgreSQL 17 | - |
+| **ORM (.NET)** | Entity Framework Core (Code First) | - |
+| **ORM (Python)** | SQLAlchemy 2.0 + asyncpg | - |
+| **Automatización** | n8n (Workflow Automation) | Agentes IA |
+| **Frontend (Admin)** | Angular 18+ (Material Design) | Admin |
+| **Frontend (Operacion)** | React + Vite + Tailwind CSS (PWA) | Almacenista |
+| **Frontend (Campo)** | React + Vite + Tailwind CSS (PWA) | Chofer |
+| **Infraestructura** | Docker Compose, Nginx (Reverse Proxy) | - |
+| **Hosting** | Digital Ocean Droplet (Linux) | - |
+
+---
+
+## Design System
+
+El proyecto utiliza un estilo visual **Neo-Brutalism** con la paleta de colores "Industrial Solar":
+
+| Token | Color | Uso |
+| :------ | :-------- | :------------------------------ |
+| `oxide` | `#C85A17` | Acciones, acentos, hover states |
+| `sand` | `#E8E6E1` | Fondos secundarios |
+| `black` | `#000000` | Bordes, texto, sombras |
+| `white` | `#FAFAFA` | Fondos principales |
+
+### Tipografía
+
+- **Logo:** New Rocker (display font)
+- **Títulos:** Merriweather (serif)
+- **Body:** Inter (sans-serif)
+
+### Componentes
+
+Los frontends incluyen componentes pre-estilizados: `btn`, `btn-primary`, `btn-oxide`, `card`, `input` con sombras brutalist y transiciones sólidas (sin gradientes).
+
+> UI inspirada en [neobrutalism-components](https://github.com/ekmas/neobrutalism-components)
---
@@ -123,26 +215,105 @@ graph TD
CC -->|LastMile| G
```
+### Integración n8n (Automatización)
+
+```mermaid
+flowchart LR
+ subgraph Backend["Parhelion API"]
+ API[Controllers]
+ WP[WebhookPublisher]
+ NC[NotificationsController]
+ end
+
+ subgraph n8n["n8n Workflows"]
+ WH{{Webhook Trigger}}
+ AI[/"AI Agent
(Claude/GPT)"/]
+ HTTP[HTTP Request]
+ end
+
+ subgraph Mobile["Apps Móviles"]
+ APP[Driver App]
+ end
+
+ API -->|"shipment.exception"| WP
+ WP -->|"POST /webhook"| WH
+ WH --> AI
+ AI --> HTTP
+ HTTP -->|"POST /api/notifications"| NC
+ HTTP -->|"GET /api/drivers/nearby"| API
+ NC -.->|"Push Notification"| APP
+```
+
+**Flujo de Crisis Management:**
+
+1. Backend detecta `ShipmentStatus.Exception` → publica webhook
+2. n8n recibe evento → activa Agente IA
+3. Agente consulta `/api/drivers/nearby` con coordenadas del incidente
+4. Agente crea notificación para chofer de rescate
+5. App móvil recibe push notification
+
+---
+
+## Base de Datos
+
+### Tecnologías
+
+| Componente | Tecnología | Versión |
+| ---------- | ------------------------------------- | ----------- |
+| ORM | Entity Framework Core | 8.0.10 |
+| Provider | Npgsql.EntityFrameworkCore.PostgreSQL | 8.0.10 |
+| Database | PostgreSQL | 17 (Docker) |
+| Migrations | Code First | |
+
+### Características de Seguridad
+
+- **Anti SQL Injection:** Queries parameterizadas automáticas de EF Core
+- **Multi-Tenancy:** Query Filters globales por TenantId
+- **Soft Delete:** Todas las entidades soportan borrado lógico
+- **Audit Trail:** CreatedAt, UpdatedAt, DeletedAt automáticos
+- **Password Hashing:** BCrypt (usuarios) + Argon2id (admins)
+
+### Naming Convention
+
+```
+PascalCase en C# → PascalCase en PostgreSQL
+Ejemplo: ShipmentItem.TenantId → "ShipmentItems"."TenantId"
+```
+
+Para más detalles técnicos, ver [Sección 12 de database-schema.md](./database-schema.md#12-metodología-de-implementación-detalles-técnicos)
+
---
## Estructura del Proyecto
```
-src/
+backend/src/
├── Parhelion.Domain/ # Núcleo: Entidades y Excepciones (Sin dependencias)
├── Parhelion.Application/ # Reglas: DTOs, Interfaces, Validaciones
├── Parhelion.Infrastructure/ # Persistencia: DbContext, Repositorios, Migraciones
└── Parhelion.API/ # Entrada: Controllers, JWT Config, DI
+
+service-python/ # Microservicio Python (Analytics & Predictions)
+├── src/parhelion_py/ # Clean Architecture: domain, application, infrastructure, api
+│ ├── domain/ # Entidades, Value Objects, Interfaces
+│ ├── application/ # DTOs, Services, Use Cases
+│ ├── infrastructure/ # Database, External Clients
+│ └── api/ # FastAPI Routers, Middleware
+└── tests/ # pytest unit/integration tests
```
---
-## Documentación
+## Documentacion
-| Documento | Descripción |
-| :----------------------------------------------- | :-------------------------------------------- |
-| [Requerimientos (MVP)](./requirments.md) | Especificación funcional completa del sistema |
-| [Esquema de Base de Datos](./database-schema.md) | Diagrama ER, entidades y reglas de negocio |
+| Documento | Descripcion |
+| :----------------------------------------------- | :---------------------------------------------- |
+| [Requerimientos (MVP)](./requirments.md) | Especificacion funcional completa del sistema |
+| [Esquema de Base de Datos](./database-schema.md) | Diagrama ER, entidades y reglas de negocio |
+| [Arquitectura de API](./api-architecture.md) | Estructura de capas y endpoints (.NET + Python) |
+| [Python Analytics](./python-analytics.md) | Roadmap, 10 objetivos, estructura del servicio |
+| [Guia de Webhooks](./service-webhooks.md) | Integracion n8n, eventos y notificaciones |
+| [CHANGELOG](./CHANGELOG.md) | Historial detallado de todas las versiones |
---
@@ -171,6 +342,102 @@ src/
---
+## Roadmap
+
+### Completado
+
+| Version | Fecha | Descripcion |
+| ------- | ------- | ----------------------------------------------------------- |
+| v0.1.0 | 2025-12 | Estructura inicial, documentación de requerimientos |
+| v0.2.0 | 2025-12 | Domain Layer: Entidades base y enumeraciones |
+| v0.3.0 | 2025-12 | Infrastructure Layer: EF Core, PostgreSQL, Migrations |
+| v0.4.0 | 2025-12 | API Layer: Controllers base, JWT Authentication |
+| v0.5.0 | 2025-12 | Services Layer: Repository Pattern, UnitOfWork |
+| v0.5.1 | 2025-12 | Foundation Tests: DTOs, Repository, UnitOfWork |
+| v0.5.2 | 2025-12 | Services Implementation: 16 interfaces, 15 implementaciones |
+| v0.5.3 | 2025-12 | Integration Tests: 72 tests para Services |
+| v0.5.4 | 2025-12 | Swagger/OpenAPI, Business Logic Workflow |
+| v0.5.5 | 2025-12 | WMS/TMS Services, Business Rules, 122 tests |
+| v0.5.6 | 2025-12 | n8n Integration, Webhooks, Notifications, ServiceApiKey |
+| v0.5.7 | 2025-12 | Dynamic PDF Generation, Checkpoint Timeline, POD Signatures |
+
+### En Progreso (v0.6.x - Python Integration)
+
+| Version | Nombre Clave | Descripción |
+| ---------------- | ------------- | -------------------------------------------------- |
+| **v0.6.0-alpha** | `foundation` | Estructura base Python, Docker, health checks |
+| v0.6.0-beta | `integration` | Comunicación API ↔ Python, autenticación interna |
+| v0.6.0-rc.1 | `validation` | Tests de integración, documentación |
+| **v0.6.0** | `Python Core` | Release estable con microservicio Python integrado |
+
+### Proximas Versiones (v0.7.0-v1.0.0)
+
+#### v0.7.0-v0.7.4: Operaciones de Campo (QR + Rutas)
+
+| Version | Feature | Descripción |
+| ------- | ---------------- | -------------------------------------------------- |
+| v0.7.0 | QR Generation | Generación de códigos QR por envío (Angular Admin) |
+| v0.7.1 | QR Scanning | Lectura QR en React PWA (Driver + Operaciones) |
+| v0.7.2 | Custody Transfer | Transferencia de custodia digital con checkpoint |
+| v0.7.3 | Route Assignment | Asignación de rutas predefinidas a shipments |
+| v0.7.4 | Route Progress | Avance automático por pasos de ruta |
+
+#### v0.8.0-v0.8.5: Frontend Admin Panel (Angular)
+
+| Version | Feature | Descripción |
+| ------- | ----------------- | --------------------------------------------- |
+| v0.8.0 | Admin Shell | Layout, navegación, auth guards, interceptors |
+| v0.8.1 | Core CRUD | Gestión de Tenants, Users, Roles, Employees |
+| v0.8.2 | Fleet CRUD | Gestión de Trucks, Drivers, FleetLogs |
+| v0.8.3 | Shipment CRUD | Crear envíos, asignar chofer/camión, items |
+| v0.8.4 | Shipment Tracking | Timeline de checkpoints, status updates |
+| v0.8.5 | Network CRUD | Gestión de Locations, Routes, NetworkLinks |
+
+#### v0.9.0-v0.9.6: Frontend PWAs + Dashboard
+
+| Version | Feature | Descripción |
+| ------- | -------------------- | ---------------------------------------------- |
+| v0.9.0 | Operaciones PWA | App tablet: login, lista de envíos, carga |
+| v0.9.1 | Operaciones QR | Escaneo QR, validación peso/volumen |
+| v0.9.2 | Driver PWA | App móvil: login, hoja de ruta, navegación |
+| v0.9.3 | Driver Confirmations | Confirmar llegadas, entregas, firma POD |
+| v0.9.4 | Dashboard Base | KPIs principales: envíos por status, ocupación |
+| v0.9.5 | Dashboard Analytics | Métricas con Python: tendencias, predicciones |
+| v0.9.6 | AI Predictions | Predicción ETA, alertas de retraso |
+| v0.9.7 | Dispatch Cutoff | Sistema de cortes automáticos por Hub |
+
+#### v0.9.7: Sistema de Cortes (Dispatch Cutoff)
+
+Funcionalidad inspirada en MercadoLibre para automatizar despachos:
+
+| Feature | Descripción |
+| -------------------------- | ------------------------------------------------------ |
+| **Cutoff Config** | Configuración de tiempos de corte por Hub/Location |
+| **Batch Shipments** | Agrupación automática de envíos aprobados |
+| **Truck Assignment** | Asignación inteligente de camiones disponibles |
+| **Warehouse Notification** | Notificación a PDAs de almacenistas |
+| **Location Validation** | Verificación de que operador y camión estén co-located |
+
+> **Flujo:** Shipments se acumulan → Admin aprueba antes del corte → Python agrupa y asigna → Warehouse recibe notificación → Despacho
+
+#### v1.0.0 - MVP Release (Q1 2026)
+
+| Criterio | Requerimiento |
+| ---------------- | ---------------------------------------- |
+| Backend API | 100% endpoints funcionales con tests |
+| Python Analytics | Análisis y predicciones operativas |
+| Admin Panel | CRUD completo para todas las entidades |
+| Operaciones PWA | Funcional para almacenistas |
+| Driver App PWA | Funcional para choferes con firma POD |
+| Dashboard | KPIs operativos en tiempo real |
+| Documentación | README, API docs, Swagger actualizados |
+| Deployment | Docker + Cloudflare Tunnel en producción |
+
+> **Nota:** Cada versión x.y.z puede completarse en días, no semanas.
+> Las funcionalidades se entregan incrementalmente siguiendo Agile.
+
+---
+
## Autor
**MetaCodeX** | 2025
diff --git a/api-architecture.md b/api-architecture.md
new file mode 100644
index 0000000..6fd5524
--- /dev/null
+++ b/api-architecture.md
@@ -0,0 +1,386 @@
+# Arquitectura de API - Parhelion Logistics
+
+Documentacion tecnica de la estructura API-First del backend Parhelion.
+
+## Estado Actual
+
+**Version:** 0.6.0-alpha
+**Enfoque:** Python Microservice Integration + Analytics Foundation
+**Arquitectura:** Clean Architecture + Domain-Driven Design + Microservices
+
+---
+
+## Capas del API (API Layers)
+
+El backend esta organizado en 5 capas logicas que agrupan los endpoints segun su dominio:
+
+### Core Layer
+
+Gestion de identidad, usuarios y estructura organizacional.
+
+| Endpoint | Entidad | Estado | Service |
+| ---------------- | -------- | -------- | --------------- |
+| `/api/tenants` | Tenant | Services | TenantService |
+| `/api/users` | User | Services | UserService |
+| `/api/roles` | Role | Services | RoleService |
+| `/api/employees` | Employee | Services | EmployeeService |
+| `/api/clients` | Client | Services | ClientService |
+
+### Warehouse Layer
+
+Gestion de almacenes, zonas e inventario.
+
+| Endpoint | Entidad | Estado | Service |
+| ----------------------------- | -------------------- | -------- | --------------------------- |
+| `/api/locations` | Location | Services | LocationService |
+| `/api/warehouse-zones` | WarehouseZone | Services | WarehouseZoneService |
+| `/api/warehouse-operators` | WarehouseOperator | Services | WarehouseOperatorService |
+| `/api/inventory-stocks` | InventoryStock | Services | InventoryStockService |
+| `/api/inventory-transactions` | InventoryTransaction | Services | InventoryTransactionService |
+
+### Fleet Layer
+
+Gestion de flotilla, choferes y turnos.
+
+| Endpoint | Entidad | Estado | Service |
+| ----------------- | -------- | -------- | --------------- |
+| `/api/trucks` | Truck | Services | TruckService |
+| `/api/drivers` | Driver | Services | DriverService |
+| `/api/shifts` | Shift | Skeleton | - |
+| `/api/fleet-logs` | FleetLog | Services | FleetLogService |
+
+### Shipment Layer
+
+Gestion de envios, items y trazabilidad.
+
+| Endpoint | Entidad | Estado | Service |
+| --------------------------- | ------------------ | -------- | ------------------------- | -------------------------------------- |
+| `/api/shipments` | Shipment | Services | ShipmentService |
+| `/api/shipment-items` | ShipmentItem | Services | ShipmentItemService |
+| `/api/shipment-checkpoints` | ShipmentCheckpoint | Services | ShipmentCheckpointService | `timeline/{id}`, `/by-status`, `/last` |
+| `/api/shipment-documents` | ShipmentDocument | Services | ShipmentDocumentService | `/pod/{id}` |
+| `/api/documents` | - | Services | PdfGeneratorService | PDF Generation (v0.5.7) |
+| `/api/catalog-items` | CatalogItem | Services | CatalogItemService | |
+| `/api/notifications` | Notification | Services | NotificationService | |
+
+### Documents Layer (v0.5.7 NEW)
+
+Generación dinámica de documentos PDF sin almacenamiento.
+
+| Endpoint | Documento | Entidad Input |
+| --------------------------------------- | ------------------- | -------------- |
+| `GET /api/documents/service-order/{id}` | Orden de Servicio | Shipment |
+| `GET /api/documents/waybill/{id}` | Carta Porte | Shipment |
+| `GET /api/documents/manifest/{id}` | Manifiesto de Carga | RouteBlueprint |
+| `GET /api/documents/trip-sheet/{id}` | Hoja de Ruta | Driver |
+| `GET /api/documents/pod/{id}` | Proof of Delivery | Shipment |
+
+> Los PDFs se generan on-demand con datos de BD. Cliente crea `blob:` URL local.
+
+### Network Layer
+
+Gestion de red logistica Hub and Spoke.
+
+| Endpoint | Entidad | Estado | Service |
+| ----------------------- | -------------- | -------- | ------------------ |
+| `/api/network-links` | NetworkLink | Services | NetworkLinkService |
+| `/api/route-blueprints` | RouteBlueprint | Services | RouteService |
+| `/api/route-steps` | RouteStep | Services | RouteStepService |
+
+---
+
+## Services Layer (v0.5.2)
+
+Capa de servicios que encapsula logica de negocio.
+
+### Interfaces Base
+
+| Interfaz | Descripcion |
+| -------------------- | ----------------------------------------- |
+| `IGenericService` | CRUD generico con paginacion y DTOs |
+| `ITenantService` | Extiende IGenericService para Tenants |
+| `IUserService` | Validacion de credenciales, cambio passwd |
+| `IShipmentService` | Tracking, asignacion, estatus |
+
+### Implementaciones por Capa
+
+| Capa | Services |
+| --------- | ---------------------------------------------------------------------- |
+| Core | Tenant, User, Role, Employee, Client |
+| Shipment | Shipment, ShipmentItem, Checkpoint, Document, Catalog |
+| Fleet | Driver, Truck, FleetLog |
+| Network | Location, Route, NetworkLink, RouteStep |
+| Warehouse | WarehouseZone, WarehouseOperator, InventoryStock, InventoryTransaction |
+
+---
+
+## Foundation Layer (v0.5.1)
+
+Infraestructura base para operaciones CRUD y transacciones.
+
+### Repository Pattern
+
+| Interfaz | Implementacion | Descripcion |
+| ----------------------- | ------------------- | -------------------------------- |
+| `IGenericRepository` | `GenericRepository` | CRUD generico con soft delete |
+| `ITenantRepository` | `TenantRepository` | Filtrado automatico por TenantId |
+| `IUnitOfWork` | `UnitOfWork` | Coordinacion de transacciones |
+
+---
+
+## Autenticación y Autorización
+
+### Autenticación JWT
+
+Todos los endpoints protegidos requieren JWT Bearer token:
+
+```http
+Authorization: Bearer
+```
+
+El token se obtiene via `/api/auth/login` con credenciales válidas.
+
+### Flujo de Autorización Multi-Tenant
+
+El sistema implementa un modelo de autorización jerárquico basado en roles:
+
+```mermaid
+graph TD
+ SA[SuperAdmin] -->|Crea| T[Tenants]
+ SA -->|Crea Admin de| TA[Tenant Admin]
+ TA -->|Crea usuarios de| D[Drivers]
+ TA -->|Crea usuarios de| W[Warehouse]
+ TA -->|Gestiona| S[Shipments]
+ TA -->|Gestiona| TR[Trucks]
+```
+
+| Rol | Permisos | Restricciones |
+| ---------------- | --------------------------------------------------------------- | ----------------------------------------------------------- |
+| **SuperAdmin** | Crear Tenants, crear Admin users para cualquier Tenant | No puede operar dentro de un Tenant (crear shipments, etc.) |
+| **Tenant Admin** | CRUD completo de Users, Drivers, Trucks, Shipments de su Tenant | Solo acceso a datos de su propio Tenant |
+| **Driver** | Ver shipments asignados, actualizar estado, crear checkpoints | Solo sus propios shipments |
+| **Warehouse** | Cargar/descargar items, crear checkpoints de carga | Solo en su ubicación asignada |
+
+### Herencia de TenantId
+
+Cuando un usuario crea entidades, el `TenantId` se asigna automáticamente:
+
+- **SuperAdmin + `targetTenantId`**: Puede especificar el Tenant destino (solo para crear Admins)
+- **Tenant Admin**: Todas las entidades heredan su `TenantId` automáticamente
+- **Otros roles**: Heredan `TenantId` del contexto de la sesión
+
+### ServiceApiKey (Agentes IA)
+
+Para integraciones con n8n y agentes IA, cada Tenant tiene un `ServiceApiKey` generado automáticamente:
+
+```http
+X-Service-Api-Key:
+```
+
+---
+
+## Guía de Uso de la API
+
+> **Nota:** En los ejemplos, `$API_BASE` representa la URL base de la API (ej. `https://api.example.com` o la URL de desarrollo).
+
+### 1. Login y Obtención de Token
+
+```bash
+curl -X POST $API_BASE/api/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"email":"admin@example.com","password":"Password123!"}'
+```
+
+**Response:** `{ "accessToken": "eyJ...", "refreshToken": "..." }`
+
+### 2. Crear Tenant (Solo SuperAdmin)
+
+```bash
+curl -X POST $API_BASE/api/tenants \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "TransporteMX",
+ "legalName": "Transportes MX SA de CV",
+ "rfc": "TMX010101ABC",
+ "fleetSize": 10,
+ "driverCount": 5
+ }'
+```
+
+### 3. Crear Admin de Otro Tenant (Solo SuperAdmin)
+
+```bash
+curl -X POST $API_BASE/api/users \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "email": "admin@newtenant.com",
+ "password": "SecurePass123!",
+ "fullName": "Admin Nuevo",
+ "roleId": "11111111-1111-1111-1111-111111111111",
+ "targetTenantId": ""
+ }'
+```
+
+> **Importante:** Solo SuperAdmin puede usar `targetTenantId`. Tenant Admins crean usuarios que heredan su propio TenantId.
+
+### 4. Crear Usuario en Mi Tenant (Como Tenant Admin)
+
+```bash
+curl -X POST $API_BASE/api/users \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "email": "driver@mytenant.com",
+ "password": "DriverPass123!",
+ "fullName": "Chofer Nuevo",
+ "roleId": "22222222-2222-2222-2222-222222222222"
+ }'
+```
+
+### 5. Crear Location
+
+```bash
+curl -X POST $API_BASE/api/locations \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "code": "MTY",
+ "name": "Hub Monterrey",
+ "fullAddress": "Av Industrial 500, Monterrey NL",
+ "type": "RegionalHub",
+ "latitude": 25.6866,
+ "longitude": -100.3161,
+ "isInternal": true,
+ "canReceive": true,
+ "canDispatch": true
+ }'
+```
+
+### 6. Crear Shipment
+
+```bash
+curl -X POST $API_BASE/api/shipments \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "originLocationId": "",
+ "destinationLocationId": "",
+ "recipientName": "Cliente Final",
+ "recipientPhone": "5512345678",
+ "totalWeightKg": 100.5,
+ "totalVolumeM3": 0.5,
+ "priority": "Standard"
+ }'
+```
+
+### Role IDs de Referencia
+
+| Rol | ID |
+| ---------- | -------------------------------------- |
+| SuperAdmin | `00000000-0000-0000-0000-000000000001` |
+| Admin | `11111111-1111-1111-1111-111111111111` |
+| Driver | `22222222-2222-2222-2222-222222222222` |
+| DemoUser | `33333333-3333-3333-3333-333333333333` |
+| Warehouse | `44444444-4444-4444-4444-444444444444` |
+
+---
+
+## Health Endpoints
+
+| Endpoint | Descripcion |
+| ---------------- | ----------------------------- |
+| `GET /health` | Estado del servicio |
+| `GET /health/db` | Conectividad de base de datos |
+
+---
+
+## Base de Datos
+
+- **Tablas:** 24
+- **Migraciones:** Aplicadas (EF Core Code First)
+- **Provider:** PostgreSQL 17
+
+---
+
+## Tests (xUnit)
+
+| Test Suite | Tests | Cobertura |
+| ------------------------ | ------- | -------------------------- |
+| `PaginationDtoTests` | 11 | PagedRequest, PagedResult |
+| `GenericRepositoryTests` | 9 | CRUD, Soft Delete, Queries |
+| `ServiceTests` | 72 | All Services |
+| `BusinessRulesTests` | 30 | Compatibility, FleetLog |
+| **Total** | **122** | Full backend coverage |
+
+---
+
+## Python Analytics Service (v0.6.0+)
+
+Microservicio local para análisis avanzado, predicciones y reportes.
+
+### Tecnologías
+
+| Componente | Tecnología |
+| ---------- | ------------------------ |
+| Framework | FastAPI 0.115+ |
+| Runtime | Python 3.12+ |
+| ORM | SQLAlchemy 2.0 + asyncpg |
+| Validación | Pydantic v2 |
+| Testing | pytest + pytest-asyncio |
+
+### Endpoints Python
+
+| Endpoint | Método | Descripción |
+| ----------------------------- | ------ | -------------------------------- |
+| `/health` | GET | Estado del servicio |
+| `/health/db` | GET | Conectividad PostgreSQL |
+| `/api/py/analytics/shipments` | GET | Análisis de envíos por período |
+| `/api/py/analytics/fleet` | GET | Métricas de utilización de flota |
+| `/api/py/predictions/eta` | POST | Predicción de ETA con ML |
+| `/api/py/reports/export` | POST | Generación de reportes Excel |
+
+### Autenticación Python
+
+Requiere header `X-Internal-Service-Key` para llamadas desde .NET API,
+o `Authorization: Bearer ` para llamadas desde n8n.
+
+### Comunicación Inter-Servicios
+
+```mermaid
+flowchart LR
+ subgraph Docker["Docker Network"]
+ NET[".NET API
:5000"]
+ PY["Python Analytics
:8000"]
+ DB[(PostgreSQL
:5432)]
+ end
+
+ NET <-->|"REST/JSON
Internal JWT"| PY
+ NET --> DB
+ PY --> DB
+```
+
+---
+
+## Pendientes (v0.7.0+)
+
+Los siguientes items quedan pendientes para futuras versiones:
+
+- [ ] QR Handshake (Transferencia de custodia digital via QR)
+- [ ] Route Assignment (Asignación de rutas a shipments)
+- [ ] Dashboard (KPIs operativos con procesamiento Python)
+- [ ] Predicción ETA con ML (Python)
+- [ ] Exportación Excel dinámica (Python + pandas)
+- [ ] Recuperación de contraseña
+- [ ] Demo Mode para reclutadores
+
+---
+
+## Notas de Desarrollo
+
+La gestion de endpoints durante desarrollo utiliza herramientas privadas que no forman parte del repositorio. Estas herramientas contienen credenciales y configuraciones sensibles que no deben exponerse publicamente.
+
+---
+
+**Ultima actualizacion:** 2025-12-29
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..9dbe7fa
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,63 @@
+# ===================================
+# PARHELION API - Dockerfile
+# Multi-stage build para producción
+# Version: 0.5.7
+# ===================================
+
+# --- STAGE 1: Build ---
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+LABEL maintainer="dev@parhelion.com"
+LABEL version="0.5.7"
+LABEL description="Parhelion Logistics API - WMS + TMS"
+WORKDIR /app
+
+# Copiar archivo de solución
+COPY Parhelion.sln ./
+
+# Copiar todos los archivos de proyecto manteniendo estructura src/
+COPY src/Parhelion.Domain/*.csproj ./src/Parhelion.Domain/
+COPY src/Parhelion.Application/*.csproj ./src/Parhelion.Application/
+COPY src/Parhelion.Infrastructure/*.csproj ./src/Parhelion.Infrastructure/
+COPY src/Parhelion.API/*.csproj ./src/Parhelion.API/
+
+# Copiar archivos de proyecto de tests
+COPY tests/Parhelion.Tests/*.csproj ./tests/Parhelion.Tests/
+
+# Restaurar dependencias de toda la solución
+RUN dotnet restore Parhelion.sln
+
+# Copiar todo el código fuente
+COPY src/ ./src/
+COPY tests/ ./tests/
+
+# Build de la aplicación
+RUN dotnet publish src/Parhelion.API/Parhelion.API.csproj -c Release -o /publish
+
+# --- STAGE 2: Runtime ---
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
+WORKDIR /app
+
+# Instalar curl para healthcheck (como root)
+RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
+
+# Crear usuario no-root y directorio de uploads con permisos correctos
+RUN adduser --disabled-password --gecos '' appuser && \
+ mkdir -p /app/uploads && \
+ chown -R appuser:appuser /app/uploads
+
+# Copiar artefactos del build
+COPY --from=build /publish .
+
+# Cambiar a usuario no-root
+USER appuser
+
+# Puerto por defecto
+EXPOSE 5000
+
+# Variables de entorno
+ENV ASPNETCORE_URLS=http://+:5000
+ENV ASPNETCORE_ENVIRONMENT=Production
+
+# Comando de inicio
+ENTRYPOINT ["dotnet", "Parhelion.API.dll"]
+
diff --git a/backend/Parhelion.sln b/backend/Parhelion.sln
new file mode 100644
index 0000000..de00bfd
--- /dev/null
+++ b/backend/Parhelion.sln
@@ -0,0 +1,57 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0DB876F5-F853-4A2B-B99D-B70B16F3B8DD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parhelion.Domain", "src\Parhelion.Domain\Parhelion.Domain.csproj", "{9CE90642-26E9-41D1-A0FC-E221B0926E21}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parhelion.Application", "src\Parhelion.Application\Parhelion.Application.csproj", "{145355DE-4C48-467D-8E8E-300BADDA0427}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parhelion.Infrastructure", "src\Parhelion.Infrastructure\Parhelion.Infrastructure.csproj", "{6DE6AD38-2121-4375-BA34-37389D4E9675}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parhelion.API", "src\Parhelion.API\Parhelion.API.csproj", "{7DFDCA95-1B96-42EB-9CFA-3CC2A9572E02}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{20CC5A12-6001-4C06-87DA-DE72502712C2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Parhelion.Tests", "tests\Parhelion.Tests\Parhelion.Tests.csproj", "{59145160-762F-4941-8B1D-429BEE5DB8FA}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {9CE90642-26E9-41D1-A0FC-E221B0926E21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9CE90642-26E9-41D1-A0FC-E221B0926E21}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9CE90642-26E9-41D1-A0FC-E221B0926E21}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9CE90642-26E9-41D1-A0FC-E221B0926E21}.Release|Any CPU.Build.0 = Release|Any CPU
+ {145355DE-4C48-467D-8E8E-300BADDA0427}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {145355DE-4C48-467D-8E8E-300BADDA0427}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {145355DE-4C48-467D-8E8E-300BADDA0427}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {145355DE-4C48-467D-8E8E-300BADDA0427}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6DE6AD38-2121-4375-BA34-37389D4E9675}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6DE6AD38-2121-4375-BA34-37389D4E9675}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6DE6AD38-2121-4375-BA34-37389D4E9675}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6DE6AD38-2121-4375-BA34-37389D4E9675}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7DFDCA95-1B96-42EB-9CFA-3CC2A9572E02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7DFDCA95-1B96-42EB-9CFA-3CC2A9572E02}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7DFDCA95-1B96-42EB-9CFA-3CC2A9572E02}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7DFDCA95-1B96-42EB-9CFA-3CC2A9572E02}.Release|Any CPU.Build.0 = Release|Any CPU
+ {59145160-762F-4941-8B1D-429BEE5DB8FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {59145160-762F-4941-8B1D-429BEE5DB8FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {59145160-762F-4941-8B1D-429BEE5DB8FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {59145160-762F-4941-8B1D-429BEE5DB8FA}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {9CE90642-26E9-41D1-A0FC-E221B0926E21} = {0DB876F5-F853-4A2B-B99D-B70B16F3B8DD}
+ {145355DE-4C48-467D-8E8E-300BADDA0427} = {0DB876F5-F853-4A2B-B99D-B70B16F3B8DD}
+ {6DE6AD38-2121-4375-BA34-37389D4E9675} = {0DB876F5-F853-4A2B-B99D-B70B16F3B8DD}
+ {7DFDCA95-1B96-42EB-9CFA-3CC2A9572E02} = {0DB876F5-F853-4A2B-B99D-B70B16F3B8DD}
+ {59145160-762F-4941-8B1D-429BEE5DB8FA} = {20CC5A12-6001-4C06-87DA-DE72502712C2}
+ EndGlobalSection
+EndGlobal
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..8c302c9
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,22 @@
+# Backend - Parhelion API
+
+**Stack:** C# / .NET 8 Web API
+**Patrón:** Clean Architecture
+**Base de Datos:** PostgreSQL + Entity Framework Core
+
+## Estructura Planificada
+
+```
+backend/
+├── src/
+│ ├── Parhelion.Domain/ # Entidades, Enums, Exceptions
+│ ├── Parhelion.Application/ # DTOs, Interfaces, Validators
+│ ├── Parhelion.Infrastructure/ # EF Core, Repositorios, Services
+│ └── Parhelion.API/ # Controllers, JWT, Swagger
+└── tests/
+ └── Parhelion.Tests/
+```
+
+## Documentación API
+
+Swagger disponible en: `/swagger`
diff --git a/backend/scripts/seed-e2e-fix.sql b/backend/scripts/seed-e2e-fix.sql
new file mode 100644
index 0000000..c9324ff
--- /dev/null
+++ b/backend/scripts/seed-e2e-fix.sql
@@ -0,0 +1,59 @@
+-- ================================================================
+-- PARHELION WMS - Test Data for n8n Integration E2E (FIXED)
+-- Tenant: "TransporteMX" con Admin y 3 Choferes con ubicaciones GPS
+-- ================================================================
+
+-- Insertar Drivers con nombres correctos de columnas
+INSERT INTO "Drivers" ("Id", "TenantId", "LicenseNumber", "LicenseType", "LicenseExpiration", "Status", "DefaultTruckId", "CurrentTruckId", "UserId", "CreatedAt", "IsDeleted")
+VALUES
+ ('aaaaaaaa-bbbb-cccc-dddd-drvr00000001', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'LIC-MTY-001', 'Federal', '2026-12-31', 2, 'aaaaaaaa-bbbb-cccc-dddd-truck0000001', 'aaaaaaaa-bbbb-cccc-dddd-truck0000001', 'aaaaaaaa-bbbb-cccc-dddd-driver000001', NOW(), false),
+ ('aaaaaaaa-bbbb-cccc-dddd-drvr00000002', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'LIC-MTY-002', 'Federal', '2027-06-30', 0, 'aaaaaaaa-bbbb-cccc-dddd-truck0000002', 'aaaaaaaa-bbbb-cccc-dddd-truck0000002', 'aaaaaaaa-bbbb-cccc-dddd-driver000002', NOW(), false),
+ ('aaaaaaaa-bbbb-cccc-dddd-drvr00000003', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'LIC-GDL-003', 'Federal', '2025-08-15', 0, 'aaaaaaaa-bbbb-cccc-dddd-truck0000003', 'aaaaaaaa-bbbb-cccc-dddd-truck0000003', 'aaaaaaaa-bbbb-cccc-dddd-driver000003', NOW(), false)
+ON CONFLICT ("Id") DO NOTHING;
+
+-- Insertar Shipment
+INSERT INTO "Shipments" ("Id", "TenantId", "TrackingNumber", "Status", "OriginLocationId", "DestinationLocationId", "TruckId", "DriverId", "TotalWeightKg", "TotalVolumeM3", "DeclaredValue", "ScheduledDeparture", "EstimatedArrival", "IsDelayed", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaabbbb-cccc-dddd-eeee-ffffffffffff',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'TRX-2025-001',
+ 5,
+ 'aaaaaaaa-bbbb-cccc-dddd-loc000000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-loc000000002',
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-drvr00000001',
+ 5000,
+ 20,
+ 150000.00,
+ NOW() - INTERVAL '2 hours',
+ NOW() + INTERVAL '10 hours',
+ false,
+ NOW(),
+ false
+)
+ON CONFLICT ("Id") DO NOTHING;
+
+-- Insertar ShipmentItem con dimensiones correctas
+INSERT INTO "ShipmentItems" ("Id", "ShipmentId", "Description", "PackagingType", "Quantity", "WeightKg", "WidthCm", "HeightCm", "LengthCm", "DeclaredValue", "IsFragile", "IsHazardous", "RequiresRefrigeration", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaabbbb-cccc-dddd-eeee-111111111111',
+ 'aaaabbbb-cccc-dddd-eeee-ffffffffffff',
+ 'Electrodomésticos',
+ 0,
+ 50,
+ 5000,
+ 100,
+ 100,
+ 200,
+ 150000.00,
+ false,
+ false,
+ false,
+ NOW(),
+ false
+)
+ON CONFLICT ("Id") DO NOTHING;
+
+SELECT '=== DATOS ADICIONALES INSERTADOS ===' AS resultado;
+SELECT 'Drivers: Juan(OnTrip), María(Available/Cerca), Pedro(Available/Lejos)' AS drivers;
+SELECT 'Shipment: TRX-2025-001 (Status=InTransit)' AS shipment;
diff --git a/backend/scripts/seed-e2e-test.sql b/backend/scripts/seed-e2e-test.sql
new file mode 100644
index 0000000..dc94076
--- /dev/null
+++ b/backend/scripts/seed-e2e-test.sql
@@ -0,0 +1,307 @@
+-- ================================================================
+-- PARHELION WMS - Test Data for n8n Integration E2E
+-- Tenant: "TransporteMX" con Admin y 3 Choferes con ubicaciones GPS
+-- ================================================================
+
+-- Primero limpiamos datos de prueba anteriores (si existen)
+DELETE FROM "Notifications" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+DELETE FROM "ShipmentItems" WHERE "ShipmentId" IN (SELECT "Id" FROM "Shipments" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
+DELETE FROM "Shipments" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+DELETE FROM "FleetLogs" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+DELETE FROM "Drivers" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+DELETE FROM "Trucks" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+DELETE FROM "Employees" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+DELETE FROM "Locations" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+DELETE FROM "Users" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+DELETE FROM "ServiceApiKeys" WHERE "TenantId" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+DELETE FROM "Tenants" WHERE "Id" = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
+
+-- ================================================================
+-- 1. TENANT
+-- ================================================================
+INSERT INTO "Tenants" ("Id", "CompanyName", "ContactEmail", "FleetSize", "DriverCount", "IsActive", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'TransporteMX',
+ 'admin@transportemx.com',
+ 5,
+ 3,
+ true,
+ NOW(),
+ false
+);
+
+-- ================================================================
+-- 2. SERVICE API KEY (para n8n callback - hasheada)
+-- Hash of: 'test-n8n-key-transportemx-2025'
+-- ================================================================
+INSERT INTO "ServiceApiKeys" ("Id", "TenantId", "KeyHash", "Name", "Description", "IsActive", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-111111111111',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ encode(sha256('test-n8n-key-transportemx-2025'::bytea), 'hex'),
+ 'n8n-transportemx-test',
+ 'API Key de prueba para test E2E',
+ true,
+ NOW(),
+ false
+);
+
+-- ================================================================
+-- 3. ROLE (Admin)
+-- ================================================================
+-- Usamos el rol Admin que ya debería existir
+-- Si no existe, crearlo:
+INSERT INTO "Roles" ("Id", "Name", "Description", "CreatedAt", "IsDeleted")
+VALUES ('11111111-1111-1111-1111-111111111111', 'Admin', 'Administrator', NOW(), false)
+ON CONFLICT ("Id") DO NOTHING;
+
+INSERT INTO "Roles" ("Id", "Name", "Description", "CreatedAt", "IsDeleted")
+VALUES ('22222222-2222-2222-2222-222222222222', 'Driver', 'Chofer', NOW(), false)
+ON CONFLICT ("Id") DO NOTHING;
+
+-- ================================================================
+-- 4. USERS (1 Admin + 3 Drivers)
+-- Password: Test1234! (bcrypt hash)
+-- ================================================================
+-- Admin
+INSERT INTO "Users" ("Id", "TenantId", "Email", "PasswordHash", "FullName", "RoleId", "IsDemoUser", "IsActive", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-admin0000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'admin@transportemx.com',
+ '$2a$11$K.0HwPqDcBH3V5yJ8EQR.eL1K4F5nC3.V5vI4n1tA5m6O3r7R9s0e', -- Test1234!
+ 'Carlos Admin',
+ '11111111-1111-1111-1111-111111111111',
+ false,
+ true,
+ NOW(),
+ false
+);
+
+-- Driver 1: Juan (EN MONTERREY - el que tendrá el problema)
+INSERT INTO "Users" ("Id", "TenantId", "Email", "PasswordHash", "FullName", "RoleId", "IsDemoUser", "IsActive", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-driver000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'juan@transportemx.com',
+ '$2a$11$K.0HwPqDcBH3V5yJ8EQR.eL1K4F5nC3.V5vI4n1tA5m6O3r7R9s0e',
+ 'Juan Pérez (Afectado)',
+ '22222222-2222-2222-2222-222222222222',
+ false,
+ true,
+ NOW(),
+ false
+);
+
+-- Driver 2: María (CERCA DE MONTERREY - 10km - rescatista cercana)
+INSERT INTO "Users" ("Id", "TenantId", "Email", "PasswordHash", "FullName", "RoleId", "IsDemoUser", "IsActive", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-driver000002',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'maria@transportemx.com',
+ '$2a$11$K.0HwPqDcBH3V5yJ8EQR.eL1K4F5nC3.V5vI4n1tA5m6O3r7R9s0e',
+ 'María García (Rescatista Cercana)',
+ '22222222-2222-2222-2222-222222222222',
+ false,
+ true,
+ NOW(),
+ false
+);
+
+-- Driver 3: Pedro (LEJOS - Guadalajara - 500km)
+INSERT INTO "Users" ("Id", "TenantId", "Email", "PasswordHash", "FullName", "RoleId", "IsDemoUser", "IsActive", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-driver000003',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'pedro@transportemx.com',
+ '$2a$11$K.0HwPqDcBH3V5yJ8EQR.eL1K4F5nC3.V5vI4n1tA5m6O3r7R9s0e',
+ 'Pedro Ramírez (Lejano)',
+ '22222222-2222-2222-2222-222222222222',
+ false,
+ true,
+ NOW(),
+ false
+);
+
+-- ================================================================
+-- 5. EMPLOYEES
+-- ================================================================
+INSERT INTO "Employees" ("Id", "TenantId", "FirstName", "LastName", "Position", "Email", "Phone", "HireDate", "IsActive", "UserId", "CreatedAt", "IsDeleted")
+VALUES
+ ('aaaaaaaa-bbbb-cccc-dddd-empl00000001', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'Juan', 'Pérez', 'Chofer', 'juan@transportemx.com', '+52 81 1234 5678', '2023-01-15', true, 'aaaaaaaa-bbbb-cccc-dddd-driver000001', NOW(), false),
+ ('aaaaaaaa-bbbb-cccc-dddd-empl00000002', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'María', 'García', 'Chofer', 'maria@transportemx.com', '+52 81 2345 6789', '2023-03-20', true, 'aaaaaaaa-bbbb-cccc-dddd-driver000002', NOW(), false),
+ ('aaaaaaaa-bbbb-cccc-dddd-empl00000003', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'Pedro', 'Ramírez', 'Chofer', 'pedro@transportemx.com', '+52 33 3456 7890', '2023-06-01', true, 'aaaaaaaa-bbbb-cccc-dddd-driver000003', NOW(), false);
+
+-- ================================================================
+-- 6. LOCATIONS (Hub en Monterrey, destino en CDMX)
+-- ================================================================
+INSERT INTO "Locations" ("Id", "TenantId", "Code", "Name", "Type", "Latitude", "Longitude", "IsActive", "CreatedAt", "IsDeleted")
+VALUES
+ ('aaaaaaaa-bbbb-cccc-dddd-loc000000001', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'MTY-HUB', 'Hub Monterrey', 0, 25.6866, -100.3161, true, NOW(), false),
+ ('aaaaaaaa-bbbb-cccc-dddd-loc000000002', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'CDMX-DST', 'Destino CDMX', 3, 19.4326, -99.1332, true, NOW(), false);
+
+-- ================================================================
+-- 7. TRUCKS (3 camiones con ubicaciones GPS)
+-- ================================================================
+-- Camión 1: Juan (EN MONTERREY CENTRO - el averiado)
+INSERT INTO "Trucks" ("Id", "TenantId", "PlateNumber", "Type", "MaxCapacityKg", "MaxVolumeM3", "Model", "Year", "IsActive", "LastLatitude", "LastLongitude", "LastLocationUpdate", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'MTY-001-AAA',
+ 0, -- DryBox
+ 10000,
+ 40,
+ 'Kenworth T680',
+ 2022,
+ true,
+ 25.6750, -- Lat: Centro Monterrey (punto de avería)
+ -100.3100, -- Lon
+ NOW(),
+ NOW(),
+ false
+);
+
+-- Camión 2: María (10km al norte de Monterrey - CERCANA y DISPONIBLE)
+INSERT INTO "Trucks" ("Id", "TenantId", "PlateNumber", "Type", "MaxCapacityKg", "MaxVolumeM3", "Model", "Year", "IsActive", "LastLatitude", "LastLongitude", "LastLocationUpdate", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000002',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'MTY-002-BBB',
+ 0, -- DryBox
+ 10000,
+ 40,
+ 'Freightliner Cascadia',
+ 2023,
+ true,
+ 25.7700, -- Lat: ~10km norte de MTY
+ -100.3000, -- Lon
+ NOW(),
+ NOW(),
+ false
+);
+
+-- Camión 3: Pedro (Guadalajara - LEJANO)
+INSERT INTO "Trucks" ("Id", "TenantId", "PlateNumber", "Type", "MaxCapacityKg", "MaxVolumeM3", "Model", "Year", "IsActive", "LastLatitude", "LastLongitude", "LastLocationUpdate", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000003',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'GDL-003-CCC',
+ 0, -- DryBox
+ 10000,
+ 40,
+ 'Volvo VNL 860',
+ 2021,
+ true,
+ 20.6597, -- Lat: Guadalajara
+ -103.3496, -- Lon
+ NOW(),
+ NOW(),
+ false
+);
+
+-- ================================================================
+-- 8. DRIVERS (con status y asignaciones)
+-- ================================================================
+-- Juan: Status=OnTrip (está trabajando, tendrá la avería)
+INSERT INTO "Drivers" ("Id", "TenantId", "LicenseNumber", "LicenseExpiry", "Status", "DefaultTruckId", "CurrentTruckId", "UserId", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-drvr00000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'LIC-MTY-001',
+ '2026-12-31',
+ 2, -- OnTrip
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-driver000001',
+ NOW(),
+ false
+);
+
+-- María: Status=Available (DISPONIBLE para rescate)
+INSERT INTO "Drivers" ("Id", "TenantId", "LicenseNumber", "LicenseExpiry", "Status", "DefaultTruckId", "CurrentTruckId", "UserId", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-drvr00000002',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'LIC-MTY-002',
+ '2027-06-30',
+ 0, -- Available ← Esta es la que debe encontrar n8n
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000002',
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000002',
+ 'aaaaaaaa-bbbb-cccc-dddd-driver000002',
+ NOW(),
+ false
+);
+
+-- Pedro: Status=Available pero lejano
+INSERT INTO "Drivers" ("Id", "TenantId", "LicenseNumber", "LicenseExpiry", "Status", "DefaultTruckId", "CurrentTruckId", "UserId", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-drvr00000003',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'LIC-GDL-003',
+ '2025-08-15',
+ 0, -- Available pero lejos
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000003',
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000003',
+ 'aaaaaaaa-bbbb-cccc-dddd-driver000003',
+ NOW(),
+ false
+);
+
+-- ================================================================
+-- 9. SHIPMENT (El envío que tendrá la avería)
+-- Status: InTransit (para que pueda cambiar a Exception)
+-- ================================================================
+INSERT INTO "Shipments" ("Id", "TenantId", "TrackingNumber", "Status", "OriginLocationId", "DestinationLocationId", "TruckId", "DriverId", "TotalWeightKg", "TotalVolumeM3", "DeclaredValue", "ScheduledDeparture", "EstimatedArrival", "IsDelayed", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-ship00000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
+ 'TRX-2025-001',
+ 5, -- InTransit (puede cambiar a Exception)
+ 'aaaaaaaa-bbbb-cccc-dddd-loc000000001', -- MTY Hub
+ 'aaaaaaaa-bbbb-cccc-dddd-loc000000002', -- CDMX Destino
+ 'aaaaaaaa-bbbb-cccc-dddd-truck0000001', -- Camión de Juan
+ 'aaaaaaaa-bbbb-cccc-dddd-drvr00000001', -- Juan (el afectado)
+ 5000,
+ 20,
+ 150000.00,
+ NOW() - INTERVAL '2 hours',
+ NOW() + INTERVAL '10 hours',
+ false,
+ NOW(),
+ false
+);
+
+-- Item del shipment
+INSERT INTO "ShipmentItems" ("Id", "ShipmentId", "Description", "Quantity", "WeightKg", "VolumeM3", "DeclaredValue", "RequiresRefrigeration", "IsHazardous", "CreatedAt", "IsDeleted")
+VALUES (
+ 'aaaaaaaa-bbbb-cccc-dddd-item00000001',
+ 'aaaaaaaa-bbbb-cccc-dddd-ship00000001',
+ 'Electrodomésticos',
+ 50,
+ 5000,
+ 20,
+ 150000.00,
+ false,
+ false,
+ NOW(),
+ false
+);
+
+-- ================================================================
+-- RESULTADOS ESPERADOS:
+-- ================================================================
+-- Cuando el Shipment TRX-2025-001 cambie a Exception:
+-- 1. Se publica webhook con coordenadas (25.6750, -100.3100)
+-- 2. n8n busca en GET /api/drivers/nearby?lat=25.6750&lon=-100.31&radiusKm=50
+-- 3. Debe encontrar a María (~10km) como la más cercana Y disponible
+-- 4. n8n envía 2 notificaciones:
+-- - A Juan (afectado): "Tu envío ha sido marcado como excepción"
+-- - A María (rescatista): "Se te asignó apoyo para TRX-2025-001"
+-- ================================================================
+
+SELECT 'Datos de prueba insertados correctamente' AS resultado;
+SELECT 'Tenant: TransporteMX (aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee)' AS info;
+SELECT 'Shipment: TRX-2025-001 (Status: InTransit → cambiar a Exception para trigger)' AS test;
+SELECT 'API Key de prueba: test-n8n-key-transportemx-2025' AS n8n_key;
diff --git a/backend/src/Parhelion.API/Controllers/AnalyticsController.cs b/backend/src/Parhelion.API/Controllers/AnalyticsController.cs
new file mode 100644
index 0000000..2b848cf
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/AnalyticsController.cs
@@ -0,0 +1,306 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.Interfaces;
+using Parhelion.Application.Services;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controller for ML Analytics endpoints.
+/// Orchestrates calls to Python Analytics internal service.
+///
+[ApiController]
+[Route("api/analytics")]
+[Authorize]
+[Produces("application/json")]
+public class AnalyticsController : ControllerBase
+{
+ private readonly IPythonAnalyticsClient _pythonClient;
+ private readonly ICurrentUserService _currentUser;
+ private readonly ILogger _logger;
+
+ public AnalyticsController(
+ IPythonAnalyticsClient pythonClient,
+ ICurrentUserService currentUser,
+ ILogger logger)
+ {
+ _pythonClient = pythonClient;
+ _currentUser = currentUser;
+ _logger = logger;
+ }
+
+ ///
+ /// Optimize route between two locations using graph algorithms.
+ ///
+ [HttpPost("routes/optimize")]
+ public async Task> OptimizeRoute(
+ [FromBody] RouteOptimizeApiRequest request,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Route optimize: {Origin} -> {Dest}", request.OriginId, request.DestinationId);
+
+ var result = await _pythonClient.OptimizeRouteAsync(new RouteOptimizeRequest(
+ tenantId.Value,
+ request.OriginId,
+ request.DestinationId,
+ request.AvoidLocations,
+ request.MaxTimeHours
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Recommend trucks for a shipment using ML scoring.
+ ///
+ [HttpGet("trucks/recommend/{shipmentId}")]
+ public async Task>> RecommendTrucks(
+ string shipmentId,
+ [FromQuery] int limit = 3,
+ [FromQuery] bool considerDeadhead = true,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Truck recommend for shipment: {ShipmentId}", shipmentId);
+
+ var result = await _pythonClient.RecommendTrucksAsync(new TruckRecommendRequest(
+ tenantId.Value,
+ shipmentId,
+ limit,
+ considerDeadhead
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Forecast shipment demand using time series analysis.
+ ///
+ [HttpGet("forecast/demand")]
+ public async Task> ForecastDemand(
+ [FromQuery] string? locationId = null,
+ [FromQuery] int days = 30,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Demand forecast for {Days} days", days);
+
+ var result = await _pythonClient.ForecastDemandAsync(new DemandForecastRequest(
+ tenantId.Value,
+ locationId,
+ days
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Detect anomalies in shipment tracking.
+ ///
+ [HttpGet("anomalies")]
+ public async Task>> DetectAnomalies(
+ [FromQuery] int hoursBack = 24,
+ [FromQuery] string? severity = null,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Anomaly detection for last {Hours} hours", hoursBack);
+
+ var result = await _pythonClient.DetectAnomaliesAsync(new AnomalyDetectRequest(
+ tenantId.Value,
+ hoursBack,
+ severity
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Optimize 3D cargo loading for a truck.
+ ///
+ [HttpPost("loading/optimize")]
+ public async Task> OptimizeLoading(
+ [FromBody] LoadingOptimizeApiRequest request,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Loading optimize for truck: {TruckId}", request.TruckId);
+
+ var result = await _pythonClient.OptimizeLoadingAsync(new LoadingOptimizeRequest(
+ tenantId.Value,
+ request.TruckId,
+ request.ShipmentIds
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Generate operational dashboard with KPIs.
+ ///
+ [HttpGet("dashboard")]
+ public async Task> GetDashboard(
+ [FromQuery] bool refresh = false,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Dashboard generation requested");
+
+ var result = await _pythonClient.GenerateDashboardAsync(new DashboardRequest(
+ tenantId.Value,
+ refresh
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Analyze logistics network topology.
+ ///
+ [HttpGet("network")]
+ public async Task> AnalyzeNetwork(
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Network analysis requested");
+
+ var result = await _pythonClient.AnalyzeNetworkAsync(new NetworkAnalyzeRequest(
+ tenantId.Value
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Cluster shipments geographically for route consolidation.
+ ///
+ [HttpPost("shipments/cluster")]
+ public async Task>> ClusterShipments(
+ [FromBody] ShipmentClusterApiRequest request,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Clustering into {Count} groups", request.ClusterCount);
+
+ var result = await _pythonClient.ClusterShipmentsAsync(new ShipmentClusterRequest(
+ tenantId.Value,
+ request.ClusterCount,
+ request.DateFrom,
+ request.DateTo
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Predict ETA for a shipment using ML.
+ ///
+ [HttpGet("eta/{shipmentId}")]
+ public async Task> PredictETA(
+ string shipmentId,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("ETA prediction for: {ShipmentId}", shipmentId);
+
+ var result = await _pythonClient.PredictETAAsync(new ETAPredictRequest(
+ tenantId.Value,
+ shipmentId
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Get driver performance metrics.
+ ///
+ [HttpGet("drivers/{driverId}/performance")]
+ public async Task> GetDriverPerformance(
+ string driverId,
+ [FromQuery] int days = 30,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Performance for driver: {DriverId}", driverId);
+
+ var result = await _pythonClient.GetDriverPerformanceAsync(new DriverPerformanceRequest(
+ tenantId.Value,
+ driverId,
+ days
+ ), ct);
+
+ return Ok(result);
+ }
+
+ ///
+ /// Get driver leaderboard.
+ ///
+ [HttpGet("drivers/leaderboard")]
+ public async Task>> GetLeaderboard(
+ [FromQuery] int limit = 10,
+ [FromQuery] int days = 30,
+ CancellationToken ct = default)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized();
+
+ _logger.LogInformation("Driver leaderboard, top {Limit}", limit);
+
+ var result = await _pythonClient.GetLeaderboardAsync(new LeaderboardRequest(
+ tenantId.Value,
+ limit,
+ days
+ ), ct);
+
+ return Ok(result);
+ }
+
+ private Guid? GetTenantId()
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim != null && Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return tenantId;
+ return null;
+ }
+}
+
+// ============ API Request DTOs (simplified for clients) ============
+
+public record RouteOptimizeApiRequest(
+ string OriginId,
+ string DestinationId,
+ List? AvoidLocations = null,
+ double? MaxTimeHours = null
+);
+
+public record LoadingOptimizeApiRequest(
+ string TruckId,
+ List ShipmentIds
+);
+
+public record ShipmentClusterApiRequest(
+ int ClusterCount = 5,
+ string? DateFrom = null,
+ string? DateTo = null
+);
diff --git a/backend/src/Parhelion.API/Controllers/AuthController.cs b/backend/src/Parhelion.API/Controllers/AuthController.cs
new file mode 100644
index 0000000..6de30f5
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/AuthController.cs
@@ -0,0 +1,237 @@
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.Auth;
+using Parhelion.Application.DTOs.Auth;
+using Parhelion.Domain.Entities;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador de autenticación.
+/// Maneja login, refresh de tokens y logout.
+///
+[ApiController]
+[Route("api/auth")]
+public class AuthController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+ private readonly IJwtService _jwtService;
+ private readonly IPasswordHasher _passwordHasher;
+
+ public AuthController(
+ ParhelionDbContext context,
+ IJwtService jwtService,
+ IPasswordHasher passwordHasher)
+ {
+ _context = context;
+ _jwtService = jwtService;
+ _passwordHasher = passwordHasher;
+ }
+
+ ///
+ /// Login de usuario con email y password.
+ ///
+ [HttpPost("login")]
+ [AllowAnonymous]
+ public async Task> Login([FromBody] LoginRequest request)
+ {
+ // Buscar usuario por email (ignorar filtro de tenant para login)
+ var user = await _context.Users
+ .IgnoreQueryFilters()
+ .Include(u => u.Role)
+ .Include(u => u.Tenant)
+ .FirstOrDefaultAsync(u => u.Email == request.Email && !u.IsDeleted);
+
+ if (user == null)
+ {
+ return Unauthorized(new { error = "Email o contraseña incorrectos" });
+ }
+
+ if (!user.IsActive)
+ {
+ return Unauthorized(new { error = "Usuario inactivo" });
+ }
+
+ // Verificar password
+ if (!_passwordHasher.VerifyPassword(request.Password, user.PasswordHash, user.UsesArgon2))
+ {
+ return Unauthorized(new { error = "Email o contraseña incorrectos" });
+ }
+
+ // Actualizar último login
+ user.LastLogin = DateTime.UtcNow;
+
+ // Generar tokens
+ var accessToken = _jwtService.GenerateAccessToken(user, user.Role.Name);
+ var refreshToken = _jwtService.GenerateRefreshToken();
+
+ // Guardar refresh token hasheado
+ var refreshTokenEntity = new RefreshToken
+ {
+ UserId = user.Id,
+ TokenHash = HashToken(refreshToken),
+ ExpiresAt = _jwtService.GetRefreshTokenExpiration(),
+ CreatedFromIp = GetClientIp(),
+ UserAgent = Request.Headers.UserAgent.ToString()
+ };
+
+ _context.RefreshTokens.Add(refreshTokenEntity);
+ await _context.SaveChangesAsync();
+
+ return Ok(new LoginResponse
+ {
+ AccessToken = accessToken,
+ RefreshToken = refreshToken,
+ ExpiresAt = _jwtService.GetAccessTokenExpiration(),
+ User = new UserInfo
+ {
+ Id = user.Id,
+ Email = user.Email,
+ FullName = user.FullName,
+ Role = user.Role.Name,
+ TenantId = user.TenantId
+ }
+ });
+ }
+
+ ///
+ /// Renovar access token usando refresh token.
+ ///
+ [HttpPost("refresh")]
+ [AllowAnonymous]
+ public async Task> Refresh([FromBody] RefreshTokenRequest request)
+ {
+ var tokenHash = HashToken(request.RefreshToken);
+
+ var refreshToken = await _context.RefreshTokens
+ .Include(rt => rt.User)
+ .ThenInclude(u => u.Role)
+ .FirstOrDefaultAsync(rt =>
+ rt.TokenHash == tokenHash &&
+ !rt.IsRevoked &&
+ rt.ExpiresAt > DateTime.UtcNow);
+
+ if (refreshToken == null)
+ {
+ return Unauthorized(new { error = "Refresh token inválido o expirado" });
+ }
+
+ var user = refreshToken.User;
+ if (!user.IsActive || user.IsDeleted)
+ {
+ return Unauthorized(new { error = "Usuario inactivo" });
+ }
+
+ // Revocar token actual
+ refreshToken.IsRevoked = true;
+ refreshToken.RevokedAt = DateTime.UtcNow;
+ refreshToken.RevokedReason = "Replaced";
+
+ // Generar nuevos tokens
+ var newAccessToken = _jwtService.GenerateAccessToken(user, user.Role.Name);
+ var newRefreshToken = _jwtService.GenerateRefreshToken();
+
+ // Guardar nuevo refresh token
+ var newRefreshTokenEntity = new RefreshToken
+ {
+ UserId = user.Id,
+ TokenHash = HashToken(newRefreshToken),
+ ExpiresAt = _jwtService.GetRefreshTokenExpiration(),
+ CreatedFromIp = GetClientIp(),
+ UserAgent = Request.Headers.UserAgent.ToString()
+ };
+
+ _context.RefreshTokens.Add(newRefreshTokenEntity);
+ user.LastLogin = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+
+ return Ok(new LoginResponse
+ {
+ AccessToken = newAccessToken,
+ RefreshToken = newRefreshToken,
+ ExpiresAt = _jwtService.GetAccessTokenExpiration(),
+ User = new UserInfo
+ {
+ Id = user.Id,
+ Email = user.Email,
+ FullName = user.FullName,
+ Role = user.Role.Name,
+ TenantId = user.TenantId
+ }
+ });
+ }
+
+ ///
+ /// Logout - revoca el refresh token.
+ ///
+ [HttpPost("logout")]
+ [Authorize]
+ public async Task Logout([FromBody] RefreshTokenRequest request)
+ {
+ var tokenHash = HashToken(request.RefreshToken);
+
+ var refreshToken = await _context.RefreshTokens
+ .FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash && !rt.IsRevoked);
+
+ if (refreshToken != null)
+ {
+ refreshToken.IsRevoked = true;
+ refreshToken.RevokedAt = DateTime.UtcNow;
+ refreshToken.RevokedReason = "Logout";
+ await _context.SaveChangesAsync();
+ }
+
+ return Ok(new { message = "Sesión cerrada correctamente" });
+ }
+
+ ///
+ /// Obtener información del usuario actual.
+ ///
+ [HttpGet("me")]
+ [Authorize]
+ public async Task> GetCurrentUser()
+ {
+ var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
+ if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId))
+ {
+ return Unauthorized();
+ }
+
+ var user = await _context.Users
+ .IgnoreQueryFilters()
+ .Include(u => u.Role)
+ .FirstOrDefaultAsync(u => u.Id == userId && !u.IsDeleted);
+
+ if (user == null)
+ {
+ return NotFound();
+ }
+
+ return Ok(new UserInfo
+ {
+ Id = user.Id,
+ Email = user.Email,
+ FullName = user.FullName,
+ Role = user.Role.Name,
+ TenantId = user.TenantId
+ });
+ }
+
+ // ========== Private Helpers ==========
+
+ private static string HashToken(string token)
+ {
+ using var sha256 = SHA256.Create();
+ var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token));
+ return Convert.ToBase64String(bytes);
+ }
+
+ private string GetClientIp()
+ {
+ return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/CatalogItemsController.cs b/backend/src/Parhelion.API/Controllers/CatalogItemsController.cs
new file mode 100644
index 0000000..97414d3
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/CatalogItemsController.cs
@@ -0,0 +1,201 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.DTOs.Catalog;
+using Parhelion.Domain.Entities;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador CRUD para el Catálogo de Productos.
+/// Gestiona los SKUs y sus propiedades (dimensiones, manejo especial, etc).
+///
+[ApiController]
+[Route("api/catalog-items")]
+[Authorize]
+public class CatalogItemsController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+
+ public CatalogItemsController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Obtiene todos los CatalogItems del tenant.
+ ///
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ var items = await _context.CatalogItems
+ .Where(x => !x.IsDeleted)
+ .OrderBy(x => x.Sku)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+
+ return Ok(items);
+ }
+
+ ///
+ /// Obtiene un CatalogItem por su ID.
+ ///
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id)
+ {
+ var item = await _context.CatalogItems
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+
+ if (item == null)
+ {
+ return NotFound(new { error = "CatalogItem no encontrado" });
+ }
+
+ return Ok(MapToResponse(item));
+ }
+
+ ///
+ /// Busca CatalogItems por SKU (búsqueda parcial).
+ ///
+ [HttpGet("search")]
+ public async Task>> SearchBySku([FromQuery] string sku)
+ {
+ if (string.IsNullOrWhiteSpace(sku))
+ {
+ return BadRequest(new { error = "El parámetro 'sku' es requerido" });
+ }
+
+ var items = await _context.CatalogItems
+ .Where(x => !x.IsDeleted && x.Sku.ToLower().Contains(sku.ToLower()))
+ .OrderBy(x => x.Sku)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+
+ return Ok(items);
+ }
+
+ ///
+ /// Crea un nuevo CatalogItem.
+ ///
+ [HttpPost]
+ public async Task> Create([FromBody] CreateCatalogItemRequest request)
+ {
+ // Verificar que el SKU no exista ya en el tenant
+ var existingSku = await _context.CatalogItems
+ .AnyAsync(x => x.Sku == request.Sku && !x.IsDeleted);
+
+ if (existingSku)
+ {
+ return Conflict(new { error = $"Ya existe un producto con SKU '{request.Sku}'" });
+ }
+
+ // Obtener TenantId del usuario actual
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ {
+ return Unauthorized(new { error = "No se pudo determinar el tenant del usuario" });
+ }
+
+ var item = new CatalogItem
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ Sku = request.Sku,
+ Name = request.Name,
+ Description = request.Description,
+ BaseUom = request.BaseUom,
+ DefaultWeightKg = request.DefaultWeightKg,
+ DefaultWidthCm = request.DefaultWidthCm,
+ DefaultHeightCm = request.DefaultHeightCm,
+ DefaultLengthCm = request.DefaultLengthCm,
+ RequiresRefrigeration = request.RequiresRefrigeration,
+ IsHazardous = request.IsHazardous,
+ IsFragile = request.IsFragile,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.CatalogItems.Add(item);
+ await _context.SaveChangesAsync();
+
+ return CreatedAtAction(
+ nameof(GetById),
+ new { id = item.Id },
+ MapToResponse(item));
+ }
+
+ ///
+ /// Actualiza un CatalogItem existente.
+ ///
+ [HttpPut("{id:guid}")]
+ public async Task> Update(Guid id, [FromBody] UpdateCatalogItemRequest request)
+ {
+ var item = await _context.CatalogItems
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+
+ if (item == null)
+ {
+ return NotFound(new { error = "CatalogItem no encontrado" });
+ }
+
+ item.Name = request.Name;
+ item.Description = request.Description;
+ item.BaseUom = request.BaseUom;
+ item.DefaultWeightKg = request.DefaultWeightKg;
+ item.DefaultWidthCm = request.DefaultWidthCm;
+ item.DefaultHeightCm = request.DefaultHeightCm;
+ item.DefaultLengthCm = request.DefaultLengthCm;
+ item.RequiresRefrigeration = request.RequiresRefrigeration;
+ item.IsHazardous = request.IsHazardous;
+ item.IsFragile = request.IsFragile;
+ item.IsActive = request.IsActive;
+ item.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+
+ return Ok(MapToResponse(item));
+ }
+
+ ///
+ /// Elimina (soft-delete) un CatalogItem.
+ ///
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var item = await _context.CatalogItems
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+
+ if (item == null)
+ {
+ return NotFound(new { error = "CatalogItem no encontrado" });
+ }
+
+ item.IsDeleted = true;
+ item.DeletedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+
+ return NoContent();
+ }
+
+ // ========== Mapping Helper ==========
+
+ private static CatalogItemResponse MapToResponse(CatalogItem item) => new(
+ item.Id,
+ item.Sku,
+ item.Name,
+ item.Description,
+ item.BaseUom,
+ item.DefaultWeightKg,
+ item.DefaultWidthCm,
+ item.DefaultHeightCm,
+ item.DefaultLengthCm,
+ item.DefaultVolumeM3,
+ item.RequiresRefrigeration,
+ item.IsHazardous,
+ item.IsFragile,
+ item.IsActive,
+ item.CreatedAt,
+ item.UpdatedAt
+ );
+}
diff --git a/backend/src/Parhelion.API/Controllers/ClientsController.cs b/backend/src/Parhelion.API/Controllers/ClientsController.cs
new file mode 100644
index 0000000..050cf54
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/ClientsController.cs
@@ -0,0 +1,235 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Core;
+using Parhelion.Application.Interfaces.Services;
+using Parhelion.Domain.Enums;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para gestión de clientes (remitentes/destinatarios).
+///
+[ApiController]
+[Route("api/clients")]
+[Authorize]
+public class ClientsController : ControllerBase
+{
+ private readonly IClientService _clientService;
+
+ ///
+ /// Inicializa el controlador con el servicio de Clients.
+ ///
+ /// Servicio de gestión de clientes.
+ public ClientsController(IClientService clientService)
+ {
+ _clientService = clientService;
+ }
+
+ ///
+ /// Obtiene todos los clientes con paginación.
+ ///
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de clientes.
+ [HttpGet]
+ public async Task>> GetAll(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _clientService.GetAllAsync(request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene un cliente por ID.
+ ///
+ /// ID del cliente.
+ /// Token de cancelación.
+ /// Cliente encontrado.
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _clientService.GetByIdAsync(id, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Cliente no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Busca un cliente por email.
+ ///
+ /// Email del cliente.
+ /// Token de cancelación.
+ /// Cliente encontrado.
+ [HttpGet("by-email")]
+ public async Task> GetByEmail(
+ [FromQuery] string email,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(email))
+ return BadRequest(new { error = "El parámetro 'email' es requerido" });
+
+ var item = await _clientService.GetByEmailAsync(email, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Cliente no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Busca un cliente por Tax ID (RFC).
+ ///
+ /// RFC del cliente.
+ /// Token de cancelación.
+ /// Cliente encontrado.
+ [HttpGet("by-tax-id/{taxId}")]
+ public async Task> GetByTaxId(
+ string taxId,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _clientService.GetByTaxIdAsync(taxId, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Cliente no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Obtiene clientes del tenant actual.
+ ///
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de clientes del tenant.
+ [HttpGet("current-tenant")]
+ public async Task>> GetByCurrentTenant(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _clientService.GetByTenantAsync(tenantId, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene clientes por prioridad del tenant actual.
+ ///
+ /// Prioridad del cliente (Normal, Low, High, Urgent).
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de clientes con la prioridad especificada.
+ [HttpGet("by-priority/{priority}")]
+ public async Task>> GetByPriority(
+ string priority,
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (!Enum.TryParse(priority, out var clientPriority))
+ return BadRequest(new { error = "Prioridad inválida" });
+
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _clientService.GetByPriorityAsync(
+ tenantId, clientPriority, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Busca clientes por nombre de empresa.
+ ///
+ /// Nombre parcial de la empresa.
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de clientes que coinciden.
+ [HttpGet("search")]
+ public async Task>> Search(
+ [FromQuery] string companyName,
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(companyName))
+ return BadRequest(new { error = "El parámetro 'companyName' es requerido" });
+
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _clientService.SearchByCompanyNameAsync(
+ tenantId, companyName, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Crea un nuevo cliente.
+ ///
+ /// Datos del nuevo cliente.
+ /// Token de cancelación.
+ /// Cliente creado.
+ [HttpPost]
+ public async Task> Create(
+ [FromBody] CreateClientRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _clientService.CreateAsync(request, cancellationToken);
+
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+
+ return CreatedAtAction(
+ nameof(GetById),
+ new { id = result.Data!.Id },
+ result.Data);
+ }
+
+ ///
+ /// Actualiza un cliente existente.
+ ///
+ /// ID del cliente.
+ /// Datos de actualización.
+ /// Token de cancelación.
+ /// Cliente actualizado.
+ [HttpPut("{id:guid}")]
+ public async Task> Update(
+ Guid id,
+ [FromBody] UpdateClientRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _clientService.UpdateAsync(id, request, cancellationToken);
+
+ if (!result.Success)
+ {
+ if (result.Message?.Contains("no encontrado") == true)
+ return NotFound(new { error = result.Message });
+ return Conflict(new { error = result.Message });
+ }
+
+ return Ok(result.Data);
+ }
+
+ ///
+ /// Elimina (soft-delete) un cliente.
+ ///
+ /// ID del cliente.
+ /// Token de cancelación.
+ /// 204 No Content.
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _clientService.DeleteAsync(id, cancellationToken);
+
+ if (!result.Success)
+ return NotFound(new { error = result.Message });
+
+ return NoContent();
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/DocumentsController.cs b/backend/src/Parhelion.API/Controllers/DocumentsController.cs
new file mode 100644
index 0000000..57c06aa
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/DocumentsController.cs
@@ -0,0 +1,171 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.Interfaces;
+using Parhelion.Domain.Enums;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para generación dinámica de documentos PDF.
+/// Los PDFs se generan on-demand y se retornan como bytes para crear blob URL en cliente.
+/// No hay almacenamiento permanente de archivos.
+///
+[ApiController]
+[Route("api/documents")]
+[Authorize]
+public class DocumentsController : ControllerBase
+{
+ private readonly IPdfGeneratorService _pdfGenerator;
+
+ public DocumentsController(IPdfGeneratorService pdfGenerator)
+ {
+ _pdfGenerator = pdfGenerator;
+ }
+
+ ///
+ /// Genera y retorna un PDF de Orden de Servicio.
+ /// El cliente debe crear un blob URL local para visualizar.
+ ///
+ /// ID del envío.
+ /// Token de cancelación.
+ /// PDF como application/pdf para crear blob URL.
+ [HttpGet("service-order/{shipmentId:guid}")]
+ [Produces("application/pdf")]
+ public async Task GetServiceOrder(Guid shipmentId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var pdfBytes = await _pdfGenerator.GenerateServiceOrderAsync(shipmentId, cancellationToken);
+ return File(pdfBytes, "application/pdf", $"OrdenServicio_{shipmentId:N}.pdf");
+ }
+ catch (KeyNotFoundException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// Genera y retorna un PDF de Carta Porte (Waybill).
+ ///
+ /// ID del envío.
+ /// Token de cancelación.
+ /// PDF como application/pdf.
+ [HttpGet("waybill/{shipmentId:guid}")]
+ [Produces("application/pdf")]
+ public async Task GetWaybill(Guid shipmentId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var pdfBytes = await _pdfGenerator.GenerateWaybillAsync(shipmentId, cancellationToken);
+ return File(pdfBytes, "application/pdf", $"CartaPorte_{shipmentId:N}.pdf");
+ }
+ catch (KeyNotFoundException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// Genera y retorna un PDF de Manifiesto de Carga.
+ ///
+ /// ID de la ruta.
+ /// Token de cancelación.
+ /// PDF como application/pdf.
+ [HttpGet("manifest/{routeId:guid}")]
+ [Produces("application/pdf")]
+ public async Task GetManifest(Guid routeId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var pdfBytes = await _pdfGenerator.GenerateManifestAsync(routeId, cancellationToken);
+ return File(pdfBytes, "application/pdf", $"Manifiesto_{routeId:N}.pdf");
+ }
+ catch (KeyNotFoundException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// Genera y retorna un PDF de Hoja de Ruta para un chofer.
+ ///
+ /// ID del chofer.
+ /// Fecha de la ruta (formato: yyyy-MM-dd).
+ /// Token de cancelación.
+ /// PDF como application/pdf.
+ [HttpGet("trip-sheet/{driverId:guid}")]
+ [Produces("application/pdf")]
+ public async Task GetTripSheet(
+ Guid driverId,
+ [FromQuery] DateTime? date,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var targetDate = date ?? DateTime.UtcNow.Date;
+ var pdfBytes = await _pdfGenerator.GenerateTripSheetAsync(driverId, targetDate, cancellationToken);
+ return File(pdfBytes, "application/pdf", $"HojaRuta_{driverId:N}_{targetDate:yyyyMMdd}.pdf");
+ }
+ catch (KeyNotFoundException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// Genera y retorna un PDF de Proof of Delivery.
+ /// Incluye firma digital si está disponible.
+ ///
+ /// ID del envío.
+ /// Token de cancelación.
+ /// PDF como application/pdf.
+ [HttpGet("pod/{shipmentId:guid}")]
+ [Produces("application/pdf")]
+ public async Task GetPod(Guid shipmentId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var pdfBytes = await _pdfGenerator.GeneratePodAsync(shipmentId, cancellationToken);
+ return File(pdfBytes, "application/pdf", $"POD_{shipmentId:N}.pdf");
+ }
+ catch (KeyNotFoundException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// Genera un PDF según el tipo de documento.
+ /// Endpoint genérico para flexibilidad.
+ ///
+ /// Tipo de documento (ServiceOrder, Waybill, Manifest, TripSheet, POD).
+ /// ID de la entidad.
+ /// Token de cancelación.
+ /// PDF como application/pdf.
+ [HttpGet("{documentType}/{entityId:guid}")]
+ [Produces("application/pdf")]
+ public async Task GetDocument(
+ string documentType,
+ Guid entityId,
+ CancellationToken cancellationToken = default)
+ {
+ if (!Enum.TryParse(documentType, ignoreCase: true, out var docType))
+ {
+ return BadRequest(new { error = $"Tipo de documento inválido: {documentType}" });
+ }
+
+ try
+ {
+ var pdfBytes = await _pdfGenerator.GenerateAsync(docType, entityId, cancellationToken);
+ return File(pdfBytes, "application/pdf", $"{docType}_{entityId:N}.pdf");
+ }
+ catch (KeyNotFoundException ex)
+ {
+ return NotFound(new { error = ex.Message });
+ }
+ catch (NotSupportedException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/DriversController.cs b/backend/src/Parhelion.API/Controllers/DriversController.cs
new file mode 100644
index 0000000..df87a12
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/DriversController.cs
@@ -0,0 +1,162 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Fleet;
+using Parhelion.Application.Interfaces.Services;
+using Parhelion.Domain.Enums;
+using Parhelion.API.Filters;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para gestión de choferes.
+/// CRUD, filtro por estatus, asignación de camiones y actualización de estado.
+///
+[ApiController]
+[Route("api/drivers")]
+[Authorize]
+[Produces("application/json")]
+[Consumes("application/json")]
+public class DriversController : ControllerBase
+{
+ private readonly IDriverService _driverService;
+
+ public DriversController(IDriverService driverService)
+ {
+ _driverService = driverService;
+ }
+
+ [HttpGet]
+ public async Task GetAll([FromQuery] PagedRequest request)
+ {
+ var result = await _driverService.GetAllAsync(request);
+ return Ok(result);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task GetById(Guid id)
+ {
+ var result = await _driverService.GetByIdAsync(id);
+ if (result == null) return NotFound(new { error = "Chofer no encontrado" });
+ return Ok(result);
+ }
+
+ [HttpGet("active")]
+ public async Task Active([FromQuery] PagedRequest request)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _driverService.GetByStatusAsync(tenantId.Value, DriverStatus.Available, request);
+ return Ok(result);
+ }
+
+ [HttpGet("by-status/{status}")]
+ public async Task ByStatus(string status, [FromQuery] PagedRequest request)
+ {
+ if (!Enum.TryParse(status, out var driverStatus))
+ return BadRequest(new { error = "Estatus de chofer inválido" });
+
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _driverService.GetByStatusAsync(tenantId.Value, driverStatus, request);
+ return Ok(result);
+ }
+
+ ///
+ /// Busca choferes disponibles cercanos a una ubicación.
+ /// Autenticación: JWT (Usuario) o Bearer {CallbackToken} / X-Service-Key (n8n).
+ ///
+ /// Latitud central.
+ /// Longitud central.
+ /// Radio en kilómetros (default 50).
+ /// Número de página.
+ /// Resultados por página.
+ [HttpGet("nearby")]
+ [ServiceApiKey] // Permite acceso con X-Service-Key para n8n
+ [AllowAnonymous] // Bypass [Authorize] de la clase - ServiceApiKey filter valida
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task GetNearby(
+ [FromQuery] decimal lat,
+ [FromQuery] decimal lon,
+ [FromQuery] double radius = 50,
+ [FromQuery] int pageNumber = 1,
+ [FromQuery] int pageSize = 10)
+ {
+ // 1. Intentar obtener Tenant del User Claim (JWT)
+ var tenantId = GetTenantId();
+
+ // 2. Si no hay JWT, obtener del ServiceApiKey (resuelto por el filtro)
+ if (tenantId == null && HttpContext.Items.TryGetValue(ServiceApiKeyAttribute.TenantIdKey, out var serviceTenantId))
+ {
+ tenantId = serviceTenantId as Guid?;
+ }
+
+ // 3. Si aún no hay tenant, rechazar
+ if (tenantId == null)
+ {
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+ }
+
+ var request = new PagedRequest { Page = pageNumber, PageSize = pageSize };
+ var result = await _driverService.GetNearbyDriversAsync(lat, lon, radius, tenantId.Value, request);
+ return Ok(result);
+ }
+
+ [HttpPost]
+ public async Task Create([FromBody] CreateDriverRequest request)
+ {
+ var result = await _driverService.CreateAsync(request);
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+ return CreatedAtAction(nameof(GetById), new { id = result.Data!.Id }, result.Data);
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task Update(Guid id, [FromBody] UpdateDriverRequest request)
+ {
+ var result = await _driverService.UpdateAsync(id, request);
+ if (!result.Success)
+ return (result.Message?.Contains("no encontrado") ?? false)
+ ? NotFound(new { error = result.Message })
+ : BadRequest(new { error = result.Message });
+ return Ok(result.Data);
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var result = await _driverService.DeleteAsync(id);
+ if (!result.Success) return NotFound(new { error = result.Message });
+ return NoContent();
+ }
+
+ [HttpPatch("{id:guid}/assign-truck")]
+ public async Task AssignTruck(Guid id, [FromBody] AssignTruckRequest request)
+ {
+ var result = await _driverService.AssignTruckAsync(id, request.TruckId);
+ if (!result.Success) return BadRequest(new { error = result.Message });
+ return Ok(result.Data);
+ }
+
+ [HttpPatch("{id:guid}/status")]
+ public async Task UpdateStatus(Guid id, [FromBody] string status)
+ {
+ if (!Enum.TryParse(status, out var driverStatus))
+ return BadRequest(new { error = "Estatus inválido" });
+
+ var result = await _driverService.UpdateStatusAsync(id, driverStatus);
+ if (!result.Success) return NotFound(new { error = result.Message });
+ return Ok(result.Data);
+ }
+
+ private Guid? GetTenantId()
+ {
+ var claim = User.FindFirst("tenant_id");
+ return claim != null && Guid.TryParse(claim.Value, out var id) ? id : null;
+ }
+}
+
+public record AssignTruckRequest(Guid TruckId);
diff --git a/backend/src/Parhelion.API/Controllers/EmployeesController.cs b/backend/src/Parhelion.API/Controllers/EmployeesController.cs
new file mode 100644
index 0000000..25cb322
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/EmployeesController.cs
@@ -0,0 +1,203 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Core;
+using Parhelion.Application.Interfaces.Services;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para gestión de empleados.
+///
+[ApiController]
+[Route("api/employees")]
+[Authorize]
+public class EmployeesController : ControllerBase
+{
+ private readonly IEmployeeService _employeeService;
+
+ ///
+ /// Inicializa el controlador con el servicio de Employees.
+ ///
+ /// Servicio de gestión de empleados.
+ public EmployeesController(IEmployeeService employeeService)
+ {
+ _employeeService = employeeService;
+ }
+
+ ///
+ /// Obtiene todos los empleados con paginación.
+ ///
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de empleados.
+ [HttpGet]
+ public async Task>> GetAll(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _employeeService.GetAllAsync(request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene un empleado por ID.
+ ///
+ /// ID del empleado.
+ /// Token de cancelación.
+ /// Empleado encontrado.
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _employeeService.GetByIdAsync(id, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Empleado no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Obtiene un empleado por su User ID.
+ ///
+ /// ID del usuario asociado.
+ /// Token de cancelación.
+ /// Empleado encontrado.
+ [HttpGet("by-user/{userId:guid}")]
+ public async Task> GetByUserId(
+ Guid userId,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _employeeService.GetByUserIdAsync(userId, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Empleado no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Busca un empleado por RFC.
+ ///
+ /// RFC del empleado.
+ /// Token de cancelación.
+ /// Empleado encontrado.
+ [HttpGet("by-rfc/{rfc}")]
+ public async Task> GetByRfc(
+ string rfc,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _employeeService.GetByRfcAsync(rfc, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Empleado no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Obtiene empleados por departamento del tenant actual.
+ ///
+ /// Nombre del departamento.
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de empleados del departamento.
+ [HttpGet("by-department/{department}")]
+ public async Task>> ByDepartment(
+ string department,
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _employeeService.GetByDepartmentAsync(
+ tenantId, department, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene empleados del tenant actual.
+ ///
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de empleados del tenant.
+ [HttpGet("current-tenant")]
+ public async Task>> GetByCurrentTenant(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _employeeService.GetByTenantAsync(tenantId, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Crea un nuevo empleado.
+ ///
+ /// Datos del nuevo empleado.
+ /// Token de cancelación.
+ /// Empleado creado.
+ [HttpPost]
+ public async Task> Create(
+ [FromBody] CreateEmployeeRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _employeeService.CreateAsync(request, cancellationToken);
+
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+
+ return CreatedAtAction(
+ nameof(GetById),
+ new { id = result.Data!.Id },
+ result.Data);
+ }
+
+ ///
+ /// Actualiza un empleado existente.
+ ///
+ /// ID del empleado.
+ /// Datos de actualización.
+ /// Token de cancelación.
+ /// Empleado actualizado.
+ [HttpPut("{id:guid}")]
+ public async Task> Update(
+ Guid id,
+ [FromBody] UpdateEmployeeRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _employeeService.UpdateAsync(id, request, cancellationToken);
+
+ if (!result.Success)
+ {
+ if (result.Message?.Contains("no encontrado") == true)
+ return NotFound(new { error = result.Message });
+ return Conflict(new { error = result.Message });
+ }
+
+ return Ok(result.Data);
+ }
+
+ ///
+ /// Elimina (soft-delete) un empleado.
+ ///
+ /// ID del empleado.
+ /// Token de cancelación.
+ /// 204 No Content.
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _employeeService.DeleteAsync(id, cancellationToken);
+
+ if (!result.Success)
+ return NotFound(new { error = result.Message });
+
+ return NoContent();
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/FleetLogsController.cs b/backend/src/Parhelion.API/Controllers/FleetLogsController.cs
new file mode 100644
index 0000000..fe6bcd5
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/FleetLogsController.cs
@@ -0,0 +1,78 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Fleet;
+using Parhelion.Application.Interfaces.Services;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para bitácora de flotilla (cambios de camión).
+/// Los logs son inmutables - solo se crean y consultan.
+///
+[ApiController]
+[Route("api/fleet-logs")]
+[Authorize]
+public class FleetLogsController : ControllerBase
+{
+ private readonly IFleetLogService _fleetLogService;
+
+ public FleetLogsController(IFleetLogService fleetLogService)
+ {
+ _fleetLogService = fleetLogService;
+ }
+
+ [HttpGet]
+ public async Task GetAll([FromQuery] PagedRequest request)
+ {
+ var result = await _fleetLogService.GetAllAsync(request);
+ return Ok(result);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task GetById(Guid id)
+ {
+ var result = await _fleetLogService.GetByIdAsync(id);
+ if (result == null) return NotFound(new { error = "Log no encontrado" });
+ return Ok(result);
+ }
+
+ [HttpGet("by-driver/{driverId:guid}")]
+ public async Task ByDriver(Guid driverId, [FromQuery] PagedRequest request)
+ {
+ var result = await _fleetLogService.GetByDriverAsync(driverId, request);
+ return Ok(result);
+ }
+
+ [HttpGet("by-truck/{truckId:guid}")]
+ public async Task ByTruck(Guid truckId, [FromQuery] PagedRequest request)
+ {
+ var result = await _fleetLogService.GetByTruckAsync(truckId, request);
+ return Ok(result);
+ }
+
+ [HttpPost("start-usage")]
+ public async Task StartUsage([FromBody] StartUsageRequest request)
+ {
+ var result = await _fleetLogService.StartUsageAsync(request.DriverId, request.TruckId);
+ if (!result.Success) return BadRequest(new { error = result.Message });
+ return Ok(result.Data);
+ }
+
+ [HttpPost("end-usage")]
+ public async Task EndUsage([FromBody] EndUsageRequest request)
+ {
+ // Get active log for driver and end it
+ var activeLog = await _fleetLogService.GetActiveLogForDriverAsync(request.DriverId);
+ if (activeLog == null) return NotFound(new { error = "No hay uso activo para este chofer" });
+
+ var result = await _fleetLogService.EndUsageAsync(activeLog.Id, request.EndOdometer);
+ if (!result.Success) return BadRequest(new { error = result.Message });
+ return Ok(result.Data);
+ }
+
+ // No PUT/DELETE - logs are immutable
+}
+
+public record StartUsageRequest(Guid DriverId, Guid TruckId);
+public record EndUsageRequest(Guid DriverId, decimal? EndOdometer = null);
diff --git a/backend/src/Parhelion.API/Controllers/InventoryStocksController.cs b/backend/src/Parhelion.API/Controllers/InventoryStocksController.cs
new file mode 100644
index 0000000..f95581c
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/InventoryStocksController.cs
@@ -0,0 +1,141 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.DTOs.Warehouse;
+using Parhelion.Domain.Entities;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para inventario (stocks).
+///
+[ApiController]
+[Route("api/inventory-stocks")]
+[Authorize]
+public class InventoryStocksController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+
+ public InventoryStocksController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ var items = await _context.InventoryStocks
+ .Include(x => x.Zone)
+ .Include(x => x.Product)
+ .Where(x => !x.IsDeleted)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id)
+ {
+ var item = await _context.InventoryStocks
+ .Include(x => x.Zone)
+ .Include(x => x.Product)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Stock no encontrado" });
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpGet("by-product/{productId:guid}")]
+ public async Task>> ByProduct(Guid productId)
+ {
+ var items = await _context.InventoryStocks
+ .Include(x => x.Zone)
+ .Include(x => x.Product)
+ .Where(x => !x.IsDeleted && x.ProductId == productId)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("by-zone/{zoneId:guid}")]
+ public async Task>> ByZone(Guid zoneId)
+ {
+ var items = await _context.InventoryStocks
+ .Include(x => x.Zone)
+ .Include(x => x.Product)
+ .Where(x => !x.IsDeleted && x.ZoneId == zoneId)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateInventoryStockRequest request)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var item = new InventoryStock
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ ZoneId = request.ZoneId,
+ ProductId = request.ProductId,
+ Quantity = request.Quantity,
+ QuantityReserved = request.QuantityReserved,
+ BatchNumber = request.BatchNumber,
+ ExpiryDate = request.ExpiryDate,
+ UnitCost = request.UnitCost,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.InventoryStocks.Add(item);
+ await _context.SaveChangesAsync();
+
+ item = await _context.InventoryStocks
+ .Include(x => x.Zone)
+ .Include(x => x.Product)
+ .FirstAsync(x => x.Id == item.Id);
+ return CreatedAtAction(nameof(GetById), new { id = item.Id }, MapToResponse(item));
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task> Update(Guid id, [FromBody] UpdateInventoryStockRequest request)
+ {
+ var item = await _context.InventoryStocks
+ .Include(x => x.Zone)
+ .Include(x => x.Product)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Stock no encontrado" });
+
+ item.Quantity = request.Quantity;
+ item.QuantityReserved = request.QuantityReserved;
+ item.BatchNumber = request.BatchNumber;
+ item.ExpiryDate = request.ExpiryDate;
+ item.LastCountDate = request.LastCountDate;
+ item.UnitCost = request.UnitCost;
+ item.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var item = await _context.InventoryStocks.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Stock no encontrado" });
+
+ item.IsDeleted = true;
+ item.DeletedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+ return NoContent();
+ }
+
+ private static InventoryStockResponse MapToResponse(InventoryStock x) => new(
+ x.Id, x.ZoneId, x.Zone?.Name ?? "", x.ProductId, x.Product?.Name ?? "", x.Product?.Sku ?? "",
+ x.Quantity, x.QuantityReserved, x.QuantityAvailable, x.BatchNumber, x.ExpiryDate,
+ x.LastCountDate, x.UnitCost, x.CreatedAt, x.UpdatedAt
+ );
+}
diff --git a/backend/src/Parhelion.API/Controllers/InventoryTransactionsController.cs b/backend/src/Parhelion.API/Controllers/InventoryTransactionsController.cs
new file mode 100644
index 0000000..33a319d
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/InventoryTransactionsController.cs
@@ -0,0 +1,140 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.DTOs.Warehouse;
+using Parhelion.Domain.Entities;
+using Parhelion.Domain.Enums;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para transacciones de inventario (Kardex).
+/// Las transacciones son inmutables - solo se crean y consultan.
+///
+[ApiController]
+[Route("api/inventory-transactions")]
+[Authorize]
+public class InventoryTransactionsController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+
+ public InventoryTransactionsController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ var items = await _context.InventoryTransactions
+ .Include(x => x.Product)
+ .Include(x => x.OriginZone)
+ .Include(x => x.DestinationZone)
+ .Include(x => x.PerformedBy)
+ .Where(x => !x.IsDeleted)
+ .OrderByDescending(x => x.Timestamp)
+ .Take(100)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id)
+ {
+ var item = await _context.InventoryTransactions
+ .Include(x => x.Product)
+ .Include(x => x.OriginZone)
+ .Include(x => x.DestinationZone)
+ .Include(x => x.PerformedBy)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Transacción no encontrada" });
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpGet("by-product/{productId:guid}")]
+ public async Task>> ByProduct(Guid productId)
+ {
+ var items = await _context.InventoryTransactions
+ .Include(x => x.Product)
+ .Include(x => x.OriginZone)
+ .Include(x => x.DestinationZone)
+ .Include(x => x.PerformedBy)
+ .Where(x => !x.IsDeleted && x.ProductId == productId)
+ .OrderByDescending(x => x.Timestamp)
+ .Take(100)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("by-zone/{zoneId:guid}")]
+ public async Task>> ByZone(Guid zoneId)
+ {
+ var items = await _context.InventoryTransactions
+ .Include(x => x.Product)
+ .Include(x => x.OriginZone)
+ .Include(x => x.DestinationZone)
+ .Include(x => x.PerformedBy)
+ .Where(x => !x.IsDeleted && (x.OriginZoneId == zoneId || x.DestinationZoneId == zoneId))
+ .OrderByDescending(x => x.Timestamp)
+ .Take(100)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateInventoryTransactionRequest request)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
+ if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId))
+ return Unauthorized(new { error = "No se pudo determinar el usuario" });
+
+ var item = new InventoryTransaction
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ ProductId = request.ProductId,
+ OriginZoneId = request.OriginZoneId,
+ DestinationZoneId = request.DestinationZoneId,
+ Quantity = request.Quantity,
+ TransactionType = Enum.TryParse(request.TransactionType, out var t)
+ ? t : InventoryTransactionType.Adjustment,
+ PerformedByUserId = userId,
+ ShipmentId = request.ShipmentId,
+ BatchNumber = request.BatchNumber,
+ Remarks = request.Remarks,
+ Timestamp = DateTime.UtcNow,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.InventoryTransactions.Add(item);
+ await _context.SaveChangesAsync();
+
+ item = await _context.InventoryTransactions
+ .Include(x => x.Product)
+ .Include(x => x.OriginZone)
+ .Include(x => x.DestinationZone)
+ .Include(x => x.PerformedBy)
+ .FirstAsync(x => x.Id == item.Id);
+ return CreatedAtAction(nameof(GetById), new { id = item.Id }, MapToResponse(item));
+ }
+
+ // No PUT/DELETE - transactions are immutable
+
+ private static InventoryTransactionResponse MapToResponse(InventoryTransaction x) => new(
+ x.Id, x.ProductId, x.Product?.Name ?? "",
+ x.OriginZoneId, x.OriginZone?.Name,
+ x.DestinationZoneId, x.DestinationZone?.Name,
+ x.Quantity, x.TransactionType.ToString(),
+ x.PerformedByUserId, x.PerformedBy?.FullName ?? "",
+ x.ShipmentId, x.BatchNumber, x.Remarks,
+ x.Timestamp, x.CreatedAt
+ );
+}
diff --git a/backend/src/Parhelion.API/Controllers/LocationsController.cs b/backend/src/Parhelion.API/Controllers/LocationsController.cs
new file mode 100644
index 0000000..a81fe43
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/LocationsController.cs
@@ -0,0 +1,96 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Warehouse;
+using Parhelion.Application.Interfaces.Services;
+using Parhelion.Domain.Enums;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para gestión de ubicaciones (almacenes, hubs, cross-docks).
+///
+[ApiController]
+[Route("api/locations")]
+[Authorize]
+public class LocationsController : ControllerBase
+{
+ private readonly ILocationService _locationService;
+
+ public LocationsController(ILocationService locationService)
+ {
+ _locationService = locationService;
+ }
+
+ [HttpGet]
+ public async Task GetAll([FromQuery] PagedRequest request)
+ {
+ var result = await _locationService.GetAllAsync(request);
+ return Ok(result);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task GetById(Guid id)
+ {
+ var result = await _locationService.GetByIdAsync(id);
+ if (result == null) return NotFound(new { error = "Ubicación no encontrada" });
+ return Ok(result);
+ }
+
+ [HttpGet("by-type/{type}")]
+ public async Task ByType(string type, [FromQuery] PagedRequest request)
+ {
+ if (!Enum.TryParse(type, out var locType))
+ return BadRequest(new { error = "Tipo de ubicación inválido" });
+
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _locationService.GetByTypeAsync(tenantId.Value, locType, request);
+ return Ok(result);
+ }
+
+ [HttpGet("search")]
+ public async Task Search([FromQuery] string name, [FromQuery] PagedRequest request)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _locationService.SearchByNameAsync(tenantId.Value, name, request);
+ return Ok(result);
+ }
+
+ [HttpPost]
+ public async Task Create([FromBody] CreateLocationRequest request)
+ {
+ var result = await _locationService.CreateAsync(request);
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+ return CreatedAtAction(nameof(GetById), new { id = result.Data!.Id }, result.Data);
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task Update(Guid id, [FromBody] UpdateLocationRequest request)
+ {
+ var result = await _locationService.UpdateAsync(id, request);
+ if (!result.Success)
+ return (result.Message?.Contains("no encontrad") ?? false)
+ ? NotFound(new { error = result.Message })
+ : BadRequest(new { error = result.Message });
+ return Ok(result.Data);
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var result = await _locationService.DeleteAsync(id);
+ if (!result.Success) return NotFound(new { error = result.Message });
+ return NoContent();
+ }
+
+ private Guid? GetTenantId()
+ {
+ var claim = User.FindFirst("tenant_id");
+ return claim != null && Guid.TryParse(claim.Value, out var id) ? id : null;
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/NetworkLinksController.cs b/backend/src/Parhelion.API/Controllers/NetworkLinksController.cs
new file mode 100644
index 0000000..9d13004
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/NetworkLinksController.cs
@@ -0,0 +1,129 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.DTOs.Network;
+using Parhelion.Domain.Entities;
+using Parhelion.Domain.Enums;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para enlaces de red logística.
+///
+[ApiController]
+[Route("api/network-links")]
+[Authorize]
+public class NetworkLinksController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+
+ public NetworkLinksController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ var items = await _context.NetworkLinks
+ .Include(x => x.OriginLocation)
+ .Include(x => x.DestinationLocation)
+ .Where(x => !x.IsDeleted)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id)
+ {
+ var item = await _context.NetworkLinks
+ .Include(x => x.OriginLocation)
+ .Include(x => x.DestinationLocation)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Enlace no encontrado" });
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpGet("by-location/{locationId:guid}")]
+ public async Task>> ByLocation(Guid locationId)
+ {
+ var items = await _context.NetworkLinks
+ .Include(x => x.OriginLocation)
+ .Include(x => x.DestinationLocation)
+ .Where(x => !x.IsDeleted &&
+ (x.OriginLocationId == locationId || x.DestinationLocationId == locationId))
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateNetworkLinkRequest request)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var item = new NetworkLink
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ OriginLocationId = request.OriginLocationId,
+ DestinationLocationId = request.DestinationLocationId,
+ LinkType = Enum.TryParse(request.LinkType, out var lt) ? lt : NetworkLinkType.FirstMile,
+ TransitTime = request.TransitTime,
+ IsBidirectional = request.IsBidirectional,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.NetworkLinks.Add(item);
+ await _context.SaveChangesAsync();
+
+ item = await _context.NetworkLinks
+ .Include(x => x.OriginLocation)
+ .Include(x => x.DestinationLocation)
+ .FirstAsync(x => x.Id == item.Id);
+ return CreatedAtAction(nameof(GetById), new { id = item.Id }, MapToResponse(item));
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task> Update(Guid id, [FromBody] UpdateNetworkLinkRequest request)
+ {
+ var item = await _context.NetworkLinks
+ .Include(x => x.OriginLocation)
+ .Include(x => x.DestinationLocation)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Enlace no encontrado" });
+
+ item.LinkType = Enum.TryParse(request.LinkType, out var lt) ? lt : item.LinkType;
+ item.TransitTime = request.TransitTime;
+ item.IsBidirectional = request.IsBidirectional;
+ item.IsActive = request.IsActive;
+ item.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var item = await _context.NetworkLinks.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Enlace no encontrado" });
+
+ item.IsDeleted = true;
+ item.DeletedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+ return NoContent();
+ }
+
+ private static NetworkLinkResponse MapToResponse(NetworkLink x) => new(
+ x.Id, x.OriginLocationId, x.OriginLocation?.Name ?? "",
+ x.DestinationLocationId, x.DestinationLocation?.Name ?? "",
+ x.LinkType.ToString(), x.TransitTime, x.IsBidirectional, x.IsActive,
+ x.CreatedAt, x.UpdatedAt
+ );
+}
diff --git a/backend/src/Parhelion.API/Controllers/NotificationsController.cs b/backend/src/Parhelion.API/Controllers/NotificationsController.cs
new file mode 100644
index 0000000..9c65023
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/NotificationsController.cs
@@ -0,0 +1,209 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.API.Filters;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Notification;
+using Parhelion.Application.Interfaces.Services;
+using System.Security.Claims;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controller para gestión de notificaciones.
+/// - POST: Autenticación con X-Service-Key (n8n/servicios)
+/// - GET/PATCH: Autenticación con JWT (usuarios)
+///
+[ApiController]
+[Route("api/[controller]")]
+[Produces("application/json")]
+public class NotificationsController : ControllerBase
+{
+ private readonly INotificationService _service;
+
+ public NotificationsController(INotificationService service)
+ {
+ _service = service;
+ }
+
+ // ========== PARA N8N Y SERVICIOS INTERNOS (API Key o CallbackToken) ==========
+
+ ///
+ /// Crea una nueva notificación.
+ /// Autenticación: Authorization: Bearer {CallbackToken} o X-Service-Key
+ ///
+ /// El TenantId se obtiene automáticamente del token JWT, NO del body.
+ /// Esto simplifica la integración de n8n.
+ ///
+ [ServiceApiKey]
+ [HttpPost]
+ [ProducesResponseType(typeof(OperationResult), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ public async Task Create(
+ [FromBody] CreateNotificationFromServiceRequest request,
+ CancellationToken cancellationToken)
+ {
+ // Obtener TenantId del CallbackToken/ServiceApiKey (ya validado por el atributo)
+ if (!HttpContext.Items.TryGetValue(ServiceApiKeyAttribute.TenantIdKey, out var tenantIdObj)
+ || tenantIdObj is not Guid tenantId)
+ {
+ return Unauthorized(new { error = "TenantId not found in authentication token" });
+ }
+
+ // Crear el request completo con TenantId del token
+ var fullRequest = new CreateNotificationRequest(
+ TenantId: tenantId,
+ UserId: request.UserId,
+ RoleId: request.RoleId,
+ Type: request.Type,
+ Source: request.Source,
+ Title: request.Title,
+ Message: request.Message,
+ MetadataJson: request.MetadataJson,
+ RelatedEntityType: request.RelatedEntityType,
+ RelatedEntityId: request.RelatedEntityId,
+ Priority: request.Priority,
+ RequiresAction: request.RequiresAction
+ );
+
+ var result = await _service.CreateAsync(fullRequest, cancellationToken);
+
+ if (!result.Success)
+ {
+ return BadRequest(result);
+ }
+
+ return Ok(result);
+ }
+
+ // ========== PARA APPS MÓVILES (JWT) ==========
+
+ ///
+ /// Obtiene notificaciones del usuario autenticado.
+ ///
+ [Authorize]
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)]
+ public async Task GetMyNotifications(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken)
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return Unauthorized();
+ }
+
+ var result = await _service.GetByUserAsync(userId.Value, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene el conteo de notificaciones no leídas.
+ ///
+ [Authorize]
+ [HttpGet("unread-count")]
+ [ProducesResponseType(typeof(UnreadCountResponse), StatusCodes.Status200OK)]
+ public async Task GetUnreadCount(CancellationToken cancellationToken)
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return Unauthorized();
+ }
+
+ var count = await _service.GetUnreadCountAsync(userId.Value, cancellationToken);
+ return Ok(new UnreadCountResponse(count));
+ }
+
+ ///
+ /// Obtiene una notificación por ID.
+ ///
+ [Authorize]
+ [HttpGet("{id:guid}")]
+ [ProducesResponseType(typeof(NotificationResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task GetById(Guid id, CancellationToken cancellationToken)
+ {
+ var result = await _service.GetByIdAsync(id, cancellationToken);
+
+ if (result == null)
+ {
+ return NotFound();
+ }
+
+ // Validar que pertenece al usuario
+ var userId = GetCurrentUserId();
+ if (result.UserId != userId)
+ {
+ return Forbid();
+ }
+
+ return Ok(result);
+ }
+
+ ///
+ /// Marca una notificación como leída.
+ ///
+ [Authorize]
+ [HttpPatch("{id:guid}/read")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task MarkAsRead(Guid id, CancellationToken cancellationToken)
+ {
+ // Validar que existe y pertenece al usuario
+ var notification = await _service.GetByIdAsync(id, cancellationToken);
+ if (notification == null)
+ {
+ return NotFound();
+ }
+
+ var userId = GetCurrentUserId();
+ if (notification.UserId != userId)
+ {
+ return Forbid();
+ }
+
+ var result = await _service.MarkAsReadAsync(id, cancellationToken);
+
+ if (!result.Success)
+ {
+ return NotFound(result);
+ }
+
+ return Ok();
+ }
+
+ ///
+ /// Marca todas las notificaciones del usuario como leídas.
+ ///
+ [Authorize]
+ [HttpPost("mark-all-read")]
+ [ProducesResponseType(typeof(OperationResult), StatusCodes.Status200OK)]
+ public async Task MarkAllAsRead(CancellationToken cancellationToken)
+ {
+ var userId = GetCurrentUserId();
+ if (userId == null)
+ {
+ return Unauthorized();
+ }
+
+ var result = await _service.MarkAllAsReadAsync(userId.Value, cancellationToken);
+ return Ok(result);
+ }
+
+ // ========== HELPERS ==========
+
+ private Guid? GetCurrentUserId()
+ {
+ var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? User.FindFirstValue("sub");
+
+ if (Guid.TryParse(userIdClaim, out var userId))
+ {
+ return userId;
+ }
+
+ return null;
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/RolesController.cs b/backend/src/Parhelion.API/Controllers/RolesController.cs
new file mode 100644
index 0000000..411d92a
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/RolesController.cs
@@ -0,0 +1,178 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Core;
+using Parhelion.Application.Interfaces.Services;
+using Parhelion.Domain.Enums;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para gestión de roles.
+/// Los roles son globales (no multi-tenant).
+///
+[ApiController]
+[Route("api/roles")]
+[Authorize]
+public class RolesController : ControllerBase
+{
+ private readonly IRoleService _roleService;
+
+ ///
+ /// Inicializa el controlador con el servicio de Roles.
+ ///
+ /// Servicio de gestión de roles.
+ public RolesController(IRoleService roleService)
+ {
+ _roleService = roleService;
+ }
+
+ ///
+ /// Obtiene todos los roles con paginación.
+ ///
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de roles.
+ [HttpGet]
+ public async Task>> GetAll(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _roleService.GetAllAsync(request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene un rol por ID.
+ ///
+ /// ID del rol.
+ /// Token de cancelación.
+ /// Rol encontrado.
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _roleService.GetByIdAsync(id, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Rol no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Busca un rol por nombre.
+ ///
+ /// Nombre del rol.
+ /// Token de cancelación.
+ /// Rol encontrado.
+ [HttpGet("by-name/{name}")]
+ public async Task> GetByName(
+ string name,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _roleService.GetByNameAsync(name, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Rol no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Obtiene los permisos de un rol.
+ ///
+ /// Nombre del rol.
+ /// Lista de permisos del rol.
+ [HttpGet("{name}/permissions")]
+ public ActionResult> GetPermissions(string name)
+ {
+ var permissions = _roleService.GetPermissions(name);
+ return Ok(permissions.Select(p => p.ToString()));
+ }
+
+ ///
+ /// Verifica si un rol tiene un permiso específico.
+ ///
+ /// Nombre del rol.
+ /// Permiso a verificar.
+ /// True si el rol tiene el permiso.
+ [HttpGet("{name}/has-permission/{permission}")]
+ public ActionResult HasPermission(string name, string permission)
+ {
+ if (!Enum.TryParse(permission, out var perm))
+ return BadRequest(new { error = "Permiso inválido" });
+
+ var hasPermission = _roleService.HasPermission(name, perm);
+ return Ok(new { hasPermission });
+ }
+
+ ///
+ /// Crea un nuevo rol.
+ ///
+ /// Datos del nuevo rol.
+ /// Token de cancelación.
+ /// Rol creado.
+ [HttpPost]
+ public async Task> Create(
+ [FromBody] CreateRoleRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _roleService.CreateAsync(request, cancellationToken);
+
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+
+ return CreatedAtAction(
+ nameof(GetById),
+ new { id = result.Data!.Id },
+ result.Data);
+ }
+
+ ///
+ /// Actualiza un rol existente.
+ ///
+ /// ID del rol.
+ /// Datos de actualización.
+ /// Token de cancelación.
+ /// Rol actualizado.
+ [HttpPut("{id:guid}")]
+ public async Task> Update(
+ Guid id,
+ [FromBody] UpdateRoleRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _roleService.UpdateAsync(id, request, cancellationToken);
+
+ if (!result.Success)
+ {
+ if (result.Message?.Contains("no encontrado") == true)
+ return NotFound(new { error = result.Message });
+ return Conflict(new { error = result.Message });
+ }
+
+ return Ok(result.Data);
+ }
+
+ ///
+ /// Elimina (soft-delete) un rol.
+ ///
+ /// ID del rol.
+ /// Token de cancelación.
+ /// 204 No Content.
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _roleService.DeleteAsync(id, cancellationToken);
+
+ if (!result.Success)
+ {
+ if (result.Message?.Contains("usuarios asignados") == true)
+ return Conflict(new { error = result.Message });
+ return NotFound(new { error = result.Message });
+ }
+
+ return NoContent();
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/RouteBlueprintsController.cs b/backend/src/Parhelion.API/Controllers/RouteBlueprintsController.cs
new file mode 100644
index 0000000..e14ca68
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/RouteBlueprintsController.cs
@@ -0,0 +1,89 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Network;
+using Parhelion.Application.Interfaces.Services;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para rutas predefinidas.
+///
+[ApiController]
+[Route("api/route-blueprints")]
+[Authorize]
+public class RouteBlueprintsController : ControllerBase
+{
+ private readonly IRouteService _routeService;
+
+ public RouteBlueprintsController(IRouteService routeService)
+ {
+ _routeService = routeService;
+ }
+
+ [HttpGet]
+ public async Task GetAll([FromQuery] PagedRequest request)
+ {
+ var result = await _routeService.GetAllAsync(request);
+ return Ok(result);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task GetById(Guid id)
+ {
+ var result = await _routeService.GetByIdAsync(id);
+ if (result == null) return NotFound(new { error = "Ruta no encontrada" });
+ return Ok(result);
+ }
+
+ [HttpGet("active")]
+ public async Task Active([FromQuery] PagedRequest request)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _routeService.GetActiveAsync(tenantId.Value, request);
+ return Ok(result);
+ }
+
+ [HttpGet("{id:guid}/steps")]
+ public async Task GetSteps(Guid id)
+ {
+ var result = await _routeService.GetStepsAsync(id);
+ return Ok(result);
+ }
+
+ [HttpPost]
+ public async Task Create([FromBody] CreateRouteBlueprintRequest request)
+ {
+ var result = await _routeService.CreateAsync(request);
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+ return CreatedAtAction(nameof(GetById), new { id = result.Data!.Id }, result.Data);
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task Update(Guid id, [FromBody] UpdateRouteBlueprintRequest request)
+ {
+ var result = await _routeService.UpdateAsync(id, request);
+ if (!result.Success)
+ return (result.Message?.Contains("no encontrad") ?? false)
+ ? NotFound(new { error = result.Message })
+ : BadRequest(new { error = result.Message });
+ return Ok(result.Data);
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var result = await _routeService.DeleteAsync(id);
+ if (!result.Success) return NotFound(new { error = result.Message });
+ return NoContent();
+ }
+
+ private Guid? GetTenantId()
+ {
+ var claim = User.FindFirst("tenant_id");
+ return claim != null && Guid.TryParse(claim.Value, out var id) ? id : null;
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/RouteStepsController.cs b/backend/src/Parhelion.API/Controllers/RouteStepsController.cs
new file mode 100644
index 0000000..e653edc
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/RouteStepsController.cs
@@ -0,0 +1,141 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.DTOs.Network;
+using Parhelion.Domain.Entities;
+using Parhelion.Domain.Enums;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para pasos de ruta.
+///
+[ApiController]
+[Route("api/route-steps")]
+[Authorize]
+public class RouteStepsController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+
+ public RouteStepsController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ var items = await _context.RouteSteps
+ .Include(x => x.RouteBlueprint)
+ .Include(x => x.Location)
+ .Where(x => !x.IsDeleted)
+ .OrderBy(x => x.RouteBlueprintId).ThenBy(x => x.StepOrder)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id)
+ {
+ var item = await _context.RouteSteps
+ .Include(x => x.RouteBlueprint)
+ .Include(x => x.Location)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Paso no encontrado" });
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpGet("by-route/{routeId:guid}")]
+ public async Task>> ByRoute(Guid routeId)
+ {
+ var items = await _context.RouteSteps
+ .Include(x => x.RouteBlueprint)
+ .Include(x => x.Location)
+ .Where(x => !x.IsDeleted && x.RouteBlueprintId == routeId)
+ .OrderBy(x => x.StepOrder)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateRouteStepRequest request)
+ {
+ var item = new RouteStep
+ {
+ Id = Guid.NewGuid(),
+ RouteBlueprintId = request.RouteBlueprintId,
+ LocationId = request.LocationId,
+ StepOrder = request.StepOrder,
+ StandardTransitTime = request.StandardTransitTime,
+ StepType = Enum.TryParse(request.StepType, out var st) ? st : RouteStepType.Origin,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.RouteSteps.Add(item);
+
+ // Update route totals
+ var route = await _context.RouteBlueprints.FirstOrDefaultAsync(r => r.Id == request.RouteBlueprintId);
+ if (route != null)
+ {
+ route.TotalSteps++;
+ route.TotalTransitTime += request.StandardTransitTime;
+ }
+
+ await _context.SaveChangesAsync();
+
+ item = await _context.RouteSteps
+ .Include(x => x.RouteBlueprint)
+ .Include(x => x.Location)
+ .FirstAsync(x => x.Id == item.Id);
+ return CreatedAtAction(nameof(GetById), new { id = item.Id }, MapToResponse(item));
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task> Update(Guid id, [FromBody] UpdateRouteStepRequest request)
+ {
+ var item = await _context.RouteSteps
+ .Include(x => x.RouteBlueprint)
+ .Include(x => x.Location)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Paso no encontrado" });
+
+ item.LocationId = request.LocationId;
+ item.StepOrder = request.StepOrder;
+ item.StandardTransitTime = request.StandardTransitTime;
+ item.StepType = Enum.TryParse(request.StepType, out var st) ? st : item.StepType;
+ item.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var item = await _context.RouteSteps.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Paso no encontrado" });
+
+ // Update route totals
+ var route = await _context.RouteBlueprints.FirstOrDefaultAsync(r => r.Id == item.RouteBlueprintId);
+ if (route != null)
+ {
+ route.TotalSteps--;
+ route.TotalTransitTime -= item.StandardTransitTime;
+ }
+
+ item.IsDeleted = true;
+ item.DeletedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+ return NoContent();
+ }
+
+ private static RouteStepResponse MapToResponse(RouteStep x) => new(
+ x.Id, x.RouteBlueprintId, x.RouteBlueprint?.Name ?? "",
+ x.LocationId, x.Location?.Name ?? "",
+ x.StepOrder, x.StandardTransitTime, x.StepType.ToString(),
+ x.CreatedAt, x.UpdatedAt
+ );
+}
diff --git a/backend/src/Parhelion.API/Controllers/SchemaController.cs b/backend/src/Parhelion.API/Controllers/SchemaController.cs
new file mode 100644
index 0000000..7bdb505
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/SchemaController.cs
@@ -0,0 +1,283 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Expone metadatos del schema de base de datos para herramientas de administración.
+/// Este endpoint es de solo lectura y no expone datos, solo estructura.
+///
+[ApiController]
+[Route("api/[controller]")]
+public class SchemaController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+ private static readonly object _cacheLock = new();
+ private static SchemaMetadataResponse? _cachedSchema;
+ private static DateTime _cacheExpiry = DateTime.MinValue;
+ private const int CacheTtlMinutes = 60; // Cache por 1 hora
+
+ public SchemaController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Obtiene metadatos del schema de base de datos.
+ /// Información pública: nombres de tablas, columnas y relaciones.
+ /// No expone datos sensibles ni requiere autenticación.
+ ///
+ [HttpGet("metadata")]
+ [ResponseCache(Duration = 3600)] // Browser cache 1 hora
+ public ActionResult GetSchemaMetadata()
+ {
+ // Check cache
+ lock (_cacheLock)
+ {
+ if (_cachedSchema != null && DateTime.UtcNow < _cacheExpiry)
+ {
+ return Ok(_cachedSchema);
+ }
+ }
+
+ var schema = BuildSchemaFromEfCore();
+
+ // Update cache
+ lock (_cacheLock)
+ {
+ _cachedSchema = schema;
+ _cacheExpiry = DateTime.UtcNow.AddMinutes(CacheTtlMinutes);
+ }
+
+ return Ok(schema);
+ }
+
+ ///
+ /// Fuerza recarga del cache de schema (requiere autenticación en producción).
+ ///
+ [HttpPost("refresh")]
+ public ActionResult RefreshCache()
+ {
+ lock (_cacheLock)
+ {
+ _cachedSchema = null;
+ _cacheExpiry = DateTime.MinValue;
+ }
+ return Ok(new { message = "Schema cache cleared" });
+ }
+
+ private SchemaMetadataResponse BuildSchemaFromEfCore()
+ {
+ var tables = new List();
+ var model = _context.Model;
+
+ // Categorización por módulo
+ var moduleMapping = new Dictionary
+ {
+ // Core
+ { "Tenant", "core" },
+ { "User", "core" },
+ { "Role", "core" },
+ { "RefreshToken", "core" },
+ { "Client", "core" },
+
+ // Employee
+ { "Employee", "employee" },
+ { "Shift", "employee" },
+
+ // Fleet
+ { "Driver", "fleet" },
+ { "Truck", "fleet" },
+ { "FleetLog", "fleet" },
+
+ // Warehouse
+ { "Location", "warehouse" },
+ { "WarehouseZone", "warehouse" },
+ { "WarehouseOperator", "warehouse" },
+
+ // Inventory
+ { "CatalogItem", "inventory" },
+ { "InventoryStock", "inventory" },
+ { "InventoryTransaction", "inventory" },
+
+ // Shipment
+ { "Shipment", "shipment" },
+ { "ShipmentItem", "shipment" },
+ { "ShipmentCheckpoint", "shipment" },
+ { "ShipmentDocument", "shipment" },
+
+ // Network/Routing
+ { "NetworkLink", "network" },
+ { "RouteBlueprint", "network" },
+ { "RouteStep", "network" }
+ };
+
+ var descriptions = new Dictionary
+ {
+ { "Tenant", "Multi-tenant root entity" },
+ { "User", "System users with roles" },
+ { "Role", "Admin, Driver, Warehouse, Demo" },
+ { "RefreshToken", "JWT refresh tokens" },
+ { "Client", "B2B clients (senders/recipients)" },
+ { "Employee", "Employee profiles (v0.4.3)" },
+ { "Shift", "Work shifts configuration" },
+ { "Driver", "Fleet drivers with MX legal data" },
+ { "Truck", "DryBox, Refrigerated, HAZMAT..." },
+ { "FleetLog", "Driver-Truck changes log" },
+ { "Location", "Hubs, Warehouses, Cross-docks" },
+ { "WarehouseZone", "Zones within locations" },
+ { "WarehouseOperator", "Operators assigned to zones" },
+ { "CatalogItem", "Product catalog (v0.4.4)" },
+ { "InventoryStock", "Stock by zone/lot (v0.4.4)" },
+ { "InventoryTransaction", "Kardex movements (v0.4.4)" },
+ { "Shipment", "Shipments PAR-XXXXXX" },
+ { "ShipmentItem", "Items with volumetric weight" },
+ { "ShipmentCheckpoint", "Immutable tracking events" },
+ { "ShipmentDocument", "B2B docs: Waybill, POD..." },
+ { "NetworkLink", "FirstMile, LineHaul, LastMile" },
+ { "RouteBlueprint", "Predefined Hub & Spoke routes" },
+ { "RouteStep", "Route stops with transit times" }
+ };
+
+ // Posiciones para layout visual (grid layout)
+ var positions = new Dictionary
+ {
+ // Row 1: Core
+ { "Tenant", (50, 50) },
+ { "Role", (280, 50) },
+ { "User", (510, 50) },
+ { "RefreshToken", (740, 50) },
+
+ // Row 2: Employee + Client
+ { "Employee", (50, 300) },
+ { "Shift", (280, 300) },
+ { "Client", (510, 300) },
+
+ // Row 3: Fleet
+ { "Truck", (50, 550) },
+ { "Driver", (280, 550) },
+ { "FleetLog", (510, 550) },
+
+ // Row 4: Warehouse + Inventory (RIGHT SIDE)
+ { "Location", (970, 50) },
+ { "WarehouseZone", (1200, 50) },
+ { "WarehouseOperator", (970, 300) },
+ { "CatalogItem", (1200, 300) },
+ { "InventoryStock", (970, 550) },
+ { "InventoryTransaction", (1200, 550) },
+
+ // Row 5: Shipment (BOTTOM)
+ { "Shipment", (50, 800) },
+ { "ShipmentItem", (280, 800) },
+ { "ShipmentCheckpoint", (510, 800) },
+ { "ShipmentDocument", (740, 800) },
+
+ // Row 6: Network (BOTTOM RIGHT)
+ { "NetworkLink", (970, 800) },
+ { "RouteBlueprint", (1200, 800) },
+ { "RouteStep", (1430, 800) }
+ };
+
+ foreach (var entityType in model.GetEntityTypes())
+ {
+ var entityName = entityType.ClrType.Name;
+
+ // Skip owned types
+ if (entityType.IsOwned()) continue;
+
+ var fields = new List();
+
+ foreach (var property in entityType.GetProperties())
+ {
+ var isPk = property.IsPrimaryKey();
+ var isFk = property.IsForeignKey();
+ string? fkTarget = null;
+
+ if (isFk)
+ {
+ var fkEntity = property.GetContainingForeignKeys()
+ .FirstOrDefault()?.PrincipalEntityType?.ClrType.Name;
+ fkTarget = fkEntity != null ? $"{fkEntity}s" : null;
+ }
+
+ fields.Add(new FieldMetadata
+ {
+ Name = property.Name,
+ Pk = isPk,
+ Fk = fkTarget,
+ Type = GetSimpleTypeName(property.ClrType),
+ IsNullable = property.IsNullable
+ });
+ }
+
+ var pos = positions.GetValueOrDefault(entityName, (50, 50));
+
+ tables.Add(new TableMetadata
+ {
+ Name = $"{entityName}s", // Pluralize
+ Type = moduleMapping.GetValueOrDefault(entityName, "core"),
+ Description = descriptions.GetValueOrDefault(entityName, entityName),
+ X = pos.Item1,
+ Y = pos.Item2,
+ Fields = fields
+ });
+ }
+
+ return new SchemaMetadataResponse
+ {
+ Version = "0.4.5",
+ GeneratedAt = DateTime.UtcNow,
+ TableCount = tables.Count,
+ Tables = tables.OrderBy(t => t.Type).ThenBy(t => t.Name).ToList()
+ };
+ }
+
+ private static string GetSimpleTypeName(Type type)
+ {
+ if (Nullable.GetUnderlyingType(type) is { } underlying)
+ type = underlying;
+
+ return type.Name switch
+ {
+ nameof(Guid) => "uuid",
+ nameof(String) => "string",
+ nameof(Int32) => "int",
+ nameof(Int64) => "long",
+ nameof(Decimal) => "decimal",
+ nameof(Boolean) => "bool",
+ nameof(DateTime) => "datetime",
+ nameof(TimeSpan) => "timespan",
+ _ when type.IsEnum => "enum",
+ _ => type.Name.ToLower()
+ };
+ }
+}
+
+// DTOs
+public record SchemaMetadataResponse
+{
+ public string Version { get; init; } = "";
+ public DateTime GeneratedAt { get; init; }
+ public int TableCount { get; init; }
+ public List Tables { get; init; } = new();
+}
+
+public record TableMetadata
+{
+ public string Name { get; init; } = "";
+ public string Type { get; init; } = "";
+ public string Description { get; init; } = "";
+ public int X { get; init; }
+ public int Y { get; init; }
+ public List Fields { get; init; } = new();
+}
+
+public record FieldMetadata
+{
+ public string Name { get; init; } = "";
+ public bool Pk { get; init; }
+ public string? Fk { get; init; }
+ public string Type { get; init; } = "";
+ public bool IsNullable { get; init; }
+}
diff --git a/backend/src/Parhelion.API/Controllers/ShiftsController.cs b/backend/src/Parhelion.API/Controllers/ShiftsController.cs
new file mode 100644
index 0000000..17ed407
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/ShiftsController.cs
@@ -0,0 +1,101 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.DTOs.Fleet;
+using Parhelion.Domain.Entities;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para turnos de trabajo.
+///
+[ApiController]
+[Route("api/shifts")]
+[Authorize]
+public class ShiftsController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+
+ public ShiftsController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ var items = await _context.Shifts
+ .Where(x => !x.IsDeleted)
+ .OrderBy(x => x.Name)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id)
+ {
+ var item = await _context.Shifts.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Turno no encontrado" });
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateShiftRequest request)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var item = new Shift
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ Name = request.Name,
+ StartTime = request.StartTime,
+ EndTime = request.EndTime,
+ DaysOfWeek = request.DaysOfWeek,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.Shifts.Add(item);
+ await _context.SaveChangesAsync();
+ return CreatedAtAction(nameof(GetById), new { id = item.Id }, MapToResponse(item));
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task> Update(Guid id, [FromBody] UpdateShiftRequest request)
+ {
+ var item = await _context.Shifts.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Turno no encontrado" });
+
+ item.Name = request.Name;
+ item.StartTime = request.StartTime;
+ item.EndTime = request.EndTime;
+ item.DaysOfWeek = request.DaysOfWeek;
+ item.IsActive = request.IsActive;
+ item.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var item = await _context.Shifts.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Turno no encontrado" });
+
+ item.IsDeleted = true;
+ item.DeletedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+ return NoContent();
+ }
+
+ private static ShiftResponse MapToResponse(Shift x) => new(
+ x.Id, x.Name, x.StartTime, x.EndTime, x.DaysOfWeek,
+ x.IsActive, x.CreatedAt, x.UpdatedAt
+ );
+}
diff --git a/backend/src/Parhelion.API/Controllers/ShipmentCheckpointsController.cs b/backend/src/Parhelion.API/Controllers/ShipmentCheckpointsController.cs
new file mode 100644
index 0000000..c3f07f2
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/ShipmentCheckpointsController.cs
@@ -0,0 +1,128 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Shipment;
+using Parhelion.Application.Interfaces.Services;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para checkpoints de envío (trazabilidad).
+/// Los checkpoints son inmutables: solo se pueden crear, no modificar ni eliminar.
+///
+[ApiController]
+[Route("api/shipment-checkpoints")]
+[Authorize]
+[Produces("application/json")]
+[Consumes("application/json")]
+public class ShipmentCheckpointsController : ControllerBase
+{
+ private readonly IShipmentCheckpointService _checkpointService;
+
+ public ShipmentCheckpointsController(IShipmentCheckpointService checkpointService)
+ {
+ _checkpointService = checkpointService;
+ }
+
+ ///
+ /// Obtiene todos los checkpoints con paginación.
+ ///
+ [HttpGet]
+ public async Task>> GetAll(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _checkpointService.GetAllAsync(request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene un checkpoint por ID.
+ ///
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id, CancellationToken cancellationToken = default)
+ {
+ var result = await _checkpointService.GetByIdAsync(id, cancellationToken);
+ if (result == null) return NotFound(new { error = "Checkpoint no encontrado" });
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene todos los checkpoints de un envío (timeline completo).
+ ///
+ [HttpGet("by-shipment/{shipmentId:guid}")]
+ public async Task>> ByShipment(
+ Guid shipmentId,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _checkpointService.GetByShipmentAsync(shipmentId, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene el timeline visual de un envío (formato Metro).
+ ///
+ [HttpGet("timeline/{shipmentId:guid}")]
+ public async Task>> GetTimeline(
+ Guid shipmentId,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _checkpointService.GetTimelineAsync(shipmentId, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene checkpoints filtrados por código de estatus.
+ ///
+ [HttpGet("by-status/{shipmentId:guid}/{statusCode}")]
+ public async Task>> ByStatus(
+ Guid shipmentId,
+ string statusCode,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _checkpointService.GetByStatusCodeAsync(shipmentId, statusCode, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene el último checkpoint de un envío.
+ ///
+ [HttpGet("last/{shipmentId:guid}")]
+ public async Task> GetLast(
+ Guid shipmentId,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _checkpointService.GetLastCheckpointAsync(shipmentId, cancellationToken);
+ if (result == null) return NotFound(new { error = "No hay checkpoints para este envío" });
+ return Ok(result);
+ }
+
+ ///
+ /// Crea un nuevo checkpoint de trazabilidad.
+ /// Los checkpoints son inmutables: una vez creados, no se pueden modificar.
+ ///
+ [HttpPost]
+ public async Task> Create(
+ [FromBody] CreateShipmentCheckpointRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var userId = GetUserId();
+ if (userId == null)
+ return Unauthorized(new { error = "No se pudo determinar el usuario" });
+
+ var result = await _checkpointService.CreateAsync(request, userId.Value, cancellationToken);
+
+ if (!result.Success)
+ return BadRequest(new { error = result.Message });
+
+ return CreatedAtAction(nameof(GetById), new { id = result.Data!.Id }, result.Data);
+ }
+
+ // No PUT/DELETE - checkpoints are immutable
+
+ private Guid? GetUserId()
+ {
+ var claim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
+ return claim != null && Guid.TryParse(claim.Value, out var id) ? id : null;
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/ShipmentDocumentsController.cs b/backend/src/Parhelion.API/Controllers/ShipmentDocumentsController.cs
new file mode 100644
index 0000000..6a1238b
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/ShipmentDocumentsController.cs
@@ -0,0 +1,146 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Shipment;
+using Parhelion.Application.Interfaces.Services;
+using Parhelion.Domain.Enums;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para metadata de documentos de envío.
+/// Los PDFs se generan dinámicamente via /api/documents.
+/// Este controller maneja solo el registro y metadata de documentos.
+///
+[ApiController]
+[Route("api/shipment-documents")]
+[Authorize]
+[Produces("application/json")]
+public class ShipmentDocumentsController : ControllerBase
+{
+ private readonly IShipmentDocumentService _documentService;
+
+ public ShipmentDocumentsController(IShipmentDocumentService documentService)
+ {
+ _documentService = documentService;
+ }
+
+ ///
+ /// Obtiene todos los documentos con paginación.
+ ///
+ [HttpGet]
+ public async Task>> GetAll(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _documentService.GetAllAsync(request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene un documento por ID.
+ ///
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id, CancellationToken cancellationToken = default)
+ {
+ var result = await _documentService.GetByIdAsync(id, cancellationToken);
+ if (result == null) return NotFound(new { error = "Documento no encontrado" });
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene todos los documentos de un envío.
+ ///
+ [HttpGet("by-shipment/{shipmentId:guid}")]
+ public async Task>> ByShipment(
+ Guid shipmentId,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _documentService.GetByShipmentAsync(shipmentId, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene documentos filtrados por tipo.
+ ///
+ [HttpGet("by-type/{shipmentId:guid}/{documentType}")]
+ public async Task>> ByType(
+ Guid shipmentId,
+ string documentType,
+ CancellationToken cancellationToken = default)
+ {
+ if (!Enum.TryParse(documentType, out var docType))
+ return BadRequest(new { error = "Tipo de documento inválido" });
+
+ var result = await _documentService.GetByTypeAsync(shipmentId, docType, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Registra un documento (metadata).
+ /// Para generar el PDF real, usar /api/documents/{type}/{entityId}.
+ ///
+ [HttpPost]
+ [Consumes("application/json")]
+ public async Task> Create(
+ [FromBody] CreateShipmentDocumentRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _documentService.CreateAsync(request, cancellationToken);
+
+ if (!result.Success)
+ return BadRequest(new { error = result.Message });
+
+ return CreatedAtAction(nameof(GetById), new { id = result.Data!.Id }, result.Data);
+ }
+
+ ///
+ /// Captura firma digital para POD.
+ ///
+ [HttpPost("pod/{shipmentId:guid}")]
+ [Consumes("application/json")]
+ public async Task> CapturePod(
+ Guid shipmentId,
+ [FromBody] CapturePodRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ // Crear registro de documento POD con firma
+ var createRequest = new CreateShipmentDocumentRequest(
+ ShipmentId: shipmentId,
+ DocumentType: DocumentType.POD.ToString(),
+ FileUrl: $"/api/documents/pod/{shipmentId}", // Link al endpoint de generación
+ GeneratedBy: "Driver",
+ ExpiresAt: null
+ );
+
+ var result = await _documentService.CreateAsync(createRequest, cancellationToken);
+
+ if (!result.Success)
+ return BadRequest(new { error = result.Message });
+
+ // TODO: Actualizar documento con firma via service
+ // Por ahora retornamos respuesta básica
+
+ return Ok(new PodCaptureResponse(
+ DocumentId: result.Data!.Id,
+ ShipmentId: shipmentId,
+ TrackingNumber: "", // Se obtiene del servicio
+ SignedAt: DateTime.UtcNow,
+ SignedByName: request.SignedByName,
+ FileUrl: $"/api/documents/pod/{shipmentId}"
+ ));
+ }
+
+ ///
+ /// Elimina un documento (soft delete).
+ ///
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id, CancellationToken cancellationToken = default)
+ {
+ var result = await _documentService.DeleteAsync(id, cancellationToken);
+ if (!result.Success)
+ return NotFound(new { error = result.Message });
+
+ return NoContent();
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/ShipmentItemsController.cs b/backend/src/Parhelion.API/Controllers/ShipmentItemsController.cs
new file mode 100644
index 0000000..027166a
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/ShipmentItemsController.cs
@@ -0,0 +1,136 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.DTOs.Shipment;
+using Parhelion.Domain.Entities;
+using Parhelion.Domain.Enums;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para items de envío.
+///
+[ApiController]
+[Route("api/shipment-items")]
+[Authorize]
+public class ShipmentItemsController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+
+ public ShipmentItemsController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ var items = await _context.ShipmentItems
+ .Include(x => x.Product)
+ .Where(x => !x.IsDeleted)
+ .Take(100)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id)
+ {
+ var item = await _context.ShipmentItems
+ .Include(x => x.Product)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Item no encontrado" });
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpGet("by-shipment/{shipmentId:guid}")]
+ public async Task>> ByShipment(Guid shipmentId)
+ {
+ var items = await _context.ShipmentItems
+ .Include(x => x.Product)
+ .Where(x => !x.IsDeleted && x.ShipmentId == shipmentId)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateShipmentItemRequest request)
+ {
+ var item = new ShipmentItem
+ {
+ Id = Guid.NewGuid(),
+ ShipmentId = request.ShipmentId,
+ ProductId = request.ProductId,
+ Sku = request.Sku,
+ Description = request.Description,
+ PackagingType = Enum.TryParse(request.PackagingType, out var pt) ? pt : PackagingType.Box,
+ Quantity = request.Quantity,
+ WeightKg = request.WeightKg,
+ WidthCm = request.WidthCm,
+ HeightCm = request.HeightCm,
+ LengthCm = request.LengthCm,
+ DeclaredValue = request.DeclaredValue,
+ IsFragile = request.IsFragile,
+ IsHazardous = request.IsHazardous,
+ RequiresRefrigeration = request.RequiresRefrigeration,
+ StackingInstructions = request.StackingInstructions,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.ShipmentItems.Add(item);
+ await _context.SaveChangesAsync();
+
+ item = await _context.ShipmentItems.Include(x => x.Product).FirstAsync(x => x.Id == item.Id);
+ return CreatedAtAction(nameof(GetById), new { id = item.Id }, MapToResponse(item));
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task> Update(Guid id, [FromBody] UpdateShipmentItemRequest request)
+ {
+ var item = await _context.ShipmentItems.Include(x => x.Product)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Item no encontrado" });
+
+ item.Sku = request.Sku;
+ item.Description = request.Description;
+ item.PackagingType = Enum.TryParse(request.PackagingType, out var pt) ? pt : item.PackagingType;
+ item.Quantity = request.Quantity;
+ item.WeightKg = request.WeightKg;
+ item.WidthCm = request.WidthCm;
+ item.HeightCm = request.HeightCm;
+ item.LengthCm = request.LengthCm;
+ item.DeclaredValue = request.DeclaredValue;
+ item.IsFragile = request.IsFragile;
+ item.IsHazardous = request.IsHazardous;
+ item.RequiresRefrigeration = request.RequiresRefrigeration;
+ item.StackingInstructions = request.StackingInstructions;
+ item.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var item = await _context.ShipmentItems.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Item no encontrado" });
+
+ item.IsDeleted = true;
+ item.DeletedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+ return NoContent();
+ }
+
+ private static ShipmentItemResponse MapToResponse(ShipmentItem x) => new(
+ x.Id, x.ShipmentId, x.ProductId, x.Product?.Name,
+ x.Sku, x.Description, x.PackagingType.ToString(),
+ x.Quantity, x.WeightKg, x.WidthCm, x.HeightCm, x.LengthCm,
+ x.VolumeM3, x.VolumetricWeightKg, x.DeclaredValue,
+ x.IsFragile, x.IsHazardous, x.RequiresRefrigeration,
+ x.StackingInstructions, x.CreatedAt, x.UpdatedAt
+ );
+}
diff --git a/backend/src/Parhelion.API/Controllers/ShipmentsController.cs b/backend/src/Parhelion.API/Controllers/ShipmentsController.cs
new file mode 100644
index 0000000..49342f1
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/ShipmentsController.cs
@@ -0,0 +1,232 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.API.Filters;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Shipment;
+using Parhelion.Application.Interfaces.Services;
+using Parhelion.Domain.Enums;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para gestión de envíos.
+/// Endpoints para CRUD, tracking, asignación y workflow de estados.
+///
+[ApiController]
+[Route("api/shipments")]
+[Authorize]
+[Produces("application/json")]
+[Consumes("application/json")]
+public class ShipmentsController : ControllerBase
+{
+ private readonly IShipmentService _shipmentService;
+
+ ///
+ /// Inicializa el controlador con el servicio de Shipments.
+ ///
+ public ShipmentsController(IShipmentService shipmentService)
+ {
+ _shipmentService = shipmentService;
+ }
+
+ ///
+ /// Obtiene todos los envíos con paginación.
+ ///
+ [HttpGet]
+ public async Task>> GetAll(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _shipmentService.GetAllAsync(request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene un envío por ID.
+ ///
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _shipmentService.GetByIdAsync(id, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Envío no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Busca un envío por número de tracking.
+ ///
+ [HttpGet("by-tracking/{trackingNumber}")]
+ public async Task> ByTracking(
+ string trackingNumber,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _shipmentService.GetByTrackingNumberAsync(trackingNumber, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Envío no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Obtiene envíos por estatus.
+ ///
+ [HttpGet("by-status/{status}")]
+ public async Task>> ByStatus(
+ string status,
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (!Enum.TryParse(status, out var shipmentStatus))
+ return BadRequest(new { error = "Estatus inválido" });
+
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _shipmentService.GetByStatusAsync(tenantId, shipmentStatus, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene envíos del tenant actual.
+ ///
+ [HttpGet("current-tenant")]
+ public async Task>> GetByCurrentTenant(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _shipmentService.GetByTenantAsync(tenantId, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene envíos asignados a un chofer.
+ ///
+ [HttpGet("by-driver/{driverId:guid}")]
+ public async Task>> ByDriver(
+ Guid driverId,
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _shipmentService.GetByDriverAsync(driverId, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene envíos por ubicación.
+ ///
+ [HttpGet("by-location/{locationId:guid}")]
+ public async Task>> ByLocation(
+ Guid locationId,
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _shipmentService.GetByLocationAsync(locationId, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Crea un nuevo envío.
+ ///
+ [HttpPost]
+ public async Task> Create(
+ [FromBody] CreateShipmentRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _shipmentService.CreateAsync(request, cancellationToken);
+
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+
+ return CreatedAtAction(
+ nameof(GetById),
+ new { id = result.Data!.Id },
+ result.Data);
+ }
+
+ ///
+ /// Actualiza un envío existente.
+ ///
+ [HttpPut("{id:guid}")]
+ public async Task> Update(
+ Guid id,
+ [FromBody] UpdateShipmentRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _shipmentService.UpdateAsync(id, request, cancellationToken);
+
+ if (!result.Success)
+ {
+ if (result.Message?.Contains("no encontrado") == true)
+ return NotFound(new { error = result.Message });
+ return Conflict(new { error = result.Message });
+ }
+
+ return Ok(result.Data);
+ }
+
+ ///
+ /// Asigna un envío a un chofer y camión.
+ ///
+ [HttpPatch("{id:guid}/assign")]
+ public async Task> AssignToDriver(
+ Guid id,
+ [FromQuery] Guid driverId,
+ [FromQuery] Guid truckId,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _shipmentService.AssignToDriverAsync(id, driverId, truckId, cancellationToken);
+
+ if (!result.Success)
+ return BadRequest(new { error = result.Message });
+
+ return Ok(result.Data);
+ }
+
+ ///
+ /// Actualiza el estatus de un envío.
+ /// Soporta autenticación JWT o X-Service-Key/Bearer CallbackToken
+ ///
+ [HttpPatch("{id:guid}/status")]
+ [ServiceApiKey]
+ [AllowAnonymous] // Bypass [Authorize] de clase - ServiceApiKey valida
+ public async Task> UpdateStatus(
+ Guid id,
+ [FromQuery] string status,
+ CancellationToken cancellationToken = default)
+ {
+ if (!Enum.TryParse(status, out var newStatus))
+ return BadRequest(new { error = "Estatus inválido" });
+
+ var result = await _shipmentService.UpdateStatusAsync(id, newStatus, cancellationToken);
+
+ if (!result.Success)
+ return BadRequest(new { error = result.Message });
+
+ return Ok(result.Data);
+ }
+
+ ///
+ /// Elimina (soft-delete) un envío.
+ ///
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _shipmentService.DeleteAsync(id, cancellationToken);
+
+ if (!result.Success)
+ return NotFound(new { error = result.Message });
+
+ return NoContent();
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/TenantsController.cs b/backend/src/Parhelion.API/Controllers/TenantsController.cs
new file mode 100644
index 0000000..05758ba
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/TenantsController.cs
@@ -0,0 +1,185 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Core;
+using Parhelion.Application.Interfaces.Services;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para gestión de Tenants (empresas clientes del sistema).
+/// Solo accesible por Super Admins.
+///
+[ApiController]
+[Route("api/tenants")]
+[Authorize]
+public class TenantsController : ControllerBase
+{
+ private readonly ITenantService _tenantService;
+
+ ///
+ /// Inicializa el controlador con el servicio de Tenants.
+ ///
+ /// Servicio de gestión de tenants.
+ public TenantsController(ITenantService tenantService)
+ {
+ _tenantService = tenantService;
+ }
+
+ ///
+ /// Obtiene todos los tenants con paginación.
+ ///
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de tenants.
+ [HttpGet]
+ public async Task>> GetAll(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _tenantService.GetAllAsync(request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene el tenant actual del usuario logueado.
+ ///
+ /// Token de cancelación.
+ /// Tenant actual.
+ [HttpGet("current")]
+ public async Task> GetCurrent(
+ CancellationToken cancellationToken = default)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ {
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+ }
+
+ var tenant = await _tenantService.GetByIdAsync(tenantId, cancellationToken);
+ if (tenant == null)
+ return NotFound(new { error = "Tenant no encontrado" });
+
+ return Ok(tenant);
+ }
+
+ ///
+ /// Obtiene un tenant por ID.
+ ///
+ /// ID del tenant.
+ /// Token de cancelación.
+ /// Tenant encontrado.
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _tenantService.GetByIdAsync(id, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Tenant no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Obtiene solo los tenants activos.
+ ///
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de tenants activos.
+ [HttpGet("active")]
+ public async Task>> GetActive(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _tenantService.GetActiveAsync(request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Crea un nuevo tenant.
+ ///
+ /// Datos del nuevo tenant.
+ /// Token de cancelación.
+ /// Tenant creado.
+ [HttpPost]
+ public async Task> Create(
+ [FromBody] CreateTenantRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _tenantService.CreateAsync(request, cancellationToken);
+
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+
+ return CreatedAtAction(
+ nameof(GetById),
+ new { id = result.Data!.Id },
+ result.Data);
+ }
+
+ ///
+ /// Actualiza un tenant existente.
+ ///
+ /// ID del tenant.
+ /// Datos de actualización.
+ /// Token de cancelación.
+ /// Tenant actualizado.
+ [HttpPut("{id:guid}")]
+ public async Task> Update(
+ Guid id,
+ [FromBody] UpdateTenantRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _tenantService.UpdateAsync(id, request, cancellationToken);
+
+ if (!result.Success)
+ {
+ if (result.Message?.Contains("no encontrado") == true)
+ return NotFound(new { error = result.Message });
+ return Conflict(new { error = result.Message });
+ }
+
+ return Ok(result.Data);
+ }
+
+ ///
+ /// Activa o desactiva un tenant.
+ ///
+ /// ID del tenant.
+ /// Estado deseado.
+ /// Token de cancelación.
+ /// Resultado de la operación.
+ [HttpPatch("{id:guid}/status")]
+ public async Task SetStatus(
+ Guid id,
+ [FromQuery] bool isActive,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _tenantService.SetActiveStatusAsync(id, isActive, cancellationToken);
+
+ if (!result.Success)
+ return NotFound(new { error = result.Message });
+
+ return Ok(new { message = result.Message });
+ }
+
+ ///
+ /// Elimina (soft-delete) un tenant.
+ ///
+ /// ID del tenant.
+ /// Token de cancelación.
+ /// 204 No Content.
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _tenantService.DeleteAsync(id, cancellationToken);
+
+ if (!result.Success)
+ return NotFound(new { error = result.Message });
+
+ return NoContent();
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/TrucksController.cs b/backend/src/Parhelion.API/Controllers/TrucksController.cs
new file mode 100644
index 0000000..8fb5fe8
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/TrucksController.cs
@@ -0,0 +1,125 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Fleet;
+using Parhelion.Application.Interfaces.Services;
+using Parhelion.Domain.Enums;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para gestión de camiones de la flota.
+/// CRUD completo, búsqueda por placa, filtro por tipo y gestión de estatus.
+///
+[ApiController]
+[Route("api/trucks")]
+[Authorize]
+[Produces("application/json")]
+[Consumes("application/json")]
+public class TrucksController : ControllerBase
+{
+ private readonly ITruckService _truckService;
+
+ public TrucksController(ITruckService truckService)
+ {
+ _truckService = truckService;
+ }
+
+ [HttpGet]
+ public async Task GetAll([FromQuery] PagedRequest request)
+ {
+ var result = await _truckService.GetAllAsync(request);
+ return Ok(result);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task GetById(Guid id)
+ {
+ var result = await _truckService.GetByIdAsync(id);
+ if (result == null) return NotFound(new { error = "Camión no encontrado" });
+ return Ok(result);
+ }
+
+ [HttpGet("available")]
+ public async Task Available([FromQuery] PagedRequest request)
+ {
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _truckService.GetAvailableAsync(tenantId.Value, request);
+ return Ok(result);
+ }
+
+ [HttpGet("by-type/{type}")]
+ public async Task ByType(string type, [FromQuery] PagedRequest request)
+ {
+ if (!Enum.TryParse(type, out var truckType))
+ return BadRequest(new { error = "Tipo de camión inválido" });
+
+ var tenantId = GetTenantId();
+ if (tenantId == null) return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _truckService.GetByTypeAsync(tenantId.Value, truckType, request);
+ return Ok(result);
+ }
+
+ [HttpGet("by-plate/{plate}")]
+ public async Task ByPlate(string plate)
+ {
+ var result = await _truckService.GetByPlateAsync(plate);
+ if (result == null) return NotFound(new { error = "Camión no encontrado" });
+ return Ok(result);
+ }
+
+ [HttpPost]
+ public async Task Create([FromBody] CreateTruckRequest request)
+ {
+ var result = await _truckService.CreateAsync(request);
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+ return CreatedAtAction(nameof(GetById), new { id = result.Data!.Id }, result.Data);
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task Update(Guid id, [FromBody] UpdateTruckRequest request)
+ {
+ var result = await _truckService.UpdateAsync(id, request);
+ if (!result.Success)
+ return (result.Message?.Contains("no encontrado") ?? false)
+ ? NotFound(new { error = result.Message })
+ : BadRequest(new { error = result.Message });
+ return Ok(result.Data);
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var result = await _truckService.DeleteAsync(id);
+ if (!result.Success) return NotFound(new { error = result.Message });
+ return NoContent();
+ }
+
+ [HttpPatch("{id:guid}/status")]
+ public async Task SetStatus(Guid id, [FromBody] bool isActive)
+ {
+ var result = await _truckService.SetActiveStatusAsync(id, isActive);
+ if (!result.Success) return NotFound(new { error = result.Message });
+ return Ok(result.Data);
+ }
+
+ [HttpPost("{id:guid}/location")]
+ public async Task UpdateLocation(Guid id, [FromBody] UpdateTruckLocationRequest request)
+ {
+ var result = await _truckService.UpdateLocationAsync(id, request.Latitude, request.Longitude);
+ if (!result.Success) return NotFound(new { error = result.Message });
+ return Ok();
+ }
+
+ private Guid? GetTenantId()
+ {
+ var claim = User.FindFirst("tenant_id");
+ return claim != null && Guid.TryParse(claim.Value, out var id) ? id : null;
+ }
+}
+
+public record UpdateTruckLocationRequest(decimal Latitude, decimal Longitude);
diff --git a/backend/src/Parhelion.API/Controllers/UsersController.cs b/backend/src/Parhelion.API/Controllers/UsersController.cs
new file mode 100644
index 0000000..8ef7c66
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/UsersController.cs
@@ -0,0 +1,192 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Parhelion.Application.DTOs.Common;
+using Parhelion.Application.DTOs.Core;
+using Parhelion.Application.Interfaces.Services;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para gestión de usuarios.
+///
+[ApiController]
+[Route("api/users")]
+[Authorize]
+public class UsersController : ControllerBase
+{
+ private readonly IUserService _userService;
+
+ ///
+ /// Inicializa el controlador con el servicio de Users.
+ ///
+ /// Servicio de gestión de usuarios.
+ public UsersController(IUserService userService)
+ {
+ _userService = userService;
+ }
+
+ ///
+ /// Obtiene todos los usuarios con paginación.
+ ///
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de usuarios.
+ [HttpGet]
+ public async Task>> GetAll(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _userService.GetAllAsync(request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Obtiene un usuario por ID.
+ ///
+ /// ID del usuario.
+ /// Token de cancelación.
+ /// Usuario encontrado.
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var item = await _userService.GetByIdAsync(id, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Usuario no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Obtiene usuarios del tenant actual.
+ ///
+ /// Parámetros de paginación.
+ /// Token de cancelación.
+ /// Lista paginada de usuarios del tenant.
+ [HttpGet("current-tenant")]
+ public async Task>> GetByCurrentTenant(
+ [FromQuery] PagedRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var tenantIdClaim = User.FindFirst("tenant_id");
+ if (tenantIdClaim == null || !Guid.TryParse(tenantIdClaim.Value, out var tenantId))
+ return Unauthorized(new { error = "No se pudo determinar el tenant" });
+
+ var result = await _userService.GetByTenantAsync(tenantId, request, cancellationToken);
+ return Ok(result);
+ }
+
+ ///
+ /// Busca un usuario por email.
+ ///
+ /// Email del usuario.
+ /// Token de cancelación.
+ /// Usuario encontrado.
+ [HttpGet("by-email")]
+ public async Task> GetByEmail(
+ [FromQuery] string email,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(email))
+ return BadRequest(new { error = "El parámetro 'email' es requerido" });
+
+ var item = await _userService.GetByEmailAsync(email, cancellationToken);
+ if (item == null)
+ return NotFound(new { error = "Usuario no encontrado" });
+
+ return Ok(item);
+ }
+
+ ///
+ /// Crea un nuevo usuario.
+ ///
+ /// Datos del nuevo usuario.
+ /// Token de cancelación.
+ /// Usuario creado.
+ [HttpPost]
+ public async Task> Create(
+ [FromBody] CreateUserRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _userService.CreateAsync(request, cancellationToken);
+
+ if (!result.Success)
+ return Conflict(new { error = result.Message });
+
+ return CreatedAtAction(
+ nameof(GetById),
+ new { id = result.Data!.Id },
+ result.Data);
+ }
+
+ ///
+ /// Actualiza un usuario existente.
+ ///
+ /// ID del usuario.
+ /// Datos de actualización.
+ /// Token de cancelación.
+ /// Usuario actualizado.
+ [HttpPut("{id:guid}")]
+ public async Task> Update(
+ Guid id,
+ [FromBody] UpdateUserRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _userService.UpdateAsync(id, request, cancellationToken);
+
+ if (!result.Success)
+ {
+ if (result.Message?.Contains("no encontrado") == true)
+ return NotFound(new { error = result.Message });
+ return Conflict(new { error = result.Message });
+ }
+
+ return Ok(result.Data);
+ }
+
+ ///
+ /// Cambia el password del usuario actual.
+ ///
+ /// Password actual.
+ /// Nuevo password.
+ /// Token de cancelación.
+ /// Resultado de la operación.
+ [HttpPatch("change-password")]
+ public async Task ChangePassword(
+ [FromQuery] string currentPassword,
+ [FromQuery] string newPassword,
+ CancellationToken cancellationToken = default)
+ {
+ var userIdClaim = User.FindFirst("sub") ?? User.FindFirst("user_id");
+ if (userIdClaim == null || !Guid.TryParse(userIdClaim.Value, out var userId))
+ return Unauthorized(new { error = "No se pudo determinar el usuario" });
+
+ var result = await _userService.ChangePasswordAsync(
+ userId, currentPassword, newPassword, cancellationToken);
+
+ if (!result.Success)
+ return BadRequest(new { error = result.Message });
+
+ return Ok(new { message = result.Message });
+ }
+
+ ///
+ /// Elimina (soft-delete) un usuario.
+ ///
+ /// ID del usuario.
+ /// Token de cancelación.
+ /// 204 No Content.
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(
+ Guid id,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await _userService.DeleteAsync(id, cancellationToken);
+
+ if (!result.Success)
+ return NotFound(new { error = result.Message });
+
+ return NoContent();
+ }
+}
diff --git a/backend/src/Parhelion.API/Controllers/WarehouseOperatorsController.cs b/backend/src/Parhelion.API/Controllers/WarehouseOperatorsController.cs
new file mode 100644
index 0000000..885d70b
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/WarehouseOperatorsController.cs
@@ -0,0 +1,122 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.DTOs.Warehouse;
+using Parhelion.Domain.Entities;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para operadores de almacén.
+///
+[ApiController]
+[Route("api/warehouse-operators")]
+[Authorize]
+public class WarehouseOperatorsController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+
+ public WarehouseOperatorsController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ var items = await _context.WarehouseOperators
+ .Include(x => x.Employee).ThenInclude(e => e.User)
+ .Include(x => x.AssignedLocation)
+ .Include(x => x.PrimaryZone)
+ .Where(x => !x.IsDeleted)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id)
+ {
+ var item = await _context.WarehouseOperators
+ .Include(x => x.Employee).ThenInclude(e => e.User)
+ .Include(x => x.AssignedLocation)
+ .Include(x => x.PrimaryZone)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Operador no encontrado" });
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpGet("by-location/{locationId:guid}")]
+ public async Task>> ByLocation(Guid locationId)
+ {
+ var items = await _context.WarehouseOperators
+ .Include(x => x.Employee).ThenInclude(e => e.User)
+ .Include(x => x.AssignedLocation)
+ .Include(x => x.PrimaryZone)
+ .Where(x => !x.IsDeleted && x.AssignedLocationId == locationId)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateWarehouseOperatorRequest request)
+ {
+ var item = new WarehouseOperator
+ {
+ Id = Guid.NewGuid(),
+ EmployeeId = request.EmployeeId,
+ AssignedLocationId = request.AssignedLocationId,
+ PrimaryZoneId = request.PrimaryZoneId,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.WarehouseOperators.Add(item);
+ await _context.SaveChangesAsync();
+
+ item = await _context.WarehouseOperators
+ .Include(x => x.Employee).ThenInclude(e => e.User)
+ .Include(x => x.AssignedLocation)
+ .Include(x => x.PrimaryZone)
+ .FirstAsync(x => x.Id == item.Id);
+ return CreatedAtAction(nameof(GetById), new { id = item.Id }, MapToResponse(item));
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task> Update(Guid id, [FromBody] UpdateWarehouseOperatorRequest request)
+ {
+ var item = await _context.WarehouseOperators
+ .Include(x => x.Employee).ThenInclude(e => e.User)
+ .Include(x => x.AssignedLocation)
+ .Include(x => x.PrimaryZone)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Operador no encontrado" });
+
+ item.AssignedLocationId = request.AssignedLocationId;
+ item.PrimaryZoneId = request.PrimaryZoneId;
+ item.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var item = await _context.WarehouseOperators.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Operador no encontrado" });
+
+ item.IsDeleted = true;
+ item.DeletedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+ return NoContent();
+ }
+
+ private static WarehouseOperatorResponse MapToResponse(WarehouseOperator x) => new(
+ x.Id, x.EmployeeId, x.Employee?.User?.FullName ?? "",
+ x.AssignedLocationId, x.AssignedLocation?.Name ?? "",
+ x.PrimaryZoneId, x.PrimaryZone?.Name,
+ x.CreatedAt, x.UpdatedAt
+ );
+}
diff --git a/backend/src/Parhelion.API/Controllers/WarehouseZonesController.cs b/backend/src/Parhelion.API/Controllers/WarehouseZonesController.cs
new file mode 100644
index 0000000..b5438c1
--- /dev/null
+++ b/backend/src/Parhelion.API/Controllers/WarehouseZonesController.cs
@@ -0,0 +1,113 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.DTOs.Warehouse;
+using Parhelion.Domain.Entities;
+using Parhelion.Domain.Enums;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Controllers;
+
+///
+/// Controlador para zonas de bodega.
+///
+[ApiController]
+[Route("api/warehouse-zones")]
+[Authorize]
+public class WarehouseZonesController : ControllerBase
+{
+ private readonly ParhelionDbContext _context;
+
+ public WarehouseZonesController(ParhelionDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ var items = await _context.WarehouseZones
+ .Include(x => x.Location)
+ .Where(x => !x.IsDeleted)
+ .OrderBy(x => x.Code)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> GetById(Guid id)
+ {
+ var item = await _context.WarehouseZones
+ .Include(x => x.Location)
+ .FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Zona no encontrada" });
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpGet("by-location/{locationId:guid}")]
+ public async Task>> ByLocation(Guid locationId)
+ {
+ var items = await _context.WarehouseZones
+ .Include(x => x.Location)
+ .Where(x => !x.IsDeleted && x.LocationId == locationId)
+ .OrderBy(x => x.Code)
+ .Select(x => MapToResponse(x))
+ .ToListAsync();
+ return Ok(items);
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateWarehouseZoneRequest request)
+ {
+ var item = new WarehouseZone
+ {
+ Id = Guid.NewGuid(),
+ LocationId = request.LocationId,
+ Code = request.Code,
+ Name = request.Name,
+ Type = Enum.TryParse(request.Type, out var t) ? t : WarehouseZoneType.Storage,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ _context.WarehouseZones.Add(item);
+ await _context.SaveChangesAsync();
+
+ item = await _context.WarehouseZones.Include(x => x.Location).FirstAsync(x => x.Id == item.Id);
+ return CreatedAtAction(nameof(GetById), new { id = item.Id }, MapToResponse(item));
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task> Update(Guid id, [FromBody] UpdateWarehouseZoneRequest request)
+ {
+ var item = await _context.WarehouseZones.Include(x => x.Location).FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Zona no encontrada" });
+
+ item.Code = request.Code;
+ item.Name = request.Name;
+ item.Type = Enum.TryParse(request.Type, out var t) ? t : item.Type;
+ item.IsActive = request.IsActive;
+ item.UpdatedAt = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ return Ok(MapToResponse(item));
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ var item = await _context.WarehouseZones.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
+ if (item == null) return NotFound(new { error = "Zona no encontrada" });
+
+ item.IsDeleted = true;
+ item.DeletedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+ return NoContent();
+ }
+
+ private static WarehouseZoneResponse MapToResponse(WarehouseZone x) => new(
+ x.Id, x.LocationId, x.Location?.Name ?? "", x.Code, x.Name,
+ x.Type.ToString(), x.IsActive, x.CreatedAt, x.UpdatedAt
+ );
+}
diff --git a/backend/src/Parhelion.API/Data/CrisisSeeder.cs b/backend/src/Parhelion.API/Data/CrisisSeeder.cs
new file mode 100644
index 0000000..4621ee1
--- /dev/null
+++ b/backend/src/Parhelion.API/Data/CrisisSeeder.cs
@@ -0,0 +1,166 @@
+using Parhelion.Domain.Entities;
+using Parhelion.Domain.Enums;
+using Parhelion.Application.Auth;
+using Parhelion.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Parhelion.API.Data;
+
+///
+/// Seeder específico para el flujo de Crisis Management.
+/// Crea los 3 escenarios de prueba: Victima, Rescate, Lejano.
+///
+public static class CrisisSeeder
+{
+ public static async Task SeedAsync(IServiceProvider services)
+ {
+ using var scope = services.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+ var passwordHasher = scope.ServiceProvider.GetRequiredService();
+
+ // 1. Obtener Tenant principal (asumimos que DataSeeder ya corrió)
+ var tenant = await context.Tenants.FirstOrDefaultAsync();
+ if (tenant == null)
+ {
+ Console.WriteLine("⚠️ CRISIS SEED: No tenant found. Skipping.");
+ return;
+ }
+
+ // 2. Verificar si ya existen los datos para no duplicar
+ if (await context.Trucks.AnyAsync(t => t.Plate == "VICTIM-01"))
+ {
+ Console.WriteLine("ℹ️ CRISIS SEED: Data already exists. Skipping.");
+ return;
+ }
+
+ Console.WriteLine("🚑 CRISIS SEED: Injecting test scenario data...");
+
+ // 3. Obtener Rol de Driver (asumimos ID fijo o buscamos por nombre)
+ var driverRole = await context.Roles.FirstOrDefaultAsync(r => r.Name == "Driver");
+ if (driverRole == null)
+ {
+ // Si no existe, usamos cualquier rol o lanzamos error. Por ahora creamos uno dummy en memoria si falla.
+ // Pero en producción/dev ya debería existir por DataSeeder o migraciones anteriores.
+ // Buscaremos el ID fijo de la documentación si falla el nombre
+ driverRole = await context.Roles.FindAsync(Guid.Parse("22222222-2222-2222-2222-222222222222"));
+ if (driverRole == null) throw new Exception("Driver role not found for seeding");
+ }
+
+ var defaultPass = passwordHasher.HashPassword("Test1234!");
+
+ // --- SCENARIO 1: VICTIM (El que se rompe) ---
+ // Coordenadas: 20.588056, -100.388056
+ CreateDriverStack(context, tenant.Id, driverRole.Id, defaultPass,
+ firstName: "Victim",
+ lastName: "Driver",
+ email: "victim@parhelion.com",
+ plate: "VICTIM-01",
+ lat: 20.588056m, lon: -100.388056m,
+ status: DriverStatus.OnRoute, // Está ocupado/en ruta antes de fallar
+ truckType: TruckType.DryBox);
+
+ // --- SCENARIO 2: RESCUE (El salvador cercano) ---
+ // Coordenadas: 20.612000, -100.410000 (~3-4 km cerca)
+ CreateDriverStack(context, tenant.Id, driverRole.Id, defaultPass,
+ firstName: "Rescue",
+ lastName: "Driver",
+ email: "rescue@parhelion.com",
+ plate: "RESCUE-01",
+ lat: 20.612000m, lon: -100.410000m,
+ status: DriverStatus.Available, // Debe estar disponible
+ truckType: TruckType.DryBox);
+
+ // --- SCENARIO 3: FAR (El que está en CDMX, lejos) ---
+ // Coordenadas: 19.432608, -99.133209 (~200 km lejos)
+ CreateDriverStack(context, tenant.Id, driverRole.Id, defaultPass,
+ firstName: "Far",
+ lastName: "Driver",
+ email: "far@parhelion.com",
+ plate: "FAR-01",
+ lat: 19.432608m, lon: -99.133209m,
+ status: DriverStatus.Available, // Disponible pero lejos
+ truckType: TruckType.DryBox);
+
+ await context.SaveChangesAsync();
+ Console.WriteLine("✅ CRISIS SEED: Scenario data injected successfully.");
+ }
+
+ private static void CreateDriverStack(
+ ParhelionDbContext ctx,
+ Guid tenantId,
+ Guid roleId,
+ string passwordHash,
+ string firstName,
+ string lastName,
+ string email,
+ string plate,
+ decimal lat,
+ decimal lon,
+ DriverStatus status,
+ TruckType truckType)
+ {
+ // 1. User
+ var user = new User
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ Email = email,
+ PasswordHash = passwordHash,
+ FullName = $"{firstName} {lastName}",
+ RoleId = roleId,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow,
+ UsesArgon2 = true // Asumimos default moderno
+ };
+ ctx.Users.Add(user);
+
+ // 2. Employee
+ var emp = new Employee
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ UserId = user.Id,
+ User = user,
+ Phone = "555-000-0000",
+ Department = "Fleet",
+ HireDate = DateTime.UtcNow,
+ CreatedAt = DateTime.UtcNow,
+ IsDeleted = false
+ };
+ ctx.Employees.Add(emp);
+
+ // 3. Truck
+ var truck = new Truck
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ Plate = plate,
+ Model = "Generic Test Truck",
+ Type = truckType,
+ MaxCapacityKg = 15000,
+ MaxVolumeM3 = 60,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow,
+ LastLatitude = lat,
+ LastLongitude = lon,
+ LastLocationUpdate = DateTime.UtcNow
+ };
+ ctx.Trucks.Add(truck);
+
+ // 4. Driver
+ var driver = new Driver
+ {
+ Id = Guid.NewGuid(),
+ EmployeeId = emp.Id,
+ Employee = emp,
+ LicenseNumber = $"LIC-{plate}",
+ Status = status,
+ CurrentTruckId = truck.Id,
+ CurrentTruck = truck,
+ // TenantId eliminado, no existe en Driver
+ CreatedAt = DateTime.UtcNow
+ };
+ ctx.Drivers.Add(driver);
+ }
+}
diff --git a/backend/src/Parhelion.API/Data/DataSeeder.cs b/backend/src/Parhelion.API/Data/DataSeeder.cs
new file mode 100644
index 0000000..9b07c68
--- /dev/null
+++ b/backend/src/Parhelion.API/Data/DataSeeder.cs
@@ -0,0 +1,90 @@
+using Parhelion.Domain.Entities;
+using Parhelion.Application.Auth;
+using Parhelion.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+
+namespace Parhelion.API.Data;
+
+///
+/// Seed inicial del sistema con SuperUser y DefaultTenant.
+/// Se ejecuta solo si no existen datos en la base de datos.
+///
+public static class DataSeeder
+{
+ ///
+ /// Aplica el seeder si la base de datos está vacía.
+ ///
+ public static async Task SeedAsync(IServiceProvider services)
+ {
+ using var scope = services.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+ var passwordHasher = scope.ServiceProvider.GetRequiredService();
+ var config = scope.ServiceProvider.GetRequiredService();
+
+ // Skip si ya hay datos
+ if (await context.Tenants.AnyAsync())
+ {
+ return;
+ }
+
+ // Datos del SuperUser desde config o defaults seguros
+ var superEmail = config["Seed:SuperUserEmail"] ?? "metacodex@parhelion.com";
+ var superPassword = config["Seed:SuperUserPassword"];
+
+ if (string.IsNullOrEmpty(superPassword))
+ {
+ Console.WriteLine("⚠️ SEED: No SuperUserPassword configured. Skipping seeder.");
+ Console.WriteLine(" Set Seed:SuperUserPassword in .env or appsettings to enable seeding.");
+ return;
+ }
+
+ Console.WriteLine("🌱 Seeding database with initial data...");
+
+ // 1. Crear DefaultTenant
+ var defaultTenant = new Tenant
+ {
+ Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
+ CompanyName = "Parhelion Logistics",
+ ContactEmail = "admin@parhelion.com",
+ FleetSize = 0,
+ DriverCount = 0,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow
+ };
+ context.Tenants.Add(defaultTenant);
+
+ // 2. Crear Role SuperAdmin
+ var superAdminRole = new Role
+ {
+ Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
+ Name = "SuperAdmin",
+ Description = "Super Administrator with full system access",
+ CreatedAt = DateTime.UtcNow
+ };
+ context.Roles.Add(superAdminRole);
+
+ // 3. Crear SuperUser con password hasheado (work factor 14 para admin)
+ var superUser = new User
+ {
+ Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
+ TenantId = defaultTenant.Id,
+ Email = superEmail,
+ PasswordHash = passwordHasher.HashPassword(superPassword, useArgon2: true),
+ FullName = "MetaCodeX SuperAdmin",
+ RoleId = superAdminRole.Id,
+ IsActive = true,
+ UsesArgon2 = true, // Indica que usa el hash más fuerte
+ CreatedAt = DateTime.UtcNow
+ };
+ context.Users.Add(superUser);
+
+ await context.SaveChangesAsync();
+
+ Console.WriteLine("✅ SEED: SuperUser created successfully");
+ Console.WriteLine($" Email: {superEmail}");
+ Console.WriteLine($" Role: SuperAdmin");
+ Console.WriteLine($" Tenant: Parhelion Logistics");
+ }
+}
diff --git a/backend/src/Parhelion.API/Filters/ServiceApiKeyAttribute.cs b/backend/src/Parhelion.API/Filters/ServiceApiKeyAttribute.cs
new file mode 100644
index 0000000..c77ad08
--- /dev/null
+++ b/backend/src/Parhelion.API/Filters/ServiceApiKeyAttribute.cs
@@ -0,0 +1,140 @@
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.EntityFrameworkCore;
+using Parhelion.Application.Interfaces;
+using Parhelion.Infrastructure.Data;
+
+namespace Parhelion.API.Filters;
+
+///
+/// Filtro de autenticación para servicios externos (n8n, microservicios).
+///
+/// Soporta 2 métodos de autenticación:
+/// 1. X-Service-Key: API Key persistente (lookup en BD)
+/// 2. Authorization: Bearer {CallbackToken}: JWT de corta duración (validación criptográfica)
+///
+/// El TenantId se almacena en HttpContext.Items["ServiceTenantId"]
+///
+/// Uso: [ServiceApiKey] en métodos o controladores.
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+public class ServiceApiKeyAttribute : Attribute, IAsyncActionFilter
+{
+ private const string ServiceKeyHeader = "X-Service-Key";
+ private const string AuthorizationHeader = "Authorization";
+ private const string BearerPrefix = "Bearer ";
+ public const string TenantIdKey = "ServiceTenantId";
+ public const string CorrelationIdKey = "ServiceCorrelationId";
+
+ public async Task OnActionExecutionAsync(
+ ActionExecutingContext context,
+ ActionExecutionDelegate next)
+ {
+ // ========== OPCIÓN 1: Callback Token (Bearer JWT) ==========
+ if (context.HttpContext.Request.Headers.TryGetValue(AuthorizationHeader, out var authHeader)
+ && authHeader.ToString().StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var token = authHeader.ToString()[BearerPrefix.Length..];
+
+ var tokenService = context.HttpContext.RequestServices
+ .GetService();
+
+ if (tokenService == null)
+ {
+ context.Result = new StatusCodeResult(500);
+ return;
+ }
+
+ var claims = tokenService.ValidateCallbackToken(token);
+ if (claims != null)
+ {
+ // Token válido - establecer claims y continuar
+ context.HttpContext.Items[TenantIdKey] = claims.TenantId;
+ context.HttpContext.Items[CorrelationIdKey] = claims.CorrelationId;
+ await next();
+ return;
+ }
+
+ // Token inválido o expirado
+ context.Result = new UnauthorizedObjectResult(new {
+ error = "Invalid or expired callback token"
+ });
+ return;
+ }
+
+ // ========== OPCIÓN 2: X-Service-Key (API Key persistente) ==========
+ if (context.HttpContext.Request.Headers.TryGetValue(ServiceKeyHeader, out var providedKey)
+ && !string.IsNullOrWhiteSpace(providedKey))
+ {
+ var keyHash = ComputeSha256Hash(providedKey.ToString());
+
+ var dbContext = context.HttpContext.RequestServices
+ .GetRequiredService();
+
+ var apiKey = await dbContext.ServiceApiKeys
+ .AsNoTracking()
+ .FirstOrDefaultAsync(k =>
+ k.KeyHash == keyHash &&
+ k.IsActive &&
+ !k.IsDeleted);
+
+ if (apiKey == null)
+ {
+ context.Result = new UnauthorizedObjectResult(new {
+ error = "Invalid or inactive service key"
+ });
+ return;
+ }
+
+ // Validar expiración
+ if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt.Value < DateTime.UtcNow)
+ {
+ context.Result = new UnauthorizedObjectResult(new {
+ error = "Service key has expired"
+ });
+ return;
+ }
+
+ // Almacenar TenantId
+ context.HttpContext.Items[TenantIdKey] = apiKey.TenantId;
+
+ // Actualizar LastUsedAt de forma fire-and-forget
+ _ = UpdateLastUsedAsync(context, apiKey.Id);
+
+ await next();
+ return;
+ }
+
+ // ========== SIN CREDENCIALES ==========
+ context.Result = new UnauthorizedObjectResult(new {
+ error = $"Missing authentication. Provide {ServiceKeyHeader} header or Authorization: Bearer "
+ });
+ }
+
+ private static async Task UpdateLastUsedAsync(ActionExecutingContext context, Guid apiKeyId)
+ {
+ try
+ {
+ using var scope = context.HttpContext.RequestServices
+ .GetRequiredService()
+ .CreateScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var key = await ctx.ServiceApiKeys.FindAsync(apiKeyId);
+ if (key != null)
+ {
+ key.LastUsedAt = DateTime.UtcNow;
+ key.LastUsedFromIp = context.HttpContext.Connection.RemoteIpAddress?.ToString();
+ await ctx.SaveChangesAsync();
+ }
+ }
+ catch { /* Fire and forget */ }
+ }
+
+ private static string ComputeSha256Hash(string rawData)
+ {
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawData));
+ return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
+ }
+}
diff --git a/backend/src/Parhelion.API/Parhelion.API.csproj b/backend/src/Parhelion.API/Parhelion.API.csproj
new file mode 100644
index 0000000..b22324a
--- /dev/null
+++ b/backend/src/Parhelion.API/Parhelion.API.csproj
@@ -0,0 +1,26 @@
+