# Salkantay API

## Template de endpoints (CQRS)

Este proyecto usa arquitectura CQRS estricta para crear módulos. El template completo con todos los patrones, código base, convenciones y checklists está en `.claude/endpoint-template.md` (no versionado en git).

**Cuándo cargarlo automáticamente:** Leer `.claude/endpoint-template.md` antes de escribir cualquier archivo cuando el usuario pida:
- Crear un módulo nuevo o endpoint
- Crear cualquier capa: Controllers, DTOs, Domain, Application, Infrastructure, Tests
- Crear cualquier componente de la arquitectura: Aggregate, Criteria, Handler, Inserter, Updater, Deleter, Lister, Repository, Mother, etc.
- Preguntas sobre convenciones, patrones o estructura del proyecto

**Regla:** El template manda. Nunca crear archivos que no estén documentados en el template.

---

## División de trabajo (pruebas)

**El usuario levanta XAMPP y corre las pruebas manuales/frontend él mismo** (ej. flujo Tarifarios: grid + cascada + updateByCategoryRate). Claude NO levanta el server local ni prueba endpoints con curl salvo que el usuario lo pida explícito. Claude sí corre los tests de PHPUnit.

---

## Módulos implementados

Actualizar esta tabla al final de cada sesión cuando se complete un módulo o capa nueva.

**`HistoryTables` ↔ release:** las constantes de `app/Http/History/HistoryTables.php` son la verdad; los `INSERT INTO t_history_table` del release deben seguirlas. En el 126 estaban desfasados (237–242 corridos un lugar: `t_currency_exchange` figuraba 237 y las Feature/TourCollection una menos c/u) → corregido. Al agregar un módulo, chequear que el id del INSERT == la constante. Pendiente: `ROOM_GROUP = 244` no tiene INSERT en ningún release.

| Módulo | Tabla | Controllers | Module | Notas |
|---|---|---|---|---|
| ProviderLanguage | `t_provider_language` | ✓ completo | ✓ completo | Módulo de referencia. Routes + Tests (5) incluidos. |
| CurrencyExchange | `t_currency_exchange` | ✓ completo | ✓ completo | TOP-LEVEL. Dos FKs a misma tabla (IdCurrency From/To). DECIMAL→NumericValueObject. Filtros opcionales en List con append(). Validación de par duplicado (igual o invertido From↔To, solo status≠0) en Insert/Update vía criteria `DuplicatedCurrencyExchange` + excepción 409 `CurrencyExchangeDuplicated`. |
| TypeFeature | `t_type_feature` | ✓ completo | ✓ completo | TOP-LEVEL. Sin FK. `ListTypeFeatureQuery`/`ListTypeFeature`/`TypeFeatureLister` extendidos a mano con filtro opcional `?array $ids` (patrón Currency) para soportar populate belongs-to desde otros módulos (ej. Feature). Generado con `tools/module-generator`. Routes + Tests (8) incluidos. |
| Feature | `t_feature` | ✓ completo | ✓ completo | TOP-LEVEL (FK `Id_TypeFeature` opcional en List). Boolean `Feature_IsExpirable`. Populate `TypeFeature` en List/Index usa `ListTypeFeatureQuery(ids: ...)` (ver nota TypeFeature). `ListFeatureQuery`/`ListFeature`/`FeatureLister` también extendidos con filtro opcional `?array $ids` para que módulos futuros (ProviderFeature, TourRequiredFeature) puedan poblar Feature por ids. **`ListFeatureQuery` `page`/`pageSize` ahora `?int=null`** y `ListFeature` saltea limit/offset con `if(isPaginated())` → populate por ids trae todo sin cap (reemplaza hack `pageSize:9999`). Generado con `tools/module-generator`. Routes + Tests (8) incluidos. |
| Language | `t_language` | — solo lectura | ✓ read-only | **Catálogo solo lectura** (sin Date/DateUpdate → sin Insert/Update/Delete/History). Construido a mano (el generador asume auditoría). Domain (aggregate+VOs+`IndexLanguage`/`ListLanguage`)+Infra+`ListLanguageQuery(action, ?array ids)` unpaginated+`IndexLanguageQuery`+Response(s). **Sin rutas HTTP propias** (las legacy viven en `routes/owner/language.php` con controller monolítico `Owner\Language`). Binding `LanguageRepository`→`Mysql` agregado en `routes/owner/language.php`. Existe para populate `Language` (belongs-to) desde ProviderLanguage. Prop `default`→attribute/prop `languageDefault` (evita keyword). |
| ProviderFeature | `t_provider_feature` | ✓ completo | ✓ completo | HIJO de Provider (`--child`, ruta anidada `/provider/{Id_Provider}/providerFeature`). 2 FKs: `Id_Provider` (padre) + `Id_Feature` (catálogo). Populate `Provider` y `Feature`. **DATETIME de negocio `DateStart`/`DateEnd`**: el generador los saca como `int` (solo mapea `DATE`, no `DATETIME`) → parcheados a mano a `DateTimeValueObject` + regla DTO `date_format:Y-m-d H:i:s`. `DateEnd` opcional en request pero **regla de negocio en `InsertProviderFeatureHandler`**: carga Feature vía `IndexFeatureQuery`; si `Feature_IsExpirable==2` y falta DateEnd → excepción 422 `ProviderFeatureDateEndRequired`; si no expirable y falta → `DateEnd=DateStart`. Regla solo en Insert (Update deja DateEnd opcional). Generado con `tools/module-generator`. Routes + Tests (8) incluidos. |
| TourCollection | `t_tour_collection` | ✓ completo | ✓ completo | TOP-LEVEL. Sin FK. Catálogo de colecciones de tour (columnas dinámicas del grid Tarifarios). Generado con `tools/module-generator`. `ListTourCollectionQuery`/`Lister`/`ListTourCollection` extendidos a mano con filtro opcional `?array $ids` (patrón Currency) para populate belongs-to desde CategoryProviderRate. `HistoryTables::TOUR_COLLECTION` ya existía (241). Routes + Tests (8) incluidos. `TSTourCollectionMother::random()`/`pushRandom()` aceptan `?TourCollectionStatus $status` (sin él el status sale random entre INACTIVE/ACTIVE y volaba tests que dependen de ACTIVE, ej. columnas del grid). |
| CategoryProviderRate | `t_category_provider_rate` | ✓ completo | ✓ completo | TOP-LEVEL. Tarifa por categoría de proveedor × collection (módulo Tarifarios, Camino B). 3 FKs con populate: `Id_CategoryProvider`, `Id_TourCollection`, `Id_Currency`. `Id_DefinedProvision`/`Id_TypeRoomCategory` = **int plano sin FK** (mutuamente excluyentes, 0 si no aplica; manejado en PHP). DECIMAL→NumericValueObject; DATETIME `DateStart`/`DateEnd`→DateTimeValueObject (generador ya lo hace). **Fix populate:** `getCategoryProvider` en List+Index parcheado a `name:''`+`idTypeProvider:0` (ListCategoryProviderQuery exige esos, no es catálogo simple). `TableCreation::CATEGORY_PROVIDER` agregado (faltaba). `HistoryTables::CATEGORY_PROVIDER_RATE=245`. **Sin regla de duplicado** (decisión: front resuelve por año). **Endpoint extra `GET /categoryProviderRate/grid`** (pivote Tarifarios): `CategoryProviderRateGridController` + `CategoryProviderRateGridReader` (raw SQL, self-contained, no toca módulo PTP) + `CategoryProviderRateGridDTO`. Generado con `tools/module-generator`. Routes + Tests (8 CRUD + 1 populate + 1 grid = 10) incluidos. |
| Tour (Id_TourCollection) | `t_tour` | ✓ 1 endpoint | ✓ extensión | **Extensión del módulo legacy Tour**, no módulo nuevo. `Id_TourCollection` agregado al aggregate `Tour` (attribute + ctor + getter + `setIdTourCollection`), al final de `attributes()` y del ctor — **el orden lista==ctor manda** porque `AggregateRoot::fromDTO()` hace `new static(...$data)` posicional. Los 2 únicos `new Tour(`: `TourInserterQueryHandler` (`new IdTourCollection(0)`) y `TourUpdaterQueryHandler` (`$index->idTourCollection()`, conserva). `Tour` no usa `toDTO($dtoClass)`, por eso el attribute extra no rompe Responses (`ListTourResponse`/`FindTourResponse` se arman a mano y NO exponen `Id_TourCollection`). **Endpoint `PUT /tour/{Id_Tour}/tourCollection`** body `Id_TourCollection`: copia del flujo `UpdateTourTemplate` → `Application/UpdateTourCollection/` (Query+Handler+`TourTourCollectionUpdater`+Response, vía **QueryBus** como el resto de Tour legacy) + `Owner\Tour\TourCollectionUpdateController` + `Dto/TourCollectionUpdateDTO` + `ResponsesTour`. Diferencia vs TourTemplate: **valida existencia** con `IndexTourCollectionQuery` si id≠0 (404 `TourCollectionNotFound`); `0` desasigna. History con `HistoryTables::TOUR_COLLECTION`. Tests (4) en `tests/Routes/Owner/Tour/`. |
| **Tarifarios** (CategoryProviderRate + grid + Provision/ProvisionRate + cascada) | `t_category_provider_rate`, `t_provision`, `t_provision_rate` | ✓ completo | ✓ completo | **La definición vigente vive en `.claude/tarifarios.md`** — modelo de celda, reglas de herencia, los 4 caminos de escritura, contrato del grid, comandos de consola y trampas conocidas. Resumen: el tarifario es una grilla de guías × colecciones de tour; una **celda** = guía × colección × `Id_DefinedProvision` × período exacto, y detrás tiene **N tarifas, una por tour activo** de la colección. `GET /owner/categoryProviderRate/grid` (`Id_DefinedProvision` required) devuelve `columns` + `categories[]` (plantilla) + `rows[]` (guías); cada celda **agrupa** sus N tarifas por `PriceConfidential` + `IsInherited` (`groups[]` + `tours_total`/`tours_with_rate`/`tours_without_rate`) en vez de elegir una. Escrituras: `PUT /provisionRate` (1 fila, rompe herencia si cambia `PriceConfidential`/`Id_Currency`/`DateStart`/`DateEnd` contra lo guardado), `PUT /provisionRate/updateByCategoryRate` (celda, precio del body, deja custom), `PUT /provisionRate/resetByCategoryRate` (celda, precio de la plantilla, vuelve a heredar — único camino de vuelta) y la cascada del `POST`/`PUT /categoryProviderRate` (todos los guías de la categoría, respeta las custom). Todas pasan por `ProvisionRateInheritedSyncer` (`CategoryProviderRate\Application\Sync`), orquestador sin SQL que delega en los repos de cada módulo (`TourCollectionToursRepository`, `ProviderTypeProviderCategoryRepository`, `ProvisionBulkRepository`, `ProvisionRateBulkRepository`, `ProvisionRateDuplicateRepository`, `CategoryProviderRateSyncTargetsRepository`; bindings en `AppServiceProvider`). Las cuatro escrituras responden el mismo informe `sync` (mode/scope+`completo`/actions con `overwritten_custom`/`warnings`/elapsed_ms/sample con `previous`), armado en `SyncResult::toResponseExtra()`. El detalle fila por fila queda en `rows()`, no viaja por HTTP. Comandos: `consolidate:provisionRates` + `resync:categoryProviderRates`. ⚠ **Trampa crítica**: un `IN` con ≥1000 placeholders devuelve **cero filas sin error** en MariaDB 10.4 con prepared statements nativos → el código lee "no existe" y duplica; todo `IN` masivo va por `Shared\Infraestructure\SqlInChunk` (bloques de 500). Planes históricos (superados): `.claude/plan-provisionrate-isinherited.md`, `.claude/plan-provision-materialize.md`. |
