# Importar el contenido del editor en la base de producción, sin sustituirla

**Producción nunca se sustituye, solo se le agrega.** Este es el único plan de deploy: el
alternativo —bajar la base de producción, fusionarla en local y subir el dump entero— se escribió
primero y se DESCARTÓ el 2026-09-11 por tener una ventana ciega: todo lo que entrara en producción
durante esas horas (un ticket, un registro, una solicitud de contacto) quedaba pisado al subir el
dump. Aquel documento se borró para que no queden dos caminos que se contradigan.

---

## Por qué se puede hacer en caliente

Los dos conjuntos de datos son **disjuntos**:

| | dónde vive | quién lo escribe en prod |
|---|---|---|
| Contenido del editor (pages, bloques, sectores) | solo en `itg_edit_web_temporal` | **nadie**: esas tablas aún no existen en prod |
| Datos vivos (users, tickets, contact_requests, blogs) | solo en prod | los usuarios, todo el tiempo |

Las 36 migraciones **crean** las tablas del builder vacías. Nadie las está escribiendo. Por eso
insertar el contenido no compite con nada: no hay que bloquear producción para escribir en tablas
que nadie más toca.

Lo único que sí se pisa son cuatro tablas que existen en ambos lados, y van tratadas aparte
(§ "Las tablas compartidas").

---

## ✅ Ensayado sobre una copia real de producción (dump del 2026-09-10)

Todo lo de abajo está probado de extremo a extremo contra los datos reales: 35 migraciones,
import, `slugs:sync` y `links:audit --strict`. **589 enlaces escaneados, 0 rotos.** Los datos
vivos quedaron intactos (59 usuarios, 47 tickets de venta, 8 de compra, 295 contactos, 30 blogs
con su URL). Los dos bloqueantes que salieron ya están corregidos en el repo.

### Los dos bloqueantes que el ensayo destapó (ya resueltos)

Ensayo hecho: copia de la base de producción (`itg_import_test`) + las 35 migraciones + el import.
Salieron dos cosas que **habrían roto el deploy**, y ninguna tiene que ver con la importación en sí:

### 1. Una migración falla sobre los datos reales de producción

```
2026_03_02_151205_change_description_type_machine_translation_table
SQLSTATE[22032]: Invalid JSON text: "Invalid value." at position 0
  in value for column 'description'
```

La migración hace `$table->json('description')->change()` sobre `machine_translations`, pero en
producción **las 150 filas tienen texto plano**, no JSON. En el editor pasó porque allí la columna
ya contenía documentos TipTap (`{"type":"doc","content":[…]}`).

El `ALTER` no convierte nada: solo cambia el tipo, y MySQL rechaza el valor existente. **El deploy
se detiene ahí**, con las migraciones a medias.

**✅ Arreglado** en la propia migración: convierte antes de cambiar el tipo, envolviendo el texto
plano en un documento TipTap mínimo. Idempotente — si la columna ya es json, no hace nada:

```sql
UPDATE machine_translations
SET description = JSON_OBJECT('type','doc','content', JSON_ARRAY(JSON_OBJECT(
      'type','paragraph','content', JSON_ARRAY(JSON_OBJECT('type','text','text', description)))))
WHERE NOT JSON_VALID(description);
```

Es seguro tocar esa migración: **en producción todavía no corrió**.

### 2. Faltan las migraciones de tres tablas

Tras correr las 35, estas tablas **no existen** en el destino, pero sí en el editor y el código las
usa:

| tabla | filas en el editor | quién la usa |
|---|---|---|
| `header_blocks` | 8 | `App\Models\HeaderBlock`, `HeaderBlockRelationManager` (panel) |
| `footer_blocks` | 8 | `App\Models\FooterBlock`, `FooterBlocksRelationManager` (panel) |
| `machine_complementary_accessories` | 1 | maquinaria |

Ninguna migración del repo las creaba: se hicieron a mano en la base del editor. **Sin ellas, en
producción el header y el pie del sitio no existirían.**

**✅ Arregladas** con dos migraciones nuevas en `database/migrations/module_core/`, con el esquema
exacto del editor (`SHOW CREATE TABLE`), para que el contenido exportado entre sin adaptaciones.

---

## 🔗 URLs y SEO: lo que cambia y lo que no

Comparado el sitemap actual de producción (53 URLs canónicas) contra el resultado del deploy,
sobre el dump real del 2026-09-10:

```
  se mantienen idénticas: 47
  desaparecen:             6   <- las seis son falsos positivos
```

**Ninguna URL indexada se pierde.**

**Los slugs no se tocan.** La migración copia el valor de la columna `name` tal cual; comprobado
uno a uno: 30 blogs, 25 máquinas, 7 categorías de máquina y 3 de blog, **65 de 65 idénticos**.

**Las 7 que desaparecen, en detalle:**

| URL | qué pasa |
|---|---|
| `/locale/{en,es,fr,it,pt}` | ✅ la ruta sigue existiendo; solo sale del sitemap, y bien: es una acción, no contenido |
| `/blogs/itg-brasil-saxonia/detail` | ✅ ese blog **ya estaba borrado** en producción. El sitemap actual está hardcodeado y desactualizado |

**El sector `general`** se había renombrado a `additional-accessories` en el editor, lo que habría
dejado muerta su URL. Se le devuelve el slug `general` en el catálogo, y conserva su título nuevo:
el slug solo decide la URL. Con eso `/products/general/sector` sigue respondiendo.

**El alias legacy de `/products/{slug}/sector`** había que quitarlo. `routes/web.php` lo declaraba
para redirigir 301 a la forma corta; al restituir el patrón indexado, redirigía a sí mismo — un
bucle infinito. Ahora ese path es del catálogo, como los de máquinas y blogs, y lo sirve el content
resolver. `StaleLinkFixer` tenía la misma suposición y quedó alineado.

**`ProductSector` conserva su `/sector`.** El patrón del modelo decía `products/{slug}`, que habría
cambiado 6 URLs indexadas. Era además el único tipo que perdía su sufijo — `Machine` mantiene
`/detail`, `MachineCategory` mantiene `/category`, `Blog` mantiene `/detail` — así que el cambio era
una inconsistencia, no una mejora. Se restituye a `products/{slug}/sector` y no hace falta ningún
301.

**`slug_history` queda en 0 filas.** El deploy no inventa redirecciones: la tabla queda virgen para
los cambios que haga el cliente después.

**Los enlaces del contenido también apuntaban a la forma corta.** Había bloques del builder con
`products/weaving`, `products/braiding`… escritos a mano. Corregidos en la base del editor: sin
eso, `links:audit --strict` marcaba 5 enlaces rotos. Ahora: **589 enlaces, 0 rotos.**

**Los sitemaps** se generan uno por división (8), cada uno con su propio host, cubriendo el dominio
principal y los 7 subdominios. Y se arman desde la base, no hardcodeados, así que los cambios del
cliente entran solos.

**La home `/`** no cambia: misma ruta, mismo controlador, sirve 200 directo. No hay 301 ni lo había.

---

## ☑️ El día del deploy — checklist

### Días antes (sitio abierto, sin prisa)

```bash
# 1. En LOCAL, con .env apuntando a la base del EDITOR o usando --database
php artisan content:export --database=itg_edit_web_temporal
php artisan content:storage-manifest      # tiene que decir "todos existen ✓"
```

```bash
# 2. Subir los archivos. 397 archivos, ~252 MB. RELLENAR la ruta del servidor.
rsync -avz --progress \
      --files-from=storage/app/import/storage-files.txt \
      storage/app/public/ \
      USUARIO@SERVIDOR:/RUTA/DEL/PROYECTO/storage/app/public/
```

> ⚠️ **`--files-from` es lo que hace esto seguro.** Sin él, un `rsync` de la carpeta entera sube
> 630 MB de adjuntos de tickets de prueba y pisa los reales de producción. Nunca `rsync -av
> storage/app/public/` a secas.

> 🔁 **Si tocás algo en el editor después de esto, repetí los dos pasos**: el export y el manifiesto
> se hacen con el contenido de ese momento.

```bash
# 3. Y el JSON del contenido, que no viaja en git (storage/app está en .gitignore)
scp storage/app/import/editor-content.json USUARIO@SERVIDOR:/RUTA/DEL/PROYECTO/storage/app/import/
```

### El día, en el servidor (ventana de mantenimiento, minutos)

```bash
ssh USUARIO@SERVIDOR
cd /RUTA/DEL/PROYECTO

# 4. Respaldo DENTRO de la ventana, con el sitio ya cerrado
sudo -u www-data php artisan down --render="errors::503"
mysqldump -u USER -p BASE > ~/backup-prod-$(date +%F-%H%M).sql

# 5. Código + las 36 migraciones + seeder. deploy.sh ya cierra y abre el sitio,
#    pero como aquí lo cerramos antes, al final lo deja abierto igual.
bash deploy/deploy.sh

# 6. El contenido. Primero en seco: hace todo el trabajo y lo revierte.
sudo -u www-data php artisan content:import --dry-run
sudo -u www-data php artisan content:import

# 7. Verificar antes de anunciar nada
sudo -u www-data php artisan slugs:sync          # no debería cambiar nada
sudo -u www-data php artisan links:audit --strict # 0 rotos
sudo -u www-data php artisan sitemap:generate
sudo -u www-data php artisan up
```

### Comprobar con el sitio ya abierto

- El home de cada división carga, con su cabecera y su pie
- Un blog viejo sigue abriendo por su URL de siempre
- Una máquina y un sector abren desde el menú
- Las banderas del selector de idioma y el logo se ven (vienen de `languages/` y `divisions/`)
- Los conteos siguen: 59 usuarios, 47 tickets de venta, 8 de compra, 295 contactos, 30 blogs
  *(más lo que haya entrado entre el ensayo y el deploy)*

### Medir el antes y el después: `site:snapshot` y `site:compare`

El checklist de arriba se mira a ojo y no escala a **403 URLs en ocho divisiones**. Estos dos
comandos lo hacen a máquina. Como aquí producción no se sustituye —el contenido es el mismo antes y
después, solo cambia el código— la comparación es un A/B limpio: **misma base, mismo host, misma
máquina**.

Lo que se compara NO es el HTML, que cambia entero a propósito al pasar del sitio estático al
builder, sino lo que tiene que sobrevivir al cambio:

- que **la URL siga respondiendo** — una que hoy da 200 y mañana 404 es contenido inalcanzable y
  posicionamiento perdido;
- el **`<title>` y la meta description**, que es lo que publica Google;
- el **volumen de texto visible**, que es lo que delata una sección que no se migró.

```bash
# ANTES del deploy, desde cualquier máquina con acceso al sitio público
php artisan site:snapshot --base=https://group-itg.com --out=storage/app/snapshots/antes.json

# DESPUÉS, con el sitio ya abierto. --also es imprescindible: hace que se prueben TAMBIÉN
# las URLs viejas, aunque el sitemap nuevo ya no las liste.
php artisan site:snapshot --base=https://group-itg.com \
    --also=storage/app/snapshots/antes.json --out=storage/app/snapshots/despues.json

php artisan site:compare storage/app/snapshots/antes.json storage/app/snapshots/despues.json
```

- La lista de URLs **no se inventa**: se le pide a cada división su propio `/sitemap`.
- Los **ocho hosts** salen del `--base` con la misma convención que `sitemap:generate`
  (`mexico` → `mexico.dominio`). Sirve igual contra un local, donde **`mexico.localhost` resuelve
  solo** porque `SubdomainUtil` lee el primer segmento del host.
- `site:compare` ordena por gravedad: **URLs perdidas** (rojo) · **texto desplomado** (naranja) ·
  **SEO perdido** (amarillo) · **redirigidas** (azul, que NO es un fallo: el catálogo de slugs
  redirige a propósito) · títulos y URLs nuevas (informativo, con `--todo`).

⚠️ **Contra un LOCAL la comparación miente**, porque el contenido de los dos lados es distinto: los
artículos reales de producción son más largos que los de prueba y salen falsos positivos de «texto
desplomado». Comprobado el 2026-09-16: `/blogs` reportaba −51 % de palabras y era solo eso. Contra
producción antes/después no pasa, que es la razón de usarlo así.

✅ **La foto del ANTES ya está tomada (2026-09-16):**
`storage/app/snapshots/PRODUCCION-antes-del-deploy.json` — **403 URLs de las 8 divisiones, 395 en
200**. Los 8 fallos son la misma URL, `/blogs/itg-brasil-saxonia/detail`, que **ya da 404 en
producción hoy** aunque su propio sitemap la siga anunciando: es un problema preexistente que
conviene arreglar, pero no lo causa el deploy.

### Reorganización de migraciones — ✅ Hecho (2026-09-24)

Ejecutado en la rama `pivot/reorganizar-migraciones`. Para cuando se hizo ya no eran 38 sino 43
(se sumaron 5 entre el 2026-09-18 y esa fecha), repartidas así:

| van a | qué | cuántas |
|---|---|---|
| **`module_editor/`** (nuevo) | editor, maquinaria y blogs: pages, content_block_pages, division_page, nav_menu_items, shared_sections, product_sectors, slugs, `machines_*`/`blogs_*`, y las 5 nuevas del 09-18 (home_sections, nav_menu zh_CN, product_sector_representative_blocks, slug references) | 41 |
| **`module_core/`** | `create_notifications_table` (campanita) | +1 |
| **`module_guest/`** | `null_orphan_logo_short_paths` | +1 |

Los tres casos que el plan original dejaba "sin clasificar" se resolvieron mirando quién usa cada
tabla:

- **`create_media_table`** → `module_editor`. Solo `App\Models\Page` y `App\Models\ContentBlockPage`
  implementan `HasMedia`/`InteractsWithMedia`; ningún otro modelo del proyecto usa la tabla `media`.
- **`create_site_backups_table`** → `module_editor`. La tabla la maneja
  `App\Filament\Pages\SiteBackupPage`, y Filament en este proyecto es exclusivo del Editor Web.
- **`null_orphan_logo_short_paths`** (una de las 5 nuevas) → `module_guest`, **no** `module_editor`.
  Es un data-fix sobre la tabla `logos`, y `create_logos_table` ya vive en `module_guest` desde
  antes — se mantiene junto a su tabla en vez de agruparla por "toca contenido visual del sitio".

Se verificó que mover fue seguro (mismo razonamiento que ya estaba documentado acá:
`Migrator::getMigrationFiles()` ordena por nombre entre carpetas, la tabla `migrations` no guarda
ruta, nada en `deploy.sh`/`config`/`composer.json` referencia rutas de migración) — commit de
`git mv` con 0 líneas de diff (renames limpios), sin correr Pint.

Se agregó `loadMigrationsFrom(database_path('migrations/module_editor'))` en `AppServiceProvider`,
y se actualizaron las 43 rutas movidas dentro de `pint.json → notPath` (siguen protegidas contra
reformateo cosmético; el array no se vacía, solo cambian de carpeta).

**Advertencia que sigue vigente:** la raíz sigue siendo el destino por defecto de
`php artisan make:migration`. Toda migración nueva debe crearse directamente en el módulo que
corresponda (`module_editor/`, `module_core/` o `module_guest/`) para que la raíz no se vuelva a
llenar sola.

### Limpiar las ramas — ✅ Hecho (2026-09-24)

`pivot/editor-web` y `merge/production-fix-fusion` se borraron, local y remoto. Quedan solo
`production`, `development` y `master` (congelada desde sep-2024).

Verificación hecha antes de borrar (`git fetch origin --prune` +
`git branch --merged origin/production|development`):

- **`pivot/editor-web`** → contenida en ambas. Borrado sin riesgo.
- **`merge/production-fix-fusion`** → **no** contenida en ninguna: `git diff origin/production
  merge/production-fix-fusion --stat` mostraba 1101 archivos distintos (+23788/−42174), y su
  primer commit es el arranque original del proyecto — no es ancestro de `pivot/editor-web` ni de
  `production`. No se pudo confirmar que sus 112 commits estuvieran cubiertos por otro lado. Se
  borró igual por decisión **explícita** del usuario, nombrando la rama, después de esta
  advertencia — no por el checklist automático.

⚠️ **Confirmado el bug de `git branch -d`**: al borrar `merge/production-fix-fusion` en local,
Git avisó *"has been merged to 'refs/remotes/origin/merge/production-fix-fusion', but not yet
merged to HEAD"* y la borró igual — es decir, `-d` la consideró seguro contra su propio upstream
remoto, no contra `development`/`production`. El borrado local no verifica nada por sí solo; la
única confirmación real es el `--merged origin/...` de arriba. El `push origin --delete` posterior
sí requirió una confirmación aparte por ser el paso sin deshacer.

### Si algo sale mal

`content:import` es transaccional: un fallo no deja nada a medias, el sitio sigue cerrado y se
puede reintentar. Si hay que volver atrás del todo: restaurar el dump del paso 4 y
`git checkout production && bash deploy/deploy.sh`.

---

## Decisiones cerradas (confirmadas por el cliente del proyecto)

| qué | de dónde viene | comprobación que lo respalda |
|---|---|---|
| `pages` y todo el builder | **editor** | no existen en prod |
| `product_sectors` y sus bloques | **editor** | no existen en prod |
| maquinaria (26 máquinas + 11 tablas) | **editor** | el editor tiene ediciones hasta 2026-08-20; la copia de prod está congelada en 2024-12-05. Además **no es opcional**: 7 referencias `slug:NN` del menú apuntan a `MachineCategory` |
| `languages` · `logos` · `divisions_languages` | **editor** | están en ambos, pero prod apunta a `/images/` y el editor a `storage/` |
| `blogs` y `blog_categories` | **PRODUCCIÓN** | la base del editor tiene meses; sus blogs están viejos |
| storage `blogs/` (140 MB) | **PRODUCCIÓN** | no se sube |

**Por qué dejar los blogs de prod es seguro** (comprobado, no supuesto): ningún bloque del builder
referencia blogs por id. Los 8 bloques que contienen la palabra "blog" solo llevan el texto "Il
nostro blog" como título; los listados de blog son componentes que consultan la base en vivo, así
que se alimentan de los blogs que haya en producción. Y **ninguna de las 64 referencias `slug:NN`
apunta a `Blog` ni a `BlogCategory`**.

⚠️ **Lo único que queda por verificar, y hay que hacerlo en el servidor, no aquí:**

```sql
-- sobre la base REAL de producción, antes de la ventana
SELECT COUNT(*), MAX(updated_at), MAX(created_at) FROM machines;
```

**✅ Resuelto con el dump real del 2026-09-10:** en producción las máquinas no se tocan desde
`2024-12-05 11:23:50` — ni una edición ni un alta desde entonces. Importar las del editor no pisa
ningún trabajo.

---

## Inventario real (medido, no estimado)

**Origen — `itg_edit_web_temporal`, 79 tablas:**

| grupo | tablas | filas |
|---|---|---|
| builder | `pages` 12 · `content_block_pages` 149 · `division_page` 40 · `shared_sections` 15 · `footer_blocks` 8 · `header_blocks` 8 · `nav_menu_items` 56 | 288 |
| sectores | `product_sectors` 6 · `product_sector_blocks` 63 · `product_sector_translations` 36 · `pdf_sectors` 9 + pivotes | ~120 |
| catálogo de URLs | `slugs` 81 (Blog 27, Machine 26, Page 12, MachineCategory 7, ProductSector 6, BlogCategory 3) | 81 |
| maquinaria | `machines` 26 + 11 tablas asociadas | ~600 |
| marca | `logos` 8 · `languages` 6 · `divisions_languages` 19 | 33 |

**Es poco volumen: ~1.100 filas.** El comando de importación tarda segundos, no horas. Eso es lo
que hace viable la ventana corta.

**Datos vivos que NO se tocan** (y que el dump del editor destruiría, porque los tiene
desactualizados: 29 users frente a los de prod, 43 sale_tickets, 145 contact_requests).

---

## El orden, que no cambia

Se mantiene la regla del runbook anterior, porque es correcta:

> **Las migraciones corren ANTES de importar el contenido.**

`2026_08_20_000002_move_slugs_to_catalogue` lee la columna `slug` de los blogs y máquinas **reales
de producción**, construye el catálogo y borra la columna. Es lo único que le da URL a los blogs de
prod. Si se importa antes, esa migración se encuentra datos que no son los de producción y el
ruteo se rompe.

---

## Fases

### Fase A — Storage, en caliente y sin prisa (días antes)

Los archivos pueden subirse con producción funcionando: nada los referencia hasta que exista la
fila en la base.

🔴 **No copiar directorios enteros.** De los 1,1 GB locales, **630 MB son adjuntos de tickets de
prueba** que pisarían los reales de producción. Y copiar carpetas tampoco avisa si falta un
archivo: eso se descubre con la imagen rota en el sitio.

En vez de eso, `content:storage-manifest` pregunta a la base **qué archivos usa el contenido** y
comprueba que estén en disco:

```bash
php artisan content:storage-manifest
```

Recorre las mismas columnas que `storage:clean-unused`, incluidas las rutas dentro de los JSON de
los bloques, y escribe la lista para rsync. Medido sobre el contenido real:

```
  archivos referenciados por el contenido: 397 (252.3 MB)
     pages/                         140 archivos
     machinery/                      93 archivos
     product-sectors/                91 archivos
     catalogue/                      55 archivos
     divisions/                       8 archivos
     languages/                       6 archivos
     footer-media/                    3 archivos
     header/                          1 archivos

  Todos los archivos referenciados existen en disco ✓
```

**Son 252 MB, no 1,1 GB ni los 302 MB que estimaba este documento contando carpetas enteras.** Lo
que queda fuera son huérfanos (imágenes reemplazadas que nadie referencia ya): 67 en `pages/`, 18
en `divisions/`, 13 en `machinery/`…

Si algún archivo faltara, el comando **sale con error** y lo lista en
`storage/app/import/storage-missing.txt`: mejor enterarse aquí que con la imagen rota en
producción.

Y después, la copia:

```bash
rsync -av --files-from=storage/app/import/storage-files.txt \
      storage/app/public/ SERVIDOR:RUTA/storage/app/public/
```

`--files-from` crea los directorios que haga falta y **no toca nada más**: los adjuntos de tickets
de producción ni se miran. Probado en local: copia los 397 archivos y nada más.

### Fase B — La ventana (minutos)

Una sola ventana de mantenimiento, la que ya abre `deploy.sh`:

1. **Respaldo** de la base de prod y del storage (`mysqldump` + `tar`).
2. `bash deploy/deploy.sh` → baja el sitio, `composer install`, `npm run build`, **`migrate`** (las
   36), `PermissionSeeder`, `storage:link`.
3. **`php artisan content:import --file=…`** (el comando nuevo, abajo).
4. `php artisan slugs:sync && php artisan links:audit --strict && php artisan sitemap:generate`.
5. `php artisan up`.

**Por qué los pasos 2 y 3 van en la misma ventana:** entre la migración y la importación, las
tablas del builder están vacías. Si el sitio se abriera ahí, las páginas del builder darían 404.

**Qué se pierde:** nada. Un ticket que entre durante la ventana no se pierde, se rechaza: el
usuario ve la página de mantenimiento y reintenta. Es la diferencia con el plan anterior, donde el
ticket entraba, se guardaba y luego el dump lo borraba.

### Fase C — Verificación (con el sitio ya abierto)

```sql
-- los datos vivos siguen ahí, con los conteos de ANTES de la ventana
SELECT COUNT(*) FROM users;  SELECT COUNT(*) FROM sale_tickets;
SELECT COUNT(*) FROM contact_requests;  SELECT COUNT(*) FROM blogs;
-- todo contenido tiene URL
SELECT COUNT(*) FROM pages p LEFT JOIN slugs s
  ON s.sluggable_id = p.id AND s.sluggable_type = 'App\\Models\\Page' WHERE s.id IS NULL;  -- 0
```

---

## El comando: `content:import`

```
php artisan content:import --file=storage/app/import/editor-content.json
                           [--dry-run] [--with-machinery] [--force]
```

**Cómo se genera el archivo** (en local, desde `itg_edit_web_temporal`):

```
php artisan content:export --out=storage/app/import/editor-content.json
```

JSON y no SQL, a propósito: se valida antes de ejecutar, se puede versionar y diferenciar, y no
depende del dialecto ni de la versión de MySQL del servidor.

**Lo que hace, en este orden:**

1. **Precondiciones** (aborta si alguna falla):
   - `migrate:status` sin pendientes.
   - Las tablas del builder existen y **están vacías** (si no, exige `--force`: señal de que la
     importación ya se corrió).
   - `slugs` ya tiene las filas de `Blog` — prueba de que la migración corrió y de que estamos
     sobre la base de producción, no sobre una copia del editor.
2. **Una transacción** para todo. Cualquier fallo deja la base como estaba.
3. Inserta **en orden de dependencia**: `languages` → `logos` → `divisions_languages` →
   `pages` → `content_block_pages` → `division_page` → `shared_sections` → `header_blocks` →
   `footer_blocks` → `nav_menu_items` → `product_sectors` → sus bloques, traducciones y pivotes →
   `pdf_sectors`.
4. **Los slugs, PRIMERO** (antes de los bloques y el menú, porque hay que reescribir sus
   referencias — ver la sección siguiente):
   ```sql
   DELETE FROM slugs WHERE sluggable_type IN
     ('App\Models\Page','App\Models\ProductSector','App\Models\Machine','App\Models\MachineCategory');
   ```
   y se reinsertan los del editor, guardando el mapa `id_viejo → id_nuevo`. Las filas de `Blog` y
   `BlogCategory` **no se tocan nunca**: las generó la migración con los ids de producción, y los
   blogs se quedan los de prod.
5. **Verificación dentro de la transacción**, antes del commit: ningún `pages` sin slug, ninguna
   división sin home, ningún `path` duplicado. Si algo falla → rollback y sale con error.

**Idempotente:** correrlo dos veces deja el mismo resultado. Cada grupo se borra y reinserta
completo, nunca fila por fila.

### 🔴 Las referencias internas `slug:NN` — el detalle que rompe todo

El contenido del builder no guarda rutas, guarda **referencias al catálogo**: `slug:70`, que
`slug_path()` resuelve al path actual. Medido en el editor:

**194 referencias** repartidas entre `content_block_pages`, `shared_sections`,
`product_sector_blocks`, `nav_menu_items` (`redirect` y `sub_items`), `header_blocks` y
`footer_blocks`. Apuntan a **16 slugs distintos**. (La primera estimación de este documento decía
64: la contó `content:export`, que recorre todas las columnas configuradas.)

**Si los slugs se reinsertan con ids nuevos, esas 64 referencias apuntan a otra cosa o a nada**, y
el menú entero y los enlaces de los bloques se rompen. Era el fallo de la versión anterior de este
documento, que proponía insertar los slugs "sin id, dejando que MySQL asigne".

**A qué apuntan** (comprobado): `MachineCategory` 7 · `ProductSector` 6 · `Page` 3. **Ninguna
apunta a `Blog`**, que es justo el contenido que se queda el de producción. Es decir: todas las
referencias son a contenido que se importa, así que el mapeo se puede resolver entero.

**Solución — el comando construye un mapa y reescribe:**

1. Inserta las filas de `slugs` de lo importado y guarda `id_viejo → id_nuevo`.
2. **Antes** de insertar los bloques y el menú, reescribe en su JSON cada `slug:viejo` por
   `slug:nuevo`.
3. Verifica, dentro de la transacción, que **no queda ninguna referencia `slug:NN` apuntando a un
   id inexistente**. Si queda alguna → rollback.

No sirve conservar los ids originales: la migración ya insertó filas de `Blog` y `Machine` con
autoincrement de producción, y chocarían.

### Sobre los ids

Las tablas del builder llegan vacías a producción, así que **los ids del editor se conservan tal
cual**. Eso mantiene íntegras todas las relaciones internas (`content_block_pages.page_id`,
`division_page.page_id`, `slugs.sluggable_id`) sin tener que remapear nada.

`slugs` es la excepción: la migración ya insertó filas con ids autoincrement de producción. Por eso
las filas nuevas se insertan **sin `id`**, dejando que MySQL asigne; lo que importa es
`sluggable_id`, que sí se conserva.

---

## Las tablas compartidas — la única decisión real

Cuatro grupos existen en **ambos** lados. Aquí es donde hay que decidir, no hay respuesta automática:

| tabla | situación | recomendación |
|---|---|---|
| `languages` (6) · `logos` (8) · `divisions_languages` (19) | prod las tiene, pero apuntando a `/images/`; el editor a `storage/` | **Importar.** Sin esto, logo y banderas dan 404. La migración `2026_08_28_000001` ya mueve las imágenes de marca a storage. |
| `machines` + 11 tablas (~600 filas) | existen en los dos lados con datos distintos | **Todo o nada.** Ver abajo. |

**La maquinaria: decidido — se importa del editor** (26 máquinas + 11 tablas). Con
`--with-machinery`, incluyendo sus filas de `slugs` de tipo `Machine` y `MachineCategory`, borrando
antes las que generó la migración con los ids de producción. **Obligatorio**, además, porque 7 de
las referencias `slug:NN` del menú apuntan a `MachineCategory`.

**Lo único que se queda de producción son los blogs** (tabla `blogs`, sus slugs de tipo `Blog` y
`BlogCategory`, y el directorio `blogs/` del storage).

**Media importación deja máquinas sin traducción o sin URL.** El comando lo trata como un bloque
atómico y rechaza estados intermedios.

---

## Rollback

- **Durante la fase B:** el comando es transaccional; un fallo no deja nada a medias. El sitio sigue
  en mantenimiento hasta que se decida.
- **Después:** restaurar el dump del paso B.1. Se pierden los datos que hayan entrado desde la
  ventana — por eso el respaldo se toma **dentro** de la ventana, con el sitio ya cerrado, y no
  antes.
- **El código:** `git checkout production && bash deploy/deploy.sh`.

---

## Lo que hay que construir

| # | entregable | dónde |
|---|---|---|
| 1 | ✅ `content:export` — vuelca las tablas del editor a JSON, avisa de referencias colgando | `app/Console/Commands/ContentExport.php` |
| 2 | ✅ `content:import` — valida, inserta en transacción y verifica | `app/Console/Commands/ContentImport.php` |
| 3 | ✅ Lista de tablas y su orden de dependencia, en un solo sitio | `config/content_import.php` |
| 4 | ✅ `content:storage-manifest` — lista exacta de archivos y aviso de faltantes | `app/Console/Commands/ContentStorageManifest.php` |

Ensayo obligatorio antes de producción: correr las fases B y C **sobre una copia fresca del dump de
producción**, con el storage real. Es la única forma de comprobar que las 36 migraciones pasan sobre
los datos reales, que es el punto donde el plan anterior avisaba que podía abortar
(`move_slugs_to_catalogue` aborta si hay slugs duplicados en prod).
