From 51c49327d41ae1184046f81143dc0f5cd9ca1e66 Mon Sep 17 00:00:00 2001 From: Voxan Date: Wed, 21 Oct 2026 22:00:18 +0200 Subject: [PATCH] Import --- admin/countries_settings.php | 474 +++++++++++++++++++++++++++++++++++ admin/data_quality.php | 258 +++++++++++++++++++ admin/duplicates.php | 255 +++++++++++++++++++ admin/edit_site.php | 454 +++++++++++++++++++++++++++++++++ admin/export.php | 239 ++++++++++++++++++ 5 files changed, 1680 insertions(+) create mode 100644 admin/countries_settings.php create mode 100644 admin/data_quality.php create mode 100644 admin/duplicates.php create mode 100644 admin/edit_site.php create mode 100644 admin/export.php diff --git a/admin/countries_settings.php b/admin/countries_settings.php new file mode 100644 index 0000000..658e7d7 --- /dev/null +++ b/admin/countries_settings.php @@ -0,0 +1,474 @@ + EARFCN par operateur. + * + * Cette version inclut la gestion des plages EARFCN par bande pour chaque + * operateur, car les operateurs utilisent des valeurs differentes meme + * sur la meme bande (ex: POST B20 != Tango B20). + */ +declare(strict_types=1); + +require_once __DIR__ . '/admin_auth.php'; +require_once __DIR__ . '/../lib/db.php'; +require_once __DIR__ . '/_nav.php'; + +require_admin_auth(); +$db = pdo(); +$user = current_admin_username(); +$successMessage = null; +$errorMessage = null; + +function nullable_float(array $input, string $key): ?float +{ + $value = trim((string)($input[$key] ?? '')); + if ($value === '') { + return null; + } + if (!is_numeric($value)) { + throw new InvalidArgumentException("$key doit etre un nombre."); + } + return (float)$value; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_csrf_token(); + $action = $_POST['action'] ?? ''; + + try { + if ($action === 'save_country') { + $countryId = (int)($_POST['country_id'] ?? 0); + $mcc = trim((string)($_POST['mcc'] ?? '')); + $iso2 = strtoupper(trim((string)($_POST['iso2'] ?? ''))); + $name = trim((string)($_POST['name'] ?? '')); + $lat = nullable_float($_POST, 'default_lat'); + $lon = nullable_float($_POST, 'default_lon'); + $zoomRaw = trim((string)($_POST['default_zoom'] ?? '')); + $zoom = $zoomRaw === '' ? 9 : (int)$zoomRaw; + $wmsUrl = trim((string)($_POST['cadastre_wms_url'] ?? '')) ?: null; + $wmsLayer = trim((string)($_POST['cadastre_wms_layer'] ?? '')) ?: null; + $attribution = trim((string)($_POST['cadastre_attribution_html'] ?? '')) ?: null; + $srs = trim((string)($_POST['cadastre_srs'] ?? '')) ?: 'EPSG:3857'; + + if (!preg_match('/^\d{3}$/', $mcc)) { + throw new InvalidArgumentException('MCC : 3 chiffres obligatoires.'); + } + if (!preg_match('/^[A-Z]{2}$/', $iso2)) { + throw new InvalidArgumentException('ISO2 : 2 lettres obligatoires.'); + } + if ($name === '') { + throw new InvalidArgumentException('Le nom du pays est obligatoire.'); + } + if (($lat === null) !== ($lon === null)) { + throw new InvalidArgumentException('Saisissez latitude et longitude ensemble, ou laissez les deux champs vides.'); + } + if ($lat !== null && ($lat < -90 || $lat > 90)) { + throw new InvalidArgumentException('La latitude doit etre comprise entre -90 et 90.'); + } + if ($lon !== null && ($lon < -180 || $lon > 180)) { + throw new InvalidArgumentException('La longitude doit etre comprise entre -180 et 180.'); + } + if ($zoom < 1 || $zoom > 19) { + throw new InvalidArgumentException('Le zoom doit etre compris entre 1 et 19.'); + } + + if ($countryId > 0) { + $stmt = $db->prepare( + 'UPDATE countries + SET mcc = ?, iso2 = ?, name = ?, default_lat = ?, default_lon = ?, default_zoom = ?, + cadastre_wms_url = ?, cadastre_wms_layer = ?, + cadastre_attribution_html = ?, cadastre_srs = ? + WHERE id = ?' + ); + $stmt->execute([$mcc, $iso2, $name, $lat, $lon, $zoom, $wmsUrl, $wmsLayer, $attribution, $srs, $countryId]); + if ($stmt->rowCount() === 0) { + $exists = $db->prepare('SELECT 1 FROM countries WHERE id = ?'); + $exists->execute([$countryId]); + if (!$exists->fetchColumn()) { + throw new RuntimeException('Pays introuvable.'); + } + } + $successMessage = "Pays \"$name\" mis a jour."; + } else { + $stmt = $db->prepare( + 'INSERT INTO countries + (mcc, iso2, name, default_lat, default_lon, default_zoom, + cadastre_wms_url, cadastre_wms_layer, cadastre_attribution_html, cadastre_srs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([$mcc, $iso2, $name, $lat, $lon, $zoom, $wmsUrl, $wmsLayer, $attribution, $srs]); + $successMessage = "Pays \"$name\" cree."; + } + } elseif ($action === 'delete_country') { + $countryId = (int)($_POST['country_id'] ?? 0); + if ($countryId <= 0) { + throw new InvalidArgumentException('Pays invalide.'); + } + $check = $db->prepare('SELECT COUNT(*) FROM operators WHERE country_id = ?'); + $check->execute([$countryId]); + if ((int)$check->fetchColumn() > 0) { + throw new RuntimeException('Des operateurs sont rattaches a ce pays : supprimez-les ou reaffectez-les d\'abord.'); + } + $db->prepare('DELETE FROM countries WHERE id = ?')->execute([$countryId]); + $successMessage = 'Pays supprime.'; + } elseif ($action === 'save_operator') { + $operatorId = (int)($_POST['operator_id'] ?? 0); + $countryId = (int)($_POST['country_id'] ?? 0); + $mnc = str_pad(trim((string)($_POST['mnc'] ?? '')), 2, '0', STR_PAD_LEFT); + $brand = trim((string)($_POST['brand'] ?? '')); + $fullName = trim((string)($_POST['full_name'] ?? '')) ?: null; + + if ($countryId <= 0 || !preg_match('/^\d{2,3}$/', $mnc) || $brand === '') { + throw new InvalidArgumentException('Pays, MNC (2 ou 3 chiffres) et nom commercial sont obligatoires.'); + } + $mccStmt = $db->prepare('SELECT mcc FROM countries WHERE id = ?'); + $mccStmt->execute([$countryId]); + $mcc = $mccStmt->fetchColumn(); + if ($mcc === false) { + throw new InvalidArgumentException('Pays invalide.'); + } + + if ($operatorId > 0) { + $stmt = $db->prepare('UPDATE operators SET country_id = ?, mcc = ?, mnc = ?, brand = ?, full_name = ? WHERE id = ?'); + $stmt->execute([$countryId, $mcc, $mnc, $brand, $fullName, $operatorId]); + $successMessage = "Operateur \"$brand\" mis a jour."; + } else { + $stmt = $db->prepare('INSERT INTO operators (country_id, mcc, mnc, brand, full_name) VALUES (?, ?, ?, ?, ?)'); + $stmt->execute([$countryId, $mcc, $mnc, $brand, $fullName]); + $operatorId = (int)$db->lastInsertId(); + $successMessage = "Operateur \"$brand\" ajoute."; + } + } elseif ($action === 'delete_operator') { + $operatorId = (int)($_POST['operator_id'] ?? 0); + if ($operatorId <= 0) { + throw new InvalidArgumentException('Operateur invalide.'); + } + $check = $db->prepare('SELECT COUNT(*) FROM sites WHERE operator_id = ?'); + $check->execute([$operatorId]); + if ((int)$check->fetchColumn() > 0) { + throw new RuntimeException('Des sites valides existent pour cet operateur.'); + } + $db->prepare('DELETE FROM operators WHERE id = ?')->execute([$operatorId]); + $successMessage = 'Operateur supprime.'; + } elseif ($action === 'save_band_mapping') { + $operatorId = (int)($_POST['operator_id'] ?? 0); + $band = trim((string)($_POST['band'] ?? '')); + $earfcnStart = (int)($_POST['earfcn_start'] ?? 0); + $earfcnEnd = (int)($_POST['earfcn_end'] ?? 0); + + if ($operatorId <= 0 || !preg_match('/^B\d+[a-z]?$/i', $band)) { + throw new InvalidArgumentException('Operateur et bande (ex: B20, B3, B7) obligatoires.'); + } + if ($earfcnStart <= 0 || $earfcnEnd < $earfcnStart) { + throw new InvalidArgumentException('Plage EARFCN invalide (start > 0, end >= start).'); + } + + $stmt = $db->prepare( + 'INSERT INTO operator_band_mappings (operator_id, band, earfcn_start, earfcn_end) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE earfcn_start = VALUES(earfcn_start), earfcn_end = VALUES(earfcn_end)' + ); + $stmt->execute([$operatorId, strtoupper($band), $earfcnStart, $earfcnEnd]); + $successMessage = "Mapping bande $band enregistre pour l'operateur."; + } elseif ($action === 'delete_band_mapping') { + $mappingId = (int)($_POST['mapping_id'] ?? 0); + if ($mappingId <= 0) { + throw new InvalidArgumentException('Mapping invalide.'); + } + $db->prepare('DELETE FROM operator_band_mappings WHERE id = ?')->execute([$mappingId]); + $successMessage = 'Mapping supprime.'; + } + } catch (InvalidArgumentException|RuntimeException $e) { + $errorMessage = $e->getMessage(); + } catch (PDOException $e) { + error_log('[countries_settings] PDOException: ' . $e->getMessage()); + $errorMessage = str_contains($e->getMessage(), 'Duplicate') + ? 'Ce couple MCC/MNC ou ce mapping bande existe deja.' + : 'Une erreur est survenue. Consultez les journaux du serveur si le probleme persiste.'; + } +} + +$countries = $db->query('SELECT * FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC); +$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 bande/EARFCN pour chaque operateur +$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; +} + +$editingCountry = null; +$editCountryId = filter_input(INPUT_GET, 'edit_country', FILTER_VALIDATE_INT); +if ($editCountryId) { + foreach ($countries as $country) { + if ((int)$country['id'] === $editCountryId) { + $editingCountry = $country; + break; + } + } +} + +$editingOperator = null; +$editOperatorId = filter_input(INPUT_GET, 'edit_operator', FILTER_VALIDATE_INT); +if ($editOperatorId) { + foreach ($operators as $operator) { + if ((int)$operator['id'] === $editOperatorId) { + $editingOperator = $operator; + break; + } + } +} + +function field_value(?array $source, string $key, string $fallback = ''): string +{ + return htmlspecialchars((string)($source[$key] ?? $fallback), ENT_QUOTES, 'UTF-8'); +} + +$countryFormTitle = $editingCountry ? 'Modifier le pays : ' . field_value($editingCountry, 'name') : 'Ajouter un pays'; +$operatorFormTitle = $editingOperator ? 'Modifier l\'operateur : ' . field_value($editingOperator, 'brand') : 'Ajouter un operateur'; +?> + + + + +NetMonster Admin - Pays & operateurs + + + + + +
+

Configuration des pays & operateurs

+

Configurez les pays, le centre de carte, les couches cadastre, les operateurs et les mappings bande/EARFCN.

+ + +

+ + +

+ + +
+

+
+ + + + +
+ + + +
+ +

Centre de la carte

+
+ + + +
+ +

Cadastre / WMS (optionnel)

+
+ + + + +
+ +
+ Ou trouver une URL WMS ? + Cherchez le portail geographique national et son service WMS public, generalement documente par une requete GetCapabilities. Laissez les champs WMS vides si aucune source ouverte n'est disponible : la carte restera utilisable avec OpenStreetMap. +
+ + + Annuler l'edition + +
+
+ +
+

Pays configures ()

+ + + + + + + + + + + + + + + + +
PaysMCCISO2Centre carteCadastreOperateursActions
+ + configure + + non configure + + + Modifier +
+ + + + +
+
Aucun pays configure.
+
+ +
+

+
+ + + +
+ + + +
+ + + + Annuler l'edition + +
+
+ +
+

Operateurs configures ()

+ + + + + + + + + + + + + + +
PaysBrandMCC-MNCRaison socialeActions
- + Modifier +
+ + + + +
+
Aucun operateur configure.
+
+ +
+

Mappings bande 4G → EARFCN par operateur

+

Configurez les plages EARFCN utilisees par chaque operateur sur chaque bande 4G. Ces valeurs sont utilisees lors de l'import CellMapper pour deduire l'EARFCN a partir de la bande selectionnee.

+ + +
+

(MCC / MNC )

+ +
+ + + +
+ + + + +
+
+ + + + + + + + + + + + +
BandeEARFCN debutEARFCN finActions
+
+ + + + +
+
+ +

Aucun mapping configure pour cet operateur.

+ +
+ + +

Aucun operateur configure. Ajoutez d'abord un operateur ci-dessus.

+ +
+
+ + \ No newline at end of file diff --git a/admin/data_quality.php b/admin/data_quality.php new file mode 100644 index 0000000..2574283 --- /dev/null +++ b/admin/data_quality.php @@ -0,0 +1,258 @@ +query('SELECT id, iso2, name FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC); +$operators = $db->query('SELECT id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC); + +$siteWhere = ' WHERE 1=1 '; +$siteParams = []; +if ($countryFilter !== '') { + $siteWhere .= ' AND c.iso2 = ?'; + $siteParams[] = $countryFilter; +} +if ($operatorFilter !== '') { + $siteWhere .= ' AND o.id = ?'; + $siteParams[] = (int)$operatorFilter; +} + +$siteSql = ' + SELECT s.id AS site_id, s.lat, s.lon, s.location_desc, s.address_desc, + o.brand AS operator_brand, o.mcc AS operator_mcc, o.mnc AS operator_mnc, + c.name AS country_name, COUNT(cl.id) AS cell_count + FROM sites s + JOIN operators o ON o.id = s.operator_id + JOIN countries c ON c.id = s.country_id + LEFT JOIN cells cl ON cl.site_id = s.id +' . $siteWhere . ' + GROUP BY s.id, s.lat, s.lon, s.location_desc, s.address_desc, + o.brand, o.mcc, o.mnc, c.name + ORDER BY s.id'; + +$siteStmt = $db->prepare($siteSql); +$siteStmt->execute($siteParams); +$siteRows = $siteStmt->fetchAll(PDO::FETCH_ASSOC); + +$cellWhere = ' WHERE 1=1 '; +$cellParams = []; +if ($countryFilter !== '') { + $cellWhere .= ' AND c.iso2 = ?'; + $cellParams[] = $countryFilter; +} +if ($operatorFilter !== '') { + $cellWhere .= ' AND o.id = ?'; + $cellParams[] = (int)$operatorFilter; +} + +$cellSql = ' + SELECT cl.id AS cell_pk, cl.site_id, cl.tech, + cl.mcc AS cell_mcc, cl.mnc AS cell_mnc, + cl.cell_id, cl.area_code, cl.sub_id, cl.sub_id2, + cl.band, cl.frequency, cl.azimuth, cl.bandwidth, cl.arfcn, + o.brand AS operator_brand, o.mcc AS operator_mcc, o.mnc AS operator_mnc, + c.name AS country_name + FROM cells cl + JOIN sites s ON s.id = cl.site_id + JOIN operators o ON o.id = s.operator_id + JOIN countries c ON c.id = s.country_id +' . $cellWhere . ' + ORDER BY cl.site_id, cl.tech, cl.sub_id2, cl.cell_id'; + +$cellStmt = $db->prepare($cellSql); +$cellStmt->execute($cellParams); +$cellRows = $cellStmt->fetchAll(PDO::FETCH_ASSOC); + +$issues = []; + +foreach ($siteRows as $site) { + $siteId = (int)$site['site_id']; + $lat = (float)$site['lat']; + $lon = (float)$site['lon']; + $cellCount = (int)$site['cell_count']; + $location = $site['address_desc'] ?: $site['location_desc']; + + if ($cellCount === 0) { + addIssue($issues, 'site_without_cells', 'warning', [ + 'site_id' => $siteId, 'cell_pk' => null, + 'operator_brand' => $site['operator_brand'], + 'country_name' => $site['country_name'], + 'tech' => null, 'cell_id' => null, 'sub_id2' => null, + 'band' => null, 'frequency' => null, 'azimuth' => null, + 'message' => 'Le site ne contient aucune cellule.', + 'location' => $location, + ]); + } + + if ($lat === 0.0 || $lon === 0.0) { + addIssue($issues, 'invalid_coordinates', 'error', [ + 'site_id' => $siteId, 'cell_pk' => null, + 'operator_brand' => $site['operator_brand'], + 'country_name' => $site['country_name'], + 'tech' => null, 'cell_id' => null, 'sub_id2' => null, + 'band' => null, 'frequency' => null, 'azimuth' => null, + 'message' => 'Latitude ou longitude egale a 0.', + 'location' => $location, + ]); + } + + if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) { + addIssue($issues, 'invalid_coordinates', 'error', [ + 'site_id' => $siteId, 'cell_pk' => null, + 'operator_brand' => $site['operator_brand'], + 'country_name' => $site['country_name'], + 'tech' => null, 'cell_id' => null, 'sub_id2' => null, + 'band' => null, 'frequency' => null, 'azimuth' => null, + 'message' => 'Coordonnees hors des plages GPS valides.', + 'location' => $location, + ]); + } +} + +foreach ($cellRows as $cell) { + $base = [ + 'site_id' => (int)$cell['site_id'], + 'cell_pk' => (int)$cell['cell_pk'], + 'operator_brand' => $cell['operator_brand'], + 'country_name' => $cell['country_name'], + 'tech' => $cell['tech'], + 'cell_id' => $cell['cell_id'], + 'sub_id2' => $cell['sub_id2'], + 'band' => $cell['band'], + 'frequency' => $cell['frequency'], + 'azimuth' => $cell['azimuth'], + 'location' => null, + ]; + + if ($cell['azimuth'] !== null && $cell['azimuth'] !== '') { + $azimuth = (int)$cell['azimuth']; + if ($azimuth < 0 || $azimuth > 359) { + addIssue($issues, 'invalid_azimuth', 'error', $base + [ + 'message' => 'Azimut hors de la plage 0-359 degres.', + ]); + } + } + + if ( + normalizeCode($cell['cell_mcc']) !== normalizeCode($cell['operator_mcc']) + || normalizeCode($cell['cell_mnc']) !== normalizeCode($cell['operator_mnc']) + ) { + addIssue($issues, 'mcc_mnc_mismatch', 'error', $base + [ + 'message' => 'Le MCC/MNC de la cellule ne correspond pas a son operateur.', + ]); + } + + if ( + technologyNeedsNode((string)$cell['tech']) + && ($cell['sub_id2'] === null || trim((string)$cell['sub_id2']) === '') + ) { + addIssue($issues, 'missing_enb', 'warning', $base + [ + 'message' => 'eNB/RNC absent pour cette technologie.', + ]); + } + + if ($cell['band'] === null || trim((string)$cell['band']) === '') { + addIssue($issues, 'missing_band', 'warning', $base + [ + 'message' => 'Bande radio absente.', + ]); + } + + if ($cell['frequency'] === null || trim((string)$cell['frequency']) === '') { + addIssue($issues, 'missing_frequency', 'warning', $base + [ + 'message' => 'Frequence absente.', + ]); + } + + if ($cell['bandwidth'] === null || trim((string)$cell['bandwidth']) === '') { + addIssue($issues, 'missing_bandwidth', 'info', $base + [ + 'message' => 'Largeur de bande absente.', + ]); + } +} + +$issueTypes = [ + 'site_without_cells' => 'Site sans cellule', + 'invalid_coordinates' => 'Coordonnees invalides', + 'invalid_azimuth' => 'Azimut invalide', + 'mcc_mnc_mismatch' => 'MCC/MNC incoherent', + 'missing_enb' => 'eNB/RNC absent', + 'missing_band' => 'Bande absente', + 'missing_frequency' => 'Frequence absente', + 'missing_bandwidth' => 'Largeur de bande absente', +]; + +if ($typeFilter !== '') { + $issues = array_values(array_filter($issues, static fn(array $issue): bool => $issue['type'] === $typeFilter)); +} + +$errors = count(array_filter($issues, static fn(array $issue): bool => $issue['severity'] === 'error')); +$warnings = count(array_filter($issues, static fn(array $issue): bool => $issue['severity'] === 'warning')); +$infos = count(array_filter($issues, static fn(array $issue): bool => $issue['severity'] === 'info')); + +$countSiteSql = 'SELECT COUNT(*) FROM sites s JOIN operators o ON o.id = s.operator_id JOIN countries c ON c.id = s.country_id' . $siteWhere; +$countCellSql = 'SELECT COUNT(*) FROM cells cl JOIN sites s ON s.id = cl.site_id JOIN operators o ON o.id = s.operator_id JOIN countries c ON c.id = s.country_id' . $cellWhere; +$countSiteStmt = $db->prepare($countSiteSql); +$countSiteStmt->execute($siteParams); +$siteCount = (int)$countSiteStmt->fetchColumn(); +$countCellStmt = $db->prepare($countCellSql); +$countCellStmt->execute($cellParams); +$cellCount = (int)$countCellStmt->fetchColumn(); +?> + + + + + +NetMonster Admin - Controle qualite + + + + + +

Controle qualite des donnees

Cette page est en lecture seule : elle signale les anomalies sans modifier la base.

+
Anomalies affichees
Erreurs
Avertissements
Informations
+
+

Base filtree controlee : site(s), cellule(s).

+

Anomalies

Aucune anomalie detectee avec ces filtres.
GraviteTypeOperateurSiteTechnologieeNB/RNCCell IDBandeFrequenceAzimutMessageActions
+
diff --git a/admin/duplicates.php b/admin/duplicates.php new file mode 100644 index 0000000..fe8aef2 --- /dev/null +++ b/admin/duplicates.php @@ -0,0 +1,255 @@ +" en repli. + * + * Corrige suite a l'audit du 20/09/2026 : le detail technique des + * exceptions n'est plus affiche a l'ecran ; il est journalise via + * error_log(). + * + * Note : le controle par role (require_admin_role) est volontairement + * absent de cette version, la separation des privileges etant reportee + * a une migration dediee. + */ +declare(strict_types=1); + +require_once __DIR__ . '/admin_auth.php'; +require_once __DIR__ . '/../lib/db.php'; +require_once __DIR__ . '/_nav.php'; + +require_admin_auth(); + +$db = pdo(); +$feedback = null; +$feedbackType = 'success'; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_csrf_token(); + + $action = $_POST['action'] ?? ''; + $confirmed = isset($_POST['confirm']) && $_POST['confirm'] === '1'; + + if (!$confirmed) { + $feedback = 'Action refusee : la confirmation explicite est obligatoire.'; + $feedbackType = 'error'; + } elseif ($action === 'delete_cell') { + $cellId = filter_input(INPUT_POST, 'cell_id', FILTER_VALIDATE_INT); + if ($cellId === false || $cellId === null) { + $feedback = 'Identifiant de cellule invalide.'; + $feedbackType = 'error'; + } else { + try { + $stmt = $db->prepare('DELETE FROM cells WHERE id = ? LIMIT 1'); + $stmt->execute([$cellId]); + $feedback = $stmt->rowCount() > 0 + ? sprintf('Cellule #%d supprimee.', $cellId) + : 'Aucune cellule correspondante trouvee (deja supprimee ?).'; + } catch (Throwable $e) { + error_log('[duplicates] delete_cell: ' . $e->getMessage()); + $feedback = 'Une erreur est survenue lors de la suppression.'; + $feedbackType = 'error'; + } + } + } elseif ($action === 'merge_sites') { + $keepId = filter_input(INPUT_POST, 'keep_site_id', FILTER_VALIDATE_INT); + $dropId = filter_input(INPUT_POST, 'drop_site_id', FILTER_VALIDATE_INT); + + if (!$keepId || !$dropId || $keepId === $dropId) { + $feedback = 'Selection de sites invalide pour la fusion.'; + $feedbackType = 'error'; + } else { + try { + $db->beginTransaction(); + $stmt = $db->prepare('UPDATE cells SET site_id = ? WHERE site_id = ?'); + $stmt->execute([$keepId, $dropId]); + $moved = $stmt->rowCount(); + + $stmt = $db->prepare('DELETE FROM sites WHERE id = ? LIMIT 1'); + $stmt->execute([$dropId]); + + $db->commit(); + $feedback = sprintf( + 'Fusion effectuee : %d cellule(s) rattachee(s) au site #%d, site #%d supprime.', + $moved, + $keepId, + $dropId + ); + } catch (Throwable $e) { + $db->rollBack(); + error_log('[duplicates] merge_sites: ' . $e->getMessage()); + $feedback = 'Une erreur est survenue lors de la fusion. Aucune donnee n\'a ete modifiee.'; + $feedbackType = 'error'; + } + } + } +} + +/** + * Collisions eNB/Cell ID : deux cellules distinctes qui partagent le + * meme couple (sub_id2, cell_id) sur la meme technologie sont des + * candidates a la fusion ou a la correction manuelle. area_code est + * volontairement exclu de ce regroupement (contrairement a uniq_cell) + * pour repérer aussi les cas ou le TAC differe par erreur de saisie + * entre deux lignes du meme eNB. + */ +$cellDuplicates = $db->query( + "SELECT c.sub_id2, c.cell_id, c.tech, COUNT(*) AS occurrences, + GROUP_CONCAT(c.id ORDER BY c.id SEPARATOR ',') AS cell_ids, + GROUP_CONCAT( + DISTINCT COALESCE(s.address_desc, s.location_desc, CONCAT('Site #', s.id)) + ORDER BY s.id SEPARATOR ' | ' + ) AS site_names + FROM cells c + LEFT JOIN sites s ON s.id = c.site_id + WHERE c.sub_id2 IS NOT NULL AND c.cell_id IS NOT NULL + GROUP BY c.sub_id2, c.cell_id, c.tech + HAVING occurrences > 1 + ORDER BY occurrences DESC + LIMIT 200" +)->fetchAll(PDO::FETCH_ASSOC); + +$siteDuplicates = $db->query( + "SELECT ROUND(s.lat, 5) AS lat_r, ROUND(s.lon, 5) AS lon_r, + COUNT(*) AS occurrences, + GROUP_CONCAT(s.id ORDER BY s.id SEPARATOR ',') AS site_ids, + GROUP_CONCAT( + COALESCE(s.address_desc, s.location_desc, CONCAT('Site #', s.id)) + ORDER BY s.id SEPARATOR ' | ' + ) AS names + FROM sites s + WHERE s.lat IS NOT NULL AND s.lon IS NOT NULL + AND NOT (s.lat = 0 AND s.lon = 0) + GROUP BY lat_r, lon_r + HAVING occurrences > 1 + ORDER BY occurrences DESC + LIMIT 200" +)->fetchAll(PDO::FETCH_ASSOC); + +$user = current_admin_username(); +?> + + + + + Doublons & collisions — NetMonster Admin + + + + + +
+

Doublons & collisions eNB / Cell ID

+

Cette page detecte les collisions potentielles sans jamais modifier la base automatiquement. Toute fusion ou suppression exige une confirmation explicite.

+ + +

+ +

+ + +
+

Collisions eNB / Cell ID ()

+ +

Aucune collision detectee.

+ + + + + + + + + + + + + + + + + + + + +
TechnoeNB/RNCCell IDOccurrencesSites concernesAction
+
+ + + + + + +
+
+ +
+ +
+

Sites potentiellement dupliques ()

+ +

Aucun site en collision de coordonnees.

+ + + + + + + + + + + + + + + + + + + +
LatitudeLongitudeOccurrencesSitesFusionner
+
+ + + + Garder + + Supprimer + + +
+
+ +
+
+ + \ No newline at end of file diff --git a/admin/edit_site.php b/admin/edit_site.php new file mode 100644 index 0000000..07963e0 --- /dev/null +++ b/admin/edit_site.php @@ -0,0 +1,454 @@ +getMessage()) ne sont plus affichees + * a l'ecran : elles sont journalisees via error_log() et un message + * generique est montre a l'administrateur. + * + * Note : le controle par role (require_admin_role) est volontairement + * absent de cette version, la separation des privileges etant reportee + * a une migration dediee (voir admin_auth.php). + * + * Version complète : + * - coordonnées GPS avec step="any" ; + * - ajout dynamique de nouvelles cellules ; + * - héritage MCC/MNC depuis l'opérateur du site ; + * - INSERT avec exactement 14 paramètres et NOW() pour validated_at. + */ +declare(strict_types=1); + +require_once __DIR__ . '/admin_auth.php'; +require_once __DIR__ . '/../lib/db.php'; +require_once __DIR__ . '/_nav.php'; + +require_admin_auth(); + +$db = pdo(); +$user = current_admin_username(); +$successMessage = null; +$errorMessage = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_csrf_token(); + $action = $_POST['action'] ?? ''; + + if ($action === 'save_site') { + $siteId = (int)($_POST['site_id'] ?? 0); + $lat = (float)($_POST['lat'] ?? 0); + $lon = (float)($_POST['lon'] ?? 0); + $locationDesc = trim($_POST['location_desc'] ?? ''); + $addressDesc = trim($_POST['address_desc'] ?? ''); + + $cellIds = $_POST['row_cell_pk'] ?? []; + $techs = $_POST['cell_tech'] ?? []; + $cellRadioIds = $_POST['cell_id'] ?? []; + $areaCodes = $_POST['cell_area_code'] ?? []; + $subIds = $_POST['cell_sub_id'] ?? []; + $subIds2 = $_POST['cell_sub_id2'] ?? []; + $bands = $_POST['cell_band'] ?? []; + $frequencies = $_POST['cell_frequency'] ?? []; + $azimuths = $_POST['cell_azimuth'] ?? []; + $bandwidths = $_POST['cell_bandwidth'] ?? []; + $arfcns = $_POST['cell_arfcn'] ?? []; + + $newTechs = $_POST['new_cell_tech'] ?? []; + $newCellRadioIds = $_POST['new_cell_id'] ?? []; + $newAreaCodes = $_POST['new_cell_area_code'] ?? []; + $newSubIds = $_POST['new_cell_sub_id'] ?? []; + $newSubIds2 = $_POST['new_cell_sub_id2'] ?? []; + $newBands = $_POST['new_cell_band'] ?? []; + $newFrequencies = $_POST['new_cell_frequency'] ?? []; + $newAzimuths = $_POST['new_cell_azimuth'] ?? []; + $newBandwidths = $_POST['new_cell_bandwidth'] ?? []; + $newArfcns = $_POST['new_cell_arfcn'] ?? []; + + if (!$siteId || !$lat || !$lon) { + $errorMessage = 'Coordonnees invalides.'; + } else { + $siteMccMncStmt = $db->prepare(' + SELECT o.mcc, o.mnc + FROM sites s + JOIN operators o ON o.id = s.operator_id + WHERE s.id = ? + '); + $siteMccMncStmt->execute([$siteId]); + $siteMccMnc = $siteMccMncStmt->fetch(PDO::FETCH_ASSOC); + + if (!$siteMccMnc) { + $errorMessage = 'Site introuvable, impossible de determiner le MCC/MNC.'; + } else { + $siteMcc = $siteMccMnc['mcc']; + $siteMnc = $siteMccMnc['mnc']; + + $db->beginTransaction(); + try { + $db->prepare('UPDATE sites SET lat = ?, lon = ?, location_desc = ?, address_desc = ? WHERE id = ?') + ->execute([$lat, $lon, $locationDesc, $addressDesc, $siteId]); + + $updateCell = $db->prepare(' + UPDATE cells + SET tech = ?, cell_id = ?, area_code = ?, sub_id = ?, sub_id2 = ?, + band = ?, frequency = ?, azimuth = ?, bandwidth = ?, arfcn = ?, + validated_by = ?, validated_at = NOW() + WHERE id = ? AND site_id = ? + '); + + foreach ($cellIds as $i => $cellPk) { + $cellPk = (int)$cellPk; + if (!$cellPk) { + continue; + } + + $updateCell->execute([ + $techs[$i] ?? null, + $cellRadioIds[$i] ?? null, + $areaCodes[$i] ?? null, + ($subIds[$i] ?? '') !== '' ? $subIds[$i] : null, + ($subIds2[$i] ?? '') !== '' ? $subIds2[$i] : null, + ($bands[$i] ?? '') !== '' ? $bands[$i] : null, + ($frequencies[$i] ?? '') !== '' ? $frequencies[$i] : null, + ($azimuths[$i] ?? '') !== '' ? (int)$azimuths[$i] : null, + ($bandwidths[$i] ?? '') !== '' ? $bandwidths[$i] : null, + ($arfcns[$i] ?? '') !== '' ? $arfcns[$i] : null, + $user, + $cellPk, + $siteId, + ]); + } + + // 14 marqueurs (?) et 14 valeurs dans execute(). + // validated_at est fourni par NOW(), donc aucune valeur + // supplémentaire ne doit être ajoutée dans execute(). + $insertCell = $db->prepare(' + INSERT INTO cells + ( + site_id, mcc, mnc, tech, cell_id, area_code, + sub_id, sub_id2, band, frequency, azimuth, + bandwidth, arfcn, validated_by, validated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) + '); + + foreach ($newTechs as $i => $newTech) { + $newCellIdVal = trim((string)($newCellRadioIds[$i] ?? '')); + $newAreaCodeVal = trim((string)($newAreaCodes[$i] ?? '')); + + // Ignore une ligne ajoutée mais non remplie. + if ($newCellIdVal === '' || $newAreaCodeVal === '') { + continue; + } + + $insertCell->execute([ + $siteId, + $siteMcc, + $siteMnc, + $newTech, + $newCellIdVal, + $newAreaCodeVal, + ($newSubIds[$i] ?? '') !== '' ? $newSubIds[$i] : null, + ($newSubIds2[$i] ?? '') !== '' ? $newSubIds2[$i] : null, + ($newBands[$i] ?? '') !== '' ? $newBands[$i] : null, + ($newFrequencies[$i] ?? '') !== '' ? $newFrequencies[$i] : null, + ($newAzimuths[$i] ?? '') !== '' ? (int)$newAzimuths[$i] : null, + ($newBandwidths[$i] ?? '') !== '' ? $newBandwidths[$i] : null, + ($newArfcns[$i] ?? '') !== '' ? $newArfcns[$i] : null, + $user, + ]); + } + + $db->commit(); + $successMessage = 'Site mis a jour.'; + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + error_log('[edit_site] save_site: ' . $e->getMessage()); + $errorMessage = "Une erreur est survenue lors de l'enregistrement. Consultez les journaux du serveur."; + } + } + } + } + + if ($action === 'delete_cell') { + $cellId = (int)($_POST['cell_id_pk'] ?? 0); + $siteId = (int)($_POST['site_id'] ?? 0); + + $countStmt = $db->prepare('SELECT COUNT(*) FROM cells WHERE site_id = ?'); + $countStmt->execute([$siteId]); + $remaining = (int)$countStmt->fetchColumn(); + + if ($remaining <= 1) { + $errorMessage = "Impossible de retirer la derniere cellule d'un site : supprimez plutot le site entier."; + } else { + try { + $db->prepare('DELETE FROM cells WHERE id = ? AND site_id = ?')->execute([$cellId, $siteId]); + $successMessage = 'Cellule retiree du site.'; + } catch (Throwable $e) { + error_log('[edit_site] delete_cell: ' . $e->getMessage()); + $errorMessage = 'Une erreur est survenue lors de la suppression.'; + } + } + } + + if ($action === 'delete_site') { + $siteId = (int)($_POST['site_id'] ?? 0); + try { + $db->prepare('DELETE FROM sites WHERE id = ?')->execute([$siteId]); + header('Location: edit_site.php?deleted=1'); + exit; + } catch (Throwable $e) { + error_log('[edit_site] delete_site: ' . $e->getMessage()); + $errorMessage = 'Une erreur est survenue lors de la suppression du site.'; + } + } +} + +$editingSiteId = isset($_GET['site_id']) ? (int)$_GET['site_id'] : null; +$editingSite = null; +$editingCells = []; + +if ($editingSiteId) { + $siteStmt = $db->prepare(' + SELECT s.*, o.brand AS operator_brand, o.mcc, o.mnc, + c.name AS country_name, c.default_lat, c.default_lon, c.default_zoom, + c.cadastre_wms_url, c.cadastre_wms_layer, + c.cadastre_attribution_html + FROM sites s + JOIN operators o ON o.id = s.operator_id + JOIN countries c ON c.id = s.country_id + WHERE s.id = ? + '); + $siteStmt->execute([$editingSiteId]); + $editingSite = $siteStmt->fetch(PDO::FETCH_ASSOC); + + if ($editingSite) { + $cellsStmt = $db->prepare('SELECT * FROM cells WHERE site_id = ? ORDER BY tech, cell_id'); + $cellsStmt->execute([$editingSiteId]); + $editingCells = $cellsStmt->fetchAll(PDO::FETCH_ASSOC); + } +} + +$mapLat = null; +$mapLon = null; +$coordsInvalid = false; + +if ($editingSite) { + $siteLat = (float)$editingSite['lat']; + $siteLon = (float)$editingSite['lon']; + + if ($siteLat === 0.0 && $siteLon === 0.0) { + $coordsInvalid = true; + $mapLat = (float)($editingSite['default_lat'] ?? 20); + $mapLon = (float)($editingSite['default_lon'] ?? 0); + } else { + $mapLat = $siteLat; + $mapLon = $siteLon; + } +} + +$searchQuery = trim($_GET['q'] ?? ''); +$countryFilter = $_GET['country'] ?? ''; +$operatorFilter = $_GET['operator'] ?? ''; +$sites = []; + +if (!$editingSiteId) { + $sql = ' + SELECT s.id, s.lat, s.lon, s.location_desc, s.address_desc, + o.brand AS operator_brand, c.name AS country_name, + GROUP_CONCAT(DISTINCT cl.tech ORDER BY cl.tech SEPARATOR ", ") AS techs, + COUNT(cl.id) AS cell_count + FROM sites s + JOIN operators o ON o.id = s.operator_id + JOIN countries c ON c.id = s.country_id + LEFT JOIN cells cl ON cl.site_id = s.id + WHERE 1=1 + '; + $params = []; + + if ($searchQuery !== '') { + $sql .= ' AND (s.location_desc LIKE ? OR s.address_desc LIKE ? OR cl.cell_id LIKE ? OR cl.sub_id2 LIKE ?)'; + $like = '%' . $searchQuery . '%'; + array_push($params, $like, $like, $like, $like); + } + if ($countryFilter !== '') { + $sql .= ' AND c.iso2 = ?'; + $params[] = $countryFilter; + } + if ($operatorFilter !== '') { + $sql .= ' AND o.id = ?'; + $params[] = $operatorFilter; + } + + $sql .= ' GROUP BY s.id ORDER BY s.created_at DESC LIMIT 200'; + $stmt = $db->prepare($sql); + $stmt->execute($params); + $sites = $stmt->fetchAll(PDO::FETCH_ASSOC); +} + +$countries = $db->query('SELECT id, iso2, name FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC); +$operators = $db->query('SELECT id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC); +$deletedNotice = isset($_GET['deleted']); +?> + + + + +NetMonster Admin - Edition de site + + + + + + + +
+ +

Edition d'un site valide

+

Recherchez un site pour corriger ses coordonnees, sa description, ou les parametres radio de ses cellules.

+

Site supprime avec succes.

+

+
+
+ + + + +
+ + + + + + +
OperateurPaysTechnosCellulesLieuLat/Lon
,
Aucun site trouve.
+
+ +

Edition du site —

+

← Retour a la liste

+

+

+

Attention : les coordonnees enregistrees pour ce site sont a 0,0 (invalides). La carte est centree sur le pays par defaut ; corrigez la position ci-dessous.

+
+

Localisation

+
+ + + +
+
+
+
+
+
+

Cellules du site ()

+ + + + + + + + + + + + + + + + + + +
TechnoCell IDArea codeSub IDeNB/RNCBandeFrequenceAzimutLargeurARFCN
+ + +
+
+

Zone sensible

La suppression du site retire aussi TOUTES ses cellules de la base validee (irreversible).

+ + + +

Site introuvable.

+

← Retour a la liste

+ +
+ + \ No newline at end of file diff --git a/admin/export.php b/admin/export.php new file mode 100644 index 0000000..0881b97 --- /dev/null +++ b/admin/export.php @@ -0,0 +1,239 @@ + 'GSM', '3G' => 'UMTS', '4G' => 'LTE', '5G' => 'NR', 'CDMA' => 'CDMA']; + +function composeLocationForExport(string $tech, ?string $enb, ?string $band, ?string $frequency, $azimuth, ?string $bandwidth, ?string $address): string +{ + $techLabel = TECH_RADIO_LABEL_EXPORT[$tech] ?? $tech; + $segments = []; + + if ($enb !== null && $enb !== '' && (int)$enb !== 0) { + $segments[] = "eNB ID $enb"; + } + + $radioLine = trim($techLabel . ' ' . ($band ?? '') . ' ' . ($frequency ?? '')); + if ($radioLine !== $techLabel && $radioLine !== '') { + $segments[] = $radioLine; + } + + if ($azimuth !== null && $azimuth !== '') { + $segments[] = $azimuth . '°'; + } + + if ($bandwidth !== null && $bandwidth !== '') { + $segments[] = $bandwidth; + } + + if ($address !== null && $address !== '') { + $segments[] = $address; + } + + return implode(' - ', $segments); +} + +function sanitizeFilterValue(string $value, string $pattern): string +{ + return preg_match($pattern, $value) ? $value : ''; +} + +if (isset($_GET['download'])) { + $format = $_GET['download'] === 'csv' ? 'csv' : 'ntm'; + $countryFilter = sanitizeFilterValue($_GET['country'] ?? '', '/^[A-Z]{2}$/'); + $operatorFilter = sanitizeFilterValue($_GET['operator'] ?? '', '/^\d+$/'); + $techFilter = sanitizeFilterValue($_GET['tech'] ?? '', '/^(2G|3G|4G|5G|CDMA)$/'); + + $sql = ' + SELECT cl.tech, cl.mcc, cl.mnc, cl.cell_id, cl.area_code, cl.sub_id, cl.sub_id2, + cl.band, cl.frequency, cl.azimuth, cl.bandwidth, cl.arfcn, + s.lat, s.lon, s.address_desc, o.brand AS operator_brand, c.name AS country_name, c.iso2 + FROM cells cl + JOIN sites s ON s.id = cl.site_id + JOIN operators o ON o.id = s.operator_id + JOIN countries c ON c.id = s.country_id + WHERE 1=1 + '; + $params = []; + if ($countryFilter !== '') { $sql .= ' AND c.iso2 = ?'; $params[] = $countryFilter; } + if ($operatorFilter !== '') { $sql .= ' AND o.id = ?'; $params[] = $operatorFilter; } + if ($techFilter !== '') { $sql .= ' AND cl.tech = ?'; $params[] = $techFilter; } + $sql .= ' ORDER BY c.name, o.brand, cl.tech, cl.cell_id'; + + $stmt = $db->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $labelParts = []; + if ($countryFilter !== '') $labelParts[] = strtolower($countryFilter); + if ($operatorFilter !== '') $labelParts[] = 'op' . $operatorFilter; + if ($techFilter !== '') $labelParts[] = strtolower($techFilter); + $suffix = $labelParts ? '_' . implode('_', $labelParts) : '_all'; + $filename = 'netmonster_export' . $suffix . '_' . date('Ymd_His'); + + if ($format === 'csv') { + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '.csv"'); + + $out = fopen('php://output', 'w'); + fputcsv($out, [ + 'tech', 'mcc', 'mnc', 'operator', 'country', 'cell_id', 'area_code', 'sub_id', 'enb_rnc', + 'band', 'frequency', 'azimuth', 'bandwidth', 'arfcn', 'lat', 'lon', 'address_desc', 'location_desc_recompose', + ], ';'); + foreach ($rows as $r) { + $composed = composeLocationForExport($r['tech'], (string)($r['sub_id2'] ?? ''), $r['band'], $r['frequency'], $r['azimuth'], $r['bandwidth'], $r['address_desc']); + fputcsv($out, [ + $r['tech'], $r['mcc'], $r['mnc'], $r['operator_brand'], $r['country_name'], + $r['cell_id'], $r['area_code'], $r['sub_id'], $r['sub_id2'], + $r['band'], $r['frequency'], $r['azimuth'], $r['bandwidth'], $r['arfcn'], + $r['lat'], $r['lon'], $r['address_desc'], $composed, + ], ';'); + } + fclose($out); + exit; + } + + header('Content-Type: text/plain; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '.ntm"'); + + $tagMap = ['2G' => '2G', '3G' => '3G', '4G' => '4G', '5G' => '5G', 'CDMA' => 'CD2']; + + foreach ($rows as $r) { + $tag = $tagMap[$r['tech']] ?? $r['tech']; + $lat = $r['lat'] ?? 0; + $lon = $r['lon'] ?? 0; + $arfcn = $r['arfcn'] ?? 0; + + $location = composeLocationForExport( + $r['tech'], + $r['tech'] === '4G' ? (string)($r['sub_id2'] ?? '') : null, + $r['band'], $r['frequency'], $r['azimuth'], $r['bandwidth'], $r['address_desc'] + ); + + if (in_array($r['tech'], ['3G', '4G'], true)) { + echo implode(';', [$tag, $r['mcc'], $r['mnc'], $r['cell_id'], $r['area_code'], $r['sub_id2'] ?? 0, $r['sub_id'] ?? 0, $lat, $lon, $location, $arfcn]) . "\r\n"; + } elseif ($r['tech'] === 'CDMA') { + echo implode(';', [$tag, $r['mcc'], $r['mnc'], $r['cell_id'], $r['area_code'], 0, $r['sub_id'] ?? 0, $lat, $lon, $location, '']) . "\r\n"; + } else { + echo implode(';', [$tag, $r['mcc'], $r['mnc'], $r['cell_id'], $r['area_code'], 0, $r['sub_id'] ?? 0, $lat, $lon, $location, $arfcn]) . "\r\n"; + } + } + exit; +} + +$countries = $db->query('SELECT id, iso2, name FROM countries ORDER BY name')->fetchAll(PDO::FETCH_ASSOC); +$operators = $db->query('SELECT id, mcc, mnc, brand FROM operators ORDER BY brand')->fetchAll(PDO::FETCH_ASSOC); + +$totalCells = (int)$db->query('SELECT COUNT(*) FROM cells')->fetchColumn(); +$totalSites = (int)$db->query('SELECT COUNT(*) FROM sites')->fetchColumn(); +$lastValidated = $db->query('SELECT MAX(validated_at) FROM cells')->fetchColumn(); + +$byCountryStats = $db->query(' + SELECT c.name AS country_name, c.iso2, COUNT(DISTINCT s.id) AS site_count, COUNT(cl.id) AS cell_count + FROM countries c + LEFT JOIN sites s ON s.country_id = c.id + LEFT JOIN cells cl ON cl.site_id = s.id + GROUP BY c.id + ORDER BY c.name +')->fetchAll(PDO::FETCH_ASSOC); + +$user = current_admin_username(); +?> + + + +NetMonster Admin - Export + + + + + + +
+

Export de la base pour mise en production

+

Telechargez la base validee (sites + cellules) pour l'envoyer sur votre serveur de diffusion.

+ +
+
site(s) valides
+
cellule(s) validees
+
derniere validation
+
+ +
+

Repartition par pays

+ + + + + + + + + +
PaysSitesCellules
+
+ +
+

Filtrer avant export

+

Laissez sur "Tous" pour exporter l'integralite de la base validee.

+
+ + + +
+ +
+
+

Format .ntm

+

Reconstruit le format d'origine NetMonster (CSV ";"), Location recomposee depuis les champs structures, pret a etre republie tel quel.

+ +
+
+

Format .csv

+

Format tabulaire avec colonnes separees (bande, frequence, azimut, largeur de bande), pratique pour verification dans un tableur.

+ +
+
+
+
+ + + + \ No newline at end of file