# Migrar un catálogo legado (`Mae*Controller`) a Diccionario

Runbook técnico para convertir uno de los ~30 controladores legados
`Mae*Controller.php` de `mbinv` (CRUD hecho a mano, uno por tabla) en
configuración de Diccionario (metadata-driven, sin código nuevo). Distinto
de `DEPLOY.md` (ese es el manual de soporte para desplegar/activar el
módulo en un cliente — este es el proceso para migrar una tabla puntual).

Todo el trabajo de este runbook se hace contra `mbinvdiccionario`
(132.226.40.48:3310, user `manuel`) — la base de pruebas dedicada a curar
la plantilla general, nunca contra `mbinvinstalar` real. Ver DEPLOY.md
sección 4 para cómo esa configuración llega después a un cliente real.

## 0. Clasificar la tabla antes de empezar

No todos los `Mae*Controller` son iguales. Mirar el tamaño del archivo y
qué hace, no solo el nombre:

- **Clase A — CRUD puro**: código + descripción y poco más, sin lógica de
  negocio. Migra directo con este runbook, sin sorpresas grandes.
- **Clase B — CRUD con 1-3 rasgos especiales**: FK con opción extra fija,
  "duplicar con siguiente código", subformularios por pestaña, campo con
  imagen, etc. Migra con este runbook + resolver esos rasgos puntuales
  (ver sección 6, "Huecos conocidos").
- **Clase C — no es un catálogo real**: motor de reglas, módulo
  transaccional completo (facturación, pedidos, etc.), o ni siquiera es
  una tabla maestra a pesar del nombre `Mae*`. **No migrar** — forzarlo al
  modelo de Diccionario es más trabajo que valor. Ejemplos ya identificados:
  Maepago (no es catálogo), Maeofe/Maedoc/Maeped/Maecli (motores
  transaccionales), Maepro/Maeusr/Maeven (necesitan que primero se saque
  su lógica especial — upload de imagen, sync contable externa, árbol de
  permisos — a servicios aparte).

## 1. Reunir la información del controlador legado

Antes de tocar `dic_campo`, leer estos 4 archivos de `mbinv` (el checkout
principal, no el worktree de Diccionario):

1. **`Entity/{Tabla}.php`** — columnas físicas, tipos, y sobre todo la
   llave primaria: `@ORM\Id` + `@ORM\GeneratedValue(strategy="IDENTITY")`
   significa auto-incremento real (Diccionario ya lo maneja solo); sin
   `GeneratedValue`, el código probablemente se autoasigna a mano en
   `createAction` (ver punto 3).
2. **`Form/{Tabla}Type.php`** — qué campos usa el formulario legado
   (`->add('campo', ...)`) y sus labels. Ojo con dos trampas (ver
   sección 5): nombres en camelCase que no calzan con la columna real, y
   líneas `->add(...)` comentadas que igual "cuentan".
3. **`Repository/{Tabla}Repository.php`** — si existe un método
   `sePuedeBorrar($pk)`, ahí está la regla de restricción de borrado
   (normalmente `SELECT COUNT(*) FROM tabla_hija WHERE campo_fk = :pk`).
   Si no existe el repositorio o el método, el legado no protege el
   borrado — no inventar una restricción que no estaba.
4. **`Controller/{Tabla}Controller.php`**:
   - `indexAction`: buscar `$entityId`/`$entityIdLabel` — dice qué columna
     usa el LISTADO como "código" (a veces es la PK real, a veces es un
     campo secundario tipo `idemisor`, no lo asumas).
   - `createAction`: buscar un patrón `SELECT max(e.campo) ... + 1` — es
     el "código automático", ver sección 4.

## 2. Generar los campos mecánicamente

```php
$dic->crearTablaDesdeEsquema('tabla', 'campo_pk_fisico', 'Código');
```

Esto trae todas las columnas físicas, detecta cuáles usa el `Type.php`
legado (oculta las que no), y documenta títulos desde los labels que
encuentre. Es re-ejecutable sin pisar ediciones humanas — no tiene
problema correrlo de nuevo si hace falta ajustar algo.

Si la tabla no existía antes en `dic_tabla`, agregar el título/descripción:

```php
INSERT INTO dic_tabla (tabla, titulo, descripcion, orden)
VALUES ('tabla', 'Título Plural', 'Descripción corta para el usuario.', 0);
```

## 3. Revisar y corregir lo que el auto-detector no puede saber

**Siempre revisar el resultado antes de darlo por bueno** — el
auto-detector es un parser de regex simple sobre el `Type.php`, tiene
límites conocidos (ver sección 5). Con la tabla ya creada, comparar
`SELECT campo, titulo, visible_lista, visible_form FROM dic_campo WHERE
tabla=?` contra el `Type.php` leído en el paso 1, campo por campo.

## 4. Código automático (patrón "MAX + 1")

Muy común en los catálogos viejos: el código no lo escribe el usuario, se
autoasigna en el servidor (`SELECT MAX(campo)+1`) y ni aparece en el
formulario. Diccionario tiene un token para esto —
`Dic::AUTO_SIGUIENTE` (`{auto_siguiente}`) en `valor_default` — que
`createFila()` resuelve siempre en el servidor, nunca confía en lo que
mande el cliente.

Dos variantes, según cómo se comporte en el legado:

- **Nunca se muestra** (ni al crear ni al editar) — caso típico de la PK
  natural (ej. `maedep.depto`, `maeuni.unidad`):
  ```php
  UPDATE dic_campo SET valor_default='{auto_siguiente}', visible_form=1 WHERE tabla=? AND campo=?;
  -- visible_form=1 es necesario (el ocultado-en-creacion es un check aparte
  -- en el frontend), pero si el legado tampoco lo muestra en EDICION,
  -- hay que además ponerlo en 0 para ocultarlo del todo:
  UPDATE dic_campo SET visible_form=0 WHERE tabla=? AND campo=?;
  ```
- **Se muestra siempre pero deshabilitado** (ej. `maemex.codigo`, con
  `'disabled' => true` en el Type.php) — se autoasigna igual, pero
  además debe verse (de solo lectura) al editar:
  ```php
  UPDATE dic_campo SET valor_default='{auto_siguiente}', visible_form=1, solo_lectura=1 WHERE tabla=? AND campo=?;
  ```

El token funciona en **cualquier columna**, no solo en la PK — ej.
`maeemi.idemisor` es un contador aparte de la PK real (`id`, autoincremento
de verdad) y usa el mismo mecanismo.

## 5. Trampas conocidas del auto-detector (`crearTablaDesdeEsquema`)

Encontradas migrando las 11 tablas de la ola A — revisar siempre, no
asumir que el resultado automático está completo:

1. **No entiende comentarios PHP.** Un `//->add('campo', ...)` comentado
   en el legado igual matchea la regex y se marca como "usado" (visible).
   Encontrado en Maeemi (`imprimedetalle`) y Maeuni (`contenido`, `caja`).
   Si un campo quedó visible pero en el `Type.php` está comentado,
   ocultarlo a mano.
2. **No mapea camelCase↔snake_case.** El regex busca el nombre de
   propiedad PHP (`->add('voucherEncabezado', ...)`), pero
   `crearTablaDesdeEsquema` compara contra el nombre de columna física
   (`voucher_encabezado`) — nunca hacen match, así que el campo queda
   oculto por error. Encontrado en 15 de los 27 campos de Maeemi, y de nuevo en Maepro
   (`caftaNombre`, `pedidoWebSite`, `contactoEmail`, `tipoProveedor`,
   `pedidoFormato` — 8 campos). Revisar especialmente en tablas con muchos
   campos `snake_case` con nombre de propiedad `camelCase` distinto.

Cuando alguno de estos dos casos aparezca, corregir con:
```php
UPDATE dic_campo SET visible_lista=1, visible_form=1, titulo='...' WHERE tabla=? AND campo=?; -- mostrar
UPDATE dic_campo SET visible_lista=0, visible_form=0 WHERE tabla=? AND campo=?;                -- ocultar
```

## 6. Features de Diccionario disponibles para campos especiales

| Necesidad en el legado | Cómo configurarlo en `dic_campo` |
|---|---|
| Sí/No con valores `S`/`N` | `val_tipo='si_no'` (atajo, no hace falta `val_valores`) |
| Sí/No u otro choice con otros valores (ej. `0`/`1`) | `val_tipo='lista_valores'`, `val_valores` en JSON `[{"value":"0","label":"No"},...]` |
| Lista de valores fija con más de 2 opciones | igual que arriba, cualquier cantidad de opciones |
| FK a otra tabla (select con etiqueta resuelta) | `rel_tipo='fk'`, `rel_tabla`, `rel_campo` (columna PK de la tabla relacionada), `rel_campo_desc` (columna descriptiva) — **el auto-detector nunca infiere esto solo**, siempre a mano |
| Campo calculado como copia de otro | `calc_tipo='copia'`, `calc_expr='campo_origen'` |
| Multi-select con un valor default (tabla detalle) | `rel_tipo='detalle_multiple'` + `det_tabla_valores`/`det_campo_valor`/`det_campo_valor_desc` — ver panel admin, sección "Detalle múltiple" |
| Código automático (MAX+1) | ver sección 4 |
| Restricción de borrado (hijos dependientes) | fila en `dic_restriccion` (`tabla`, `campo_pk`, `tabla_hija`, `campo_fk`, `mensaje`) — replicar exactamente lo que decía `sePuedeBorrar()` |

## Huecos conocidos

Encontrados en el análisis inicial de las ~14 tablas de clase B. Los 3
primeros ya están construidos (julio 2026) — quedan documentados aquí
como referencia de uso, no como pendiente:

1. **FK con opción extra fija o unión de tablas** — ✅ construido.
   `dic_campo.rel_extra_valor`/`rel_extra_texto` inyecta una opción fija
   al inicio de la lista (ej. Maecaj → `0 = 'TODAS'` sobre el FK a
   `maetie`). `rel_tabla2`/`rel_campo2`/`rel_campo_desc2` arma la lista
   como UNION con una segunda tabla (ej. Maerutas: `maeven` UNION
   `maecaj`). Se configuran en el panel admin, sección "Tabla rel.",
   bloque "FK — opción extra / unión con otra tabla".
2. **Acción genérica "duplicar fila con siguiente código libre"** — ✅
   construido. `Dic::duplicarFila()` + `POST /dic/datos/{tabla}/{id}/duplicar`.
   No requiere cambios de frontend — se activa agregando una fila en
   `dic_accion` con `tipo='proceso'` y
   `url_patron='{apiBase}/dic/datos/{tabla}/{pk}/duplicar'` (mismo
   dispatcher `ejecutarAccion` que ya usa cualquier acción tipo
   `proceso`). Recalcula automáticamente cualquier campo
   `{auto_siguiente}`; nunca copia columnas marcadas `es_pk=1`.
3. **Grupos de campos en pestañas dentro del mismo formulario** — ✅
   construido. `dic_campo.grupo` (texto libre, default null = "General").
   Con 2+ grupos distintos entre los campos visibles del formulario,
   `FormModal.tsx` muestra pestañas automáticamente; si al validar hay un
   error en un grupo no activo, salta a esa pestaña. Se configura en el
   panel admin junto al campo "Orden".
4. **Subida de imagen → columna con la ruta** — ✅ construido (julio 2026,
   a raíz de Maepro). `dic_campo.control_edicion='imagen'` +
   `dic_campo.imagen_carpeta` (subcarpeta bajo `web/public/images/dic/`).
   `Dic::guardarImagen()` valida el archivo por contenido real
   (`getimagesize()`, nunca por la extensión que mande el cliente), tope
   5MB, nombre determinístico `{pk}.{ext}` (reemplaza en vez de acumular).
   No pasa por `createFila()`/`updateFila()` — llega como multipart a
   `POST /dic/datos/{tabla}/{id}/imagen/{campo}`, endpoint aparte. Frontend
   (`ImagenInput` en `FormModal.tsx`) sube apenas se elige el archivo, sin
   esperar al botón Guardar — necesita la PK ya asignada, así que en
   creación queda deshabilitado con una nota. Caso real: Maepro
   (`pedido_logo_url`, carpeta `proveedor`).
5. **Campo de contraseña con hash al guardar** — ✅ construido (julio 2026,
   a raíz de Maecaj). `dic_campo.control_edicion='password'` +
   `dic_campo.password_hash` (estrategia configurable por campo — hoy solo
   `md5_upper`, el patrón legado de `maecaj.clave`/`maeusr.clave`; agregar
   más casos en el `switch` de `Dic::aplicarHashPassword()` si aparece
   otro, ej. bcrypt). Reglas: vacío al guardar = "no cambiar" (nunca se
   hashea `''`), y `Dic::getDatos()` **nunca** devuelve el hash real al
   frontend — el campo siempre llega como `''`, tanto en la grilla como al
   precargar el formulario de edición. El frontend (`FormModal.tsx`)
   agrega un segundo input "Confirmar" que es puramente del navegador (no
   se guarda) y avisa si no coincide antes de guardar. Configurar
   `obligatorio=0` en este tipo de campo — si no, el formulario exige
   reescribir la clave en cada edición aunque no se quiera cambiar. Si el
   legado restringe el formato de la clave (ej. `maecaj.clave`:
   `ctype_digit()`, solo dígitos — por compatibilidad con algo que la
   consume, no se investigó qué exactamente), replicarlo con
   `val_tipo='solo_numeros'` (u otro `validacion_tipo` existente) — la
   validación del catálogo ya lo revisa solo, sin cambios de motor.

## 7. Verificación end-to-end (obligatoria antes de dar por migrada una tabla)

No alcanza con mirar `dic_campo` — probar de verdad:

1. `Dic::getDefinicion('tabla')` y comparar campos visibles contra el
   `Type.php` legado, uno por uno.
2. `Dic::createFila('tabla', [...], ...)` **sin mandar** los campos con
   `{auto_siguiente}` — confirmar que el valor se autoasigna y coincide
   con `MAX(campo)+1`. Repetir la creación una segunda vez para confirmar
   que sigue incrementando.
3. Si hay `dic_restriccion`: `Dic::deleteFilas()` contra un registro que
   sí tenga hijos dependientes — debe bloquear con el mensaje configurado.
   Contra uno sin hijos — debe borrar sin problema.
4. Limpiar cualquier fila de prueba creada durante la verificación.
5. **Visual**: abrir la tabla en `mbinv-catalogo` (local:3000 o
   qa-manuel), revisar que la lista y el formulario de alta se vean bien
   — sobre todo en tablas con muchos campos `select`/Sí-No, que son las
   que más fácil quedan mal configuradas sin que un test de backend lo
   note.

## 8. Permisos — no olvidar

Desde el fix de permisos (julio 2026), **una tabla sin fila en
`dic_permiso` para un usuario queda completamente bloqueada para ese
usuario** (ver/crear/editar/eliminar) — ya no hay acceso total por
default. Toda tabla recién migrada necesita su fila de permiso explícita
para el usuario que la va a probar/usar, si no da 403 en todo:

```php
INSERT INTO dic_permiso (tabla, user_id, puede_ver, puede_crear, puede_editar, puede_eliminar)
VALUES ('tabla', 'usuario_id', 1, 1, 1, 1);
```

## Ola A — estado (referencia)

Las 11 tablas de clase A ya están migradas y verificadas:
Maeart, Maecas, Maedep, Maeest, Maemar, Maelin, Maevar, Maemov (comparten
plantilla `Entity/*.html.twig` en el legado), y Maeemi, Maemex, Maeuni
(vistas propias). Todas con permiso otorgado al usuario de pruebas
(`mb`, `user_id=1`).

## Ola B — estado (referencia)

**11 de 11 migradas y verificadas — ola B completa**: Maeprocedencia,
Maeindustria, Maecocinero (tabla física `maecocineros`), Maemotorista,
Maerecipiente, Maetam, Maecup, Maerutas, Maecaj, Maetie, Maetdc.

Notas puntuales de esta tanda:
- **Maecup** es la más rica migrada hasta ahora (46 columnas físicas, 21
  visibles) — tiene una **tercera trampa del auto-detector**, además de
  las dos ya documentadas en la sección 5: campos con
  `'mapped' => false` + `style: display:none` en el `Type.php` legado
  (visualmente ocultos y ni siquiera se guardan) igual matchean la regex
  y quedan marcados "usados". 14 campos de Maecup cayeron en esto —
  revisarlos a mano.
- **Maerutas**: el legado elige el origen del `choice` de vendedor en
  tiempo de ejecución (`maeven` si tiene filas, si no `maecaj`) —
  Diccionario no soporta "FK condicional según datos". Se simplificó a
  FK fijo → `maeven` (el caso normal). Si un cliente no tiene vendedores
  cargados, el select queda vacío en vez de caer a `maecaj` — limitación
  conocida, no bloqueante.
- **Maecocineros/Maerutas** tienen un campo tipo `fechabaja` que el
  legado calcula solo a partir de otro campo (`activo`/`baja`) al
  guardar — no hay forma de replicar esa lógica hoy en Diccionario
  (ver huecos abajo), así que se dejó de solo lectura/informativo en
  la lista, no editable.

**Maetdc (Tipos de Documento) — migrada** (julio 2026), reevaluación del
diagnóstico anterior. Se creía bloqueada por `getTiposAction()` (cascada de
reglas de negocio sobre 6+ banderas de `maemov`), pero al leer el código
con más detenimiento: **esa cascada solo alimenta un `<select>` de
sugerencia en el formulario de creación del legado — el backend nunca la
revalida al guardar** (`createAction()` acepta cualquiera de los 25
valores fijos de `documentoTipo` sin volver a chequear la cascada, y
tampoco hay restricción de unicidad a nivel de base de datos sobre
`es_produccion`/`expent`). Migrado como `lista_valores` simple con los 25
valores fijos — se pierde la sugerencia inteligente (filtrar opciones
según el movimiento y qué ya está tomado), pero ninguna garantía de datos
real se pierde con eso, porque el legado tampoco la tenía a nivel de
servidor.

72 columnas físicas, 33 visibles en el formulario, 4 pestañas (General,
Contable / SIRVO, Impuestos, Tiquete). Notas:

- **Trampa nueva del auto-detector (la opuesta a Maecaj/Maetie)**: acá el
  regex SÍ detectó de más — `MaetdcType.php` tiene ~25 líneas de
  `->add(...)` comentadas (código muerto dejado a propósito) y el parser
  las contó igual como "usadas". 21 campos quedaron visibles por error
  (`status`, `correl`, `cambio`, `costo`, `cuentacargo`, etc.) y se
  ocultaron a mano. Ya estaba documentada como trampa #1 en la sección 5
  ("comentarios de PHP igual matcheados"), esta es la primera vez que se
  ve en la práctica con este volumen.
- **Columna faltante encontrada de paso**: `usaUnidadEmpaque` existe en
  `MaetdcType.php` y en la Entity (`@ORM\Column(name="usa_unidad_empaque")`)
  pero la columna física **no existe** en `mbinvdiccionario` — drift de
  esquema preexistente, no se investigó si es específico de esta base o
  sistémico. El auto-detector correctamente no generó `dic_campo` para
  ella (solo lee columnas físicas reales); se omitió sin más, no bloqueó
  la migración.
- `movimiento` es FK a `maemov`, simplificado a "todas las filas" — el
  legado filtra con un WHERE (excluye ventas/anulación/cierre/apertura/
  físico/importación), que Diccionario no soporta a nivel de FK. Aparecen
  algunas opciones de más en el desplegable; no bloqueante.
- `tipodoc` (PK física) es también el código de negocio auto-asignado
  (MAX+1), mismo patrón que `maetie.tienda`.
- Restricción de borrado: `maedoc.tipodoc` (única, simple, sin las
  complicaciones de Maetie).
- Sin acción "Duplicar" — el legado de Maetdc no la tiene (a diferencia de
  Maecaj/Maetie/Maeofe), no se agregó de más.

Con esto, **la ola B queda completa: 11 de 11**.

**Maecaj (Cajeros) — migrada y verificada** (julio 2026). 100 columnas
físicas, 81 visibles en el formulario, agrupadas en 13 pestañas (General +
12 más, siguiendo los propios comentarios de sección del `MaecajType.php`
legado: Facturación/Macrobase/Documento/Cuenta por Cobrar/Corte de
Caja/Clientes/Autorización Remota/Certificados y Vale/Avanzado/POSTouch/
Solo MBCaja/Listas de precio). Notas:

- **Trampa nueva del auto-detector**: `MaecajType.php` es un `FormType`
  enorme (~150 líneas, ~80 `->add()` encadenados) — `extraerInfoFormType()`
  solo detectó ~12 de esos campos como "usados", el resto quedó marcado
  oculto por defecto. No se investigó la causa exacta del regex (probablemente
  se pierde en cadenas `->add()->add()->add()` muy largas sin cortar), pero
  el síntoma es claro: **en FormTypes grandes, no confiar en el conteo de
  "campos detectados" — comparar a mano contra el archivo fuente**. Se
  corrigió mapeando los ~80 campos a mano (título + grupo) directo desde el
  `.php`, en un solo script en vez de uno por uno en el panel.
- **`vendedor`** es `varchar(255)` (parece multi-valor por el nombre del
  parámetro `vendedores` en el controller) pero el controller lo trata como
  escalar (`setVendedor($request->get('vendedores'))`, sin split de comas) y
  el `<select>` del legado no tiene `multiple` — es un FK simple a
  `maeven.vendedor`, no `detalle_multiple`. Confirmado también por los datos
  reales (cero filas con coma). Configurado con opción extra `0 = Ninguno`.
- **`tienda`** FK a `maetie.tienda`, opción extra `0 = TODAS` (igual que
  Maecli/Maecas) — la pantalla legada dedicada `/maecaj/tienda/{id}` para
  cambiar solo la tienda ya no hace falta: el campo vive en la pestaña
  "General" del formulario unificado.
- **`permite_modificar_cliente_listas`** y **`hace_descuento_lista_listas`**
  sí son multi-valor real (`<select multiple>` + `IN (...)` en SQL) —
  `rel_tipo='detalle_multiple'` contra `listaprecios` (`lista`/`descripcion`).
- **Contraseña** (`clave`) — **revisado después** (julio 2026, revisión de
  "acciones que abren el formulario viejo y regresan al catálogo viejo"): se
  construyó `control_edicion='password'` genérico en el motor (ver sección
  de huecos construidos más abajo) y `clave` ya vive en su propia pestaña
  "Contraseña" del formulario unificado — la acción `tipo=url` hacia
  `/maecaj/password/{pk}` quedó desactivada (`activo=0`, no borrada, por si
  hace falta revertir). `clavea`/`clave_nueva` (mirror sin uso real —
  ningún otro archivo lee `clave_nueva`) se dejaron ocultas, no se
  investigó su propósito histórico más allá de confirmar que nada las
  consume hoy.
- **Restricción de borrado con 2 columnas hijas sobre el mismo padre**:
  `hdettra.cajero` Y `hdettra.autorizadopor` referencian ambas
  `maecaj.cajero` — se resolvió con 2 filas en `dic_restriccion` (una por
  `campo_fk`), sin cambios de motor.
- **Fix de motor real, no solo config**: `dic_restriccion.campo_pk` existía
  en el schema desde antes pero `Dic::verificarRestricciones()` nunca lo
  leía — asumía que la columna de borrado (`id`) era la misma que la tabla
  hija referencia. Funcionaba por coincidencia en toda tabla migrada hasta
  ahora porque PK física y columna referenciada eran la misma. Maecaj es el
  primer caso real donde `id` (PK física) ≠ `cajero` (código de negocio que
  `hdettra` realmente referencia) — corregido: si `campo_pk` guardado
  difiere de la columna usada para borrar, se traducen los ids antes de
  comparar contra la tabla hija. Sin este fix, borrar cualquier fila de
  Maecaj habría ignorado silenciosamente la restricción real.
- **Límite conocido, sin resolver**: el legado oculta la fila del usuario
  `MB` en el listado a menos que quien esté viendo también sea `MB`
  (filtro por sesión en `indexAction`). Diccionario no tiene "visibilidad
  condicional por fila según quién pregunta" — solo permisos por tabla.  La
  fila de `MB` es visible en el catálogo nuevo para cualquiera con permiso
  de ver `maecaj`. No bloqueante (es solo el usuario de soporte/pruebas),
  pero documentado por si en algún cliente real importa.
- Acciones agregadas: **Duplicar** (usa el motor genérico
  `Dic::duplicarFila()` — nota: el legado además pone el nombre en blanco
  como "Nuevo Cajero N" y limpia el email al duplicar; la versión genérica
  copia esos campos tal cual, el usuario los edita después — diferencia
  menor, aceptada) y **Cambiar contraseña** (url a la pantalla legada).
- Config subida a la plantilla general (`mbinvestructura`) vía
  "Aportar a la plantilla" — un cliente nuevo que active Diccionario ya
  nace con Maecaj preconfigurado.

**Maetie (Tiendas) — migrada, con alcance reducido deliberadamente**
(julio 2026). 145 columnas físicas (la tabla más grande migrada hasta
ahora), 83 visibles en el formulario, 6 pestañas (General, Contable,
GFACE, GFACE — Establecimiento, GFACE — Correo, FEL).

- **Ver + editar + crear + duplicar habilitados; eliminar sigue
  deliberadamente fuera** (crear/duplicar resueltos julio 2026, ver
  detalle más abajo):
  - Al eliminar, el legado valida existencias en 2 fuentes
    (`checkStoreStock()`) y hace match por `LIKE` contra
    `documentosmaestro.tiendas` (CSV, no es un FK normal) — ninguno de los
    dos es expresable con `dic_restriccion` (solo hace `COUNT(*) WHERE
    campo_fk IN (...)`). Se configuró la única restricción que SÍ es un FK
    limpio (`maedoc.tienda`) como red de seguridad parcial, pero
    **no reemplaza las validaciones del legado** — no habilitar "eliminar"
    para clientes reales sin revisar esto primero.
  - `dic_permiso` debe quedar con `puede_eliminar=0` para clientes reales,
    a propósito (en la base de pruebas está en 1 solo para poder probar
    todo el flujo, no replicar ese valor tal cual a un cliente).
- **Sin campos contables como FK real**: `codgrupo`/`codempresa`/
  `cuentainv`/`cuentacostoventa`/`cc`/etc. dependen de una nomenclatura
  contable dinámica según `codempresa` (`Contabilidad::getNomenclatura()`)
  — Diccionario no soporta "FK condicional según otro campo" (misma
  limitación ya documentada para Maerutas). Quedaron como texto libre.
- El tab GFACE tiene varios campos `choice` con un único valor posible
  fijo (ej. país=GT, moneda=GTQ) — se migraron igual como
  `lista_valores` de 1 opción en vez de simplificarlos a texto, para que
  el desplegable documente cuál es el único valor válido.
- Ningún cambio de motor nuevo en la migración original (ver + editar) —
  reutiliza todo lo construido para Maecaj sin sorpresas. Habilitar crear
  y duplicar sí requirió motor nuevo, ver más abajo.
- **Acción "Autocompletar GFACE" agregada después** (revisión de acciones
  faltantes, julio 2026): `tienda_gface` no era solo un formulario
  alterno — al guardar aplica un preset por certificador
  (`web/js/certificadores_gface.json`) que sobrescribe
  `gface_nit`/`gface_metodo`/`fel_url_certificador`/`fel_url_anulacion`/
  etc. según la empresa GFACE elegida. Expuesta como acción `tipo=proceso`
  reutilizando la lógica privada `applyGfaceJsonExact()` ya existente en
  `MaetieController`. Requirió agregar excepción de firewall
  (`^/tienda/api/gface-autocompletar` en `security.yml`) — sin eso el
  catálogo público (autenticado por token, no por sesión) recibía 302 a
  login, mismo patrón que ya existía para `/maecli/api/alta|baja`.
  También se agregaron a los `lista_valores` de varios campos GFACE/FEL
  los valores reales que el preset escribe (no coincidían con las
  opciones originales del `FormType` legado — drift ya existente en el
  propio legado, no introducido por esta migración).
- **Revisión sistemática de "acciones faltantes"** en Maecaj/Maetie/Maetdc
  (rutas del controller más allá del CRUD básico): `maecaj_tienda` y
  `tipo_get` resultaron redundantes/ya cubiertas, sin nada que agregar.
  Confirmado NO hay lógica oculta similar a GFACE en esas dos tablas.

**Crear y Duplicar habilitados** (julio 2026) — requirió 3 capacidades
nuevas de motor, ninguna específica de Maetie (reutilizables por cualquier
catálogo futuro con el mismo patrón):

1. **`dic_tabla.post_crear_tabla`/`post_crear_campo_pk`/
   `post_crear_campo_usuario`** — al crear una fila, si las 3 columnas
   están configuradas, `Dic::aplicarPostCrear()` inserta además una fila
   en esa tabla extra con `{campo_pk: <PK nueva>, campo_usuario: <usuario
   que crea>}`. Para Maetie: `dettie`/`tienda`/`usuario` — así el usuario
   que crea una tienda queda con acceso, igual que el legado. Usa la PK
   "natural" (`dic_campo.es_pk=1`) si existe, si no `lastInsertId()` —
   mismo criterio que ya usaba `duplicarFila()`. Solo aplica a
   `createFila()`, no a duplicar ni a carga masiva (el legado tampoco lo
   hace al duplicar una tienda, confirmado leyendo `duplicarAction()`).
2. **`dic_campo.valor_unico`** — si se guarda exactamente ese valor en el
   campo, `Dic::verificarValorUnico()` (llamado desde `createFila()` y
   `updateFila()`) rechaza el alta/edición si ya existe OTRO registro con
   el mismo valor. Para Maetie: `es_transito` con `valor_unico='S'`,
   replica `yaExisteTransito()` del legado (solo una tienda puede ser "de
   tránsito"). Mensaje de error generado con `titulo_reporte` del campo.
3. **`dic_campo.duplicar_sufijo`** — descubierto necesario recién al
   probar Duplicar de verdad: `descripcion` tiene una restricción UNIQUE
   física (`maetie.unica`) y `duplicarFila()` copiaba el valor tal cual,
   así que duplicar SIEMPRE fallaba con un error crudo de MySQL. Es
   exactamente por lo que el legado le agregaba " (Duplicada YYYY-MM-DD)"
   a la descripción — no era cosmético. Con `duplicar_sufijo=1`, el motor
   le agrega " (copia \<código nuevo\>)" (más seguro que una fecha: nunca
   choca, incluso duplicando el mismo origen varias veces el mismo día).
   Maecaj/Maeofe nunca lo necesitaron porque ninguno tiene un campo NO-PK
   con restricción UNIQUE — primera vez que aparece este caso.

También se corrigió que **`Dic::exportarPlantilla()`/`importarPlantilla()`
no propagaban estas 3 capacidades a `mbinvestructura`** — la lista
`$CAMPOS_PLANTILLA_RELLENABLES` (dic_campo) y la nueva
`$TABLA_PLANTILLA_RELLENABLES` (dic_tabla) no las incluían. Corregido
agregándolas a ambas listas. **Nota aparte, no resuelta ahora**: al
revisar esto se confirmó que `$CAMPOS_PLANTILLA_RELLENABLES` ya estaba
desactualizada desde antes — le faltan `rel_extra_valor`/`rel_extra_texto`/
`rel_tabla2`/`rel_campo2`/`rel_campo_desc2`, los campos de detalle
múltiple (`det_*`), `calc_tipo`/`calc_expr`, `password_hash`, `grupo`, y
el propio `valor_default` (el patrón AUTO_SIGUIENTE tampoco se propagaba
a la plantilla antes de esto). Ninguna tabla migrada hasta ahora dependía
de que esos campos viajaran a la plantilla, así que no se tocó — pendiente
si se necesita en el futuro.

Configuración aplicada y ya exportada a la plantilla general
(`mbinvestructura`): `maetie.tienda` con `valor_default={auto_siguiente}`,
`maetie.es_transito` con `valor_unico=S`, `maetie.descripcion` con
`duplicar_sufijo=1`, `dic_tabla` de maetie con
`post_crear_tabla=dettie`/`post_crear_campo_pk=tienda`/
`post_crear_campo_usuario=usuario`, y una fila `dic_accion` nueva
("Duplicar", tipo proceso, `url_patron` con `?pk=tienda` explícito — ver
gotcha abajo). Verificado end-to-end (curl + Playwright contra la UI
real) contra `mbinvdiccionario` local: crear vía formulario inserta
`dettie` correctamente, `es_transito` único se respeta en create y
update, duplicar genera el sufijo y no choca con la restricción UNIQUE.

**Gotcha de `url_patron` para tablas sin PK física `id`**: el frontend
(`construirUrlAccion()` en `CatalogoGrid.tsx`) solo interpola `{pk}` en
el PATH de la URL de una acción tipo "proceso" — a diferencia de las
llamadas de edición inline (`api.ts`), NO agrega `?pk=<campo>` como query
string automáticamente. El backend (`DicController::duplicarAction()`/
`updateAction()`) por defecto asume `pk=id`. Para tablas donde la PK
natural NO es un `id` físico (ej. `maetie.tienda`, sin AUTO_INCREMENT
separado — a diferencia de `maecaj`, que sí tiene `id` físico +
`cajero` natural), el `url_patron` de la acción debe incluir `?pk=<campo>`
explícito al final, ej. `{apibase}/dic/datos/maetie/{pk}/duplicar?pk=tienda`.
Nunca había hecho falta antes porque Maecaj/Maeofe sí tienen `id` físico.

**Bug encontrado de paso en `admin_tabla.html.twig`**: `toggleCampo()` (los
toggles rápidos de "visible en lista"/"solo lectura" desde la tabla del
admin, sin abrir el modal) reconstruía el payload a mano con su propia
lista de campos — igual que `buildPayloadFromCampo()` antes de que
[[mbinv_diccionario_feature_estado]] la corrigiera, pero esta NUNCA se
había actualizado: le faltaban `grupo`/`password_hash`/`rel_extra_valor`/
`rel_extra_texto`/`rel_tabla2`/`rel_campo2`/`rel_campo_desc2`/
`calc_tipo`/`calc_expr`. Como `saveCampo()` siempre reescribe la fila
completa, un toggle rápido en cualquier campo con esos valores configurados
los ponía en `null` en silencio. Corregido haciendo que `toggleCampo()`
reutilice `buildPayloadFromCampo()` en vez de duplicar la lista — elimina
la causa raíz (dos listas de campos que se pueden desincronizar) en vez de
solo agregar los que faltaban.

## Maeusr (Usuarios y Permisos) — panel unificado, fuera del runbook estándar

**No es una migración de catálogo normal** — `maeusr` sigue en
`Dic::TABLAS_PROHIBIDAS` a propósito (tabla sensible: contraseñas,
permisos) y este trabajo no pasa por el motor genérico de Diccionario en
absoluto. El disparador fue el usuario mostrando capturas de
`mbvb6/frmUsuarios.frm` (el sistema VB6 anterior): una sola ventana
maestro-detalle (lista de usuarios a la izquierda, árbol de permisos a la
derecha) — comparado con el `mbinv` actual, que resultó **peor** que el
VB6 en el punto de documentos/permisos: 3 `<select multiple>` planos sin
agrupar (Usar/Desbloquear/Reportar) metidos en el formulario gigante de
edición, más una página aparte para el árbol de menú.

**Modelo de datos real** (5 dimensiones de permisos por usuario, más
grande de lo que parecía al inicio):
1. **Identidad + ~30 flags** — columnas directas en `maeusr` (¿modifica
   costo?, ¿ve precio?, pedidos a proveedor, traslados, manifiestos,
   inventario, backorder...). Campo exacto: ver `MaeusrType.php`.
2. **Menú** — árbol de navegación, JSON crudo en `maeusr.menu`
   (formato plano `[{id,titulo,permitido}]`, armado y aplanado en el
   navegador — igual en la pantalla legada `accesos.html.twig` como en el
   panel nuevo). Fuente del árbol: `Parametros::getMenuAll($usuarioId)`
   (8 categorías top-level, cada una con métodos PHP propios
   `getMenuCatalogos()`/`getMenuMovimientos()`/etc — no hay tabla `menu`
   real detrás, esa tabla física es vestigial).
3. **Documentos** — `maetdc` × `dettdc`, 2 flags independientes por tipo
   de documento (`utilizar`, `desbloquear`).
4. **Tiendas** — `maetie` × `dettie` (la tabla que se investigó al migrar
   Maetie; confirmado acá que su propósito real es "qué tiendas puede
   usar cada usuario", no un registro de auditoría).
5. **Reportes** — `web_reporte` (446 filas, solo 52 con `activo='S'`,
   agrupadas por columna `grupo`: Ventas/Administrativos/CxC/Interfaces/
   Inventario) × `web_reporte_detalle` (`activo` char 'S'/'N', no 0/1
   como `dettdc`).

**Decisión de alcance (usuario, julio 2026)**: construir por fases,
empezando por General + Menú + Documentos (las 3 más usadas a diario),
luego Tiendas + Reportes en una segunda pasada (Fase 2).

**Qué se construyó (Fase 1)**:
- Backend: `MaeusrController` — endpoints JSON nuevos bajo
  `/usuario/api/...` (lista, general get/save/create, menú get/save,
  documentos get + toggle instantáneo por checkbox). Todo a mano —
  `getConexion()->fetchAll/fetchOne/executeNonQueryWithParameters` con SQL
  directo, no Doctrine QueryBuilder ni `Dic::`. Requirió excepción de
  firewall (`^/usuario/api`, `security.yml`) — mismo patrón que ya
  existía para `/maecli/api` y `/tienda/api/gface-autocompletar`.
- El árbol de Menú reutiliza `Parametros::getMenuAll()` tal cual (no se
  reimplementó la lógica de los 8 métodos `getMenu*()`) — el endpoint
  nuevo solo lo expone como JSON en vez de renderizarlo en Twig, y el
  guardado reusa el mismo `$entity->setMenu(json_encode(...))` que ya
  usaba `accesosAction`.
- Frontend: pantalla nueva `mbinv-catalogo/src/app/usuarios` — **a
  propósito NO reutiliza `CatalogoGrid`** (aclaración del usuario: la
  tabla de navegación es chica y concreta, no se beneficia de
  paginación/export/columnas configurables de un catálogo). Lista simple
  con búsqueda + 3 pestañas (General/Menú/Documentos), todo en una sola
  pantalla sin navegar — el árbol de Menú marca/desmarca todo un subárbol
  al tocar un padre (replica el plugin `checktree` de la pantalla legada).
- `usuariosApi.ts` tiene su **propio** `apiFetch` local, no importa el de
  `api.ts` — ese archivo tiene cambios sin commitear de Pedidos a
  Proveedor en este repo (ver aislamiento de `api.ts` en las notas de
  sesión), depender de un export suyo rompía el build en un checkout
  limpio del servidor (encontrado al desplegar, corregido enseguida).

**Qué se construyó (Fase 2 — Tiendas y Reportes)**:
- Backend: 4 endpoints nuevos en `MaeusrController`, mismo patrón bespoke
  de Fase 1 (auth+CORS vía `BaseController`, SQL directo vía
  `getConexion()`): `GET/POST /usuario/api/{id}/tiendas` y
  `/tienda` (toggle sobre `dettie`), `GET/POST
  /usuario/api/{id}/reportes` y `/reporte` (toggle sobre
  `web_reporte_detalle`, cuidado con `activo` siendo char 'S'/'N' y no
  0/1 como en `dettdc`).
- Frontend: `TiendasTab.tsx` (lista simple, guardado instantáneo por
  checkbox, mismo patrón que `DocumentosTab`) y `ReportesTab.tsx`
  (agrupado por `grupo` con el mismo sistema de tarjetas por color
  (`ACENTO_GRUPO`) que `GeneralTab`, más buscador — 52 reportes activos
  sin agrupar habría sido una lista plana interminable).
- Verificado end-to-end con datos reales tanto en local
  (`mbinv-dic`/`mbinv-catalogo` local) como contra el despliegue real
  (`qa-manuel.sistemasmb.com` + `catalogo.sistemasmb.com`): round-trip de
  toggle en ambas pestañas, confirmado que el checkbox se deshabilita
  mientras guarda (`disabled={guardando === id}`) — evita doble-submit,
  pero hay que esperar a que se rehabilite antes de un segundo click en
  pruebas automatizadas contra el servidor local (que puede tardar) o el
  click se pierde silenciosamente (no es bug, es el lock funcionando).

**Qué se construyó (Fase 3 — Duplicar usuario)**:
- Backend: `apiDuplicateAction` (`POST /usuario/api/{id}/duplicate`,
  body `{nombre}`). En vez de mantener una lista explícita de columnas
  (como `MAEUSR_FLAGS`), usa `SHOW COLUMNS FROM maeusr` menos
  `usuario`/`nombre` y arma un `INSERT ... SELECT` — copia **todo** lo
  demás de la fila origen (incluida `clave` y `menu`), igual alcance que
  el clon de Doctrine del `duplicateAction` legado pero sin quedar
  desactualizado si se agrega una columna nueva a `maeusr`. Reutiliza la
  misma validación de nombre disponible que `apiCreateAction`
  (`puede_crear`), y copia también `dettie`/`dettdc`/
  `web_reporte_detalle` con `INSERT ... SELECT ... WHERE usuario = origen`.
- Frontend: botón "Duplicar…" junto a las pestañas (visible con un
  usuario seleccionado) → modal que pide el nombre del usuario nuevo
  (mismo combo de disponibles que "Nuevo usuario") → al terminar,
  refresca la lista y selecciona automáticamente el usuario recién
  creado.
- Verificado end-to-end (backend por curl y UI por Playwright) contra
  `mbinvdiccionario` local con un login de prueba desechable insertado y
  eliminado en `user`+`maeusr`+`dettie`+`dettdc`+`web_reporte_detalle` —
  confirmado que las 5 dimensiones se copian igual que en el origen.

**Fase 3 completa — no queda ningún pendiente explícito para Maeusr.**

## Maeplu (Productos) — el catálogo más grande y complejo, migración por fases

**No es una migración estándar de este runbook.** `maeplu` es la tabla que
se dejó deliberadamente para el final ("maeplu/maeofe/listaprecios que
tienen más complejidad"). El plan completo (auditoría BO vs. legado VB6,
hallazgos, arquitectura híbrida recomendada, hoja de ruta de 7 fases) vive
en el plan mode file — resumen aquí de lo ya construido.

**Hallazgo clave de la auditoría**: 7 subsistemas de maeplu no encajan en
el patrón "una fila, campos simples" (fórmulas/BOM, `detuni` con cascada
de costeo, imágenes con dos generaciones, listas de precio con 6 variantes
de cálculo, mín/máx y localidades con auto-seed por tienda, categorías en
árbol N:M, código de barras multi-valor). Por eso el enfoque es **híbrido**:
Fase 1 (campos simples) por Diccionario estándar, el resto en paneles
bespoke por fase, mismo espíritu que Maeusr.

Auditoría del lado legado reveló que `frmExpress.frm` (VB6) **no tiene
campos hardcodeados** — es un motor de grilla genérico cuyo set de
columnas se arma en runtime desde `maegrd`/`detgrd` (`FUNCION='PRODUCTOS'`,
131 columnas registradas, la mayoría ocultas por defecto, configurable por
usuario). No es replicable ni deseable tal cual en un formulario web
moderno — el equivalente ya es AG Grid con columnas configurables.

**Fase 1 completa (2026-07-18) — campos simples (~49 de 139 columnas
físicas)**, sobre un scaffold parcial que ya existía en `dic_campo` de una
pasada de auto-detección anterior sin curar (encontrado con bugs: 3
dimensiones con `rel_tabla` configurado pero `control_edicion='text'` en
vez de select, 4 dimensiones sin ninguna relación, `fraccion` sin
`si_no`, y varios campos derivados/legacy quedaron visibles del
auto-detector sin que nadie los hubiera revisado).

**2 capacidades nuevas de motor construidas para esto, genéricas — no
específicas de Maeplu**:
1. **`control_edicion='autocomplete'`** — ya estaba reservado en
   `CampoDict`/el `<select>` del admin desde antes, nunca implementado.
   Las 9 dimensiones jerárquicas (departamento/línea/artículo/tamaño/
   marca/variedad/estilo/casa/proveedor) pueden ser catálogos grandes en
   un cliente real — el `rel_tabla`/`rel_campo` normal precarga TODAS las
   opciones (límite 500, sin búsqueda), no escala. `Dic::buscarAutocomplete()`/
   `resolverAutocomplete()` + rutas `GET /dic/autocomplete/{tabla}/{campo}/buscar|resolver/{valor}`
   — reutilizan las mismas columnas `rel_tabla`/`rel_campo`/`rel_campo_desc`
   que ya existían, solo cambia cómo se resuelven las opciones (bajo
   demanda vs. eager). Frontend: `AutocompleteInput` en `FormModal.tsx`
   (debounce 250ms, resuelve la etiqueta del valor guardado al montar sin
   cargar la lista completa).
2. **Fix de `AUTO_SIGUIENTE` (MAX+1)** — usaba `MAX(campo)` de texto
   plano; `maeplu.plu` mezcla códigos numéricos con cuentas especiales no
   numéricas (`abierto`/`giftcard`/`valemerca`/etc., exentas de la regla
   "precio no puede ser cero" según la auditoría del controller legado) —
   `MAX()` de texto ordena `'valemerca' > cualquier número`, así que
   `(int)MAX` daba 0 y el siguiente código calculado (1) colisionaba con
   un PLU real ya existente. Ahora usa `MAX(CAST(campo AS UNSIGNED))`,
   sin cambiar el comportamiento en columnas puramente numéricas
   (Maetie.tienda, Maecaj.cajero siguen igual).

**Bug encontrado y corregido de paso**: `Dic::exportarPlantilla()` nunca
propagaba `control_edicion`/`visible_form`/`visible_lista`/`grupo`/
`obligatorio`/`solo_lectura` a `mbinvestructura` — solo los campos de
"contenido" (`rel_*`, `val_*`, `valor_default`, vía
`$CAMPOS_PLANTILLA_RELLENABLES`). Como Fase 1 es exactamente curar esos
campos de visibilidad/control, el primer intento de exportar no tuvo
efecto real (devolvía `ok:true` pero no cambiaba nada en la plantilla).
Estos campos nunca encajan en el criterio "vacío/lleno" de
`$CAMPOS_PLANTILLA_RELLENABLES` porque siempre tienen un valor por
defecto no-vacío desde `crearTablaDesdeEsquema()` — se separaron en
`$CAMPOS_PLANTILLA_CURACION`, usado **solo al exportar, nunca al
importar** (importar overwrite de estos campos en un cliente que ya
tiene *algo* configurado es una decisión aparte, no resuelta ahora).

**Decisiones de alcance confirmadas por el usuario (2026-07-18)**:
- Gasolinera (`tipoProducto='COMBU'`): solo 1 cliente activo — queda de
  última (Fase 7), se evalúa si se construye al llegar ahí.
- Trazabilidad (serie/lote/vencimiento/fabricación) y Comandas: aunque
  están marcadas "[EXPERIMENTAL]" en el código del controller legado,
  **ambas tienen clientes reales usándolas hoy** — van en Fase 1 como
  campos normales, no se descartan.
- `/producto/editar/{id}` (pantalla de edición paralela y más simple que
  `/producto/edit/{id}`): confirmado código muerto, no se migra.
- Proveedor se dejó como `rel_tipo=detalle_multiple` (multi-proveedor por
  producto vía `detpro`, ya estaba así en el scaffold previo) en vez de
  aplanarlo al campo simple que usa `MaepluType.php` hoy — coincide con
  el patrón `CBODET` (multi-valor) del legado VB6, es más capaz que la
  versión actual del BO, y ya estaba construido/funcionando.
- Código de barras (`label`, `rel_tipo=detalle_multiple` sobre `detplu`)
  también ya estaba correctamente configurado en el scaffold previo —
  técnicamente es alcance de Fase 2 del plan, pero como ya funcionaba se
  dejó activo como bonus en vez de ocultarlo.

**Campos ocultados explícitamente** (visible=1 heredado del
auto-detector, pero derivados/legacy/fuera de alcance de Fase 1):
`pagaiva` (derivado de `iva` por el controller), `series` (legacy,
distinto de `usa_serie`), `es_top`/`fecha_ingreso`/`pide_duracion`
(forzados por el controller, no editables), `impcombust`/
`combustible_codigo` (tab Valores/Gasolinera, Fase 2/7),
`empaque_contenido`/`empaque_descripcion` (tab Valores, Fase 2).

**Simplificaciones deliberadas de Fase 1** (acordadas con el usuario,
revisar si algún cliente real las necesita más completas):
- Código PLU: solo `MAX+1` (con el fix de CAST), sin el algoritmo de
  "huecos" (`/producto/plu/disponible`) ni "copia de código" del BO.
- IVA: `lista_valores` fija editable por admin (0%/12% para este
  cliente de prueba), en vez de la lógica dinámica de 3 fuentes + exento
  del controller legado (triplicada literalmente en el código, ~80
  líneas).
- `tipo_producto`/`tipo_etiqueta`: texto libre, sin el multi-select
  dinámico poblado desde un parámetro JSON por tenant.

**Verificado end-to-end**: backend por curl (crear con PLU auto-numerado,
editar campos de todos los tipos incluidos S/N y autocomplete) y frontend
por Playwright (los 7 tabs renderizan, autocomplete busca/resuelve/
selecciona correctamente) — tanto contra `mbinvdiccionario` local como
contra el despliegue real (`qa-manuel.sistemasmb.com` +
`catalogo.sistemasmb.com`). Config exportada a `mbinvestructura`.

**Fase 2 completa (2026-07-18) — Valores/Costeo** (código de barras ya
venía activo desde Fase 1, ver arriba). Confirmado con el usuario que
este panel **no encaja en el editor genérico** de Diccionario: tiene
cálculo en vivo interdependiente entre campos, visibilidad condicional
por moneda, y permisos por campo — mismo tipo de decisión que llevó a
Maeusr a ser bespoke.

**Cómo se expone**: nuevo botón "Valores" en la fila del catálogo,
implementado con `dic_accion.tipo='modal'` — **mecanismo que ya existía
en el motor** (abre un iframe con la URL de `url_patron`), nunca antes
usado. `url_patron='/producto-valores/{pk}'` es una ruta RELATIVA (sin
`{apibase}`) — el iframe la resuelve contra el origen de la página
padre (mismo dominio que `catalogo.sistemasmb.com`), así que la página
bespoke hereda el token/api de `sessionStorage` sin necesidad de
leerlos de la querystring. Patrón reutilizable para cualquier futura
pantalla bespoke embebida en el catálogo.

**Backend** (`ProductoController.php`, no pasa por `Dic.php`):
`apiValoresGetAction`/`apiValoresSaveAction` — mezcla `maeplu`+`detuni`
replicando **exactamente** la lógica de `updateAction` del legado (no
una versión simplificada):
- Rama por moneda de compra (E/D/Q): euros→dólares→costo bruto con
  gastos+IVA, o dólares→costo bruto, o costo bruto directo en local.
- Los mismos ~15 campos "zombie" de `detuni` que el legado fuerza a 0 en
  cada save (`gastosval`, `tienda`, `minimo`, `margenminimo`,
  `margenmayo`, `preciodolares`, `preciomayo`, `precioktl`, `peso_bruto`,
  `peso_neto`, `precio_anterior`, `margenval`, `precioiva`,
  `dolares_bruto` según rama).
- `margennominal`/`margen` >10 se asumen "porcentaje sin dividir" (ej.
  25 en vez de 0.25) — misma heurística frágil del legado, replicada tal
  cual.
- Auto-crea la fila "base" de `detuni` (`contenido=1`) si no existe,
  igual que `clsValores.getCostos()` del VB6.
- Permisos por campo (`vercosto`/`modcosto`/`verprecio`/`modprecio` de
  `maeusr`) — el backend los aplica de verdad (403 si falta permiso), no
  solo los oculta en el frontend.
- Reutiliza los mismos triggers en cascada que ya existían
  (`Producto::recalcularFormulasDesdeComponente()`,
  `Producto::setPreciosTodasLasListas()`) — no se reimplementó nada de
  esa lógica.

**Frontend**: `calculoCosteo.ts` — cascada de cálculo portada 1:1 del JS
inline de `Producto/edit.html.twig` (`function calculos()`), verificada
campo por campo contra el legado (costo bruto, descuento, costo, costo
sin IVA, precio sugerido, margen real). Página
`app/producto-valores/[plu]/page.tsx` recalcula en cada tecla, igual que
el original.

**Bug real encontrado en pruebas (no en el código, en el tipo)**: el
parámetro `margensobreprecio` (`parametros_backoffice`) llega desde el
backend como número JSON (`0`), pero el cálculo lo comparaba como string
(`=== '0'`) — la comparación fallaba en silencio y siempre tomaba la
rama equivocada (margen sobre precio en vez de margen sobre costo). Se
detectó visualmente (Margen real mostraba 100% para un producto sin
costo, debía mostrar 0%) — normalizado con `String(...)` antes de
comparar. Buena señal de que vale la pena probar con datos reales y no
solo confiar en que "compila".

**Verificado end-to-end**: curl (crear/guardar con valores reales,
confirmado en `detuni` y `maeplu.precio`) y Playwright (modal abre,
campos calculan en vivo con los valores exactos esperados: costo bruto
100 − 10% descuento → costo 90 → sicosto 80.3571 con IVA 12%, margen 25%
sobre costo → precio sugerido 112.50) — tanto local como en
`qa-manuel.sistemasmb.com`+`catalogo.sistemasmb.com`. Config exportada a
`mbinvestructura`.

**Fase 3 completa (2026-07-18) — Categorías + Localidades + Mín/Máx**,
las tres piezas medianas del plan, cada una con un patrón distinto.

**Categorías** — el legado VB6 tiene una UI de árbol con checkboxes
(auto-marca el padre si todas las hijas están marcadas), pero se
confirmó que la tabla real `categorias` es **plana** (`id` no
auto_increment, `descripcion`, sin columna de jerarquía) — el propio BO
actual ya la trata como un `<select multiple>` plano. Conclusión: no
hacía falta construir ninguna UI de árbol, la relación N:M
(`categorias_detalle`) cabía en el mecanismo genérico
`rel_tipo=detalle_multiple` que ya existía — pero al intentar usarlo
aparecieron **3 bugs latentes en el motor**, nunca antes disparados
porque ningún `detalle_multiple` previo tenía estas características:
1. **Nombre de columna distinto entre maestro y detalle**: el
   mecanismo asumía que la PK de la tabla de opciones
   (`det_tabla_valores`) se llama igual que la columna de valor en la
   tabla detalle (`rel_tabla`) — cierto por coincidencia en
   `detpro.proveedor`/`maepro.proveedor`, falso en
   `categorias_detalle.categoria_id` vs. `categorias.id`. Fix: nueva
   columna `dic_campo.det_campo_valor_maestro` (fallback a
   `det_campo_valor` si es NULL, no rompe nada existente) usada en 3
   sitios de `Dic.php` (`getDefinicion()`, `tablaLookupCampo()`,
   `getDetalleMultiple()`).
2. **Sincronización con columna simple inexistente**: `getDetalleMultiple()`/
   `guardarDetalleMultiple()` intentaban leer/escribir sin condición una
   "versión simple" del campo directo en la tabla padre — asumía que
   todo `detalle_multiple` viene de aplanar una columna legacy.
   `maeplu.categorias` es puramente virtual (nunca existió como columna
   simple) → `SQLSTATE[42S22]: Column not found`. Fix: ambas funciones
   ahora chequean `in_array($campo, $this->getCamposValidos($tabla))`
   antes de tocar la tabla padre.
3. **`categorias_detalle` sin columna `es_default`** (la requiere el
   mecanismo, ya la tenían `detplu`/`detpro`) — agregada de forma
   idempotente en `Instalacion.php`, mismo patrón que las otras dos.

Los 3 fixes son genéricos — benefician cualquier futuro campo
`detalle_multiple` con las mismas características, no solo Categorías.
Reutiliza el componente `DetalleMultipleModal.tsx` ya existente sin
modificarlo.

**Localidades** (tienda × local → fila `detloc`) — bespoke, no encaja en
`detalle_multiple` (es una dimensión más: no es "N valores por fila
padre" sino "N valores por fila padre × tienda"). Backend
`apiLocalidadesGetAction`/`apiLocalidadAgregarAction`/
`apiLocalidadEliminarAction`/`apiLocalidadNuevaAction` en
`ProductoController.php`, replicando la validación exacta del legado:
duplicado (tienda+plu+local), longitud máxima de `maeloc.local`, unidad
heredada de `Producto::getUnidadBase()`, y exclusión UX de localidades
ya usadas por el producto en cualquier tienda (no es constraint de BD,
es filtro de la lista "disponibles"). Página
`app/producto-localidades/[plu]/page.tsx`.

**Mín/Máx** (`maeexi`, una fila por tienda) — bespoke, mismo patrón de
"auto-seed perezoso" que ya se usó para otras piezas: al primer GET se
crea una fila `existmin=0/existmax=0` por cada `maetie` que aún no
tenga una, replicando el comportamiento del legado (el seed no ocurre
al crear el producto, ocurre al primer acceso a la pestaña).
`apiMinMaxGetAction`/`apiMinMaxSaveAction`, guardado validado
"todo o nada" (si cualquier fila tiene mínimo > máximo se rechaza el
lote completo, no se guarda parcial) — igual que `bulkMinMaxAction` del
legado. Página `app/producto-minmax/[plu]/page.tsx`.

**Ambas nuevas pantallas usan el mismo mecanismo `dic_accion.tipo='modal'`**
descubierto en Fase 2 — ya son 3 paneles bespoke usando este patrón
(Valores, Localidades, Mín/Máx), consolidándolo como la forma estándar
de embeber pantallas fuera del editor genérico dentro del catálogo.

**Verificado end-to-end**: curl contra `mbinvdiccionario` local y
`qa-manuel.sistemasmb.com` (ambos endpoints, GET/POST/DELETE) y
Playwright contra el despliegue real (`catalogo.sistemasmb.com`) — los
3 botones de acción abren su modal correspondiente, cargan datos reales
del PLU de prueba y los formularios coinciden con el diseño esperado.
Config exportada a `mbinvestructura` (141 campos, 3 acciones: Valores/
Localidades/Mín-Máx).

**Fase 4 completa (2026-07-18) — Imágenes**. Decisión confirmada con el
usuario: la generación BLOB (`fotos`/`fotos_detalle`) es la definitiva —
la columna legada `maeplu.imagenes` (archivo en disco, con marca de
agua) no se porta, 0 filas la usan en producción.

**Estructura real** (confirmada contra la BD, no solo el código):
portada = `fotos` (1:1 vía `UNIQUE KEY plu`, no hay columna
`es_principal`, la unicidad de la fila ES la marca de portada). Galería
= `fotos_detalle` (N:1, ordenada por `correlativo`) — sin entidad
Doctrine, todo por SQL crudo; 0 filas en producción al momento de migrar
(sin datos legados de galería que preservar).

**Cómo se sirve**: el legado nunca sirve el BLOB por HTTP — lo
materializa a `web/public/images/botones/producto_{plu}.jpg` en el
primer GET (caché de filesystem sin invalidación por TTL, dos copias
del mismo código en `viewAction`/`imagenAction`) y deja que el
webserver sirva el archivo estático. El panel nuevo no reinventa ese
caché: sirve el BLOB como **data URI base64 embebido en el JSON**
(mismo patrón que `Reportes/ProductosConFoto.php`, que ya hace
`TO_BASE64()` en SQL), con el mime real detectado por contenido
(`getimagesizefromstring()`) en vez de asumir siempre JPEG como hace
todo el código legado. El caché de disco legado se invalida (`unlink`)
cada vez que este panel cambia la portada, para que las vistas viejas
(`view.html.twig`, `imagenAction`) no sigan sirviendo una imagen vieja.

**Backend** (`ProductoController.php`): `apiFotosGetAction` (GET),
`apiFotoPrincipalSaveAction`/`apiFotoGaleriaSaveAction` (POST),
`apiFotoGaleriaEliminarAction` (DELETE) y
`apiFotoGaleriaUsarComoPrincipalAction` (POST) — esta última es
**funcionalidad nueva, no existía en el legado**: promueve una foto de
galería a portada intercambiando con la portada actual (que pasa al
final de la galería en vez de perderse), a diferencia del intercambio
puramente visual sin persistir que tenía el template legado
(`imagen.html.twig`, clic en miniatura no guardaba nada). Portada se
guarda por upsert Doctrine (mismo patrón que `fileAction` del legado,
aprovecha el `UNIQUE KEY plu`); galería reutiliza
`Conexion::saveGaleryImage()` ya existente (bind de blob correcto con
`PDO::PARAM_LOB`) en vez de reimplementar ese binding a mano. Cada
cambio de portada sincroniza `maeplu.foto` ('S'/'N', antes solo se
actualizaba de forma perezosa al visitar la ficha vieja) para no
desactualizar los reportes "Productos con/sin Foto".

**Bug corregido de paso**: `eliminarFotoAction` del legado no
parametrizaba `$plu` en el `DELETE`/`UPDATE` de reordenamiento de
`fotos_detalle` (`WHERE plu = $plu` sin comillas) — se rompía con PLUs
no puramente numéricos. El endpoint nuevo usa
`executeNonQueryWithParameters` en ambas queries.

**Límites nuevos** (no existían en el código legado, que no validaba
nada de esto — auditoría explícita, sin GD/Imagick en ningún punto del
flujo legado): máx. 5 MB por imagen, formatos JPEG/PNG/WEBP validados
por contenido real vía `getimagesizefromstring()` (no por extensión ni
MIME del navegador), máx. 12 fotos en galería. Sin redimensionado ni
generación de miniaturas server-side (tampoco existía antes).

**Verificado end-to-end**: curl contra `mbinvdiccionario` local (los 5
endpoints, incluyendo verificación byte-a-byte del intercambio
portada↔galería decodificando el primer píxel de cada PNG de prueba
para confirmar que los colores correctos terminan en el lugar correcto)
y Playwright tanto local como contra el despliegue real
(`qa-manuel.sistemasmb.com`+`catalogo.sistemasmb.com`): subir imagen
principal, subir a galería, promover una foto de galería a portada
(con la anterior reapareciendo al final de la galería), eliminar de
galería con recompactación de correlativos, y rechazo de formato no
soportado. Datos de prueba limpiados tras verificar. Config exportada a
`mbinvestructura` (141 campos, 4 acciones).

**Fase 5 completa (2026-07-18) — Listas de precio**, con un alcance
reducido a propósito (confirmado con el usuario) tras descubrir en
auditoría que el sistema real tiene **4 subsistemas**, no 2 como decía
el plan original: maestro `listaprecios` (35 columnas), detalle
`listapreciosdetalle` (precio calculado por producto×lista),
`listaprecios_criterios` (motor de aplicación masiva por SQL dinámico,
depende de `maegrd` — el mismo motor de grilla configurable ya
descartado para Maeplu) y `listapreciosmayoreo` (precios por volumen,
CRUD propio sin cascada de cálculo). Esta fase cubrió los primeros dos;
los otros dos quedan fuera de alcance por ahora.

**Maestro `listaprecios`**: migrado como catálogo genérico normal de
Diccionario (15 campos curados de 35 físicos, siguiendo exactamente el
formulario que ya usa el CRUD legado `Listaprecios/index.html.twig` —
los ~20 campos restantes ni siquiera están expuestos ahí, quedaron sin
`dic_campo`). Encajó sin fricción: el único caso "especial" — solo una
lista puede ser la estándar (`estandar='S'`) — usa `dic_campo.valor_unico`
tal cual, la misma capacidad de motor construida en la ola de Maetie,
sin tocar nada nuevo. Acción "Duplicar" agregada con el patrón estándar
ya usado en otros catálogos. `tiendas` (CSV de códigos de tienda en el
legado, con sentinela `0`=todas) se dejó como campo de texto simple en
vez de construir un multi-select nuevo — simplificación deliberada,
documentada aquí por si algún cliente real lo necesita más completo.

**Detalle por producto (`listapreciosdetalle`)**: bespoke, mismo
`dic_accion.tipo='modal'` que las fases anteriores. Backend
(`apiListasGetAction`/`apiListasSaveAction`) **no reimplementa nada del
cálculo** — llama directo a `Producto::setPreciosTodasLasListas()` (para
forzar el recálculo al abrir, igual que `listaPreciosAction` del legado)
y `Producto::setPrecioLista()` (para guardar, sea por margen o por
precio). El switch de las 6 variantes de `costo_para_calculo` (Mejor,
Bajo, Actual, Promedio, Último, Móvil) vive únicamente en `Producto.php`
— el endpoint nuevo solo lee su resultado.

**Hallazgo de inconsistencia real, resuelto por decisión del usuario**:
para listas con `margen_tipo='C'`, el endpoint legado
(`ProductoController::listaPreciosIdAction()`, ruta vieja
`/listapreciosid/{id}`) tenía una fórmula distinta e incluso más
incompleta que `Producto::setPrecioLista()` — ni siquiera usaba el
costo en el cálculo final del precio, solo el precio estándar del
producto. El usuario confirmó usar la fórmula de `Producto.php`
(`margen=(precio_lista−costo)/costo×100`, `precio=costo×(1+margen/100)`)
como la correcta; el endpoint nuevo la hereda automáticamente al llamar
a `setPrecioLista()` en vez de reimplementar, así que el bug legado no
se propaga.

**Límite conocido, no resuelto** (documentado, no corregido en esta
fase): el legado VB6 (`frmListaPrecios.frm`) valida que el precio de
lista no pueda quedar por debajo del costo; ni el BO actual ni este
endpoint nuevo replican esa validación — el costo relevante depende de
cuál de las 6 variantes tenga configurada la lista, calculado dentro de
un método privado de `Producto.php` que habría que exponer para validar
sin reimplementar el cálculo.

**Verificado end-to-end**: curl contra `mbinvdiccionario` local y
`qa-manuel.sistemasmb.com` (crear lista no-estándar, forzar recálculo,
guardar por margen y por precio, confirmando aritméticamente que el
costo usado es el correcto — `sicosto×(1+iva/100)` para la variante
"Mejor Costo" — y que ambas direcciones del cálculo bidireccional dan
resultados consistentes) y Playwright contra el despliegue real
(`catalogo.sistemasmb.com`): el botón "Listas" abre el modal, la tabla
carga la lista de prueba, guardar por margen actualiza el precio
correctamente. Datos de prueba limpiados tras verificar. Config
exportada a `mbinvestructura` para ambas tablas (`maeplu`: 141 campos/5
acciones; `listaprecios`: 15 campos/1 acción).

**Fase 6 completa (2026-07-20) — "Copiar de otro producto"**, fuera del
orden original del plan (el usuario pidió priorizarla sobre Fórmulas/
Producción). Antes de construirla se auditó "Ofertas" (`maeofe`) como
posible Fase 6 alternativa — el usuario decidió **no tocar Ofertas por
ahora** ("está en pruebas [en otro lado]"), así que queda sin fase
asignada, ver hallazgos completos más abajo.

**Hallazgo clave, corrige una premisa del usuario**: se pensaba que el
mecanismo real de "copiar producto" usaba `maegrd.copiar_al_duplicar`
como bandera por campo. Investigación exhaustiva (grep en todo el
código PHP y VB6, `git log -S` sobre ~9200 commits de todas las ramas)
confirmó que esa columna **nunca fue conectada a ningún código** — está
huérfana en el esquema, todo en `'N'`/`'U'` sin lector real. El
mecanismo que sí funciona hoy es un allowlist de ~35 campos
**hardcodeado en JavaScript** dentro de `Producto/add.html.twig`
(botón "Copia de Código", solo en alta de producto, líneas 168-254 del
handler): el usuario solo teclea el PLU origen en un modal, sin ver ni
elegir qué se copia — el JS rellena siempre los mismos campos del
formulario, dejando el PLU (siguiente disponible) y el código de barras
(`label`, se limpia explícito) fuera.

**Diseño nuevo**: `dic_campo.permite_copiar` (columna nueva del motor
genérico, no específica de Maeplu) marca qué campos son elegibles —
esta vez sí real y administrable desde el editor de campos del admin de
Diccionario (checkbox "Permite copiar", mismo patrón que "Sufijo al
duplicar"). Sembrado inicial: 46 campos simples de `maeplu` (General,
Atributos, Comandas, Contabilidad, Tablas —las 9 dimensiones—,
Trazabilidad, WEB), excluyendo `plu` (PK) y los campos
`rel_tipo=detalle_multiple` (`label`, `proveedor`, `categorias` — esos
no pasan por `Dic::updateFila()`, necesitarían su propio tratamiento).

**Backend** (`ProductoController.php`): `apiCopiarCamposAction` (catálogo
de campos copiables), `apiCopiarDesdeAction` (valores del producto
origen, ya filtrados a los campos permitidos), `apiCopiarAplicarAction`
(aplica el subconjunto elegido). **El servidor nunca confía en la lista
de campos que manda el cliente** — `apiCopiarAplicarAction` siempre
vuelve a filtrar contra `permite_copiar=1` antes de leer/escribir nada,
verificado explícitamente con curl (mandar un campo no autorizado como
`precio` junto a uno válido — el servidor lo descarta en silencio,
`camposCopiados` refleja solo los válidos). Reutiliza `Dic::updateFila()`
tal cual para aplicar — hereda gratis toda su validación (`valor_unico`,
límites de longitud, normalización de tipos, bitácora) sin reimplementar
nada.

**Frontend**: panel `producto-copiar/[plu]` — input de PLU origen,
checklist agrupado por pestaña (mismo agrupamiento que el editor
genérico) con vista previa del valor de cada campo, todos pre-marcados,
el usuario desmarca lo que no quiere antes de aplicar.

**Verificado end-to-end**: curl local (incluyendo el caso de seguridad
del campo no autorizado) y Playwright contra el despliegue real
(`catalogo.sistemasmb.com`) — el checklist carga 46 campos agrupados
correctamente con los valores del producto origen. Config exportada a
`mbinvestructura` (141 campos con `permite_copiar` propagado, 6 acciones).

---

**Ofertas (`maeofe`) — auditado, decisión explícita de NO migrar por
ahora.** Resultó considerablemente más grande y acoplado de lo que
sugería el plan original:
- Maestro de 50 columnas con un campo virtual "tipo de oferta" (selector
  único en la UI) que en realidad son 8 columnas booleanas mutuamente
  excluyentes (`global`/`dosxuno`/`tresxuno`/`tresxdos`/`mitadprecio`/
  `bonificacion`/`por_monto`/`mix_and_match`) — no encaja en el editor
  campo-a-campo sin lógica bespoke.
- Detalle en dos tablas con redundancia real (`detofe` con columnas
  `regalo*` inline, `detofe_regalo` para "varios regalos" — coexisten
  según un flag del legado, no es error).
- Motor de aplicación masiva por criterio SQL dinámico (`usa_criterio`/
  `criterio`) que depende de `maegrd` — la misma dependencia que ya se
  descartó dos veces (Maeplu, Listas de precio).
- **La aplicación real de la oferta al vender no vive en este repo** —
  está en `mbvb6/Comunes/Clases/clsOfertas.cls` (555 líneas), consumida
  por MBCaja (POS de escritorio). Migrar el CRUD de `maeofe` a
  Diccionario cambiaría solo la administración, no el comportamiento en
  caja.
- Datos en `mbinvdiccionario`: 2 filas de `maeofe`, ambas de prueba
  (vigencia vencida en 2022/2023), 0 filas de detalle — no hay volumen
  real que consultar desde esta base.

Si se retoma en el futuro, empezar por el plan reducido ya evaluado:
maestro + panel bespoke de detalle simple (agregar/quitar productos uno
por uno, sin motor de criterio ni "varios regalos").

---

**Fase 7 completa (2026-07-20) — Fórmulas/Producción (BOM).** Última
fase del plan original — el subsistema más grande y complejo de toda la
migración. Antes de construir se hizo una auditoría exhaustiva del
editor legado (`/producto/formula/{id}`, `formulaTmp.html.twig` +
`ProductoController::formula*Action`, ~1000 líneas entre las dos) que
encontró **8 bugs reales**, confirmados leyendo el código (no
sospechas). Decisión del usuario: corregirlos al migrar, no
replicarlos.

**Alcance**: editor de ingredientes + cabecera (incluye instrucciones
de receta) + "copiar fórmula" reconstruida. Las instrucciones de
receta pasaron por dos diseños: el primer intento portó los 4 campos
sueltos del VB6 legado (temperatura/tiempo de mezcla/con-sin fuego,
muertos en el BO web desde hace tiempo) — el usuario los encontró
confusos y pidió reemplazarlos por un solo campo de **texto enriquecido**
(HTML con formato + imágenes embebidas en base64), editado con un
componente nuevo `RichTextEditor` (contentEditable + toolbar, sin
dependencias nuevas) sobre `maefor.obs`, que por eso pasó de
`varchar(255)` a `MEDIUMTEXT` (aplicado directo en ambas bases +
agregado a `Instalacion.php` para que se propague a instalaciones de
clientes nuevas). **Fuera de alcance a propósito**: distribución semanal
(`maefor.lunes..domingo`) — la auditoría no encontró ningún proceso que
la consuma dentro de `ProductoBundle`, y el usuario confirmó que
tampoco la usa; y el subsistema de `ProcesosController.php`
(importación masiva por Excel + recosteo/cuadre masivo, ~19000 líneas)
— comparte las mismas 4 tablas pero tiene una implementación de
cálculo **totalmente independiente** (ni siquiera llama a
`Producto::recalcularFormula()`), queda intacto.

**Los 8 bugs encontrados, y qué se hizo con cada uno**:
1. **IVA en el costeo — CORREGIDO** (decisión explícita del usuario):
   `Producto::recalcularFormula()` calculaba el "costo sin IVA" de la
   fórmula usando el % de IVA del producto FORMULADO para TODOS los
   ingredientes — matemáticamente incorrecto si un ingrediente exento
   se mezcla con uno gravado. Ahora cada ingrediente se destapa con su
   propio IVA antes de sumar, y el resultado se re-grava con el IVA del
   producto padre. Este fix vive en la clase de negocio compartida
   (`Producto.php`), no en el panel — también beneficia a cualquier
   otro caller fuera de esta migración. **Verificado matemáticamente**:
   con un ingrediente exento (IVA 0%, costo 100) en una fórmula de un
   producto con IVA 12%, el resultado correcto es sicosto=100/costo=112
   (antes del fix: sicosto=89.29/costo=100, incorrecto).
2. **"Copiar fórmula" — RECONSTRUIDA corrigiendo 3 bugs** (decisión
   explícita del usuario): (a) el legado asignaba SIEMPRE
   `detfor_produccion` como tabla destino cuando el destino no tenía
   fórmula, sin importar el tipo del origen — ahora copia al mismo tipo
   que el origen; (b) el modo "fusionar ingredientes" hacía un `INSERT`
   ciego en `maefor` que **siempre fallaba con clave duplicada** cuando
   el destino ya tenía su propia fórmula — que es justo su caso de uso
   principal, la función estaba rota para su propósito real — ahora
   hace upsert real (**verificado**: fusionar hacia un producto con
   fórmula propia ya no falla, y el ingrediente compartido entre ambas
   fórmulas queda con la suma correcta de cantidades); (c) el checkbox
   "copiar cantidad de lote" no hacía nada (el valor se pisaba
   incondicionalmente) — ahora sí se respeta (**verificado**: con el
   checkbox desmarcado, el lote destino queda en 1, no el del origen).
3. **Bloqueo por existencia al eliminar fórmula — CORREGIDO**: el
   legado (`formulaEliminarAction`) no validaba nada antes de borrar
   toda la fórmula. El endpoint nuevo sí valida (mismo criterio real ya
   usado para conversión de tipo venta↔producción,
   `Producto::existenciasPorTienda()`).
4. **Validación de fórmulas cíclicas — AGREGADA** (no existía en el
   editor interactivo legado, solo en la carga masiva por Excel): el
   endpoint de agregar ingrediente ahora recorre la fórmula del
   ingrediente candidato (y la de sus propios ingredientes, etc.) hasta
   20 niveles buscando el producto padre, antes de permitir agregarlo.
5. **Validación "el total no puede exceder la cantidad de lote" —
   AGREGADA** (existía en el VB6 legado, `frmProduccionFormulas.frm`,
   pero se perdió en el BO web): agregar/editar un ingrediente ahora la
   respeta.
6. El interruptor global `parametros_globales.modifica_formulas` nunca
   bloqueaba nada por un bug de comparación de tipos (`'N' == $array`
   siempre falso) — **no se portó**, sin UI que lo exponga tampoco, se
   deja fuera del alcance de esta fase (documentado como hallazgo, no
   como pendiente).
7. Asimetría de validación en "copiar fórmula" (bloqueaba si el destino
   tenía `detfor_produccion` pero no si tenía `detfor`) — corregida
   junto con el bug #2.
8. Bug de copy-paste en `formulaGrabarPorcentajeAction`
   (`if ($lunes == "")` debía decir `$domingo`) — irrelevante, esa
   función completa (distribución semanal) queda fuera de alcance.

**Decisión de arquitectura (usuario) — sin tabla de borrador
compartida**: el legado usa `detfortmp`, una tabla física global
indexada **solo por `plu`**, no por usuario/sesión — si dos personas
abren la fórmula del mismo producto, la segunda persona borra en
silencio el borrador de la primera con solo abrir la pantalla. El panel
nuevo no replica ese patrón: cada acción (agregar/editar/quitar
ingrediente, guardar cabecera) escribe directo contra
`detfor`/`detfor_produccion`, sin paso de "borrador" — mismo patrón ya
usado en Localidades/Imágenes.

**Backend**: 11 endpoints nuevos en `ProductoController.php`, todos
reutilizando `catalogoAutenticar`/`catalogoCors` y las funciones de
negocio existentes de `Producto.php` (`existenciasPorTienda`,
`getUnidadBase`, `recalcularFormula`) sin reimplementarlas. Un detalle
de diseño no trivial: el "tipo" de fórmula (venta/producción) se
deriva de si `detfor`/`detfor_produccion` tienen filas — pero una
fórmula recién creada (cabecera en `maefor`, cero ingredientes todavía)
no tiene forma de que el servidor recuerde qué tipo se eligió, así que
el endpoint de "agregar ingrediente" acepta un `tipo` opcional como
respaldo para ese caso específico (el frontend lo manda mientras el
servidor todavía no puede derivarlo de las tablas).

**Frontend**: panel `producto-formula/[plu]` — pantalla de elegir tipo
si no tiene fórmula, cabecera con instrucciones de receta, tabla de
ingredientes con buscador tipo autocompletar, costo sin/con IVA y
precio de venta (gateados por permiso `vercosto`), sección "copiar de
otra fórmula", eliminar fórmula completa.

**Bug encontrado en pruebas (no en el código de negocio, en el
render)**: `<textarea>` de observaciones recibía `null` en vez de
cadena vacía cuando la columna estaba sin valor en la base — React
tira warning "value prop should not be null". Corregido con `?? ''`.

**Verificado end-to-end**: curl exhaustivo local (auto-referencia,
duplicado, límite de lote, ciclo, el fix de IVA verificado
matemáticamente con un caso de IVA mixto real, copiar en modo
reemplazar y en modo fusionar incluyendo el caso que antes rompía
siempre) y Playwright contra el despliegue real
(`catalogo.sistemasmb.com`) con el producto de prueba real que ya tenía
fórmula (`272`/`271`). Datos de prueba adicionales limpiados tras
verificar. Config exportada a `mbinvestructura` (141 campos, 7
acciones — las 7 fases).

---

Con esto se completan las **7 fases del plan original** para `maeplu`.
Quedan fuera de alcance, sin fase asignada, si se retoman en el futuro:
Ofertas (`maeofe`, ver arriba), el motor de criterios SQL dinámicos de
Listas de precio, `listapreciosmayoreo`, y el subsistema de
importación/recosteo masivo de `ProcesosController.php` para fórmulas.
