# Plan de implementación — Backend EggChecker de FastAPI (Python) a API REST en PHP vanilla

- **Proyecto:** EggChecker
- **Repo de referencia (Python/FastAPI):** https://github.com/miguejpaezb/eggchecker.git
- **Destino:** `C:\server\www\EggChecker-PHP\app\backend\`
- **URLs objetivo:**
  - Local: `http://localhost/EggChecker-PHP/api/...`
  - Producción: `https://app.eggchecker.click/api/...`
- **Base de datos:** MySQL 8 (dev y prod), `utf8mb4`
- **Alcance:** todos los módulos (59 endpoints), autenticación JWT Bearer + bcrypt, copia fiel de respuestas.
- **Fecha del plan:** 2026-10-16

---

## 1. Decisiones confirmadas

| Tema | Decisión |
|---|---|
| Framework | PHP 8.5 vanilla, **0 dependencias** (sin Composer) |
| Autenticación | JWT Bearer HS256 (`sub` + `exp`), bcrypt (`password_hash` / `password_verify`) |
| Base de datos | MySQL 8 en dev y prod, acceso con PDO (`pdo_mysql`) |
| Generación de PDF | **FPDF vendorizado** (1 archivo, sin Composer), reproduciendo la maquetación de ReportLab |
| Scripts CLI | `tools/seed.php` (carga esquema/seed) + `tools/smoke.php` (verificación end-to-end) |
| Errores 422 | **Forma compatible** FastAPI `{"detail":[{type,loc,msg,input}]}` incluyendo el nombre del campo |
| Docroot producción | Subdominio en hosting compartido Apache. `.htaccess` con variante `public_html` (`../api/index.php`) y variante raíz (`api/index.php`) |

### Entorno local verificado
- PHP 8.5.7 (CLI), extensiones: `pdo_mysql`, `pdo_sqlite`, `openssl`, `mbstring`, `fileinfo`, `gd`, `curl`, `sodium`, `json`.
- Apache 2.4 con `mod_rewrite` habilitado y `AllowOverride All`, `DocumentRoot = C:/server/www`.
- MySQL en ejecución. **Composer NO instalado** → nada de dependencias gestionadas.

---

## 2. Arquitectura (espejo de la capa Python)

Python: `routers (api/*.py) → services (*_service.py) → schemas (Pydantic) → models (SQLAlchemy)`.

PHP: `Controllers (api) → Services (reglas de negocio) → Validator (schemas) → Repositories (SQL) → Core`.

```
Cliente (React / móvil)
        │  HTTP JSON + JWT
        ▼
┌────────────────────────────────────────────┐
│  PHP vanilla (/api)                        │
│   public/index.php   → front controller    │
│   routes.php         → tabla de rutas      │
│   Controllers/       → rutas HTTP          │
│   Services/          → reglas de negocio   │
│   Validator/         → validación (422)    │
│   Repositories/      → SQL (PDO)           │
│   Core/              → JWT, DB, Router...  │
└────────────────────────────────────────────┘
        ▼
   MySQL 8 (dev y prod)
```

---

## 3. Estructura de archivos propuesta

```
app/backend/
  .env                              # credenciales MySQL + JWT + IA (NO versionado)
  .env.example                      # plantilla
  README.md                         # cómo levantar y desplegar
  .htaccess                         # raíz: niega acceso a src/, db/, uploads/, .env
  public/
    index.php                       # front controller (bootstrap + CORS + routing)
    .htaccess                       # rewrite todo -> index.php
  src/
    Core/
      Config.php                    # carga .env -> getenv
      Database.php                  # PDO singleton / get_db()
      Request.php                   # body JSON, query, headers, multipart
      Response.php                  # json(), error(), status()
      Router.php                    # match método+path, {param}, orden de declaración
      Validator.php                 # validación de esquemas -> 422 estilo FastAPI
      Jwt.php                       # encode/decode HS256 (compatible con python-jose)
      Security.php                  # hash/verificar password, tokens recuperación
      Plans.php                     # planes gratuito/premium + validar_limite
      HttpException.php             # status + detail
    Middleware/
      Auth.php                      # get_current_usuario (Bearer JWT)
      Cors.php                      # Access-Control-* configurable
    Controllers/
      HealthController.php
      AuthController.php
      UsuarioController.php
      CamadaController.php
      CategoriaInsumoController.php
      InsumoController.php
      ProduccionController.php
      ClienteController.php
      VentaController.php
      ReporteController.php
      NotificacionController.php
      DashboardController.php
      AnalisisIaController.php
    Services/
      AuthService.php
      CamadaService.php
      CategoriaService.php
      InsumoService.php
      StockService.php
      ProduccionService.php
      ClienteService.php
      VentaService.php
      ReporteService.php
      ReportePdf.php                # FPDF
      NotificacionService.php
      DashboardService.php
      AnalisisIaService.php
      IaProveedor.php               # Gemini + Demo
      RecomendacionesIa.php
    Repositories/                   # acceso SQL por tabla
      UsuarioRepository.php
      CamadaRepository.php
      InsumoRepository.php
      ProduccionRepository.php
      ClienteRepository.php
      PedidoRepository.php
      NotificacionRepository.php
      AnalisisIaRepository.php
      CatalogoRepository.php        # tipo_huevo, categoria_insumo
    Models/
      Constantes.php                # EDAD_*, HUEVOS_POR_CUBETA, TIPO_HUEVO_NO_VENDIBLE...
    routes.php                      # [method, path, handler, requiereAuth]
  db/
    eggchecker_schema.sql           # DDL MySQL: crea BD + 16 tablas + 3 vistas + catálogos
    eggchecker_seed.sql             # datos de prueba completos (equivalente al seed Python)
    usuario_prueba.sql              # usuario(s) de prueba con bcrypt real + datos mínimos
  tools/
    seed.php                        # CLI: aplica schema/seed o datos de prueba
    smoke.php                       # CLI: prueba end-to-end por módulo
  vendor/
    fpdf/fpdf.php                   # FPDF (archivo único)
  uploads/
    analisis/                       # fotos IA (denegado por .htaccess)
```

---

## 4. Contratos transversales (se codifican una sola vez)

### 4.1 Autenticación JWT
- Algoritmo **HS256**, firmado con `JWT_SECRET_KEY`.
- Payload: `{ "sub": "<id_usuario como string>", "exp": <timestamp UTC> }`.
- Expiración: `JWT_EXPIRE_MINUTES = 129600` (90 días).
- Firma: `base64url(header).base64url(payload).base64url(hash_hmac('sha256', "$h.$p", $secret, true))`.
- Compatible con `python-jose`: mismos claims y formato estándar.
- Verificación: firma + `exp`; si falla → `401 {"detail":"Token inválido o expirado"}`.
- Sin header `Authorization: Bearer <token>` → `401 {"detail":"No autenticado"}`.
- Usuario inexistente o `activo = false` → `401 {"detail":"No autenticado"}`.

### 4.2 Contraseñas
- `password_hash($p, PASSWORD_BCRYPT)` (produce `$2y$`).
- `password_verify()` acepta hashes `$2b$`, `$2a$`, `$2y$` → verifica hashes generados por Python/passlib.
- Tokens de recuperación: token opaco `bin2hex(random_bytes(32))`; se guarda **solo** su SHA-256 (64 hex).

### 4.3 Forma de las respuestas y errores
- HTTPException → `{"detail": "<mensaje>"}` con el status correcto.
- 422 → `{"detail": [ { "type": "...", "loc": ["body","campo"], "msg": "...", "input": <valor> } ]}`.
- 204 → sin cuerpo.
- CORS: `Access-Control-Allow-Origin` (lista en `.env`), `Allow-Credentials: true`, `Allow-Methods: *`, `Allow-Headers: *`; responde `OPTIONS` (preflight) con 200.

### 4.4 Serialización (fidelidad Pydantic)
- `DECIMAL(x,y)` → **string** `"12000.00"` (PDO ya lo devuelve como string).
- `DATE` → `"YYYY-MM-DD"`.
- `DATETIME` → ISO `"YYYY-MM-DDTHH:MM:SS"` (convertir el espacio a `T`).
- `BOOL` (tinyint 1/0) → `true`/`false`.
- Enteros explícitos donde Python devuelve `int` (p. ej. `cantidad_actual`, `total_disponible`).
- Orden de claves igual al de los `*Response` de Python (no es semánticamente obligatorio, pero se mantiene).

### 4.5 Aislamiento multi-tenant
- Toda consulta filtra por `id_usuario` del token.
- Recurso de otro usuario o inexistente → **404** (nunca 403).

### 4.6 Planes (`Plans.php`)
| Plan | `aves_max` | `clientes_max` | `ia_incluida` |
|---|---|---|---|
| `gratuito` | 300 | 10 | false |
| `premium` | null (ilimitado) | null (ilimitado) | true |

`validar_limite(actual, nuevo, maximo)`: `true` si `maximo is None` o `actual + nuevo <= maximo`.

### 4.7 `.env` (plantilla)
```dotenv
APP_ENV=local
APP_URL=http://localhost/EggChecker-PHP
ALLOWED_ORIGINS=http://localhost:5173

# MySQL (poner aquí nombre y credenciales de la base de datos)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=eggchecker
DB_USER=root
DB_PASS=
DB_CHARSET=utf8mb4

# JWT
JWT_SECRET_KEY=cambiar-esta-llave-en-produccion
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=129600

# Recuperación de contraseña
RECOVERY_TOKEN_EXPIRE_MINUTES=30
FRONTEND_URL=http://localhost:5173

# IA (Gemini)
GEMINI_API_KEY=
GEMINI_MODEL=gemini-3.5-flash-lite
GEMINI_MODEL_RESPALDO=gemini-3.1-flash-lite
IA_TIMEOUT_SEGUNDOS=45
IA_UPLOAD_DIR=uploads/analisis
IA_MAX_MB=8
IA_LIMITE_DIARIO=20
```

---

## 5. Archivos `.htaccess`

### 5.1 Local — `EggChecker-PHP/.htaccess`
```apache
RewriteEngine On
# /api/... -> front controller del backend
RewriteRule ^api/(.*)$ app/backend/public/index.php [QSA,L]
```

### 5.2 Local — `app/backend/public/.htaccess`
```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
```

### 5.3 Local — `app/backend/.htaccess` (protección de código y secretos)
```apache
# Denegar acceso directo a código, secretos, datos y uploads
RewriteRule ^(src|db|tools|vendor|uploads)/ - [F,L]
<FilesMatch "^\.env">
  Require all denied
</FilesMatch>
```

### 5.4 Producción — docroot del subdominio (variante A: docroot = `public_html`)
```apache
RewriteEngine On
# https://app.eggchecker.click/api/... -> backend ubicado en api/ hermano de public_html
RewriteRule ^api/(.*)$ ../api/index.php [QSA,L]
```
### 5.5 Producción (variante B: docroot = raíz del subdominio)
```apache
RewriteEngine On
# Si el docroot ES la raíz de app.eggchecker.click/
RewriteRule ^api/(.*)$ api/index.php [QSA,L]
```
> Se confirmará el nombre real del docroot al desplegar. Ambas variantes quedan comentadas en el archivo final.

### Layout de despliegue en producción (propuesto)
```
/home/usuario/app.eggchecker.click/
  api/                 # contenido de app/backend/public/ (index.php + .htaccess)
  backend/             # src/, vendor/, db/, uploads/, .env (fuera del docroot)
  public_html/         # docroot del subdominio
    .htaccess          # reescribe ^api/(.*) -> ../api/index.php
    ...                # frontend
```

---

## 6. Archivos SQL (para phpMyAdmin)

### 6.1 `db/eggchecker_schema.sql`
- `CREATE DATABASE IF NOT EXISTS eggchecker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;` + `USE eggchecker;`
- 16 tablas del modelo canónico (invertir el dialecto SQLite→MySQL): `AUTO_INCREMENT`, `TINYINT(1)`/`BOOLEAN`, `DECIMAL`, `DATE`/`DATETIME`, `ENUM` o `VARCHAR + CHECK`, `InnoDB`, `utf8mb4`, FKs con `ON DELETE`/`ON UPDATE` originales, `UNIQUE`, `CHECK` e índices.
  - `usuario`, `token_recuperacion`, `categoria_insumo`, `insumo`, `movimiento_insumo`, `camada`, `evento_sanitario`, `tipo_huevo`, `produccion_diaria`, `produccion_detalle`, `stock_produccion`, `cliente`, `pedido`, `detalle_pedido`, `analisis_ia`, `notificacion`.
- 3 vistas: `v_produccion_detallada`, `v_alertas_insumo`, `v_pedidos_pendientes` (se mantienen por fidelidad al DDL; la lógica PHP no depende de ellas).
- Inserta los catálogos fijos: `tipo_huevo` (AA, A, B, No_apto) y `categoria_insumo` (6 categorías).

### 6.2 `db/usuario_prueba.sql`
- Usuario(s) de prueba con **hash bcrypt real** de una contraseña conocida (p. ej. `Test1234!`), plan `premium` y `gratuito`.
- Datos mínimos para probar todos los endpoints: 1 categoría de insumo ya viene del esquema, 1 insumo, 1 camada, 1 cliente, 1 stock, etc.

### 6.3 `db/eggchecker_seed.sql`
- Equivalente al seed canónico de Python (10 usuarios, 12 camadas, 14 insumos, 14 movimientos, 10 eventos sanitarios, 12 producciones + 48 detalles, 32 stock, 12 clientes, 12 pedidos + 20 detalles, 10 análisis IA).
- Los `contrasena_hash` del seed de Python son de ejemplo (inválidos) → se reemplazan por hashes bcrypt reales.
- `fecha_creacion` de camadas = `fecha_ingreso 00:00:00` (fuera de la ventana de edición de 24 h).

---

## 7. Fases de implementación (una por módulo)

> Cada fase se cierra con criterios de aceptación verificables. Dependencias: Fase 0 es base de todo; Notificaciones depende de Insumos; Dashboard depende de Insumos/Ventas; Reportes depende de casi todos; IA depende de Camadas/Producción.

### Fase 0 — Núcleo / infraestructura *(base)*
- **Entregables:** `Core/*` (Config, Database, Request, Response, Router, Validator, Jwt, Security, Plans, HttpException), `Middleware/{Auth,Cors}`, `public/index.php`, `routes.php`, `.htaccess` (dev + prod), `db/eggchecker_schema.sql`, `db/usuario_prueba.sql`, `tools/seed.php`, `.env` y `.env.example`.
- **Endpoints:** `GET /api/health`.
- **Aceptación:** `http://localhost/EggChecker-PHP/api/health` → `200 {"status":"ok"}`; el `.sql` crea la BD en phpMyAdmin sin errores; login con el usuario de prueba devuelve un JWT decodificable (`sub`, `exp`).

### Fase 1 — Autenticación, Usuarios y Perfil *(7 endpoints)*
- `POST /api/auth/registro` → 201, `UsuarioResponse`; política de contraseña (8–72, sin espacios, 1 mayúscula, 1 número, 1 símbolo); email válido; plan normalizado; correo único → 409 `El correo ya está registrado`.
- `POST /api/auth/login` → 200 `{access_token, token_type:"bearer"}`; 401 `Credenciales inválidas` (correo inexistente, contraseña mala o inactivo).
- `POST /api/auth/recuperar` → 200 `{"mensaje":"Se ha enviado un enlace de recuperación a tu correo"}`; 404 `No encontramos una cuenta con ese correo. ¿Deseas registrarte?`; 500 si falla el envío; invalida tokens previos; guarda SHA-256 + expiración 30 min.
- `GET /api/usuarios/me` → `PerfilResponse` (incluye `plan`, `aves_max`, `clientes_max`, `ia_incluida`, `aves_actuales`, `clientes_actuales`, `total_huevos_producidos`, `total_aves_gestionadas`, 4 preferencias).
- `PUT /api/usuarios/me` → 200; cambio de correo exige `contrasena_actual` (401 `La contraseña actual es incorrecta`); correo duplicado → 409.
- `PUT /api/usuarios/me/notificaciones` → 200 `PerfilResponse` (4 booleanos requeridos).
- `PUT /api/usuarios/me/contrasena` → 200 `{"mensaje":"Contraseña actualizada correctamente"}`; 401 contraseña actual incorrecta.
- **Nota:** no existe endpoint de "reset/confirmar" en el backend Python; **no se implementa**.
- **Aceptación:** registro→login→`/me`; errores exactos; verificación de hash `$2b$` preexistente.

### Fase 2 — Camadas *(9 endpoints)*
- `POST /api/camadas` (201), `GET /api/camadas` (`?estado`), `GET /api/camadas/{id}` (detalle con `edad_dias`, `fecha_retiro_estimada`), `PATCH /api/camadas/{id}` (`extra=forbid`), `POST /{id}/mortalidad`, `POST /{id}/avanzar-semana`, `POST /{id}/seguir-activa`, `POST /{id}/descartar`, `GET /{id}/edad`.
- **Reglas:** `edad_semanas` inicial 16; 504 días de vida productiva; producción a las 28 semanas; decisión a las 72; ventana de edición de `cantidad_inicial` 24 h; límite de aves del plan; `requiere_decision`; ajuste `nueva_actual = cantidad_actual + (nueva_inicial − cantidad_inicial)`; mortalidad crea `evento_sanitario`.
- **Errores exactos:** `La fecha de ingreso solo puede ser hoy o ayer` (400), `Límite de aves del plan alcanzado` (400), `Solo se pueden editar camadas activas` (400), `La cantidad inicial no se puede editar: la camada se registró hace más de 24 horas` (400), `El ajuste dejaría la cantidad actual en negativo` (400), `Solo se puede registrar mortalidad en camadas activas` (400), `La mortalidad excede la cantidad actual` (400), `Solo se puede avanzar semana en camadas activas` (400), `Solo se puede continuar una camada activa` (400), `La camada no requiere decisión` (400), `La camada ya está retirada y no se puede reactivar` (400), `Camada no encontrada` (404).
- **Aceptación:** todos los `detail` y códigos; PATCH con campo prohibido → 422.

### Fase 3 — Inventario: Categorías e Insumos *(15 endpoints)*
- **Categorías (5):** `POST` (201, 409 `La categoría ya existe`), `GET` (orden por nombre), `GET /{id}`, `PATCH /{id}` (409), `DELETE /{id}` (204; 409 `No se puede eliminar: tiene insumos asociados`; 404 `Categoría no encontrada`). Catálogo global compartido.
- **Insumos (10):** `POST` (201; 404 `Categoría no encontrada`; 422 stock/umbral ≤ 0), `GET` (`?categoria`, `?activo`; excluye descontinuados; orden por nombre), `GET /alertas` (déficit desc), `GET /{id}`, `PATCH /{id}` (`extra=forbid`), `POST /{id}/suspender`, `POST /{id}/activar`, `POST /{id}/descontinuar`, `POST /{id}/movimientos` (201, `stock_resultante`), `GET /{id}/movimientos` (historial desc).
- **Reglas:** costo promedio ponderado en entradas; transacción atómica; salida > stock → 400 `Stock insuficiente para registrar la salida`; suspender/descontinuar irregulares con sus mensajes; efecto lateral de notificaciones de stock.
- **Aceptación:** salida fallida no deja movimiento ni cambia stock; alertas excluyen suspendidos.

### Fase 4 — Producción *(5 endpoints)*
- `POST /api/produccion` (201, upsert por día/camada, cubeta×30, detalle por tipo), `GET /api/produccion` (`?camada`, `?fecha`), `GET /api/produccion/resumen` (`total_hoy/semana/mes` + `por_tipo` con 4 claves), `GET /api/produccion/{id}`, `GET /api/huevos/disponibles` (producido − vendido, incluye No_apto).
- **Errores:** `Solo se puede registrar producción del día actual` (400), `La camada no está en etapa de producción` (400), `Debe registrar al menos un tipo de huevo` (400), `El total no puede superar las aves actuales de la camada` (400), `Producción no encontrada` (404), `Ya existe producción registrada para esta camada y fecha` (409).
- **Efecto:** actualiza `stock_produccion` por el delta del mismo día.
- **Aceptación:** 1+1 cubetas = 60; 4 tipos presentes siempre; repetir el día recalcula y no duplica.

### Fase 5 — Clientes *(7 endpoints)*
- `POST /api/clientes` (201; 400 `Límite de clientes del plan alcanzado`; teléfono solo dígitos), `GET /api/clientes` (`?activo=false` incluye suspendidos; orden por nombre), `GET /{id}`, `PATCH /{id}` (`extra=forbid`), `POST /{id}/suspender`, `POST /{id}/activar`, `POST /{id}/eliminar` (body `{contrasena}`; 401 `Contraseña incorrecta`; 409 `No se puede eliminar: el cliente tiene pedidos asociados`; 204).
- **Aceptación:** aislamiento por usuario (404), borrado físico solo sin pedidos.

### Fase 6 — Ventas *(8 endpoints)* + Stock
- `GET /api/ventas/stock` (excluye No_apto), `GET /api/ventas/pedidos`, `GET /api/ventas/pedidos/{id}`, `POST /api/ventas/pedidos` (201), `PATCH /api/ventas/pedidos/{id}`, `PATCH /api/ventas/pedidos/{id}/estado`, `POST /api/ventas/pedidos/{id}/cancelar`, `POST /api/ventas/pedidos/{id}/eliminar` (204).
- **Estados:** `pendiente`, `enviado`, `recibido`, `cancelado`. Transiciones: `pendiente→enviado`, `pendiente→recibido`, `enviado→recibido`; `cancelado` solo desde `pendiente`/`enviado`; `recibido`/`cancelado` terminales.
- **Errores:** `El cliente está suspendido; actívalo para registrar la venta` (400), `No se puede repetir el mismo tipo de huevo en un pedido` (400), `Tipo de huevo no encontrado` (404), `Los huevos No_apto no se pueden vender` (400), `Stock insuficiente de {tipo}: disponible {n}` (400), `No se pudo registrar el pedido` (409), `Solo se pueden editar pedidos pendientes` (400), `No se puede pasar el pedido a '{estado}'` (400), `Solo se pueden cancelar pedidos pendientes o en camino` (400), `No se puede eliminar un pedido en este estado` (400), `Contraseña incorrecta` (401).
- **Efectos:** descuenta/repone stock, fija `valor_unidad = precio_unitario`, actualiza `fecha_ultima_compra`.
- **Aceptación:** ciclo completo crea/edita/cancela/elimina con stock coherente.

### Fase 7 — Notificaciones *(4 endpoints)*
- `GET /api/notificaciones` (genera avisos perezosos; no leídas primero, más recientes primero), `POST /api/notificaciones/leer-todas` (204), `POST /api/notificaciones/{id}/leer` (200; 404 `Notificación no encontrada`), `DELETE /api/notificaciones/{id}` (204, soft-delete `eliminada=1`).
- **Generación:** camadas activas ≥ 72 semanas → tipo `camada_limite`, título `Camada al límite`, mensaje `Camada {nombre} ha pasado las 72 semanas`. Insumos → `sin_stock` / `stock_bajo` / `stock_minimo` con textos y severidad definidos; no recrear si el usuario la eliminó sin cambio de stock; cerrar al reabastecer/suspender/descontinuar.
- **Aceptación:** no duplica; eliminada no reaparece; aviso por camada al límite.

### Fase 8 — Dashboard *(1 endpoint)*
- `GET /api/dashboard` → `produccion_hoy`, `variacion_produccion` (null si ayer=0), `aves_activas`, `pedidos_pendientes` (pendiente+enviado), `alertas_count` + `alertas[]`, `semana{serie(7, etiquetas Lun..Dom), total_huevos, mejor_dia, valor_producido, tasa_postura, mortalidad}`, `pedidos_recientes` (top 3, `descripcion` "10 AA").
- **Aceptación:** con seed, Decimal-string y ceros correctos; `tasa_postura = total/(aves×7)×100`.

### Fase 9 — Reportes *(2 endpoints)* + PDF
- `GET /api/reportes/consolidado` → `periodo`, `produccion` (total, cubetas, no aptos, promedio diario, por tipo con 4, cubetas por tipo sin No_apto, por camada, serie), `ventas` (ingreso, unidades, pedidos, ticket, por tipo, top 5 clientes, serie), `gastos` (total, por insumo), `ganancia` (valor, porcentaje), `salud`, `alimento`, `inventario`, `camada` (opcional).
- `GET /api/reportes/pdf` → FPDF; `Content-Type: application/pdf`; `Content-Disposition: attachment; filename="reporte_rentabilidad_{desde}_{hasta}[_{camada}].pdf"`; bytes inician con `%PDF`.
- **Reglas:** `desde > hasta` → 400 `La fecha inicial no puede ser posterior a la final`; camada ajena → 404 `Camada no encontrada`; `agrupacion` inválida → 422; redondeo `ROUND_HALF_UP` a 0.01; Decimal-string.
- **Aceptación:** totales y series idénticos al Python; PDF abre y descarga con nombre correcto.

### Fase 10 — Análisis IA *(7 endpoints)* + Gemini
- `GET /api/analisis-ia/estado` (único accesible sin Premium), `POST /api/analisis-ia` (multipart `imagen` + `id_camada`), `GET /api/analisis-ia` (`?id_camada`, `?limite` 1–200), `GET /api/analisis-ia/{id}`, `GET /api/analisis-ia/{id}/imagen` (autenticado + anti path-traversal), `PATCH /api/analisis-ia/{id}/retroalimentacion`, `DELETE /api/analisis-ia/{id}` (204 + unlink).
- **Imagen:** validación por firma binaria JPEG/PNG/WEBP (no por MIME declarado); 400 vacía / 413 > `IA_MAX_MB` / 415 formato; guarda en `IA_UPLOAD_DIR/u{id}/{uuid}.{ext}`; borra archivo si falla el commit.
- **IA:** Gemini `generateContent` con imagen inline base64; modelo principal + respaldo; timeout `IA_TIMEOUT_SEGUNDOS`; sin `GEMINI_API_KEY` → **modo demo** con el mismo diagnóstico de ejemplo (12 huevos, calidad `regular`, puntaje 72, anomalías suciedad/grieta, distribución `{AA:6,A:3,B:2,No_apto:1}`, confianza 0.75).
- **Recomendaciones** con contexto de camada/postura (7 días).
- **Errores:** 403 `El análisis de huevos con IA está disponible en el plan Premium.`, 429 `Alcanzaste el límite de {n} análisis por día. Intenta de nuevo mañana.`, 422 foto sin huevos, 503 mensajes de proveedor, 404 `Análisis no encontrado` / `Imagen no disponible`.
- **Aceptación:** modo demo idéntico; plan gratuito → 403; límite → 429; imagen ajena → 404.

### Fase 11 — Cierre, seed completo y despliegue
- `db/eggchecker_seed.sql` completo con hashes bcrypt reales.
- `tools/smoke.php`: flujo end-to-end (Auth→Camada→Producción→Venta→Reporte→IA) validando status y claves contra los valores esperados de los tests Python.
- `.htaccess` de producción + `README.md` de despliegue.
- Checklist de paridad de los 59 endpoints y de los códigos 400/401/403/404/409/413/415/422/429/500.
- **Aceptación:** smoke test verde en todos los módulos; checklist completo.

---

## 8. Inventario de endpoints por módulo (59)

| # | Módulo | Endpoints |
|---|---|---|
| 1 | Health | 1 |
| 2 | Auth | 3 |
| 3 | Usuarios/Perfil | 4 (me GET/PUT, me/notificaciones, me/contrasena) |
| 4 | Camadas | 9 |
| 5 | Inventario (categorías + insumos) | 15 |
| 6 | Producción | 5 |
| 7 | Clientes | 7 |
| 8 | Ventas | 8 |
| 9 | Reportes | 2 |
| 10 | Notificaciones | 4 |
| 11 | Dashboard | 1 |
| | **Total** | **59** |

---

## 9. Estrategia de verificación

1. **`tools/smoke.php`** (por módulo): arranca sesión, recorre el flujo principal y comprueba status + claves/valores esperados (derivados de los tests Python).
2. **Pruebas manuales** con `curl`/Postman sobre `http://localhost/EggChecker-PHP/api/...`.
3. **Checklist de paridad**: por cada endpoint, comparar contra la respuesta de la versión Python (mismo JSON, mismos `detail`, mismos códigos).
4. **Compatibilidad**: verificar login con un hash `$2b$` generado en Python y consumo del backend PHP por el frontend React.

---

## 10. Riesgos y puntos abiertos

| Riesgo | Mitigación |
|---|---|
| Paridad exacta de mensajes 422 (Pydantic) | Forma compatible `{"detail":[...]}` con `loc`/`msg`/campo; los tests solo verifican status 422 |
| PDF: FPDF ≠ ReportLab pixel-perfect | Reproducir secciones, tablas y colores; el PDF es válido aunque el layout no sea idéntico al píxel |
| `.htaccess` de producción | Depende del nombre real del docroot; se dejan ambas variantes comentadas |
| Email de recuperación real | Python lo tiene como mock no-op; se replica igual (no se envía correo) |
| Hashes del seed canónico inválidos | Se reemplazan por hashes bcrypt reales de una contraseña conocida |

| Punto abierto | Estado |
|---|---|
| Nombre real del docroot en producción (`public_html` vs raíz del subdominio) | A confirmar al desplegar |
| ¿Incluir `/docs` Swagger? | No solicitado; fuera de alcance por ahora |
| Origen CORS en producción | Configurable en `ALLOWED_ORIGINS` del `.env` |
```
