# Hoja de Distribución: matriz de paridad VB6

Revisión técnica: **2026-08-05**. Rama: `nfeat-1517-HojaDeDistribucion`.

Fuentes funcionales inspeccionadas en `mbustamante59/mbvb6` (`master`):
`frmHojaIng`, `frmHojaAplicar`, `frmHojaBloqueo`, `frmHojaFechas`,
`frmHojaVale`, `frmHojaRevision`, `frmGastos`, `clsHoja`, `frmHojaInterna`,
`frmHojaIntAplicar`, `frmHojaIntVale`, `frmHojaOfertas`, `frmHojaFisico`,
`frmHojaProductos`, `frmHojaIntParm`, `MBInventario.vbp` y las llamadas
relacionadas de `clsConexion`, `clsInventario`, `clsDocumento` y `clsReporte`.

Convención: `HC` = `src/MB/ProductoBundle/Controller/HojaController.php`;
`HST` = `src/MB/ProductoBundle/Resources/views/Hoja/index.html.twig`. Todas las
rutas cuelgan de `/hoja`.

**Los permisos de esta matriz se extrajeron del código, no de la intención**:
son los nombres que `HojaController::assertHojaPermiso()` pasa a
`HojaPermisoService` (ver `docs/hoja-distribucion-permisos.md` para el mapeo a
columnas de `maeusr`). Un recuento automático de los 48 endpoints confirma que
cada uno valida permiso y que las 24 escrituras POST validan además token CSRF.

## Estado por función

| Función | VB6 | Archivo / clase / método web | Ruta y vista | Permiso backend | Tablas | Estado real | Prueba automática / manual | Diferencia deliberada y evidencia |
|---|---|---|---|---|---|---|---|---|
| Consulta y diagnóstico de configuración | carga de `frmHojaIng` | `HC::indexAction`; `HojaConfiguracionService::diagnosticar`, `getCondicionTiendaHoja` | `GET /` ; HST | `ver` | `hoja`, `hoja_detalle`, `maetdc`, `maetie`, `parametros_backoffice` | IMPLEMENTADO CON MEJORA WEB | `HojaServiciosBaseDatosTest::testLaCondicionDeTiendasUsaHojaAparecer`, `testConfiguracionResuelveTiposMaestroYAplicacion`; guía §3 | Maestro explícito en `parametros_backoffice.hoja_tipodoc_maestro` con respaldo `eshojaimp`; se rechaza configuración ambigua en vez de tomar el primer registro. Las tiendas salen de `maetie.hoja_aparecer='S'`, que es lo que usa `clsInventario`; `usahoja` queda sólo como respaldo. |
| Crear manual | alta en `frmHojaIng` | `HC::apiCrearManualAction`; `HojaService::crearManual` | `POST /api/manual` ; barra Nueva | `crear` | `hoja` | IMPLEMENTADO CON MEJORA WEB | revisión estática; guía §4 | `GET_LOCK` + transacción para el correlativo y respaldo con índice único (migración). |
| Crear con todos los productos | `frmHojaInterna.cmdActualizar_Click` | `HC::apiCrearProductosAction` (`modo=todos`) | `POST /api/productos` ; barra Todos los productos | `crear` | `maeplu`, `detuni`, `existencias_tienda`, `hoja`, `hoja_detalle` | IMPLEMENTADO CON MEJORA WEB | revisión estática; guía §4 | Conserva alcance Sólo bodega / Todo, días de venta e inventario; informa seleccionados, insertados y descartados. |
| Crear por parámetros | `frmHojaInterna.cmdSQL_Click`, `frmHojaIntParm` | `HC::apiCrearProductosAction` (`modo=parametros`), `getFiltrosProducto` | `POST /api/productos` ; barra Por parámetros | `crear` | `maeplu`, `hoja`, `hoja_detalle` | IMPLEMENTADO CON MEJORA WEB | revisión estática; guía §4 | Sustituye el SQL libre de VB6 por filtros de lista blanca (texto, depto, proveedor, marca, catálogo, categoría, familia, género, talla, color), cada uno validado contra `information_schema`. |
| Crear desde documentos | `frmHojaInterna.cmdDocto_Click` | `HC::getDocumentosAction`, `crearPorDocumentoAction` | `GET /getdocumentos`, `POST /crearpordocumento` ; modal Documentos | `crear_desde_documentos` | `maedoc`, `detmov`, `hoja`, `hoja_detalle` | IMPLEMENTADO CON MEJORA WEB | guía §4 | Previsualiza y confirma antes de crear; excluye documentos anulados; no crea PLU desconocidos. Igual que `frmFiltroDocumento`: la lista de tipos sale de `maetdc` sin filtrar por el permiso de utilizar (se omiten los de hoja); todos los documentos elegidos deben ser de la misma fecha (validado en pantalla y en el servidor); «Todo» pregunta «Ha escogido la opción de todas las tiendas»; se recuerdan días de venta, inventario máximo y alcance; Enter pasa al siguiente control y el doble clic marca el documento. Pendiente: la lista de bodegas sigue por permisos de tienda (`dettie`), VB6 la filtra por catálogo del usuario. |
| Crear por reposición | `frmHojaInterna.cmdReposicion_Click` | `HC::apiCrearReposicionAction`; `crearHojaDesdeSeleccion` | `POST /api/reposicion/crear` ; barra Reposición | `crear` | `hdettra`, `maeplu`, `hoja`, `hoja_detalle` | IMPLEMENTADO | revisión estática; guía §4 | Lee el histórico `hdettra` por rango y distribuye por tienda; valida que lo distribuido no supere la existencia. |
| Incorporar ofertas | `frmHojaInterna.cmdOfertas_Click`, `frmHojaOfertas` | `HC::apiOfertasAction`, `apiCrearOfertasAction` | `GET /api/ofertas`, `POST /api/ofertas/crear` ; barra Ofertas | `ver` / `crear` | `maeofe`, `detofe`, `hoja_detalle` | IMPLEMENTADO | revisión estática; guía §4 | Sólo ofertas vigentes; falla explícitamente si la instalación no tiene `maeofe`/`detofe`. |
| Importar productos y distribución | `frmHojaInterna.cmdImportar_Click`, `frmImportarHojas` | `HC::apiImportarAction`, `leerArchivoHoja`, `leerDbfHoja`, `normalizarImportacionHoja` | `POST /api/importar` ; barra Importar | `crear` | archivo, `maeplu`, `detplu`, `hoja`, `hoja_detalle` | IMPLEMENTADO CON MEJORA WEB | revisión estática; guía §4 | Acepta CSV/TXT/XLS/XLSX/DBF, detecta separador y encabezados, previsualiza, informa filas inválidas y exige confirmación antes de crear. |
| Selección masiva sobre hoja existente | `frmHojaInterna.cmdDetalle_Click`, `frmHojaProductos` | `HC::apiAgregarProductosLoteAction` | `POST /api/productos/agregar-lote` ; modal Agregar varios | `agregar_producto` | `maeplu`, `detofe`, `hoja_detalle` | IMPLEMENTADO | revisión estática; guía §5 | Tres modalidades: filtros, ofertas y lista de PLU o códigos de barra. |
| Editar distribución, costo, flete y precio | `numReturn`, `Totales`, `getTiendas` | `HC::apiUpdateHojaDetalleAction`; `HojaService::actualizarDetalleLote`, `HojaDetalleValidator` | `POST /api/update/hojadetalle` ; grilla HST | `editar_cantidades`, `editar_costo`, `editar_flete`, `editar_precio` (según celda) | `hoja_detalle`, `hoja` | IMPLEMENTADO CON MEJORA WEB | `HojaDetalleValidatorTest`; guía §5 | Lista blanca de columnas `Txx`; `tiendas` y `bodega` se calculan en el servidor; rechaza editar una tienda bloqueada y el costo o precio de una hoja aplicada. |
| Encabezado (fecha, días de venta e inventario) | controles de `frmHojaIng` | `HC::apiEncabezadoAction`; `HojaService::actualizarEncabezado` | `POST /api/encabezado` ; encabezado HST | `cambiar_fechas` y/o `editar_cantidades` según el campo | `hoja`, `hoja_detalle` | IMPLEMENTADO CON MEJORA WEB | revisión estática; guía §5 | Cambiar días de venta recalcula `Vxx` y `Dxx`; cambiar la fecha general exige que no haya documentos activos. |
| Actualizar existencias | `frmHojaInterna.cmdRefrescar_Click` | `HC::apiRefrescarExistenciasAction` | `POST /api/existencias/refrescar` ; barra Existencias | `editar_cantidades` | `existencias_tienda`, `hoja_detalle` | IMPLEMENTADO CON MEJORA WEB | revisión estática; guía §5 | Conserva `Txx`, recalcula `tiendas` y `bodega` y aborta si lo distribuido supera la existencia actual. |
| Recalcular documento fuente | `frmHojaInterna.cmdFuente_Click` | `HC::apiRefrescarFuentesAction`, `setDocumentoFuente` | `POST /api/fuentes/refrescar` ; barra Fuentes | `editar_cantidades` | `maedoc`, `detmov`, `hoja_detalle` | IMPLEMENTADO | revisión estática; guía §5 | Toma los tipos de compra configurados, no números fijos. |
| Agregar y eliminar producto | `numReturn`, `cmdEliminar_Click` | `HC::apiBuscarProductosAction`, `apiAddProductoAction`, `apiDeleteProductoAction` | `GET /api/productos/buscar`, `POST /api/add/producto`, `POST /api/delete/producto` ; HST | `agregar_producto` / `eliminar_producto` | `maeplu`, `detplu`, `detuni`, `hoja_detalle` | IMPLEMENTADO CON MEJORA WEB | guía §5 | Rechaza duplicados, código ambiguo, PLU inactivo y eliminar un producto ya aplicado en alguna tienda. |
| Convertir tipo de hoja | hoja interna vs. distribución en `frmHojaInterna` | `HC::apiTiposConversionAction`, `apiConvertirAction` | `GET /api/tipos-conversion`, `POST /api/convertir` ; barra Convertir | `crear` + `eliminar_producto` | `hoja`, `hoja_detalle`, `maetdc` | IMPLEMENTADO CON MEJORA WEB | revisión estática; guía §5 | Exige hoja completamente desaplicada y asigna correlativo nuevo bajo `GET_LOCK`. |
| Eliminar hoja completa | eliminación de encabezado | `HC::apiEliminarHojaAction` | `POST /api/eliminar` ; barra Eliminar | `eliminar_producto` | `hoja`, `hoja_detalle` | IMPLEMENTADO CON MEJORA WEB | revisión estática; guía §5 | Sólo borra la hoja de trabajo, nunca documentos ni movimientos, y exige que no haya documento activo. |
| Resumen y cuadre | `cmdSuma_Click`, `cmdCuadre_Click` | `HC::apiResumenAction`; `HojaService::resumen` | `GET /api/resumen` ; modal Resumen | `ver` | `hoja_detalle`, `hoja` | IMPLEMENTADO | `HojaDetalleValidatorTest`; guía §6 | Cuadre explícito `total = bodega + tiendas` con tolerancia 1e-5, más conteo de duplicados, sin costo y sin precio. |
| Fechas por tienda | `frmHojaFechas` | `HC::apiFechasResumenAction`, `apiFechasAction`, `apiAplicarFechaAction`; `HojaService::getFechas`, `actualizarFechas` | `GET /api/fechas/resumen`, `POST /api/fechas`, `POST /api/aplicar/fecha` ; modal Fechas | `ver` / `cambiar_fechas` | `hoja.Fxx`, `maedoc`, físicos y movimientos | IMPLEMENTADO CON MEJORA WEB | guía §7 | VB6 guardaba al salir de cada control; aquí hay previsualización, asignar a todas, restaurar general, un único Guardar, transacción y bitácora. Informa qué tienda está aplicada o bloqueada y valida inventario físico y movimientos posteriores. |
| Gastos de importación | `frmGastos.Calcular`, `cmdGrabar_Click` | `HC::apiGastosGetAction`, `apiGastosPrevisualizarAction`, `apiGastosUpdateAction`, `gastosPdfAction`; `HojaGastosCalculator`, `HojaService::actualizarGastos` | `GET/POST /api/gastos`, `POST /api/gastos/previsualizar`, `GET /gastos/{tipodoc}/{hoja}` ; modal Gastos | `editar_gastos` + `vercosto` | `hoja`, `hoja_detalle` | IMPLEMENTADO CON MEJORA WEB | `HojaGastosCalculatorTest` (total 725, unitario 7.25); guía §8 | Fórmula idéntica a `frmGastos.Calcular`: intereses pre y post = monto·%/100/365·días; total = bancarios + pre + flete + seguro + impuestos + post + proveedor; unitario = total/pares. Agrega antes, nuevo, diferencia, productos afectados, confirmación, transacción y bitácora. |
| Aplicación parcial e incremental | `frmHojaAplicar`, `frmHojaIntAplicar` | `HC::apiAplicarResumenAction`, `apiAplicarAction`; `insertMaedocHoja`, `getDetalleParaAplicar`, `getDetalleSalidaBodega` | `GET /api/aplicar/resumen`, `POST /api/aplicar` ; modal Aplicar | `ver` / `aplicar` | `hoja`, `hoja_detalle`, `maedoc`, `detmov` | IMPLEMENTADO CON MEJORA WEB | `HojaCicloInventarioTest` (3 vueltas completas); guía §9 | Exige cuadre antes de aplicar, bloquea la hoja con `FOR UPDATE`, revalida la concurrencia dentro de la transacción y toma moneda, tasa y `server_id` de la configuración, nunca fijos. |
| Reaplicar y reemplazar | `frmHojaAplicar.cmdImportar_Click` | `HC::apiAplicarAction` (`reemplazar=1`) + `DocumentoAnulacionService` | `POST /api/aplicar` ; modal Aplicar | `reaplicar` | `maedoc`, `maedoc_anulado`, `detmov` | IMPLEMENTADO CON MEJORA WEB | `HojaCicloInventarioTest::testLaSalidaDeBodegaNoSeInflaAlReaplicar` | Anula e invierte en vez de borrar y exige motivo. La salida de bodega se reconstruye con la **suma firmada** de las entradas activas: contar sólo `movimiento='E'` volvía a sumar las entradas ya anuladas. |
| Desaplicar parcial y total | `frmHojaInterna.cmdDesaplicar_Click` | `HC::apiDesaplicarAction`, `validarDocumentosParaDesaplicarSegura` | `POST /api/desaplicar` ; modal Aplicar | `desaplicar` | `maedoc`, `maedoc_anulado`, `detmov` | IMPLEMENTADO CON MEJORA WEB | `HojaCicloInventarioTest::testLaDesaplicacionNoBorraDocumentosNiMovimientos`; el neto vuelve a 0 | Exige motivo; valida fecha, inventario físico posterior, movimientos posteriores y trazabilidad; recalcula la salida de bodega para las tiendas restantes. |
| Bloquear y desbloquear | `frmHojaBloqueo` | `HC::apiBloqueoAction` | `POST /api/bloqueo` ; modal Aplicar | `bloquear` / `desbloquear` | `hoja`, `maedoc` | IMPLEMENTADO CON MEJORA WEB | guía §10 | Sólo documentos activos; deja usuario, fecha y hora, y refleja el estado en `hoja.bloqueado`. |
| Imprimir hoja | `cmdHoja_Click`, `impresionConFormulas` | `HC::imprimirAction`; `imprimir.pdf.twig` | `GET /imprimir/{tipodoc}/{hoja}` | `imprimir_hoja` | `hoja`, `hoja_detalle`, `detloc` | IMPLEMENTADO | `lint:twig` 6/6; guía §11 | Ruta y plantilla propias, sin reutilizar el reporte de traslado. Oculta costo y precio según `vercosto` y `verprecio`, y muestra estado y localidades. |
| Vales por tienda | `frmHojaVale`, `frmHojaIntVale`, `preImpresion` | `HojaValeService::getTiendasDisponibles`, `previsualizar`, `resolverPrecio`; `HC::apiVales*`, `valesPdfAction`; `vales.pdf.twig` | `GET /api/vales/tiendas`, `POST /api/vales/previsualizar`, `GET /vales/{tipodoc}/{hoja}` ; modal Vales | `imprimir_vales` + `verprecio` para importes | `hoja`, `hoja_detalle`, `maetie`, `maeplu`, `detuni`, `detloc`, `maeofe`, `detofe` | IMPLEMENTADO CON MEJORA WEB | `HojaValeServiceTest` (4 reglas de precio) y `HojaServiciosBaseDatosTest` (5 casos contra la base); guía §12 | Reglas de precio idénticas a VB6: `moneda='$'`→`preciodolares`, `moneda='M'`→`preciomayo`, oferta si es menor, en los demás casos precio normal; además se considera la oferta vigente de `maeofe`/`detofe`. Sin `TRUNCATE hoja_impresion`: cada solicitud construye sus datos, así que dos usuarios simultáneos no se mezclan. Incluye la localidad de `detloc` y columnas en blanco para el conteo. |
| Ventas con existencia | `cmdVentas_Click(0)` | `HojaVentasService::consultar` (`con_existencia`) | `GET /api/ventas` ; pestaña Con existencia | `consultar_ventas` | `hoja_detalle`, `detmov`, `maedoc`, `maetdc`, `maemov`, `maeplu`, `detuni` | IMPLEMENTADO CON MEJORA WEB | `HojaServiciosBaseDatosTest::testVentasConYSinExistencia`, `testVentasExplainNoRecorreDetmovCompleto`; guía §13 | La venta se identifica por `maemov.esventa='S'` en lugar de los tipos fijos 2 y 3 de VB6. No se genera un `OR` por PLU: se acota con `EXISTS` sobre `hoja_detalle`, y se excluyen documentos anulados. |
| Ventas sin existencia | `cmdVentas_Click(1)` | mismo motor, `sin_existencia` | misma ruta ; pestaña Sin existencia | `consultar_ventas` | mismas, sin la subconsulta de existencia | IMPLEMENTADO | `testVentasConYSinExistencia` comprueba que no trae existencia | Diferencia respecto de "con existencia": omite existencia inicial y actual, y conserva distribuido, venta, saldo y porcentaje. |
| Ventas por intervalos | `cmdVentas_Click(2)` | `HojaVentasService::calcularIntervalos`, `consultar` (`intervalos`) | misma ruta ; pestaña Por periodos | `consultar_ventas` | `hoja_detalle`, `detmov` | IMPLEMENTADO CON MEJORA WEB | `HojaVentasServiceTest` y `testVentasPorIntervalosGeneraColumnasEnBackend` (acumulado y no acumulado) | Intervalo configurable con 30 días por omisión. Las columnas de periodo se nombran `periodo_N` en el backend con fechas calculadas: el navegador nunca manda nombres de columna. |
| Ventas por intervalos y tienda | `cmdVentas_Click(3)` | mismo motor, `intervalos_tienda` | misma ruta ; pestaña Por tienda | `consultar_ventas` | anteriores y `maetie` | IMPLEMENTADO | `testVentasPorIntervalosYTiendaSeparaCadaTienda` | Una, varias o todas las tiendas de la hoja, con descripción de tienda. |
| Filtro productos con y sin venta | filtros de `frmHojaIng` | `HojaVentasService::filtroVenta`, `filtrosVentaValidos` | parámetro `venta` ; selector Productos | `consultar_ventas` | mismas | IMPLEMENTADO | `testVentasFiltraProductosConYSinVenta`, `testVentasRechazaFiltroDeVentaInvalido` | Lista blanca `todos`, `con_venta`, `sin_venta`; cualquier otro valor se rechaza. |
| Exportar ventas | reportes de `clsReporte` | `HC::ventasExportarAction`, `ventasPdfAction`; `ventas.pdf.twig` | `GET /ventas/exportar`, `GET /ventas/pdf/{tipodoc}/{hoja}` | `consultar_ventas` | sólo lectura | IMPLEMENTADO CON MEJORA WEB | rutas verificadas; `lint:twig` | CSV y XLS con BOM UTF-8 para que Excel no rompa los acentos, más PDF propio. |
| Auditoría rápida global | `frmHojaRevision.cmdRevisar_Click(0)` | `HojaAuditoriaService::rapidaGlobal` | `GET /api/auditoria/rapida` ; pestaña Rápida | `auditoria_global` | `hoja`, `hoja_detalle`, `maedoc`, `detmov`, `maedoc_anulado` | IMPLEMENTADO CON MEJORA WEB | `testAuditoriaRapidaDetectaCuadreYDiferencia` | Una sola consulta agrupada en lugar del bucle por hoja de VB6. Lo que se espera que salga de bodega es `SUM(tiendas)`; el remanente se informa aparte como `remanente_bodega`. |
| Auditoría detallada global | `frmHojaRevision.cmdRevisar_Click(1)` | `HojaAuditoriaService::detalladaGlobal`, `getIncidenciasEstructurales` | `GET /api/auditoria/detallada`, `GET /auditoria/exportar/detallada` ; pestaña Detallada | `auditoria_global` | anteriores, `maeplu`, `detuni` | IMPLEMENTADO CON MEJORA WEB | `HojaAuditoriaServiceTest`, `testAuditoriaDetectaMovimientoFaltante`, `testAuditoriaDetectaAnuladoConTotalDistintoDeCero` | Detecta documento y movimiento faltante, duplicado, anulado contado como activo, documento sin hoja, movimiento sin documento, salida de bodega y entrada de tienda incorrectas, origen, destino y fecha incorrectos, PLU y unidad inexistentes, aplicación parcial incoherente y movimiento inverso incompleto. |
| Auditoría individual | `frmHojaRevision.cmdRevisar_Click(2)` | `HojaAuditoriaService::individual`, reutiliza el motor detallado | `GET /api/auditoria/individual`, `GET /api/revision` ; pestaña Hoja seleccionada | `auditoria_individual` | mismas | IMPLEMENTADO CON MEJORA WEB | `testAuditoriaDetalladaNoInventaIncidenciasEnHojaAplicada`, `testAuditoriaNoReportaFalsosPositivosTrasReaplicar` | Agrega resumen, documentos activos y anulados, y movimientos firmados. No duplica el motor de auditoría. |
| Auditoría de costo, flete y precio | `frmHojaRevision.cmdRevisar_Click(3)` | `HojaAuditoriaService::compararValores`, `sincronizarValores`, `getColumnasValores` | `GET /api/auditoria/valores`, `POST /api/auditoria/valores/sincronizar` ; pestaña Costos | `auditoria_global` / `sincronizar_costos` + `vercosto` + `verprecio` | `hoja_detalle`, `detuni` | IMPLEMENTADO CON MEJORA WEB | `testCompararValoresDetectaDiferenciaDeCostoYPrecio`, `testSincronizarValoresSeNiegaEnHojaAplicada`, `testSincronizarValoresActualizaHojaSinAplicar` | Compara contra `detuni.dolares`, `gastosval` y `precio` como VB6, degradando si la instalación no tiene esas columnas. VB6 preguntaba y actualizaba dentro del recorrido; aquí la consulta nunca escribe y la sincronización es selectiva, confirmada, transaccional, con bitácora general y por detalle, y se niega si la hoja está aplicada. |
| Exportación remota | `cmdInternet_Click`, `SendMovimientos`, `ODBCInternet` | `HojaExportacionService::estado`, `exportar`, `crearClaveLote` | `GET/POST /api/exportacion` ; modal Exportar | `exportar_remotamente` | `maedoc`, `detmov` | **PARCIAL** (Opción B implementada; contrato HTTP probado contra receptor simulado, sin receptor real verificado) | `HojaExportacionServiceTest`; `HojaExportacionRondaCompletaTest` (7 casos con receptor simulado, incluida concurrencia real) | Sustituye la conexión ODBC remota por un POST autenticado a `hoja_exportacion_url` con `Idempotency-Key` derivado del lote (SHA-256 de tipodoc, hoja e ids). `exportado='S'` sólo después de la confirmación; ante fallo queda en `'N'` y se puede reintentar con el mismo token; se probó que dos confirmaciones concurrentes sobre el mismo lote no dejan un documento confirmado y otro no. **Decidido (2026-08-05)**: sin tabla de lotes propia; se mantiene el historial vía bitácora. **Pendiente sin fecha**: confirmación de negocio sobre si este canal ODBC sigue vigente o ya está sustituido (se preguntó explícitamente; respuesta: hay que confirmarlo con el cliente); y no hay receptor real configurado en ningún ambiente, así que aunque el contrato ya se probó contra un simulador, la ruta feliz nunca se ejecutó contra un servicio real. Ver `docs/hoja-distribucion-exportacion.md`. |
| Anulación compartida | borrado físico en formularios y documento | `DocumentoAnulacionService::anularPorId`; `DocumentoController` delega | flujos de Documento y Hoja | permiso de anulación de Documento más el de Hoja | `maedoc`, `maedoc_anulado`, `detmov` | REEMPLAZADO FUNCIONALMENTE | `DocumentoAnulacionServiceTest`, `HojaCicloInventarioTest` | VB6 borraba documento y movimientos. Aquí se conservan, se agregan los inversos por saldo y se registra la anulación, de modo que la existencia neta vuelve exactamente al estado previo sin perder historia. |
| Permisos | `maeusr`, `con.par` | `HojaPermisoService`, `MaeusrType`, `MaeusrController` | los 48 endpoints; acciones HST | interruptor `parametros_backoffice.usa_hoja_distribucion`, maestro `maeusr.hoja_todo`, banderas `hoja_*`, `modcosto`, `modprecio`, `vercosto`, `verprecio` | `parametros_backoffice`, `maeusr` | IMPLEMENTADO CON MEJORA WEB | `HojaPermisoServiceTest`; recuento automático de los 48 endpoints; guía §16 | Ocultar el botón no sustituye la validación: todo endpoint valida en backend. Si falta la columna se niega el permiso (salvo `ver`), nunca se concede por omisión. |
| CSRF y doble envío | no aplica | `HC::assertHojaWriteRequest` | las 24 escrituras POST | el de cada acción | sesión | IMPLEMENTADO CON MEJORA WEB | recuento automático: 24 de 24 POST con CSRF; guía §17 | Token `hoja_write` por cabecera `X-Hoja-CSRF` o campo `_token`, más deshabilitar botones y control transaccional. |
| Integridad e índices | esquema de `clsConexion` | `scripts/migrations/20260805_hoja_distribucion_integridad.sql` y su rollback | ejecución manual | DBA | `hoja`, `hoja_detalle`, `maedoc_anulado`, `detmov` | IMPLEMENTADO | probada en MySQL 5.7.44 aislado: alta, idempotencia, rollback dos veces y aborto ante duplicados sin borrar datos | Crea `maedoc_anulado`, que no creaba nadie y sin la cual desaplicar y reaplicar quedaban muertos. |

## Diferencias deliberadas frente a VB6

1. **No se borran documentos ni movimientos.** La desaplicación anula, invierte
   y deja rastro en `maedoc_anulado`.
2. **No se truncan tablas compartidas.** VB6 hacía
   `TRUNCATE TABLE hoja_impresion` para armar el vale y dos usuarios simultáneos
   se pisaban. Cada solicitud construye sus datos.
3. **No hay tablas temporales globales.** Las de creación llevan sufijo por
   usuario y `uniqid()`, y se eliminan en `finally`.
4. **No se concatena SQL remoto.** El receptor es una API autenticada con clave
   de idempotencia.
5. **No se toma el primer registro de una configuración ambigua**: se exige un
   único tipo y se informa el conflicto.
6. **No se codifican números fijos.** VB6 usaba los tipos 2 y 3 para venta, el
   tipo 7 para la hoja y `INVGLBBOD` para la bodega. Aquí la venta es
   `maemov.esventa='S'`, el tipo sale de `maetdc` y `parametros_backoffice`, y la
   bodega es `hoja.bodega`. Moneda, tasa y `server_id` se leen de la
   configuración.
7. **No se crean productos automáticamente**: `maeplu` y su unidad en `detuni`
   deben existir.
8. **Las tiendas de la hoja salen de `hoja_aparecer`**, igual que VB6, en lugar
   de `usahoja`.

## Alcance de la evidencia

Verificado en esta revisión con PHP 5.6.40 y MySQL 5.7.44 en el ambiente
aislado `docker/hoja-test`:

- `php -l` sobre todos los PHP modificados.
- `lint:twig` 6 de 6 y `lint:yaml` 10 de 10.
- 95 pruebas y 370 assertions en verde, de las cuales 32 ejecutan SQL real (7
  de ellas contra un receptor HTTP simulado, sin red real).
- Migración de integridad y su rollback, incluido el aborto ante duplicados.
- El contrato HTTP de la exportación remota (envío, confirmación, rollback,
  reintento, concurrencia) contra un receptor simulado, no real.

**No** verificado, y necesario para cerrar la aceptación:

- La exportación remota contra un receptor real (única fila `PARCIAL`; falta
  además confirmación de negocio sobre si el canal sigue vigente).
- La revisión visual de los PDF de hoja, vales, gastos y ventas.
- El recorrido completo de la interfaz por un operador acostumbrado a VB6.
- La ejecución sobre una copia controlada de datos del cliente: las pruebas
  corren sobre un esquema mínimo, no sobre el esquema completo de producción.

No se declara 100 % de paridad ni aceptación de negocio.
