feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles

- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD)
  * DEV: Clés TEST Pierre (mode test)
  * REC: Clés TEST Client (mode test)
  * PROD: Clés LIVE Client (mode live)
- Ajout de la gestion des bases de données immeubles/bâtiments
  * Configuration buildings_database pour DEV/REC/PROD
  * Service BuildingService pour enrichissement des adresses
- Optimisations pages et améliorations ergonomie
- Mises à jour des dépendances Composer
- Nettoyage des fichiers obsolètes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-11-09 18:26:27 +01:00
parent 21657a3820
commit 2f5946a184
812 changed files with 142105 additions and 25992 deletions

View File

@@ -0,0 +1,290 @@
# 🔧 CORRECTIONS CRITIQUES - migrate_from_backup.php
## ❌ ERREURS DÉTECTÉES
### 1. **migrateUsers** (ligne 456)
```sql
-- ERREUR
u.nom, u.prenom, u.nom_sect, u.username, u.password, u.phone, u.mobile
-- CORRECTION (noms réels dans geosector.users)
u.libelle, u.prenom, u.nom_tournee, u.username, u.userpass, u.telephone, u.mobile
```
### 2. **migrateOpePass** (ligne 1043)
```sql
-- ERREUR
p.passed_at, p.libelle, p.email, p.phone
-- CORRECTION (noms réels dans geosector.ope_pass)
p.date_eve AS passed_at, p.libelle AS encrypted_name, p.email, p.phone
```
### 3. **migrateSectorsAdresses** (ligne 777)
```sql
-- ERREUR
sa.osm_id, sa.osm_name, sa.osm_date_creat
-- CORRECTION (ces champs n'existent PAS dans geosector.sectors_adresses)
-- Ces champs doivent être mis à 0 ou NULL dans la cible
0 AS osm_id, '' AS osm_name, NULL AS osm_date_creat
```
### 4. **migrateOpeUsersSectors** (ligne 955)
```sql
-- ERREUR
ous.date_creat, ous.fk_user_creat, ous.date_modif, ous.fk_user_modif
-- CORRECTION (geosector.ope_users_sectors n'a PAS ces champs)
NULL AS created_at, NULL AS fk_user_creat, NULL AS updated_at, NULL AS fk_user_modif
```
### 5. **migrateMedias** (à vérifier)
```sql
-- ERREUR potentielle
m.support_rowid
-- CORRECTION
m.support_rowid AS support_id
```
### 6. **migrateOperations** (erreur NOT NULL)
```sql
-- PROBLÈME: Column 'fk_user_modif' cannot be null
-- CORRECTION: Utiliser 0 au lieu de NULL
'fk_user_modif' => $row['fk_user_modif'] ?? 0
```
---
## ✅ SOLUTION RAPIDE
Créez un script `HOTFIX_migrate.sql` pour corriger rapidement :
```sql
-- Permettre NULL sur les champs problématiques
ALTER TABLE operations MODIFY COLUMN fk_user_modif INT(10) UNSIGNED NULL DEFAULT NULL;
ALTER TABLE ope_sectors MODIFY COLUMN fk_user_modif INT(10) UNSIGNED NULL DEFAULT NULL;
ALTER TABLE ope_users MODIFY COLUMN fk_user_creat INT(10) UNSIGNED NULL DEFAULT NULL;
ALTER TABLE ope_users MODIFY COLUMN fk_user_modif INT(10) UNSIGNED NULL DEFAULT NULL;
ALTER TABLE ope_users_sectors MODIFY COLUMN fk_user_creat INT(10) UNSIGNED NULL DEFAULT NULL;
ALTER TABLE ope_users_sectors MODIFY COLUMN fk_user_modif INT(10) UNSIGNED NULL DEFAULT NULL;
```
OU utiliser `0` à la place de `NULL` systématiquement dans le script PHP.
---
## 📋 STATUT DES CORRECTIONS (10/10/2025)
1.**migrateEntites** - CORRIGÉ (cp, tel1, tel2, demo)
2.**migrateUsers** - CORRIGÉ (libelle, nom_tournee, telephone, userpass, alert_email) - Lignes 455-537
3.**migrateOperations** - CORRIGÉ (fk_user_modif ?? 0, fk_user_creat ?? 0) - Lignes 614-625
4.**migrateOpeSectors** - CORRIGÉ (fk_user_modif ?? 0, fk_user_creat ?? 0) - Lignes 727-738
5.**migrateSectorsAdresses** - CORRIGÉ (osm_id=0, osm_name='', osm_date_creat=null, created_at/updated_at=null) - Lignes 776-855
6.**migrateOpeUsers** - CORRIGÉ (vérification existence user dans TARGET avant insertion) - Lignes 960-1020
7.**migrateOpeUsersSectors** - CORRIGÉ (date_creat, fk_user_creat, date_modif, fk_user_modif = null + vérification user) - Lignes 1054-1135
8.**migrateOpePass** - CORRIGÉ (date_eve, libelle, recu + fk_type_reglement forcé à 4 si invalide + vérification user) - Lignes 1215-1330
9.**migrateMedias** - CORRIGÉ (support_rowid, type_fichier, hauteur/largeur) - Lignes 1281-1343
10.**countTargetRows()** - CORRIGÉ (requêtes SQL spécifiques par table avec JOINs corrects) - Lignes 303-355
---
## ✅ CORRECTIONS APPLIQUÉES
**Toutes les erreurs ont été corrigées dans `migrate_from_backup.php`.**
Les corrections incluent :
- Utilisation des vrais noms de colonnes SOURCE (`geosector-structure.sql`)
- Gestion des champs manquants dans SOURCE avec valeurs par défaut
- Utilisation de `?? 0` au lieu de `?? null` pour les FK NOT NULL
- Suppression des champs inexistants dans les requêtes SELECT
**ATTENTION** : Les noms de colonnes TARGET n'ont PAS été vérifiés contre `geo_app_structure.sql`.
Le script utilise peut-être les mauvais noms TARGET (à vérifier avec `migrate_users.php` et autres `migrate_*.php` de référence).
---
## 🔧 CORRECTIONS RÉCENTES (Session actuelle)
### 10. **Vérification FK users** (lignes 1008-1015, 1117-1125, 1257-1266)
**Problème** : Violations de contraintes FK car certains `fk_user` référencent des utilisateurs absents dans TARGET.
**Solution** : Ajout de vérification d'existence avant insertion :
```php
// Vérifier que fk_user existe dans users de la TARGET
$checkUser = $this->targetDb->prepare("SELECT id FROM users WHERE id = ?");
$checkUser->execute([$row['fk_user']]);
if (!$checkUser->fetch()) {
$this->log(" ⚠ Record {$row['rowid']}: user {$row['fk_user']} non trouvé, ignoré", 'WARNING');
continue;
}
```
**Appliqué sur** :
- `migrateOpeUsers()` - ligne 1008
- `migrateOpeUsersSectors()` - ligne 1117
- `migrateOpePass()` - ligne 1257
**Résultat** : Les enregistrements avec FK invalides sont ignorés avec un WARNING au lieu de provoquer une erreur fatale.
### 11. **countTargetRows() - Requêtes SQL spécifiques** (lignes 303-355)
**Problème** : Erreurs SQL car toutes les tables n'ont pas les mêmes colonnes/relations :
- `Unknown column 'fk_entite' in 'WHERE'` pour `entites`
- `Unknown column 't.fk_operation' in 'ON'` pour `operations`, `ope_pass_histo`, `medias`
**Solution** : Requêtes SQL personnalisées par table :
```php
// Pour entites : pas de FK, juste l'ID
if ($tableName === 'entites') {
$sql = "SELECT COUNT(*) as count FROM $tableName WHERE id = :entity_id";
}
// Pour operations : FK directe vers entites
else if ($tableName === 'operations') {
$sql = "SELECT COUNT(*) as count FROM $tableName WHERE fk_entite = :entity_id";
}
// Pour sectors_adresses : JOIN via ope_sectors -> operations
else if ($tableName === 'sectors_adresses') {
$sql = "SELECT COUNT(*) as count FROM $tableName sa
INNER JOIN ope_sectors s ON sa.fk_sector = s.id
INNER JOIN operations o ON s.fk_operation = o.id
WHERE o.fk_entite = :entity_id";
}
// Pour tables avec fk_operation directe
else if (in_array($tableName, ['ope_sectors', 'ope_users', 'ope_users_sectors', 'ope_pass', 'ope_pass_histo', 'medias'])) {
$sql = "SELECT COUNT(*) as count FROM $tableName t
INNER JOIN operations o ON t.fk_operation = o.id
WHERE o.fk_entite = :entity_id";
}
```
**Résultat** : Comptages TARGET précis et sans erreurs SQL pour toutes les tables.
### 12. **fk_type_reglement validation** (lignes 1237-1241)
**Problème** : FK violations car certains `fk_type_reglement` référencent des IDs inexistants dans `x_types_reglements` (IDs valides : 1, 2, 3).
**Solution** : Forcer à 4 ("-") si valeur invalide (comme dans `migrate_ope_pass.php`) :
```php
// Vérification et correction du type de règlement
$fkTypeReglement = $row['fk_type_reglement'] ?? 1;
if (!in_array($fkTypeReglement, [1, 2, 3])) {
$fkTypeReglement = 4; // Forcer à 4 ("-") si différent de 1, 2 ou 3
}
```
**Résultat** : Tous les `ope_pass` sont migrés sans violation de FK sur `fk_type_reglement`.
### 13. **Limitation aux 3 dernières opérations** (lignes 646-647) ⚠️ IMPORTANT
**Problème** : Migration de TOUTES les opérations au lieu des 3 dernières uniquement.
**Solution** : Ajout de `ORDER BY rowid DESC LIMIT 3` dans la requête :
```php
// Ne migrer que les 3 dernières opérations (plus récentes)
$sql .= " ORDER BY rowid DESC LIMIT 3";
```
**Résultat** : Seules les 3 opérations les plus récentes (par rowid DESC) sont migrées par entité.
**Impact** : Réduit considérablement le volume de données migrées et toutes les tables liées (ope_sectors, ope_users, ope_users_sectors, ope_pass, medias, sectors_adresses).
### 14. **Option de suppression avant migration** (lignes 127-200, 1692, 1722, 1776) ⭐ NOUVELLE FONCTIONNALITÉ
**Besoin** : Permettre de supprimer les données existantes d'une entité dans TARGET avant migration pour repartir à zéro.
**Solution** : Ajout du paramètre `--delete-before` :
**Script bash** (lignes 174-183) :
```bash
# Demander si suppression des données de l'entité avant migration
echo -ne "${YELLOW}3⃣ Supprimer les données existantes de cette entité dans la TARGET avant migration ? (y/N): ${NC}"
read -r DELETE_BEFORE
DELETE_FLAG=""
if [[ $DELETE_BEFORE =~ ^[Yy]$ ]]; then
echo -e "${GREEN}${NC} Les données seront supprimées avant migration"
DELETE_FLAG="--delete-before"
fi
```
**Script PHP** - Méthode `deleteEntityData()` (lignes 127-200) :
```php
private function deleteEntityData($entityId) {
// Ordre de suppression inverse pour respecter les FK
$deletionOrder = [
'medias', 'ope_pass_histo', 'ope_pass', 'ope_users_sectors',
'ope_users', 'sectors_adresses', 'ope_sectors', 'operations', 'users'
];
foreach ($deletionOrder as $table) {
// Suppression via JOIN avec operations pour respecter FK
DELETE t FROM $table t
INNER JOIN operations o ON t.fk_operation = o.id
WHERE o.fk_entite = ?
}
}
```
**Résultat** :
- En mode interactif, l'utilisateur peut choisir de supprimer les données existantes avant migration
- Suppression propre dans l'ordre inverse des FK (pas d'erreur de contrainte)
- L'entité elle-même n'est PAS supprimée (car peut avoir d'autres données liées)
- Transaction avec rollback en cas d'erreur
**Usage** :
```bash
# Interactif
./scripts/migrate_batch.sh
# Choisir option d) puis répondre 'y' à la question de suppression
# Direct
php migrate_from_backup.php --source-db=geosector_20251008 --mode=entity --entity-id=5 --delete-before
```
---
## 📊 RÉSULTATS MIGRATION TEST (Entité #5)
Dernière exécution avec toutes les corrections :
-**Entités** : 1 SOURCE → 1 TARGET
-**Users** : 21 SOURCE → 21 TARGET (100%)
-**Operations** : 4 SOURCE → 4 TARGET (100%)
-**Ope_sectors** : 64 SOURCE → 64 TARGET (100%)
- ⚠️ **Sectors_adresses** : 1975 SOURCE → 1040 TARGET (différence de -935, à investiguer)
-**Ope_users** : 20 migrés (0 erreurs après vérification FK)
-**Ope_users_sectors** : 20 migrés (0 erreurs après vérification FK)
- ⚠️ **Ope_pass** : 466 erreurs (users manquants - comportement attendu avec validation FK)
-**Medias** : Migration réussie
### 15. **Ajout de contraintes UNIQUE pour éviter les doublons** (10/10/2025) ⭐ CONTRAINTES MANQUANTES
**Problème** : Les tables `ope_users` et `ope_users_sectors` n'avaient PAS de contrainte UNIQUE sur leurs combinaisons de FK, permettant des doublons massifs.
**Diagnostic** :
- Table `ope_users` : 186+ doublons pour la même paire (fk_operation, fk_user)
- Table `ope_users_sectors` : Risque de doublons sur (fk_operation, fk_user, fk_sector)
- Le `ON DUPLICATE KEY UPDATE` ne fonctionnait pas car aucune contrainte UNIQUE n'existait
**Solution** : Création du script `scripts/sql/add_unique_constraints_ope_tables.sql` qui :
1. Supprime les doublons existants (garde la première occurrence, supprime les duplicatas)
2. Ajoute `UNIQUE KEY idx_operation_user (fk_operation, fk_user)` sur `ope_users`
3. Ajoute `UNIQUE KEY idx_operation_user_sector (fk_operation, fk_user, fk_sector)` sur `ope_users_sectors`
4. Vérifie les contraintes et compte les doublons restants
**Fichiers modifiés** :
- `scripts/sql/add_unique_constraints_ope_tables.sql` - Script SQL d'ajout des contraintes
- `scripts/php/geo_app_structure.sql` - Documentation de la structure cible avec contraintes
**À exécuter AVANT la prochaine migration** :
```bash
mysql -u root -p pra_geo < scripts/sql/add_unique_constraints_ope_tables.sql
```
**Puis re-migrer l'entité** :
```bash
php migrate_from_backup.php --source-db=geosector_20251008 --mode=entity --entity-id=5 --delete-before
```
---
**Prochaines étapes** :
1. ✅ Exécuter le script SQL pour ajouter les contraintes UNIQUE
2. ✅ Re-migrer l'entité #5 avec `--delete-before` pour vérifier l'absence de doublons
3. Investiguer la différence de -935 sur `sectors_adresses`
4. Analyser les 466 erreurs sur `ope_pass` (probablement des références à des users d'autres entités)
5. Tester sur une autre entité pour valider la stabilité des corrections

View File

@@ -0,0 +1,350 @@
# Instructions de modification des scripts de migration
## Modifications à effectuer
### 1. migrate_from_backup.php
#### A. Remplacer les lignes 31-50 (configuration DB)
**ANCIEN** :
```php
private $sourceDbName;
private $targetDbName;
private $sourceDb;
private $targetDb;
private $mode;
private $entityId;
private $logFile;
private $deleteBefore;
// Configuration MariaDB (maria4 sur IN4)
// pra-geo se connecte à maria4 via l'IP du container
private const DB_HOST = '13.23.33.4'; // maria4 sur IN4
private const DB_PORT = 3306;
private const DB_USER = 'pra_geo_user';
private const DB_PASS = 'd2jAAGGWi8fxFrWgXjOA';
// Pour la base source (backup), on utilise pra_geo_user (avec SELECT sur geosector_*)
// L'utilisateur root n'est pas accessible depuis pra-geo (13.23.33.22)
private const DB_USER_ROOT = 'pra_geo_user';
private const DB_PASS_ROOT = 'd2jAAGGWi8fxFrWgXjOA';
```
**NOUVEAU** :
```php
private $sourceDbName;
private $targetDbName;
private $sourceDb;
private $targetDb;
private $mode;
private $entityId;
private $logFile;
private $deleteBefore;
private $env;
// Configuration multi-environnement
private const ENVIRONMENTS = [
'rca' => [
'host' => '13.23.33.3', // maria3 sur IN3
'port' => 3306,
'user' => 'rca_geo_user',
'pass' => 'UPf3C0cQ805LypyM71iW',
'target_db' => 'rca_geo',
'source_db' => 'geosector' // Base synchronisée par PM7
],
'pra' => [
'host' => '13.23.33.4', // maria4 sur IN4
'port' => 3306,
'user' => 'pra_geo_user',
'pass' => 'd2jAAGGWi8fxFrWgXjOA',
'target_db' => 'pra_geo',
'source_db' => 'geosector' // Base synchronisée par PM7
]
];
```
#### B. Modifier le constructeur (ligne 67)
**ANCIEN** :
```php
public function __construct($sourceDbName, $targetDbName, $mode = 'global', $entityId = null, $logFile = null, $deleteBefore = false) {
$this->sourceDbName = $sourceDbName;
$this->targetDbName = $targetDbName;
$this->mode = $mode;
$this->entityId = $entityId;
$this->logFile = $logFile ?? '/var/back/migration_' . date('Ymd_His') . '.log';
$this->deleteBefore = $deleteBefore;
$this->log("=== Migration depuis backup PM7 ===");
$this->log("Source: {$sourceDbName}");
$this->log("Cible: {$targetDbName}");
$this->log("Mode: {$mode}");
```
**NOUVEAU** :
```php
public function __construct($env, $mode = 'global', $entityId = null, $logFile = null, $deleteBefore = false) {
// Validation de l'environnement
if (!isset(self::ENVIRONMENTS[$env])) {
throw new Exception("Invalid environment: $env. Use 'rca' or 'pra'");
}
$this->env = $env;
$config = self::ENVIRONMENTS[$env];
$this->sourceDbName = $config['source_db'];
$this->targetDbName = $config['target_db'];
$this->mode = $mode;
$this->entityId = $entityId;
$this->logFile = $logFile ?? '/var/back/migration_' . date('Ymd_His') . '.log';
$this->deleteBefore = $deleteBefore;
$this->log("=== Migration depuis backup PM7 ===");
$this->log("Environment: {$env}");
$this->log("Source: {$this->sourceDbName} → Cible: {$this->targetDbName}");
$this->log("Mode: {$mode}");
```
#### C. Modifier connect() (lignes 90-112)
**Remplacer toutes les constantes** :
- `self::DB_HOST``self::ENVIRONMENTS[$this->env]['host']`
- `self::DB_PORT``self::ENVIRONMENTS[$this->env]['port']`
- `self::DB_USER_ROOT``self::ENVIRONMENTS[$this->env]['user']`
- `self::DB_PASS_ROOT``self::ENVIRONMENTS[$this->env]['pass']`
- `self::DB_USER``self::ENVIRONMENTS[$this->env]['user']`
- `self::DB_PASS``self::ENVIRONMENTS[$this->env]['pass']`
#### D. Modifier parseArguments() (vers la fin du fichier)
**ANCIEN** :
```php
$args = [
'source-db' => null,
'target-db' => 'pra_geo',
'mode' => 'global',
'entity-id' => null,
'log' => null,
'delete-before' => true,
'help' => false
];
```
**NOUVEAU** :
```php
$args = [
'env' => 'rca', // Défaut: recette
'mode' => 'global',
'entity-id' => null,
'log' => null,
'delete-before' => true,
'help' => false
];
```
#### E. Modifier showHelp()
**ANCIEN** :
```php
--source-db=NAME Nom de la base source (backup restauré, ex: geosector_20251007) [REQUIS]
--target-db=NAME Nom de la base cible (défaut: pra_geo)
```
**NOUVEAU** :
```php
--env=ENV Environment: 'rca' (recette) ou 'pra' (production) [défaut: rca]
```
**ANCIEN** (exemples) :
```php
php migrate_from_backup.php --source-db=geosector_20251007 --target-db=pra_geo --mode=global
```
**NOUVEAU** :
```php
php migrate_from_backup.php --env=pra --mode=global
php migrate_from_backup.php --env=rca --mode=entity --entity-id=45
```
#### F. Modifier validation des arguments
**ANCIEN** :
```php
if (!$args['source-db']) {
echo "Erreur: --source-db est requis\n\n";
showHelp();
exit(1);
}
```
**NOUVEAU** :
```php
if (!in_array($args['env'], ['rca', 'pra'])) {
echo "Erreur: --env doit être 'rca' ou 'pra'\n\n";
showHelp();
exit(1);
}
```
#### G. Modifier instanciation BackupMigration
**ANCIEN** :
```php
$migration = new BackupMigration(
$args['source-db'],
$args['target-db'],
$args['mode'],
$args['entity-id'],
$args['log'],
(bool)$args['delete-before']
);
```
**NOUVEAU** :
```php
$migration = new BackupMigration(
$args['env'],
$args['mode'],
$args['entity-id'],
$args['log'],
(bool)$args['delete-before']
);
```
---
### 2. migrate_batch.sh
#### A. Ajouter détection automatique de l'environnement (après ligne 22)
**AJOUTER** :
```bash
# Détection automatique de l'environnement
if [ -f "/etc/hostname" ]; then
CONTAINER_NAME=$(cat /etc/hostname)
case $CONTAINER_NAME in
rca-geo)
ENV="rca"
;;
pra-geo)
ENV="pra"
;;
*)
ENV="rca" # Défaut
;;
esac
else
ENV="rca" # Défaut
fi
```
#### B. Remplacer lignes 26-27
**ANCIEN** :
```bash
SOURCE_DB="geosector_20251013_13"
TARGET_DB="pra_geo"
```
**NOUVEAU** :
```bash
# SOURCE_DB et TARGET_DB ne sont plus utilisés
# Ils sont déduits de --env dans migrate_from_backup.php
```
#### C. Ajouter option --env dans le parsing (ligne 68)
**AJOUTER avant `--interactive|-i)` ** :
```bash
--env)
ENV="$2"
shift 2
;;
```
#### D. Modifier les appels PHP - ligne 200-206
**ANCIEN** :
```bash
php "$MIGRATION_SCRIPT" \
--source-db="$SOURCE_DB" \
--target-db="$TARGET_DB" \
--mode=entity \
--entity-id="$SPECIFIC_ENTITY_ID" \
--log="$ENTITY_LOG" \
$DELETE_FLAG
```
**NOUVEAU** :
```bash
php "$MIGRATION_SCRIPT" \
--env="$ENV" \
--mode=entity \
--entity-id="$SPECIFIC_ENTITY_ID" \
--log="$ENTITY_LOG" \
$DELETE_FLAG
```
#### E. Modifier les appels PHP - ligne 374-379
**ANCIEN** :
```bash
php "$MIGRATION_SCRIPT" \
--source-db="$SOURCE_DB" \
--target-db="$TARGET_DB" \
--mode=entity \
--entity-id="$ENTITY_ID" \
--log="$ENTITY_LOG" > /tmp/migration_output_$$.txt 2>&1
```
**NOUVEAU** :
```bash
php "$MIGRATION_SCRIPT" \
--env="$ENV" \
--mode=entity \
--entity-id="$ENTITY_ID" \
--log="$ENTITY_LOG" > /tmp/migration_output_$$.txt 2>&1
```
#### F. Modifier les messages de log (lignes 289-291)
**ANCIEN** :
```bash
log "📅 Date: $(date '+%Y-%m-%d %H:%M:%S')"
log "📁 Source: $SOURCE_DB"
log "📁 Cible: $TARGET_DB"
```
**NOUVEAU** :
```bash
log "📅 Date: $(date '+%Y-%m-%d %H:%M:%S')"
log "🌍 Environment: $ENV"
log "📁 Source: geosector → Target: (déduit de \$ENV)"
```
---
## Nouveaux usages
### Sur rca-geo (IN3)
```bash
# Détection automatique
./migrate_batch.sh
# Ou explicite
./migrate_batch.sh --env=rca
# Migration PHP directe
php php/migrate_from_backup.php --env=rca --mode=entity --entity-id=45
```
### Sur pra-geo (IN4)
```bash
# Détection automatique
./migrate_batch.sh
# Ou explicite
./migrate_batch.sh --env=pra
# Migration PHP directe
php php/migrate_from_backup.php --env=pra --mode=entity --entity-id=45
```

File diff suppressed because it is too large Load Diff

273
api/scripts/cron/CRON.md Normal file
View File

@@ -0,0 +1,273 @@
# Documentation des tâches CRON - API Geosector
Ce dossier contient les scripts automatisés de maintenance et de traitement pour l'API Geosector.
## Scripts disponibles
### 1. `process_email_queue.php`
**Fonction** : Traite la queue d'emails en attente (reçus fiscaux, notifications)
**Caractéristiques** :
- Traite 50 emails maximum par exécution
- 3 tentatives maximum par email
- Lock file pour éviter l'exécution simultanée
- Nettoyage automatique des emails envoyés de plus de 30 jours
**Fréquence recommandée** : Toutes les 5 minutes
**Ligne crontab** :
```bash
*/5 * * * * /usr/bin/php /var/www/geosector/api/scripts/cron/process_email_queue.php >> /var/www/geosector/api/logs/email_queue.log 2>&1
```
---
### 2. `cleanup_security_data.php`
**Fonction** : Purge les données de sécurité obsolètes selon la politique de rétention
**Données nettoyées** :
- Métriques de performance : 30 jours
- Tentatives de login échouées : 7 jours
- Alertes résolues : 90 jours
- IPs expirées : Déblocage immédiat
**Fréquence recommandée** : Quotidien à 2h du matin
**Ligne crontab** :
```bash
0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php >> /var/www/geosector/api/logs/cleanup_security.log 2>&1
```
---
### 3. `cleanup_logs.php`
**Fonction** : Supprime les fichiers de logs de plus de 10 jours
**Caractéristiques** :
- Cible tous les fichiers `*.log` dans `/api/logs/`
- Exclut le dossier `/logs/events/` (rétention 15 mois)
- Rétention : 10 jours
- Logs détaillés des fichiers supprimés et taille libérée
- Lock file pour éviter l'exécution simultanée
**Fréquence recommandée** : Quotidien à 3h du matin
**Ligne crontab** :
```bash
0 3 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_logs.php >> /var/www/geosector/api/logs/cleanup_logs.log 2>&1
```
---
### 4. `rotate_event_logs.php`
**Fonction** : Rotation des logs d'événements JSONL (système EventLogService)
**Politique de rétention (15 mois)** :
- 0-15 mois : fichiers `.jsonl` conservés (non compressés pour accès API)
- > 15 mois : suppression automatique
**Caractéristiques** :
- Suppression des fichiers > 15 mois
- Pas de compression (fichiers accessibles par l'API)
- Logs détaillés des suppressions
- Lock file pour éviter l'exécution simultanée
**Fréquence recommandée** : Mensuel le 1er à 3h du matin
**Ligne crontab** :
```bash
0 3 1 * * /usr/bin/php /var/www/geosector/api/scripts/cron/rotate_event_logs.php >> /var/www/geosector/api/logs/rotation_events.log 2>&1
```
---
### 5. `update_stripe_devices.php`
**Fonction** : Met à jour la liste des appareils Android certifiés pour Tap to Pay
**Caractéristiques** :
- Liste de 95+ devices intégrée
- Ajoute les nouveaux appareils certifiés
- Met à jour les versions Android minimales
- Désactive les appareils obsolètes
- Notification email si changements importants
- Possibilité de personnaliser via `/data/stripe_certified_devices.json`
**Fréquence recommandée** : Hebdomadaire le dimanche à 3h
**Ligne crontab** :
```bash
0 3 * * 0 /usr/bin/php /var/www/geosector/api/scripts/cron/update_stripe_devices.php >> /var/www/geosector/api/logs/stripe_devices.log 2>&1
```
---
### 6. `sync_databases.php`
**Fonction** : Synchronise les bases de données entre environnements
**Note** : Ce script est spécifique à un cas d'usage particulier. Vérifier son utilité avant activation.
**Fréquence recommandée** : À définir selon les besoins
**Ligne crontab** :
```bash
# À configurer selon les besoins
# 0 4 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/sync_databases.php >> /var/www/geosector/api/logs/sync_databases.log 2>&1
```
---
## Installation sur les containers Incus
### 1. Déployer les scripts sur les environnements
```bash
# DEV (dva-geo sur IN3)
./deploy-api.sh
# RECETTE (rca-geo sur IN3)
./deploy-api.sh rca
# PRODUCTION (pra-geo sur IN4)
./deploy-api.sh pra
```
### 2. Configurer le crontab sur chaque container
```bash
# Se connecter au container
incus exec dva-geo -- sh # ou rca-geo, pra-geo
# Éditer le crontab
crontab -e
# Ajouter les lignes ci-dessous (adapter les chemins si nécessaire)
```
### 3. Configuration complète recommandée
```bash
# Traitement de la queue d'emails (toutes les 5 minutes)
*/5 * * * * /usr/bin/php /var/www/geosector/api/scripts/cron/process_email_queue.php >> /var/www/geosector/api/logs/email_queue.log 2>&1
# Nettoyage des données de sécurité (quotidien à 2h)
0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php >> /var/www/geosector/api/logs/cleanup_security.log 2>&1
# Nettoyage des anciens logs (quotidien à 3h)
0 3 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_logs.php >> /var/www/geosector/api/logs/cleanup_logs.log 2>&1
# Rotation des logs événements (mensuel le 1er à 3h)
0 3 1 * * /usr/bin/php /var/www/geosector/api/scripts/cron/rotate_event_logs.php >> /var/www/geosector/api/logs/rotation_events.log 2>&1
# Mise à jour des devices Stripe (hebdomadaire dimanche à 3h)
0 3 * * 0 /usr/bin/php /var/www/geosector/api/scripts/cron/update_stripe_devices.php >> /var/www/geosector/api/logs/stripe_devices.log 2>&1
```
### 4. Vérifier que les CRONs sont actifs
```bash
# Lister les CRONs configurés
crontab -l
# Vérifier les logs pour s'assurer qu'ils s'exécutent
tail -f /var/www/geosector/api/logs/email_queue.log
tail -f /var/www/geosector/api/logs/cleanup_logs.log
```
---
## Surveillance et monitoring
### Emplacement des logs
Tous les logs CRON sont stockés dans `/var/www/geosector/api/logs/` :
- `email_queue.log` : Traitement de la queue d'emails
- `cleanup_security.log` : Nettoyage des données de sécurité
- `cleanup_logs.log` : Nettoyage des anciens fichiers logs
- `rotation_events.log` : Rotation des logs événements JSONL
- `stripe_devices.log` : Mise à jour des devices Tap to Pay
### Vérification de l'exécution
```bash
# Voir les dernières exécutions du processeur d'emails
tail -n 50 /var/www/geosector/api/logs/email_queue.log
# Voir les derniers nettoyages de logs
tail -n 50 /var/www/geosector/api/logs/cleanup_logs.log
# Voir les dernières rotations des logs événements
tail -n 50 /var/www/geosector/api/logs/rotation_events.log
# Voir les dernières mises à jour Stripe
tail -n 50 /var/www/geosector/api/logs/stripe_devices.log
```
---
## Notes importantes
1. **Détection d'environnement** : Tous les scripts détectent automatiquement l'environnement via `gethostname()` :
- `pra-geo` → Production (app3.geosector.fr)
- `rca-geo` → Recette (rapp.geosector.fr)
- `dva-geo` → Développement (dapp.geosector.fr)
2. **Lock files** : Les scripts critiques utilisent des fichiers de lock dans `/tmp/` pour éviter l'exécution simultanée
3. **Permissions** : Les scripts doivent être exécutables (`chmod +x script.php`)
4. **Logs** : Tous les scripts loggent via `LogService` pour traçabilité complète
---
## Dépannage
### Le CRON ne s'exécute pas
```bash
# Vérifier que le service cron est actif
rc-service crond status # Alpine Linux
# Relancer le service si nécessaire
rc-service crond restart
```
### Erreur de permissions
```bash
# Vérifier les permissions du script
ls -l /var/www/geosector/api/scripts/cron/
# Rendre exécutable si nécessaire
chmod +x /var/www/geosector/api/scripts/cron/*.php
# Vérifier les permissions du dossier logs
ls -ld /var/www/geosector/api/logs/
```
### Lock file bloqué
```bash
# Si un script semble bloqué, supprimer le lock file
rm /tmp/process_email_queue.lock
rm /tmp/cleanup_logs.lock
```

165
api/scripts/cron/cleanup_logs.php Executable file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env php
<?php
/**
* Script CRON pour nettoyer les anciens fichiers de logs
* Supprime les fichiers .log de plus de 10 jours dans le dossier /logs/
*
* À exécuter quotidiennement via crontab :
* 0 3 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_logs.php >> /var/www/geosector/api/logs/cleanup_logs.log 2>&1
*/
declare(strict_types=1);
// Configuration
define('LOG_RETENTION_DAYS', 10);
define('LOCK_FILE', '/tmp/cleanup_logs.lock');
// Empêcher l'exécution multiple simultanée
if (file_exists(LOCK_FILE)) {
$lockTime = filemtime(LOCK_FILE);
// Si le lock a plus de 30 minutes, on le supprime (processus probablement bloqué)
if (time() - $lockTime > 1800) {
unlink(LOCK_FILE);
} else {
die("Le processus est déjà en cours d'exécution\n");
}
}
// Créer le fichier de lock
file_put_contents(LOCK_FILE, getmypid());
// Enregistrer un handler pour supprimer le lock en cas d'arrêt
register_shutdown_function(function() {
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
});
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
// Détecter l'environnement basé sur le hostname
$hostname = gethostname();
if (strpos($hostname, 'pra') !== false) {
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
} elseif (strpos($hostname, 'rca') !== false) {
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
} else {
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DVA par défaut
}
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
// Définir getallheaders si elle n'existe pas (CLI)
if (!function_exists('getallheaders')) {
function getallheaders() {
return [];
}
}
}
// Chargement de l'environnement
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
require_once __DIR__ . '/../../src/Services/LogService.php';
try {
// Initialisation de la configuration
$appConfig = AppConfig::getInstance();
$environment = $appConfig->getEnvironment();
// Définir le chemin du dossier logs
$logDir = __DIR__ . '/../../logs';
if (!is_dir($logDir)) {
echo "Le dossier de logs n'existe pas : {$logDir}\n";
exit(0);
}
// Date limite (10 jours en arrière)
$cutoffDate = time() - (LOG_RETENTION_DAYS * 24 * 60 * 60);
// Lister tous les fichiers .log (exclure le dossier events/)
$logFiles = glob($logDir . '/*.log');
// Exclure explicitement les logs du sous-dossier events/
$logFiles = array_filter($logFiles, function($file) {
return strpos($file, '/events/') === false;
});
if (empty($logFiles)) {
echo "Aucun fichier .log trouvé dans {$logDir}\n";
exit(0);
}
$deletedCount = 0;
$deletedSize = 0;
$deletedFiles = [];
foreach ($logFiles as $file) {
$fileTime = filemtime($file);
// Vérifier si le fichier est plus vieux que la date limite
if ($fileTime < $cutoffDate) {
$fileSize = filesize($file);
$fileName = basename($file);
if (unlink($file)) {
$deletedCount++;
$deletedSize += $fileSize;
$deletedFiles[] = $fileName;
echo "Supprimé : {$fileName} (" . number_format($fileSize / 1024, 2) . " KB)\n";
} else {
echo "ERREUR : Impossible de supprimer {$fileName}\n";
}
}
}
// Logger le résumé
if ($deletedCount > 0) {
$message = sprintf(
"Nettoyage des logs terminé - %d fichier(s) supprimé(s) - %.2f MB libérés",
$deletedCount,
$deletedSize / (1024 * 1024)
);
LogService::log($message, [
'level' => 'info',
'script' => 'cleanup_logs.php',
'environment' => $environment,
'deleted_count' => $deletedCount,
'deleted_size_mb' => round($deletedSize / (1024 * 1024), 2),
'deleted_files' => $deletedFiles
]);
echo "\n" . $message . "\n";
} else {
echo "Aucun fichier à supprimer (tous les logs ont moins de " . LOG_RETENTION_DAYS . " jours)\n";
}
} catch (Exception $e) {
$errorMsg = 'Erreur lors du nettoyage des logs : ' . $e->getMessage();
LogService::log($errorMsg, [
'level' => 'error',
'script' => 'cleanup_logs.php',
'trace' => $e->getTraceAsString()
]);
echo $errorMsg . "\n";
// Supprimer le lock en cas d'erreur
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
exit(1);
}
// Supprimer le lock
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
exit(0);

View File

@@ -41,14 +41,14 @@ register_shutdown_function(function() {
if (php_sapi_name() === 'cli') {
// Détecter l'environnement basé sur le hostname ou un paramètre
$hostname = gethostname();
if (strpos($hostname, 'prod') !== false) {
$_SERVER['SERVER_NAME'] = 'app.geosector.fr';
} elseif (strpos($hostname, 'rec') !== false || strpos($hostname, 'rapp') !== false) {
if (strpos($hostname, 'pra') !== false) {
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
} elseif (strpos($hostname, 'rca') !== false) {
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
} else {
$_SERVER['SERVER_NAME'] = 'app.geo.dev'; // DVA par défaut
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DVA par défaut
}
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
@@ -69,6 +69,7 @@ require_once __DIR__ . '/../../src/Services/LogService.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
use App\Services\LogService;
try {
// Initialisation de la configuration

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env php
<?php
/**
* Script CRON pour rotation des logs d'événements JSONL
*
* Politique de rétention : 15 mois
* - 0-15 mois : fichiers .jsonl conservés (non compressés pour accès API)
* - > 15 mois : suppression
*
* À exécuter mensuellement via crontab (1er du mois à 3h) :
* 0 3 1 * * /usr/bin/php /var/www/geosector/api/scripts/cron/rotate_event_logs.php >> /var/www/geosector/api/logs/rotation_events.log 2>&1
*/
declare(strict_types=1);
// Configuration
define('RETENTION_MONTHS', 15); // Conserver 15 mois
define('LOCK_FILE', '/tmp/rotate_event_logs.lock');
// Empêcher l'exécution multiple simultanée
if (file_exists(LOCK_FILE)) {
$lockTime = filemtime(LOCK_FILE);
// Si le lock a plus de 2 heures, on le supprime (processus probablement bloqué)
if (time() - $lockTime > 7200) {
unlink(LOCK_FILE);
} else {
die("Le processus est déjà en cours d'exécution\n");
}
}
// Créer le fichier de lock
file_put_contents(LOCK_FILE, getmypid());
// Enregistrer un handler pour supprimer le lock en cas d'arrêt
register_shutdown_function(function() {
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
});
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
// Détecter l'environnement basé sur le hostname
$hostname = gethostname();
if (strpos($hostname, 'pra') !== false) {
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
} elseif (strpos($hostname, 'rca') !== false) {
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
} else {
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DVA par défaut
}
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
// Définir getallheaders si elle n'existe pas (CLI)
if (!function_exists('getallheaders')) {
function getallheaders() {
return [];
}
}
}
// Chargement de l'environnement
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
require_once __DIR__ . '/../../src/Services/LogService.php';
try {
// Initialisation de la configuration
$appConfig = AppConfig::getInstance();
$environment = $appConfig->getEnvironment();
// Définir le chemin du dossier des logs événements
$eventLogDir = __DIR__ . '/../../logs/events';
if (!is_dir($eventLogDir)) {
echo "Le dossier de logs événements n'existe pas : {$eventLogDir}\n";
exit(0);
}
// Date limite de suppression
$deletionDate = strtotime('-' . RETENTION_MONTHS . ' months');
// Lister tous les fichiers .jsonl
$jsonlFiles = glob($eventLogDir . '/*.jsonl');
if (empty($jsonlFiles)) {
echo "Aucun fichier .jsonl trouvé dans {$eventLogDir}\n";
exit(0);
}
$deletedCount = 0;
$deletedSize = 0;
$deletedFiles = [];
// ========================================
// Suppression des fichiers > 15 mois
// ========================================
foreach ($jsonlFiles as $file) {
$fileTime = filemtime($file);
// Vérifier si le fichier est plus vieux que la date de rétention
if ($fileTime < $deletionDate) {
$fileSize = filesize($file);
$fileName = basename($file);
if (unlink($file)) {
$deletedCount++;
$deletedSize += $fileSize;
$deletedFiles[] = $fileName;
echo "Supprimé : {$fileName} (> " . RETENTION_MONTHS . " mois, " .
number_format($fileSize / 1024, 2) . " KB)\n";
} else {
echo "ERREUR : Impossible de supprimer {$fileName}\n";
}
}
}
// ========================================
// RÉSUMÉ ET LOGGING
// ========================================
if ($deletedCount > 0) {
$message = sprintf(
"Rotation des logs événements terminée - %d fichier(s) supprimé(s) - %.2f MB libérés",
$deletedCount,
$deletedSize / (1024 * 1024)
);
LogService::log($message, [
'level' => 'info',
'script' => 'rotate_event_logs.php',
'environment' => $environment,
'deleted_count' => $deletedCount,
'deleted_size_mb' => round($deletedSize / (1024 * 1024), 2),
'deleted_files' => $deletedFiles
]);
echo "\n" . $message . "\n";
} else {
echo "Aucune rotation nécessaire - Tous les fichiers .jsonl ont moins de " . RETENTION_MONTHS . " mois\n";
}
} catch (Exception $e) {
$errorMsg = 'Erreur lors de la rotation des logs événements : ' . $e->getMessage();
LogService::log($errorMsg, [
'level' => 'error',
'script' => 'rotate_event_logs.php',
'trace' => $e->getTraceAsString()
]);
echo $errorMsg . "\n";
// Supprimer le lock en cas d'erreur
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
exit(1);
}
// Supprimer le lock
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
exit(0);

View File

@@ -1,186 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour vérifier le processeur de queue d'emails
* Affiche les emails en attente sans les envoyer
*/
declare(strict_types=1);
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
$_SERVER['SERVER_NAME'] = $_SERVER['SERVER_NAME'] ?? 'app.geo.dev'; // DVA par défaut
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
// Définir getallheaders si elle n'existe pas (CLI)
if (!function_exists('getallheaders')) {
function getallheaders() {
return [];
}
}
}
require_once __DIR__ . '/../../src/Core/Database.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
try {
// Initialiser la configuration
$appConfig = AppConfig::getInstance();
$dbConfig = $appConfig->getDatabaseConfig();
// Initialiser la base de données avec la configuration
Database::init($dbConfig);
$db = Database::getInstance();
echo "=== TEST DE LA QUEUE D'EMAILS ===\n\n";
// Statistiques générales
$stmt = $db->query('
SELECT
status,
COUNT(*) as count,
MIN(created_at) as oldest,
MAX(created_at) as newest
FROM email_queue
GROUP BY status
');
$stats = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "STATISTIQUES:\n";
echo "-------------\n";
foreach ($stats as $stat) {
echo sprintf(
"Status: %s - Nombre: %d (Plus ancien: %s, Plus récent: %s)\n",
$stat['status'],
$stat['count'],
$stat['oldest'] ?? 'N/A',
$stat['newest'] ?? 'N/A'
);
}
echo "\n";
// Emails en attente
$stmt = $db->prepare('
SELECT
eq.id,
eq.fk_pass,
eq.to_email,
eq.subject,
eq.created_at,
eq.attempts,
eq.status,
p.fk_type,
p.montant,
p.nom_recu
FROM email_queue eq
LEFT JOIN ope_pass p ON eq.fk_pass = p.id
WHERE eq.status = ?
ORDER BY eq.created_at DESC
LIMIT 10
');
$stmt->execute(['pending']);
$pendingEmails = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($pendingEmails)) {
echo "Aucun email en attente.\n";
} else {
echo "EMAILS EN ATTENTE (10 plus récents):\n";
echo "------------------------------------\n";
foreach ($pendingEmails as $email) {
echo sprintf(
"ID: %d | Passage: %d | Destinataire: %s\n",
$email['id'],
$email['fk_pass'],
$email['to_email']
);
echo sprintf(
" Sujet: %s\n",
$email['subject']
);
echo sprintf(
" Créé le: %s | Tentatives: %d\n",
$email['created_at'],
$email['attempts']
);
if ($email['fk_pass'] > 0) {
echo sprintf(
" Passage - Type: %s | Montant: %.2f€ | Reçu: %s\n",
$email['fk_type'] == 1 ? 'DON' : 'Autre',
$email['montant'] ?? 0,
$email['nom_recu'] ?? 'Non généré'
);
}
echo "---\n";
}
}
// Emails échoués
$stmt = $db->prepare('
SELECT
id,
fk_pass,
to_email,
subject,
created_at,
attempts,
error_message
FROM email_queue
WHERE status = ?
ORDER BY created_at DESC
LIMIT 5
');
$stmt->execute(['failed']);
$failedEmails = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($failedEmails)) {
echo "\nEMAILS ÉCHOUÉS (5 plus récents):\n";
echo "--------------------------------\n";
foreach ($failedEmails as $email) {
echo sprintf(
"ID: %d | Passage: %d | Destinataire: %s\n",
$email['id'],
$email['fk_pass'],
$email['to_email']
);
echo sprintf(
" Sujet: %s\n",
$email['subject']
);
echo sprintf(
" Tentatives: %d | Erreur: %s\n",
$email['attempts'],
$email['error_message'] ?? 'Non spécifiée'
);
echo "---\n";
}
}
// Vérifier la configuration SMTP
echo "\nCONFIGURATION SMTP:\n";
echo "-------------------\n";
$smtpConfig = $appConfig->getSmtpConfig();
$emailConfig = $appConfig->getEmailConfig();
echo "Host: " . ($smtpConfig['host'] ?? 'Non configuré') . "\n";
echo "Port: " . ($smtpConfig['port'] ?? 'Non configuré') . "\n";
echo "Username: " . ($smtpConfig['user'] ?? 'Non configuré') . "\n";
echo "Password: " . (isset($smtpConfig['pass']) ? '***' : 'Non configuré') . "\n";
echo "Encryption: " . ($smtpConfig['secure'] ?? 'Non configuré') . "\n";
echo "From Email: " . ($emailConfig['from'] ?? 'Non configuré') . "\n";
echo "Contact Email: " . ($emailConfig['contact'] ?? 'Non configuré') . "\n";
echo "\n=== FIN DU TEST ===\n";
} catch (Exception $e) {
echo "ERREUR: " . $e->getMessage() . "\n";
exit(1);
}
exit(0);

View File

@@ -42,7 +42,7 @@ register_shutdown_function(function() {
if (php_sapi_name() === 'cli') {
$hostname = gethostname();
if (strpos($hostname, 'prod') !== false || strpos($hostname, 'pra') !== false) {
$_SERVER['SERVER_NAME'] = 'app.geosector.fr';
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
} elseif (strpos($hostname, 'rec') !== false || strpos($hostname, 'rca') !== false) {
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
} else {
@@ -67,6 +67,8 @@ require_once dirname(dirname(__DIR__)) . '/src/Config/AppConfig.php';
require_once dirname(dirname(__DIR__)) . '/src/Core/Database.php';
require_once dirname(dirname(__DIR__)) . '/src/Services/LogService.php';
use App\Services\LogService;
try {
echo "[" . date('Y-m-d H:i:s') . "] Début de la mise à jour des devices Stripe certifiés\n";

467
api/scripts/migrate_batch.sh Executable file
View File

@@ -0,0 +1,467 @@
#!/bin/bash
###############################################################################
# Script de migration en batch des entités depuis geosector_20251008
#
# Usage: ./migrate_batch.sh [options]
#
# Options:
# --start N Commencer à partir de l'entité N (défaut: 1)
# --limit N Migrer seulement N entités (défaut: toutes)
# --dry-run Simuler sans exécuter
# --continue Continuer après une erreur (défaut: s'arrêter)
# --interactive Mode interactif (défaut si aucune option)
#
# Exemple:
# ./migrate_batch.sh --start 10 --limit 5
# ./migrate_batch.sh --continue
# ./migrate_batch.sh --interactive
###############################################################################
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JSON_FILE="${SCRIPT_DIR}/migrations_entites.json"
LOG_DIR="/var/www/geosector/api/logs/migrations"
MIGRATION_SCRIPT="${SCRIPT_DIR}/php/migrate_from_backup.php"
SOURCE_DB="geosector_20251013_13"
TARGET_DB="pra_geo"
# Paramètres par défaut
START_INDEX=1
LIMIT=0
DRY_RUN=0
CONTINUE_ON_ERROR=0
INTERACTIVE_MODE=0
SPECIFIC_ENTITY_ID=""
SPECIFIC_CP=""
# Couleurs
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Sauvegarder le nombre d'arguments avant le parsing
INITIAL_ARGS=$#
# Parse des arguments
while [[ $# -gt 0 ]]; do
case $1 in
--start)
START_INDEX="$2"
shift 2
;;
--limit)
LIMIT="$2"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
--continue)
CONTINUE_ON_ERROR=1
shift
;;
--interactive|-i)
INTERACTIVE_MODE=1
shift
;;
--help)
grep "^#" "$0" | grep -v "^#!/bin/bash" | sed 's/^# //'
exit 0
;;
*)
echo "Option inconnue: $1"
echo "Utilisez --help pour l'aide"
exit 1
;;
esac
done
# Activer le mode interactif si aucun argument n'a été fourni
if [ $INITIAL_ARGS -eq 0 ]; then
INTERACTIVE_MODE=1
fi
# Vérifications préalables
if [ ! -f "$JSON_FILE" ]; then
echo -e "${RED}❌ Fichier JSON introuvable: $JSON_FILE${NC}"
exit 1
fi
if [ ! -f "$MIGRATION_SCRIPT" ]; then
echo -e "${RED}❌ Script de migration introuvable: $MIGRATION_SCRIPT${NC}"
exit 1
fi
# Créer le répertoire de logs
mkdir -p "$LOG_DIR"
# Fichiers de log
BATCH_LOG="${LOG_DIR}/batch_$(date +%Y%m%d_%H%M%S).log"
SUCCESS_LOG="${LOG_DIR}/success.log"
ERROR_LOG="${LOG_DIR}/errors.log"
# MODE INTERACTIF
if [ $INTERACTIVE_MODE -eq 1 ]; then
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
echo -e "${CYAN} 🔧 Mode interactif - Migration d'entités GeoSector${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
echo ""
# Question 1: Migration globale ou ciblée ?
echo -e "${YELLOW}1⃣ Type de migration :${NC}"
echo -e " ${CYAN}a)${NC} Migration globale (toutes les entités)"
echo -e " ${CYAN}b)${NC} Migration par lot (plage d'entités)"
echo -e " ${CYAN}c)${NC} Migration par code postal"
echo -e " ${CYAN}d)${NC} Migration d'une entité spécifique (ID)"
echo ""
echo -ne "${YELLOW}Votre choix (a/b/c/d) : ${NC}"
read -r MIGRATION_TYPE
echo ""
case $MIGRATION_TYPE in
a|A)
# Migration globale - garder les valeurs par défaut
START_INDEX=1
LIMIT=0
echo -e "${GREEN}${NC} Migration globale sélectionnée"
;;
b|B)
# Migration par lot
echo -e "${YELLOW}2⃣ Configuration du lot :${NC}"
echo -ne " Première entité (index, défaut=1) : "
read -r USER_START
if [ -n "$USER_START" ]; then
START_INDEX=$USER_START
fi
echo -ne " Limite (nombre d'entités, défaut=toutes) : "
read -r USER_LIMIT
if [ -n "$USER_LIMIT" ]; then
LIMIT=$USER_LIMIT
fi
echo ""
echo -e "${GREEN}${NC} Migration par lot : de l'index $START_INDEX, limite de $LIMIT entités"
;;
c|C)
# Migration par code postal
echo -ne "${YELLOW}2⃣ Code postal à migrer : ${NC}"
read -r SPECIFIC_CP
echo ""
if [ -z "$SPECIFIC_CP" ]; then
echo -e "${RED}❌ Code postal requis${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Migration pour le code postal : $SPECIFIC_CP"
;;
d|D)
# Migration d'une entité spécifique - bypass complet du JSON
echo -ne "${YELLOW}2⃣ ID de l'entité à migrer : ${NC}"
read -r SPECIFIC_ENTITY_ID
echo ""
if [ -z "$SPECIFIC_ENTITY_ID" ]; then
echo -e "${RED}❌ ID d'entité requis${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Migration de l'entité ID : $SPECIFIC_ENTITY_ID"
echo ""
# Demander si suppression des données de l'entité avant migration
echo -ne "${YELLOW}3⃣ Supprimer les données existantes de cette entité dans la TARGET avant migration ? (y/N): ${NC}"
read -r DELETE_BEFORE
DELETE_FLAG=""
if [[ $DELETE_BEFORE =~ ^[Yy]$ ]]; then
echo -e "${GREEN}${NC} Les données seront supprimées avant migration"
DELETE_FLAG="--delete-before"
else
echo -e "${BLUE}${NC} Les données seront conservées (ON DUPLICATE KEY UPDATE)"
fi
echo ""
# Confirmer la migration
echo -ne "${YELLOW}⚠️ Confirmer la migration de l'entité #${SPECIFIC_ENTITY_ID} ? (y/N): ${NC}"
read -r CONFIRM
if [[ ! $CONFIRM =~ ^[Yy]$ ]]; then
echo -e "${RED}❌ Migration annulée${NC}"
exit 0
fi
# Exécuter directement la migration sans passer par le JSON
ENTITY_LOG="${LOG_DIR}/entity_${SPECIFIC_ENTITY_ID}_$(date +%Y%m%d_%H%M%S).log"
echo ""
echo -e "${BLUE}⏳ Migration de l'entité #${SPECIFIC_ENTITY_ID} en cours...${NC}"
php "$MIGRATION_SCRIPT" \
--source-db="$SOURCE_DB" \
--target-db="$TARGET_DB" \
--mode=entity \
--entity-id="$SPECIFIC_ENTITY_ID" \
--log="$ENTITY_LOG" \
$DELETE_FLAG
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}✅ Entité #${SPECIFIC_ENTITY_ID} migrée avec succès${NC}"
echo -e "${BLUE}📋 Log détaillé : $ENTITY_LOG${NC}"
else
echo -e "${RED}❌ Erreur lors de la migration de l'entité #${SPECIFIC_ENTITY_ID}${NC}"
echo -e "${RED}📋 Voir le log : $ENTITY_LOG${NC}"
exit 1
fi
exit 0
;;
*)
echo -e "${RED}❌ Choix invalide${NC}"
exit 1
;;
esac
echo ""
fi
# Fonctions utilitaires
log() {
echo -e "$1" | tee -a "$BATCH_LOG"
}
log_success() {
echo "$1" >> "$SUCCESS_LOG"
log "${GREEN}${NC} $1"
}
log_error() {
echo "$1" >> "$ERROR_LOG"
log "${RED}${NC} $1"
}
# Extraire les entity_id du JSON (compatible sans jq)
get_entity_ids() {
if [ -n "$SPECIFIC_ENTITY_ID" ]; then
# Entité spécifique par ID - chercher exactement "entity_id" : ID,
grep "\"entity_id\" : ${SPECIFIC_ENTITY_ID}," "$JSON_FILE" | sed 's/.*: \([0-9]*\).*/\1/'
elif [ -n "$SPECIFIC_CP" ]; then
# Entités par code postal
grep -B 2 "\"code_postal\" : \"$SPECIFIC_CP\"" "$JSON_FILE" | grep '"entity_id"' | sed 's/.*: \([0-9]*\).*/\1/'
else
# Toutes les entités
grep '"entity_id"' "$JSON_FILE" | sed 's/.*: \([0-9]*\).*/\1/'
fi
}
# Compter le nombre total d'entités
TOTAL_ENTITIES=$(get_entity_ids | wc -l)
# Vérifier si des entités ont été trouvées
if [ $TOTAL_ENTITIES -eq 0 ]; then
if [ -n "$SPECIFIC_ENTITY_ID" ]; then
echo -e "${RED}❌ Entité #${SPECIFIC_ENTITY_ID} introuvable dans le fichier JSON${NC}"
elif [ -n "$SPECIFIC_CP" ]; then
echo -e "${RED}❌ Aucune entité trouvée pour le code postal ${SPECIFIC_CP}${NC}"
else
echo -e "${RED}❌ Aucune entité trouvée${NC}"
fi
exit 1
fi
# Calculer le nombre d'entités à migrer
if [ $LIMIT -gt 0 ]; then
END_INDEX=$((START_INDEX + LIMIT - 1))
if [ $END_INDEX -gt $TOTAL_ENTITIES ]; then
END_INDEX=$TOTAL_ENTITIES
fi
else
END_INDEX=$TOTAL_ENTITIES
fi
# Bannière de démarrage
echo ""
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
log "${BLUE} Migration en batch des entités GeoSector${NC}"
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
log "📅 Date: $(date '+%Y-%m-%d %H:%M:%S')"
log "📁 Source: $SOURCE_DB"
log "📁 Cible: $TARGET_DB"
# Afficher les informations selon le mode
if [ -n "$SPECIFIC_ENTITY_ID" ]; then
log "🎯 Mode: Migration d'une entité spécifique"
log "📊 Entité ID: $SPECIFIC_ENTITY_ID"
elif [ -n "$SPECIFIC_CP" ]; then
log "🎯 Mode: Migration par code postal"
log "📮 Code postal: $SPECIFIC_CP"
log "📊 Entités trouvées: $TOTAL_ENTITIES"
else
TOTAL_AVAILABLE=$(grep '"entity_id"' "$JSON_FILE" | wc -l)
log "📊 Total entités disponibles: $TOTAL_AVAILABLE"
log "🎯 Entités à migrer: $START_INDEX à $END_INDEX"
fi
if [ $DRY_RUN -eq 1 ]; then
log "${YELLOW}🔍 Mode DRY-RUN (simulation)${NC}"
fi
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
echo ""
# Confirmation utilisateur
if [ $DRY_RUN -eq 0 ]; then
echo -ne "${YELLOW}⚠️ Confirmer la migration ? (y/N): ${NC}"
read -r CONFIRM
if [[ ! $CONFIRM =~ ^[Yy]$ ]]; then
log "❌ Migration annulée par l'utilisateur"
exit 0
fi
echo ""
fi
# Compteurs
SUCCESS_COUNT=0
ERROR_COUNT=0
SKIPPED_COUNT=0
CURRENT_INDEX=0
# Début de la migration
START_TIME=$(date +%s)
# Lire les entity_id et migrer
get_entity_ids | while read -r ENTITY_ID; do
CURRENT_INDEX=$((CURRENT_INDEX + 1))
# Filtrer par index
if [ $CURRENT_INDEX -lt $START_INDEX ]; then
continue
fi
if [ $CURRENT_INDEX -gt $END_INDEX ]; then
break
fi
# Récupérer les détails de l'entité depuis le JSON (match exact avec la virgule)
ENTITY_INFO=$(grep -A 8 "\"entity_id\" : ${ENTITY_ID}," "$JSON_FILE")
ENTITY_NAME=$(echo "$ENTITY_INFO" | grep '"nom"' | sed 's/.*: "\(.*\)".*/\1/')
ENTITY_CP=$(echo "$ENTITY_INFO" | grep '"code_postal"' | sed 's/.*: "\(.*\)".*/\1/')
NB_USERS=$(echo "$ENTITY_INFO" | grep '"nb_users"' | sed 's/.*: \([0-9]*\).*/\1/')
NB_PASSAGES=$(echo "$ENTITY_INFO" | grep '"nb_passages"' | sed 's/.*: \([0-9]*\).*/\1/')
# Afficher la progression
PROGRESS=$((CURRENT_INDEX - START_INDEX + 1))
TOTAL=$((END_INDEX - START_INDEX + 1))
PERCENT=$((PROGRESS * 100 / TOTAL))
log ""
log "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log "${BLUE}[$PROGRESS/$TOTAL - ${PERCENT}%]${NC} Entité #${ENTITY_ID}: ${ENTITY_NAME} (${ENTITY_CP})"
log " 👥 Users: ${NB_USERS} | 📍 Passages: ${NB_PASSAGES}"
# Mode dry-run
if [ $DRY_RUN -eq 1 ]; then
log "${YELLOW} 🔍 [DRY-RUN] Simulation de la migration${NC}"
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
# Exécuter la migration
ENTITY_LOG="${LOG_DIR}/entity_${ENTITY_ID}_$(date +%Y%m%d_%H%M%S).log"
log " ⏳ Migration en cours..."
php "$MIGRATION_SCRIPT" \
--source-db="$SOURCE_DB" \
--target-db="$TARGET_DB" \
--mode=entity \
--entity-id="$ENTITY_ID" \
--log="$ENTITY_LOG" > /tmp/migration_output_$$.txt 2>&1
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
# Succès
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
log_success "Entité #${ENTITY_ID} (${ENTITY_NAME}) migrée avec succès"
# Afficher un résumé du log avec détails
if [ -f "$ENTITY_LOG" ]; then
# Chercher la ligne avec les marqueurs #STATS#
STATS_LINE=$(grep "#STATS#" "$ENTITY_LOG" 2>/dev/null)
if [ -n "$STATS_LINE" ]; then
# Extraire chaque compteur
OPE=$(echo "$STATS_LINE" | grep -oE 'OPE:[0-9]+' | cut -d: -f2)
USERS=$(echo "$STATS_LINE" | grep -oE 'USER:[0-9]+' | cut -d: -f2)
SECTORS=$(echo "$STATS_LINE" | grep -oE 'SECTOR:[0-9]+' | cut -d: -f2)
PASSAGES=$(echo "$STATS_LINE" | grep -oE 'PASS:[0-9]+' | cut -d: -f2)
# Valeurs par défaut si extraction échoue
OPE=${OPE:-0}
USERS=${USERS:-0}
SECTORS=${SECTORS:-0}
PASSAGES=${PASSAGES:-0}
log " 📊 ope: ${OPE} | users: ${USERS} | sectors: ${SECTORS} | passages: ${PASSAGES}"
else
log " 📊 Statistiques non disponibles"
fi
fi
else
# Erreur
ERROR_COUNT=$((ERROR_COUNT + 1))
log_error "Entité #${ENTITY_ID} (${ENTITY_NAME}) - Erreur code $EXIT_CODE"
# Afficher les dernières lignes du log d'erreur
if [ -f "/tmp/migration_output_$$.txt" ]; then
log "${RED} 📋 Dernières erreurs:${NC}"
tail -5 "/tmp/migration_output_$$.txt" | sed 's/^/ /' | tee -a "$BATCH_LOG"
fi
# Arrêter ou continuer ?
if [ $CONTINUE_ON_ERROR -eq 0 ]; then
log ""
log "${RED}❌ Migration interrompue suite à une erreur${NC}"
log " Utilisez --continue pour continuer malgré les erreurs"
exit 1
fi
fi
# Nettoyage
rm -f "/tmp/migration_output_$$.txt"
# Pause entre les migrations (pour éviter de surcharger)
sleep 1
done
# Fin de la migration
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
HOURS=$((DURATION / 3600))
MINUTES=$(((DURATION % 3600) / 60))
SECONDS=$((DURATION % 60))
# Résumé final
log ""
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
log "${BLUE} Résumé de la migration${NC}"
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
log "✅ Succès: ${GREEN}${SUCCESS_COUNT}${NC}"
log "❌ Erreurs: ${RED}${ERROR_COUNT}${NC}"
log "⏭️ Ignorées: ${YELLOW}${SKIPPED_COUNT}${NC}"
log "⏱️ Durée: ${HOURS}h ${MINUTES}m ${SECONDS}s"
log ""
log "📋 Logs détaillés:"
log " - Batch: $BATCH_LOG"
log " - Succès: $SUCCESS_LOG"
log " - Erreurs: $ERROR_LOG"
log " - Individuels: $LOG_DIR/entity_*.log"
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
# Code de sortie
if [ $ERROR_COUNT -gt 0 ]; then
exit 1
else
exit 0
fi

View File

@@ -0,0 +1,410 @@
# Migration v2 - Architecture modulaire
## Vue d'ensemble
Cette nouvelle architecture simplifie la migration en utilisant :
- **Source fixe** : `geosector` (synchronisée 2x/jour par PM7 depuis nx4)
- **Multi-environnement** : `--env=dva` (développement), `--env=rca` (recette) ou `--env=pra` (production)
- **Auto-détection** : L'environnement est détecté automatiquement selon le serveur
- **Classes réutilisables** : Configuration, Logger, Connexion
## Structure modulaire
```
migration2/
├── README.md # Ce fichier
├── logs/ # Logs de migration (auto-créé)
│ └── .gitignore
├── php/
│ ├── migrate_from_backup.php # Script principal orchestrateur
│ └── lib/
│ ├── DatabaseConfig.php # Configuration multi-env
│ ├── MigrationLogger.php # Gestion des logs
│ ├── DatabaseConnection.php # Connexions PDO
│ ├── OperationMigrator.php # Migration des opérations
│ ├── UserMigrator.php # Migration des ope_users
│ ├── SectorMigrator.php # Migration des secteurs
│ └── PassageMigrator.php # Migration des passages
```
**Architecture modulaire** : Chaque type de données a son propre migrator spécialisé, orchestré par le script principal.
## ⚠️ AVERTISSEMENT IMPORTANT
**Par défaut, le script SUPPRIME toutes les données de l'entité dans la base cible avant la migration.**
Cela inclut :
- ✅ Toutes les opérations de l'entité
- ✅ Tous les utilisateurs de l'entité
- ✅ Tous les secteurs et passages
- ✅ Tous les médias associés
- L'entité elle-même est conservée (seules les données liées sont supprimées)
Pour **désactiver** la suppression et conserver les données existantes :
```bash
php php/migrate_from_backup.php --mode=entity --entity-id=45 --delete-before=false
```
⚠️ **Attention** : Sans suppression préalable, risque de doublons si les données existent déjà.
---
## Utilisation
### Migration d'une entité spécifique
#### Sur dva-geo (IN3)
```bash
# Auto-détection de l'environnement (recommandé)
php php/migrate_from_backup.php --mode=entity --entity-id=45
# Ou avec environnement explicite
php php/migrate_from_backup.php --env=dva --mode=entity --entity-id=45
```
#### Sur rca-geo (IN3)
```bash
# Auto-détection de l'environnement (recommandé)
php php/migrate_from_backup.php --mode=entity --entity-id=45
# Ou avec environnement explicite
php php/migrate_from_backup.php --env=rca --mode=entity --entity-id=45
```
#### Sur pra-geo (IN4)
```bash
# Auto-détection de l'environnement (recommandé)
php php/migrate_from_backup.php --mode=entity --entity-id=45
# Ou avec environnement explicite
php php/migrate_from_backup.php --env=pra --mode=entity --entity-id=45
```
### Migration globale (toutes les entités)
```bash
# Sur dva-geo, rca-geo ou pra-geo
php php/migrate_from_backup.php --mode=global
```
### Options disponibles
```bash
--env=ENV # 'dva' (développement), 'rca' (recette) ou 'pra' (production)
# Par défaut : auto-détection selon le hostname
--mode=MODE # 'global' ou 'entity' (défaut: global)
--entity-id=ID # ID de l'entité à migrer (requis si mode=entity)
--log=PATH # Fichier de log personnalisé
# Par défaut : logs/migration_YYYYMMDD_HHMMSS.log
--delete-before # Supprimer les données existantes avant migration (défaut: true)
--help # Afficher l'aide complète
```
### Exemples d'utilisation
```bash
# Migration STANDARD (avec suppression des données existantes - recommandé)
php php/migrate_from_backup.php --mode=entity --entity-id=45
# Migration SANS suppression (pour ajout/mise à jour uniquement - risque de doublons)
php php/migrate_from_backup.php --mode=entity --entity-id=45 --delete-before=false
# Migration avec log personnalisé
php php/migrate_from_backup.php --mode=entity --entity-id=45 --log=/custom/path/entity_45.log
# Afficher l'aide complète
php php/migrate_from_backup.php --help
```
## Différences avec l'ancienne version
| Aspect | Ancien | Nouveau |
|--------|--------|---------|
| **Source** | `--source-db=geosector_YYYYMMDD_HH` | Toujours `geosector` (fixe) |
| **Cible** | `--target-db=pra_geo` | Déduite de `--env` ou auto-détectée (dva_geo, rca_geo, pra_geo) |
| **Config** | Constantes hardcodées | Classes configurables |
| **Environnement** | Manuel | Auto-détection par hostname (dva-geo, rca-geo, pra-geo) |
| **Arguments** | 2 arguments DB requis | 1 seul `--env` (optionnel) |
## Avantages
**Plus simple** : Plus besoin de spécifier les noms de bases
**Plus sûr** : Moins de risques d'erreurs de saisie
**Plus flexible** : Fonctionne sur dva-geo, rca-geo et pra-geo sans modification
**Plus maintenable** : Configuration centralisée dans DatabaseConfig
**Meilleurs logs** : Séparateurs, niveaux (info/warning/error/success)
## Déploiement
### Copier vers dva-geo (IN3)
```bash
scp -r migration2 root@195.154.80.116:/tmp/
ssh root@195.154.80.116 "incus file push -r /tmp/migration2 dva-geo/var/www/geosector/api/scripts/"
```
### Copier vers rca-geo (IN3)
```bash
scp -r migration2 root@195.154.80.116:/tmp/
ssh root@195.154.80.116 "incus file push -r /tmp/migration2 rca-geo/var/www/geosector/api/scripts/"
```
### Copier vers pra-geo (IN4)
```bash
scp -r migration2 root@51.159.7.190:/tmp/
ssh root@51.159.7.190 "incus file push -r /tmp/migration2 pra-geo/var/www/geosector/api/scripts/"
```
### Test après déploiement
```bash
# Se connecter au container
incus exec dva-geo -- bash # ou rca-geo, ou pra-geo
# Tester avec une entité
cd /var/www/geosector/api/scripts/migration2
php php/migrate_from_backup.php --mode=entity --entity-id=45
```
## Logs
Les logs sont enregistrés par défaut dans :
```
scripts/migration2/logs/migration_[MODE]_YYYYMMDD_HHMMSS.log
```
**Nommage automatique selon le mode :**
- Migration globale : `migration_global_20251021_143045.log`
- Migration d'une entité : `migration_entite_45_20251021_143045.log`
Format des logs :
- `[INFO]` : Informations générales
- `[SUCCESS]` : Opérations réussies
- `[WARNING]` : Avertissements
- `[ERROR]` : Erreurs
Le dossier `logs/` est créé automatiquement si nécessaire.
**Note :** Vous pouvez toujours spécifier un fichier de log personnalisé avec l'option `--log=PATH`.
## Récapitulatif de migration
À la fin de chaque migration, un **récapitulatif détaillé** est automatiquement affiché et enregistré dans le fichier de log.
### Format du récapitulatif
```
========================================
📊 RÉCAPITULATIF DE LA MIGRATION
========================================
Entité: Nom de l'entité (ID: XX)
Date: YYYY-MM-DD HH:MM:SS
Opérations migrées: 3
Opération #1: "Adhésions 2024" (ID: 850)
├─ Utilisateurs: 12
├─ Secteurs: 5
├─ Passages totaux: 245
└─ Détail par secteur:
├─ Centre-ville (ID: 5400)
│ ├─ Utilisateurs affectés: 3
│ └─ Passages: 67
├─ Quartier Est (ID: 5401)
│ ├─ Utilisateurs affectés: 5
│ └─ Passages: 98
└─ Nord (ID: 5402)
├─ Utilisateurs affectés: 4
└─ Passages: 80
Opération #2: "Collecte Printemps" (ID: 851)
├─ Utilisateurs: 8
├─ Secteurs: 3
├─ Passages totaux: 156
└─ Détail par secteur:
[...]
========================================
```
### Informations fournies
Le récapitulatif inclut pour chaque migration :
**Au niveau de l'entité :**
- Nom et ID de l'entité
- Date et heure de la migration
- Nombre total d'opérations migrées
**Pour chaque opération :**
- Nom et nouvel ID
- Nombre d'utilisateurs migrés
- Nombre de secteurs migrés
- Nombre total de passages migrés
**Pour chaque secteur :**
- Nom et nouvel ID
- Nombre d'utilisateurs affectés au secteur
- Nombre de passages effectués dans le secteur
### Utilisation du récapitulatif
Ce récapitulatif permet de :
- ✅ Vérifier rapidement que toutes les données ont été migrées
- ✅ Comparer avec les données source pour validation
- ✅ Identifier d'éventuelles anomalies (secteurs vides, passages manquants)
- ✅ Documenter précisément ce qui a été migré
- ✅ Tracer les migrations pour audit
Le récapitulatif est présent à la fois :
- **À l'écran** (stdout) en temps réel
- **Dans le fichier de log** pour conservation
## Dépannage
### Erreur "env doit être 'dva', 'rca' ou 'pra'"
L'auto-détection a échoué. Spécifiez manuellement :
```bash
php php/migrate_from_backup.php --env=dva --mode=entity --entity-id=45
```
### Erreur de connexion
Vérifiez que vous êtes bien dans le bon container (dva-geo, rca-geo ou pra-geo).
### Données dupliquées après migration
Si vous avez des doublons, c'est que vous avez utilisé `--delete-before=false` sur des données existantes.
**Solution** : Refaire la migration avec suppression (défaut) :
```bash
php php/migrate_from_backup.php --mode=entity --entity-id=45
```
### Vérifier ce qui sera supprimé avant migration
Consultez la section "Ordre de suppression" ci-dessous pour voir exactement quelles tables seront affectées.
### Logs non créés
Vérifiez les permissions du dossier `logs/` :
```bash
ls -la scripts/migration2/logs/
```
## Détails techniques
### Architecture hiérarchique de migration
La migration fonctionne par **opération** avec une hiérarchie complète :
```
Pour chaque opération de l'entité:
migrateOperation($oldOperationId)
├── Créer operation
├── Migrer ope_users (DISTINCT depuis ope_users_sectors)
│ └── Mapper oldUserId → newOpeUserId
├── Pour chaque secteur DISTINCT de l'opération:
│ └── migrateSector($oldOperationId, $newOperationId, $oldSectorId)
│ ├── Créer ope_sectors
│ ├── Mapper "opId_sectId" → newOpeSectorId
│ ├── Migrer sectors_adresses (fk_sector = newOpeSectorId)
│ ├── Migrer ope_users_sectors (avec mappings users + sector)
│ ├── Migrer ope_pass (avec mappings users + sector)
│ │ └── Pour chaque passage:
│ │ └── migratePassageHisto($oldPassId, $newPassId)
│ └── Migrer médias des passages
└── Migrer médias de l'opération
```
### Changement d'organisation des données : Exemple concret
#### Contexte : Opération de collecte des adhésions 2024
**Ancienne organisation** (base geosector - partagée) :
- 1 opération "Adhésions 2024" avec ID 450
- 3 utilisateurs affectés : Jean (ID 100), Marie (ID 101), Paul (ID 102)
- 2 secteurs utilisés : Centre-ville (ID 1004) et Quartier Est (ID 1005)
- Jean travaille sur Centre-ville, Marie et Paul sur Quartier Est
Dans l'ancienne base :
- Les 3 users existent UNE SEULE FOIS dans la table centrale `users`
- Les 2 secteurs existent UNE SEULE FOIS dans la table centrale `sectors`
- Les liens entre users et secteurs sont dans `ope_users_sectors`
- Les passages font référence directement aux users (ID 100, 101, 102)
**Nouvelle organisation** (base rca_geo/pra_geo - isolée par opération) :
Après migration, **CHAQUE opération devient autonome** :
- L'opération "Adhésions 2024" reçoit un nouvel ID (exemple : 850)
- Les 3 utilisateurs sont **dupliqués** dans `ope_users` avec de nouveaux IDs :
- Jean → ope_users.id = 2500 (avec fk_user = 100 et fk_operation = 850)
- Marie → ope_users.id = 2501 (avec fk_user = 101 et fk_operation = 850)
- Paul → ope_users.id = 2502 (avec fk_user = 102 et fk_operation = 850)
- Les 2 secteurs sont **dupliqués** dans `ope_sectors` :
- Centre-ville → ope_sectors.id = 5400 (avec fk_operation = 850)
- Quartier Est → ope_sectors.id = 5401 (avec fk_operation = 850)
- Tous les passages sont mis à jour pour référencer les NOUVEAUX IDs (2500, 2501, 2502)
**Pourquoi cette duplication ?**
**Isolation complète** : Si l'opération est supprimée, tout part avec (secteurs, users, passages)
**Performance** : Pas de jointures complexes entre opérations
**Historique** : Les données de l'opération restent figées dans le temps
**Simplicité** : Chaque opération est indépendante
**Impact pour un utilisateur qui travaille sur 3 opérations différentes** :
- Il existera 1 seule fois dans la table centrale `users` (ID 100)
- Il existera 3 fois dans `ope_users` (1 enregistrement par opération)
- Chaque enregistrement `ope_users` garde la référence vers `users.id = 100`
Cette architecture permet de **fermer** une opération complètement sans impacter les autres.
### Sélection des opérations à migrer
Pour chaque entité, **maximum 3 opérations** sont migrées :
1. **1 opération active** (`active = 1`)
2. **2 dernières opérations inactives** (`active = 0`) ayant au moins **10 passages effectués** (`fk_type = 1`)
### Ordre de suppression (si --delete-before=true)
Les données sont supprimées dans cet ordre pour respecter les contraintes de clés étrangères :
1. `medias` - Médias associés à l'entité ou aux opérations
2. `ope_pass_histo` - Historique des passages
3. `ope_pass` - Passages
4. `ope_users_sectors` - Associations utilisateurs/secteurs
5. `ope_users` - Utilisateurs d'opérations
6. `sectors_adresses` - Adresses de secteurs
7. `ope_sectors` - Secteurs d'opérations
8. `operations` - Opérations
9. `users` - Utilisateurs de l'entité
⚠️ **L'entité elle-même** (`entites`) **n'est jamais supprimée**.
### Tables de référence non migrées
Les tables suivantes ne sont **pas** migrées car déjà remplies dans la cible :
- `x_*` - Tables de référence (secteurs, adresses, etc.)
## Notes importantes
1. **Configuration centralisée** : Les paramètres de connexion DB sont récupérés depuis `AppConfig.php` - pas de duplication
2. **Chiffrement** : ApiService est toujours utilisé pour les mots de passe
3. **Logique métier** : Inchangée (migrateEntites, migrateUsers, etc.)
4. **Mappings** : Secteurs et adresses sont toujours mappés automatiquement
5. **Backup** : Un backup de l'ancien script est disponible dans `migrate_from_backup.php.backup`
6. **Suppression par défaut** : Activée pour éviter les doublons et garantir une migration propre
## Statut
**Architecture modulaire v2** :
- ✅ DatabaseConfig.php - Configuration multi-environnement
- ✅ MigrationLogger.php - Gestion des logs
- ✅ DatabaseConnection.php - Connexions PDO
- ✅ OperationMigrator.php - Migration hiérarchique des opérations
- ✅ UserMigrator.php - Migration des utilisateurs par opération
- ✅ SectorMigrator.php - Migration des secteurs par opération
- ✅ PassageMigrator.php - Migration des passages et historiques
- ✅ migrate_from_backup.php - Script principal orchestrateur
- ⏳ Tests sur rca-geo
- ⏳ Tests sur pra-geo
## Support
En cas de problème, consulter les logs détaillés ou contacter l'équipe technique.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
*.log

View File

@@ -0,0 +1,467 @@
#!/bin/bash
###############################################################################
# Script de migration en batch des entités depuis geosector_20251008
#
# Usage: ./migrate_batch.sh [options]
#
# Options:
# --start N Commencer à partir de l'entité N (défaut: 1)
# --limit N Migrer seulement N entités (défaut: toutes)
# --dry-run Simuler sans exécuter
# --continue Continuer après une erreur (défaut: s'arrêter)
# --interactive Mode interactif (défaut si aucune option)
#
# Exemple:
# ./migrate_batch.sh --start 10 --limit 5
# ./migrate_batch.sh --continue
# ./migrate_batch.sh --interactive
###############################################################################
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JSON_FILE="${SCRIPT_DIR}/migrations_entites.json"
LOG_DIR="/var/www/geosector/api/logs/migrations"
MIGRATION_SCRIPT="${SCRIPT_DIR}/php/migrate_from_backup.php"
SOURCE_DB="geosector_20251013_13"
TARGET_DB="pra_geo"
# Paramètres par défaut
START_INDEX=1
LIMIT=0
DRY_RUN=0
CONTINUE_ON_ERROR=0
INTERACTIVE_MODE=0
SPECIFIC_ENTITY_ID=""
SPECIFIC_CP=""
# Couleurs
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Sauvegarder le nombre d'arguments avant le parsing
INITIAL_ARGS=$#
# Parse des arguments
while [[ $# -gt 0 ]]; do
case $1 in
--start)
START_INDEX="$2"
shift 2
;;
--limit)
LIMIT="$2"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
--continue)
CONTINUE_ON_ERROR=1
shift
;;
--interactive|-i)
INTERACTIVE_MODE=1
shift
;;
--help)
grep "^#" "$0" | grep -v "^#!/bin/bash" | sed 's/^# //'
exit 0
;;
*)
echo "Option inconnue: $1"
echo "Utilisez --help pour l'aide"
exit 1
;;
esac
done
# Activer le mode interactif si aucun argument n'a été fourni
if [ $INITIAL_ARGS -eq 0 ]; then
INTERACTIVE_MODE=1
fi
# Vérifications préalables
if [ ! -f "$JSON_FILE" ]; then
echo -e "${RED}❌ Fichier JSON introuvable: $JSON_FILE${NC}"
exit 1
fi
if [ ! -f "$MIGRATION_SCRIPT" ]; then
echo -e "${RED}❌ Script de migration introuvable: $MIGRATION_SCRIPT${NC}"
exit 1
fi
# Créer le répertoire de logs
mkdir -p "$LOG_DIR"
# Fichiers de log
BATCH_LOG="${LOG_DIR}/batch_$(date +%Y%m%d_%H%M%S).log"
SUCCESS_LOG="${LOG_DIR}/success.log"
ERROR_LOG="${LOG_DIR}/errors.log"
# MODE INTERACTIF
if [ $INTERACTIVE_MODE -eq 1 ]; then
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
echo -e "${CYAN} 🔧 Mode interactif - Migration d'entités GeoSector${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
echo ""
# Question 1: Migration globale ou ciblée ?
echo -e "${YELLOW}1⃣ Type de migration :${NC}"
echo -e " ${CYAN}a)${NC} Migration globale (toutes les entités)"
echo -e " ${CYAN}b)${NC} Migration par lot (plage d'entités)"
echo -e " ${CYAN}c)${NC} Migration par code postal"
echo -e " ${CYAN}d)${NC} Migration d'une entité spécifique (ID)"
echo ""
echo -ne "${YELLOW}Votre choix (a/b/c/d) : ${NC}"
read -r MIGRATION_TYPE
echo ""
case $MIGRATION_TYPE in
a|A)
# Migration globale - garder les valeurs par défaut
START_INDEX=1
LIMIT=0
echo -e "${GREEN}${NC} Migration globale sélectionnée"
;;
b|B)
# Migration par lot
echo -e "${YELLOW}2⃣ Configuration du lot :${NC}"
echo -ne " Première entité (index, défaut=1) : "
read -r USER_START
if [ -n "$USER_START" ]; then
START_INDEX=$USER_START
fi
echo -ne " Limite (nombre d'entités, défaut=toutes) : "
read -r USER_LIMIT
if [ -n "$USER_LIMIT" ]; then
LIMIT=$USER_LIMIT
fi
echo ""
echo -e "${GREEN}${NC} Migration par lot : de l'index $START_INDEX, limite de $LIMIT entités"
;;
c|C)
# Migration par code postal
echo -ne "${YELLOW}2⃣ Code postal à migrer : ${NC}"
read -r SPECIFIC_CP
echo ""
if [ -z "$SPECIFIC_CP" ]; then
echo -e "${RED}❌ Code postal requis${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Migration pour le code postal : $SPECIFIC_CP"
;;
d|D)
# Migration d'une entité spécifique - bypass complet du JSON
echo -ne "${YELLOW}2⃣ ID de l'entité à migrer : ${NC}"
read -r SPECIFIC_ENTITY_ID
echo ""
if [ -z "$SPECIFIC_ENTITY_ID" ]; then
echo -e "${RED}❌ ID d'entité requis${NC}"
exit 1
fi
echo -e "${GREEN}${NC} Migration de l'entité ID : $SPECIFIC_ENTITY_ID"
echo ""
# Demander si suppression des données de l'entité avant migration
echo -ne "${YELLOW}3⃣ Supprimer les données existantes de cette entité dans la TARGET avant migration ? (y/N): ${NC}"
read -r DELETE_BEFORE
DELETE_FLAG=""
if [[ $DELETE_BEFORE =~ ^[Yy]$ ]]; then
echo -e "${GREEN}${NC} Les données seront supprimées avant migration"
DELETE_FLAG="--delete-before"
else
echo -e "${BLUE}${NC} Les données seront conservées (ON DUPLICATE KEY UPDATE)"
fi
echo ""
# Confirmer la migration
echo -ne "${YELLOW}⚠️ Confirmer la migration de l'entité #${SPECIFIC_ENTITY_ID} ? (y/N): ${NC}"
read -r CONFIRM
if [[ ! $CONFIRM =~ ^[Yy]$ ]]; then
echo -e "${RED}❌ Migration annulée${NC}"
exit 0
fi
# Exécuter directement la migration sans passer par le JSON
ENTITY_LOG="${LOG_DIR}/entity_${SPECIFIC_ENTITY_ID}_$(date +%Y%m%d_%H%M%S).log"
echo ""
echo -e "${BLUE}⏳ Migration de l'entité #${SPECIFIC_ENTITY_ID} en cours...${NC}"
php "$MIGRATION_SCRIPT" \
--source-db="$SOURCE_DB" \
--target-db="$TARGET_DB" \
--mode=entity \
--entity-id="$SPECIFIC_ENTITY_ID" \
--log="$ENTITY_LOG" \
$DELETE_FLAG
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}✅ Entité #${SPECIFIC_ENTITY_ID} migrée avec succès${NC}"
echo -e "${BLUE}📋 Log détaillé : $ENTITY_LOG${NC}"
else
echo -e "${RED}❌ Erreur lors de la migration de l'entité #${SPECIFIC_ENTITY_ID}${NC}"
echo -e "${RED}📋 Voir le log : $ENTITY_LOG${NC}"
exit 1
fi
exit 0
;;
*)
echo -e "${RED}❌ Choix invalide${NC}"
exit 1
;;
esac
echo ""
fi
# Fonctions utilitaires
log() {
echo -e "$1" | tee -a "$BATCH_LOG"
}
log_success() {
echo "$1" >> "$SUCCESS_LOG"
log "${GREEN}${NC} $1"
}
log_error() {
echo "$1" >> "$ERROR_LOG"
log "${RED}${NC} $1"
}
# Extraire les entity_id du JSON (compatible sans jq)
get_entity_ids() {
if [ -n "$SPECIFIC_ENTITY_ID" ]; then
# Entité spécifique par ID - chercher exactement "entity_id" : ID,
grep "\"entity_id\" : ${SPECIFIC_ENTITY_ID}," "$JSON_FILE" | sed 's/.*: \([0-9]*\).*/\1/'
elif [ -n "$SPECIFIC_CP" ]; then
# Entités par code postal
grep -B 2 "\"code_postal\" : \"$SPECIFIC_CP\"" "$JSON_FILE" | grep '"entity_id"' | sed 's/.*: \([0-9]*\).*/\1/'
else
# Toutes les entités
grep '"entity_id"' "$JSON_FILE" | sed 's/.*: \([0-9]*\).*/\1/'
fi
}
# Compter le nombre total d'entités
TOTAL_ENTITIES=$(get_entity_ids | wc -l)
# Vérifier si des entités ont été trouvées
if [ $TOTAL_ENTITIES -eq 0 ]; then
if [ -n "$SPECIFIC_ENTITY_ID" ]; then
echo -e "${RED}❌ Entité #${SPECIFIC_ENTITY_ID} introuvable dans le fichier JSON${NC}"
elif [ -n "$SPECIFIC_CP" ]; then
echo -e "${RED}❌ Aucune entité trouvée pour le code postal ${SPECIFIC_CP}${NC}"
else
echo -e "${RED}❌ Aucune entité trouvée${NC}"
fi
exit 1
fi
# Calculer le nombre d'entités à migrer
if [ $LIMIT -gt 0 ]; then
END_INDEX=$((START_INDEX + LIMIT - 1))
if [ $END_INDEX -gt $TOTAL_ENTITIES ]; then
END_INDEX=$TOTAL_ENTITIES
fi
else
END_INDEX=$TOTAL_ENTITIES
fi
# Bannière de démarrage
echo ""
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
log "${BLUE} Migration en batch des entités GeoSector${NC}"
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
log "📅 Date: $(date '+%Y-%m-%d %H:%M:%S')"
log "📁 Source: $SOURCE_DB"
log "📁 Cible: $TARGET_DB"
# Afficher les informations selon le mode
if [ -n "$SPECIFIC_ENTITY_ID" ]; then
log "🎯 Mode: Migration d'une entité spécifique"
log "📊 Entité ID: $SPECIFIC_ENTITY_ID"
elif [ -n "$SPECIFIC_CP" ]; then
log "🎯 Mode: Migration par code postal"
log "📮 Code postal: $SPECIFIC_CP"
log "📊 Entités trouvées: $TOTAL_ENTITIES"
else
TOTAL_AVAILABLE=$(grep '"entity_id"' "$JSON_FILE" | wc -l)
log "📊 Total entités disponibles: $TOTAL_AVAILABLE"
log "🎯 Entités à migrer: $START_INDEX à $END_INDEX"
fi
if [ $DRY_RUN -eq 1 ]; then
log "${YELLOW}🔍 Mode DRY-RUN (simulation)${NC}"
fi
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
echo ""
# Confirmation utilisateur
if [ $DRY_RUN -eq 0 ]; then
echo -ne "${YELLOW}⚠️ Confirmer la migration ? (y/N): ${NC}"
read -r CONFIRM
if [[ ! $CONFIRM =~ ^[Yy]$ ]]; then
log "❌ Migration annulée par l'utilisateur"
exit 0
fi
echo ""
fi
# Compteurs
SUCCESS_COUNT=0
ERROR_COUNT=0
SKIPPED_COUNT=0
CURRENT_INDEX=0
# Début de la migration
START_TIME=$(date +%s)
# Lire les entity_id et migrer
get_entity_ids | while read -r ENTITY_ID; do
CURRENT_INDEX=$((CURRENT_INDEX + 1))
# Filtrer par index
if [ $CURRENT_INDEX -lt $START_INDEX ]; then
continue
fi
if [ $CURRENT_INDEX -gt $END_INDEX ]; then
break
fi
# Récupérer les détails de l'entité depuis le JSON (match exact avec la virgule)
ENTITY_INFO=$(grep -A 8 "\"entity_id\" : ${ENTITY_ID}," "$JSON_FILE")
ENTITY_NAME=$(echo "$ENTITY_INFO" | grep '"nom"' | sed 's/.*: "\(.*\)".*/\1/')
ENTITY_CP=$(echo "$ENTITY_INFO" | grep '"code_postal"' | sed 's/.*: "\(.*\)".*/\1/')
NB_USERS=$(echo "$ENTITY_INFO" | grep '"nb_users"' | sed 's/.*: \([0-9]*\).*/\1/')
NB_PASSAGES=$(echo "$ENTITY_INFO" | grep '"nb_passages"' | sed 's/.*: \([0-9]*\).*/\1/')
# Afficher la progression
PROGRESS=$((CURRENT_INDEX - START_INDEX + 1))
TOTAL=$((END_INDEX - START_INDEX + 1))
PERCENT=$((PROGRESS * 100 / TOTAL))
log ""
log "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log "${BLUE}[$PROGRESS/$TOTAL - ${PERCENT}%]${NC} Entité #${ENTITY_ID}: ${ENTITY_NAME} (${ENTITY_CP})"
log " 👥 Users: ${NB_USERS} | 📍 Passages: ${NB_PASSAGES}"
# Mode dry-run
if [ $DRY_RUN -eq 1 ]; then
log "${YELLOW} 🔍 [DRY-RUN] Simulation de la migration${NC}"
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
# Exécuter la migration
ENTITY_LOG="${LOG_DIR}/entity_${ENTITY_ID}_$(date +%Y%m%d_%H%M%S).log"
log " ⏳ Migration en cours..."
php "$MIGRATION_SCRIPT" \
--source-db="$SOURCE_DB" \
--target-db="$TARGET_DB" \
--mode=entity \
--entity-id="$ENTITY_ID" \
--log="$ENTITY_LOG" > /tmp/migration_output_$$.txt 2>&1
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
# Succès
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
log_success "Entité #${ENTITY_ID} (${ENTITY_NAME}) migrée avec succès"
# Afficher un résumé du log avec détails
if [ -f "$ENTITY_LOG" ]; then
# Chercher la ligne avec les marqueurs #STATS#
STATS_LINE=$(grep "#STATS#" "$ENTITY_LOG" 2>/dev/null)
if [ -n "$STATS_LINE" ]; then
# Extraire chaque compteur
OPE=$(echo "$STATS_LINE" | grep -oE 'OPE:[0-9]+' | cut -d: -f2)
USERS=$(echo "$STATS_LINE" | grep -oE 'USER:[0-9]+' | cut -d: -f2)
SECTORS=$(echo "$STATS_LINE" | grep -oE 'SECTOR:[0-9]+' | cut -d: -f2)
PASSAGES=$(echo "$STATS_LINE" | grep -oE 'PASS:[0-9]+' | cut -d: -f2)
# Valeurs par défaut si extraction échoue
OPE=${OPE:-0}
USERS=${USERS:-0}
SECTORS=${SECTORS:-0}
PASSAGES=${PASSAGES:-0}
log " 📊 ope: ${OPE} | users: ${USERS} | sectors: ${SECTORS} | passages: ${PASSAGES}"
else
log " 📊 Statistiques non disponibles"
fi
fi
else
# Erreur
ERROR_COUNT=$((ERROR_COUNT + 1))
log_error "Entité #${ENTITY_ID} (${ENTITY_NAME}) - Erreur code $EXIT_CODE"
# Afficher les dernières lignes du log d'erreur
if [ -f "/tmp/migration_output_$$.txt" ]; then
log "${RED} 📋 Dernières erreurs:${NC}"
tail -5 "/tmp/migration_output_$$.txt" | sed 's/^/ /' | tee -a "$BATCH_LOG"
fi
# Arrêter ou continuer ?
if [ $CONTINUE_ON_ERROR -eq 0 ]; then
log ""
log "${RED}❌ Migration interrompue suite à une erreur${NC}"
log " Utilisez --continue pour continuer malgré les erreurs"
exit 1
fi
fi
# Nettoyage
rm -f "/tmp/migration_output_$$.txt"
# Pause entre les migrations (pour éviter de surcharger)
sleep 1
done
# Fin de la migration
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
HOURS=$((DURATION / 3600))
MINUTES=$(((DURATION % 3600) / 60))
SECONDS=$((DURATION % 60))
# Résumé final
log ""
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
log "${BLUE} Résumé de la migration${NC}"
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
log "✅ Succès: ${GREEN}${SUCCESS_COUNT}${NC}"
log "❌ Erreurs: ${RED}${ERROR_COUNT}${NC}"
log "⏭️ Ignorées: ${YELLOW}${SKIPPED_COUNT}${NC}"
log "⏱️ Durée: ${HOURS}h ${MINUTES}m ${SECONDS}s"
log ""
log "📋 Logs détaillés:"
log " - Batch: $BATCH_LOG"
log " - Succès: $SUCCESS_LOG"
log " - Erreurs: $ERROR_LOG"
log " - Individuels: $LOG_DIR/entity_*.log"
log "${BLUE}═══════════════════════════════════════════════════════════${NC}"
# Code de sortie
if [ $ERROR_COUNT -gt 0 ]; then
exit 1
else
exit 0
fi

View File

@@ -0,0 +1,176 @@
<?php
/**
* Classe abstraite de base pour tous les migrators
*
* Fournit les méthodes communes pour migrer des données d'une table
*/
abstract class DataMigrator
{
protected $connection;
protected $logger;
protected $sourceDb;
protected $targetDb;
/**
* Constructeur
*
* @param DatabaseConnection $connection Connexion aux bases
* @param MigrationLogger $logger Logger
*/
public function __construct(DatabaseConnection $connection, MigrationLogger $logger)
{
$this->connection = $connection;
$this->logger = $logger;
$this->sourceDb = $connection->getSourceDb();
$this->targetDb = $connection->getTargetDb();
}
/**
* Méthode principale de migration (à implémenter dans chaque migrator)
*
* @param int|null $entityId ID de l'entité à migrer (null = toutes)
* @param bool $deleteBefore Supprimer les données existantes avant migration
* @return array ['success' => int, 'errors' => int]
*/
abstract public function migrate(?int $entityId = null, bool $deleteBefore = false): array;
/**
* Retourne le nom de la table gérée par ce migrator
*/
abstract public function getTableName(): string;
/**
* Supprime les données d'une entité dans la cible
* À surcharger si la logique de suppression est spécifique
*
* @param int $entityId ID de l'entité
* @return int Nombre de lignes supprimées
*/
protected function deleteEntityData(int $entityId): int
{
$table = $this->getTableName();
try {
// Par défaut: suppression simple avec fk_entite
$stmt = $this->targetDb->prepare("DELETE FROM $table WHERE fk_entite = ?");
$stmt->execute([$entityId]);
$deleted = $stmt->rowCount();
if ($deleted > 0) {
$this->logger->debug(" Supprimé $deleted ligne(s) de $table pour entité #$entityId");
}
return $deleted;
} catch (PDOException $e) {
$this->logger->warning(" Erreur suppression $table: " . $e->getMessage());
return 0;
}
}
/**
* Compte les lignes dans la source
*
* @param int|null $entityId ID de l'entité (null = toutes)
* @return int Nombre de lignes
*/
protected function countSourceRows(?int $entityId = null): int
{
return $this->connection->countSourceRows($this->getTableName(), $entityId);
}
/**
* Compte les lignes dans la cible
*
* @param int|null $entityId ID de l'entité (null = toutes)
* @return int Nombre de lignes
*/
protected function countTargetRows(?int $entityId = null): int
{
return $this->connection->countTargetRows($this->getTableName(), $entityId);
}
/**
* Log le début de la migration d'une table
*/
protected function logStart(?int $entityId = null): void
{
$table = $this->getTableName();
$entityStr = $entityId ? " pour entité #$entityId" : " (toutes les entités)";
$this->logger->info("🔄 Migration de $table{$entityStr}...");
}
/**
* Log la fin de la migration avec statistiques
*
* @param int $success Nombre de succès
* @param int $errors Nombre d'erreurs
* @param int|null $entityId ID de l'entité
*/
protected function logEnd(int $success, int $errors, ?int $entityId = null): void
{
$table = $this->getTableName();
$sourceCount = $this->countSourceRows($entityId);
$targetCount = $this->countTargetRows($entityId);
$diff = $targetCount - $sourceCount;
$diffStr = $diff >= 0 ? "+$diff" : "$diff";
if ($errors > 0) {
$this->logger->warning(" ⚠️ $table: $success succès, $errors erreurs");
} else {
$this->logger->success("$table: $success enregistrement(s) migré(s)");
}
$this->logger->info(" 📊 SOURCE: $sourceCount → CIBLE: $targetCount (différence: $diffStr)");
}
/**
* Exécute une requête INSERT avec ON DUPLICATE KEY UPDATE
*
* @param string $insertSql SQL d'insertion
* @param array $data Données à insérer
* @return bool True si succès
*/
protected function insertOrUpdate(string $insertSql, array $data): bool
{
try {
$stmt = $this->targetDb->prepare($insertSql);
$stmt->execute($data);
return true;
} catch (PDOException $e) {
$this->logger->debug(" Erreur INSERT: " . $e->getMessage());
return false;
}
}
/**
* Démarre une transaction sur la cible
*/
protected function beginTransaction(): void
{
if (!$this->targetDb->inTransaction()) {
$this->targetDb->beginTransaction();
}
}
/**
* Commit la transaction
*/
protected function commit(): void
{
if ($this->targetDb->inTransaction()) {
$this->targetDb->commit();
}
}
/**
* Rollback la transaction
*/
protected function rollback(): void
{
if ($this->targetDb->inTransaction()) {
$this->targetDb->rollBack();
}
}
}

View File

@@ -0,0 +1,192 @@
<?php
/**
* Configuration des environnements de migration
*
* Utilise AppConfig pour récupérer la configuration DB
* Source: geosector (synchronisée par PM7)
* Cibles: dva_geo (IN3/maria3), rca_geo (IN3/maria3) ou pra_geo (IN4/maria4)
*/
class DatabaseConfig
{
private const ENV_MAPPING = [
'dva' => [
'name' => 'DÉVELOPPEMENT',
'hostname' => 'dapp.geosector.fr',
'source_db' => 'geosector',
'target_db' => 'dva_geo'
],
'rca' => [
'name' => 'RECETTE',
'hostname' => 'rapp.geosector.fr',
'source_db' => 'geosector',
'target_db' => 'rca_geo'
],
'pra' => [
'name' => 'PRODUCTION',
'hostname' => 'app3.geosector.fr',
'source_db' => 'geosector',
'target_db' => 'pra_geo'
]
];
private $env;
private $config;
private $appConfig;
/**
* Constructeur
*
* @param string $env Environnement: 'dva', 'rca' ou 'pra'
* @throws Exception Si l'environnement est invalide
*/
public function __construct(string $env)
{
if (!isset(self::ENV_MAPPING[$env])) {
throw new Exception("Invalid environment: $env. Use 'dva', 'rca' or 'pra'");
}
$this->env = $env;
// Charger AppConfig (remonter de 4 niveaux: lib -> php -> migration2 -> scripts -> api)
$appConfigPath = dirname(__DIR__, 4) . '/src/Config/AppConfig.php';
if (!file_exists($appConfigPath)) {
throw new Exception("AppConfig not found at: $appConfigPath");
}
require_once $appConfigPath;
// Simuler le host pour AppConfig en CLI
$hostname = self::ENV_MAPPING[$env]['hostname'];
$_SERVER['SERVER_NAME'] = $hostname;
$_SERVER['HTTP_HOST'] = $hostname;
$this->appConfig = AppConfig::getInstance();
// Récupérer la config DB depuis AppConfig
$dbConfig = $this->appConfig->getDatabaseConfig();
if (!$dbConfig || !isset($dbConfig['host'])) {
throw new Exception("Database configuration not found for hostname: $hostname");
}
// Construire la config pour la migration
$this->config = [
'name' => self::ENV_MAPPING[$env]['name'],
'host' => $dbConfig['host'],
'port' => $dbConfig['port'] ?? 3306,
'user' => $dbConfig['username'],
'pass' => $dbConfig['password'],
'source_db' => self::ENV_MAPPING[$env]['source_db'],
'target_db' => self::ENV_MAPPING[$env]['target_db']
];
}
/**
* Retourne l'environnement actuel
*/
public function getEnv(): string
{
return $this->env;
}
/**
* Retourne le nom complet de l'environnement
*/
public function getEnvName(): string
{
return $this->config['name'];
}
/**
* Retourne l'hôte de la base de données
*/
public function getHost(): string
{
return $this->config['host'];
}
/**
* Retourne le port de la base de données
*/
public function getPort(): int
{
return $this->config['port'];
}
/**
* Retourne l'utilisateur de la base de données
*/
public function getUser(): string
{
return $this->config['user'];
}
/**
* Retourne le mot de passe de la base de données
*/
public function getPassword(): string
{
return $this->config['pass'];
}
/**
* Retourne le nom de la base source
*/
public function getSourceDb(): string
{
return $this->config['source_db'];
}
/**
* Retourne le nom de la base cible
*/
public function getTargetDb(): string
{
return $this->config['target_db'];
}
/**
* Retourne toute la configuration
*/
public function getConfig(): array
{
return $this->config;
}
/**
* Détecte automatiquement l'environnement depuis le hostname
*
* @return string 'dva', 'rca' ou 'pra' (défaut: 'dva')
*/
public static function autoDetect(): string
{
$hostname = gethostname();
switch ($hostname) {
case 'dva-geo':
return 'dva';
case 'rca-geo':
return 'rca';
case 'pra-geo':
return 'pra';
default:
return 'dva'; // Défaut
}
}
/**
* Vérifie si un environnement existe
*/
public static function exists(string $env): bool
{
return isset(self::ENV_MAPPING[$env]);
}
/**
* Retourne la liste des environnements disponibles
*/
public static function getAvailableEnvironments(): array
{
return array_keys(self::ENV_MAPPING);
}
}

View File

@@ -0,0 +1,201 @@
<?php
/**
* Gestion des connexions PDO
*
* Crée et maintient les connexions aux bases source et cible
*/
class DatabaseConnection
{
private $config;
private $logger;
private $sourceDb;
private $targetDb;
/**
* Constructeur
*
* @param DatabaseConfig $config Configuration de l'environnement
* @param MigrationLogger $logger Logger pour les messages
*/
public function __construct(DatabaseConfig $config, MigrationLogger $logger)
{
$this->config = $config;
$this->logger = $logger;
}
/**
* Établit les connexions aux bases source et cible
*
* @return bool True si succès
*/
public function connect(): bool
{
try {
// Connexion à la base source
$this->connectSource();
// Connexion à la base cible
$this->connectTarget();
// Vérifier les versions MariaDB
$this->checkVersions();
return true;
} catch (PDOException $e) {
$this->logger->error("Erreur de connexion: " . $e->getMessage());
return false;
}
}
/**
* Connexion à la base source
*/
private function connectSource(): void
{
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$this->config->getHost(),
$this->config->getPort(),
$this->config->getSourceDb()
);
$this->sourceDb = new PDO($dsn, $this->config->getUser(), $this->config->getPassword(), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => 600,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4"
]);
$this->logger->success("✓ Connexion SOURCE: {$this->config->getSourceDb()} sur {$this->config->getHost()}");
}
/**
* Connexion à la base cible
*/
private function connectTarget(): void
{
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$this->config->getHost(),
$this->config->getPort(),
$this->config->getTargetDb()
);
$this->targetDb = new PDO($dsn, $this->config->getUser(), $this->config->getPassword(), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => 600,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4"
]);
$this->logger->success("✓ Connexion CIBLE: {$this->config->getTargetDb()} sur {$this->config->getHost()}");
}
/**
* Vérifie et affiche les versions MariaDB
*/
private function checkVersions(): void
{
$sourceVersion = $this->sourceDb->query("SELECT VERSION()")->fetchColumn();
$targetVersion = $this->targetDb->query("SELECT VERSION()")->fetchColumn();
$this->logger->info(" Version SOURCE: $sourceVersion");
$this->logger->info(" Version CIBLE: $targetVersion");
}
/**
* Retourne la connexion à la base source
*/
public function getSourceDb(): PDO
{
if (!$this->sourceDb) {
throw new Exception("Source database not connected. Call connect() first.");
}
return $this->sourceDb;
}
/**
* Retourne la connexion à la base cible
*/
public function getTargetDb(): PDO
{
if (!$this->targetDb) {
throw new Exception("Target database not connected. Call connect() first.");
}
return $this->targetDb;
}
/**
* Compte le nombre de lignes dans une table de la source
*
* @param string $table Nom de la table
* @param int|null $entityId Filtrer par fk_entite (optionnel)
* @return int Nombre de lignes
*/
public function countSourceRows(string $table, ?int $entityId = null): int
{
$sql = "SELECT COUNT(*) FROM $table";
if ($entityId !== null) {
// Tables avec fk_entite direct
if (in_array($table, ['users', 'operations', 'entites'])) {
$sql .= " WHERE fk_entite = :entity_id";
}
// Tables liées via operations
elseif (in_array($table, ['ope_sectors', 'ope_users', 'ope_pass'])) {
$sql .= " WHERE fk_operation IN (SELECT id FROM operations WHERE fk_entite = :entity_id)";
}
}
$stmt = $this->sourceDb->prepare($sql);
if ($entityId !== null) {
$stmt->execute(['entity_id' => $entityId]);
} else {
$stmt->execute();
}
return (int) $stmt->fetchColumn();
}
/**
* Compte le nombre de lignes dans une table de la cible
*
* @param string $table Nom de la table
* @param int|null $entityId Filtrer par fk_entite (optionnel)
* @return int Nombre de lignes
*/
public function countTargetRows(string $table, ?int $entityId = null): int
{
$sql = "SELECT COUNT(*) FROM $table";
if ($entityId !== null) {
if (in_array($table, ['users', 'operations', 'entites'])) {
$sql .= " WHERE fk_entite = :entity_id";
}
elseif (in_array($table, ['ope_sectors', 'ope_users', 'ope_pass'])) {
$sql .= " WHERE fk_operation IN (SELECT id FROM operations WHERE fk_entite = :entity_id)";
}
}
$stmt = $this->targetDb->prepare($sql);
if ($entityId !== null) {
$stmt->execute(['entity_id' => $entityId]);
} else {
$stmt->execute();
}
return (int) $stmt->fetchColumn();
}
/**
* Ferme les connexions
*/
public function close(): void
{
$this->sourceDb = null;
$this->targetDb = null;
$this->logger->info("Connexions fermées");
}
}

View File

@@ -0,0 +1,219 @@
<?php
/**
* Gestion des logs de migration
*
* Écrit dans un fichier et affiche à l'écran avec timestamps
*/
class MigrationLogger
{
private $logFile;
private $verbose;
/**
* Constructeur
*
* @param string|null $logFile Chemin du fichier de log (null = auto-généré)
* @param bool $verbose Afficher les logs à l'écran
*/
public function __construct(?string $logFile = null, bool $verbose = true)
{
// Définir le répertoire de logs par défaut (migration2/logs/)
$defaultLogDir = dirname(__DIR__, 2) . '/logs';
$this->logFile = $logFile ?? $defaultLogDir . '/migration_' . date('Ymd_His') . '.log';
$this->verbose = $verbose;
// Créer le dossier parent si nécessaire
$dir = dirname($this->logFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// Vérifier que le fichier est accessible en écriture
if (!is_writable(dirname($this->logFile))) {
throw new Exception("Log directory is not writable: " . dirname($this->logFile));
}
}
/**
* Log un message avec niveau INFO
*/
public function info(string $message): void
{
$this->log($message, 'INFO');
}
/**
* Log un message avec niveau SUCCESS
*/
public function success(string $message): void
{
$this->log($message, 'SUCCESS');
}
/**
* Log un message avec niveau WARNING
*/
public function warning(string $message): void
{
$this->log($message, 'WARNING');
}
/**
* Log un message avec niveau ERROR
*/
public function error(string $message): void
{
$this->log($message, 'ERROR');
}
/**
* Log un message avec niveau DEBUG
*/
public function debug(string $message): void
{
$this->log($message, 'DEBUG');
}
/**
* Log une ligne de séparation
*/
public function separator(): void
{
$this->log(str_repeat('=', 80), 'INFO');
}
/**
* Log générique
*
* @param string $message Message à logger
* @param string $level Niveau: INFO, SUCCESS, WARNING, ERROR, DEBUG
*/
private function log(string $message, string $level = 'INFO'): void
{
$timestamp = date('Y-m-d H:i:s');
$logLine = "[{$timestamp}] [{$level}] {$message}\n";
// Écriture dans le fichier
file_put_contents($this->logFile, $logLine, FILE_APPEND);
// Affichage à l'écran si verbose
if ($this->verbose) {
$this->printColored($message, $level);
}
}
/**
* Affiche un message coloré selon le niveau
*/
private function printColored(string $message, string $level): void
{
$colors = [
'INFO' => "\033[0;37m", // Blanc
'SUCCESS' => "\033[0;32m", // Vert
'WARNING' => "\033[0;33m", // Jaune
'ERROR' => "\033[0;31m", // Rouge
'DEBUG' => "\033[0;36m" // Cyan
];
$reset = "\033[0m";
$color = $colors[$level] ?? $colors['INFO'];
echo $color . $message . $reset . "\n";
}
/**
* Retourne le chemin du fichier de log
*/
public function getLogFile(): string
{
return $this->logFile;
}
/**
* Log des statistiques de migration
*
* @param array $stats Tableau associatif [table => count]
*/
public function logStats(array $stats): void
{
$this->separator();
$this->info("📊 Statistiques de migration:");
foreach ($stats as $table => $count) {
$this->info(" - {$table}: {$count} enregistrement(s)");
}
$this->separator();
}
/**
* Log une ligne spéciale pour parsing automatique
* Format: #STATS# KEY1:VAL1 KEY2:VAL2 ...
*/
public function logParsableStats(array $stats): void
{
$pairs = [];
foreach ($stats as $key => $value) {
$pairs[] = strtoupper($key) . ':' . $value;
}
$line = '#STATS# ' . implode(' ', $pairs);
$this->log($line, 'INFO');
}
/**
* Affiche et log un récapitulatif complet de migration
*
* @param array $summary Tableau de statistiques hiérarchique
*/
public function logMigrationSummary(array $summary): void
{
$this->separator();
$this->separator();
$this->info("📊 RÉCAPITULATIF DE LA MIGRATION");
$this->separator();
// Entité
if (isset($summary['entity'])) {
$this->info("Entité: {$summary['entity']['name']} (ID: {$summary['entity']['id']})");
}
$this->info("Date: " . date('Y-m-d H:i:s'));
$this->info("");
// Nombre total d'opérations
$totalOperations = count($summary['operations'] ?? []);
$this->success("Opérations migrées: {$totalOperations}");
$this->info("");
// Détail par opération
$operationNum = 1;
foreach ($summary['operations'] ?? [] as $operation) {
$this->info("Opération #{$operationNum}: \"{$operation['name']}\" (ID: {$operation['id']})");
$this->info(" ├─ Utilisateurs: {$operation['users']}");
$this->info(" ├─ Secteurs: {$operation['sectors']}");
$this->info(" ├─ Passages totaux: {$operation['total_passages']}");
if (!empty($operation['sectors_detail'])) {
$this->info(" └─ Détail par secteur:");
$sectorCount = count($operation['sectors_detail']);
$sectorNum = 0;
foreach ($operation['sectors_detail'] as $sector) {
$sectorNum++;
$isLast = ($sectorNum === $sectorCount);
$prefix = $isLast ? " └─" : " ├─";
$this->info("{$prefix} {$sector['name']} (ID: {$sector['id']})");
$this->info(" " . ($isLast ? " " : "") . " ├─ Utilisateurs affectés: {$sector['users']}");
$this->info(" " . ($isLast ? " " : "") . " └─ Passages: {$sector['passages']}");
}
}
$this->info("");
$operationNum++;
}
$this->separator();
}
}

View File

@@ -0,0 +1,312 @@
<?php
/**
* Migration des opérations complètes
*
* Orchestre la migration d'une opération avec tous ses utilisateurs,
* secteurs, passages et médias. Utilise UserMigrator et SectorMigrator.
*/
class OperationMigrator
{
private PDO $sourceDb;
private PDO $targetDb;
private MigrationLogger $logger;
private UserMigrator $userMigrator;
private SectorMigrator $sectorMigrator;
/**
* Constructeur
*
* @param PDO $sourceDb Connexion source
* @param PDO $targetDb Connexion cible
* @param MigrationLogger $logger Logger
* @param UserMigrator $userMigrator Migrator d'utilisateurs
* @param SectorMigrator $sectorMigrator Migrator de secteurs
*/
public function __construct(
PDO $sourceDb,
PDO $targetDb,
MigrationLogger $logger,
UserMigrator $userMigrator,
SectorMigrator $sectorMigrator
) {
$this->sourceDb = $sourceDb;
$this->targetDb = $targetDb;
$this->logger = $logger;
$this->userMigrator = $userMigrator;
$this->sectorMigrator = $sectorMigrator;
}
/**
* Récupère les opérations à migrer pour une entité
* - 1 opération active
* - 2 dernières opérations inactives avec au moins 10 passages effectués
*
* @param int $entityId ID de l'entité
* @return array Liste des IDs d'opérations à migrer
*/
public function getOperationsToMigrate(int $entityId): array
{
$operationIds = [];
// 1. Récupérer l'opération active (pour vérification)
$stmt = $this->sourceDb->prepare("
SELECT rowid
FROM operations
WHERE fk_entite = :entity_id AND active = 1
LIMIT 1
");
$stmt->execute([':entity_id' => $entityId]);
$activeOp = $stmt->fetch(PDO::FETCH_COLUMN);
// 2. Récupérer les 2 dernières opérations inactives avec >= 10 passages effectués
// ORDER BY DESC pour avoir les plus récentes, puis on inverse
$stmt = $this->sourceDb->prepare("
SELECT o.rowid, COUNT(p.rowid) as nb_passages
FROM operations o
LEFT JOIN ope_pass p ON p.fk_operation = o.rowid AND p.fk_type = 1
WHERE o.fk_entite = :entity_id
AND o.active = 0
" . ($activeOp ? "AND o.rowid != :active_id" : "") . "
GROUP BY o.rowid
HAVING nb_passages >= 10
ORDER BY o.rowid DESC
LIMIT 2
");
$params = [':entity_id' => $entityId];
if ($activeOp) {
$params[':active_id'] = $activeOp;
}
$stmt->execute($params);
$inactiveOps = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Inverser pour avoir l'ordre chronologique (plus ancienne → plus récente)
$inactiveOps = array_reverse($inactiveOps);
foreach ($inactiveOps as $op) {
$operationIds[] = $op['rowid'];
$this->logger->info("✓ Opération inactive trouvée: {$op['rowid']} ({$op['nb_passages']} passages)");
}
// 3. Ajouter l'opération active EN DERNIER
if ($activeOp) {
$operationIds[] = $activeOp;
$this->logger->info("✓ Opération active trouvée: {$activeOp}");
}
$this->logger->info("📊 Total: " . count($operationIds) . " opération(s) à migrer");
return $operationIds;
}
/**
* Migre une opération complète avec tous ses utilisateurs et secteurs
*
* @param int $oldOperationId ID de l'opération dans l'ancienne base
* @return array|null Tableau de statistiques ou null en cas d'erreur
*/
public function migrateOperation(int $oldOperationId): ?array
{
$this->logger->separator();
$this->logger->info("🔄 Migration de l'opération ID: {$oldOperationId}");
try {
// 1. Récupérer l'opération source
$stmt = $this->sourceDb->prepare("
SELECT * FROM operations
WHERE rowid = :id
");
$stmt->execute([':id' => $oldOperationId]);
$operation = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$operation) {
$this->logger->warning("Opération {$oldOperationId} non trouvée");
return null;
}
// 2. Créer l'opération dans la nouvelle base
$newOperationId = $this->createOperation($operation);
if (!$newOperationId) {
return null;
}
$this->logger->success("✓ Opération créée avec ID: {$newOperationId}");
// 3. Migrer les utilisateurs de l'opération
// Pour opération active : tous les users actifs de l'entité
// Pour opération inactive : uniquement ceux dans ope_users_sectors
$entityId = (int)$operation['fk_entite'];
$isActiveOperation = (int)$operation['active'] === 1;
$userResult = $this->userMigrator->migrateOperationUsers(
$oldOperationId,
$newOperationId,
$entityId,
$isActiveOperation
);
$userMapping = $userResult['mapping'];
$usersCount = $userResult['count'];
if (empty($userMapping)) {
$this->logger->warning("Aucun utilisateur migré, abandon de l'opération {$oldOperationId}");
return null;
}
// 4. Récupérer les secteurs DISTINCTS de l'opération
$stmt = $this->sourceDb->prepare("
SELECT DISTINCT fk_sector
FROM ope_users_sectors
WHERE fk_operation = :operation_id AND active = 1
");
$stmt->execute([':operation_id' => $oldOperationId]);
$sectors = $stmt->fetchAll(PDO::FETCH_COLUMN);
$this->logger->info("📍 " . count($sectors) . " secteur(s) distinct(s) à migrer");
// 5. Migrer chaque secteur et collecter les stats
$sectorsDetail = [];
$totalPassages = 0;
foreach ($sectors as $oldSectorId) {
$sectorStats = $this->sectorMigrator->migrateSector(
$oldOperationId,
$newOperationId,
$oldSectorId,
$userMapping
);
if ($sectorStats) {
$sectorsDetail[] = $sectorStats;
$totalPassages += $sectorStats['passages'];
}
}
// 6. Migrer les médias de l'opération (support='operations')
$this->migrateOperationMedias($oldOperationId, $newOperationId);
$this->logger->success("✅ Migration de l'opération {$oldOperationId} terminée");
// 7. Retourner les statistiques
return [
'id' => $newOperationId,
'name' => $operation['libelle'],
'users' => $usersCount,
'sectors' => count($sectorsDetail),
'total_passages' => $totalPassages,
'sectors_detail' => $sectorsDetail
];
} catch (Exception $e) {
$this->logger->error("❌ Erreur migration opération {$oldOperationId}: " . $e->getMessage());
return null;
}
}
/**
* Crée une opération dans la nouvelle base
*
* @param array $operation Données de l'opération
* @return int|null ID de la nouvelle opération ou null en cas d'erreur
*/
private function createOperation(array $operation): ?int
{
try {
$stmt = $this->targetDb->prepare("
INSERT INTO operations (
fk_entite, libelle, date_deb, date_fin,
chk_distinct_sectors,
created_at, fk_user_creat, updated_at, fk_user_modif, chk_active
) VALUES (
:fk_entite, :libelle, :date_deb, :date_fin,
:chk_distinct_sectors,
:created_at, :fk_user_creat, :updated_at, :fk_user_modif, :chk_active
)
");
$stmt->execute([
':fk_entite' => $operation['fk_entite'],
':libelle' => $operation['libelle'],
':date_deb' => $operation['date_deb'],
':date_fin' => $operation['date_fin'],
':chk_distinct_sectors' => $operation['chk_distinct_sectors'],
':created_at' => $operation['date_creat'],
':fk_user_creat' => $operation['fk_user_creat'],
':updated_at' => $operation['date_modif'],
':fk_user_modif' => $operation['fk_user_modif'] ?? 0,
':chk_active' => $operation['active']
]);
return (int)$this->targetDb->lastInsertId();
} catch (Exception $e) {
$this->logger->error("❌ Erreur création opération: " . $e->getMessage());
return null;
}
}
/**
* Migre les médias d'une opération
*
* @param int $oldOperationId ID ancienne opération
* @param int $newOperationId ID nouvelle opération
* @return int Nombre de médias migrés
*/
private function migrateOperationMedias(int $oldOperationId, int $newOperationId): int
{
$stmt = $this->sourceDb->prepare("
SELECT * FROM medias
WHERE support = 'operations' AND support_rowid = :operation_id
");
$stmt->execute([':operation_id' => $oldOperationId]);
$medias = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($medias)) {
return 0;
}
$count = 0;
foreach ($medias as $media) {
$stmt = $this->targetDb->prepare("
INSERT INTO medias (
dir0, dir1, dir2, support, support_rowid,
fichier, type_fichier, description, position,
hauteur, largeur, niveaugris,
created_at, fk_user_creat, updated_at, fk_user_modif
) VALUES (
:dir0, :dir1, :dir2, :support, :support_rowid,
:fichier, :type_fichier, :description, :position,
:hauteur, :largeur, :niveaugris,
:created_at, :fk_user_creat, :updated_at, :fk_user_modif
)
");
$stmt->execute([
':dir0' => $media['dir0'],
':dir1' => $media['dir1'],
':dir2' => $media['dir2'],
':support' => $media['support'],
':support_rowid' => $newOperationId,
':fichier' => $media['fichier'],
':type_fichier' => $media['type_fichier'],
':description' => $media['description'],
':position' => $media['position'],
':hauteur' => $media['hauteur'],
':largeur' => $media['largeur'],
':niveaugris' => $media['niveaugris'],
':created_at' => $media['date_creat'],
':fk_user_creat' => $media['fk_user_creat'],
':updated_at' => $media['date_modif'],
':fk_user_modif' => $media['fk_user_modif']
]);
$count++;
}
$this->logger->success("{$count} média(s) migré(s)");
return $count;
}
}

View File

@@ -0,0 +1,256 @@
<?php
/**
* Migration des passages (ope_pass) et historiques (ope_pass_histo)
*
* Gère la migration des passages avec vérification du trio
* (operation, user, sector) et migration des historiques associés
*/
class PassageMigrator
{
private PDO $sourceDb;
private PDO $targetDb;
private MigrationLogger $logger;
/**
* Constructeur
*
* @param PDO $sourceDb Connexion source
* @param PDO $targetDb Connexion cible
* @param MigrationLogger $logger Logger
*/
public function __construct(PDO $sourceDb, PDO $targetDb, MigrationLogger $logger)
{
$this->sourceDb = $sourceDb;
$this->targetDb = $targetDb;
$this->logger = $logger;
}
/**
* Migre les passages d'un secteur dans une opération
*
* @param int $oldOperationId ID ancienne opération
* @param int $newOperationId ID nouvelle opération
* @param int $oldSectorId ID ancien secteur
* @param int $newOpeSectorId ID nouveau ope_sectors
* @param array $userMapping Mapping oldUserId => newOpeUserId
* @return int Nombre de passages migrés
*/
public function migratePassages(
int $oldOperationId,
int $newOperationId,
int $oldSectorId,
int $newOpeSectorId,
array $userMapping
): int {
$stmt = $this->sourceDb->prepare("
SELECT * FROM ope_pass
WHERE fk_operation = :operation_id
AND fk_sector = :sector_id
");
$stmt->execute([
':operation_id' => $oldOperationId,
':sector_id' => $oldSectorId
]);
$passages = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($passages)) {
return 0;
}
$count = 0;
foreach ($passages as $passage) {
// Vérifier que l'utilisateur a été migré
if (!isset($userMapping[$passage['fk_user']])) {
$this->logger->warning(" ⚠ Passage {$passage['rowid']}: User {$passage['fk_user']} non trouvé dans mapping");
continue;
}
// Récupérer l'ID de ope_users depuis le mapping
$newOpeUserId = $userMapping[$passage['fk_user']];
// Vérifier que le trio (operation, user, sector) existe dans ope_users_sectors
if (!$this->verifyUserSectorAssociation($newOperationId, $newOpeUserId, $newOpeSectorId)) {
$this->logger->warning(" ⚠ Passage {$passage['rowid']}: Trio (op={$newOperationId}, user={$newOpeUserId}, sector={$newOpeSectorId}) inexistant");
continue;
}
// Insérer le passage avec l'ID de ope_users
$newPassId = $this->insertPassage($passage, $newOperationId, $newOpeSectorId, $newOpeUserId);
if ($newPassId) {
// Migrer l'historique du passage
$this->migratePassageHisto($passage['rowid'], $newPassId, $userMapping);
$count++;
}
}
if ($count > 0) {
$this->logger->success("{$count} passage(s) migré(s)");
}
return $count;
}
/**
* Vérifie qu'une association user-sector existe dans ope_users_sectors
*
* @param int $operationId ID opération
* @param int $userId ID ope_users (mapping)
* @param int $sectorId ID ope_sectors
* @return bool True si l'association existe
*/
private function verifyUserSectorAssociation(int $operationId, int $userId, int $sectorId): bool
{
$stmt = $this->targetDb->prepare("
SELECT COUNT(*) FROM ope_users_sectors
WHERE fk_operation = :operation_id
AND fk_user = :user_id
AND fk_sector = :sector_id
");
$stmt->execute([
':operation_id' => $operationId,
':user_id' => $userId,
':sector_id' => $sectorId
]);
return $stmt->fetchColumn() > 0;
}
/**
* Insère un passage dans la nouvelle base
*
* @param array $passage Données du passage
* @param int $newOperationId ID nouvelle opération
* @param int $newOpeSectorId ID nouveau secteur
* @param int $userId ID de ope_users (mapping)
* @return int|null ID du nouveau passage ou null en cas d'erreur
*/
private function insertPassage(
array $passage,
int $newOperationId,
int $newOpeSectorId,
int $userId
): ?int {
try {
$stmt = $this->targetDb->prepare("
INSERT INTO ope_pass (
fk_operation, fk_sector, fk_user, fk_adresse,
passed_at, fk_type, numero, rue, rue_bis, ville,
fk_habitat, appt, niveau, residence,
gps_lat, gps_lng, encrypted_name, montant, fk_type_reglement,
remarque, nom_recu, encrypted_email, email_erreur, chk_email_sent,
encrypted_phone, docremis, date_repasser, nb_passages,
chk_gps_maj, chk_map_create, chk_mobile, chk_synchro,
chk_api_adresse, chk_maj_adresse, anomalie,
created_at, fk_user_creat, updated_at, fk_user_modif, chk_active
) VALUES (
:fk_operation, :fk_sector, :fk_user, :fk_adresse,
:passed_at, :fk_type, :numero, :rue, :rue_bis, :ville,
:fk_habitat, :appt, :niveau, :residence,
:gps_lat, :gps_lng, :encrypted_name, :montant, :fk_type_reglement,
:remarque, :nom_recu, :encrypted_email, :email_erreur, :chk_email_sent,
:encrypted_phone, :docremis, :date_repasser, :nb_passages,
:chk_gps_maj, :chk_map_create, :chk_mobile, :chk_synchro,
:chk_api_adresse, :chk_maj_adresse, :anomalie,
:created_at, :fk_user_creat, :updated_at, :fk_user_modif, :chk_active
)
");
// Chiffrer les données sensibles
require_once dirname(__DIR__, 4) . '/src/Services/ApiService.php';
$stmt->execute([
':fk_operation' => $newOperationId,
':fk_sector' => $newOpeSectorId,
':fk_user' => $userId, // ID de ope_users (mapping)
':fk_adresse' => $passage['fk_adresse'],
':passed_at' => $passage['date_eve'],
':fk_type' => $passage['fk_type'],
':numero' => $passage['numero'],
':rue' => $passage['rue'],
':rue_bis' => $passage['rue_bis'],
':ville' => $passage['ville'],
':fk_habitat' => $passage['fk_habitat'],
':appt' => $passage['appt'],
':niveau' => $passage['niveau'],
':residence' => $passage['lieudit'] ?? null,
':gps_lat' => $passage['gps_lat'],
':gps_lng' => $passage['gps_lng'],
':encrypted_name' => $passage['libelle'] ? ApiService::encryptData($passage['libelle']) : '', // Chiffrer avec IV aléatoire
':montant' => $passage['montant'],
':fk_type_reglement' => (!empty($passage['fk_type_reglement']) && $passage['fk_type_reglement'] > 0) ? $passage['fk_type_reglement'] : 4,
':remarque' => $passage['remarque'],
':nom_recu' => $passage['recu'] ?? null,
':encrypted_email' => $passage['email'] ? ApiService::encryptSearchableData($passage['email']) : null,
':email_erreur' => $passage['email_erreur'],
':chk_email_sent' => $passage['chk_email_sent'],
':encrypted_phone' => $passage['phone'] ? ApiService::encryptData($passage['phone']) : '',
':docremis' => $passage['docremis'],
':date_repasser' => $passage['date_repasser'],
':nb_passages' => ($passage['fk_type'] == 2) ? 0 : $passage['nb_passages'],
':chk_gps_maj' => $passage['chk_gps_maj'],
':chk_map_create' => $passage['chk_map_create'],
':chk_mobile' => $passage['chk_mobile'],
':chk_synchro' => $passage['chk_synchro'],
':chk_api_adresse' => $passage['chk_api_adresse'],
':chk_maj_adresse' => $passage['chk_maj_adresse'],
':anomalie' => $passage['anomalie'],
':created_at' => $passage['date_creat'],
':fk_user_creat' => $passage['fk_user_creat'] ?? 0,
':updated_at' => $passage['date_modif'],
':fk_user_modif' => $passage['fk_user_modif'] ?? 0,
':chk_active' => $passage['active']
]);
return (int)$this->targetDb->lastInsertId();
} catch (Exception $e) {
$this->logger->error(" ❌ Erreur insertion passage {$passage['rowid']}: " . $e->getMessage());
return null;
}
}
/**
* Migre l'historique d'un passage
*
* @param int $oldPassId ID ancien passage
* @param int $newPassId ID nouveau passage
* @param array $userMapping Non utilisé (conservé pour compatibilité)
* @return int Nombre d'entrées d'historique migrées
*/
public function migratePassageHisto(int $oldPassId, int $newPassId, array $userMapping): int
{
$stmt = $this->sourceDb->prepare("
SELECT * FROM ope_pass_histo WHERE fk_pass = :pass_id
");
$stmt->execute([':pass_id' => $oldPassId]);
$histos = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($histos)) {
return 0;
}
$count = 0;
foreach ($histos as $histo) {
$stmt = $this->targetDb->prepare("
INSERT INTO ope_pass_histo (
fk_pass, date_histo, sujet, remarque
) VALUES (
:fk_pass, :date_histo, :sujet, :remarque
)
");
$stmt->execute([
':fk_pass' => $newPassId,
':date_histo' => $histo['date_histo'],
':sujet' => $histo['sujet'],
':remarque' => $histo['remarque']
]);
$count++;
}
return $count;
}
}

View File

@@ -0,0 +1,289 @@
<?php
/**
* Migration des secteurs (ope_sectors) et données associées
*
* Gère la migration des secteurs avec leurs adresses, associations
* utilisateurs-secteurs, et passages. Utilise PassageMigrator pour les passages.
*/
class SectorMigrator
{
private PDO $sourceDb;
private PDO $targetDb;
private MigrationLogger $logger;
private PassageMigrator $passageMigrator;
private array $sectorMapping = [];
/**
* Constructeur
*
* @param PDO $sourceDb Connexion source
* @param PDO $targetDb Connexion cible
* @param MigrationLogger $logger Logger
* @param PassageMigrator $passageMigrator Migrator de passages
*/
public function __construct(
PDO $sourceDb,
PDO $targetDb,
MigrationLogger $logger,
PassageMigrator $passageMigrator
) {
$this->sourceDb = $sourceDb;
$this->targetDb = $targetDb;
$this->logger = $logger;
$this->passageMigrator = $passageMigrator;
}
/**
* Migre un secteur dans le contexte d'une opération
*
* @param int $oldOperationId ID ancienne opération
* @param int $newOperationId ID nouvelle opération
* @param int $oldSectorId ID ancien secteur
* @param array $userMapping Mapping oldUserId => newOpeUserId
* @return array|null ['id' => int, 'name' => string, 'users' => int, 'passages' => int] ou null en cas d'erreur
*/
public function migrateSector(
int $oldOperationId,
int $newOperationId,
int $oldSectorId,
array $userMapping
): ?array {
$this->logger->info(" 📍 Migration secteur ID: {$oldSectorId}");
try {
// 1. Récupérer le secteur source
$stmt = $this->sourceDb->prepare("
SELECT * FROM sectors WHERE rowid = :id
");
$stmt->execute([':id' => $oldSectorId]);
$sector = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$sector) {
$this->logger->warning(" Secteur {$oldSectorId} non trouvé");
return null;
}
// 2. Créer dans ope_sectors
$newOpeSectorId = $this->createOpeSector($sector, $newOperationId);
if (!$newOpeSectorId) {
return null;
}
// 3. Mapper "operationId_sectorId" → newOpeSectorId
$mappingKey = "{$oldOperationId}_{$oldSectorId}";
$this->sectorMapping[$mappingKey] = $newOpeSectorId;
$this->logger->success(" ✓ Secteur créé avec ID: {$newOpeSectorId}");
// 4. Migrer sectors_adresses
$this->migrateSectorAddresses($oldSectorId, $newOpeSectorId);
// 5. Migrer ope_users_sectors
$usersCount = $this->migrateUsersSectors($oldOperationId, $newOperationId, $oldSectorId, $newOpeSectorId, $userMapping);
// 6. Migrer ope_pass
$passagesCount = $this->passageMigrator->migratePassages(
$oldOperationId,
$newOperationId,
$oldSectorId,
$newOpeSectorId,
$userMapping
);
return [
'id' => $newOpeSectorId,
'name' => $sector['libelle'],
'users' => $usersCount,
'passages' => $passagesCount
];
} catch (Exception $e) {
$this->logger->error(" ❌ Erreur migration secteur {$oldSectorId}: " . $e->getMessage());
return null;
}
}
/**
* Crée un secteur dans ope_sectors
*
* @param array $sector Données du secteur
* @param int $newOperationId ID nouvelle opération
* @return int|null ID du nouveau secteur ou null en cas d'erreur
*/
private function createOpeSector(array $sector, int $newOperationId): ?int
{
try {
$stmt = $this->targetDb->prepare("
INSERT INTO ope_sectors (
fk_operation, libelle, sector, color,
created_at, fk_user_creat, updated_at, fk_user_modif, chk_active
) VALUES (
:fk_operation, :libelle, :sector, :color,
:created_at, :fk_user_creat, :updated_at, :fk_user_modif, :chk_active
)
");
$stmt->execute([
':fk_operation' => $newOperationId,
':libelle' => $sector['libelle'],
':sector' => $sector['sector'],
':color' => $sector['color'],
':created_at' => $sector['date_creat'],
':fk_user_creat' => $sector['fk_user_creat'] ?? 0,
':updated_at' => $sector['date_modif'],
':fk_user_modif' => $sector['fk_user_modif'] ?? 0,
':chk_active' => $sector['active']
]);
return (int)$this->targetDb->lastInsertId();
} catch (Exception $e) {
$this->logger->error(" ❌ Erreur création secteur: " . $e->getMessage());
return null;
}
}
/**
* Migre les adresses d'un secteur
*
* @param int $oldSectorId ID ancien secteur
* @param int $newOpeSectorId ID nouveau ope_sectors
* @return int Nombre d'adresses migrées
*/
private function migrateSectorAddresses(int $oldSectorId, int $newOpeSectorId): int
{
$stmt = $this->sourceDb->prepare("
SELECT * FROM sectors_adresses WHERE fk_sector = :sector_id
");
$stmt->execute([':sector_id' => $oldSectorId]);
$addresses = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($addresses)) {
return 0;
}
$count = 0;
foreach ($addresses as $address) {
$stmt = $this->targetDb->prepare("
INSERT INTO sectors_adresses (
fk_adresse, fk_sector, numero, rue_bis, rue, cp, ville,
gps_lat, gps_lng
) VALUES (
:fk_adresse, :fk_sector, :numero, :rue_bis, :rue, :cp, :ville,
:gps_lat, :gps_lng
)
");
$stmt->execute([
':fk_adresse' => $address['fk_adresse'], // Garde la valeur telle quelle
':fk_sector' => $newOpeSectorId,
':numero' => $address['numero'],
':rue_bis' => $address['rue_bis'],
':rue' => $address['rue'],
':cp' => $address['cp'],
':ville' => $address['ville'],
':gps_lat' => $address['gps_lat'],
':gps_lng' => $address['gps_lng']
]);
$count++;
}
$this->logger->success("{$count} adresse(s) migrée(s)");
return $count;
}
/**
* Migre les associations utilisateurs-secteurs
*
* @param int $oldOperationId ID ancienne opération
* @param int $newOperationId ID nouvelle opération
* @param int $oldSectorId ID ancien secteur
* @param int $newOpeSectorId ID nouveau ope_sectors
* @param array $userMapping Mapping oldUserId => newOpeUserId
* @return int Nombre d'associations migrées
*/
private function migrateUsersSectors(
int $oldOperationId,
int $newOperationId,
int $oldSectorId,
int $newOpeSectorId,
array $userMapping
): int {
$stmt = $this->sourceDb->prepare("
SELECT * FROM ope_users_sectors
WHERE fk_operation = :operation_id
AND fk_sector = :sector_id
AND active = 1
");
$stmt->execute([
':operation_id' => $oldOperationId,
':sector_id' => $oldSectorId
]);
$usersSectors = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($usersSectors)) {
return 0;
}
$count = 0;
foreach ($usersSectors as $us) {
// Vérifier que l'utilisateur existe dans le mapping
// (le mapping sert juste à vérifier que l'user a été migré)
if (!isset($userMapping[$us['fk_user']])) {
$this->logger->warning(" ⚠ User {$us['fk_user']} non trouvé dans mapping");
continue;
}
$stmt = $this->targetDb->prepare("
INSERT INTO ope_users_sectors (
fk_operation, fk_user, fk_sector,
created_at, fk_user_creat, updated_at, fk_user_modif, chk_active
) VALUES (
:fk_operation, :fk_user, :fk_sector,
:created_at, :fk_user_creat, :updated_at, :fk_user_modif, :chk_active
)
");
$stmt->execute([
':fk_operation' => $newOperationId,
':fk_user' => $userMapping[$us['fk_user']], // ID de ope_users (mapping)
':fk_sector' => $newOpeSectorId,
':created_at' => date('Y-m-d H:i:s'),
':fk_user_creat' => 0,
':updated_at' => null,
':fk_user_modif' => null,
':chk_active' => $us['active']
]);
$count++;
}
$this->logger->success("{$count} association(s) user-secteur migrée(s)");
return $count;
}
/**
* Retourne le mapping des secteurs
*
* @return array "operationId_sectorId" => newOpeSectorId
*/
public function getSectorMapping(): array
{
return $this->sectorMapping;
}
/**
* Définit le mapping des secteurs (utile pour réutilisation)
*
* @param array $mapping "operationId_sectorId" => newOpeSectorId
*/
public function setSectorMapping(array $mapping): void
{
$this->sectorMapping = $mapping;
}
}

View File

@@ -0,0 +1,163 @@
<?php
/**
* Migration des utilisateurs d'opérations (ope_users)
*
* Gère la création des utilisateurs par opération et le mapping
* oldUserId (users.rowid) → newOpeUserId (ope_users.id)
*/
class UserMigrator
{
private PDO $sourceDb;
private PDO $targetDb;
private MigrationLogger $logger;
private array $userMapping = [];
/**
* Constructeur
*
* @param PDO $sourceDb Connexion source
* @param PDO $targetDb Connexion cible
* @param MigrationLogger $logger Logger
*/
public function __construct(PDO $sourceDb, PDO $targetDb, MigrationLogger $logger)
{
$this->sourceDb = $sourceDb;
$this->targetDb = $targetDb;
$this->logger = $logger;
}
/**
* Migre les utilisateurs d'une opération
* - Si opération active : TOUS les users actifs de l'entité
* - Si opération inactive : Uniquement ceux dans ope_users_sectors
*
* @param int $oldOperationId ID ancienne opération
* @param int $newOperationId ID nouvelle opération
* @param int $entityId ID de l'entité
* @param bool $isActiveOperation True si opération active
* @return array ['mapping' => array, 'count' => int]
*/
public function migrateOperationUsers(
int $oldOperationId,
int $newOperationId,
int $entityId,
bool $isActiveOperation
): array {
$this->logger->info("👥 Migration des utilisateurs de l'opération...");
// Réinitialiser le mapping pour cette opération
$this->userMapping = [];
// Récupérer les utilisateurs selon le type d'opération
if ($isActiveOperation) {
// Pour l'opération active : TOUS les users actifs de l'entité
$this->logger->info(" Opération ACTIVE : migration de tous les users actifs de l'entité");
$stmt = $this->sourceDb->prepare("
SELECT rowid
FROM users
WHERE fk_entite = :entity_id AND active = 1
");
$stmt->execute([':entity_id' => $entityId]);
$userIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
} else {
// Pour les opérations inactives : uniquement ceux dans ope_users_sectors
$this->logger->info(" Opération INACTIVE : migration des users affectés aux secteurs");
$stmt = $this->sourceDb->prepare("
SELECT DISTINCT fk_user
FROM ope_users_sectors
WHERE fk_operation = :operation_id AND active = 1
");
$stmt->execute([':operation_id' => $oldOperationId]);
$userIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
if (empty($userIds)) {
$this->logger->warning("Aucun utilisateur trouvé pour l'opération {$oldOperationId}");
return ['mapping' => [], 'count' => 0];
}
$count = 0;
foreach ($userIds as $oldUserId) {
// Récupérer les infos utilisateur depuis la table users
$stmt = $this->sourceDb->prepare("
SELECT * FROM users WHERE rowid = :id AND active = 1
");
$stmt->execute([':id' => $oldUserId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
$this->logger->warning(" ⚠ Utilisateur {$oldUserId} non trouvé ou inactif");
continue;
}
// Créer dans ope_users de la nouvelle base
$stmt = $this->targetDb->prepare("
INSERT INTO ope_users (
fk_operation, fk_user, fk_role,
first_name, encrypted_name, sect_name,
created_at, fk_user_creat, updated_at, fk_user_modif, chk_active
) VALUES (
:fk_operation, :fk_user, :fk_role,
:first_name, :encrypted_name, :sect_name,
:created_at, :fk_user_creat, :updated_at, :fk_user_modif, :chk_active
)
");
$stmt->execute([
':fk_operation' => $newOperationId,
':fk_user' => $oldUserId, // Référence vers users.id
':fk_role' => $user['fk_role'],
':first_name' => $user['prenom'],
':encrypted_name' => ApiService::encryptData($user['libelle']), // Chiffrer le nom avec IV aléatoire
':sect_name' => $user['nom_tournee'],
':created_at' => $user['date_creat'],
':fk_user_creat' => $user['fk_user_creat'],
':updated_at' => $user['date_modif'],
':fk_user_modif' => $user['fk_user_modif'],
':chk_active' => $user['active']
]);
$newOpeUserId = (int)$this->targetDb->lastInsertId();
// Mapper oldUserId → newOpeUserId
$this->userMapping[$oldUserId] = $newOpeUserId;
$count++;
}
$this->logger->success("{$count} utilisateur(s) migré(s)");
return ['mapping' => $this->userMapping, 'count' => $count];
}
/**
* Retourne le mapping des utilisateurs
*
* @return array oldUserId => newOpeUserId
*/
public function getUserMapping(): array
{
return $this->userMapping;
}
/**
* Définit le mapping des utilisateurs (utile pour réutilisation)
*
* @param array $mapping oldUserId => newOpeUserId
*/
public function setUserMapping(array $mapping): void
{
$this->userMapping = $mapping;
}
/**
* Récupère le nouvel ID ope_users depuis le mapping
*
* @param int $oldUserId ID ancien utilisateur
* @return int|null Nouvel ID ope_users ou null si non trouvé
*/
public function getMappedUserId(int $oldUserId): ?int
{
return $this->userMapping[$oldUserId] ?? null;
}
}

View File

@@ -0,0 +1,471 @@
#!/usr/bin/env php
<?php
/**
* Script de migration v2 - Architecture modulaire
*
* Utilise les migrators spécialisés pour une migration hiérarchique par opération.
* Source fixe: geosector (synchronisée 2x/jour par PM7 depuis nx4)
* Cible: dva_geo (développement), rca_geo (recette) ou pra_geo (production)
*
* Usage:
* Migration d'une entité:
* php migrate_from_backup.php --mode=entity --entity-id=2
*
* Migration globale (toutes les entités):
* php migrate_from_backup.php --mode=global
*
* Avec environnement explicite:
* php migrate_from_backup.php --env=dva --mode=entity --entity-id=2
*/
// Inclure ApiService pour le chiffrement
require_once dirname(__DIR__, 3) . '/src/Services/ApiService.php';
// Inclure les classes v2
require_once __DIR__ . '/lib/DatabaseConfig.php';
require_once __DIR__ . '/lib/MigrationLogger.php';
require_once __DIR__ . '/lib/DatabaseConnection.php';
require_once __DIR__ . '/lib/UserMigrator.php';
require_once __DIR__ . '/lib/PassageMigrator.php';
require_once __DIR__ . '/lib/SectorMigrator.php';
require_once __DIR__ . '/lib/OperationMigrator.php';
// Configuration PHP pour les grosses migrations
ini_set('memory_limit', '512M');
ini_set('max_execution_time', '3600'); // 1 heure max
class DataMigration
{
private PDO $sourceDb;
private PDO $targetDb;
private MigrationLogger $logger;
private DatabaseConfig $config;
private OperationMigrator $operationMigrator;
// Options
private string $mode;
private ?int $entityId;
private bool $deleteBefore;
// Statistiques
private array $migrationStats = [];
public function __construct(string $env, string $mode = 'global', ?int $entityId = null, ?string $logFile = null, bool $deleteBefore = true)
{
// Initialisation config et logger
$this->config = new DatabaseConfig($env);
$this->mode = $mode;
$this->entityId = $entityId;
$this->deleteBefore = $deleteBefore;
// Générer le nom du fichier log selon le mode si non spécifié
if (!$logFile) {
$logDir = dirname(__DIR__, 2) . '/logs';
$timestamp = date('Ymd_His');
if ($mode === 'entity' && $entityId) {
$logFile = "{$logDir}/migration_entite_{$entityId}_{$timestamp}.log";
} else {
$logFile = "{$logDir}/migration_global_{$timestamp}.log";
}
}
$this->logger = new MigrationLogger($logFile);
// Log header
$this->logHeader();
// Connexions
$dbConnection = new DatabaseConnection($this->config, $this->logger);
$dbConnection->connect();
$this->sourceDb = $dbConnection->getSourceDb();
$this->targetDb = $dbConnection->getTargetDb();
// Initialiser les migrators
$this->initializeMigrators();
}
private function initializeMigrators(): void
{
// Créer les migrators dans l'ordre de dépendance
$passageMigrator = new PassageMigrator($this->sourceDb, $this->targetDb, $this->logger);
$sectorMigrator = new SectorMigrator($this->sourceDb, $this->targetDb, $this->logger, $passageMigrator);
$userMigrator = new UserMigrator($this->sourceDb, $this->targetDb, $this->logger);
$this->operationMigrator = new OperationMigrator(
$this->sourceDb,
$this->targetDb,
$this->logger,
$userMigrator,
$sectorMigrator
);
}
public function run(): void
{
if ($this->mode === 'entity') {
if (!$this->entityId) {
throw new Exception("entity-id requis en mode entity");
}
$this->migrateEntity($this->entityId);
} else {
$this->migrateAllEntities();
}
// Afficher le récapitulatif
if (!empty($this->migrationStats)) {
$this->logger->logMigrationSummary($this->migrationStats);
}
$this->logger->separator();
$this->logger->success("🎉 Migration terminée !");
$this->logger->info("📄 Log: " . $this->logger->getLogFile());
}
private function migrateEntity(int $entityId): void
{
$this->logger->separator();
$this->logger->info("🏢 Migration de l'entité ID: {$entityId}");
// Supprimer les données existantes si demandé
if ($this->deleteBefore) {
$this->deleteEntityData($entityId);
}
// Migrer l'entité elle-même
$this->migrateEntityRecord($entityId);
// Migrer les users de l'entité (table centrale users)
$this->migrateEntityUsers($entityId);
// Récupérer le nom de l'entité pour les stats
$stmt = $this->sourceDb->prepare("SELECT libelle FROM users_entites WHERE rowid = :id");
$stmt->execute([':id' => $entityId]);
$entityName = $stmt->fetchColumn();
// Récupérer et migrer les opérations
$operationIds = $this->operationMigrator->getOperationsToMigrate($entityId);
$operations = [];
foreach ($operationIds as $oldOperationId) {
$operationStats = $this->operationMigrator->migrateOperation($oldOperationId);
if ($operationStats) {
$operations[] = $operationStats;
}
}
// Stocker les stats pour cette entité
$this->migrationStats = [
'entity' => [
'id' => $entityId,
'name' => $entityName ?: "Entité #{$entityId}"
],
'operations' => $operations
];
}
private function migrateAllEntities(): void
{
// Récupérer toutes les entités actives
$stmt = $this->sourceDb->query("SELECT rowid FROM users_entites WHERE active = 1 ORDER BY rowid");
$entities = $stmt->fetchAll(PDO::FETCH_COLUMN);
$this->logger->info("📊 " . count($entities) . " entité(s) à migrer");
$allOperations = [];
foreach ($entities as $entityId) {
// Sauvegarder les stats actuelles avant de migrer
$previousStats = $this->migrationStats;
$this->migrateEntity($entityId);
// Agréger les opérations de toutes les entités
if (!empty($this->migrationStats['operations'])) {
$allOperations = array_merge($allOperations, $this->migrationStats['operations']);
}
}
// Stocker les stats globales
$this->migrationStats = [
'operations' => $allOperations
];
}
private function deleteEntityData(int $entityId): void
{
$this->logger->separator();
$this->logger->warning("🗑️ Suppression des données de l'entité {$entityId}...");
// Ordre inverse des contraintes FK
$tables = [
'medias' => "fk_entite = {$entityId} OR fk_operation IN (SELECT id FROM operations WHERE fk_entite = {$entityId})",
'ope_pass_histo' => "fk_pass IN (SELECT id FROM ope_pass WHERE fk_operation IN (SELECT id FROM operations WHERE fk_entite = {$entityId}))",
'ope_pass' => "fk_operation IN (SELECT id FROM operations WHERE fk_entite = {$entityId})",
'ope_users_sectors' => "fk_operation IN (SELECT id FROM operations WHERE fk_entite = {$entityId})",
'ope_users' => "fk_operation IN (SELECT id FROM operations WHERE fk_entite = {$entityId})",
'sectors_adresses' => "fk_sector IN (SELECT id FROM ope_sectors WHERE fk_operation IN (SELECT id FROM operations WHERE fk_entite = {$entityId}))",
'ope_sectors' => "fk_operation IN (SELECT id FROM operations WHERE fk_entite = {$entityId})",
'operations' => "fk_entite = {$entityId}",
'users' => "fk_entite = {$entityId}"
];
foreach ($tables as $table => $condition) {
$stmt = $this->targetDb->query("DELETE FROM {$table} WHERE {$condition}");
$count = $stmt->rowCount();
if ($count > 0) {
$this->logger->info("{$table}: {$count} ligne(s) supprimée(s)");
}
}
$this->logger->success("✓ Suppression terminée");
}
private function migrateEntityRecord(int $entityId): void
{
// Vérifier si existe déjà
$stmt = $this->targetDb->prepare("SELECT COUNT(*) FROM entites WHERE id = :id");
$stmt->execute([':id' => $entityId]);
if ($stmt->fetchColumn() > 0) {
$this->logger->info("Entité {$entityId} existe déjà, skip");
return;
}
// Récupérer depuis source
$stmt = $this->sourceDb->prepare("SELECT * FROM users_entites WHERE rowid = :id");
$stmt->execute([':id' => $entityId]);
$entity = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$entity) {
throw new Exception("Entité {$entityId} non trouvée");
}
// Insérer dans cible (schéma geo_app)
$stmt = $this->targetDb->prepare("
INSERT INTO entites (
id, encrypted_name, adresse1, adresse2, code_postal, ville,
fk_region, fk_type, encrypted_phone, encrypted_mobile, encrypted_email,
gps_lat, gps_lng, chk_stripe, encrypted_stripe_id, encrypted_iban, encrypted_bic,
chk_demo, chk_mdp_manuel, chk_username_manuel, chk_user_delete_pass,
chk_copie_mail_recu, chk_accept_sms, chk_lot_actif,
created_at, fk_user_creat, updated_at, fk_user_modif, chk_active
) VALUES (
:id, :encrypted_name, :adresse1, :adresse2, :code_postal, :ville,
:fk_region, :fk_type, :encrypted_phone, :encrypted_mobile, :encrypted_email,
:gps_lat, :gps_lng, :chk_stripe, :encrypted_stripe_id, :encrypted_iban, :encrypted_bic,
:chk_demo, :chk_mdp_manuel, :chk_username_manuel, :chk_user_delete_pass,
:chk_copie_mail_recu, :chk_accept_sms, :chk_lot_actif,
:created_at, :fk_user_creat, :updated_at, :fk_user_modif, :chk_active
)
");
$stmt->execute([
':id' => $entityId,
':encrypted_name' => $entity['libelle'] ? ApiService::encryptData($entity['libelle']) : '',
':adresse1' => $entity['adresse1'] ?? '',
':adresse2' => $entity['adresse2'] ?? '',
':code_postal' => $entity['cp'] ?? '',
':ville' => $entity['ville'] ?? '',
':fk_region' => $entity['fk_region'],
':fk_type' => $entity['fk_type'] ?? 1,
':encrypted_phone' => $entity['tel1'] ? ApiService::encryptData($entity['tel1']) : '',
':encrypted_mobile' => $entity['tel2'] ? ApiService::encryptData($entity['tel2']) : '',
':encrypted_email' => $entity['email'] ? ApiService::encryptSearchableData($entity['email']) : '',
':gps_lat' => $entity['gps_lat'] ?? '',
':gps_lng' => $entity['gps_lng'] ?? '',
':chk_stripe' => 0,
':encrypted_stripe_id' => '',
':encrypted_iban' => $entity['iban'] ? ApiService::encryptData($entity['iban']) : '',
':encrypted_bic' => $entity['bic'] ? ApiService::encryptData($entity['bic']) : '',
':chk_demo' => $entity['demo'] ?? 1,
':chk_mdp_manuel' => $entity['chk_mdp_manuel'] ?? 0,
':chk_username_manuel' => 0,
':chk_user_delete_pass' => 0,
':chk_copie_mail_recu' => $entity['chk_copie_mail_recu'] ?? 0,
':chk_accept_sms' => $entity['chk_accept_sms'] ?? 0,
':chk_lot_actif' => 0,
':created_at' => date('Y-m-d H:i:s'),
':fk_user_creat' => 0,
':updated_at' => $entity['date_modif'],
':fk_user_modif' => $entity['fk_user_modif'] ?? 0,
':chk_active' => $entity['active'] ?? 1
]);
$this->logger->success("✓ Entité {$entityId} migrée");
}
private function migrateEntityUsers(int $entityId): void
{
$stmt = $this->sourceDb->prepare("SELECT * FROM users WHERE fk_entite = :entity_id AND active = 1");
$stmt->execute([':entity_id' => $entityId]);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
$count = 0;
foreach ($users as $user) {
// Vérifier si existe déjà
$stmt = $this->targetDb->prepare("SELECT COUNT(*) FROM users WHERE id = :id");
$stmt->execute([':id' => $user['rowid']]);
if ($stmt->fetchColumn() > 0) {
continue; // Skip si existe
}
// Insérer l'utilisateur
$stmt = $this->targetDb->prepare("
INSERT INTO users (
id, fk_entite, fk_role, first_name, encrypted_name,
encrypted_user_name, user_pass_hash, encrypted_email, encrypted_phone, encrypted_mobile,
created_at, fk_user_creat, updated_at, fk_user_modif, chk_active
) VALUES (
:id, :fk_entite, :fk_role, :first_name, :encrypted_name,
:encrypted_user_name, :user_pass_hash, :encrypted_email, :encrypted_phone, :encrypted_mobile,
:created_at, :fk_user_creat, :updated_at, :fk_user_modif, :chk_active
)
");
$stmt->execute([
':id' => $user['rowid'],
':fk_entite' => $user['fk_entite'],
':fk_role' => $user['fk_role'],
':first_name' => $user['prenom'],
':encrypted_name' => ApiService::encryptData($user['libelle']), // Chiffrer avec IV aléatoire
':encrypted_user_name' => ApiService::encryptSearchableData($user['username']),
':user_pass_hash' => $user['userpswd'], // Hash bcrypt du mot de passe
':encrypted_email' => $user['email'] ? ApiService::encryptSearchableData($user['email']) : null,
':encrypted_phone' => $user['telephone'] ? ApiService::encryptData($user['telephone']) : null,
':encrypted_mobile' => $user['mobile'] ? ApiService::encryptData($user['mobile']) : null,
':created_at' => $user['date_creat'],
':fk_user_creat' => $user['fk_user_creat'],
':updated_at' => $user['date_modif'],
':fk_user_modif' => $user['fk_user_modif'],
':chk_active' => $user['active']
]);
$count++;
}
$this->logger->success("{$count} utilisateur(s) de l'entité migré(s)");
}
private function logHeader(): void
{
$this->logger->separator();
$this->logger->info("🚀 Migration v2 - Architecture modulaire");
$this->logger->info("📅 Date: " . date('Y-m-d H:i:s'));
$this->logger->info("🌍 Environnement: " . $this->config->getEnvName());
$this->logger->info("🔧 Mode: " . $this->mode);
if ($this->entityId) {
$this->logger->info("🏢 Entité: " . $this->entityId);
}
$this->logger->info("🗑️ Suppression avant: " . ($this->deleteBefore ? 'OUI' : 'NON'));
$this->logger->separator();
}
}
// === GESTION DES ARGUMENTS CLI ===
function parseArguments(array $argv): array
{
$options = [
'env' => DatabaseConfig::autoDetect(),
'mode' => 'global',
'entity-id' => null,
'log' => null,
'delete-before' => true,
'help' => false
];
foreach ($argv as $arg) {
if ($arg === '--help') {
$options['help'] = true;
} elseif (preg_match('/^--env=(.+)$/', $arg, $matches)) {
$options['env'] = $matches[1];
} elseif (preg_match('/^--mode=(.+)$/', $arg, $matches)) {
$options['mode'] = $matches[1];
} elseif (preg_match('/^--entity-id=(\d+)$/', $arg, $matches)) {
$options['entity-id'] = (int)$matches[1];
} elseif (preg_match('/^--log=(.+)$/', $arg, $matches)) {
$options['log'] = $matches[1];
} elseif ($arg === '--delete-before=false') {
$options['delete-before'] = false;
}
}
return $options;
}
function showHelp(): void
{
echo <<<HELP
🚀 Migration v2 - Architecture modulaire
USAGE:
php migrate_from_backup.php [OPTIONS]
OPTIONS:
--env=ENV Environnement: 'dva' (développement), 'rca' (recette) ou 'pra' (production)
Par défaut: auto-détection selon hostname
--mode=MODE Mode de migration: 'global' ou 'entity'
Par défaut: global
--entity-id=ID ID de l'entité à migrer (requis si mode=entity)
--log=PATH Fichier de log personnalisé
Par défaut: logs/migration_YYYYMMDD_HHMMSS.log
--delete-before Supprimer les données existantes avant migration
Par défaut: true
Utiliser --delete-before=false pour désactiver
--help Afficher cette aide
EXEMPLES:
# Migration d'une entité avec suppression (recommandé)
php migrate_from_backup.php --mode=entity --entity-id=2
# Migration sans suppression (risque de doublons)
php migrate_from_backup.php --mode=entity --entity-id=2 --delete-before=false
# Migration globale de toutes les entités
php migrate_from_backup.php --mode=global
# Spécifier l'environnement manuellement (DVA, RCA ou PRA)
php migrate_from_backup.php --env=dva --mode=entity --entity-id=2
HELP;
}
// === POINT D'ENTRÉE ===
try {
$options = parseArguments($argv);
if ($options['help']) {
showHelp();
exit(0);
}
// Valider l'environnement
if (!DatabaseConfig::exists($options['env'])) {
throw new Exception("Invalid environment: {$options['env']}. Use 'dva', 'rca' or 'pra'");
}
// Créer et exécuter la migration
$migration = new DataMigration(
$options['env'],
$options['mode'],
$options['entity-id'],
$options['log'],
$options['delete-before']
);
$migration->run();
} catch (Exception $e) {
echo "❌ ERREUR: " . $e->getMessage() . "\n";
exit(1);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +0,0 @@
-- Migration : Ajout des champs manquants dans email_queue
-- Date : 2025-01-06
-- Description : Ajoute sent_at et error_message pour le bon fonctionnement du CRON
USE geo_app;
-- Vérifier si les champs existent déjà avant de les ajouter
SET @db_name = DATABASE();
SET @table_name = 'email_queue';
-- Ajouter sent_at si n'existe pas
SET @column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = @table_name
AND COLUMN_NAME = 'sent_at'
);
SET @sql = IF(@column_exists = 0,
'ALTER TABLE email_queue ADD COLUMN sent_at TIMESTAMP NULL DEFAULT NULL AFTER status',
'SELECT "Column sent_at already exists" AS message'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Ajouter error_message si n'existe pas
SET @column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = @table_name
AND COLUMN_NAME = 'error_message'
);
SET @sql = IF(@column_exists = 0,
'ALTER TABLE email_queue ADD COLUMN error_message TEXT NULL DEFAULT NULL AFTER attempts',
'SELECT "Column error_message already exists" AS message'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Vérifier le résultat
SELECT
'Migration terminée' AS status,
COLUMN_NAME,
COLUMN_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name
AND TABLE_NAME = @table_name
AND COLUMN_NAME IN ('sent_at', 'error_message');

View File

@@ -1,94 +0,0 @@
-- =====================================================
-- Migration Stripe : is_striped → stripe_payment_id
-- Date : Janvier 2025
-- Description : Refactoring pour simplifier la gestion des paiements Stripe
-- =====================================================
-- 1. Modifier la table ope_pass
-- ------------------------------
ALTER TABLE `ope_pass` DROP COLUMN IF EXISTS `chk_striped`;
ALTER TABLE `ope_pass` ADD COLUMN `stripe_payment_id` VARCHAR(50) DEFAULT NULL COMMENT 'ID du PaymentIntent Stripe (pi_xxx)';
ALTER TABLE `ope_pass` ADD INDEX `idx_stripe_payment` (`stripe_payment_id`);
-- 2. Modifier stripe_payment_history pour la rendre indépendante
-- ----------------------------------------------------------------
-- Supprimer la clé étrangère vers stripe_payment_intents
ALTER TABLE `stripe_payment_history`
DROP FOREIGN KEY IF EXISTS `stripe_payment_history_ibfk_1`;
-- Modifier la colonne pour stocker directement l'ID Stripe (totalement indépendante)
ALTER TABLE `stripe_payment_history`
DROP INDEX IF EXISTS `idx_fk_payment_intent`,
CHANGE COLUMN `fk_payment_intent` `stripe_payment_intent_id` VARCHAR(255) DEFAULT NULL COMMENT 'ID du PaymentIntent Stripe',
ADD INDEX `idx_stripe_payment_intent_id` (`stripe_payment_intent_id`);
-- 3. Modifier stripe_refunds pour la rendre indépendante
-- --------------------------------------------------------
ALTER TABLE `stripe_refunds`
DROP FOREIGN KEY IF EXISTS `stripe_refunds_ibfk_1`;
-- Modifier la colonne pour stocker directement l'ID Stripe (totalement indépendante)
ALTER TABLE `stripe_refunds`
DROP INDEX IF EXISTS `idx_fk_payment_intent`,
CHANGE COLUMN `fk_payment_intent` `stripe_payment_intent_id` VARCHAR(255) NOT NULL COMMENT 'ID du PaymentIntent Stripe',
ADD INDEX `idx_stripe_payment_intent_id` (`stripe_payment_intent_id`);
-- 4. Supprimer la vue qui dépend de stripe_payment_intents
-- ----------------------------------------------------------
DROP VIEW IF EXISTS `v_stripe_payment_stats`;
-- 5. Supprimer la table stripe_payment_intents
-- ---------------------------------------------
DROP TABLE IF EXISTS `stripe_payment_intents`;
-- 6. Créer une nouvelle vue basée sur ope_pass
-- ----------------------------------------------
CREATE OR REPLACE VIEW `v_stripe_payment_stats` AS
SELECT
o.fk_entite,
e.encrypted_name as entite_name,
p.fk_user,
CONCAT(u.first_name, ' ', u.sect_name) as user_name,
COUNT(DISTINCT p.id) as total_ventes,
COUNT(DISTINCT CASE WHEN p.stripe_payment_id IS NOT NULL THEN p.id END) as ventes_stripe,
SUM(CASE WHEN p.stripe_payment_id IS NOT NULL THEN p.montant ELSE 0 END) as montant_stripe,
SUM(CASE WHEN p.stripe_payment_id IS NULL THEN p.montant ELSE 0 END) as montant_autres,
DATE(p.created_at) as date_vente
FROM ope_pass p
LEFT JOIN operations o ON p.fk_operation = o.id
LEFT JOIN entites e ON o.fk_entite = e.id
LEFT JOIN users u ON p.fk_user = u.id
WHERE p.fk_type = 2 -- Type vente calendrier
GROUP BY o.fk_entite, p.fk_user, DATE(p.created_at);
-- 7. Vue pour les statistiques par entité uniquement
-- ----------------------------------------------------
CREATE OR REPLACE VIEW `v_stripe_entite_stats` AS
SELECT
e.id as entite_id,
e.encrypted_name as entite_name,
sa.stripe_account_id,
sa.charges_enabled,
sa.payouts_enabled,
COUNT(DISTINCT p.id) as total_passages,
COUNT(DISTINCT CASE WHEN p.stripe_payment_id IS NOT NULL THEN p.id END) as passages_stripe,
SUM(CASE WHEN p.stripe_payment_id IS NOT NULL THEN p.montant ELSE 0 END) as revenue_stripe,
SUM(p.montant) as revenue_total
FROM entites e
LEFT JOIN stripe_accounts sa ON e.id = sa.fk_entite
LEFT JOIN operations o ON e.id = o.fk_entite
LEFT JOIN ope_pass p ON o.id = p.fk_operation
GROUP BY e.id, e.encrypted_name, sa.stripe_account_id;
-- 8. Fonction helper pour vérifier si un passage a un paiement Stripe
-- ---------------------------------------------------------------------
-- NOTE: Si vous exécutez en copier/coller, cette fonction est optionnelle
-- Vous pouvez l'ignorer ou l'exécuter séparément avec DELIMITER
-- =====================================================
-- FIN DE LA MIGRATION
-- =====================================================
-- Tables supprimées : stripe_payment_intents
-- Tables modifiées : ope_pass, stripe_payment_history, stripe_refunds
-- Tables conservées : stripe_accounts, stripe_terminal_readers, etc.
-- =====================================================

View File

@@ -1,197 +0,0 @@
-- =============================================================
-- Tables pour l'intégration Stripe Connect + Terminal
-- Date: 2025-09-01
-- Version: 1.0.0
-- Préfixe: stripe_
-- =============================================================
-- Table pour stocker les comptes Stripe Connect des amicales
CREATE TABLE IF NOT EXISTS stripe_accounts (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
fk_entite INT(10) UNSIGNED NOT NULL,
stripe_account_id VARCHAR(255) UNIQUE,
stripe_location_id VARCHAR(255),
charges_enabled BOOLEAN DEFAULT FALSE,
payouts_enabled BOOLEAN DEFAULT FALSE,
onboarding_completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (fk_entite) REFERENCES entites(id) ON DELETE CASCADE,
INDEX idx_fk_entite (fk_entite),
INDEX idx_stripe_account_id (stripe_account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour stocker les intentions de paiement
CREATE TABLE IF NOT EXISTS stripe_payment_intents (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stripe_payment_intent_id VARCHAR(255) UNIQUE,
fk_entite INT(10) UNSIGNED NOT NULL,
fk_user INT(10) UNSIGNED NOT NULL,
amount INT NOT NULL COMMENT 'Montant en centimes',
currency VARCHAR(3) DEFAULT 'eur',
status VARCHAR(50),
application_fee INT COMMENT 'Commission en centimes',
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (fk_entite) REFERENCES entites(id) ON DELETE CASCADE,
FOREIGN KEY (fk_user) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_fk_entite (fk_entite),
INDEX idx_fk_user (fk_user),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour les readers Terminal (Tap to Pay virtuel)
CREATE TABLE IF NOT EXISTS stripe_terminal_readers (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stripe_reader_id VARCHAR(255) UNIQUE,
fk_entite INT(10) UNSIGNED NOT NULL,
label VARCHAR(255),
location VARCHAR(255),
status VARCHAR(50),
device_type VARCHAR(50) COMMENT 'ios_tap_to_pay, android_tap_to_pay',
device_info JSON COMMENT 'Infos sur le device (modèle, OS, etc)',
last_seen_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (fk_entite) REFERENCES entites(id) ON DELETE CASCADE,
INDEX idx_fk_entite (fk_entite),
INDEX idx_device_type (device_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour les appareils Android certifiés Tap to Pay
CREATE TABLE IF NOT EXISTS stripe_android_certified_devices (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
manufacturer VARCHAR(100),
model VARCHAR(200),
model_identifier VARCHAR(200),
tap_to_pay_certified BOOLEAN DEFAULT FALSE,
certification_date DATE,
min_android_version INT,
country VARCHAR(2) DEFAULT 'FR',
notes TEXT,
last_verified TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_manufacturer_model (manufacturer, model),
INDEX idx_certified (tap_to_pay_certified, country),
UNIQUE KEY unique_device (manufacturer, model, model_identifier)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour l'historique des paiements (pour audit et réconciliation)
CREATE TABLE IF NOT EXISTS stripe_payment_history (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
fk_payment_intent INT(10) UNSIGNED,
event_type VARCHAR(50) COMMENT 'created, processing, succeeded, failed, refunded',
event_data JSON,
webhook_id VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (fk_payment_intent) REFERENCES stripe_payment_intents(id) ON DELETE CASCADE,
INDEX idx_fk_payment_intent (fk_payment_intent),
INDEX idx_event_type (event_type),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour les remboursements
CREATE TABLE IF NOT EXISTS stripe_refunds (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stripe_refund_id VARCHAR(255) UNIQUE,
fk_payment_intent INT(10) UNSIGNED NOT NULL,
amount INT NOT NULL COMMENT 'Montant remboursé en centimes',
reason VARCHAR(100) COMMENT 'duplicate, fraudulent, requested_by_customer',
status VARCHAR(50),
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (fk_payment_intent) REFERENCES stripe_payment_intents(id) ON DELETE CASCADE,
INDEX idx_fk_payment_intent (fk_payment_intent),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour les webhooks reçus (pour éviter les doublons et debug)
CREATE TABLE IF NOT EXISTS stripe_webhooks (
id INT(10) UNSIGNED PRIMARY KEY AUTO_INCREMENT,
stripe_event_id VARCHAR(255) UNIQUE,
event_type VARCHAR(100),
livemode BOOLEAN DEFAULT FALSE,
payload JSON,
processed BOOLEAN DEFAULT FALSE,
error_message TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
INDEX idx_event_type (event_type),
INDEX idx_processed (processed),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insertion des appareils Android certifiés pour Tap to Pay en France
INSERT INTO stripe_android_certified_devices (manufacturer, model, model_identifier, tap_to_pay_certified, min_android_version, certification_date) VALUES
-- Samsung
('Samsung', 'Galaxy S21', 'SM-G991B', TRUE, 11, '2023-01-01'),
('Samsung', 'Galaxy S21+', 'SM-G996B', TRUE, 11, '2023-01-01'),
('Samsung', 'Galaxy S21 Ultra', 'SM-G998B', TRUE, 11, '2023-01-01'),
('Samsung', 'Galaxy S22', 'SM-S901B', TRUE, 12, '2023-01-01'),
('Samsung', 'Galaxy S22+', 'SM-S906B', TRUE, 12, '2023-01-01'),
('Samsung', 'Galaxy S22 Ultra', 'SM-S908B', TRUE, 12, '2023-01-01'),
('Samsung', 'Galaxy S23', 'SM-S911B', TRUE, 13, '2023-06-01'),
('Samsung', 'Galaxy S23+', 'SM-S916B', TRUE, 13, '2023-06-01'),
('Samsung', 'Galaxy S23 Ultra', 'SM-S918B', TRUE, 13, '2023-06-01'),
('Samsung', 'Galaxy S24', 'SM-S921B', TRUE, 14, '2024-01-01'),
('Samsung', 'Galaxy S24+', 'SM-S926B', TRUE, 14, '2024-01-01'),
('Samsung', 'Galaxy S24 Ultra', 'SM-S928B', TRUE, 14, '2024-01-01'),
-- Google Pixel
('Google', 'Pixel 6', 'oriole', TRUE, 12, '2023-01-01'),
('Google', 'Pixel 6 Pro', 'raven', TRUE, 12, '2023-01-01'),
('Google', 'Pixel 6a', 'bluejay', TRUE, 12, '2023-03-01'),
('Google', 'Pixel 7', 'panther', TRUE, 13, '2023-03-01'),
('Google', 'Pixel 7 Pro', 'cheetah', TRUE, 13, '2023-03-01'),
('Google', 'Pixel 7a', 'lynx', TRUE, 13, '2023-06-01'),
('Google', 'Pixel 8', 'shiba', TRUE, 14, '2023-10-01'),
('Google', 'Pixel 8 Pro', 'husky', TRUE, 14, '2023-10-01'),
('Google', 'Pixel Fold', 'felix', TRUE, 13, '2023-07-01'),
-- OnePlus
('OnePlus', '9', 'LE2113', TRUE, 11, '2023-03-01'),
('OnePlus', '9 Pro', 'LE2123', TRUE, 11, '2023-03-01'),
('OnePlus', '10 Pro', 'NE2213', TRUE, 12, '2023-06-01'),
('OnePlus', '11', 'CPH2449', TRUE, 13, '2023-09-01'),
-- Xiaomi
('Xiaomi', 'Mi 11', 'M2011K2G', TRUE, 11, '2023-06-01'),
('Xiaomi', '12', '2201123G', TRUE, 12, '2023-09-01'),
('Xiaomi', '12 Pro', '2201122G', TRUE, 12, '2023-09-01'),
('Xiaomi', '13', '2211133G', TRUE, 13, '2024-01-01'),
('Xiaomi', '13 Pro', '2210132G', TRUE, 13, '2024-01-01');
-- Vue pour faciliter les requêtes de statistiques
CREATE OR REPLACE VIEW v_stripe_payment_stats AS
SELECT
spi.fk_entite,
e.encrypted_name AS entite_name,
spi.fk_user,
u.encrypted_name AS user_nom,
u.first_name AS user_prenom,
COUNT(CASE WHEN spi.status = 'succeeded' THEN 1 END) as total_ventes,
SUM(CASE WHEN spi.status = 'succeeded' THEN spi.amount ELSE 0 END) as total_montant,
SUM(CASE WHEN spi.status = 'succeeded' THEN spi.application_fee ELSE 0 END) as total_commissions,
DATE(spi.created_at) as date_vente
FROM stripe_payment_intents spi
LEFT JOIN entites e ON spi.fk_entite = e.id
LEFT JOIN users u ON spi.fk_user = u.id
GROUP BY spi.fk_entite, spi.fk_user, DATE(spi.created_at);
-- Vue pour le dashboard des amicales
CREATE OR REPLACE VIEW v_stripe_amicale_dashboard AS
SELECT
sa.fk_entite,
e.encrypted_name AS entite_name,
sa.stripe_account_id,
sa.charges_enabled,
sa.payouts_enabled,
COUNT(DISTINCT spi.id) as total_transactions,
SUM(CASE WHEN spi.status = 'succeeded' THEN spi.amount ELSE 0 END) as total_revenus,
SUM(CASE WHEN spi.status = 'succeeded' THEN spi.application_fee ELSE 0 END) as total_frais_plateforme,
MAX(spi.created_at) as derniere_transaction
FROM stripe_accounts sa
LEFT JOIN entites e ON sa.fk_entite = e.id
LEFT JOIN stripe_payment_intents spi ON sa.fk_entite = spi.fk_entite
GROUP BY sa.fk_entite, sa.stripe_account_id;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,473 @@
# TODO - Isolation complète des opérations
## 🎯 Objectif
Mettre en place une **isolation complète par opération** où chaque opération est totalement autonome et peut être supprimée indépendamment sans impacter les autres opérations ou la table centrale `users`.
## 📊 Architecture cible
```
operations (id: 850)
├── ope_users (id: 2500, fk_operation: 850, fk_user: 100)
│ ├── ope_users_sectors (fk_user: 2500 ← ope_users.id, fk_sector: 5400)
│ └── ope_pass (fk_user: 2500 ← ope_users.id, fk_sector: 5400)
└── ope_sectors (id: 5400, fk_operation: 850)
users (id: 100) ← table centrale (conservée même si opération supprimée)
```
---
## ✅ Tâche 1 : Modification du schéma SQL
### 📁 Fichier : `scripts/orga/fix_fk_constraints.sql`
### Actions
- [ ] **1.1** Tester le script SQL sur **dva_geo** (DEV)
```bash
incus exec dva-geo -- mysql rca_geo < /var/www/geosector/api/scripts/orga/fix_fk_constraints.sql
```
- [ ] **1.2** Vérifier les contraintes après exécution :
```sql
SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = 'rca_geo'
AND TABLE_NAME IN ('ope_users_sectors', 'ope_pass')
AND COLUMN_NAME = 'fk_user';
```
Résultat attendu :
- `ope_users_sectors.fk_user → ope_users.id`
- `ope_pass.fk_user → ope_users.id`
- [ ] **1.3** Appliquer sur **rca_geo** (RECETTE) après validation sur dva_geo
- [ ] **1.4** Appliquer sur **pra_geo** (PRODUCTION) après validation sur rca_geo
### ⚠️ Important
- Les données existantes doivent être **nettoyées avant** d'appliquer le script
- Ou bien : recréer toutes les données avec la nouvelle migration
- Les FK `ON DELETE CASCADE` supprimeront automatiquement `ope_users_sectors` et `ope_pass` quand `ope_users` est supprimé
---
## ✅ Tâche 2 : Correction du script de migration2
### 📁 Fichiers concernés
1. `scripts/migration2/php/lib/SectorMigrator.php`
2. `scripts/migration2/php/lib/PassageMigrator.php`
### Actions
#### 2.1 SectorMigrator.php - Migration de ope_users_sectors
- [ ] **Ligne 253** : Changer de `users.id` vers `ope_users.id`
```php
// ❌ AVANT
':fk_user' => $us['fk_user'], // ID de users (table centrale)
// ✅ APRÈS
':fk_user' => $userMapping[$us['fk_user']], // ID de ope_users (mapping)
```
#### 2.2 PassageMigrator.php - Migration de ope_pass
- [ ] **Ligne 64-67** : Vérifier le mapping existe
- [ ] **Ligne 77** : Passer `ope_users.id` au lieu de `users.id`
```php
// ❌ AVANT (ligne 77)
$newPassId = $this->insertPassage($passage, $newOperationId, $newOpeSectorId, $passage['fk_user']);
// ✅ APRÈS
$newOpeUserId = $userMapping[$passage['fk_user']];
$newPassId = $this->insertPassage($passage, $newOperationId, $newOpeSectorId, $newOpeUserId);
```
- [ ] **Ligne 164** : Utiliser le paramètre `$userId` qui sera maintenant `ope_users.id`
```php
// ❌ AVANT
':fk_user' => $userId, // ID de users (table centrale)
// ✅ APRÈS (le paramètre $userId contiendra déjà ope_users.id)
':fk_user' => $userId, // ID de ope_users
```
- [ ] **Ligne 71** : Corriger `verifyUserSectorAssociation` pour vérifier avec `ope_users.id`
```php
// ❌ AVANT
if (!$this->verifyUserSectorAssociation($newOperationId, $passage['fk_user'], $newOpeSectorId)) {
// ✅ APRÈS
if (!$this->verifyUserSectorAssociation($newOperationId, $newOpeUserId, $newOpeSectorId)) {
```
#### 2.3 Tester la migration complète
- [ ] **Sur dva_geo** : Vider les données d'une entité et relancer la migration
```bash
php php/migrate_from_backup.php --mode=entity --entity-id=5
```
- [ ] **Vérifier** dans la base que :
- `ope_users_sectors.fk_user` contient des IDs de `ope_users.id`
- `ope_pass.fk_user` contient des IDs de `ope_users.id`
- Les valeurs correspondent bien au mapping
- [ ] **Vérifier** qu'on peut supprimer une opération et que tout part avec (CASCADE)
```sql
DELETE FROM operations WHERE id = 850;
-- Doit supprimer automatiquement :
-- - ope_users (ON DELETE CASCADE depuis operations)
-- - ope_users_sectors (ON DELETE CASCADE depuis ope_users)
-- - ope_pass (ON DELETE CASCADE depuis ope_users)
-- - ope_sectors (ON DELETE CASCADE depuis operations)
```
---
## ✅ Tâche 3 : Vérifications API
### Impact sur les endpoints API
#### 3.1 Vérifier les requêtes utilisant `ope_pass.fk_user`
- [ ] **Rechercher** tous les endpoints qui lisent `ope_pass.fk_user`
```bash
grep -r "ope_pass.*fk_user" src/Controllers/
grep -r "fk_user.*ope_pass" src/Controllers/
```
- [ ] **Vérifier** que ces endpoints :
- Font-ils des JOIN avec `users` via `ope_pass.fk_user` ?
- Si OUI : Ajouter un JOIN via `ope_users` :
```sql
-- ❌ AVANT
SELECT op.*, u.encrypted_name
FROM ope_pass op
JOIN users u ON op.fk_user = u.id
-- ✅ APRÈS
SELECT op.*, u.encrypted_name
FROM ope_pass op
JOIN ope_users ou ON op.fk_user = ou.id
JOIN users u ON ou.fk_user = u.id
```
#### 3.2 Vérifier les requêtes utilisant `ope_users_sectors.fk_user`
- [ ] **Rechercher** tous les endpoints qui lisent `ope_users_sectors.fk_user`
```bash
grep -r "ope_users_sectors.*fk_user" src/Controllers/
```
- [ ] **Vérifier** la même chose : si JOIN avec `users`, ajouter passage par `ope_users`
#### 3.3 Endpoints probablement concernés
À vérifier :
- [ ] `OperationController` - Liste des utilisateurs d'une opération
- [ ] `PassageController` - Liste/détails des passages
- [ ] `SectorController` - Liste des secteurs avec utilisateurs affectés
- [ ] Tout endpoint retournant des statistiques par utilisateur
---
## ✅ Tâche 4 : Corrections API - Response JSON Login
### Impact sur la réponse JSON du login
#### 4.1 Groupe `users_sectors` - Ajouter `ope_user_id`
**Problème identifié** : Flutter reçoit `users_sectors` avec `id` (users.id) mais les `passages` ont `fk_user` (ope_users.id). Le mapping est impossible.
**Solution** : Modifier la requête dans `LoginController.php` (lignes 426 et 1181) pour retourner les deux IDs :
```sql
-- ✅ APRÈS
SELECT DISTINCT
u.id as user_id, -- users.id (table centrale, pour gestion membres)
ou.id as ope_user_id, -- ope_users.id (pour lier avec passages/sectors)
ou.first_name,
u.encrypted_name,
u.sect_name,
us.fk_sector
FROM users u
JOIN ope_users ou ON u.id = ou.fk_user
JOIN ope_users_sectors us ON ou.id = us.fk_user AND ou.fk_operation = us.fk_operation
WHERE us.fk_sector IN ($sectorIdsString)
AND us.fk_operation = ?
AND us.chk_active = 1
AND u.chk_active = 1
AND u.id != ?
```
**Résultat JSON attendu** :
```json
{
"user_id": 123, // users.id (pour gestion des membres dans l'interface)
"ope_user_id": 50, // ope_users.id (pour lier avec passages.fk_user et sectors)
"first_name": "Jane",
"name": "Jane Smith",
"sect_name": "Smith",
"fk_sector": 456
}
```
**Usage Flutter** :
```dart
// Trouver les passages d'un utilisateur
passages.where((p) => p.fkUser == usersSectors[i].opeUserId) // ✅ OK
```
- [ ] **Modifier** `LoginController.php` ligne 426 (méthode `login()`)
- [ ] **Modifier** `LoginController.php` ligne 1181 (méthode `checkSession()`)
- [ ] **Tester** la réponse JSON du login en mode admin
---
## ✅ Tâche 5 : Vérifications Flutter - Gestion des IDs
### Impact sur l'application mobile
#### 5.1 Modèles de données
- [ ] **Vérifier** le modèle `UserSector` (ou équivalent)
- Ajouter le champ `opeUserId` (int) pour stocker `ope_users.id`
- Conserver `userId` (int) pour stocker `users.id`
- [ ] **Vérifier** le modèle `Passage` (ou équivalent)
- Le champ `fkUser` pointe maintenant vers `ope_users.id`
#### 5.2 Gestion des secteurs (Mode Admin)
- [ ] **Création de secteur**
- L'API crée dans `ope_sectors`
- Attribution des users : utiliser `ope_user_id` (pas `user_id`)
- Endpoint : `POST /api/sectors`
- Body : `{ ..., users: [50, 51, 52] }` ← IDs de `ope_users`
- [ ] **Modification de secteur**
- Attribution des users : utiliser `ope_user_id`
- Endpoint : `PUT /api/sectors/:id`
- Body : `{ ..., users: [50, 51, 52] }` ← IDs de `ope_users`
- [ ] **Suppression de secteur**
- L'API supprime dans `ope_pass`, `ope_users_sectors` et `ope_sectors`
- CASCADE gère automatiquement les dépendances
- Endpoint : `DELETE /api/sectors/:id`
#### 5.3 Gestion des membres (Mode Admin)
- [ ] **Création de membre**
- L'API crée dans `users` (table centrale)
- L'API crée aussi dans `ope_users` pour l'opération active
- **Réponse attendue** :
```json
{
"status": "success",
"user": {
"id": 123, // users.id
"ope_user_id": 50, // ope_users.id (nouveau)
"first_name": "John",
"name": "John Doe",
...
}
}
```
- Endpoint : `POST /api/users`
- Flutter stocke les 2 IDs : `userId` et `opeUserId`
- [ ] **Modification de membre**
- L'API met à jour `users` (table centrale)
- L'API met à jour aussi `ope_users` pour l'opération active
- Endpoint : `PUT /api/users/:id`
- [ ] **Suppression de membre**
- L'API supprime de `ope_users` (opération active)
- L'API supprime de `users` (table centrale)
- CASCADE supprime automatiquement `ope_users_sectors` et `ope_pass`
- Endpoint : `DELETE /api/users/:id?transfer_to=XX`
#### 5.4 Gestion des passages (Mode Admin & User)
- [ ] **Création de passage**
- Attribution automatique du `ope_sectors.id` le plus proche
- Attribution du `ope_users.id` (utilisateur connecté ou sélectionné)
- Endpoint : `POST /api/passages`
- Body : `{ ..., fk_user: 50, fk_sector: 456 }` ← IDs de `ope_users` et `ope_sectors`
- [ ] **Modification de passage**
- Attribution du `ope_users.id` si changement d'utilisateur
- Endpoint : `PUT /api/passages/:id`
- Body : `{ ..., fk_user: 50 }` ← ID de `ope_users`
- [ ] **Suppression de passage**
- L'API supprime dans `ope_pass`
- Endpoint : `DELETE /api/passages/:id`
#### 5.5 Interface Flutter - Mapping des IDs
**Scénarios à gérer** :
1. **Affichage des secteurs avec utilisateurs affectés** :
```dart
// Utiliser usersSectors[i].opeUserId pour lier avec passages
final userPassages = passages.where((p) =>
p.fkUser == usersSectors[i].opeUserId &&
p.fkSector == sector.id
).toList();
```
2. **Attribution d'un passage à un utilisateur** :
```dart
// Envoyer ope_user_id dans la requête API
await apiService.createPassage({
...passageData,
'fk_user': userSector.opeUserId, // ope_users.id
'fk_sector': sector.id
});
```
3. **Affichage du nom d'un utilisateur depuis un passage** :
```dart
// Chercher dans usersSectors avec ope_user_id
final userSector = usersSectors.firstWhere(
(us) => us.opeUserId == passage.fkUser,
orElse: () => null
);
final userName = userSector?.name ?? 'Inconnu';
```
4. **Gestion des membres** :
```dart
// Conserver les 2 IDs lors de la création
final newMember = await apiService.createUser(userData);
membres.add(Member(
userId: newMember['id'], // users.id
opeUserId: newMember['ope_user_id'], // ope_users.id
...
));
```
#### 5.6 Tests d'affichage
- [ ] Tester l'affichage des passages avec noms d'utilisateurs
- [ ] Tester l'affichage des secteurs avec utilisateurs affectés
- [ ] Tester la création d'un membre (vérifier que les 2 IDs sont reçus)
- [ ] Tester la suppression d'un membre (vérifier le transfert de passages)
- [ ] Tester la création d'un secteur avec attribution d'utilisateurs
- [ ] Tester la création d'un passage avec attribution d'utilisateur
- [ ] Tester la suppression d'une opération (doit tout nettoyer)
---
## 📋 Ordre d'exécution recommandé
1. ✅ **Corriger le code de migration2** (PHP)
2. ✅ **Tester sur dva_geo** avec schéma modifié
3. ✅ **Vérifier l'API** sur dva_geo
4. ✅ **Vérifier Flutter** avec dva_geo
5. 🚀 **Déployer le schéma SQL** sur rca_geo
6. 🚀 **Déployer le code** sur rca_geo
7. ✅ **Tester en recette**
8. 🚀 **Déployer en production** (pra_geo)
---
## 🔍 Requêtes SQL utiles pour vérification
### Vérifier les contraintes FK actuelles
```sql
SELECT
TABLE_NAME,
COLUMN_NAME,
CONSTRAINT_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND (TABLE_NAME = 'ope_pass' OR TABLE_NAME = 'ope_users_sectors')
AND COLUMN_NAME = 'fk_user';
```
### Vérifier l'intégrité des données après migration
```sql
-- Vérifier que tous les fk_user de ope_pass existent dans ope_users
SELECT COUNT(*) as orphans
FROM ope_pass op
LEFT JOIN ope_users ou ON op.fk_user = ou.id
WHERE ou.id IS NULL;
-- Résultat attendu : 0
-- Vérifier que tous les fk_user de ope_users_sectors existent dans ope_users
SELECT COUNT(*) as orphans
FROM ope_users_sectors ous
LEFT JOIN ope_users ou ON ous.fk_user = ou.id
WHERE ou.id IS NULL;
-- Résultat attendu : 0
```
### Tester la suppression en cascade
```sql
-- Compter avant suppression
SELECT
(SELECT COUNT(*) FROM ope_users WHERE fk_operation = 850) as ope_users_count,
(SELECT COUNT(*) FROM ope_users_sectors WHERE fk_operation = 850) as ope_users_sectors_count,
(SELECT COUNT(*) FROM ope_pass WHERE fk_operation = 850) as ope_pass_count,
(SELECT COUNT(*) FROM ope_sectors WHERE fk_operation = 850) as ope_sectors_count;
-- Supprimer l'opération
DELETE FROM operations WHERE id = 850;
-- Vérifier que tout a été supprimé (doit retourner 0 partout)
SELECT
(SELECT COUNT(*) FROM ope_users WHERE fk_operation = 850) as ope_users_count,
(SELECT COUNT(*) FROM ope_users_sectors WHERE fk_operation = 850) as ope_users_sectors_count,
(SELECT COUNT(*) FROM ope_pass WHERE fk_operation = 850) as ope_pass_count,
(SELECT COUNT(*) FROM ope_sectors WHERE fk_operation = 850) as ope_sectors_count;
```
---
## 📝 Notes importantes
### Avantages de cette architecture
✅ **Isolation complète** : Supprimer une opération supprime tout (ope_users, secteurs, passages)
✅ **Performance** : Pas de jointures complexes avec la table centrale `users`
✅ **Historique** : Les données d'une opération sont figées dans le temps
✅ **Simplicité** : Requêtes plus simples, moins de risques d'incohérences
### Implications
⚠️ **Duplication** : Un utilisateur travaillant sur 3 opérations aura 3 entrées dans `ope_users`
⚠️ **Taille** : La table `ope_users` sera plus volumineuse
⚠️ **Jointures** : Pour remonter aux infos de la table `users`, il faut passer par `ope_users.fk_user`
### Rétrocompatibilité
❌ Ce changement **CASSE** la compatibilité avec les données existantes
✅ Nécessite une **re-migration complète** de toutes les entités après modification du schéma
✅ Ou bien : script de transformation des données existantes (plus complexe)
---
## 🎯 Statut
- [ ] Schéma SQL modifié sur dva_geo
- [ ] Code migration2 corrigé
- [ ] API vérifiée et corrigée
- [ ] Flutter vérifié et corrigé
- [ ] Tests complets sur dva_geo
- [ ] Déploiement rca_geo
- [ ] Déploiement pra_geo

View File

@@ -0,0 +1,65 @@
-- ================================================================================
-- Script de migration : Correction des contraintes FK pour isolation par opération
-- ================================================================================
--
-- Ce script modifie les contraintes de clés étrangères pour que :
-- - ope_users_sectors.fk_user → pointe vers ope_users.id (au lieu de users.id)
-- - ope_pass.fk_user → pointe vers ope_users.id (au lieu de users.id)
--
-- Cela permet une isolation complète des opérations : supprimer une opération
-- supprime automatiquement tous ses ope_users, ope_sectors, ope_users_sectors et ope_pass.
--
-- ORDRE D'EXÉCUTION :
-- 1. dva_geo (DEV) - test
-- 2. rca_geo (RECETTE)
-- 3. pra_geo (PRODUCTION)
--
-- ================================================================================
USE dva_geo; -- Adapter selon l'environnement (dva_geo, rca_geo, pra_geo)
-- ================================================================================
-- 1. Modification de ope_users_sectors.fk_user
-- ================================================================================
-- Supprimer l'ancienne contrainte FK
ALTER TABLE ope_users_sectors
DROP FOREIGN KEY ope_users_sectors_ibfk_2;
-- Recréer la contrainte FK vers ope_users.id
ALTER TABLE ope_users_sectors
ADD CONSTRAINT ope_users_sectors_ibfk_2
FOREIGN KEY (fk_user) REFERENCES ope_users (id) ON DELETE CASCADE ON UPDATE CASCADE;
-- ================================================================================
-- 2. Modification de ope_pass.fk_user
-- ================================================================================
-- Supprimer l'ancienne contrainte FK
ALTER TABLE ope_pass
DROP FOREIGN KEY ope_pass_ibfk_3;
-- Recréer la contrainte FK vers ope_users.id
ALTER TABLE ope_pass
ADD CONSTRAINT ope_pass_ibfk_3
FOREIGN KEY (fk_user) REFERENCES ope_users (id) ON DELETE CASCADE ON UPDATE CASCADE;
-- ================================================================================
-- Vérification finale
-- ================================================================================
SELECT
TABLE_NAME,
COLUMN_NAME,
CONSTRAINT_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('ope_users_sectors', 'ope_pass')
AND COLUMN_NAME = 'fk_user'
ORDER BY TABLE_NAME;
-- Résultat attendu :
-- ope_pass | fk_user | ope_pass_ibfk_3 | ope_users | id
-- ope_users_sectors | fk_user | ope_users_sectors_ibfk_2 | ope_users | id

View File

@@ -0,0 +1,121 @@
-- ================================================================================
-- Script de migration SÉCURISÉ : Correction des contraintes FK pour isolation par opération
-- ================================================================================
--
-- Ce script modifie les contraintes de clés étrangères pour que :
-- - ope_users_sectors.fk_user → pointe vers ope_users.id (au lieu de users.id)
-- - ope_pass.fk_user → pointe vers ope_users.id (au lieu de users.id)
--
-- Version SÉCURISÉE : Vérifie l'existence des contraintes avant de les supprimer
--
-- ================================================================================
USE dva_geo;
-- ================================================================================
-- Afficher les contraintes FK actuelles
-- ================================================================================
SELECT
TABLE_NAME,
COLUMN_NAME,
CONSTRAINT_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = 'dva_geo'
AND TABLE_NAME IN ('ope_users_sectors', 'ope_pass')
AND COLUMN_NAME = 'fk_user'
ORDER BY TABLE_NAME;
-- ================================================================================
-- 1. Modification de ope_users_sectors.fk_user
-- ================================================================================
-- Supprimer l'ancienne contrainte FK si elle existe
SET @constraint_exists = (
SELECT COUNT(*)
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = 'dva_geo'
AND TABLE_NAME = 'ope_users_sectors'
AND COLUMN_NAME = 'fk_user'
AND CONSTRAINT_NAME LIKE '%ibfk%'
);
SET @sql = IF(@constraint_exists > 0,
CONCAT('ALTER TABLE ope_users_sectors DROP FOREIGN KEY ',
(SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = 'dva_geo'
AND TABLE_NAME = 'ope_users_sectors'
AND COLUMN_NAME = 'fk_user'
AND CONSTRAINT_NAME LIKE '%ibfk%'
LIMIT 1)),
'SELECT "Aucune contrainte FK à supprimer sur ope_users_sectors" AS message'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Recréer la contrainte FK vers ope_users.id
ALTER TABLE ope_users_sectors
ADD CONSTRAINT ope_users_sectors_ibfk_2
FOREIGN KEY (fk_user) REFERENCES ope_users (id) ON DELETE CASCADE ON UPDATE CASCADE;
-- ================================================================================
-- 2. Modification de ope_pass.fk_user
-- ================================================================================
-- Supprimer l'ancienne contrainte FK si elle existe
SET @constraint_exists = (
SELECT COUNT(*)
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = 'dva_geo'
AND TABLE_NAME = 'ope_pass'
AND COLUMN_NAME = 'fk_user'
AND CONSTRAINT_NAME LIKE '%ibfk%'
);
SET @sql = IF(@constraint_exists > 0,
CONCAT('ALTER TABLE ope_pass DROP FOREIGN KEY ',
(SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = 'dva_geo'
AND TABLE_NAME = 'ope_pass'
AND COLUMN_NAME = 'fk_user'
AND CONSTRAINT_NAME LIKE '%ibfk%'
LIMIT 1)),
'SELECT "Aucune contrainte FK à supprimer sur ope_pass" AS message'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Recréer la contrainte FK vers ope_users.id
ALTER TABLE ope_pass
ADD CONSTRAINT ope_pass_ibfk_3
FOREIGN KEY (fk_user) REFERENCES ope_users (id) ON DELETE CASCADE ON UPDATE CASCADE;
-- ================================================================================
-- Vérification finale
-- ================================================================================
SELECT
TABLE_NAME,
COLUMN_NAME,
CONSTRAINT_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = 'dva_geo'
AND TABLE_NAME IN ('ope_users_sectors', 'ope_pass')
AND COLUMN_NAME = 'fk_user'
ORDER BY TABLE_NAME;
-- Résultat attendu :
-- ope_pass | fk_user | ope_pass_ibfk_3 | ope_users | id
-- ope_users_sectors | fk_user | ope_users_sectors_ibfk_2 | ope_users | id
SELECT '✓ Contraintes FK modifiées avec succès !' AS status;

View File

@@ -0,0 +1,93 @@
-- ================================================================================
-- Script de nettoyage complet des tables - DVA_GEO
-- ================================================================================
--
-- Ce script vide toutes les tables pour repartir à zéro.
-- ATTENTION : Toutes les données seront perdues !
--
-- Usage : À exécuter sur dva_geo UNIQUEMENT (environnement de développement)
--
-- ================================================================================
USE dva_geo;
-- Désactiver temporairement les vérifications de clés étrangères
SET FOREIGN_KEY_CHECKS = 0;
-- ================================================================================
-- 1. Tables dépendantes (dans l'ordre des dépendances)
-- ================================================================================
TRUNCATE TABLE ope_pass_histo;
TRUNCATE TABLE ope_pass;
TRUNCATE TABLE ope_users_sectors;
TRUNCATE TABLE sectors_adresses;
TRUNCATE TABLE ope_sectors;
TRUNCATE TABLE ope_users;
TRUNCATE TABLE medias;
TRUNCATE TABLE operations;
-- ================================================================================
-- 2. Tables liées aux utilisateurs
-- ================================================================================
TRUNCATE TABLE user_devices;
-- ================================================================================
-- 3. Tables de chat
-- ================================================================================
TRUNCATE TABLE chat_messages;
TRUNCATE TABLE chat_participants;
TRUNCATE TABLE chat_read_receipts;
TRUNCATE TABLE chat_rooms;
-- ================================================================================
-- 4. Tables principales
-- ================================================================================
TRUNCATE TABLE users;
TRUNCATE TABLE entites;
-- Réactiver les vérifications de clés étrangères
SET FOREIGN_KEY_CHECKS = 1;
-- ================================================================================
-- Vérification : Compter les lignes restantes
-- ================================================================================
SELECT
'ope_pass_histo' AS table_name, COUNT(*) AS rows_count FROM ope_pass_histo
UNION ALL
SELECT 'ope_pass', COUNT(*) FROM ope_pass
UNION ALL
SELECT 'ope_users_sectors', COUNT(*) FROM ope_users_sectors
UNION ALL
SELECT 'sectors_adresses', COUNT(*) FROM sectors_adresses
UNION ALL
SELECT 'ope_sectors', COUNT(*) FROM ope_sectors
UNION ALL
SELECT 'ope_users', COUNT(*) FROM ope_users
UNION ALL
SELECT 'medias', COUNT(*) FROM medias
UNION ALL
SELECT 'operations', COUNT(*) FROM operations
UNION ALL
SELECT 'user_devices', COUNT(*) FROM user_devices
UNION ALL
SELECT 'chat_messages', COUNT(*) FROM chat_messages
UNION ALL
SELECT 'chat_participants', COUNT(*) FROM chat_participants
UNION ALL
SELECT 'chat_read_receipts', COUNT(*) FROM chat_read_receipts
UNION ALL
SELECT 'chat_rooms', COUNT(*) FROM chat_rooms
UNION ALL
SELECT 'users', COUNT(*) FROM users
UNION ALL
SELECT 'entites', COUNT(*) FROM entites
ORDER BY table_name;
-- Résultat attendu : 0 partout
SELECT '✓ Toutes les tables ont été vidées avec succès !' AS status;

View File

@@ -0,0 +1,150 @@
-- ================================================================================
-- Script de vérification : Isolation complète des opérations
-- ================================================================================
--
-- Ce script vérifie que l'isolation par opération fonctionne correctement
--
-- ================================================================================
USE dva_geo;
-- ================================================================================
-- 1. Vérifier les contraintes FK
-- ================================================================================
SELECT '=== VÉRIFICATION DES CONTRAINTES FK ===' AS '';
SELECT
TABLE_NAME,
COLUMN_NAME,
CONSTRAINT_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = 'dva_geo'
AND TABLE_NAME IN ('ope_users_sectors', 'ope_pass')
AND COLUMN_NAME = 'fk_user'
ORDER BY TABLE_NAME;
-- Résultat attendu :
-- ope_pass | fk_user | ope_pass_ibfk_3 | ope_users | id
-- ope_users_sectors | fk_user | ope_users_sectors_ibfk_2 | ope_users | id
-- ================================================================================
-- 2. Vérifier l'intégrité des données (pas d'orphelins)
-- ================================================================================
SELECT '=== VÉRIFICATION INTÉGRITÉ DES DONNÉES ===' AS '';
-- Vérifier que tous les fk_user de ope_pass existent dans ope_users
SELECT
'ope_pass → ope_users' AS verification,
COUNT(*) as orphelins
FROM ope_pass op
LEFT JOIN ope_users ou ON op.fk_user = ou.id
WHERE ou.id IS NULL;
-- Résultat attendu : 0
-- Vérifier que tous les fk_user de ope_users_sectors existent dans ope_users
SELECT
'ope_users_sectors → ope_users' AS verification,
COUNT(*) as orphelins
FROM ope_users_sectors ous
LEFT JOIN ope_users ou ON ous.fk_user = ou.id
WHERE ou.id IS NULL;
-- Résultat attendu : 0
-- ================================================================================
-- 3. Statistiques de migration
-- ================================================================================
SELECT '=== STATISTIQUES DE MIGRATION ===' AS '';
-- Nombre d'entités
SELECT 'Entités' AS table_name, COUNT(*) AS count FROM entites
UNION ALL
-- Nombre d'opérations
SELECT 'Opérations' AS table_name, COUNT(*) AS count FROM operations
UNION ALL
-- Nombre d'utilisateurs dans la table centrale
SELECT 'Users (centrale)' AS table_name, COUNT(*) AS count FROM users
UNION ALL
-- Nombre d'utilisateurs dans les opérations
SELECT 'ope_users' AS table_name, COUNT(*) AS count FROM ope_users
UNION ALL
-- Nombre de secteurs
SELECT 'ope_sectors' AS table_name, COUNT(*) AS count FROM ope_sectors
UNION ALL
-- Nombre d'associations user-secteur
SELECT 'ope_users_sectors' AS table_name, COUNT(*) AS count FROM ope_users_sectors
UNION ALL
-- Nombre de passages
SELECT 'ope_pass' AS table_name, COUNT(*) AS count FROM ope_pass
UNION ALL
-- Nombre d'historiques de passage
SELECT 'ope_pass_histo' AS table_name, COUNT(*) AS count FROM ope_pass_histo;
-- ================================================================================
-- 4. Détail par opération
-- ================================================================================
SELECT '=== DÉTAIL PAR OPÉRATION ===' AS '';
SELECT
o.id AS operation_id,
o.libelle AS operation_name,
(SELECT COUNT(*) FROM ope_users WHERE fk_operation = o.id) AS nb_users,
(SELECT COUNT(*) FROM ope_sectors WHERE fk_operation = o.id) AS nb_sectors,
(SELECT COUNT(*) FROM ope_users_sectors WHERE fk_operation = o.id) AS nb_user_sector_links,
(SELECT COUNT(*) FROM ope_pass WHERE fk_operation = o.id) AS nb_passages
FROM operations o
ORDER BY o.id;
-- ================================================================================
-- 5. Vérifier la relation users → ope_users
-- ================================================================================
SELECT '=== RELATION users → ope_users ===' AS '';
SELECT
u.id AS user_id,
u.first_name,
u.sect_name,
COUNT(DISTINCT ou.fk_operation) AS nb_operations,
GROUP_CONCAT(DISTINCT ou.fk_operation ORDER BY ou.fk_operation) AS operations_ids
FROM users u
LEFT JOIN ope_users ou ON u.id = ou.fk_user
GROUP BY u.id, u.first_name, u.sect_name
ORDER BY u.id;
-- ================================================================================
-- 6. TEST DE SUPPRESSION (commenté pour sécurité)
-- ================================================================================
SELECT '=== INSTRUCTIONS POUR TEST DE SUPPRESSION ===' AS '';
SELECT 'Pour tester la suppression en CASCADE, décommentez la section ci-dessous' AS instruction;
-- Compter avant suppression (remplacer [ID_OPERATION] par un ID réel)
/*
SET @operation_id = [ID_OPERATION];
SELECT
CONCAT('Opération ID: ', @operation_id) AS info,
(SELECT COUNT(*) FROM ope_users WHERE fk_operation = @operation_id) as ope_users_count,
(SELECT COUNT(*) FROM ope_users_sectors WHERE fk_operation = @operation_id) as ope_users_sectors_count,
(SELECT COUNT(*) FROM ope_pass WHERE fk_operation = @operation_id) as ope_pass_count,
(SELECT COUNT(*) FROM ope_sectors WHERE fk_operation = @operation_id) as ope_sectors_count;
-- Supprimer l'opération
DELETE FROM operations WHERE id = @operation_id;
-- Vérifier que tout a été supprimé (doit retourner 0 partout)
SELECT
CONCAT('Après suppression de l''opération ID: ', @operation_id) AS info,
(SELECT COUNT(*) FROM ope_users WHERE fk_operation = @operation_id) as ope_users_count,
(SELECT COUNT(*) FROM ope_users_sectors WHERE fk_operation = @operation_id) as ope_users_sectors_count,
(SELECT COUNT(*) FROM ope_pass WHERE fk_operation = @operation_id) as ope_pass_count,
(SELECT COUNT(*) FROM ope_sectors WHERE fk_operation = @operation_id) as ope_sectors_count;
*/
SELECT '✓ Vérifications terminées avec succès !' AS status;

View File

@@ -0,0 +1,182 @@
#!/bin/bash
#
# Script de patch pour adapter migrate_from_backup.php et migrate_batch.sh
# pour fonctionner avec --env=rca|pra et source=geosector
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PHP_SCRIPT="$SCRIPT_DIR/php/migrate_from_backup.php"
BATCH_SCRIPT="$SCRIPT_DIR/migrate_batch.sh"
echo "=== Patching migration scripts ==="
echo ""
# Backup des fichiers originaux
echo "Creating backups..."
cp "$PHP_SCRIPT" "$PHP_SCRIPT.backup"
cp "$BATCH_SCRIPT" "$BATCH_SCRIPT.backup"
echo "✓ Backups created"
echo ""
# ============================================================
# PATCH 1: migrate_from_backup.php - Configuration multi-env
# ============================================================
echo "Patching migrate_from_backup.php..."
# Étape 1: Remplacer les constantes DB par configuration multi-env
sed -i '31,50s/.*/ \/\/ REPLACED BY PATCH - see below/' "$PHP_SCRIPT"
# Insérer la nouvelle configuration après la ligne 38
sed -i '38a\
private $env;\
\
\/\/ Configuration multi-environnement\
private const ENVIRONMENTS = [\
'\''rca'\'' => [\
'\''host'\'' => '\''13.23.33.3'\'', \/\/ maria3 sur IN3\
'\''port'\'' => 3306,\
'\''user'\'' => '\''rca_geo_user'\'',\
'\''pass'\'' => '\''UPf3C0cQ805LypyM71iW'\'',\
'\''target_db'\'' => '\''rca_geo'\'',\
'\''source_db'\'' => '\''geosector'\'' \/\/ Base synchronisée par PM7\
],\
'\''pra'\'' => [\
'\''host'\'' => '\''13.23.33.4'\'', \/\/ maria4 sur IN4\
'\''port'\'' => 3306,\
'\''user'\'' => '\''pra_geo_user'\'',\
'\''pass'\'' => '\''d2jAAGGWi8fxFrWgXjOA'\'',\
'\''target_db'\'' => '\''pra_geo'\'',\
'\''source_db'\'' => '\''geosector'\'' \/\/ Base synchronisée par PM7\
]\
];' "$PHP_SCRIPT"
# Étape 2: Modifier le constructeur pour accepter $env
sed -i 's/public function __construct($sourceDbName, $targetDbName, $mode/public function __construct($env, $mode/' "$PHP_SCRIPT"
# Étape 3: Adapter le corps du constructeur
sed -i '/public function __construct/,/^ }$/{
s/\$this->sourceDbName = \$sourceDbName;/\$this->env = \$env;\n if (!isset(self::ENVIRONMENTS[\$env])) {\n throw new Exception("Invalid environment: \$env. Use '\''rca'\'' or '\''pra'\''");\n }\n \$config = self::ENVIRONMENTS[\$env];\n \$this->sourceDbName = \$config['\''source_db'\''];\n \$this->targetDbName = \$config['\''target_db'\''];/
s/\$this->targetDbName = \$targetDbName;//
s/Source: {\$sourceDbName}/Environment: \$env/
s/Cible: {\$targetDbName}/Source: {\$this->sourceDbName} → Target: {\$this->targetDbName}/
}' "$PHP_SCRIPT"
# Étape 4: Modifier connect() pour utiliser la config de l'env
sed -i '/public function connect()/,/^ }$/{
s/self::DB_HOST/self::ENVIRONMENTS[\$this->env]['\''host'\'']/g
s/self::DB_PORT/self::ENVIRONMENTS[\$this->env]['\''port'\'']/g
s/self::DB_USER_ROOT/self::ENVIRONMENTS[\$this->env]['\''user'\'']/g
s/self::DB_PASS_ROOT/self::ENVIRONMENTS[\$this->env]['\''pass'\'']/g
s/self::DB_USER/self::ENVIRONMENTS[\$this->env]['\''user'\'']/g
s/self::DB_PASS/self::ENVIRONMENTS[\$this->env]['\''pass'\'']/g
}' "$PHP_SCRIPT"
# Étape 5: Modifier parseArguments() - supprimer source-db et target-db, ajouter env
sed -i '/function parseArguments/,/^}$/{
s/'\''source-db'\'' => null,/'\''env'\'' => '\''rca'\'',/
s/'\''target-db'\'' => '\''pra_geo'\'',//
}' "$PHP_SCRIPT"
# Étape 6: Modifier showHelp()
sed -i '/function showHelp/,/^}$/{
s/--source-db=NAME.*\[REQUIS\]/--env=ENV Environment: '\''rca'\'' (recette) ou '\''pra'\'' (production) [défaut: rca]/
s/--target-db=NAME.*/ (supprimé - déduit automatiquement de --env)/
s/--source-db=geosector_20251007/--env=rca/g
s/--target-db=pra_geo//g
s/--target-db=rca_geo//g
}' "$PHP_SCRIPT"
# Étape 7: Modifier la validation des arguments
sed -i '/Validation des arguments/,/exit(1);/{
s/if (!$args\['\''source-db'\''\])/if (!isset(self::ENVIRONMENTS[\$args['\''env'\'']]))/
s/--source-db est requis/--env doit être '\''rca'\'' ou '\''pra'\''/
}' "$PHP_SCRIPT"
# Étape 8: Modifier l'instanciation de BackupMigration
sed -i '/new BackupMigration/,/);/{
s/\$args\['\''source-db'\''\],/\$args['\''env'\''],/
s/\$args\['\''target-db'\''\],//
}' "$PHP_SCRIPT"
echo "✓ migrate_from_backup.php patched"
echo ""
# ============================================================
# PATCH 2: migrate_batch.sh - Adapter pour env rca/pra
# ============================================================
echo "Patching migrate_batch.sh..."
# Étape 1: Détecter l'environnement automatiquement ou via paramètre
sed -i '/# Configuration/a\
\
# Détection automatique de l'\''environnement\
if [ -f "/etc/hostname" ]; then\
CONTAINER_NAME=$(cat /etc/hostname)\
case $CONTAINER_NAME in\
rca-geo)\
ENV="rca"\
;;\
pra-geo)\
ENV="pra"\
;;\
*)\
ENV="rca" # Défaut\
;;\
esac\
else\
ENV="rca" # Défaut\
fi' "$BATCH_SCRIPT"
# Étape 2: Remplacer SOURCE_DB et TARGET_DB
sed -i 's/SOURCE_DB="geosector_20251013_13"/# SOURCE_DB removed - always "geosector" (deduced from --env)/' "$BATCH_SCRIPT"
sed -i 's/TARGET_DB="pra_geo"/# TARGET_DB removed - deduced from --env/' "$BATCH_SCRIPT"
# Étape 3: Ajouter option --env dans le parsing
sed -i '/--interactive|-i)/i\
--env)\
ENV="$2"\
shift 2\
;;' "$BATCH_SCRIPT"
# Étape 4: Modifier les appels à migrate_from_backup.php - ligne 200
sed -i '200,210s/--source-db="\$SOURCE_DB"/--env="$ENV"/' "$BATCH_SCRIPT"
sed -i '200,210s/--target-db="\$TARGET_DB"//' "$BATCH_SCRIPT"
# Étape 5: Modifier les appels dans la boucle - ligne 374
sed -i '374,380s/--source-db="\$SOURCE_DB"/--env="$ENV"/' "$BATCH_SCRIPT"
sed -i '374,380s/--target-db="\$TARGET_DB"//' "$BATCH_SCRIPT"
# Étape 6: Mettre à jour les messages de log
sed -i 's/📁 Source: \$SOURCE_DB/🌍 Environment: $ENV/' "$BATCH_SCRIPT"
sed -i 's/📁 Cible: \$TARGET_DB/📁 Source: geosector → Target: (déduit de $ENV)/' "$BATCH_SCRIPT"
echo "✓ migrate_batch.sh patched"
echo ""
# ============================================================
# Résumé
# ============================================================
echo "=== Patch completed ==="
echo ""
echo "Backups saved:"
echo " - $PHP_SCRIPT.backup"
echo " - $BATCH_SCRIPT.backup"
echo ""
echo "New usage:"
echo " # Sur rca-geo (détection auto)"
echo " ./migrate_batch.sh"
echo ""
echo " # Sur pra-geo avec --env explicite"
echo " ./migrate_batch.sh --env=pra"
echo ""
echo " # Migration d'une entité spécifique"
echo " php php/migrate_from_backup.php --env=rca --mode=entity --entity-id=45"
echo ""
echo "To restore backups:"
echo " cp $PHP_SCRIPT.backup $PHP_SCRIPT"
echo " cp $BATCH_SCRIPT.backup $BATCH_SCRIPT"

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env php
<?php
/**
* Script : Créer les Stripe Terminal Locations manquantes
*
* Raison : Certains comptes Stripe Connect ont été créés avant l'implémentation
* de la création automatique de Location. Ce script crée les Locations
* manquantes pour tous les comptes existants.
*
* Date : 2025-11-03
* Auteur : Migration automatique
*/
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
$hostname = gethostname();
if (strpos($hostname, 'pra') !== false) {
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
} elseif (strpos($hostname, 'rca') !== false) {
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
} else {
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr';
}
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
if (!function_exists('getallheaders')) {
function getallheaders() {
return [];
}
}
}
// Charger l'autoloader Composer (pour Stripe SDK)
require_once dirname(dirname(__DIR__)) . '/vendor/autoload.php';
// Charger les classes nécessaires explicitement
require_once dirname(dirname(__DIR__)) . '/src/Config/AppConfig.php';
require_once dirname(dirname(__DIR__)) . '/src/Core/Database.php';
require_once dirname(dirname(__DIR__)) . '/src/Services/ApiService.php';
require_once dirname(dirname(__DIR__)) . '/src/Services/LogService.php';
require_once dirname(dirname(__DIR__)) . '/src/Services/StripeService.php';
use App\Services\StripeService;
// Initialiser la configuration
$config = AppConfig::getInstance();
$env = $config->getEnvironment();
$dbConfig = $config->getDatabaseConfig();
echo "\n";
echo "=============================================================================\n";
echo " Création des Stripe Terminal Locations manquantes\n";
echo "=============================================================================\n";
echo "Environnement : " . strtoupper($env) . "\n";
echo "Base de données : " . $dbConfig['name'] . "\n";
echo "\n";
try {
// Initialiser la base de données avec la configuration
Database::init($dbConfig);
$db = Database::getInstance();
// StripeService est un singleton
$stripeService = StripeService::getInstance();
// 1. Identifier les comptes sans Location
echo "📋 Recherche des comptes Stripe sans Location...\n\n";
$stmt = $db->query("
SELECT
sa.id,
sa.fk_entite,
sa.stripe_account_id,
sa.stripe_location_id,
e.encrypted_name,
e.adresse1,
e.adresse2,
e.code_postal,
e.ville
FROM stripe_accounts sa
INNER JOIN entites e ON sa.fk_entite = e.id
WHERE sa.stripe_account_id IS NOT NULL
AND (sa.stripe_location_id IS NULL OR sa.stripe_location_id = '')
AND e.chk_active = 1
");
$accountsWithoutLocation = $stmt->fetchAll(PDO::FETCH_ASSOC);
$total = count($accountsWithoutLocation);
if ($total === 0) {
echo "✅ Aucun compte sans Location trouvé. Tous les comptes sont à jour !\n\n";
exit(0);
}
echo " Trouvé $total compte(s) sans Location :\n\n";
foreach ($accountsWithoutLocation as $account) {
$name = !empty($account['encrypted_name'])
? ApiService::decryptData($account['encrypted_name'])
: 'Amicale #' . $account['fk_entite'];
echo " - Entité #{$account['fk_entite']} : $name\n";
echo " Stripe Account : {$account['stripe_account_id']}\n";
echo " Adresse : {$account['adresse1']}, {$account['code_postal']} {$account['ville']}\n\n";
}
// Demander confirmation
echo "⚠️ Voulez-vous créer les Locations manquantes ? (yes/no) : ";
$handle = fopen("php://stdin", "r");
$line = trim(fgets($handle));
fclose($handle);
if ($line !== 'yes') {
echo "❌ Opération annulée.\n\n";
exit(0);
}
echo "\n🚀 Création des Locations...\n\n";
// Initialiser Stripe avec la bonne clé selon le mode
$stripeConfig = $config->getStripeConfig();
$stripeMode = $stripeConfig['mode'] ?? 'test';
$stripeSecretKey = ($stripeMode === 'live')
? $stripeConfig['secret_key_live']
: $stripeConfig['secret_key_test'];
\Stripe\Stripe::setApiKey($stripeSecretKey);
echo " Mode Stripe : " . strtoupper($stripeMode) . "\n\n";
$success = 0;
$errors = 0;
// 2. Créer les Locations manquantes
foreach ($accountsWithoutLocation as $account) {
$entiteId = $account['fk_entite'];
$stripeAccountId = $account['stripe_account_id'];
$name = !empty($account['encrypted_name'])
? ApiService::decryptData($account['encrypted_name'])
: 'Amicale #' . $entiteId;
echo "🔧 Entité #{$entiteId} : $name\n";
try {
// Construire l'adresse
$adresse1 = !empty($account['adresse1']) ? $account['adresse1'] : 'Adresse non renseignée';
$ville = !empty($account['ville']) ? $account['ville'] : 'Ville';
$codePostal = !empty($account['code_postal']) ? $account['code_postal'] : '00000';
// Construire l'adresse pour Stripe (ne pas envoyer line2 si vide)
$addressData = [
'line1' => $adresse1,
'city' => $ville,
'postal_code' => $codePostal,
'country' => 'FR',
];
// Ajouter line2 seulement s'il n'est pas vide
if (!empty($account['adresse2'])) {
$addressData['line2'] = $account['adresse2'];
}
// Créer la Location via Stripe API
$location = \Stripe\Terminal\Location::create([
'display_name' => $name,
'address' => $addressData,
'metadata' => [
'entite_id' => $entiteId,
'type' => 'tap_to_pay',
'created_by' => 'migration_script'
]
], [
'stripe_account' => $stripeAccountId
]);
$locationId = $location->id;
// Mettre à jour la base de données
$updateStmt = $db->prepare("
UPDATE stripe_accounts
SET stripe_location_id = :location_id,
updated_at = NOW()
WHERE id = :id
");
$updateStmt->execute([
'location_id' => $locationId,
'id' => $account['id']
]);
echo " ✅ Location créée : $locationId\n\n";
$success++;
} catch (\Stripe\Exception\ApiErrorException $e) {
echo " ❌ Erreur Stripe : " . $e->getMessage() . "\n\n";
$errors++;
} catch (Exception $e) {
echo " ❌ Erreur : " . $e->getMessage() . "\n\n";
$errors++;
}
}
// 3. Résumé
echo "\n";
echo "=============================================================================\n";
echo " Résumé de l'opération\n";
echo "=============================================================================\n";
echo "✅ Locations créées avec succès : $success\n";
echo "❌ Erreurs : $errors\n";
echo "📊 Total traité : $total\n";
echo "\n";
// 4. Vérification finale
echo "🔍 Vérification finale...\n";
$stmt = $db->query("
SELECT COUNT(*) as remaining
FROM stripe_accounts sa
WHERE sa.stripe_account_id IS NOT NULL
AND (sa.stripe_location_id IS NULL OR sa.stripe_location_id = '')
");
$remaining = $stmt->fetch(PDO::FETCH_ASSOC);
echo " Comptes restants sans Location : " . $remaining['remaining'] . "\n\n";
if ($remaining['remaining'] == 0) {
echo "🎉 Tous les comptes Stripe ont maintenant une Location !\n\n";
}
} catch (Exception $e) {
echo "\n";
echo "=============================================================================\n";
echo " ❌ ERREUR\n";
echo "=============================================================================\n";
echo "Message : " . $e->getMessage() . "\n";
echo "Fichier : " . $e->getFile() . ":" . $e->getLine() . "\n";
echo "\n";
exit(1);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,543 @@
#!/usr/bin/env php
<?php
/**
* Script de migration VERBOSE avec détails table par table
*
* Usage:
* php migrate_from_backup_verbose.php \
* --source-db=geosector_20251008 \
* --target-db=pra_geo \
* --entity-id=1178 \
* --limit-operations=3
*/
// Inclusion des dépendances de l'API
require_once dirname(dirname(__DIR__)) . '/bootstrap.php';
use GeoSector\Services\ApiService;
// Configuration
const DB_HOST = '13.23.33.4';
const DB_PORT = 3306;
const DB_USER = 'pra_geo_user';
const DB_PASS = 'd2jAAGGWi8fxFrWgXjOA';
const DB_USER_ROOT = 'root';
const DB_PASS_ROOT = 'MyAlpLocal,90b';
// Couleurs pour terminal
const C_RESET = "\033[0m";
const C_RED = "\033[0;31m";
const C_GREEN = "\033[0;32m";
const C_YELLOW = "\033[1;33m";
const C_BLUE = "\033[0;34m";
const C_CYAN = "\033[0;36m";
const C_BOLD = "\033[1m";
// Variables globales
$sourceDb = null;
$targetDb = null;
$sourceDbName = null;
$targetDbName = null;
$entityId = null;
$limitOperations = 3;
$stats = [
'entites' => ['source' => 0, 'migrated' => 0],
'users' => ['source' => 0, 'migrated' => 0],
'operations' => ['source' => 0, 'migrated' => 0],
'ope_sectors' => ['source' => 0, 'migrated' => 0],
'sectors_adresses' => ['source' => 0, 'migrated' => 0],
'ope_users' => ['source' => 0, 'migrated' => 0],
'ope_users_sectors' => ['source' => 0, 'migrated' => 0],
'ope_pass' => ['source' => 0, 'migrated' => 0],
'ope_pass_histo' => ['source' => 0, 'migrated' => 0],
'medias' => ['source' => 0, 'migrated' => 0],
];
// Fonctions utilitaires
function println($message, $color = C_RESET) {
echo $color . $message . C_RESET . "\n";
}
function printBox($title, $color = C_BLUE) {
$width = 70;
$titleLen = strlen($title);
$padding = ($width - $titleLen - 2) / 2;
println(str_repeat("", $width), $color);
println(str_repeat(" ", floor($padding)) . $title . str_repeat(" ", ceil($padding)), $color);
println(str_repeat("", $width), $color);
}
function printStep($step, $substep = null) {
if ($substep) {
println(" ├─ " . $substep, C_CYAN);
} else {
println("\n" . C_BOLD . "" . $step . C_RESET);
}
}
function printStat($label, $source, $migrated, $indent = " ") {
$status = ($source === $migrated) ? C_GREEN . "" : C_YELLOW . "";
println($indent . "📊 {$label}: {$source} source → {$migrated} migré(s) {$status}" . C_RESET);
}
function connectDatabases($sourceDbName, $targetDbName) {
global $sourceDb, $targetDb;
printStep("Connexion aux bases de données");
try {
// Base source
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
DB_HOST, DB_PORT, $sourceDbName);
$sourceDb = new PDO($dsn, DB_USER_ROOT, DB_PASS_ROOT, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
printStep("Source connectée: {$sourceDbName}", true);
// Base cible
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
DB_HOST, DB_PORT, $targetDbName);
$targetDb = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
printStep("Cible connectée: {$targetDbName}", true);
return true;
} catch (PDOException $e) {
println("✗ Erreur connexion: " . $e->getMessage(), C_RED);
return false;
}
}
function getEntityInfo($entityId) {
global $sourceDb;
$stmt = $sourceDb->prepare("
SELECT rowid, libelle, cp, ville
FROM users_entites
WHERE rowid = ?
");
$stmt->execute([$entityId]);
return $stmt->fetch();
}
function migrateReferenceTable($tableName) {
global $sourceDb, $targetDb;
printStep("Migration table: {$tableName}");
// Compter source
$count = $sourceDb->query("SELECT COUNT(*) FROM {$tableName}")->fetchColumn();
printStep("Source: {$count} enregistrements", true);
if ($count === 0) {
printStep("Aucune donnée à migrer", true);
return 0;
}
// Récupérer les données
$rows = $sourceDb->query("SELECT * FROM {$tableName}")->fetchAll();
// Préparer l'insertion
$columns = array_keys($rows[0]);
$placeholders = array_map(fn($col) => ":{$col}", $columns);
$sql = sprintf(
"INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s",
$tableName,
implode(', ', $columns),
implode(', ', $placeholders),
implode(', ', array_map(fn($col) => "{$col} = VALUES({$col})", $columns))
);
$stmt = $targetDb->prepare($sql);
$success = 0;
foreach ($rows as $row) {
try {
$stmt->execute($row);
$success++;
} catch (PDOException $e) {
// Ignorer erreurs
}
}
printStep("Migré: {$success}/{$count}", true);
return $success;
}
function migrateEntite($entityId) {
global $sourceDb, $targetDb, $stats;
printStep("ÉTAPE 1: Migration de l'entité #{$entityId}");
// Récupérer l'entité source
$stmt = $sourceDb->prepare("
SELECT * FROM users_entites WHERE rowid = ?
");
$stmt->execute([$entityId]);
$entity = $stmt->fetch();
if (!$entity) {
println(" ✗ Entité introuvable", C_RED);
return false;
}
$stats['entites']['source'] = 1;
println(" 📋 Entité: " . $entity['libelle']);
println(" 📍 Code postal: " . ($entity['cp'] ?? 'N/A'));
println(" 🏙️ Ville: " . ($entity['ville'] ?? 'N/A'));
// Chiffrer les données
$encryptedName = ApiService::encryptSearchableData($entity['libelle']);
$encryptedEmail = !empty($entity['email']) ? ApiService::encryptSearchableData($entity['email']) : '';
$encryptedPhone = !empty($entity['phone']) ? ApiService::encryptData($entity['phone']) : '';
$encryptedMobile = !empty($entity['mobile']) ? ApiService::encryptData($entity['mobile']) : '';
// Insérer dans la cible
$sql = "INSERT INTO entites (
id, encrypted_name, code_postal, ville, encrypted_email, encrypted_phone, encrypted_mobile,
fk_region, fk_type, chk_active, created_at, updated_at
) VALUES (
:id, :name, :cp, :ville, :email, :phone, :mobile,
:region, :type, :active, :created, :updated
) ON DUPLICATE KEY UPDATE
encrypted_name = VALUES(encrypted_name),
code_postal = VALUES(code_postal),
ville = VALUES(ville)";
$stmt = $targetDb->prepare($sql);
$stmt->execute([
'id' => $entity['rowid'],
'name' => $encryptedName,
'cp' => $entity['cp'] ?? '',
'ville' => $entity['ville'] ?? '',
'email' => $encryptedEmail,
'phone' => $encryptedPhone,
'mobile' => $encryptedMobile,
'region' => $entity['fk_region'] ?? 1,
'type' => $entity['fk_type'] ?? 1,
'active' => $entity['active'] ?? 1,
'created' => $entity['date_creat'],
'updated' => $entity['date_modif']
]);
$stats['entites']['migrated'] = 1;
printStat("Entité", 1, 1);
return true;
}
function migrateUsers($entityId) {
global $sourceDb, $targetDb, $stats;
printStep("ÉTAPE 2: Migration des utilisateurs");
// Compter source
$count = $sourceDb->prepare("SELECT COUNT(*) FROM users WHERE fk_entite = ? AND active = 1");
$count->execute([$entityId]);
$sourceCount = $count->fetchColumn();
$stats['users']['source'] = $sourceCount;
println(" 📊 Source: {$sourceCount} utilisateurs actifs");
if ($sourceCount === 0) {
println(" ⚠️ Aucun utilisateur à migrer", C_YELLOW);
return 0;
}
// Récupérer les users
$stmt = $sourceDb->prepare("
SELECT * FROM users WHERE fk_entite = ? AND active = 1
");
$stmt->execute([$entityId]);
$users = $stmt->fetchAll();
$success = 0;
foreach ($users as $user) {
try {
$encryptedName = ApiService::encryptSearchableData($user['nom']);
$encryptedUsername = !empty($user['username']) ? ApiService::encryptSearchableData($user['username']) : '';
$encryptedEmail = !empty($user['email']) ? ApiService::encryptSearchableData($user['email']) : '';
$encryptedPhone = !empty($user['telephone']) ? ApiService::encryptData($user['telephone']) : '';
$encryptedMobile = !empty($user['mobile']) ? ApiService::encryptData($user['mobile']) : '';
$sql = "INSERT INTO users (
id, fk_entite, fk_role, encrypted_name, first_name,
encrypted_user_name, user_pass_hash, encrypted_email,
encrypted_phone, encrypted_mobile, chk_active, created_at, updated_at
) VALUES (
:id, :entity, :role, :name, :firstname,
:username, :pass, :email,
:phone, :mobile, :active, :created, :updated
) ON DUPLICATE KEY UPDATE
encrypted_name = VALUES(encrypted_name),
encrypted_email = VALUES(encrypted_email)";
$stmt = $targetDb->prepare($sql);
$stmt->execute([
'id' => $user['rowid'],
'entity' => $entityId,
'role' => $user['fk_role'] ?? 1,
'name' => $encryptedName,
'firstname' => $user['prenom'] ?? '',
'username' => $encryptedUsername,
'pass' => $user['password'] ?? '',
'email' => $encryptedEmail,
'phone' => $encryptedPhone,
'mobile' => $encryptedMobile,
'active' => 1,
'created' => $user['date_creat'],
'updated' => $user['date_modif']
]);
$success++;
} catch (PDOException $e) {
// Ignorer
}
}
$stats['users']['migrated'] = $success;
printStat("Utilisateurs", $sourceCount, $success);
return $success;
}
function migrateOperations($entityId, $limit = 3) {
global $sourceDb, $targetDb, $stats;
printStep("ÉTAPE 3: Migration des opérations (limite: {$limit})");
// Compter toutes les opérations
$count = $sourceDb->prepare("SELECT COUNT(*) FROM operations WHERE fk_entite = ? AND active = 1");
$count->execute([$entityId]);
$totalCount = $count->fetchColumn();
println(" 📊 Total disponible: {$totalCount} opérations");
println(" 🎯 Limitation: {$limit} dernières opérations");
$stats['operations']['source'] = min($limit, $totalCount);
// Récupérer les N dernières opérations
$stmt = $sourceDb->prepare("
SELECT * FROM operations
WHERE fk_entite = ? AND active = 1
ORDER BY date_creat DESC
LIMIT ?
");
$stmt->execute([$entityId, $limit]);
$operations = $stmt->fetchAll();
if (empty($operations)) {
println(" ⚠️ Aucune opération à migrer", C_YELLOW);
return [];
}
$migratedOps = [];
foreach ($operations as $op) {
try {
$sql = "INSERT INTO operations (
id, fk_entite, libelle, date_deb, date_fin,
chk_distinct_sectors, chk_active, created_at, updated_at
) VALUES (
:id, :entity, :libelle, :datedeb, :datefin,
:distinct, :active, :created, :updated
) ON DUPLICATE KEY UPDATE
libelle = VALUES(libelle)";
$stmt = $targetDb->prepare($sql);
$stmt->execute([
'id' => $op['rowid'],
'entity' => $entityId,
'libelle' => $op['libelle'],
'datedeb' => $op['date_deb'],
'datefin' => $op['date_fin'],
'distinct' => $op['chk_distinct_sectors'] ?? 0,
'active' => 1,
'created' => $op['date_creat'],
'updated' => $op['date_modif']
]);
$migratedOps[] = $op['rowid'];
$stats['operations']['migrated']++;
println(" ├─ Opération #{$op['rowid']}: " . $op['libelle'], C_GREEN);
} catch (PDOException $e) {
println(" ├─ ✗ Erreur opération #{$op['rowid']}: " . $e->getMessage(), C_RED);
}
}
printStat("Opérations", count($operations), count($migratedOps));
return $migratedOps;
}
function migrateOperationDetails($operationId, $entityId) {
global $sourceDb, $targetDb, $stats;
println("\n " . C_BOLD . "┌─ Détails opération #{$operationId}" . C_RESET);
// 1. Compter les passages
$passCount = $sourceDb->prepare("SELECT COUNT(*) FROM ope_pass WHERE fk_operation = ?");
$passCount->execute([$operationId]);
$nbPassages = $passCount->fetchColumn();
println(" │ 📊 Passages disponibles: {$nbPassages}");
// 2. Compter les ope_users
$opeUsersCount = $sourceDb->prepare("SELECT COUNT(*) FROM ope_users WHERE fk_operation = ?");
$opeUsersCount->execute([$operationId]);
$nbOpeUsers = $opeUsersCount->fetchColumn();
$stats['ope_users']['source'] += $nbOpeUsers;
println(" │ 👥 Associations users: {$nbOpeUsers}");
// 3. Compter les secteurs (via ope_users_sectors)
$sectorsCount = $sourceDb->prepare("
SELECT COUNT(DISTINCT ous.fk_sector)
FROM ope_users_sectors ous
WHERE ous.fk_operation = ?
");
$sectorsCount->execute([$operationId]);
$nbSectors = $sectorsCount->fetchColumn();
println(" │ 🗺️ Secteurs distincts: {$nbSectors}");
println(" └─ " . C_CYAN . "Migration des données associées..." . C_RESET);
// Migration ope_users (simplifié pour l'exemple)
// ... (code de migration réel ici)
$stats['ope_pass']['source'] += $nbPassages;
}
// === MAIN ===
function parseArguments($argv) {
$args = [
'source-db' => null,
'target-db' => 'pra_geo',
'entity-id' => null,
'limit-operations' => 3,
'help' => false
];
foreach ($argv as $arg) {
if (strpos($arg, '--') === 0) {
$parts = explode('=', substr($arg, 2), 2);
$key = $parts[0];
$value = $parts[1] ?? true;
if (array_key_exists($key, $args)) {
$args[$key] = $value;
}
}
}
return $args;
}
// Vérifier CLI
if (php_sapi_name() !== 'cli') {
die("Ce script doit être exécuté en ligne de commande.\n");
}
$args = parseArguments($argv);
if ($args['help'] || !$args['source-db'] || !$args['entity-id']) {
echo <<<HELP
Usage: php migrate_from_backup_verbose.php [OPTIONS]
Options:
--source-db=NAME Base source (ex: geosector_20251008) [REQUIS]
--target-db=NAME Base cible (défaut: pra_geo)
--entity-id=ID ID de l'entité à migrer [REQUIS]
--limit-operations=N Nombre d'opérations à migrer (défaut: 3)
--help Affiche cette aide
Exemple:
php migrate_from_backup_verbose.php \\
--source-db=geosector_20251008 \\
--target-db=pra_geo \\
--entity-id=1178 \\
--limit-operations=3
HELP;
exit($args['help'] ? 0 : 1);
}
$sourceDbName = $args['source-db'];
$targetDbName = $args['target-db'];
$entityId = (int)$args['entity-id'];
$limitOperations = (int)$args['limit-operations'];
// Bannière
printBox("MIGRATION VERBOSE - DÉTAILS TABLE PAR TABLE", C_BLUE);
println("📅 Date: " . date('Y-m-d H:i:s'));
println("📁 Source: {$sourceDbName}");
println("📁 Cible: {$targetDbName}");
println("🎯 Entité: #{$entityId}");
println("📊 Limite opérations: {$limitOperations}");
println("");
// Connexion
if (!connectDatabases($sourceDbName, $targetDbName)) {
exit(1);
}
// Récupérer infos entité
$entityInfo = getEntityInfo($entityId);
if (!$entityInfo) {
println("✗ Entité #{$entityId} introuvable", C_RED);
exit(1);
}
println("\n📋 Entité trouvée: " . $entityInfo['libelle']);
println("📍 CP: " . ($entityInfo['cp'] ?? 'N/A') . " - Ville: " . ($entityInfo['ville'] ?? 'N/A'));
println("");
// Migration des tables de référence (x_*)
printBox("TABLES DE RÉFÉRENCE", C_CYAN);
$referenceTables = ['x_devises', 'x_entites_types', 'x_types_passages',
'x_types_reglements', 'x_users_roles'];
foreach ($referenceTables as $table) {
migrateReferenceTable($table);
}
// Migration entité
printBox("MIGRATION ENTITÉ", C_CYAN);
if (!migrateEntite($entityId)) {
println("✗ Échec migration entité", C_RED);
exit(1);
}
// Migration users
printBox("MIGRATION UTILISATEURS", C_CYAN);
migrateUsers($entityId);
// Migration opérations
printBox("MIGRATION OPÉRATIONS", C_CYAN);
$migratedOps = migrateOperations($entityId, $limitOperations);
// Détails par opération
foreach ($migratedOps as $opId) {
migrateOperationDetails($opId, $entityId);
}
// Résumé final
printBox("RÉSUMÉ DE LA MIGRATION", C_GREEN);
foreach ($stats as $table => $data) {
if ($data['source'] > 0 || $data['migrated'] > 0) {
printStat(ucfirst($table), $data['source'], $data['migrated'], " ");
}
}
println("\n✅ Migration terminée avec succès!", C_GREEN);
exit(0);

View File

@@ -0,0 +1,282 @@
<?php
/**
* Script de vérification de la cohérence des structures
* avant migration entre geosector (source) et geo_app (cible)
*
* Ce script compare les colonnes de chaque table migrée
* et identifie les incohérences potentielles
*
* Usage: php scripts/php/verify_migration_structure.php
*/
require_once dirname(__DIR__) . '/config.php';
// Couleurs pour le terminal
define('COLOR_GREEN', "\033[0;32m");
define('COLOR_RED', "\033[0;31m");
define('COLOR_YELLOW', "\033[1;33m");
define('COLOR_BLUE', "\033[0;34m");
define('COLOR_RESET', "\033[0m");
// Fonction pour afficher des messages colorés
function printColor($message, $color = COLOR_RESET) {
echo $color . $message . COLOR_RESET . PHP_EOL;
}
// Fonction pour obtenir les colonnes d'une table
function getTableColumns($pdo, $tableName) {
try {
$stmt = $pdo->query("DESCRIBE `$tableName`");
$columns = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$columns[$row['Field']] = [
'type' => $row['Type'],
'null' => $row['Null'],
'key' => $row['Key'],
'default' => $row['Default'],
'extra' => $row['Extra']
];
}
return $columns;
} catch (PDOException $e) {
return null;
}
}
// Mappings de colonnes connus
$columnMappings = [
// Mappings globaux
'global' => [
'rowid' => 'id',
'active' => 'chk_active',
'date_creat' => 'created_at',
'date_modif' => 'updated_at',
],
// Mappings spécifiques par table
'users_entites' => [
'table_target' => 'entites',
'mappings' => [
'libelle' => 'encrypted_name',
'tel1' => 'encrypted_phone',
'tel2' => 'encrypted_mobile',
'email' => 'encrypted_email',
'iban' => 'encrypted_iban',
'bic' => 'encrypted_bic',
'cp' => 'code_postal',
]
],
'users' => [
'mappings' => [
'libelle' => 'encrypted_name',
'username' => 'encrypted_user_name',
'userpswd' => 'user_pass_hash',
'userpass' => 'user_pass_hash',
'prenom' => 'first_name',
'nom_tournee' => 'sect_name',
'telephone' => 'encrypted_phone',
'mobile' => 'encrypted_mobile',
'email' => 'encrypted_email',
'alert_email' => 'chk_alert_email',
]
],
'ope_pass' => [
'mappings' => [
'date_eve' => 'passed_at',
'libelle' => 'encrypted_name',
'email' => 'encrypted_email',
'phone' => 'encrypted_phone',
'recu' => 'nom_recu',
]
],
'medias' => [
'mappings' => [
'support_rowid' => 'support_id',
]
],
'x_villes' => [
'mappings' => [
'cp' => 'code_postal',
]
],
];
// Tables à vérifier (source => cible)
$tablesToVerify = [
'x_devises' => 'x_devises',
'x_entites_types' => 'x_entites_types',
'x_types_passages' => 'x_types_passages',
'x_types_reglements' => 'x_types_reglements',
'x_users_roles' => 'x_users_roles',
'x_pays' => 'x_pays',
'x_regions' => 'x_regions',
'x_departements' => 'x_departements',
'x_villes' => 'x_villes',
'users_entites' => 'entites',
'users' => 'users',
'operations' => 'operations',
'ope_users' => 'ope_users',
'ope_users_sectors' => 'ope_users_sectors',
'ope_pass' => 'ope_pass',
'ope_pass_histo' => 'ope_pass_histo',
'medias' => 'medias',
'sectors_adresses' => 'sectors_adresses',
];
try {
printColor("\n╔══════════════════════════════════════════════════════════════╗", COLOR_BLUE);
printColor("║ VÉRIFICATION DES STRUCTURES DE MIGRATION ║", COLOR_BLUE);
printColor("╚══════════════════════════════════════════════════════════════╝", COLOR_BLUE);
// Connexion aux bases de données
printColor("\n[INFO] Connexion aux bases de données...", COLOR_BLUE);
$sourceDb = getSourceConnection();
$targetDb = getTargetConnection();
printColor("[OK] Connexions établies", COLOR_GREEN);
$totalIssues = 0;
$totalWarnings = 0;
$totalTables = count($tablesToVerify);
foreach ($tablesToVerify as $sourceTable => $targetTable) {
printColor("\n" . str_repeat("", 70), COLOR_BLUE);
printColor("📊 Table: $sourceTable$targetTable", COLOR_BLUE);
printColor(str_repeat("", 70), COLOR_BLUE);
// Récupérer les colonnes
$sourceCols = getTableColumns($sourceDb, $sourceTable);
$targetCols = getTableColumns($targetDb, $targetTable);
if ($sourceCols === null) {
printColor("❌ ERREUR: Table source '$sourceTable' introuvable", COLOR_RED);
$totalIssues++;
continue;
}
if ($targetCols === null) {
printColor("❌ ERREUR: Table cible '$targetTable' introuvable", COLOR_RED);
$totalIssues++;
continue;
}
// Récupérer les mappings pour cette table
$tableMappings = $columnMappings['global'];
if (isset($columnMappings[$sourceTable]['mappings'])) {
$tableMappings = array_merge($tableMappings, $columnMappings[$sourceTable]['mappings']);
}
// Vérifier chaque colonne source
$unmappedSourceCols = [];
$mappedCols = 0;
foreach ($sourceCols as $sourceCol => $sourceInfo) {
// Chercher la colonne cible
$targetCol = $tableMappings[$sourceCol] ?? $sourceCol;
if (isset($targetCols[$targetCol])) {
$mappedCols++;
// Colonne existe et mappée correctement
} else {
// Vérifier si c'est une colonne qui doit être ignorée
$ignoredCols = ['dir0', 'dir1', 'dir2', 'type_fichier', 'position', 'hauteur', 'largeur',
'niveaugris', 'lieudit', 'chk_habitat_vide', 'lot_nb_passages', 'departement',
'fk_user', 'chk_api_adresse', 'num_adherent', 'libelle_naissance', 'josh',
'email_secondaire', 'infos', 'ltt', 'lng', 'sector', 'dept_naissance',
'commune_naissance', 'anciennete', 'fk_categorie', 'fk_sous_categorie',
'adresse_1', 'adresse_2', 'cp', 'ville', 'matricule', 'fk_grade',
'chk_adherent_ud', 'chk_adherent_ur', 'chk_adherent_fns', 'chk_archive',
'chk_double_affectation', 'date_creat', 'appname', 'http_host', 'tva_intra',
'rcs', 'siret', 'ape', 'couleur', 'prefecture', 'fk_titre_gerant',
'gerant_prenom', 'gerant_nom', 'site_url', 'gerant_signature',
'tampon_signature', 'banque_libelle', 'banque_adresse', 'banque_cp',
'banque_ville', 'genbase', 'groupebase', 'userbase', 'passbase', 'demo',
'lib_vert', 'lib_verts', 'lib_orange', 'lib_oranges', 'lib_rouge', 'lib_rouges',
'lib_bleu', 'lib_bleus', 'icon_siege', 'icon_siege_color', 'btn_width',
'nbmembres', 'nbconnex'];
if (in_array($sourceCol, $ignoredCols)) {
// Colonne volontairement non migrée
continue;
}
$unmappedSourceCols[] = $sourceCol;
}
}
// Vérifier les nouvelles colonnes dans la cible
$newTargetCols = [];
foreach ($targetCols as $targetCol => $targetInfo) {
// Vérifier si cette colonne existe dans la source
$sourceCol = array_search($targetCol, $tableMappings);
if ($sourceCol === false) {
$sourceCol = $targetCol; // Même nom
}
if (!isset($sourceCols[$sourceCol])) {
// Vérifier si c'est une colonne attendue (timestamp auto, etc.)
$autoColumns = ['created_at', 'updated_at', 'id'];
if (!in_array($targetCol, $autoColumns)) {
$newTargetCols[] = $targetCol;
}
}
}
// Affichage des résultats
printColor("✓ Colonnes source mappées: $mappedCols/" . count($sourceCols), COLOR_GREEN);
if (!empty($unmappedSourceCols)) {
printColor("⚠ Colonnes source NON mappées:", COLOR_YELLOW);
foreach ($unmappedSourceCols as $col) {
printColor(" - $col ({$sourceCols[$col]['type']})", COLOR_YELLOW);
}
$totalWarnings += count($unmappedSourceCols);
}
if (!empty($newTargetCols)) {
printColor(" Nouvelles colonnes dans cible (seront NULL/défaut):", COLOR_YELLOW);
foreach ($newTargetCols as $col) {
$defaultValue = $targetCols[$col]['default'] ?? 'NULL';
$nullable = $targetCols[$col]['null'] === 'YES' ? '(nullable)' : '(NOT NULL)';
printColor(" - $col ({$targetCols[$col]['type']}) = $defaultValue $nullable", COLOR_YELLOW);
}
$totalWarnings += count($newTargetCols);
}
if (empty($unmappedSourceCols) && empty($newTargetCols)) {
printColor("✓ Aucun problème détecté", COLOR_GREEN);
}
}
// Résumé final
printColor("\n" . str_repeat("", 70), COLOR_BLUE);
printColor("📈 RÉSUMÉ DE LA VÉRIFICATION", COLOR_BLUE);
printColor(str_repeat("", 70), COLOR_BLUE);
printColor("Tables vérifiées: $totalTables", COLOR_BLUE);
if ($totalIssues > 0) {
printColor("❌ Erreurs critiques: $totalIssues", COLOR_RED);
} else {
printColor("✓ Aucune erreur critique", COLOR_GREEN);
}
if ($totalWarnings > 0) {
printColor("⚠ Avertissements: $totalWarnings", COLOR_YELLOW);
printColor(" (colonnes non mappées ou nouvelles colonnes)", COLOR_YELLOW);
} else {
printColor("✓ Aucun avertissement", COLOR_GREEN);
}
printColor("\n💡 Recommandations:", COLOR_BLUE);
printColor(" - Vérifiez que les colonnes non mappées sont intentionnelles", COLOR_RESET);
printColor(" - Les nouvelles colonnes cible utiliseront leurs valeurs par défaut", COLOR_RESET);
printColor(" - Consultez README-migration.md pour plus de détails", COLOR_RESET);
// Fermer le tunnel SSH
closeSshTunnel();
printColor("\n✓ Vérification terminée\n", COLOR_GREEN);
} catch (Exception $e) {
printColor("\n❌ ERREUR CRITIQUE: " . $e->getMessage(), COLOR_RED);
closeSshTunnel();
exit(1);
}

View File

@@ -0,0 +1,34 @@
-- ========================================
-- Script SIMPLE d'ajout de contraintes UNIQUE
-- Pour tables avec peu de données (pas de suppression de doublons)
-- Date: 2025-10-10
-- ========================================
USE pra_geo;
-- Vérifier d'abord s'il y a des doublons
SELECT 'Vérification doublons ope_users...' as status;
SELECT fk_operation, fk_user, COUNT(*) as count
FROM ope_users
GROUP BY fk_operation, fk_user
HAVING count > 1;
SELECT 'Vérification doublons ope_users_sectors...' as status;
SELECT fk_operation, fk_user, fk_sector, COUNT(*) as count
FROM ope_users_sectors
GROUP BY fk_operation, fk_user, fk_sector
HAVING count > 1;
-- Ajouter les contraintes UNIQUE directement
-- Si des doublons existent, MySQL retournera une erreur explicite
ALTER TABLE ope_users
ADD UNIQUE KEY `idx_operation_user` (`fk_operation`, `fk_user`);
ALTER TABLE ope_users_sectors
ADD UNIQUE KEY `idx_operation_user_sector` (`fk_operation`, `fk_user`, `fk_sector`);
-- Vérification
SHOW INDEX FROM ope_users WHERE Key_name = 'idx_operation_user';
SHOW INDEX FROM ope_users_sectors WHERE Key_name = 'idx_operation_user_sector';
SELECT 'TERMINÉ ✓' as status;

View File

@@ -0,0 +1,59 @@
-- ========================================
-- Script d'ajout de contraintes UNIQUE
-- Pour éviter les doublons dans ope_users et ope_users_sectors
-- Date: 2025-10-10
-- ========================================
USE pra_geo;
-- ========================================
-- 1. TABLE ope_users
-- ========================================
-- Vérifier et supprimer les doublons existants avant d'ajouter la contrainte
-- (Garde la première occurrence, supprime les duplicatas)
DELETE ou1 FROM ope_users ou1
INNER JOIN ope_users ou2
WHERE ou1.id > ou2.id
AND ou1.fk_operation = ou2.fk_operation
AND ou1.fk_user = ou2.fk_user;
-- Ajouter la contrainte UNIQUE sur (fk_operation, fk_user)
ALTER TABLE ope_users
ADD UNIQUE KEY `idx_operation_user` (`fk_operation`, `fk_user`);
-- ========================================
-- 2. TABLE ope_users_sectors
-- ========================================
-- Vérifier et supprimer les doublons existants avant d'ajouter la contrainte
-- (Garde la première occurrence, supprime les duplicatas)
DELETE ous1 FROM ope_users_sectors ous1
INNER JOIN ope_users_sectors ous2
WHERE ous1.id > ous2.id
AND ous1.fk_operation = ous2.fk_operation
AND ous1.fk_user = ous2.fk_user
AND ous1.fk_sector = ous2.fk_sector;
-- Ajouter la contrainte UNIQUE sur (fk_operation, fk_user, fk_sector)
ALTER TABLE ope_users_sectors
ADD UNIQUE KEY `idx_operation_user_sector` (`fk_operation`, `fk_user`, `fk_sector`);
-- ========================================
-- Vérification
-- ========================================
-- Vérifier les contraintes ajoutées
SHOW INDEX FROM ope_users WHERE Key_name = 'idx_operation_user';
SHOW INDEX FROM ope_users_sectors WHERE Key_name = 'idx_operation_user_sector';
-- Compter les doublons restants (devrait retourner 0 lignes)
SELECT fk_operation, fk_user, COUNT(*) as count
FROM ope_users
GROUP BY fk_operation, fk_user
HAVING count > 1;
SELECT fk_operation, fk_user, fk_sector, COUNT(*) as count
FROM ope_users_sectors
GROUP BY fk_operation, fk_user, fk_sector
HAVING count > 1;

View File

@@ -0,0 +1,70 @@
-- ========================================
-- Script OPTIMISÉ d'ajout de contraintes UNIQUE
-- Pour tables avec beaucoup de données
-- Date: 2025-10-10
-- ========================================
USE pra_geo;
-- ========================================
-- OPTION 1 : Compter les doublons d'abord
-- ========================================
SELECT 'Analyse des doublons dans ope_users...' as status;
SELECT COUNT(*) as total_rows,
COUNT(DISTINCT fk_operation, fk_user) as unique_combinations,
COUNT(*) - COUNT(DISTINCT fk_operation, fk_user) as duplicates
FROM ope_users;
SELECT 'Analyse des doublons dans ope_users_sectors...' as status;
SELECT COUNT(*) as total_rows,
COUNT(DISTINCT fk_operation, fk_user, fk_sector) as unique_combinations,
COUNT(*) - COUNT(DISTINCT fk_operation, fk_user, fk_sector) as duplicates
FROM ope_users_sectors;
-- ========================================
-- OPTION 2 : Supprimer RAPIDEMENT les doublons
-- Créer une table temporaire avec les IDs à garder
-- ========================================
-- Pour ope_users
CREATE TEMPORARY TABLE ope_users_to_keep AS
SELECT MIN(id) as id_to_keep, fk_operation, fk_user
FROM ope_users
GROUP BY fk_operation, fk_user;
-- Supprimer tous les doublons (plus rapide avec NOT IN + subquery)
DELETE FROM ope_users
WHERE id NOT IN (SELECT id_to_keep FROM ope_users_to_keep);
DROP TEMPORARY TABLE ope_users_to_keep;
-- Pour ope_users_sectors
CREATE TEMPORARY TABLE ope_users_sectors_to_keep AS
SELECT MIN(id) as id_to_keep, fk_operation, fk_user, fk_sector
FROM ope_users_sectors
GROUP BY fk_operation, fk_user, fk_sector;
DELETE FROM ope_users_sectors
WHERE id NOT IN (SELECT id_to_keep FROM ope_users_sectors_to_keep);
DROP TEMPORARY TABLE ope_users_sectors_to_keep;
-- ========================================
-- OPTION 3 : Ajouter les contraintes UNIQUE
-- ========================================
ALTER TABLE ope_users
ADD UNIQUE KEY `idx_operation_user` (`fk_operation`, `fk_user`);
ALTER TABLE ope_users_sectors
ADD UNIQUE KEY `idx_operation_user_sector` (`fk_operation`, `fk_user`, `fk_sector`);
-- ========================================
-- Vérification finale
-- ========================================
SHOW INDEX FROM ope_users WHERE Key_name = 'idx_operation_user';
SHOW INDEX FROM ope_users_sectors WHERE Key_name = 'idx_operation_user_sector';
SELECT 'TERMINÉ - Contraintes UNIQUE ajoutées avec succès' as status;

View File

@@ -0,0 +1,181 @@
-- =========================================================
-- Script de vidage des tables de données (PRODUCTION)
-- Option B : Vider TOUTES les tables SAUF x_* et entité 1
-- Conserve les tables de référence x_*
-- Conserve l'entité id=1 (super admins) et ses users/opérations
-- Date: 2025-10-09
-- =========================================================
-- Désactiver les contraintes de clés étrangères temporairement
SET FOREIGN_KEY_CHECKS = 0;
-- =========================================================
-- TABLES CHAT
-- =========================================================
TRUNCATE TABLE chat_read_receipts;
TRUNCATE TABLE chat_messages;
TRUNCATE TABLE chat_participants;
TRUNCATE TABLE chat_rooms;
-- =========================================================
-- TABLES EMAIL
-- =========================================================
TRUNCATE TABLE email_queue;
TRUNCATE TABLE email_counter;
-- =========================================================
-- TABLES SÉCURITÉ
-- =========================================================
TRUNCATE TABLE sec_failed_login_attempts;
TRUNCATE TABLE sec_blocked_ips;
TRUNCATE TABLE sec_alerts;
TRUNCATE TABLE sec_performance_metrics;
-- =========================================================
-- TABLES STRIPE
-- =========================================================
TRUNCATE TABLE stripe_webhooks;
TRUNCATE TABLE stripe_payment_history;
TRUNCATE TABLE stripe_refunds;
TRUNCATE TABLE stripe_terminal_readers;
TRUNCATE TABLE stripe_android_certified_devices;
-- NOTE: stripe_accounts conservé car lié à entites via FK
-- =========================================================
-- TABLES DONNÉES MÉTIER (conserver entité 1)
-- =========================================================
-- 1. Supprimer les devices des users (sauf entité 1)
DELETE FROM user_devices
WHERE fk_user IN (SELECT id FROM users WHERE fk_entite != 1);
-- 2. Supprimer les sessions (sauf users entité 1)
DELETE FROM z_sessions
WHERE fk_user IN (SELECT id FROM users WHERE fk_entite != 1);
-- 3. Supprimer les médias (sauf entité 1)
DELETE FROM medias WHERE fk_entite != 1;
-- 4. Supprimer les comptes Stripe (sauf entité 1)
DELETE FROM stripe_accounts WHERE fk_entite != 1;
-- 5. Supprimer l'historique des passages (sauf entité 1)
DELETE FROM ope_pass_histo
WHERE fk_pass IN (
SELECT id FROM ope_pass
WHERE fk_operation IN (
SELECT id FROM operations WHERE fk_entite != 1
)
);
-- 6. Supprimer les passages (sauf ceux des opérations de l'entité 1)
DELETE FROM ope_pass
WHERE fk_operation IN (
SELECT id FROM operations WHERE fk_entite != 1
);
-- 7. Supprimer les associations users-sectors (sauf entité 1)
DELETE FROM ope_users_sectors
WHERE fk_operation IN (
SELECT id FROM operations WHERE fk_entite != 1
);
-- 8. Supprimer les associations users-operations (sauf entité 1)
DELETE FROM ope_users
WHERE fk_operation IN (
SELECT id FROM operations WHERE fk_entite != 1
);
-- 9. Supprimer les adresses de secteurs (sauf entité 1)
DELETE FROM sectors_adresses
WHERE fk_sector IN (
SELECT id FROM ope_sectors WHERE fk_operation IN (
SELECT id FROM operations WHERE fk_entite != 1
)
);
-- 10. Supprimer les secteurs (sauf ceux de l'entité 1)
DELETE FROM ope_sectors
WHERE fk_operation IN (
SELECT id FROM operations WHERE fk_entite != 1
);
-- 11. Supprimer les opérations (sauf celles de l'entité 1)
DELETE FROM operations WHERE fk_entite != 1;
-- 12. Supprimer les utilisateurs (sauf ceux de l'entité 1)
DELETE FROM users WHERE fk_entite != 1;
-- 13. Supprimer les entités (sauf l'entité 1)
DELETE FROM entites WHERE id != 1;
-- 14. Vider la table params (paramètres globaux)
TRUNCATE TABLE params;
-- Réactiver les contraintes de clés étrangères
SET FOREIGN_KEY_CHECKS = 1;
-- =========================================================
-- VÉRIFICATIONS POST-VIDAGE
-- =========================================================
SELECT '========================================' as '';
SELECT '=== TABLES DE DONNÉES (après vidage) ===' as '';
SELECT '========================================' as '';
SELECT 'entites' as table_name, COUNT(*) as count FROM entites
UNION ALL SELECT 'users', COUNT(*) FROM users
UNION ALL SELECT 'operations', COUNT(*) FROM operations
UNION ALL SELECT 'ope_sectors', COUNT(*) FROM ope_sectors
UNION ALL SELECT 'ope_pass', COUNT(*) FROM ope_pass
UNION ALL SELECT 'medias', COUNT(*) FROM medias
UNION ALL SELECT 'user_devices', COUNT(*) FROM user_devices
UNION ALL SELECT 'z_sessions', COUNT(*) FROM z_sessions;
SELECT '' as '';
SELECT '========================================' as '';
SELECT '=== TABLES CHAT (doivent être vides) ===' as '';
SELECT '========================================' as '';
SELECT 'chat_rooms' as table_name, COUNT(*) as count FROM chat_rooms
UNION ALL SELECT 'chat_messages', COUNT(*) FROM chat_messages
UNION ALL SELECT 'chat_participants', COUNT(*) FROM chat_participants
UNION ALL SELECT 'chat_read_receipts', COUNT(*) FROM chat_read_receipts;
SELECT '' as '';
SELECT '========================================' as '';
SELECT '=== TABLES STRIPE (vides sauf accounts) ===' as '';
SELECT '========================================' as '';
SELECT 'stripe_accounts' as table_name, COUNT(*) as count FROM stripe_accounts
UNION ALL SELECT 'stripe_webhooks', COUNT(*) FROM stripe_webhooks
UNION ALL SELECT 'stripe_terminal_readers', COUNT(*) FROM stripe_terminal_readers
UNION ALL SELECT 'stripe_android_certified_devices', COUNT(*) FROM stripe_android_certified_devices;
SELECT '' as '';
SELECT '========================================' as '';
SELECT '=== ENTITÉ 1 (doit être conservée) ===' as '';
SELECT '========================================' as '';
SELECT id, encrypted_name, encrypted_email, chk_active FROM entites WHERE id = 1;
SELECT '' as '';
SELECT 'Nombre de users entité 1:' as info, COUNT(*) as count FROM users WHERE fk_entite = 1;
SELECT 'Nombre d\'opérations entité 1:' as info, COUNT(*) as count FROM operations WHERE fk_entite = 1;
SELECT '' as '';
SELECT '========================================' as '';
SELECT '=== TABLES x_* (doivent être remplies) ===' as '';
SELECT '========================================' as '';
SELECT 'x_devises' as table_name, COUNT(*) as count FROM x_devises
UNION ALL SELECT 'x_pays', COUNT(*) FROM x_pays
UNION ALL SELECT 'x_regions', COUNT(*) FROM x_regions
UNION ALL SELECT 'x_departements', COUNT(*) FROM x_departements
UNION ALL SELECT 'x_villes', COUNT(*) FROM x_villes
UNION ALL SELECT 'x_departements_contours', COUNT(*) FROM x_departements_contours
UNION ALL SELECT 'x_entites_types', COUNT(*) FROM x_entites_types
UNION ALL SELECT 'x_types_passages', COUNT(*) FROM x_types_passages
UNION ALL SELECT 'x_types_reglements', COUNT(*) FROM x_types_reglements
UNION ALL SELECT 'x_users_roles', COUNT(*) FROM x_users_roles
UNION ALL SELECT 'x_users_titres', COUNT(*) FROM x_users_titres;

View File

@@ -1,112 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Script de test pour générer manuellement un reçu
* Usage: php generate_receipt_manual.php <passage_id>
*/
declare(strict_types=1);
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DEV
$_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
if (!function_exists('getallheaders')) {
function getallheaders() {
return [];
}
}
}
// Chargement de l'environnement
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../src/Config/AppConfig.php';
require_once __DIR__ . '/../../src/Core/Database.php';
require_once __DIR__ . '/../../src/Services/LogService.php';
require_once __DIR__ . '/../../src/Services/ReceiptService.php';
// Vérifier qu'un ID de passage est fourni
if ($argc < 2) {
echo "Usage: php generate_receipt_manual.php <passage_id>\n";
exit(1);
}
$passageId = (int)$argv[1];
try {
echo "=== Test de génération de reçu ===\n";
echo "Passage ID: $passageId\n\n";
// Initialisation de la configuration
$appConfig = AppConfig::getInstance();
$dbConfig = $appConfig->getDatabaseConfig();
// Initialiser la base de données
Database::init($dbConfig);
$db = Database::getInstance();
echo "✓ Connexion à la base de données OK\n";
// Vérifier le passage
$stmt = $db->prepare('SELECT id, fk_type, encrypted_email, nom_recu FROM ope_pass WHERE id = ?');
$stmt->execute([$passageId]);
$passage = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$passage) {
echo "✗ Passage $passageId non trouvé\n";
exit(1);
}
echo "✓ Passage trouvé\n";
echo " - fk_type: " . $passage['fk_type'] . "\n";
echo " - encrypted_email: " . (!empty($passage['encrypted_email']) ? 'OUI' : 'NON') . "\n";
echo " - nom_recu: " . ($passage['nom_recu'] ?: 'vide') . "\n\n";
// Déchiffrer l'email
if (!empty($passage['encrypted_email'])) {
$email = \ApiService::decryptSearchableData($passage['encrypted_email']);
echo " - Email déchiffré: $email\n";
echo " - Email valide: " . (filter_var($email, FILTER_VALIDATE_EMAIL) ? 'OUI' : 'NON') . "\n\n";
} else {
echo "✗ Aucun email chiffré trouvé\n";
exit(1);
}
// Générer le reçu
echo "Génération du reçu...\n";
$receiptService = new \App\Services\ReceiptService();
$result = $receiptService->generateReceiptForPassage($passageId);
if ($result) {
echo "✓ Reçu généré avec succès !\n\n";
// Vérifier l'email dans la queue
$stmt = $db->prepare('SELECT id, to_email, status, created_at FROM email_queue WHERE fk_pass = ? ORDER BY created_at DESC LIMIT 1');
$stmt->execute([$passageId]);
$queueEmail = $stmt->fetch(PDO::FETCH_ASSOC);
if ($queueEmail) {
echo "✓ Email ajouté à la queue\n";
echo " - Queue ID: " . $queueEmail['id'] . "\n";
echo " - Destinataire: " . $queueEmail['to_email'] . "\n";
echo " - Status: " . $queueEmail['status'] . "\n";
echo " - Créé: " . $queueEmail['created_at'] . "\n";
} else {
echo "✗ Aucun email trouvé dans la queue\n";
}
} else {
echo "✗ Échec de la génération du reçu\n";
echo "Consultez /var/www/geosector/api/logs/api.log pour plus de détails\n";
}
} catch (Exception $e) {
echo "✗ ERREUR: " . $e->getMessage() . "\n";
echo $e->getTraceAsString() . "\n";
exit(1);
}
echo "\n=== Fin du test ===\n";
exit(0);