This commit is contained in:
Voxan committed 2026-10-21 22:00:18 +02:00
1 parent eee6418ea4
commit 51c49327d4
5 files changed
+1680

No files matched your search

+474
View File
@@ -0,0 +1,474 @@
<?php
/**
* admin/countries_settings.php
*
* Gestion des pays, de leur centre de carte/cadastre, des operateurs
* et des mappings bande 4G -> 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';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>NetMonster Admin - Pays &amp; operateurs</title>
<link rel="stylesheet" href="assets/theme.css">
</head>
<body>
<?php render_admin_nav('countries_settings.php', $user); ?>
<main class="page-wrap" id="top">
<h1>Configuration des pays &amp; operateurs</h1>
<p class="page-subtitle">Configurez les pays, le centre de carte, les couches cadastre, les operateurs et les mappings bande/EARFCN.</p>
<?php if ($successMessage !== null): ?>
<p class="msg-ok"><?= htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<?php if ($errorMessage !== null): ?>
<p class="msg-err"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<section class="card">
<h2><?= $countryFormTitle ?></h2>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_country">
<input type="hidden" name="country_id" value="<?= (int)($editingCountry['id'] ?? 0) ?>">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:0.9rem;margin-bottom:0.9rem;">
<label>MCC (3 chiffres)
<input type="text" name="mcc" pattern="\d{3}" maxlength="3" required value="<?= field_value($editingCountry, 'mcc') ?>" placeholder="ex: 270">
</label>
<label>Code ISO2
<input type="text" name="iso2" pattern="[A-Za-z]{2}" maxlength="2" required value="<?= field_value($editingCountry, 'iso2') ?>" placeholder="ex: LU">
</label>
<label>Nom du pays
<input type="text" name="name" required value="<?= field_value($editingCountry, 'name') ?>" placeholder="ex: Luxembourg">
</label>
</div>
<h3 style="margin:1rem 0 0.5rem;">Centre de la carte</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:0.9rem;margin-bottom:0.9rem;">
<label>Latitude par defaut
<input type="number" step="any" min="-90" max="90" name="default_lat" value="<?= field_value($editingCountry, 'default_lat') ?>" placeholder="ex: 49.8153">
</label>
<label>Longitude par defaut
<input type="number" step="any" min="-180" max="180" name="default_lon" value="<?= field_value($editingCountry, 'default_lon') ?>" placeholder="ex: 6.1296">
</label>
<label>Zoom par defaut
<input type="number" name="default_zoom" min="1" max="19" value="<?= field_value($editingCountry, 'default_zoom', '9') ?>">
</label>
</div>
<h3 style="margin:1rem 0 0.5rem;">Cadastre / WMS (optionnel)</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:0.9rem;margin-bottom:0.9rem;">
<label style="grid-column:1 / -1;">URL du service WMS cadastre
<input type="url" name="cadastre_wms_url" value="<?= field_value($editingCountry, 'cadastre_wms_url') ?>" placeholder="https://exemple.tld/wms">
</label>
<label>Nom / identifiant de la couche WMS
<input type="text" name="cadastre_wms_layer" value="<?= field_value($editingCountry, 'cadastre_wms_layer') ?>" placeholder="ex: parcelles">
</label>
<label>SRS
<input type="text" name="cadastre_srs" value="<?= field_value($editingCountry, 'cadastre_srs', 'EPSG:3857') ?>" placeholder="EPSG:3857">
</label>
<label style="grid-column:1 / -1;">Attribution cadastre
<input type="text" name="cadastre_attribution_html" value="<?= field_value($editingCountry, 'cadastre_attribution_html') ?>" placeholder="Credit obligatoire de la source de donnees">
</label>
</div>
<details class="help">
<summary>Ou trouver une URL WMS ?</summary>
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.
</details>
<button type="submit" class="primary"><?= $editingCountry ? 'Enregistrer les modifications' : 'Creer le pays' ?></button>
<?php if ($editingCountry): ?>
<a href="countries_settings.php" style="margin-left:.7rem;">Annuler l'edition</a>
<?php endif; ?>
</form>
</section>
<section class="card">
<h2>Pays configures (<?= count($countries) ?>)</h2>
<table class="data-table">
<tr><th>Pays</th><th>MCC</th><th>ISO2</th><th>Centre carte</th><th>Cadastre</th><th>Operateurs</th><th>Actions</th></tr>
<?php foreach ($countries as $country): ?>
<tr>
<td><?= field_value($country, 'name') ?></td>
<td><?= field_value($country, 'mcc') ?></td>
<td><?= field_value($country, 'iso2') ?></td>
<td><?= $country['default_lat'] !== null ? field_value($country, 'default_lat') . ', ' . field_value($country, 'default_lon') : '&mdash;' ?></td>
<td>
<?php if (!empty($country['cadastre_wms_url'])): ?>
<span class="cadastre-badge">configure</span>
<?php else: ?>
<span class="no-cadastre">non configure</span>
<?php endif; ?>
</td>
<td><?= count($operatorsByCountry[$country['id']] ?? []) ?></td>
<td>
<a href="countries_settings.php?edit_country=<?= (int)$country['id'] ?>#top">Modifier</a>
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer ce pays ? Cette action est impossible tant que des operateurs y sont rattaches.');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_country">
<input type="hidden" name="country_id" value="<?= (int)$country['id'] ?>">
<button type="submit" class="reject-small">Supprimer</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($countries)): ?>
<tr><td colspan="7" class="no-cadastre">Aucun pays configure.</td></tr>
<?php endif; ?>
</table>
</section>
<section class="card">
<h2><?= $operatorFormTitle ?></h2>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_operator">
<input type="hidden" name="operator_id" value="<?= (int)($editingOperator['id'] ?? 0) ?>">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:0.9rem;margin-bottom:0.9rem;">
<label>Pays
<select name="country_id" required>
<option value="">-- choisir --</option>
<?php foreach ($countries as $country): ?>
<option value="<?= (int)$country['id'] ?>" <?= (int)($editingOperator['country_id'] ?? 0) === (int)$country['id'] ? 'selected' : '' ?>>
<?= field_value($country, 'name') ?> (MCC <?= field_value($country, 'mcc') ?>)
</option>
<?php endforeach; ?>
</select>
</label>
<label>MNC
<input type="text" name="mnc" pattern="\d{2,3}" maxlength="3" required value="<?= field_value($editingOperator, 'mnc') ?>" placeholder="ex: 01">
</label>
<label>Nom commercial
<input type="text" name="brand" required value="<?= field_value($editingOperator, 'brand') ?>" placeholder="ex: POST">
</label>
</div>
<label style="display:block;margin-bottom:0.9rem;">Raison sociale complete
<input type="text" name="full_name" value="<?= field_value($editingOperator, 'full_name') ?>" placeholder="Optionnel">
</label>
<button type="submit" class="primary"><?= $editingOperator ? 'Enregistrer les modifications' : 'Ajouter l\'operateur' ?></button>
<?php if ($editingOperator): ?>
<a href="countries_settings.php" style="margin-left:.7rem;">Annuler l'edition</a>
<?php endif; ?>
</form>
</section>
<section class="card">
<h2>Operateurs configures (<?= count($operators) ?>)</h2>
<table class="data-table">
<tr><th>Pays</th><th>Brand</th><th>MCC-MNC</th><th>Raison sociale</th><th>Actions</th></tr>
<?php foreach ($operators as $operator): ?>
<tr>
<td><?= field_value($operator, 'country_name') ?></td>
<td><?= field_value($operator, 'brand') ?></td>
<td><?= field_value($operator, 'mcc') ?>-<?= field_value($operator, 'mnc') ?></td>
<td><?= field_value($operator, 'full_name') ?></td>
<td>
<a href="countries_settings.php?edit_operator=<?= (int)$operator['id'] ?>#top">Modifier</a>
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer cet operateur ? Cette action est impossible tant que des sites lui sont rattaches.');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_operator">
<input type="hidden" name="operator_id" value="<?= (int)$operator['id'] ?>">
<button type="submit" class="reject-small">Supprimer</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($operators)): ?>
<tr><td colspan="5" class="no-cadastre">Aucun operateur configure.</td></tr>
<?php endif; ?>
</table>
</section>
<section class="card">
<h2>Mappings bande 4G → EARFCN par operateur</h2>
<p class="page-subtitle">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.</p>
<?php foreach ($operators as $operator): ?>
<div style="margin:1.2rem 0; padding:1rem; border:1px solid var(--border); border-radius:var(--radius);">
<h3 style="margin:0 0 0.5rem;"><?= field_value($operator, 'brand') ?> (MCC <?= field_value($operator, 'mcc') ?> / MNC <?= field_value($operator, 'mnc') ?>)</h3>
<form method="post" style="margin-bottom:0.8rem;">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_band_mapping">
<input type="hidden" name="operator_id" value="<?= (int)$operator['id'] ?>">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:0.7rem;align-items:end;">
<label>Bande
<input type="text" name="band" pattern="B\d+[a-z]?" required placeholder="ex: B20" style="width:100%;">
</label>
<label>EARFCN debut
<input type="number" name="earfcn_start" min="0" required placeholder="ex: 6200" style="width:100%;">
</label>
<label>EARFCN fin
<input type="number" name="earfcn_end" min="0" required placeholder="ex: 6349" style="width:100%;">
</label>
<button type="submit" class="primary" style="padding:0.5rem 1rem;">Ajouter</button>
</div>
</form>
<?php if (!empty($bandMappingsByOperator[$operator['id']])): ?>
<table class="data-table" style="margin-top:0.5rem;">
<tr><th>Bande</th><th>EARFCN debut</th><th>EARFCN fin</th><th>Actions</th></tr>
<?php foreach ($bandMappingsByOperator[$operator['id']] as $mapping): ?>
<tr>
<td><code><?= field_value($mapping, 'band') ?></code></td>
<td><?= (int)$mapping['earfcn_start'] ?></td>
<td><?= (int)$mapping['earfcn_end'] ?></td>
<td>
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer ce mapping ?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_band_mapping">
<input type="hidden" name="mapping_id" value="<?= (int)$mapping['id'] ?>">
<button type="submit" class="reject-small">Supprimer</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php else: ?>
<p class="no-cadastre" style="margin:0.5rem 0;">Aucun mapping configure pour cet operateur.</p>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (empty($operators)): ?>
<p class="no-cadastre">Aucun operateur configure. Ajoutez d'abord un operateur ci-dessus.</p>
<?php endif; ?>
</section>
</main>
</body>
</html>
+258
View File
@@ -0,0 +1,258 @@
<?php
/**
* admin/data_quality.php
*
* Tableau de controle qualite en lecture seule.
* Aucun INSERT, UPDATE ou DELETE n'est execute par cette page.
*/
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();
function h(mixed $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function addIssue(array &$issues, string $type, string $severity, array $data): void
{
$data['type'] = $type;
$data['severity'] = $severity;
$issues[] = $data;
}
function normalizeCode(mixed $value): string
{
return trim((string)$value);
}
function technologyNeedsNode(string $tech): bool
{
return in_array(strtoupper(trim($tech)), ['3G', '4G', '5G'], true);
}
$countryFilter = trim((string)($_GET['country'] ?? ''));
$operatorFilter = trim((string)($_GET['operator'] ?? ''));
$typeFilter = trim((string)($_GET['type'] ?? ''));
$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);
$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();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NetMonster Admin - Controle qualite</title>
<link rel="stylesheet" href="assets/theme.css">
<style>
.quality-summary{display:grid;grid-template-columns:repeat(4,minmax(130px,1fr));gap:.8rem;margin-bottom:1rem}.quality-card{border-radius:8px;padding:1rem;background:#fff;border:1px solid #dce2ea}.quality-card.error{border-left:4px solid #dc2626}.quality-card.warning{border-left:4px solid #d97706}.quality-card.info{border-left:4px solid #2563eb}.quality-number{font-size:1.65rem;font-weight:700;margin-top:.25rem}.quality-label{color:#64748b;font-size:.8rem}.filters{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:.6rem;align-items:end}.filters label{display:block;font-size:.78rem;color:#64748b;margin-bottom:.25rem}.filters input,.filters select{width:100%;box-sizing:border-box}.issue-table{margin-top:1rem;overflow:auto}.issue-table table{min-width:1050px}.issue-type{font-size:.75rem;padding:.2rem .45rem;border-radius:999px;white-space:nowrap}.issue-type.error{color:#991b1b;background:#fee2e2}.issue-type.warning{color:#92400e;background:#fef3c7}.issue-type.info{color:#1e40af;background:#dbeafe}.action-links{display:flex;gap:.35rem;flex-wrap:wrap}.action-links a{font-size:.78rem}.empty-state{padding:1.5rem;text-align:center;color:#64748b}@media(max-width:800px){.quality-summary{grid-template-columns:repeat(2,1fr)}.filters{grid-template-columns:1fr}}
</style>
</head>
<body>
<?php render_admin_nav('data_quality.php', $user); ?>
<div class="page-wrap"><h1>Controle qualite des donnees</h1><p class="page-subtitle">Cette page est en lecture seule : elle signale les anomalies sans modifier la base.</p>
<div class="quality-summary"><div class="quality-card"><div class="quality-label">Anomalies affichees</div><div class="quality-number"><?= count($issues) ?></div></div><div class="quality-card error"><div class="quality-label">Erreurs</div><div class="quality-number"><?= $errors ?></div></div><div class="quality-card warning"><div class="quality-label">Avertissements</div><div class="quality-number"><?= $warnings ?></div></div><div class="quality-card info"><div class="quality-label">Informations</div><div class="quality-number"><?= $infos ?></div></div></div>
<div class="card"><form class="filters" method="get"><div><label for="country">Pays</label><select id="country" name="country"><option value="">Tous les pays</option><?php foreach($countries as $country): ?><option value="<?= h($country['iso2']) ?>" <?= $countryFilter === $country['iso2'] ? 'selected' : '' ?>><?= h($country['name']) ?></option><?php endforeach; ?></select></div><div><label for="operator">Operateur</label><select id="operator" name="operator"><option value="">Tous les operateurs</option><?php foreach($operators as $operator): ?><option value="<?= (int)$operator['id'] ?>" <?= $operatorFilter === (string)$operator['id'] ? 'selected' : '' ?>><?= h($operator['brand']) ?></option><?php endforeach; ?></select></div><div><label for="type">Type d'anomalie</label><select id="type" name="type"><option value="">Tous les types</option><?php foreach($issueTypes as $key=>$label): ?><option value="<?= h($key) ?>" <?= $typeFilter === $key ? 'selected' : '' ?>><?= h($label) ?></option><?php endforeach; ?></select></div><button type="submit" class="primary">Filtrer</button></form></div>
<div class="card" style="margin-top:1rem"><p class="page-subtitle" style="margin:0">Base filtree controlee : <?= $siteCount ?> site(s), <?= $cellCount ?> cellule(s).</p></div>
<div class="card issue-table"><h2>Anomalies</h2><?php if(empty($issues)): ?><div class="empty-state">Aucune anomalie detectee avec ces filtres.</div><?php else: ?><table class="data-table"><tr><th>Gravite</th><th>Type</th><th>Operateur</th><th>Site</th><th>Technologie</th><th>eNB/RNC</th><th>Cell ID</th><th>Bande</th><th>Frequence</th><th>Azimut</th><th>Message</th><th>Actions</th></tr><?php foreach($issues as $issue): ?><tr><td><span class="issue-type <?= h($issue['severity']) ?>"><?= h(ucfirst($issue['severity'])) ?></span></td><td><?= h($issueTypes[$issue['type']] ?? $issue['type']) ?></td><td><?= h($issue['operator_brand'] ?? '') ?></td><td><?= (int)$issue['site_id'] ?></td><td><?= h($issue['tech'] ?? '') ?></td><td><?= h($issue['sub_id2'] ?? '') ?></td><td><?= h($issue['cell_id'] ?? '') ?></td><td><?= h($issue['band'] ?? '') ?></td><td><?= h($issue['frequency'] ?? '') ?></td><td><?= $issue['azimuth'] === null || $issue['azimuth'] === '' ? '' : h($issue['azimuth']) . '°' ?></td><td><?= h($issue['message']) ?></td><td><div class="action-links"><a href="edit_site.php?site_id=<?= (int)$issue['site_id'] ?>">Editer</a><?php if(!empty($issue['sub_id2'])): ?><a href="search_site.php?q=<?= rawurlencode((string)$issue['sub_id2']) ?>">Rechercher eNB</a><?php endif; ?></div></td></tr><?php endforeach; ?></table><?php endif; ?></div>
</div></body></html>
+255
View File
@@ -0,0 +1,255 @@
<?php
/**
* admin/duplicates.php
*
* Detection et gestion des doublons / collisions eNB-Cell ID.
* Suit le meme modele de securite que data_quality.php :
* - authentification admin obligatoire (admin_auth.php),
* - jeton CSRF sur toute action de fusion/suppression,
* - aucune suppression automatique : chaque action necessite une
* confirmation explicite de l'utilisateur (case a cocher + bouton),
* - requetes preparees uniquement, jamais de concatenation SQL.
*
* CORRIGE (20/09/2026, suite a erreur 500 en usage reel) : cette page
* utilisait des noms de colonnes inexistants dans le schema reel
* (c.enb, c.technology, s.name), ecrits avant que le schema definitif
* (install_full_schema.sql) ne soit connu. Alignee maintenant sur les
* vraies colonnes :
* - cells.tech (pas "technology")
* - cells.sub_id2 (eNB/RNC, pas de colonne "enb")
* - sites n'a pas de colonne "name" : utilise address_desc en
* priorite, puis location_desc, puis "Site #<id>" 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();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Doublons &amp; collisions — NetMonster Admin</title>
<link rel="stylesheet" href="assets/theme.css">
</head>
<body>
<?php render_admin_nav('duplicates.php', $user); ?>
<main class="page-wrap">
<h1>Doublons &amp; collisions eNB / Cell ID</h1>
<p class="page-subtitle">Cette page detecte les collisions potentielles sans jamais modifier la base automatiquement. Toute fusion ou suppression exige une confirmation explicite.</p>
<?php if ($feedback !== null): ?>
<p class="<?= $feedbackType === 'error' ? 'msg-err' : 'msg-ok' ?>">
<?= htmlspecialchars($feedback, ENT_QUOTES, 'UTF-8') ?>
</p>
<?php endif; ?>
<section class="card">
<h2>Collisions eNB / Cell ID (<?= count($cellDuplicates) ?>)</h2>
<?php if (empty($cellDuplicates)): ?>
<p>Aucune collision detectee.</p>
<?php else: ?>
<table class="data-table">
<tr>
<th>Techno</th>
<th>eNB/RNC</th>
<th>Cell ID</th>
<th>Occurrences</th>
<th>Sites concernes</th>
<th>Action</th>
</tr>
<?php foreach ($cellDuplicates as $row): ?>
<tr>
<td><?= htmlspecialchars((string)$row['tech'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$row['sub_id2'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$row['cell_id'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= (int)$row['occurrences'] ?></td>
<td><?= htmlspecialchars((string)$row['site_names'], ENT_QUOTES, 'UTF-8') ?></td>
<td>
<form method="post" onsubmit="return confirm('Confirmer la suppression de cette cellule en doublon ?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_cell">
<input type="hidden" name="confirm" value="1">
<?php $ids = explode(',', (string)$row['cell_ids']); ?>
<select name="cell_id">
<?php foreach ($ids as $id): ?>
<option value="<?= (int)$id ?>">Cellule #<?= (int)$id ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="reject-small">Supprimer</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</section>
<section class="card">
<h2>Sites potentiellement dupliques (<?= count($siteDuplicates) ?>)</h2>
<?php if (empty($siteDuplicates)): ?>
<p>Aucun site en collision de coordonnees.</p>
<?php else: ?>
<table class="data-table">
<tr>
<th>Latitude</th>
<th>Longitude</th>
<th>Occurrences</th>
<th>Sites</th>
<th>Fusionner</th>
</tr>
<?php foreach ($siteDuplicates as $row): ?>
<?php $siteIds = explode(',', (string)$row['site_ids']); ?>
<tr>
<td><?= htmlspecialchars((string)$row['lat_r'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string)$row['lon_r'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= (int)$row['occurrences'] ?></td>
<td><?= htmlspecialchars((string)$row['names'], ENT_QUOTES, 'UTF-8') ?></td>
<td>
<form method="post" onsubmit="return confirm('Confirmer la fusion de ces deux sites ? Cette action est irreversible.');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="merge_sites">
<input type="hidden" name="confirm" value="1">
Garder
<select name="keep_site_id">
<?php foreach ($siteIds as $id): ?>
<option value="<?= (int)$id ?>">#<?= (int)$id ?></option>
<?php endforeach; ?>
</select>
Supprimer
<select name="drop_site_id">
<?php foreach ($siteIds as $id): ?>
<option value="<?= (int)$id ?>">#<?= (int)$id ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="reject-small">Fusionner</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</section>
</main>
</body>
</html>
+454
View File
@@ -0,0 +1,454 @@
<?php
/**
* admin/edit_site.php
*
* Corrige suite a l'audit du 20/09/2026 :
* - Le flux WMS cadastre (URL, nom de couche, attribution HTML) est
* desormais encode en JSON avec JSON_HEX_* au lieu d'addslashes(),
* qui n'est pas un encodeur JavaScript fiable et pouvait permettre
* une injection si un pays contenait une valeur cadastre malicieuse
* (le champ cadastre_attribution_html est explicitement du HTML,
* donc a risque si mal echappe).
* - Les erreurs techniques ($e->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']);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>NetMonster Admin - Edition de site</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin></script>
<link rel="stylesheet" href="assets/theme.css">
<style>
.search-row { display:flex; gap:.6rem; margin-bottom:1rem; flex-wrap:wrap; }
.search-row input[type=text] { flex:1; min-width:220px; }
table.sites-table td { vertical-align:middle; }
button.edit-btn { background:var(--primary); color:#fff; border:none; padding:.3rem .7rem; border-radius:4px; cursor:pointer; font-size:.78rem; }
.danger-zone { border:1px solid #f3c1bd; background:#fdeceb; border-radius:var(--radius-sm); padding:1rem; margin-top:1.4rem; }
.danger-zone h3 { margin:0 0 .5rem; font-size:.95rem; color:var(--danger-dark); }
#editMap { height:320px; width:100%; margin-bottom:1rem; background:#e8eaf0; border-radius:var(--radius-sm); }
button.add-cell-btn { background:var(--primary); color:#fff; border:none; padding:.4rem .9rem; border-radius:4px; cursor:pointer; font-size:.82rem; margin:.6rem 0 1rem; }
tr.new-cell-row td { background:#f3f8ff; }
</style>
</head>
<body>
<?php render_admin_nav('edit_site.php', $user); ?>
<div class="page-wrap">
<?php if (!$editingSiteId): ?>
<h1>Edition d'un site valide</h1>
<p class="page-subtitle">Recherchez un site pour corriger ses coordonnees, sa description, ou les parametres radio de ses cellules.</p>
<?php if ($deletedNotice): ?><p class="msg-ok">Site supprime avec succes.</p><?php endif; ?>
<?php if ($successMessage): ?><p class="msg-ok"><?= htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<div class="card">
<form class="search-row" method="get">
<input type="text" name="q" placeholder="Rechercher par lieu, adresse, Cell ID ou eNB..." value="<?= htmlspecialchars($searchQuery, ENT_QUOTES, 'UTF-8') ?>">
<select name="country"><option value="">Tous les pays</option><?php foreach ($countries as $c): ?><option value="<?= htmlspecialchars($c['iso2'], ENT_QUOTES, 'UTF-8') ?>" <?= $countryFilter === $c['iso2'] ? 'selected' : '' ?>><?= htmlspecialchars($c['name'], ENT_QUOTES, 'UTF-8') ?></option><?php endforeach; ?></select>
<select name="operator"><option value="">Tous les operateurs</option><?php foreach ($operators as $o): ?><option value="<?= $o['id'] ?>" <?= (string)$operatorFilter === (string)$o['id'] ? 'selected' : '' ?>><?= htmlspecialchars($o['brand'], ENT_QUOTES, 'UTF-8') ?></option><?php endforeach; ?></select>
<button type="submit" class="primary">Rechercher</button>
</form>
<table class="data-table sites-table">
<tr><th>Operateur</th><th>Pays</th><th>Technos</th><th>Cellules</th><th>Lieu</th><th>Lat/Lon</th><th></th></tr>
<?php foreach ($sites as $s): ?>
<tr><td><?= htmlspecialchars($s['operator_brand'], ENT_QUOTES, 'UTF-8') ?></td><td><?= htmlspecialchars($s['country_name'], ENT_QUOTES, 'UTF-8') ?></td><td><?= htmlspecialchars((string)($s['techs'] ?? ''), ENT_QUOTES, 'UTF-8') ?></td><td><?= (int)$s['cell_count'] ?></td><td><?= htmlspecialchars($s['address_desc'] ?? $s['location_desc'] ?? '', ENT_QUOTES, 'UTF-8') ?></td><td><?= htmlspecialchars((string)$s['lat'], ENT_QUOTES, 'UTF-8') ?>, <?= htmlspecialchars((string)$s['lon'], ENT_QUOTES, 'UTF-8') ?></td><td><button class="edit-btn" onclick="window.location='?site_id=<?= $s['id'] ?>'">Editer</button></td></tr>
<?php endforeach; ?>
<?php if (empty($sites)): ?><tr><td colspan="7" class="no-cadastre">Aucun site trouve.</td></tr><?php endif; ?>
</table>
</div>
<?php elseif ($editingSite): ?>
<h1>Edition du site &mdash; <?= htmlspecialchars($editingSite['operator_brand'], ENT_QUOTES, 'UTF-8') ?></h1>
<p class="page-subtitle"><a href="edit_site.php">&larr; Retour a la liste</a></p>
<?php if ($successMessage): ?><p class="msg-ok"><?= htmlspecialchars($successMessage, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<?php if ($errorMessage): ?><p class="msg-err"><?= htmlspecialchars($errorMessage, ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
<?php if ($coordsInvalid): ?><p class="msg-err">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.</p><?php endif; ?>
<div class="card">
<h2>Localisation</h2><div id="editMap"></div>
<form method="post" id="editSiteForm">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_site">
<input type="hidden" name="site_id" value="<?= (int)$editingSite['id'] ?>">
<div class="form-grid wide-3">
<div><label>Latitude</label><input type="number" step="any" name="lat" id="latInput" value="<?= htmlspecialchars((string)$editingSite['lat'], ENT_QUOTES, 'UTF-8') ?>" required></div>
<div><label>Longitude</label><input type="number" step="any" name="lon" id="lonInput" value="<?= htmlspecialchars((string)$editingSite['lon'], ENT_QUOTES, 'UTF-8') ?>" required></div>
<div><label>Operateur / pays (non modifiable ici)</label><input type="text" value="<?= htmlspecialchars($editingSite['operator_brand'] . ' - ' . $editingSite['country_name'], ENT_QUOTES, 'UTF-8') ?>" disabled></div>
</div>
<div class="form-grid" style="margin-top:.6rem"><div><label>Adresse / lieu</label><input type="text" name="address_desc" value="<?= htmlspecialchars($editingSite['address_desc'] ?? '', ENT_QUOTES, 'UTF-8') ?>"></div><div><label>Description (Location brute, optionnel)</label><input type="text" name="location_desc" value="<?= htmlspecialchars($editingSite['location_desc'] ?? '', ENT_QUOTES, 'UTF-8') ?>"></div></div>
<h2 style="margin-top:1.4rem">Cellules du site (<?= count($editingCells) ?>)</h2>
<table class="data-table" id="cellsTable">
<tr><th>Techno</th><th>Cell ID</th><th>Area code</th><th>Sub ID</th><th>eNB/RNC</th><th>Bande</th><th>Frequence</th><th>Azimut</th><th>Largeur</th><th>ARFCN</th><th></th></tr>
<?php foreach ($editingCells as $c): ?>
<tr>
<input type="hidden" name="row_cell_pk[]" value="<?= (int)$c['id'] ?>">
<td><select name="cell_tech[]"><?php foreach (['2G','3G','4G','5G','CDMA'] as $t): ?><option value="<?= $t ?>" <?= $c['tech'] === $t ? 'selected' : '' ?>><?= $t ?></option><?php endforeach; ?></select></td>
<td><input type="text" name="cell_id[]" value="<?= htmlspecialchars((string)$c['cell_id'], ENT_QUOTES, 'UTF-8') ?>"></td>
<td><input type="text" name="cell_area_code[]" value="<?= htmlspecialchars((string)$c['area_code'], ENT_QUOTES, 'UTF-8') ?>"></td>
<td><input type="text" name="cell_sub_id[]" value="<?= htmlspecialchars((string)($c['sub_id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"></td>
<td><input type="text" name="cell_sub_id2[]" value="<?= htmlspecialchars((string)($c['sub_id2'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"></td>
<td><input type="text" name="cell_band[]" value="<?= htmlspecialchars((string)($c['band'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"></td>
<td><input type="text" name="cell_frequency[]" value="<?= htmlspecialchars((string)($c['frequency'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"></td>
<td><input type="number" min="0" max="359" name="cell_azimuth[]" value="<?= htmlspecialchars((string)($c['azimuth'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"></td>
<td><input type="text" name="cell_bandwidth[]" value="<?= htmlspecialchars((string)($c['bandwidth'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"></td>
<td><input type="text" name="cell_arfcn[]" value="<?= htmlspecialchars((string)($c['arfcn'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"></td>
<td><button type="button" class="reject-small" onclick="deleteCell(<?= (int)$c['id'] ?>, <?= (int)$editingSiteId ?>)">Retirer</button></td>
</tr>
<?php endforeach; ?>
</table>
<button type="button" class="add-cell-btn" id="addCellBtn">+ Ajouter une bande</button>
<button type="submit" class="validate" style="margin-top:1rem">Enregistrer les modifications</button>
</form>
</div>
<div class="danger-zone"><h3>Zone sensible</h3><p class="page-subtitle" style="margin-bottom:.6rem">La suppression du site retire aussi TOUTES ses cellules de la base validee (irreversible).</p><form method="post" onsubmit="return confirm('Supprimer definitivement ce site et toutes ses cellules ?')"><?= csrf_field() ?><input type="hidden" name="action" value="delete_site"><input type="hidden" name="site_id" value="<?= (int)$editingSiteId ?>"><button type="submit" class="reject">Supprimer ce site</button></form></div>
<form method="post" id="deleteCellForm" style="display:none"><?= csrf_field() ?><input type="hidden" name="action" value="delete_cell"><input type="hidden" name="cell_id_pk" id="deleteCellPk"><input type="hidden" name="site_id" id="deleteCellSiteId"></form>
<script>
function deleteCell(cellPk, siteId) {
if (!confirm('Retirer cette cellule du site ?')) return;
document.getElementById('deleteCellPk').value = cellPk;
document.getElementById('deleteCellSiteId').value = siteId;
document.getElementById('deleteCellForm').submit();
}
let newCellIndex = 0;
document.getElementById('addCellBtn').addEventListener('click', function () {
const table = document.getElementById('cellsTable');
const row = document.createElement('tr');
row.className = 'new-cell-row';
row.innerHTML = `
<td><select name="new_cell_tech[${newCellIndex}]"><option value="2G">2G</option><option value="3G">3G</option><option value="4G" selected>4G</option><option value="5G">5G</option><option value="CDMA">CDMA</option></select></td>
<td><input type="text" name="new_cell_id[${newCellIndex}]" placeholder="Cell ID"></td>
<td><input type="text" name="new_cell_area_code[${newCellIndex}]" placeholder="Area code"></td>
<td><input type="text" name="new_cell_sub_id[${newCellIndex}]" placeholder="Sub ID"></td>
<td><input type="text" name="new_cell_sub_id2[${newCellIndex}]" placeholder="eNB/RNC"></td>
<td><input type="text" name="new_cell_band[${newCellIndex}]" placeholder="Bande (ex: B7)"></td>
<td><input type="text" name="new_cell_frequency[${newCellIndex}]" placeholder="Frequence"></td>
<td><input type="number" min="0" max="359" name="new_cell_azimuth[${newCellIndex}]" placeholder="Azimut"></td>
<td><input type="text" name="new_cell_bandwidth[${newCellIndex}]" placeholder="Largeur"></td>
<td><input type="text" name="new_cell_arfcn[${newCellIndex}]" placeholder="ARFCN"></td>
<td><button type="button" class="reject-small" onclick="this.closest('tr').remove()">Annuler</button></td>
`;
table.appendChild(row);
newCellIndex++;
});
try {
const editMap = L.map('editMap').setView([<?= $mapLat ?>, <?= $mapLon ?>], <?= $coordsInvalid ? 6 : 16 ?>);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors'
}).addTo(editMap);
<?php if (!empty($editingSite['cadastre_wms_url']) && !empty($editingSite['cadastre_wms_layer'])): ?>
L.tileLayer.wms(
<?= json_encode($editingSite['cadastre_wms_url'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>,
{
layers: <?= json_encode($editingSite['cadastre_wms_layer'], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>,
format: 'image/png',
transparent: true,
opacity: 0.6,
attribution: <?= json_encode($editingSite['cadastre_attribution_html'] ?? '', JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>
}
).addTo(editMap);
<?php endif; ?>
const marker = L.marker([<?= $mapLat ?>, <?= $mapLon ?>], {draggable: true}).addTo(editMap);
marker.on('dragend', function (e) {
const pos = e.target.getLatLng();
document.getElementById('latInput').value = pos.lat.toFixed(6);
document.getElementById('lonInput').value = pos.lng.toFixed(6);
});
setTimeout(() => editMap.invalidateSize(), 100);
window.addEventListener('resize', () => editMap.invalidateSize());
} catch (err) {
document.getElementById('editMap').innerHTML = '<p style="padding:1rem;color:#b71c1c">Erreur de chargement de la carte.</p>';
console.error('Erreur Leaflet sur edit_site.php :', err);
}
</script>
<?php else: ?>
<p class="msg-err">Site introuvable.</p>
<p><a href="edit_site.php">&larr; Retour a la liste</a></p>
<?php endif; ?>
</div>
</body>
</html>
+239
View File
@@ -0,0 +1,239 @@
<?php
/**
* admin/export.php
* Menu de navigation mis a jour (ajout Import .ntm, Edition de site).
*/
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();
const TECH_RADIO_LABEL_EXPORT = ['2G' => '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();
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"><title>NetMonster Admin - Export</title>
<link rel="stylesheet" href="assets/theme.css">
<style>
.export-options { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; }
.export-card { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 1rem; background: var(--surface-alt); }
.export-card h3 { margin: 0 0 .4rem; font-size: .95rem; }
.export-card p { margin: 0 0 .8rem; font-size: .82rem; color: var(--text-muted); }
</style>
</head>
<body>
<?php render_admin_nav('export.php', $user); ?>
<div class="page-wrap">
<h1>Export de la base pour mise en production</h1>
<p class="page-subtitle">Telechargez la base validee (sites + cellules) pour l'envoyer sur votre serveur de diffusion.</p>
<div class="stats-bar">
<div class="stat-chip"><strong><?= $totalSites ?></strong>site(s) valides</div>
<div class="stat-chip"><strong><?= $totalCells ?></strong>cellule(s) validees</div>
<div class="stat-chip"><strong><?= $lastValidated ? htmlspecialchars(date('d/m/Y H:i', strtotime($lastValidated))) : '—' ?></strong>derniere validation</div>
</div>
<div class="card">
<h2>Repartition par pays</h2>
<table class="data-table">
<tr><th>Pays</th><th>Sites</th><th>Cellules</th></tr>
<?php foreach ($byCountryStats as $s): ?>
<tr>
<td><?= htmlspecialchars($s['country_name']) ?></td>
<td><?= (int)$s['site_count'] ?></td>
<td><?= (int)$s['cell_count'] ?></td>
</tr>
<?php endforeach; ?>
</table>
</div>
<div class="card">
<h2>Filtrer avant export</h2>
<p class="page-subtitle" style="margin-bottom:0.8rem;">Laissez sur "Tous" pour exporter l'integralite de la base validee.</p>
<form class="filters" method="get" id="exportFilters">
<select name="country">
<option value="">Tous les pays</option>
<?php foreach ($countries as $c): ?>
<option value="<?= htmlspecialchars($c['iso2']) ?>"><?= htmlspecialchars($c['name']) ?></option>
<?php endforeach; ?>
</select>
<select name="operator">
<option value="">Tous les operateurs</option>
<?php foreach ($operators as $o): ?>
<option value="<?= $o['id'] ?>"><?= htmlspecialchars($o['brand']) ?> (<?= htmlspecialchars($o['mcc'] . '-' . $o['mnc']) ?>)</option>
<?php endforeach; ?>
</select>
<select name="tech">
<option value="">Toutes technos</option>
<?php foreach (['2G', '3G', '4G', '5G', 'CDMA'] as $t): ?>
<option value="<?= $t ?>"><?= $t ?></option>
<?php endforeach; ?>
</select>
</form>
<div class="export-options">
<div class="export-card">
<h3>Format .ntm</h3>
<p>Reconstruit le format d'origine NetMonster (CSV ";"), Location recomposee depuis les champs structures, pret a etre republie tel quel.</p>
<button type="button" class="primary" onclick="downloadExport('ntm')">Telecharger en .ntm</button>
</div>
<div class="export-card">
<h3>Format .csv</h3>
<p>Format tabulaire avec colonnes separees (bande, frequence, azimut, largeur de bande), pratique pour verification dans un tableur.</p>
<button type="button" class="primary" onclick="downloadExport('csv')">Telecharger en .csv</button>
</div>
</div>
</div>
</div>
<script>
function downloadExport(format) {
const form = document.getElementById('exportFilters');
const params = new URLSearchParams(new FormData(form));
params.set('download', format);
window.location.href = 'export.php?' + params.toString();
}
</script>
</body>
</html>