# Tutorial · Herrajes (Pedido · Catálogo · Reglas)

Tutorial paso a paso del **panel de Herrajes** de Despiece: armar el
pedido con su catálogo de SKUs y un motor de reglas que mapea *rol de
pieza × intervalo mm* a cantidad automática. Se abre desde el **botón
Herraje de la topbar** (no tiene atajo de teclado propio — la callback
`onHerraje` solo está cableada al `#tb-herraje` y al item de menú
correspondiente).

El modelo de datos vive en **dos localStorage** aparte del proyecto
(`despiece.herrajeria.v1` con 29 SKUs default + customs,
`despiece.hardware-rules.v1` con 7 reglas default). El pedido por
proyecto está en `parts.hardwareList` con líneas
`{skuCode, qty, origin: 'manual' | 'auto'}`. Aplicar reglas regenera
solo las líneas `auto`; las `manual` quedan intactas.

---

## 0. Modelo mental: tres capas para responder "¿cuántas bisagras tengo que pedir?"

Antes de meternos en la UI, vale la pena tener clara la pregunta de
negocio. Cuando un taller importa un modelo con 12 puertas y 6 estantes
y abre Excel paralelo, lo que está haciendo a mano es:

1. Recorrer las puertas una por una, medir la altura y ver en qué
   tramo cae (2 bisagras hasta 1500 mm, 3 hasta 2200 mm, 4 por
   encima) para saber cuántas bisagras lleva cada una.
2. Sumar un tirador por puerta, sumar 4 pines por estante, etc.

El módulo de Herrajes hace exactamente eso, pero con datos
configurables en vez de a ojo. Hay tres capas independientes:

```text
┌─────────────────────────────────┐   ┌──────────────────────────────────┐
│ Catálogo (localStorage taller)  │   │ Reglas (localStorage taller)     │
│  HerrajeItem.code/name/cost/cat │   │  HardwareRule.role × [min,max)   │
│                                 │   │              → sku × qtyPerPart  │
└────────────┬────────────────────┘   └─────────────┬────────────────────┘
             │                                      │
             └──────────────────┬───────────────────┘
                                ▼
              applyHardwareRules() ← parts.partRole + dims
                                │
                                ▼
              parts.hardwareList: HardwareLine[]
                                │
        ┌───────────┬───────────┼───────────┬─────────────┐
        ▼           ▼           ▼           ▼             ▼
     Pedido    Etiquetas   Presupuesto  Export CSV   Export XLSX
```

- El **catálogo** es la fuente de verdad de *qué existe y a qué
  precio* (los SKUs de tu proveedor).
- Las **reglas** son la fuente de verdad de *qué cantidad va por
  pieza* (tu saber hacer de taller, parametrizable).
- El **pedido** (`parts.hardwareList`) es la materialización en este
  proyecto: la suma de `(catálogo × reglas)` para cada pieza, más
  cualquier línea manual que hayas agregado.

Las tres capas viven en lugares distintos (catálogo y reglas son del
taller; pedido es del proyecto). Eso permite que cambies un precio en
el catálogo y se recalculen subtotales sin tocar reglas, y que cambies
una regla y se regenere el pedido sin tocar el catálogo.

---

## 1. Cargar el modelo y abrir Herraje

![Botón Herraje en la topbar](img/01-overview-herrajes.jpg)

Al importar el STEP/IGES/BREP/glTF/GLB, el botón Herraje de la
topbar se habilita. La primera vez que abrís Herraje en este proyecto,
el panel se inicializa con:

- **Pedido** vacío (hardwareList sin líneas).
- **Catálogo** con 29 SKUs precargados de fábrica
  (`DEFAULT_HERRAJERIA_ITEMS` en `src/herrajerie/catalog-defaults.ts`).
- **Reglas** con 7 reglas default (`defaultHardwareRules()` en
  `src/herrajerie/rules-defaults.ts`).

> **Cuándo se inicializan catálogo y reglas**: la primera vez que
> abrís el panel en una sesión, `loadHerrajeriaCatalog()` y
> `loadHardwareRules()` leen localStorage; si está vacío, devuelven
> los defaults sin red ni sincronización. Una vez cargado, todo lo que
> edites ahí se persiste en este navegador.

El panel es full-screen modal con shell `.herraje-sheet` y tres vistas
segmentadas *Pedido · Catálogo · Reglas*. Las vistas son exclusivas —
cambiar de tab destruye y re-monta el contenido de la vista target
(en vez de tenerlas apiladas).

## 2. Vista Pedido: lista del proyecto

![Vista Pedido con líneas del proyecto](img/02-pedido-herrajes.jpg)

La vista **Pedido** muestra `state.project.hardwareList` con columnas
SKU · Nombre · Cant · Unidad · Costo unit · Subtotal · Notas. La
toolbar tiene:

| Botón / selector | Acción |
|------------------|--------|
| `#herraje-list-search` | Buscador live con autocomplete contra el catálogo (código + nombre). |
| `data-action="add"` | + Línea: inserta una fila nueva vacía para que escribas el SKU manualmente. |
| `data-action="apply-rules"` | Aplicar reglas (paso 3). |
| `data-action="groupby-flat" \| "groupby-assembly"` | Switch Plano / Por mueble (subtotal por assembly). En **Por mueble**, las filas de una línea `auto` repartida entre dos o más muebles son de sólo lectura. |

Cada línea tiene una *provenance badge*: `auto` (lila / teal, según el
tema) indica que la línea fue generada por aplicar reglas; `manual`
(gris) indica que la escribiste a mano. Las dos conviven en la misma
tabla sin separarse.

Arriba de la tabla puede aparecer un aviso: el listado guardado ya no
coincide con las reglas o las piezas actuales. Es el recordatorio de
volver a apretar **Aplicar reglas** para regenerar las líneas `auto`.

### 2.1 Anatomía de una `HardwareLine`

Detrás del row visible hay un objeto `HardwareLine` con estos campos:

| Campo | Tipo | Origen | Quién lo edita |
|---|---|---|---|
| `id` | uuid v4 | generado al crear | inmutable |
| `skuCode` | FK string al `HerrajeItem.code` | catálogo (FK) o tipeado | manual al crear |
| `qty` | number ≥ 1 | manual o auto | manual + Aplicar reglas |
| `origin` | `'manual' \| 'auto'` | sistema | sólo Apply lo cambia |
| `notes` | string opcional | manual | manual de la línea |
| `unitCost` | number opcional (override) | manual | manual de la línea |

> **Por qué el badge `auto` no es decorativo**: es el dato que usa
> `applyHardwareRules` para saber qué líneas descartar antes de
> regenerar (`apply-rules.ts:112-116`:
> `existing.filter(l => l.origin !== 'auto')`). Si ves un row `auto`
> mal en tu CSV de proveedor, la solución no es borrarlo a mano — es
> ajustar la regla, volver a aplicar y dejar que el motor lo regenere.

## 3. Aplicar reglas — el corazón del motor

![Aplicar reglas regenera las líneas auto](img/03-apply-rules-herrajes.jpg)

El botón llama `runApplyHardwareRules()`
(`src/herrajerie/run-apply.ts:24-49`) que termina en
`applyHardwareRules(...)` en `src/herrajerie/apply-rules.ts`. El motor
recorre 7 fases, cada una con un rol claro.

### 3.1 Clamp del multiplicador (`apply-rules.ts:108-110`)

```ts
const qtyMul = quantityMultiplier && Number.isFinite(quantityMultiplier) && quantityMultiplier > 1
  ? Math.floor(quantityMultiplier)
  : 1;
```

Defensivo: aunque el caller pase `0`, `NaN`, `2.7` o negativo, el motor
trata todo lo "no es entero ≥ 2" como 1 (sin escalado). Esto preserva
la compat con proyectos y tests pre-multiplier (ver spec
`2026-07-17-herraje-multiplier.md`).

### 3.2 Loop exterior: por cada pieza (`apply-rules.ts:132-182`)

Filtros en orden, cortocircuitando en cuanto uno falla:

1. `visibleOnly && part.object?.visible === false` → skip (respeta el
   flag de Settings → Export).
2. `role = part.partRole ?? 'none'`; si es `'none'` o vacío → skip (la
   pieza no aporta nada al herraje).

### 3.3 Cómputo de X (`apply-rules.ts:138`)

```ts
const x = boardDimensions(part.dimXmm, part.dimYmm, part.dimZmm).x;
```

`x` es la **cara mayor del AABB** de la pieza
(`boardDimensions()` en `src/export-bom/board-dimensions.ts`). Una
puerta 600×1800×18 → X=**1800**, Y=600. Justificación de diseño: las
reglas de bisagras son por **altura de puerta**, y X resulta ser la
dimensión mayor de la cara. **Limitación documentada**: la spec v2
sólo cubre X; una futura v3 podría cubrir Y (ancho) y Z (espesor).

### 3.4 Loop interior: por cada regla (`apply-rules.ts:142-179`)

Para cada pieza, se iteran las reglas aplicando filtros en orden:

1. `!rule.enabled` → skip. Las reglas deshabilitadas ni aportan qty ni
   participan en el overlap check (sólo "reservan" rangos futuros).
2. `role !== rule.role` → skip. La pieza no es del rol que la regla
   matchea.
3. `seenSku.has(rule.skuCode)` → skip. **Defensa runtime**: si dos
   reglas enabled matchean el mismo SKU en la misma pieza (no debería
   pasar si la validación de solapes está OK), gana la primera en
   orden del array.
4. `!inRange(x, rule.minMm, rule.maxMm)` → skip. Intervalo half-open
   `[min, max)` (semi-abierto: `minMm ≤ x < maxMm`).
5. `!knownSkuCodes.has(rule.skuCode)` → `skippedOrphanSku++`,
   continuar. Una regla que apunta a un SKU borrado del catálogo no
   rompe la corrida, sólo se cuenta aparte.

#### Por qué intervalos semi-abiertos `[0, 1500) ∪ [1500, 2200) ∪ [2200, ∞)`

Una pieza de X=1500 mm cae en la regla `[1500, 2200)`, **NO** en
`[0, 1500)`. Si los rangos fueran cerrados en ambos lados
(`[0, 1500] ∪ [1500, 2200]`), los bordes se solaparían en 1500 y la
validación rechazaría las reglas por `overlap`. Definición formal en
`rules-interval.ts:7-11`:

```ts
export function inRange(x: number, minMm?: number, maxMm?: number): boolean {
  if (minMm !== undefined && x < minMm) return false;
  if (maxMm !== undefined && x >= maxMm) return false;
  return true;
}
```

El helper `rangesOverlap()` (`rules-interval.ts:17-27`) usa la
condición `aLo < bHi && bLo < aHi` para detectar solapes; los
adyacentes (que tocan en el extremo compartido) **NO** se consideran
solapados gracias al half-open.

### 3.5 Acumulación (`apply-rules-match.ts` + `apply-rules-emit.ts`)

Si todos los filtros pasan:

```ts
// apply-rules-match.ts (1)
stats.pieces++;
partsBySku.set(rule.skuCode,
  (partsBySku.get(rule.skuCode) ?? 0) + 1);
seenSku.add(rule.skuCode);
matchedThisPart = true;

// apply-rules-emit.ts (2)
const groups = Math.floor(stats.pieces / (rule.perPieces ?? 1));
if (groups > 0) qtyBySku.set(rule.skuCode,
  (qtyBySku.get(rule.skuCode) ?? 0) + groups * rule.qtyPerPart * qtyMul);
```

**El multiplicador (`qtyMul`) entra acá** — escala el cómputo, NO la
regla. La regla sigue diciendo "2 bisagras por puerta"; lo que cambia
es el factor de la obra.

**Con `perPieces` la suma no es por pieza**: el motor cuenta primero
cuántas piezas matchea cada regla y sólo después emite `floor(piezas /
perPieces) × qtyPerPart × qtyMul`. Un resto incompleto no emite nada,
y un SKU que queda en 0 no emite línea. Son dos pases
—`apply-rules-match.ts` cuenta, `apply-rules-emit.ts` emite— porque la
cantidad no existe hasta terminar de recorrer el modelo.

### 3.6 Breakdown por ensamblaje (opt-in, `apply-rules.ts:165-178`)

Cuando `withAssemblyBreakdown: true` y `partAncestors` están poblados
(cómputo en `computeAssemblyGroups` + `tree-builder` +
`findAncestorAssemblyIds`):

- **Leaf-only**: la pieza se cuenta UNA sola vez, atribuida al asmId
  más profundo (`asmIds[length-1]`). Un cajón dentro de COCINA no
  duplica en COCINA.
- Piezas ausentes del mapa se ignoran para breakdown (igual
  contribuyen a `qtyBySku` global).

### 3.7 Reemplazo selectivo (`apply-rules.ts:112-116, 184-196`)

```ts
const kept = qtyMul > 1
  ? existing.filter(l => l.origin !== 'auto')
              .map(l => ({ ...l, qty: l.qty * qtyMul }))
  : existing.filter(l => l.origin !== 'auto');

const autoLines: HardwareLine[] = [];
for (const [skuCode, qty] of qtyBySku) {
  autoLines.push({
    id: uuid(),
    skuCode,
    qty,
    origin: 'auto',
    notes: `auto · ${n} piezas`,
  });
}

return { nextList: [...kept, ...autoLines], summary };
```

`nextList = [...manual (× qtyMul), ...autoLines (× qtyMul)]`. Las
líneas `manual` **no se tocan** salvo por el escalado del
multiplicador. Las `auto` se descartan y se regeneran limpias en cada
Apply. **Re-aplicar no duplica auto** — siempre una línea por SKU, no
por pieza.

### 3.8 Summary devuelto (`apply-rules.ts:198-223`)

```text
partsMatched     → piezas que aportaron ≥1 regla
linesAuto        → líneas auto emitidas (1 por SKU)
qtyTotal         → suma qty de las auto (post-multiplier)
skippedOrphanSku → matches omitidos por SKU fuera de catálogo
byAssembly?      → opt-in; Map<skuCode, Map<asmId, { partIds, qty }>>
```

### 3.9 Worked example end-to-end

Proyecto `CocinaV9`: 3 puertas con role `puerta`, ninguna asignada a
mano aún.

```text
pieza A: 600×500×18   → X=600   → rule-puerta-bis-0  [0,1500)     → BIS-35 × 2
pieza B: 1800×600×18  → X=1800  → rule-puerta-bis-1  [1500,2200)  → BIS-35 × 3
pieza C: 2400×900×18  → X=2400  → rule-puerta-bis-2  [2200,+∞)    → BIS-35 × 4

+ cada puerta también matchea rule-puerta-tir (sin rango, cualquier alto) → TIR-J × 1
```

`Aplicar reglas` (multiplier=1) produce:

```text
partsMatched:    3
linesAuto:       2   (BIS-35 + TIR-J)
qtyTotal:       12   (9 + 3)
skippedOrphanSku: 0

state.project.hardwareList =
  { skuCode: 'BIS-35', qty: 9, origin: 'auto', notes: 'auto · 3 piezas' }
  { skuCode: 'TIR-J',  qty: 3, origin: 'auto', notes: 'auto · 3 piezas' }
```

Si activás `herrajeMultiplierEnabled` + `projectMultiplier=3` (mismo
proyecto, "3 cocinas iguales"), el mismo input produce `qtyTotal: 36`
con las mismas 2 líneas, sin editar reglas.

## 4. Vista Catálogo: 29 SKUs precargados

![Vista Catálogo con SKUs y CRUD inline](img/04-catalogo-herrajes.jpg)

La vista **Catálogo** muestra `loadHerrajeriaCatalog()` con las 9
categorías:

| Categoría | Built-ins | Ejemplos |
|-----------|------------|----------|
| `bisagra` | 5 | BIS-35, BIS-PREMIUM, BIS-PIV, BIS-VIDRIO-LO, TIP-ON |
| `corredera` | 6 | COR-TEL-30/35/40/45/50, COR-TEL-CUSTOM |
| `tirador` | 4 | TIR-J, TIR-U, TIR-EMB, TIR-BOTON |
| `union` | 4 | GRP-UNI, GRP-CONF, TAR-DOWEL, TORN-30 |
| `soporte` | 3 | PIN-EST-5, PIN-EST-7, SOP-PERCH |
| `pata` | 2 | PATA-REG, RUEDA-GIR |
| `cerradura` | 2 | LOCK-CAJ, LOCK-PTA-EMB |
| `kit` | 1 | KIT-COR (riel + 2 roldanas) |
| `otro` | 2 | PERCHERO-TUB, ZAPATERO (los dos *a medida*) |

Los tres últimos (`SOP-PERCH`, `PERCHERO-TUB`, `ZAPATERO`) entraron para
que `add_clothes_rail` y `add_shoe_rack` no emitan códigos que no existen
en el catálogo.

Las **9 categorías** viven en
`src/herrajerie/catalog-defs.ts:HERRAJE_CATEGORY_DEFS` con sus
aliases por locale. Las 3 unidades son `unidad | par | juego`. El CRUD
es **inline** (celda por celda con autocomplete) — no hay modal de
edición.

> El `code` es **slug-like**, mayúsculas, guiones (la normalización
> vive en `normalizeCode()` en `types.ts:136-138`: trim + uppercase +
> colapsa espacios a `-`). Es la FK que las reglas y el pedido
> referencian — si lo cambiás, tenés que remapear las reglas y líneas
> a mano.

Con el SKU en uso por el proyecto abierto, el `code` es el **único**
campo bloqueado: precio, nombre, categoría, unidad, familia, notas y
medidas se editan igual. Al cambiar el `cost`, las líneas sin override
propio toman el precio nuevo; las que tienen override lo conservan.
Borrar el SKU sí sigue bloqueado mientras haya líneas usándolo.

### 4.1 Por qué catálogo ≠ reglas

El catálogo es la **fuente de verdad de precios**: cambiar `cost` de
`BIS-35` recalcula subtotales en todo el proyecto automáticamente, sin
tocar las reglas. Las reglas son la **fuente de verdad de
cantidades**: cambiar una regla regenera líneas `auto` sin tocar el
catálogo. Mantener estas dos dimensiones separadas es lo que permite
ajustar el precio de tu proveedor sin invalidar tu conocimiento de
taller.

## 5. Roles sugeridos por SKU

![Popover de roles sugeridos en una fila del catálogo](img/05-roles-popover-herrajes.jpg)

Cada `HerrajeItem` carga un array `roles: string[]` con los **roles
sugeridos** (qué roles de pieza típicamente usan este herraje). Por
ejemplo, `BIS-35` arranca con `['puerta']`, `PIN-EST-5` con
`['estante']`, `PATA-REG` con `['base']`.

El catálogo herraje es la **fuente de verdad** para el popover: los
roles son los mismos del módulo Roles (built-ins + customs de
`getRoleDefs()`). El sentinel `'none'` se filtra.

### 5.1 Distinción clave: sugerencia vs dato real

Tres campos con nombres similares pero responsabilidades distintas:

```text
HerrajeItem.roles: ['puerta']      ← sugerencia VISUAL (catálogo)
part.partRole: 'puerta'           ← dato REAL de la pieza (modelo)
HardwareRule.role: 'puerta'       ← filtro de la regla (regla)

El motor lee part.partRole        (apply-rules.ts:135)
El catálogo sugiere.              (UI badge "sugerido para puerta")
La pieza decide.                  (Shift+B, edición manual, auto-detect)
La regla filtra.                  (overlap check, half-open interval)
```

> **¿Cómo se conecta con las reglas?** Las reglas de *Rol → SKU ×
> intervalo* no leen los roles del catálogo — leen el
> `part.partRole` de cada pieza. Los roles del catálogo son sólo
> **sugerencia visual** para que el taller sepa "este herraje va con
> piezas de rol X". Si tu pieza tiene rol `puerta` y existe la regla
> `(puerta, BIS-35, 0–1500 mm, 2 u.)`, la pieza suma 2 a BIS-35 al
> aplicar reglas.

## 6. Vista Reglas: rol → SKU × intervalo mm

![Vista Reglas con las 7 reglas default](img/06-reglas-herrajes.jpg)

La vista **Reglas** muestra `loadHardwareRules()` con 7 reglas
default del taller (definidas en
`src/herrajerie/rules-defaults.ts`):

```
puerta  ── BIS-35  2 u.   0–1500 mm
        ── BIS-35  3 u.   1500–2200 mm
        ── BIS-35  4 u.   ≥ 2200 mm
        ── TIR-J   1 u.   (cualquier alto)
estante ── PIN-EST-5  4 u.
base    ── PATA-REG 4 u.
lateral_cajon ── COR-TEL-50  1 par · cada 2 piezas
```

El campo `x` (eje mayor de la pieza en mm) cae en cada regla vía
`inRange(x, minMm, maxMm)`. Los bordes son semi-abiertos
(`minMm <= x < maxMm`) y `minMm`/`maxMm` son opcionales: sin `minMm`
el match arranca en 0; sin `maxMm` el match va a infinito. La tercera
regla puerta (`≥ 2200 mm`) tiene `minMm: 2200` y `maxMm` indefinido —
cubre cualquier pieza arriba de 2200 mm.

### 6.1 Función conceptual de una regla

Si tenemos que reducir mentalmente "Aplicar reglas" a una sola
función, es:

```text
f(role, X) → qty del SKU
       ↑   ↑
       │   └─ boardDimensions(p).x   (cara mayor)
       └───── part.partRole ?? 'none'
```

Una regla es una fila de la "tabla de despacho": si el rol matchea y
el alto cae en el rango, suma `qtyPerPart` al acumulador del SKU. La
pieza pasa por todas las reglas activas; cada SKU que matchea suma
**una vez por pieza** (no se duplica si dos reglas enabled solapan,
ver §3.4 punto 3).

### 6.2 Cobertura de las 7 default y por qué esos números

- **Bisagras por rango de altura**: una puerta típica de placard en AR
  PyME lleva 2 bisagras hasta 1500 mm (puerta de bajo-mesada o
  alacena), 3 entre 1500 y 2200 mm (puerta alta de placard), 4 a
  partir de 2200 mm (puerta de piso a techo, vestidor). Los tres
  rangos semi-abiertos cubren `[0, +∞)` sin solapar.
- **Tirador universal**: 1 por puerta, sin importar alto. Una regla
  sin `minMm`/`maxMm` matchea cualquier X.
- **Estantes**: 4 pines por estante (soporte estándar de melamina).
  Sin rango.
- **Base**: 4 patas por base (mueble de piso). Sin rango.
- **Correderas de cajón**: 1 par por cajón, no por lateral. La regla
  de `lateral_cajon` lleva `Cant / pieza` 1 y `Cada N piezas` 2, así
  los dos laterales de un cajón piden un solo par y un lateral suelto
  no pide nada.

### 6.3 Cómo agregar una regla

Para "puerta alta de cocina, lleva 5 bisagras a partir de 2600 mm",
crear:

```text
role: 'puerta'  sku: 'BIS-35'  minMm: 2600  maxMm: (vacío = ∞)  qty: 5
```

La cobertura `[0, ∞)` de las 3 default queda:
`[0, 1500) ∪ [1500, 2200) ∪ [2200, +∞)`. La nueva `[2600, +∞)` se solapa
con la última `puerta BIS-35` (`[2200, +∞)`); al guardar, la validación
va a disparar `overlap` salvo que deshabilites una de las dos. Diseñá
reglas disjuntas (half-open adyacentes o none).

## 7. Crear regla custom

![Form de creación de regla](img/07-nueva-regla-herrajes.jpg)

**+ Nueva regla** agrega una fila con id vacío (`addHerrajeriaRule()`
en `src/herrajerie/rules-store.ts`). La fila draft está forzada a
`enabled: false` hasta completar *rol* y *SKU*; el resto se puede
dejar incompleto mientras tipeás.

Cuando completás los campos y guardás,
`validateHardwareRules` corre antes de persistir
(`rules-validate.ts:140-151`):

| Código de error | Cuándo | Ejemplo disparador |
|-----------------|--------|--------------------|
| `invalid_id` | El id está vacío o sólo whitespace. | Draft recién agregado antes de tocar nada |
| `invalid_role` | Rol no está en el catálogo de roles conocidos (built-ins + customs), o es `'none'`. | rol `puert` (typo, falta la `a`) |
| `unknown_sku` | El SKU no está en el catálogo de herrajes del taller. | `BIS-99` que el taller borró del catálogo |
| `invalid_qty` | `qtyPerPart` no es entero 1–999. | `2.5` (decimal), `0` (en regla enabled), `1000` (fuera de rango) |
| `invalid_per_pieces` | `perPieces` no es entero ≥ 1. | `0`, `2.5`, `-1` |
| `invalid_min` / `invalid_max` | Borde negativo o no finito. | `minMm: -5`, `maxMm: NaN` |
| `invalid_range` | `maxMm ≤ minMm`. | `min: 600, max: 600` (punto único) |
| `overlap` | Dos reglas enabled con mismo (rol, sku) y rangos solapados. | `[0, 600)` + `[500, 700)` con mismo rol+sku |

> **Draft vs persistente**: los drafts incompletos se persisten
> (`forceWriteHardwareRules` en `rules-store.ts:131`) para que no se
> pierdan al recargar. Sólo se bloquea *enabled*; si tenés 6 reglas
> OK y agregás una draft rota, no se pierden las 6 — sólo la nueva
> queda `enabled: false` hasta que la termines. Esto permite agregar
> una fila "placeholder" sin invalidar el resto del taller.

## 8. Multiplicador de obra (×N cocinas)

![Multiplicador de obra escalando el pedido](img/08-multiplicador-herrajes.jpg)

El multiplicador de obra vive en dos settings:

- `settings.herrajeMultiplierEnabled` — toggle en Settings → Herraje /
  stock (default `false`).
- `settings.projectMultiplier` — entero 1–99 (default 1).

Cuando el flag está activo, `runApplyHardwareRules` escala la qty de
**todas** las líneas resultantes (manual + auto) por el multiplicador.
La separación conceptual es clave:

```text
SIN multiplier ×3              CON multiplier ×3
─────────────────────          ─────────────────────
qtyBySku  = Σ qtyPerPart       qtyBySku  = Σ qtyPerPart × 3
manual[i].qty = N              manual[i].qty = N × 3
rule.qtyPerPart = 2            rule.qtyPerPart = 2  (sin tocar)
```

```ts
// apply-rules.ts (con multiplier)
const qtyMul = quantityMultiplier && quantityMultiplier > 1
  ? Math.floor(quantityMultiplier)
  : 1;

const kept = qtyMul > 1
  ? existing.filter(l => l.origin !== 'auto')
              .map(l => ({ ...l, qty: l.qty * qtyMul }))
  : existing.filter(l => l.origin !== 'auto');

// también: qtyBySku acumula con rule.qtyPerPart * qtyMul
```

> **Por qué separar cómputo y regla**: pasar de ×2 a ×3 no debe
> romper las reglas custom del taller. Cambiar el multiplier edita el
> factor de la obra; cambiar las reglas edita "qué cantidad va por
> pieza". Dos dimensiones ortogonales, dos settings separados.

> **NO escala** `qtyPerPart` de las reglas — la regla sigue siendo
> «por pieza». Lo que se escala es el cómputo. Así un cambio
> posterior en el multiplicador (ej. pasar de ×2 a ×3) no rompe las
> reglas custom del taller.

> **Default off**: la spec (`2026-07-17-herraje-multiplier.md`) fija
> el default en `false` para preservar el comportamiento histórico
> (proyectos y tests existentes NO multiplican). Cuando agregás
> `projectMultiplier>1` a un proyecto viejo, el herraje también se
> multiplica sólo si activás explícitamente el flag en Settings.

## 9. Settings: Export / Import JSON · library share

![Botones Importar / Exportar JSON del panel](img/09-import-export-herrajes.jpg)

Hay **tres niveles de I/O** para el herraje:

| Nivel | Salida | Comando |
|-------|--------|---------|
| **Per-panel JSON** | `herrajeria.json` (catálogo + recientes) | Catálogo → Exportar JSON / Importar JSON |
| **Library share** | URL con catálogo encodeado (`despiece-library` envelope v1, gzip+base64) | Catálogo → ⋯ → Compartir enlace / Pegar enlace |
| **Configuración I/O** | `config.zip` con TODO (catálogo + reglas + fotos + materiales + roles + máquinas + stock + glue-up + lineales) | Settings → Exportar / Importar |

El library share tiene cap 65 KB por la limitación de URL — si tu
catálogo supera eso, va como `.zip` con las fotos adjuntas. Cada SKU
puede tener una foto en `localStorage[despiece.herraje-images.v1]`
que se incluye en el I/O.

### 9.1 Mapa de persistencia por capa

| Dato | Dónde | Bump `PROJECT_FORMAT_VERSION` |
|------|-------|-------------------------------|
| Catálogo de herrajes | `localStorage[despiece.herrajeria.v1]` | N/A |
| Reglas de taller | `localStorage[despiece.hardware-rules.v1]` | N/A |
| Fotos de herrajes | `localStorage[despiece.herraje-images.v1]` | N/A |
| Settings (incl. `herrajeMultiplierEnabled`) | `localStorage[despiece.settings.v1]` | N/A |
| Pedido del proyecto (`parts.hardwareList[]`) | `.despiece` (per-proyecto) | **no** (soft) |
| `parts.hardwareRulesMode` / `hardwareRules` | `.despiece` (legacy, soft) | **no** |

> **Soft fields**: el catálogo (`herrajeriaCatalog`), las reglas
> (`hardwareRules`) y la lista de herraje (`hardwareList`) **no
> bumpan** `PROJECT_FORMAT_VERSION` — son soft fields que se migran
> con sane defaults si el modelo tiene un valor legacy.

## 10. Atajos de teclado

![Hoja de atajos](img/10-atajos-herrajes.jpg)

`Help` (botón de la topbar o `?`) abre la hoja completa. Atajos del
flujo de herraje:

| Atajo | Acción |
|-------|--------|
| Click en *#tb-herraje* | Abrir / cerrar el panel de Herraje (toggleHerrajePanel). |
| `Esc` | Cerrar el panel (no bloquea si hay un export en curso). |
| En el buscador | Type-ahead con autocomplete; `Enter` agrega el primer resultado o el SKU tipeado. |
| Click en SKU de catálogo | Selecciona + resalta su fila (drag & inline edit). |
| Click en *Aplicar reglas* | Regenera las líneas `auto` (sin tocar las `manual`). |
| `?` / `F1` | Abrir la hoja de atajos completa. |
| `F` / `Home` | Fit / Fit + iso del viewport 3D. |

En Mac el modificador de sistema es `⌘`; en otros sistemas `Ctrl`.

---

## 11. Por conjunto (assembly breakdown)

Además del modo **Plano**, el switch `groupby-assembly` en la toolbar
de Pedido abre la vista **Por mueble**: las líneas `auto` se reparten
por ensamblaje del product tree y muestran subtotal por grupo.

Para obras con varios muebles (ej. una cocina con bajo-mesada +
alacena + cajonera), este modo te dice qué bisagras van a la alacena
y cuáles al bajo-mesada:

- **Filas de sólo lectura**: si una línea `auto` se reparte entre dos
  o más muebles, sus filas acá no se editan — las varias filas
  comparten una sola línea guardada, y editar una sobrescribía la
  línea entera. Editala o quitala en el modo **Plano**.
- **Construcción**: `computeAssemblyGroups`
  (`src/herrajerie/assembly-grouping.ts:36-84`) arma el product tree
  vía `buildTree(m.model, state.assemblyNames.names)` y mapea cada
  pieza a su `partAncestors: partId → [root...leaf]`.
- **Atribución leaf-only**: la pieza se cuenta UNA sola vez, atribuida
  al asmId más profundo. El sub-conjunto `cajón` queda en "cajón", no
  en "Mueble cocina". Ver spec
  `2026-07-17-herrajerie-group-by-assembly.md`.
- **Subtotales por grupo**: `AssemblyGroup.subtotal = Σ(rows.subtotal)`
  se precomputa para no recalcular en cada render.
- **Manual siempre aparte**: las líneas `origin: 'manual'` caen en el
  grupo `(Manual)` (`MANUAL_GROUP_ID`), separadas de las auto, en
  orden estable (manual al final, el resto alfabético por label).
- **Multiplier**: el breakdown `(sku, asm) → qty` también escala por
  `projectMultiplier` cuando `herrajeMultiplierEnabled` está on.

Cuándo usarlo: cuando el cliente te pide "cuánto herraje lleva la
cocina + cuánto lleva la alacena por separado" para facturar
parcialmente. Cuándo NO usarlo: si tenés un solo mueble en el
proyecto, el modo Plano es más directo.

## 12. Pipeline integrado

El herraje no termina en el panel: alimenta varios consumidores aguas
abajo, todos leyendo `state.project.hardwareList` o sus derivados:

| Consumidor | Qué lee | Para qué |
|-------------|---------|----------|
| Pedido de materiales (`hardwareCost`) | `buildHardwareRows(list, catalog)` | Suma de subtotales herraje en el pedido al proveedor |
| Etiquetas (`label-section`) | `state.project.hardwareList` agrupado por (sku, asm) | Breakdown por mueble en etiquetas |
| Presupuesto al cliente | `state.project.hardwareList` con unitCost | Sección "Herraje" del PDF branded |
| Export CSV heredado | filas planas del listado | Descarga CSV |
| Export XLSX | filas + estilos por categoría | Planilla editable |
| Export PDF | filas agrupadas por categoría | Pedido al proveedor |

> **Implicancia del multiplier**: como `runApplyHardwareRules`
> pre-escala las qty (manual + auto), todos los consumidores leen qty
> ya multiplicada — no requieren enterarse del flag. Cambiar
> `herrajeMultiplierEnabled` retroactivo requiere re-aplicar reglas
> para que las auto vuelvan a escalar; las manuales se editan a mano
> (decisión de spec).

---

## 13. Ver también

- [Tutorial · Cantos y auto-cantos](cantos-y-auto-cantos.html)
- [Tutorial · Roles y auto-roles](roles-y-auto-roles.html)
- [Tutorial · Optimización de corte](optimizacion-corte.html)
- [Tutorial · Plano de taller](plano-de-taller.html)
- [Tutorial · Materiales del taller](materiales.html)
- Spec
  [`2026-07-16-bom-herrajerie-simple.md`](../superpowers/specs/2026-07-16-bom-herrajerie-simple.md)
  — modelo de datos del herraje + BOM (v1, manual).
- Spec
  [`2026-07-17-hardware-role-rules.md`](../superpowers/specs/2026-07-17-hardware-role-rules.md)
  — motor de reglas rol × intervalo → SKU × qty (v2, auto-derivación).
- Spec
  [`2026-07-17-herrajerie-group-by-assembly.md`](../superpowers/specs/2026-07-17-herrajerie-group-by-assembly.md)
  — breakdown por mueble.
- Spec
  [`2026-07-17-herraje-multiplier.md`](../superpowers/specs/2026-07-17-herraje-multiplier.md)
  — multiplicador de obra.
- Spec
  [`2026-07-18-herraje-library-share.md`](../superpowers/specs/2026-07-18-herraje-library-share.md)
  — library share URL.
