diff --git a/admin/_nav.php b/admin/_nav.php
new file mode 100644
index 0000000..818ed52
--- /dev/null
+++ b/admin/_nav.php
@@ -0,0 +1,78 @@
+ $activePage === $page ? 'active' : '';
+ ?>
+
+
+
NetMonster Admin
+
+ Ouvrir le menu
+
+
+ Accueil
+
+ Données ▾
+
+
+
+ Import & validation ▾
+
+
+
+ Configuration ▾
+
+
+
+ Suivi & sécurité ▾
+
+
+
+
Connecté : = htmlspecialchars($user, ENT_QUOTES, 'UTF-8') ?>
+
+
+ true exige HTTPS. Si vous testez en HTTP pur
+ // sur une VM sans certificat, passez temporairement cette valeur a
+ // false, sinon le cookie de session ne sera jamais transmis et la
+ // connexion semblera "ne pas fonctionner" (boucle de redirection vers
+ // login.php). Remettez-le a true avant la mise en production reelle.
+ session_set_cookie_params([
+ 'lifetime' => 0,
+ 'path' => '/admin',
+ 'secure' => true,
+ 'httponly' => true,
+ 'samesite' => 'Strict',
+ ]);
+ session_start();
+}
+
+require_once __DIR__ . '/../lib/csrf.php';
+
+function require_admin_auth(): void
+{
+ if (empty($_SESSION['admin_user_id'])) {
+ header('Location: /admin/login.php');
+ exit;
+ }
+
+ if (!empty($_SESSION['last_activity']) && (time() - $_SESSION['last_activity']) > SESSION_IDLE_TIMEOUT) {
+ session_unset();
+ session_destroy();
+ header('Location: /admin/login.php?timeout=1');
+ exit;
+ }
+ $_SESSION['last_activity'] = time();
+}
+
+function current_admin_username(): string
+{
+ return $_SESSION['admin_username'] ?? 'unknown';
+}
+
+/**
+ * IP du visiteur, lue exclusivement depuis REMOTE_ADDR. Ne fait
+ * volontairement pas confiance a X-Forwarded-For : sur cette
+ * installation, Apache recoit les connexions directement (ou via
+ * Tailscale), sans reverse proxy declare qui nettoierait cet en-tete.
+ * Un client pourrait sinon forger cet en-tete pour usurper une IP et
+ * contourner le rate limiting.
+ */
+function client_ip_address(): string
+{
+ return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
+}
+
+function normalize_login_identifier(string $identifier): string
+{
+ return mb_strtolower(trim($identifier));
+}
+
+/**
+ * Verifie si l'identifiant+IP OU l'IP seule ont depasse leur seuil
+ * respectif d'echecs sur la fenetre glissante. Stocke en base, donc
+ * non contournable en changeant de session/cookie cote client.
+ */
+function is_login_locked_out(PDO $db, string $identifier): bool
+{
+ $normalized = normalize_login_identifier($identifier);
+ $ip = client_ip_address();
+ $windowStart = date('Y-m-d H:i:s', time() - LOGIN_ATTEMPTS_WINDOW_SECONDS);
+
+ // Seuil cible : ce compte precis, depuis cette IP precise.
+ $accountStmt = $db->prepare(
+ 'SELECT COUNT(*), MAX(attempted_at) FROM login_attempts
+ WHERE username_normalized = ? AND ip_address = ?
+ AND success = 0 AND attempted_at >= ?'
+ );
+ $accountStmt->execute([$normalized, $ip, $windowStart]);
+ [$accountFailures, $accountLastAttempt] = $accountStmt->fetch(PDO::FETCH_NUM);
+
+ if ((int)$accountFailures >= MAX_LOGIN_ATTEMPTS_PER_ACCOUNT
+ && $accountLastAttempt !== null
+ && (time() - strtotime($accountLastAttempt)) < LOCKOUT_SECONDS_PER_ACCOUNT
+ ) {
+ return true;
+ }
+
+ // Seuil large : toute cette IP, tous identifiants confondus.
+ $ipStmt = $db->prepare(
+ 'SELECT COUNT(*), MAX(attempted_at) FROM login_attempts
+ WHERE ip_address = ? AND success = 0 AND attempted_at >= ?'
+ );
+ $ipStmt->execute([$ip, $windowStart]);
+ [$ipFailures, $ipLastAttempt] = $ipStmt->fetch(PDO::FETCH_NUM);
+
+ if ((int)$ipFailures >= MAX_LOGIN_ATTEMPTS_PER_IP
+ && $ipLastAttempt !== null
+ && (time() - strtotime($ipLastAttempt)) < LOCKOUT_SECONDS_PER_IP
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+function record_login_attempt(PDO $db, string $identifier, bool $success, bool $turnstileVerified = false): void
+{
+ $stmt = $db->prepare(
+ 'INSERT INTO login_attempts (username_normalized, ip_address, success, turnstile_verified)
+ VALUES (?, ?, ?, ?)'
+ );
+ $stmt->execute([
+ normalize_login_identifier($identifier),
+ client_ip_address(),
+ $success ? 1 : 0,
+ $turnstileVerified ? 1 : 0,
+ ]);
+}
+
+function attempt_admin_login(PDO $db, string $username, string $password): bool
+{
+ $stmt = $db->prepare('SELECT id, username, password_hash FROM admin_users WHERE username = ?');
+ $stmt->execute([$username]);
+ $user = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if ($user && password_verify($password, $user['password_hash'])) {
+ $_SESSION['admin_user_id'] = $user['id'];
+ $_SESSION['admin_username'] = $user['username'];
+ $_SESSION['last_activity'] = time();
+ session_regenerate_id(true);
+ return true;
+ }
+
+ return false;
+}
\ No newline at end of file
diff --git a/admin/audit_log.php b/admin/audit_log.php
new file mode 100644
index 0000000..4ee5078
--- /dev/null
+++ b/admin/audit_log.php
@@ -0,0 +1,169 @@
+= ?';
+ $params[] = $dateFrom . ' 00:00:00';
+}
+if ($dateTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
+ $where[] = 'created_at <= ?';
+ $params[] = $dateTo . ' 23:59:59';
+}
+
+$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
+
+$page = max(1, filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1);
+$perPage = 50;
+$offset = ($page - 1) * $perPage;
+
+$countStmt = $db->prepare("SELECT COUNT(*) FROM audit_log $whereSql");
+$countStmt->execute($params);
+$total = (int)$countStmt->fetchColumn();
+
+$sql = "SELECT id, created_at, admin_username, action, entity_type, entity_id, details
+ FROM audit_log
+ $whereSql
+ ORDER BY created_at DESC
+ LIMIT $perPage OFFSET $offset";
+$stmt = $db->prepare($sql);
+$stmt->execute($params);
+$entries = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+$totalPages = max(1, (int)ceil($total / $perPage));
+$user = current_admin_username();
+
+function build_query_string(array $overrides): string
+{
+ $base = [
+ 'user' => $_GET['user'] ?? '',
+ 'action' => $_GET['action'] ?? '',
+ 'entity_type' => $_GET['entity_type'] ?? '',
+ 'date_from' => $_GET['date_from'] ?? '',
+ 'date_to' => $_GET['date_to'] ?? '',
+ 'page' => $_GET['page'] ?? 1,
+ ];
+ $merged = array_merge($base, $overrides);
+ return http_build_query($merged);
+}
+?>
+
+
+
+
+ Sauvegardes de la base
+ Cette page n'execute aucune commande sur le serveur. Elle fournit une commande prete a l'emploi et journalise les sauvegardes realisees manuellement ou par cron.
+
+
+
+ = htmlspecialchars($feedback, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+ Commande de sauvegarde recommandee
+ A executer sur le serveur, avec un fichier .my.cnf contenant les identifiants (jamais dans ce fichier PHP ni dans un depot Git) :
+ = htmlspecialchars($dumpCommand, ENT_QUOTES, 'UTF-8') ?>
+
+ Commande de restauration
+ = htmlspecialchars($restoreCommand, ENT_QUOTES, 'UTF-8') ?>
+
+ Recommandations :
+
+ Stockez .my.cnf avec les permissions 600, proprietaire du compte de service uniquement.
+ Chiffrez l'archive si elle est copiee hors du serveur (ex. gpg --symmetric).
+ Automatisez via un timer systemd ou une tache cron dediee, jamais depuis cette interface web.
+ Testez regulierement la restauration sur un environnement isole.
+
+
+
+
+ Journaliser une sauvegarde effectuee
+
+
+
+
+ Historique des sauvegardes journalisees
+
+
+ Date
+ Utilisateur
+ Libelle
+ Taille
+ Note
+
+
+
+ = htmlspecialchars((string)$row['created_at'], ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars((string)$row['admin_username'], ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars((string)$row['label'], ENT_QUOTES, 'UTF-8') ?>
+ = $row['size_bytes_reported'] !== null ? number_format((float)$row['size_bytes_reported'], 0, ',', ' ') . ' o' : '—' ?>
+ = htmlspecialchars((string)$row['note'], ENT_QUOTES, 'UTF-8') ?>
+
+
+
+ Aucune sauvegarde journalisee.
+
+
+
+
+
+
\ No newline at end of file
diff --git a/admin/cellmapper_import.php b/admin/cellmapper_import.php
new file mode 100644
index 0000000..1bfa802
--- /dev/null
+++ b/admin/cellmapper_import.php
@@ -0,0 +1,619 @@
+ 'B1 (2100 MHz)',
+ 'B2' => 'B2 (1900 MHz)',
+ 'B3' => 'B3 (1800 MHz)',
+ 'B4' => 'B4 (AWS)',
+ 'B5' => 'B5 (850 MHz)',
+ 'B7' => 'B7 (2600 MHz)',
+ 'B8' => 'B8 (900 MHz)',
+ 'B20' => 'B20 (800 MHz)',
+ 'B28' => 'B28 (700 MHz)',
+ 'B38' => 'B38 (TDD 2600)',
+ 'B40' => 'B40 (TDD 2300)',
+ 'B41' => 'B41 (TDD 2500)',
+];
+
+/**
+ * Recupere les mappings bande->EARFCN pour un operateur donne.
+ * Retourne un tableau associatif ['B20' => ['start'=>6200,'end'=>6349], ...]
+ */
+function getOperatorBandMappings(PDO $db, int $operatorId): array
+{
+ $stmt = $db->prepare('SELECT band, earfcn_start, earfcn_end FROM operator_band_mappings WHERE operator_id = ?');
+ $stmt->execute([$operatorId]);
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ $mappings = [];
+ foreach ($rows as $row) {
+ $mappings[$row['band']] = [
+ 'start' => (int)$row['earfcn_start'],
+ 'end' => (int)$row['earfcn_end'],
+ ];
+ }
+ return $mappings;
+}
+
+/**
+ * Deduit l'EARFCN pour une bande donnee selon le mapping operateur.
+ * Retourne l'EARFCN de debut de plage (valeur typique utilisee par l'operateur).
+ */
+function getEarfcnForBand(array $mappings, string $band): ?int
+{
+ if (!isset($mappings[$band])) {
+ return null;
+ }
+ return $mappings[$band]['start'];
+}
+
+/**
+ * Convertit un EARFCN LTE en numero de bande 3GPP (fallback si pas de mapping operateur).
+ */
+function earfcnToBand(int $earfcn, string $tech): ?string
+{
+ if ($tech !== '4G') {
+ return null;
+ }
+ static $ranges = [
+ [0, 599, 'B1'], [600, 1199, 'B2'], [1200, 1949, 'B3'], [1950, 2399, 'B4'],
+ [2400, 2649, 'B5'], [2650, 2749, 'B6'], [2750, 3449, 'B7'], [3450, 3799, 'B8'],
+ [3800, 4149, 'B9'], [4150, 4749, 'B10'], [4750, 4949, 'B11'], [5010, 5179, 'B12'],
+ [5180, 5279, 'B13'], [5280, 5379, 'B14'], [5730, 5849, 'B18'], [5850, 5999, 'B19'],
+ [6000, 6149, 'B20'], [6150, 6449, 'B21'], [6450, 6599, 'B22'], [6600, 7399, 'B23'],
+ [7500, 7699, 'B24'], [7700, 8039, 'B25'], [8040, 8689, 'B26'], [8690, 9039, 'B27'],
+ [9040, 9209, 'B28'], [9210, 9659, 'B29'], [9660, 9769, 'B30'], [9770, 9869, 'B31'],
+ [9870, 10359, 'B32'],
+ [36000, 36199, 'B33'], [36200, 36349, 'B34'], [36350, 36949, 'B35'],
+ [36950, 37549, 'B36'], [37550, 37749, 'B37'], [37750, 38249, 'B38'],
+ [38250, 38649, 'B39'], [38650, 39649, 'B40'], [39650, 41589, 'B41'],
+ [41590, 43589, 'B42'], [43590, 45589, 'B43'], [45590, 46589, 'B44'],
+ [46590, 46789, 'B45'], [46790, 54539, 'B46'], [54540, 55239, 'B47'],
+ [55240, 56739, 'B48'], [65536, 66435, 'B65'], [66436, 67335, 'B66'],
+ [67336, 67535, 'B67'], [67536, 67835, 'B68'], [67836, 68335, 'B69'],
+ [68336, 68585, 'B70'], [68586, 68935, 'B71'], [68936, 68985, 'B72'],
+ [68986, 69035, 'B73'], [69036, 69465, 'B74'], [69466, 70315, 'B75'],
+ [70316, 70365, 'B76'], [70366, 70545, 'B85'],
+ ];
+ foreach ($ranges as [$lo, $hi, $band]) {
+ if ($earfcn >= $lo && $earfcn <= $hi) {
+ return $band;
+ }
+ }
+ return null;
+}
+
+function ratToTech(string $rat): ?string
+{
+ $rat = strtoupper(trim($rat));
+ return match (true) {
+ str_contains($rat, 'NR') => '5G',
+ str_contains($rat, 'LTE') => '4G',
+ str_contains($rat, 'UMTS'), str_contains($rat, 'HSPA'), str_contains($rat, 'WCDMA') => '3G',
+ str_contains($rat, 'GSM'), str_contains($rat, 'EDGE'), str_contains($rat, 'GPRS') => '2G',
+ str_contains($rat, 'CDMA') => 'CDMA',
+ default => null,
+ };
+}
+
+function parseCellMapperJson(string $json): array
+{
+ $decoded = json_decode($json, true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ return [null, [], 'JSON invalide : ' . json_last_error_msg()];
+ }
+
+ $data = $decoded['responseData'] ?? $decoded;
+ if (!is_array($data)) {
+ return [null, [], 'Structure JSON inattendue : cle "responseData" absente ou invalide.'];
+ }
+
+ $rat = (string)($data['RAT'] ?? $data['RATSubType'] ?? '');
+ $tech = ratToTech($rat);
+ if ($tech === null) {
+ return [null, [], "Technologie radio non reconnue (RAT=\"$rat\")."];
+ }
+
+ $siteId = $data['siteID'] ?? null;
+ if ($siteId === null || !is_numeric($siteId)) {
+ return [null, [], 'Champ "siteID" (eNB) absent ou invalide dans le JSON.'];
+ }
+ $enb = (int)$siteId;
+
+ $regionId = $data['regionID'] ?? null;
+ $areaCode = ($regionId !== null && is_numeric($regionId)) ? (int)$regionId : null;
+
+ $provider = $data['Provider'] ?? [];
+ $providerId = $provider['providerID'] ?? null;
+
+ $cellsRaw = $data['cells'] ?? [];
+ if (!is_array($cellsRaw) || empty($cellsRaw)) {
+ return [null, [], 'Aucune cellule trouvee dans le champ "cells" du JSON.'];
+ }
+
+ $channels = $data['channels'] ?? [];
+ $channels = is_array($channels) ? array_values($channels) : [];
+
+ $entries = [];
+ foreach ($cellsRaw as $internalId => $cell) {
+ if (!is_array($cell) || !isset($cell['Sector'])) {
+ continue;
+ }
+ $entries[] = $cell;
+ }
+ usort($entries, static fn($a, $b) => ((int)$a['Sector']) <=> ((int)$b['Sector']));
+
+ $meta = [
+ 'tech' => $tech,
+ 'enb' => $enb,
+ 'area_code' => $areaCode,
+ 'provider_id' => $providerId !== null ? (int)$providerId : null,
+ 'site_id_raw' => (string)$siteId,
+ ];
+
+ $cells = [];
+ foreach ($entries as $index => $cell) {
+ $cellId = (int)$cell['Sector'];
+ $pci = isset($cell['PCI']) && is_numeric($cell['PCI']) ? (int)$cell['PCI'] : null;
+
+ $earfcn = null;
+ if (isset($channels[$index]) && is_numeric($channels[$index])) {
+ $earfcn = (int)$channels[$index];
+ }
+
+ $bandwidth = null;
+ if (isset($cell['Bandwidth']) && is_numeric($cell['Bandwidth'])) {
+ $bandwidth = ((int)$cell['Bandwidth']) . 'MHz';
+ }
+
+ $band = $earfcn !== null ? earfcnToBand($earfcn, $tech) : null;
+
+ $cells[] = [
+ 'cell_id' => $cellId,
+ 'pci' => $pci,
+ 'arfcn' => $earfcn,
+ 'band' => $band,
+ 'bandwidth' => $bandwidth,
+ ];
+ }
+
+ return [$meta, $cells, null];
+}
+
+function findStagingCellsForEnb(PDO $db, string $tech, string $mcc, string $mnc, int $enb): array
+{
+ $stmt = $db->prepare(
+ 'SELECT id, cell_id, sub_id, arfcn, band, bandwidth, area_code, status
+ FROM staging_cells
+ WHERE tech = ? AND mcc = ? AND mnc = ? AND sub_id2 = ?'
+ );
+ $stmt->execute([$tech, $mcc, $mnc, $enb]);
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ $byCellId = [];
+ foreach ($rows as $row) {
+ $byCellId[(int)$row['cell_id']] = $row;
+ }
+ return $byCellId;
+}
+
+// Traitement du formulaire
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ require_csrf_token();
+
+ $mcc = trim((string)($_POST['mcc'] ?? ''));
+ $mnc = trim((string)($_POST['mnc'] ?? ''));
+ $operatorId = (int)($_POST['operator_id'] ?? 0);
+ $selectedBand = trim((string)($_POST['band'] ?? ''));
+ $jsonInput = trim((string)($_POST['json_input'] ?? ''));
+ $uploadedFile = $_FILES['json_file'] ?? null;
+
+ if ($uploadedFile && $uploadedFile['error'] === UPLOAD_ERR_OK) {
+ if (!is_uploaded_file($uploadedFile['tmp_name'])) {
+ $errorMessage = 'Fichier invalide (upload non reconnu comme legitime).';
+ } elseif ($uploadedFile['size'] > MAX_JSON_SIZE_BYTES) {
+ $errorMessage = 'Le fichier depasse la limite de ' . (MAX_JSON_SIZE_BYTES / 1024 / 1024) . ' Mo.';
+ } else {
+ $jsonInput = (string)file_get_contents($uploadedFile['tmp_name']);
+ }
+ }
+
+ if ($errorMessage === null && $jsonInput === '') {
+ $errorMessage = 'Aucune donnee JSON fournie (ni fichier, ni texte colle).';
+ } elseif ($errorMessage === null && !preg_match('/^\d{3}$/', $mcc)) {
+ $errorMessage = 'MCC invalide (3 chiffres attendus).';
+ } elseif ($errorMessage === null && !preg_match('/^\d{2,3}$/', $mnc)) {
+ $errorMessage = 'MNC invalide (2 ou 3 chiffres attendus).';
+ } elseif ($errorMessage === null && strlen($jsonInput) > MAX_JSON_SIZE_BYTES) {
+ $errorMessage = 'Le contenu JSON colle depasse la limite de ' . (MAX_JSON_SIZE_BYTES / 1024 / 1024) . ' Mo.';
+ } else {
+ $mcc = str_pad($mcc, 3, '0', STR_PAD_LEFT);
+ $mnc = str_pad($mnc, 2, '0', STR_PAD_LEFT);
+
+ [$meta, $parsedCells, $parseError] = parseCellMapperJson($jsonInput);
+
+ if ($parseError !== null) {
+ $errorMessage = $parseError;
+ } else {
+ $tech = $meta['tech'];
+ $enb = $meta['enb'];
+ $areaCode = $meta['area_code'];
+
+ try {
+ // Si operator_id est fourni, recuperer ses mappings bande/EARFCN
+ $bandMappings = $operatorId > 0 ? getOperatorBandMappings($db, $operatorId) : [];
+
+ // Si une bande est selectionnee, deduire l'EARFCN du mapping operateur
+ $forcedEarfcn = null;
+ $forcedBand = null;
+ if ($selectedBand !== '' && !empty($bandMappings[$selectedBand])) {
+ $forcedBand = strtoupper($selectedBand);
+ $forcedEarfcn = getEarfcnForBand($bandMappings, $forcedBand);
+ }
+
+ $stmt = $db->prepare('SELECT id FROM operators WHERE mcc = ? AND mnc = ?');
+ $stmt->execute([$mcc, $mnc]);
+ $operatorIdFromDb = $stmt->fetchColumn();
+ $operatorIdFromDb = $operatorIdFromDb !== false ? (int)$operatorIdFromDb : null;
+
+ $existingByCellId = findStagingCellsForEnb($db, $tech, $mcc, $mnc, $enb);
+
+ $updateStmt = $db->prepare(
+ 'UPDATE staging_cells
+ SET sub_id = ?, arfcn = ?, band = ?, bandwidth = ?, area_code = COALESCE(area_code, ?)
+ WHERE id = ?'
+ );
+ $insertStmt = $db->prepare(
+ 'INSERT INTO staging_cells
+ (import_id, tech, mcc, mnc, operator_guess, cell_id, area_code, sub_id, sub_id2,
+ band, frequency, azimuth, bandwidth, address_desc,
+ lat, lon, location_desc, arfcn, raw_line, source, possible_dup_of, status)
+ VALUES
+ (NULL, ?, ?, ?, ?, ?, ?, ?, ?,
+ ?, NULL, NULL, ?, NULL,
+ NULL, NULL, ?, ?, ?, "cellmapper_import", NULL, "pending")'
+ );
+
+ $db->beginTransaction();
+
+ $updated = 0;
+ $created = 0;
+ $skippedNoChange = 0;
+ $details = [];
+
+ foreach ($parsedCells as $cell) {
+ $cellId = $cell['cell_id'];
+
+ // Appliquer la bande et l'EARFCN forces si selectionnes
+ $bandToUse = $forcedBand ?? $cell['band'];
+ $arfcnToUse = $forcedEarfcn ?? $cell['arfcn'];
+
+ if (isset($existingByCellId[$cellId])) {
+ $existing = $existingByCellId[$cellId];
+
+ $noChange = (int)($existing['sub_id'] ?? -1) === ($cell['pci'] ?? -1)
+ && (int)($existing['arfcn'] ?? -1) === ($arfcnToUse ?? -1)
+ && ($existing['band'] ?? null) === $bandToUse
+ && ($existing['bandwidth'] ?? null) === $cell['bandwidth']
+ && ($existing['area_code'] !== null || $areaCode === null);
+
+ if ($noChange) {
+ $skippedNoChange++;
+ $details[] = "Cellule $cellId (eNB $enb) : deja a jour, aucune modification.";
+ continue;
+ }
+
+ $updateStmt->execute([
+ $cell['pci'],
+ $arfcnToUse,
+ $bandToUse,
+ $cell['bandwidth'],
+ $areaCode,
+ $existing['id'],
+ ]);
+ $updated++;
+ $tacNote = $existing['area_code'] === null && $areaCode !== null ? ", TAC=$areaCode (complete)" : '';
+ $details[] = "Cellule $cellId (eNB $enb) : mise a jour (PCI={$cell['pci']}, ARFCN=$arfcnToUse, bande=$bandToUse$tacNote).";
+ } else {
+ $rawLine = sprintf(
+ 'cellmapper:enb=%d;cell_id=%d;pci=%s;arfcn=%s;band=%s;bandwidth=%s;tac=%s',
+ $enb,
+ $cellId,
+ $cell['pci'] ?? 'null',
+ $arfcnToUse ?? 'null',
+ $bandToUse ?? 'null',
+ $cell['bandwidth'] ?? 'null',
+ $areaCode ?? 'null'
+ );
+
+ $insertStmt->execute([
+ $tech,
+ $mcc,
+ $mnc,
+ $operatorIdFromDb,
+ $cellId,
+ $areaCode,
+ $cell['pci'],
+ $enb,
+ $bandToUse,
+ $cell['bandwidth'],
+ 'Import CellMapper : coordonnees/azimut/adresse a completer manuellement',
+ $arfcnToUse,
+ $rawLine,
+ ]);
+ $created++;
+ $isNewEnb = empty($existingByCellId);
+ $details[] = "Cellule $cellId (eNB $enb) : creee" . ($isNewEnb ? ' (nouvel eNB, a localiser via cadastre)' : ' (nouvelle cellule sur eNB existant)') . ", TAC=" . ($areaCode ?? 'inconnu') . ".";
+ }
+ }
+
+ $db->commit();
+
+ $importSummary = [
+ 'tech' => $tech,
+ 'enb' => $enb,
+ 'mcc' => $mcc,
+ 'mnc' => $mnc,
+ 'area_code' => $areaCode,
+ 'total_parsed' => count($parsedCells),
+ 'updated' => $updated,
+ 'created' => $created,
+ 'skipped_no_change' => $skippedNoChange,
+ 'details' => $details,
+ ];
+
+ $successMessage = sprintf(
+ 'eNB %d (%s, MCC %s / MNC %s, TAC %s) : %d cellule(s) analysee(s) — %d creee(s), %d mise(s) a jour, %d deja a jour.',
+ $enb,
+ $tech,
+ $mcc,
+ $mnc,
+ $areaCode ?? 'inconnu',
+ count($parsedCells),
+ $created,
+ $updated,
+ $skippedNoChange
+ );
+ } catch (Throwable $e) {
+ if ($db->inTransaction()) {
+ $db->rollBack();
+ }
+ error_log('[cellmapper_import] ' . $e->getMessage());
+ $errorMessage = 'Une erreur est survenue lors du traitement. Aucune donnee n\'a ete modifiee. Consultez les journaux du serveur.';
+ }
+ }
+ }
+}
+
+// Charger les operateurs pour le select
+$operators = $db->query(
+ 'SELECT o.*, c.name AS country_name FROM operators o
+ JOIN countries c ON c.id = o.country_id
+ ORDER BY c.name, o.brand'
+)->fetchAll(PDO::FETCH_ASSOC);
+
+$operatorsByCountry = [];
+foreach ($operators as $op) {
+ $operatorsByCountry[$op['country_id']][] = $op;
+}
+
+// Charger les mappings pour chaque operateur (pour le JS)
+$bandMappingsByOperator = [];
+$mappings = $db->query('SELECT * FROM operator_band_mappings ORDER BY operator_id, band')->fetchAll(PDO::FETCH_ASSOC);
+foreach ($mappings as $m) {
+ $bandMappingsByOperator[$m['operator_id']][] = $m;
+}
+?>
+
+
+
+
+
Import CellMapper (JSON)
+
+ Collez ou televersez l'export JSON d'un site CellMapper (un eNB). Selectionnez la bande 4G pour
+ remplir automatiquement l'EARFCN selon le mapping de l'operateur. Seuls les parametres radio
+ techniques sont extraits (Cell ID, PCI, EARFCN, bande, largeur de bande, TAC).
+
+
+
= htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?>
+
= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?>
+
+
+
Nouvel import
+
+ = csrf_field() ?>
+
+
+ MCC
+
+
+ MNC
+
+
+ Operateur (optionnel, pour mapping EARFCN)
+
+ -- selectionner --
+
+
+ = htmlspecialchars($op['brand'], ENT_QUOTES, 'UTF-8') ?> (= htmlspecialchars($op['country_name'], ENT_QUOTES, 'UTF-8') ?>)
+
+
+
+
+
+
+
+ Bande 4G (optionnel, remplit EARFCN auto)
+
+ -- deduire du JSON ou laisser vide --
+ $bandLabel): ?>
+ = htmlspecialchars($bandLabel, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+ EARFCN (rempli auto si bande + operateur selectionnes)
+
+
+
+
+
+
+ MCC/MNC ne sont pas presents dans le JSON CellMapper : ils doivent etre saisis manuellement.
+ Si vous selectionnez un operateur et une bande, l'EARFCN sera rempli automatiquement selon
+ le mapping configure dans countries_settings.php.
+
+
+
+
Selectionnez un fichier .json (taille maximale : 2 Mo)
+
+
+
+ ou collez le JSON ici
+ = htmlspecialchars((string)($_POST['json_input'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
+
+
+ Analyser et integrer en staging
+
+
+
+
+
+
Resultat de l'import — eNB = (int)$importSummary['enb'] ?> (= htmlspecialchars($importSummary['tech'], ENT_QUOTES, 'UTF-8') ?>) — TAC = htmlspecialchars((string)($importSummary['area_code'] ?? 'inconnu'), ENT_QUOTES, 'UTF-8') ?>
+
+
= (int)$importSummary['total_parsed'] ?> cellule(s) analysee(s)
+
= (int)$importSummary['created'] ?> creee(s)
+
= (int)$importSummary['updated'] ?> mise(s) a jour
+
= (int)$importSummary['skipped_no_change'] ?> deja a jour (ignoree(s))
+
+
+
Detail
+
+
= htmlspecialchars($line, ENT_QUOTES, 'UTF-8') ?>
+
+
+
Aller a la page de validation →
+
+
+
+
+
Rappel des regles appliquees
+
+ Correspondance stricte sur (tech, mcc, mnc, eNB) : jamais de melange entre operateurs differents.
+ Le TAC/area_code provient de regionID et n'est utilise que pour completer les lignes ou il est absent.
+ Cellule deja en staging avec le meme Cell ID → mise a jour des seuls champs radio (PCI, EARFCN, bande, largeur de bande, TAC si manquant).
+ Cellule non presente sur un eNB deja en staging → ajout.
+ eNB totalement absent du staging → ajout quand meme, a localiser ensuite via le cadastre.
+ Coordonnees, azimut et adresse ne sont jamais lus depuis CellMapper ni ecrases par cet import.
+ Si une bande est selectionnee, l'EARFCN est deduit du mapping operateur (sinon, deduction depuis le JSON).
+
+
+
+
+
+
+
\ No newline at end of file