# Reporte: Migración de Secciones con Media al Patrón BaseSectionService (2026-05-21)

Este documento detalla la migración de las secciones de la Ficha de Inventario que manejan archivos multimedia (imágenes y documentos) al nuevo patrón arquitectónico basado en `BaseSectionService` y `SectionController`.

---

## 1. Arquitectura de Media Asíncrona (Staging)

### Problema

Las Actions viejas manejaban la carga de archivos directamente en el endpoint de guardado (`multipart/form-data` con binarios). Esto causaba:
- Acoplamiento entre HTTP y lógica de dominio
- Duplicación de código de manejo de archivos entre acciones
- Imposibilidad de previsualizar archivos antes de guardar

### Solución: Staging + UUID Reference

Se implementó un flujo de dos pasos:

```
Paso 1: Subida a staging
  POST /fichas-inventario/media/upload (multipart/form-data)
  → Retorna { uuid, original_name, size, preview_url }

Paso 2: Guardado de sección
  PUT /fichas-inventario/{inventario}/seccion-X (application/json)
  → Payload referencia archivos por UUID en vez de binarios
```

### Componentes Nuevos

| Componente | Archivo | Función |
|------------|---------|---------|
| Migración | `database/migrations/2026_05_21_211237_create_pending_uploads_table.php` | Tabla staging con `uuid` único y `user_id` |
| Modelo | `app/Models/PendingUpload.php` | Modelo con `HasMedia`, colección `'temp'` (singleFile) |
| Controller | `app/Http/Controllers/Inventario/MediaUploadController.php` | `store()` para subir, `preview()` para previsualizar |
| Command | `app/Console/Commands/PruneTemporaryUploads.php` | `media:prune-temporary` — elimina uploads > 24h |
| Trait | `app/Services/Inventario/Core/HasSectionMedia.php` | `syncMedia()` y `formatMediaCollection()` |

---

## 2. Trait `HasSectionMedia`

Trait reutilizable que proporciona sincronización declarativa de archivos multimedia para cualquier modelo que implemente `HasMedia`.

### Método `syncMedia()`

```php
protected function syncMedia(HasMedia $model, string $collectionName, array $items): void
```

Flujo por cada item del array:
1. **`id` presente** → Actualiza metadata (`custom_properties`) del media existente
2. **`uuid` presente** → Mueve archivo desde `pending_uploads` al modelo destino via `$tempMedia->move()`
3. **`remove: true`** → Elimina el media del modelo
4. Archivos no mencionados en el payload **no se eliminan** (no es declarativo, es explícito)

### Método `formatMediaCollection()`

```php
protected function formatMediaCollection(Collection $mediaItems, string $collectionName): array
```

Formatea la salida de archivos fusionando `custom_properties` almacenados con datos estructurales de Spatie.

---

## 3. Secciones Migradas

### S6 — Plano de Ubicación (`Seccion6Service`)

| Campo | Detalle |
|-------|---------|
| Colección | `plano_ubicacion` (singleFile, imágenes) |
| Tipo | Single image |
| Validación custom | Requiere `file` o `id` en `archivo` (via `prepareData()`) |
| Response | Modelo con `archivo` formateado |

### S7 — Fotografía Actual (`Seccion7Service`)

| Campo | Detalle |
|-------|---------|
| Colección | `foto_actual` (singleFile, imágenes) |
| Tipo | Single image + metadata |
| Metadata | `caption`, `fuente`, `fecha_toma`, `hora_toma`, `photo_code` |
| Validación custom | Requiere `file` o `id` en `archivo` |
| Response | Modelo con `archivo` formateado |

### S12 — Elementos Tipológicos (`Seccion12Service`)

| Campo | Detalle |
|-------|---------|
| Colección | `foto_tipologica` (multi-file, imágenes) |
| Tipo | Múltiples imágenes |
| Validación custom | Mínimo 2 archivos no eliminados |
| Metadata | `caption`, `source`, `taken_date`, `taken_time`, `photo_code`, `sort_order` |
| Response | Modelo con `archivos` array formateado |

### S13 — Entorno Urbano (`Seccion13Service`)

| Campo | Detalle |
|-------|---------|
| Colección | `foto_entorno_urbano` (multi-file, imágenes) |
| Tipo | Datos estructurados + imágenes opcionales |
| Campos | 26 campos (booleans + strings) + `archivos` array |
| Persist | `updateOrCreate` para datos + `syncMedia` para archivos |
| Response | Modelo con todos los campos + `archivos` formateado |

### Anexos (`SeccionAnexosService`)

| Campo | Detalle |
|-------|---------|
| Colección | `anexo_fotografico` (multi-file, imágenes + PDF) |
| Modelo | `Inventario` directamente (no child model) |
| Extiende | `BaseSectionService` con `$relation = 'media'` (dummy) |
| `get()` | Retorna `$inventario` directamente |
| `persist()` | Opera sobre `$inventario` con `syncMedia()` |
| Factory | Registrado como caso especial `'anexos' => 'SeccionAnexosService'` |
| Response | Modelo Inventario con archivos |

---

## 4. Validación Custom sin `afterValidator`

Las Actions viejas usaban `afterValidator` de Lorisleiva para validaciones que dependen del estado del payload (ej: "requiere al menos 1 archivo"). `BaseSectionService` no tiene este hook.

**Solución:** Se implementa en `prepareData()` lanzando `ValidationException`:

```php
protected function prepareData(array $data): array
{
    $archivo = $data['archivo'] ?? [];
    if (empty($archivo['id']) && empty($archivo['uuid'])) {
        throw ValidationException::withMessages([
            'archivo' => 'Debe subir un plano de ubicación.',
        ]);
    }
    return $data;
}
```

`prepareData()` se ejecuta DENTRO de `DB::transaction()` en `save()`. Si lanza una excepción, la transacción hace rollback automáticamente.

---

## 5. Rutas Migradas

| Ruta vieja | Ruta nueva |
|-----------|-----------|
| `Route::match(['put','post'], .../seccion-6, SaveSeccion6Action)` | `Route::put(.../seccion-6, SectionController@update)->defaults('relation', 'seccion6')` |
| `Route::match(['put','post'], .../seccion-7, SaveSeccion7Action)` | `Route::put(.../seccion-7, SectionController@update)->defaults('relation', 'seccion7')` |
| `Route::put(.../seccion-12, SaveSeccion12Action)` | `Route::put(.../seccion-12, SectionController@update)->defaults('relation', 'seccion12')` |
| `Route::put(.../seccion-13, SaveSeccion13Action)` | `Route::put(.../seccion-13, SectionController@update)->defaults('relation', 'seccion13')` |
| `Route::put(.../seccion-anexos, SaveSeccionAnexosAction)` | `Route::put(.../seccion-anexos, SectionController@update)->defaults('relation', 'anexos')` |

**Nota:** Secciones 6 y 7 cambiaron de `PUT|POST` a solo `PUT`. Frontend debe adaptarse.

**Rutas nuevas de media:**
- `POST /fichas-inventario/media/upload` → `MediaUploadController@store`
- `GET /fichas-inventario/media/temp/{uuid}` → `MediaUploadController@preview`

---

## 6. Archivos Creados

```
database/migrations/2026_05_21_211237_create_pending_uploads_table.php
app/Models/PendingUpload.php
app/Http/Controllers/Inventario/MediaUploadController.php
app/Console/Commands/PruneTemporaryUploads.php
app/Services/Inventario/Core/HasSectionMedia.php
app/Services/Inventario/Seccion6Service.php
app/Services/Inventario/Seccion7Service.php
app/Services/Inventario/Seccion12Service.php
app/Services/Inventario/Seccion13Service.php
app/Services/Inventario/SeccionAnexosService.php
tests/Feature/FichaInventario/MediaUploadTest.php
tests/Feature/FichaInventario/Seccion6SaveTest.php
tests/Feature/FichaInventario/Seccion7SaveTest.php
tests/Feature/FichaInventario/Seccion12SaveTest.php
tests/Feature/FichaInventario/Seccion13SaveTest.php
tests/Feature/FichaInventario/SeccionAnexosSaveTest.php
```

## 7. Archivos Eliminados (tests legacy)

```
tests/Feature/FichaInventario/SaveSeccion6Test.php
tests/Feature/FichaInventario/SaveSeccion7Test.php
tests/Feature/FichaInventario/SaveSeccion12Test.php
tests/Feature/FichaInventario/SaveSeccion13Test.php
tests/Feature/FichaInventario/SaveSeccionAnexosTest.php
```

## 8. Archivos Modificados

```
app/Services/Inventario/Core/SectionServiceFactory.php — Agregado caso especial 'anexos'
routes/web.php — Migradas 5 rutas + agregadas 2 rutas de media
```

---

## 9. Tests

| Suite | Tests | Assertions | Estado |
|-------|-------|------------|--------|
| MediaUploadTest | 5 | 8 | Todos pasan |
| Seccion6SaveTest | 5 | 8 | Todos pasan |
| Seccion7SaveTest | 4 | 10 | Todos pasan |
| Seccion12SaveTest | 4 | 6 | Todos pasan |
| Seccion13SaveTest | 3 | 5 | Todos pasan |
| SeccionAnexosSaveTest | 4 | 6 | Todos pasan |
| Secciones 1-15 (anteriores) | 48 | 139 | Todos pasan |
| **Total** | **79** | **200** | **Todos pasan** |

---

## 10. Migración de Sección 4 — Uso Funcional

### Problema

La Sección 4 era la única sección estándar que aún usaba `SaveSeccion4Action` (Lorisleiva). Su particularidad: usa **HasMany** (`InvSeccion4Uso`) en vez de HasOne, porque un inmueble puede tener múltiples usos funcionales con porcentajes.

### Solución: `Seccion4Service` con override de `persist()`

```php
class Seccion4Service extends BaseSectionService
{
    protected string $relation = 'seccion4';

    private const CATALOG_SECTION_CODE = '4';
    // ...
}
```

**Diferencia clave con las demás secciones:** El `persist()` override implementa lógica HasMany:
1. **Eliminar** usos desmarcados (`whereNotIn('tipo_uso_id', $nuevosIds)->delete()`)
2. **Crear o actualizar** cada uso seleccionado (`updateOrCreate` por `tipo_uso_id`)
3. Marcar sección completada y retornar modelo con relación cargada

### Catálogo Existente

Los 8 tipos de uso ya están en la tabla unificada `f_inventario_items_catalogo` con `section_code = '4'`. La referencia al catálogo se hace mediante una constante `CATALOG_SECTION_CODE = '4'` en el servicio, eliminando la dependencia al enum `InventarioSectionCode`.

### Contrato JSON

```json
// Request: PUT /fichas-inventario/{inventario}/seccion-4
{
  "usos": [
    { "tipo_uso_id": 1, "porcentaje_original": 80, "porcentaje_actual": 60 },
    { "tipo_uso_id": 4, "porcentaje_original": null, "porcentaje_actual": 30 }
  ]
}

// Response: 200 OK
{
  "id": 1,
  "f_inventario_id": 7,
  "usosFuncionales": [...]
}
```

### Validación Custom

- Al menos un uso debe tener `porcentaje_actual` (via `prepareData()` con `ValidationException`)
- `tipo_uso_id` validado contra `f_inventario_items_catalogo` con `section_code = '4'`
- Porcentajes entre 1 y 100

### Limpieza de Factory

Se eliminó `'usosFuncionales' => 'Seccion4Service'` de `SPECIAL_RELATIONS`. Ahora `'seccion4'` sigue la convención `studly()` → `Seccion4Service` automáticamente.

---

## 11. Eliminación de `SPECIAL_RELATIONS` del Factory

### Problema

`SectionServiceFactory` tenía un `SPECIAL_RELATIONS` que mapeaba nombres de relación no convencionales a sus services:

```php
private const SPECIAL_RELATIONS = [
    'usosFuncionales' => 'Seccion4Service',
    'anexos' => 'SeccionAnexosService',
];
```

Esto violaba la convención `studly()` y creaba acoplamiento innecesario.

### Solución

1. **S4:** `'seccion4'` → `studly()` → `Seccion4Service` ✅ (ya seguía la convención)
2. **Anexos:** Se renombró la relación de `'anexos'` a `'seccionAnexos'` en la ruta:
   ```
   'anexos'       → studly() → 'Anexos'       → ❌ AnexosService
   'seccionAnexos' → studly() → 'SeccionAnexos' → ✅ SeccionAnexosService
   ```

### Factory Final

```php
class SectionServiceFactory
{
    private const NAMESPACE = 'App\\Services\\Inventario\\';

    public static function make(string $relation): BaseSectionService
    {
        $className = str($relation)->studly()->toString().'Service';
        $fullClassName = self::NAMESPACE.$className;

        if (! class_exists($fullClassName)) {
            throw new InvalidArgumentException(
                "Servicio para la relación '{$relation}' no encontrado."
            );
        }

        return app($fullClassName);
    }
}
```

Cero `SPECIAL_RELATIONS`. Toda sección resolve por convención.

---

## 12. Pendiente para Futuro

- **Sección 18 (Planos Técnicos):** Excluida de este refactor por su complejidad (dos colecciones media + `section_code` mapping + `rulesForReview()` para completitud condicional)
- **Comando `media:prune-temporary`:** Creado pero no registrado en el scheduler. Pendiente de configurar en producción
- **Secciones 0, 16, 16-1, 17, 19:** Secciones sin media que aún usan Actions legacy. Pendientes de migración futura
