Chantier archivage flotte : restructuration par boîtier (pas seulement les bateaux affectés), quarantaine MMSI/sans_affectation avec script de réexamen dédié (cron_quarantaine.php), rejet des erreurs techniques non transitoires, fonctions communes extraites (fonctions_archivage.php), renommages de cohérence (TB_Affectations_bateaux, TB_Generations_bateaux, ID_Bateau_affecte), traçabilité MMSI complète (cf synthèse §38-39)

This commit is contained in:
2026-10-02 09:21:59 +00:00
parent 1dd4d0c288
commit 3a9056f5bc
5 changed files with 1084 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
<?php
// ============================================================================
// fonctions_archivage.php - Fonctions communes à cron_archivage.php (traitement
// initial des archives reçues des CM5) et cron_quarantaine.php (réexamen des
// archives mises en quarantaine) - cf synthèse §32-39.
//
// Nécessite d'avoir déjà inclus config/config.php et config/fonctions_bdd.php,
// et que CLE_CHIFFREMENT_ARCHIVE, BDD_BATEAUX_HOST/PORT/USER/PASSWORD soient
// définis (via config/confidentiel.php).
// ============================================================================
// ----------------------------------------------------------------------------
// Répertoires partagés entre les deux scripts (cf synthèse §38) :
// - REPERTOIRE_VERSIONS_SQL : fichiers de migration SQL (lecture seule).
// - REPERTOIRE_QUARANTAINE : archives en attente d'une décision administrative
// (MMSI divergent, ou aucune affectation active) - écrit par
// cron_archivage.php, lu ET écrit par cron_quarantaine.php.
// - REPERTOIRE_ERREURS : archives rejetées suite à une erreur technique non
// transitoire - écrit par les deux scripts, à usage de suivi manuel.
// ----------------------------------------------------------------------------
const REPERTOIRE_VERSIONS_SQL = "/home/koriolan/versions/shipheart/ressources/sql";
const REPERTOIRE_QUARANTAINE = "/home/koriolan/bateaux/archives/quarantaines";
const REPERTOIRE_ERREURS = "/home/koriolan/bateaux/archives/erreurs";
// ----------------------------------------------------------------------------
// Résout l'affectation active d'un boîtier (par adresse MAC) - cf synthèse
// §38. Renvoie ['existe' => bool, 'id_bateau' => int|null]. 'existe' => false
// couvre deux cas distincts à cet endroit, traités de la même façon : boîtier
// jamais affecté (première connexion), ou affecté par le passé puis retiré
// (toutes ses lignes ont une Date_fin renseignée).
// ----------------------------------------------------------------------------
function Resoudre_affectation_active($db, $adresse_mac)
{
$requete = "SELECT ID_Bateau FROM TB_Affectations_bateaux WHERE Adresse_mac=? AND Date_fin IS NULL";
$stmt = $db->prepare($requete);
$stmt->bind_param('s', $adresse_mac);
$stmt->execute();
$stmt->bind_result($id_bateau);
$trouve = $stmt->fetch();
$stmt->close();
if (!$trouve) return ['existe' => false, 'id_bateau' => null];
return ['existe' => true, 'id_bateau' => $id_bateau];
}
// ----------------------------------------------------------------------------
// Déchiffre + décompresse le contenu brut d'une archive (même chaîne openssl,
// exactement, que côté CM5 - cf synthèse §33.2), puis extrait les champs de
// son en-tête (# [Champ=Valeur]). Renvoie ['ok'=>true, 'entete'=>[...],
// 'sql'=>string] ou ['ok'=>false, 'raison'=>string].
// Nécessite CLE_CHIFFREMENT_ARCHIVE (config/confidentiel.php) - même valeur,
// exactement, que côté CM5 (cf synthèse §35.6).
// ----------------------------------------------------------------------------
function Decrypter_archive($contenu_chiffre)
{
$fichier_enc = tempnam(sys_get_temp_dir(), 'arch_enc_');
$fichier_gz = $fichier_enc . '.gz';
file_put_contents($fichier_enc, $contenu_chiffre);
$commande = "openssl enc -aes-256-cbc -pbkdf2 -iter 10000 -md sha256 -d -in " . escapeshellarg($fichier_enc)
. " -out " . escapeshellarg($fichier_gz) . " -pass pass:" . escapeshellarg(CLE_CHIFFREMENT_ARCHIVE) . " 2>&1";
exec($commande, $sortie, $code_retour);
if ($code_retour !== 0)
{
unlink($fichier_enc);
return ['ok' => false, 'raison' => "échec du déchiffrement"];
}
$sql = @shell_exec("gzip -dc " . escapeshellarg($fichier_gz) . " 2>&1");
unlink($fichier_enc);
@unlink($fichier_gz);
if ($sql === null || $sql === '')
{
return ['ok' => false, 'raison' => "échec de la décompression"];
}
// Lecture des lignes d'en-tête (uniquement les toutes premières lignes '# [Champ=Valeur]') :
$entete = [];
foreach (explode("\n", $sql) as $ligne)
{
if (!preg_match('/^#\s*\[([A-Za-z_]+)=(.*)\]\s*$/', $ligne, $m)) break; // fin de l'en-tête dès la 1ère ligne qui ne correspond plus
$entete[$m[1]] = $m[2];
}
return ['ok' => true, 'entete' => $entete, 'sql' => $sql];
}
// ----------------------------------------------------------------------------
// Résout la génération active d'un bateau (cf synthèse §35.4). Renvoie
// ['existe'=>bool, 'cle'=>int|null, 'numero'=>int|null, 'nom_base'=>string|null]
// - 'existe'=>false signifie "aucune génération active" (tout premier bateau,
// cf synthèse §35.5 point 4), PAS une erreur.
// ----------------------------------------------------------------------------
function Resoudre_generation_active($db, $id_bateau)
{
$requete = "SELECT Cle_primaire, Numero_generation, Nom_base FROM TB_Generations_bateaux WHERE ID_Bateau=? AND Date_fin IS NULL";
$stmt = $db->prepare($requete);
$stmt->bind_param('i', $id_bateau);
$stmt->execute();
$stmt->bind_result($cle, $numero, $nom_base);
$trouve = $stmt->fetch();
$stmt->close();
if (!$trouve) return ['existe' => false, 'cle' => null, 'numero' => null, 'nom_base' => null];
return ['existe' => true, 'cle' => $cle, 'numero' => $numero, 'nom_base' => $nom_base];
}
// ----------------------------------------------------------------------------
// Détermine si cette archive doit démarrer une NOUVELLE génération (§35.5) :
// - 'factory' ou 'restauration' dans l'en-tête -> toujours une nouvelle
// génération, quelle que soit la génération active actuelle.
// - 'normale' mais aucune génération active -> tout premier bateau,
// création de la génération 1 (même traitement que factory/restauration
// à partir de ce point, cf synthèse §35.5).
// - 'normale' avec une génération active -> pas de nouvelle génération,
// traitement incrémental habituel dans la génération existante.
// ----------------------------------------------------------------------------
function Nouvelle_generation_necessaire($origine_bdd, $generation_active)
{
if ($origine_bdd === 'factory' || $origine_bdd === 'restauration') return true;
return !$generation_active['existe'];
}
// ----------------------------------------------------------------------------
// Résout le nom du fichier de migration SQL correspondant à une version
// logicielle donnée (Version_soft_no_package, transmis dans l'en-tête de
// chaque archive - cf synthèse §32.2). Renvoie le nom de fichier (string),
// ou false si la version est inconnue ou la ligne TB_Versions_sql absente.
// ----------------------------------------------------------------------------
// ----------------------------------------------------------------------------
// Compare le MMSI reçu à celui déjà enregistré pour ce bateau (cf synthèse
// §32.3). Si TB_Bateaux.MMSI est NULL, la première valeur reçue est affectée
// automatiquement (aucun risque, rien à contredire). Renvoie :
// ['statut' => 'ok']
// ['statut' => 'quarantaine', 'attendu' => ..., 'recu' => ...]
// ----------------------------------------------------------------------------
function Verifier_mmsi($db_principale, $id_bateau, $mmsi_recu)
{
$requete = "SELECT MMSI FROM TB_Bateaux WHERE Cle_primaire=?";
$stmt = $db_principale->prepare($requete);
$stmt->bind_param('i', $id_bateau);
$stmt->execute();
$stmt->bind_result($mmsi_attendu);
$stmt->fetch();
$stmt->close();
if ($mmsi_attendu === null)
{
$requete = "UPDATE TB_Bateaux SET MMSI=? WHERE Cle_primaire=?";
$stmt = $db_principale->prepare($requete);
$stmt->bind_param('si', $mmsi_recu, $id_bateau);
$stmt->execute();
$stmt->close();
// Auto-affectation : l'attendu devient le reçu (rien à contredire) - toujours renseigné en sortie,
// jamais NULL, pour ne jamais laisser planer une ambiguïté entre "MMSI inconnu" et "MMSI connu mais
// pas indiqué ici" (cf synthèse §39) :
return ['statut' => 'ok', 'recu' => $mmsi_recu, 'attendu' => $mmsi_recu];
}
if ($mmsi_attendu === $mmsi_recu) return ['statut' => 'ok', 'recu' => $mmsi_recu, 'attendu' => $mmsi_attendu];
return ['statut' => 'quarantaine', 'attendu' => $mmsi_attendu, 'recu' => $mmsi_recu];
}
function Resoudre_fichier_sql($db, $version_package)
{
$requete = "SELECT vs.Fichier
FROM TB_Versions v
JOIN TB_Versions_sql vs ON vs.Cle_primaire = v.ID_Version_sql
WHERE v.Nom = ?";
$stmt = $db->prepare($requete);
$stmt->bind_param('s', $version_package);
$stmt->execute();
$stmt->bind_result($fichier);
$trouve = $stmt->fetch();
$stmt->close();
if (!$trouve) return false;
return $fichier;
}
// ----------------------------------------------------------------------------
// Remplace le nom de base littéral "shipheart" (utilisé dans Factory_structure
// pour le CM5 lui-même) par le vrai nom de la base par bateau/génération
// côté serveur (cf synthèse §35.4/36) - ancré précisément sur le contexte
// (DROP/CREATE DATABASE, USE), jamais un remplacement aveugle du mot : une
// valeur de configuration ("logo_shipheart.png") contient aussi ce mot sans
// aucun rapport avec un nom de base. Renvoie [contenu_adapte, nb_remplacements].
// ----------------------------------------------------------------------------
function Adapter_nom_base($contenu_sql, $nom_base_cible)
{
$nom_echappe = '`' . $nom_base_cible . '`';
$contenu_sql = preg_replace('/\bDATABASE IF EXISTS `?shipheart`?\b/', 'DATABASE IF EXISTS ' . $nom_echappe, $contenu_sql, -1, $n1);
$contenu_sql = preg_replace('/\bDATABASE IF NOT EXISTS `?shipheart`?\b/', 'DATABASE IF NOT EXISTS ' . $nom_echappe, $contenu_sql, -1, $n2);
$contenu_sql = preg_replace('/\bUSE `?shipheart`?\s*;/', 'USE ' . $nom_echappe . ';', $contenu_sql, -1, $n3);
return [$contenu_sql, $n1 + $n2 + $n3];
}
// ----------------------------------------------------------------------------
// Crée une nouvelle génération pour un bateau (cf synthèse §35.4/35.5/36) :
// calcule le nom de la nouvelle base, adapte et exécute le SQL fourni (déjà
// résolu par Extraire_zone_usine), clôture l'ancienne génération active si
// elle existe (jamais supprimée), puis enregistre la nouvelle dans
// TB_Generations_bateaux. Renvoie ['ok'=>bool, 'nom_base'=>string, ...].
// Exécution via le client mysql en ligne de commande (comme côté CM5,
// updater.php) - un bloc SQL avec DROP/CREATE DATABASE ne se prête pas bien
// à mysqli::multi_query.
// ----------------------------------------------------------------------------
function Creer_generation($db_principale, $id_bateau, $motif, $contenu_sql)
{
$generation_actuelle = Resoudre_generation_active($db_principale, $id_bateau);
$prochain_numero = $generation_actuelle['existe'] ? $generation_actuelle['numero'] + 1 : 1;
$nom_base = "ship2net_bateau_{$id_bateau}_{$prochain_numero}";
[$sql_adapte, $nb_remplacements] = Adapter_nom_base($contenu_sql, $nom_base);
if ($nb_remplacements < 1)
{
return ['ok' => false, 'raison' => "aucune substitution de nom de base effectuée - fichier SQL probablement mal formé"];
}
$fichier_temp = tempnam(sys_get_temp_dir(), 'usine_');
file_put_contents($fichier_temp, $sql_adapte);
$commande = "mysql --host=" . escapeshellarg(BDD_BATEAUX_HOST) . " --port=" . escapeshellarg(BDD_BATEAUX_PORT)
. " --user=" . escapeshellarg(BDD_BATEAUX_USER) . " --password=" . escapeshellarg(BDD_BATEAUX_PASSWORD)
. " < " . escapeshellarg($fichier_temp) . " 2>&1";
exec($commande, $sortie, $code_retour);
unlink($fichier_temp);
if ($code_retour !== 0)
{
return ['ok' => false, 'raison' => "échec de l'exécution SQL : " . implode(" | ", $sortie)];
}
if ($generation_actuelle['existe'])
{
$requete = "UPDATE TB_Generations_bateaux SET Date_fin=NOW() WHERE Cle_primaire=?";
$stmt = $db_principale->prepare($requete);
$stmt->bind_param('i', $generation_actuelle['cle']);
$stmt->execute();
$stmt->close();
}
$requete = "INSERT INTO TB_Generations_bateaux (ID_Bateau, Numero_generation, Nom_base, Motif) VALUES (?, ?, ?, ?)";
$stmt = $db_principale->prepare($requete);
$stmt->bind_param('iiss', $id_bateau, $prochain_numero, $nom_base, $motif);
if (!$stmt->execute())
{
$erreur = $db_principale->error;
$stmt->close();
return ['ok' => false, 'raison' => "base créée mais son enregistrement dans TB_Generations_bateaux a échoué : " . $erreur];
}
$stmt->close();
return ['ok' => true, 'nom_base' => $nom_base, 'numero' => $prochain_numero];
}
// ----------------------------------------------------------------------------
// Extrait la zone "usine" d'un fichier de migration (Factory_structure +
// Factory_data ensemble, jusqu'à la fin du fichier) - pour une génération
// neuve (cf synthèse §32.2, §35.5). Renvoie false si le marqueur est
// introuvable (fichier mal formé - ne devrait jamais arriver en pratique).
// ----------------------------------------------------------------------------
function Extraire_zone_usine($contenu_fichier)
{
if (!preg_match('/^#\s*Factory_structure\b/m', $contenu_fichier, $m, PREG_OFFSET_CAPTURE)) return false;
return substr($contenu_fichier, $m[0][1]);
}
// ----------------------------------------------------------------------------
// Extrait les segments incrémentaux entre deux versions (exclusive pour le
// départ, inclusive pour la cible) - pour une mise à jour incrémentale d'une
// génération déjà existante (cf synthèse §32.2). $depart et $cible sont des
// noms de fichier ("shipheart_sql_AAMMJJ_HHMMSS.sql"), résolus au préalable
// via TB_Versions/TB_Versions_sql (bloc 4a). Si $depart === $cible, renvoie
// une chaîne vide (rien à migrer - cas normal si le bateau n'a pas été mis à
// jour depuis la dernière archive). Renvoie false si l'un des deux marqueurs
// est introuvable.
// ----------------------------------------------------------------------------
function Extraire_segments_incrementaux($contenu_fichier, $depart, $cible)
{
if ($depart === $cible) return '';
$motif_depart = '/^#\s*Fin de ' . preg_quote($depart, '/') . '\b.*$/m';
if (!preg_match($motif_depart, $contenu_fichier, $m_depart, PREG_OFFSET_CAPTURE)) return false;
$debut = $m_depart[0][1] + strlen($m_depart[0][0]);
$motif_cible = '/^#\s*Fin de ' . preg_quote($cible, '/') . '\b.*$/m';
if (!preg_match($motif_cible, $contenu_fichier, $m_cible, PREG_OFFSET_CAPTURE)) return false;
$fin = $m_cible[0][1] + strlen($m_cible[0][0]);
if ($fin <= $debut) return false; // Ordre incohérent (cible avant départ) - ne devrait jamais arriver, "pas d'update en arrière" (§32.2)
return substr($contenu_fichier, $debut, $fin - $debut);
}
// ----------------------------------------------------------------------------
// Détermine la liste des tables fragmentées (incrémentales) d'une base bateau,
// par la MÊME méthode que côté CM5 (SHOW TABLES LIKE 'TB_Mesures%' + TB_Historique,
// cf sauvegarde_bdd()/bdd_admin.c) - jamais une liste codée en dur, pour rester
// robuste si une nouvelle table TB_Mesures_xxx apparaît un jour.
// ----------------------------------------------------------------------------
function Resoudre_tables_fragmentees($connexion_bateau)
{
$tables = ['TB_Historique'];
$resultat = $connexion_bateau->query("SHOW TABLES LIKE 'TB_Mesures%'");
while ($ligne = $resultat->fetch_row()) $tables[] = $ligne[0];
return $tables;
}
// ----------------------------------------------------------------------------
// Charge le contenu réel d'une archive (déjà déchiffré) dans la base d'une
// génération : les tables permanentes sont vidées puis rechargées, les tables
// incrémentales (fragmentées) sont uniquement complétées (jamais vidées) -
// cf synthèse §32/37. Le contenu de l'archive (ses INSERT) complète donc
// naturellement les tables fragmentées et remplace les permanentes, sans
// avoir besoin de séparer les deux : seul le DELETE préalable fait la
// différence entre les deux comportements. Exécution via mysql CLI (comme
// Creer_generation), SET FOREIGN_KEY_CHECKS=0 par précaution (l'ordre des
// INSERT du dump ne respecte pas forcément les dépendances).
// ----------------------------------------------------------------------------
function Charger_contenu_archive($nom_base, $sql_archive)
{
$connexion_bateau = new mysqli(BDD_BATEAUX_HOST, BDD_BATEAUX_USER, BDD_BATEAUX_PASSWORD, $nom_base, (int)BDD_BATEAUX_PORT);
if ($connexion_bateau->connect_error)
{
return ['ok' => false, 'raison' => "connexion à {$nom_base} impossible : " . $connexion_bateau->connect_error];
}
$tables_fragmentees = Resoudre_tables_fragmentees($connexion_bateau);
$resultat_tables = $connexion_bateau->query("SHOW TABLES");
$toutes_tables = [];
while ($ligne = $resultat_tables->fetch_row()) $toutes_tables[] = $ligne[0];
$tables_permanentes = array_diff($toutes_tables, $tables_fragmentees);
$connexion_bateau->close();
$script = "SET FOREIGN_KEY_CHECKS=0;\n";
foreach ($tables_permanentes as $table)
{
$script .= "DELETE FROM `{$table}`;\n";
}
$script .= $sql_archive . "\n";
$script .= "SET FOREIGN_KEY_CHECKS=1;\n";
$fichier_temp = tempnam(sys_get_temp_dir(), 'charge_');
file_put_contents($fichier_temp, $script);
$commande = "mysql --host=" . escapeshellarg(BDD_BATEAUX_HOST) . " --port=" . escapeshellarg(BDD_BATEAUX_PORT)
. " --user=" . escapeshellarg(BDD_BATEAUX_USER) . " --password=" . escapeshellarg(BDD_BATEAUX_PASSWORD)
. " " . escapeshellarg($nom_base) . " < " . escapeshellarg($fichier_temp) . " 2>&1";
exec($commande, $sortie, $code_retour);
unlink($fichier_temp);
if ($code_retour !== 0)
{
return ['ok' => false, 'raison' => "échec du chargement : " . implode(" | ", $sortie)];
}
return ['ok' => true];
}