# Pruebas manuales de Hoja de Distribución

## Preparación

1. Ejecute la migración de integridad en la base de pruebas y revise su salida:

   ```bash
   mysql -u<usuario> -p <base> < scripts/migrations/20260805_hoja_distribucion_integridad.sql
   ```

   Se detiene con error si hay duplicados en `hoja` o `hoja_detalle`; en ese caso
   hay que auditarlos y corregirlos a mano antes de continuar, nunca borrarlos
   para que entre el índice. Crea `maedoc_anulado` si falta: **sin esa tabla,
   desaplicar y reaplicar están deshabilitados**. Es idempotente y hace commit
   implícito, así que no se puede envolver en una transacción. Para deshacer la
   estructura existe el `_rollback`.
2. Configure `parametros_backoffice.hoja_tipodoc_maestro` con el tipo del
   maestro y exactamente un `maetdc.eshoja='S'` para la aplicación. Si el
   parámetro no existe, `maetdc.eshojaimp='S'` se usa solo como compatibilidad.
3. Las tiendas que participan en la hoja son las de **`maetie.hoja_aparecer='S'`**,
   igual que en VB6 (`usahoja` sólo se usa si la instalación no tiene
   `hoja_aparecer`). Cada una debe tener `hoja.Fxx` y `hoja_detalle.Txx`; las
   operaciones rechazan la solicitud e indican la columna faltante. Verifíquelo:

   ```sql
   SELECT tienda, descripcion, hoja_aparecer, usahoja
   FROM maetie WHERE alta <> 'N' ORDER BY tienda;
   ```

   Si `hoja_aparecer='S'` y `usahoja=0`, es el caso normal: antes de esta
   corrección el módulo web no mostraba ninguna tienda.
4. Use un usuario autenticado con visualización de costos/precios y permiso de
   anulación de documentos. Las pruebas de denegación se repiten con un usuario
   sin esos permisos.
5. Seleccione tres tiendas activas, distintas de la bodega, con la misma regla
   de consignación. Prepare al menos dos productos activos con unidad principal,
   costo y precio distintos de cero.

Reemplace en las consultas siguientes `:tipo_hoja`, `:hoja`,
`:tipo_aplicacion`, `:bodega` y la lista de tiendas por valores reales. Son
consultas no destructivas.

## Caso completo

### 1. Crear y editar

1. Pulse **Manual**, seleccione fecha y bodega.
2. Abra la hoja creada y agregue los dos PLU.
3. Edite `TOTAL` y distribuya cantidades a tres `Txx`.
4. Guarde. Pulse **Resumen**.

Resultado esperado: `TIENDAS=ΣTxx`, `BODEGA=TOTAL-TIENDAS`, diferencia cero y
estado CUADRA. Una cantidad negativa o una suma mayor a TOTAL es rechazada por
el servidor.

```sql
SELECT * FROM hoja WHERE tipodoc=:tipo_hoja AND hoja=:hoja;
SELECT * FROM hoja_detalle WHERE tipodoc=:tipo_hoja AND hoja=:hoja ORDER BY id;
```

### 2. Aplicar dos tiendas

1. Pulse **Aplicar**, seleccione dos tiendas, revise fechas y previsualización.
2. Confirme una sola vez; pruebe además un doble clic rápido.

Resultado esperado: dos entradas de tienda, una salida de bodega y estado
PARCIALMENTE APLICADA. La segunda solicitud no duplica documentos.

```sql
SELECT md.id,md.tienda,md.tipodoc,md.documento,md.fecha,md.total,md.bloqueo,
       IF(ma.maedoc_id IS NULL,'ACTIVO','ANULADO') AS estado
FROM maedoc md
LEFT JOIN maedoc_anulado ma ON ma.maedoc_id=md.id
WHERE md.tipodoc=:tipo_aplicacion AND md.documento=CAST(:hoja AS CHAR)
ORDER BY md.id;

SELECT dm.tienda,dm.plu,
       SUM(IF(dm.movimiento='E',dm.cantidad,-dm.cantidad)) AS cantidad
FROM detmov dm
INNER JOIN maedoc md
  ON md.tienda=dm.tienda AND md.tipodoc=dm.tipodoc
 AND md.documento=dm.documento
LEFT JOIN maedoc_anulado ma ON ma.maedoc_id=md.id
WHERE dm.tipodoc=:tipo_aplicacion
  AND dm.documento=CAST(:hoja AS CHAR)
  AND ma.maedoc_id IS NULL
GROUP BY dm.tienda,dm.plu;
```

### 3. Aplicar la tercera

Seleccione únicamente la tienda pendiente y aplique.

Resultado esperado: la salida de bodega anterior se conserva y recibe sólo el
incremento de la tercera tienda; el neto de bodega equivale al negativo de
todas las entradas activas. Estado APLICADA.

```sql
SELECT plu,SUM(IF(tienda=:bodega,
                  IF(movimiento='E',cantidad,-cantidad),0)) AS bodega,
           SUM(IF(tienda<>:bodega,
                  IF(movimiento='E',cantidad,-cantidad),0)) AS tiendas
FROM detmov
WHERE tipodoc=:tipo_aplicacion AND documento=CAST(:hoja AS CHAR)
GROUP BY plu;
```

Para cada PLU, `bodega + tiendas = 0`.

### 4. Imprimir

Pulse imprimir y abra la URL `/hoja/imprimir/{tipo}/{hoja}?_format=pdf`.

Resultado esperado: PDF horizontal con encabezados repetibles, datos del
maestro, tiendas dinámicas y totales. La URL y la plantilla pertenecen a Hoja;
no aparece `trasladoentransito_imprimir`.

### 4b. Vales

1. Pulse **Vales** y confirme que sólo se habiliten tiendas con cantidades.
2. Seleccione una, varias y luego todas las tiendas disponibles.
3. Revise la previsualización y genere el PDF combinado.
4. Repita con un usuario sin `verprecio`.

Resultado esperado: un bloque separado por tienda, cantidades y totales
correctos; precio normal, oferta menor, mayoreo o moneda extranjera según la
tienda. Sin `verprecio` no se muestran importes. Dos solicitudes no comparten
datos porque no existe tabla temporal global.

Verifique además, comparando con el vale de VB6:

- Aparece la columna **Localidad** con el dato de `detloc` de la bodega (VB6 lo
  llevaba en `hoja_impresion.localidad`). Si sale vacía, revise que la bodega
  tenga localidades cargadas para esos PLU.
- El encabezado muestra empresa, número y **tipo de hoja con descripción**,
  fecha de hoja, fecha de aplicación, y bodega y tienda con código y descripción.
- Hay columnas en blanco para el conteo manual y las tres firmas (enviado,
  chequeado, recibido).
- Abrir `/hoja/vales/{tipo}/{hoja}?_format=pdf` **sin** `tiendas=` responde un
  error claro pidiendo seleccionar tienda, no un mensaje sobre "la tienda 0".

Contraste el precio elegido con la regla del legado:

```sql
SELECT mt.tienda, mt.moneda,
       hd.precio AS precio_hoja, mp.preciooferta, mp.preciomayo, mp.preciodolares
FROM hoja_detalle hd
INNER JOIN maeplu mp ON mp.plu=hd.plu
CROSS JOIN maetie mt
WHERE hd.tipodoc=:tipo_hoja AND hd.hoja=:hoja AND mt.tienda=:tienda;
```

`moneda='$'` usa `preciodolares`; `moneda='M'`, `preciomayo`; si hay oferta menor
al precio, la oferta; en los demás casos el precio de la hoja.

### 5. Desaplicar una tienda

1. Filtre aplicadas, seleccione una y pulse **Desaplicar**.
2. Ingrese un motivo.

Resultado esperado: la entrada seleccionada y la salida de bodega anterior se
registran en `maedoc_anulado`; aparecen movimientos inversos; se crea una nueva
salida de bodega sólo para las dos tiendas restantes. No cambia
`hoja_detalle`.

```sql
SELECT ma.*,md.tienda,md.tipodoc,md.documento
FROM maedoc_anulado ma
INNER JOIN maedoc md ON md.id=ma.maedoc_id
WHERE md.tipodoc=:tipo_aplicacion AND md.documento=CAST(:hoja AS CHAR);

SELECT COUNT(*) AS asignaciones
FROM hoja_detalle WHERE tipodoc=:tipo_hoja AND hoja=:hoja;
```

Repita intentando desaplicar cuando haya un movimiento posterior, un físico
igual/posterior o trazabilidad: la transacción completa debe rechazarse y las
consultas anteriores no deben cambiar.

### 6. Reaplicar

Seleccione la tienda desaplicada y aplique de nuevo. Luego seleccione una
tienda ya aplicada y confirme **reemplazo seguro**, con observación obligatoria.

Resultado esperado: la reaplicación crea documentos activos nuevos; el
reemplazo registra anteriores en `maedoc_anulado`, genera inversos y recrea
entrada/salida en una transacción.

**Cambie la cantidad distribuida antes de reaplicar** (por ejemplo, de 10 a 7) y
verifique con esta consulta, que es la que delata el defecto corregido: la salida
de bodega debe valer lo distribuido vigente y no la suma de lo anulado más lo
nuevo.

```sql
-- Suma FIRMADA por tienda de los documentos activos de la hoja.
-- Las tiendas deben dar exactamente lo distribuido; la bodega, su negativo;
-- y el total del documento debe ser cero, porque un traslado no crea existencia.
SELECT dm.tienda,
       SUM(IF(dm.movimiento='E',dm.cantidad,-dm.cantidad)) AS neto_firmado,
       SUM(IF(dm.movimiento='E',dm.cantidad,0))            AS solo_entradas
FROM detmov dm
INNER JOIN maedoc md
        ON md.tienda=dm.tienda AND md.tipodoc=dm.tipodoc AND md.documento=dm.documento
WHERE dm.tipodoc=:tipo_aplicacion
  AND dm.documento=:hoja
  AND NOT EXISTS (SELECT 1 FROM maedoc_anulado ma WHERE ma.maedoc_id=md.id)
GROUP BY dm.tienda WITH ROLLUP;
```

En la tienda reaplicada `neto_firmado` debe ser 7 mientras `solo_entradas` marca
17: esa diferencia es justamente lo que antes se escribía como salida de bodega.
La fila de totales (`ROLLUP`) debe dar `neto_firmado = 0`.

### 7. Auditoría final

Abra **Revisión**. Cada fila debe tener diferencia cero. Verifique:

```sql
SELECT hd.plu,hd.total,hd.bodega,hd.tiendas,
       hd.total-(hd.bodega+hd.tiendas) AS diferencia
FROM hoja_detalle hd
WHERE hd.tipodoc=:tipo_hoja AND hd.hoja=:hoja
HAVING ABS(diferencia)>0.00001;
```

No debe devolver filas.

Abra también **Auditoría → Hoja seleccionada** sobre la hoja que acaba de
reaplicar. **No debe reportar ninguna incidencia.** En particular no deben
aparecer `MOVIMIENTO_INVERSO_INCOMPLETO`, `DOCUMENTO_ANULADO_CONTADO_ACTIVO`,
`MOVIMIENTO_DUPLICADO`, `SALIDA_BODEGA_INCORRECTA` ni `FECHA_INCONSISTENTE`:
esos cinco se disparaban antes sobre hojas correctas, porque `detmov` no guarda
`maedoc_id` y el documento anulado comparte la clave natural con el vigente.
Si alguno reaparece, es una regresión, no un problema de los datos.

### 8. Fechas

1. Pulse **Fechas** y revise fecha general, fecha efectiva por tienda, estado y
   explicación.
2. Cambie una tienda, asigne otra fecha a todas y restaure una fila a la fecha
   general.
3. Revise la previsualización y confirme una sola vez.

Resultado esperado: se guarda el lote completo o ninguno; una tienda bloqueada,
con físico incompatible o movimientos posteriores es rechazada y se explica si
requiere reaplicar.

### 9. Gastos

1. Pulse **Gastos**, cambie bancarios, flete, seguro, impuestos, intereses,
   días, tasa y moneda que existan en el esquema.
2. Active propagación y previsualice.
3. Compare anterior, nuevo, diferencia y filas afectadas; confirme.

Resultado esperado: cálculo igual a `frmGastos`, transacción única, bitácora y
mensaje con filas modificadas. Una hoja en estado incompatible no cambia.

### 10. Ventas

Ejecute las pestañas **Con existencia**, **Sin existencia**, **Por periodos** y
**Por tienda** para el mismo rango. Pruebe 30 días, otro intervalo, acumulado y
no acumulado; exporte CSV, Excel y PDF.

Use el selector **Productos** con `Todos`, `Con venta` y `Sin venta`: las tres
listas deben ser coherentes (con + sin = todos) y `Sin venta` debe mostrar
únicamente productos con venta cero en el rango.

Resultado esperado: distribuido, venta, saldo y porcentaje coinciden entre
pantalla/exportación. Sin existencia omite columnas físicas. Las columnas de
periodo corresponden a fechas calculadas por el servidor.

Abra el CSV exportado en Excel y confirme que las descripciones con acentos se
ven bien: ahora lleva BOM UTF-8.

Sobre una hoja de tamaño real, revise el plan con `/hoja/api/ventas/explain` y
confirme que `detmov` **no** aparece con `type=ALL`. Si aparece, falta ejecutar la
migración de integridad, que agrega `ix_detmov_tipodoc_documento` y
`ix_detmov_plu_tienda_fecha`. Anote el tiempo de respuesta de la consulta más
pesada (Por tienda, rango amplio) como referencia de rendimiento.

```sql
SELECT dm.tienda,dm.plu,
       SUM(IF(dm.movimiento='S',dm.cantidad,-dm.cantidad)) AS venta
FROM detmov dm
INNER JOIN maetdc td ON td.tipodoc=dm.tipodoc
INNER JOIN maemov mm ON mm.movimiento=td.movimiento AND mm.esventa='S'
WHERE dm.fecha BETWEEN :fecha_inicial AND :fecha_final
GROUP BY dm.tienda,dm.plu;
```

### 11. Auditoría

Abra **Auditoría** y ejecute rápida global, detallada global, hoja seleccionada
y valores. Exporte cada resultado. Introduzca en la copia de QA una diferencia
controlada y confirme que se clasifique. Para valores, seleccione filas,
previsualice y sincronice sólo una hoja no aplicada.

Resultado esperado: la consulta de valores nunca modifica; la sincronización
guarda anterior/nuevo en la tabla mensual `bitacorasqlYYYYMM` de la base
configurada en `parametros_backoffice.temporales`. Documentos anulados no se
cuentan como activos y los inversos incompletos aparecen como incidencia.

### 12. Exportación

Configure `hoja_exportacion_url` y `hoja_exportacion_token` exclusivamente con
el receptor de QA. Pulse **Exportar**, seleccione tiendas y envíe. Simule un
error, reintente el mismo lote y luego confirme desde el receptor.

Resultado esperado: el fallo conserva `exportado='N'`; el reintento reutiliza
la clave idempotente; sólo una respuesta explícita `confirmado` cambia los
documentos activos a `exportado='S'`.

```sql
SELECT lote,estado,intentos,error,creado,confirmado
FROM hoja_exportacion_lote ORDER BY id DESC;
SELECT lote,tienda,maedoc_id,estado,error
FROM hoja_exportacion_detalle ORDER BY id DESC;
```

### 13. Permisos

Ejecute cada acción primero con su bandera `maeusr.hoja_*='N'` y luego con
la bandera en `S`. Compruebe especialmente aplicar, desaplicar, sincronizar
valores y exportar desde el mantenimiento normal de usuarios.

Resultado esperado: el servidor responde denegación aunque se invoque la URL
directamente. `vercosto` y `verprecio` controlan datos sensibles además de los
permisos del módulo.

## Bitácora

Busque en la tabla mensual `bitacorasqlYYYYMM` de la base configurada en
`parametros_backoffice.temporales` los eventos `HOJA_CREACION_MANUAL`,
`HOJA_DETALLE_EDITAR`, `HOJA_APLICADA`, `DOCUMENTO_ANULADO`,
`HOJA_DESAPLICADA` y `HOJA_IMPRESION`. Deben incluir usuario, tipo, hoja,
bodega/tiendas, resultado y motivo cuando corresponda.

## Pruebas negativas adicionales

- POST sin `X-Hoja-CSRF`: mensaje de token inválido.
- Segundo tipo con `eshoja='S'`: diagnóstico ambiguo y aplicación rechazada.
- Tienda sin `Txx` o `Fxx`: diagnóstico rojo; no se aplica parcialmente.
- Producto sin `detuni`: alta rechazada.
- Costo/precio cero: aplicación rechazada.
- Tienda destino igual a bodega: aplicación rechazada.
- Usuario sin permiso de anulación: desaplicación/reemplazo rechazados.
- Usuario sin permiso específico: acceso directo al endpoint rechazado.
- Repetición del mismo lote remoto: no crea un segundo lote lógico.
- Sin URL/token remoto: exportación rechazada y ningún documento se marca.
