From 3a9056f5bc14a9c55fee16bd18e4cabc9a07597d Mon Sep 17 00:00:00 2001 From: Pierre Date: Fri, 2 Oct 2026 09:21:59 +0000 Subject: [PATCH] =?UTF-8?q?Chantier=20archivage=20flotte=20:=20restructura?= =?UTF-8?q?tion=20par=20bo=C3=AEtier=20(pas=20seulement=20les=20bateaux=20?= =?UTF-8?q?affect=C3=A9s),=20quarantaine=20MMSI/sans=5Faffectation=20avec?= =?UTF-8?q?=20script=20de=20r=C3=A9examen=20d=C3=A9di=C3=A9=20(cron=5Fquar?= =?UTF-8?q?antaine.php),=20rejet=20des=20erreurs=20techniques=20non=20tran?= =?UTF-8?q?sitoires,=20fonctions=20communes=20extraites=20(fonctions=5Farc?= =?UTF-8?q?hivage.php),=20renommages=20de=20coh=C3=A9rence=20(TB=5FAffecta?= =?UTF-8?q?tions=5Fbateaux,=20TB=5FGenerations=5Fbateaux,=20ID=5FBateau=5F?= =?UTF-8?q?affecte),=20tra=C3=A7abilit=C3=A9=20MMSI=20compl=C3=A8te=20(cf?= =?UTF-8?q?=20synth=C3=A8se=20=C2=A738-39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cron_archivage.php | 480 ++++++++++++++++++++++++++++++++++++++++ cron_quarantaine.php | 220 ++++++++++++++++++ fonctions_archivage.php | 362 ++++++++++++++++++++++++++++++ sql/detail_erreur.sql | 7 + sql/renommages.sql | 15 ++ 5 files changed, 1084 insertions(+) create mode 100644 cron_archivage.php create mode 100644 cron_quarantaine.php create mode 100644 fonctions_archivage.php create mode 100644 sql/detail_erreur.sql create mode 100644 sql/renommages.sql diff --git a/cron_archivage.php b/cron_archivage.php new file mode 100644 index 0000000..cbbd82d --- /dev/null +++ b/cron_archivage.php @@ -0,0 +1,480 @@ + string, 'Adresse_netbird' => string]. +// ---------------------------------------------------------------------------- +function Lister_boitiers_connus($db) +{ + $requete = "SELECT Adresse_mac, Adresse_netbird FROM TB_Cartes + WHERE Adresse_netbird IS NOT NULL AND Adresse_netbird <> ''"; + $resultat = $db->query($requete); + $boitiers = []; + while ($ligne = $resultat->fetch_assoc()) $boitiers[] = $ligne; + return $boitiers; +} + + +// ---------------------------------------------------------------------------- +// Interroge GET /archives sur le boîtier d'un bateau. Renvoie : +// ['ok' => true, 'archives' => [...]] en cas de succès +// ['ok' => false, 'raison' => "..."] sinon (bateau injoignable, réponse +// invalide...) - jamais une erreur +// fatale, le cron doit continuer +// avec les autres bateaux. +// Le tri chronologique est déjà garanti côté API par le nom de fichier +// (cf synthèse §32.4) ; retrié ici aussi par précaution, sans coût réel. +// ---------------------------------------------------------------------------- +function Lister_archives_en_attente($adresse_netbird) +{ + if (empty($adresse_netbird)) + { + return ['ok' => false, 'raison' => "aucune adresse NetBird enregistrée pour ce boîtier"]; + } + + $url = "http://" . $adresse_netbird . ":" . PORT_API_CM5 . "/archives"; + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, TIMEOUT_CONNEXION_CM5); + curl_setopt($ch, CURLOPT_TIMEOUT, TIMEOUT_REQUETE_CM5); + $reponse_brute = curl_exec($ch); + $erreur_curl = curl_error($ch); + $code_http = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($reponse_brute === false) + { + return ['ok' => false, 'raison' => "injoignable (" . $erreur_curl . ")"]; + } + if ($code_http !== 200) + { + return ['ok' => false, 'raison' => "réponse HTTP " . $code_http]; + } + + $donnees = json_decode($reponse_brute, true); + if (!is_array($donnees) || ($donnees['status'] ?? '') !== 'ok' || !isset($donnees['archives'])) + { + return ['ok' => false, 'raison' => "réponse JSON invalide ou inattendue"]; + } + + $archives = $donnees['archives']; + usort($archives, fn($a, $b) => strcmp($a['nom'], $b['nom'])); + + return ['ok' => true, 'archives' => $archives]; +} + +// ---------------------------------------------------------------------------- +// Extrait l'empreinte (16 hex) portée par le nom de fichier - même format, +// exactement, que la validation déjà faite côté shipheart_api (§33.1). +// Renvoie false si le nom ne correspond pas au format attendu. +// ---------------------------------------------------------------------------- +function Extraire_empreinte_nom($nom_fichier) +{ + if (!preg_match('/^[0-9A-Fa-f]{12}_\d{6}_\d{6}_([0-9A-Fa-f]{16})\.sql\.gz\.enc$/', $nom_fichier, $m)) return false; + return strtolower($m[1]); +} + +// ---------------------------------------------------------------------------- +// GET /archives/ - récupère le contenu brut d'une archive précise. +// Même forme de retour que Lister_archives_en_attente() (['ok' => bool, ...]). +// ---------------------------------------------------------------------------- +function Recuperer_archive($adresse_netbird, $nom_fichier) +{ + $url = "http://" . $adresse_netbird . ":" . PORT_API_CM5 . "/archives/" . rawurlencode($nom_fichier); + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, TIMEOUT_CONNEXION_CM5); + curl_setopt($ch, CURLOPT_TIMEOUT, TIMEOUT_REQUETE_CM5); + $contenu = curl_exec($ch); + $erreur_curl = curl_error($ch); + $code_http = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($contenu === false) return ['ok' => false, 'raison' => "injoignable (" . $erreur_curl . ")"]; + if ($code_http !== 200) return ['ok' => false, 'raison' => "réponse HTTP " . $code_http]; + + return ['ok' => true, 'contenu' => $contenu]; +} + +// ---------------------------------------------------------------------------- +// Vérifie que le contenu récupéré correspond bien à l'empreinte portée par +// son nom - détecte une corruption de transfert (cf synthèse §32.4/33.1). +// Ne jamais supprimer l'archive côté CM5 si cette vérification échoue - une +// nouvelle tentative au prochain cycle récupérera à nouveau le même fichier. +// ---------------------------------------------------------------------------- +function Integrite_confirmee($nom_fichier, $contenu) +{ + $empreinte_attendue = Extraire_empreinte_nom($nom_fichier); + if ($empreinte_attendue === false) return false; + $empreinte_calculee = substr(hash('sha256', $contenu), 0, 16); + return $empreinte_calculee === $empreinte_attendue; +} + + + + +// ---------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- + +// Copie de sauvegarde des archives mises en quarantaine (MMSI ou affectation absente) - avant de +// confirmer la suppression côté CM5, pour ne jamais perdre le contenu pendant que la quarantaine est en +// attente (cf synthèse §38, correction d'un manque identifié en testant le mécanisme). + + + +// ---------------------------------------------------------------------------- +// Met une archive en quarantaine (divergence MMSI) : sauvegarde son contenu +// CHIFFRÉ tel que reçu (pour ne rien perdre pendant que la quarantaine est en +// attente - correction d'un manque identifié en testant le mécanisme, cf +// synthèse §38), enregistre la ligne dans TB_Archives_traitees, puis confirme +// la suppression côté CM5 (son intégrité a déjà été vérifiée avant cet appel). +// ---------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- +// Met une archive en quarantaine faute d'affectation active (boîtier jamais +// affecté, ou retiré - cf synthèse §38). Contrairement à la quarantaine MMSI, +// aucun déchiffrement n'est nécessaire : sans bateau à qui l'attribuer, ni +// l'en-tête ni le contenu ne servent à rien à ce stade. +// ---------------------------------------------------------------------------- +function Mettre_en_quarantaine_sans_affectation($db_principale, $adresse_netbird, $nom_fichier, $contenu_chiffre, $adresse_mac) +{ + if (!is_dir(REPERTOIRE_QUARANTAINE)) @mkdir(REPERTOIRE_QUARANTAINE, 0750, true); + file_put_contents(REPERTOIRE_QUARANTAINE . "/" . $nom_fichier, $contenu_chiffre); + + $statut = 'en_quarantaine_sans_affectation'; + $requete = "INSERT INTO TB_Archives_traitees (Nom_fichier, Adresse_mac, Statut) VALUES (?, ?, ?)"; + $stmt = $db_principale->prepare($requete); + $stmt->bind_param('sss', $nom_fichier, $adresse_mac, $statut); + $stmt->execute(); + $stmt->close(); + + Confirmer_suppression_archive($adresse_netbird, $nom_fichier); +} + +// ---------------------------------------------------------------------------- +// Rejette une archive suite à une erreur technique NON transitoire (cf +// synthèse §38) : déchiffrement, version inconnue, fichier SQL introuvable, +// échec de création de génération, échec de chargement. Sauvegarde le +// contenu chiffré dans un répertoire dédié (suivi manuel, reprise éventuelle +// une fois la cause corrigée), enregistre Statut='rejetee' avec le détail de +// l'erreur, puis confirme la suppression côté CM5 - inutile de laisser le +// CM5 retransmettre indéfiniment un fichier dont le contenu ne changera +// jamais. +// ---------------------------------------------------------------------------- +function Rejeter_archive($db_principale, $adresse_netbird, $nom_fichier, $contenu_chiffre, $adresse_mac, $id_bateau, $detail_erreur, $mmsi_recu = null, $mmsi_attendu = null) +{ + if (!is_dir(REPERTOIRE_ERREURS)) @mkdir(REPERTOIRE_ERREURS, 0750, true); + file_put_contents(REPERTOIRE_ERREURS . "/" . $nom_fichier, $contenu_chiffre); + + $statut = 'rejetee'; + $requete = "INSERT INTO TB_Archives_traitees + (Nom_fichier, Adresse_mac, ID_Bateau_affecte, ID_Bateau_final, Statut, Detail_erreur, MMSI_recu, MMSI_attendu, Date_resolution) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())"; + $stmt = $db_principale->prepare($requete); + $stmt->bind_param('ssiissss', $nom_fichier, $adresse_mac, $id_bateau, $id_bateau, $statut, $detail_erreur, $mmsi_recu, $mmsi_attendu); + $stmt->execute(); + $stmt->close(); + + Confirmer_suppression_archive($adresse_netbird, $nom_fichier); +} + +function Mettre_en_quarantaine_mmsi($db_principale, $adresse_netbird, $nom_fichier, $contenu_chiffre, $adresse_mac, $id_bateau, $mmsi_recu, $mmsi_attendu) +{ + if (!is_dir(REPERTOIRE_QUARANTAINE)) @mkdir(REPERTOIRE_QUARANTAINE, 0750, true); + file_put_contents(REPERTOIRE_QUARANTAINE . "/" . $nom_fichier, $contenu_chiffre); + + $statut = 'en_quarantaine_mmsi'; + $requete = "INSERT INTO TB_Archives_traitees + (Nom_fichier, Adresse_mac, ID_Bateau_affecte, Statut, MMSI_recu, MMSI_attendu) + VALUES (?, ?, ?, ?, ?, ?)"; + $stmt = $db_principale->prepare($requete); + $stmt->bind_param('ssisss', $nom_fichier, $adresse_mac, $id_bateau, $statut, $mmsi_recu, $mmsi_attendu); + $stmt->execute(); + $stmt->close(); + + Confirmer_suppression_archive($adresse_netbird, $nom_fichier); +} + + + + + + +// ---------------------------------------------------------------------------- +// Vérifie si cette archive a déjà été traitée (quel que soit le résultat) - +// garde-fou anti-double-traitement, en complément de l'index UNIQUE déjà +// présent sur TB_Archives_traitees.Nom_fichier (cf synthèse §32.6/34.3). +// ---------------------------------------------------------------------------- +function Archive_deja_traitee($db_principale, $nom_fichier) +{ + $requete = "SELECT 1 FROM TB_Archives_traitees WHERE Nom_fichier=?"; + $stmt = $db_principale->prepare($requete); + $stmt->bind_param('s', $nom_fichier); + $stmt->execute(); + $stmt->store_result(); + $trouve = $stmt->num_rows > 0; + $stmt->close(); + return $trouve; +} + +// ---------------------------------------------------------------------------- +// Enregistre le résultat du traitement d'une archive dans TB_Archives_traitees +// (cf synthèse §34.3). $id_generation peut être null (non encore résolu, ex : +// bateau sans aucune affectation). +// ---------------------------------------------------------------------------- +function Enregistrer_archive_traitee($db_principale, $nom_fichier, $adresse_mac, $id_bateau, $id_generation, $statut, $mmsi_recu = null, $mmsi_attendu = null) +{ + // MMSI_recu/MMSI_attendu toujours renseignés quand connus, même en cas de concordance parfaite - + // trace d'historique, et par principe : un NULL ne doit jamais pouvoir signifier à la fois "MMSI + // inconnu" et "MMSI connu mais non indiqué ici" (cf synthèse §39). + $requete = "INSERT INTO TB_Archives_traitees + (Nom_fichier, Adresse_mac, ID_Bateau_affecte, ID_Bateau_final, ID_Generation, Statut, MMSI_recu, MMSI_attendu, Date_resolution) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())"; + $stmt = $db_principale->prepare($requete); + $stmt->bind_param('ssiiisss', $nom_fichier, $adresse_mac, $id_bateau, $id_bateau, $id_generation, $statut, $mmsi_recu, $mmsi_attendu); + $ok = $stmt->execute(); + $stmt->close(); + return $ok; +} + +// ---------------------------------------------------------------------------- +// DELETE /archives/ - confirme le traitement auprès du CM5, qui peut +// alors supprimer le fichier local (cf synthèse §33.1). +// ---------------------------------------------------------------------------- +function Confirmer_suppression_archive($adresse_netbird, $nom_fichier) +{ + $url = "http://" . $adresse_netbird . ":" . PORT_API_CM5 . "/archives/" . rawurlencode($nom_fichier); + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, TIMEOUT_CONNEXION_CM5); + curl_setopt($ch, CURLOPT_TIMEOUT, TIMEOUT_REQUETE_CM5); + $reponse = curl_exec($ch); + $code_http = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + return ($reponse !== false && $code_http === 200); +} + + + +// ---------------------------------------------------------------------------- +// Bloc principal - pour l'instant, se limite à afficher ce qui serait traité. +// Les blocs suivants remplaceront cet affichage par le traitement réel. +// ---------------------------------------------------------------------------- +function Executer_cron_archivage() +{ + // Ce script tourne nativement sur le VPS (pas dans le conteneur ship2net-php) - contrairement au site + // web, il ne peut pas résoudre "ship2net-db" (nom DNS interne à Docker). Le port de ship2net-db a été + // publié sur 127.0.0.1:3307 uniquement (jamais exposé publiquement, ni même via NetBird) - le 3306 + // natif du port est déjà pris par l'instance MariaDB native du VPS, un usage différent (cf synthèse §37). + Ouverture_bdd("127.0.0.1:3307", BDD_USER, BDD_PASSWORD, BDD_DATABASE); + $db = $_SESSION['db']; + + $boitiers = Lister_boitiers_connus($db); + echo count($boitiers) . " boîtier(s) connu(s) avec une adresse NetBird renseignée.\n\n"; + + foreach ($boitiers as $boitier) + { + $adresse_mac = $boitier['Adresse_mac']; + $resultat = Lister_archives_en_attente($boitier['Adresse_netbird']); + + if (!$resultat['ok']) + { + echo "Boîtier {$adresse_mac} : " . $resultat['raison'] . " - ignoré pour ce cycle.\n"; + continue; + } + + $nb = count($resultat['archives']); + if ($nb === 0) continue; // rien à signaler pour ce boîtier ce cycle-ci + + // Résolu UNE SEULE FOIS par boîtier (pas par archive) : toutes les archives d'un même passage + // partagent la même adresse MAC, et l'affectation ne change jamais en cours d'exécution du + // script - seul un geste administratif explicite la modifie (cf synthèse §38). + $affectation = Resoudre_affectation_active($db, $adresse_mac); + $libelle = $affectation['existe'] ? "Bateau {$affectation['id_bateau']}" : "Boîtier {$adresse_mac} (aucune affectation active)"; + echo "{$libelle} : {$nb} archive(s) en attente.\n"; + + foreach ($resultat['archives'] as $archive) + { + $nom = $archive['nom']; + + if (Archive_deja_traitee($db, $nom)) + { + echo " - {$nom} : déjà traitée - confirmation de suppression seulement.\n"; + Confirmer_suppression_archive($boitier['Adresse_netbird'], $nom); + continue; + } + + $recuperation = Recuperer_archive($boitier['Adresse_netbird'], $nom); + if (!$recuperation['ok']) + { + echo " - {$nom} : ÉCHEC de récupération (" . $recuperation['raison'] . ") - nouvelle tentative au prochain cycle.\n"; + continue; + } + + if (!Integrite_confirmee($nom, $recuperation['contenu'])) + { + echo " - {$nom} : empreinte invalide, archive potentiellement corrompue - nouvelle tentative au prochain cycle.\n"; + continue; + } + + // Aucune affectation active (jamais affecté, ou retiré) : quarantaine directe, sans + // déchiffrement - ni l'en-tête ni le contenu ne servent à rien sans bateau à qui l'attribuer + // (cf synthèse §38) : + if (!$affectation['existe']) + { + echo " - {$nom} : aucune affectation active pour ce boîtier - mise en quarantaine.\n"; + Mettre_en_quarantaine_sans_affectation($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac); + continue; + } + $id_bateau = $affectation['id_bateau']; + + $dechiffrement = Decrypter_archive($recuperation['contenu']); + if (!$dechiffrement['ok']) + { + echo " - {$nom} : ÉCHEC de déchiffrement (" . $dechiffrement['raison'] . ") - rejetée.\n"; + Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Échec de déchiffrement : " . $dechiffrement['raison']); + continue; + } + + $origine_bdd = $dechiffrement['entete']['Origine_bdd'] ?? 'normale'; + $mmsi_recu = $dechiffrement['entete']['No_mmsi'] ?? null; + $mmsi_attendu_connu = null; // reste null si No_mmsi absent de l'en-tête - sinon toujours renseigné (cf synthèse §39) + if ($mmsi_recu !== null) + { + $verification_mmsi = Verifier_mmsi($db, $id_bateau, $mmsi_recu); + $mmsi_attendu_connu = $verification_mmsi['attendu']; + if ($verification_mmsi['statut'] === 'quarantaine') + { + echo " - {$nom} : MMSI reçu ({$mmsi_recu}) différent de celui attendu ({$verification_mmsi['attendu']}) - mise en quarantaine.\n"; + Mettre_en_quarantaine_mmsi($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, $mmsi_recu, $verification_mmsi['attendu']); + continue; + } + } + + $version_cible = $dechiffrement['entete']['Version_soft_no_package'] ?? null; + $generation_active = Resoudre_generation_active($db, $id_bateau); + $nouvelle_generation = Nouvelle_generation_necessaire($origine_bdd, $generation_active); + + echo " - {$nom} : récupérée et intègre (" . strlen($recuperation['contenu']) . " octets), origine=" . $origine_bdd . "\n"; + + $fichier_sql = Resoudre_fichier_sql($db, $version_cible); + if ($fichier_sql === false) + { + echo " ÉCHEC : version logicielle '{$version_cible}' inconnue de TB_Versions - rejetée.\n"; + Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Version logicielle '{$version_cible}' inconnue de TB_Versions", $mmsi_recu, $mmsi_attendu_connu); + continue; + } + $contenu_fichier_sql = @file_get_contents(REPERTOIRE_VERSIONS_SQL . "/" . $fichier_sql); + if ($contenu_fichier_sql === false) + { + echo " ÉCHEC : fichier {$fichier_sql} introuvable sur le VPS - rejetée.\n"; + Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Fichier {$fichier_sql} introuvable sur le VPS", $mmsi_recu, $mmsi_attendu_connu); + continue; + } + + if ($nouvelle_generation) + { + $zone_usine = Extraire_zone_usine($contenu_fichier_sql); + if ($zone_usine === false) + { + echo " ÉCHEC : marqueur Factory_structure introuvable dans {$fichier_sql} - rejetée.\n"; + Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Marqueur Factory_structure introuvable dans {$fichier_sql}", $mmsi_recu, $mmsi_attendu_connu); + continue; + } + // 'factory'/'restauration' est toujours un fait rapporté honnêtement par le bateau - à + // conserver tel quel pour le diagnostic, qu'une génération existait déjà côté serveur ou + // non. Seul 'normale' sans aucune génération existante (un bateau vraiment neuf, qui n'a + // jamais rien envoyé) devient 'creation_initiale' (cf synthèse §37). + if ($origine_bdd === 'factory' || $origine_bdd === 'restauration') + { + $motif = $origine_bdd; + } + else + { + $motif = 'creation_initiale'; + } + $resultat_generation = Creer_generation($db, $id_bateau, $motif, $zone_usine); + if (!$resultat_generation['ok']) + { + echo " ÉCHEC de création de génération : " . $resultat_generation['raison'] . " - rejetée.\n"; + Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Échec de création de génération : " . $resultat_generation['raison'], $mmsi_recu, $mmsi_attendu_connu); + continue; + } + $nom_base_cible = $resultat_generation['nom_base']; + echo " Nouvelle génération créée : {$nom_base_cible} (motif={$motif}).\n"; + } + else + { + $nom_base_cible = $generation_active['nom_base']; + echo " Génération existante : {$nom_base_cible}.\n"; + // Bloc suivant : rejeu des segments incrémentaux si la version a changé, + // via Extraire_segments_incrementaux() + exécution (même mécanique que Creer_generation). + } + $chargement = Charger_contenu_archive($nom_base_cible, $dechiffrement['sql']); + if (!$chargement['ok']) + { + echo " ÉCHEC de chargement dans {$nom_base_cible} : " . $chargement['raison'] . " - rejetée.\n"; + Rejeter_archive($db, $boitier['Adresse_netbird'], $nom, $recuperation['contenu'], $adresse_mac, $id_bateau, "Échec de chargement dans {$nom_base_cible} : " . $chargement['raison'], $mmsi_recu, $mmsi_attendu_connu); + continue; + } + echo " Contenu chargé dans {$nom_base_cible}.\n"; + + // Résolution de la vraie Cle_primaire de la génération (celle juste créée, ou l'existante) : + $generation_pour_enregistrement = Resoudre_generation_active($db, $id_bateau); + Enregistrer_archive_traitee($db, $nom, $adresse_mac, $id_bateau, $generation_pour_enregistrement['cle'], 'traitee', $mmsi_recu, $mmsi_attendu_connu); + + if (Confirmer_suppression_archive($boitier['Adresse_netbird'], $nom)) + { + echo " Archive confirmée et supprimée côté CM5.\n"; + } + else + { + echo " AVERTISSEMENT : confirmation de suppression échouée (le fichier restera côté CM5, sans conséquence - cf synthèse §32.5).\n"; + } + } + } + + Fermeture_bdd(); +} + +Executer_cron_archivage(); diff --git a/cron_quarantaine.php b/cron_quarantaine.php new file mode 100644 index 0000000..35fde55 --- /dev/null +++ b/cron_quarantaine.php @@ -0,0 +1,220 @@ +prepare($requete); + $stmt->bind_param('s', $nom_fichier); + $stmt->execute(); + $stmt->bind_result($statut); + $trouve = $stmt->fetch(); + $stmt->close(); + if (!$trouve) return null; + return ['statut' => $statut]; +} + +// ---------------------------------------------------------------------------- +// Met à jour une ligne TB_Archives_traitees existante (contrairement à +// cron_archivage.php, qui en crée toujours une nouvelle) - la ligne de +// quarantaine, créée au moment de la mise en attente, est ici tranchée +// définitivement (cf synthèse §39). +// ---------------------------------------------------------------------------- +function Trancher_archive_en_attente($db, $nom_fichier, $id_bateau_final, $id_generation, $statut, $detail_erreur = null, $mmsi_recu = null, $mmsi_attendu = null) +{ + // MMSI_recu/MMSI_attendu ne sont mis à jour QUE si connus à cet appel (une archive mise en quarantaine + // 'sans_affectation' n'avait pas encore de MMSI à comparer) - sinon, on conserve les valeurs déjà + // enregistrées au moment de la mise en quarantaine initiale, plutôt que de les écraser par NULL + // (cf synthèse §39) : + if ($mmsi_recu !== null) + { + $requete = "UPDATE TB_Archives_traitees + SET ID_Bateau_final=?, ID_Generation=?, Statut=?, Detail_erreur=?, MMSI_recu=?, MMSI_attendu=?, Date_resolution=NOW() + WHERE Nom_fichier=?"; + $stmt = $db->prepare($requete); + $stmt->bind_param('iisssss', $id_bateau_final, $id_generation, $statut, $detail_erreur, $mmsi_recu, $mmsi_attendu, $nom_fichier); + } + else + { + $requete = "UPDATE TB_Archives_traitees + SET ID_Bateau_final=?, ID_Generation=?, Statut=?, Detail_erreur=?, Date_resolution=NOW() + WHERE Nom_fichier=?"; + $stmt = $db->prepare($requete); + $stmt->bind_param('iisss', $id_bateau_final, $id_generation, $statut, $detail_erreur, $nom_fichier); + } + $ok = $stmt->execute(); + $stmt->close(); + return $ok; +} + +// ---------------------------------------------------------------------------- +// Reconstruit l'adresse MAC (format XX:XX:XX:XX:XX:XX) à partir des 12 +// premiers caractères hexadécimaux du nom de fichier. +// ---------------------------------------------------------------------------- +function Adresse_mac_depuis_nom($nom_fichier) +{ + preg_match('/^([0-9A-Fa-f]{12})_/', $nom_fichier, $m); + return implode(':', str_split(strtoupper($m[1]), 2)); +} + +function Executer_cron_quarantaine() +{ + Ouverture_bdd("127.0.0.1:3307", BDD_USER, BDD_PASSWORD, BDD_DATABASE); + $db = $_SESSION['db']; + + if (!is_dir(REPERTOIRE_QUARANTAINE)) + { + echo "Répertoire de quarantaine introuvable (" . REPERTOIRE_QUARANTAINE . ") - rien à faire.\n"; + Fermeture_bdd(); + return; + } + + $fichiers = array_values(array_filter(scandir(REPERTOIRE_QUARANTAINE), + fn($f) => preg_match('/^[0-9A-Fa-f]{12}_\d{6}_\d{6}_[0-9A-Fa-f]{16}\.sql\.gz\.enc$/', $f))); + echo count($fichiers) . " archive(s) en quarantaine à réexaminer.\n\n"; + + foreach ($fichiers as $nom) + { + $chemin = REPERTOIRE_QUARANTAINE . "/" . $nom; + + $ligne = Lire_ligne_en_attente($db, $nom); + if ($ligne === null) + { + echo " - {$nom} : aucune ligne TB_Archives_traitees en attente trouvée - incohérence, à examiner manuellement.\n"; + continue; + } + + $adresse_mac = Adresse_mac_depuis_nom($nom); + $contenu_chiffre = file_get_contents($chemin); + + $affectation = Resoudre_affectation_active($db, $adresse_mac); + if (!$affectation['existe']) + { + echo " - {$nom} : toujours aucune affectation active - reste en quarantaine.\n"; + continue; + } + $id_bateau = $affectation['id_bateau']; + + $dechiffrement = Decrypter_archive($contenu_chiffre); + if (!$dechiffrement['ok']) + { + // Un échec de déchiffrement ne se résoudra pas en attendant (contrairement à l'affectation ou + // au MMSI, qui dépendent d'une action administrative future) - rejet direct, comme + // cron_archivage.php (cf synthèse §38) : + echo " - {$nom} : ÉCHEC de déchiffrement (" . $dechiffrement['raison'] . ") - rejetée.\n"; + Trancher_archive_en_attente($db, $nom, $id_bateau, null, 'rejetee', "Échec de déchiffrement : " . $dechiffrement['raison']); + if (!is_dir(REPERTOIRE_ERREURS)) @mkdir(REPERTOIRE_ERREURS, 0750, true); + rename($chemin, REPERTOIRE_ERREURS . "/" . $nom); + continue; + } + + $mmsi_recu = $dechiffrement['entete']['No_mmsi'] ?? null; + $mmsi_attendu_connu = null; + if ($mmsi_recu !== null) + { + $verification_mmsi = Verifier_mmsi($db, $id_bateau, $mmsi_recu); + $mmsi_attendu_connu = $verification_mmsi['attendu']; + if ($verification_mmsi['statut'] === 'quarantaine') + { + echo " - {$nom} : MMSI toujours divergent ({$mmsi_recu} / {$verification_mmsi['attendu']}) - reste en quarantaine.\n"; + continue; + } + } + + // La cause de la quarantaine est levée : traitement complet, même déroulé que cron_archivage.php : + $origine_bdd = $dechiffrement['entete']['Origine_bdd'] ?? 'normale'; + $version_cible = $dechiffrement['entete']['Version_soft_no_package'] ?? null; + $generation_active = Resoudre_generation_active($db, $id_bateau); + $nouvelle_generation = Nouvelle_generation_necessaire($origine_bdd, $generation_active); + + $fichier_sql = Resoudre_fichier_sql($db, $version_cible); + if ($fichier_sql === false) + { + echo " - {$nom} : version logicielle '{$version_cible}' inconnue de TB_Versions - rejetée.\n"; + Trancher_archive_en_attente($db, $nom, $id_bateau, null, 'rejetee', "Version logicielle '{$version_cible}' inconnue de TB_Versions", $mmsi_recu, $mmsi_attendu_connu); + if (!is_dir(REPERTOIRE_ERREURS)) @mkdir(REPERTOIRE_ERREURS, 0750, true); + rename($chemin, REPERTOIRE_ERREURS . "/" . $nom); + continue; + } + $contenu_fichier_sql = @file_get_contents(REPERTOIRE_VERSIONS_SQL . "/" . $fichier_sql); + if ($contenu_fichier_sql === false) + { + echo " - {$nom} : fichier {$fichier_sql} introuvable sur le VPS - rejetée.\n"; + Trancher_archive_en_attente($db, $nom, $id_bateau, null, 'rejetee', "Fichier {$fichier_sql} introuvable sur le VPS", $mmsi_recu, $mmsi_attendu_connu); + if (!is_dir(REPERTOIRE_ERREURS)) @mkdir(REPERTOIRE_ERREURS, 0750, true); + rename($chemin, REPERTOIRE_ERREURS . "/" . $nom); + continue; + } + + if ($nouvelle_generation) + { + $zone_usine = Extraire_zone_usine($contenu_fichier_sql); + if ($zone_usine === false) + { + echo " - {$nom} : marqueur Factory_structure introuvable dans {$fichier_sql} - rejetée.\n"; + Trancher_archive_en_attente($db, $nom, $id_bateau, null, 'rejetee', "Marqueur Factory_structure introuvable dans {$fichier_sql}", $mmsi_recu, $mmsi_attendu_connu); + if (!is_dir(REPERTOIRE_ERREURS)) @mkdir(REPERTOIRE_ERREURS, 0750, true); + rename($chemin, REPERTOIRE_ERREURS . "/" . $nom); + continue; + } + $motif = ($origine_bdd === 'factory' || $origine_bdd === 'restauration') ? $origine_bdd : 'creation_initiale'; + $resultat_generation = Creer_generation($db, $id_bateau, $motif, $zone_usine); + if (!$resultat_generation['ok']) + { + echo " - {$nom} : échec de création de génération : " . $resultat_generation['raison'] . " - rejetée.\n"; + Trancher_archive_en_attente($db, $nom, $id_bateau, null, 'rejetee', "Échec de création de génération : " . $resultat_generation['raison'], $mmsi_recu, $mmsi_attendu_connu); + if (!is_dir(REPERTOIRE_ERREURS)) @mkdir(REPERTOIRE_ERREURS, 0750, true); + rename($chemin, REPERTOIRE_ERREURS . "/" . $nom); + continue; + } + $nom_base_cible = $resultat_generation['nom_base']; + } + else + { + $nom_base_cible = $generation_active['nom_base']; + } + + $chargement = Charger_contenu_archive($nom_base_cible, $dechiffrement['sql']); + if (!$chargement['ok']) + { + echo " - {$nom} : échec de chargement dans {$nom_base_cible} : " . $chargement['raison'] . " - rejetée.\n"; + Trancher_archive_en_attente($db, $nom, $id_bateau, null, 'rejetee', "Échec de chargement dans {$nom_base_cible} : " . $chargement['raison'], $mmsi_recu, $mmsi_attendu_connu); + if (!is_dir(REPERTOIRE_ERREURS)) @mkdir(REPERTOIRE_ERREURS, 0750, true); + rename($chemin, REPERTOIRE_ERREURS . "/" . $nom); + continue; + } + + $generation_pour_enregistrement = Resoudre_generation_active($db, $id_bateau); + Trancher_archive_en_attente($db, $nom, $id_bateau, $generation_pour_enregistrement['cle'], 'traitee', null, $mmsi_recu, $mmsi_attendu_connu); + unlink($chemin); + echo " - {$nom} : quarantaine levée, chargée dans {$nom_base_cible}.\n"; + } + + Fermeture_bdd(); +} + +Executer_cron_quarantaine(); diff --git a/fonctions_archivage.php b/fonctions_archivage.php new file mode 100644 index 0000000..394972d --- /dev/null +++ b/fonctions_archivage.php @@ -0,0 +1,362 @@ + 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]; +} diff --git a/sql/detail_erreur.sql b/sql/detail_erreur.sql new file mode 100644 index 0000000..a2e3e58 --- /dev/null +++ b/sql/detail_erreur.sql @@ -0,0 +1,7 @@ +-- ============================================================================ +-- Ajout de Detail_erreur (cf synthèse §38) : trace la cause d'un rejet ou +-- d'une quarantaine directement en base, pour un suivi manuel sans dépendre +-- des journaux de la tâche cron (volume négligeable, usage rare). +-- ============================================================================ +ALTER TABLE `TB_Archives_traitees` + ADD COLUMN `Detail_erreur` TEXT NULL COMMENT 'Détail de l''erreur technique ayant mené à Statut=rejetee (ou tout autre statut, si utile)' AFTER `MMSI_attendu`; diff --git a/sql/renommages.sql b/sql/renommages.sql new file mode 100644 index 0000000..a754eee --- /dev/null +++ b/sql/renommages.sql @@ -0,0 +1,15 @@ +-- ============================================================================ +-- Renommages de cohérence (cf synthèse §38) : +-- - TB_Affectations_bateau -> TB_Affectations_bateaux +-- - TB_Generations_bateau -> TB_Generations_bateaux +-- - TB_Archives_traitees.ID_Bateau_resolu -> ID_Bateau_affecte (pour ne plus +-- confondre avec Date_resolution, qui porte un sens différent : la date +-- à laquelle le STATUT a été tranché, pas la date de résolution de +-- l'affectation elle-même) +-- ============================================================================ +RENAME TABLE `TB_Affectations_bateau` TO `TB_Affectations_bateaux`; +RENAME TABLE `TB_Generations_bateau` TO `TB_Generations_bateaux`; + +ALTER TABLE `TB_Archives_traitees` + CHANGE COLUMN `ID_Bateau_resolu` `ID_Bateau_affecte` INT UNSIGNED NULL + COMMENT 'Déduit de TB_Affectations_bateaux à la réception - NULL si aucune affectation active trouvée';