# Especificación: impresión térmica con cola y agente local

Documento portable para replicar en otro proyecto Laravel + Vue. Describe **qué hace** el sistema, **cómo fluye** la impresión y **qué archivos/piezas** implementar.

---

## 1. Problema y solución

| Escenario | Problema |
|-----------|----------|
| App web en **dominio/nube** | El servidor no puede hablar con la impresora USB/local de la caja |
| POS en navegador | No puede imprimir directo por CORS/seguridad del navegador |

**Solución:** cola en servidor + agente PHP en la PC de caja.

```
[Vue POS / Historial]  →  [API Laravel]  →  [tabla print_jobs]
                                                    ↑ poll cada N seg
                                            [print-agent en PC caja]
                                                    ↓ ESC/POS
                                            [Impresora térmica local]
```

El navegador **nunca** habla con la impresora. Solo encola trabajos vía API.

---

## 2. Modos de operación

Variable de entorno: `PRINT_MODE`

| Modo | Valor | Comportamiento |
|------|-------|----------------|
| **Directo** | `direct` (default) | Laravel imprime en el mismo host con `mike42/escpos-php` (desarrollo o servidor en la misma PC) |
| **Cola** | `queue` | Laravel solo inserta en `print_jobs`; el agente local imprime |

Config: `config/printing.php`

```php
'mode' => env('PRINT_MODE', 'direct'),
'agent_poll_interval' => (int) env('PRINT_AGENT_POLL_INTERVAL', 3),
'agent_request_timeout' => (int) env('PRINT_AGENT_REQUEST_TIMEOUT', 30),
```

**Producción en nube:** `PRINT_MODE=queue` en `.env` del servidor.

---

## 3. Flujo completo (modo cola)

### 3.1 Venta en POS (imprimir al crear)

```
1. Usuario confirma venta → POST /api/ventas (o equivalente)
2. VentaController guarda venta en transacción
3. Tras commit: TicketService::imprimirTicket($venta, null, $user, esReimpresion: false)
4. TicketService:
   a. Resuelve impresora (por defecto / tipo venta / primera activa)
   b. ImpresoraAccessService::validarAccesoImpresion() → permisos + restricción por impresora
   c. Genera texto plano del ticket (generarTicketVenta)
   d. Si PrintJobService::usaCola() → encolarTicketVenta() → INSERT print_jobs
   e. Retorna { success: true, codigo: 'en_cola', print_job_id: N }
5. API responde 201 con impresion_venta en JSON
6. Frontend (POS) muestra toast "Ticket en cola..." (composable use-ticket-impresion)
```

### 3.2 Reimprimir desde historial

```
1. POST /api/ventas/{id}/reimprimir-ticket  (requiere impresion.reimprimir)
2. Mismo TicketService::imprimirTicket(..., esReimpresion: true)
3. Encola otro print_job
```

### 3.3 Ticket de prueba (config hardware)

```
1. POST /api/configuracion-hardware/{id}/probar-impresion
2. TicketService::imprimirPrueba() → encolarTicketPrueba()
```

### 3.4 Agente local (bucle infinito)

```
Cada POLL_INTERVAL segundos:
1. GET  /api/print-agent/heartbeat        (header X-Print-Agent-Token)
2. GET  /api/print-agent/jobs/pending     → reclama jobs (estado → processing)
3. Por cada job:
   a. Reconstruye impresora desde impresora_snapshot (JSON guardado al encolar)
   b. ImpresionService::imprimirEscPosLocal() en la PC local
   c. Si OK  → POST /api/print-agent/jobs/{id}/complete
   d. Si fail → POST /api/print-agent/jobs/{id}/fail (reintenta hasta max_intentos)
```

---

## 4. Diagrama de estados del job

```mermaid
stateDiagram-v2
    [*] --> pending: encolar
    pending --> processing: agente reclama
    processing --> completed: complete OK
    processing --> pending: fail (intentos < max)
    processing --> failed: fail (intentos >= max)
    pending --> cancelled: cancelar manual
    processing --> cancelled: cancelar manual
```

Estados: `pending`, `processing`, `completed`, `failed`, `cancelled`

---

## 5. Base de datos

### 5.1 `print_jobs`

| Columna | Tipo | Uso |
|---------|------|-----|
| id | bigint | PK |
| tipo | string | `ticket_venta`, `ticket_prueba` |
| estado | string | ver diagrama |
| venta_id | FK nullable | venta asociada |
| configuracion_hardware_id | FK nullable | impresora usada |
| sucursal_id | FK nullable | filtro por sucursal del agente |
| user_id | FK nullable | quién encoló |
| contenido | longText | **texto plano** del ticket (ya renderizado) |
| codigo_barras | string nullable | valor para CODE128/CODE39 después del texto |
| columnas | tinyint | 32 (58mm) o 42 (80mm) |
| impresora_snapshot | json | copia de IP, nombre_sistema, puerto, ancho_papel al encolar |
| intentos, max_intentos | tinyint | reintentos |
| error_mensaje, error_codigo | nullable | último error |
| started_at, completed_at | timestamps | auditoría |

Índices: `(estado, created_at)`, `(sucursal_id, estado)`, `venta_id`

**Importante:** el snapshot evita que un cambio posterior en la impresora rompa jobs ya encolados.

### 5.2 `print_agent_tokens`

| Columna | Uso |
|---------|-----|
| nombre | etiqueta ("Caja Villahermosa") |
| token_hash | SHA-256 del token plano (nunca guardar token en claro) |
| sucursal_id | agente solo reclama jobs de esa sucursal (o sucursal_id null) |
| impresora_ids | JSON array; null/[] = todas las impresoras de la sucursal |
| activo | bool |
| last_seen_at | actualizado en cada request del agente (semáforo "en línea") |

### 5.3 `impresora_usuario` (pivot)

Restricción opcional por impresora:

- **Pivot vacío** → cualquier usuario con permiso `impresion.*` puede usar esa impresora
- **Con usuarios** → solo esos usuarios (+ admin + quien tenga `administracion.configuracion_hardware`)

### 5.4 Tabla de impresoras (existente en este proyecto)

Modelo `Impresora` extiende `configuracion_hardware` con `tipo = 'impresora'`.

Campos relevantes para impresión:

- `activo`, `es_por_defecto`, `tipo_ticket` (`venta`)
- `ip_url` → IP de red (puerto default 9100)
- `nombre_sistema` → nombre Windows o ruta UNC `\\servidor\impresora`
- `ancho_papel` → 58 o 80 mm
- `puerto`

---

## 6. Permisos (Spatie)

| Permiso | Quién lo necesita |
|---------|-------------------|
| `impresion.imprimir` | POS al vender (primera impresión) |
| `impresion.reimprimir` | Historial / reimprimir |
| `impresion.probar` | Botón probar impresión en hardware |
| `impresion.ver_cola` | Ver cola, estado agente, gestionar agentes |
| `impresion.gestionar_usuarios_impresora` | Asignar usuarios a impresora (opcional UI) |

**Roles típicos:**

- **Administrador:** todos
- **Ventas (POS):** `imprimir` + `reimprimir`
- **Gerente:** + `ver_cola`

Tras asignar permisos en BD, el usuario debe **cerrar sesión y volver a entrar** (permisos en token/store del frontend).

---

## 7. Backend Laravel — componentes

### 7.1 Servicios (orden de dependencia)

```
ImpresionService          → ESC/POS real (red / Windows / UNC)
ImpresoraAccessService    → permisos usuario + pivot impresora_usuario
PrintJobService           → cola, reclamar, completar, fallar, snapshot
TicketService             → generar texto ticket + orquestar imprimir/colar
```

#### `TicketService::imprimirTicket()` — punto central

```php
// Pseudocódigo
function imprimirTicket(Venta $venta, ?int $impresoraId, ?User $user, bool $esReimpresion): array
{
    $impresora = $impresoraId ? Impresora::find(...) : obtenerImpresoraPorDefecto();
    if (!$impresora) return error('sin_impresora');

    if ($user) validarAccesoImpresion($user, $impresora, $esReimpresion);
    validarDatosConexionImpresora($impresora); // IP o nombre Windows

    $contenido = generarTicketVenta($venta, $anchoMm);
    $codigoBarras = $venta->codigoBarrasValor();

    if (PrintJobService::usaCola()) {
        return encolarTicketVenta(...); // codigo: en_cola
    }

    return ImpresionService::imprimirEscPosLocal(...); // modo direct
}
```

#### `PrintJobService::reclamarPendientes()`

- Transacción con `lockForUpdate`
- Filtra: `estado = pending` AND (`sucursal_id = agente.sucursal` OR `sucursal_id IS NULL`)
- Filtra por `agente->aceptaImpresora(configuracion_hardware_id)`
- Marca `processing`, incrementa `intentos`

#### `ImpresionService::imprimirEscPosLocal()`

- Librería: `mike42/escpos-php`
- Conectores:
  - IP válida → `NetworkPrintConnector`
  - Ruta `\\...` → `FilePrintConnector` + copy a UNC
  - Otro → `WindowsPrintConnector`
- Marcador en texto: `[[IMPRIMIR_CODIGO_BARRAS]]` → imprime barcode después del cuerpo
- Formato: línea 1 grande centrada, separadores `=`/`-`, TOTAL en negrita, feed + cut

### 7.2 Modelos

- `PrintJob` — constantes TIPO_*, ESTADO_*, `impresoraDesdeSnapshot()`
- `PrintAgentToken` — `generarTokenPlano()`, `hashToken()`, `aceptaImpresora()`
- `Impresora` — scope `tipo=impresora`, `usuariosPermitidos()` belongsToMany

### 7.3 Controladores

| Controlador | Auth | Endpoints |
|-------------|------|-----------|
| `PrintAgentController` | middleware `print.agent` (token, no Sanctum) | heartbeat, pending, complete, fail |
| `PrintJobController` | Sanctum + permisos | index, resumen, cancelar, agentes CRUD, estado agentes |
| `VentaController` | Sanctum | reimprimirTicket; en store llama TicketService tras crear venta |
| `ConfiguracionHardwareController` | Sanctum | probarImpresion |

### 7.4 Middleware `AuthenticatePrintAgent`

Lee token de:

1. Header `X-Print-Agent-Token`
2. Bearer token
3. Body `token`

Busca `token_hash = sha256(token)`, `activo = true`, actualiza `last_seen_at`.

Registrar en `Kernel.php`: `'print.agent' => AuthenticatePrintAgent::class`

### 7.5 Rutas API (resumen)

```php
// Sin Sanctum — solo token de agente
Route::prefix('/print-agent')->middleware('print.agent')->group(function () {
    Route::get('/heartbeat', ...);
    Route::get('/jobs/pending', ...);
    Route::post('/jobs/{printJob}/complete', ...);
    Route::post('/jobs/{printJob}/fail', ...);
});

// Con Sanctum
Route::prefix('/print-jobs')->group(function () {
    Route::get('/', ...);                    // impresion.ver_cola
    Route::get('/resumen', ...);
    Route::get('/agentes/estado', ...);      // semáforo en línea (last_seen < 15s)
    Route::post('/agentes', ...);            // devuelve token plano UNA vez
    Route::post('/agentes/{id}/regenerar-token', ...);
    Route::post('/{printJob}/cancelar', ...);
});

Route::post('/ventas/{venta}/reimprimir-ticket', ...);
Route::post('/configuracion-hardware/{id}/probar-impresion', ...);
```

### 7.6 Comandos Artisan (opcional)

- `php artisan print-agent:token {sucursal_id} --name="Caja"` — crear agente CLI
- `php artisan print-agent:run` — agente embebido en Laravel (alternativa al proyecto standalone)

### 7.7 Dependencia Composer (servidor)

```json
"mike42/escpos-php": "^4.0"
```

---

## 8. Formato del ticket (texto plano)

El ticket **no es HTML ni PDF**. Es texto con saltos de línea que `ImpresionService` interpreta:

- Línea vacía → feed
- Primera línea de contenido (después del separador) → texto grande centrado (nombre empresa)
- Líneas solo `=` o `-` → separador centrado
- Línea que empieza con `TOTAL:` → negrita
- Marcador `[[IMPRIMIR_CODIGO_BARRAS]]` → se omite en texto; el valor va en columna `codigo_barras`

`TicketService::generarTicketVenta()` debe:

- Normalizar acentos/ñ para impresoras sin UTF-8
- Partir líneas largas según columnas (32 o 42)
- Incluir folio, fecha, productos, pagos, código de barras si aplica

En otro proyecto: adaptar solo `generarTicketVenta()` a tu modelo de venta; el resto del pipeline es igual.

---

## 9. Frontend Vue 3

### 9.1 Repositorios

**`TicketRepository.js`**

```js
POST ventas/{id}/reimprimir-ticket  // body opcional: { impresora_id }
```

**`PrintJobRepository.js`**

```js
GET    print-jobs
GET    print-jobs/resumen
POST   print-jobs/{id}/cancelar
GET    print-jobs/agentes
GET    print-jobs/agentes/estado
POST   print-jobs/agentes
POST   print-jobs/agentes/{id}/regenerar-token
```

### 9.2 Composable `use-ticket-impresion.js`

| Función | Uso |
|---------|-----|
| `impresionEnCola(res)` | `res.codigo === 'en_cola'` |
| `impresionFallaSinImpresora(res)` | mostrar vista previa PDF/HTML si no hay impresora |
| `notificarResultadoVenta(res)` | Swal tras crear venta en POS |
| `debeMostrarVistaPreviaTicket(res, sucursal)` | lógica de negocio post-venta |

### 9.3 Integración POS (`ventas/index.vue`)

Tras `create` venta:

```js
const impresion = result.impresion_venta;
notificarResultadoVenta(impresion);
// opcional: vista previa si sin impresora o sucursal venta
```

La API debe devolver `impresion_venta` en la respuesta de crear venta.

### 9.4 Historial (`ventas/historial.vue`)

- Botón reimprimir visible si `permissions.includes('impresion.reimprimir')`
- Llama `TicketRepository.reimprimirTicket(ventaId)`

### 9.5 Vistas admin

| Ruta | Permiso | Función |
|------|---------|---------|
| `/impresion/cola` | `impresion.ver_cola` | Tabla jobs, filtros, cancelar, CRUD agentes, copiar token |
| `/impresion/estado-agente` | `impresion.ver_cola` | Poll cada 5s a `/agentes/estado`, semáforo verde/rojo |

Sidebar: enlaces bajo Administración.

---

## 10. Proyecto standalone `print-agent/`

Carpeta separada en la PC de caja (no requiere Laravel completo).

### Estructura

```
print-agent/
  bin/run.php          # bucle: sleep(POLL_INTERVAL), runCycle()
  src/
    Config.php         # lee .env
    ApiClient.php      # Guzzle → API
    PrintAgent.php     # heartbeat + fetch + imprimir + confirm
    ImpresionService.php  # copia de lógica ESC/POS (misma que servidor)
    ImpresoraConfig.php   # fromSnapshot(json)
  .env.example
  run.bat
  VolteosPrintAgent-startup.vbs   # inicio oculto Windows
```

### `.env` del agente

```env
API_URL=https://tu-dominio.com/api
AGENT_TOKEN=token_plano_de_64_chars
POLL_INTERVAL=3
REQUEST_TIMEOUT=30
LOG_FILE=storage/agent.log
```

### Dependencias agente

```json
"guzzlehttp/guzzle": "^7.0",
"mike42/escpos-php": "^4.0"
```

### Ciclo `PrintAgent::runCycle()`

1. Heartbeat (una vez al inicio)
2. `GET /print-agent/jobs/pending`
3. Por job: imprimir local → `complete` o `fail`

**El agente usa `impresora_snapshot` del job**, no consulta la BD de impresoras. La PC debe tener acceso a la impresora (driver Windows, red local, etc.).

---

## 11. Respuestas API importantes

### Encolar exitoso

```json
{
  "success": true,
  "message": "Ticket en cola de impresión. El agente local lo imprimirá en breve.",
  "codigo": "en_cola",
  "print_job_id": 42
}
```

### Sin permiso

```json
{
  "success": false,
  "error": "No tiene permiso para imprimir tickets.",
  "codigo": "sin_permiso"
}
```

### Crear venta (POS)

```json
{
  "data": { ...venta },
  "message": "Venta creada...",
  "impresion_venta": { "success": true, "codigo": "en_cola", "print_job_id": 42 }
}
```

### Pending jobs (agente)

```json
{
  "data": [
    {
      "id": 42,
      "tipo": "ticket_venta",
      "contenido": "====...\nEMPRESA\n...",
      "codigo_barras": "VH123456",
      "columnas": 42,
      "impresora_snapshot": { "id": 1, "nombre_sistema": "EPSON TM-T20", "ip_url": "", "puerto": 9100, "ancho_papel": 80 }
    }
  ]
}
```

---

## 12. Reglas de autorización del agente

Un agente con `sucursal_id = 2` reclama jobs donde:

- `sucursal_id = 2` **o** `sucursal_id IS NULL` (tickets de prueba)
- Y `configuracion_hardware_id` está en `impresora_ids` (o lista vacía = todas)

Al confirmar `complete`/`fail`, misma validación (`agentePuedeGestionarJob`).

---

## 13. Checklist para implementar en otro proyecto (prompt para Cursor)

Copia este bloque al otro repo:

---

**Tarea:** Implementar impresión térmica con cola y agente local según especificación `especificacion-impresion-cola.md`.

**Backend:**

1. Migraciones: `print_jobs`, `print_agent_tokens`, `impresora_usuario`
2. `config/printing.php` + `PRINT_MODE` en `.env`
3. Modelos: `PrintJob`, `PrintAgentToken`; extender modelo Impresora con `usuariosPermitidos()`
4. Servicios: `ImpresionService`, `ImpresoraAccessService`, `PrintJobService`, `TicketService`
5. Middleware `AuthenticatePrintAgent` + registro en Kernel
6. Controladores: `PrintAgentController`, `PrintJobController`
7. Rutas API según sección 7.5
8. Migración permisos Spatie `impresion.*` asignados a roles de venta
9. En el controlador de ventas: tras crear venta, llamar `TicketService::imprimirTicket()` y devolver `impresion_venta`
10. Endpoint `reimprimir-ticket` y `probar-impresion` en hardware
11. `composer require mike42/escpos-php`

**Frontend:**

1. `TicketRepository.js`, `PrintJobRepository.js`
2. Composable `use-ticket-impresion.js`
3. Integrar notificación en POS tras crear venta
4. Botón reimprimir en historial con permiso `impresion.reimprimir`
5. Vistas `/impresion/cola` y `/impresion/estado-agente` + router + sidebar

**Agente local:**

1. Copiar carpeta `print-agent/` completa
2. `composer install` en PC de caja
3. Configurar `.env` con `API_URL` y `AGENT_TOKEN`
4. Crear agente desde admin o `print-agent:token`
5. Ejecutar `run.bat` o VBS de inicio automático

**Producción:**

- Servidor: `PRINT_MODE=queue`
- PC caja: agente corriendo + impresora configurada en admin (IP o nombre Windows)
- Usuarios POS: permisos `impresion.imprimir` y `impresion.reimprimir`

**Adaptaciones al otro proyecto:**

- Renombrar `Venta` / `venta_id` si el documento es otro (ej. `Pedido`)
- Ajustar `generarTicketVenta()` al layout del ticket
- Tabla impresoras: puede llamarse distinto; mantener `impresora_snapshot` con los mismos keys
- Si no hay sucursales: `sucursal_id` nullable en jobs y un solo agente global

---

## 14. Archivos de referencia en este repositorio

| Pieza | Ruta |
|-------|------|
| Config | `config/printing.php` |
| Cola | `app/Services/PrintJobService.php` |
| Ticket | `app/Services/TicketService.php` |
| ESC/POS | `app/Services/ImpresionService.php` |
| Permisos impresora | `app/Services/ImpresoraAccessService.php` |
| API agente | `app/Http/Controllers/PrintAgentController.php` |
| API cola | `app/Http/Controllers/PrintJobController.php` |
| Middleware | `app/Http/Middleware/AuthenticatePrintAgent.php` |
| Rutas | `routes/api.php` (bloques print-agent y print-jobs) |
| Migraciones | `database/migrations/2026_06_27_00000*.php` |
| Agente standalone | `print-agent/` |
| Frontend cola | `resources/js/src/views/impresion/cola.vue` |
| Frontend estado | `resources/js/src/views/impresion/estado-agente.vue` |
| Composable | `resources/js/src/composables/use-ticket-impresion.js` |

---

## 15. Errores frecuentes

| Síntoma | Causa | Solución |
|---------|-------|----------|
| No encola en POS/historial | Rol sin `impresion.imprimir` / `reimprimir` | Migración permisos + re-login |
| Botón reimprimir no aparece | Permisos en store desactualizados | Cerrar sesión |
| Cola vacía | `PRINT_MODE=direct` | Cambiar a `queue` |
| Agente 401 | Token incorrecto | Regenerar en cola → actualizar `.env` agente |
| Job 403 al completar | `sucursal_id` del job ≠ agente | Ticket prueba debe tener sucursal del usuario o null |
| No imprime físicamente | Nombre Windows incorrecto en caja | Probar impresión desde PC con mismo nombre/IP |
| `sin_impresora` | No hay impresora activa por defecto | Marcar una en config hardware |

---

*Generado desde proyecto entradas_volteos — arquitectura Laravel 10 + Vue 3 + Spatie Permission + ESC/POS.*
