---
name: project_copie_reservations
description: Copie de réservations entre subnets (v0.18) — remap IP conserver-hôte, analyzer partagé import/copie, statut reserved, bargraph live pending
metadata:
  type: project
---

**Copie de réservations entre subnets (v0.18)** — copie une sélection de réservations d'un
subnet source vers un cible, en transposant les IP (et gateway/routes) sur le réseau cible.
**Copie pure, non destructive** (source conservée). Aucune migration SQL.

- **Point de départ** : bouton **« Copier vers… »** dans la barre de sélection de `subnet.php`
  (à côté d'Exporter/Supprimer, [[project_mass_export_delete]]) → POST host_ids + subnet_id
  vers `public/subnet_copy.php`.
- **`subnet_copy.php`** : 3 étapes dans un fichier, dispatch sur `$_POST['step']`, **ordre 2→1→0
  avec `exit`** (l'ownership check host_id↔source kea_subnet_id + résolution source tourne en
  tête, avant le dispatch → couvre les 3 étapes). requireRole('admin','tech').
  - Étape 0 : dropdown cible (tous subnets sauf source). Étape 1 : remap + analyze + preview +
    bargraph. Étape 2 : garde-fous serveur re-vérifiés, add/update, 1 seul `SyncService::sync()`,
    audit `reservation.copy`, flash, redirect vers cible.

- **`lib/SubnetRemap.php`** (pure) : `remapIp(string $ip, int $srcPrefix, int $dstNet, int $dstPrefix): ?string`
  — « conserver l'hôte, changer le réseau » (`ip & ~srcMask` recollé sur préfixe cible).
  **null si débordement** du masque cible → statut `overflow` (propre à la copie, éditable).
  **Signature à 4 args** : pas de `$srcNet` (inutile — seul le préfixe source sert au masque).
  Gateway/route-gw remappées **uniquement si internes au réseau source** (`ipInCidr`), sinon
  inchangées ; `dest` d'une route jamais touché.

- **`lib/ReservationImportAnalyzer.php`** : `analyzeRows`/`normalizeMac`/`inPool` **extraits de
  `subnet_import.php`** (classe pure, testable). Consommée par l'import CSV **et** la copie.
  `require ReservedIps.php` en **lazy** (dans la méthode, pas file-top — convention codebase).
  Statuts : clean/skip/ip_conflict/mac_conflict/invalid/pool/**reserved** (+ overflow posé hors
  analyzer par la copie). Le statut **`reserved`** ([[reserved_ips]]) corrige un bug préexistant
  de l'import (une IP réservée passait `clean` et était créée en silence). Éditable dans import
  ET copie.

- **Résolution mac_conflict en copie** (la MAC existe déjà dans le cible) : select **keep**
  (défaut, ignore) / **update** (`Reservation::update` sur la résa cible). Pas de custom_ip
  (même MAC = collision). **Piège corrigé** : le garde-fou doublon IP indexe `$existingIps[ip]=host_id`
  et ne bloque que si l'IP est détenue par une **autre** résa (sinon une MAJ no-op était refusée).
  En import, le mac_conflict garde ses 3 options historiques (update/keep/custom_ip).

- **Bargraph live** ([[bargraph_component]]) : `subnet-bargraph.js` gagne un **5e type `pending`**
  (bleu clair hachuré, non cliquable, fusionne qu'avec pending) et les callbacks `onHoleClick(ip)`
  + `onDraw()` (appelé après chaque draw réel — sert à ré-attacher la capture clic-trou, évite
  une race au premier paint 0px). `parseReserved` est **exporté**.
  - **`public/assets/js/subnet-copy.js`** (partagé copie + import mono) : `collectPendingRows()`
    lit le tableau → blocs pending live (par-dessus `EXISTING_ROWS` = réservations du cible).
    **Clic-trou → champ IP actif** : cible = `.ip-field` qui a le focus au `mousedown` (avant le
    blur), sinon hint. Flash vert `.ip-filled-flash`. Aucun état persistant.
  - **Feedback live (`recolor()`, sync — pas de debounce, appelé aux mêmes déclencheurs que le
    bargraph)** : chaque ligne porte une classe visuelle **`vis-ok`/`vis-conflict`/`vis-ignored`**
    (fond plein via `--bs-table-bg > td` : vert `#d7f0df` / rouge `#ff9999` / gris `#e4e6e8` —
    Bootstrap recouvre un `background` posé sur le `<tr>`, d'où `--bs-table-bg`), **découplée** du
    `status-*` d'origine (conservé dans le hidden `[status]`). Règles : décochée (case active) →
    gris ; conflit dont l'IP ressaisie devient valide → vert ; sinon couleur d'origine. Le **texte
    de statut** de la ligne suit aussi (`.status-note`, `data-note` = note serveur d'origine :
    « Prête à créer »/« Prête (mise à jour) »/« Ignorée (décochée) »/note d'origine). Les 3
    **compteurs d'en-tête** (`#cnt-ok/conflict/ignored`) sont recalculés par `recolor()`.
  - **`ipValidForPreview(ip, ownHostId)`** : format + bornes + hors pools + hors réservé + **non
    déjà prise par une réservation existante** (`EXISTING_BY_IP` = ip_int→host_id ; `ownHostId`
    exclut l'IP propre d'un conflit MAC en MAJ). Validation optimiste d'affichage — la barrière
    qui fait foi reste le garde-fou serveur.
  - **Infobulle bargraph = singleton `sharedTip()`** (créée 1×, réutilisée par chaque `init()`,
    masquée à chaque reconstruction). Piège corrigé : avant, chaque re-render créait un nouveau
    `<div sbg-tip>` jamais retiré, et l'ancien restait affiché quand son segment était détruit
    sans `mouseleave` (ex. après un clic-trou) → tooltips accumulés / figés.

- **Bargraph sur l'import** : uniquement en **mono-subnet** (`!$multiMode`), pas en global.
  Réemploi de subnet-copy.js via classes **additives** (`copy-row` sur `<tr>`, `ip-field` sur les
  inputs new_ip — sans retirer `new-ip-field`/`mac-action-sel`). `collectPendingRows` tolère
  l'absence de case `[do]` (actif par défaut) et le select import `[action]`.

- **`lib/sbg_helpers.php`** : `buildBargraphSubnet($subnet)` extrait de subnet.php, partagé par
  subnet.php / subnet_copy.php / import mono.

**Format routes en copie** : `routes_raw` = `"dest - gw, dest - gw"` (format d'affichage propre à
la copie, re-parsé par `copyParseRoutesRaw`), **DIFFÉRENT** du format CSV pipe/dash de l'import.
Helpers locaux `copyRowToData()` / `copyParseRoutesRaw()` dans subnet_copy.php.

**Vérif E2E** ([[reference_test_db]]) : remap réel ADSL /24 → fake2 /24 conserve l'hôte
(.10→.10, .126→.126), toutes IP dans le CIDR cible, analyzer classe `clean`. Convention int IP
identique partout : `(int)sprintf('%u', ip2long($ip))` == JS `>>> 0`.
