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.
+
+
+ = htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?>
+
+
+ = htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+ = $countryFormTitle ?>
+
+
+
+
+ Pays configures (= count($countries) ?>)
+
+ Pays MCC ISO2 Centre carte Cadastre Operateurs Actions
+
+
+ = field_value($country, 'name') ?>
+ = field_value($country, 'mcc') ?>
+ = field_value($country, 'iso2') ?>
+ = $country['default_lat'] !== null ? field_value($country, 'default_lat') . ', ' . field_value($country, 'default_lon') : '—' ?>
+
+
+ configure
+
+ non configure
+
+
+ = count($operatorsByCountry[$country['id']] ?? []) ?>
+
+ Modifier
+
+
+
+
+
+ Aucun pays configure.
+
+
+
+
+
+ = $operatorFormTitle ?>
+
+
+
+
+ Operateurs configures (= count($operators) ?>)
+
+ Pays Brand MCC-MNC Raison sociale Actions
+
+
+ = field_value($operator, 'country_name') ?>
+ = field_value($operator, 'brand') ?>
+ = field_value($operator, 'mcc') ?>-= field_value($operator, 'mnc') ?>
+ = field_value($operator, 'full_name') ?>
+
+ 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.
+
+
+
+
= field_value($operator, 'brand') ?> (MCC = field_value($operator, 'mcc') ?> / MNC = field_value($operator, 'mnc') ?>)
+
+
+
+
+
+ Bande EARFCN debut EARFCN fin Actions
+
+
+ = field_value($mapping, 'band') ?>
+ = (int)$mapping['earfcn_start'] ?>
+ = (int)$mapping['earfcn_end'] ?>
+
+
+
+
+
+
+
+
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
= count($issues) ?>
Avertissements
= $warnings ?>
+
+
Base filtree controlee : = $siteCount ?> site(s), = $cellCount ?> cellule(s).
+
Anomalies Aucune anomalie detectee avec ces filtres.
Gravite Type Operateur Site Technologie eNB/RNC Cell ID Bande Frequence Azimut Message Actions = h(ucfirst($issue['severity'])) ?> = h($issueTypes[$issue['type']] ?? $issue['type']) ?> = h($issue['operator_brand'] ?? '') ?> = (int)$issue['site_id'] ?> = h($issue['tech'] ?? '') ?> = h($issue['sub_id2'] ?? '') ?> = h($issue['cell_id'] ?? '') ?> = h($issue['band'] ?? '') ?> = h($issue['frequency'] ?? '') ?> = $issue['azimuth'] === null || $issue['azimuth'] === '' ? '' : h($issue['azimuth']) . '°' ?> = h($issue['message']) ?>
+
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.
+
+
+
+ = htmlspecialchars($feedback, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+ Collisions eNB / Cell ID (= count($cellDuplicates) ?>)
+
+ Aucune collision detectee.
+
+
+
+ Techno
+ eNB/RNC
+ Cell ID
+ Occurrences
+ Sites concernes
+ Action
+
+
+
+ = htmlspecialchars((string)$row['tech'], ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars((string)$row['sub_id2'], ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars((string)$row['cell_id'], ENT_QUOTES, 'UTF-8') ?>
+ = (int)$row['occurrences'] ?>
+ = htmlspecialchars((string)$row['site_names'], ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+
+
+
+
+
+
+ Sites potentiellement dupliques (= count($siteDuplicates) ?>)
+
+ Aucun site en collision de coordonnees.
+
+
+
+ Latitude
+ Longitude
+ Occurrences
+ Sites
+ Fusionner
+
+
+
+
+ = htmlspecialchars((string)$row['lat_r'], ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars((string)$row['lon_r'], ENT_QUOTES, 'UTF-8') ?>
+ = (int)$row['occurrences'] ?>
+ = htmlspecialchars((string)$row['names'], ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+
+
+
+
+
+
+
\ 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.
+
= htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+ Operateur Pays Technos Cellules Lieu Lat/Lon
+
+ = htmlspecialchars($s['operator_brand'], ENT_QUOTES, 'UTF-8') ?> = htmlspecialchars($s['country_name'], ENT_QUOTES, 'UTF-8') ?> = htmlspecialchars((string)($s['techs'] ?? ''), ENT_QUOTES, 'UTF-8') ?> = (int)$s['cell_count'] ?> = htmlspecialchars($s['address_desc'] ?? $s['location_desc'] ?? '', ENT_QUOTES, 'UTF-8') ?> = htmlspecialchars((string)$s['lat'], ENT_QUOTES, 'UTF-8') ?>, = htmlspecialchars((string)$s['lon'], ENT_QUOTES, 'UTF-8') ?> Editer
+
+ Aucun site trouve.
+
+
+
+
Edition du site — = htmlspecialchars($editingSite['operator_brand'], ENT_QUOTES, 'UTF-8') ?>
+
← Retour a la liste
+
= htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?>
+
= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?>
+
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.
+
+
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.
+
+
+
= $totalSites ?> site(s) valides
+
= $totalCells ?> cellule(s) validees
+
= $lastValidated ? htmlspecialchars(date('d/m/Y H:i', strtotime($lastValidated))) : '—' ?> derniere validation
+
+
+
+
Repartition par pays
+
+ Pays Sites Cellules
+
+
+ = htmlspecialchars($s['country_name']) ?>
+ = (int)$s['site_count'] ?>
+ = (int)$s['cell_count'] ?>
+
+
+
+
+
+
+
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.
+
Telecharger en .ntm
+
+
+
Format .csv
+
Format tabulaire avec colonnes separees (bande, frequence, azimut, largeur de bande), pratique pour verification dans un tableur.
+
Telecharger en .csv
+
+
+
+
+
+
+
+
\ No newline at end of file