# SPEC — Órdenes de Compra V2 (`PurchaseOrder` V2)

**Estado:** Borrador para análisis y aprobación
**Autor:** Miguel Ayala · **Fecha:** 2026-08-27
**Módulo:** Importaciones → Órdenes de Compra
**Diseño base:** Claude Design — `Ordenes de Compra - Propuestas.dc.html`, artboards **1a** (vista principal) y **1c** (diligenciamiento con lectura de documento)
**Componente actual:** [PurchaseOrder.php](../app/Http/Livewire/Importaciones/PurchaseOrder.php)

---

## 0. Resumen ejecutivo

Se propone una versión 2 del módulo de Órdenes de Compra, en dos etapas independientes y desplegables por separado:

| Etapa | Alcance | Artboard |
|---|---|---|
| **1** | Vista principal: embudo de ciclo de vida en 4 fases + tabla densa operativa con avance por orden | `1a` |
| **2** | Formulario de creación/edición con **carga de PDF y extracción asistida de datos**, visor lado a lado y cotejo campo por campo | `1c` |

La V2 **convive** con la V1 (no la reemplaza en el primer despliegue). Se habilita por menú a un grupo empresarial piloto, se valida en producción y luego se retira la V1.

**Dos hallazgos que condicionan el plan** (detalle en §7):

1. El SDK oficial `anthropic-ai/sdk` exige `php ^8.1`; el proyecto corre **PHP 7.4** → la integración de extracción se hace por **HTTP crudo con `Http::` (Guzzle)**, no por SDK.
2. Los campos que el diseño 1a muestra como columnas (`Ruta`, `Incoterm`, ETA) **hoy están vacíos o en `0`** en la mayoría de las órdenes. La V2 los expone y el formulario V2 + la extracción de PDF son precisamente lo que los llena hacia adelante.

---

## 1. Análisis del módulo actual

### 1.1 Piezas existentes

| Archivo | Líneas | Rol |
|---|---|---|
| [PurchaseOrder.php](../app/Http/Livewire/Importaciones/PurchaseOrder.php) | 221 | Listado, cálculo de status, anulación |
| [purchase-order.blade.php](../resources/views/livewire/importaciones/purchase-order.blade.php) | 856 | Vista del listado (dos tablas casi idénticas: Abiertas / Cerradas) |
| [PurchaseOrderForm.php](../app/Http/Livewire/Importaciones/PurchaseOrderForm.php) | 123 | Cabecera de la orden (7 campos) |
| [PurchaseOrderProductForm.php](../app/Http/Livewire/Importaciones/PurchaseOrderProductForm.php) | 324 | Líneas de producto + consulta de RIM |
| [PurchaseOrderStatus.php](../app/Http/Livewire/Importaciones/PurchaseOrderStatus.php) | 113 | Panel de ejecución (operaciones, facturado) |
| [PurchaseOrderStats.php](../app/Http/Livewire/Importaciones/PurchaseOrderStats.php) | 124 | Top productos / exportadores / órdenes |
| [PurchaseOrderMassImport.php](../app/Http/Livewire/Importaciones/PurchaseOrderMassImport.php) | 101 | Importación masiva desde Excel |

**Rutas** ([web.php:887-891](../routes/web.php#L887-L891)):

```
GET /impo/order              → purchase-iframe   (shell Bootstrap + iframe)
GET /impo/purchase-order     → purchase-order    (contenido Tailwind)
GET /impo/purchase-procesar  → purchase-procesar (formulario)
```

### 1.2 Modelo de datos

**`PO`** (conexión `mysql`, 57 columnas). Ya contiene todo lo que el diseño 1c pide y hoy nadie diligencia:

`po`, `fecha_po`, `fecha_etd`, `id_compania`, `IdSupplier`, `IdPaisOrigen`, `IdComex`, `comentario`
`IdIncoterms`, `IdPuertoOrigen`, `IdPuertoDestino`, `IdMoneda`, `vr_fob_usd`, `IdStatus`, `vessel`, `fecha_bl`, `documento_transporte`

**`po_detalle`** (líneas): `id_material`, `cantidad_material`, `valor_unitario`, `valor_total`, `id_categoria_prod`, `unidad_medida`, `posicion_pedido`.

**`documentos`** (ya existe, con `id_po`): `ruta`, `nom_archivo`, `size`, `ext`, `param_data` (longtext), `id_tipo_documento`, `estado`. **Es el lugar natural para guardar el PDF y su extracción** — no hace falta tabla nueva para el documento.

**Catálogos disponibles:** `puertos`, `incoterms`, `moneda`, `pais`, `Supplier`, `compania`, `Materiales`.

### 1.3 Cómo se calcula hoy el estado

`consultarPurchaseOrders()` ([PurchaseOrder.php:80-157](../app/Http/Livewire/Importaciones/PurchaseOrder.php#L80-L157)) trae **todas** las órdenes del grupo desde `2026-01-01` con eager loading de 6 relaciones, y en PHP calcula:

```
total_productos = Σ po_detalle.cantidad_material
total_facturado = Σ (ordenServicio → cabezaFactura → detalleFactura.CANTIDAD)   [websimex]
saldo           = total_productos − total_facturado

status = 'abierta'  si  no hay órdenes de servicio
       = 'cerrada'  si  saldo == 0 y total_facturado > 0
       = 'proceso'  en cualquier otro caso
```

Ese estado mide **cumplimiento comercial** (cuánto se facturó), no **fase logística**. El diseño 1a pide la fase logística. La V2 conserva ambos (§4.2).

### 1.4 Deuda técnica identificada (se corrige en la V2)

| # | Problema | Impacto |
|---|---|---|
| D1 | Sin paginación: `->get()` sobre todas las órdenes del grupo | La consulta y el render crecen sin techo |
| D2 | `PurchaseOrderStats` repite la misma consulta pesada, con otra fecha de corte (`2025-01-01` vs `2026-01-01`) | Doble costo por carga de página |
| D3 | Las tablas de "Abiertas" y "Cerradas" están **duplicadas literalmente** en el Blade (líneas 200-514 y 524-825) | Todo cambio hay que hacerlo dos veces |
| D4 | `getOrdersFiltradas()` se invoca desde `render()` **y** desde el botón Consultar | La consulta se ejecuta en cada ciclo de Livewire |
| D5 | Filtro `filtroStatus` con lógica contradictoria ([línea 179](../app/Http/Livewire/Importaciones/PurchaseOrder.php#L179): `whereIn(...)->where(...)`) | El filtro por tarjeta no funciona como se espera |
| D6 | `search` solo busca por `po` | El diseño pide buscar por pedido, supplier, SED o contenedor |
| D7 | `PurchaseOrderStatus` asume `$this->purchaseOrder` no nulo en [línea 96](../app/Http/Livewire/Importaciones/PurchaseOrderStatus.php#L96) tras haberlo contemplado nulo en la 49 | Error 500 si el `id` no existe |
| D8 | Botón "Anular" sin `Auth()->user()->operacion == 'E'` en la tabla de Cerradas ([línea 643](../resources/views/livewire/importaciones/purchase-order.blade.php#L643)) | Inconsistencia de permisos entre pestañas |

---

## 2. Lectura del diseño

### 2.1 Artboard `1a` — Vista principal

- **Encabezado:** título, conteo de órdenes vigentes, marca de actualización, botones `Importación masiva` y `+ Nueva Orden`.
- **Embudo (4 tarjetas):** `Sin Confirmar` · `Sin Embarcar` · `En Tránsito` · `Con Arribo`. Cada una: conteo, % del total, barra de proporción y una **línea de contexto operativo** distinta por fase ("Sin respuesta del proveedor 6 días", "Con booking confirmado 58 de 62", "7 con ETA vencida", "Tránsito promedio 21 días").
- **Barra de filtros:** buscador ancho + chips `Todas` / `Abiertas` / `Cerradas` / `⚠ En riesgo`.
- **Tabla densa (7 columnas):** `Pedido` · `Supplier / Origen` · `SED` · `Ruta` · `ETA` · `Avance del ciclo` (4 segmentos + etiqueta de estado) · `Recibido / de Ordenado`.
- **Tokens:** color de marca vía variable CSS `--brand`; tipografías IBM Plex Sans/Mono.

### 2.2 Artboard `1c` — Diligenciamiento con lectura de documento

- **Encabezado:** identificación de la orden (soporta **orden consolidada de varios pedidos**) + acciones `Regresar` / `Guardar borrador` / `Confirmar y crear orden`.
- **Banda del documento:** nombre del PDF, páginas, peso, tiempo de lectura, contador `N campos extraídos` / `M requieren revisión`, acciones `Reemplazar documento` / `Re-extraer`.
- **Panel izquierdo (600px):** visor del PDF con paginación, zoom, resaltado de campos y leyenda de confianza (amarillo = alta confianza, naranja = requiere revisión).
- **Panel derecho:**
  - *Información general* — 8 campos en grilla 2×, cada uno con chip `Extraído`/`Revisar`, valor editable, enlace `Ver en doc.` y una **pista de procedencia** ("Normalizado desde *Dominican Republic*", "Formato ambiguo DD/MM vs MM/DD — confirma la fecha").
  - *Productos detectados* — tabla con checkbox de cotejo, código, nombre + nota de homologación, categoría, cantidad, precio; pie con total.
  - *Trazabilidad de la ejecución* — línea de tiempo de 4 hitos alineada con el embudo de 1a.
- **Estado previo:** zona de arrastre "Arrastra aquí la Orden de Compra — PDF, imagen o correo (.eml) hasta 20 MB" con salida `Diligenciar manualmente`.

> `support.js` del proyecto de diseño es el runtime del canvas de Claude Design (parser de `<x-dc>`, `sc-for`, `DCLogic`, panel de props). **No se porta nada de ese archivo**; los `{{ ... }}` y `<sc-for>` del artboard son plantillas del canvas y se traducen a Blade/Livewire.

---

## 3. Decisiones de arquitectura

| # | Decisión | Justificación |
|---|---|---|
| A1 | **Convivencia**, no reemplazo: componentes y rutas nuevas `…V2`, entrada de menú aparte | La V1 sigue en producción para todos los clientes mientras se valida la V2 con un piloto |
| A2 | Se mantiene el patrón vigente **iframe + Livewire** (shell `planti` → iframe → `layouts.tailwind`) | Es lo que indica `CLAUDE.md`; no se introduce un patrón nuevo en un módulo de negocio |
| A3 | El embudo de 4 fases se **deriva** en consulta, no se persiste en `PO.IdStatus` | `IdStatus` está en `NULL` en las 44.705 órdenes activas; poblarlo requiere un `sync` que no existe. Derivar evita un estado que puede quedar desactualizado |
| A4 | Los tokens `--brand` del diseño se alimentan de `session('color')` | Es el mecanismo de marca por cliente que ya usa todo el CTC |
| A5 | La extracción de PDF va en `app/Services/PurchaseOrderExtractionService.php`, siguiendo el patrón de [ShipsgoService.php](../app/Services/ShipsgoService.php) | Es el único patrón de integración externa del repo y `CLAUDE.md` lo señala como el a replicar |
| A6 | Credenciales en `config/services.php` + `.env` | Regla no negociable §6 de `CLAUDE.md`. **No** repetir el patrón de `ConexionAWS.php:29` |
| A7 | Toda consulta filtra por `session('GrupoEmpresarial')` e `id_websimex` de las compañías | Regla no negociable §1 de `CLAUDE.md` |
| A8 | Código compatible con **PHP 7.4** | El proyecto corre 7.4 y `composer.json` declara `^7.3`. Sin `match`, `enum`, `?->`, propiedades tipadas ni argumentos nombrados |

---

## 4. ETAPA 1 — Vista principal

### 4.1 Entregables

| Tipo | Archivo |
|---|---|
| Componente | `app/Http/Livewire/Importaciones/PurchaseOrderV2.php` |
| Vista Livewire | `resources/views/livewire/importaciones/purchase-order-v2.blade.php` |
| Parciales | `.../purchase-order-v2/_embudo.blade.php`, `_filtros.blade.php`, `_fila.blade.php`, `_detalle.blade.php` |
| Vista contenido | `resources/views/importaciones/purchase-order-v2.blade.php` |
| Vista iframe | `resources/views/importaciones/purchase-v2-iframe.blade.php` |
| Rutas | `impo/order-v2` (iframe) y `impo/purchase-order-v2` (contenido), ambas con `->middleware('auth')` |
| BD | 1 fila en `menus` + `id` agregado al JSON `compania.aplicaciones` del cliente piloto |

Los parciales `_fila` / `_detalle` **eliminan la duplicación D3**: una sola definición de fila, reutilizada por las dos pestañas.

### 4.2 Derivación del embudo de 4 fases

Insumos (todos ya disponibles): `PO.fecha_etd`, `PO.ordenServicio` (→ `estados_do` en `websimex`), `Pedido→importaciones` (`FECHA_ESTIMADA_ARRIBO`, `FECHA_LLEGADA_MCIA`), `cabezaFactura→detalleFactura.CANTIDAD`.

**Regla propuesta** — para una orden con varias órdenes de servicio, la fase de la orden es la **menos avanzada** de sus OS (una orden no está "Con Arribo" hasta que llegó todo), y el % de recepción se muestra aparte:

| Fase | Regla | Color |
|---|---|---|
| `Sin Confirmar` | `ordenServicio->count() == 0` **y** `fecha_etd` vacía | `#64748B` (slate) |
| `Sin Embarcar` | Tiene `fecha_etd` o ≥1 OS, pero ninguna OS con `FECHA_ESTIMADA_ARRIBO` | `--brand` |
| `En Tránsito` | ≥1 OS con `FECHA_ESTIMADA_ARRIBO` y sin `FECHA_LLEGADA_MCIA` | `#B4740A` (ámbar) |
| `Con Arribo` | Todas las OS con `FECHA_LLEGADA_MCIA` | `#1F7A4D` (verde) |

**Se conservan además** los indicadores actuales, que miden otra cosa: `total_productos`, `total_facturado`, `saldo` y el status comercial `abierta / proceso / cerrada`. La columna `Recibido / de Ordenado` del diseño es exactamente `total_facturado / total_productos`.

**Chip `⚠ En riesgo`:** órdenes `En Tránsito` cuyo `FECHA_ESTIMADA_ARRIBO` < hoy sin `FECHA_LLEGADA_MCIA` (ETA vencida), más `Sin Confirmar` con más de N días desde `fecha_po`. N se define en la aprobación (propuesta: 7).

### 4.3 Mapeo de columnas 1a → datos

| Columna del diseño | Origen | Nota |
|---|---|---|
| `Pedido` | `PO.po` | Ver §4.4 — órdenes consolidadas |
| `Supplier / Origen` | `Supplier.nombre` / `pais.nombre` vía `IdPaisOrigen` | |
| `SED` | `Carbon::parse(fecha_etd)->weekOfYear` (accessor `semana` ya existente) | Vacío si `fecha_etd` es nula |
| `Ruta` | `puertos.nombre` (`IdPuertoOrigen`) → `puertos.nombre` (`IdPuertoDestino`) | **Hoy mayoritariamente `0`/`NULL`** — mostrar `—` |
| `ETA` | `MIN(importaciones.FECHA_ESTIMADA_ARRIBO)` de las OS | |
| `Avance del ciclo` | Derivación §4.2 (4 segmentos) + etiqueta | |
| `Recibido / de Ordenado` | `total_facturado` / `total_productos` | Ya se calcula |

**Métricas de contexto de las tarjetas del embudo:**

| Tarjeta | Métrica | Cálculo |
|---|---|---|
| Sin Confirmar | "Sin respuesta del proveedor N días" | `AVG(hoy − fecha_po)` del grupo |
| Sin Embarcar | "Con booking confirmado N de M" | OS con `NUMERO_DOCUMENTO_TRANSPORTE` no vacío |
| En Tránsito | "N con ETA vencida" | Regla de riesgo §4.2 |
| Con Arribo | "Tránsito promedio N días" | `AVG(FECHA_LLEGADA_MCIA − fecha_etd)` |

### 4.4 Órdenes consolidadas

En los datos actuales del grupo, sobre 1.006 órdenes de 2026: **143 tienen espacio** y **113 tienen guion** en `PO.po` — es decir, el caso "una orden que agrupa varios pedidos" que muestra el artboard 1c ya existe en producción. La V2 lo formaliza:

- La lista muestra `4700000293 +3` (primer número + conteo), con el detalle completo al expandir.
- El parser (`explode` por `-`, `,`, `/` y espacio, con `trim`) vive en un único helper del modelo, reutilizado por Etapa 1 y Etapa 2.
- **`PO.po` sigue siendo un `varchar` único; no se normaliza el esquema.** Cambiar la cardinalidad de `PO` afectaría el `join` `estados_do.NUMERO_ORDEN_COMPRA` y toda la sincronización con el ERP — fuera de alcance.

### 4.5 Rendimiento (corrige D1, D2, D4)

1. **Paginación en servidor** (`WithPagination`, 25 filas) sobre `PO`, con eager loading acotado a la página.
2. **Conteos del embudo por agregación**, no cargando colecciones: una consulta de `COUNT` agrupada por fase, no `collect()->where()->count()` sobre todo el universo.
3. `consultarPurchaseOrders()` **deja de ejecutarse dentro de `render()`**; pasa a dispararse por acción (`Consultar`, cambio de chip, cambio de página) y a cachearse en propiedad durante el ciclo.
4. `PurchaseOrderStats` se fusiona en el mismo componente para no repetir la consulta pesada. Se unifica la fecha de corte, hoy inconsistente (`2025-01-01` vs `2026-01-01`) → **parámetro configurable, propuesta: año en curso**.
5. **Búsqueda ampliada (D6):** `po`, `Supplier.nombre`, `SED`, y `NUMERO_DO` / número de contenedor vía `unidad_carga`.

### 4.6 Resultado de la implementación

**Etapa 1 implementada.** Medición real contra el grupo 27 (brinsa, 251 órdenes de 2026):

| | V1 | V2 |
|---|---|---|
| Tiempo de render | 10.486 ms | **1.007 ms** |
| Consultas | 45 (16 CTC + 29 Websimex) | **6** (4 CTC + 2 Websimex) |
| HTML enviado | 2.651.511 bytes | **120.027 bytes** |

La BD es remota y compartida: **cada consulta cuesta ~130 ms solo de latencia**, independientemente de su complejidad. Por eso la palanca fue reducir viajes, no optimizar SQL. Tres decisiones lo consiguen:

1. Los agregados logístico y de facturación se resuelven con **2 consultas `GROUP BY`** contra Websimex, no recorriendo colecciones.
2. El nombre del importador y el del segmento salen de catálogos **ya cargados en `mount()`** — cero consultas por página.
3. Las líneas de producto se cargan **solo de la orden que el usuario expande** (`verDetalle`), no de las 25 filas: ahorra 3 consultas en cada render.

**Ajustes hechos sobre lo especificado, por defectos detectados al medir:**

| Hallazgo | Corrección |
|---|---|
| «Tránsito promedio 35.856 días»: fechas corruptas de `PO` y `diffInDays` devolviendo valor absoluto | Se descartan los tramos imposibles (llegada anterior al despacho, o superior a `MAX_DIAS_TRANSITO = 365`). Ahora da 40 días |
| Una orden de servicio ya arribada arrastraba su ETA vencida y ponía la orden «En riesgo» | La ETA y el riesgo se calculan **solo sobre las OS que aún no llegan** (`eta_pendiente`). «En Tránsito» pasó de 15 a 5 y «En riesgo» de 11 a 1 — los otros eran falsos positivos |
| `MIN(FECHA_LLEGADA_MCIA)` medía hasta el primer arribo | Se usa `MAX`: la orden está completa cuando llega el último |
| Buscar un contenedor cuya orden es de otro año devolvía «Sin resultados» sin explicación | Si la búsqueda no da resultados en el año activo, se ofrecen los años donde sí hay coincidencias, como botones |
| Pulsar «anular» también desplegaba el detalle de la fila | `stopPropagation` en la celda de acciones |
| `anularOrden()` es invocable desde el navegador aunque el botón esté oculto | Validación de `operacion == 'E'` **en servidor** (corrige D8 de raíz, no solo en la vista) |
| Marcas claras (`#CCCCCC` en exiros, `#C1C1C1` en indusel) dejaban el texto blanco ilegible | El color de texto sobre `--brand` se calcula por luminancia |

**Ajustes de la validación en navegador:**

| # | Ajuste | Implementación |
|---|---|---|
| 1 | El panel no excede la pantalla | El contenedor del iframe se dimensiona por JS al alto disponible del viewport, en vez de la proporción fija `padding-top: 56.25%` que dejaba el pie bajo el pliegue. Dentro, la raíz es `h-screen` con `overflow-hidden` y sólo la tabla scrollea |
| 2 | Cabecera fija de tabla | `thead` con `sticky top-0`, fondo sólido y `border-separate` (con el `border-collapse` que Tailwind aplica por defecto, el borde del encabezado sticky no se pinta) |
| 3 | Indicador de carga al pulsar la fila | La flecha de la fila se sustituye por un spinner con `wire:target="verDetalle(id)"`, por fila. Las consultas de listado muestran además un velo sobre la tabla |
| 4 | Fondo destacado en la fila de detalle | Tinte al 7% + borde izquierdo al 45%, **derivados del hex de la marca** (ver abajo) |
| 5 | Acciones compactas | Columna de acciones al ancho del contenido (`w-[1%]`) pegada a «Recibido»; **se retiró el icono de ojo** («Ver ejecución»), quedan editar y anular |

Se añadió además un botón **Ocultar embudo**: con el panel acotado al alto de pantalla, plegarlo devuelve ~150 px de tabla al trabajar el listado.

**Por qué el tinte no usa `bg-{color_tw}-50`:** `color_tw` y `color_p` se contradicen en varios clientes — fedearroz es `red` con marca verde `#00923f`, brenntag es `blue` con marca morada `#a129e4`, brinsa es `sky` con marca azul marino `#021F59`. Usar la clase de Tailwind daría a fedearroz un fondo rojizo sobre una marca verde. El tinte se calcula como `rgba()` del mismo hex que alimenta `--brand`, así siempre corresponde a la marca real.

### 4.6.1 Enlace configurable con las órdenes de servicio del ERP

La V1 y la primera versión de la V2 asumían un único enlace: `PO.po` → `estados_do.NUMERO_ORDEN_COMPRA`. **No vale para todos los clientes.** Medición sobre datos de 2026 (órdenes de servicio alcanzadas por estrategia, contra las OS realmente existentes):

| Cliente | POs | por `po` | por `numero_do` | por fila (mixto) | OS reales |
|---|---|---|---|---|---|
| adama | 355 | **2** | 215 | 215 | 210 |
| brinsa | 251 | **199** | 29 | 182 | 197 |
| koba | 167 | **212** | 164 | 164 | 212 |
| brenntag | 221 | **181** | 0 | 181 | 179 |

La diferencia es de **grano**, no de preferencia:

- En brinsa / koba / brenntag una fila de `PO` es una orden que abarca **varias** OS → enlace 1:N por `NUMERO_ORDEN_COMPRA`.
- En **ADAMA** una fila de `PO` **es** una OS → enlace 1:1 por `numero_do`. Caso real verificado: `po = 4500068548` tiene **14 filas con 14 DOs distintas**; con el enlace por `po` las 14 verían el mismo conjunto de OS. Como ADAMA además no diligencia `NUMERO_ORDEN_COMPRA`, ese conjunto es vacío y las 14 caían en «Sin Confirmar». Con el enlace por DO cada fila trae exactamente 1 OS (máximo verificado: 1).

**No se puede deducir del dato.** koba tiene `numero_do` diligenciado en el **100%** de sus filas y aun así su enlace correcto es por orden de compra (212 OS contra 164). Cualquier heurística sobre la presencia del campo se equivoca; hay que declararlo.

**Dónde vive la configuración:** [`config/controltower.php`](../config/controltower.php), mapa `id_grupo_empresarial → modo`, con `orden_compra` por defecto. Se descartó:

- **Columna nueva en `compania` / `grupo_empresarial`** — exige DDL sobre una BD de producción compartida (§3 de `CLAUDE.md`) para un dato que cambia una vez en la vida del cliente. Queda como alternativa si el negocio necesita cambiarlo sin desplegar.
- **Reutilizar `grupo_empresarial.filtro_oc_ws`** — guarda un fragmento SQL crudo que se concatena a la consulta (`POController.php:1706`). Es un vector de inyección; no se replica.

**Aislamiento en el código:** todo el conocimiento del enlace vive en `PurchaseOrderV2::resolverEnlace()` y `alcanceOrdenesServicio()`. El nombre de columna sale de una lista blanca en el `switch`, nunca de la configuración: un valor desconocido cae al enlace por defecto en vez de romper la consulta (verificado con un valor malicioso). Mover el ajuste a BD más adelante toca un solo método.

**Detalle asociado:** el filtro `NUMERO_DO NOT LIKE '%-%'` (que excluye DOs hijas de un fraccionamiento) sólo aplica al enlace 1:N, donde inflaría el conteo. Con enlace 1:1 la fila de `PO` nombra una DO concreta y se respeta.

### 4.6.2 Acceso de terceros (solo consulta)

Un tercero (`grupo_empresarial.es_tercero`) no tiene compañías propias: consulta los grupos listados en `companias_tercero` (JSON con ids de grupo), cada uno con sus propias compañías. Hoy hay uno: **grupo 44 (transborder) → grupo 41 (nestle)**.

**Todo el filtrado pasa por una sola propiedad, `$gruposVisibles`**, resuelta en `resolverAlcance()`. Al integrarlo aparecieron **seis puntos** que seguían acotados a un único grupo y que habrían dejado al tercero con la pantalla a medias: las compañías de `mount()` (de las que dependen ETA, facturado y segmento), los años disponibles, el detalle expandible, la anulación y los dos del listado. Concentrarlos evita que el próximo punto que se añada se olvide de considerarlo.

**Solo consulta**, en los dos niveles:

| | Tratamiento |
|---|---|
| Vista | No se renderizan «+ Nueva Orden», importación masiva, edición ni anulación |
| Servidor | `anularOrden()` y `PurchaseOrderFormV2::guardar()` rechazan a un tercero aunque se invoquen a mano |

Ocultar el botón no basta: los métodos de Livewire siguen siendo invocables desde el navegador, y el formulario tiene ruta propia. Verificado invocando ambos directamente.

**Un tercero mal configurado no ve nada.** Si `companias_tercero` viene vacío o ilegible, el alcance queda **vacío** en lugar de caer al grupo propio — precisamente el grupo bajo el que está registrado y cuyos datos no le corresponden. Se avisa en pantalla.

**Limitación conocida.** Cada grupo puede tener su propio modo de enlace con el ERP (§4.6.1). Si un tercero abarcara grupos con modos distintos, los agregados habría que calcularlos por separado para cada uno. Hoy no ocurre —el único tercero ve un solo grupo, con el modo por defecto—, así que en vez de resolverlo a medias **se detecta y se avisa** en pantalla y en el log. Probado simulando un tercero sobre adama (`numero_do`) + nestle (`orden_compra`): lo detecta.

### 4.6.3 El segmento de cliente sale del ERP, no de `PO.IdComex`

Corrige un defecto **heredado de la V1**: tanto la columna como el filtro leían `PO.IdComex`, y ese campo no contiene el segmento.

| Cliente (segmento activo) | `PO.IdComex` diligenciado | `estados_do.ID_SEGMENTO_CLIENTE` |
|---|---|---|
| brenntag (8) | 0 de 1.334 | sí |
| tenaris (4) | 4.809 de 6.402 | sí |

Lo de tenaris parecía respaldar `IdComex`, pero al cotejarlo no coincide **nunca** con el ERP (0 de 358 comparaciones). Sus valores de `IdComex` son `10, 13, 11, 28, 27, 38, 37`, mientras sus segmentos válidos son `65, 66, 67, 71, 103, 104, 115, 117`: **cero solapamiento**. `PO.IdComex` guarda otra cosa; no es un segmento y no se usa como respaldo.

Implementación:

- **Columna:** el id sale de `MAX(NULLIF(ed.ID_SEGMENTO_CLIENTE, 0))` dentro del agregado logístico que ya se ejecutaba — **cero consultas adicionales**. Se verificó que ninguna orden tiene órdenes de servicio con segmentos distintos (283 OC de brenntag, 0 con varios), así que el agregado es determinista.
- **Filtro:** al ser un dato del ERP, primero se resuelve el segmento a valores de enlace (`enlacesPorSegmento()`) y con esos se acota `PO`. Cuesta **una consulta más, y solo cuando el filtro está activo** (medido: 2+2 sin filtro, 2+3 con filtro).

Resultado en brenntag: **178 de 221 órdenes** resuelven segmento, repartidas en 6 responsables, todos del catálogo del cliente. Cada segmento filtra sin colar órdenes ajenas. De las 43 sin segmento, **41 no tienen ninguna orden de servicio** todavía y 2 la tienen con el campo vacío en el ERP.

> El gemelo de exportaciones y el resto de módulos que muestran segmento leyendo `IdComex` tienen el mismo defecto. Queda registrado; corregirlos está fuera del alcance de esta spec.

**Hallazgo de datos (extiende R2):** `Ruta` e `Incoterm` siguen vacíos en la mayoría de las órdenes porque `IdPuertoOrigen`/`IdIncoterms` no se diligencian. La Etapa 2 es lo que los puebla.

### 4.7 Criterios de aceptación — Etapa 1

Verificado automáticamente contra datos reales (script de verificación, §9):

- [x] Las 4 tarjetas del embudo suman exactamente el total de órdenes filtradas (251 == 251).
- [x] Los estados comerciales también suman el total (96 + 20 + 135 == 251).
- [x] Cada chip (`Todas`/`Abiertas`/`Cerradas`/`En riesgo`) y cada tarjeta del embudo filtra la tabla y respeta la paginación (corrige D5).
- [x] La tabla renderiza las 7 columnas del artboard 1a con datos reales; los campos sin dato muestran `—`.
- [x] La búsqueda encuentra por pedido, supplier, SED y contenedor (`TCLU4953668` → 1 orden).
- [x] `--brand` toma `grupo_empresarial.color_p`; verificado en 5 grupos, incluidos dos de marca clara.
- [x] **Aislamiento:** verificado en los grupos 27, 8 y 1 — cero órdenes de otro grupo y cero ids comunes entre grupos.
- [x] La columna de segmento aparece solo en el cliente que lo tiene activo.
- [x] Los permisos `operacion == 'E'` se aplican en la vista **y en el servidor** (corrige D8).
- [x] Paginación: página 1 y 2 sin filas comunes; cambiar de página cierra el detalle abierto.
- [x] Con 251 órdenes la página carga en ~1 s (V1: ~10,5 s).

Pendiente de verificación **manual en navegador** (no cubierto por el script):

- [ ] Render visual en el iframe dentro del shell `planti`.
- [ ] Comportamiento del `wire:loading` y de los toasts de anulación.
- [ ] Confirmar los 4 conteos del embudo contra una consulta SQL directa hecha por el negocio.

---

## 5. ETAPA 2 — Formulario con extracción desde PDF

### 5.1 Flujo funcional

```
1. Zona de arrastre → PDF / imagen (≤ 20 MB)
2. Guardar en S3 (FileAWS) + registro en `documentos` (id_po, ruta, ext, size)
3. Extraer → Claude API (documento base64 + JSON Schema)
4. Persistir el JSON crudo en `documentos.param_data`
5. Precargar el formulario: cada campo con valor + confianza + procedencia
6. El usuario cotejа campo por campo contra el visor
7. Confirmar → escribir en `PO` + `po_detalle`
```

Salida siempre disponible: **`Diligenciar manualmente`** — el formulario V2 debe funcionar completo sin subir ningún documento.

### 5.2 Entregables

| Tipo | Archivo |
|---|---|
| Componente | `app/Http/Livewire/Importaciones/PurchaseOrderFormV2.php` |
| Sub-componente | `app/Http/Livewire/Importaciones/PurchaseOrderDocumentoV2.php` (carga + extracción + visor) |
| Servicio | `app/Services/PurchaseOrderExtractionService.php` |
| Config | Bloque `purchase_order_ia` en `config/services.php` |
| Vistas | `purchase-order-form-v2.blade.php` + parciales `_visor`, `_campos`, `_productos`, `_trazabilidad` |
| Ruta | `impo/purchase-procesar-v2` con `->middleware('auth')` |

### 5.3 Servicio de extracción

**Restricción confirmada:** `anthropic-ai/sdk` requiere `php ^8.1` (verificado con `composer show anthropic-ai/sdk --all`). Este proyecto corre **PHP 7.4** → **no se instala el SDK**. Se llama la API por HTTP con el facade `Http::` de Laravel, igual que `ShipsgoService`.

```
POST https://api.anthropic.com/v1/messages
Headers: x-api-key, anthropic-version: 2023-06-01, content-type: application/json
Body:
  model: claude-opus-5
  max_tokens: 16000
  system: <instrucciones de extracción, en español>
  messages: [{ role: user, content: [
      { type: document, source: { type: base64, media_type: application/pdf, data: <b64 sin saltos> } },
      { type: text, text: <catálogos vigentes del cliente: suppliers, países, incoterms, puertos, materiales> }
  ]}]
  output_config: { format: { type: json_schema, schema: <esquema §5.4> } }
```

Detalles de implementación:

- **Modelo:** `claude-opus-5`. Costo de referencia: $5 / MTok entrada, $25 / MTok salida. Una OC de 2 páginas ≈ 3-5k tokens de entrada → **fracciones de centavo por documento**. `claude-sonnet-5` ($2/$10) queda como palanca de costo si el volumen crece; se decide con datos reales tras el piloto, no antes.
- **Salida estructurada:** `output_config.format` con `type: json_schema` — la respuesta viene como JSON válido en el primer bloque `text`, sin *parsing* frágil.
- **Caché de prompt:** los catálogos del cliente son estables entre documentos → `cache_control: {type: ephemeral}` sobre ese bloque. Verificar con `usage.cache_read_input_tokens`.
- **Errores:** el `Http::` de Laravel no lanza excepción por sí solo; validar `$response->successful()`, registrar con `Log::error` (patrón de `ShipsgoService`) y devolver `['exito' => false, ...]`. Nunca dejar al usuario sin la ruta manual.
- **Límites duros de la API:** 32 MB por request, 100 páginas en modelos de contexto 200k. El límite de negocio (20 MB, el mismo de `FileAWS::initUpload_AWS`) se valida **antes** de llamar.
- **Secretos:** `ANTHROPIC_API_KEY` en `.env` → `config('services.purchase_order_ia.api_key')`. Nunca en código.

### 5.4 Esquema de extracción

```
{
  "orden": {
    "numeros_orden": [string],          // soporta orden consolidada
    "supplier": string,
    "pais_origen": string,
    "fecha_orden": "YYYY-MM-DD"|null,
    "fecha_estimada_despacho": "YYYY-MM-DD"|null,
    "incoterm": string|null,
    "puerto_origen": string|null,
    "puerto_destino": string|null,
    "moneda": string|null,
    "valor_total": number|null,
    "observaciones": string|null
  },
  "productos": [{
    "codigo": string|null,
    "descripcion": string,
    "cantidad": number,
    "unidad_medida": string|null,
    "valor_unitario": number|null,
    "valor_total": number|null
  }],
  "confianza": { "<campo>": "alta"|"revisar" },
  "procedencia": { "<campo>": string }   // "Leído como 06-AUG-2026", "Normalizado desde Dominican Republic"
}
```

`confianza` y `procedencia` alimentan directamente los chips (`Extraído` / `Revisar`) y las pistas bajo cada campo del artboard 1c. **Ningún campo se marca como cotejado automáticamente:** el usuario confirma cada uno, y el contador `Cotejadas 6 de 8` refleja esa acción humana.

### 5.5 Homologación contra los catálogos del CTC

El PDF trae texto libre; el CTC necesita **IDs**. Después de la extracción, y **en PHP, no en el modelo**:

| Campo extraído | Se resuelve contra | Si no hay coincidencia |
|---|---|---|
| `supplier` | `Supplier` del grupo (`inactivo = 0`) | Chip `Revisar` + selector abierto |
| `pais_origen` | `pais` del grupo | Chip `Revisar` |
| `incoterm` / puertos / moneda | `incoterms`, `puertos`, `moneda` | Chip `Revisar` |
| `productos[].codigo` | `Materiales.codigo` / `codigo_homologacion` del grupo | Fila marcada, con acción `Crear producto` o `Homologar` |

La coincidencia es **exacta primero, luego normalizada** (mayúsculas, sin tildes, sin puntuación). Si un producto no homologa, la orden se puede guardar igual con las líneas que sí; nada se crea de forma silenciosa en los catálogos.

### 5.6 Persistencia

| Dato | Destino |
|---|---|
| PDF | S3 vía `FileAWS::initUpload_AWS` (bucket y carpeta ya definidos por `session('folder')`) |
| Metadatos del archivo | `documentos` (`id_po`, `ruta`, `nom_archivo`, `ext`, `size`, `id_tipo_documento`, `usuario_registro`) |
| JSON de extracción | `documentos.param_data` (longtext) — **auditoría de qué leyó la IA vs. qué confirmó el usuario** |
| Cabecera confirmada | `PO` (`po`, `fecha_po`, `fecha_etd`, `IdSupplier`, `IdPaisOrigen`, `IdIncoterms`, `IdPuertoOrigen`, `IdPuertoDestino`, `IdMoneda`, `vr_fob_usd`, `comentario`) |
| Líneas confirmadas | `po_detalle` |

**No se requiere ninguna tabla nueva.** Consistente con la regla §3 de `CLAUDE.md` (no se ejecutan migraciones; el esquema se administra fuera del repo). Solo hace falta una fila en `tipo_documentos` para el tipo "Orden de Compra" del grupo piloto.

### 5.7 Visor de PDF

- `pdf.js` servido desde `public/` (**no CDN** — hay clientes con salida a internet restringida).
- Paginación, zoom, descarga.
- **`Ver en doc.` / resaltado de coordenadas queda FUERA del alcance de la Etapa 2.** Requiere que el extractor devuelva *bounding boxes* por campo, lo que multiplica el costo y la complejidad. En su lugar: `Ver en doc.` **salta a la página** donde se encontró el campo (número de página sí se extrae). El resaltado exacto se evalúa como Etapa 3 según el uso real.

### 5.8 `QUEUE_CONNECTION=sync` — decisión de latencia

El entorno ejecuta las colas dentro del request (trampa documentada en `CLAUDE.md`). Una extracción tarda ~5-15 s.

**Propuesta:** ejecución **síncrona** dentro de la acción Livewire, con `wire:loading` cubriendo el panel y el `timeout` de `Http::` fijado en 120 s. Es honesto con la infraestructura actual y evita introducir un worker que hoy nadie opera. Si el piloto muestra que la espera molesta, la evolución natural es `QUEUE_CONNECTION=database` + polling de Livewire — **cambio de infraestructura, fuera del alcance de esta spec**.

### 5.9 Resultado de la implementación

**Etapa 2 implementada, con el método de extracción pendiente de conectar.** Todo lo demás funciona: carga a S3, visor, cotejo campo por campo, homologación contra catálogos, trazabilidad y persistencia.

**Método de extracción conectado: Gemini.** `ExtractorGemini` implementa el contrato — sube el documento a la File API, pide `generateContent` con el prompt de `tipo_documento_global` (id 1) y traduce el JSON al contrato. Ver §5.11.

**El punto de conexión.** Se implementa una sola interfaz y se cambia una línea:

```php
// app/Providers/AppServiceProvider.php
$this->app->bind(ExtractorOrdenCompra::class, MiExtractor::class);
```

`app/Services/ExtraccionOrdenCompra/`:

| Archivo | Rol |
|---|---|
| `ExtractorOrdenCompra.php` | **La interfaz a implementar.** 4 métodos: `disponible()`, `nombre()`, `extensionesSoportadas()`, `extraer()` |
| `ResultadoExtraccion.php` | Lo que devuelve `extraer()`: campos, productos, páginas, duración y datos crudos |
| `CampoExtraido.php` | Un valor con su confianza (`alta` / `revisar`) y su procedencia |
| `ExtractorNoConfigurado.php` | Implementación actual: `disponible()` devuelve `false` |
| `HomologadorCatalogos.php` | Traduce texto libre a ids del CTC. **No lo toca quien implemente la extracción** |

Tres reglas del contrato, verificadas:

1. `extraer()` **nunca lanza excepción**; un fallo se devuelve como `ResultadoExtraccion::fallo('mensaje')`. El componente además envuelve la llamada en `try/catch` por si una implementación futura no lo respeta: el formulario nunca debe quedar inutilizable.
2. Se devuelve **texto libre, no ids del CTC**. La homologación contra suppliers, países, incoterms, puertos, moneda y materiales la hace `HomologadorCatalogos` — quien implemente la lectura no necesita conocer el modelo de datos.
3. Lo ambiguo se marca con `CampoExtraido::revisar()` y su motivo. **Ningún campo llega cotejado**: lo confirma el usuario uno por uno.

Mientras `disponible()` sea `false`, la interfaz no promete una lectura que no va a ocurrir: la zona de carga lo dice, el documento igual queda adjunto a la orden, y el formulario es plenamente utilizable a mano.

**Homologación** (funciona hoy, sin extractor): exacta → normalizada (mayúsculas, sin tildes ni puntuación) → contención, y esta última **solo si es inequívoca**. Con varios candidatos no elige: se lo pide al usuario. Nada se crea en los catálogos automáticamente; una línea sin homologar no se persiste a medias.

**Los Incoterms no se filtran por tenant.** Son un estándar internacional y la tabla es común a todos los clientes; su columna `id_grupo_empresarial` es un rezago que no se eliminó. Las **17 filas activas pertenecen todas al grupo 1**, así que filtrar por tenant dejaba el catálogo vacío para todos los demás clientes y ningún Incoterm podía homologar. Verificado tras el cambio: los grupos 1, 2, 8 y 27 ven las 17 y «FOB Miami» resuelve al mismo id en los cuatro.

> La tabla tiene además un `CPT` duplicado, pero es el registro inactivo (id 30), de modo que el catálogo activo no tiene códigos repetidos y la homologación no queda ambigua.

**Tope de carga.** El componente lo deriva del límite real de Livewire (`config/livewire.php`), hoy **12 MB**. Se ató al config a propósito: validar a 20 MB con un framework que corta en 12 haría fallar los archivos intermedios con un error genérico antes de llegar a la validación. Para llegar a los 20 MB que ya tolera `FileAWS`, en `config/livewire.php`:

```php
'rules' => ['required', 'file', 'max:20480'],
```

Es un **cambio global**: afecta a todas las cargas de la aplicación. Por eso no se hizo sin consultar.

**Ajustes sobre lo especificado:**

| Punto | Decisión |
|---|---|
| Dos componentes (`FormV2` + `DocumentoV2`) | **Uno solo.** El resultado de la extracción tiene que fluir a los campos; separarlos obligaba a sincronizar estado entre componentes sin ganancia |
| Visor con `pdf.js` | Visor nativo del navegador. Evita servir una librería para lo que el navegador ya hace; `pdf.js` se justifica cuando se implemente el resaltado por coordenadas (Etapa 3) |
| `@disabled()` | Es de Laravel 9; aquí corre 8.76 |

**Verificado end-to-end contra el grupo empresarial 2 (asercol, cliente de pruebas): 36 comprobaciones en verde**, incluida la inserción real en `PO` y `po_detalle`, la re-edición de la orden creada, el rechazo de una orden de otro cliente, el bloqueo de guardado para un usuario `operacion != 'E'`, y la limpieza de los registros de prueba. La Etapa 1 sigue sin regresiones (20 comprobaciones).

#### Campo «Número DO» para clientes con enlace por DO

Para los clientes configurados con `enlace_orden_servicio = 'numero_do'` (hoy ADAMA), el formulario expone un campo **Número DO**: es la clave con la que la orden encuentra su ejecución en el ERP, y sin diligenciarla la orden quedaría permanentemente en «Sin Confirmar». En el resto de clientes el campo **no aparece** y la columna `PO.numero_do` **no se escribe**: pedir un dato que ahí no significa nada solo ensucia la tabla.

Reglas, sustentadas en los datos:

| Punto | Decisión | Por qué |
|---|---|---|
| ¿Único? | **No se impone unicidad** | En ADAMA hay 6.893 filas con 6.085 valores distintos; uno se repite 16 veces y `"N/A"` aparece 87 veces. Bloquear el guardado contradiría los datos reales |
| ¿Obligatorio? | Opcional para **guardar borrador**, requerido para **confirmar** | La orden puede registrarse antes de que el ERP tenga la DO |
| Verificación | Al escribirlo se contrasta contra `estados_do` | Tres avisos: *encontrada* (verde), *encontrada pero ya referenciada por N órdenes* (ámbar, informativo), *no existe para tus importadores* (ámbar). Ninguno bloquea |
| Longitud | `maxlength=15` | Es el ancho de `PO.numero_do` y de `estados_do.NUMERO_DO` |

La trazabilidad también pasó a leer el Número DO **del formulario** en lugar de releerlo de la base: antes una orden nueva nunca mostraba ejecución porque aún no existía en `PO`.

#### Corrección: «Diligenciar manualmente» no ocultaba el panel de carga

`diligenciarManualmente()` marcaba el modo pero no cambiaba `$documento`, así que la zona de arrastre a pantalla completa seguía visible sobre el formulario. Ahora, en cuanto se elige el modo manual —o se edita una orden existente— la zona grande se reemplaza por una **barra delgada** con la opción de adjuntar el documento en cualquier momento.

Al revisarlo apareció el mismo problema al revés en `quitarDocumento()`: dejaba `documento` y `extraccion` en nulo, con lo que **el formulario entero desaparecía llevándose los campos ya diligenciados**. Ahora quitar el adjunto conmuta a modo manual y el trabajo se conserva.

**Pendiente:** una fila en `tipo_documentos` para el tipo «Orden de Compra» de cada cliente. El código tolera su ausencia (`id_tipo_documento` queda nulo) y el documento igual se registra.

### 5.10.1 Dos fallos de carga encontrados en producción

**1. `filesize(): stat failed` — el objeto de Livewire no sirve para `FileAWS`.**
`FileAWS::initUpload_AWS()` hace `filesize($archivo)`, lo que castea el objeto a string. En un `TemporaryUploadedFile`, `getPathname()` devuelve **la ruta temporal de PHP, ya borrada** — el contenido real está en `getRealPath()`. Se resuelve materializando una ruta legible y reconstruyendo un `UploadedFile` sobre ella; si `getRealPath()` tampoco existe, se vuelca el contenido a un temporal propio que se limpia al terminar. `getSize()` tenía el mismo problema (pasa por Flysystem) y se reemplazó por `filesize()` sobre la ruta real.

**2. `InvalidAccessKeyId` — la llave AWS estaba muerta.**
`app/ConexionAWS.php:29` tenía las credenciales escritas en el código, y esa llave había sido rotada: AWS respondía *"The AWS Access Key Id you provided does not exist in our records"*. **Esto dejaba sin subir documentos a toda la aplicación**, no solo a este módulo — cualquier controlador que use `FileAWS` estaba igual de roto.

Siguiendo §6 de `CLAUDE.md` («si se toca ese archivo, migrar a `config('filesystems.disks.s3')`»), `ConexionAWS` ahora lee las credenciales, la región y el bucket de la configuración. Con eso, rotar una llave es editar `.env` — no el código — y el secreto sale del repositorio. Se añadió `ConexionAWS::Bucket()` para no repetir el nombre escrito a mano.

> Nota: `FileAWS::getBucket_AWS()` llama a `listBuckets()` y **descarta el resultado**, devolviendo la constante `"awsasercol"`. Es código muerto que además exige un permiso IAM innecesario. No se tocó porque con las credenciales correctas funciona, pero conviene retirarlo.

**3. 404 en el visor — `FileAWS` devuelve la clave, no la URL.**
`uploadAWS()` retorna `$arch`, que es la *clave* del objeto (`controltower/asercol/…`). Servida tal cual en el `src` de un iframe, el navegador la resuelve contra el dominio de la aplicación → 404. El resto de la aplicación guarda en `documentos.ruta` la **URL completa** (`https://<bucket>.s3.amazonaws.com/<clave>`), así que se construye en el componente (`urlPublicaS3()`) para respetar esa convención.

Al mismo tiempo apareció que `FileAWS` concatena carpeta y nombre **sin separador** cuando se le pasa un destino propio — la rama `jdefault` sí añade la barra, la otra no —, con lo que la clave quedaba `ORDENES-COMPRAoc-2026…`. Se corrige pasando `'ORDENES-COMPRA/'`, sin tocar el helper compartido.

Verificado no solo que la URL se construya, sino que **responda `HTTP 200` con `application/pdf`**.

> Observación de seguridad, fuera del alcance de esta spec: `FileAWS::uploadFile_AWS()` sube con `ACL: public-read`, de modo que cualquiera con la URL puede leer la orden de compra de un cliente. Es el patrón vigente para **todos** los documentos de la aplicación, no algo introducido aquí. Migrarlo a URLs firmadas es un trabajo transversal que conviene evaluar aparte.

**Degradación elegante.** Aprovechando el diagnóstico, archivar y leer pasaron a ser realmente independientes: si S3 falla, el documento **igual se lee y la orden se puede diligenciar**; se muestra un aviso explícito de que el adjunto no quedó guardado, en vez de bloquear todo el formulario. Antes un fallo de almacenamiento dejaba el módulo inutilizable.

### 5.11 Extractor de Gemini

Reemplaza lo previsto en §5.3 (Claude por HTTP): el cliente ya tiene Gemini. El contrato no cambió — solo su implementación.

**Flujo:** subir a la File API → `generateContent` → traducir el JSON → **borrar el archivo del proveedor**. Esto último es deliberado: son documentos de clientes y Gemini los retiene 48 h por defecto.

**Configuración** (`config/services.php`, credencial en `.env`):

```php
'gemini' => [
    'api_key' => env('GEMINI_API_KEY'),
    'modelo'  => env('GEMINI_MODEL', 'gemini-3.6-flash'),
    'timeout' => env('GEMINI_TIMEOUT', 120),
],
```

`gemini-2.5-flash` **ya no se habilita para cuentas nuevas** — la propia API devuelve 404 señalando `gemini-3.6-flash` como reemplazo. Se detectó probando contra la API real.

El *binding* se resuelve en tiempo de ejecución: si falta la clave o el prompt, cae a `ExtractorNoConfigurado` en lugar de prometer una lectura que no va a ocurrir.

**Mapeo del JSON del prompt al contrato**

| Prompt | Campo | Nota |
|---|---|---|
| `PO_Number` | `numeros_orden` | Se divide por separadores → soporta órdenes consolidadas |
| `Vendor` | `supplier` | |
| `Date` | `fecha_orden` | Normalizada a `Y-m-d`, con detección de ambigüedad |
| `Delivery_Date` | `fecha_etd` | **Siempre `revisar`** — ver abajo |
| `Terms_of_Delivery` | `incoterm` | Se extrae el código de textos como «CFR Cartagena» |
| `Currency` | `moneda` | |
| `Total_Value` | `valor_total` | `alta` solo si cuadra con las líneas + `Total_Tax` |
| `Terms_of_Payment`, `Buyer`, `Total_Tax` | `observaciones` | No tienen casilla propia pero el usuario debe verlos |
| `Line_Items[]` | `productos[]` | |
| — | `pais_origen` | **El prompt no lo extrae**, aunque el formulario lo exige. Lo diligencia el usuario |

**Dos discrepancias del prompt que conviene revisar:**

1. **`Delivery_Date` no es `fecha_etd`.** El prompt lo define como «cuándo se espera *recibir* la mercancía», mientras que `PO.fecha_etd` es la fecha estimada de *despacho*. Se mapea igual pero **siempre marcado `revisar`** con la explicación visible. Si el negocio quiere el despacho, hay que pedirlo en el prompt.
2. **Falta el país de origen**, que sí es obligatorio en el formulario.

**Fechas ambiguas.** El ejemplo del prompt usa `MM/DD/YYYY` (`07/23/2026`). Cuando ambos números son ≤ 12 no hay forma de saberlo del dato: se interpreta según esa convención y se marca `revisar` mostrando la lectura elegida. Cuando uno excede 12, se deduce sin ambigüedad. También se aceptan ISO y `06-AUG-2026`.

**Robustez añadida** sobre lo que el prompt promete, porque los modelos no siempre cumplen al pie de la letra: se toleran cercas ```` ```json ````, se tratan `"No especificado"`/`"N/A"`/`"-"` como nulos, y se parsean números con separadores de miles en ambas convenciones (`1.234,56` y `1,234.56`). Además se fija `response_mime_type: application/json` y `temperature: 0`.

**Coherencia de la línea: cantidad × precio contra el total.**

Caso real encontrado en producción (OC `4581620491`, Nestlé): el CTC mostraba *«recibido 24.98 de 25,000 · saldo 24,975.03»* cuando la orden había arribado casi completa. La causa no era el cálculo sino los datos: el documento declara `Quantity: 25000` (kg), `Unit_Price: 1861` (por **tonelada**) y `Net_Value: 46525`. Los tres números son correctos por separado pero **incompatibles entre sí** — 25.000 × 1.861 son 46,5 millones, no 46.525. El ERP factura en la unidad del precio (`CANTIDAD 24.975`, `CODIGO_UNIDAD_CCIAL_DIAN = 'MT'`), así que comparaba toneladas contra kilos.

No hay con qué convertir automáticamente: `po_detalle.unidad_medida` está vacío en el 79% de las líneas (y en el 100% de las de Nestlé), y ni `material` ni `productos` del ERP guardan unidad o factor. **Tampoco se corrige por cuenta propia**: cuál de los dos valores es el bueno depende de la convención del cliente.

Lo que sí es verificable sin suposiciones es que la línea se contradice, así que el extractor lo detecta y lo dice:

> La línea no cuadra: 25,000 × 1,861 = 46,525,000, pero el documento declara 46,525. El precio parece estar en otra unidad (factor 1,000): con este total la cantidad sería 25 y el documento dice "KG". Confirma cuál corresponde.

La línea se marca en rojo, **bloquea la confirmación** de la orden y el motivo aparece en el botón. Corregirla desde el modal recalcula el total y la desbloquea. La detección tolera el redondeo (0,5% o 2 centésimas) y reconoce los factores típicos de cambio de unidad (1000, 100, 12 y sus inversos); un descuadre que no encaja en ninguno se reporta igual, sin inventar explicación.

> Medición del alcance: el desajuste ×1000 aparece en **2 de 2 líneas de Nestlé y en 0 de brinsa, adama y brenntag**. Ambas líneas de Nestlé las creó este formulario durante las pruebas del 29/08, así que **conviene corregirlas o eliminarlas**: con la cantidad en toneladas (25 y 18,75) el saldo de la orden pasa de 24.975,03 a 0,025.

**Verificado contra la API real** con un PDF de orden de compra generado para la prueba: 8 campos extraídos (2 marcados `revisar`, correctamente: la fecha ambigua `11/03/2026` y el `Delivery_Date`), 2 líneas con cantidades numéricas, incoterm `CFR` aislado de «CFR Cartagena», total cuadrado contra las líneas, y homologación exitosa del supplier y de un producto contra los catálogos del grupo 2. ~10 s, 1.303 tokens de entrada y 333 de salida. Un archivo corrupto devuelve fallo sin lanzar.

### 5.10 Criterios de aceptación — Etapa 2

- [ ] El formulario funciona **completo sin subir documento** (ruta `Diligenciar manualmente`).
- [ ] Un PDF de OC real del cliente piloto extrae correctamente: números de orden, supplier, país, fechas, incoterm y ≥90% de las líneas de producto.
- [ ] Los campos ambiguos (fecha `DD/MM` vs `MM/DD`, incoterm sin puerto) llegan marcados como `Revisar`, no como `Extraído`.
- [ ] Ningún campo queda cotejado sin acción del usuario.
- [ ] El JSON crudo de la extracción queda en `documentos.param_data`.
- [ ] Un fallo de la API (clave inválida, timeout, 429) muestra mensaje claro y **deja el formulario usable manualmente**.
- [ ] Un archivo > 20 MB o de tipo no permitido se rechaza antes de llamar a la API.
- [ ] Al confirmar, `PO` y `po_detalle` quedan con los mismos valores que muestra la pantalla.
- [ ] Se verifica en el log que **no** se envían datos de un grupo empresarial distinto al del usuario en los catálogos del prompt.

---

## 6. Plan de trabajo

### Etapa 1

| # | Tarea | Depende de |
|---|---|---|
| 1.1 | `PurchaseOrderV2` con contexto de tenant, paginación y consulta base | — |
| 1.2 | Derivación del embudo de 4 fases + agregados de las tarjetas | 1.1 |
| 1.3 | Vista + parciales (embudo, filtros, fila, detalle) con tokens `--brand` | 1.1 |
| 1.4 | Chips de filtro, búsqueda ampliada, regla de "En riesgo" | 1.2, 1.3 |
| 1.5 | Fusión de `PurchaseOrderStats` y unificación de la fecha de corte | 1.1 |
| 1.6 | Rutas + vistas contenedoras + fila en `menus` + `compania.aplicaciones` | 1.3 |
| 1.7 | Verificación manual (§9) con dos grupos empresariales | todas |

**Estado:** 1.1 a 1.5 implementadas y verificadas. De 1.6, las rutas y vistas están hechas; **falta el paso en base de datos** (§6.1). 1.7 pendiente de la validación en navegador.

### 6.1 Paso en base de datos — pendiente de aprobación

El módulo no aparece en el menú hasta ejecutar esto. **No se ha ejecutado**: toca la BD de producción y depende de P2 (grupo piloto).

```sql
-- 1) Registrar el módulo. La ruta es la del iframe, no la del contenido.
INSERT INTO menus (nombre, ruta, depende, orden, perfiles, estado)
VALUES ('Órdenes de Compra V2', 'impo/order-v2', <id_menu_padre>, <orden>, '<perfiles>', 1);

-- 2) Añadir el id devuelto al JSON compania.aplicaciones del cliente piloto.
--    Revisar el valor actual antes de escribir; es un JSON, no una lista plana.
SELECT id, razon_social, aplicaciones FROM compania WHERE id_grupo_empresarial = <grupo_piloto>;
```

### Etapa 2

| # | Tarea | Depende de |
|---|---|---|
| 2.1 | `PurchaseOrderExtractionService` + config + `.env` | — |
| 2.2 | Prompt y JSON Schema; ajuste con 5-10 PDFs reales del piloto | 2.1 |
| 2.3 | Homologación contra catálogos (supplier, país, incoterm, puertos, materiales) | 2.2 |
| 2.4 | `PurchaseOrderDocumentoV2`: carga, S3, registro en `documentos`, visor `pdf.js` | — |
| 2.5 | `PurchaseOrderFormV2`: campos con chip/procedencia + cotejo + tabla de productos | 2.3, 2.4 |
| 2.6 | Trazabilidad de ejecución (línea de tiempo de 4 hitos) | 2.5, 1.2 |
| 2.7 | Persistencia transaccional en `PO` + `po_detalle` | 2.5 |
| 2.8 | Manejo de errores, límites y ruta manual garantizada | 2.7 |
| 2.9 | Verificación manual con documentos reales | todas |

**No se toca la Etapa 1 desde la Etapa 2** salvo por la derivación de fases (1.2), que la trazabilidad reutiliza.

---

## 7. Riesgos y supuestos

| # | Riesgo / Supuesto | Mitigación |
|---|---|---|
| R1 | **PHP 7.4 impide el SDK oficial** (`anthropic-ai/sdk` exige `^8.1`) — verificado | Integración por HTTP crudo con `Http::`. Si algún día se sube a PHP 8.1, el servicio se puede migrar al SDK sin tocar los componentes |
| R2 | **`Ruta`, `Incoterm`, `ETA` están vacíos en la mayoría de las órdenes actuales** (`IdPuertoOrigen`/`IdIncoterms` en `0` o `NULL`) | La V2 los expone con `—`; el formulario V2 y la extracción son el mecanismo que los puebla hacia adelante. **Esperar columnas vacías en el piloto es lo normal, no un defecto** |
| R3 | Datos de `fecha_po` inconsistentes: hay órdenes activas con fechas 2028-2031 | Definir en la aprobación si el embudo filtra por rango de `fecha_po` o los muestra. Propuesta: mostrarlos y marcarlos, no ocultarlos |
| R4 | Extracción incorrecta que se persiste sin revisión | Ningún campo se marca cotejado automáticamente; `param_data` guarda el JSON crudo para auditar |
| R5 | Latencia de la extracción con `QUEUE_CONNECTION=sync` | §5.8 — síncrono con `wire:loading`; la cola es evolución posterior |
| R6 | Costo de la API sin techo | Límite de 20 MB / documento, y `Log` de tokens por extracción para medir el costo real del piloto |
| R7 | Fuga entre clientes en los catálogos que van en el prompt | Todos los catálogos se consultan filtrando por `id_grupo_empresarial`; se verifica explícitamente (§9) |
| R8 | El módulo no aparece tras desplegar | Recordatorio: hace falta la fila en `menus` **y** el `id` dentro del JSON `compania.aplicaciones` del cliente |
| R9 | El gemelo de exportaciones (`ex_po`) queda desalineado | **Fuera de alcance declarado.** Regla §7 de `CLAUDE.md`: se deja registrado que exportaciones necesitará el mismo trabajo |

---

## 8. Fuera de alcance

- Reemplazo o eliminación de la V1 (se decide tras el piloto).
- El gemelo de **exportaciones** (`ExPo` / `ex_po`).
- Resaltado por coordenadas dentro del PDF (§5.7) — posible Etapa 3.
- Migración a colas asíncronas.
- Cambios de esquema en `PO` / `po_detalle`.
- Normalización de las órdenes consolidadas a filas separadas (§4.4).
- Ingesta de correos `.eml` que muestra el artboard (solo PDF e imagen en Etapa 2).

---

## 9. Verificación

No hay pruebas automatizadas en el repo (`tests/` solo tiene el esqueleto). La verificación es manual:

```bash
php -l app/Http/Livewire/Importaciones/PurchaseOrderV2.php
php -l app/Http/Livewire/Importaciones/PurchaseOrderFormV2.php
php -l app/Services/PurchaseOrderExtractionService.php
php artisan route:list | grep purchase
php artisan config:clear          # tras tocar config/services.php o .env
```

Luego, en navegador (Laravel Herd):

1. Entrar con un usuario del **grupo empresarial piloto** → el módulo aparece en el menú.
2. **Verificar explícitamente que no se filtran datos de otro cliente:** entrar con un usuario de otro grupo y confirmar que ninguna orden del piloto es visible, ni en la tabla, ni en el embudo, ni en los catálogos del formulario.
3. Comprobar los 4 conteos del embudo contra una consulta SQL directa.
4. Etapa 2: subir un PDF real, cotejar campo por campo, confirmar, y verificar en BD que `PO` y `po_detalle` coinciden con la pantalla.

> ⚠️ El `.env` apunta a **bases de datos reales compartidas**. Todas las pruebas de escritura se hacen sobre órdenes creadas para la prueba, nunca sobre órdenes de producción del cliente.

---

## 10. Puntos que requieren aprobación explícita

| # | Punto | Propuesta |
|---|---|---|
| P1 | ¿V2 convive con V1 o la reemplaza de una vez? | **Convivir**, con grupo piloto |
| P2 | Grupo empresarial piloto | **Grupo 27 (brinsa)** — es de donde salieron los datos del diseño (251 órdenes en 2026, supplier BRINSA DOMINICANA) y es sobre el que se verificó la Etapa 1 |
| P3 | Umbral de "En riesgo" para `Sin Confirmar` | 7 días desde `fecha_po` |
| P4 | Fecha de corte del listado (hoy inconsistente: 2025 vs 2026) | Año en curso, configurable |
| P5 | Tratamiento de órdenes con `fecha_po` futura inconsistente (R3) | Mostrar y marcar, no ocultar |
| P6 | Modelo de extracción | `claude-opus-5`; revisar `claude-sonnet-5` con datos de costo del piloto |
| P7 | ¿Se aprueba dejar el resaltado por coordenadas para una Etapa 3? | Sí |
| P8 | ¿Se aprueba dejar exportaciones (`ex_po`) fuera de alcance? | Sí, registrado como trabajo pendiente |
