# Limpioo Console by Netwash

Panel web de administracion para operadores y merchants del ecosistema Limpioo.

Limpioo Console permite gestionar cuentas SaaS, conectarse a la API de Netwash, editar configuraciones de dispositivos, publicar recompensas y suscripciones, generar ventas asistidas con QR y operar sobre los datos del tenant desde una interfaz separada de la webapp cliente.

Este README documenta solo el proyecto `limpioo.console`. La webapp cliente `limpioo` tiene su propio README y su propio ciclo funcional.

Ultima revision: Abril 2026

---

## Indice

- [Resumen](#resumen)
- [Independencia del proyecto](#independencia-del-proyecto)
- [Punto de interconexion con limpioo](#punto-de-interconexion-con-limpioo)
- [Arquitectura](#arquitectura)
- [Entornos](#entornos)
- [Requisitos](#requisitos)
- [Instalacion](#instalacion)
- [Estructura del proyecto](#estructura-del-proyecto)
- [Autenticacion y sesiones](#autenticacion-y-sesiones)
- [Variables de sesion](#variables-de-sesion)
- [Modelo de datos](#modelo-de-datos)
- [Modulos principales](#modulos-principales)
- [Sistema de ventas hibrido](#sistema-de-ventas-hibrido)
- [Sistema QR](#sistema-qr)
- [Sistema de devoluciones](#sistema-de-devoluciones)
- [Sistema de movimientos](#sistema-de-movimientos)
- [Sistema de facturas y Verifacti](#sistema-de-facturas-y-verifacti)
- [Endpoints AJAX completos](#endpoints-ajax-completos)
- [Includes y librerias internas](#includes-y-librerias-internas)
- [Librerias JavaScript propias](#librerias-javascript-propias)
- [table.js referencia de API](#tablejs-referencia-de-api)
- [Internacionalizacion](#internacionalizacion)
- [Sistema de impresion](#sistema-de-impresion)
- [Data Manager y backups SIF](#data-manager-y-backups-sif)
- [Seguridad](#seguridad)
- [Deuda tecnica conocida](#deuda-tecnica-conocida)
- [Operacion y mantenimiento](#operacion-y-mantenimiento)
- [Indice de documentacion](#indice-de-documentacion)
- [public/temp/](#publictemp)
- [Changelog](#changelog)

---

## Resumen

Responsabilidades principales de `limpioo.console`:

- dashboard y metricas para el operador
- alta de cuentas SaaS y aprovisionamiento de base de datos dedicada
- gestion de configuracion de dispositivos
- edicion y publicacion de recompensas y suscripciones
- generacion manual de codigos QR y ventas asistidas
- consulta de ventas, devoluciones y movimientos
- exportacion e importacion cifrada de datos del tenant
- gestion de facturas con Verifacti
- mantenimiento operativo del despliegue

Este proyecto es un back-office PHP con interfaz web propia. No contiene el checkout cliente, no procesa el flujo Redsys del usuario final y no activa directamente el servicio desde el navegador del cliente. Esas responsabilidades viven en `limpioo`.

---

## Independencia del proyecto

`limpioo.console` y `limpioo` son proyectos independientes.

Separacion real:

- cada uno tiene su propio `README.md`, su propio `public/` y su propio flujo de entrada
- la consola autentica con email y password de operador, mientras que `limpioo` usa cookie `NETSESSID`, UUID y verificacion por email del cliente final
- la consola administra datos y configuraciones; `limpioo` ejecuta la experiencia de compra, pago y activacion
- la consola puede desplegarse y mantenerse como panel administrativo aunque el cliente final no navegue por ella

Regla practica:

- documenta aqui solo el comportamiento administrativo y operacional de la consola
- documenta en `limpioo/README.md` solo el comportamiento de la webapp cliente
- si un cambio afecta a ambos, deja el contrato compartido en ambos README, pero no mezcles las responsabilidades

---

## Punto de interconexion con limpioo

La interconexion entre ambos proyectos existe, pero es limitada y concreta.

### 1. Comparten entorno y API de Netwash

- `limpioo.console/includes/config.php` resuelve `development` y `production` para la consola
- `limpioo/includes/config.php` hace lo mismo para la webapp cliente
- si ambos proyectos conviven en la misma instalacion, deben apuntar al mismo entorno para que las `apikey`, los dispositivos y las bases de datos tenant coincidan

### 2. La consola genera previews que consume limpioo

- `limpioo.console/public/apprewards.php` y `limpioo.console/public/appsubscriptions.php` crean una previsualizacion llamando a `POST /app/1.0/device/preview/create`
- la consola muestra un QR con `?in=<previewKey>` para abrir la vista previa desde la app web
- `limpioo/public/index.php` detecta esas keys de preview y entra en `PREVIEW_MODE`
- `limpioo/public/index.php` consulta `GET /app/1.0/device/preview`
- `limpioo/includes/preview/preview.php` renderiza la configuracion simulada con el look & feel de la app

### 3. Comparten topologia de datos, no el runtime del usuario

- la consola aprovisiona cuentas SaaS y bases de datos dedicadas mediante `ajax/auth/signup/post.php`
- `limpioo` consume despues esas configuraciones y las bases tenant para operar con clientes finales
- esto es acoplamiento de datos y de operacion, no una dependencia directa del runtime de la compra

### 4. Hay tooling cruzado de despliegue y verificacion

- `scripts/deploy.sh` puede configurar `limpioo.console` y, si el directorio hermano existe, tambien `limpioo`
- `tests/verify_ecosystem.php` comprueba la convivencia de ambos proyectos
- ese tooling compartido no cambia la separacion funcional: el back-office sigue aqui y la experiencia cliente sigue en `limpioo`

---

## Arquitectura

```text
                    Navegador operador
                           |
                           v  HTTP/HTTPS
          +------------------------------------------------+
          |  PRESENTACION  public/*.php                    |
          |  login  index  sales  customers  makecode      |
          |  appconfig  apprewards  appsubscriptions  ...  |
          |                                                |
          |  componets/  navbar.php   footer.php           |
          +------------------+-----------------------------+
                             |
               ..............|..............
               .  HTML/SSR   .  AJAX/fetch .
               .             .             .
               v             v             v
          +------------------------------------------------+
          |  LOGICA  ajax/**                               |
          |  auth/         code/        customer/          |
          |  data/         sales/       printer/           |
          |  index/metrics/                                |
          +------------------+-----------------------------+
                             |
                             v  require_once
          +------------------------------------------------+
          |  NUCLEO  includes/                             |
          |  config.php            global.php              |
          |  SessionHandler.php    DataManager.php         |
          |  i18n.php              sif_helpers.php         |
          |                        sif_audit.php           |
          +----------+---------------------------+---------+
                     |                           |
                     v                           v
     +--------------------+     +--------------------------------+
     |  BD maestra        |     |  BD tenant (por operador)      |
     |  limpioo           |     |  limpioo_userdb_N              |
     |                    |     |                                |
     |  users             |     |  users          log            |
     |  users_databases   |     |  qr             qr_items       |
     |  sessions          |     |  qr_usage_log   subscriptions  |
     |  fcm_tokens        |     |  subscription_members          |
     |  user_uuid_dbs     |     |  rewards        tokenization   |
     +--------------------+     |  globals        codes          |
                                |  uses           fcm_tokens     |
                                +--------------------------------+

          +------------------------------------------------+
          |  SERVICIOS EXTERNOS  (llamadas HTTP salientes) |
          |                                                |
          |  Netwash API    dispositivos, preview, publish |
          |  Redsys         webhook pago, gateway refund   |
          |  Verifacti      facturas electronicas AEAT     |
          |  limpioo.print  impresion termica (PDF/temp)   |
          +------------------------------------------------+
```

> **No pertenece a este proyecto:** el checkout del cliente final, el flujo Redsys del usuario y la activacion del servicio tras el pago. Esas responsabilidades viven en `limpioo`.

---

## Entornos

| Entorno | Main URL | API URL |
| --- | --- | --- |
| `development` | `https://developer.netwash.app/limpioo.console` o el host actual | `https://developer.netwash.app:1880` |
| `production` | `https://limpioo.console.netwash.app` o el host actual | `https://api.netwash.app:1880` |

Deteccion de entorno (orden de prioridad):

1. variable del servidor `LIMPIOO_CONSOLE_ENV`
2. archivo local `includes/env.local.php`
3. por defecto `production`

Archivo plantilla:

```bash
cp includes/env.local.php.example includes/env.local.php
```

Nota: si tambien esta desplegado `limpioo`, ambos proyectos deben usar el mismo entorno efectivo.

---

## Requisitos

- PHP 7.4 o superior con extensions: `mysqli`, `curl`, `json`, `openssl`, `zip`
- MariaDB/MySQL 10+ accesible desde PHP, con tablas de timezone cargadas (`mysql_tzinfo_to_sql`)
- Servidor web Apache/Nginx con `public/` como document root
- Acceso a la API de Netwash (`apikey` por tenant)
- Permisos de escritura sobre `public/temp/qr` y `public/temp/`
- Esquema maestro `limpioo` creado con el script de base de datos

---

## Instalacion

### Opcion rapida — script de autoConfiguracion

Crea las bases de datos, configura la zona horaria y provisiona el usuario MySQL en un solo paso:

```bash
sudo bash database/setup.sh
```

El script es interactivo. Pregunta las credenciales root de MySQL, el usuario de aplicacion y el nombre de la primera BD tenant. Al finalizar muestra un resumen de verificacion.

### Opcion manual

1. Clonar el repositorio en el servidor web.

   ```bash
   git clone <repo> /var/www/html/limpioo.console
   ```

2. Configurar el entorno local.

   ```bash
   cp includes/env.local.php.example includes/env.local.php
   ```

3. Crear los symlinks esperados bajo `public/`.

   ```bash
   cd /var/www/html/limpioo.console/public
   ln -sf ../includes includes
   ln -sf ../assets assets
   ln -sf ../ajax ajax
   ln -sf ../componets componets
   ```

4. Crear el directorio temporal para QR.

   ```bash
   mkdir -p public/temp/qr
   chmod 775 public/temp/qr
   ```

5. Crear los esquemas de base de datos.

   ```bash
   # BD maestra
   mysql -u root -p < database/admin/v2.0/database.sql

   # BD tenant (una por operador; sustituir N por el numero)
   mysql -u root -p -e "CREATE DATABASE limpioo_userdb_1"
   mysql -u root -p limpioo_userdb_1 < database/application/v2.0/database.sql
   ```

6. Si existe el directorio hermano `limpioo`, puedes usar el script de despliegue conjunto.

   ```bash
   bash scripts/deploy.sh
   ```

### Configuracion que no esta completamente centralizada

La consola arrastra configuracion maestra repartida en varios puntos. Si cambias credenciales o estrategia de aprovisionamiento, revisa como minimo:

- `includes/global.php`
- `includes/SessionHandler.php`
- `includes/DataManager.php`
- `ajax/auth/signin/post.php`
- `ajax/auth/signup/post.php`
- `ajax/account/update.php`

### Verificacion

Cuando `limpioo.console` y `limpioo` conviven en el mismo host:

```bash
php /var/www/html/limpioo.console/tests/verify_ecosystem.php
```

---

## Estructura del proyecto

```text
limpioo.console/
├── README.md
├── ajax/                          # Endpoints AJAX del panel
│   ├── account/
│   │   └── update.php
│   ├── auth/
│   │   ├── heartbeat.php
│   │   ├── signin/post.php
│   │   └── signup/post.php
│   ├── code/
│   │   ├── active.php
│   │   └── make.php
│   ├── customer/
│   │   ├── del.php
│   │   ├── get.php
│   │   ├── all/get.php
│   │   └── subscription_members/get.php
│   ├── data/
│   │   ├── export.php
│   │   ├── import.php
│   │   └── validate.php
│   ├── index/metrics/
│   │   ├── top/customers/get.php
│   │   └── total/
│   │       ├── concurrent/get.php
│   │       ├── expired/get.php
│   │       ├── mostprofitable/get.php
│   │       ├── products/get.php
│   │       ├── products/grouped/get.php
│   │       ├── products_promo/get.php
│   │       ├── products_refunded/get.php
│   │       ├── refunds/get.php
│   │       ├── sales/get.php
│   │       ├── sales_promo/get.php
│   │       ├── subscriptions_amount/get.php
│   │       ├── subscriptions_uses/get.php
│   │       ├── topsellers/get.php
│   │       └── interactive/
│   │           ├── expired/get.php
│   │           ├── products/get.php
│   │           ├── products_promo/get.php
│   │           ├── products_refunded/get.php
│   │           ├── refunds/get.php
│   │           ├── sales/get.php
│   │           ├── sales_promo/get.php
│   │           ├── subscriptions_amount/get.php
│   │           └── subscriptions_uses/get.php
│   ├── printer/
│   │   ├── code/ticket-pdf.php
│   │   ├── reports/common/make.php
│   │   ├── sale/delivery-note/make.php
│   │   ├── sale/invoice/make.php
│   │   ├── sale/simplified-invoice/make.php
│   │   └── use/ticket/make.php
│   └── sales/
│       ├── debug_refund.php
│       ├── extend_expiration.php
│       ├── get.php
│       ├── get_refund_reasons.php
│       ├── notify.php
│       ├── notify_complex.php
│       ├── notify_old.php
│       ├── refund.php
│       ├── search.php
│       ├── search_new.php
│       ├── actions/refund_qr.php
│       ├── all/get.php
│       ├── customer/update.php
│       ├── details/get.php
│       ├── invoice/add.php
│       ├── invoice/annulment/doit.php
│       └── movements/
│           ├── legacy_movements.php
│           └── qr_movements.php
├── assets/
│   ├── bootstrap-5.3.3/
│   ├── bootstrap-icons-1.11.3/
│   ├── chart@v4.4.9/             # Chart.js + plugin zoom
│   ├── css/
│   │   ├── application.css       # Estilos generales del panel
│   │   ├── holo.css              # Tema holo
│   │   ├── loader.css
│   │   └── style.css
│   ├── dayjs@1/
│   ├── flag-icons@7.2.3/
│   ├── fontawesome-free-6.7.2-web/
│   ├── img/
│   ├── jquery-3.7.1/
│   ├── js/                       # Librerias JS propias
│   │   ├── form.js
│   │   ├── fullscreen.js
│   │   ├── money.js
│   │   ├── session-handler.js
│   │   ├── table.js
│   │   ├── table.md              # Documentacion de table.js
│   │   ├── timeline.js
│   │   └── toast.js
│   ├── palettes/                 # Paletas de colores JSON para Chart.js
│   ├── qrcodejs/
│   └── sweetalert2@11/
├── componets/                    # Componentes compartidos (typo legacy)
│   ├── navbar.php
│   └── footer.php
├── database/
│   ├── setup.sh                  # Script de autoConfiguracion
│   ├── admin/
│   │   └── database.sql          # Esquema BD maestra limpioo
│   └── application/
│       └── v1.0/
│           └── database.sql      # Esquema plantilla BD tenant
├── docs/
│   ├── data-manager.md
│   ├── netwash_2.0_rev_1.4.pdf
│   └── ticket-pdf-system.md
├── includes/
│   ├── config.php
│   ├── DataManager.php
│   ├── env.local.php             # (no versionado)
│   ├── env.local.php.example
│   ├── global.php
│   ├── i18n.php
│   ├── SessionHandler.php
│   ├── sif_audit.php
│   └── sif_helpers.php
├── locale/
│   └── index/
│       ├── en.php
│       └── es.php
├── public/                       # Document root
│   ├── account-settings.php
│   ├── appconfig.php
│   ├── apprewards.php
│   ├── appsubscriptions.php
│   ├── blankPage.php
│   ├── customers.php
│   ├── data-manager.php
│   ├── index.php
│   ├── login.php
│   ├── logout.php
│   ├── makecode.php
│   ├── register.php
│   ├── sales.php
│   ├── scancode.php
│   ├── tablePage.php
│   ├── timeline.php
│   ├── temp/                     # PDFs y QRs temporales (no versionado)
│   ├── ajax -> ../ajax
│   ├── assets -> ../assets
│   ├── componets -> ../componets
│   └── includes -> ../includes
├── scripts/
│   ├── cleanup_temp_qr.sh
│   ├── deploy.sh
│   ├── limpioo.print.py
│   ├── limpioo.print.ini
│   ├── limpioo.print install readme.md
│   ├── Thermal Pritner Windows Test Tools install readme.md
│   └── nssm-2.24/
├── tests/
│   └── verify_ecosystem.php
└── vendor/
    ├── PDF_Code128/
    └── phpqrcode/
```

---

## Autenticacion y sesiones

### Alta de cuenta SaaS

`ajax/auth/signup/post.php`:

1. Valida email y verifica que no exista ya en `limpioo.users`
2. Hashea la password con `password_hash()`
3. Genera un nombre de BD dedicada `limpioo_userdb_<id>` y un usuario MySQL unico
4. Cifra la password del usuario MySQL con AES-256-GCM (clave base64 fija en `$encKeyBase64`)
5. Guarda `iv`, `tag` y `db_password_enc` en `limpioo.users_databases`
6. Crea la BD y el usuario MySQL ejecutando `GRANT ALL PRIVILEGES`
7. Importa el esquema base desde `LIMPIOO_DB_TEMPLATE_SQL` (constante definida en `global.php`)
8. Devuelve `{ success: true }` al navegador

El usuario MySQL que ejecuta el signup necesita `GRANT OPTION` a nivel global.

### Inicio de sesion

`ajax/auth/signin/post.php`:

1. Valida email y password contra `limpioo.users`
2. Recupera la fila de `limpioo.users_databases`
3. Descifra `db_password_enc` con AES-256-GCM usando `iv` y `tag`
4. Prueba la conexion real a la BD tenant
5. Si la prueba tiene exito, guarda en `$_SESSION` todas las variables listadas en la siguiente seccion
6. Devuelve `{ success: true }`

### Persistencia de sesion

`includes/SessionHandler.php`:

- Implementa `SessionHandlerInterface` y almacena sesiones en `limpioo.sessions`
- Duracion: 30 dias (2592000 segundos)
- Registra `user_id`, `user_agent`, `ip_address` y `expires_at`
- Regenera el `session_id` cada 24 horas para reducir riesgo de fijacion de sesion
- Cookie configurada con `httponly: true`, `samesite: Lax` y `secure: true` si hay HTTPS

### Heartbeat

`ajax/auth/heartbeat.php` recibe peticiones POST periodicas desde `assets/js/session-handler.js` para actualizar `last_activity` en la tabla de sesiones y evitar que expire la sesion del operador mientras el panel esta abierto.

---

## Variables de sesion

Disponibles despues del login en todos los endpoints AJAX que incluyen `global.php`:

| Variable | Tipo | Descripcion |
| --- | --- | --- |
| `$_SESSION['user_id']` | int | ID del operador en `limpioo.users` |
| `$_SESSION['email']` | string | Email del operador |
| `$_SESSION['server']` | string | Host MySQL del tenant (`localhost`) |
| `$_SESSION['username']` | string | Usuario MySQL del tenant |
| `$_SESSION['password']` | string | Password MySQL del tenant (descifrada en memoria) |
| `$_SESSION['dbname']` | string | Nombre de la BD tenant (`limpioo_userdb_N`) |
| `$_SESSION['apikey']` | string | API key para llamadas a la API Netwash |
| `$_SESSION['APP_ROOT']` | string | Ruta absoluta al directorio raiz del proyecto |
| `$_SESSION['_last_regeneration']` | int | Timestamp UNIX de la ultima regeneracion de sesion |

Todos los endpoints deben verificar `isset($_SESSION['user_id'])` antes de procesar la peticion. Si no existe, responder con HTTP 401.

---

## Modelo de datos

### Base maestra `limpioo`

Esquema definido en `database/admin/v2.0/database.sql`.

| Tabla | Funcion |
| --- | --- |
| `users` | Cuentas de operador del SaaS. Campos: `id`, `email`, `password_hash`, `email_verified`, `created_at` |
| `users_databases` | Relacion operador → BD tenant con credenciales cifradas. Campos: `db_name`, `db_username`, `db_password_enc` (AES-256-GCM), `iv`, `tag`, `api_key`, `enabled` |
| `sessions` | Sesiones PHP almacenadas en BD por `SessionHandler.php`. Campos: `id`, `user_id`, `data`, `last_activity`, `expires_at`, `user_agent`, `ip_address` |
| `fcm_tokens` | Tokens FCM para notificaciones push globales a nivel de plataforma |
| `user_uuid_databases` | Cache de mapeo UUID de cliente → nombre de BD tenant. Permite resolucion O(1) sin escanear todas las BDs |

### Base tenant `limpioo_userdb_N`

Esquema plantilla en `database/application/v2.0/database.sql`. Se crea una BD por operador durante el signup.

| Tabla | Funcion |
| --- | --- |
| `users` | Clientes finales del lavadero. Campos: `id`, `email`, `uuid`, `confirmed`, `password`, `WHO`, `WHERE` |
| `codes` | Codigos de verificacion de email de 4 digitos. Clave primaria compuesta `(UUID, CODE)` |
| `globals` | Almacen key-value para configuracion del tenant. Cada fila es una variable de configuracion con timestamp |
| `log` | **Ventas legacy**. Cada fila = 1 operacion de pago desde la app Redsys. Campos clave: `Id`, `UUID`, `P_DATE` (texto, hora Madrid), `P_ORDER`, `P_AMOUNT`, `P_DEBIT`, `P_DATA` (JSON), `USED`, `U_DATE`, `R_DATE`, `R_DATA`, `HUB`, `THING`, `NAME`, `G_POINTS`, `G_AMOUNT`, `SUBSCRIPTION`, `SUBSCRI_WASH`, `LICENSE_PLATE` |
| `rewards` | Saldo de recompensas por usuario y moneda. Clave unica `(UUID, CURRENCY)` |
| `tokenization` | Tokens de pago recurrente Redsys COF. Clave unica `WIU = MD5(UUID.HUB.WHERE)` |
| `subscriptions` | Suscripciones activas de usuarios. Una fila por combinacion `(uuid, item_id, license_plate)`. Estados: `new`, `active`, `cancelled`, `expired` |
| `subscription_members` | Miembros familiares de suscripciones multi-matricula. Estados: `pending`, `active`, `grace`, `removed` |
| `qr` | **Ventas QR**. Cabecera de cada QR generado en consola. Campos clave: `id`, `code` (6 chars), `customer_email`, `location`, `device_name`, `payment_method`, `total_amount`, `status`, `valid_from`, `valid_until`, `created_at` |
| `qr_items` | Items individuales dentro de un QR (1 QR → N items). Campos clave: `qr_id`, `product_name`, `quantity`, `quantity_used`, `quantity_refunded`, `quantity_remaining` (columna calculada), `unit_price`, `first_used_at`, `last_used_at` |
| `qr_usage_log` | Registro de cada uso individual de un item QR. Un registro por escaneo. Campos: `qr_item_id`, `qr_id`, `qr_code`, `quantity_used`, `used_at`, `used_by_device`, `program_name` (nombre corto del programa resuelto via API, ej: "Pista 3"; NULL si no aplica), `device_thing` (slug del dispositivo fisico, ej: "Box-1-4"; coincide con `log.THING` para consolidar ambas fuentes en el dashboard), `location`, `notes`, `user_uuid` |
| `uses` | Contador de uso de la API por dispositivo y fecha |
| `fcm_tokens` | Tokens FCM de clientes finales para notificaciones push del tenant |

---

## Modulos principales

| Ruta | Funcion |
| --- | --- |
| `public/index.php` | Dashboard con metricas, KPIs, graficos (Chart.js) y tabla top clientes. Usa `i18n.php` para traduccion (idioma desde cookie `selectedLanguage`) |
| `public/login.php` | Inicio de sesion del operador. Sin autenticacion previa requerida |
| `public/register.php` | Registro de nueva cuenta SaaS. Llama a `ajax/auth/signup/post.php` |
| `public/logout.php` | Cierre de sesion y redireccion a `login.php` |
| `public/sales.php` | Vista hibrida de ventas Legacy y QR, devoluciones, documentos y movimientos. 3332 lineas |
| `public/makecode.php` | Generacion manual de codigos/QR y flujo asistido por dispositivo. Puede actuar en modo AJAX cuando recibe `?ajax=get_device_programs` |
| `public/appconfig.php` | Configuracion operativa del dispositivo. Requiere `key` via POST |
| `public/apprewards.php` | Edicion de recompensas y vista previa antes de publicar |
| `public/appsubscriptions.php` | Edicion de suscripciones y preview/publicacion via API |
| `public/data-manager.php` | Exportacion e importacion cifrada de datos del tenant |
| `public/customers.php` | Operacion sobre clientes del tenant. Carga datos via `$load()` helper interno |
| `public/account-settings.php` | Ajustes de cuenta: password con medidor de fortaleza (zxcvbn) y `api_key` |
| `public/timeline.php` | Vista temporal de operaciones. Renderizada por `assets/js/timeline.js` |
| `public/scancode.php` | Interfaz de codigo de activacion de 6 digitos (html5-qrcode). Sin autenticacion requerida |
| `public/blankPage.php` | Plantilla vacia para desarrollo de nuevas paginas |
| `public/tablePage.php` | Plantilla con tabla para desarrollo de nuevas paginas |

---

## Sistema de ventas hibrido

`public/sales.php` y `ajax/sales/all/get.php` implementan una vista unificada de dos sistemas de venta que coexisten:

### Ventas Legacy

- Origen: tabla `log` de la BD tenant
- Generadas por el flujo Redsys de la app `limpioo`
- Identificadas por `P_ORDER` (numero de pedido Redsys)
- Fechas: `P_DATE`, `U_DATE`, `R_DATE` almacenadas como texto en formato `dd/mm/yyyy HH:mm:ss` en hora de Madrid (escritas directamente por la app)
- Estado de devolucion: `R_DATE` no nulo = devuelta
- Estado de uso: campo `USED` (tinyint)

### Ventas QR

- Origen: tablas `qr`, `qr_items` y `qr_usage_log`
- Generadas por el operador desde `makecode.php`
- Identificadas por `qr.code` (6 caracteres)
- Fechas: `DATETIME` nativo de MySQL (ahora en hora de Madrid por configuracion de timezone)
- Estado: campo `qr.status` (`pending`, `active`, `used`, `expired`, `refunded`, `partial_refund`)

### Normalizacion para la tabla

`ajax/sales/all/get.php` ejecuta dos consultas separadas y unifica los resultados bajo un esquema comun:

| Campo unificado | Legacy | QR |
| --- | --- | --- |
| `source_system` | `legacy` | `qr` |
| `order_reference` | `P_ORDER` | `qr.code` |
| `customer_email` | JOIN con `users.email` por UUID | `qr.customer_email` |
| `sale_date` | `P_DATE` (texto) | `DATE_FORMAT(created_at, ...)` |
| `total_amount` | `P_AMOUNT` | `total_amount` |
| `wash_type` | segun campos (`SUBSCRIPTION`, `G_POINTS`, `SUBSCRI_WASH`) | segun campos QR |

### Tipos de lavado (wash_type)

| Valor | Condicion de deteccion | Badge |
| --- | --- | --- |
| `Pago` | `SUBSCRIPTION=0`, `G_POINTS` vacio, `SUBSCRI_WASH=0` | `btn-primary` |
| `Suscripcion` | `SUBSCRIPTION=1` o `SUBSCRI_WASH=1` | `btn-success` |
| `Recompensa` | `G_CURRENCY` no vacio, `G_POINTS` no vacio, `SUBSCRIPTION=0` | `btn-warning` |
| `Promocion` | `G_POINTS` no vacio, `SUBSCRIPTION=0`, `G_CURRENCY` vacio | `btn-promo` (naranja) |

### Configuracion de columnas persistida

`sales.php` guarda la visibilidad y el orden de las columnas de la tabla en `localStorage` bajo la clave `limpioo_sales_columns_v1`. Al cargar la pagina, `applyColumnConfig()` restaura el estado guardado. El usuario puede cambiar columnas desde un offcanvas con toggles y drag-and-drop.

### Busqueda cliente

La busqueda de texto (campo `#any-search`) opera sobre un cache normalizado `salesNormalizedCache` construido en el cliente al cargar los datos. Incluye campos calculados como `status_label` y `wash_type_label` para que los badges sean buscables.

---

## Sistema QR

El sistema QR permite al operador generar codigos de un solo uso o multi-uso que el cliente canjea en la maquina de lavado.

### Flujo completo de generacion

```text
1. Operador entra en makecode.php
   - selecciona dispositivo (lista de $DEVICES desde API)
   - selecciona items/programas del dispositivo
   - configura cantidad, email del cliente, ubicacion y opciones de pago

2. makecode.php llama a ajax/code/make.php (POST)
   - crea registro en qr (status=pending)
   - crea registros en qr_items (uno por item seleccionado)
   - genera imagen QR con phpqrcode y la guarda en public/temp/qr/
   - genera PDF con ticket de 80mm si se solicita impresion
   - devuelve { success, qr_id, code, qr_image_url, pdf_url }

3. El cliente escanea el QR con la app limpioo o desde scancode.php
   - la app/scancode llama a ajax/code/active.php con { code, key }
   - active.php verifica el codigo y comprueba validez (status, valid_until)
   - llama a getDeviceInfo($device_key) → GET /app/1.0/device:
     extrae device_thing (ultimo segmento del topic, ej: "Box-1-4")
     y program_name (primer item activo, ej: "Pista 3") en formato corto
   - actualiza qr.status = 'active' o 'used'
   - inserta registro en qr_usage_log con program_name y device_thing resueltos
   - actualiza qr_items.quantity_used y last_used_at
   - actualiza qr.used_at
```

### Campos de estado del QR

| Estado | Significado |
| --- | --- |
| `pending` | Generado, no usado aun |
| `active` | En uso (multi-uso con usos restantes) |
| `used` | Completamente agotado |
| `expired` | `used_at` es NULL y `valid_until` ha pasado |
| `refunded` | Devuelto completamente |
| `partial_refund` | Devuelcion parcial (algunos items devueltos) |

### Extension de expiracion

`ajax/sales/extend_expiration.php` permite al operador ampliar la fecha `valid_until` de un QR. Solo funciona sobre QRs con `status = 'expired'` o `valid_until` proxima.

### Ticket PDF del QR

`ajax/printer/code/ticket-pdf.php` genera un PDF de 80mm con:

- datos del dispositivo y ubicacion
- codigo QR impreso con `phpqrcode`
- codigo de barras Code128 con `PDF_Code128`
- lista de items con cantidades y precios

---

## Sistema de devoluciones

Hay dos mecanismos independientes segun el origen de la venta.

### Devolucion legacy

`ajax/sales/refund.php`:

- Solo acepta `source_system = 'legacy'`
- Busca en `log` por `Id`
- Verifica que `R_DATE` sea NULL (no devuelta ya)
- Construye una peticion de devolucion Redsys con `P_ORDER`, `P_DEBIT` y credenciales del merchant
- Llama a la API del gateway de pago
- Si tiene exito, actualiza `log.R_DATE` y `log.R_DATA` con los datos de la devolucion
- Si `P_DEBIT == 0`, usa un fallback de valor simulado para testing

Nota: el endpoint tiene codigo de debug activo (valor simulado, forzado a tipo `gateway`). Ver [deuda tecnica](#deuda-tecnica-conocida).

### Devolucion QR

`ajax/sales/actions/refund_qr.php`:

- Acepta una lista `selected_services` con los items especificos a devolver (granular)
- Cada item indica `product_name`, `price` e `is_used` (si ya fue canjeado)
- Usa transaccion MySQL (`autocommit(false)`) para hacer atomicamente:
  - actualizar `qr_items.quantity_refunded`
  - insertar en `qr_usage_log` con notas de devolucion
  - actualizar `qr.status` a `refunded` o `partial_refund`
- Devuelve el resumen de lo devuelto agrupado por producto, distinguiendo usados y no usados

### Motivos de devolucion

`ajax/sales/get_refund_reasons.php` devuelve los valores distintos del campo de motivo almacenados en BD para alimentar el desplegable del formulario de devolucion.

---

## Sistema de movimientos

Los movimientos muestran el historial detallado de una venta: datos originales, pagos, usos, devoluciones y recompensas.

### Movimientos legacy

`ajax/sales/movements/legacy_movements.php` (POST, param `sale_id`):

- Obtiene la fila de `log` con JOIN a `users` para el email
- Construye el desglose de la venta: datos de pago, importe real en EUR, descuento aplicado por recompensa (`G_AMOUNT`), importe cargado al cliente
- Calcula `realPriceEur = P_AMOUNT / 100`, `discountEur = G_AMOUNT / 100`, `chargedEur = realPriceEur - discountEur`
- Devuelve tambien datos de uso (`USED`, `U_DATE`) y devolucion (`R_DATE`, `R_DATA`)
- Si el pago usa recompensas (`G_POINTS` no vacio), expone el saldo de la tabla `rewards`

### Movimientos QR

`ajax/sales/movements/qr_movements.php` (POST, param `qr_id`):

- Obtiene cabecera del QR desde `qr`
- Lista todos los items del QR desde `qr_items`
- Para cada item, lista todos los usos desde `qr_usage_log`
- Calcula el resumen: total comprado, total usado, total devuelto, total restante
- Devuelve la estructura anidada: cabecera + items[] + cada item con usos[]

---

## Sistema de facturas y Verifacti

### Alta de factura

`ajax/sales/invoice/add.php` (POST, JSON body):

- Recibe `deliveryNoteIdList` (array de IDs de albaranes) e `isSimplified` (bool)
- Obtiene configuracion de Verifacti desde `company_configuration.verifacti_url` y `.verifacti_apikey`
- Si no hay configuracion, genera el documento sin registrar en Verifacti
- Valida NIF del cliente; si no es valido, fuerza tipo `F2` (factura simplificada)
- Calcula `sif_calc_hash()` para integridad
- Llama a la API de Verifacti y guarda `uuid`, `identifier` y `qr` en BD
- Maneja explicitamente respuestas 401 y errores AEAT

### Anulacion de factura

`ajax/sales/invoice/annulment/doit.php`:

- Genera una nota de credito en Verifacti referenciando la factura original
- Actualiza el estado de la factura en BD

---

## Endpoints AJAX completos

### Patron estandar de endpoint

Todos los endpoints siguen este patron:

```
1. require_once global.php
2. Verificar $_SESSION['user_id'] (HTTP 401 si no existe)
3. Verificar $_SERVER['REQUEST_METHOD'] (HTTP 405 si no coincide)
4. header('Content-Type: application/json; charset=utf-8')
5. Obtener y sanitizar entrada de $_POST o php://input
6. Conectar a BD tenant con credenciales de sesion
7. Ejecutar operacion con prepared statements
8. catch Throwable → log → JSON con success/message
```

### Autenticacion y cuenta

| Ruta | Metodo | Funcion |
| --- | --- | --- |
| `ajax/auth/signup/post.php` | POST | Alta de operador + BD dedicada + usuario MySQL + esquema tenant |
| `ajax/auth/signin/post.php` | POST | Login: valida, descifra creds, prueba conexion, carga sesion |
| `ajax/auth/heartbeat.php` | POST | Renueva timestamp de ultima actividad en sesion |
| `ajax/account/update.php` | POST | Actualiza password del operador y `api_key` |

### Codigos QR

| Ruta | Metodo | Funcion |
| --- | --- | --- |
| `ajax/code/make.php` | POST (JSON) | Crea QR + items en BD, genera imagen y PDF opcional |
| `ajax/code/active.php` | POST | Verifica y registra uso de un codigo de 6 caracteres |

### Clientes

| Ruta | Metodo | Funcion |
| --- | --- | --- |
| `ajax/customer/all/get.php` | GET/POST | Lista todos los clientes del tenant |
| `ajax/customer/get.php` | GET | Obtiene un cliente por `customerId` |
| `ajax/customer/del.php` | POST | Elimina un registro de cliente |
| `ajax/customer/subscription_members/get.php` | POST | Miembros de una suscripcion, filtrados opcionalmente por UUID del titular |

### Data Manager

| Ruta | Metodo | Funcion |
| --- | --- | --- |
| `ajax/data/export.php` | POST | Genera y descarga backup cifrado AES-256-GCM de la BD tenant |
| `ajax/data/validate.php` | POST | Valida integridad, formato y pertenencia de un archivo SIF |
| `ajax/data/import.php` | POST | Restaura la BD tenant desde un backup SIF validado |

### Metricas del dashboard

Todos POST. Alimentan `public/index.php`.

| Ruta | Funcion |
| --- | --- |
| `ajax/index/metrics/top/customers/get.php` | Top clientes por ventas y recurrencia media |
| `ajax/index/metrics/total/sales/get.php` | Total de ventas entre fechas |
| `ajax/index/metrics/total/refunds/get.php` | Total de devoluciones entre fechas |
| `ajax/index/metrics/total/products/get.php` | Datos de uso por producto |
| `ajax/index/metrics/total/products/grouped/get.php` | Productos agrupados con drill-down: nivel 1 por dispositivo, nivel 2 por programa (param `device_type`) |
| `ajax/index/metrics/total/products_refunded/get.php` | Productos devueltos |
| `ajax/index/metrics/total/expired/get.php` | QRs vendidos pero expirados sin usar |
| `ajax/index/metrics/total/concurrent/get.php` | Concurrencia por dia de semana / franja horaria |
| `ajax/index/metrics/total/mostprofitable/get.php` | Productos mas rentables |
| `ajax/index/metrics/total/topsellers/get.php` | Productos mas vendidos |
| `ajax/index/metrics/total/products_promo/get.php` | Total de lavados de promocion (G_POINTS > 0, sin suscripcion) entre fechas |
| `ajax/index/metrics/total/sales_promo/get.php` | Importe total de lavados de promocion/reward entre fechas |
| `ajax/index/metrics/total/subscriptions_amount/get.php` | Importe total de suscripciones (SUBSCRIPTION=1) entre fechas |
| `ajax/index/metrics/total/subscriptions_uses/get.php` | Total de lavados de suscripcion (SUBSCRI_WASH=1) entre fechas |
| `ajax/index/metrics/total/interactive/sales/get.php` | Ventas para vista interactiva con filtros de fecha |
| `ajax/index/metrics/total/interactive/refunds/get.php` | Devoluciones para vista interactiva |
| `ajax/index/metrics/total/interactive/products/get.php` | Productos para vista interactiva |
| `ajax/index/metrics/total/interactive/products_promo/get.php` | Lavados de promocion para vista interactiva (drill-down temporal) |
| `ajax/index/metrics/total/interactive/sales_promo/get.php` | Importe de promociones para vista interactiva (drill-down temporal) |
| `ajax/index/metrics/total/interactive/subscriptions_amount/get.php` | Importe de suscripciones para vista interactiva (drill-down temporal) |
| `ajax/index/metrics/total/interactive/subscriptions_uses/get.php` | Lavados de suscripcion para vista interactiva (drill-down temporal) |
| `ajax/index/metrics/total/interactive/products_refunded/get.php` | Productos devueltos para vista interactiva |
| `ajax/index/metrics/total/interactive/expired/get.php` | QRs expirados para vista interactiva |

### Ventas

| Ruta | Metodo | Funcion |
| --- | --- | --- |
| `ajax/sales/all/get.php` | POST | Vista unificada Legacy + QR con todos los detalles |
| `ajax/sales/get.php` | GET | Una venta especifica por `saleId` |
| `ajax/sales/details/get.php` | GET | Detalle enriquecido con referencia de documento |
| `ajax/sales/search.php` | POST | Busqueda legacy por filtros |
| `ajax/sales/search_new.php` | POST | Busqueda hibrida Legacy + QR |
| `ajax/sales/customer/update.php` | POST | Asocia `customer_id` a registros de venta |
| `ajax/sales/extend_expiration.php` | POST | Extiende `valid_until` de un QR |
| `ajax/sales/notify.php` | POST | Handler simplificado de notificacion Redsys (testing) |
| `ajax/sales/notify_complex.php` | POST | Handler completo de notificacion de devolucion Redsys |
| `ajax/sales/notify_old.php` | POST | Handler legacy Redsys (version anterior) |
| `ajax/sales/debug_refund.php` | — | Script de depuracion del flujo de devoluciones |

### Facturas

| Ruta | Metodo | Funcion |
| --- | --- | --- |
| `ajax/sales/invoice/add.php` | POST (JSON) | Genera factura/factura simplificada y la registra en Verifacti |
| `ajax/sales/invoice/annulment/doit.php` | POST | Genera nota de credito (anulacion) en Verifacti |

### Movimientos

| Ruta | Metodo | Funcion |
| --- | --- | --- |
| `ajax/sales/movements/legacy_movements.php` | POST (`sale_id`) | Log de movimientos completo de una venta legacy |
| `ajax/sales/movements/qr_movements.php` | POST (`qr_id`) | Log de movimientos completo de un QR con items y usos |

### Devoluciones

| Ruta | Metodo | Funcion |
| --- | --- | --- |
| `ajax/sales/refund.php` | POST | Devolucion de venta legacy via gateway Redsys |
| `ajax/sales/actions/refund_qr.php` | POST | Devolucion granular de QR con seleccion de items |
| `ajax/sales/get_refund_reasons.php` | GET | Motivos de devolucion distintos en BD |

### Impresion y documentos PDF

| Ruta | Metodo | Funcion |
| --- | --- | --- |
| `ajax/printer/code/ticket-pdf.php` | POST | Ticket PDF 80mm: QR, code128, items, datos dispositivo |
| `ajax/printer/sale/invoice/make.php` | POST | Factura PDF A4 agrupando albaranes |
| `ajax/printer/sale/simplified-invoice/make.php` | POST | Factura simplificada PDF 80x258mm |
| `ajax/printer/sale/delivery-note/make.php` | POST | Albaran PDF |
| `ajax/printer/use/ticket/make.php` | POST | Ticket PDF de uso de servicio |
| `ajax/printer/reports/common/make.php` | POST | Informes comunes desde datos POST |

---

## Includes y librerias internas

| Archivo | Funcion |
| --- | --- |
| `includes/config.php` | Deteccion de entorno (development/staging/production), URLs de API y main, flag de debug |
| `includes/global.php` | Arranca sesion en BD via `SessionHandler.php`, define `LIMPIOO_ROOT`, `LIMPIOO_DB_ADMIN_SQL`, `LIMPIOO_DB_TEMPLATE_SQL`, carga `config.php`, establece `date_default_timezone_set('Europe/Madrid')` |
| `includes/SessionHandler.php` | Implementa `SessionHandlerInterface` sobre `limpioo.sessions`. Duracion 30 dias, regenera ID cada 24h |
| `includes/DataManager.php` | Export/import cifrado AES-256-GCM de BDs tenant. Dump SQL + meta.json + firma OpenSSL en ZIP |
| `includes/i18n.php` | Funcion `__(string $locale, string $key, ?string $path)` con cache estatica por combinacion path+locale |
| `includes/sif_helpers.php` | `verify_sif_backup_zip()` (verifica ZIP SIF: SHA-256 + firma OpenSSL) y `logSifEvent()` |
| `includes/sif_audit.php` | Registro de eventos de auditoria SIF en base de datos |
| `includes/env.local.php` | Overrides locales de entorno, API URL y main URL (no versionado) |
| `includes/env.local.php.example` | Plantilla para crear `env.local.php` |

### Vendor (sin Composer)

| Libreria | Funcion |
| --- | --- |
| `vendor/phpqrcode/` | Generacion de imagenes QR en PNG |
| `vendor/PDF_Code128/` | Generacion de codigos de barras Code128 para PDFs |

---

## Librerias JavaScript propias

Ubicadas en `assets/js/`. Se cargan desde las paginas de `public/`.

| Archivo | Funcion |
| --- | --- |
| `table.js` | Tablas dinamicas con esquema JSON, paginacion, CRUD, eventos, exportar, busqueda y drag-and-drop de columnas |
| `form.js` | Validacion de formularios, serializacion y helpers de input |
| `money.js` | Formateo de moneda (EUR) y operaciones con decimales |
| `timeline.js` | Renderizado de vistas temporales y lineas de tiempo |
| `toast.js` | Notificaciones tipo toast para feedback al usuario |
| `session-handler.js` | Keep-alive de sesion: llama a `ajax/auth/heartbeat.php` periodicamente |
| `fullscreen.js` | Toggle de modo pantalla completa |

---

## table.js referencia de API

`initializeTable(tableId, data, schema, config)` devuelve un objeto con la siguiente API publica:

| Metodo | Firma | Descripcion |
| --- | --- | --- |
| `add()` | `add()` | Abre formulario o inserta fila inline |
| `export()` | `export()` | Exporta datos actuales (callback o descarga) |
| `addByJson(data)` | `addByJson(obj\|arr)` | Añade filas desde objeto o array JSON |
| `cancelInputs()` | `cancelInputs()` | Cancela la edicion inline activa |
| `getData()` | `getData() → Array` | Datos de todas las filas principales (`main-row`) |
| `updateRow(i, data)` | `updateRow(index, data)` | Actualiza celda(s) de una fila por indice |
| `updateRowByObject(i, data)` | `updateRowByObject(index, data)` | Reemplaza valores directamente en el DOM |
| `deleteRow(i)` | `deleteRow(index)` | Elimina fila principal y su `subtable-row` asociada |
| `fillEditingInputs(data)` | `fillEditingInputs(obj)` | Rellena los inputs del formulario abierto |
| `triggerOnSelect(i)` | `triggerOnSelect(index)` | Dispara `onSelect` manualmente en una fila |
| `getSelectedRowsData()` | `getSelectedRowsData() → Array` | Datos de filas marcadas (requiere `multipleSelect`) |
| `clear()` | `clear()` | Vacia el cuerpo de la tabla |
| `getSchema()` | `getSchema() → Array` | Copia profunda del schema |

Opciones del esquema de columna:

| Propiedad | Tipo | Descripcion |
| --- | --- | --- |
| `key` | string | Nombre del campo en el objeto de datos |
| `title` | string | Cabecera de la columna |
| `type` | string | `text`, `number`, `email`, `boolean`, `datetime`, etc. |
| `visible` | bool | Si se muestra o se oculta inicialmente |
| `editable` | bool | Si la columna es editable en linea |
| `required` | bool | Si es obligatoria al crear/editar |
| `visible_on_form` | bool | Si aparece en el formulario modal/offcanvas |

Callbacks de configuracion CRUD:

| Callback | Firma | Cuando se invoca |
| --- | --- | --- |
| `onAdd` | `(rowData, rowIndex)` | Al confirmar nueva fila |
| `onUpdate` | `(rowData, rowIndex)` | Al guardar cambios en una fila |
| `onDelete` | `(rowData, rowIndex, confirmFn, cancelFn)` | Al pulsar eliminar; llamar `confirmFn()` para ejecutar |
| `onSelect` | `(rowData, rowIndex)` | Al hacer clic en una fila principal |
| `onSearch` | `(rowData, $input, rowIndex)` | Al pulsar F2 en un input |
| `onExport` | `(jsonString)` | Al pulsar exportar |
| `onRowPrint` | `(rowData)` | Al pulsar el boton de imprimir de una fila |
| `onEditStart` | `(rowData, rowIndex) → bool` | Al iniciar edicion; devolver `false` cancela |
| `onAddStart` | `()` | Antes de abrir el formulario de nueva fila |
| `onDraw` | `()` | Tras renderizar todas las filas iniciales |

Modos de formulario: `inline`, `modal`, `offcanvas`.

### Subtablas relacionadas (filas expandibles)

Cuando se configura `crudConfig.subtable`, cada fila principal muestra un boton chevron en la columna de acciones. La carga es **lazy** (primera apertura).

| Propiedad | Tipo | Descripcion |
| --- | --- | --- |
| `schema` | Array | Esquema de columnas de la subtabla |
| `key` | string | Clave del objeto fila con datos embebidos (array) |
| `fetch` | Function | `(rowData) → Promise<Array>`. Prioridad sobre `key` |
| `showIf` | Function | `(rowData) → bool`. Decide si se muestra el boton chevron |

Clases CSS internas: `main-row` (filas principales), `subtable-row` (filas ocultas de subtabla).

Ejemplo de uso en `customers.php`:

```javascript
subtable: {
    schema: [ { key: 'email', title: 'Email', type: 'email', visible: true } ],
    showIf: function(row) { return parseInt(row.invited_members) > 0; },
    fetch: function(row) {
        return fetchJ('../ajax/customer/subscription_members/get.php', { uuid: row.uuid });
    }
}
```

---

## Internacionalizacion

Sistema basado en archivos PHP que devuelven arrays key-value.

- Funcion de traduccion: `__($locale, $key, $path)` definida en `includes/i18n.php`
- Idiomas disponibles: `es` (espanol), `en` (ingles)
- Archivos de locale: `locale/index/es.php`, `locale/index/en.php`
- El idioma se detecta desde la cookie `selectedLanguage` (por defecto `es`)
- Cache estatica por combinacion path+locale; evita lecturas repetidas en cada llamada

---

## Sistema de impresion

La consola incluye un sistema completo de impresion de tickets para impresoras termicas de 80mm.

### Componentes

1. **Generacion de PDF en servidor**: endpoints en `ajax/printer/` generan PDFs con FPDF + phpqrcode + PDF_Code128
2. **Servicio de impresion automatica en Windows**: `scripts/limpioo.print.py` (o `.exe` compilado) monitorea la carpeta de descargas y envia automaticamente los PDFs `limpioo_*.pdf` a la impresora via SumatraPDF
3. **Configuracion del servicio**: `scripts/limpioo.print.ini` define directorio a monitorear y ruta de SumatraPDF
4. **Service wrapper**: `scripts/nssm-2.24/` permite registrar `limpioo.print` como servicio de Windows

### Flujo de impresion

```text
Usuario genera ticket en consola web
  → Backend genera PDF en public/temp/
  → Navegador descarga limpioo_*.pdf
  → limpioo.print detecta el archivo
  → Envia a impresora via SumatraPDF
  → Elimina el PDF temporal
```

Documentacion detallada en:
- [scripts/limpioo.print install readme.md](scripts/limpioo.print%20install%20readme.md)
- [scripts/Thermal Pritner Windows Test Tools install readme.md](scripts/Thermal%20Pritner%20Windows%20Test%20Tools%20install%20readme.md)
- [docs/ticket-pdf-system.md](docs/ticket-pdf-system.md)

---

## Data Manager y backups SIF

El sistema SIF (Secure Import Format) permite exportar e importar la BD completa de un tenant de forma cifrada y verificable.

### Formato del backup

Un backup SIF es un ZIP que contiene tres archivos:

```
sif_meta_<timestamp>.json          ← metadatos: db_name, user_id, fecha, SHA-256
sif_meta_<timestamp>.json.sig      ← firma OpenSSL RSA-SHA256 del meta.json
<db_name>_<timestamp>.sql          ← dump SQL completo de la BD tenant
```

### Flujo de exportacion

```
ajax/data/export.php
  → DataManager::export()
    → mysqldump de la BD tenant
    → calc SHA-256 del dump
    → crear meta.json con sha256, db_name, user_id, created_at
    → firmar meta.json con clave privada RSA
    → empaquetar en ZIP
    → enviar como descarga con Content-Disposition: attachment
```

### Flujo de importacion

```
ajax/data/validate.php
  → DataManager::validate()
    → abrir ZIP, extraer dump + meta.json + sig
    → verificar SHA-256 del dump con meta.json
    → verificar firma RSA del meta.json
    → verificar que meta.json.user_id == $_SESSION['user_id']
    → devolver { ok, sha_ok, sig_ok, db_name, created_at }

ajax/data/import.php
  → DataManager::import()
    → validar (igual que validate)
    → ejecutar dump SQL contra la BD tenant del usuario
    → no permite importar en una BD de otro usuario
```

Documentacion completa en [docs/data-manager.md](docs/data-manager.md).

---

## Seguridad

### Autenticacion

- Passwords de operador hasheados con `password_hash()` / verificados con `password_verify()`
- Credentials de BD tenant cifradas con AES-256-GCM; `iv` y `tag` almacenados por separado
- Clave de cifrado en base64 hardcodeada en signin/signup (ver deuda tecnica)
- Session ID regenerado cada 24h (`session_regenerate_id(true)`)
- Cookie de sesion con `httponly: true`, `samesite: Lax`, `secure` segun protocolo

### Acceso a base de datos

- Todos los endpoints validan `$_SESSION['user_id']` antes de cualquier operacion
- Consultas con prepared statements + `bind_param()` para evitar inyeccion SQL
- Cada tenant tiene su propio usuario MySQL con permisos exclusivos sobre su BD
- La BD maestra solo es accesible con `newuser` (credenciales hardcodeadas en varios archivos)

### Backup SIF

- Dump cifrado en transito a nivel HTTPS
- Firma RSA-SHA256 del meta.json para verificar autenticidad
- Hash SHA-256 del dump para verificar integridad
- Verificacion de pertenencia: `meta.json.user_id` debe coincidir con la sesion activa

### Limitaciones conocidas

- La clave AES (`YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=`) esta hardcodeada en `signin/post.php`; no rotar sin actualizar todos los registros cifrados
- Las credenciales de la BD maestra (`newuser`/`n#wus#r`) estan en varios archivos; no centralizadas
- No hay rate limiting en los endpoints de autenticacion
- Algunos endpoints tienen la verificacion de sesion comentada o con fallback de debugging activo

---

## Deuda tecnica conocida

Puntos del codigo que requieren atencion antes de un entorno de produccion critico:

| Archivo | Problema |
| --- | --- |
| `ajax/sales/all/get.php` | Verificacion de sesion comentada; tiene fallback con credenciales hardcodeadas para testing |
| `ajax/sales/movements/legacy_movements.php` | Mismo fallback de sesion comentado |
| `ajax/sales/movements/qr_movements.php` | Mismo fallback de sesion comentado |
| `ajax/sales/refund.php` | Fuerza `gateway` para todas las devoluciones; usa valor simulado `1.50 EUR` cuando `P_DEBIT == 0` |
| `ajax/auth/signin/post.php` | Clave AES-256 hardcodeada en el codigo fuente |
| `ajax/auth/signup/post.php` | Clave AES-256 hardcodeada en el codigo fuente |
| `includes/DataManager.php` | Credenciales de BD maestra hardcodeadas en la clase |
| `includes/SessionHandler.php` | Credenciales de BD maestra hardcodeadas en la clase |
| `ajax/code/make.php` | `error_reporting(E_ALL); ini_set('display_errors', 1)` activo |
| Generales | `CORS header: Access-Control-Allow-Origin: *` en algunos endpoints |

---

## Operacion y mantenimiento

### Zona horaria

Todo el sistema (MySQL, PHP y la app Limpioo) opera en `Europe/Madrid`. Las fechas se almacenan y consultan en hora local espanola, sin conversiones CONVERT_TZ.

| Componente | Configuracion | Archivo |
| --- | --- | --- |
| MySQL/MariaDB | `default-time-zone = 'Europe/Madrid'` | `/etc/mysql/mariadb.conf.d/50-server.cnf` |
| PHP | `date.timezone = Europe/Madrid` | `/etc/php/8.2/apache2/php.ini`, `cli/php.ini`, `cgi/php.ini` |
| Limpioo (app) | Escribe fechas como texto en hora local espanola en campo `P_DATE` de `log` (formato `dd/mm/yyyy HH:mm:ss`) | — |
| Sistema QR | Usa `NOW()` / `CURRENT_TIMESTAMP()` de MySQL, que devuelve hora espanola tras la configuracion | — |
| `global.php` | `date_default_timezone_set('Europe/Madrid')` en PHP runtime | `includes/global.php` |

> **Si se migra el servidor o se reinstala MySQL**: cargar las tablas de timezone con
> `mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql`
> y verificar con `SELECT @@global.time_zone` que devuelve `Europe/Madrid`.

### Herramientas incluidas

| Script | Funcion |
| --- | --- |
| `database/setup.sh` | Autoonfiguracion completa: timezone MySQL/PHP, BDs maestra y tenant, usuario de aplicacion |
| `scripts/deploy.sh` | Despliegue guiado; si encuentra el directorio hermano `limpioo`, configura ambos proyectos |
| `scripts/cleanup_temp_qr.sh` | Limpieza de QR e imagenes temporales en `public/temp/` |
| `scripts/limpioo.print.py` | Servicio Python de impresion automatica de tickets |
| `scripts/limpioo.print.ini` | Configuracion del servicio de impresion |
| `scripts/nssm-2.24/` | NSSM para registrar `limpioo.print` como servicio Windows |
| `tests/verify_ecosystem.php` | Chequeo operativo de convivencia entre consola y app |

### Recomendaciones

- Manten alineado el entorno entre consola y app (`development` o `production`)
- No asumas que toda la configuracion maestra esta centralizada en un solo archivo; revisa los puntos de la seccion de instalacion
- Cuando cambies el contrato de preview o de provisioning, actualiza tambien `limpioo/README.md`
- Ejecuta `cleanup_temp_qr.sh` periodicamente o via cron para evitar acumulacion de archivos temporales

  ```bash
  # Cron: eliminar QRs temporales cada hora
  0 * * * * /var/www/html/limpioo.console/scripts/cleanup_temp_qr.sh >> /var/log/cleanup_qr.log 2>&1
  ```

- Para crear una BD tenant adicional para un nuevo operador:

  ```bash
  mysql -u root -p -e "CREATE DATABASE limpioo_userdb_2"
  mysql -u root -p limpioo_userdb_2 < database/application/v2.0/database.sql
  mysql -u root -p -e "GRANT ALL PRIVILEGES ON limpioo_userdb_2.* TO 'newuser'@'localhost'"
  ```

---

## Indice de documentacion

### Documentacion principal

| Archivo | Descripcion |
| --- | --- |
| [README.md](README.md) | Este documento. Vision completa del proyecto, arquitectura, endpoints, modulos y operacion |

### Documentacion tecnica en `docs/`

| Archivo | Descripcion |
| --- | --- |
| [docs/data-manager.md](docs/data-manager.md) | Arquitectura del sistema de backup/restauracion cifrado: flujo de export, validate e import, seguridad AES-256, formato JSON, API de `DataManager`, consideraciones de rendimiento y limitaciones |
| [docs/ticket-pdf-system.md](docs/ticket-pdf-system.md) | Sistema de generacion de tickets PDF para impresoras 80mm: estructura del ticket, parsing de topics MQTT, flujo de generacion, integracion con `makecode.php` y librerias utilizadas (FPDF, phpqrcode) |

### Documentacion de scripts en `scripts/`

| Archivo | Descripcion |
| --- | --- |
| [scripts/limpioo.print install readme.md](scripts/limpioo.print%20install%20readme.md) | Guia de instalacion del sistema de impresion automatica: metodo ejecutable (.exe), metodo Python, configuracion de SumatraPDF, inicio automatico con Windows, solucion de problemas |
| [scripts/Thermal Pritner Windows Test Tools install readme.md](scripts/Thermal%20Pritner%20Windows%20Test%20Tools%20install%20readme.md) | Guia de instalacion de drivers POS80 para impresoras termicas: driver, puertos, diagnostico, verificacion post-instalacion |

### Documentacion de terceros (assets)

| Archivo | Descripcion |
| --- | --- |
| [assets/flag-icons@7.2.3/README.md](assets/flag-icons@7.2.3/README.md) | README upstream de flag-icons (libreria de banderas SVG — no requiere mantenimiento del proyecto) |

---

## public/temp/

> Contenido original de `public/temp/README.md`.

Directorio de archivos temporales generados en tiempo de ejecucion (PDFs de tickets, imagenes QR). No versionado. Se crea automaticamente durante el despliegue o la primera ejecucion.

Para limpiar periodicamente:

```bash
bash scripts/cleanup_temp_qr.sh
```

O via cron (ver seccion [Operacion y mantenimiento](#operacion-y-mantenimiento)).

---

## Changelog

### Abril 2026 — apprewards.php: layout de 3 paneles rediseñado y editor Summernote en fullscreen contenido

#### `public/apprewards.php` — Layout de 3 paneles redimensionables

Refactorización completa de la interfaz de configuración de recompensas y monedas virtuales para adoptar un layout de tres columnas redimensionables con comportamiento de IDE.

**Estructura visual:**

| Panel | Contenido | Comportamiento |
|---|---|---|
| Izquierda (`rewPanelLeft`) | Árbol de dispositivos (jsTree) + tabla de programas | Colapsable, splitter vertical izquierdo |
| Centro (`rewPanelCenter`) | Tabs Recompensas / Monedas virtuales + tablas de edición | Flexible, ocupa el espacio restante |
| Derecha (`rewPanelRight`) | Panel de propiedades del programa seleccionado | Colapsable, splitter vertical derecho |

**Splitters (`rewMakeSplitter`):**

- Arrastre con ratón entre paneles.
- Respeta `min-width` (120 px por defecto para los tres paneles) y `max-width` (25 vw para laterales).
- Calcula `dClamped` para que ningún panel cruce su mínimo ni su máximo al mover el splitter.
- Se ocultan automáticamente cuando el panel adyacente está colapsado.

**Collapse/expand (`rewTogglePanel`):**

- Guarda el ancho actual en `_rewPanelSavedWidth` antes de colapsar.
- Al colapsar, el panel pasa a `54 px` (solo iconos) y el panel central recobra el espacio libre.
- Al expandir, restaura el ancho guardado.
- El icono del botón alterna entre `bi-layout-sidebar` / `bi-layout-sidebar-inset` (y sus variantes `reverse`).

**ResizeObserver — ajuste proporcional:**

- Dos observadores independientes:
  - `rewAdjustPanels`: escucha `#rewWorkspace`; si los paneles laterales dejan menos de 120 px al centro, los encoge proporcionalmente.
  - `rewClampLayout`: escucha `.rew-workspace`; misma lógica con margen de 200 px para el centro.
- Necesarios para cuando el usuario redimensiona la ventana o abre DevTools.

**Tabs del área central:**

- Implementados como `btn-check` + `label` de Bootstrap (radio group) en lugar de `<nav class="nav">`.
- Lógica de cambio en `rewSwitchTab(tab)`.
- `ResizeObserver` sobre `.rew-tabs-navbar`: cuando el texto de los labels no cabe, se añade la clase `icon-only` al grupo y los `<span class="tab-label">` se ocultan via CSS, mostrando solo los iconos.

**Árbol de dispositivos:**

- `rewLoadByToken(token, nodeName)`: carga un dispositivo por token desde la API, construye el objeto `device` local y llama a `rewLoadProgramsTable`.
- Nodos sin `data.token` abren/cierran el nodo en lugar de cargar datos.

#### `public/apprewards.php` — Editor Summernote fullscreen contenido en el panel central

**Problema raíz:** Summernote 0.8.20 (`summernote-lite`) no emite ningún evento jQuery (`summernote.fullscreen.enter` / `summernote.fullscreen.exit`) al entrar o salir de pantalla completa. El método `toggle()` del módulo interno `fullscreen` solo actualiza la toolbar, sin llamar a `triggerEvent`.

**Solución: MutationObserver sobre `document.body`:**

```js
const observer = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    if (mutation.attributeName !== 'class') return;
    const el = mutation.target;
    if (!el.classList.contains('note-editor')) return;
    const isFullscreen = el.classList.contains('fullscreen');
    // ...
  });
});
observer.observe(document.body, {
  subtree: true, attributes: true, attributeFilter: ['class']
});
```

**Comportamiento al entrar en fullscreen:**

1. Se guarda el parent original (`el._fsOrigParent`) y el siguiente hermano (`el._fsOrigNext`) del `.note-editor` — solo si aún no están guardados (la guarda `if (!el._fsOrigParent)` evita que un toggle de codeview dentro de fullscreen sobreescriba los valores originales).
2. El `.note-editor` se mueve a `.rew-workspace-content` con `appendChild`.
3. Se añade la clase `fs-active` a `.rew-workspace-content`.
4. Se elimina el padding del contenedor.

**Comportamiento al salir de fullscreen:**

1. Se reinserta el `.note-editor` en su posición original via `insertBefore` o `appendChild`.
2. Se limpia `_fsOrigParent` / `_fsOrigNext`.
3. Se elimina `fs-active` y se restaura el padding.

**CSS asociado:**

```css
/* El workspace-content es el nuevo "viewport" del editor en fullscreen */
.rew-workspace-content { position: relative; }

/* El editor en fullscreen ocupa todo el contenedor, no 100vw/100vh */
.rew-workspace-content > .note-editor.note-frame.fullscreen {
  position: absolute !important;
  top: 0; left: 0;
  width: 100% !important;
  height: 100% !important;
}

/* Las pestañas de tabla se ocultan mientras hay un editor en fullscreen */
.rew-workspace-content.fs-active > .rew-tab-pane {
  display: none !important;
}
```

**Caso especial — codeview dentro de fullscreen:**

Cuando el usuario activa la vista de código (`</>`) estando ya en fullscreen, el MutationObserver se dispara de nuevo porque la clase `codeview` se añade a `.note-editor`. Como `_fsOrigParent` ya está definido, la guarda `if (!el._fsOrigParent)` impide que se sobreescriba y el editor se queda correctamente posicionado dentro de `.rew-workspace-content` durante toda la sesión de fullscreen.

---

### Abril 2026 — Clientes, subtablas y contabilidad de miembros extra

#### `public/customers.php` — Schema ampliado y columnas calculadas

- **SQL** (`ajax/customer/all/get.php`): la consulta incluye ahora todas las columnas de la tabla `subscriptions` (24 campos) más `invited_members` calculado con `COUNT(sm.id)`.
- **Schema `$tblDtaCustomers`**: 27 columnas. Visibles por defecto: Email, Confirmado, Suscrito, Plan, Matrícula, Invitados, Importe. El resto `visible: false` (usuario elige activarlas).
- **Columna calculada `Importe (€)`**: `subscription_price + (invited_members × extra_member_price)`. Si el usuario no está suscrito, la celda queda en blanco. `editable: false`.
- **Columna `Invitados`**: muestra `''` (blanco) para usuarios sin suscripción activa; número entero para suscritos.

#### `assets/js/table.js` — Sistema de subtablas expandibles

Nuevo soporte de subtablas lazy por fila (`main-row` / `subtable-row`):

| Elemento | Detalle |
|---|---|
| `crudConfig.subtable` | Nueva opcion. Acepta `schema`, `key`, `fetch`, `showIf` |
| `showIf(rowData) → bool` | Controla si se renderiza el boton chevron para esa fila en concreto |
| `getMainRowIndex(tr)` | Helper interno: calcula el indice considerando solo `.main-row` |
| Selectors corregidos | Todas las referencias `tableBody.find('tr')` actualizadas a `tableBody.find('tr.main-row')` |
| Delete en cascada | `tr.next('.subtable-row').remove()` antes de `tr.remove()` |
| Iconos | `bi-chevron-down` (colapsado) / `bi-chevron-up` (expandido) |
| Carga lazy | `subtableTd.data('loaded', true)` tras primera apertura; no vuelve a llamar a `fetch` |

#### `ajax/customer/subscription_members/get.php` — Filtro por UUID

- Parametro `POST['uuid']` opcional. Sin UUID devuelve todos (retrocompatible). Con UUID filtra los miembros de esa suscripcion especifica.

#### `assets/js/table.md` — Documentacion actualizada

- Documentacion reescrita para reflejar API publica real, subtablas, `showIf`, callbacks con firmas completas.

---

### Abril 2026 — Contabilidad de aceptacion de miembro extra (cross-project)

> Cambio coordinado entre `limpioo.console` y `limpioo`. Ver §33 de `limpioo/README.md` para la descripcion completa.

**Contexto:** Al aceptar una invitacion familiar, el cobro MIT prorrateado del extra no quedaba registrado en la tabla `log` porque `subscribe.php` (pasarela) solo escribe en fichero de texto. Las metricas del console mostraban el importe incompleto.

**Solucion en `limpioo/public/invite.php`:** Tras activar al miembro (`status = 'active'`), si `prorated_amount > 0` se inserta un registro en `log` con `SUBSCRIPTION=1`, `SUBSCRI_WASH=0`. El campo `P_DATA.type = "family_member_acceptance"` actua como discriminador.

**Solucion en `limpioo/public/tickets.php`:** Se filtra ese discriminador de `$ROWS` antes de renderizar, para que el usuario no vea el cobro como una suscripcion independiente en su historial.

---

### Abril 2026 — UI: Tabla de ventas (public/sales.php)

- `public/sales.php`: ajuste en la generación de los botones de la columna de acciones para ventas de tipo "Suscripción". Cuando el estado sería "No usado" para un ítem de suscripción, el botón principal muestra ahora el icono infinito (`bi bi-infinity`) en lugar del texto "No usado".
- El tooltip se actualiza a "Suscripción (uso ilimitado)" y la lógica de ajuste de texto evita truncar HTML en botones que contienen iconos.
- Se preserva el comportamiento del dropdown y las acciones asociadas (devolución, ver detalles, descargar ticket).
- Archivos modificados: `public/sales.php`.

Razón: mejorar la claridad visual para ventas de suscripción y evitar confusión con estados de un solo uso.

**Impacto en metricas del console:** `subscriptions_amount/get.php` y `sales/get.php` leen `SUBSCRIPTION=1 AND P_AMOUNT > 0`, por lo que el importe del extra ya computa correctamente en el totales del dashboard.

---

### Abril 2026 — Dashboard: Chart2 drill-down interactivo por dispositivo

#### `public/index.php` — Chart2 con navegacion a dos niveles

Chart2 (donut de productos) pasa de ser un grafico estatico a un grafico interactivo con drill-down:

- **Nivel 1 (dispositivos)**: agrupa por tipo de dispositivo (Puente de lavado, Boxes de lavado, Aspiradores). Cada barra muestra el total de lavados del dispositivo con desglose por tipo (regular, promocion, suscripcion).
- **Nivel 2 (programas)**: al hacer clic en una barra de dispositivo, se profundiza mostrando los programas/pistas de ese dispositivo concreto.
- **Toolbar**: nueva barra de navegacion sobre el grafico con boton Back (deshabilitado en nivel 1) y boton Fullscreen.
- **Cache de datos**: al hacer drill-down, los datos del nivel 1 se cachean en `ST.chart2State.cachedDevices` para restaurar instantaneamente al volver atras sin nueva peticion al servidor.
- **Fullscreen**: el card `#Chart2-Card` soporta pantalla completa con icono dinamico (expand/compress).
- **Reset automatico**: al cambiar de ubicacion (selector de dispositivos), Chart2 vuelve al nivel 1.

Archivos modificados: `public/index.php` (HTML del card + JS del dashboard).

#### `ajax/index/metrics/total/products/grouped/get.php` — Backend de dos niveles

El endpoint ahora soporta dos niveles de agrupacion controlados por el parametro POST `device_type`:

| Parametro | Nivel | Agrupacion log | Agrupacion QR |
|---|---|---|---|
| Ausente o vacio | 1 (dispositivos) | `REPLACE(SUBSTRING_INDEX(THING,'-',2),'-',' ')` → "Puente 1", "Box 1" | `REPLACE(SUBSTRING_INDEX(qul.device_thing,'-',2),'-',' ')` |
| Nombre de dispositivo | 2 (programas) | `TRIM(SUBSTRING_INDEX(NAME, ' - ', -1))` → "Pista 3" | `COALESCE(qul.program_name, qi.product_name)` |

La respuesta incluye un campo `level` (`devices` o `programs`) para que el frontend sepa en que nivel esta.

---

### Abril 2026 — Resolucion de nombres de programa en activaciones QR

#### Problema: "Cualquier Pista" en lugar del nombre real

Al generar un QR en `makecode.php`, los items con titulo generico (ej: "Pista 1") se renombraban a "Cualquier Pista" porque al momento de la venta no se sabia en que pista concreta se ejecutaria el lavado. Esto provocaba que en el drill-down de Chart2, los lavados QR aparecieran como "Cualquier Pista" en vez del programa real ejecutado.

#### Solucion

1. **Nueva columna `qr_usage_log.program_name`** (`VARCHAR(255) NULL DEFAULT NULL`): almacena el nombre real del programa resuelto en el momento de la activacion.

2. **Funcion `getDeviceInfo($device_key)` en `ajax/code/active.php`**: consulta la API de Netwash (`GET /app/1.0/device`) para obtener el primer item activo del dispositivo. Extrae el nombre corto del programa (parte tras `" - "` del titulo) y el slug del dispositivo (`device_thing`). Se invoca siempre en cada activacion.

3. **INSERT modificado en `verifyQRCode()`**: el registro en `qr_usage_log` ahora incluye `program_name` con el nombre resuelto (o NULL si la resolucion falla — degradacion graceful).

4. **Query del dashboard actualizada**: `products/grouped/get.php` usa `COALESCE(qul.program_name, qi.product_name)` en el nivel de programas, de forma que el nombre resuelto tiene prioridad y el generico actua como fallback.

5. **Schema actualizado**: `database/application/v2.0/database.sql` refleja la nueva columna.

Archivos modificados:
- `ajax/code/active.php` (funcion + INSERT)
- `ajax/index/metrics/total/products/grouped/get.php` (COALESCE en query)
- `database/application/v2.0/database.sql` (columna `program_name`)

---

### Abril 2026 — Eliminacion del modo "Todas las metricas" en Chart1

#### `public/index.php` — Simplificacion de vistas de Chart1

Se elimina el modo "Todas las metricas" (`all`) de Chart1, dejando unicamente dos vistas:

- **Lavados** (`usage`): barras apiladas con Regulares, Promocion, Suscripcion, Devoluciones, Expirados.
- **Financieras** (`financial`): barras apiladas con importes en euros (Lavados, Promocion, Suscripcion, Devoluciones).

Cambios:

- **Dropdown**: eliminada la opcion "Todas las metricas" y el separador. Labels renombrados de "Solo Lavados" a "Lavados" y "Solo Financieras (€)" a "Financieras (€)".
- **Default**: cambiado de `'all'` a `'usage'` en la inicializacion del estado y en `restoreMetricsViewUI()`.
- **Variables eliminadas**: `isAll`, `isUsage`, el bloque `case 'all': default:` con 9 datasets duales (lavados + importes).
- **Tooltip simplificado**: eliminado el IIFE `c1Tooltip` con tooltip externo HTML (`.c1-tooltip`). Reemplazado por un objeto plano con callbacks nativos `filter`, `title`, `label`.
- **Scales simplificados**: eliminado eje `yRight`, condicionales `isAll` en `stacked`, y titulo de eje Y. Ahora `stacked: true` fijo en ambos ejes.
- **Interaction**: cambiado de condicional `isAll ? 'nearest' : 'index'` a `mode: 'index'` fijo.

---

### Abril 2026 — Chart2: agrupacion por THING (dispositivo fisico)

#### `ajax/index/metrics/total/products/grouped/get.php` — Niveles de agrupacion por THING

Chart2 pasa de agrupar por `DEVICE` (tipo generico) a agrupar por `THING` (dispositivo fisico concreto):

**Nivel 1 (dispositivos)**:
- Antes: `GROUP BY DEVICE` → "Puente de Lavado", "Boxes de lavado"
- Ahora: `GROUP BY REPLACE(SUBSTRING_INDEX(THING, '-', 2), '-', ' ')` → "Puente 1", "Box 1"
- El campo `THING` sigue el formato `{Tipo}-{Numero}-{Total}` (ej: `Puente-1-1`, `Box-1-4`). Se extrae `{Tipo}-{Numero}` y se convierte el guion a espacio.

**Nivel 2 (programas/pistas)**:
- Filtro cambiado de `DEVICE = ?` a `SUBSTRING_INDEX(THING, '-', 2) = ?`
- El frontend envia el label con espacio ("Puente 1"), el backend lo convierte a guion ("Puente-1") con `str_replace()`.
- Ordenamiento: nombres con numeral primero (ASC por numero: Pista 1, Pista 2...), despues sin numeral (DESC por `MAX(P_AMOUNT)` — precio maximo del programa).

Se anadio `P_AMOUNT` / `unit_price` al SELECT interno para calcular `max_price` usado en el ORDER BY del nivel 2.

---

### Abril 2026 — Tablas "Mas rentables" y "Preferido por usuarios": agrupacion inteligente

#### `ajax/index/metrics/total/mostprofitable/get.php` y `topsellers/get.php`

Las tablas de ranking del dashboard agrupaban por `NAME` directamente, lo que separaba "Pista 1", "Pista 2", etc. como productos independientes sin contexto de dispositivo.

**Nueva logica de agrupacion**:
- Si `NAME` coincide con `^Pista [0-9]` → agrupar por `DEVICE` (ej: "Boxes de lavado").
- Si no → prefijo con primer segmento de `THING` + ` - ` + `NAME` (ej: "Puente - Excellent", "Puente - Premium").

**Ordenamiento**: alfabetico ASC por `item_label` (antes era por revenue DESC / units DESC).

Resultado con datos de ejemplo:

| Antes | Ahora |
|---|---|
| Suscripcion High Premium | Boxes de lavado |
| Excellent | Puente - Excellent |
| Premium | Puente - Premium |
| Pista 1 | Puente - Suscripcion High Premium |

Archivos modificados:
- `ajax/index/metrics/total/products/grouped/get.php` (agrupacion por THING, orden nivel 2)
- `ajax/index/metrics/total/mostprofitable/get.php` (agrupacion inteligente NAME/DEVICE)
- `ajax/index/metrics/total/topsellers/get.php` (agrupacion inteligente NAME/DEVICE)
- `public/index.php` (eliminacion modo "all" de Chart1)

---

### Abril 2026 — Nueva columna `qr_usage_log.device_thing`

#### Problema: imposible consolidar pagos QR con pagos fisicos en el chart

Los registros de `qr_usage_log` no tenian referencia al slug del dispositivo fisico (`THING`, ej: `Box-1-4`). En el drill-down de Chart2 nivel 1, los lavados QR quedaban desconectados del `THING` que genera el pago fisico en `log`, impidiendo agrupar ambas fuentes bajo el mismo dispositivo.

#### Solucion

1. **Nueva columna `qr_usage_log.device_thing`** (`VARCHAR(100) NULL DEFAULT NULL`): almacena el ultimo segmento del topic MQTT del dispositivo que proceso el escaneo (ej: `Box-1-4`, `Puente-1-1`). Se obtiene parseando el campo `topic` de la respuesta `GET /app/1.0/device`.

2. **Resolucion en `ajax/code/active.php`**: la funcion `getDeviceInfo($device_key)` consulta la API, extrae el ultimo segmento de `topic` como `device_thing` y lo incluye en el INSERT de `qr_usage_log`.

3. **Query del dashboard actualizada**: `products/grouped/get.php` nivel 1 usa `SUBSTRING_INDEX(qul.device_thing, '-', 2)` para agrupar los lavados QR junto a los fisicos. Nivel 2 filtra por `SUBSTRING_INDEX(qul.device_thing, '-', 2) = '{thingPrefix}'` con fallback legacy `qi.device_name` para registros anteriores sin `device_thing`.

4. **Script de migracion `upgrade_v1_to_v2.sh`**: la fase 8 soporta el flag `--backfill-thing=SLUG` que hace `UPDATE qr_usage_log SET device_thing = ? WHERE device_thing IS NULL` para rellenos retrospectivos de instalaciones monodispositivo.

5. **Schema actualizado**: `database/application/v2.0/database.sql` incluye la columna `device_thing`.

Archivos modificados:
- `ajax/code/active.php` (funcion `getDeviceInfo`, INSERT)
- `ajax/index/metrics/total/products/grouped/get.php` (agrupacion por `device_thing`)
- `database/application/v2.0/database.sql` (columna `device_thing`)
- `database/migrations/upgrade_v1_to_v2.sh` (flag `--backfill-thing`)

---

### Abril 2026 — Etiquetas cortas de programa en Chart2 nivel 2

#### Problema: barra "Boxes de lavado - Pista 4" en vez de "Pista 4"

En el drill-down de Chart2 nivel 2 (programas de un dispositivo), las etiquetas de los lavados QR aparecian en formato largo (`"Boxes de lavado - Pista 4"`) porque:

- La API de Netwash devuelve los titulos de los items como `"{device_name} - {programa}"` (ej: `"Boxes de lavado - Pista 3"`).
- El campo `log.NAME` de los pagos fisicos tambien sigue ese formato.
- El frontend recibe etiquetas distintas para la misma pista segun el origen del dato.

Ademas, la logica de resolucion anterior intentaba inferir el `program_id` del sufijo numerico del topic (ej: `-4` en `Box-1-4`), lo que es incorrecto porque el sufijo del topic no tiene por que coincidir con el `id` del item en la API.

#### Solucion

1. **`ajax/code/active.php` — `getDeviceInfo()`**: al buscar el nombre del programa en los items de la API, se extrae solo la parte despues del ultimo `" - "` usando `strrpos` + `substr`. Si no hay `" - "` se usa el titulo completo.

2. **`ajax/code/active.php` — logica de resolucion**: se elimina la doble llamada a la API y la inferencia del `program_id` por sufijo numerico del topic. Ahora se hace una sola llamada y se usa el **primer item** que devuelve la API como programa activo del dispositivo, igual que `limpioo/public/active.php` usa `$ITEM["title"]`. Esto es correcto porque en instalaciones normales cada dispositivo expone exactamente un programa activo.

3. **`ajax/index/metrics/total/products/grouped/get.php` nivel 2**:
   - `$logGroupCol` cambia de `NAME` a `TRIM(SUBSTRING_INDEX(NAME, ' - ', -1))`: extrae la parte tras `" - "` del `log.NAME` fisico.
   - `$qrGroupCol` cambia de `COALESCE(qul.program_name, CONCAT(qi.device_name,' - ',qi.product_name))` a `COALESCE(qul.program_name, qi.product_name)`: usa el nombre corto almacenado o el `product_name` de fallback.

Resultado: ambas fuentes (pago fisico y escaneo QR) consolidan bajo la misma barra con etiqueta corta (`"Pista 3"`, `"Pista 4"`...).

Archivos modificados:
- `ajax/code/active.php` (`getDeviceInfo` extraccion de nombre corto, eliminacion de doble llamada)
- `ajax/index/metrics/total/products/grouped/get.php` (query nivel 2)

---

### Abril 2026 — Configuracion de `local_db.devices`: relay compartido para Box-*

Todos los dispositivos con topic `*/Box-*` se configuraron con el mismo `topi2` (topic secundario de relay MQTT) mediante:

```sql
UPDATE local_db.devices
SET topi2 = 'yx62fX07,@_>r-5/relay72892'
WHERE topic LIKE '%/Box-%';
```

Esto unifica el canal de relay de los boxes de lavado en la instalacion de desarrollo, alineandolo con la configuracion que ya tenia `Box-2` previamente.

Tabla afectada: `local_db.devices` (columnas `topic`, `topi2`, `token`, `apikey`, `enabled`). No forma parte del schema de tenant; es configuracion del broker local.

---

### Abril 2026 — Bug fix: `P_AMOUNT` destructivo — importes desde `P_DATA`/`R_DATA`

#### Problema raíz

`notify.php` (proyecto `limpioo`) reduce `P_AMOUNT` en la BD al procesar una devolución, sobreescribiendo el importe original. Por tanto, tras una devolución `P_AMOUNT` no refleja el cobro original sino el neto resultante, lo que hacía que los endpoints de la consola mostrasen importes incorrectos.

**Campo fiable para el importe original:** `P_DATA.Ds_Amount` (JSON preservado siempre).
**Campo fiable para el importe devuelto:** `R_DATA.Ds_Amount` (JSON de respuesta Redsys del reembolso).

#### Correcciones aplicadas

**`ajax/sales/movements/legacy_movements.php`**
- Importe cobrado: `P_DATA.Ds_Amount / 100` en vez de `P_AMOUNT / 100`.
- Importe devuelto: `R_DATA.Ds_Amount / 100`.
- Etiqueta del detalle «Importe cobrado»: usa `DEVICE - NAME` del registro (ej: `Boxes de lavado - Pista 1`) en vez de un genérico `$serviceName`.

**`ajax/sales/search.php`**, **`ajax/sales/search_new.php`**, **`ajax/sales/all/get.php`**
- Importe original leído de `P_DATA.Ds_Amount`; importe devuelto de `R_DATA.Ds_Amount`.
- Limpieza de prefijo en `product_name`: si `NAME` sigue el patrón `"{DEVICE} - {programa}"`, se elimina el prefijo `DEVICE` para mostrar solo el nombre del programa (ej: `"Boxes de lavado - Pista 4"` → `"Pista 4"`).

**`public/sales.php`**
- Columna `P_AMOUNT` marcada como `"visible" => true` (antes oculta).
- `signed_amount` calculado como `P_AMOUNT - AMOUNT_REFUNDED` (neto entregado al operador).
- `calculateTotals()` usa `P_AMOUNT` para ingresos y `AMOUNT_REFUNDED` para devoluciones.

Archivos modificados:
- `ajax/sales/movements/legacy_movements.php`
- `ajax/sales/search.php`
- `ajax/sales/search_new.php`
- `ajax/sales/all/get.php`
- `public/sales.php`

---

### Abril 2026 — Bug fix: métrica «Lavados Devueltos» con cálculo proporcional

#### Problema

El endpoint `ajax/index/metrics/total/products_refunded/get.php` devolvía como «unidades devueltas» directamente el campo `R_AMOUNT` de la BD, que es el importe en céntimos del reembolso, no una cantidad de unidades. Esto hacía que el widget del dashboard mostrase cifras absurdes (ej: `100` en vez de `1` unidad devuelta).

#### Solución

Cálculo proporcional basado en la fracción del importe reembolsado sobre el original:

```sql
ROUND(QUANTITY * R_DATA.Ds_Amount / P_DATA.Ds_Amount)
```

Ejemplo: 3 lavados comprados, 300 céntimos originales, 100 céntimos devueltos → `ROUND(3 × 100 / 300)` = 1 lavado devuelto.

Archivos modificados:
- `ajax/index/metrics/total/products_refunded/get.php` — cálculo proporcional con `P_DATA`/`R_DATA`

---

### Abril 2026 — Bug fix: agrupación en «Productos más rentables» y «Preferido por los usuarios»

#### Problema

Las tablas del dashboard mostraban filas duplicadas para dispositivos no-rollover (boxes, aspiradoras):

- `"Boxes de lavado"` — generada cuando `NAME = 'Pista N'` (genérico, sin prefijo)
- `"Box - Boxes de lavado - Pista 4"` — generada cuando `NAME = 'Boxes de lavado - Pista 4'` (compuesto), donde el `ELSE` añadía encima el prefijo de `THING`

Resultado: el mismo dispositivo aparecía en dos filas distintas con métricas fragmentadas.

#### Causa raíz

El `CASE` en la consulta SQL solo tenía dos ramas:

```sql
CASE
    WHEN NAME REGEXP '^Pista [0-9]' THEN DEVICE          -- genérico → nombre del dispositivo
    ELSE CONCAT(SUBSTRING_INDEX(THING, '-', 1), ' - ', NAME)  -- todo lo demás → prefijo + NAME
END
```

Cuando `NAME` ya incluía el dispositivo como prefijo (`"Boxes de lavado - Pista 4"`), el `ELSE` lo convertía en `"Box - Boxes de lavado - Pista 4"`.

#### Solución

Se añadió una rama intermedia que detecta `NAME` compuesto (contiene `' - '`) y extrae solo la parte del dispositivo con `SUBSTRING_INDEX(NAME, ' - ', 1)`:

```sql
CASE
    WHEN NAME REGEXP '^Pista [0-9]' THEN DEVICE                        -- genérico → "Boxes de lavado"
    WHEN NAME LIKE '% - %' THEN SUBSTRING_INDEX(NAME, ' - ', 1)        -- compuesto → "Boxes de lavado"
    ELSE CONCAT(SUBSTRING_INDEX(THING, '-', 1), ' - ', NAME)           -- rollover → "Puente - Excellent"
END
```

Con esto todos los registros de un mismo dispositivo (independientemente del formato de `NAME`) consolidan bajo la misma etiqueta:

| `NAME` en BD | Etiqueta mostrada |
|---|---|
| `"Pista 4"` | `"Boxes de lavado"` |
| `"Boxes de lavado - Pista 4"` | `"Boxes de lavado"` |
| `"Aspirador - Pista 1"` | `"Aspirador"` |
| `"Excellent"` | `"Puente - Excellent"` |

Archivos modificados:
- `ajax/index/metrics/total/mostprofitable/get.php` — `CASE` extendido con rama `NAME LIKE '% - %'`
- `ajax/index/metrics/total/topsellers/get.php` — mismo cambio

---

### Abril 2026 — Bug fix: ERR_CACHE_MISSING al pulsar atrás desde `apprewards.php` / `appsubscriptions.php`

#### Problema

Al navegar a `apprewards.php` o `appsubscriptions.php` desde la navbar del console, el formulario oculto (`#rewardsForm` / `#suscriptionsForm`) enviaba el parámetro `key` por **POST**. El servidor respondía directamente con HTML, sin redirección previa.

El navegador no puede almacenar respuestas POST en la caché de navegación hacia atrás/adelante (BFCache). Al pulsar el botón «atrás», el navegador intentaba restaurar una entrada que no existe en caché, produciendo el error:

```
ERR_CACHE_MISSING
```

Ambas páginas leían el parámetro únicamente de `$_POST['key']`, por lo que la URL nunca contenía el `key` y no era navegable ni recargable directamente.

#### Causa raíz

```php
// Antes (solo POST):
$KEY = isset($_POST['key']) ? $_POST['key'] : '';
```

La página se servía como respuesta directa al POST, sin URL propia persistente.

#### Solución — patrón PRG con key en sesión (Post → Redirect → Get)

Cuando la página recibe la petición por POST, almacena el key saneado en `$_SESSION['device_key']` y emite un `302 → GET` limpio (sin parámetros en la URL). El navegador sigue la redirección y carga la página vía GET, cuya URL es estable y almacenable en BFCache. El botón «atrás» la restaura sin error. El key no queda expuesto en la URL.

```php
// Después (PRG + sesión):
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['key'])) {
  $_SESSION['device_key'] = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_POST['key']);
  header('Location: appconfig.php');   // o apprewards.php / appsubscriptions.php
  exit();
}
$KEY = $_SESSION['device_key'] ?? '';
```

Archivos modificados:
- `public/apprewards.php` — patrón PRG + key en sesión
- `public/appsubscriptions.php` — mismo cambio
- `public/appconfig.php` — mismo cambio

---

### Abril 2026 — Bug fix: título duplicado en tabla de programas de `apprewards.php` / `appsubscriptions.php`

#### Problema

La columna «Programa/Pista» de la tabla de items mostraba el nombre del dispositivo duplicado para ciertos programas:

```
Boxes de lavado - Boxes de lavado - Pista 3
```

Mientras que otros programas se mostraban correctamente:

```
Boxes de lavado - Pista 1
```

#### Causa raíz

La API de Netwash devuelve el campo `title` de los items en dos formatos distintos según el programa:

| Formato devuelto por la API | Ejemplo |
|---|---|
| Nombre corto (sin prefijo) | `"Pista 1"` |
| Nombre completo (con prefijo) | `"Boxes de lavado - Pista 3"` |

El código construía el título así en ambos ficheros:

```php
"title" => $JSONFILE["name"] . " - " . $it["title"]
```

Cuando `$it["title"]` ya incluía el prefijo del dispositivo, el resultado era `"Boxes de lavado - Boxes de lavado - Pista 3"`.

#### Solución

Antes de prefijar, se extrae la parte corta del programa (lo que hay tras el último `" - "`). Si el título no contiene `" - "`, se usa tal cual.

```php
"title" => $JSONFILE["name"] . " - " . (
    strpos($it["title"], ' - ') !== false
        ? trim(substr($it["title"], strrpos($it["title"], ' - ') + 3))
        : $it["title"]
)
```

Con esto ambos formatos producen el mismo resultado: `"Boxes de lavado - Pista N"`.

El mismo criterio se aplica en `appsubscriptions.php` para el `<h6 id="subscriptionTitle">` que muestra el nombre del programa seleccionado.

Archivos modificados:
- `public/apprewards.php` — extracción de nombre corto antes de construir `title`
- `public/appsubscriptions.php` — mismo cambio

---

### Abril 2026 — `apprewards.php`: toolbar flotante de Summernote, reset de estilos y PRG

#### Toolbar flotante al editar celdas con Summernote

La toolbar de Summernote estaba siempre visible dentro de la celda en edición, ocupando espacio y desplazando el contenido de la tabla. Se reemplazó por un toolbar flotante con posición fija en el viewport que solo aparece al enfocar el editor.

**Comportamiento:**

- La toolbar está oculta por defecto mediante CSS (`display: none !important`).
- Al hacer foco (`onFocus`), se calcula la posición del editor en el viewport. Si hay espacio suficiente encima (`rect.top >= tbH + 8`), la toolbar aparece por encima; si no, por debajo.
- La posición se comunica via variables CSS `--rew-tb-top` / `--rew-tb-left` sobre el `.note-editor`.
- Se añade la clase `rew-toolbar-active` al editor, que activa el toolbar flotante via CSS (`position: fixed`, `z-index: 9990`, `box-shadow`).
- Al perder el foco (`onBlur`), tras 300 ms se verifica que el foco no ha pasado a un elemento dentro del propio editor (ej: un dropdown de la toolbar) antes de quitar `rew-toolbar-active`.
- Solo puede haber un toolbar activo a la vez: al activar uno se desactiva cualquier otro previamente activo.

**CSS añadido:**

```css
td .note-editor.note-frame .note-toolbar { display: none !important; }

td .note-editor.note-frame.rew-toolbar-active:not(.fullscreen) .note-toolbar {
  display: flex !important;
  position: fixed !important;
  top: var(--rew-tb-top, 60px) !important;
  left: var(--rew-tb-left, 10px) !important;
  z-index: 9990 !important;
  background: #fff !important;
  border: 1px solid #dee2e6 !important;
  border-radius: 4px !important;
  box-shadow: 0 4px 16px rgba(0,0,0,0.22) !important;
  flex-wrap: wrap !important;
  max-width: min(720px, 94vw) !important;
}
```

#### Eliminación de tooltips (`title=""`) en cabeceras y celdas

**En las cabeceras (`<th>`):** se eliminaron los atributos `title="..."` descriptivos de todas las columnas de las tablas `#tbl-program-rewards` y `#tbl-program-paymentcoins`.

**En las celdas (`<td>`):** la función `render()` de `setupTable` generaba dinámicamente el atributo `title` en cada celda con el contenido de texto del campo (tooltips con el HTML convertido a texto plano, con variables `{FROM}`/`{TO}` resueltas). Se eliminó toda la lógica de `td_tooltip_text` y `safe_tooltip`, y el `<td>` ya no incluye `title`. Cambio aplicado en las dos copias de `setupTable` del archivo.

#### Summernote no modifica estilos al inicializarse

Al abrir una celda en edición, Summernote inyectaba automáticamente `style="font-family: sans-serif; font-size: 16px;"` sobre el contenido existente al inicializarse, alterando el formato guardado.

**Solución en dos capas:**

1. `defaultFontName: false` y `defaultFontSize: false` en la configuración de Summernote evitan que el editor asuma un tipo y tamaño de fuente por defecto.
2. Antes de inicializar, el HTML original de la celda se guarda en `_origHtml = $(this).val()`. El callback `onInit` restaura ese HTML inmediatamente (`$(this).summernote('code', _origHtml)`), deshaciendo cualquier modificación residual que Summernote haya podido hacer durante la inicialización.

Ambos cambios se aplican en las dos instancias de Summernote del archivo.

#### Reset de estilos inline en celdas renderizadas

El HTML guardado en los campos `title` y `message` puede contener estilos inline (`font-family`, `font-size`, `color`, etc.) que afectan visualmente a la celda en modo lectura (fuera de la edición). Se añadió una regla CSS que neutraliza esos estilos dentro de las celdas renderizadas:

```css
td[data-json-field] > div,
td[data-json-field] > div * {
  all: unset;
  display: revert;
}
```

Esto evita que el contenido enriquecido de una celda imponga tamaños de fuente o colores al resto de la tabla.

#### Bug fix: diálogo "Confirmar reenvío del formulario" al recargar `apprewards.php`

El navbar incluía un formulario oculto `#rewardsForm` con `method="POST"` que enviaba el token del dispositivo seleccionado a `apprewards.php`. La página respondía directamente con HTML sin redirección previa. Al recargar la página, el navegador detectaba que la última carga fue por POST y mostraba el diálogo nativo "¿Confirmar reenvío del formulario?".

`appconfig.php` y `appsubscriptions.php` ya tenían el patrón PRG correcto. Se aplicó el mismo patrón en `apprewards.php`:

```php
// PRG: evita el diálogo "confirmar reenvío" al recargar la página
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  header('Location: apprewards.php');
  exit();
}
```

Al recibir cualquier POST, la página redirige inmediatamente a un GET limpio. El navegador carga la URL en GET, que es estable, recargable y compatible con BFCache.

Archivos modificados:
- `public/apprewards.php` — toolbar flotante, eliminación de tooltips, `defaultFontName`/`defaultFontSize`, `onInit` con restauración de HTML, CSS reset de estilos inline, patrón PRG

