Compare commits
2 Commits
7b78037175
...
v3.6.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b6808db25 | |||
| 232940b1eb |
@@ -10,6 +10,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Web: `cd web && npm run dev` - run Svelte dev server
|
||||
- Web build: `cd web && npm run build` - build web app for production
|
||||
|
||||
## Post-modification checks (OBLIGATOIRE)
|
||||
After modifying any code file, run the appropriate linter:
|
||||
- Dart/Flutter: `cd app && flutter analyze [modified_files]`
|
||||
- PHP: `php -l [modified_file]` (syntax check)
|
||||
|
||||
## Code Style Guidelines
|
||||
- Flutter/Dart: Follow Flutter lint rules in analysis_options.yaml
|
||||
- Naming: camelCase for variables/methods, PascalCase for classes/enums
|
||||
|
||||
153
HOWTO-PROKOV.md
Normal file
153
HOWTO-PROKOV.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Prokov - Gestion des tâches
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Prokov est l'outil de gestion de projets et tâches utilisé pour suivre l'avancement de tous les projets 2026.
|
||||
|
||||
**URL** : https://prokov.unikoffice.com
|
||||
**API** : https://prokov.unikoffice.com/api/
|
||||
|
||||
## Compte Claude
|
||||
|
||||
Claude Code peut interagir directement avec l'API Prokov.
|
||||
|
||||
| Paramètre | Valeur |
|
||||
|-----------|--------|
|
||||
| Email | pierre@d6mail.fr |
|
||||
| Password | d66,Pierre |
|
||||
| Entity | 1 |
|
||||
| Role | owner |
|
||||
|
||||
## Projets
|
||||
|
||||
| ID | Projet | Parent | Description |
|
||||
|----|--------|--------|-------------|
|
||||
| 1 | Prokov | - | Gestionnaire de tâches |
|
||||
| 2 | Sogoms | - | API auto-générée Go |
|
||||
| 4 | Geosector | - | Application Amicales Pompiers |
|
||||
| 14 | Geosector-App | 4 | App Flutter |
|
||||
| 15 | Geosector-API | 4 | API backend |
|
||||
| 16 | Geosector-Web | 4 | Site web |
|
||||
| 5 | Cleo | - | - |
|
||||
| 6 | Serveurs | - | Infra |
|
||||
| 8 | UnikOffice | - | - |
|
||||
| 21 | 2026 | - | Plateforme micro-services |
|
||||
| 22 | 2026-Go | 21 | Modules Go (Thierry) |
|
||||
| 23 | 2026-Flutter | 21 | App Flutter (Pierre) |
|
||||
| 24 | 2026-Infra | 21 | Infrastructure (commun) |
|
||||
|
||||
## Statuts
|
||||
|
||||
| ID | Nom | Actif |
|
||||
|----|-----|-------|
|
||||
| 1 | Backlog | Oui |
|
||||
| 2 | À faire | Oui |
|
||||
| 3 | En cours | Oui |
|
||||
| 4 | À tester | Oui |
|
||||
| 5 | Livré | Oui |
|
||||
| 6 | Terminé | Non |
|
||||
| 7 | Archivé | Non |
|
||||
|
||||
## Utilisation avec Claude Code
|
||||
|
||||
### Lire les tâches d'un projet
|
||||
|
||||
> "Montre-moi les tâches du projet 2026"
|
||||
|
||||
Claude va récupérer les tâches via l'API.
|
||||
|
||||
### Créer une tâche
|
||||
|
||||
> "Crée une tâche 'Implémenter mod-cpu' dans 2026-Go avec priorité 3"
|
||||
|
||||
### Mettre à jour un statut
|
||||
|
||||
> "Passe la tâche #170 en statut 'En cours'"
|
||||
|
||||
### Marquer comme terminé
|
||||
|
||||
> "Marque la tâche #170 comme terminée"
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentification
|
||||
|
||||
```bash
|
||||
# Login (récupère le token JWT)
|
||||
curl -s -X POST "https://prokov.unikoffice.com/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"pierre@d6mail.fr","password":"d66,Pierre"}'
|
||||
```
|
||||
|
||||
### Projets
|
||||
|
||||
```bash
|
||||
# Liste des projets
|
||||
curl -s "https://prokov.unikoffice.com/api/projects" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Créer un projet
|
||||
curl -s -X POST "https://prokov.unikoffice.com/api/projects" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Mon Projet","description":"...","color":"#2563eb"}'
|
||||
|
||||
# Créer un sous-projet
|
||||
curl -s -X POST "https://prokov.unikoffice.com/api/projects" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Sous-Projet","parent_id":21}'
|
||||
```
|
||||
|
||||
### Tâches
|
||||
|
||||
```bash
|
||||
# Tâches d'un projet
|
||||
curl -s "https://prokov.unikoffice.com/api/tasks?project_id=21" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Créer une tâche
|
||||
curl -s -X POST "https://prokov.unikoffice.com/api/tasks" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"Ma tâche","project_id":22,"status_id":2,"priority":3}'
|
||||
|
||||
# Mettre à jour une tâche
|
||||
curl -s -X PUT "https://prokov.unikoffice.com/api/tasks/170" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status_id":3}'
|
||||
```
|
||||
|
||||
### Statuts
|
||||
|
||||
```bash
|
||||
# Liste des statuts
|
||||
curl -s "https://prokov.unikoffice.com/api/statuses" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Workflow Git (à implémenter)
|
||||
|
||||
Le hook post-commit pourra détecter les `#ID` dans les messages de commit et mettre automatiquement les tâches en "À tester".
|
||||
|
||||
```bash
|
||||
git commit -m "feat: nouvelle fonctionnalité #170 #171"
|
||||
# → Tâches 170 et 171 passent en statut 4 (À tester)
|
||||
```
|
||||
|
||||
## Structure projets 2026
|
||||
|
||||
```
|
||||
/home/pierre/dev/2026/
|
||||
├── prokov/ # ID 1 - Gestionnaire tâches
|
||||
├── sogoms/ # ID 2 - API Go
|
||||
├── geosector/ # ID 4 - App géospatiale
|
||||
│ ├── app/ # ID 14
|
||||
│ ├── api/ # ID 15
|
||||
│ └── web/ # ID 16
|
||||
├── resalice/ # Migration vers Sogoms
|
||||
├── monipocket/ # À intégrer dans 2026
|
||||
├── unikoffice/ # ID 8
|
||||
└── cleo/ # ID 5
|
||||
```
|
||||
@@ -1,651 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -uo pipefail
|
||||
# Note: Removed -e to allow script to continue on errors
|
||||
# Errors are handled explicitly with ERROR_COUNT
|
||||
|
||||
# Parse command line arguments
|
||||
ONLY_DB=false
|
||||
if [[ "${1:-}" == "-onlydb" ]]; then
|
||||
ONLY_DB=true
|
||||
echo "Mode: Database backup only"
|
||||
fi
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="$SCRIPT_DIR/d6back.yaml"
|
||||
LOG_DIR="$SCRIPT_DIR/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
LOG_FILE="$LOG_DIR/d6back-$(date +%Y%m%d).log"
|
||||
ERROR_COUNT=0
|
||||
RECAP_FILE="/tmp/backup_recap_$$.txt"
|
||||
|
||||
# Lock file to prevent concurrent executions
|
||||
LOCK_FILE="/var/lock/d6back.lock"
|
||||
exec 200>"$LOCK_FILE"
|
||||
if ! flock -n 200; then
|
||||
echo "ERROR: Another backup is already running" >&2
|
||||
exit 1
|
||||
fi
|
||||
trap 'flock -u 200' EXIT
|
||||
|
||||
# Clean old log files (keep only last 10)
|
||||
find "$LOG_DIR" -maxdepth 1 -name "d6back-*.log" -type f 2>/dev/null | sort -r | tail -n +11 | xargs -r rm -f || true
|
||||
|
||||
# Check dependencies - COMMENTED OUT
|
||||
# for cmd in yq ssh tar openssl; do
|
||||
# if ! command -v "$cmd" &> /dev/null; then
|
||||
# echo "ERROR: $cmd is required but not installed" | tee -a "$LOG_FILE"
|
||||
# exit 1
|
||||
# fi
|
||||
# done
|
||||
|
||||
# Load config
|
||||
DIR_BACKUP=$(yq '.global.dir_backup' "$CONFIG_FILE" | tr -d '"')
|
||||
ENC_KEY_PATH=$(yq '.global.enc_key' "$CONFIG_FILE" | tr -d '"')
|
||||
BACKUP_SERVER=$(yq '.global.backup_server // "BACKUP"' "$CONFIG_FILE" | tr -d '"')
|
||||
EMAIL_TO=$(yq '.global.email_to // "support@unikoffice.com"' "$CONFIG_FILE" | tr -d '"')
|
||||
KEEP_DIRS=$(yq '.global.keep_dirs' "$CONFIG_FILE" | tr -d '"')
|
||||
KEEP_DB=$(yq '.global.keep_db' "$CONFIG_FILE" | tr -d '"')
|
||||
|
||||
# Load encryption key
|
||||
if [[ ! -f "$ENC_KEY_PATH" ]]; then
|
||||
echo "ERROR: Encryption key not found: $ENC_KEY_PATH" | tee -a "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
ENC_KEY=$(cat "$ENC_KEY_PATH")
|
||||
|
||||
echo "=== Backup Started $(date) ===" | tee -a "$LOG_FILE"
|
||||
echo "Backup directory: $DIR_BACKUP" | tee -a "$LOG_FILE"
|
||||
|
||||
# Check available disk space
|
||||
DISK_USAGE=$(df "$DIR_BACKUP" | tail -1 | awk '{print $5}' | sed 's/%//')
|
||||
DISK_FREE=$((100 - DISK_USAGE))
|
||||
|
||||
if [[ $DISK_FREE -lt 20 ]]; then
|
||||
echo "WARNING: Low disk space! Only ${DISK_FREE}% free on backup partition" | tee -a "$LOG_FILE"
|
||||
|
||||
# Send warning email
|
||||
echo "Sending DISK SPACE WARNING email to $EMAIL_TO (${DISK_FREE}% free)" | tee -a "$LOG_FILE"
|
||||
if command -v msmtp &> /dev/null; then
|
||||
{
|
||||
echo "To: $EMAIL_TO"
|
||||
echo "Subject: Backup${BACKUP_SERVER} WARNING - Low disk space (${DISK_FREE}% free)"
|
||||
echo ""
|
||||
echo "WARNING: Low disk space on $(hostname)"
|
||||
echo ""
|
||||
echo "Backup directory: $DIR_BACKUP"
|
||||
echo "Disk usage: ${DISK_USAGE}%"
|
||||
echo "Free space: ${DISK_FREE}%"
|
||||
echo ""
|
||||
echo "The backup will continue but please free up some space soon."
|
||||
echo ""
|
||||
echo "Date: $(date '+%d.%m.%Y %H:%M')"
|
||||
} | msmtp "$EMAIL_TO"
|
||||
echo "DISK SPACE WARNING email sent successfully to $EMAIL_TO" | tee -a "$LOG_FILE"
|
||||
else
|
||||
echo "WARNING: msmtp not found - DISK WARNING email NOT sent" | tee -a "$LOG_FILE"
|
||||
fi
|
||||
else
|
||||
echo "Disk space OK: ${DISK_FREE}% free" | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
# Initialize recap file
|
||||
echo "BACKUP REPORT - $(hostname) - $(date '+%d.%m.%Y %H')h" > "$RECAP_FILE"
|
||||
echo "========================================" >> "$RECAP_FILE"
|
||||
echo "" >> "$RECAP_FILE"
|
||||
|
||||
# Function to format size in MB with thousand separator
|
||||
format_size_mb() {
|
||||
local file="$1"
|
||||
if [[ -f "$file" ]]; then
|
||||
local size_kb=$(du -k "$file" | cut -f1)
|
||||
local size_mb=$((size_kb / 1024))
|
||||
# Add thousand separator with printf and sed
|
||||
printf "%d" "$size_mb" | sed ':a;s/\B[0-9]\{3\}\>/\.&/;ta'
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to calculate age in days
|
||||
get_age_days() {
|
||||
local file="$1"
|
||||
local now=$(date +%s)
|
||||
local file_time=$(stat -c %Y "$file" 2>/dev/null || echo 0)
|
||||
echo $(( (now - file_time) / 86400 ))
|
||||
}
|
||||
|
||||
# Function to get week number of year for a file
|
||||
get_week_year() {
|
||||
local file="$1"
|
||||
local file_time=$(stat -c %Y "$file" 2>/dev/null || echo 0)
|
||||
date -d "@$file_time" +"%Y-%W"
|
||||
}
|
||||
|
||||
# Function to cleanup old backups according to retention policy
|
||||
cleanup_old_backups() {
|
||||
local DELETED_COUNT=0
|
||||
local KEPT_COUNT=0
|
||||
|
||||
echo "" | tee -a "$LOG_FILE"
|
||||
echo "=== Starting Backup Retention Cleanup ===" | tee -a "$LOG_FILE"
|
||||
|
||||
# Parse retention periods
|
||||
local KEEP_DIRS_DAYS=${KEEP_DIRS%d} # Remove 'd' suffix
|
||||
|
||||
# Parse database retention (5d,3w,15m)
|
||||
IFS=',' read -r KEEP_DB_DAILY KEEP_DB_WEEKLY KEEP_DB_MONTHLY <<< "$KEEP_DB"
|
||||
local KEEP_DB_DAILY_DAYS=${KEEP_DB_DAILY%d}
|
||||
local KEEP_DB_WEEKLY_WEEKS=${KEEP_DB_WEEKLY%w}
|
||||
local KEEP_DB_MONTHLY_MONTHS=${KEEP_DB_MONTHLY%m}
|
||||
|
||||
# Convert to days
|
||||
local KEEP_DB_WEEKLY_DAYS=$((KEEP_DB_WEEKLY_WEEKS * 7))
|
||||
local KEEP_DB_MONTHLY_DAYS=$((KEEP_DB_MONTHLY_MONTHS * 30))
|
||||
|
||||
echo "Retention policy: dirs=${KEEP_DIRS_DAYS}d, db=${KEEP_DB_DAILY_DAYS}d/${KEEP_DB_WEEKLY_WEEKS}w/${KEEP_DB_MONTHLY_MONTHS}m" | tee -a "$LOG_FILE"
|
||||
|
||||
# Process each host directory
|
||||
for host_dir in "$DIR_BACKUP"/*; do
|
||||
if [[ ! -d "$host_dir" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local host_name=$(basename "$host_dir")
|
||||
echo " Cleaning host: $host_name" | tee -a "$LOG_FILE"
|
||||
|
||||
# Clean directory backups (*.tar.gz but not *.sql.gz.enc)
|
||||
while IFS= read -r -d '' file; do
|
||||
if [[ $(basename "$file") == *".sql.gz.enc" ]]; then
|
||||
continue # Skip SQL files
|
||||
fi
|
||||
|
||||
local age_days=$(get_age_days "$file")
|
||||
|
||||
if [[ $age_days -gt $KEEP_DIRS_DAYS ]]; then
|
||||
rm -f "$file"
|
||||
echo " Deleted: $(basename "$file") (${age_days}d > ${KEEP_DIRS_DAYS}d)" | tee -a "$LOG_FILE"
|
||||
((DELETED_COUNT++))
|
||||
else
|
||||
((KEPT_COUNT++))
|
||||
fi
|
||||
done < <(find "$host_dir" -name "*.tar.gz" -type f -print0 2>/dev/null)
|
||||
|
||||
# Clean database backups with retention policy
|
||||
declare -A db_files
|
||||
|
||||
while IFS= read -r -d '' file; do
|
||||
local filename=$(basename "$file")
|
||||
local db_name=${filename%%_*}
|
||||
|
||||
if [[ -z "${db_files[$db_name]:-}" ]]; then
|
||||
db_files[$db_name]="$file"
|
||||
else
|
||||
db_files[$db_name]+=$'\n'"$file"
|
||||
fi
|
||||
done < <(find "$host_dir" -name "*.sql.gz.enc" -type f -print0 2>/dev/null)
|
||||
|
||||
# Process each database
|
||||
for db_name in "${!db_files[@]}"; do
|
||||
# Sort files by age (newest first)
|
||||
mapfile -t files < <(echo "${db_files[$db_name]}" | while IFS= read -r f; do
|
||||
echo "$f"
|
||||
done | xargs -I {} stat -c "%Y {}" {} 2>/dev/null | sort -rn | cut -d' ' -f2-)
|
||||
|
||||
# Track which files to keep
|
||||
declare -A keep_daily
|
||||
declare -A keep_weekly
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
local age_days=$(get_age_days "$file")
|
||||
|
||||
if [[ $age_days -le $KEEP_DB_DAILY_DAYS ]]; then
|
||||
# Keep all files within daily retention
|
||||
((KEPT_COUNT++))
|
||||
|
||||
elif [[ $age_days -le $KEEP_DB_WEEKLY_DAYS ]]; then
|
||||
# Weekly retention: keep one per day
|
||||
local file_date=$(date -d "@$(stat -c %Y "$file")" +"%Y-%m-%d")
|
||||
|
||||
if [[ -z "${keep_daily[$file_date]:-}" ]]; then
|
||||
keep_daily[$file_date]="$file"
|
||||
((KEPT_COUNT++))
|
||||
else
|
||||
rm -f "$file"
|
||||
((DELETED_COUNT++))
|
||||
fi
|
||||
|
||||
elif [[ $age_days -le $KEEP_DB_MONTHLY_DAYS ]]; then
|
||||
# Monthly retention: keep one per week
|
||||
local week_year=$(get_week_year "$file")
|
||||
|
||||
if [[ -z "${keep_weekly[$week_year]:-}" ]]; then
|
||||
keep_weekly[$week_year]="$file"
|
||||
((KEPT_COUNT++))
|
||||
else
|
||||
rm -f "$file"
|
||||
((DELETED_COUNT++))
|
||||
fi
|
||||
|
||||
else
|
||||
# Beyond retention period
|
||||
rm -f "$file"
|
||||
echo " Deleted: $(basename "$file") (${age_days}d > ${KEEP_DB_MONTHLY_DAYS}d)" | tee -a "$LOG_FILE"
|
||||
((DELETED_COUNT++))
|
||||
fi
|
||||
done
|
||||
|
||||
unset keep_daily keep_weekly
|
||||
done
|
||||
|
||||
unset db_files
|
||||
done
|
||||
|
||||
echo "Cleanup completed: ${DELETED_COUNT} deleted, ${KEPT_COUNT} kept" | tee -a "$LOG_FILE"
|
||||
|
||||
# Add cleanup summary to recap file
|
||||
echo "" >> "$RECAP_FILE"
|
||||
echo "CLEANUP SUMMARY:" >> "$RECAP_FILE"
|
||||
echo " Files deleted: $DELETED_COUNT" >> "$RECAP_FILE"
|
||||
echo " Files kept: $KEPT_COUNT" >> "$RECAP_FILE"
|
||||
}
|
||||
|
||||
# Function to backup a single database (must be defined before use)
|
||||
backup_database() {
|
||||
local database="$1"
|
||||
local timestamp="$(date +%Y%m%d_%H)"
|
||||
local backup_file="$backup_dir/sql/${database}_${timestamp}.sql.gz.enc"
|
||||
|
||||
echo " Backing up database: $database" | tee -a "$LOG_FILE"
|
||||
|
||||
if [[ "$ssh_user" != "root" ]]; then
|
||||
CMD_PREFIX="sudo"
|
||||
else
|
||||
CMD_PREFIX=""
|
||||
fi
|
||||
|
||||
# Execute backup with encryption
|
||||
# First test MySQL connection to get clear error messages (|| true to continue on error)
|
||||
MYSQL_TEST=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
|
||||
"$CMD_PREFIX incus exec $container_name -- bash -c 'cat > /tmp/d6back.cnf << EOF
|
||||
[client]
|
||||
user=$db_user
|
||||
password=$db_pass
|
||||
host=$db_host
|
||||
EOF
|
||||
chmod 600 /tmp/d6back.cnf
|
||||
mariadb --defaults-extra-file=/tmp/d6back.cnf -e \"SELECT 1\" 2>&1
|
||||
rm -f /tmp/d6back.cnf'" 2>/dev/null || true)
|
||||
|
||||
if ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
|
||||
"$CMD_PREFIX incus exec $container_name -- bash -c 'cat > /tmp/d6back.cnf << EOF
|
||||
[client]
|
||||
user=$db_user
|
||||
password=$db_pass
|
||||
host=$db_host
|
||||
EOF
|
||||
chmod 600 /tmp/d6back.cnf
|
||||
mariadb-dump --defaults-extra-file=/tmp/d6back.cnf --single-transaction --lock-tables=false --add-drop-table --create-options --databases $database 2>/dev/null | sed -e \"/^CREATE DATABASE/s/\\\`$database\\\`/\\\`${database}_${timestamp}\\\`/\" -e \"/^USE/s/\\\`$database\\\`/\\\`${database}_${timestamp}\\\`/\" | gzip
|
||||
rm -f /tmp/d6back.cnf'" | \
|
||||
openssl enc -aes-256-cbc -salt -pass pass:"$ENC_KEY" -pbkdf2 > "$backup_file" 2>/dev/null; then
|
||||
|
||||
# Validate backup file size (encrypted SQL should be > 100 bytes)
|
||||
if [[ -f "$backup_file" ]]; then
|
||||
file_size=$(stat -c%s "$backup_file" 2>/dev/null || echo 0)
|
||||
if [[ $file_size -lt 100 ]]; then
|
||||
# Analyze MySQL connection test results
|
||||
if [[ "$MYSQL_TEST" == *"Access denied"* ]]; then
|
||||
echo " ERROR: MySQL authentication failed for $database on $host_name/$container_name" | tee -a "$LOG_FILE"
|
||||
echo " User: $db_user@$db_host - Check password in configuration" | tee -a "$LOG_FILE"
|
||||
elif [[ "$MYSQL_TEST" == *"Unknown database"* ]]; then
|
||||
echo " ERROR: Database '$database' does not exist on $host_name/$container_name" | tee -a "$LOG_FILE"
|
||||
elif [[ "$MYSQL_TEST" == *"Can't connect"* ]]; then
|
||||
echo " ERROR: Cannot connect to MySQL server at $db_host in $container_name" | tee -a "$LOG_FILE"
|
||||
else
|
||||
echo " ERROR: Backup file too small (${file_size} bytes): $database on $host_name/$container_name" | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
((ERROR_COUNT++))
|
||||
rm -f "$backup_file"
|
||||
else
|
||||
size=$(du -h "$backup_file" | cut -f1)
|
||||
size_mb=$(format_size_mb "$backup_file")
|
||||
echo " ✓ Saved (encrypted): $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
|
||||
echo " SQL: $(basename "$backup_file") - ${size_mb} Mo" >> "$RECAP_FILE"
|
||||
|
||||
# Test backup integrity
|
||||
if ! openssl enc -aes-256-cbc -d -pass pass:"$ENC_KEY" -pbkdf2 -in "$backup_file" | gunzip -t 2>/dev/null; then
|
||||
echo " ERROR: Backup integrity check failed for $database" | tee -a "$LOG_FILE"
|
||||
((ERROR_COUNT++))
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " ERROR: Backup file not created: $database" | tee -a "$LOG_FILE"
|
||||
((ERROR_COUNT++))
|
||||
fi
|
||||
else
|
||||
# Analyze MySQL connection test for failed backup
|
||||
if [[ "$MYSQL_TEST" == *"Access denied"* ]]; then
|
||||
echo " ERROR: MySQL authentication failed for $database on $host_name/$container_name" | tee -a "$LOG_FILE"
|
||||
echo " User: $db_user@$db_host - Check password in configuration" | tee -a "$LOG_FILE"
|
||||
elif [[ "$MYSQL_TEST" == *"Unknown database"* ]]; then
|
||||
echo " ERROR: Database '$database' does not exist on $host_name/$container_name" | tee -a "$LOG_FILE"
|
||||
elif [[ "$MYSQL_TEST" == *"Can't connect"* ]]; then
|
||||
echo " ERROR: Cannot connect to MySQL server at $db_host in $container_name" | tee -a "$LOG_FILE"
|
||||
else
|
||||
echo " ERROR: Failed to backup database $database on $host_name/$container_name" | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
((ERROR_COUNT++))
|
||||
rm -f "$backup_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Process each host
|
||||
host_count=$(yq '.hosts | length' "$CONFIG_FILE")
|
||||
|
||||
for ((i=0; i<$host_count; i++)); do
|
||||
host_name=$(yq ".hosts[$i].name" "$CONFIG_FILE" | tr -d '"')
|
||||
host_ip=$(yq ".hosts[$i].ip" "$CONFIG_FILE" | tr -d '"')
|
||||
ssh_user=$(yq ".hosts[$i].user" "$CONFIG_FILE" | tr -d '"')
|
||||
ssh_key=$(yq ".hosts[$i].key" "$CONFIG_FILE" | tr -d '"')
|
||||
ssh_port=$(yq ".hosts[$i].port // 22" "$CONFIG_FILE" | tr -d '"')
|
||||
|
||||
echo "Processing host: $host_name ($host_ip)" | tee -a "$LOG_FILE"
|
||||
echo "" >> "$RECAP_FILE"
|
||||
echo "HOST: $host_name ($host_ip)" >> "$RECAP_FILE"
|
||||
echo "----------------------------" >> "$RECAP_FILE"
|
||||
|
||||
# Test SSH connection
|
||||
if ! ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 -o StrictHostKeyChecking=no "$ssh_user@$host_ip" "true" 2>/dev/null; then
|
||||
echo " ERROR: Cannot connect to $host_name ($host_ip)" | tee -a "$LOG_FILE"
|
||||
((ERROR_COUNT++))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Process containers
|
||||
container_count=$(yq ".hosts[$i].containers | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
|
||||
|
||||
for ((c=0; c<$container_count; c++)); do
|
||||
container_name=$(yq ".hosts[$i].containers[$c].name" "$CONFIG_FILE" | tr -d '"')
|
||||
|
||||
echo " Processing container: $container_name" | tee -a "$LOG_FILE"
|
||||
|
||||
# Add container to recap
|
||||
echo "" >> "$RECAP_FILE"
|
||||
echo " Container: $container_name" >> "$RECAP_FILE"
|
||||
|
||||
# Create backup directories
|
||||
backup_dir="$DIR_BACKUP/$host_name/$container_name"
|
||||
mkdir -p "$backup_dir"
|
||||
mkdir -p "$backup_dir/sql"
|
||||
|
||||
# Backup directories (skip if -onlydb mode)
|
||||
if [[ "$ONLY_DB" == "false" ]]; then
|
||||
dir_count=$(yq ".hosts[$i].containers[$c].dirs | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
|
||||
|
||||
for ((d=0; d<$dir_count; d++)); do
|
||||
dir_path=$(yq ".hosts[$i].containers[$c].dirs[$d]" "$CONFIG_FILE" | sed 's/^"\|"$//g')
|
||||
|
||||
# Use sudo if not root
|
||||
if [[ "$ssh_user" != "root" ]]; then
|
||||
CMD_PREFIX="sudo"
|
||||
else
|
||||
CMD_PREFIX=""
|
||||
fi
|
||||
|
||||
# Special handling for /var/www - backup each subdirectory separately
|
||||
if [[ "$dir_path" == "/var/www" ]]; then
|
||||
echo " Backing up subdirectories of $dir_path" | tee -a "$LOG_FILE"
|
||||
|
||||
# Get list of subdirectories
|
||||
subdirs=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
|
||||
"$CMD_PREFIX incus exec $container_name -- find /var/www -maxdepth 1 -type d ! -path /var/www" 2>/dev/null || echo "")
|
||||
|
||||
for subdir in $subdirs; do
|
||||
subdir_name=$(basename "$subdir" | tr '/' '_')
|
||||
backup_file="$backup_dir/www_${subdir_name}_$(date +%Y%m%d_%H).tar.gz"
|
||||
|
||||
echo " Backing up: $subdir" | tee -a "$LOG_FILE"
|
||||
|
||||
if ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
|
||||
"$CMD_PREFIX incus exec $container_name -- tar czf - $subdir 2>/dev/null" > "$backup_file"; then
|
||||
|
||||
# Validate backup file size (tar.gz should be > 1KB)
|
||||
if [[ -f "$backup_file" ]]; then
|
||||
file_size=$(stat -c%s "$backup_file" 2>/dev/null || echo 0)
|
||||
if [[ $file_size -lt 1024 ]]; then
|
||||
echo " WARNING: Backup file very small (${file_size} bytes): $subdir" | tee -a "$LOG_FILE"
|
||||
# Keep the file but note it's small
|
||||
size=$(du -h "$backup_file" | cut -f1)
|
||||
size_mb=$(format_size_mb "$backup_file")
|
||||
echo " ✓ Saved (small): $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
|
||||
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo (WARNING: small)" >> "$RECAP_FILE"
|
||||
else
|
||||
size=$(du -h "$backup_file" | cut -f1)
|
||||
size_mb=$(format_size_mb "$backup_file")
|
||||
echo " ✓ Saved: $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
|
||||
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo" >> "$RECAP_FILE"
|
||||
fi
|
||||
|
||||
# Test tar integrity
|
||||
if ! tar tzf "$backup_file" >/dev/null 2>&1; then
|
||||
echo " ERROR: Tar integrity check failed" | tee -a "$LOG_FILE"
|
||||
((ERROR_COUNT++))
|
||||
fi
|
||||
else
|
||||
echo " ERROR: Backup file not created: $subdir" | tee -a "$LOG_FILE"
|
||||
((ERROR_COUNT++))
|
||||
fi
|
||||
else
|
||||
echo " ERROR: Failed to backup $subdir" | tee -a "$LOG_FILE"
|
||||
((ERROR_COUNT++))
|
||||
rm -f "$backup_file"
|
||||
fi
|
||||
done
|
||||
else
|
||||
# Normal backup for other directories
|
||||
dir_name=$(basename "$dir_path" | tr '/' '_')
|
||||
backup_file="$backup_dir/${dir_name}_$(date +%Y%m%d_%H).tar.gz"
|
||||
|
||||
echo " Backing up: $dir_path" | tee -a "$LOG_FILE"
|
||||
|
||||
if ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
|
||||
"$CMD_PREFIX incus exec $container_name -- tar czf - $dir_path 2>/dev/null" > "$backup_file"; then
|
||||
|
||||
# Validate backup file size (tar.gz should be > 1KB)
|
||||
if [[ -f "$backup_file" ]]; then
|
||||
file_size=$(stat -c%s "$backup_file" 2>/dev/null || echo 0)
|
||||
if [[ $file_size -lt 1024 ]]; then
|
||||
echo " WARNING: Backup file very small (${file_size} bytes): $dir_path" | tee -a "$LOG_FILE"
|
||||
# Keep the file but note it's small
|
||||
size=$(du -h "$backup_file" | cut -f1)
|
||||
size_mb=$(format_size_mb "$backup_file")
|
||||
echo " ✓ Saved (small): $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
|
||||
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo (WARNING: small)" >> "$RECAP_FILE"
|
||||
else
|
||||
size=$(du -h "$backup_file" | cut -f1)
|
||||
size_mb=$(format_size_mb "$backup_file")
|
||||
echo " ✓ Saved: $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
|
||||
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo" >> "$RECAP_FILE"
|
||||
fi
|
||||
|
||||
# Test tar integrity
|
||||
if ! tar tzf "$backup_file" >/dev/null 2>&1; then
|
||||
echo " ERROR: Tar integrity check failed" | tee -a "$LOG_FILE"
|
||||
((ERROR_COUNT++))
|
||||
fi
|
||||
else
|
||||
echo " ERROR: Backup file not created: $dir_path" | tee -a "$LOG_FILE"
|
||||
((ERROR_COUNT++))
|
||||
fi
|
||||
else
|
||||
echo " ERROR: Failed to backup $dir_path" | tee -a "$LOG_FILE"
|
||||
((ERROR_COUNT++))
|
||||
rm -f "$backup_file"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi # End of directory backup section
|
||||
|
||||
# Backup databases
|
||||
db_user=$(yq ".hosts[$i].containers[$c].db_user" "$CONFIG_FILE" 2>/dev/null | tr -d '"')
|
||||
db_pass=$(yq ".hosts[$i].containers[$c].db_pass" "$CONFIG_FILE" 2>/dev/null | tr -d '"')
|
||||
db_host=$(yq ".hosts[$i].containers[$c].db_host // \"localhost\"" "$CONFIG_FILE" 2>/dev/null | tr -d '"')
|
||||
|
||||
# Check if we're in onlydb mode
|
||||
if [[ "$ONLY_DB" == "true" ]]; then
|
||||
# Use onlydb list if it exists
|
||||
onlydb_count=$(yq ".hosts[$i].containers[$c].onlydb | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
|
||||
if [[ "$onlydb_count" != "0" ]] && [[ "$onlydb_count" != "null" ]]; then
|
||||
db_count="$onlydb_count"
|
||||
use_onlydb=true
|
||||
else
|
||||
# No onlydb list, skip this container in onlydb mode
|
||||
continue
|
||||
fi
|
||||
else
|
||||
# Normal mode - use databases list
|
||||
db_count=$(yq ".hosts[$i].containers[$c].databases | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
|
||||
use_onlydb=false
|
||||
fi
|
||||
|
||||
if [[ -n "$db_user" ]] && [[ -n "$db_pass" ]] && [[ "$db_count" != "0" ]]; then
|
||||
for ((db=0; db<$db_count; db++)); do
|
||||
if [[ "$use_onlydb" == "true" ]]; then
|
||||
db_name=$(yq ".hosts[$i].containers[$c].onlydb[$db]" "$CONFIG_FILE" | tr -d '"')
|
||||
else
|
||||
db_name=$(yq ".hosts[$i].containers[$c].databases[$db]" "$CONFIG_FILE" | tr -d '"')
|
||||
fi
|
||||
|
||||
if [[ "$db_name" == "ALL" ]]; then
|
||||
echo " Fetching all databases..." | tee -a "$LOG_FILE"
|
||||
|
||||
# Get database list
|
||||
if [[ "$ssh_user" != "root" ]]; then
|
||||
db_list=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
|
||||
"sudo incus exec $container_name -- bash -c 'cat > /tmp/d6back.cnf << EOF
|
||||
[client]
|
||||
user=$db_user
|
||||
password=$db_pass
|
||||
host=$db_host
|
||||
EOF
|
||||
chmod 600 /tmp/d6back.cnf
|
||||
mariadb --defaults-extra-file=/tmp/d6back.cnf -e \"SHOW DATABASES;\" 2>/dev/null
|
||||
rm -f /tmp/d6back.cnf'" | \
|
||||
grep -Ev '^(Database|information_schema|performance_schema|mysql|sys)$' || echo "")
|
||||
else
|
||||
db_list=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
|
||||
"incus exec $container_name -- bash -c 'cat > /tmp/d6back.cnf << EOF
|
||||
[client]
|
||||
user=$db_user
|
||||
password=$db_pass
|
||||
host=$db_host
|
||||
EOF
|
||||
chmod 600 /tmp/d6back.cnf
|
||||
mariadb --defaults-extra-file=/tmp/d6back.cnf -e \"SHOW DATABASES;\" 2>/dev/null
|
||||
rm -f /tmp/d6back.cnf'" | \
|
||||
grep -Ev '^(Database|information_schema|performance_schema|mysql|sys)$' || echo "")
|
||||
fi
|
||||
|
||||
# Backup each database
|
||||
for single_db in $db_list; do
|
||||
backup_database "$single_db"
|
||||
done
|
||||
else
|
||||
backup_database "$db_name"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "=== Backup Completed $(date) ===" | tee -a "$LOG_FILE"
|
||||
|
||||
# Cleanup old backups according to retention policy
|
||||
cleanup_old_backups
|
||||
|
||||
# Show summary
|
||||
total_size=$(du -sh "$DIR_BACKUP" 2>/dev/null | cut -f1)
|
||||
echo "Total backup size: $total_size" | tee -a "$LOG_FILE"
|
||||
|
||||
# Add summary to recap
|
||||
echo "" >> "$RECAP_FILE"
|
||||
echo "========================================" >> "$RECAP_FILE"
|
||||
|
||||
# Add size details per host/container
|
||||
echo "BACKUP SIZES:" >> "$RECAP_FILE"
|
||||
for host_dir in "$DIR_BACKUP"/*; do
|
||||
if [[ -d "$host_dir" ]]; then
|
||||
host_name=$(basename "$host_dir")
|
||||
host_size=$(du -sh "$host_dir" 2>/dev/null | cut -f1)
|
||||
echo "" >> "$RECAP_FILE"
|
||||
echo " $host_name: $host_size" >> "$RECAP_FILE"
|
||||
|
||||
# Size per container
|
||||
for container_dir in "$host_dir"/*; do
|
||||
if [[ -d "$container_dir" ]]; then
|
||||
container_name=$(basename "$container_dir")
|
||||
container_size=$(du -sh "$container_dir" 2>/dev/null | cut -f1)
|
||||
echo " - $container_name: $container_size" >> "$RECAP_FILE"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> "$RECAP_FILE"
|
||||
echo "TOTAL SIZE: $total_size" >> "$RECAP_FILE"
|
||||
echo "COMPLETED: $(date '+%d.%m.%Y %H:%M')" >> "$RECAP_FILE"
|
||||
|
||||
# Prepare email subject with date format
|
||||
DATE_SUBJECT=$(date '+%d.%m.%Y %H')
|
||||
|
||||
# Send recap email
|
||||
if [[ $ERROR_COUNT -gt 0 ]]; then
|
||||
echo "Total errors: $ERROR_COUNT" | tee -a "$LOG_FILE"
|
||||
|
||||
# Add errors to recap
|
||||
echo "" >> "$RECAP_FILE"
|
||||
echo "ERRORS DETECTED: $ERROR_COUNT" >> "$RECAP_FILE"
|
||||
echo "----------------------------" >> "$RECAP_FILE"
|
||||
grep -i "ERROR" "$LOG_FILE" >> "$RECAP_FILE"
|
||||
|
||||
# Send email with ERROR in subject
|
||||
echo "Sending ERROR email to $EMAIL_TO (Errors found: $ERROR_COUNT)" | tee -a "$LOG_FILE"
|
||||
if command -v msmtp &> /dev/null; then
|
||||
{
|
||||
echo "To: $EMAIL_TO"
|
||||
echo "Subject: Backup${BACKUP_SERVER} ERROR $DATE_SUBJECT"
|
||||
echo ""
|
||||
cat "$RECAP_FILE"
|
||||
} | msmtp "$EMAIL_TO"
|
||||
echo "ERROR email sent successfully to $EMAIL_TO" | tee -a "$LOG_FILE"
|
||||
else
|
||||
echo "WARNING: msmtp not found - ERROR email NOT sent" | tee -a "$LOG_FILE"
|
||||
fi
|
||||
else
|
||||
echo "Backup completed successfully with no errors" | tee -a "$LOG_FILE"
|
||||
|
||||
# Send success recap email
|
||||
echo "Sending SUCCESS recap email to $EMAIL_TO" | tee -a "$LOG_FILE"
|
||||
if command -v msmtp &> /dev/null; then
|
||||
{
|
||||
echo "To: $EMAIL_TO"
|
||||
echo "Subject: Backup${BACKUP_SERVER} $DATE_SUBJECT"
|
||||
echo ""
|
||||
cat "$RECAP_FILE"
|
||||
} | msmtp "$EMAIL_TO"
|
||||
echo "SUCCESS recap email sent successfully to $EMAIL_TO" | tee -a "$LOG_FILE"
|
||||
else
|
||||
echo "WARNING: msmtp not found - SUCCESS recap email NOT sent" | tee -a "$LOG_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Clean up recap file
|
||||
rm -f "$RECAP_FILE"
|
||||
|
||||
# Exit with error code if there were errors
|
||||
if [[ $ERROR_COUNT -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,112 +0,0 @@
|
||||
# Configuration for MariaDB and directories backup
|
||||
# Backup structure: $dir_backup/$hostname/$containername/ for dirs
|
||||
# $dir_backup/$hostname/$containername/sql/ for databases
|
||||
|
||||
# Global parameters
|
||||
global:
|
||||
backup_server: PM7 # Nom du serveur de backup (PM7, PM1, etc.)
|
||||
email_to: support@unikoffice.com # Email de notification
|
||||
dir_backup: /var/pierre/back # Base backup directory
|
||||
enc_key: /home/pierre/.key_enc # Encryption key for SQL backups
|
||||
keep_dirs: 7d # Garde 7 jours pour les dirs
|
||||
keep_db: 5d,3w,15m # 5 jours complets, 3 semaines (1/jour), 15 mois (1/semaine)
|
||||
|
||||
# Hosts configuration
|
||||
hosts:
|
||||
- name: IN2
|
||||
ip: 145.239.9.105
|
||||
user: debian
|
||||
key: /home/pierre/.ssh/backup_key
|
||||
port: 22
|
||||
dirs:
|
||||
- /etc/nginx
|
||||
containers:
|
||||
- name: nx4
|
||||
db_user: root
|
||||
db_pass: MyDebServer,90b
|
||||
db_host: localhost
|
||||
dirs:
|
||||
- /etc/nginx
|
||||
- /var/www
|
||||
databases:
|
||||
- ALL # Backup all databases
|
||||
onlydb: # Used only with -onlydb parameter (optional)
|
||||
- turing
|
||||
|
||||
- name: IN3
|
||||
ip: 195.154.80.116
|
||||
user: pierre
|
||||
key: /home/pierre/.ssh/backup_key
|
||||
port: 22
|
||||
dirs:
|
||||
- /etc/nginx
|
||||
containers:
|
||||
- name: nx4
|
||||
db_user: root
|
||||
db_pass: MyAlpLocal,90b
|
||||
db_host: localhost
|
||||
dirs:
|
||||
- /etc/nginx
|
||||
- /var/www
|
||||
databases:
|
||||
- ALL # Backup all databases
|
||||
onlydb: # Used only with -onlydb parameter (optional)
|
||||
- geosector
|
||||
|
||||
- name: rca-geo
|
||||
dirs:
|
||||
- /etc/nginx
|
||||
- /var/www
|
||||
|
||||
- name: dva-res
|
||||
db_user: root
|
||||
db_pass: MyAlpineDb.90b
|
||||
db_host: localhost
|
||||
dirs:
|
||||
- /etc/nginx
|
||||
- /var/www
|
||||
databases:
|
||||
- ALL
|
||||
onlydb:
|
||||
- resalice
|
||||
|
||||
- name: dva-front
|
||||
dirs:
|
||||
- /etc/nginx
|
||||
- /var/www
|
||||
|
||||
- name: maria3
|
||||
db_user: root
|
||||
db_pass: MyAlpLocal,90b
|
||||
db_host: localhost
|
||||
dirs:
|
||||
- /etc/my.cnf.d
|
||||
- /var/osm
|
||||
- /var/log
|
||||
databases:
|
||||
- ALL
|
||||
onlydb:
|
||||
- cleo
|
||||
- rca_geo
|
||||
|
||||
- name: IN4
|
||||
ip: 51.159.7.190
|
||||
user: pierre
|
||||
key: /home/pierre/.ssh/backup_key
|
||||
port: 22
|
||||
dirs:
|
||||
- /etc/nginx
|
||||
containers:
|
||||
- name: maria4
|
||||
db_user: root
|
||||
db_pass: MyAlpLocal,90b
|
||||
db_host: localhost
|
||||
dirs:
|
||||
- /etc/my.cnf.d
|
||||
- /var/osm
|
||||
- /var/log
|
||||
databases:
|
||||
- ALL
|
||||
onlydb:
|
||||
- cleo
|
||||
- pra_geo
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
CONFIG_FILE="backpm7.yaml"
|
||||
|
||||
# Check if file argument is provided
|
||||
if [ $# -eq 0 ]; then
|
||||
echo -e "${RED}Error: No input file specified${NC}"
|
||||
echo "Usage: $0 <database.sql.gz.enc>"
|
||||
echo "Example: $0 wordpress_20250905_14.sql.gz.enc"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INPUT_FILE="$1"
|
||||
|
||||
# Check if input file exists
|
||||
if [ ! -f "$INPUT_FILE" ]; then
|
||||
echo -e "${RED}Error: File not found: $INPUT_FILE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to load encryption key from config
|
||||
load_key_from_config() {
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo -e "${YELLOW}Warning: $CONFIG_FILE not found${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check for yq
|
||||
if ! command -v yq &> /dev/null; then
|
||||
echo -e "${RED}Error: yq is required to read config file${NC}"
|
||||
echo "Install with: sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local key_path=$(yq '.global.enc_key' "$CONFIG_FILE" | tr -d '"')
|
||||
|
||||
if [ -z "$key_path" ]; then
|
||||
echo -e "${RED}Error: enc_key not found in $CONFIG_FILE${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$key_path" ]; then
|
||||
echo -e "${RED}Error: Encryption key file not found: $key_path${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
ENC_KEY=$(cat "$key_path")
|
||||
echo -e "${GREEN}Encryption key loaded from: $key_path${NC}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Check file type early - accept both old and new naming
|
||||
if [[ "$INPUT_FILE" != *.sql.gz.enc ]] && [[ "$INPUT_FILE" != *.sql.tar.gz.enc ]]; then
|
||||
echo -e "${RED}Error: File must be a .sql.gz.enc or .sql.tar.gz.enc file${NC}"
|
||||
echo "This tool only decrypts SQL backup files created by backpm7.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get encryption key from config
|
||||
if ! load_key_from_config; then
|
||||
echo -e "${RED}Error: Cannot load encryption key${NC}"
|
||||
echo "Make sure $CONFIG_FILE exists and contains enc_key path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Process SQL backup file
|
||||
echo -e "${BLUE}Decrypting SQL backup: $INPUT_FILE${NC}"
|
||||
|
||||
# Determine output file - extract just the filename and put in current directory
|
||||
BASENAME=$(basename "$INPUT_FILE")
|
||||
if [[ "$BASENAME" == *.sql.tar.gz.enc ]]; then
|
||||
OUTPUT_FILE="${BASENAME%.sql.tar.gz.enc}.sql"
|
||||
else
|
||||
OUTPUT_FILE="${BASENAME%.sql.gz.enc}.sql"
|
||||
fi
|
||||
|
||||
# Decrypt and decompress in one command
|
||||
echo "Decrypting to: $OUTPUT_FILE"
|
||||
|
||||
# Decrypt and decompress in one pipeline
|
||||
if openssl enc -aes-256-cbc -d -salt -pass pass:"$ENC_KEY" -pbkdf2 -in "$INPUT_FILE" | gunzip > "$OUTPUT_FILE" 2>/dev/null; then
|
||||
# Get file size
|
||||
size=$(du -h "$OUTPUT_FILE" | cut -f1)
|
||||
echo -e "${GREEN}✓ Successfully decrypted: $OUTPUT_FILE ($size)${NC}"
|
||||
|
||||
# Show first few lines of SQL
|
||||
echo -e "${BLUE}First 5 lines of SQL:${NC}"
|
||||
head -n 5 "$OUTPUT_FILE"
|
||||
else
|
||||
echo -e "${RED}✗ Decryption failed${NC}"
|
||||
echo "Possible causes:"
|
||||
echo " - Wrong encryption key"
|
||||
echo " - Corrupted file"
|
||||
echo " - File was encrypted differently"
|
||||
|
||||
# Try to help debug
|
||||
echo -e "\n${YELLOW}Debug info:${NC}"
|
||||
echo "File size: $(du -h "$INPUT_FILE" | cut -f1)"
|
||||
echo "First bytes (should start with 'Salted__'):"
|
||||
hexdump -C "$INPUT_FILE" | head -n 1
|
||||
|
||||
# Let's also check what key we're using (first 10 chars)
|
||||
echo "Key begins with: ${ENC_KEY:0:10}..."
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Operation completed successfully${NC}"
|
||||
@@ -1,248 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# sync_geosector.sh - Synchronise les backups geosector depuis PM7 vers maria3 (IN3) et maria4 (IN4)
|
||||
#
|
||||
# Ce script :
|
||||
# 1. Trouve le dernier backup chiffré de geosector sur PM7
|
||||
# 2. Le déchiffre et décompresse localement
|
||||
# 3. Le transfère et l'importe dans IN3/maria3/geosector
|
||||
# 4. Le transfère et l'importe dans IN4/maria4/geosector
|
||||
#
|
||||
# Installation: /var/pierre/bat/sync_geosector.sh
|
||||
# Usage: ./sync_geosector.sh [--force] [--date YYYYMMDD_HH]
|
||||
#
|
||||
|
||||
set -uo pipefail
|
||||
# Note: Removed -e to allow script to continue on sync errors
|
||||
# Errors are handled explicitly with ERROR_COUNT
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="$SCRIPT_DIR/d6back.yaml"
|
||||
BACKUP_DIR="/var/pierre/back/IN3/nx4/sql"
|
||||
ENC_KEY_FILE="/home/pierre/.key_enc"
|
||||
SSH_KEY="/home/pierre/.ssh/backup_key"
|
||||
TEMP_DIR="/tmp/geosector_sync"
|
||||
LOG_FILE="/var/pierre/bat/logs/sync_geosector.log"
|
||||
RECAP_FILE="/tmp/sync_geosector_recap_$$.txt"
|
||||
|
||||
# Load email config from d6back.yaml
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
EMAIL_TO=$(yq '.global.email_to // "support@unikoffice.com"' "$CONFIG_FILE" | tr -d '"')
|
||||
BACKUP_SERVER=$(yq '.global.backup_server // "BACKUP"' "$CONFIG_FILE" | tr -d '"')
|
||||
else
|
||||
EMAIL_TO="support@unikoffice.com"
|
||||
BACKUP_SERVER="BACKUP"
|
||||
fi
|
||||
|
||||
# Serveurs cibles
|
||||
IN3_HOST="195.154.80.116"
|
||||
IN3_USER="pierre"
|
||||
IN3_CONTAINER="maria3"
|
||||
|
||||
IN4_HOST="51.159.7.190"
|
||||
IN4_USER="pierre"
|
||||
IN4_CONTAINER="maria4"
|
||||
|
||||
# Credentials MariaDB
|
||||
DB_USER="root"
|
||||
IN3_DB_PASS="MyAlpLocal,90b" # maria3
|
||||
IN4_DB_PASS="MyAlpLocal,90b" # maria4
|
||||
DB_NAME="geosector"
|
||||
|
||||
# Fonctions utilitaires
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
error() {
|
||||
log "ERROR: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -d "$TEMP_DIR" ]]; then
|
||||
log "Nettoyage de $TEMP_DIR"
|
||||
rm -rf "$TEMP_DIR"
|
||||
fi
|
||||
rm -f "$RECAP_FILE"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# Lecture de la clé de chiffrement
|
||||
if [[ ! -f "$ENC_KEY_FILE" ]]; then
|
||||
error "Clé de chiffrement non trouvée: $ENC_KEY_FILE"
|
||||
fi
|
||||
ENC_KEY=$(cat "$ENC_KEY_FILE")
|
||||
|
||||
# Parsing des arguments
|
||||
FORCE=0
|
||||
SPECIFIC_DATE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--force)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
--date)
|
||||
SPECIFIC_DATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [--force] [--date YYYYMMDD_HH]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Trouver le fichier backup
|
||||
if [[ -n "$SPECIFIC_DATE" ]]; then
|
||||
BACKUP_FILE="$BACKUP_DIR/geosector_${SPECIFIC_DATE}.sql.gz.enc"
|
||||
if [[ ! -f "$BACKUP_FILE" ]]; then
|
||||
error "Backup non trouvé: $BACKUP_FILE"
|
||||
fi
|
||||
else
|
||||
# Chercher le plus récent
|
||||
BACKUP_FILE=$(find "$BACKUP_DIR" -name "geosector_*.sql.gz.enc" -type f -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-)
|
||||
if [[ -z "$BACKUP_FILE" ]]; then
|
||||
error "Aucun backup geosector trouvé dans $BACKUP_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
BACKUP_BASENAME=$(basename "$BACKUP_FILE")
|
||||
log "Backup sélectionné: $BACKUP_BASENAME"
|
||||
|
||||
# Initialiser le fichier récapitulatif
|
||||
echo "SYNC GEOSECTOR REPORT - $(hostname) - $(date '+%d.%m.%Y %H')h" > "$RECAP_FILE"
|
||||
echo "========================================" >> "$RECAP_FILE"
|
||||
echo "" >> "$RECAP_FILE"
|
||||
echo "Backup source: $BACKUP_BASENAME" >> "$RECAP_FILE"
|
||||
echo "" >> "$RECAP_FILE"
|
||||
|
||||
# Créer le répertoire temporaire
|
||||
mkdir -p "$TEMP_DIR"
|
||||
DECRYPTED_FILE="$TEMP_DIR/geosector.sql"
|
||||
|
||||
# Étape 1: Déchiffrer et décompresser
|
||||
log "Déchiffrement et décompression du backup..."
|
||||
if ! openssl enc -aes-256-cbc -d -pass pass:"$ENC_KEY" -pbkdf2 -in "$BACKUP_FILE" | gunzip > "$DECRYPTED_FILE"; then
|
||||
error "Échec du déchiffrement/décompression"
|
||||
fi
|
||||
|
||||
FILE_SIZE=$(du -h "$DECRYPTED_FILE" | cut -f1)
|
||||
log "Fichier SQL déchiffré: $FILE_SIZE"
|
||||
|
||||
echo "Decrypted SQL size: $FILE_SIZE" >> "$RECAP_FILE"
|
||||
echo "" >> "$RECAP_FILE"
|
||||
|
||||
# Compteur d'erreurs
|
||||
ERROR_COUNT=0
|
||||
|
||||
# Fonction pour synchroniser vers un serveur
|
||||
sync_to_server() {
|
||||
local HOST=$1
|
||||
local USER=$2
|
||||
local CONTAINER=$3
|
||||
local DB_PASS=$4
|
||||
local SERVER_NAME=$5
|
||||
|
||||
log "=== Synchronisation vers $SERVER_NAME ($HOST) ==="
|
||||
echo "TARGET: $SERVER_NAME ($HOST/$CONTAINER)" >> "$RECAP_FILE"
|
||||
|
||||
# Test de connexion SSH
|
||||
if ! ssh -i "$SSH_KEY" -o ConnectTimeout=10 "$USER@$HOST" "echo 'SSH OK'" &>/dev/null; then
|
||||
log "ERROR: Impossible de se connecter à $HOST via SSH"
|
||||
echo " ✗ SSH connection FAILED" >> "$RECAP_FILE"
|
||||
((ERROR_COUNT++))
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Import dans MariaDB
|
||||
log "Import dans $SERVER_NAME/$CONTAINER/geosector..."
|
||||
|
||||
# Drop et recréer la base sur le serveur distant
|
||||
if ! ssh -i "$SSH_KEY" "$USER@$HOST" "incus exec $CONTAINER --project default -- mariadb -u root -p'$DB_PASS' -e 'DROP DATABASE IF EXISTS $DB_NAME; CREATE DATABASE $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'"; then
|
||||
log "ERROR: Échec de la création de la base sur $SERVER_NAME"
|
||||
echo " ✗ Database creation FAILED" >> "$RECAP_FILE"
|
||||
((ERROR_COUNT++))
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Filtrer et importer le SQL (enlever CREATE DATABASE et USE avec timestamp)
|
||||
log "Filtrage et import du SQL..."
|
||||
if ! sed -e '/^CREATE DATABASE.*geosector_[0-9]/d' \
|
||||
-e '/^USE.*geosector_[0-9]/d' \
|
||||
"$DECRYPTED_FILE" | \
|
||||
ssh -i "$SSH_KEY" "$USER@$HOST" "incus exec $CONTAINER --project default -- mariadb -u root -p'$DB_PASS' $DB_NAME"; then
|
||||
log "ERROR: Échec de l'import sur $SERVER_NAME"
|
||||
echo " ✗ SQL import FAILED" >> "$RECAP_FILE"
|
||||
((ERROR_COUNT++))
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "$SERVER_NAME: Import réussi"
|
||||
echo " ✓ Import SUCCESS" >> "$RECAP_FILE"
|
||||
echo "" >> "$RECAP_FILE"
|
||||
}
|
||||
|
||||
# Synchronisation vers IN3/maria3
|
||||
sync_to_server "$IN3_HOST" "$IN3_USER" "$IN3_CONTAINER" "$IN3_DB_PASS" "IN3/maria3"
|
||||
|
||||
# Synchronisation vers IN4/maria4
|
||||
sync_to_server "$IN4_HOST" "$IN4_USER" "$IN4_CONTAINER" "$IN4_DB_PASS" "IN4/maria4"
|
||||
|
||||
# Finaliser le récapitulatif
|
||||
echo "========================================" >> "$RECAP_FILE"
|
||||
echo "COMPLETED: $(date '+%d.%m.%Y %H:%M')" >> "$RECAP_FILE"
|
||||
|
||||
# Préparer le sujet email avec date
|
||||
DATE_SUBJECT=$(date '+%d.%m.%Y %H')
|
||||
|
||||
# Envoyer l'email récapitulatif
|
||||
if [[ $ERROR_COUNT -gt 0 ]]; then
|
||||
log "Total errors: $ERROR_COUNT"
|
||||
|
||||
# Ajouter les erreurs au récap
|
||||
echo "" >> "$RECAP_FILE"
|
||||
echo "ERRORS DETECTED: $ERROR_COUNT" >> "$RECAP_FILE"
|
||||
echo "----------------------------" >> "$RECAP_FILE"
|
||||
grep -i "ERROR" "$LOG_FILE" | tail -20 >> "$RECAP_FILE"
|
||||
|
||||
# Envoyer email avec ERROR dans le sujet
|
||||
log "Sending ERROR email to $EMAIL_TO (Errors found: $ERROR_COUNT)"
|
||||
if command -v msmtp &> /dev/null; then
|
||||
{
|
||||
echo "To: $EMAIL_TO"
|
||||
echo "Subject: Sync${BACKUP_SERVER} ERROR $DATE_SUBJECT"
|
||||
echo ""
|
||||
cat "$RECAP_FILE"
|
||||
} | msmtp "$EMAIL_TO"
|
||||
log "ERROR email sent successfully to $EMAIL_TO"
|
||||
else
|
||||
log "WARNING: msmtp not found - ERROR email NOT sent"
|
||||
fi
|
||||
|
||||
log "=== Synchronisation terminée avec des erreurs ==="
|
||||
exit 1
|
||||
else
|
||||
log "=== Synchronisation terminée avec succès ==="
|
||||
log "Les bases geosector sur maria3 et maria4 sont à jour avec le backup $BACKUP_BASENAME"
|
||||
|
||||
# Envoyer email de succès
|
||||
log "Sending SUCCESS recap email to $EMAIL_TO"
|
||||
if command -v msmtp &> /dev/null; then
|
||||
{
|
||||
echo "To: $EMAIL_TO"
|
||||
echo "Subject: Sync${BACKUP_SERVER} $DATE_SUBJECT"
|
||||
echo ""
|
||||
cat "$RECAP_FILE"
|
||||
} | msmtp "$EMAIL_TO"
|
||||
log "SUCCESS recap email sent successfully to $EMAIL_TO"
|
||||
else
|
||||
log "WARNING: msmtp not found - SUCCESS recap email NOT sent"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
@@ -1225,6 +1225,86 @@ php scripts/php/migrate_from_backup.php \
|
||||
|
||||
---
|
||||
|
||||
#### 7. Statistiques Events pour Admin Flutter
|
||||
|
||||
**Demandé le :** 22/12/2025
|
||||
**Objectif :** Permettre aux admins Flutter de consulter les logs Events avec des stats quotidiennes, hebdomadaires et mensuelles, et drill-down vers le détail.
|
||||
|
||||
**Architecture choisie :** Stats pré-agrégées en SQL + détail JSONL à la demande
|
||||
|
||||
**Pourquoi cette approche :**
|
||||
|
||||
- Évite de parser les fichiers JSONL à chaque requête Flutter
|
||||
- Transfert minimal (~1-10 KB par requête)
|
||||
- Calculs hebdo/mensuel à la volée depuis `daily` (pas de tables supplémentaires)
|
||||
- Détail paginé uniquement sur demande
|
||||
|
||||
**Phase 1 : Base de données** ✅ (22/12/2025)
|
||||
|
||||
- [x] Créer la table `event_stats_daily`
|
||||
- Colonnes : `stat_date`, `entity_id`, `event_type`, `count`, `sum_amount`, `unique_users`, `metadata`
|
||||
- Index : `(entity_id, stat_date)`, unique `(stat_date, entity_id, event_type)`
|
||||
- [x] Script SQL de création : `scripts/sql/create_event_stats_daily.sql`
|
||||
|
||||
**Phase 2 : CRON d'agrégation** ✅ (22/12/2025)
|
||||
|
||||
- [x] Créer `scripts/cron/aggregate_event_stats.php`
|
||||
- Parse le fichier JSONL de J-1 (ou date passée en paramètre)
|
||||
- Agrège par entity_id et event_type
|
||||
- INSERT/UPDATE dans `event_stats_daily`
|
||||
- Calcule `unique_users` (COUNT DISTINCT sur user_id)
|
||||
- Calcule `sum_amount` pour les passages
|
||||
- Stocke metadata JSON (top 5 secteurs, erreurs fréquentes, etc.)
|
||||
- [x] Ajouter au crontab : exécution à 01h00 chaque nuit (via deploy-api.sh)
|
||||
- [x] Script de rattrapage : `php aggregate_event_stats.php --from=2025-01-01 --to=2025-12-21`
|
||||
|
||||
**Phase 3 : Service EventStatsService** ✅ (22/12/2025)
|
||||
|
||||
- [x] Créer `src/Services/EventStatsService.php`
|
||||
- `getSummary(?int $entityId, ?string $date)` : Stats du jour
|
||||
- `getDaily(?int $entityId, string $from, string $to, array $eventTypes)` : Stats journalières
|
||||
- `getWeekly(?int $entityId, string $from, string $to, array $eventTypes)` : Calculé depuis daily
|
||||
- `getMonthly(?int $entityId, int $year, array $eventTypes)` : Calculé depuis daily
|
||||
- `getDetails(?int $entityId, string $date, ?string $eventType, int $limit, int $offset)` : Lecture JSONL paginée
|
||||
- `getEventTypes()` : Liste des types d'événements disponibles
|
||||
- `hasStatsForDate(string $date)` : Vérifie si stats existent
|
||||
|
||||
**Phase 4 : Controller et Routes** ✅ (22/12/2025)
|
||||
|
||||
- [x] Créer `src/Controllers/EventStatsController.php`
|
||||
- `summary()` : GET /api/events/stats/summary?date=
|
||||
- `daily()` : GET /api/events/stats/daily?from=&to=&events=
|
||||
- `weekly()` : GET /api/events/stats/weekly?from=&to=&events=
|
||||
- `monthly()` : GET /api/events/stats/monthly?year=&events=
|
||||
- `details()` : GET /api/events/stats/details?date=&event=&limit=&offset=
|
||||
- `types()` : GET /api/events/stats/types
|
||||
- [x] Ajouter les routes dans `Router.php`
|
||||
- [x] Vérification des droits : Admin entité (role_id = 2) ou Super-admin (role_id = 1)
|
||||
- [x] Super-admin : peut voir toutes les entités (entity_id = NULL ou ?entity_id=X)
|
||||
|
||||
**Phase 5 : Optimisations** ✅ (22/12/2025)
|
||||
|
||||
- [x] Compression gzip sur les réponses JSON (si >1KB et client supporte)
|
||||
- [x] Header `ETag` sur /summary et /daily (cache 5 min, 304 Not Modified)
|
||||
- [x] Filtrage des champs sensibles dans /details (IP tronquée, user_agent supprimé)
|
||||
- [x] Limite max 100 events par requête /details
|
||||
|
||||
**Phase 6 : Tests et documentation**
|
||||
|
||||
- [ ] Tests unitaires EventStatsService
|
||||
- [ ] Tests endpoints avec différents rôles
|
||||
- [ ] Documentation Postman/Swagger des endpoints
|
||||
- [ ] Mise à jour TECHBOOK.md avec exemples de réponses JSON
|
||||
|
||||
**Estimation :** 2-3 jours de développement
|
||||
|
||||
**Dépendances :**
|
||||
|
||||
- EventLogService déjà en place ✅
|
||||
- Fichiers JSONL générés quotidiennement ✅
|
||||
|
||||
---
|
||||
|
||||
### 🟢 PRIORITÉ BASSE
|
||||
|
||||
#### 7. Amélioration de la suppression des utilisateurs
|
||||
|
||||
@@ -36,7 +36,7 @@ FINAL_OWNER_LOGS="nobody"
|
||||
FINAL_GROUP_LOGS="nginx"
|
||||
|
||||
# Configuration de sauvegarde
|
||||
BACKUP_DIR="/data/backup/geosector/api"
|
||||
BACKUP_DIR="/home/pierre/samba/back/geosector/api"
|
||||
|
||||
# Couleurs pour les messages
|
||||
GREEN='\033[0;32m'
|
||||
@@ -179,6 +179,14 @@ if [ "$SOURCE_TYPE" = "local_code" ]; then
|
||||
--exclude='*.swp' \
|
||||
--exclude='*.swo' \
|
||||
--exclude='*~' \
|
||||
--exclude='docs/*.geojson' \
|
||||
--exclude='docs/*.sql' \
|
||||
--exclude='docs/*.pdf' \
|
||||
--exclude='composer.phar' \
|
||||
--exclude='scripts/migration*' \
|
||||
--exclude='scripts/php' \
|
||||
--exclude='CLAUDE.md' \
|
||||
--exclude='TODO-API.md' \
|
||||
-czf "${ARCHIVE_PATH}" . 2>/dev/null || echo_error "Failed to create archive"
|
||||
|
||||
echo_info "Archive created: ${ARCHIVE_PATH}"
|
||||
@@ -198,6 +206,16 @@ elif [ "$SOURCE_TYPE" = "remote_container" ]; then
|
||||
--exclude='uploads' \
|
||||
--exclude='sessions' \
|
||||
--exclude='opendata' \
|
||||
--exclude='docs/*.geojson' \
|
||||
--exclude='docs/*.sql' \
|
||||
--exclude='docs/*.pdf' \
|
||||
--exclude='composer.phar' \
|
||||
--exclude='scripts/migration*' \
|
||||
--exclude='scripts/php' \
|
||||
--exclude='CLAUDE.md' \
|
||||
--exclude='TODO-API.md' \
|
||||
--exclude='*.tar.gz' \
|
||||
--exclude='vendor' \
|
||||
-czf /tmp/${ARCHIVE_NAME} -C ${API_PATH} .
|
||||
" || echo_error "Failed to create archive on remote"
|
||||
|
||||
@@ -288,11 +306,11 @@ if [ "$DEST_HOST" != "local" ]; then
|
||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type d -exec chmod 755 {} \; &&
|
||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type f -exec chmod 644 {} \; &&
|
||||
|
||||
# Permissions spéciales pour logs
|
||||
# Permissions spéciales pour logs (PHP-FPM tourne sous nobody)
|
||||
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/logs/events &&
|
||||
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER_LOGS}:${FINAL_GROUP} ${API_PATH}/logs &&
|
||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type d -exec chmod 750 {} \; &&
|
||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type f -exec chmod 640 {} \; &&
|
||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type d -exec chmod 775 {} \; &&
|
||||
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type f -exec chmod 664 {} \; &&
|
||||
|
||||
# Permissions spéciales pour uploads
|
||||
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/uploads &&
|
||||
@@ -342,8 +360,8 @@ if [ "$DEST_HOST" != "local" ]; then
|
||||
# GEOSECTOR API - Security data cleanup (daily at 2am)
|
||||
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
|
||||
|
||||
# GEOSECTOR API - Stripe devices update (weekly Sunday at 3am)
|
||||
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
|
||||
# GEOSECTOR API - Event stats aggregation (daily at 1am)
|
||||
0 1 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/aggregate_event_stats.php >> /var/www/geosector/api/logs/aggregate_stats.log 2>&1
|
||||
EOF
|
||||
|
||||
# Installer le nouveau crontab
|
||||
|
||||
@@ -89,7 +89,75 @@ PUT /api/users/123 // users.id
|
||||
1. **Reçus fiscaux** : PDF auto (<5KB) pour dons, envoi email queue
|
||||
2. **Logos entités** : Upload PNG/JPG, redimensionnement 250x250px, base64
|
||||
3. **Migration** : Endpoints REST par entité (9 phases)
|
||||
4. **CRONs** : Email queue (*/5), cleanup sécurité (2h), Stripe devices (dim 3h)
|
||||
4. **CRONs** : Email queue (*/5), cleanup sécurité (2h)
|
||||
|
||||
## 📊 Statistiques Events (Admin Flutter)
|
||||
|
||||
### Architecture
|
||||
|
||||
**Principe** : Stats pré-agrégées en SQL + détail JSONL à la demande
|
||||
|
||||
| Source | Usage | Performance |
|
||||
|--------|-------|-------------|
|
||||
| Table `event_stats_daily` | Dashboard, graphiques, tendances | Instantané (~1ms) |
|
||||
| Fichiers JSONL | Détail événements (clic sur stat) | Paginé (~50-100ms) |
|
||||
|
||||
### Flux de données
|
||||
|
||||
1. **EventLogService** écrit les événements dans `/logs/events/YYYY-MM-DD.jsonl`
|
||||
2. **CRON nightly** agrège J-1 dans `event_stats_daily`
|
||||
3. **API** sert les stats agrégées (SQL) ou le détail paginé (JSONL)
|
||||
4. **Flutter Admin** affiche dashboard avec drill-down
|
||||
|
||||
### Table d'agrégation
|
||||
|
||||
**`event_stats_daily`** : Une ligne par (date, entité, type d'événement)
|
||||
|
||||
| Colonne | Description |
|
||||
|---------|-------------|
|
||||
| `stat_date` | Date des stats |
|
||||
| `entity_id` | Entité (NULL = global super-admin) |
|
||||
| `event_type` | Type événement (login_success, passage_created, etc.) |
|
||||
| `count` | Nombre d'occurrences |
|
||||
| `sum_amount` | Somme montants (passages) |
|
||||
| `unique_users` | Utilisateurs distincts |
|
||||
| `metadata` | JSON agrégé (top secteurs, erreurs fréquentes, etc.) |
|
||||
|
||||
### Endpoints API
|
||||
|
||||
| Endpoint | Période | Source | Taille réponse |
|
||||
|----------|---------|--------|----------------|
|
||||
| `GET /events/stats/summary` | Jour courant | SQL | ~1 KB |
|
||||
| `GET /events/stats/daily` | Plage dates | SQL | ~5 KB |
|
||||
| `GET /events/stats/weekly` | Calculé depuis daily | SQL | ~2 KB |
|
||||
| `GET /events/stats/monthly` | Calculé depuis daily | SQL | ~1 KB |
|
||||
| `GET /events/details` | Détail paginé | JSONL | ~10 KB |
|
||||
|
||||
### Optimisations transfert Flutter
|
||||
|
||||
- **Pagination** : 50 events max par requête détail
|
||||
- **Champs filtrés** : Pas d'IP ni user_agent complet dans les réponses
|
||||
- **Compression gzip** : -70% sur JSON
|
||||
- **Cache HTTP** : ETag sur stats (changent 1x/jour)
|
||||
- **Calcul hebdo/mensuel** : À la volée depuis `daily` (pas de tables supplémentaires)
|
||||
|
||||
### Types d'événements agrégés
|
||||
|
||||
| Catégorie | Events |
|
||||
|-----------|--------|
|
||||
| **Auth** | login_success, login_failed, logout |
|
||||
| **Passages** | passage_created, passage_updated, passage_deleted |
|
||||
| **Secteurs** | sector_created, sector_updated, sector_deleted |
|
||||
| **Users** | user_created, user_updated, user_deleted |
|
||||
| **Entités** | entity_created, entity_updated, entity_deleted |
|
||||
| **Opérations** | operation_created, operation_updated, operation_deleted |
|
||||
| **Stripe** | stripe_payment_created, stripe_payment_success, stripe_payment_failed, stripe_payment_cancelled, stripe_terminal_error |
|
||||
|
||||
### Accès et sécurité
|
||||
|
||||
- **Rôle requis** : Admin entité (role_id = 2) ou Super-admin (role_id = 1)
|
||||
- **Isolation** : Admin voit uniquement les stats de son entité
|
||||
- **Super-admin** : Accès global (entity_id = NULL dans requêtes)
|
||||
|
||||
## 🚀 Déploiement
|
||||
|
||||
@@ -172,4 +240,4 @@ DELETE FROM operations WHERE id = 850;
|
||||
|
||||
---
|
||||
|
||||
**Mis à jour : 26 Octobre 2025**
|
||||
**Mis à jour : 22 Décembre 2025**
|
||||
|
||||
@@ -157,12 +157,21 @@ register_shutdown_function(function() use ($requestUri, $requestMethod) {
|
||||
// Alerter sur les erreurs 500
|
||||
if ($statusCode >= 500) {
|
||||
$error = error_get_last();
|
||||
$errorMessage = $error['message'] ?? null;
|
||||
|
||||
// Si pas d'erreur PHP, c'est probablement une exception capturée
|
||||
// Le détail de l'erreur sera dans les logs applicatifs
|
||||
if ($errorMessage === null) {
|
||||
$errorMessage = 'Exception capturée (voir logs/app.log pour détails)';
|
||||
}
|
||||
|
||||
AlertService::trigger('HTTP_500', [
|
||||
'endpoint' => $requestUri,
|
||||
'method' => $requestMethod,
|
||||
'error_message' => $error['message'] ?? 'Unknown error',
|
||||
'error_file' => $error['file'] ?? 'Unknown',
|
||||
'error_message' => $errorMessage,
|
||||
'error_file' => $error['file'] ?? 'N/A',
|
||||
'error_line' => $error['line'] ?? 0,
|
||||
'stack_trace' => 'Consulter logs/app.log pour le stack trace complet',
|
||||
'message' => "Erreur serveur 500 sur $requestUri"
|
||||
], 'ERROR');
|
||||
}
|
||||
|
||||
@@ -94,30 +94,7 @@ Ce dossier contient les scripts automatisés de maintenance et de traitement pou
|
||||
|
||||
---
|
||||
|
||||
### 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`
|
||||
### 5. `sync_databases.php`
|
||||
|
||||
**Fonction** : Synchronise les bases de données entre environnements
|
||||
|
||||
@@ -175,9 +152,6 @@ crontab -e
|
||||
|
||||
# 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
|
||||
@@ -203,7 +177,6 @@ Tous les logs CRON sont stockés dans `/var/www/geosector/api/logs/` :
|
||||
- `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
|
||||
|
||||
@@ -216,9 +189,6 @@ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
456
api/scripts/cron/aggregate_event_stats.php
Executable file
456
api/scripts/cron/aggregate_event_stats.php
Executable file
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Script CRON pour agrégation des statistiques d'événements
|
||||
*
|
||||
* Parse les fichiers JSONL et agrège les données dans event_stats_daily
|
||||
*
|
||||
* Usage:
|
||||
* php aggregate_event_stats.php # Agrège J-1
|
||||
* php aggregate_event_stats.php --date=2025-12-20 # Agrège une date spécifique
|
||||
* php aggregate_event_stats.php --from=2025-12-01 --to=2025-12-21 # Rattrapage plage
|
||||
*
|
||||
* À exécuter quotidiennement via crontab (1h du matin) :
|
||||
* 0 1 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/aggregate_event_stats.php >> /var/www/geosector/api/logs/aggregate_stats.log 2>&1
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Configuration
|
||||
define('LOCK_FILE', '/tmp/aggregate_event_stats.lock');
|
||||
define('EVENT_LOG_DIR', __DIR__ . '/../../logs/events');
|
||||
|
||||
// Empêcher l'exécution multiple simultanée
|
||||
if (file_exists(LOCK_FILE)) {
|
||||
$lockTime = filemtime(LOCK_FILE);
|
||||
if (time() - $lockTime > 3600) {
|
||||
unlink(LOCK_FILE);
|
||||
} else {
|
||||
die("[" . date('Y-m-d H:i:s') . "] Le processus est déjà en cours d'exécution\n");
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents(LOCK_FILE, (string) getmypid());
|
||||
|
||||
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') {
|
||||
$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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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';
|
||||
|
||||
use App\Services\LogService;
|
||||
|
||||
/**
|
||||
* Parse les arguments CLI
|
||||
*/
|
||||
function parseArgs(array $argv): array
|
||||
{
|
||||
$args = [
|
||||
'date' => null,
|
||||
'from' => null,
|
||||
'to' => null,
|
||||
];
|
||||
|
||||
foreach ($argv as $arg) {
|
||||
if (strpos($arg, '--date=') === 0) {
|
||||
$args['date'] = substr($arg, 7);
|
||||
} elseif (strpos($arg, '--from=') === 0) {
|
||||
$args['from'] = substr($arg, 7);
|
||||
} elseif (strpos($arg, '--to=') === 0) {
|
||||
$args['to'] = substr($arg, 5);
|
||||
}
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère la liste des dates à traiter
|
||||
*/
|
||||
function getDatesToProcess(array $args): array
|
||||
{
|
||||
$dates = [];
|
||||
|
||||
if ($args['date']) {
|
||||
$dates[] = $args['date'];
|
||||
} elseif ($args['from'] && $args['to']) {
|
||||
$current = new DateTime($args['from']);
|
||||
$end = new DateTime($args['to']);
|
||||
while ($current <= $end) {
|
||||
$dates[] = $current->format('Y-m-d');
|
||||
$current->modify('+1 day');
|
||||
}
|
||||
} else {
|
||||
// Par défaut : J-1
|
||||
$dates[] = date('Y-m-d', strtotime('-1 day'));
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse un fichier JSONL et retourne les événements
|
||||
*/
|
||||
function parseJsonlFile(string $filePath): array
|
||||
{
|
||||
$events = [];
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
return $events;
|
||||
}
|
||||
|
||||
$handle = fopen($filePath, 'r');
|
||||
if (!$handle) {
|
||||
return $events;
|
||||
}
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$event = json_decode($line, true);
|
||||
if ($event && isset($event['event'])) {
|
||||
$events[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrège les événements par entity_id et event_type
|
||||
*/
|
||||
function aggregateEvents(array $events): array
|
||||
{
|
||||
$stats = [];
|
||||
|
||||
foreach ($events as $event) {
|
||||
$entityId = $event['entity_id'] ?? null;
|
||||
$eventType = $event['event'] ?? 'unknown';
|
||||
$userId = $event['user_id'] ?? null;
|
||||
|
||||
// Clé d'agrégation : entity_id peut être NULL (stats globales)
|
||||
$key = ($entityId ?? 'NULL') . '|' . $eventType;
|
||||
|
||||
if (!isset($stats[$key])) {
|
||||
$stats[$key] = [
|
||||
'entity_id' => $entityId,
|
||||
'event_type' => $eventType,
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
'user_ids' => [],
|
||||
'metadata' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$stats[$key]['count']++;
|
||||
|
||||
// Collecter les user_ids pour unique_users
|
||||
if ($userId !== null) {
|
||||
$stats[$key]['user_ids'][$userId] = true;
|
||||
}
|
||||
|
||||
// Somme des montants pour les passages et paiements Stripe
|
||||
if (in_array($eventType, ['passage_created', 'passage_updated'])) {
|
||||
$amount = $event['amount'] ?? 0;
|
||||
$stats[$key]['sum_amount'] += (float) $amount;
|
||||
} elseif (in_array($eventType, ['stripe_payment_success', 'stripe_payment_created'])) {
|
||||
// Montant en centimes -> euros
|
||||
$amount = ($event['amount'] ?? 0) / 100;
|
||||
$stats[$key]['sum_amount'] += (float) $amount;
|
||||
}
|
||||
|
||||
// Collecter metadata spécifiques
|
||||
collectMetadata($stats[$key], $event);
|
||||
}
|
||||
|
||||
// Convertir user_ids en count
|
||||
foreach ($stats as &$stat) {
|
||||
$stat['unique_users'] = count($stat['user_ids']);
|
||||
unset($stat['user_ids']);
|
||||
|
||||
// Finaliser les metadata
|
||||
$stat['metadata'] = finalizeMetadata($stat['metadata'], $stat['event_type']);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collecte les métadonnées spécifiques par type d'événement
|
||||
*/
|
||||
function collectMetadata(array &$stat, array $event): void
|
||||
{
|
||||
$eventType = $event['event'] ?? '';
|
||||
|
||||
switch ($eventType) {
|
||||
case 'login_failed':
|
||||
$reason = $event['reason'] ?? 'unknown';
|
||||
$stat['metadata']['reasons'][$reason] = ($stat['metadata']['reasons'][$reason] ?? 0) + 1;
|
||||
break;
|
||||
|
||||
case 'passage_created':
|
||||
$sectorId = $event['sector_id'] ?? null;
|
||||
if ($sectorId) {
|
||||
$stat['metadata']['sectors'][$sectorId] = ($stat['metadata']['sectors'][$sectorId] ?? 0) + 1;
|
||||
}
|
||||
$paymentType = $event['payment_type'] ?? 'unknown';
|
||||
$stat['metadata']['payment_types'][$paymentType] = ($stat['metadata']['payment_types'][$paymentType] ?? 0) + 1;
|
||||
break;
|
||||
|
||||
case 'stripe_payment_failed':
|
||||
$errorCode = $event['error_code'] ?? 'unknown';
|
||||
$stat['metadata']['error_codes'][$errorCode] = ($stat['metadata']['error_codes'][$errorCode] ?? 0) + 1;
|
||||
break;
|
||||
|
||||
case 'stripe_terminal_error':
|
||||
$errorCode = $event['error_code'] ?? 'unknown';
|
||||
$stat['metadata']['error_codes'][$errorCode] = ($stat['metadata']['error_codes'][$errorCode] ?? 0) + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalise les métadonnées (top 5, tri, etc.)
|
||||
*/
|
||||
function finalizeMetadata(array $metadata, string $eventType): ?array
|
||||
{
|
||||
if (empty($metadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
// Top 5 secteurs
|
||||
if (isset($metadata['sectors'])) {
|
||||
arsort($metadata['sectors']);
|
||||
$result['top_sectors'] = array_slice($metadata['sectors'], 0, 5, true);
|
||||
}
|
||||
|
||||
// Raisons d'échec login
|
||||
if (isset($metadata['reasons'])) {
|
||||
arsort($metadata['reasons']);
|
||||
$result['failure_reasons'] = $metadata['reasons'];
|
||||
}
|
||||
|
||||
// Types de paiement
|
||||
if (isset($metadata['payment_types'])) {
|
||||
arsort($metadata['payment_types']);
|
||||
$result['payment_types'] = $metadata['payment_types'];
|
||||
}
|
||||
|
||||
// Codes d'erreur
|
||||
if (isset($metadata['error_codes'])) {
|
||||
arsort($metadata['error_codes']);
|
||||
$result['error_codes'] = $metadata['error_codes'];
|
||||
}
|
||||
|
||||
return empty($result) ? null : $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insère ou met à jour les stats dans la base de données
|
||||
*/
|
||||
function upsertStats(PDO $db, string $date, array $stats): int
|
||||
{
|
||||
$upsertedCount = 0;
|
||||
|
||||
$sql = "
|
||||
INSERT INTO event_stats_daily
|
||||
(stat_date, entity_id, event_type, count, sum_amount, unique_users, metadata)
|
||||
VALUES
|
||||
(:stat_date, :entity_id, :event_type, :count, :sum_amount, :unique_users, :metadata)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
count = VALUES(count),
|
||||
sum_amount = VALUES(sum_amount),
|
||||
unique_users = VALUES(unique_users),
|
||||
metadata = VALUES(metadata),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
";
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
|
||||
foreach ($stats as $stat) {
|
||||
try {
|
||||
$stmt->execute([
|
||||
'stat_date' => $date,
|
||||
'entity_id' => $stat['entity_id'],
|
||||
'event_type' => $stat['event_type'],
|
||||
'count' => $stat['count'],
|
||||
'sum_amount' => $stat['sum_amount'],
|
||||
'unique_users' => $stat['unique_users'],
|
||||
'metadata' => $stat['metadata'] ? json_encode($stat['metadata'], JSON_UNESCAPED_UNICODE) : null,
|
||||
]);
|
||||
$upsertedCount++;
|
||||
} catch (PDOException $e) {
|
||||
echo " ERREUR insertion {$stat['event_type']}: " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $upsertedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère également les stats globales (entity_id = NULL)
|
||||
*/
|
||||
function generateGlobalStats(array $statsByEntity): array
|
||||
{
|
||||
$globalStats = [];
|
||||
|
||||
foreach ($statsByEntity as $stat) {
|
||||
$eventType = $stat['event_type'];
|
||||
|
||||
if (!isset($globalStats[$eventType])) {
|
||||
$globalStats[$eventType] = [
|
||||
'entity_id' => null,
|
||||
'event_type' => $eventType,
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
'unique_users' => 0,
|
||||
'metadata' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$globalStats[$eventType]['count'] += $stat['count'];
|
||||
$globalStats[$eventType]['sum_amount'] += $stat['sum_amount'];
|
||||
$globalStats[$eventType]['unique_users'] += $stat['unique_users'];
|
||||
}
|
||||
|
||||
return array_values($globalStats);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAIN
|
||||
// ============================================================
|
||||
|
||||
try {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Démarrage de l'agrégation des statistiques\n";
|
||||
|
||||
// Initialisation
|
||||
$appConfig = AppConfig::getInstance();
|
||||
$config = $appConfig->getFullConfig();
|
||||
$environment = $appConfig->getEnvironment();
|
||||
|
||||
echo "Environnement: {$environment}\n";
|
||||
|
||||
Database::init($config['database']);
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Parser les arguments
|
||||
$args = parseArgs($argv);
|
||||
$dates = getDatesToProcess($args);
|
||||
|
||||
echo "Dates à traiter: " . implode(', ', $dates) . "\n\n";
|
||||
|
||||
$totalStats = 0;
|
||||
$totalEvents = 0;
|
||||
|
||||
foreach ($dates as $date) {
|
||||
$jsonlFile = EVENT_LOG_DIR . '/' . $date . '.jsonl';
|
||||
|
||||
echo "--- Traitement de {$date} ---\n";
|
||||
|
||||
if (!file_exists($jsonlFile)) {
|
||||
echo " Fichier non trouvé: {$jsonlFile}\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileSize = filesize($jsonlFile);
|
||||
echo " Fichier: " . basename($jsonlFile) . " (" . number_format($fileSize / 1024, 2) . " KB)\n";
|
||||
|
||||
// Parser le fichier
|
||||
$events = parseJsonlFile($jsonlFile);
|
||||
$eventCount = count($events);
|
||||
echo " Événements parsés: {$eventCount}\n";
|
||||
|
||||
if ($eventCount === 0) {
|
||||
echo " Aucun événement à agréger\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalEvents += $eventCount;
|
||||
|
||||
// Agréger par entity/event_type
|
||||
$stats = aggregateEvents($events);
|
||||
echo " Agrégations par entité: " . count($stats) . "\n";
|
||||
|
||||
// Générer les stats globales
|
||||
$globalStats = generateGlobalStats($stats);
|
||||
echo " Agrégations globales: " . count($globalStats) . "\n";
|
||||
|
||||
// Fusionner stats entités + globales
|
||||
$allStats = array_merge(array_values($stats), $globalStats);
|
||||
|
||||
// Insérer en base
|
||||
$upserted = upsertStats($db, $date, $allStats);
|
||||
echo " Stats insérées/mises à jour: {$upserted}\n";
|
||||
|
||||
$totalStats += $upserted;
|
||||
}
|
||||
|
||||
// Résumé
|
||||
echo "\n=== RÉSUMÉ ===\n";
|
||||
echo "Dates traitées: " . count($dates) . "\n";
|
||||
echo "Événements traités: {$totalEvents}\n";
|
||||
echo "Stats agrégées: {$totalStats}\n";
|
||||
|
||||
// Log
|
||||
LogService::log('Agrégation des statistiques terminée', [
|
||||
'level' => 'info',
|
||||
'script' => 'aggregate_event_stats.php',
|
||||
'environment' => $environment,
|
||||
'dates_count' => count($dates),
|
||||
'events_count' => $totalEvents,
|
||||
'stats_count' => $totalStats,
|
||||
]);
|
||||
|
||||
echo "\n[" . date('Y-m-d H:i:s') . "] Agrégation terminée avec succès\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errorMsg = 'Erreur lors de l\'agrégation: ' . $e->getMessage();
|
||||
|
||||
LogService::log($errorMsg, [
|
||||
'level' => 'error',
|
||||
'script' => 'aggregate_event_stats.php',
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
echo "\n❌ ERREUR: {$errorMsg}\n";
|
||||
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
@@ -1,444 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Script CRON pour mettre à jour la liste des appareils certifiés Stripe Tap to Pay
|
||||
*
|
||||
* Ce script récupère et met à jour la liste des appareils Android certifiés
|
||||
* pour Tap to Pay en France dans la table stripe_android_certified_devices
|
||||
*
|
||||
* À exécuter hebdomadairement via crontab :
|
||||
* Exemple: 0 3 * * 0 /usr/bin/php /path/to/api/scripts/cron/update_stripe_devices.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Configuration
|
||||
define('LOCK_FILE', '/tmp/update_stripe_devices.lock');
|
||||
define('DEVICES_JSON_URL', 'https://raw.githubusercontent.com/stripe/stripe-terminal-android/master/tap-to-pay/certified-devices.json');
|
||||
define('DEVICES_LOCAL_FILE', __DIR__ . '/../../data/stripe_certified_devices.json');
|
||||
|
||||
// Empêcher l'exécution multiple simultanée
|
||||
if (file_exists(LOCK_FILE)) {
|
||||
$lockTime = filemtime(LOCK_FILE);
|
||||
if (time() - $lockTime > 3600) { // Lock de plus d'1 heure = processus bloqué
|
||||
unlink(LOCK_FILE);
|
||||
} else {
|
||||
die("[" . date('Y-m-d H:i:s') . "] 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') {
|
||||
$hostname = gethostname();
|
||||
if (strpos($hostname, 'prod') !== false || strpos($hostname, 'pra') !== false) {
|
||||
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
|
||||
} elseif (strpos($hostname, 'rec') !== false || strpos($hostname, 'rca') !== false) {
|
||||
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
|
||||
} else {
|
||||
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DVA par défaut
|
||||
}
|
||||
$_SERVER['REQUEST_URI'] = '/cron/update_stripe_devices';
|
||||
$_SERVER['REQUEST_METHOD'] = 'CLI';
|
||||
$_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'];
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
|
||||
// Définir getallheaders si elle n'existe pas (CLI)
|
||||
if (!function_exists('getallheaders')) {
|
||||
function getallheaders() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Charger l'environnement
|
||||
require_once dirname(dirname(__DIR__)) . '/vendor/autoload.php';
|
||||
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";
|
||||
|
||||
// Initialiser la configuration et la base de données
|
||||
$appConfig = AppConfig::getInstance();
|
||||
$dbConfig = $appConfig->getDatabaseConfig();
|
||||
Database::init($dbConfig);
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Logger le début
|
||||
LogService::log("Début de la mise à jour des devices Stripe certifiés", [
|
||||
'source' => 'cron',
|
||||
'script' => 'update_stripe_devices.php'
|
||||
]);
|
||||
|
||||
// Étape 1: Récupérer la liste des devices
|
||||
$devicesData = fetchCertifiedDevices();
|
||||
|
||||
if (empty($devicesData)) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Aucune donnée de devices récupérée\n";
|
||||
LogService::log("Aucune donnée de devices récupérée", ['level' => 'warning']);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Étape 2: Traiter et mettre à jour la base de données
|
||||
$stats = updateDatabase($db, $devicesData);
|
||||
|
||||
// Étape 3: Logger les résultats
|
||||
$message = sprintf(
|
||||
"Mise à jour terminée : %d ajoutés, %d modifiés, %d désactivés, %d inchangés",
|
||||
$stats['added'],
|
||||
$stats['updated'],
|
||||
$stats['disabled'],
|
||||
$stats['unchanged']
|
||||
);
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] $message\n";
|
||||
|
||||
LogService::log($message, [
|
||||
'source' => 'cron',
|
||||
'stats' => $stats
|
||||
]);
|
||||
|
||||
// Étape 4: Envoyer une notification si changements significatifs
|
||||
if ($stats['added'] > 0 || $stats['disabled'] > 0) {
|
||||
sendNotification($stats);
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Mise à jour terminée avec succès\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
$errorMsg = "Erreur lors de la mise à jour des devices: " . $e->getMessage();
|
||||
echo "[" . date('Y-m-d H:i:s') . "] $errorMsg\n";
|
||||
LogService::log($errorMsg, [
|
||||
'level' => 'error',
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la liste des devices certifiés
|
||||
* Essaie d'abord depuis une URL externe, puis depuis un fichier local en fallback
|
||||
*/
|
||||
function fetchCertifiedDevices(): array {
|
||||
// Liste maintenue manuellement des devices certifiés en France
|
||||
// Source: Documentation Stripe Terminal et tests confirmés
|
||||
$frenchCertifiedDevices = [
|
||||
// Samsung Galaxy S Series
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21', 'model_identifier' => 'SM-G991B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21+', 'model_identifier' => 'SM-G996B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 Ultra', 'model_identifier' => 'SM-G998B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 FE', 'model_identifier' => 'SM-G990B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22', 'model_identifier' => 'SM-S901B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22+', 'model_identifier' => 'SM-S906B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22 Ultra', 'model_identifier' => 'SM-S908B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23', 'model_identifier' => 'SM-S911B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23+', 'model_identifier' => 'SM-S916B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 Ultra', 'model_identifier' => 'SM-S918B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 FE', 'model_identifier' => 'SM-S711B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24', 'model_identifier' => 'SM-S921B', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24+', 'model_identifier' => 'SM-S926B', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24 Ultra', 'model_identifier' => 'SM-S928B', 'min_android_version' => 14],
|
||||
|
||||
// Samsung Galaxy Note
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20', 'model_identifier' => 'SM-N980F', 'min_android_version' => 10],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20 Ultra', 'model_identifier' => 'SM-N986B', 'min_android_version' => 10],
|
||||
|
||||
// Samsung Galaxy Z Fold
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold3', 'model_identifier' => 'SM-F926B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold4', 'model_identifier' => 'SM-F936B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold5', 'model_identifier' => 'SM-F946B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold6', 'model_identifier' => 'SM-F956B', 'min_android_version' => 14],
|
||||
|
||||
// Samsung Galaxy Z Flip
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip3', 'model_identifier' => 'SM-F711B', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip4', 'model_identifier' => 'SM-F721B', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip5', 'model_identifier' => 'SM-F731B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip6', 'model_identifier' => 'SM-F741B', 'min_android_version' => 14],
|
||||
|
||||
// Samsung Galaxy A Series (haut de gamme)
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy A54', 'model_identifier' => 'SM-A546B', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Samsung', 'model' => 'Galaxy A73', 'model_identifier' => 'SM-A736B', 'min_android_version' => 12],
|
||||
|
||||
// Google Pixel
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 6', 'model_identifier' => 'oriole', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 6 Pro', 'model_identifier' => 'raven', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 6a', 'model_identifier' => 'bluejay', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 7', 'model_identifier' => 'panther', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 7 Pro', 'model_identifier' => 'cheetah', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 7a', 'model_identifier' => 'lynx', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 8', 'model_identifier' => 'shiba', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 8 Pro', 'model_identifier' => 'husky', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 8a', 'model_identifier' => 'akita', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 9', 'model_identifier' => 'tokay', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro', 'model_identifier' => 'caiman', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro XL', 'model_identifier' => 'komodo', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel Fold', 'model_identifier' => 'felix', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Google', 'model' => 'Pixel Tablet', 'model_identifier' => 'tangorpro', 'min_android_version' => 13],
|
||||
|
||||
// OnePlus
|
||||
['manufacturer' => 'OnePlus', 'model' => '9', 'model_identifier' => 'LE2113', 'min_android_version' => 11],
|
||||
['manufacturer' => 'OnePlus', 'model' => '9 Pro', 'model_identifier' => 'LE2123', 'min_android_version' => 11],
|
||||
['manufacturer' => 'OnePlus', 'model' => '10 Pro', 'model_identifier' => 'NE2213', 'min_android_version' => 12],
|
||||
['manufacturer' => 'OnePlus', 'model' => '10T', 'model_identifier' => 'CPH2413', 'min_android_version' => 12],
|
||||
['manufacturer' => 'OnePlus', 'model' => '11', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
|
||||
['manufacturer' => 'OnePlus', 'model' => '11R', 'model_identifier' => 'CPH2487', 'min_android_version' => 13],
|
||||
['manufacturer' => 'OnePlus', 'model' => '12', 'model_identifier' => 'CPH2581', 'min_android_version' => 14],
|
||||
['manufacturer' => 'OnePlus', 'model' => '12R', 'model_identifier' => 'CPH2585', 'min_android_version' => 14],
|
||||
['manufacturer' => 'OnePlus', 'model' => 'Open', 'model_identifier' => 'CPH2551', 'min_android_version' => 13],
|
||||
|
||||
// Xiaomi
|
||||
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11', 'model_identifier' => 'M2011K2G', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11 Ultra', 'model_identifier' => 'M2102K1G', 'min_android_version' => 11],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '12', 'model_identifier' => '2201123G', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '12 Pro', 'model_identifier' => '2201122G', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '12T Pro', 'model_identifier' => '2207122MC', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '13', 'model_identifier' => '2211133G', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '13 Pro', 'model_identifier' => '2210132G', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '13T Pro', 'model_identifier' => '23078PND5G', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '14', 'model_identifier' => '23127PN0CG', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '14 Pro', 'model_identifier' => '23116PN5BG', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Xiaomi', 'model' => '14 Ultra', 'model_identifier' => '24030PN60G', 'min_android_version' => 14],
|
||||
|
||||
// OPPO
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find X3 Pro', 'model_identifier' => 'CPH2173', 'min_android_version' => 11],
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find X5 Pro', 'model_identifier' => 'CPH2305', 'min_android_version' => 12],
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find X6 Pro', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find N2', 'model_identifier' => 'CPH2399', 'min_android_version' => 13],
|
||||
['manufacturer' => 'OPPO', 'model' => 'Find N3', 'model_identifier' => 'CPH2499', 'min_android_version' => 13],
|
||||
|
||||
// Realme
|
||||
['manufacturer' => 'Realme', 'model' => 'GT 2 Pro', 'model_identifier' => 'RMX3301', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Realme', 'model' => 'GT 3', 'model_identifier' => 'RMX3709', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Realme', 'model' => 'GT 5 Pro', 'model_identifier' => 'RMX3888', 'min_android_version' => 14],
|
||||
|
||||
// Honor
|
||||
['manufacturer' => 'Honor', 'model' => 'Magic5 Pro', 'model_identifier' => 'PGT-N19', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Honor', 'model' => 'Magic6 Pro', 'model_identifier' => 'BVL-N49', 'min_android_version' => 14],
|
||||
['manufacturer' => 'Honor', 'model' => '90', 'model_identifier' => 'REA-NX9', 'min_android_version' => 13],
|
||||
|
||||
// ASUS
|
||||
['manufacturer' => 'ASUS', 'model' => 'Zenfone 9', 'model_identifier' => 'AI2202', 'min_android_version' => 12],
|
||||
['manufacturer' => 'ASUS', 'model' => 'Zenfone 10', 'model_identifier' => 'AI2302', 'min_android_version' => 13],
|
||||
['manufacturer' => 'ASUS', 'model' => 'ROG Phone 7', 'model_identifier' => 'AI2205', 'min_android_version' => 13],
|
||||
|
||||
// Nothing
|
||||
['manufacturer' => 'Nothing', 'model' => 'Phone (1)', 'model_identifier' => 'A063', 'min_android_version' => 12],
|
||||
['manufacturer' => 'Nothing', 'model' => 'Phone (2)', 'model_identifier' => 'A065', 'min_android_version' => 13],
|
||||
['manufacturer' => 'Nothing', 'model' => 'Phone (2a)', 'model_identifier' => 'A142', 'min_android_version' => 14],
|
||||
];
|
||||
|
||||
// Essayer de charger depuis un fichier JSON local si présent
|
||||
if (file_exists(DEVICES_LOCAL_FILE)) {
|
||||
$localData = json_decode(file_get_contents(DEVICES_LOCAL_FILE), true);
|
||||
if (!empty($localData)) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Données chargées depuis le fichier local\n";
|
||||
return array_merge($frenchCertifiedDevices, $localData);
|
||||
}
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Utilisation de la liste intégrée des devices certifiés\n";
|
||||
return $frenchCertifiedDevices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la base de données avec les nouvelles données
|
||||
*/
|
||||
function updateDatabase($db, array $devices): array {
|
||||
$stats = [
|
||||
'added' => 0,
|
||||
'updated' => 0,
|
||||
'disabled' => 0,
|
||||
'unchanged' => 0,
|
||||
'total' => 0
|
||||
];
|
||||
|
||||
// Récupérer tous les devices existants
|
||||
$stmt = $db->prepare("SELECT * FROM stripe_android_certified_devices WHERE country = 'FR'");
|
||||
$stmt->execute();
|
||||
$existingDevices = [];
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$key = $row['manufacturer'] . '|' . $row['model'] . '|' . $row['model_identifier'];
|
||||
$existingDevices[$key] = $row;
|
||||
}
|
||||
|
||||
// Marquer tous les devices pour tracking
|
||||
$processedKeys = [];
|
||||
|
||||
// Traiter chaque device de la nouvelle liste
|
||||
foreach ($devices as $device) {
|
||||
$key = $device['manufacturer'] . '|' . $device['model'] . '|' . $device['model_identifier'];
|
||||
$processedKeys[$key] = true;
|
||||
|
||||
if (isset($existingDevices[$key])) {
|
||||
// Le device existe, vérifier s'il faut le mettre à jour
|
||||
$existing = $existingDevices[$key];
|
||||
|
||||
// Vérifier si des champs ont changé
|
||||
$needsUpdate = false;
|
||||
if ($existing['min_android_version'] != $device['min_android_version']) {
|
||||
$needsUpdate = true;
|
||||
}
|
||||
if ($existing['tap_to_pay_certified'] != 1) {
|
||||
$needsUpdate = true;
|
||||
}
|
||||
|
||||
if ($needsUpdate) {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE stripe_android_certified_devices
|
||||
SET min_android_version = :min_version,
|
||||
tap_to_pay_certified = 1,
|
||||
last_verified = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE manufacturer = :manufacturer
|
||||
AND model = :model
|
||||
AND model_identifier = :model_identifier
|
||||
AND country = 'FR'
|
||||
");
|
||||
$stmt->execute([
|
||||
'min_version' => $device['min_android_version'],
|
||||
'manufacturer' => $device['manufacturer'],
|
||||
'model' => $device['model'],
|
||||
'model_identifier' => $device['model_identifier']
|
||||
]);
|
||||
$stats['updated']++;
|
||||
|
||||
LogService::log("Device mis à jour", [
|
||||
'device' => $device['manufacturer'] . ' ' . $device['model']
|
||||
]);
|
||||
} else {
|
||||
// Juste mettre à jour last_verified
|
||||
$stmt = $db->prepare("
|
||||
UPDATE stripe_android_certified_devices
|
||||
SET last_verified = NOW()
|
||||
WHERE manufacturer = :manufacturer
|
||||
AND model = :model
|
||||
AND model_identifier = :model_identifier
|
||||
AND country = 'FR'
|
||||
");
|
||||
$stmt->execute([
|
||||
'manufacturer' => $device['manufacturer'],
|
||||
'model' => $device['model'],
|
||||
'model_identifier' => $device['model_identifier']
|
||||
]);
|
||||
$stats['unchanged']++;
|
||||
}
|
||||
} else {
|
||||
// Nouveau device, l'ajouter
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO stripe_android_certified_devices
|
||||
(manufacturer, model, model_identifier, tap_to_pay_certified,
|
||||
certification_date, min_android_version, country, notes, last_verified)
|
||||
VALUES
|
||||
(:manufacturer, :model, :model_identifier, 1,
|
||||
NOW(), :min_version, 'FR', 'Ajouté automatiquement via CRON', NOW())
|
||||
");
|
||||
$stmt->execute([
|
||||
'manufacturer' => $device['manufacturer'],
|
||||
'model' => $device['model'],
|
||||
'model_identifier' => $device['model_identifier'],
|
||||
'min_version' => $device['min_android_version']
|
||||
]);
|
||||
$stats['added']++;
|
||||
|
||||
LogService::log("Nouveau device ajouté", [
|
||||
'device' => $device['manufacturer'] . ' ' . $device['model']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Désactiver les devices qui ne sont plus dans la liste
|
||||
foreach ($existingDevices as $key => $existing) {
|
||||
if (!isset($processedKeys[$key]) && $existing['tap_to_pay_certified'] == 1) {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE stripe_android_certified_devices
|
||||
SET tap_to_pay_certified = 0,
|
||||
notes = CONCAT(IFNULL(notes, ''), ' | Désactivé le ', NOW(), ' (non présent dans la mise à jour)'),
|
||||
updated_at = NOW()
|
||||
WHERE id = :id
|
||||
");
|
||||
$stmt->execute(['id' => $existing['id']]);
|
||||
$stats['disabled']++;
|
||||
|
||||
LogService::log("Device désactivé", [
|
||||
'device' => $existing['manufacturer'] . ' ' . $existing['model'],
|
||||
'reason' => 'Non présent dans la liste mise à jour'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$stats['total'] = count($devices);
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification email aux administrateurs si changements importants
|
||||
*/
|
||||
function sendNotification(array $stats): void {
|
||||
try {
|
||||
// Récupérer la configuration
|
||||
$appConfig = AppConfig::getInstance();
|
||||
$emailConfig = $appConfig->getEmailConfig();
|
||||
|
||||
if (empty($emailConfig['admin_email'])) {
|
||||
return; // Pas d'email admin configuré
|
||||
}
|
||||
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Préparer le contenu de l'email
|
||||
$subject = "Mise à jour des devices Stripe Tap to Pay";
|
||||
$body = "Bonjour,\n\n";
|
||||
$body .= "La mise à jour automatique de la liste des appareils certifiés Stripe Tap to Pay a été effectuée.\n\n";
|
||||
$body .= "Résumé des changements :\n";
|
||||
$body .= "- Nouveaux appareils ajoutés : " . $stats['added'] . "\n";
|
||||
$body .= "- Appareils mis à jour : " . $stats['updated'] . "\n";
|
||||
$body .= "- Appareils désactivés : " . $stats['disabled'] . "\n";
|
||||
$body .= "- Appareils inchangés : " . $stats['unchanged'] . "\n";
|
||||
$body .= "- Total d'appareils traités : " . $stats['total'] . "\n\n";
|
||||
|
||||
if ($stats['added'] > 0) {
|
||||
$body .= "Les nouveaux appareils ont été automatiquement ajoutés à la base de données.\n";
|
||||
}
|
||||
|
||||
if ($stats['disabled'] > 0) {
|
||||
$body .= "Certains appareils ont été désactivés car ils ne sont plus certifiés.\n";
|
||||
}
|
||||
|
||||
$body .= "\nConsultez les logs pour plus de détails.\n";
|
||||
$body .= "\nCordialement,\nLe système GeoSector";
|
||||
|
||||
// Insérer dans la queue d'emails
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO email_queue
|
||||
(to_email, subject, body, status, created_at, attempts)
|
||||
VALUES
|
||||
(:to_email, :subject, :body, 'pending', NOW(), 0)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
'to_email' => $emailConfig['admin_email'],
|
||||
'subject' => $subject,
|
||||
'body' => $body
|
||||
]);
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Notification ajoutée à la queue d'emails\n";
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Ne pas faire échouer le script si l'email ne peut pas être envoyé
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Impossible d'envoyer la notification: " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
19
api/scripts/sql/alter_ope_pass_fk_sector_default_null.sql
Normal file
19
api/scripts/sql/alter_ope_pass_fk_sector_default_null.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Migration: Modifier fk_sector pour avoir DEFAULT NULL au lieu de DEFAULT 0
|
||||
-- Raison: La FK vers ope_sectors(id) ne permet pas la valeur 0 (aucun secteur avec id=0)
|
||||
-- Date: 2026-01-16
|
||||
|
||||
-- 1. D'abord, mettre à NULL les passages qui ont fk_sector = 0
|
||||
UPDATE ope_pass SET fk_sector = NULL WHERE fk_sector = 0;
|
||||
|
||||
-- 2. Modifier la colonne pour avoir DEFAULT NULL
|
||||
ALTER TABLE ope_pass MODIFY COLUMN fk_sector INT UNSIGNED DEFAULT NULL;
|
||||
|
||||
-- Vérification
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_DEFAULT,
|
||||
IS_NULLABLE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ope_pass'
|
||||
AND COLUMN_NAME = 'fk_sector';
|
||||
57
api/scripts/sql/create_event_stats_daily.sql
Normal file
57
api/scripts/sql/create_event_stats_daily.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- ============================================================
|
||||
-- Table event_stats_daily - Statistiques d'événements agrégées
|
||||
-- Version: 1.0
|
||||
-- Date: 2025-12-22
|
||||
-- ============================================================
|
||||
--
|
||||
-- Usage: Exécuter dans les 3 environnements (DEV, REC, PROD)
|
||||
-- mysql -u user -p database < create_event_stats_daily.sql
|
||||
--
|
||||
-- Description:
|
||||
-- Stocke les statistiques quotidiennes agrégées depuis les
|
||||
-- fichiers JSONL (/logs/events/YYYY-MM-DD.jsonl)
|
||||
-- Alimentée par le CRON aggregate_event_stats.php (1x/nuit)
|
||||
--
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_stats_daily (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
-- Clés d'agrégation
|
||||
stat_date DATE NOT NULL COMMENT 'Date des statistiques',
|
||||
entity_id INT UNSIGNED NULL COMMENT 'ID entité (NULL = stats globales super-admin)',
|
||||
event_type VARCHAR(50) NOT NULL COMMENT 'Type événement (login_success, passage_created, etc.)',
|
||||
|
||||
-- Compteurs
|
||||
count INT UNSIGNED DEFAULT 0 COMMENT 'Nombre d\'occurrences',
|
||||
sum_amount DECIMAL(12,2) DEFAULT 0.00 COMMENT 'Somme des montants (passages/paiements)',
|
||||
unique_users INT UNSIGNED DEFAULT 0 COMMENT 'Nombre d\'utilisateurs distincts',
|
||||
|
||||
-- Métadonnées agrégées (JSON)
|
||||
metadata JSON NULL COMMENT 'Données agrégées: top secteurs, erreurs fréquentes, etc.',
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Contraintes
|
||||
UNIQUE KEY uk_date_entity_event (stat_date, entity_id, event_type),
|
||||
INDEX idx_entity_date (entity_id, stat_date),
|
||||
INDEX idx_date (stat_date),
|
||||
INDEX idx_event_type (event_type)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Statistiques quotidiennes agrégées des événements (EventLogService)';
|
||||
|
||||
-- ============================================================
|
||||
-- Exemples de données attendues
|
||||
-- ============================================================
|
||||
--
|
||||
-- | stat_date | entity_id | event_type | count | sum_amount | unique_users |
|
||||
-- |------------|-----------|------------------|-------|------------|--------------|
|
||||
-- | 2025-12-22 | 5 | login_success | 45 | 0.00 | 12 |
|
||||
-- | 2025-12-22 | 5 | passage_created | 128 | 2450.00 | 8 |
|
||||
-- | 2025-12-22 | 5 | stripe_payment_success | 12 | 890.00 | 6 |
|
||||
-- | 2025-12-22 | NULL | login_success | 320 | 0.00 | 85 | <- Global
|
||||
--
|
||||
-- ============================================================
|
||||
5
api/scripts/sql/drop_stripe_devices_table.sql
Normal file
5
api/scripts/sql/drop_stripe_devices_table.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Suppression de la table stripe_android_certified_devices
|
||||
-- Cette table n'est plus utilisée : la vérification de compatibilité Tap to Pay
|
||||
-- se fait maintenant directement côté client via le SDK Stripe Terminal
|
||||
|
||||
DROP TABLE IF EXISTS stripe_android_certified_devices;
|
||||
484
api/src/Controllers/EventStatsController.php
Normal file
484
api/src/Controllers/EventStatsController.php
Normal file
@@ -0,0 +1,484 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
require_once __DIR__ . '/../Services/LogService.php';
|
||||
require_once __DIR__ . '/../Services/EventStatsService.php';
|
||||
|
||||
use PDO;
|
||||
use Database;
|
||||
use Response;
|
||||
use Session;
|
||||
use App\Services\LogService;
|
||||
use App\Services\EventStatsService;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* EventStatsController - Contrôleur des statistiques d'événements
|
||||
*
|
||||
* Endpoints pour consulter les stats agrégées et le détail des événements.
|
||||
* Accès réservé aux Admin entité (role_id = 2) et Super-admin (role_id = 1).
|
||||
*/
|
||||
class EventStatsController
|
||||
{
|
||||
private PDO $db;
|
||||
private EventStatsService $statsService;
|
||||
|
||||
/** @var array Rôles autorisés à consulter les stats */
|
||||
private const ALLOWED_ROLES = [1, 2]; // Super-admin, Admin
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::getInstance();
|
||||
$this->statsService = new EventStatsService();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/summary
|
||||
* Récupère le résumé des stats pour une date
|
||||
*
|
||||
* Query params:
|
||||
* - date: Date (YYYY-MM-DD), défaut = aujourd'hui
|
||||
*/
|
||||
public function summary(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$date = $_GET['date'] ?? date('Y-m-d');
|
||||
|
||||
// Validation de la date
|
||||
if (!$this->isValidDate($date)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$summary = $this->statsService->getSummary($entityId, $date);
|
||||
|
||||
$this->jsonWithCache([
|
||||
'status' => 'success',
|
||||
'data' => $summary,
|
||||
], true);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération du résumé des stats', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des statistiques',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/daily
|
||||
* Récupère les stats journalières sur une plage de dates
|
||||
*
|
||||
* Query params:
|
||||
* - from: Date début (YYYY-MM-DD), requis
|
||||
* - to: Date fin (YYYY-MM-DD), requis
|
||||
* - events: Types d'événements (comma-separated), optionnel
|
||||
*/
|
||||
public function daily(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$from = $_GET['from'] ?? null;
|
||||
$to = $_GET['to'] ?? null;
|
||||
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
|
||||
|
||||
// Validation des dates
|
||||
if (!$from || !$to) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Les paramètres from et to sont requis',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($from > $to) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'La date de début doit être antérieure à la date de fin',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limiter la plage à 90 jours
|
||||
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
|
||||
if ($daysDiff > 90) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'La plage de dates ne peut pas dépasser 90 jours',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$daily = $this->statsService->getDaily($entityId, $from, $to, $eventTypes);
|
||||
|
||||
$this->jsonWithCache([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'days' => $daily,
|
||||
],
|
||||
], true);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération des stats journalières', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des statistiques',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/weekly
|
||||
* Récupère les stats hebdomadaires sur une plage de dates
|
||||
*
|
||||
* Query params:
|
||||
* - from: Date début (YYYY-MM-DD), requis
|
||||
* - to: Date fin (YYYY-MM-DD), requis
|
||||
* - events: Types d'événements (comma-separated), optionnel
|
||||
*/
|
||||
public function weekly(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$from = $_GET['from'] ?? null;
|
||||
$to = $_GET['to'] ?? null;
|
||||
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
|
||||
|
||||
// Validation des dates
|
||||
if (!$from || !$to) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Les paramètres from et to sont requis',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limiter la plage à 1 an
|
||||
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
|
||||
if ($daysDiff > 365) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'La plage de dates ne peut pas dépasser 1 an',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$weekly = $this->statsService->getWeekly($entityId, $from, $to, $eventTypes);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'weeks' => $weekly,
|
||||
],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération des stats hebdomadaires', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des statistiques',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/monthly
|
||||
* Récupère les stats mensuelles pour une année
|
||||
*
|
||||
* Query params:
|
||||
* - year: Année (YYYY), défaut = année courante
|
||||
* - events: Types d'événements (comma-separated), optionnel
|
||||
*/
|
||||
public function monthly(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y');
|
||||
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
|
||||
|
||||
// Validation de l'année
|
||||
if ($year < 2020 || $year > (int) date('Y') + 1) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Année invalide',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$monthly = $this->statsService->getMonthly($entityId, $year, $eventTypes);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'year' => $year,
|
||||
'months' => $monthly,
|
||||
],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération des stats mensuelles', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des statistiques',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/details
|
||||
* Récupère le détail des événements (lecture JSONL paginée)
|
||||
*
|
||||
* Query params:
|
||||
* - date: Date (YYYY-MM-DD), requis
|
||||
* - event: Type d'événement, optionnel
|
||||
* - limit: Nombre max (défaut 50, max 100)
|
||||
* - offset: Décalage pour pagination (défaut 0)
|
||||
*/
|
||||
public function details(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entityId = $this->getEntityIdForQuery();
|
||||
$date = $_GET['date'] ?? null;
|
||||
$eventType = $_GET['event'] ?? null;
|
||||
$limit = isset($_GET['limit']) ? min((int) $_GET['limit'], 100) : 50;
|
||||
$offset = isset($_GET['offset']) ? max((int) $_GET['offset'], 0) : 0;
|
||||
|
||||
// Validation de la date
|
||||
if (!$date) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Le paramètre date est requis',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isValidDate($date)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
|
||||
], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$details = $this->statsService->getDetails($entityId, $date, $eventType, $limit, $offset);
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'data' => $details,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération du détail des événements', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des événements',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/events/stats/types
|
||||
* Récupère la liste des types d'événements disponibles
|
||||
*/
|
||||
public function types(): void
|
||||
{
|
||||
try {
|
||||
if (!$this->checkAccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$types = $this->statsService->getEventTypes();
|
||||
|
||||
Response::json([
|
||||
'status' => 'success',
|
||||
'data' => $types,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
LogService::error('Erreur lors de la récupération des types d\'événements', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur lors de la récupération des types',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== MÉTHODES PRIVÉES ====================
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur a accès aux stats
|
||||
*/
|
||||
private function checkAccess(): bool
|
||||
{
|
||||
$roleId = Session::getRole();
|
||||
|
||||
if (!in_array($roleId, self::ALLOWED_ROLES)) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Accès non autorisé. Rôle Admin ou Super-admin requis.',
|
||||
], 403);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine l'entity_id à utiliser pour la requête
|
||||
* Super-admin (role_id = 1) peut voir toutes les entités (null)
|
||||
* Admin (role_id = 2) voit uniquement son entité
|
||||
*/
|
||||
private function getEntityIdForQuery(): ?int
|
||||
{
|
||||
$roleId = Session::getRole();
|
||||
|
||||
// Super-admin : accès global
|
||||
if ($roleId === 1) {
|
||||
// Permettre de filtrer par entité si spécifié
|
||||
if (isset($_GET['entity_id'])) {
|
||||
return (int) $_GET['entity_id'];
|
||||
}
|
||||
return null; // Stats globales
|
||||
}
|
||||
|
||||
// Admin : uniquement son entité
|
||||
return Session::getEntityId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide le format d'une date
|
||||
*/
|
||||
private function isValidDate(string $date): bool
|
||||
{
|
||||
$d = \DateTime::createFromFormat('Y-m-d', $date);
|
||||
return $d && $d->format('Y-m-d') === $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une réponse JSON avec support ETag et compression gzip
|
||||
*
|
||||
* @param array $data Données à envoyer
|
||||
* @param bool $useCache Activer le cache ETag
|
||||
*/
|
||||
private function jsonWithCache(array $data, bool $useCache = true): void
|
||||
{
|
||||
// Nettoyer tout buffer existant
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// Encoder en JSON
|
||||
$jsonResponse = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($jsonResponse === false) {
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Erreur d\'encodage de la réponse',
|
||||
], 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Headers CORS
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Origin, Content-Type, X-Auth-Token, Authorization, X-Requested-With');
|
||||
header('Access-Control-Expose-Headers: Content-Length, ETag');
|
||||
|
||||
// Content-Type
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
// ETag pour le cache
|
||||
if ($useCache) {
|
||||
$etag = '"' . md5($jsonResponse) . '"';
|
||||
header('ETag: ' . $etag);
|
||||
header('Cache-Control: private, max-age=300'); // 5 minutes
|
||||
|
||||
// Vérifier If-None-Match
|
||||
$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
|
||||
if ($ifNoneMatch === $etag) {
|
||||
http_response_code(304); // Not Modified
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Compression gzip si supportée
|
||||
$supportsGzip = isset($_SERVER['HTTP_ACCEPT_ENCODING'])
|
||||
&& strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false;
|
||||
|
||||
if ($supportsGzip && strlen($jsonResponse) > 1024) {
|
||||
$compressed = gzencode($jsonResponse, 6);
|
||||
if ($compressed !== false) {
|
||||
header('Content-Encoding: gzip');
|
||||
header('Content-Length: ' . strlen($compressed));
|
||||
http_response_code(200);
|
||||
echo $compressed;
|
||||
flush();
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Réponse non compressée
|
||||
header('Content-Length: ' . strlen($jsonResponse));
|
||||
http_response_code(200);
|
||||
echo $jsonResponse;
|
||||
flush();
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -2086,18 +2086,38 @@ class LoginController {
|
||||
], 201);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->db->rollBack();
|
||||
LogService::log('Erreur lors de la création du compte GeoSector', [
|
||||
// Vérifier si une transaction est active avant de faire rollback
|
||||
if ($this->db->inTransaction()) {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
|
||||
// Construire un message d'erreur détaillé pour le logging
|
||||
$errorDetails = [
|
||||
'level' => 'error',
|
||||
'error' => $e->getMessage(),
|
||||
'email' => $email,
|
||||
'amicaleName' => $amicaleName,
|
||||
'postalCode' => $postalCode
|
||||
]);
|
||||
'exception_class' => get_class($e),
|
||||
'error_message' => $e->getMessage(),
|
||||
'error_code' => $e->getCode(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'email' => $email ?? 'non disponible',
|
||||
'amicaleName' => $amicaleName ?? 'non disponible',
|
||||
'postalCode' => $postalCode ?? 'non disponible',
|
||||
'trace' => $e->getTraceAsString()
|
||||
];
|
||||
|
||||
// Si c'est une PDOException, ajouter les infos SQL
|
||||
if ($e instanceof PDOException) {
|
||||
$errorDetails['pdo_error_info'] = $this->db->errorInfo();
|
||||
}
|
||||
|
||||
LogService::log('Erreur lors de la création du compte GeoSector', $errorDetails);
|
||||
|
||||
// Retourner un message utilisateur clair (ne pas exposer les détails techniques)
|
||||
$userMessage = 'Une erreur est survenue lors de la création du compte. Veuillez réessayer ou contacter le support.';
|
||||
|
||||
Response::json([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage()
|
||||
'message' => $userMessage
|
||||
], 500);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ require_once __DIR__ . '/../Services/LogService.php';
|
||||
require_once __DIR__ . '/../Services/EventLogService.php';
|
||||
require_once __DIR__ . '/../Services/ApiService.php';
|
||||
require_once __DIR__ . '/../Services/ReceiptService.php';
|
||||
require_once __DIR__ . '/../Services/SectorService.php';
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
@@ -19,6 +20,7 @@ use Session;
|
||||
use App\Services\LogService;
|
||||
use App\Services\EventLogService;
|
||||
use App\Services\ApiService;
|
||||
use App\Services\SectorService;
|
||||
use Exception;
|
||||
use DateTime;
|
||||
|
||||
@@ -516,14 +518,26 @@ class PassageController {
|
||||
}
|
||||
|
||||
// Récupérer ope_users.id pour l'utilisateur du passage
|
||||
// $data['fk_user'] contient users.id, on doit le convertir en ope_users.id
|
||||
// $data['fk_user'] peut contenir soit users.id soit ope_users.id
|
||||
$passageUserId = (int)$data['fk_user'];
|
||||
$stmtOpeUser = $this->db->prepare('
|
||||
|
||||
// Vérifier d'abord si c'est déjà un ope_users.id valide
|
||||
$stmtCheckOpeUser = $this->db->prepare('
|
||||
SELECT id FROM ope_users
|
||||
WHERE fk_user = ? AND fk_operation = ?
|
||||
WHERE id = ? AND fk_operation = ?
|
||||
');
|
||||
$stmtOpeUser->execute([$passageUserId, $operationId]);
|
||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||
$stmtCheckOpeUser->execute([$passageUserId, $operationId]);
|
||||
$opeUserId = $stmtCheckOpeUser->fetchColumn();
|
||||
|
||||
if (!$opeUserId) {
|
||||
// Ce n'est pas un ope_users.id, essayer de le convertir depuis users.id
|
||||
$stmtOpeUser = $this->db->prepare('
|
||||
SELECT id FROM ope_users
|
||||
WHERE fk_user = ? AND fk_operation = ?
|
||||
');
|
||||
$stmtOpeUser->execute([$passageUserId, $operationId]);
|
||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||
}
|
||||
|
||||
if (!$opeUserId) {
|
||||
Response::json([
|
||||
@@ -533,6 +547,88 @@ class PassageController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Détermination automatique du secteur
|
||||
$sectorId = null;
|
||||
$gpsLat = isset($data['gps_lat']) && $data['gps_lat'] !== '' ? (float)$data['gps_lat'] : null;
|
||||
$gpsLng = isset($data['gps_lng']) && $data['gps_lng'] !== '' ? (float)$data['gps_lng'] : null;
|
||||
|
||||
// 1. Si fk_sector > 0 fourni → l'utiliser directement
|
||||
if (isset($data['fk_sector']) && (int)$data['fk_sector'] > 0) {
|
||||
$sectorId = (int)$data['fk_sector'];
|
||||
LogService::info('[PassageController] Secteur fourni par le client', [
|
||||
'sector_id' => $sectorId
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. Si pas de secteur et GPS valide (différent de 0.0) → recherche par GPS
|
||||
if ($sectorId === null && $gpsLat !== null && $gpsLng !== null && ($gpsLat != 0.0 || $gpsLng != 0.0)) {
|
||||
$sectorService = new SectorService();
|
||||
$sectorId = $sectorService->findSectorByGps($operationId, $gpsLat, $gpsLng);
|
||||
LogService::info('[PassageController] Recherche secteur par GPS', [
|
||||
'operation_id' => $operationId,
|
||||
'lat' => $gpsLat,
|
||||
'lng' => $gpsLng,
|
||||
'sector_found' => $sectorId
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. Si toujours pas de secteur et adresse fournie → géocodage + recherche
|
||||
if ($sectorId === null && !empty($data['numero']) && !empty($data['rue']) && !empty($data['ville'])) {
|
||||
// Récupérer le code postal de l'entité pour la vérification du département
|
||||
$stmtEntite = $this->db->prepare('
|
||||
SELECT e.code_postal FROM entites e
|
||||
INNER JOIN operations o ON o.fk_entite = e.id
|
||||
WHERE o.id = ?
|
||||
');
|
||||
$stmtEntite->execute([$operationId]);
|
||||
$entiteCp = $stmtEntite->fetchColumn() ?: '';
|
||||
|
||||
$sectorService = new SectorService();
|
||||
$result = $sectorService->findSectorByAddress(
|
||||
$operationId,
|
||||
trim($data['numero']),
|
||||
$data['rue_bis'] ?? '',
|
||||
trim($data['rue']),
|
||||
trim($data['ville']),
|
||||
$entiteCp
|
||||
);
|
||||
|
||||
if ($result) {
|
||||
$sectorId = $result['sector_id'];
|
||||
// Mettre à jour les coordonnées GPS si le géocodage les a trouvées
|
||||
if ($result['gps_lat'] && $result['gps_lng']) {
|
||||
$gpsLat = $result['gps_lat'];
|
||||
$gpsLng = $result['gps_lng'];
|
||||
}
|
||||
LogService::info('[PassageController] Recherche secteur par adresse', [
|
||||
'operation_id' => $operationId,
|
||||
'adresse' => $data['numero'] . ' ' . $data['rue'] . ' ' . $data['ville'],
|
||||
'sector_found' => $sectorId,
|
||||
'gps_geocoded' => ($result['gps_lat'] && $result['gps_lng'])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback : si toujours pas de secteur, prendre le 1er secteur de l'opération
|
||||
if ($sectorId === null) {
|
||||
$stmtFirstSector = $this->db->prepare('
|
||||
SELECT id FROM ope_sectors
|
||||
WHERE fk_operation = ? AND chk_active = 1
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmtFirstSector->execute([$operationId]);
|
||||
$firstSectorId = $stmtFirstSector->fetchColumn();
|
||||
|
||||
if ($firstSectorId) {
|
||||
$sectorId = (int)$firstSectorId;
|
||||
LogService::info('[PassageController] Fallback: premier secteur de l\'opération', [
|
||||
'operation_id' => $operationId,
|
||||
'sector_id' => $sectorId
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Chiffrement des données sensibles
|
||||
$encryptedName = '';
|
||||
if (isset($data['name']) && !empty(trim($data['name']))) {
|
||||
@@ -549,7 +645,7 @@ class PassageController {
|
||||
// Préparation des données pour l'insertion
|
||||
$insertData = [
|
||||
'fk_operation' => $operationId,
|
||||
'fk_sector' => isset($data['fk_sector']) ? (int)$data['fk_sector'] : 0,
|
||||
'fk_sector' => $sectorId, // Peut être NULL si aucun secteur trouvé
|
||||
'fk_user' => $opeUserId,
|
||||
'fk_adresse' => $data['fk_adresse'] ?? '',
|
||||
'passed_at' => isset($data['passed_at']) ? $data['passed_at'] : null,
|
||||
@@ -562,8 +658,8 @@ class PassageController {
|
||||
'appt' => $data['appt'] ?? '',
|
||||
'niveau' => $data['niveau'] ?? '',
|
||||
'residence' => $data['residence'] ?? '',
|
||||
'gps_lat' => $data['gps_lat'] ?? '',
|
||||
'gps_lng' => $data['gps_lng'] ?? '',
|
||||
'gps_lat' => $gpsLat ?? '',
|
||||
'gps_lng' => $gpsLng ?? '',
|
||||
'encrypted_name' => $encryptedName,
|
||||
'montant' => isset($data['montant']) ? (float)$data['montant'] : 0.00,
|
||||
'fk_type_reglement' => isset($data['fk_type_reglement']) ? (int)$data['fk_type_reglement'] : 1,
|
||||
@@ -596,7 +692,7 @@ class PassageController {
|
||||
EventLogService::logPassageCreated(
|
||||
(int)$passageId,
|
||||
$insertData['fk_operation'],
|
||||
$insertData['fk_sector'],
|
||||
$insertData['fk_sector'] ?? 0, // 0 si secteur non trouvé
|
||||
$insertData['montant'],
|
||||
(string)$insertData['fk_type_reglement']
|
||||
);
|
||||
|
||||
@@ -15,14 +15,12 @@ require_once __DIR__ . '/../Services/ApiService.php';
|
||||
class SectorController
|
||||
{
|
||||
private \PDO $db;
|
||||
private LogService $logService;
|
||||
private AddressService $addressService;
|
||||
private DepartmentBoundaryService $boundaryService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::getInstance();
|
||||
$this->logService = new LogService();
|
||||
$this->addressService = new AddressService();
|
||||
$this->boundaryService = new DepartmentBoundaryService();
|
||||
}
|
||||
@@ -72,7 +70,7 @@ class SectorController
|
||||
Response::json(['status' => 'success', 'data' => $sectors]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de la récupération des secteurs', [
|
||||
LogService::error('Erreur lors de la récupération des secteurs', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
@@ -152,14 +150,14 @@ class SectorController
|
||||
$departmentsTouched = $this->boundaryService->getDepartmentsForSector($coordinates);
|
||||
|
||||
if (empty($departmentsTouched)) {
|
||||
$this->logService->warning('Aucun département trouvé pour le secteur', [
|
||||
LogService::warning('Aucun département trouvé pour le secteur', [
|
||||
'libelle' => $data['libelle'],
|
||||
'entity_id' => $entityId,
|
||||
'entity_dept' => $departement
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->warning('Impossible de vérifier les limites départementales', [
|
||||
LogService::warning('Impossible de vérifier les limites départementales', [
|
||||
'error' => $e->getMessage(),
|
||||
'libelle' => $data['libelle']
|
||||
]);
|
||||
@@ -169,7 +167,7 @@ class SectorController
|
||||
try {
|
||||
$addressCount = $this->addressService->countAddressesInPolygon($coordinates, $entityId);
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->warning('Impossible de récupérer les adresses du secteur', [
|
||||
LogService::warning('Impossible de récupérer les adresses du secteur', [
|
||||
'error' => $e->getMessage(),
|
||||
'libelle' => $data['libelle'],
|
||||
'entity_id' => $entityId
|
||||
@@ -208,7 +206,7 @@ class SectorController
|
||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||
|
||||
if (!$opeUserId) {
|
||||
$this->logService->warning('ope_users.id non trouvé pour cette opération', [
|
||||
LogService::warning('ope_users.id non trouvé pour cette opération', [
|
||||
'ope_users_id' => $memberId,
|
||||
'operation_id' => $operationId
|
||||
]);
|
||||
@@ -275,7 +273,7 @@ class SectorController
|
||||
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->warning('Erreur lors de la récupération des passages orphelins', [
|
||||
LogService::warning('Erreur lors de la récupération des passages orphelins', [
|
||||
'sector_id' => $sectorId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
@@ -335,7 +333,7 @@ class SectorController
|
||||
$firstOpeUserId = $stmtFirstOpeUser->fetchColumn();
|
||||
|
||||
if (!$firstOpeUserId) {
|
||||
$this->logService->warning('Premier ope_users.id non trouvé pour cette opération', [
|
||||
LogService::warning('Premier ope_users.id non trouvé pour cette opération', [
|
||||
'ope_users_id' => $users[0],
|
||||
'operation_id' => $operationId
|
||||
]);
|
||||
@@ -401,7 +399,7 @@ class SectorController
|
||||
|
||||
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
|
||||
if ($fkHabitat == 2 && $nbLog > 1) {
|
||||
$this->logService->info('[SectorController] Création passages immeuble avec GPS uniformisés', [
|
||||
LogService::info('[SectorController] Création passages immeuble avec GPS uniformisés', [
|
||||
'address_id' => $address['id'],
|
||||
'nb_passages' => $nbLog,
|
||||
'gps_lat' => $gpsLat,
|
||||
@@ -410,7 +408,7 @@ class SectorController
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->warning('Erreur lors de la création d\'un passage', [
|
||||
LogService::warning('Erreur lors de la création d\'un passage', [
|
||||
'address_id' => $address['id'],
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
@@ -421,7 +419,7 @@ class SectorController
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// En cas d'erreur avec les adresses, on ne bloque pas la création du secteur
|
||||
$this->logService->error('Erreur lors du stockage des adresses du secteur', [
|
||||
LogService::error('Erreur lors du stockage des adresses du secteur', [
|
||||
'sector_id' => $sectorId,
|
||||
'error' => $e->getMessage(),
|
||||
'entity_id' => $entityId
|
||||
@@ -525,7 +523,7 @@ class SectorController
|
||||
$responseData['users_sectors'][] = $userData;
|
||||
}
|
||||
|
||||
$this->logService->info('Secteur créé', [
|
||||
LogService::info('Secteur créé', [
|
||||
'sector_id' => $sectorId,
|
||||
'libelle' => $sectorData['libelle'],
|
||||
'entity_id' => $entityId,
|
||||
@@ -567,7 +565,7 @@ class SectorController
|
||||
if ($this->db->inTransaction()) {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
$this->logService->error('Erreur lors de la création du secteur', [
|
||||
LogService::error('Erreur lors de la création du secteur', [
|
||||
'error' => $e->getMessage(),
|
||||
'data' => $data ?? null
|
||||
]);
|
||||
@@ -634,7 +632,7 @@ class SectorController
|
||||
|
||||
// Gestion des membres (reçus comme 'users' depuis Flutter)
|
||||
if (isset($data['users'])) {
|
||||
$this->logService->info('[UPDATE USERS] Début modification des membres', [
|
||||
LogService::info('[UPDATE USERS] Début modification des membres', [
|
||||
'sector_id' => $id,
|
||||
'users_demandes' => $data['users'],
|
||||
'nb_users' => count($data['users'])
|
||||
@@ -642,27 +640,27 @@ class SectorController
|
||||
|
||||
// Récupérer l'opération du secteur pour l'INSERT
|
||||
$opQuery = "SELECT fk_operation FROM ope_sectors WHERE id = :sector_id";
|
||||
$this->logService->info('[UPDATE USERS] SQL - Récupération fk_operation', [
|
||||
LogService::info('[UPDATE USERS] SQL - Récupération fk_operation', [
|
||||
'query' => $opQuery,
|
||||
'params' => ['sector_id' => $id]
|
||||
]);
|
||||
$opStmt = $this->db->prepare($opQuery);
|
||||
$opStmt->execute(['sector_id' => $id]);
|
||||
$operationId = $opStmt->fetch()['fk_operation'];
|
||||
$this->logService->info('[UPDATE USERS] fk_operation récupéré', [
|
||||
LogService::info('[UPDATE USERS] fk_operation récupéré', [
|
||||
'operation_id' => $operationId
|
||||
]);
|
||||
|
||||
// Supprimer les affectations existantes
|
||||
$deleteQuery = "DELETE FROM ope_users_sectors WHERE fk_sector = :sector_id";
|
||||
$this->logService->info('[UPDATE USERS] SQL - Suppression des anciens membres', [
|
||||
LogService::info('[UPDATE USERS] SQL - Suppression des anciens membres', [
|
||||
'query' => $deleteQuery,
|
||||
'params' => ['sector_id' => $id]
|
||||
]);
|
||||
$deleteStmt = $this->db->prepare($deleteQuery);
|
||||
$deleteStmt->execute(['sector_id' => $id]);
|
||||
$deletedCount = $deleteStmt->rowCount();
|
||||
$this->logService->info('[UPDATE USERS] Membres supprimés', [
|
||||
LogService::info('[UPDATE USERS] Membres supprimés', [
|
||||
'nb_deleted' => $deletedCount
|
||||
]);
|
||||
|
||||
@@ -670,7 +668,7 @@ class SectorController
|
||||
if (!empty($data['users'])) {
|
||||
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active)
|
||||
VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)";
|
||||
$this->logService->info('[UPDATE USERS] SQL - Requête INSERT préparée', [
|
||||
LogService::info('[UPDATE USERS] SQL - Requête INSERT préparée', [
|
||||
'query' => $insertQuery
|
||||
]);
|
||||
$insertStmt = $this->db->prepare($insertQuery);
|
||||
@@ -689,7 +687,7 @@ class SectorController
|
||||
$opeUserId = $stmtOpeUser->fetchColumn();
|
||||
|
||||
if (!$opeUserId) {
|
||||
$this->logService->warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [
|
||||
LogService::warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [
|
||||
'ope_users_id' => $memberId,
|
||||
'operation_id' => $operationId
|
||||
]);
|
||||
@@ -703,17 +701,17 @@ class SectorController
|
||||
'sector_id' => $id,
|
||||
'user_creat' => $_SESSION['user_id'] ?? null
|
||||
];
|
||||
$this->logService->info('[UPDATE USERS] SQL - INSERT user', [
|
||||
LogService::info('[UPDATE USERS] SQL - INSERT user', [
|
||||
'params' => $params
|
||||
]);
|
||||
$insertStmt->execute($params);
|
||||
$insertedUsers[] = $memberId;
|
||||
$this->logService->info('[UPDATE USERS] User inséré avec succès', [
|
||||
LogService::info('[UPDATE USERS] User inséré avec succès', [
|
||||
'user_id' => $memberId
|
||||
]);
|
||||
} catch (\PDOException $e) {
|
||||
$failedUsers[] = $memberId;
|
||||
$this->logService->warning('[UPDATE USERS] ERREUR insertion user', [
|
||||
LogService::warning('[UPDATE USERS] ERREUR insertion user', [
|
||||
'sector_id' => $id,
|
||||
'user_id' => $memberId,
|
||||
'error' => $e->getMessage(),
|
||||
@@ -722,7 +720,7 @@ class SectorController
|
||||
}
|
||||
}
|
||||
|
||||
$this->logService->info('[UPDATE USERS] Résultat des insertions', [
|
||||
LogService::info('[UPDATE USERS] Résultat des insertions', [
|
||||
'users_demandes' => $data['users'],
|
||||
'users_inseres' => $insertedUsers,
|
||||
'users_echoues' => $failedUsers,
|
||||
@@ -744,7 +742,7 @@ class SectorController
|
||||
$chkAdressesChange = $data['chk_adresses_change'] ?? 1;
|
||||
|
||||
if (isset($data['sector']) && $chkAdressesChange == 0) {
|
||||
$this->logService->info('[UPDATE] Modification secteur sans recalcul adresses/passages', [
|
||||
LogService::info('[UPDATE] Modification secteur sans recalcul adresses/passages', [
|
||||
'sector_id' => $id,
|
||||
'chk_adresses_change' => $chkAdressesChange
|
||||
]);
|
||||
@@ -770,7 +768,7 @@ class SectorController
|
||||
}
|
||||
|
||||
// Récupérer et stocker les nouvelles adresses
|
||||
$this->logService->info('[UPDATE] Récupération des adresses', [
|
||||
LogService::info('[UPDATE] Récupération des adresses', [
|
||||
'sector_id' => $id,
|
||||
'entity_id' => $entityId,
|
||||
'nb_points' => count($coordinates)
|
||||
@@ -781,7 +779,7 @@ class SectorController
|
||||
// Enrichir les adresses avec les données bâtiments
|
||||
$addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId);
|
||||
|
||||
$this->logService->info('[UPDATE] Adresses récupérées', [
|
||||
LogService::info('[UPDATE] Adresses récupérées', [
|
||||
'sector_id' => $id,
|
||||
'nb_addresses' => count($addresses)
|
||||
]);
|
||||
@@ -815,12 +813,12 @@ class SectorController
|
||||
]);
|
||||
}
|
||||
|
||||
$this->logService->info('[UPDATE] Adresses stockées dans sectors_adresses', [
|
||||
LogService::info('[UPDATE] Adresses stockées dans sectors_adresses', [
|
||||
'sector_id' => $id,
|
||||
'nb_stored' => count($addresses)
|
||||
]);
|
||||
} else {
|
||||
$this->logService->warning('[UPDATE] Aucune adresse trouvée pour le secteur', [
|
||||
LogService::warning('[UPDATE] Aucune adresse trouvée pour le secteur', [
|
||||
'sector_id' => $id,
|
||||
'entity_id' => $entityId
|
||||
]);
|
||||
@@ -828,19 +826,19 @@ class SectorController
|
||||
|
||||
// Vérifier si c'est un problème de connexion à la base d'adresses
|
||||
if (!$this->addressService->isConnected()) {
|
||||
$this->logService->warning('[UPDATE] Base d\'adresses non accessible - passages créés sans adresses', [
|
||||
LogService::warning('[UPDATE] Base d\'adresses non accessible - passages créés sans adresses', [
|
||||
'sector_id' => $id
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('[UPDATE] Erreur lors de la mise à jour des adresses du secteur', [
|
||||
LogService::error('[UPDATE] Erreur lors de la mise à jour des adresses du secteur', [
|
||||
'sector_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
|
||||
// Maintenant que les adresses sont mises à jour, traiter les passages
|
||||
$this->logService->info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]);
|
||||
LogService::info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]);
|
||||
$passageCounters = $this->updatePassagesForSector($id, $data['sector']);
|
||||
}
|
||||
|
||||
@@ -934,7 +932,7 @@ class SectorController
|
||||
WHERE ous.fk_sector = :sector_id
|
||||
ORDER BY u.id";
|
||||
|
||||
$this->logService->info('[UPDATE USERS] SQL - Récupération finale des users', [
|
||||
LogService::info('[UPDATE USERS] SQL - Récupération finale des users', [
|
||||
'query' => $usersQuery,
|
||||
'params' => ['sector_id' => $id]
|
||||
]);
|
||||
@@ -944,7 +942,7 @@ class SectorController
|
||||
$usersSectors = $usersStmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||
|
||||
$userIds = array_column($usersSectors, 'id');
|
||||
$this->logService->info('[UPDATE USERS] Users récupérés après commit', [
|
||||
LogService::info('[UPDATE USERS] Users récupérés après commit', [
|
||||
'sector_id' => $id,
|
||||
'users_ids' => $userIds,
|
||||
'nb_users' => count($userIds),
|
||||
@@ -971,7 +969,7 @@ class SectorController
|
||||
$usersDecrypted[] = $userData;
|
||||
}
|
||||
|
||||
$this->logService->info('Secteur modifié', [
|
||||
LogService::info('Secteur modifié', [
|
||||
'sector_id' => $id,
|
||||
'updates' => array_keys($data),
|
||||
'passage_counters' => $passageCounters,
|
||||
@@ -999,7 +997,7 @@ class SectorController
|
||||
if ($this->db->inTransaction()) {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
$this->logService->error('Erreur lors de la modification du secteur', [
|
||||
LogService::error('Erreur lors de la modification du secteur', [
|
||||
'sector_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
@@ -1065,7 +1063,7 @@ class SectorController
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de la récupération des adresses du secteur', [
|
||||
LogService::error('Erreur lors de la récupération des adresses du secteur', [
|
||||
'sector_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
@@ -1198,7 +1196,7 @@ class SectorController
|
||||
$passagesDecrypted[] = $passage;
|
||||
}
|
||||
|
||||
$this->logService->info('Secteur supprimé', [
|
||||
LogService::info('Secteur supprimé', [
|
||||
'sector_id' => $id,
|
||||
'libelle' => $sector['libelle'],
|
||||
'passages_deleted' => $passagesToDelete,
|
||||
@@ -1216,7 +1214,7 @@ class SectorController
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->db->rollBack();
|
||||
$this->logService->error('Erreur lors de la suppression du secteur', [
|
||||
LogService::error('Erreur lors de la suppression du secteur', [
|
||||
'sector_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
@@ -1238,7 +1236,7 @@ class SectorController
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de la vérification des contours départementaux', [
|
||||
LogService::error('Erreur lors de la vérification des contours départementaux', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
|
||||
@@ -1298,7 +1296,7 @@ class SectorController
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de la vérification des limites', [
|
||||
LogService::error('Erreur lors de la vérification des limites', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
|
||||
@@ -1422,7 +1420,7 @@ class SectorController
|
||||
$addressesStmt->execute(['sector_id' => $sectorId]);
|
||||
$addresses = $addressesStmt->fetchAll();
|
||||
|
||||
$this->logService->info('[updatePassagesForSector] Adresses dans sectors_adresses', [
|
||||
LogService::info('[updatePassagesForSector] Adresses dans sectors_adresses', [
|
||||
'sector_id' => $sectorId,
|
||||
'nb_addresses' => count($addresses)
|
||||
]);
|
||||
@@ -1435,7 +1433,7 @@ class SectorController
|
||||
$firstUserId = $firstUser ? $firstUser['fk_user'] : null;
|
||||
|
||||
if ($firstUserId && !empty($addresses)) {
|
||||
$this->logService->info('[updatePassagesForSector] Traitement des passages', [
|
||||
LogService::info('[updatePassagesForSector] Traitement des passages', [
|
||||
'user_id' => $firstUserId,
|
||||
'nb_addresses' => count($addresses)
|
||||
]);
|
||||
@@ -1594,7 +1592,7 @@ class SectorController
|
||||
$insertStmt->execute($insertParams);
|
||||
$counters['passages_created'] = count($toInsert);
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de l\'insertion multiple des passages', [
|
||||
LogService::error('Erreur lors de l\'insertion multiple des passages', [
|
||||
'sector_id' => $sectorId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
@@ -1658,12 +1656,12 @@ class SectorController
|
||||
$counters['passages_updated'] = count($toUpdate);
|
||||
|
||||
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
|
||||
$this->logService->info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [
|
||||
LogService::info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [
|
||||
'nb_updated' => count($toUpdate),
|
||||
'sector_id' => $sectorId
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de la mise à jour multiple des passages', [
|
||||
LogService::error('Erreur lors de la mise à jour multiple des passages', [
|
||||
'sector_id' => $sectorId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
@@ -1680,7 +1678,7 @@ class SectorController
|
||||
$deleteStmt->execute($toDelete);
|
||||
$counters['passages_deleted'] += count($toDelete);
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de la suppression multiple des passages', [
|
||||
LogService::error('Erreur lors de la suppression multiple des passages', [
|
||||
'sector_id' => $sectorId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
@@ -1688,7 +1686,7 @@ class SectorController
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->logService->warning('[updatePassagesForSector] Pas de création de passages', [
|
||||
LogService::warning('[updatePassagesForSector] Pas de création de passages', [
|
||||
'reason' => !$firstUserId ? 'Pas d\'utilisateur affecté' : 'Pas d\'adresses',
|
||||
'first_user_id' => $firstUserId,
|
||||
'nb_addresses' => count($addresses)
|
||||
@@ -1697,14 +1695,14 @@ class SectorController
|
||||
|
||||
|
||||
// Retourner les compteurs détaillés
|
||||
$this->logService->info('[updatePassagesForSector] Fin traitement', [
|
||||
LogService::info('[updatePassagesForSector] Fin traitement', [
|
||||
'sector_id' => $sectorId,
|
||||
'counters' => $counters
|
||||
]);
|
||||
return $counters;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('Erreur lors de la mise à jour des passages', [
|
||||
LogService::error('Erreur lors de la mise à jour des passages', [
|
||||
'sector_id' => $sectorId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
@@ -196,7 +196,8 @@ class StripeController extends Controller {
|
||||
SELECT p.*, o.fk_entite, o.id as operation_id
|
||||
FROM ope_pass p
|
||||
JOIN operations o ON p.fk_operation = o.id
|
||||
WHERE p.id = ? AND p.fk_user = ?
|
||||
JOIN ope_users ou ON p.fk_user = ou.id
|
||||
WHERE p.id = ? AND ou.fk_user = ?
|
||||
');
|
||||
$stmt->execute([$passageId, Session::getUserId()]);
|
||||
$passage = $stmt->fetch();
|
||||
@@ -469,70 +470,6 @@ class StripeController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/stripe/devices/check-tap-to-pay
|
||||
* Vérifier la compatibilité Tap to Pay d'un appareil
|
||||
*/
|
||||
public function checkTapToPayCapability(): void {
|
||||
try {
|
||||
$data = $this->getJsonInput();
|
||||
|
||||
$platform = $data['platform'] ?? '';
|
||||
|
||||
if ($platform === 'ios') {
|
||||
// Pour iOS, on vérifie côté client (iPhone XS+ avec iOS 16.4+)
|
||||
$this->sendSuccess([
|
||||
'message' => 'Vérification iOS à faire côté client',
|
||||
'requirements' => 'iPhone XS ou plus récent avec iOS 16.4+',
|
||||
'details' => 'iOS 16.4 minimum requis pour le support PIN complet'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($platform === 'android') {
|
||||
$manufacturer = $data['manufacturer'] ?? '';
|
||||
$model = $data['model'] ?? '';
|
||||
|
||||
if (!$manufacturer || !$model) {
|
||||
$this->sendError('Manufacturer et model requis pour Android', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->stripeService->checkAndroidTapToPayCompatibility($manufacturer, $model);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->sendSuccess($result);
|
||||
} else {
|
||||
$this->sendError($result['message'], 400);
|
||||
}
|
||||
} else {
|
||||
$this->sendError('Platform doit être ios ou android', 400);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->sendError('Erreur: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/stripe/devices/certified-android
|
||||
* Récupérer la liste des appareils Android certifiés
|
||||
*/
|
||||
public function getCertifiedAndroidDevices(): void {
|
||||
try {
|
||||
$result = $this->stripeService->getCertifiedAndroidDevices();
|
||||
|
||||
if ($result['success']) {
|
||||
$this->sendSuccess(['devices' => $result['devices']]);
|
||||
} else {
|
||||
$this->sendError($result['message'], 400);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->sendError('Erreur: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/stripe/config
|
||||
* Récupérer la configuration publique Stripe
|
||||
@@ -784,4 +721,117 @@ class StripeController extends Controller {
|
||||
$this->sendError('Erreur lors de la création de la location: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/stripe/terminal/connection-token
|
||||
* Créer un Connection Token pour Stripe Terminal/Tap to Pay
|
||||
* Requis par le SDK Stripe Terminal pour se connecter aux readers
|
||||
*/
|
||||
public function createConnectionToken(): void {
|
||||
try {
|
||||
$this->requireAuth();
|
||||
|
||||
$data = $this->getJsonInput();
|
||||
$entiteId = $data['amicale_id'] ?? Session::getEntityId();
|
||||
|
||||
if (!$entiteId) {
|
||||
$this->sendError('ID entité requis', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier les droits sur cette entité
|
||||
$userRole = Session::getRole() ?? 0;
|
||||
if (Session::getEntityId() != $entiteId && $userRole < 3) {
|
||||
$this->sendError('Non autorisé pour cette entité', 403);
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->stripeService->createConnectionToken($entiteId);
|
||||
|
||||
if ($result['success']) {
|
||||
$this->sendSuccess([
|
||||
'secret' => $result['secret']
|
||||
]);
|
||||
} else {
|
||||
$this->sendError($result['message'], 400);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->sendError('Erreur lors de la création du connection token: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/stripe/payments/cancel
|
||||
* Annuler un PaymentIntent Stripe
|
||||
*
|
||||
* Payload:
|
||||
* {
|
||||
* "payment_intent_id": "pi_3SWvho2378xpV4Rn1J43Ks7M"
|
||||
* }
|
||||
*/
|
||||
public function cancelPayment(): void {
|
||||
try {
|
||||
$this->requireAuth();
|
||||
|
||||
$data = $this->getJsonInput();
|
||||
|
||||
// Validation
|
||||
if (!isset($data['payment_intent_id'])) {
|
||||
$this->sendError('payment_intent_id requis', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$paymentIntentId = $data['payment_intent_id'];
|
||||
|
||||
// Vérifier que le passage existe et appartient à l'utilisateur
|
||||
$stmt = $this->db->prepare('
|
||||
SELECT p.*, o.fk_entite, ou.fk_user as ope_user_id
|
||||
FROM ope_pass p
|
||||
JOIN operations o ON p.fk_operation = o.id
|
||||
JOIN ope_users ou ON p.fk_user = ou.id
|
||||
WHERE p.stripe_payment_id = ?
|
||||
');
|
||||
$stmt->execute([$paymentIntentId]);
|
||||
$passage = $stmt->fetch();
|
||||
|
||||
if (!$passage) {
|
||||
$this->sendError('Paiement non trouvé', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier les droits
|
||||
$userId = Session::getUserId();
|
||||
$userEntityId = Session::getEntityId();
|
||||
|
||||
if ($passage['ope_user_id'] != $userId && $passage['fk_entite'] != $userEntityId) {
|
||||
$this->sendError('Non autorisé', 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// Annuler le PaymentIntent via StripeService
|
||||
$result = $this->stripeService->cancelPaymentIntent($paymentIntentId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Retirer le stripe_payment_id du passage
|
||||
$stmt = $this->db->prepare('
|
||||
UPDATE ope_pass
|
||||
SET stripe_payment_id = NULL, updated_at = NOW()
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$passage['id']]);
|
||||
|
||||
$this->sendSuccess([
|
||||
'status' => 'canceled',
|
||||
'payment_intent_id' => $paymentIntentId,
|
||||
'passage_id' => $passage['id']
|
||||
]);
|
||||
} else {
|
||||
$this->sendError($result['message'], 400);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->sendError('Erreur: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,85 +202,46 @@ class StripeWebhookController extends Controller {
|
||||
* Gérer un paiement réussi
|
||||
*/
|
||||
private function handlePaymentIntentSucceeded($paymentIntent): void {
|
||||
// Mettre à jour le statut en base
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE stripe_payment_intents
|
||||
SET status = :status, updated_at = NOW()
|
||||
WHERE stripe_payment_intent_id = :pi_id"
|
||||
);
|
||||
$stmt->execute([
|
||||
'status' => 'succeeded',
|
||||
'pi_id' => $paymentIntent->id
|
||||
]);
|
||||
// Pour Tap to Pay, le paiement est instantané et déjà enregistré dans ope_pass
|
||||
// Ce webhook sert principalement pour les Payment Links (QR Code) asynchrones
|
||||
|
||||
// Enregistrer dans l'historique
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
|
||||
);
|
||||
// Vérifier si le passage existe et mettre à jour si nécessaire
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT id FROM ope_pass
|
||||
WHERE stripe_payment_id = :pi_id
|
||||
");
|
||||
$stmt->execute(['pi_id' => $paymentIntent->id]);
|
||||
$localPayment = $stmt->fetch();
|
||||
$passage = $stmt->fetch();
|
||||
|
||||
if ($localPayment) {
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO stripe_payment_history
|
||||
(fk_payment_intent, event_type, event_data, created_at)
|
||||
VALUES (:fk_pi, 'succeeded', :data, NOW())"
|
||||
);
|
||||
$stmt->execute([
|
||||
'fk_pi' => $localPayment['id'],
|
||||
'data' => json_encode([
|
||||
'amount' => $paymentIntent->amount,
|
||||
'currency' => $paymentIntent->currency,
|
||||
'payment_method' => $paymentIntent->payment_method,
|
||||
'charges' => $paymentIntent->charges->data
|
||||
])
|
||||
]);
|
||||
if ($passage) {
|
||||
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency} - Passage {$passage['id']}");
|
||||
|
||||
// TODO: Envoyer un reçu par email
|
||||
// TODO: Mettre à jour les statistiques en temps réel
|
||||
} else {
|
||||
error_log("Payment succeeded: {$paymentIntent->id} but no passage found in ope_pass");
|
||||
}
|
||||
|
||||
// TODO: Envoyer un reçu par email
|
||||
// TODO: Mettre à jour les statistiques en temps réel
|
||||
|
||||
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gérer un paiement échoué
|
||||
*/
|
||||
private function handlePaymentIntentFailed($paymentIntent): void {
|
||||
// Mettre à jour le statut
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE stripe_payment_intents
|
||||
SET status = :status, updated_at = NOW()
|
||||
WHERE stripe_payment_intent_id = :pi_id"
|
||||
);
|
||||
$stmt->execute([
|
||||
'status' => 'failed',
|
||||
'pi_id' => $paymentIntent->id
|
||||
]);
|
||||
|
||||
// Enregistrer dans l'historique avec la raison de l'échec
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
|
||||
);
|
||||
// Vérifier si le passage existe
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT id FROM ope_pass
|
||||
WHERE stripe_payment_id = :pi_id
|
||||
");
|
||||
$stmt->execute(['pi_id' => $paymentIntent->id]);
|
||||
$localPayment = $stmt->fetch();
|
||||
$passage = $stmt->fetch();
|
||||
|
||||
if ($localPayment) {
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO stripe_payment_history
|
||||
(fk_payment_intent, event_type, event_data, created_at)
|
||||
VALUES (:fk_pi, 'failed', :data, NOW())"
|
||||
);
|
||||
$stmt->execute([
|
||||
'fk_pi' => $localPayment['id'],
|
||||
'data' => json_encode([
|
||||
'error' => $paymentIntent->last_payment_error,
|
||||
'cancellation_reason' => $paymentIntent->cancellation_reason
|
||||
])
|
||||
]);
|
||||
if ($passage) {
|
||||
// Optionnel : Marquer le passage comme échec ou supprimer le stripe_payment_id
|
||||
// Pour l'instant on log seulement
|
||||
error_log("Payment failed: {$paymentIntent->id} for passage {$passage['id']}, reason: " . json_encode($paymentIntent->last_payment_error));
|
||||
} else {
|
||||
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
|
||||
}
|
||||
|
||||
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,17 +319,8 @@ class StripeWebhookController extends Controller {
|
||||
* Gérer une action réussie sur un Terminal reader
|
||||
*/
|
||||
private function handleTerminalReaderActionSucceeded($reader): void {
|
||||
// Mettre à jour le statut du reader
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE stripe_terminal_readers
|
||||
SET status = :status, last_seen_at = NOW()
|
||||
WHERE stripe_reader_id = :reader_id"
|
||||
);
|
||||
$stmt->execute([
|
||||
'status' => 'online',
|
||||
'reader_id' => $reader->id
|
||||
]);
|
||||
|
||||
// Note: Pour Tap to Pay, il n'y a pas de readers physiques
|
||||
// Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
|
||||
error_log("Terminal reader action succeeded: {$reader->id}");
|
||||
}
|
||||
|
||||
@@ -376,17 +328,8 @@ class StripeWebhookController extends Controller {
|
||||
* Gérer une action échouée sur un Terminal reader
|
||||
*/
|
||||
private function handleTerminalReaderActionFailed($reader): void {
|
||||
// Mettre à jour le statut du reader
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE stripe_terminal_readers
|
||||
SET status = :status, last_seen_at = NOW()
|
||||
WHERE stripe_reader_id = :reader_id"
|
||||
);
|
||||
$stmt->execute([
|
||||
'status' => 'error',
|
||||
'reader_id' => $reader->id
|
||||
]);
|
||||
|
||||
// Note: Pour Tap to Pay, il n'y a pas de readers physiques
|
||||
// Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
|
||||
error_log("Terminal reader action failed: {$reader->id}");
|
||||
}
|
||||
}
|
||||
@@ -135,13 +135,13 @@ class Router {
|
||||
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
|
||||
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
|
||||
|
||||
// Tap to Pay - Vérification compatibilité et configuration
|
||||
$this->post('stripe/devices/check-tap-to-pay', ['StripeController', 'checkTapToPayCapability']);
|
||||
$this->get('stripe/devices/certified-android', ['StripeController', 'getCertifiedAndroidDevices']);
|
||||
// Tap to Pay - Configuration
|
||||
$this->post('stripe/locations', ['StripeController', 'createLocation']);
|
||||
$this->post('stripe/terminal/connection-token', ['StripeController', 'createConnectionToken']);
|
||||
|
||||
// Paiements
|
||||
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
|
||||
$this->post('stripe/payments/cancel', ['StripeController', 'cancelPayment']);
|
||||
$this->post('stripe/payment-links', ['StripeController', 'createPaymentLink']);
|
||||
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
|
||||
|
||||
@@ -152,6 +152,14 @@ class Router {
|
||||
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
|
||||
$this->post('stripe/webhooks', ['StripeWebhookController', 'handleWebhook']);
|
||||
|
||||
// Routes Statistiques Events (Admin uniquement)
|
||||
$this->get('events/stats/summary', ['EventStatsController', 'summary']);
|
||||
$this->get('events/stats/daily', ['EventStatsController', 'daily']);
|
||||
$this->get('events/stats/weekly', ['EventStatsController', 'weekly']);
|
||||
$this->get('events/stats/monthly', ['EventStatsController', 'monthly']);
|
||||
$this->get('events/stats/details', ['EventStatsController', 'details']);
|
||||
$this->get('events/stats/types', ['EventStatsController', 'types']);
|
||||
|
||||
// Routes Migration (Admin uniquement)
|
||||
$this->get('migrations/test-connections', ['MigrationController', 'testConnections']);
|
||||
$this->get('migrations/entities/available', ['MigrationController', 'getAvailableEntities']);
|
||||
|
||||
@@ -21,19 +21,16 @@ class AddressService
|
||||
{
|
||||
private ?PDO $addressesDb = null;
|
||||
private PDO $mainDb;
|
||||
private $logService;
|
||||
private $buildingService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logService = new LogService();
|
||||
|
||||
try {
|
||||
$this->addressesDb = \AddressesDatabase::getInstance();
|
||||
$this->logService->info('[AddressService] Connexion à la base d\'adresses réussie');
|
||||
LogService::info('[AddressService] Connexion à la base d\'adresses réussie');
|
||||
} catch (\Exception $e) {
|
||||
// Si la connexion échoue, on continue sans la base d'adresses
|
||||
$this->logService->error('[AddressService] Connexion à la base d\'adresses impossible', [
|
||||
LogService::error('[AddressService] Connexion à la base d\'adresses impossible', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
@@ -94,13 +91,13 @@ class AddressService
|
||||
{
|
||||
// Si pas de connexion à la base d'adresses, retourner un tableau vide
|
||||
if (!$this->addressesDb) {
|
||||
$this->logService->error('[AddressService] Pas de connexion à la base d\'adresses externe', [
|
||||
LogService::error('[AddressService] Pas de connexion à la base d\'adresses externe', [
|
||||
'entity_id' => $entityId
|
||||
]);
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->logService->info('[AddressService] Début recherche adresses', [
|
||||
LogService::info('[AddressService] Début recherche adresses', [
|
||||
'entity_id' => $entityId,
|
||||
'nb_coordinates' => count($coordinates)
|
||||
]);
|
||||
@@ -117,11 +114,11 @@ class AddressService
|
||||
// Si aucun département n'est trouvé par analyse spatiale,
|
||||
// chercher d'abord dans le département de l'entité et ses limitrophes
|
||||
$entityDept = $this->getDepartmentForEntity($entityId);
|
||||
$this->logService->info('[AddressService] Département de l\'entité', [
|
||||
LogService::info('[AddressService] Département de l\'entité', [
|
||||
'departement' => $entityDept
|
||||
]);
|
||||
if (!$entityDept) {
|
||||
$this->logService->error('[AddressService] Impossible de déterminer le département de l\'entité', [
|
||||
LogService::error('[AddressService] Impossible de déterminer le département de l\'entité', [
|
||||
'entity_id' => $entityId
|
||||
]);
|
||||
throw new RuntimeException("Impossible de déterminer le département");
|
||||
@@ -131,7 +128,7 @@ class AddressService
|
||||
$priorityDepts = $boundaryService->getPriorityDepartments($entityDept);
|
||||
|
||||
// Log pour debug
|
||||
$this->logService->warning('[AddressService] Aucun département trouvé par analyse spatiale', [
|
||||
LogService::warning('[AddressService] Aucun département trouvé par analyse spatiale', [
|
||||
'departements_prioritaires' => implode(', ', $priorityDepts)
|
||||
]);
|
||||
|
||||
@@ -204,7 +201,7 @@ class AddressService
|
||||
}
|
||||
|
||||
// Log pour debug
|
||||
$this->logService->info('[AddressService] Recherche dans table', [
|
||||
LogService::info('[AddressService] Recherche dans table', [
|
||||
'table' => $tableName,
|
||||
'departement' => $deptCode,
|
||||
'nb_adresses' => count($addresses)
|
||||
@@ -212,7 +209,7 @@ class AddressService
|
||||
|
||||
} catch (PDOException $e) {
|
||||
// Log l'erreur mais continue avec les autres départements
|
||||
$this->logService->error('[AddressService] Erreur SQL', [
|
||||
LogService::error('[AddressService] Erreur SQL', [
|
||||
'table' => $tableName,
|
||||
'departement' => $deptCode,
|
||||
'error' => $e->getMessage(),
|
||||
@@ -221,7 +218,7 @@ class AddressService
|
||||
}
|
||||
}
|
||||
|
||||
$this->logService->info('[AddressService] Fin recherche adresses', [
|
||||
LogService::info('[AddressService] Fin recherche adresses', [
|
||||
'total_adresses' => count($allAddresses)
|
||||
]);
|
||||
return $allAddresses;
|
||||
@@ -243,7 +240,7 @@ class AddressService
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->logService->info('[AddressService] Début enrichissement avec bâtiments', [
|
||||
LogService::info('[AddressService] Début enrichissement avec bâtiments', [
|
||||
'entity_id' => $entityId,
|
||||
'nb_addresses' => count($addresses)
|
||||
]);
|
||||
@@ -262,7 +259,7 @@ class AddressService
|
||||
}
|
||||
}
|
||||
|
||||
$this->logService->info('[AddressService] Fin enrichissement avec bâtiments', [
|
||||
LogService::info('[AddressService] Fin enrichissement avec bâtiments', [
|
||||
'total_adresses' => count($enrichedAddresses),
|
||||
'nb_immeubles' => $nbImmeubles,
|
||||
'nb_maisons' => $nbMaisons
|
||||
@@ -271,7 +268,7 @@ class AddressService
|
||||
return $enrichedAddresses;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logService->error('[AddressService] Erreur lors de l\'enrichissement', [
|
||||
LogService::error('[AddressService] Erreur lors de l\'enrichissement', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
@@ -231,7 +231,7 @@ class ApiService {
|
||||
* @param int $minLength Longueur minimale du nom d'utilisateur (par défaut 10)
|
||||
* @return string Nom d'utilisateur généré
|
||||
*/
|
||||
public static function generateUserName(PDO $db, string $name, string $postalCode, string $cityName, int $minLength = 10): string {
|
||||
public static function generateUserName(\PDO $db, string $name, string $postalCode, string $cityName, int $minLength = 10): string {
|
||||
// Nettoyer et préparer les chaînes
|
||||
$name = preg_replace('/[^a-zA-Z0-9]/', '', strtolower($name));
|
||||
$postalCode = preg_replace('/[^0-9]/', '', $postalCode);
|
||||
@@ -277,7 +277,7 @@ class ApiService {
|
||||
// Vérifier si le nom d'utilisateur existe déjà
|
||||
$stmt = $db->prepare('SELECT COUNT(*) as count FROM users WHERE encrypted_user_name = ?');
|
||||
$stmt->execute([$encryptedUsername]);
|
||||
$result = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if ($result && $result['count'] == 0) {
|
||||
$isUnique = true;
|
||||
|
||||
@@ -14,9 +14,9 @@ class EmailTemplates {
|
||||
Votre compte a été créé avec succès sur <b>GeoSector</b>.<br><br>
|
||||
<b>Identifiant :</b> $username<br>
|
||||
<b>Mot de passe :</b> $password<br><br>
|
||||
Vous pouvez vous connecter dès maintenant sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
|
||||
À très bientôt,<br>
|
||||
L'équipe GeoSector";
|
||||
L'équipe GeoSector<br>
|
||||
<span style='color:#666; font-size:12px;'>Support : support@geosector.fr</span>";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,9 +80,9 @@ class EmailTemplates {
|
||||
Bonjour $name,<br><br>
|
||||
Vous avez demandé la réinitialisation de votre mot de passe sur <b>GeoSector</b>.<br><br>
|
||||
<b>Nouveau mot de passe :</b> $password<br><br>
|
||||
Vous pouvez vous connecter avec ce nouveau mot de passe sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
|
||||
À très bientôt,<br>
|
||||
L'équipe GeoSector";
|
||||
L'équipe GeoSector<br>
|
||||
<span style='color:#666; font-size:12px;'>Support : support@geosector.fr</span>";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -305,6 +305,141 @@ class EventLogService
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== MÉTHODES STRIPE ====================
|
||||
|
||||
/**
|
||||
* Log la création d'un PaymentIntent
|
||||
*
|
||||
* @param string $paymentIntentId ID Stripe du PaymentIntent
|
||||
* @param int $passageId ID du passage
|
||||
* @param int $amount Montant en centimes
|
||||
* @param string $method Méthode (tap_to_pay, qr_code, web)
|
||||
*/
|
||||
public static function logStripePaymentCreated(
|
||||
string $paymentIntentId,
|
||||
int $passageId,
|
||||
int $amount,
|
||||
string $method
|
||||
): void {
|
||||
$entityId = Session::getEntityId();
|
||||
self::writeEvent('stripe_payment_created', [
|
||||
'payment_intent_id' => $paymentIntentId,
|
||||
'passage_id' => $passageId,
|
||||
'entity_id' => $entityId,
|
||||
'amount' => $amount,
|
||||
'method' => $method
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log un paiement Stripe réussi
|
||||
*
|
||||
* @param string $paymentIntentId ID Stripe du PaymentIntent
|
||||
* @param int $passageId ID du passage
|
||||
* @param int $amount Montant en centimes
|
||||
* @param string $method Méthode (tap_to_pay, qr_code, web)
|
||||
*/
|
||||
public static function logStripePaymentSuccess(
|
||||
string $paymentIntentId,
|
||||
int $passageId,
|
||||
int $amount,
|
||||
string $method
|
||||
): void {
|
||||
$entityId = Session::getEntityId();
|
||||
self::writeEvent('stripe_payment_success', [
|
||||
'payment_intent_id' => $paymentIntentId,
|
||||
'passage_id' => $passageId,
|
||||
'entity_id' => $entityId,
|
||||
'amount' => $amount,
|
||||
'method' => $method
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log un paiement Stripe échoué
|
||||
*
|
||||
* @param string|null $paymentIntentId ID Stripe (peut être null si création échouée)
|
||||
* @param int|null $passageId ID du passage (peut être null)
|
||||
* @param int|null $amount Montant en centimes (peut être null)
|
||||
* @param string $method Méthode tentée
|
||||
* @param string $errorCode Code d'erreur
|
||||
* @param string $errorMessage Message d'erreur
|
||||
*/
|
||||
public static function logStripePaymentFailed(
|
||||
?string $paymentIntentId,
|
||||
?int $passageId,
|
||||
?int $amount,
|
||||
string $method,
|
||||
string $errorCode,
|
||||
string $errorMessage
|
||||
): void {
|
||||
$entityId = Session::getEntityId();
|
||||
$data = [
|
||||
'entity_id' => $entityId,
|
||||
'method' => $method,
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $errorMessage
|
||||
];
|
||||
|
||||
if ($paymentIntentId !== null) {
|
||||
$data['payment_intent_id'] = $paymentIntentId;
|
||||
}
|
||||
if ($passageId !== null) {
|
||||
$data['passage_id'] = $passageId;
|
||||
}
|
||||
if ($amount !== null) {
|
||||
$data['amount'] = $amount;
|
||||
}
|
||||
|
||||
self::writeEvent('stripe_payment_failed', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log l'annulation d'un paiement Stripe
|
||||
*
|
||||
* @param string $paymentIntentId ID Stripe du PaymentIntent
|
||||
* @param int|null $passageId ID du passage (peut être null)
|
||||
* @param string $reason Raison (user_cancelled, timeout, error, etc.)
|
||||
*/
|
||||
public static function logStripePaymentCancelled(
|
||||
string $paymentIntentId,
|
||||
?int $passageId,
|
||||
string $reason
|
||||
): void {
|
||||
$entityId = Session::getEntityId();
|
||||
$data = [
|
||||
'payment_intent_id' => $paymentIntentId,
|
||||
'entity_id' => $entityId,
|
||||
'reason' => $reason
|
||||
];
|
||||
|
||||
if ($passageId !== null) {
|
||||
$data['passage_id'] = $passageId;
|
||||
}
|
||||
|
||||
self::writeEvent('stripe_payment_cancelled', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log une erreur du Terminal Tap to Pay
|
||||
*
|
||||
* @param string $errorCode Code d'erreur (cardReadTimedOut, device_not_compatible, etc.)
|
||||
* @param string $errorMessage Message d'erreur
|
||||
* @param array $metadata Métadonnées supplémentaires (device_model, is_simulated, etc.)
|
||||
*/
|
||||
public static function logStripeTerminalError(
|
||||
string $errorCode,
|
||||
string $errorMessage,
|
||||
array $metadata = []
|
||||
): void {
|
||||
$entityId = Session::getEntityId();
|
||||
self::writeEvent('stripe_terminal_error', array_merge([
|
||||
'entity_id' => $entityId,
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $errorMessage
|
||||
], $metadata));
|
||||
}
|
||||
|
||||
// ==================== MÉTHODES OPÉRATIONS ====================
|
||||
|
||||
/**
|
||||
|
||||
535
api/src/Services/EventStatsService.php
Normal file
535
api/src/Services/EventStatsService.php
Normal file
@@ -0,0 +1,535 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Database;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* EventStatsService - Service de statistiques d'événements
|
||||
*
|
||||
* Fournit des méthodes pour récupérer les statistiques agrégées
|
||||
* depuis la table event_stats_daily et le détail depuis les fichiers JSONL.
|
||||
*
|
||||
* @see docs/TECHBOOK.md section "Statistiques Events"
|
||||
*/
|
||||
class EventStatsService
|
||||
{
|
||||
/** @var string Chemin du dossier des logs événements */
|
||||
private const EVENT_LOG_DIR = __DIR__ . '/../../logs/events';
|
||||
|
||||
/** @var int Limite max pour le détail */
|
||||
private const MAX_DETAILS_LIMIT = 100;
|
||||
|
||||
/** @var PDO Instance de la base de données */
|
||||
private PDO $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
// ==================== MÉTHODES PUBLIQUES ====================
|
||||
|
||||
/**
|
||||
* Récupère le résumé des stats pour une date donnée
|
||||
*
|
||||
* @param int|null $entityId ID entité (null = toutes entités pour super-admin)
|
||||
* @param string|null $date Date (YYYY-MM-DD), défaut = aujourd'hui
|
||||
* @return array Stats résumées par catégorie
|
||||
*/
|
||||
public function getSummary(?int $entityId, ?string $date = null): array
|
||||
{
|
||||
$date = $date ?? date('Y-m-d');
|
||||
|
||||
$sql = "
|
||||
SELECT event_type, count, sum_amount, unique_users, metadata
|
||||
FROM event_stats_daily
|
||||
WHERE stat_date = :date
|
||||
";
|
||||
$params = ['date' => $date];
|
||||
|
||||
if ($entityId !== null) {
|
||||
$sql .= " AND entity_id = :entity_id";
|
||||
$params['entity_id'] = $entityId;
|
||||
} else {
|
||||
$sql .= " AND entity_id IS NULL";
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $this->formatSummary($date, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les stats journalières sur une plage de dates
|
||||
*
|
||||
* @param int|null $entityId ID entité (null = toutes entités)
|
||||
* @param string $from Date début (YYYY-MM-DD)
|
||||
* @param string $to Date fin (YYYY-MM-DD)
|
||||
* @param array $eventTypes Filtrer par types d'événements (optionnel)
|
||||
* @return array Stats par jour
|
||||
*/
|
||||
public function getDaily(?int $entityId, string $from, string $to, array $eventTypes = []): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT stat_date, event_type, count, sum_amount, unique_users
|
||||
FROM event_stats_daily
|
||||
WHERE stat_date BETWEEN :from AND :to
|
||||
";
|
||||
$params = ['from' => $from, 'to' => $to];
|
||||
|
||||
if ($entityId !== null) {
|
||||
$sql .= " AND entity_id = :entity_id";
|
||||
$params['entity_id'] = $entityId;
|
||||
} else {
|
||||
$sql .= " AND entity_id IS NULL";
|
||||
}
|
||||
|
||||
if (!empty($eventTypes)) {
|
||||
$placeholders = [];
|
||||
foreach ($eventTypes as $i => $type) {
|
||||
$key = "event_type_{$i}";
|
||||
$placeholders[] = ":{$key}";
|
||||
$params[$key] = $type;
|
||||
}
|
||||
$sql .= " AND event_type IN (" . implode(', ', $placeholders) . ")";
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY stat_date ASC, event_type ASC";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $this->formatDaily($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les stats hebdomadaires (calculées depuis daily)
|
||||
*
|
||||
* @param int|null $entityId ID entité
|
||||
* @param string $from Date début
|
||||
* @param string $to Date fin
|
||||
* @param array $eventTypes Filtrer par types d'événements
|
||||
* @return array Stats par semaine
|
||||
*/
|
||||
public function getWeekly(?int $entityId, string $from, string $to, array $eventTypes = []): array
|
||||
{
|
||||
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
|
||||
|
||||
$weekly = [];
|
||||
|
||||
foreach ($daily as $day) {
|
||||
$date = new \DateTime($day['date']);
|
||||
$weekStart = clone $date;
|
||||
$weekStart->modify('monday this week');
|
||||
$weekKey = $weekStart->format('Y-m-d');
|
||||
|
||||
if (!isset($weekly[$weekKey])) {
|
||||
$weekly[$weekKey] = [
|
||||
'week_start' => $weekKey,
|
||||
'week_number' => (int) $date->format('W'),
|
||||
'year' => (int) $date->format('Y'),
|
||||
'events' => [],
|
||||
'totals' => [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Agréger les événements
|
||||
foreach ($day['events'] as $eventType => $stats) {
|
||||
if (!isset($weekly[$weekKey]['events'][$eventType])) {
|
||||
$weekly[$weekKey]['events'][$eventType] = [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
'unique_users' => 0,
|
||||
];
|
||||
}
|
||||
$weekly[$weekKey]['events'][$eventType]['count'] += $stats['count'];
|
||||
$weekly[$weekKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
|
||||
$weekly[$weekKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
|
||||
}
|
||||
|
||||
$weekly[$weekKey]['totals']['count'] += $day['totals']['count'];
|
||||
$weekly[$weekKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
|
||||
}
|
||||
|
||||
return array_values($weekly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les stats mensuelles (calculées depuis daily)
|
||||
*
|
||||
* @param int|null $entityId ID entité
|
||||
* @param int $year Année
|
||||
* @param array $eventTypes Filtrer par types d'événements
|
||||
* @return array Stats par mois
|
||||
*/
|
||||
public function getMonthly(?int $entityId, int $year, array $eventTypes = []): array
|
||||
{
|
||||
$from = "{$year}-01-01";
|
||||
$to = "{$year}-12-31";
|
||||
|
||||
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
|
||||
|
||||
$monthly = [];
|
||||
|
||||
foreach ($daily as $day) {
|
||||
$monthKey = substr($day['date'], 0, 7); // YYYY-MM
|
||||
|
||||
if (!isset($monthly[$monthKey])) {
|
||||
$monthly[$monthKey] = [
|
||||
'month' => $monthKey,
|
||||
'year' => $year,
|
||||
'month_number' => (int) substr($monthKey, 5, 2),
|
||||
'events' => [],
|
||||
'totals' => [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// Agréger les événements
|
||||
foreach ($day['events'] as $eventType => $stats) {
|
||||
if (!isset($monthly[$monthKey]['events'][$eventType])) {
|
||||
$monthly[$monthKey]['events'][$eventType] = [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
'unique_users' => 0,
|
||||
];
|
||||
}
|
||||
$monthly[$monthKey]['events'][$eventType]['count'] += $stats['count'];
|
||||
$monthly[$monthKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
|
||||
$monthly[$monthKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
|
||||
}
|
||||
|
||||
$monthly[$monthKey]['totals']['count'] += $day['totals']['count'];
|
||||
$monthly[$monthKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
|
||||
}
|
||||
|
||||
return array_values($monthly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le détail des événements depuis le fichier JSONL
|
||||
*
|
||||
* @param int|null $entityId ID entité (null = tous)
|
||||
* @param string $date Date (YYYY-MM-DD)
|
||||
* @param string|null $eventType Filtrer par type d'événement
|
||||
* @param int $limit Nombre max de résultats
|
||||
* @param int $offset Décalage pour pagination
|
||||
* @return array Événements détaillés avec pagination
|
||||
*/
|
||||
public function getDetails(
|
||||
?int $entityId,
|
||||
string $date,
|
||||
?string $eventType = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0
|
||||
): array {
|
||||
$limit = min($limit, self::MAX_DETAILS_LIMIT);
|
||||
|
||||
$filePath = self::EVENT_LOG_DIR . '/' . $date . '.jsonl';
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
return [
|
||||
'date' => $date,
|
||||
'events' => [],
|
||||
'pagination' => [
|
||||
'total' => 0,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'has_more' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$events = [];
|
||||
$total = 0;
|
||||
$currentIndex = 0;
|
||||
|
||||
$handle = fopen($filePath, 'r');
|
||||
if (!$handle) {
|
||||
return [
|
||||
'date' => $date,
|
||||
'events' => [],
|
||||
'pagination' => [
|
||||
'total' => 0,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'has_more' => false,
|
||||
],
|
||||
'error' => 'Impossible de lire le fichier',
|
||||
];
|
||||
}
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$event = json_decode($line, true);
|
||||
if (!$event || !isset($event['event'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filtrer par entity_id
|
||||
if ($entityId !== null) {
|
||||
$eventEntityId = $event['entity_id'] ?? null;
|
||||
if ($eventEntityId !== $entityId) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer par event_type
|
||||
if ($eventType !== null && ($event['event'] ?? '') !== $eventType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$total++;
|
||||
|
||||
// Pagination
|
||||
if ($currentIndex >= $offset && count($events) < $limit) {
|
||||
$events[] = $this->sanitizeEventForOutput($event);
|
||||
}
|
||||
|
||||
$currentIndex++;
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'events' => $events,
|
||||
'pagination' => [
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'has_more' => ($offset + $limit) < $total,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les types d'événements disponibles
|
||||
*
|
||||
* @return array Liste des types d'événements
|
||||
*/
|
||||
public function getEventTypes(): array
|
||||
{
|
||||
return [
|
||||
'auth' => ['login_success', 'login_failed', 'logout'],
|
||||
'passages' => ['passage_created', 'passage_updated', 'passage_deleted'],
|
||||
'sectors' => ['sector_created', 'sector_updated', 'sector_deleted'],
|
||||
'users' => ['user_created', 'user_updated', 'user_deleted'],
|
||||
'entities' => ['entity_created', 'entity_updated', 'entity_deleted'],
|
||||
'operations' => ['operation_created', 'operation_updated', 'operation_deleted'],
|
||||
'stripe' => [
|
||||
'stripe_payment_created',
|
||||
'stripe_payment_success',
|
||||
'stripe_payment_failed',
|
||||
'stripe_payment_cancelled',
|
||||
'stripe_terminal_error',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si des stats existent pour une date
|
||||
*
|
||||
* @param string $date Date à vérifier
|
||||
* @return bool
|
||||
*/
|
||||
public function hasStatsForDate(string $date): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT COUNT(*) FROM event_stats_daily WHERE stat_date = :date
|
||||
");
|
||||
$stmt->execute(['date' => $date]);
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
// ==================== MÉTHODES PRIVÉES ====================
|
||||
|
||||
/**
|
||||
* Formate le résumé des stats par catégorie
|
||||
*/
|
||||
private function formatSummary(string $date, array $rows): array
|
||||
{
|
||||
$summary = [
|
||||
'date' => $date,
|
||||
'stats' => [
|
||||
'auth' => ['success' => 0, 'failed' => 0, 'logout' => 0],
|
||||
'passages' => ['created' => 0, 'updated' => 0, 'deleted' => 0, 'amount' => 0.0],
|
||||
'users' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||
'sectors' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||
'entities' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||
'operations' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
|
||||
'stripe' => ['created' => 0, 'success' => 0, 'failed' => 0, 'cancelled' => 0, 'amount' => 0.0],
|
||||
],
|
||||
'totals' => [
|
||||
'events' => 0,
|
||||
'unique_users' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
$uniqueUsersSet = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$eventType = $row['event_type'];
|
||||
$count = (int) $row['count'];
|
||||
$amount = (float) $row['sum_amount'];
|
||||
$uniqueUsers = (int) $row['unique_users'];
|
||||
|
||||
$summary['totals']['events'] += $count;
|
||||
$uniqueUsersSet[$eventType] = $uniqueUsers;
|
||||
|
||||
// Mapper vers les catégories
|
||||
switch ($eventType) {
|
||||
case 'login_success':
|
||||
$summary['stats']['auth']['success'] = $count;
|
||||
break;
|
||||
case 'login_failed':
|
||||
$summary['stats']['auth']['failed'] = $count;
|
||||
break;
|
||||
case 'logout':
|
||||
$summary['stats']['auth']['logout'] = $count;
|
||||
break;
|
||||
case 'passage_created':
|
||||
$summary['stats']['passages']['created'] = $count;
|
||||
$summary['stats']['passages']['amount'] += $amount;
|
||||
break;
|
||||
case 'passage_updated':
|
||||
$summary['stats']['passages']['updated'] = $count;
|
||||
break;
|
||||
case 'passage_deleted':
|
||||
$summary['stats']['passages']['deleted'] = $count;
|
||||
break;
|
||||
case 'user_created':
|
||||
$summary['stats']['users']['created'] = $count;
|
||||
break;
|
||||
case 'user_updated':
|
||||
$summary['stats']['users']['updated'] = $count;
|
||||
break;
|
||||
case 'user_deleted':
|
||||
$summary['stats']['users']['deleted'] = $count;
|
||||
break;
|
||||
case 'sector_created':
|
||||
$summary['stats']['sectors']['created'] = $count;
|
||||
break;
|
||||
case 'sector_updated':
|
||||
$summary['stats']['sectors']['updated'] = $count;
|
||||
break;
|
||||
case 'sector_deleted':
|
||||
$summary['stats']['sectors']['deleted'] = $count;
|
||||
break;
|
||||
case 'entity_created':
|
||||
$summary['stats']['entities']['created'] = $count;
|
||||
break;
|
||||
case 'entity_updated':
|
||||
$summary['stats']['entities']['updated'] = $count;
|
||||
break;
|
||||
case 'entity_deleted':
|
||||
$summary['stats']['entities']['deleted'] = $count;
|
||||
break;
|
||||
case 'operation_created':
|
||||
$summary['stats']['operations']['created'] = $count;
|
||||
break;
|
||||
case 'operation_updated':
|
||||
$summary['stats']['operations']['updated'] = $count;
|
||||
break;
|
||||
case 'operation_deleted':
|
||||
$summary['stats']['operations']['deleted'] = $count;
|
||||
break;
|
||||
case 'stripe_payment_created':
|
||||
$summary['stats']['stripe']['created'] = $count;
|
||||
break;
|
||||
case 'stripe_payment_success':
|
||||
$summary['stats']['stripe']['success'] = $count;
|
||||
$summary['stats']['stripe']['amount'] += $amount;
|
||||
break;
|
||||
case 'stripe_payment_failed':
|
||||
$summary['stats']['stripe']['failed'] = $count;
|
||||
break;
|
||||
case 'stripe_payment_cancelled':
|
||||
$summary['stats']['stripe']['cancelled'] = $count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Estimation des utilisateurs uniques (max des catégories car overlap possible)
|
||||
$summary['totals']['unique_users'] = !empty($uniqueUsersSet) ? max($uniqueUsersSet) : 0;
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate les stats journalières
|
||||
*/
|
||||
private function formatDaily(array $rows): array
|
||||
{
|
||||
$daily = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$date = $row['stat_date'];
|
||||
|
||||
if (!isset($daily[$date])) {
|
||||
$daily[$date] = [
|
||||
'date' => $date,
|
||||
'events' => [],
|
||||
'totals' => [
|
||||
'count' => 0,
|
||||
'sum_amount' => 0.0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$eventType = $row['event_type'];
|
||||
$count = (int) $row['count'];
|
||||
$amount = (float) $row['sum_amount'];
|
||||
|
||||
$daily[$date]['events'][$eventType] = [
|
||||
'count' => $count,
|
||||
'sum_amount' => $amount,
|
||||
'unique_users' => (int) $row['unique_users'],
|
||||
];
|
||||
|
||||
$daily[$date]['totals']['count'] += $count;
|
||||
$daily[$date]['totals']['sum_amount'] += $amount;
|
||||
}
|
||||
|
||||
return array_values($daily);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie un événement pour l'affichage (supprime données sensibles)
|
||||
*/
|
||||
private function sanitizeEventForOutput(array $event): array
|
||||
{
|
||||
// Supprimer l'IP complète, garder seulement les 2 premiers octets
|
||||
if (isset($event['ip'])) {
|
||||
$parts = explode('.', $event['ip']);
|
||||
if (count($parts) === 4) {
|
||||
$event['ip'] = $parts[0] . '.' . $parts[1] . '.x.x';
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer le user_agent complet
|
||||
unset($event['user_agent']);
|
||||
|
||||
// Supprimer les données chiffrées si présentes
|
||||
unset($event['encrypted_name']);
|
||||
unset($event['encrypted_email']);
|
||||
|
||||
return $event;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class ExportService {
|
||||
$filepath = $exportDir . '/' . $filename;
|
||||
|
||||
// Créer le spreadsheet
|
||||
$spreadsheet = new PhpOffice\PhpSpreadsheet\Spreadsheet();
|
||||
$spreadsheet = new Spreadsheet();
|
||||
|
||||
// Insérer les données
|
||||
$this->createPassagesSheet($spreadsheet, $operationId, $userId);
|
||||
@@ -283,11 +283,11 @@ class ExportService {
|
||||
$dateEve = $passage['passed_at'] ? date('d/m/Y', strtotime($passage['passed_at'])) : '';
|
||||
$heureEve = $passage['passed_at'] ? date('H:i', strtotime($passage['passed_at'])) : '';
|
||||
|
||||
// Déchiffrer les données
|
||||
$donateur = ApiService::decryptData($passage['encrypted_name']);
|
||||
// Déchiffrer les données (avec vérification null)
|
||||
$donateur = !empty($passage['encrypted_name']) ? ApiService::decryptData($passage['encrypted_name']) : '';
|
||||
$email = !empty($passage['encrypted_email']) ? ApiService::decryptSearchableData($passage['encrypted_email']) : '';
|
||||
$phone = !empty($passage['encrypted_phone']) ? ApiService::decryptData($passage['encrypted_phone']) : '';
|
||||
$userName = ApiService::decryptData($passage['user_name']);
|
||||
$userName = !empty($passage['user_name']) ? ApiService::decryptData($passage['user_name']) : '';
|
||||
|
||||
// Type de passage
|
||||
$typeLabels = [
|
||||
@@ -382,7 +382,7 @@ class ExportService {
|
||||
foreach ($users as $user) {
|
||||
$rowData = [
|
||||
$user['id'],
|
||||
ApiService::decryptData($user['encrypted_name']),
|
||||
!empty($user['encrypted_name']) ? ApiService::decryptData($user['encrypted_name']) : '',
|
||||
$user['first_name'],
|
||||
!empty($user['encrypted_email']) ? ApiService::decryptSearchableData($user['encrypted_email']) : '',
|
||||
!empty($user['encrypted_phone']) ? ApiService::decryptData($user['encrypted_phone']) : '',
|
||||
@@ -480,7 +480,7 @@ class ExportService {
|
||||
|
||||
$row = 2;
|
||||
foreach ($userSectors as $us) {
|
||||
$userName = ApiService::decryptData($us['user_name']);
|
||||
$userName = !empty($us['user_name']) ? ApiService::decryptData($us['user_name']) : '';
|
||||
$fullUserName = $us['first_name'] ? $us['first_name'] . ' ' . $userName : $userName;
|
||||
|
||||
$rowData = [
|
||||
@@ -690,11 +690,11 @@ class ExportService {
|
||||
$dateEve = $p["passed_at"] ? date("d/m/Y", strtotime($p["passed_at"])) : "";
|
||||
$heureEve = $p["passed_at"] ? date("H:i", strtotime($p["passed_at"])) : "";
|
||||
|
||||
// Déchiffrer les données
|
||||
$donateur = ApiService::decryptData($p["encrypted_name"]);
|
||||
// Déchiffrer les données (avec vérification null)
|
||||
$donateur = !empty($p["encrypted_name"]) ? ApiService::decryptData($p["encrypted_name"]) : "";
|
||||
$email = !empty($p["encrypted_email"]) ? ApiService::decryptSearchableData($p["encrypted_email"]) : "";
|
||||
$phone = !empty($p["encrypted_phone"]) ? ApiService::decryptData($p["encrypted_phone"]) : "";
|
||||
$userName = ApiService::decryptData($p["user_name"]);
|
||||
$userName = !empty($p["user_name"]) ? ApiService::decryptData($p["user_name"]) : "";
|
||||
|
||||
// Nettoyer les données (comme dans l'ancienne version)
|
||||
$nom = str_replace("/", "-", $userName);
|
||||
|
||||
@@ -8,6 +8,12 @@ use AppConfig;
|
||||
use ClientDetector;
|
||||
|
||||
class LogService {
|
||||
/** @var int Permissions du dossier */
|
||||
private const DIR_PERMISSIONS = 0750;
|
||||
|
||||
/** @var int Permissions des fichiers */
|
||||
private const FILE_PERMISSIONS = 0640;
|
||||
|
||||
public static function log(string $message, array $metadata = []): void {
|
||||
// Obtenir les informations client via ClientDetector
|
||||
$clientInfo = ClientDetector::getClientInfo();
|
||||
@@ -67,12 +73,10 @@ class LogService {
|
||||
|
||||
// Créer le dossier logs s'il n'existe pas
|
||||
if (!is_dir($logDir)) {
|
||||
if (!mkdir($logDir, 0777, true)) {
|
||||
if (!mkdir($logDir, self::DIR_PERMISSIONS, true)) {
|
||||
error_log("Impossible de créer le dossier de logs: {$logDir}");
|
||||
return; // Sortir de la fonction si on ne peut pas créer le dossier
|
||||
}
|
||||
// S'assurer que les permissions sont correctes
|
||||
chmod($logDir, 0777);
|
||||
}
|
||||
|
||||
// Vérifier si le dossier est accessible en écriture
|
||||
@@ -139,26 +143,29 @@ class LogService {
|
||||
$message
|
||||
]) . "\n";
|
||||
|
||||
// Écrire dans le fichier avec gestion d'erreur
|
||||
if (file_put_contents($filename, $logLine, FILE_APPEND) === false) {
|
||||
// Écrire dans le fichier avec gestion d'erreur et verrouillage
|
||||
if (file_put_contents($filename, $logLine, FILE_APPEND | LOCK_EX) === false) {
|
||||
error_log("Impossible d'écrire dans le fichier de logs: {$filename}");
|
||||
} else {
|
||||
// Appliquer les permissions au fichier
|
||||
@chmod($filename, self::FILE_PERMISSIONS);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log("Erreur lors de l'écriture des logs: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function info(string $message, array $metadata = []): void {
|
||||
public static function info(string $message, array $metadata = []): void {
|
||||
$metadata['level'] = 'info';
|
||||
self::log($message, $metadata);
|
||||
}
|
||||
|
||||
public function warning(string $message, array $metadata = []): void {
|
||||
public static function warning(string $message, array $metadata = []): void {
|
||||
$metadata['level'] = 'warning';
|
||||
self::log($message, $metadata);
|
||||
}
|
||||
|
||||
public function error(string $message, array $metadata = []): void {
|
||||
public static function error(string $message, array $metadata = []): void {
|
||||
$metadata['level'] = 'error';
|
||||
self::log($message, $metadata);
|
||||
}
|
||||
|
||||
292
api/src/Services/SectorService.php
Normal file
292
api/src/Services/SectorService.php
Normal file
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Database;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Service global pour la gestion des secteurs
|
||||
*
|
||||
* Fournit des fonctions réutilisables pour :
|
||||
* - Géocoder une adresse via api-adresse.data.gouv.fr
|
||||
* - Trouver un secteur à partir de coordonnées GPS
|
||||
* - Trouver un secteur à partir d'une adresse
|
||||
*/
|
||||
class SectorService
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = Database::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Géocode une adresse via api-adresse.data.gouv.fr
|
||||
*
|
||||
* @param string $num Numéro de rue
|
||||
* @param string $bis Complément (bis, ter, etc.)
|
||||
* @param string $rue Nom de la rue
|
||||
* @param string $ville Nom de la ville
|
||||
* @param string $cp Code postal (pour vérifier le département)
|
||||
* @return array|null [lat, lng] ou null si non trouvé ou score trop faible
|
||||
*/
|
||||
public function geocodeAddress(string $num, string $bis, string $rue, string $ville, string $cp): ?array
|
||||
{
|
||||
try {
|
||||
// Construire l'URL de l'API
|
||||
$query = trim($num . $bis) . ' ' . $rue . ' ' . $ville;
|
||||
$url = 'https://api-adresse.data.gouv.fr/search/?q=' . urlencode($query);
|
||||
|
||||
LogService::info('[SectorService] Géocodage adresse', [
|
||||
'url' => $url,
|
||||
'adresse' => $query
|
||||
]);
|
||||
|
||||
// Appel à l'API
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
$json = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || empty($json)) {
|
||||
LogService::warning('[SectorService] Erreur API géocodage', [
|
||||
'http_code' => $httpCode,
|
||||
'adresse' => $query
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($json);
|
||||
|
||||
if (empty($data->features)) {
|
||||
LogService::info('[SectorService] Aucun résultat de géocodage', [
|
||||
'adresse' => $query
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$score = $data->features[0]->properties->score ?? 0;
|
||||
|
||||
// Vérifier le score (> 0.7 = 70% de confiance)
|
||||
if (floatval($score) <= 0.7) {
|
||||
LogService::info('[SectorService] Score géocodage trop faible', [
|
||||
'score' => $score,
|
||||
'adresse' => $query
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier le département
|
||||
$cpTrouve = $data->features[0]->properties->postcode ?? '';
|
||||
$deptTrouve = substr($cpTrouve, 0, 2);
|
||||
|
||||
$cpAmicale = $cp;
|
||||
if (strlen($cpAmicale) == 4) {
|
||||
$cpAmicale = '0' . $cpAmicale;
|
||||
}
|
||||
$deptAmicale = substr($cpAmicale, 0, 2);
|
||||
|
||||
if ($deptTrouve !== $deptAmicale) {
|
||||
LogService::warning('[SectorService] Département différent', [
|
||||
'dept_trouve' => $deptTrouve,
|
||||
'dept_attendu' => $deptAmicale,
|
||||
'adresse' => $query
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extraire les coordonnées [lng, lat] -> [lat, lng]
|
||||
$coordinates = $data->features[0]->geometry->coordinates;
|
||||
$lat = (float)$coordinates[1];
|
||||
$lng = (float)$coordinates[0];
|
||||
|
||||
LogService::info('[SectorService] Géocodage réussi', [
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'score' => $score,
|
||||
'adresse' => $query
|
||||
]);
|
||||
|
||||
return ['lat' => $lat, 'lng' => $lng];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
LogService::error('[SectorService] Erreur géocodage', [
|
||||
'error' => $e->getMessage(),
|
||||
'adresse' => "$num$bis $rue $ville"
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le secteur contenant une position GPS pour une opération donnée
|
||||
*
|
||||
* @param int $operationId ID de l'opération
|
||||
* @param float $lat Latitude
|
||||
* @param float $lng Longitude
|
||||
* @return int|null ID du secteur trouvé ou null
|
||||
*/
|
||||
public function findSectorByGps(int $operationId, float $lat, float $lng): ?int
|
||||
{
|
||||
try {
|
||||
// Récupérer tous les secteurs de l'opération avec leur polygone
|
||||
$query = "SELECT id, sector FROM ope_sectors
|
||||
WHERE fk_operation = :operation_id
|
||||
AND chk_active = 1
|
||||
AND sector IS NOT NULL
|
||||
AND sector != ''";
|
||||
|
||||
$stmt = $this->db->prepare($query);
|
||||
$stmt->execute(['operation_id' => $operationId]);
|
||||
$sectors = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($sectors)) {
|
||||
LogService::info('[SectorService] Aucun secteur trouvé pour l\'opération', [
|
||||
'operation_id' => $operationId
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tester chaque secteur
|
||||
foreach ($sectors as $sector) {
|
||||
$polygon = $this->parseSectorPolygon($sector['sector']);
|
||||
|
||||
if (empty($polygon)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isPointInsidePolygon($lat, $lng, $polygon)) {
|
||||
LogService::info('[SectorService] Secteur trouvé par GPS', [
|
||||
'sector_id' => $sector['id'],
|
||||
'operation_id' => $operationId,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng
|
||||
]);
|
||||
return (int)$sector['id'];
|
||||
}
|
||||
}
|
||||
|
||||
LogService::info('[SectorService] Aucun secteur ne contient ce point GPS', [
|
||||
'operation_id' => $operationId,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'nb_sectors_tested' => count($sectors)
|
||||
]);
|
||||
|
||||
return null;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
LogService::error('[SectorService] Erreur findSectorByGps', [
|
||||
'error' => $e->getMessage(),
|
||||
'operation_id' => $operationId,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le secteur pour une adresse (géocodage + recherche GPS)
|
||||
*
|
||||
* @param int $operationId ID de l'opération
|
||||
* @param string $num Numéro de rue
|
||||
* @param string $bis Complément
|
||||
* @param string $rue Nom de la rue
|
||||
* @param string $ville Nom de la ville
|
||||
* @param string $cp Code postal
|
||||
* @return array|null ['sector_id' => int, 'gps_lat' => float, 'gps_lng' => float] ou null
|
||||
*/
|
||||
public function findSectorByAddress(int $operationId, string $num, string $bis, string $rue, string $ville, string $cp): ?array
|
||||
{
|
||||
// Étape 1 : Géocoder l'adresse
|
||||
$coords = $this->geocodeAddress($num, $bis, $rue, $ville, $cp);
|
||||
|
||||
if (!$coords) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Étape 2 : Chercher le secteur avec les coordonnées obtenues
|
||||
$sectorId = $this->findSectorByGps($operationId, $coords['lat'], $coords['lng']);
|
||||
|
||||
if (!$sectorId) {
|
||||
// Retourner quand même les coordonnées GPS trouvées (utiles pour mettre à jour le passage)
|
||||
return [
|
||||
'sector_id' => null,
|
||||
'gps_lat' => $coords['lat'],
|
||||
'gps_lng' => $coords['lng']
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'sector_id' => $sectorId,
|
||||
'gps_lat' => $coords['lat'],
|
||||
'gps_lng' => $coords['lng']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse le format de polygone stocké en base (lat/lng#lat/lng#...)
|
||||
*
|
||||
* @param string $sectorString Format "lat/lng#lat/lng#..."
|
||||
* @return array Array de ['lat' => float, 'lng' => float]
|
||||
*/
|
||||
private function parseSectorPolygon(string $sectorString): array
|
||||
{
|
||||
$polygon = [];
|
||||
$points = explode('#', rtrim($sectorString, '#'));
|
||||
|
||||
foreach ($points as $point) {
|
||||
if (!empty($point) && strpos($point, '/') !== false) {
|
||||
list($lat, $lng) = explode('/', $point);
|
||||
$polygon[] = [
|
||||
'lat' => (float)$lat,
|
||||
'lng' => (float)$lng
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $polygon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un point est à l'intérieur d'un polygone
|
||||
* Utilise l'algorithme de ray casting
|
||||
*
|
||||
* @param float $lat Latitude du point
|
||||
* @param float $lng Longitude du point
|
||||
* @param array $polygon Array de ['lat' => float, 'lng' => float]
|
||||
* @return bool
|
||||
*/
|
||||
private function isPointInsidePolygon(float $lat, float $lng, array $polygon): bool
|
||||
{
|
||||
$x = $lat;
|
||||
$y = $lng;
|
||||
$inside = false;
|
||||
$count = count($polygon);
|
||||
|
||||
for ($i = 0, $j = $count - 1; $i < $count; $j = $i++) {
|
||||
$xi = $polygon[$i]['lat'];
|
||||
$yi = $polygon[$i]['lng'];
|
||||
$xj = $polygon[$j]['lat'];
|
||||
$yj = $polygon[$j]['lng'];
|
||||
|
||||
$intersect = (($yi > $y) != ($yj > $y))
|
||||
&& ($x < ($xj - $xi) * ($y - $yi) / ($yj - $yi) + $xi);
|
||||
|
||||
if ($intersect) {
|
||||
$inside = !$inside;
|
||||
}
|
||||
}
|
||||
|
||||
return $inside;
|
||||
}
|
||||
}
|
||||
@@ -465,6 +465,7 @@ class StripeService {
|
||||
$entiteId = $params['fk_entite'] ?? 0;
|
||||
$userId = $params['fk_user'] ?? 0;
|
||||
$metadata = $params['metadata'] ?? [];
|
||||
$paymentMethodTypes = $params['payment_method_types'] ?? ['card_present'];
|
||||
|
||||
if ($amount < 100) {
|
||||
throw new Exception("Le montant minimum est de 1€");
|
||||
@@ -481,117 +482,51 @@ class StripeService {
|
||||
throw new Exception("Compte Stripe non trouvé");
|
||||
}
|
||||
|
||||
// Pas de commission plateforme - 100% pour l'amicale
|
||||
// Déterminer le mode : Tap to Pay (card_present) ou Payment Link (card)
|
||||
$isTapToPay = in_array('card_present', $paymentMethodTypes);
|
||||
|
||||
// Créer le PaymentIntent sans commission
|
||||
$paymentIntent = $this->stripe->paymentIntents->create([
|
||||
// Configuration du PaymentIntent selon le mode
|
||||
$paymentIntentData = [
|
||||
'amount' => $amount,
|
||||
'currency' => 'eur',
|
||||
'payment_method_types' => ['card_present'],
|
||||
'payment_method_types' => $paymentMethodTypes,
|
||||
'capture_method' => 'automatic',
|
||||
// Pas d'application_fee_amount - tout va à l'amicale
|
||||
'transfer_data' => [
|
||||
'destination' => $account['stripe_account_id'],
|
||||
],
|
||||
'metadata' => array_merge($metadata, [
|
||||
'entite_id' => $entiteId,
|
||||
'user_id' => $userId,
|
||||
'calendrier_annee' => date('Y'),
|
||||
]),
|
||||
]);
|
||||
];
|
||||
|
||||
// Sauvegarder en base
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO stripe_payment_intents
|
||||
(stripe_payment_intent_id, fk_entite, fk_user, amount, currency, status, application_fee, metadata, created_at)
|
||||
VALUES (:pi_id, :fk_entite, :fk_user, :amount, :currency, :status, :app_fee, :metadata, NOW())"
|
||||
// Options Stripe (avec ou sans stripe_account)
|
||||
$stripeOptions = [];
|
||||
|
||||
if ($isTapToPay) {
|
||||
// TAP TO PAY : Paiement direct sur le compte connecté
|
||||
// Le PaymentIntent est créé sur le compte de l'amicale
|
||||
$stripeOptions['stripe_account'] = $account['stripe_account_id'];
|
||||
} else {
|
||||
// PAYMENT LINK / WEB : Paiement via la plateforme avec transfert
|
||||
// Le PaymentIntent est créé sur la plateforme et transféré
|
||||
$paymentIntentData['transfer_data'] = [
|
||||
'destination' => $account['stripe_account_id'],
|
||||
];
|
||||
}
|
||||
|
||||
// Créer le PaymentIntent
|
||||
$paymentIntent = $this->stripe->paymentIntents->create(
|
||||
$paymentIntentData,
|
||||
$stripeOptions
|
||||
);
|
||||
$stmt->execute([
|
||||
'pi_id' => $paymentIntent->id,
|
||||
'fk_entite' => $entiteId,
|
||||
'fk_user' => $userId,
|
||||
'amount' => $amount,
|
||||
'currency' => 'eur',
|
||||
'status' => $paymentIntent->status,
|
||||
'app_fee' => 0, // Pas de commission
|
||||
'metadata' => json_encode($metadata)
|
||||
]);
|
||||
|
||||
// Note : Le payment_intent_id est sauvegardé dans ope_pass.stripe_payment_id par le controller
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'client_secret' => $paymentIntent->client_secret,
|
||||
'payment_intent_id' => $paymentIntent->id,
|
||||
'amount' => $amount,
|
||||
'application_fee' => 0 // Pas de commission
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Erreur: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier la compatibilité Tap to Pay d'un appareil Android
|
||||
*/
|
||||
public function checkAndroidTapToPayCompatibility(string $manufacturer, string $model): array {
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM stripe_android_certified_devices
|
||||
WHERE manufacturer = :manufacturer
|
||||
AND model = :model
|
||||
AND tap_to_pay_certified = 1
|
||||
AND country = 'FR'"
|
||||
);
|
||||
$stmt->execute([
|
||||
'manufacturer' => $manufacturer,
|
||||
'model' => $model
|
||||
]);
|
||||
|
||||
$device = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($device) {
|
||||
return [
|
||||
'success' => true,
|
||||
'tap_to_pay_supported' => true,
|
||||
'message' => 'Tap to Pay disponible sur cet appareil',
|
||||
'min_android_version' => $device['min_android_version']
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'tap_to_pay_supported' => false,
|
||||
'message' => 'Appareil non certifié pour Tap to Pay en France',
|
||||
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 16.4+'
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Erreur: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupérer les appareils Android certifiés
|
||||
*/
|
||||
public function getCertifiedAndroidDevices(): array {
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT manufacturer, model, model_identifier, min_android_version
|
||||
FROM stripe_android_certified_devices
|
||||
WHERE tap_to_pay_certified = 1 AND country = 'FR'
|
||||
ORDER BY manufacturer, model"
|
||||
);
|
||||
$stmt->execute();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'devices' => $stmt->fetchAll(PDO::FETCH_ASSOC)
|
||||
'mode' => $isTapToPay ? 'tap_to_pay' : 'payment_link'
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
@@ -747,6 +682,47 @@ class StripeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Annuler un PaymentIntent Stripe
|
||||
*
|
||||
* @param string $paymentIntentId L'ID du PaymentIntent à annuler
|
||||
* @return array ['success' => bool, 'status' => string|null, 'message' => string|null]
|
||||
*/
|
||||
public function cancelPaymentIntent(string $paymentIntentId): array {
|
||||
try {
|
||||
// Annuler le PaymentIntent via l'API Stripe
|
||||
$paymentIntent = $this->stripe->paymentIntents->cancel($paymentIntentId);
|
||||
|
||||
LogService::log('PaymentIntent annulé', [
|
||||
'payment_intent_id' => $paymentIntentId,
|
||||
'status' => $paymentIntent->status
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $paymentIntent->status,
|
||||
'payment_intent_id' => $paymentIntentId
|
||||
];
|
||||
|
||||
} catch (ApiErrorException $e) {
|
||||
LogService::log('Erreur annulation PaymentIntent Stripe', [
|
||||
'level' => 'error',
|
||||
'payment_intent_id' => $paymentIntentId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Erreur Stripe: ' . $e->getMessage()
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Erreur: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir le mode actuel (test ou live)
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
{
|
||||
"name": "_macros",
|
||||
"rootUri": "file:///opt/flutter/bin/cache/dart-sdk/pkg/_macros",
|
||||
"rootUri": "file:///home/pierre/.local/flutter/bin/cache/dart-sdk/pkg/_macros",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
{
|
||||
"name": "built_value",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/built_value-8.12.0",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/built_value-8.12.3",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.0"
|
||||
},
|
||||
@@ -169,6 +169,12 @@
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "cookie_jar",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/cookie_jar-4.0.8",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.15"
|
||||
},
|
||||
{
|
||||
"name": "cross_file",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/cross_file-0.3.4+2",
|
||||
@@ -241,6 +247,12 @@
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.14"
|
||||
},
|
||||
{
|
||||
"name": "dio_cookie_manager",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_cookie_manager-3.3.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.18"
|
||||
},
|
||||
{
|
||||
"name": "dio_web_adapter",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_web_adapter-2.1.1",
|
||||
@@ -297,10 +309,16 @@
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"rootUri": "file:///opt/flutter/packages/flutter",
|
||||
"rootUri": "file:///home/pierre/.local/flutter/packages/flutter",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "flutter_compass",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/flutter_compass-0.8.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.12"
|
||||
},
|
||||
{
|
||||
"name": "flutter_launcher_icons",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/flutter_launcher_icons-0.14.4",
|
||||
@@ -339,7 +357,7 @@
|
||||
},
|
||||
{
|
||||
"name": "flutter_localizations",
|
||||
"rootUri": "file:///opt/flutter/packages/flutter_localizations",
|
||||
"rootUri": "file:///home/pierre/.local/flutter/packages/flutter_localizations",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
@@ -375,13 +393,13 @@
|
||||
},
|
||||
{
|
||||
"name": "flutter_test",
|
||||
"rootUri": "file:///opt/flutter/packages/flutter_test",
|
||||
"rootUri": "file:///home/pierre/.local/flutter/packages/flutter_test",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "flutter_web_plugins",
|
||||
"rootUri": "file:///opt/flutter/packages/flutter_web_plugins",
|
||||
"rootUri": "file:///home/pierre/.local/flutter/packages/flutter_web_plugins",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
@@ -483,7 +501,7 @@
|
||||
},
|
||||
{
|
||||
"name": "http",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http-1.5.0",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http-1.6.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
@@ -501,7 +519,7 @@
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/image-4.5.4",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/image-4.7.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.0"
|
||||
},
|
||||
@@ -873,7 +891,7 @@
|
||||
},
|
||||
{
|
||||
"name": "sky_engine",
|
||||
"rootUri": "file:///opt/flutter/bin/cache/pkg/sky_engine",
|
||||
"rootUri": "file:///home/pierre/.local/flutter/bin/cache/pkg/sky_engine",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
@@ -1089,9 +1107,9 @@
|
||||
},
|
||||
{
|
||||
"name": "watcher",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/watcher-1.1.4",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/watcher-1.2.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
@@ -1154,10 +1172,10 @@
|
||||
"languageVersion": "3.0"
|
||||
}
|
||||
],
|
||||
"generated": "2025-11-09T17:48:24.059730Z",
|
||||
"generated": "2026-01-19T15:01:26.574661Z",
|
||||
"generator": "pub",
|
||||
"generatorVersion": "3.5.4",
|
||||
"flutterRoot": "file:///opt/flutter",
|
||||
"flutterRoot": "file:///home/pierre/.local/flutter",
|
||||
"flutterVersion": "3.24.5",
|
||||
"pubCache": "file:///home/pierre/.pub-cache"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,208 +1,143 @@
|
||||
# 🍎 Guide de Build iOS - GEOSECTOR
|
||||
|
||||
**Date de création** : 21/10/2025
|
||||
**Version actuelle** : 3.4.2 (Build 342)
|
||||
**Dernière mise à jour** : 16/11/2025
|
||||
**Version système** : Workflow automatisé depuis Debian
|
||||
|
||||
---
|
||||
|
||||
## 📋 **Prérequis**
|
||||
## 📋 Prérequis
|
||||
|
||||
### Sur le Mac mini
|
||||
- ✅ macOS installé
|
||||
- ✅ Xcode installé avec Command Line Tools
|
||||
- ✅ Flutter installé (3.24.5 LTS recommandé)
|
||||
- ✅ CocoaPods installé (`sudo gem install cocoapods`)
|
||||
- ✅ Certificats Apple configurés (Team ID: 6WT84NWCTC)
|
||||
### Mac mini (192.168.1.34)
|
||||
- ✅ Xcode + Command Line Tools
|
||||
- ✅ Flutter 3.24.5 LTS
|
||||
- ✅ CocoaPods installé
|
||||
- ✅ Certificats Apple (Team: **6WT84NWCTC**)
|
||||
|
||||
### Sur Debian
|
||||
- ✅ Accès SSH au Mac mini (192.168.1.34)
|
||||
- ✅ rsync installé
|
||||
### PC Debian (développement)
|
||||
- ✅ Accès SSH au Mac mini
|
||||
- ✅ Fichier `../VERSION` à jour
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **Procédure complète**
|
||||
## 🚀 Build iOS - Workflow complet
|
||||
|
||||
### **Étape 1 : Transfert depuis Debian vers Mac mini**
|
||||
### **Commande unique depuis Debian**
|
||||
|
||||
```bash
|
||||
# Sur votre machine Debian
|
||||
cd /home/pierre/dev/geosector/app
|
||||
|
||||
# Lancer le transfert
|
||||
./transfer-to-mac.sh
|
||||
./ios.sh
|
||||
```
|
||||
|
||||
**Ce que fait le script** :
|
||||
1. Détecte automatiquement la version (ex: 342)
|
||||
2. Crée le dossier `app_342` sur le Mac mini
|
||||
3. Transfert tous les fichiers nécessaires (lib, ios, pubspec.yaml, etc.)
|
||||
4. Exclut les dossiers inutiles (build, .dart_tool, Pods, etc.)
|
||||
|
||||
**Durée** : 2-5 minutes (selon la connexion réseau)
|
||||
|
||||
**Note** : Vous devrez saisir le mot de passe du Mac mini
|
||||
1. ✅ Lit `../VERSION` (ex: 3.5.3)
|
||||
2. ✅ Met à jour `pubspec.yaml` (3.5.3+353)
|
||||
3. ✅ Teste connexion Mac mini
|
||||
4. ✅ Transfert rsync → `/Users/pierre/dev/geosector/app_353/`
|
||||
5. 🔀 **Choix A** : Lance build SSH automatique
|
||||
6. 🔀 **Choix B** : Instructions manuelles
|
||||
|
||||
---
|
||||
|
||||
### **Étape 2 : Connexion au Mac mini**
|
||||
### **Option A : Build automatique (recommandé)**
|
||||
|
||||
Sélectionner **A** dans le menu :
|
||||
- SSH automatique vers Mac mini
|
||||
- Lance `ios-build-mac.sh`
|
||||
- Ouvre Xcode pour l'archive
|
||||
|
||||
### **Option B : Build manuel**
|
||||
|
||||
```bash
|
||||
# Depuis Debian
|
||||
ssh pierre@192.168.1.34
|
||||
|
||||
# Aller dans le dossier transféré
|
||||
cd /Users/pierre/dev/geosector/app_342
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Étape 3 : Lancer le build iOS**
|
||||
|
||||
```bash
|
||||
# Sur le Mac mini
|
||||
cd /Users/pierre/dev/geosector/app_353
|
||||
./ios-build-mac.sh
|
||||
```
|
||||
|
||||
**Ce que fait le script** :
|
||||
1. ✅ Nettoie le projet (`flutter clean`)
|
||||
2. ✅ Récupère les dépendances (`flutter pub get`)
|
||||
3. ✅ Installe les pods (`pod install`)
|
||||
4. ✅ Compile en release (`flutter build ios --release`)
|
||||
5. ✅ Ouvre Xcode pour l'archive (signature manuelle plus fiable)
|
||||
---
|
||||
|
||||
**Durée de préparation** : 5-10 minutes
|
||||
## 📦 Archive et Upload (Xcode)
|
||||
|
||||
**Résultat** : Xcode s'ouvre, prêt pour Product > Archive
|
||||
**Xcode s'ouvre automatiquement** après le build ✅
|
||||
|
||||
1. ⏳ Attendre chargement Xcode
|
||||
2. ✅ Vérifier **Signing & Capabilities**
|
||||
- Team : `6WT84NWCTC`
|
||||
- "Automatically manage signing" : ✅
|
||||
3. 🧹 **Product > Clean Build Folder** (⌘⇧K)
|
||||
4. 📦 **Product > Archive** (⏳ 5-10 min)
|
||||
5. 📤 **Organizer** → **Distribute App**
|
||||
6. ☁️ **App Store Connect** → **Upload**
|
||||
7. ✅ **Upload** (⏳ 2-5 min)
|
||||
|
||||
---
|
||||
|
||||
### **Étape 4 : Créer l'archive et upload vers App Store Connect**
|
||||
## 📱 TestFlight (App Store Connect)
|
||||
|
||||
**Xcode est ouvert automatiquement** ✅
|
||||
https://appstoreconnect.apple.com
|
||||
|
||||
Dans Xcode :
|
||||
1. ⏳ Attendre le chargement (quelques secondes)
|
||||
2. ✅ Vérifier **Signing & Capabilities** : Team = 6WT84NWCTC, "Automatically manage signing" coché
|
||||
3. 🧹 **Product > Clean Build Folder** (Cmd+Shift+K)
|
||||
4. 📦 **Product > Archive**
|
||||
5. ⏳ Attendre l'archive (5-10 minutes)
|
||||
6. 📤 **Organizer** s'ouvre → Clic **Distribute App**
|
||||
7. ☁️ Choisir **App Store Connect**
|
||||
8. ✅ **Upload** → Automatique
|
||||
9. 🚀 **Next** jusqu'à validation finale
|
||||
|
||||
**⚠️ Ne PAS utiliser xcodebuild en ligne de commande** : erreurs de signature (errSecInternalComponent). Xcode GUI obligatoire.
|
||||
1. **Apps** > **GeoSector** > **TestFlight**
|
||||
2. ⏳ Attendre traitement (5-15 min)
|
||||
3. Build **353 (3.5.3)** apparaît
|
||||
4. **Conformité export** :
|
||||
- Utilise chiffrement ? → **Oui**
|
||||
- Algorithmes exempts ? → **Aucun des algorithmes mentionnés**
|
||||
5. **Testeurs internes** → Ajouter ton Apple ID
|
||||
6. 📧 Invitation TestFlight envoyée
|
||||
|
||||
---
|
||||
|
||||
## 📁 **Structure des dossiers sur Mac mini**
|
||||
## ✅ Checklist rapide
|
||||
|
||||
```
|
||||
/Users/pierre/dev/geosector/
|
||||
├── app_342/ # Version 3.4.2 (Build 342)
|
||||
│ ├── ios/
|
||||
│ ├── lib/
|
||||
│ ├── pubspec.yaml
|
||||
│ ├── ios-build-mac.sh # Script de build
|
||||
│ └── build/
|
||||
│ └── Runner.xcarchive # Archive générée
|
||||
├── app_341/ # Version précédente (si existe)
|
||||
└── app_343/ # Version future
|
||||
```
|
||||
|
||||
**Avantage** : Garder plusieurs versions côte à côte pour tests/rollback
|
||||
- [ ] Mettre à jour `../VERSION` (ex: 3.5.4)
|
||||
- [ ] Lancer `./ios.sh` depuis Debian
|
||||
- [ ] Archive créée dans Xcode
|
||||
- [ ] Upload vers App Store Connect
|
||||
- [ ] Conformité export renseignée
|
||||
- [ ] Testeur interne ajouté
|
||||
- [ ] App installée via TestFlight
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **Résolution de problèmes**
|
||||
## 🔧 Résolution problèmes
|
||||
|
||||
### **Erreur : "Flutter not found"**
|
||||
### Erreur SSH "Too many authentication failures"
|
||||
✅ **Corrigé** : Le script force l'authentification par mot de passe
|
||||
|
||||
```bash
|
||||
# Vérifier que Flutter est dans le PATH
|
||||
echo $PATH | grep flutter
|
||||
|
||||
# Ajouter Flutter au PATH (dans ~/.zshrc ou ~/.bash_profile)
|
||||
export PATH="$PATH:/opt/flutter/bin"
|
||||
source ~/.zshrc
|
||||
### Erreur de signature Xcode
|
||||
```
|
||||
Signing & Capabilities > Team = 6WT84NWCTC
|
||||
"Automatically manage signing" ✅
|
||||
```
|
||||
|
||||
### **Erreur : "xcodebuild not found"**
|
||||
|
||||
### Pod install échoue
|
||||
```bash
|
||||
# Installer Xcode Command Line Tools
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
### **Erreur lors de pod install**
|
||||
|
||||
```bash
|
||||
# Sur le Mac mini
|
||||
cd ios
|
||||
rm -rf Pods Podfile.lock
|
||||
pod install --repo-update
|
||||
cd ..
|
||||
```
|
||||
|
||||
### **Erreur de signature**
|
||||
|
||||
1. Ouvrir Xcode : `open ios/Runner.xcworkspace`
|
||||
2. Sélectionner le target "Runner"
|
||||
3. Onglet "Signing & Capabilities"
|
||||
4. Vérifier Team ID : `6WT84NWCTC`
|
||||
5. Cocher "Automatically manage signing"
|
||||
|
||||
### **Archive créée mais vide**
|
||||
|
||||
Vérifier que la compilation iOS a réussi :
|
||||
```bash
|
||||
flutter build ios --release --no-codesign --verbose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **Checklist de validation**
|
||||
## 🎯 Workflow version complète
|
||||
|
||||
- [ ] Version/Build incrémenté dans `pubspec.yaml`
|
||||
- [ ] Compilation iOS réussie
|
||||
- [ ] Archive validée dans Xcode Organizer
|
||||
- [ ] Build uploadé vers App Store Connect
|
||||
- [ ] **TestFlight** : Ajouter build au groupe "Testeurs externes"
|
||||
- [ ] Renseigner "Infos sur l'exportation de conformité" :
|
||||
- **App utilise chiffrement ?** → Oui
|
||||
- **Algorithmes exempts listés ?** → **Aucun des algorithmes mentionnés ci-dessus**
|
||||
- (App utilise HTTPS standard iOS uniquement)
|
||||
- [ ] Soumettre build pour révision TestFlight
|
||||
- [ ] *(Optionnel)* Captures/Release notes pour production App Store
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Workflow complet**
|
||||
|
||||
```bash
|
||||
# 1. Debian → Transfert
|
||||
cd /home/pierre/dev/geosector/app
|
||||
./transfer-to-mac.sh
|
||||
|
||||
# 2. Mac mini → Build + Archive
|
||||
ssh pierre@192.168.1.34
|
||||
cd /Users/pierre/dev/geosector/app_342
|
||||
./ios-build-mac.sh
|
||||
# Xcode s'ouvre → Product > Clean + Archive
|
||||
|
||||
# 3. Upload → TestFlight
|
||||
# Organizer > Distribute App > App Store Connect > Upload
|
||||
# App Store Connect > TestFlight > Conformité export
|
||||
```mermaid
|
||||
Debian (dev) → Mac mini (build) → App Store Connect → TestFlight → iPhone
|
||||
│ │ │ │ │
|
||||
↓ ↓ ↓ ↓ ↓
|
||||
ios.sh build iOS Upload Traitement Install
|
||||
+ Archive (5-15 min)
|
||||
```
|
||||
|
||||
**Temps total** : 20-30 minutes (build + upload + traitement Apple)
|
||||
|
||||
---
|
||||
|
||||
## 📞 **Support**
|
||||
## 📞 Liens utiles
|
||||
|
||||
- **Documentation Apple** : https://developer.apple.com
|
||||
- **App Store Connect** : https://appstoreconnect.apple.com
|
||||
- **TestFlight** : App dans l'App Store
|
||||
- **Flutter iOS** : https://docs.flutter.dev/deployment/ios
|
||||
|
||||
---
|
||||
|
||||
✅ **Prêt pour la production !** 🚀
|
||||
✅ **Prêt pour TestFlight !** 🚀
|
||||
|
||||
335
app/android.sh
335
app/android.sh
@@ -57,42 +57,181 @@ if ! command -v flutter &> /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Récupérer la version depuis pubspec.yaml
|
||||
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | sed 's/+/-/')
|
||||
if [ -z "$VERSION" ]; then
|
||||
print_error "Impossible de récupérer la version depuis pubspec.yaml"
|
||||
# Étape 0 : Synchroniser la version depuis ../VERSION
|
||||
print_message "Étape 0/5 : Synchronisation de la version..."
|
||||
echo
|
||||
|
||||
VERSION_FILE="../VERSION"
|
||||
if [ ! -f "$VERSION_FILE" ]; then
|
||||
print_error "Fichier VERSION introuvable : $VERSION_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extraire le version code
|
||||
VERSION_CODE=$(echo $VERSION | cut -d'-' -f2)
|
||||
# Lire la version depuis le fichier (enlever espaces/retours à la ligne)
|
||||
VERSION_NUMBER=$(cat "$VERSION_FILE" | tr -d '\n\r ' | tr -d '[:space:]')
|
||||
if [ -z "$VERSION_NUMBER" ]; then
|
||||
print_error "Le fichier VERSION est vide"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_message "Version lue depuis $VERSION_FILE : $VERSION_NUMBER"
|
||||
|
||||
# Calculer le versionCode (supprimer les points)
|
||||
VERSION_CODE=$(echo $VERSION_NUMBER | tr -d '.')
|
||||
if [ -z "$VERSION_CODE" ]; then
|
||||
print_error "Impossible d'extraire le version code"
|
||||
print_error "Impossible de calculer le versionCode"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_message "Version détectée : $VERSION"
|
||||
print_message "Version code calculé : $VERSION_CODE"
|
||||
|
||||
# Mettre à jour pubspec.yaml
|
||||
print_message "Mise à jour de pubspec.yaml..."
|
||||
sed -i.bak "s/^version:.*/version: $VERSION_NUMBER+$VERSION_CODE/" pubspec.yaml
|
||||
|
||||
# Vérifier que la mise à jour a réussi
|
||||
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //')
|
||||
if [ "$UPDATED_VERSION" != "$VERSION_NUMBER+$VERSION_CODE" ]; then
|
||||
print_error "Échec de la mise à jour de pubspec.yaml"
|
||||
print_error "Attendu : $VERSION_NUMBER+$VERSION_CODE"
|
||||
print_error "Obtenu : $UPDATED_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_success "pubspec.yaml mis à jour : version: $VERSION_NUMBER+$VERSION_CODE"
|
||||
print_message "build.gradle.kts se synchronisera automatiquement via Flutter Gradle Plugin"
|
||||
echo
|
||||
|
||||
# Récupérer la version finale pour l'affichage
|
||||
VERSION="$VERSION_NUMBER-$VERSION_CODE"
|
||||
print_message "Version finale : $VERSION"
|
||||
print_message "Version code : $VERSION_CODE"
|
||||
echo
|
||||
|
||||
# Vérifier la présence du keystore
|
||||
if [ ! -f "android/app/geosector2025.jks" ]; then
|
||||
print_error "Fichier keystore introuvable : android/app/geosector2025.jks"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Vérifier la présence du fichier key.properties
|
||||
if [ ! -f "android/key.properties" ]; then
|
||||
print_error "Fichier key.properties introuvable"
|
||||
print_error "Ce fichier est nécessaire pour signer l'application"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_success "Configuration de signature vérifiée"
|
||||
# Demander le mode Debug ou Release
|
||||
print_message "========================================="
|
||||
print_message " MODE DE BUILD"
|
||||
print_message "========================================="
|
||||
echo
|
||||
print_message "Choisissez le mode de build :"
|
||||
echo
|
||||
print_message " ${YELLOW}[D]${NC} Debug"
|
||||
print_message " ✓ Installation rapide via ADB"
|
||||
print_message " ✓ Hot reload possible"
|
||||
print_message " ✓ Logs complets"
|
||||
print_message " ⚠ Tap to Pay simulé uniquement"
|
||||
print_message " ⚠ Performance non optimisée"
|
||||
echo
|
||||
print_message " ${GREEN}[R]${NC} Release (recommandé)"
|
||||
print_message " ✓ APK/AAB optimisé"
|
||||
print_message " ✓ Tap to Pay réel en production"
|
||||
print_message " ✓ Performance maximale"
|
||||
echo
|
||||
read -p "Votre choix (D/R) [défaut: R] : " -n 1 -r BUILD_TYPE
|
||||
echo
|
||||
echo
|
||||
|
||||
# Définir le flag de build et le suffixe pour les noms de fichiers
|
||||
BUILD_MODE_FLAG="--release"
|
||||
MODE_SUFFIX="release"
|
||||
SKIP_R8_CHOICE=false
|
||||
|
||||
if [[ $BUILD_TYPE =~ ^[Dd]$ ]]; then
|
||||
BUILD_MODE_FLAG="--debug"
|
||||
MODE_SUFFIX="debug"
|
||||
SKIP_R8_CHOICE=true
|
||||
print_success "Mode Debug sélectionné"
|
||||
echo
|
||||
print_warning "Attention : Tap to Pay ne fonctionnera qu'en mode simulé"
|
||||
echo
|
||||
|
||||
# En mode debug, pas de choix R8 ni de vérification keystore
|
||||
USE_R8=false
|
||||
COPY_DEBUG_FILES=false
|
||||
else
|
||||
print_success "Mode Release sélectionné"
|
||||
echo
|
||||
fi
|
||||
|
||||
# Demander le mode R8 SEULEMENT si Release
|
||||
if [ "$SKIP_R8_CHOICE" = false ]; then
|
||||
print_message "========================================="
|
||||
print_message " OPTIMISATION RELEASE"
|
||||
print_message "========================================="
|
||||
echo
|
||||
print_message "Choisissez le niveau d'optimisation :"
|
||||
echo
|
||||
print_message " ${GREEN}[A]${NC} Production - R8/ProGuard activé"
|
||||
print_message " ✓ Taille réduite (~30-40%)"
|
||||
print_message " ✓ Code obscurci (sécurité)"
|
||||
print_message " ✓ Génère mapping.txt pour débogage"
|
||||
print_message " ✓ Génère symboles natifs"
|
||||
echo
|
||||
print_message " ${YELLOW}[B]${NC} Test interne - Sans R8/ProGuard (défaut)"
|
||||
print_message " ✓ Build plus rapide"
|
||||
print_message " ✓ Pas d'obscurcissement (débogage facile)"
|
||||
print_message " ⚠ Taille plus importante"
|
||||
print_message " ⚠ Avertissements Google Play Console"
|
||||
echo
|
||||
read -p "Votre choix (A/B) [défaut: B] : " -n 1 -r BUILD_MODE
|
||||
echo
|
||||
echo
|
||||
|
||||
# Définir les variables selon le choix
|
||||
USE_R8=false
|
||||
COPY_DEBUG_FILES=false
|
||||
|
||||
if [[ $BUILD_MODE =~ ^[Aa]$ ]]; then
|
||||
USE_R8=true
|
||||
COPY_DEBUG_FILES=true
|
||||
print_success "Mode Production sélectionné - R8/ProGuard activé"
|
||||
else
|
||||
print_success "Mode Test interne sélectionné - R8/ProGuard désactivé"
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
|
||||
# Vérifier la présence du keystore SEULEMENT si Release
|
||||
if [ "$SKIP_R8_CHOICE" = false ]; then
|
||||
if [ ! -f "android/app/geosector2025.jks" ]; then
|
||||
print_error "Fichier keystore introuvable : android/app/geosector2025.jks"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Vérifier la présence du fichier key.properties
|
||||
if [ ! -f "android/key.properties" ]; then
|
||||
print_error "Fichier key.properties introuvable"
|
||||
print_error "Ce fichier est nécessaire pour signer l'application"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_success "Configuration de signature vérifiée"
|
||||
echo
|
||||
fi
|
||||
|
||||
# Activer R8 si demandé (modification temporaire du build.gradle.kts)
|
||||
GRADLE_FILE="android/app/build.gradle.kts"
|
||||
GRADLE_BACKUP="android/app/build.gradle.kts.backup"
|
||||
|
||||
if [ "$USE_R8" = true ]; then
|
||||
print_message "Activation de R8/ProGuard dans build.gradle.kts..."
|
||||
|
||||
# Créer une sauvegarde
|
||||
cp "$GRADLE_FILE" "$GRADLE_BACKUP"
|
||||
|
||||
# Activer minifyEnabled et shrinkResources
|
||||
sed -i.tmp 's/isMinifyEnabled = false/isMinifyEnabled = true/' "$GRADLE_FILE"
|
||||
sed -i.tmp 's/isShrinkResources = false/isShrinkResources = true/' "$GRADLE_FILE"
|
||||
|
||||
# Nettoyer les fichiers temporaires de sed
|
||||
rm -f "${GRADLE_FILE}.tmp"
|
||||
|
||||
print_success "R8/ProGuard activé temporairement"
|
||||
echo
|
||||
fi
|
||||
|
||||
# Étape 1 : Nettoyer le projet
|
||||
print_message "Étape 1/4 : Nettoyage du projet..."
|
||||
print_message "Étape 1/5 : Nettoyage du projet..."
|
||||
flutter clean
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Projet nettoyé"
|
||||
@@ -103,7 +242,7 @@ fi
|
||||
echo
|
||||
|
||||
# Étape 2 : Récupérer les dépendances
|
||||
print_message "Étape 2/4 : Récupération des dépendances..."
|
||||
print_message "Étape 2/5 : Récupération des dépendances..."
|
||||
flutter pub get
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Dépendances récupérées"
|
||||
@@ -113,8 +252,23 @@ else
|
||||
fi
|
||||
echo
|
||||
|
||||
# Étape 2.5 : Patcher nfc_manager pour AGP 8+
|
||||
print_message "Étape 2.5/5 : Patch nfc_manager pour Android Gradle Plugin 8+..."
|
||||
NFC_PATCH_SCRIPT="./fastlane/scripts/commun/fix-nfc-manager.sh"
|
||||
if [ -f "$NFC_PATCH_SCRIPT" ]; then
|
||||
bash "$NFC_PATCH_SCRIPT"
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Patch nfc_manager appliqué"
|
||||
else
|
||||
print_warning "Le patch nfc_manager a échoué (peut être déjà appliqué)"
|
||||
fi
|
||||
else
|
||||
print_warning "Script de patch nfc_manager introuvable : $NFC_PATCH_SCRIPT"
|
||||
fi
|
||||
echo
|
||||
|
||||
# Étape 3 : Analyser le code (optionnel mais recommandé)
|
||||
print_message "Étape 3/4 : Analyse du code Dart..."
|
||||
print_message "Étape 3/5 : Analyse du code Dart..."
|
||||
flutter analyze --no-fatal-infos --no-fatal-warnings || {
|
||||
print_warning "Des avertissements ont été détectés dans le code"
|
||||
read -p "Voulez-vous continuer malgré les avertissements ? (y/n) " -n 1 -r
|
||||
@@ -128,9 +282,9 @@ print_success "Analyse du code terminée"
|
||||
echo
|
||||
|
||||
# Étape 4 : Générer le bundle
|
||||
print_message "Étape 4/4 : Génération du bundle Android..."
|
||||
print_message "Étape 4/5 : Génération du bundle Android..."
|
||||
print_message "Cette opération peut prendre plusieurs minutes..."
|
||||
flutter build appbundle --release
|
||||
flutter build appbundle $BUILD_MODE_FLAG
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Bundle généré avec succès"
|
||||
else
|
||||
@@ -139,15 +293,23 @@ else
|
||||
fi
|
||||
echo
|
||||
|
||||
# Restaurer le build.gradle.kts original si modifié
|
||||
if [ "$USE_R8" = true ] && [ -f "$GRADLE_BACKUP" ]; then
|
||||
print_message "Restauration du build.gradle.kts original..."
|
||||
mv "$GRADLE_BACKUP" "$GRADLE_FILE"
|
||||
print_success "Fichier restauré"
|
||||
echo
|
||||
fi
|
||||
|
||||
# Vérifier que le bundle a été créé
|
||||
BUNDLE_PATH="build/app/outputs/bundle/release/app-release.aab"
|
||||
BUNDLE_PATH="build/app/outputs/bundle/$MODE_SUFFIX/app-$MODE_SUFFIX.aab"
|
||||
if [ ! -f "$BUNDLE_PATH" ]; then
|
||||
print_error "Bundle introuvable à l'emplacement attendu : $BUNDLE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copier le bundle à la racine avec le nouveau nom
|
||||
FINAL_NAME="geosector-$VERSION_CODE.aab"
|
||||
FINAL_NAME="geosector-$VERSION_CODE-$MODE_SUFFIX.aab"
|
||||
print_message "Copie du bundle vers : $FINAL_NAME"
|
||||
cp "$BUNDLE_PATH" "$FINAL_NAME"
|
||||
|
||||
@@ -162,6 +324,47 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copier les fichiers de débogage si Option A sélectionnée
|
||||
if [ "$COPY_DEBUG_FILES" = true ]; then
|
||||
echo
|
||||
print_message "Copie des fichiers de débogage pour Google Play Console..."
|
||||
|
||||
# Créer un dossier de release
|
||||
RELEASE_DIR="release-$VERSION_CODE"
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
|
||||
# Copier le bundle
|
||||
cp "$FINAL_NAME" "$RELEASE_DIR/"
|
||||
|
||||
# Copier le fichier mapping.txt (R8/ProGuard)
|
||||
MAPPING_FILE="build/app/outputs/mapping/release/mapping.txt"
|
||||
if [ -f "$MAPPING_FILE" ]; then
|
||||
cp "$MAPPING_FILE" "$RELEASE_DIR/mapping.txt"
|
||||
print_success "Fichier mapping.txt copié"
|
||||
else
|
||||
print_warning "Fichier mapping.txt introuvable (peut être normal)"
|
||||
fi
|
||||
|
||||
# Copier les symboles natifs
|
||||
SYMBOLS_ZIP="build/app/intermediates/merged_native_libs/release/out/lib"
|
||||
if [ -d "$SYMBOLS_ZIP" ]; then
|
||||
# Créer une archive des symboles
|
||||
cd build/app/intermediates/merged_native_libs/release/out
|
||||
zip -r "../../../../../../$RELEASE_DIR/native-symbols.zip" lib/
|
||||
cd - > /dev/null
|
||||
print_success "Symboles natifs archivés"
|
||||
else
|
||||
print_warning "Symboles natifs introuvables (peut être normal)"
|
||||
fi
|
||||
|
||||
print_success "Fichiers de débogage copiés dans : $RELEASE_DIR/"
|
||||
echo
|
||||
print_message "Pour uploader sur Google Play Console :"
|
||||
print_message "1. Bundle : $RELEASE_DIR/$FINAL_NAME"
|
||||
print_message "2. Mapping : $RELEASE_DIR/mapping.txt"
|
||||
print_message "3. Symboles : $RELEASE_DIR/native-symbols.zip"
|
||||
fi
|
||||
|
||||
echo
|
||||
print_message "========================================="
|
||||
print_success " GÉNÉRATION TERMINÉE AVEC SUCCÈS !"
|
||||
@@ -170,11 +373,37 @@ echo
|
||||
print_message "Bundle généré : ${GREEN}$FINAL_NAME${NC}"
|
||||
print_message "Version : $VERSION"
|
||||
print_message "Chemin : $(pwd)/$FINAL_NAME"
|
||||
echo
|
||||
print_message "Prochaines étapes :"
|
||||
print_message "1. Tester le bundle sur un appareil Android"
|
||||
print_message "2. Uploader sur Google Play Console"
|
||||
print_message "3. Soumettre pour review"
|
||||
|
||||
if [ "$BUILD_MODE_FLAG" = "--debug" ]; then
|
||||
echo
|
||||
print_message "Mode : ${YELLOW}Debug${NC}"
|
||||
print_message "⚠ Tap to Pay simulé uniquement"
|
||||
print_message "✓ Logs complets disponibles"
|
||||
echo
|
||||
print_message "Prochaines étapes :"
|
||||
print_message "1. Installer l'APK sur l'appareil (proposé ci-dessous)"
|
||||
print_message "2. Tester l'application avec adb logcat"
|
||||
print_message "3. Pour Tap to Pay réel, relancer en mode Release"
|
||||
elif [ "$USE_R8" = true ]; then
|
||||
echo
|
||||
print_message "Mode : ${GREEN}Release - Production (R8/ProGuard activé)${NC}"
|
||||
print_message "Dossier release : ${GREEN}$RELEASE_DIR/${NC}"
|
||||
echo
|
||||
print_message "Prochaines étapes :"
|
||||
print_message "1. Tester le bundle sur un appareil Android"
|
||||
print_message "2. Uploader le bundle sur Google Play Console"
|
||||
print_message "3. Uploader mapping.txt et native-symbols.zip"
|
||||
print_message "4. Soumettre pour review"
|
||||
else
|
||||
echo
|
||||
print_message "Mode : ${GREEN}Release${NC} - ${YELLOW}Test interne (R8/ProGuard désactivé)${NC}"
|
||||
print_warning "Avertissements attendus sur Google Play Console"
|
||||
echo
|
||||
print_message "Prochaines étapes :"
|
||||
print_message "1. Tester le bundle sur un appareil Android"
|
||||
print_message "2. Uploader sur Google Play Console (test interne)"
|
||||
print_message "3. Pour production, relancer avec Option A"
|
||||
fi
|
||||
echo
|
||||
|
||||
# Optionnel : Générer aussi l'APK
|
||||
@@ -182,18 +411,50 @@ read -p "Voulez-vous aussi générer l'APK pour des tests ? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_message "Génération de l'APK..."
|
||||
flutter build apk --release
|
||||
flutter build apk $BUILD_MODE_FLAG
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
APK_PATH="build/app/outputs/flutter-apk/app-release.apk"
|
||||
APK_PATH="build/app/outputs/flutter-apk/app-$MODE_SUFFIX.apk"
|
||||
if [ -f "$APK_PATH" ]; then
|
||||
APK_NAME="geosector-$VERSION_CODE.apk"
|
||||
APK_NAME="geosector-$VERSION_CODE-$MODE_SUFFIX.apk"
|
||||
cp "$APK_PATH" "$APK_NAME"
|
||||
print_success "APK généré : $APK_NAME"
|
||||
|
||||
# Afficher la taille de l'APK
|
||||
APK_SIZE=$(du -h "$APK_NAME" | cut -f1)
|
||||
print_message "Taille de l'APK : $APK_SIZE"
|
||||
|
||||
# Si mode Debug, proposer installation automatique
|
||||
if [ "$BUILD_MODE_FLAG" = "--debug" ]; then
|
||||
echo
|
||||
read -p "Installer l'APK debug sur l'appareil connecté ? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_message "Désinstallation de l'ancienne version..."
|
||||
adb uninstall fr.geosector.app3 2>/dev/null || print_warning "Aucune version précédente trouvée"
|
||||
print_message "Installation sur l'appareil..."
|
||||
adb install "$APK_NAME"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "APK installé avec succès"
|
||||
|
||||
# Proposer de lancer l'app
|
||||
read -p "Lancer l'application ? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
adb shell am start -n fr.geosector.app3/.MainActivity
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Application lancée"
|
||||
else
|
||||
print_warning "Impossible de lancer l'application"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
print_error "Échec de l'installation"
|
||||
print_message "Vérifiez qu'un appareil est bien connecté : adb devices"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
print_warning "Échec de la génération de l'APK (le bundle a été créé avec succès)"
|
||||
|
||||
@@ -55,9 +55,13 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Optimisations sans ProGuard pour éviter les problèmes
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
// Optimisations R8/ProGuard avec règles personnalisées
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
|
||||
// Configuration de signature
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
|
||||
92
app/android/app/build.gradle.kts.backup
Executable file
92
app/android/app/build.gradle.kts.backup
Executable file
@@ -0,0 +1,92 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// Charger les propriétés de signature
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "fr.geosector.app3"
|
||||
compileSdk = 35 // Requis par plusieurs plugins (flutter_local_notifications, stripe, etc.)
|
||||
ndkVersion = "27.0.12077973"
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "21"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// Application ID for Google Play Store
|
||||
applicationId = "fr.geosector.app3"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
// Minimum SDK 28 requis pour Stripe Tap to Pay
|
||||
minSdk = 28
|
||||
targetSdk = 35 // API 35 requise par Google Play (Oct 2024+)
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Optimisations sans ProGuard pour éviter les problèmes
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
|
||||
// Configuration de signature
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
|
||||
debug {
|
||||
// Mode debug pour le développement
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-DEBUG"
|
||||
}
|
||||
}
|
||||
|
||||
// Résolution des conflits de fichiers dupliqués (Stripe + BouncyCastle)
|
||||
packaging {
|
||||
resources {
|
||||
pickFirst("org/bouncycastle/**")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
57
app/android/app/proguard-rules.pro
vendored
Normal file
57
app/android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
# Règles ProGuard/R8 pour GEOSECTOR
|
||||
# =====================================
|
||||
|
||||
## Règles générées automatiquement par R8 (classes manquantes)
|
||||
## Ces classes Java ne sont pas disponibles sur Android mais ne sont pas utilisées
|
||||
-dontwarn java.beans.ConstructorProperties
|
||||
-dontwarn java.beans.Transient
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
-dontwarn org.slf4j.impl.StaticMDCBinder
|
||||
|
||||
## Règles pour Google Play Core (composants différés - non utilisés)
|
||||
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
|
||||
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
|
||||
-dontwarn com.google.android.play.core.tasks.OnFailureListener
|
||||
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
|
||||
-dontwarn com.google.android.play.core.tasks.Task
|
||||
|
||||
## Règles pour Stripe SDK
|
||||
-keep class com.stripe.** { *; }
|
||||
-keepclassmembers class com.stripe.** { *; }
|
||||
|
||||
## Règles pour Jackson (utilisé par Stripe)
|
||||
-keep class com.fasterxml.jackson.** { *; }
|
||||
-keepclassmembers class com.fasterxml.jackson.** { *; }
|
||||
-dontwarn com.fasterxml.jackson.databind.**
|
||||
|
||||
## Règles pour les modèles de données (Hive)
|
||||
-keep class fr.geosector.app3.** { *; }
|
||||
-keepclassmembers class fr.geosector.app3.** { *; }
|
||||
|
||||
## Règles pour les réflexions Flutter
|
||||
-keep class io.flutter.app.** { *; }
|
||||
-keep class io.flutter.plugin.** { *; }
|
||||
-keep class io.flutter.util.** { *; }
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
|
||||
## Règles pour les annotations
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
|
||||
## Optimisation
|
||||
-optimizationpasses 5
|
||||
-dontusemixedcaseclassnames
|
||||
-dontskipnonpubliclibraryclasses
|
||||
-dontpreverify
|
||||
-verbose
|
||||
Binary file not shown.
@@ -41,7 +41,7 @@ FINAL_OWNER="nginx"
|
||||
FINAL_GROUP="nginx"
|
||||
|
||||
# Configuration de sauvegarde
|
||||
BACKUP_DIR="/data/backup/geosector"
|
||||
BACKUP_DIR="/home/pierre/samba/back/geosector/app/"
|
||||
|
||||
# Couleurs pour les messages
|
||||
GREEN='\033[0;32m'
|
||||
@@ -254,9 +254,13 @@ EOF
|
||||
echo_info "Fixing web assets structure..."
|
||||
./copy-web-images.sh || echo_error "Failed to fix web assets"
|
||||
|
||||
# Créer l'archive depuis le build
|
||||
# Créer l'archive depuis le build (avec exclusions pour réduire la taille)
|
||||
echo_info "Creating archive from build..."
|
||||
tar -czf "${TEMP_ARCHIVE}" -C ${FLUTTER_BUILD_DIR} . || echo_error "Failed to create archive"
|
||||
tar -czf "${TEMP_ARCHIVE}" -C ${FLUTTER_BUILD_DIR} \
|
||||
--exclude='*.symbols' \
|
||||
--exclude='*.kra' \
|
||||
--exclude='.DS_Store' \
|
||||
. || echo_error "Failed to create archive"
|
||||
|
||||
create_local_backup "${TEMP_ARCHIVE}" "dev"
|
||||
|
||||
|
||||
498
app/deploy-ios-full-auto.sh
Executable file
498
app/deploy-ios-full-auto.sh
Executable file
@@ -0,0 +1,498 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de déploiement iOS automatisé pour GEOSECTOR
|
||||
# Version: 1.0
|
||||
# Date: 2025-12-05
|
||||
# Auteur: Pierre (avec l'aide de Claude)
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy-ios-full-auto.sh # Utilise ../VERSION
|
||||
# ./deploy-ios-full-auto.sh 3.6.0 # Version spécifique
|
||||
# ./deploy-ios-full-auto.sh 3.6.0 --skip-build # Skip Flutter build si déjà fait
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# =====================================
|
||||
# Configuration
|
||||
# =====================================
|
||||
|
||||
# Couleurs pour les messages
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
MAGENTA='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration Mac mini
|
||||
MAC_MINI_HOST="minipi4" # Nom défini dans ~/.ssh/config
|
||||
MAC_BASE_DIR="/Users/pierre/dev/geosector"
|
||||
|
||||
# Timestamp pour logs et archives
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
LOG_FILE="./logs/deploy-ios-${TIMESTAMP}.log"
|
||||
mkdir -p ./logs
|
||||
|
||||
# Variables globales pour le rapport
|
||||
STEP_START_TIME=0
|
||||
TOTAL_START_TIME=$(date +%s)
|
||||
ERRORS_COUNT=0
|
||||
WARNINGS_COUNT=0
|
||||
|
||||
# =====================================
|
||||
# Fonctions utilitaires
|
||||
# =====================================
|
||||
|
||||
log() {
|
||||
echo -e "$1" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
log_step() {
|
||||
STEP_START_TIME=$(date +%s)
|
||||
log "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
log "${CYAN}▶ $1${NC}"
|
||||
log "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
}
|
||||
|
||||
log_substep() {
|
||||
log "${MAGENTA} ➜ $1${NC}"
|
||||
}
|
||||
|
||||
log_info() {
|
||||
log "${BLUE}ℹ ${NC}$1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
local elapsed=$(($(date +%s) - STEP_START_TIME))
|
||||
log "${GREEN}✓${NC} $1 ${CYAN}(${elapsed}s)${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
((WARNINGS_COUNT++))
|
||||
log "${YELLOW}⚠ ${NC}$1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
((ERRORS_COUNT++))
|
||||
log "${RED}✗${NC} $1"
|
||||
}
|
||||
|
||||
log_fatal() {
|
||||
log_error "$1"
|
||||
log "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
log "${RED}DÉPLOIEMENT ÉCHOUÉ${NC}"
|
||||
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
log_error "Consultez le log: ${LOG_FILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Fonction pour exécuter une commande et capturer les erreurs
|
||||
safe_exec() {
|
||||
local cmd="$1"
|
||||
local error_msg="$2"
|
||||
|
||||
if ! eval "$cmd" >> "${LOG_FILE}" 2>&1; then
|
||||
log_fatal "$error_msg"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fonction pour exécuter une commande SSH avec gestion d'erreurs
|
||||
ssh_exec() {
|
||||
local cmd="$1"
|
||||
local error_msg="$2"
|
||||
|
||||
if ! ssh "$MAC_MINI_HOST" "$cmd" >> "${LOG_FILE}" 2>&1; then
|
||||
log_fatal "$error_msg"
|
||||
fi
|
||||
}
|
||||
|
||||
# =====================================
|
||||
# En-tête
|
||||
# =====================================
|
||||
|
||||
clear
|
||||
log "${BLUE}╔════════════════════════════════════════════════════════╗${NC}"
|
||||
log "${BLUE}║ ║${NC}"
|
||||
log "${BLUE}║ ${GREEN}🍎 DÉPLOIEMENT iOS AUTOMATISÉ${BLUE} ║${NC}"
|
||||
log "${BLUE}║ ${CYAN}GEOSECTOR - Full Automation${BLUE} ║${NC}"
|
||||
log "${BLUE}║ ║${NC}"
|
||||
log "${BLUE}╚════════════════════════════════════════════════════════╝${NC}"
|
||||
log ""
|
||||
log_info "Démarrage: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
log_info "Log file: ${LOG_FILE}"
|
||||
log ""
|
||||
|
||||
# =====================================
|
||||
# Étape 1 : Gestion de la version
|
||||
# =====================================
|
||||
|
||||
log_step "ÉTAPE 1/8 : Gestion de la version"
|
||||
|
||||
# Déterminer la version à utiliser
|
||||
if [ "${1:-}" != "" ] && [[ ! "${1}" =~ ^-- ]]; then
|
||||
VERSION="$1"
|
||||
log_info "Version fournie en argument: ${VERSION}"
|
||||
else
|
||||
# Lire depuis ../VERSION
|
||||
if [ ! -f ../VERSION ]; then
|
||||
log_fatal "Fichier ../VERSION introuvable et aucune version fournie"
|
||||
fi
|
||||
VERSION=$(cat ../VERSION | tr -d '\n\r ' | tr -d '[:space:]')
|
||||
log_info "Version lue depuis ../VERSION: ${VERSION}"
|
||||
fi
|
||||
|
||||
# Vérifier le format de version
|
||||
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
log_fatal "Format de version invalide: ${VERSION} (attendu: x.x.x)"
|
||||
fi
|
||||
|
||||
# Calculer le build number
|
||||
BUILD_NUMBER=$(echo $VERSION | tr -d '.')
|
||||
FULL_VERSION="${VERSION}+${BUILD_NUMBER}"
|
||||
|
||||
log_success "Version configurée"
|
||||
log_info " Version name: ${GREEN}${VERSION}${NC}"
|
||||
log_info " Build number: ${GREEN}${BUILD_NUMBER}${NC}"
|
||||
log_info " Full version: ${GREEN}${FULL_VERSION}${NC}"
|
||||
|
||||
# =====================================
|
||||
# Étape 2 : Mise à jour pubspec.yaml
|
||||
# =====================================
|
||||
|
||||
log_step "ÉTAPE 2/8 : Mise à jour pubspec.yaml"
|
||||
|
||||
# Backup du pubspec.yaml
|
||||
cp pubspec.yaml pubspec.yaml.backup
|
||||
|
||||
# Mise à jour de la version
|
||||
sed -i "s/^version: .*/version: $FULL_VERSION/" pubspec.yaml
|
||||
|
||||
# Vérifier la mise à jour
|
||||
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
|
||||
if [ "$UPDATED_VERSION" != "$FULL_VERSION" ]; then
|
||||
log_fatal "Échec de la mise à jour de pubspec.yaml (attendu: $FULL_VERSION, obtenu: $UPDATED_VERSION)"
|
||||
fi
|
||||
|
||||
log_success "pubspec.yaml mis à jour"
|
||||
|
||||
# =====================================
|
||||
# Étape 3 : Préparation du projet
|
||||
# =====================================
|
||||
|
||||
SKIP_BUILD=false
|
||||
if [[ "${2:-}" == "--skip-build" ]]; then
|
||||
SKIP_BUILD=true
|
||||
log_warning "Mode --skip-build activé, Flutter build sera ignoré"
|
||||
fi
|
||||
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
log_step "ÉTAPE 3/8 : Préparation du projet Flutter"
|
||||
|
||||
log_substep "Configuration du cache local"
|
||||
export PUB_CACHE="$PWD/.pub-cache-local"
|
||||
export GRADLE_USER_HOME="$PWD/.gradle-local"
|
||||
mkdir -p "$PUB_CACHE" "$GRADLE_USER_HOME"
|
||||
log_info " Cache Pub: $PUB_CACHE"
|
||||
log_info " Cache Gradle: $GRADLE_USER_HOME"
|
||||
|
||||
log_substep "Nettoyage du projet"
|
||||
safe_exec "flutter clean" "Échec du nettoyage Flutter"
|
||||
|
||||
log_substep "Récupération des dépendances"
|
||||
safe_exec "flutter pub get" "Échec de flutter pub get"
|
||||
|
||||
log_substep "Application du patch nfc_manager"
|
||||
safe_exec "./fastlane/scripts/commun/fix-nfc-manager.sh" "Échec du patch nfc_manager"
|
||||
|
||||
log_substep "Application du patch permission_handler (si nécessaire)"
|
||||
if [ -f "./fastlane/scripts/commun/fix-permission-handler.sh" ]; then
|
||||
safe_exec "./fastlane/scripts/commun/fix-permission-handler.sh" "Échec du patch permission_handler"
|
||||
fi
|
||||
|
||||
log_substep "Génération des fichiers Hive"
|
||||
safe_exec "dart run build_runner build --delete-conflicting-outputs" "Échec de la génération de code"
|
||||
|
||||
log_success "Projet préparé (dépendances + patchs + génération de code)"
|
||||
log_info " ⚠️ Build iOS sera fait sur le Mac mini via Fastlane"
|
||||
else
|
||||
log_step "ÉTAPE 3/8 : Préparation du projet (BUILD SKIPPED)"
|
||||
|
||||
log_substep "Configuration du cache local uniquement"
|
||||
export PUB_CACHE="$PWD/.pub-cache-local"
|
||||
export GRADLE_USER_HOME="$PWD/.gradle-local"
|
||||
|
||||
if [ ! -d "$PUB_CACHE" ]; then
|
||||
log_warning "Cache local introuvable, le build pourrait échouer sur le Mac mini"
|
||||
fi
|
||||
|
||||
log_success "Cache configuré (build Flutter ignoré)"
|
||||
fi
|
||||
|
||||
# =====================================
|
||||
# Étape 4 : Vérification de la connexion Mac mini
|
||||
# =====================================
|
||||
|
||||
log_step "ÉTAPE 4/8 : Connexion au Mac mini"
|
||||
|
||||
log_substep "Test de connexion SSH à ${MAC_MINI_HOST}"
|
||||
|
||||
if ! ssh "$MAC_MINI_HOST" "echo 'Connection OK'" >> "${LOG_FILE}" 2>&1; then
|
||||
log_fatal "Impossible de se connecter au Mac mini (${MAC_MINI_HOST})"
|
||||
fi
|
||||
|
||||
log_success "Connexion SSH établie"
|
||||
|
||||
# Vérifier l'environnement Mac
|
||||
log_substep "Vérification de l'environnement Mac"
|
||||
MAC_INFO=$(ssh "$MAC_MINI_HOST" "sw_vers -productVersion && xcodebuild -version | head -1 && flutter --version | head -1" 2>/dev/null || echo "N/A")
|
||||
log_info "$(echo "$MAC_INFO" | head -1 | xargs -I {} echo " macOS: {}")"
|
||||
log_info "$(echo "$MAC_INFO" | sed -n '2p' | xargs -I {} echo " Xcode: {}")"
|
||||
log_info "$(echo "$MAC_INFO" | sed -n '3p' | xargs -I {} echo " Flutter: {}")"
|
||||
|
||||
# =====================================
|
||||
# Étape 5 : Transfert vers Mac mini
|
||||
# =====================================
|
||||
|
||||
log_step "ÉTAPE 5/8 : Transfert du projet vers Mac mini"
|
||||
|
||||
DEST_DIR="${MAC_BASE_DIR}/app_${BUILD_NUMBER}"
|
||||
|
||||
log_substep "Création du dossier de destination: ${DEST_DIR}"
|
||||
ssh_exec "mkdir -p ${DEST_DIR}" "Impossible de créer le dossier ${DEST_DIR} sur le Mac mini"
|
||||
|
||||
log_substep "Transfert rsync (peut prendre 2-5 minutes)"
|
||||
TRANSFER_START=$(date +%s)
|
||||
|
||||
rsync -avz --progress \
|
||||
--exclude='build/' \
|
||||
--exclude='.dart_tool/' \
|
||||
--exclude='ios/Pods/' \
|
||||
--exclude='ios/.symlinks/' \
|
||||
--exclude='macos/Pods/' \
|
||||
--exclude='linux/flutter/ephemeral/' \
|
||||
--exclude='windows/flutter/ephemeral/' \
|
||||
--exclude='android/build/' \
|
||||
--exclude='*.aab' \
|
||||
--exclude='*.apk' \
|
||||
--exclude='logs/' \
|
||||
--exclude='*.log' \
|
||||
./ "${MAC_MINI_HOST}:${DEST_DIR}/" >> "${LOG_FILE}" 2>&1 || log_fatal "Échec du transfert rsync"
|
||||
|
||||
TRANSFER_TIME=$(($(date +%s) - TRANSFER_START))
|
||||
|
||||
log_success "Transfert terminé"
|
||||
log_info " Destination: ${DEST_DIR}"
|
||||
log_info " Durée: ${TRANSFER_TIME}s"
|
||||
|
||||
# =====================================
|
||||
# Étape 6 : Build et Archive avec Fastlane
|
||||
# =====================================
|
||||
|
||||
log_step "ÉTAPE 6/8 : Build et Archive iOS avec Fastlane"
|
||||
|
||||
log_info "Cette étape peut prendre 15-25 minutes"
|
||||
log_info "Fastlane va :"
|
||||
log_info " 1. Nettoyer les artefacts"
|
||||
log_info " 2. Installer les CocoaPods"
|
||||
log_info " 3. Analyser le code"
|
||||
log_info " 4. Build Flutter iOS"
|
||||
log_info " 5. Archive Xcode (gym)"
|
||||
log_info " 6. Export IPA"
|
||||
log_info ""
|
||||
log_substep "Lancement de: cd ${DEST_DIR} && fastlane ios build"
|
||||
|
||||
FASTLANE_START=$(date +%s)
|
||||
|
||||
# Créer un fichier temporaire pour capturer la sortie Fastlane
|
||||
FASTLANE_LOG="/tmp/fastlane-ios-${TIMESTAMP}.log"
|
||||
|
||||
# Exécuter Fastlane en temps réel avec affichage des étapes importantes
|
||||
ssh -t "$MAC_MINI_HOST" "cd ${DEST_DIR} && /opt/homebrew/bin/fastlane ios build" 2>&1 | tee -a "${LOG_FILE}" | tee "${FASTLANE_LOG}" | while IFS= read -r line; do
|
||||
# Afficher les lignes importantes
|
||||
if echo "$line" | grep -qE "(🧹|📦|🔧|🔍|🏗️|✓|✗|Error|error:|ERROR|Build succeeded|Build failed)"; then
|
||||
echo -e "${CYAN} ${line}${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Vérifier le code de retour de Fastlane
|
||||
FASTLANE_EXIT_CODE=${PIPESTATUS[0]}
|
||||
FASTLANE_TIME=$(($(date +%s) - FASTLANE_START))
|
||||
|
||||
if [ $FASTLANE_EXIT_CODE -ne 0 ]; then
|
||||
log_error "Fastlane a échoué (code: ${FASTLANE_EXIT_CODE})"
|
||||
log_error "Analyse des erreurs..."
|
||||
|
||||
# Extraire les erreurs du log Fastlane
|
||||
if [ -f "${FASTLANE_LOG}" ]; then
|
||||
log ""
|
||||
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
log "${RED}ERREURS DÉTECTÉES :${NC}"
|
||||
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
grep -i "error:\|Error:\|ERROR:\|❌\|✗" "${FASTLANE_LOG}" | head -20 | while IFS= read -r error_line; do
|
||||
log "${RED} ${error_line}${NC}"
|
||||
done
|
||||
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
fi
|
||||
|
||||
log_fatal "Build iOS échoué via Fastlane. Consultez ${FASTLANE_LOG} pour plus de détails."
|
||||
fi
|
||||
|
||||
log_success "Build et Archive iOS réussis"
|
||||
log_info " Durée totale Fastlane: ${FASTLANE_TIME}s ($((FASTLANE_TIME/60))m $((FASTLANE_TIME%60))s)"
|
||||
|
||||
# Vérifier que l'IPA existe
|
||||
log_substep "Vérification de l'IPA généré"
|
||||
IPA_EXISTS=$(ssh "$MAC_MINI_HOST" "test -f ${DEST_DIR}/build/ios/ipa/Runner.ipa && echo 'YES' || echo 'NO'")
|
||||
|
||||
if [ "$IPA_EXISTS" != "YES" ]; then
|
||||
log_fatal "IPA non trouvé dans ${DEST_DIR}/build/ios/ipa/Runner.ipa"
|
||||
fi
|
||||
|
||||
IPA_SIZE=$(ssh "$MAC_MINI_HOST" "du -h ${DEST_DIR}/build/ios/ipa/Runner.ipa | cut -f1")
|
||||
log_info " IPA trouvé: ${GREEN}${IPA_SIZE}${NC}"
|
||||
|
||||
# =====================================
|
||||
# Étape 7 : Upload vers TestFlight (optionnel)
|
||||
# =====================================
|
||||
|
||||
log_step "ÉTAPE 7/8 : Upload vers TestFlight"
|
||||
|
||||
log ""
|
||||
log_info "${YELLOW}Voulez-vous uploader l'IPA vers TestFlight maintenant ?${NC}"
|
||||
log_info " [Y] Oui - Upload automatique via fastlane ios upload"
|
||||
log_info " [N] Non - Je ferai l'upload manuellement plus tard"
|
||||
log ""
|
||||
|
||||
read -p "$(echo -e ${CYAN}Votre choix [Y/n]: ${NC})" -n 1 -r UPLOAD_CHOICE
|
||||
echo
|
||||
log ""
|
||||
|
||||
if [[ $UPLOAD_CHOICE =~ ^[Yy]$ ]] || [ -z "$UPLOAD_CHOICE" ]; then
|
||||
log_substep "Lancement de: fastlane ios upload"
|
||||
log_info "Upload vers TestFlight (peut prendre 5-10 minutes)"
|
||||
|
||||
UPLOAD_START=$(date +%s)
|
||||
|
||||
ssh -t "$MAC_MINI_HOST" "cd ${DEST_DIR} && /opt/homebrew/bin/fastlane ios upload" 2>&1 | tee -a "${LOG_FILE}"
|
||||
UPLOAD_EXIT_CODE=${PIPESTATUS[0]}
|
||||
UPLOAD_TIME=$(($(date +%s) - UPLOAD_START))
|
||||
|
||||
if [ $UPLOAD_EXIT_CODE -ne 0 ]; then
|
||||
log_error "Upload TestFlight échoué (code: ${UPLOAD_EXIT_CODE})"
|
||||
log_warning "L'IPA est disponible sur le Mac mini, vous pouvez réessayer manuellement"
|
||||
log_info " Commande: ssh $MAC_USER@$MAC_MINI_IP \"cd ${DEST_DIR} && fastlane ios upload\""
|
||||
else
|
||||
log_success "Upload TestFlight réussi"
|
||||
log_info " Durée: ${UPLOAD_TIME}s"
|
||||
log_info " URL: ${CYAN}https://appstoreconnect.apple.com${NC}"
|
||||
fi
|
||||
else
|
||||
log_info "Upload ignoré. Pour uploader manuellement plus tard :"
|
||||
log_info " ${CYAN}ssh $MAC_MINI_HOST \"cd ${DEST_DIR} && /opt/homebrew/bin/fastlane ios upload\"${NC}"
|
||||
fi
|
||||
|
||||
# =====================================
|
||||
# Étape 8 : Nettoyage et archivage
|
||||
# =====================================
|
||||
|
||||
log_step "ÉTAPE 8/8 : Nettoyage et archivage"
|
||||
|
||||
log_substep "Voulez-vous archiver le dossier de build ?"
|
||||
log_info " [Y] Oui - Créer une archive ${DEST_DIR}.tar.gz"
|
||||
log_info " [N] Non - Garder le dossier tel quel (défaut)"
|
||||
log ""
|
||||
|
||||
read -p "$(echo -e ${CYAN}Votre choix [y/N]: ${NC})" -n 1 -r ARCHIVE_CHOICE
|
||||
echo
|
||||
log ""
|
||||
|
||||
if [[ $ARCHIVE_CHOICE =~ ^[Yy]$ ]]; then
|
||||
log_substep "Création de l'archive..."
|
||||
ssh_exec "cd ${MAC_BASE_DIR} && tar -czf app_${BUILD_NUMBER}.tar.gz app_${BUILD_NUMBER}" \
|
||||
"Échec de la création de l'archive"
|
||||
|
||||
ARCHIVE_SIZE=$(ssh "$MAC_MINI_HOST" "du -h ${MAC_BASE_DIR}/app_${BUILD_NUMBER}.tar.gz | cut -f1")
|
||||
log_success "Archive créée"
|
||||
log_info " Archive: ${MAC_BASE_DIR}/app_${BUILD_NUMBER}.tar.gz (${ARCHIVE_SIZE})"
|
||||
|
||||
log_substep "Suppression du dossier de build"
|
||||
ssh_exec "rm -rf ${DEST_DIR}" "Échec de la suppression du dossier"
|
||||
log_success "Dossier de build supprimé"
|
||||
else
|
||||
log_info "Dossier conservé: ${DEST_DIR}"
|
||||
fi
|
||||
|
||||
# Restaurer le pubspec.yaml original (optionnel)
|
||||
log_substep "Restauration de pubspec.yaml local"
|
||||
mv pubspec.yaml.backup pubspec.yaml
|
||||
log_info " pubspec.yaml local restauré à son état initial"
|
||||
|
||||
# =====================================
|
||||
# Rapport final
|
||||
# =====================================
|
||||
|
||||
TOTAL_TIME=$(($(date +%s) - TOTAL_START_TIME))
|
||||
TOTAL_MINUTES=$((TOTAL_TIME / 60))
|
||||
TOTAL_SECONDS=$((TOTAL_TIME % 60))
|
||||
|
||||
log ""
|
||||
log "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
|
||||
log "${GREEN}║ ║${NC}"
|
||||
log "${GREEN}║ ✓ DÉPLOIEMENT iOS TERMINÉ AVEC SUCCÈS ║${NC}"
|
||||
log "${GREEN}║ ║${NC}"
|
||||
log "${GREEN}╚════════════════════════════════════════════════════════╝${NC}"
|
||||
log ""
|
||||
log "${CYAN}📊 RAPPORT DE DÉPLOIEMENT${NC}"
|
||||
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
log ""
|
||||
log " ${BLUE}Version déployée:${NC} ${GREEN}${VERSION} (Build ${BUILD_NUMBER})${NC}"
|
||||
log " ${BLUE}Destination:${NC} ${DEST_DIR}"
|
||||
log " ${BLUE}IPA généré:${NC} ${GREEN}${IPA_SIZE}${NC}"
|
||||
log " ${BLUE}Durée totale:${NC} ${GREEN}${TOTAL_MINUTES}m ${TOTAL_SECONDS}s${NC}"
|
||||
log ""
|
||||
if [ $WARNINGS_COUNT -gt 0 ]; then
|
||||
log " ${YELLOW}⚠ Avertissements:${NC} ${WARNINGS_COUNT}"
|
||||
fi
|
||||
if [ $ERRORS_COUNT -gt 0 ]; then
|
||||
log " ${RED}✗ Erreurs:${NC} ${ERRORS_COUNT}"
|
||||
fi
|
||||
log ""
|
||||
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
log ""
|
||||
log "${BLUE}📱 PROCHAINES ÉTAPES${NC}"
|
||||
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
log ""
|
||||
|
||||
if [[ $UPLOAD_CHOICE =~ ^[Yy]$ ]] || [ -z "$UPLOAD_CHOICE" ]; then
|
||||
if [ $UPLOAD_EXIT_CODE -eq 0 ]; then
|
||||
log " 1. ${GREEN}✓${NC} IPA uploadé sur TestFlight"
|
||||
log " 2. Accéder à App Store Connect:"
|
||||
log " ${CYAN}https://appstoreconnect.apple.com${NC}"
|
||||
log " 3. Attendre le traitement Apple (5-15 min)"
|
||||
log " 4. Configurer la conformité export si demandée"
|
||||
log " 5. Ajouter des testeurs internes"
|
||||
log " 6. Installer via TestFlight sur iPhone"
|
||||
else
|
||||
log " 1. ${YELLOW}⚠${NC} Upload TestFlight a échoué"
|
||||
log " 2. Réessayer manuellement:"
|
||||
log " ${CYAN}ssh $MAC_USER@$MAC_MINI_IP \"cd ${DEST_DIR} && fastlane ios upload\"${NC}"
|
||||
fi
|
||||
else
|
||||
log " 1. L'IPA est prêt sur le Mac mini"
|
||||
log " 2. Pour uploader vers TestFlight:"
|
||||
log " ${CYAN}ssh $MAC_USER@$MAC_MINI_IP \"cd ${DEST_DIR} && fastlane ios upload\"${NC}"
|
||||
log " 3. Ou distribuer l'IPA manuellement via Xcode"
|
||||
fi
|
||||
|
||||
log ""
|
||||
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
log ""
|
||||
log " ${BLUE}Log complet:${NC} ${LOG_FILE}"
|
||||
log " ${BLUE}Fin:${NC} $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
log ""
|
||||
|
||||
# Nettoyer le log Fastlane temporaire
|
||||
rm -f "${FASTLANE_LOG}"
|
||||
|
||||
exit 0
|
||||
@@ -278,55 +278,102 @@ Log::info('Stripe account activated', [
|
||||
|
||||
## 📱 FLOW TAP TO PAY (Application Flutter)
|
||||
|
||||
### 🎯 Architecture technique
|
||||
|
||||
Le flow Tap to Pay repose sur trois composants principaux :
|
||||
|
||||
1. **DeviceInfoService** - Vérification de compatibilité hardware (Android SDK ≥ 28, NFC disponible)
|
||||
2. **StripeTapToPayService** - Gestion du SDK Stripe Terminal et des paiements
|
||||
3. **Backend API** - Endpoints PHP pour les tokens de connexion et PaymentIntents
|
||||
|
||||
### 🔄 Diagramme de séquence complet
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌──────────┐ ┌─────────┐
|
||||
│ App Flutter │ │ API PHP │ │ Stripe │ │ Carte │
|
||||
└──────┬──────┘ └──────┬──────┘ └────┬─────┘ └────┬────┘
|
||||
│ │ │ │
|
||||
[1] │ Validation form │ │ │
|
||||
│ + montant CB │ │ │
|
||||
│ │ │ │
|
||||
[2] │ POST/PUT passage │ │ │
|
||||
│──────────────────>│ │ │
|
||||
│ │ │ │
|
||||
[3] │<──────────────────│ │ │
|
||||
│ Passage ID: 456 │ │ │
|
||||
│ │ │ │
|
||||
[4] │ POST create-intent│ │ │
|
||||
│──────────────────>│ (avec passage_id: 456) │
|
||||
│ │ │ │
|
||||
[5] │ │ Create PaymentIntent │
|
||||
│ │─────────────────>│ │
|
||||
│ │ │ │
|
||||
[6] │ │<─────────────────│ │
|
||||
│ │ pi_xxx + secret │ │
|
||||
│ │ │ │
|
||||
[7] │<──────────────────│ │ │
|
||||
│ PaymentIntent ID │ │ │
|
||||
│ │ │ │
|
||||
[8] │ SDK Terminal Init │ │ │
|
||||
│ "Approchez carte" │ │ │
|
||||
│ │ │ │
|
||||
[9] │<──────────────────────────────────────────────────────│
|
||||
│ NFC : Lecture carte sans contact │
|
||||
│ │ │ │
|
||||
[10] │ Process Payment │ │ │
|
||||
│───────────────────────────────────>│ │
|
||||
│ │ │ │
|
||||
[11] │<───────────────────────────────────│ │
|
||||
│ Payment Success │ │
|
||||
│ │ │ │
|
||||
[12] │ POST confirm │ │ │
|
||||
│──────────────────>│ │ │
|
||||
│ │ │ │
|
||||
[13] │ PUT passage/456 │ │ │
|
||||
│──────────────────>│ (ajout stripe_payment_id) │
|
||||
│ │ │ │
|
||||
[14] │<──────────────────│ │ │
|
||||
│ Passage updated │ │ │
|
||||
│ │ │ │
|
||||
┌─────────────┐ ┌─────────────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐
|
||||
│ App Flutter │ │ DeviceInfo │ │ API │ │ Stripe │ │ SDK Terminal │
|
||||
│ │ │ Service │ │ PHP │ │ │ │ │
|
||||
└──────┬──────┘ └────────┬────────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘
|
||||
│ │ │ │ │
|
||||
[1] │ Login utilisateur │ │ │ │
|
||||
│────────────────────>│ │ │ │
|
||||
│ │ │ │ │
|
||||
[2] │ │ checkStripeCertification() │ │
|
||||
│ │ • Android SDK ≥ 28 │ │
|
||||
│ │ • NFC disponible │ │
|
||||
│ │ │ │ │
|
||||
[3] │<────────────────────│ │ │ │
|
||||
│ ✅ Compatible │ │ │ │
|
||||
│ │ │ │ │
|
||||
[4] │ Validation form │ │ │ │
|
||||
│ + montant CB │ │ │ │
|
||||
│ │ │ │ │
|
||||
[5] │ POST/PUT passage │ │ │ │
|
||||
│────────────────────────────────────────>│ │ │
|
||||
│ │ │ │ │
|
||||
[6] │<────────────────────────────────────────│ │ │
|
||||
│ Passage ID: 456 │ │ │ │
|
||||
│ │ │ │ │
|
||||
[7] │ initialize() │ │ │ │
|
||||
│────────────────────────────────────────────────────────────────────────────>│
|
||||
│ │ │ │ │
|
||||
[8] │ │ │ │ Terminal.initTerminal()
|
||||
│ │ │ │ │ (fetchToken callback)
|
||||
│ │ │ │ │
|
||||
[9] │ │ │ POST /terminal/connection-token │
|
||||
│────────────────────────────────────────>│ │ │
|
||||
│ {amicale_id, stripe_account, location_id} │ │
|
||||
│ │ │ │ │
|
||||
[10] │ │ │ CreateConnectionToken │
|
||||
│ │ │───────────────>│ │
|
||||
│ │ │ │ │
|
||||
[11] │ │ │<───────────────│ │
|
||||
│ │ │ {secret: "..."}│ │
|
||||
│ │ │ │ │
|
||||
[12] │<────────────────────────────────────────│ │ │
|
||||
│ Connection Token │ │ │ │
|
||||
│ │ │ │ │
|
||||
[13] │────────────────────────────────────────────────────────────────────────────>│
|
||||
│ Token delivered to SDK │ │ ✅ SDK Ready │
|
||||
│ │ │ │ │
|
||||
[14] │ createPaymentIntent() │ │ │
|
||||
│────────────────────────────────────────>│ │ │
|
||||
│ {amount, passage_id, amicale_id} │ │ │
|
||||
│ │ │ │ │
|
||||
[15] │ │ │ Create PaymentIntent │
|
||||
│ │ │───────────────>│ │
|
||||
│ │ │ │ │
|
||||
[16] │ │ │<───────────────│ │
|
||||
│ │ │ pi_xxx + secret│ │
|
||||
│ │ │ │ │
|
||||
[17] │<────────────────────────────────────────│ │ │
|
||||
│ PaymentIntent ID │ │ │ │
|
||||
│ │ │ │ │
|
||||
[18] │ collectPayment() │ │ │ │
|
||||
│────────────────────────────────────────────────────────────────────────────>│
|
||||
│ │ │ │ │
|
||||
[19] │ │ │ │ discoverReaders()
|
||||
│ │ │ │ + connectReader()
|
||||
│ │ │ │ │
|
||||
[20] │ │ │ │ collectPaymentMethod()
|
||||
│ 💳 "Présentez la carte" │ │ ⏳ Attente NFC │
|
||||
│ │ │ │ │
|
||||
[21] │<──────────────────────────────────────────────────────────── 📱 TAP CARTE │
|
||||
│ │ │ │ │
|
||||
[22] │ confirmPayment() │ │ │ │
|
||||
│────────────────────────────────────────────────────────────────────────────>│
|
||||
│ │ │ │ │
|
||||
[23] │ │ │ │ confirmPaymentIntent()
|
||||
│ │ │ │ │
|
||||
[24] │ │ │ │ ✅ Succeeded │
|
||||
│<────────────────────────────────────────────────────────────────────────────│
|
||||
│ Payment Success │ │ │ │
|
||||
│ │ │ │ │
|
||||
[25] │ PUT passage/456 │ │ │ │
|
||||
│────────────────────────────────────────>│ │ │
|
||||
│ {stripe_payment_id: "pi_xxx"} │ │ │
|
||||
│ │ │ │ │
|
||||
[26] │<────────────────────────────────────────│ │ │
|
||||
│ ✅ Passage updated │ │ │ │
|
||||
```
|
||||
|
||||
### 🎮 Gestion du Terminal de Paiement
|
||||
@@ -378,6 +425,59 @@ Le terminal de paiement reste affiché jusqu'à la réponse définitive de Strip
|
||||
- **Persistence** : Le terminal reste ouvert jusqu'à réponse définitive de Stripe
|
||||
- **Gestion d'erreur** : Possibilité de réessayer sans perdre le contexte
|
||||
|
||||
### 🔑 Connection Token - Flow détaillé
|
||||
|
||||
Le Connection Token est crucial pour le SDK Stripe Terminal. Il est récupéré via un callback au moment de l'initialisation.
|
||||
|
||||
**Code côté App (stripe_tap_to_pay_service.dart:87-89) :**
|
||||
```dart
|
||||
await Terminal.initTerminal(
|
||||
fetchToken: _fetchConnectionToken, // Callback appelé automatiquement
|
||||
);
|
||||
```
|
||||
|
||||
**Callback de récupération (lignes 137-161) :**
|
||||
```dart
|
||||
Future<String> _fetchConnectionToken() async {
|
||||
debugPrint('🔑 Récupération du token de connexion Stripe...');
|
||||
|
||||
final response = await ApiService.instance.post(
|
||||
'/stripe/terminal/connection-token',
|
||||
data: {
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'stripe_account': _stripeAccountId,
|
||||
'location_id': _locationId,
|
||||
}
|
||||
);
|
||||
|
||||
final token = response.data['secret'];
|
||||
if (token == null || token.isEmpty) {
|
||||
throw Exception('Token de connexion invalide');
|
||||
}
|
||||
|
||||
debugPrint('✅ Token de connexion récupéré');
|
||||
return token;
|
||||
}
|
||||
```
|
||||
|
||||
**Backend PHP :**
|
||||
```php
|
||||
// POST /stripe/terminal/connection-token
|
||||
$token = \Stripe\Terminal\ConnectionToken::create([], [
|
||||
'stripe_account' => $amicale->stripe_id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'secret' => $token->secret,
|
||||
]);
|
||||
```
|
||||
|
||||
**Points importants :**
|
||||
- ✅ Le token est **temporaire** (valide quelques minutes)
|
||||
- ✅ Un nouveau token est créé à **chaque initialisation** du SDK
|
||||
- ✅ Le token est spécifique au **compte Stripe Connect** de l'amicale
|
||||
- ✅ Utilisé pour **authentifier** le Terminal SDK auprès de Stripe
|
||||
|
||||
### 📋 Détail des étapes
|
||||
|
||||
#### Étape 1 : VALIDATION DU FORMULAIRE
|
||||
@@ -1085,28 +1185,168 @@ POST/PUT /api/passages
|
||||
|
||||
## 🔄 GESTION DES ERREURS
|
||||
|
||||
### 📱 Erreurs Tap to Pay
|
||||
### 📱 Erreurs Tap to Pay - Messages utilisateur clairs
|
||||
|
||||
| Code erreur | Description | Action utilisateur |
|
||||
|-------------|-------------|-------------------|
|
||||
| `device_not_compatible` | iPhone non compatible | Afficher message explicatif |
|
||||
| `nfc_disabled` | NFC désactivé | Demander activation dans réglages |
|
||||
| `card_declined` | Carte refusée | Essayer autre carte |
|
||||
| `insufficient_funds` | Solde insuffisant | Essayer autre carte |
|
||||
| `network_error` | Erreur réseau | Réessayer ou mode offline |
|
||||
| `timeout` | Timeout lecture carte | Rapprocher carte et réessayer |
|
||||
L'application détecte automatiquement le type d'erreur et affiche un message adapté :
|
||||
|
||||
### 🔄 Flow de retry
|
||||
#### Gestion intelligente des erreurs (passage_form_dialog.dart)
|
||||
|
||||
```dart
|
||||
catch (e) {
|
||||
final errorMsg = e.toString().toLowerCase();
|
||||
String userMessage;
|
||||
bool shouldCancelPayment = true;
|
||||
|
||||
if (errorMsg.contains('canceled') || errorMsg.contains('cancelled')) {
|
||||
// Annulation volontaire
|
||||
userMessage = 'Paiement annulé';
|
||||
|
||||
} else if (errorMsg.contains('cardreadtimedout') || errorMsg.contains('timed out')) {
|
||||
// Timeout NFC avec conseils
|
||||
userMessage = 'Lecture de la carte impossible.\n\n'
|
||||
'Conseils :\n'
|
||||
'• Maintenez la carte contre le dos du téléphone\n'
|
||||
'• Ne bougez pas jusqu\'à confirmation\n'
|
||||
'• Retirez la coque si nécessaire\n'
|
||||
'• Essayez différentes positions sur le téléphone';
|
||||
|
||||
} else if (errorMsg.contains('already') && errorMsg.contains('payment')) {
|
||||
// PaymentIntent existe déjà
|
||||
userMessage = 'Un paiement est déjà en cours pour ce passage.\n'
|
||||
'Veuillez réessayer dans quelques instants.';
|
||||
shouldCancelPayment = false;
|
||||
}
|
||||
|
||||
// Annulation automatique du PaymentIntent pour permettre nouvelle tentative
|
||||
if (shouldCancelPayment && _paymentIntentId != null) {
|
||||
await StripeTapToPayService.instance.cancelPayment(_paymentIntentId!);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Table des erreurs et actions
|
||||
|
||||
| Type erreur | Message utilisateur | Action automatique |
|
||||
|-------------|--------------------|--------------------|
|
||||
| `canceled` / `cancelled` | "Paiement annulé" | Annulation PaymentIntent ✅ |
|
||||
| `cardReadTimedOut` | Message avec 4 conseils NFC | Annulation PaymentIntent ✅ |
|
||||
| `already payment` | "Paiement déjà en cours" | Pas d'annulation ⏳ |
|
||||
| `device_not_compatible` | "Appareil non compatible" | Annulation PaymentIntent ✅ |
|
||||
| `nfc_disabled` | "NFC désactivé" | Annulation PaymentIntent ✅ |
|
||||
| Autre erreur | Message technique complet | Annulation PaymentIntent ✅ |
|
||||
|
||||
### ⚠️ Contraintes NFC - Tap to Pay vs Google Pay
|
||||
|
||||
**Différence fondamentale :**
|
||||
- **Google Pay (émission)** : Le téléphone *émet* un signal NFC puissant → fonctionne avec coque
|
||||
- **Tap to Pay (réception)** : Le téléphone *lit* le signal de la carte → très sensible aux interférences
|
||||
|
||||
#### Coques problématiques
|
||||
- ❌ **Kevlar / Carbone** : Fibres conductrices perturbent la réception NFC
|
||||
- ❌ **Métal** : Bloque complètement les ondes NFC
|
||||
- ❌ **Coque épaisse** : Réduit la portée effective
|
||||
- ✅ **TPU / Silicone** : Compatible
|
||||
|
||||
#### Bonnes pratiques pour réussite NFC
|
||||
|
||||
**Position optimale :**
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 📱 Téléphone │
|
||||
│ │
|
||||
│ [Capteur NFC]│ ← Généralement vers le haut du dos
|
||||
│ │
|
||||
│ │
|
||||
│ 💳 Carte ici │ ← Maintenir immobile 3-5 secondes
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**Checklist utilisateur :**
|
||||
1. ✅ Retirer la coque si échec
|
||||
2. ✅ Carte à plat contre le dos du téléphone
|
||||
3. ✅ Ne pas bouger pendant toute la lecture
|
||||
4. ✅ Essayer différentes positions (haut/milieu du téléphone)
|
||||
5. ✅ Carte sans contact activée (logo sans contact visible)
|
||||
|
||||
### 🔄 Flow de retry automatique
|
||||
|
||||
```
|
||||
1. Erreur détectée
|
||||
2. Message utilisateur explicite
|
||||
3. Option "Réessayer" proposée
|
||||
4. Conservation du montant et contexte
|
||||
5. Nouveau PaymentIntent si nécessaire
|
||||
6. Maximum 3 tentatives
|
||||
1. Erreur détectée → Analyse du type
|
||||
2. Annulation automatique PaymentIntent (si applicable)
|
||||
3. Message clair avec conseils contextuels
|
||||
4. Bouton "Réessayer" disponible
|
||||
5. Nouveau PaymentIntent créé automatiquement
|
||||
6. Conservation du contexte (montant, passage)
|
||||
```
|
||||
|
||||
**Avantages :**
|
||||
- ✅ Pas de blocage "PaymentIntent déjà existant"
|
||||
- ✅ Nombre illimité de tentatives
|
||||
- ✅ Contexte préservé (pas besoin de tout ressaisir)
|
||||
- ✅ Messages orientés solution plutôt qu'erreur technique
|
||||
|
||||
### 🏗️ Environnement et Build Release
|
||||
|
||||
#### Détection automatique de l'environnement
|
||||
|
||||
L'application détecte l'environnement via l'URL de l'API (plus fiable que `kDebugMode`) :
|
||||
|
||||
```dart
|
||||
// stripe_tap_to_pay_service.dart (lignes 236-252)
|
||||
Future<bool> _ensureReaderConnected() async {
|
||||
// Détection via URL API
|
||||
final apiUrl = ApiService.instance.baseUrl;
|
||||
final isProduction = apiUrl.contains('app3.geosector.fr');
|
||||
final isSimulated = !isProduction;
|
||||
|
||||
final config = TapToPayDiscoveryConfiguration(
|
||||
isSimulated: isSimulated,
|
||||
);
|
||||
|
||||
debugPrint('🔧 Environnement: ${isProduction ? "PRODUCTION (réel)" : "DEV/REC (simulé)"}');
|
||||
debugPrint('🔧 isSimulated: $isSimulated');
|
||||
}
|
||||
```
|
||||
|
||||
**Mapping environnement :**
|
||||
| URL API | Environnement | Reader Stripe | Cartes acceptées |
|
||||
|---------|---------------|---------------|------------------|
|
||||
| `dapp.geosector.fr` | DEV | Simulé | Cartes test uniquement |
|
||||
| `rapp.geosector.fr` | REC | Simulé | Cartes test uniquement |
|
||||
| `app3.geosector.fr` | PROD | Réel | Cartes réelles uniquement |
|
||||
|
||||
#### ⚠️ Restriction Stripe - Build Release obligatoire en PROD
|
||||
|
||||
**Erreur si app debuggable en PROD :**
|
||||
```
|
||||
Debuggable applications are not supported when using the production
|
||||
version of the Tap to Pay reader. Please use a simulated version of
|
||||
the reader by setting TapToPayDiscoveryConfiguration.isSimulated to true.
|
||||
```
|
||||
|
||||
**Solution - Build release :**
|
||||
```bash
|
||||
# Build APK optimisé pour production
|
||||
flutter build apk --release
|
||||
|
||||
# Installation sur device
|
||||
adb install build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
**Différences debug vs release :**
|
||||
| Aspect | Debug (`flutter run`) | Release (`flutter build`) |
|
||||
|--------|-----------------------|--------------------|
|
||||
| **Optimisation** | ❌ Code non optimisé | ✅ R8/ProGuard activé |
|
||||
| **Taille APK** | ~200 MB | ~30-50 MB |
|
||||
| **Performance** | Lente (dev mode) | Rapide (optimisée) |
|
||||
| **Tap to Pay PROD** | ❌ Refusé par Stripe | ✅ Accepté |
|
||||
| **Débogage** | ✅ Hot reload, logs | ❌ Pas de hot reload |
|
||||
|
||||
**Pourquoi Stripe refuse les apps debug :**
|
||||
- **Sécurité renforcée** : Les apps debuggables peuvent être inspectées
|
||||
- **Conformité PCI-DSS** : Exigences de sécurité pour paiements réels
|
||||
- **Protection production** : Éviter utilisation accidentelle de readers réels en dev
|
||||
|
||||
---
|
||||
|
||||
## 📊 MONITORING ET LOGS
|
||||
|
||||
216
app/docs/connexions-api.md
Normal file
216
app/docs/connexions-api.md
Normal file
@@ -0,0 +1,216 @@
|
||||
API Event Stats - Guide Flutter
|
||||
|
||||
Authentification
|
||||
|
||||
Toutes les routes nécessitent un Bearer Token (session) et un rôle Admin (2) ou Super-admin (1).
|
||||
|
||||
headers: {
|
||||
'Authorization': 'Bearer $sessionToken',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
1. Résumé du jour
|
||||
|
||||
GET /api/events/stats/summary?date=2025-12-22
|
||||
|
||||
| Param | Type | Requis | Description |
|
||||
| ----- | ------ | ------ | ------------------------------------- |
|
||||
| date | string | Non | Date YYYY-MM-DD (défaut: aujourd'hui) |
|
||||
|
||||
Réponse (~1 KB) :
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"date": "2025-12-22",
|
||||
"stats": {
|
||||
"auth": { "success": 45, "failed": 3, "logout": 12 },
|
||||
"passages": { "created": 128, "updated": 5, "deleted": 2, "amount": 2450.00 },
|
||||
"users": { "created": 2, "updated": 5, "deleted": 0 },
|
||||
"sectors": { "created": 1, "updated": 3, "deleted": 0 },
|
||||
"stripe": { "created": 15, "success": 12, "failed": 1, "cancelled": 2, "amount": 890.00 }
|
||||
},
|
||||
"totals": { "events": 245, "unique_users": 18 }
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
2. Stats journalières (graphiques)
|
||||
|
||||
GET /api/events/stats/daily?from=2025-12-01&to=2025-12-22&events=passage_created,login_success
|
||||
|
||||
| Param | Type | Requis | Description |
|
||||
| ------ | ------ | ------ | ------------------------------- |
|
||||
| from | string | Oui | Date début YYYY-MM-DD |
|
||||
| to | string | Oui | Date fin YYYY-MM-DD |
|
||||
| events | string | Non | Types filtrés (comma-separated) |
|
||||
|
||||
Limite : 90 jours max
|
||||
|
||||
Réponse (~5 KB) :
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"from": "2025-12-01",
|
||||
"to": "2025-12-22",
|
||||
"days": [
|
||||
{
|
||||
"date": "2025-12-01",
|
||||
"events": {
|
||||
"passage_created": { "count": 45, "sum_amount": 850.00, "unique_users": 8 },
|
||||
"login_success": { "count": 12, "sum_amount": 0, "unique_users": 12 }
|
||||
},
|
||||
"totals": { "count": 57, "sum_amount": 850.00 }
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
3. Stats hebdomadaires
|
||||
|
||||
GET /api/events/stats/weekly?from=2025-10-01&to=2025-12-22
|
||||
|
||||
| Param | Type | Requis | Description |
|
||||
| ------ | ------ | ------ | ------------- |
|
||||
| from | string | Oui | Date début |
|
||||
| to | string | Oui | Date fin |
|
||||
| events | string | Non | Types filtrés |
|
||||
|
||||
Limite : 365 jours max
|
||||
|
||||
Réponse (~2 KB) :
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"from": "2025-10-01",
|
||||
"to": "2025-12-22",
|
||||
"weeks": [
|
||||
{
|
||||
"week_start": "2025-12-16",
|
||||
"week_number": 51,
|
||||
"year": 2025,
|
||||
"events": {
|
||||
"passage_created": { "count": 320, "sum_amount": 5200.00, "unique_users": 15 }
|
||||
},
|
||||
"totals": { "count": 450, "sum_amount": 5200.00 }
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
4. Stats mensuelles
|
||||
|
||||
GET /api/events/stats/monthly?year=2025&events=passage_created,stripe_payment_success
|
||||
|
||||
| Param | Type | Requis | Description |
|
||||
| ------ | ------ | ------ | ------------------------------ |
|
||||
| year | int | Non | Année (défaut: année courante) |
|
||||
| events | string | Non | Types filtrés |
|
||||
|
||||
Réponse (~1 KB) :
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"year": 2025,
|
||||
"months": [
|
||||
{
|
||||
"month": "2025-01",
|
||||
"year": 2025,
|
||||
"month_number": 1,
|
||||
"events": {
|
||||
"passage_created": { "count": 1250, "sum_amount": 18500.00, "unique_users": 25 }
|
||||
},
|
||||
"totals": { "count": 1800, "sum_amount": 18500.00 }
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
5. Détail des événements (drill-down)
|
||||
|
||||
GET /api/events/stats/details?date=2025-12-22&event=login_failed&limit=50&offset=0
|
||||
|
||||
| Param | Type | Requis | Description |
|
||||
| ------ | ------ | ------ | ------------------------------------ |
|
||||
| date | string | Oui | Date YYYY-MM-DD |
|
||||
| event | string | Non | Filtrer par type |
|
||||
| limit | int | Non | Max résultats (défaut: 50, max: 100) |
|
||||
| offset | int | Non | Pagination (défaut: 0) |
|
||||
|
||||
Réponse (~10 KB) :
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"date": "2025-12-22",
|
||||
"events": [
|
||||
{
|
||||
"timestamp": "2025-12-22T08:15:32Z",
|
||||
"event": "login_failed",
|
||||
"username": "jean.dupont",
|
||||
"reason": "invalid_password",
|
||||
"attempt": 2,
|
||||
"ip": "192.168.x.x",
|
||||
"platform": "ios",
|
||||
"app_version": "3.5.2"
|
||||
},
|
||||
...
|
||||
],
|
||||
"pagination": {
|
||||
"total": 3,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"has_more": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
6. Types d'événements disponibles
|
||||
|
||||
GET /api/events/stats/types
|
||||
|
||||
Réponse :
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"auth": ["login_success", "login_failed", "logout"],
|
||||
"passages": ["passage_created", "passage_updated", "passage_deleted"],
|
||||
"sectors": ["sector_created", "sector_updated", "sector_deleted"],
|
||||
"users": ["user_created", "user_updated", "user_deleted"],
|
||||
"entities": ["entity_created", "entity_updated", "entity_deleted"],
|
||||
"operations": ["operation_created", "operation_updated", "operation_deleted"],
|
||||
"stripe": ["stripe_payment_created", "stripe_payment_success", "stripe_payment_failed", "stripe_payment_cancelled", "stripe_terminal_error"]
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
Codes d'erreur
|
||||
|
||||
| Code | Signification |
|
||||
| ---- | -------------------------------------------- |
|
||||
| 200 | Succès |
|
||||
| 400 | Paramètre invalide (date, plage trop grande) |
|
||||
| 403 | Accès refusé (rôle insuffisant) |
|
||||
| 500 | Erreur serveur |
|
||||
|
||||
---
|
||||
|
||||
Super-admin uniquement
|
||||
|
||||
Le super-admin peut ajouter ?entity_id=X pour filtrer par entité :
|
||||
GET /api/events/stats/summary?date=2025-12-22&entity_id=5
|
||||
|
||||
Sans ce paramètre, il voit les stats globales de toutes les entités.
|
||||
@@ -12,6 +12,9 @@
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
# Configure PATH pour Homebrew (M1/M2/M4 Mac)
|
||||
ENV['PATH'] = "/opt/homebrew/bin:#{ENV['PATH']}"
|
||||
|
||||
# =============================================================================
|
||||
# ANDROID
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/lib" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.idea" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/.dart_tool" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/.pub" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/example/.dart_tool" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/example/.pub" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/example/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/.dart_tool" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/.pub" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/example/.dart_tool" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/example/.pub" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/example/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/.dart_tool" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/.pub" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/example/.dart_tool" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/example/.pub" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/example/build" />
|
||||
</content>
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||
<orderEntry type="library" name="Flutter Plugins" level="project" />
|
||||
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -33,9 +33,21 @@ fi
|
||||
|
||||
# Récupérer la version depuis pubspec.yaml
|
||||
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
|
||||
VERSION_NUMBER=$(echo $VERSION | cut -d'+' -f1)
|
||||
VERSION_CODE=$(echo $VERSION | cut -d'+' -f2)
|
||||
|
||||
echo -e "${YELLOW}📦 Version détectée :${NC} $VERSION"
|
||||
echo -e "${YELLOW} Version name :${NC} $VERSION_NUMBER"
|
||||
echo -e "${YELLOW} Build number :${NC} $VERSION_CODE"
|
||||
echo ""
|
||||
|
||||
# Vérifier que la version est bien synchronisée depuis transfer-to-mac.sh
|
||||
if [ -z "$VERSION_CODE" ]; then
|
||||
echo -e "${RED}⚠️ Avertissement: Version code introuvable${NC}"
|
||||
echo -e "${YELLOW}Assurez-vous d'avoir utilisé transfer-to-mac.sh pour synchroniser la version${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Étape 1 : Clean
|
||||
echo -e "${YELLOW}🧹 Étape 1/5 : Nettoyage du projet...${NC}"
|
||||
flutter clean
|
||||
@@ -50,6 +62,12 @@ echo ""
|
||||
|
||||
# Étape 3 : Pod install
|
||||
echo -e "${YELLOW}🔧 Étape 3/5 : Installation des CocoaPods...${NC}"
|
||||
|
||||
# Configurer l'environnement Ruby Homebrew
|
||||
export PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/bin:$PATH"
|
||||
export GEM_HOME="/opt/homebrew/lib/ruby/gems/3.4.0"
|
||||
echo -e "${BLUE}ℹ Environnement Ruby Homebrew configuré${NC}"
|
||||
|
||||
cd ios
|
||||
rm -rf Pods Podfile.lock
|
||||
pod install --repo-update
|
||||
@@ -57,10 +75,29 @@ cd ..
|
||||
echo -e "${GREEN}✓ CocoaPods installés${NC}"
|
||||
echo ""
|
||||
|
||||
# Étape 4 : Build iOS Release
|
||||
echo -e "${YELLOW}🏗️ Étape 4/5 : Compilation iOS en mode release...${NC}"
|
||||
flutter build ios --release
|
||||
echo -e "${GREEN}✓ Compilation terminée${NC}"
|
||||
# Étape 4 : Build iOS
|
||||
echo -e "${YELLOW}🏗️ Étape 4/5 : Choix du mode de compilation...${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Quel mode de compilation souhaitez-vous utiliser ?${NC}"
|
||||
echo -e " ${GREEN}[D]${NC} Debug - Pour tester Stripe Tap to Pay (défaut)"
|
||||
echo -e " ${YELLOW}[R]${NC} Release - Pour distribution App Store"
|
||||
echo ""
|
||||
read -p "Votre choix (D/R) [défaut: D] : " -n 1 -r BUILD_MODE
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Définir le mode de build
|
||||
if [[ $BUILD_MODE =~ ^[Rr]$ ]]; then
|
||||
BUILD_FLAG="--release"
|
||||
BUILD_MODE_NAME="Release"
|
||||
else
|
||||
BUILD_FLAG="--debug"
|
||||
BUILD_MODE_NAME="Debug"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}🏗️ Compilation iOS en mode ${BUILD_MODE_NAME}...${NC}"
|
||||
flutter build ios $BUILD_FLAG
|
||||
echo -e "${GREEN}✓ Compilation terminée (mode ${BUILD_MODE_NAME})${NC}"
|
||||
echo ""
|
||||
|
||||
# Étape 5 : Ouvrir Xcode
|
||||
|
||||
249
app/ios.sh
Executable file
249
app/ios.sh
Executable file
@@ -0,0 +1,249 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de génération iOS pour GEOSECTOR
|
||||
# Usage: ./ios.sh
|
||||
|
||||
set -e # Arrêter le script en cas d'erreur
|
||||
|
||||
# Couleurs pour les messages
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration Mac mini
|
||||
MAC_MINI_IP="192.168.1.34"
|
||||
MAC_USER="pierre"
|
||||
|
||||
# Fonction pour afficher les messages
|
||||
print_message() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
# Fonction pour gérer les erreurs
|
||||
handle_error() {
|
||||
print_error "Une erreur est survenue lors de l'exécution de la commande"
|
||||
print_error "Ligne $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Trap pour capturer les erreurs
|
||||
trap 'handle_error $LINENO' ERR
|
||||
|
||||
# Vérifier que nous sommes dans le bon dossier
|
||||
if [ ! -f "pubspec.yaml" ]; then
|
||||
print_error "Ce script doit être exécuté depuis le dossier racine de l'application Flutter"
|
||||
print_error "Fichier pubspec.yaml introuvable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_message "========================================="
|
||||
print_message " GEOSECTOR - Génération iOS"
|
||||
print_message "========================================="
|
||||
echo
|
||||
|
||||
# Vérifier que Flutter est installé
|
||||
if ! command -v flutter &> /dev/null; then
|
||||
print_error "Flutter n'est pas installé ou n'est pas dans le PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Étape 1 : Synchroniser la version depuis ../VERSION
|
||||
print_message "Étape 1/4 : Synchronisation de la version..."
|
||||
echo
|
||||
|
||||
VERSION_FILE="../VERSION"
|
||||
if [ ! -f "$VERSION_FILE" ]; then
|
||||
print_error "Fichier VERSION introuvable : $VERSION_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Lire la version depuis le fichier (enlever espaces/retours à la ligne)
|
||||
VERSION_NUMBER=$(cat "$VERSION_FILE" | tr -d '\n\r ' | tr -d '[:space:]')
|
||||
if [ -z "$VERSION_NUMBER" ]; then
|
||||
print_error "Le fichier VERSION est vide"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_message "Version lue depuis $VERSION_FILE : $VERSION_NUMBER"
|
||||
|
||||
# Calculer le versionCode (supprimer les points)
|
||||
VERSION_CODE=$(echo $VERSION_NUMBER | tr -d '.')
|
||||
if [ -z "$VERSION_CODE" ]; then
|
||||
print_error "Impossible de calculer le versionCode"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_message "Version code calculé : $VERSION_CODE"
|
||||
|
||||
# Mettre à jour pubspec.yaml
|
||||
print_message "Mise à jour de pubspec.yaml..."
|
||||
sed -i.bak "s/^version:.*/version: $VERSION_NUMBER+$VERSION_CODE/" pubspec.yaml
|
||||
|
||||
# Vérifier que la mise à jour a réussi
|
||||
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //')
|
||||
if [ "$UPDATED_VERSION" != "$VERSION_NUMBER+$VERSION_CODE" ]; then
|
||||
print_error "Échec de la mise à jour de pubspec.yaml"
|
||||
print_error "Attendu : $VERSION_NUMBER+$VERSION_CODE"
|
||||
print_error "Obtenu : $UPDATED_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_success "pubspec.yaml mis à jour : version: $VERSION_NUMBER+$VERSION_CODE"
|
||||
echo
|
||||
|
||||
# Récupérer la version finale pour l'affichage
|
||||
VERSION="$VERSION_NUMBER-$VERSION_CODE"
|
||||
print_message "Version finale : $VERSION"
|
||||
print_message "Version code : $VERSION_CODE"
|
||||
echo
|
||||
|
||||
# Étape 2 : Vérifier la connexion au Mac mini
|
||||
print_message "Étape 2/4 : Vérification de la connexion au Mac mini..."
|
||||
echo
|
||||
|
||||
print_message "Test de connexion à $MAC_USER@$MAC_MINI_IP..."
|
||||
|
||||
if ssh -o ConnectTimeout=5 -o BatchMode=yes "$MAC_USER@$MAC_MINI_IP" exit 2>/dev/null; then
|
||||
print_success "Connexion SSH au Mac mini établie"
|
||||
else
|
||||
print_warning "Impossible de se connecter au Mac mini en mode non-interactif"
|
||||
print_message "Le transfert demandera votre mot de passe"
|
||||
fi
|
||||
echo
|
||||
|
||||
# Étape 3 : Nettoyer le projet
|
||||
print_message "Étape 3/4 : Nettoyage du projet local..."
|
||||
echo
|
||||
|
||||
print_message "Nettoyage en cours..."
|
||||
flutter clean
|
||||
print_success "Projet nettoyé"
|
||||
echo
|
||||
|
||||
# Étape 4 : Transfert vers Mac mini
|
||||
print_message "Étape 4/4 : Transfert vers Mac mini..."
|
||||
echo
|
||||
|
||||
# Construire le chemin de destination avec numéro de version
|
||||
DESTINATION_DIR="app_$VERSION_CODE"
|
||||
DESTINATION="$MAC_USER@$MAC_MINI_IP:/Users/pierre/dev/geosector/$DESTINATION_DIR"
|
||||
|
||||
print_message "Configuration du transfert :"
|
||||
print_message " Source : $(pwd)"
|
||||
print_message " Destination : $DESTINATION"
|
||||
echo
|
||||
|
||||
# Supprimer le dossier de destination s'il existe déjà
|
||||
print_message "Suppression du dossier existant sur le Mac mini (si présent)..."
|
||||
ssh -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password \
|
||||
"$MAC_USER@$MAC_MINI_IP" "rm -rf /Users/pierre/dev/geosector/$DESTINATION_DIR" 2>/dev/null || true
|
||||
print_success "Dossier nettoyé"
|
||||
echo
|
||||
|
||||
print_warning "Note: Vous allez devoir saisir le mot de passe du Mac mini"
|
||||
print_message "rsync va créer le dossier de destination automatiquement"
|
||||
echo
|
||||
|
||||
# Transfert réel (rsync créera le dossier automatiquement)
|
||||
# Options SSH pour éviter "too many authentication failures"
|
||||
rsync -avz --progress \
|
||||
-e "ssh -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password" \
|
||||
--rsync-path="mkdir -p /Users/pierre/dev/geosector/$DESTINATION_DIR && rsync" \
|
||||
--exclude='build/' \
|
||||
--exclude='.dart_tool/' \
|
||||
--exclude='ios/Pods/' \
|
||||
--exclude='ios/.symlinks/' \
|
||||
--exclude='macos/Pods/' \
|
||||
--exclude='linux/flutter/ephemeral/' \
|
||||
--exclude='windows/flutter/ephemeral/' \
|
||||
--exclude='.pub-cache/' \
|
||||
--exclude='android/build/' \
|
||||
--exclude='*.aab' \
|
||||
--exclude='*.apk' \
|
||||
./ "$DESTINATION/"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo
|
||||
print_success "Transfert terminé avec succès !"
|
||||
echo
|
||||
else
|
||||
print_error "Erreur lors du transfert"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Afficher le résumé
|
||||
echo
|
||||
print_message "========================================="
|
||||
print_success " TRANSFERT TERMINÉ AVEC SUCCÈS !"
|
||||
print_message "========================================="
|
||||
echo
|
||||
print_message "Version : ${GREEN}$VERSION${NC}"
|
||||
print_message "Dossier sur le Mac : ${GREEN}/Users/pierre/dev/geosector/$DESTINATION_DIR${NC}"
|
||||
echo
|
||||
|
||||
# Proposer de lancer le build automatiquement
|
||||
print_message "Voulez-vous lancer le build iOS maintenant ?"
|
||||
echo
|
||||
print_message " ${GREEN}[A]${NC} Se connecter en SSH et lancer le build automatiquement"
|
||||
print_message " ${YELLOW}[B]${NC} Afficher les instructions manuelles (défaut)"
|
||||
echo
|
||||
read -p "Votre choix (A/B) [défaut: B] : " -n 1 -r BUILD_CHOICE
|
||||
echo
|
||||
echo
|
||||
|
||||
if [[ $BUILD_CHOICE =~ ^[Aa]$ ]]; then
|
||||
print_message "Connexion au Mac mini et lancement du build..."
|
||||
echo
|
||||
|
||||
# Se connecter en SSH et lancer le build
|
||||
# Options SSH pour éviter "too many authentication failures"
|
||||
ssh -t -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password \
|
||||
"$MAC_USER@$MAC_MINI_IP" "cd /Users/pierre/dev/geosector/$DESTINATION_DIR && ./ios-build-mac.sh"
|
||||
|
||||
echo
|
||||
print_success "Build terminé sur le Mac mini"
|
||||
echo
|
||||
print_message "Prochaines étapes sur le Mac mini :"
|
||||
print_message "1. Xcode est maintenant ouvert"
|
||||
print_message "2. Vérifier Signing & Capabilities (Team: 6WT84NWCTC)"
|
||||
print_message "3. Product > Archive"
|
||||
print_message "4. Organizer > Distribute App > App Store Connect"
|
||||
else
|
||||
print_message "========================================="
|
||||
print_message " INSTRUCTIONS MANUELLES"
|
||||
print_message "========================================="
|
||||
echo
|
||||
print_message "Sur votre Mac mini, exécutez les commandes suivantes :"
|
||||
echo
|
||||
echo -e "${YELLOW}# Se connecter au Mac mini${NC}"
|
||||
echo "ssh $MAC_USER@$MAC_MINI_IP"
|
||||
echo
|
||||
echo -e "${YELLOW}# Aller dans le dossier du projet${NC}"
|
||||
echo "cd /Users/pierre/dev/geosector/$DESTINATION_DIR"
|
||||
echo
|
||||
echo -e "${YELLOW}# Lancer le build iOS${NC}"
|
||||
echo "./ios-build-mac.sh"
|
||||
echo
|
||||
print_message "========================================="
|
||||
echo
|
||||
print_message "Ou copiez-collez cette commande complète :"
|
||||
echo
|
||||
echo -e "${GREEN}ssh -t $MAC_USER@$MAC_MINI_IP \"cd /Users/pierre/dev/geosector/$DESTINATION_DIR && ./ios-build-mac.sh\"${NC}"
|
||||
fi
|
||||
|
||||
echo
|
||||
print_success "Script terminé !"
|
||||
BIN
app/ios/GeoSector_v3_Development.mobileprovision
Normal file
BIN
app/ios/GeoSector_v3_Development.mobileprovision
Normal file
Binary file not shown.
@@ -488,7 +488,8 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
@@ -504,7 +505,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 App Store";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 Development";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
@@ -680,7 +681,8 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
@@ -696,7 +698,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 App Store";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 Development";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -710,7 +712,8 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
@@ -726,7 +729,7 @@
|
||||
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 App Store";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 Development";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
|
||||
8
app/ios/Runner/Runner.entitlements
Normal file
8
app/ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.proximity-reader.payment.acceptance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/services/theme_service.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
// Import conditionnel pour le web
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
@@ -24,6 +27,7 @@ import 'package:geosector_app/presentation/pages/messages_page.dart';
|
||||
import 'package:geosector_app/presentation/pages/amicale_page.dart';
|
||||
import 'package:geosector_app/presentation/pages/operations_page.dart';
|
||||
import 'package:geosector_app/presentation/pages/field_mode_page.dart';
|
||||
import 'package:geosector_app/presentation/pages/connexions_page.dart';
|
||||
|
||||
// Instances globales des repositories (plus besoin d'injecter ApiService)
|
||||
final operationRepository = OperationRepository();
|
||||
@@ -44,10 +48,80 @@ class GeosectorApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver {
|
||||
// Clé globale pour accéder au contexte de l'app (pour les dialogues)
|
||||
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Sur Web, intercepter F5 / Ctrl+R pour proposer un refresh des données
|
||||
if (kIsWeb) {
|
||||
_setupF5Interceptor();
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure l'interception de F5/Ctrl+R sur Web
|
||||
void _setupF5Interceptor() {
|
||||
html.window.onKeyDown.listen((event) {
|
||||
// Détecter F5 ou Ctrl+R
|
||||
final isF5 = event.key == 'F5';
|
||||
final isCtrlR = (event.ctrlKey || event.metaKey) && event.key?.toLowerCase() == 'r';
|
||||
|
||||
if (isF5 || isCtrlR) {
|
||||
event.preventDefault();
|
||||
debugPrint('🔄 F5/Ctrl+R intercepté - Affichage du dialogue de refresh');
|
||||
_showRefreshDialog();
|
||||
}
|
||||
});
|
||||
debugPrint('🌐 Intercepteur F5/Ctrl+R configuré pour Web');
|
||||
}
|
||||
|
||||
/// Affiche le dialogue de confirmation de refresh
|
||||
void _showRefreshDialog() {
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
debugPrint('⚠️ Impossible d\'afficher le dialogue - contexte non disponible');
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.refresh, color: Colors.blue),
|
||||
SizedBox(width: 12),
|
||||
Text('Recharger les données ?'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Voulez-vous actualiser vos données depuis le serveur ?\n\n'
|
||||
'Vos modifications non synchronisées seront conservées.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
debugPrint('❌ Refresh annulé par l\'utilisateur');
|
||||
},
|
||||
child: const Text('Non'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
debugPrint('✅ Refresh demandé par l\'utilisateur');
|
||||
// TODO: Implémenter le refresh des données via API
|
||||
},
|
||||
child: const Text('Oui, recharger'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -158,6 +232,7 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
|
||||
/// Création du routeur avec configuration pour URLs propres
|
||||
GoRouter _createRouter() {
|
||||
return GoRouter(
|
||||
navigatorKey: navigatorKey,
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
@@ -322,6 +397,15 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
|
||||
return const OperationsPage();
|
||||
},
|
||||
),
|
||||
// Sous-route pour connexions (role 2+ uniquement)
|
||||
GoRoute(
|
||||
path: 'connexions',
|
||||
name: 'admin-connexions',
|
||||
builder: (context, state) {
|
||||
debugPrint('GoRoute: Affichage de ConnexionsPage (unifiée)');
|
||||
return const ConnexionsPage();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -26,7 +26,7 @@ class RoomAdapter extends TypeAdapter<Room> {
|
||||
unreadCount: fields[6] as int,
|
||||
recentMessages: (fields[7] as List?)
|
||||
?.map((dynamic e) => (e as Map).cast<String, dynamic>())
|
||||
?.toList(),
|
||||
.toList(),
|
||||
updatedAt: fields[8] as DateTime?,
|
||||
createdBy: fields[9] as int?,
|
||||
isSynced: fields[10] as bool,
|
||||
|
||||
@@ -28,9 +28,8 @@ class ChatService {
|
||||
|
||||
Timer? _syncTimer;
|
||||
DateTime? _lastSyncTimestamp;
|
||||
DateTime? _lastFullSync;
|
||||
static const Duration _syncInterval = Duration(seconds: 15); // Changé à 15 secondes comme suggéré par l'API
|
||||
static const Duration _fullSyncInterval = Duration(minutes: 5);
|
||||
static const Duration _syncInterval = Duration(seconds: 30); // Sync toutes les 30 secondes
|
||||
static const Duration _initialSyncDelay = Duration(seconds: 10); // Délai avant première sync
|
||||
|
||||
/// Initialisation avec gestion des rôles et configuration YAML
|
||||
static Future<void> init({
|
||||
@@ -76,9 +75,12 @@ class ChatService {
|
||||
// Charger le dernier timestamp de sync depuis Hive
|
||||
await _instance!._loadSyncTimestamp();
|
||||
|
||||
// Faire la sync initiale complète au login
|
||||
await _instance!.getRooms(forceFullSync: true);
|
||||
debugPrint('✅ Sync initiale complète effectuée au login');
|
||||
// Faire la sync initiale complète au login avec délai de 10 secondes
|
||||
debugPrint('⏳ Sync initiale chat programmée dans 10 secondes...');
|
||||
Future.delayed(_initialSyncDelay, () async {
|
||||
await _instance!.getRooms(forceFullSync: true);
|
||||
debugPrint('✅ Sync initiale complète effectuée au login');
|
||||
});
|
||||
|
||||
// Démarrer la synchronisation incrémentale périodique
|
||||
_instance!._startSync();
|
||||
@@ -136,6 +138,13 @@ class ChatService {
|
||||
|
||||
/// Obtenir les rooms avec synchronisation incrémentale
|
||||
Future<List<Room>> getRooms({bool forceFullSync = false}) async {
|
||||
// DÉSACTIVATION TEMPORAIRE - Retour direct du cache sans appeler l'API
|
||||
debugPrint('🚫 API /chat/rooms désactivée - utilisation du cache uniquement');
|
||||
return _roomsBox.values.toList()
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
|
||||
/* Code original commenté pour désactiver les appels API
|
||||
// Vérifier la connectivité
|
||||
if (!connectivityService.isConnected) {
|
||||
debugPrint('📵 Pas de connexion réseau - utilisation du cache');
|
||||
@@ -156,15 +165,17 @@ class ChatService {
|
||||
if (needsFullSync || _lastSyncTimestamp == null) {
|
||||
// Synchronisation complète
|
||||
debugPrint('🔄 Synchronisation complète des rooms...');
|
||||
response = await _dio.get('/chat/rooms');
|
||||
// response = await _dio.get('/chat/rooms'); // COMMENTÉ - Désactivation GET /chat/rooms
|
||||
return; // Retour anticipé pour éviter l'appel API
|
||||
_lastFullSync = now;
|
||||
} else {
|
||||
// Synchronisation incrémentale
|
||||
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
|
||||
// debugPrint('🔄 Synchronisation incrémentale depuis $isoTimestamp');
|
||||
response = await _dio.get('/chat/rooms', queryParameters: {
|
||||
'updated_after': isoTimestamp,
|
||||
});
|
||||
// response = await _dio.get('/chat/rooms', queryParameters: { // COMMENTÉ - Désactivation GET /chat/rooms
|
||||
// 'updated_after': isoTimestamp,
|
||||
// });
|
||||
return; // Retour anticipé pour éviter l'appel API
|
||||
}
|
||||
|
||||
// Extraire le timestamp de synchronisation fourni par l'API
|
||||
@@ -348,6 +359,7 @@ class ChatService {
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
}
|
||||
*/// Fin du code commenté
|
||||
}
|
||||
|
||||
/// Créer une room avec vérification des permissions
|
||||
@@ -754,7 +766,7 @@ class ChatService {
|
||||
});
|
||||
|
||||
// Pas de sync immédiate ici car déjà faite dans init()
|
||||
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)');
|
||||
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 30 secondes)');
|
||||
}
|
||||
|
||||
/// Mettre en pause les synchronisations (app en arrière-plan)
|
||||
|
||||
@@ -146,9 +146,9 @@ class AppKeys {
|
||||
1: {
|
||||
'titres': 'Effectués',
|
||||
'titre': 'Effectué',
|
||||
'couleur1': 0xFF00E09D, // Vert (Figma)
|
||||
'couleur2': 0xFF00E09D, // Vert (Figma)
|
||||
'couleur3': 0xFF00E09D, // Vert (Figma)
|
||||
'couleur1': 0xFF008000, // Vert foncé
|
||||
'couleur2': 0xFF008000, // Vert foncé
|
||||
'couleur3': 0xFF008000, // Vert foncé
|
||||
'icon_data': Icons.task_alt,
|
||||
},
|
||||
2: {
|
||||
@@ -170,9 +170,9 @@ class AppKeys {
|
||||
4: {
|
||||
'titres': 'Dons',
|
||||
'titre': 'Don',
|
||||
'couleur1': 0xFF395AA7, // Bleu (Figma)
|
||||
'couleur2': 0xFF395AA7, // Bleu (Figma)
|
||||
'couleur3': 0xFF395AA7, // Bleu (Figma)
|
||||
'couleur1': 0xFF00BCD4, // Cyan
|
||||
'couleur2': 0xFF00BCD4, // Cyan
|
||||
'couleur3': 0xFF00BCD4, // Cyan
|
||||
'icon_data': Icons.volunteer_activism,
|
||||
},
|
||||
5: {
|
||||
|
||||
594
app/lib/core/data/models/event_stats_model.dart
Normal file
594
app/lib/core/data/models/event_stats_model.dart
Normal file
@@ -0,0 +1,594 @@
|
||||
// Modèles pour les statistiques d'événements (connexions, passages, etc.)
|
||||
//
|
||||
// Ces modèles ne sont PAS stockés dans Hive car les données sont récupérées
|
||||
// à la demande depuis l'API et ne nécessitent pas de persistance locale.
|
||||
|
||||
/// Statistiques d'authentification
|
||||
class AuthStats {
|
||||
final int success;
|
||||
final int failed;
|
||||
final int logout;
|
||||
|
||||
const AuthStats({
|
||||
required this.success,
|
||||
required this.failed,
|
||||
required this.logout,
|
||||
});
|
||||
|
||||
factory AuthStats.fromJson(Map<String, dynamic> json) {
|
||||
return AuthStats(
|
||||
success: _parseInt(json['success']),
|
||||
failed: _parseInt(json['failed']),
|
||||
logout: _parseInt(json['logout']),
|
||||
);
|
||||
}
|
||||
|
||||
int get total => success + failed + logout;
|
||||
}
|
||||
|
||||
/// Statistiques de passages
|
||||
class PassageStats {
|
||||
final int created;
|
||||
final int updated;
|
||||
final int deleted;
|
||||
final double amount;
|
||||
|
||||
const PassageStats({
|
||||
required this.created,
|
||||
required this.updated,
|
||||
required this.deleted,
|
||||
required this.amount,
|
||||
});
|
||||
|
||||
factory PassageStats.fromJson(Map<String, dynamic> json) {
|
||||
return PassageStats(
|
||||
created: _parseInt(json['created']),
|
||||
updated: _parseInt(json['updated']),
|
||||
deleted: _parseInt(json['deleted']),
|
||||
amount: _parseDouble(json['amount']),
|
||||
);
|
||||
}
|
||||
|
||||
int get total => created + updated + deleted;
|
||||
}
|
||||
|
||||
/// Statistiques utilisateurs
|
||||
class UserStats {
|
||||
final int created;
|
||||
final int updated;
|
||||
final int deleted;
|
||||
|
||||
const UserStats({
|
||||
required this.created,
|
||||
required this.updated,
|
||||
required this.deleted,
|
||||
});
|
||||
|
||||
factory UserStats.fromJson(Map<String, dynamic> json) {
|
||||
return UserStats(
|
||||
created: _parseInt(json['created']),
|
||||
updated: _parseInt(json['updated']),
|
||||
deleted: _parseInt(json['deleted']),
|
||||
);
|
||||
}
|
||||
|
||||
int get total => created + updated + deleted;
|
||||
}
|
||||
|
||||
/// Statistiques secteurs
|
||||
class SectorStats {
|
||||
final int created;
|
||||
final int updated;
|
||||
final int deleted;
|
||||
|
||||
const SectorStats({
|
||||
required this.created,
|
||||
required this.updated,
|
||||
required this.deleted,
|
||||
});
|
||||
|
||||
factory SectorStats.fromJson(Map<String, dynamic> json) {
|
||||
return SectorStats(
|
||||
created: _parseInt(json['created']),
|
||||
updated: _parseInt(json['updated']),
|
||||
deleted: _parseInt(json['deleted']),
|
||||
);
|
||||
}
|
||||
|
||||
int get total => created + updated + deleted;
|
||||
}
|
||||
|
||||
/// Statistiques Stripe
|
||||
class StripeStats {
|
||||
final int created;
|
||||
final int success;
|
||||
final int failed;
|
||||
final int cancelled;
|
||||
final double amount;
|
||||
|
||||
const StripeStats({
|
||||
required this.created,
|
||||
required this.success,
|
||||
required this.failed,
|
||||
required this.cancelled,
|
||||
required this.amount,
|
||||
});
|
||||
|
||||
factory StripeStats.fromJson(Map<String, dynamic> json) {
|
||||
return StripeStats(
|
||||
created: _parseInt(json['created']),
|
||||
success: _parseInt(json['success']),
|
||||
failed: _parseInt(json['failed']),
|
||||
cancelled: _parseInt(json['cancelled']),
|
||||
amount: _parseDouble(json['amount']),
|
||||
);
|
||||
}
|
||||
|
||||
int get total => created + success + failed + cancelled;
|
||||
}
|
||||
|
||||
/// Statistiques globales d'une journée
|
||||
class DayStats {
|
||||
final AuthStats auth;
|
||||
final PassageStats passages;
|
||||
final UserStats users;
|
||||
final SectorStats sectors;
|
||||
final StripeStats stripe;
|
||||
|
||||
const DayStats({
|
||||
required this.auth,
|
||||
required this.passages,
|
||||
required this.users,
|
||||
required this.sectors,
|
||||
required this.stripe,
|
||||
});
|
||||
|
||||
factory DayStats.fromJson(Map<String, dynamic> json) {
|
||||
return DayStats(
|
||||
auth: AuthStats.fromJson(json['auth'] ?? {}),
|
||||
passages: PassageStats.fromJson(json['passages'] ?? {}),
|
||||
users: UserStats.fromJson(json['users'] ?? {}),
|
||||
sectors: SectorStats.fromJson(json['sectors'] ?? {}),
|
||||
stripe: StripeStats.fromJson(json['stripe'] ?? {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Totaux d'une journée
|
||||
class DayTotals {
|
||||
final int events;
|
||||
final int uniqueUsers;
|
||||
|
||||
const DayTotals({
|
||||
required this.events,
|
||||
required this.uniqueUsers,
|
||||
});
|
||||
|
||||
factory DayTotals.fromJson(Map<String, dynamic> json) {
|
||||
return DayTotals(
|
||||
events: _parseInt(json['events']),
|
||||
uniqueUsers: _parseInt(json['unique_users']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Résumé complet d'une journée (réponse de /stats/summary)
|
||||
class EventSummary {
|
||||
final DateTime date;
|
||||
final DayStats stats;
|
||||
final DayTotals totals;
|
||||
|
||||
const EventSummary({
|
||||
required this.date,
|
||||
required this.stats,
|
||||
required this.totals,
|
||||
});
|
||||
|
||||
factory EventSummary.fromJson(Map<String, dynamic> json) {
|
||||
return EventSummary(
|
||||
date: DateTime.parse(json['date']),
|
||||
stats: DayStats.fromJson(json['stats'] ?? {}),
|
||||
totals: DayTotals.fromJson(json['totals'] ?? {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques d'un type d'événement pour une période
|
||||
class EventTypeStats {
|
||||
final int count;
|
||||
final double sumAmount;
|
||||
final int uniqueUsers;
|
||||
|
||||
const EventTypeStats({
|
||||
required this.count,
|
||||
required this.sumAmount,
|
||||
required this.uniqueUsers,
|
||||
});
|
||||
|
||||
factory EventTypeStats.fromJson(Map<String, dynamic> json) {
|
||||
return EventTypeStats(
|
||||
count: _parseInt(json['count']),
|
||||
sumAmount: _parseDouble(json['sum_amount']),
|
||||
uniqueUsers: _parseInt(json['unique_users']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Données d'une journée dans les stats quotidiennes
|
||||
class DailyStatsEntry {
|
||||
final DateTime date;
|
||||
final Map<String, EventTypeStats> events;
|
||||
final int totalCount;
|
||||
final double totalAmount;
|
||||
|
||||
const DailyStatsEntry({
|
||||
required this.date,
|
||||
required this.events,
|
||||
required this.totalCount,
|
||||
required this.totalAmount,
|
||||
});
|
||||
|
||||
factory DailyStatsEntry.fromJson(Map<String, dynamic> json) {
|
||||
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
|
||||
final events = <String, EventTypeStats>{};
|
||||
|
||||
for (final entry in eventsJson.entries) {
|
||||
events[entry.key] = EventTypeStats.fromJson(entry.value);
|
||||
}
|
||||
|
||||
final totals = json['totals'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return DailyStatsEntry(
|
||||
date: DateTime.parse(json['date']),
|
||||
events: events,
|
||||
totalCount: _parseInt(totals['count']),
|
||||
totalAmount: _parseDouble(totals['sum_amount']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse des stats quotidiennes (/stats/daily)
|
||||
class DailyStats {
|
||||
final DateTime from;
|
||||
final DateTime to;
|
||||
final List<DailyStatsEntry> days;
|
||||
|
||||
const DailyStats({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.days,
|
||||
});
|
||||
|
||||
factory DailyStats.fromJson(Map<String, dynamic> json) {
|
||||
final daysJson = json['days'] as List<dynamic>? ?? [];
|
||||
|
||||
return DailyStats(
|
||||
from: DateTime.parse(json['from']),
|
||||
to: DateTime.parse(json['to']),
|
||||
days: daysJson.map((d) => DailyStatsEntry.fromJson(d)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Données d'une semaine dans les stats hebdomadaires
|
||||
class WeeklyStatsEntry {
|
||||
final DateTime weekStart;
|
||||
final int weekNumber;
|
||||
final int year;
|
||||
final Map<String, EventTypeStats> events;
|
||||
final int totalCount;
|
||||
final double totalAmount;
|
||||
|
||||
const WeeklyStatsEntry({
|
||||
required this.weekStart,
|
||||
required this.weekNumber,
|
||||
required this.year,
|
||||
required this.events,
|
||||
required this.totalCount,
|
||||
required this.totalAmount,
|
||||
});
|
||||
|
||||
factory WeeklyStatsEntry.fromJson(Map<String, dynamic> json) {
|
||||
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
|
||||
final events = <String, EventTypeStats>{};
|
||||
|
||||
for (final entry in eventsJson.entries) {
|
||||
events[entry.key] = EventTypeStats.fromJson(entry.value);
|
||||
}
|
||||
|
||||
final totals = json['totals'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return WeeklyStatsEntry(
|
||||
weekStart: DateTime.parse(json['week_start']),
|
||||
weekNumber: _parseInt(json['week_number']),
|
||||
year: _parseInt(json['year']),
|
||||
events: events,
|
||||
totalCount: _parseInt(totals['count']),
|
||||
totalAmount: _parseDouble(totals['sum_amount']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse des stats hebdomadaires (/stats/weekly)
|
||||
class WeeklyStats {
|
||||
final DateTime from;
|
||||
final DateTime to;
|
||||
final List<WeeklyStatsEntry> weeks;
|
||||
|
||||
const WeeklyStats({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.weeks,
|
||||
});
|
||||
|
||||
factory WeeklyStats.fromJson(Map<String, dynamic> json) {
|
||||
final weeksJson = json['weeks'] as List<dynamic>? ?? [];
|
||||
|
||||
return WeeklyStats(
|
||||
from: DateTime.parse(json['from']),
|
||||
to: DateTime.parse(json['to']),
|
||||
weeks: weeksJson.map((w) => WeeklyStatsEntry.fromJson(w)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Données d'un mois dans les stats mensuelles
|
||||
class MonthlyStatsEntry {
|
||||
final String month; // Format: "2025-01"
|
||||
final int year;
|
||||
final int monthNumber;
|
||||
final Map<String, EventTypeStats> events;
|
||||
final int totalCount;
|
||||
final double totalAmount;
|
||||
|
||||
const MonthlyStatsEntry({
|
||||
required this.month,
|
||||
required this.year,
|
||||
required this.monthNumber,
|
||||
required this.events,
|
||||
required this.totalCount,
|
||||
required this.totalAmount,
|
||||
});
|
||||
|
||||
factory MonthlyStatsEntry.fromJson(Map<String, dynamic> json) {
|
||||
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
|
||||
final events = <String, EventTypeStats>{};
|
||||
|
||||
for (final entry in eventsJson.entries) {
|
||||
events[entry.key] = EventTypeStats.fromJson(entry.value);
|
||||
}
|
||||
|
||||
final totals = json['totals'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return MonthlyStatsEntry(
|
||||
month: json['month'] ?? '',
|
||||
year: _parseInt(json['year']),
|
||||
monthNumber: _parseInt(json['month_number']),
|
||||
events: events,
|
||||
totalCount: _parseInt(totals['count']),
|
||||
totalAmount: _parseDouble(totals['sum_amount']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse des stats mensuelles (/stats/monthly)
|
||||
class MonthlyStats {
|
||||
final int year;
|
||||
final List<MonthlyStatsEntry> months;
|
||||
|
||||
const MonthlyStats({
|
||||
required this.year,
|
||||
required this.months,
|
||||
});
|
||||
|
||||
factory MonthlyStats.fromJson(Map<String, dynamic> json) {
|
||||
final monthsJson = json['months'] as List<dynamic>? ?? [];
|
||||
|
||||
return MonthlyStats(
|
||||
year: _parseInt(json['year']),
|
||||
months: monthsJson.map((m) => MonthlyStatsEntry.fromJson(m)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Détail d'un événement individuel
|
||||
class EventDetail {
|
||||
final DateTime timestamp;
|
||||
final String event;
|
||||
final String? username;
|
||||
final String? reason;
|
||||
final int? attempt;
|
||||
final String? ip;
|
||||
final String? platform;
|
||||
final String? appVersion;
|
||||
final Map<String, dynamic>? extra;
|
||||
|
||||
const EventDetail({
|
||||
required this.timestamp,
|
||||
required this.event,
|
||||
this.username,
|
||||
this.reason,
|
||||
this.attempt,
|
||||
this.ip,
|
||||
this.platform,
|
||||
this.appVersion,
|
||||
this.extra,
|
||||
});
|
||||
|
||||
factory EventDetail.fromJson(Map<String, dynamic> json) {
|
||||
return EventDetail(
|
||||
timestamp: DateTime.parse(json['timestamp']),
|
||||
event: json['event'] ?? '',
|
||||
username: json['username'],
|
||||
reason: json['reason'],
|
||||
attempt: json['attempt'] != null ? _parseInt(json['attempt']) : null,
|
||||
ip: json['ip'],
|
||||
platform: json['platform'],
|
||||
appVersion: json['app_version'],
|
||||
extra: json,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pagination pour les détails
|
||||
class EventPagination {
|
||||
final int total;
|
||||
final int limit;
|
||||
final int offset;
|
||||
final bool hasMore;
|
||||
|
||||
const EventPagination({
|
||||
required this.total,
|
||||
required this.limit,
|
||||
required this.offset,
|
||||
required this.hasMore,
|
||||
});
|
||||
|
||||
factory EventPagination.fromJson(Map<String, dynamic> json) {
|
||||
return EventPagination(
|
||||
total: _parseInt(json['total']),
|
||||
limit: _parseInt(json['limit']),
|
||||
offset: _parseInt(json['offset']),
|
||||
hasMore: json['has_more'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse des détails d'événements (/stats/details)
|
||||
class EventDetails {
|
||||
final DateTime date;
|
||||
final List<EventDetail> events;
|
||||
final EventPagination pagination;
|
||||
|
||||
const EventDetails({
|
||||
required this.date,
|
||||
required this.events,
|
||||
required this.pagination,
|
||||
});
|
||||
|
||||
factory EventDetails.fromJson(Map<String, dynamic> json) {
|
||||
final eventsJson = json['events'] as List<dynamic>? ?? [];
|
||||
|
||||
return EventDetails(
|
||||
date: DateTime.parse(json['date']),
|
||||
events: eventsJson.map((e) => EventDetail.fromJson(e)).toList(),
|
||||
pagination: EventPagination.fromJson(json['pagination'] ?? {}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Types d'événements disponibles
|
||||
class EventTypes {
|
||||
final List<String> auth;
|
||||
final List<String> passages;
|
||||
final List<String> sectors;
|
||||
final List<String> users;
|
||||
final List<String> entities;
|
||||
final List<String> operations;
|
||||
final List<String> stripe;
|
||||
|
||||
const EventTypes({
|
||||
required this.auth,
|
||||
required this.passages,
|
||||
required this.sectors,
|
||||
required this.users,
|
||||
required this.entities,
|
||||
required this.operations,
|
||||
required this.stripe,
|
||||
});
|
||||
|
||||
factory EventTypes.fromJson(Map<String, dynamic> json) {
|
||||
return EventTypes(
|
||||
auth: _parseStringList(json['auth']),
|
||||
passages: _parseStringList(json['passages']),
|
||||
sectors: _parseStringList(json['sectors']),
|
||||
users: _parseStringList(json['users']),
|
||||
entities: _parseStringList(json['entities']),
|
||||
operations: _parseStringList(json['operations']),
|
||||
stripe: _parseStringList(json['stripe']),
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient tous les types d'événements à plat
|
||||
List<String> get allTypes => [
|
||||
...auth,
|
||||
...passages,
|
||||
...sectors,
|
||||
...users,
|
||||
...entities,
|
||||
...operations,
|
||||
...stripe,
|
||||
];
|
||||
|
||||
/// Obtient le libellé français d'un type d'événement
|
||||
static String getLabel(String eventType) {
|
||||
switch (eventType) {
|
||||
// Auth
|
||||
case 'login_success': return 'Connexion réussie';
|
||||
case 'login_failed': return 'Connexion échouée';
|
||||
case 'logout': return 'Déconnexion';
|
||||
// Passages
|
||||
case 'passage_created': return 'Passage créé';
|
||||
case 'passage_updated': return 'Passage modifié';
|
||||
case 'passage_deleted': return 'Passage supprimé';
|
||||
// Sectors
|
||||
case 'sector_created': return 'Secteur créé';
|
||||
case 'sector_updated': return 'Secteur modifié';
|
||||
case 'sector_deleted': return 'Secteur supprimé';
|
||||
// Users
|
||||
case 'user_created': return 'Utilisateur créé';
|
||||
case 'user_updated': return 'Utilisateur modifié';
|
||||
case 'user_deleted': return 'Utilisateur supprimé';
|
||||
// Entities
|
||||
case 'entity_created': return 'Entité créée';
|
||||
case 'entity_updated': return 'Entité modifiée';
|
||||
case 'entity_deleted': return 'Entité supprimée';
|
||||
// Operations
|
||||
case 'operation_created': return 'Opération créée';
|
||||
case 'operation_updated': return 'Opération modifiée';
|
||||
case 'operation_deleted': return 'Opération supprimée';
|
||||
// Stripe
|
||||
case 'stripe_payment_created': return 'Paiement créé';
|
||||
case 'stripe_payment_success': return 'Paiement réussi';
|
||||
case 'stripe_payment_failed': return 'Paiement échoué';
|
||||
case 'stripe_payment_cancelled': return 'Paiement annulé';
|
||||
case 'stripe_terminal_error': return 'Erreur terminal';
|
||||
default: return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient la catégorie d'un type d'événement
|
||||
static String getCategory(String eventType) {
|
||||
if (eventType.startsWith('login') || eventType == 'logout') return 'auth';
|
||||
if (eventType.startsWith('passage')) return 'passages';
|
||||
if (eventType.startsWith('sector')) return 'sectors';
|
||||
if (eventType.startsWith('user')) return 'users';
|
||||
if (eventType.startsWith('entity')) return 'entities';
|
||||
if (eventType.startsWith('operation')) return 'operations';
|
||||
if (eventType.startsWith('stripe')) return 'stripe';
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers pour parser les types depuis JSON (gère int/string)
|
||||
int _parseInt(dynamic value) {
|
||||
if (value == null) return 0;
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
if (value is double) return value.toInt();
|
||||
return 0;
|
||||
}
|
||||
|
||||
double _parseDouble(dynamic value) {
|
||||
if (value == null) return 0.0;
|
||||
if (value is double) return value;
|
||||
if (value is int) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value) ?? 0.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
List<String> _parseStringList(dynamic value) {
|
||||
if (value == null) return [];
|
||||
if (value is List) return value.map((e) => e.toString()).toList();
|
||||
return [];
|
||||
}
|
||||
@@ -247,10 +247,10 @@ class OperationRepository extends ChangeNotifier {
|
||||
debugPrint('✅ Opérations traitées');
|
||||
}
|
||||
|
||||
// Traiter les secteurs (groupe secteurs) via DataLoadingService
|
||||
if (responseData['secteurs'] != null) {
|
||||
// Traiter les secteurs (groupe sectors) via DataLoadingService
|
||||
if (responseData['sectors'] != null) {
|
||||
await DataLoadingService.instance
|
||||
.processSectorsFromApi(responseData['secteurs']);
|
||||
.processSectorsFromApi(responseData['sectors']);
|
||||
debugPrint('✅ Secteurs traités');
|
||||
}
|
||||
|
||||
@@ -521,10 +521,10 @@ class OperationRepository extends ChangeNotifier {
|
||||
debugPrint('✅ Opérations traitées');
|
||||
}
|
||||
|
||||
// Traiter les secteurs (groupe secteurs) via DataLoadingService
|
||||
if (responseData['secteurs'] != null) {
|
||||
// Traiter les secteurs (groupe sectors) via DataLoadingService
|
||||
if (responseData['sectors'] != null) {
|
||||
await DataLoadingService.instance
|
||||
.processSectorsFromApi(responseData['secteurs']);
|
||||
.processSectorsFromApi(responseData['sectors']);
|
||||
debugPrint('✅ Secteurs traités');
|
||||
}
|
||||
|
||||
|
||||
@@ -246,8 +246,9 @@ class PassageRepository extends ChangeNotifier {
|
||||
|
||||
// Mode online : traitement normal
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
// Récupérer l'ID du nouveau passage depuis la réponse
|
||||
final passageId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
|
||||
// Récupérer l'ID du nouveau passage depuis la réponse (passage_id ou id)
|
||||
final rawId = response.data['passage_id'] ?? response.data['id'];
|
||||
final passageId = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
|
||||
// Créer le passage localement avec l'ID retourné par l'API
|
||||
final newPassage = passage.copyWith(
|
||||
@@ -431,10 +432,10 @@ class PassageRepository extends ChangeNotifier {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
throw Exception('Mise à jour refusée par le serveur');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la mise à jour du passage: $e');
|
||||
return false;
|
||||
rethrow; // Propager l'exception originale avec son message
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
@@ -65,16 +67,49 @@ class ApiService {
|
||||
headers['X-App-Identifier'] = _appIdentifier;
|
||||
_dio.options.headers.addAll(headers);
|
||||
|
||||
// Gestionnaire de cookies pour les sessions PHP
|
||||
// IMPORTANT: Désactivé sur Web car les navigateurs bloquent la manipulation des cookies via XHR
|
||||
// Sur Web, on utilise uniquement le header Authorization avec Bearer token
|
||||
if (!kIsWeb) {
|
||||
final cookieJar = CookieJar();
|
||||
_dio.interceptors.add(CookieManager(cookieJar));
|
||||
debugPrint('🍪 [API] Gestionnaire de cookies activé (mobile)');
|
||||
} else {
|
||||
debugPrint('🌐 [API] Mode Web - pas de CookieManager (Bearer token uniquement)');
|
||||
}
|
||||
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
debugPrint('🌐 [API] Requête: ${options.method} ${options.path}');
|
||||
debugPrint('🔑 [API] _sessionId présent: ${_sessionId != null}');
|
||||
debugPrint('🔑 [API] Headers: ${options.headers}');
|
||||
if (_sessionId != null) {
|
||||
debugPrint('🔑 [API] Token: Bearer ${_sessionId!.substring(0, 20)}...');
|
||||
options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId';
|
||||
} else {
|
||||
debugPrint('⚠️ [API] PAS DE TOKEN - Requête non authentifiée');
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (DioException error, handler) {
|
||||
if (error.response?.statusCode == 401) {
|
||||
_sessionId = null;
|
||||
final path = error.requestOptions.path;
|
||||
debugPrint('❌ [API] Erreur 401 sur: $path');
|
||||
|
||||
// Ne pas reset le token pour les requêtes non critiques
|
||||
final nonCriticalPaths = [
|
||||
'/users/device-info',
|
||||
'/chat/rooms',
|
||||
];
|
||||
|
||||
final isNonCritical = nonCriticalPaths.any((p) => path.contains(p));
|
||||
|
||||
if (isNonCritical) {
|
||||
debugPrint('⚠️ [API] Requête non critique - Token conservé');
|
||||
} else {
|
||||
debugPrint('❌ [API] Requête critique - Token invalidé');
|
||||
_sessionId = null;
|
||||
}
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
@@ -1066,15 +1101,21 @@ class ApiService {
|
||||
if (data.containsKey('session_id')) {
|
||||
final sessionId = data['session_id'];
|
||||
if (sessionId != null) {
|
||||
debugPrint('🔐 [LOGIN] Token reçu du serveur: $sessionId');
|
||||
debugPrint('🔐 [LOGIN] Longueur token: ${sessionId.toString().length}');
|
||||
setSessionId(sessionId);
|
||||
debugPrint('🔐 [LOGIN] Token stocké dans _sessionId');
|
||||
|
||||
// Collecter et envoyer les informations du device après login réussi
|
||||
debugPrint('📱 Collecte des informations device après login...');
|
||||
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
|
||||
debugPrint('✅ Informations device collectées et envoyées');
|
||||
}).catchError((error) {
|
||||
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
|
||||
// Ne pas bloquer le login si l'envoi des infos device échoue
|
||||
// Délai de 1 seconde pour laisser la session PHP se stabiliser
|
||||
debugPrint('📱 Collecte des informations device après login (délai 1s)...');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
|
||||
debugPrint('✅ Informations device collectées et envoyées');
|
||||
}).catchError((error) {
|
||||
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
|
||||
// Ne pas bloquer le login si l'envoi des infos device échoue
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ⚠️ AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
|
||||
// This file is automatically generated by deploy-app.sh script
|
||||
// Last update: 2025-11-09 12:39:26
|
||||
// Last update: 2026-01-19 15:35:06
|
||||
// Source: ../VERSION file
|
||||
//
|
||||
// GEOSECTOR App Version Service
|
||||
@@ -8,10 +8,10 @@
|
||||
|
||||
class AppInfoService {
|
||||
// Version number (format: x.x.x)
|
||||
static const String version = '3.5.2';
|
||||
static const String version = '3.6.3';
|
||||
|
||||
// Build number (version without dots: xxx)
|
||||
static const String buildNumber = '352';
|
||||
static const String buildNumber = '363';
|
||||
|
||||
// Full version string (format: vx.x.x+xxx)
|
||||
static String get fullVersion => 'v$version+$buildNumber';
|
||||
|
||||
@@ -140,18 +140,43 @@ class CurrentUserService extends ChangeNotifier {
|
||||
|
||||
Future<void> loadFromHive() async {
|
||||
try {
|
||||
final box = Hive.box<UserModel>(AppKeys.userBoxName); // Nouvelle Box
|
||||
final user = box.get('current_user');
|
||||
// 1. Récupérer l'ID utilisateur depuis settings
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
debugPrint('⚠️ Box settings non ouverte, impossible de charger l\'utilisateur');
|
||||
_currentUser = null;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
final userId = settingsBox.get('current_user_id');
|
||||
|
||||
if (userId == null) {
|
||||
debugPrint('ℹ️ Aucun current_user_id trouvé dans settings');
|
||||
_currentUser = null;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🔍 Recherche utilisateur avec ID: $userId');
|
||||
|
||||
// 2. Récupérer l'utilisateur avec le bon ID
|
||||
final box = Hive.box<UserModel>(AppKeys.userBoxName);
|
||||
final user = box.get(userId);
|
||||
|
||||
if (user?.hasValidSession == true) {
|
||||
_currentUser = user;
|
||||
debugPrint('📥 Utilisateur chargé depuis Hive: ${user?.email}');
|
||||
debugPrint('📥 Utilisateur chargé depuis Hive: ${user?.email} (ID: $userId)');
|
||||
|
||||
// Charger le mode d'affichage sauvegardé lors de la connexion
|
||||
await _loadDisplayMode();
|
||||
} else {
|
||||
_currentUser = null;
|
||||
debugPrint('ℹ️ Aucun utilisateur valide trouvé dans Hive');
|
||||
if (user == null) {
|
||||
debugPrint('ℹ️ Utilisateur ID $userId non trouvé dans la box');
|
||||
} else {
|
||||
debugPrint('ℹ️ Session expirée pour l\'utilisateur ${user.email}');
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
@@ -211,18 +212,18 @@ class DeviceInfoService {
|
||||
}
|
||||
|
||||
bool _checkIosTapToPaySupport(String machine, String systemVersion) {
|
||||
// iPhone XS et plus récents (liste des identifiants)
|
||||
final supportedDevices = [
|
||||
'iPhone11,', // XS, XS Max
|
||||
'iPhone12,', // 11, 11 Pro, 11 Pro Max
|
||||
'iPhone13,', // 12 series
|
||||
'iPhone14,', // 13 series
|
||||
'iPhone15,', // 14 series
|
||||
'iPhone16,', // 15 series
|
||||
];
|
||||
// Vérifier le modèle : iPhone11,x ou supérieur (iPhone XS+)
|
||||
// Extraire le numéro majeur de l'identifiant (ex: "iPhone17,3" -> 17)
|
||||
bool deviceSupported = false;
|
||||
|
||||
// Vérifier le modèle
|
||||
bool deviceSupported = supportedDevices.any((prefix) => machine.startsWith(prefix));
|
||||
if (machine.startsWith('iPhone')) {
|
||||
final match = RegExp(r'iPhone(\d+),').firstMatch(machine);
|
||||
if (match != null) {
|
||||
final majorVersion = int.tryParse(match.group(1) ?? '0') ?? 0;
|
||||
// iPhone XS = iPhone11,x donc >= 11 pour supporter Tap to Pay
|
||||
deviceSupported = majorVersion >= 11;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier la version iOS (16.4+ selon la documentation officielle Stripe)
|
||||
final versionParts = systemVersion.split('.');
|
||||
@@ -334,10 +335,10 @@ class DeviceInfoService {
|
||||
return deviceInfo;
|
||||
}
|
||||
|
||||
/// Vérifie la certification Stripe Tap to Pay via l'API
|
||||
/// Vérifie la certification Stripe Tap to Pay via le SDK Stripe Terminal
|
||||
Future<bool> checkStripeCertification() async {
|
||||
try {
|
||||
// Sur Web, toujours non certifié
|
||||
// Sur Web, toujours non supporté
|
||||
if (kIsWeb) {
|
||||
debugPrint('📱 Web platform - Tap to Pay non supporté');
|
||||
return false;
|
||||
@@ -354,33 +355,35 @@ class DeviceInfoService {
|
||||
return isSupported;
|
||||
}
|
||||
|
||||
// Android : vérification via l'API Stripe
|
||||
// Android : vérification des pré-requis hardware de base
|
||||
// Note: Le vrai check de compatibilité avec découverte de readers se fera
|
||||
// dans StripeTapToPayService lors du premier paiement
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await _deviceInfo.androidInfo;
|
||||
debugPrint('📱 Vérification Tap to Pay pour ${androidInfo.manufacturer} ${androidInfo.model}');
|
||||
|
||||
debugPrint('📱 Vérification certification Stripe pour ${androidInfo.manufacturer} ${androidInfo.model}');
|
||||
|
||||
try {
|
||||
final response = await ApiService.instance.post(
|
||||
'/stripe/devices/check-tap-to-pay',
|
||||
data: {
|
||||
'platform': 'android',
|
||||
'manufacturer': androidInfo.manufacturer,
|
||||
'model': androidInfo.model,
|
||||
},
|
||||
);
|
||||
|
||||
final tapToPaySupported = response.data['tap_to_pay_supported'] == true;
|
||||
final message = response.data['message'] ?? '';
|
||||
|
||||
debugPrint('📱 Résultat certification Stripe: $tapToPaySupported - $message');
|
||||
return tapToPaySupported;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de la vérification Stripe: $e');
|
||||
// En cas d'erreur API, on se base sur la vérification locale
|
||||
return androidInfo.version.sdkInt >= 28;
|
||||
// Vérifications préalables de base
|
||||
if (androidInfo.version.sdkInt < 28) {
|
||||
debugPrint('❌ Android SDK trop ancien: ${androidInfo.version.sdkInt} (minimum 28)');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier la disponibilité du NFC
|
||||
try {
|
||||
final nfcAvailable = await NfcManager.instance.isAvailable();
|
||||
if (!nfcAvailable) {
|
||||
debugPrint('❌ NFC non disponible sur cet appareil');
|
||||
return false;
|
||||
}
|
||||
debugPrint('✅ NFC disponible');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Impossible de vérifier NFC: $e');
|
||||
// On continue quand même, ce n'est pas bloquant à ce stade
|
||||
}
|
||||
|
||||
// Pré-requis de base OK
|
||||
debugPrint('✅ Pré-requis Android OK (SDK ${androidInfo.version.sdkInt}, NFC disponible)');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -390,22 +393,89 @@ class DeviceInfoService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Vérifie si le device peut utiliser Tap to Pay
|
||||
bool canUseTapToPay() {
|
||||
final deviceInfo = getStoredDeviceInfo();
|
||||
|
||||
// Vérifications requises
|
||||
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
|
||||
// Utiliser la certification Stripe si disponible, sinon l'ancienne vérification
|
||||
// checkStripeCertification() fait déjà toutes les vérifications (modèle, iOS version, NFC Android)
|
||||
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
||||
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||
|
||||
// Batterie minimum 10% pour les paiements
|
||||
final sufficientBattery = batteryLevel != null && batteryLevel >= 10;
|
||||
|
||||
return nfcCapable && stripeCertified == true && sufficientBattery;
|
||||
return stripeCertified == true && sufficientBattery;
|
||||
}
|
||||
|
||||
/// Stream pour surveiller les changements de batterie
|
||||
Stream<BatteryState> get batteryStateStream => _battery.onBatteryStateChanged;
|
||||
/// Retourne la raison pour laquelle Tap to Pay n'est pas disponible
|
||||
/// Retourne null si Tap to Pay est disponible
|
||||
String? getTapToPayUnavailableReason() {
|
||||
// Sur Web, Tap to Pay n'est jamais disponible
|
||||
if (kIsWeb) {
|
||||
return 'Tap to Pay non disponible sur Web';
|
||||
}
|
||||
|
||||
final deviceInfo = getStoredDeviceInfo();
|
||||
|
||||
// Vérifier la batterie
|
||||
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||
if (batteryLevel == null) {
|
||||
return 'Niveau de batterie inconnu';
|
||||
}
|
||||
if (batteryLevel < 10) {
|
||||
return 'Batterie insuffisante ($batteryLevel%) - Min. 10% requis';
|
||||
}
|
||||
|
||||
// Vérifier la certification Stripe (inclut déjà modèle, iOS version, NFC Android)
|
||||
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
||||
if (stripeCertified != true) {
|
||||
return 'Appareil non certifié pour Tap to Pay';
|
||||
}
|
||||
|
||||
// Tout est OK
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Version asynchrone avec vérification NFC en temps réel (Android uniquement)
|
||||
Future<String?> getTapToPayUnavailableReasonAsync() async {
|
||||
// Sur Web, Tap to Pay n'est jamais disponible
|
||||
if (kIsWeb) {
|
||||
return 'Tap to Pay non disponible sur Web';
|
||||
}
|
||||
|
||||
final deviceInfo = getStoredDeviceInfo();
|
||||
final platform = deviceInfo['platform'];
|
||||
|
||||
// Vérifier la batterie
|
||||
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||
if (batteryLevel == null) {
|
||||
return 'Niveau de batterie inconnu';
|
||||
}
|
||||
if (batteryLevel < 10) {
|
||||
return 'Batterie insuffisante ($batteryLevel%) - Min. 10% requis';
|
||||
}
|
||||
|
||||
// Sur Android, vérifier le NFC EN TEMPS RÉEL (peut être désactivé dans les paramètres)
|
||||
if (platform == 'Android') {
|
||||
try {
|
||||
final nfcAvailable = await NfcManager.instance.isAvailable();
|
||||
if (!nfcAvailable) {
|
||||
return 'NFC désactivé - Activez-le dans les paramètres Android';
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Impossible de vérifier le statut NFC: $e');
|
||||
return 'Impossible de vérifier le NFC';
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier la certification Stripe (inclut déjà modèle, iOS version)
|
||||
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
||||
if (stripeCertified != true) {
|
||||
return 'Appareil non certifié pour Tap to Pay';
|
||||
}
|
||||
|
||||
// Tout est OK
|
||||
return null;
|
||||
}
|
||||
}
|
||||
312
app/lib/core/services/event_stats_service.dart
Normal file
312
app/lib/core/services/event_stats_service.dart
Normal file
@@ -0,0 +1,312 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/data/models/event_stats_model.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Service pour récupérer les statistiques d'événements depuis l'API.
|
||||
///
|
||||
/// Ce service est un singleton qui gère les appels API vers les endpoints
|
||||
/// /api/events/stats/*. Il est accessible uniquement aux admins (rôle >= 2).
|
||||
class EventStatsService {
|
||||
static EventStatsService? _instance;
|
||||
|
||||
EventStatsService._internal();
|
||||
|
||||
static EventStatsService get instance {
|
||||
_instance ??= EventStatsService._internal();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
final _dateFormat = DateFormat('yyyy-MM-dd');
|
||||
|
||||
/// Récupère le résumé des stats pour une date donnée.
|
||||
///
|
||||
/// GET /api/events/stats/summary?date=YYYY-MM-DD&entity_id=X
|
||||
///
|
||||
/// [date] : Date à récupérer (défaut: aujourd'hui)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
Future<EventSummary> getSummary({
|
||||
DateTime? date,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
|
||||
if (date != null) {
|
||||
queryParams['date'] = _dateFormat.format(date);
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération résumé: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/summary',
|
||||
queryParameters: queryParams.isNotEmpty ? queryParams : null,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return EventSummary.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération du résumé',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getSummary: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des statistiques', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les stats quotidiennes pour une période.
|
||||
///
|
||||
/// GET /api/events/stats/daily?from=YYYY-MM-DD&to=YYYY-MM-DD&events=type1,type2
|
||||
///
|
||||
/// [from] : Date de début (obligatoire)
|
||||
/// [to] : Date de fin (obligatoire)
|
||||
/// [events] : Types d'événements à filtrer (optionnel)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
///
|
||||
/// Limite : 90 jours maximum
|
||||
Future<DailyStats> getDailyStats({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
List<String>? events,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
// Vérifier la limite de 90 jours
|
||||
final daysDiff = to.difference(from).inDays;
|
||||
if (daysDiff > 90) {
|
||||
throw const ApiException('La période ne peut pas dépasser 90 jours');
|
||||
}
|
||||
|
||||
final queryParams = <String, dynamic>{
|
||||
'from': _dateFormat.format(from),
|
||||
'to': _dateFormat.format(to),
|
||||
};
|
||||
|
||||
if (events != null && events.isNotEmpty) {
|
||||
queryParams['events'] = events.join(',');
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération stats quotidiennes: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/daily',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return DailyStats.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des stats quotidiennes',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getDailyStats: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des statistiques quotidiennes', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les stats hebdomadaires pour une période.
|
||||
///
|
||||
/// GET /api/events/stats/weekly?from=YYYY-MM-DD&to=YYYY-MM-DD&events=type1,type2
|
||||
///
|
||||
/// [from] : Date de début (obligatoire)
|
||||
/// [to] : Date de fin (obligatoire)
|
||||
/// [events] : Types d'événements à filtrer (optionnel)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
///
|
||||
/// Limite : 365 jours maximum
|
||||
Future<WeeklyStats> getWeeklyStats({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
List<String>? events,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
// Vérifier la limite de 365 jours
|
||||
final daysDiff = to.difference(from).inDays;
|
||||
if (daysDiff > 365) {
|
||||
throw const ApiException('La période ne peut pas dépasser 365 jours');
|
||||
}
|
||||
|
||||
final queryParams = <String, dynamic>{
|
||||
'from': _dateFormat.format(from),
|
||||
'to': _dateFormat.format(to),
|
||||
};
|
||||
|
||||
if (events != null && events.isNotEmpty) {
|
||||
queryParams['events'] = events.join(',');
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération stats hebdomadaires: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/weekly',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return WeeklyStats.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des stats hebdomadaires',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getWeeklyStats: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des statistiques hebdomadaires', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les stats mensuelles pour une année.
|
||||
///
|
||||
/// GET /api/events/stats/monthly?year=YYYY&events=type1,type2
|
||||
///
|
||||
/// [year] : Année (défaut: année courante)
|
||||
/// [events] : Types d'événements à filtrer (optionnel)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
Future<MonthlyStats> getMonthlyStats({
|
||||
int? year,
|
||||
List<String>? events,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
|
||||
if (year != null) {
|
||||
queryParams['year'] = year.toString();
|
||||
}
|
||||
|
||||
if (events != null && events.isNotEmpty) {
|
||||
queryParams['events'] = events.join(',');
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération stats mensuelles: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/monthly',
|
||||
queryParameters: queryParams.isNotEmpty ? queryParams : null,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return MonthlyStats.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des stats mensuelles',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getMonthlyStats: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des statistiques mensuelles', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les détails des événements pour une date.
|
||||
///
|
||||
/// GET /api/events/stats/details?date=YYYY-MM-DD&event=type&limit=50&offset=0
|
||||
///
|
||||
/// [date] : Date à récupérer (obligatoire)
|
||||
/// [event] : Type d'événement à filtrer (optionnel)
|
||||
/// [limit] : Nombre de résultats max (défaut: 50, max: 100)
|
||||
/// [offset] : Pagination (défaut: 0)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
Future<EventDetails> getDetails({
|
||||
required DateTime date,
|
||||
String? event,
|
||||
int? limit,
|
||||
int? offset,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'date': _dateFormat.format(date),
|
||||
};
|
||||
|
||||
if (event != null && event.isNotEmpty) {
|
||||
queryParams['event'] = event;
|
||||
}
|
||||
|
||||
if (limit != null) {
|
||||
queryParams['limit'] = limit.toString();
|
||||
}
|
||||
|
||||
if (offset != null) {
|
||||
queryParams['offset'] = offset.toString();
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération détails: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/details',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return EventDetails.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des détails',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getDetails: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des détails', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les types d'événements disponibles.
|
||||
///
|
||||
/// GET /api/events/stats/types
|
||||
Future<EventTypes> getEventTypes() async {
|
||||
try {
|
||||
debugPrint('📊 [EventStats] Récupération types d\'événements');
|
||||
|
||||
final response = await ApiService.instance.get('/events/stats/types');
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return EventTypes.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des types',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getEventTypes: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des types d\'événements', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Réinitialise le singleton (pour les tests)
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,57 @@ class HiveService {
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
// === INITIALISATION LÉGÈRE POUR F5 (préserve les données) ===
|
||||
|
||||
/// Initialisation légère de Hive SANS destruction des données
|
||||
/// Utilisée pour le F5 sur Web afin de vérifier si une session existe
|
||||
/// Retourne true si l'initialisation a réussi et qu'une session utilisateur existe
|
||||
Future<bool> initializeWithoutReset() async {
|
||||
try {
|
||||
debugPrint('🔧 Initialisation légère de Hive (préservation des données)...');
|
||||
|
||||
// 1. Initialisation de base de Hive (idempotent)
|
||||
await Hive.initFlutter();
|
||||
debugPrint('✅ Hive.initFlutter() terminé');
|
||||
|
||||
// 2. Enregistrement des adaptateurs (idempotent)
|
||||
_registerAdapters();
|
||||
|
||||
// 3. Ouvrir les boxes SANS les détruire
|
||||
await _createAllBoxes();
|
||||
|
||||
// 4. Vérifier si une session utilisateur existe
|
||||
bool hasSession = false;
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
final userId = settingsBox.get('current_user_id');
|
||||
if (userId != null) {
|
||||
// Vérifier que l'utilisateur existe dans la box user
|
||||
if (Hive.isBoxOpen(AppKeys.userBoxName)) {
|
||||
final userBox = Hive.box<UserModel>(AppKeys.userBoxName);
|
||||
final user = userBox.get(userId);
|
||||
if (user != null && user.hasValidSession) {
|
||||
hasSession = true;
|
||||
debugPrint('✅ Session utilisateur trouvée pour ID: $userId');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur vérification session: $e');
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ Initialisation légère terminée, session existante: $hasSession');
|
||||
return hasSession;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'initialisation légère: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// === INITIALISATION COMPLÈTE (appelée par main.dart) ===
|
||||
|
||||
/// Initialisation complète de Hive avec réinitialisation totale
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:mek_stripe_terminal/mek_stripe_terminal.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import 'device_info_service.dart';
|
||||
@@ -13,6 +14,7 @@ class StripeTapToPayService {
|
||||
StripeTapToPayService._internal();
|
||||
|
||||
bool _isInitialized = false;
|
||||
static bool _terminalInitialized = false; // Flag STATIC pour savoir si Terminal.initTerminal a été appelé (global à l'app)
|
||||
String? _stripeAccountId;
|
||||
String? _locationId;
|
||||
bool _deviceCompatible = false;
|
||||
@@ -78,6 +80,36 @@ class StripeTapToPayService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Initialiser le SDK Stripe Terminal (une seule fois par session app)
|
||||
if (!_terminalInitialized) {
|
||||
try {
|
||||
debugPrint('🔧 Initialisation du SDK Stripe Terminal...');
|
||||
await Terminal.initTerminal(
|
||||
fetchToken: _fetchConnectionToken,
|
||||
);
|
||||
_terminalInitialized = true;
|
||||
debugPrint('✅ SDK Stripe Terminal initialisé');
|
||||
} catch (e) {
|
||||
final errorMsg = e.toString().toLowerCase();
|
||||
debugPrint('🔍 Exception capturée lors de l\'initialisation: $e');
|
||||
debugPrint('🔍 Type d\'exception: ${e.runtimeType}');
|
||||
|
||||
// Vérifier plusieurs variantes du message "already initialized"
|
||||
if (errorMsg.contains('already initialized') ||
|
||||
errorMsg.contains('already been initialized') ||
|
||||
errorMsg.contains('sdkfailure')) {
|
||||
debugPrint('ℹ️ SDK Stripe Terminal déjà initialisé (détecté via exception)');
|
||||
_terminalInitialized = true;
|
||||
// Ne PAS rethrow - continuer normalement car c'est un état valide
|
||||
} else {
|
||||
debugPrint('❌ Erreur inattendue lors de l\'initialisation du SDK');
|
||||
rethrow; // Autre erreur, on la propage
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugPrint('ℹ️ SDK Stripe Terminal déjà initialisé, réutilisation');
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ Tap to Pay initialisé avec succès');
|
||||
|
||||
@@ -101,6 +133,34 @@ class StripeTapToPayService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback pour récupérer un token de connexion depuis l'API
|
||||
Future<String> _fetchConnectionToken() async {
|
||||
try {
|
||||
debugPrint('🔑 Récupération du token de connexion Stripe...');
|
||||
|
||||
final response = await ApiService.instance.post(
|
||||
'/stripe/terminal/connection-token',
|
||||
data: {
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'stripe_account': _stripeAccountId,
|
||||
'location_id': _locationId,
|
||||
}
|
||||
);
|
||||
|
||||
final token = response.data['secret'];
|
||||
if (token == null || token.isEmpty) {
|
||||
throw Exception('Token de connexion invalide');
|
||||
}
|
||||
|
||||
debugPrint('✅ Token de connexion récupéré');
|
||||
return token;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur récupération token: $e');
|
||||
throw Exception('Impossible de récupérer le token de connexion');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un PaymentIntent pour un paiement Tap to Pay
|
||||
Future<PaymentIntentResult?> createPaymentIntent({
|
||||
required int amountInCents,
|
||||
@@ -124,21 +184,25 @@ class StripeTapToPayService {
|
||||
// Extraire passage_id des metadata si présent
|
||||
final passageId = metadata?['passage_id'] ?? '0';
|
||||
|
||||
final requestData = {
|
||||
'amount': amountInCents,
|
||||
'currency': 'eur',
|
||||
'description': description ?? 'Calendrier pompiers',
|
||||
'payment_method_types': ['card_present'], // Pour Tap to Pay
|
||||
'capture_method': 'automatic',
|
||||
'passage_id': int.tryParse(passageId.toString()) ?? 0,
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
'stripe_account': _stripeAccountId,
|
||||
'location_id': _locationId,
|
||||
'metadata': metadata,
|
||||
};
|
||||
|
||||
debugPrint('🔵 Données envoyées create-intent: $requestData');
|
||||
|
||||
final response = await ApiService.instance.post(
|
||||
'/stripe/payments/create-intent',
|
||||
data: {
|
||||
'amount': amountInCents,
|
||||
'currency': 'eur',
|
||||
'description': description ?? 'Calendrier pompiers',
|
||||
'payment_method_types': ['card_present'], // Pour Tap to Pay
|
||||
'capture_method': 'automatic',
|
||||
'passage_id': int.tryParse(passageId.toString()) ?? 0,
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
'stripe_account': _stripeAccountId,
|
||||
'location_id': _locationId,
|
||||
'metadata': metadata,
|
||||
},
|
||||
data: requestData,
|
||||
);
|
||||
|
||||
final result = PaymentIntentResult(
|
||||
@@ -169,11 +233,110 @@ class StripeTapToPayService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simule le processus de collecte de paiement
|
||||
/// (Dans la version finale, cela appellera le SDK natif)
|
||||
/// Découvre et connecte le reader Tap to Pay local
|
||||
Future<bool> _ensureReaderConnected() async {
|
||||
try {
|
||||
debugPrint('🔍 Découverte du reader Tap to Pay...');
|
||||
|
||||
// Configuration pour découvrir le reader local (Tap to Pay)
|
||||
// Détection de l'environnement via l'URL de l'API (plus fiable que kDebugMode)
|
||||
final apiUrl = ApiService.instance.baseUrl;
|
||||
final isProduction = apiUrl.contains('app3.geosector.fr');
|
||||
final isSimulated = !isProduction; // Simulé uniquement si pas en PROD
|
||||
|
||||
final config = TapToPayDiscoveryConfiguration(
|
||||
isSimulated: isSimulated,
|
||||
);
|
||||
|
||||
debugPrint('🔧 Environnement: ${isProduction ? "PRODUCTION (réel)" : "DEV/REC (simulé)"}');
|
||||
debugPrint('🔧 isSimulated: $isSimulated');
|
||||
|
||||
// Découvrir les readers avec un Completer pour gérer le stream correctement
|
||||
final completer = Completer<Reader?>();
|
||||
StreamSubscription<List<Reader>>? subscription;
|
||||
|
||||
subscription = Terminal.instance.discoverReaders(config).listen(
|
||||
(readers) {
|
||||
debugPrint('📡 Stream readers reçu: ${readers.length} reader(s)');
|
||||
if (readers.isNotEmpty && !completer.isCompleted) {
|
||||
debugPrint('📱 ${readers.length} reader(s) trouvé(s): ${readers.map((r) => r.label).join(", ")}');
|
||||
completer.complete(readers.first);
|
||||
subscription?.cancel();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('❌ Erreur lors de la découverte: $error');
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
subscription?.cancel();
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('🏁 Stream découverte terminé');
|
||||
if (!completer.isCompleted) {
|
||||
debugPrint('⚠️ Découverte terminée sans reader trouvé');
|
||||
completer.complete(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('⏳ Attente du résultat de la découverte...');
|
||||
|
||||
// Attendre le résultat avec timeout
|
||||
final reader = await completer.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
debugPrint('⏱️ Timeout lors de la découverte du reader');
|
||||
subscription?.cancel();
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (reader == null) {
|
||||
debugPrint('❌ Aucun reader Tap to Pay trouvé');
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint('📱 Reader trouvé: ${reader.label}');
|
||||
|
||||
// Se connecter au reader
|
||||
debugPrint('🔌 Connexion au reader...');
|
||||
final connectionConfig = TapToPayConnectionConfiguration(
|
||||
locationId: _locationId ?? '',
|
||||
readerDelegate: null, // Pas de delegate pour l'instant
|
||||
);
|
||||
|
||||
await Terminal.instance.connectReader(
|
||||
reader,
|
||||
configuration: connectionConfig,
|
||||
);
|
||||
|
||||
debugPrint('✅ Connecté au reader Tap to Pay');
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur connexion reader: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Collecte le paiement avec le SDK Stripe Terminal
|
||||
Future<bool> collectPayment(PaymentIntentResult paymentIntent) async {
|
||||
try {
|
||||
debugPrint('💳 Collecte du paiement...');
|
||||
debugPrint('💳 Collecte du paiement avec SDK...');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.processing,
|
||||
message: 'Préparation du terminal...',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
));
|
||||
|
||||
// 1. S'assurer qu'un reader est connecté
|
||||
debugPrint('🔌 Vérification connexion reader...');
|
||||
final readerConnected = await _ensureReaderConnected();
|
||||
if (!readerConnected) {
|
||||
throw Exception('Impossible de se connecter au reader Tap to Pay');
|
||||
}
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.processing,
|
||||
@@ -181,11 +344,22 @@ class StripeTapToPayService {
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
));
|
||||
|
||||
// TODO: Ici, intégrer le vrai SDK Stripe Terminal
|
||||
// Pour l'instant, on simule une attente
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
// 2. Récupérer le PaymentIntent depuis le SDK avec le clientSecret
|
||||
debugPrint('💳 Récupération du PaymentIntent...');
|
||||
final stripePaymentIntent = await Terminal.instance.retrievePaymentIntent(
|
||||
paymentIntent.clientSecret,
|
||||
);
|
||||
|
||||
debugPrint('✅ Paiement collecté');
|
||||
// 3. Utiliser le SDK Stripe Terminal pour collecter le paiement
|
||||
debugPrint('💳 En attente du paiement sans contact...');
|
||||
final collectedPaymentIntent = await Terminal.instance.collectPaymentMethod(
|
||||
stripePaymentIntent,
|
||||
);
|
||||
|
||||
// Sauvegarder le PaymentIntent collecté pour l'étape de confirmation
|
||||
paymentIntent._collectedPaymentIntent = collectedPaymentIntent;
|
||||
|
||||
debugPrint('✅ Paiement collecté via SDK');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.confirming,
|
||||
@@ -208,33 +382,37 @@ class StripeTapToPayService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirme le paiement auprès du serveur
|
||||
/// Confirme le paiement via le SDK Stripe Terminal
|
||||
Future<bool> confirmPayment(PaymentIntentResult paymentIntent) async {
|
||||
try {
|
||||
debugPrint('✅ Confirmation du paiement...');
|
||||
debugPrint('✅ Confirmation du paiement via SDK...');
|
||||
|
||||
// Notifier le serveur du succès
|
||||
await ApiService.instance.post(
|
||||
'/stripe/payments/confirm',
|
||||
data: {
|
||||
'payment_intent_id': paymentIntent.paymentIntentId,
|
||||
'amount': paymentIntent.amount,
|
||||
'status': 'succeeded',
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
},
|
||||
// Vérifier que le paiement a été collecté
|
||||
if (paymentIntent._collectedPaymentIntent == null) {
|
||||
throw Exception('Le paiement doit d\'abord être collecté');
|
||||
}
|
||||
|
||||
// Utiliser le SDK Stripe Terminal pour confirmer le paiement
|
||||
final confirmedPaymentIntent = await Terminal.instance.confirmPaymentIntent(
|
||||
paymentIntent._collectedPaymentIntent!,
|
||||
);
|
||||
|
||||
debugPrint('🎉 Paiement confirmé avec succès');
|
||||
// Vérifier le statut final
|
||||
if (confirmedPaymentIntent.status == PaymentIntentStatus.succeeded) {
|
||||
debugPrint('🎉 Paiement confirmé avec succès via SDK');
|
||||
debugPrint(' Payment Intent: ${paymentIntent.paymentIntentId}');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.success,
|
||||
message: 'Paiement réussi',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
amount: paymentIntent.amount,
|
||||
));
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.success,
|
||||
message: 'Paiement réussi',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
amount: paymentIntent.amount,
|
||||
));
|
||||
|
||||
return true;
|
||||
return true;
|
||||
} else {
|
||||
throw Exception('Paiement non confirmé: ${confirmedPaymentIntent.status}');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur confirmation paiement: $e');
|
||||
@@ -304,6 +482,9 @@ class PaymentIntentResult {
|
||||
final String clientSecret;
|
||||
final int amount;
|
||||
|
||||
// PaymentIntent collecté (utilisé entre collectPayment et confirmPayment)
|
||||
PaymentIntent? _collectedPaymentIntent;
|
||||
|
||||
PaymentIntentResult({
|
||||
required this.paymentIntentId,
|
||||
required this.clientSecret,
|
||||
|
||||
@@ -31,6 +31,7 @@ class ApiException implements Exception {
|
||||
if (response?.data != null) {
|
||||
try {
|
||||
final data = response!.data as Map<String, dynamic>;
|
||||
debugPrint('🔍 API Error Response: $data');
|
||||
|
||||
// Message spécifique de l'API
|
||||
if (data.containsKey('message')) {
|
||||
@@ -42,12 +43,21 @@ class ApiException implements Exception {
|
||||
errorCode = data['error_code'] as String;
|
||||
}
|
||||
|
||||
// Détails supplémentaires
|
||||
// Détails supplémentaires - peut être une Map ou une List
|
||||
if (data.containsKey('errors')) {
|
||||
details = data['errors'] as Map<String, dynamic>?;
|
||||
final errorsData = data['errors'];
|
||||
if (errorsData is Map<String, dynamic>) {
|
||||
// Format: {field: [errors]}
|
||||
details = errorsData;
|
||||
} else if (errorsData is List) {
|
||||
// Format: [error1, error2, ...]
|
||||
details = {'errors': errorsData};
|
||||
}
|
||||
debugPrint('🔍 Validation Errors: $details');
|
||||
}
|
||||
} catch (e) {
|
||||
// Si on ne peut pas parser la réponse, utiliser le message par défaut
|
||||
debugPrint('⚠️ Impossible de parser la réponse d\'erreur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +140,43 @@ class ApiException implements Exception {
|
||||
String toString() => message;
|
||||
|
||||
/// Obtenir un message d'erreur formaté pour l'affichage
|
||||
String get displayMessage => message;
|
||||
String get displayMessage {
|
||||
debugPrint('🔍 [displayMessage] statusCode: $statusCode');
|
||||
debugPrint('🔍 [displayMessage] isValidationError: $isValidationError');
|
||||
debugPrint('🔍 [displayMessage] details: $details');
|
||||
debugPrint('🔍 [displayMessage] details != null: ${details != null}');
|
||||
debugPrint('🔍 [displayMessage] details!.isNotEmpty: ${details != null ? details!.isNotEmpty : "null"}');
|
||||
|
||||
// Si c'est une erreur de validation avec des détails, formater le message
|
||||
if (isValidationError && details != null && details!.isNotEmpty) {
|
||||
debugPrint('✅ [displayMessage] Formatage des erreurs de validation');
|
||||
final buffer = StringBuffer(message);
|
||||
buffer.write('\n');
|
||||
|
||||
details!.forEach((field, errors) {
|
||||
if (errors is List) {
|
||||
// Si le champ est 'errors', c'est une liste simple d'erreurs
|
||||
if (field == 'errors') {
|
||||
for (final error in errors) {
|
||||
buffer.write('• $error\n');
|
||||
}
|
||||
} else {
|
||||
// Sinon c'est un champ avec une liste d'erreurs
|
||||
for (final error in errors) {
|
||||
buffer.write('• $field: $error\n');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
buffer.write('• $field: $errors\n');
|
||||
}
|
||||
});
|
||||
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
|
||||
debugPrint('⚠️ [displayMessage] Retour du message simple');
|
||||
return message;
|
||||
}
|
||||
|
||||
/// Vérifier si c'est une erreur de validation
|
||||
bool get isValidationError => statusCode == 422 || statusCode == 400;
|
||||
|
||||
1135
app/lib/presentation/admin/admin_connexions_page.dart
Normal file
1135
app/lib/presentation/admin/admin_connexions_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -807,7 +807,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
const SizedBox(
|
||||
height: 16),
|
||||
Text(
|
||||
'Vous allez recevoir un email contenant :',
|
||||
'Vous allez recevoir 2 emails contenant :',
|
||||
style: theme
|
||||
.textTheme
|
||||
.bodyMedium,
|
||||
@@ -852,7 +852,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
width: 4),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Un lien pour définir votre mot de passe'),
|
||||
'Votre mot de passe de connexion'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -313,13 +313,29 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
// Appeler le nouvel endpoint API pour restaurer la session
|
||||
// IMPORTANT: Utiliser getWithoutQueue() pour ne JAMAIS mettre cette requête en file d'attente
|
||||
// Les refresh de session sont liés à une session spécifique et ne doivent pas être rejoués
|
||||
debugPrint('🔄 Appel API: user/session avec sessionId: ${sessionId.substring(0, 10)}...');
|
||||
|
||||
final response = await ApiService.instance.getWithoutQueue(
|
||||
'/api/user/session',
|
||||
'user/session',
|
||||
queryParameters: {'mode': displayMode},
|
||||
);
|
||||
|
||||
// Gestion des codes de retour HTTP
|
||||
final statusCode = response.statusCode ?? 0;
|
||||
|
||||
// Vérifier que la réponse est bien du JSON et pas du HTML
|
||||
if (response.data is String) {
|
||||
final dataStr = response.data as String;
|
||||
if (dataStr.contains('<!DOCTYPE') || dataStr.contains('<html')) {
|
||||
debugPrint('❌ ERREUR: L\'API a retourné du HTML au lieu de JSON !');
|
||||
debugPrint('❌ StatusCode: $statusCode');
|
||||
debugPrint('❌ URL de base: ${ApiService.instance.baseUrl}');
|
||||
debugPrint('❌ Début de la réponse: ${dataStr.substring(0, 100)}...');
|
||||
await CurrentUserService.instance.clearUser();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final data = response.data as Map<String, dynamic>?;
|
||||
|
||||
switch (statusCode) {
|
||||
@@ -599,12 +615,59 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
});
|
||||
}
|
||||
|
||||
// Étape 2: Initialisation Hive - 15 à 60% (étape la plus longue)
|
||||
// === GESTION F5 WEB : Vérifier session AVANT de détruire les données ===
|
||||
// Sur Web, on essaie d'abord de récupérer une session existante
|
||||
if (kIsWeb) {
|
||||
debugPrint('🌐 Web détecté - tentative de récupération de session existante...');
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Vérification de session...";
|
||||
_progress = 0.20;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialisation légère qui préserve les données
|
||||
final hasExistingSession = await HiveService.instance.initializeWithoutReset();
|
||||
|
||||
if (hasExistingSession) {
|
||||
debugPrint('✅ Session existante détectée, tentative de restauration...');
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Restauration de la session...";
|
||||
_progress = 0.40;
|
||||
});
|
||||
}
|
||||
|
||||
// Tenter la restauration via l'API
|
||||
final sessionRestored = await _handleSessionRefreshIfNeeded();
|
||||
if (sessionRestored) {
|
||||
debugPrint('✅ Session restaurée via F5 - fin de l\'initialisation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Si la restauration API échoue, on continue vers le login
|
||||
debugPrint('⚠️ Restauration API échouée, passage au login normal');
|
||||
} else {
|
||||
debugPrint('ℹ️ Pas de session existante, initialisation normale');
|
||||
}
|
||||
}
|
||||
|
||||
// === INITIALISATION NORMALE (si pas de session F5 ou pas Web) ===
|
||||
// Étape 2: Initialisation Hive complète - 15 à 60%
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Configuration du stockage...";
|
||||
_progress = 0.30;
|
||||
});
|
||||
}
|
||||
|
||||
await HiveService.instance.initializeAndResetHive();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Configuration du stockage...";
|
||||
_statusMessage = "Préparation des données...";
|
||||
_progress = 0.45;
|
||||
});
|
||||
}
|
||||
@@ -613,7 +676,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Préparation des données...";
|
||||
_statusMessage = "Ouverture des bases...";
|
||||
_progress = 0.60;
|
||||
});
|
||||
}
|
||||
@@ -621,19 +684,9 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
// Étape 3: Ouverture des Box - 60 à 80%
|
||||
await HiveService.instance.ensureBoxesAreOpen();
|
||||
|
||||
// NOUVEAU : Vérifier et nettoyer si nouvelle version (Web uniquement)
|
||||
// Maintenant que les boxes sont ouvertes, on peut vérifier la version dans Hive
|
||||
// Vérifier et nettoyer si nouvelle version (Web uniquement)
|
||||
await _checkVersionAndCleanIfNeeded();
|
||||
|
||||
// NOUVEAU : Détecter et gérer le F5 (refresh de page web avec session existante)
|
||||
final sessionRestored = await _handleSessionRefreshIfNeeded();
|
||||
if (sessionRestored) {
|
||||
// Session restaurée avec succès, on arrête ici
|
||||
// L'utilisateur a été redirigé vers son interface
|
||||
debugPrint('✅ Session restaurée via F5 - fin de l\'initialisation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Gérer la box pending_requests séparément pour préserver les données
|
||||
try {
|
||||
debugPrint('📦 Gestion de la box pending_requests...');
|
||||
|
||||
39
app/lib/presentation/pages/connexions_page.dart
Normal file
39
app/lib/presentation/pages/connexions_page.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
|
||||
import 'package:geosector_app/presentation/admin/admin_connexions_page.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
|
||||
/// Page des connexions et événements utilisant AppScaffold.
|
||||
/// Accessible uniquement aux administrateurs (rôle >= 2).
|
||||
///
|
||||
/// - Admin Amicale (rôle 2) : voit les connexions de son amicale uniquement
|
||||
/// - Super Admin (rôle >= 3) : voit les connexions de toutes les amicales
|
||||
class ConnexionsPage extends StatelessWidget {
|
||||
const ConnexionsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Vérifier le rôle pour l'accès
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final userRole = currentUser?.role ?? 1;
|
||||
|
||||
// Vérifier que l'utilisateur a le rôle 2 minimum (admin amicale)
|
||||
if (userRole < 2) {
|
||||
// Rediriger vers le dashboard user
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Navigator.of(context).pushReplacementNamed('/user/dashboard');
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
key: const ValueKey('connexions_scaffold_admin'),
|
||||
selectedIndex: 6, // Connexions est l'index 6
|
||||
pageTitle: 'Connexions',
|
||||
body: AdminConnexionsPage(
|
||||
userRepository: userRepository,
|
||||
amicaleRepository: amicaleRepository,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,17 +40,19 @@ class _HomeContentState extends State<HomeContent> {
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
// Retourner seulement le contenu (sans scaffold)
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
|
||||
vertical: AppTheme.spacingL,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Widget BtnPassages
|
||||
const BtnPassages(),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
return Column(
|
||||
children: [
|
||||
// Widget BtnPassages collé en haut/gauche/droite
|
||||
const BtnPassages(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
|
||||
vertical: AppTheme.spacingL,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
// LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement)
|
||||
isDesktop
|
||||
@@ -172,9 +174,12 @@ class _HomeContentState extends State<HomeContent> {
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construit la carte de répartition par type de passage
|
||||
|
||||
@@ -123,6 +123,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
// État pour bloquer la sauvegarde du zoom lors du centrage sur secteur
|
||||
bool _isCenteringOnSector = false;
|
||||
|
||||
// Source des tuiles de la carte (IGN Plan ou IGN Ortho)
|
||||
TileSource _tileSource = TileSource.ignPlan;
|
||||
|
||||
// Comptages des secteurs (calculés uniquement lors de création/modification de secteurs)
|
||||
Map<int, int> _sectorPassageCount = {};
|
||||
Map<int, int> _sectorMemberCount = {};
|
||||
@@ -215,6 +218,16 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
_settingsBox.put('mapZoom', 15.0);
|
||||
debugPrint('🔍 MapPage: Aucun zoom sauvegardé, utilisation du défaut = 15.0');
|
||||
}
|
||||
|
||||
// Charger la source des tuiles (IGN Plan par défaut)
|
||||
final savedTileSource = _settingsBox.get('mapTileSource');
|
||||
if (savedTileSource != null) {
|
||||
_tileSource = TileSource.values.firstWhere(
|
||||
(t) => t.name == savedTileSource,
|
||||
orElse: () => TileSource.ignPlan,
|
||||
);
|
||||
debugPrint('🗺️ MapPage: Source tuiles chargée = $_tileSource');
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour gérer les changements de sélection de secteur
|
||||
@@ -4151,8 +4164,8 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
initialZoom: _currentZoom,
|
||||
mapController: _mapController,
|
||||
disableDrag: _isDraggingPoint,
|
||||
// Utiliser OpenStreetMap temporairement sur mobile si Mapbox échoue
|
||||
useOpenStreetMap: !kIsWeb, // true sur mobile, false sur web
|
||||
// Utiliser les tuiles IGN (Plan ou Ortho selon le choix utilisateur)
|
||||
tileSource: _tileSource,
|
||||
labelMarkers: _buildSectorLabels(),
|
||||
markers: [
|
||||
..._buildMarkers(),
|
||||
@@ -4199,14 +4212,38 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
),
|
||||
)),
|
||||
|
||||
// Boutons d'action en haut à droite (Web uniquement et admin seulement)
|
||||
if (kIsWeb && canEditSectors)
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Bouton switch IGN Plan / Ortho en haut à droite (visible pour tous)
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Bouton switch IGN Plan / Ortho
|
||||
// L'icône montre l'action (vers quoi on bascule), pas l'état actuel
|
||||
_buildActionButton(
|
||||
icon: _tileSource == TileSource.ignPlan
|
||||
? Icons.satellite_alt // En mode plan → afficher satellite pour basculer
|
||||
: Icons.map_outlined, // En mode ortho → afficher plan pour basculer
|
||||
tooltip: _tileSource == TileSource.ignPlan
|
||||
? 'Passer en vue satellite'
|
||||
: 'Passer en vue plan',
|
||||
color: Colors.white,
|
||||
iconColor: Colors.blueGrey[700],
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_tileSource = _tileSource == TileSource.ignPlan
|
||||
? TileSource.ignOrtho
|
||||
: TileSource.ignPlan;
|
||||
_settingsBox.put('mapTileSource', _tileSource.name);
|
||||
debugPrint('🗺️ MapPage: Source tuiles changée = $_tileSource');
|
||||
});
|
||||
},
|
||||
),
|
||||
// Espacement avant les boutons admin
|
||||
if (kIsWeb && canEditSectors) const SizedBox(height: 16),
|
||||
// Boutons admin (création, modification, suppression de secteurs)
|
||||
if (kIsWeb && canEditSectors) ...[
|
||||
// Bouton Créer
|
||||
_buildActionButton(
|
||||
icon: Icons.pentagon_outlined,
|
||||
@@ -4246,8 +4283,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Menu contextuel (apparaît selon le mode) - Web uniquement et admin seulement
|
||||
if (kIsWeb && canEditSectors && _mapMode != MapMode.view)
|
||||
|
||||
@@ -8,13 +8,14 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter_compass/flutter_compass.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/grouped_passages_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/mapbox_map.dart' show TileSource;
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
|
||||
@@ -59,10 +60,20 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
// Listener pour les changements de la box passages
|
||||
Box<PassageModel>? _passagesBox;
|
||||
|
||||
// Source des tuiles de la carte (IGN Plan ou IGN Ortho)
|
||||
TileSource _tileSource = TileSource.ignPlan;
|
||||
Box? _settingsBox;
|
||||
|
||||
// Mode boussole (Android/iOS uniquement)
|
||||
bool _compassModeEnabled = false;
|
||||
StreamSubscription<CompassEvent>? _compassSubscription;
|
||||
double _currentHeading = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
_loadTileSourceSetting();
|
||||
|
||||
// Écouter les changements de la Hive box passages pour rafraîchir la carte
|
||||
_passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
@@ -85,6 +96,26 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
}
|
||||
}
|
||||
|
||||
// Charger le paramètre de source des tuiles depuis Hive
|
||||
Future<void> _loadTileSourceSetting() async {
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
|
||||
} else {
|
||||
_settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
}
|
||||
|
||||
final savedTileSource = _settingsBox?.get('mapTileSource');
|
||||
if (savedTileSource != null && mounted) {
|
||||
setState(() {
|
||||
_tileSource = TileSource.values.firstWhere(
|
||||
(t) => t.name == savedTileSource,
|
||||
orElse: () => TileSource.ignPlan,
|
||||
);
|
||||
});
|
||||
debugPrint('FieldMode: Source tuiles chargée = $_tileSource');
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeWebMode() async {
|
||||
// Essayer d'obtenir la position réelle depuis le navigateur
|
||||
try {
|
||||
@@ -539,6 +570,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
void dispose() {
|
||||
_positionStreamSubscription?.cancel();
|
||||
_qualityUpdateTimer?.cancel();
|
||||
_compassSubscription?.cancel();
|
||||
_gpsBlinkController.dispose();
|
||||
_networkBlinkController.dispose();
|
||||
_searchController.dispose();
|
||||
@@ -546,6 +578,35 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Activer/désactiver le mode boussole (Android/iOS uniquement)
|
||||
void _toggleCompassMode() {
|
||||
if (kIsWeb) return; // Pas de boussole sur web
|
||||
|
||||
setState(() {
|
||||
_compassModeEnabled = !_compassModeEnabled;
|
||||
});
|
||||
|
||||
if (_compassModeEnabled) {
|
||||
// Activer l'écoute de la boussole
|
||||
_compassSubscription = FlutterCompass.events?.listen((CompassEvent event) {
|
||||
if (event.heading != null && mounted) {
|
||||
setState(() {
|
||||
_currentHeading = event.heading!;
|
||||
});
|
||||
// Faire pivoter la carte selon la direction
|
||||
_mapController.rotate(-_currentHeading);
|
||||
}
|
||||
});
|
||||
debugPrint('FieldMode: Mode boussole activé');
|
||||
} else {
|
||||
// Désactiver l'écoute et remettre la carte vers le nord
|
||||
_compassSubscription?.cancel();
|
||||
_compassSubscription = null;
|
||||
_mapController.rotate(0);
|
||||
debugPrint('FieldMode: Mode boussole désactivé');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -823,10 +884,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
);
|
||||
}
|
||||
|
||||
final apiService = ApiService.instance;
|
||||
final mapboxApiKey =
|
||||
AppKeys.getMapboxApiKey(apiService.getCurrentEnvironment());
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
@@ -837,21 +894,36 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
initialZoom: 17,
|
||||
maxZoom: 19,
|
||||
minZoom: 10,
|
||||
interactionOptions: const InteractionOptions(
|
||||
interactionOptions: InteractionOptions(
|
||||
enableMultiFingerGestureRace: true,
|
||||
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||
// Permettre la rotation uniquement si le mode boussole est activé
|
||||
flags: _compassModeEnabled
|
||||
? InteractiveFlag.all
|
||||
: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
// Utiliser l'API v4 de Mapbox sur mobile ou OpenStreetMap en fallback
|
||||
urlTemplate: kIsWeb
|
||||
? 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=$mapboxApiKey'
|
||||
: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', // OpenStreetMap temporairement sur mobile
|
||||
// Utiliser les tuiles IGN (Plan ou Ortho selon le choix utilisateur)
|
||||
urlTemplate: _tileSource == TileSource.ignOrtho
|
||||
? 'https://data.geopf.fr/wmts?'
|
||||
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
|
||||
'&TILEMATRIXSET=PM'
|
||||
'&LAYER=ORTHOIMAGERY.ORTHOPHOTOS'
|
||||
'&STYLE=normal'
|
||||
'&FORMAT=image/jpeg'
|
||||
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}'
|
||||
: 'https://data.geopf.fr/wmts?'
|
||||
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
|
||||
'&TILEMATRIXSET=PM'
|
||||
'&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2'
|
||||
'&STYLE=normal'
|
||||
'&FORMAT=image/png'
|
||||
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}',
|
||||
userAgentPackageName: 'app3.geosector.fr',
|
||||
additionalOptions: const {
|
||||
'attribution': '© OpenStreetMap contributors',
|
||||
},
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 20,
|
||||
minZoom: 7,
|
||||
),
|
||||
// Markers des passages
|
||||
MarkerLayer(
|
||||
@@ -900,6 +972,56 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
),
|
||||
// Boutons haut droite (IGN + Boussole)
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: Column(
|
||||
children: [
|
||||
// Bouton switch IGN Plan / Ortho
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'tileSource',
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.green[700],
|
||||
tooltip: _tileSource == TileSource.ignPlan
|
||||
? 'Passer en vue satellite'
|
||||
: 'Passer en vue plan',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_tileSource = _tileSource == TileSource.ignPlan
|
||||
? TileSource.ignOrtho
|
||||
: TileSource.ignPlan;
|
||||
});
|
||||
// Sauvegarder le choix
|
||||
_settingsBox?.put('mapTileSource', _tileSource.name);
|
||||
debugPrint('FieldMode: Source tuiles = $_tileSource');
|
||||
},
|
||||
// L'icône montre l'action (vers quoi on bascule), pas l'état actuel
|
||||
child: Icon(
|
||||
_tileSource == TileSource.ignPlan
|
||||
? Icons.satellite_alt // En mode plan → afficher satellite pour basculer
|
||||
: Icons.map_outlined, // En mode ortho → afficher plan pour basculer
|
||||
),
|
||||
),
|
||||
// Bouton mode boussole (uniquement sur mobile)
|
||||
if (!kIsWeb) ...[
|
||||
const SizedBox(height: 8),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'compass',
|
||||
backgroundColor: _compassModeEnabled ? Colors.green[700] : Colors.white,
|
||||
foregroundColor: _compassModeEnabled ? Colors.white : Colors.green[700],
|
||||
tooltip: _compassModeEnabled
|
||||
? 'Désactiver le mode boussole'
|
||||
: 'Activer le mode boussole',
|
||||
onPressed: _toggleCompassMode,
|
||||
child: Icon(
|
||||
_compassModeEnabled ? Icons.explore : Icons.explore_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -305,6 +305,11 @@ class NavigationHelper {
|
||||
selectedIcon: Icon(Icons.calendar_today),
|
||||
label: 'Opérations',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.analytics_outlined),
|
||||
selectedIcon: Icon(Icons.analytics),
|
||||
label: 'Connexions',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -341,6 +346,9 @@ class NavigationHelper {
|
||||
case 5:
|
||||
context.go('/admin/operations');
|
||||
break;
|
||||
case 6:
|
||||
context.go('/admin/connexions');
|
||||
break;
|
||||
default:
|
||||
context.go('/admin');
|
||||
}
|
||||
@@ -380,6 +388,7 @@ class NavigationHelper {
|
||||
if (cleanRoute.contains('/admin/messages')) return 3;
|
||||
if (cleanRoute.contains('/admin/amicale')) return 4;
|
||||
if (cleanRoute.contains('/admin/operations')) return 5;
|
||||
if (cleanRoute.contains('/admin/connexions')) return 6;
|
||||
return 0; // Dashboard par défaut
|
||||
} else {
|
||||
if (cleanRoute.contains('/user/history')) return 1;
|
||||
@@ -400,6 +409,7 @@ class NavigationHelper {
|
||||
case 3: return 'messages';
|
||||
case 4: return 'amicale';
|
||||
case 5: return 'operations';
|
||||
case 6: return 'connexions';
|
||||
default: return 'dashboard';
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -40,7 +40,7 @@ class BtnPassages extends StatelessWidget {
|
||||
final shouldShowLotType = _shouldShowLotType();
|
||||
|
||||
return SizedBox(
|
||||
height: 80,
|
||||
height: 92, // 80 + 12 pour le triangle indicateur
|
||||
width: double.infinity,
|
||||
child: ValueListenableBuilder<Box<PassageModel>>(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
@@ -121,6 +121,7 @@ class BtnPassages extends StatelessWidget {
|
||||
/// Colonne TOTAL (cliquable, affiche tous les passages)
|
||||
Widget _buildTotalColumn(BuildContext context, int total) {
|
||||
final bool isSelected = selectedTypeId == null;
|
||||
final Color bgColor = Colors.grey[200]!;
|
||||
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
@@ -147,55 +148,71 @@ class BtnPassages extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
border: Border.all(
|
||||
color: Colors.grey[400]!,
|
||||
width: isSelected ? 5 : 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
bottomLeft: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.route,
|
||||
size: 20,
|
||||
color: Colors.black54,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
total.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
border: Border.all(
|
||||
color: Colors.grey[400]!,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
bottomLeft: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.route,
|
||||
size: 20,
|
||||
color: Colors.black54,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
total.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
total > 1 ? 'passages' : 'passage',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
total > 1 ? 'passages' : 'passage',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
// Triangle indicateur de sélection
|
||||
if (isSelected)
|
||||
Center(
|
||||
child: CustomPaint(
|
||||
size: const Size(20, 12),
|
||||
painter: _TrianglePainter(color: bgColor),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -236,62 +253,78 @@ class BtnPassages extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: couleur.withOpacity(0.1),
|
||||
border: Border.all(
|
||||
color: couleur,
|
||||
width: isSelected ? 5 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
iconData,
|
||||
size: 20,
|
||||
color: couleur,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: couleur,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text(
|
||||
titre,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
border: Border.all(
|
||||
color: couleur,
|
||||
width: 1,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
iconData,
|
||||
size: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text(
|
||||
titre,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Triangle indicateur de sélection
|
||||
if (isSelected)
|
||||
Center(
|
||||
child: CustomPaint(
|
||||
size: const Size(20, 12),
|
||||
painter: _TrianglePainter(color: couleur),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Colonne NOUVEAU PASSAGE (bouton +, fond vert)
|
||||
/// Colonne NOUVEAU PASSAGE (bouton +, fond blanc)
|
||||
Widget _buildAddColumn(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -302,47 +335,55 @@ class BtnPassages extends StatelessWidget {
|
||||
_showPassageFormDialog(context);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.buttonSuccessColor.withOpacity(0.1),
|
||||
border: Border.all(
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
bottomRight: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 24,
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Nouveau',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: Colors.grey[400]!,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
bottomRight: Radius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 24,
|
||||
color: Colors.black87,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Nouveau',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Espace pour aligner avec les autres colonnes (pas de triangle sur ce bouton)
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -377,3 +418,30 @@ class BtnPassages extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CustomPainter pour dessiner un triangle pointant vers le bas
|
||||
class _TrianglePainter extends CustomPainter {
|
||||
final Color color;
|
||||
|
||||
_TrianglePainter({required this.color});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final path = Path()
|
||||
..moveTo(0, 0) // Coin supérieur gauche
|
||||
..lineTo(size.width, 0) // Coin supérieur droit
|
||||
..lineTo(size.width / 2, size.height) // Pointe en bas au centre
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _TrianglePainter oldDelegate) {
|
||||
return oldDelegate.color != color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart'; // Import du service singleton
|
||||
|
||||
/// Enum représentant les différentes sources de tuiles disponibles
|
||||
enum TileSource {
|
||||
/// Tuiles Mapbox (par défaut)
|
||||
mapbox,
|
||||
/// Tuiles OpenStreetMap
|
||||
openStreetMap,
|
||||
/// Tuiles IGN Plan (carte routière française)
|
||||
ignPlan,
|
||||
/// Tuiles IGN Ortho Photos (photos aériennes)
|
||||
ignOrtho,
|
||||
}
|
||||
|
||||
/// Widget de carte réutilisable utilisant Mapbox
|
||||
///
|
||||
/// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit
|
||||
@@ -48,8 +60,12 @@ class MapboxMap extends StatefulWidget {
|
||||
final bool disableDrag;
|
||||
|
||||
/// Utiliser OpenStreetMap au lieu de Mapbox (en cas de problème de token)
|
||||
@Deprecated('Utiliser tileSource à la place')
|
||||
final bool useOpenStreetMap;
|
||||
|
||||
/// Source des tuiles de la carte (Mapbox, OpenStreetMap, IGN Plan, IGN Ortho)
|
||||
final TileSource tileSource;
|
||||
|
||||
const MapboxMap({
|
||||
super.key,
|
||||
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
|
||||
@@ -64,6 +80,7 @@ class MapboxMap extends StatefulWidget {
|
||||
this.mapStyle,
|
||||
this.disableDrag = false,
|
||||
this.useOpenStreetMap = false,
|
||||
this.tileSource = TileSource.mapbox,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -125,7 +142,7 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
_cacheInitialized = true;
|
||||
});
|
||||
}
|
||||
debugPrint('MapboxMap: Cache initialisé avec succès pour ${widget.useOpenStreetMap ? "OpenStreetMap" : "Mapbox"}');
|
||||
debugPrint('MapboxMap: Cache initialisé avec succès');
|
||||
} catch (e) {
|
||||
debugPrint('MapboxMap: Erreur lors de l\'initialisation du cache: $e');
|
||||
// En cas d'erreur, on continue sans cache
|
||||
@@ -175,30 +192,76 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne l'URL template pour la source de tuiles sélectionnée
|
||||
String _getTileUrlTemplate() {
|
||||
// Rétrocompatibilité avec useOpenStreetMap
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
if (widget.useOpenStreetMap && widget.tileSource == TileSource.mapbox) {
|
||||
return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
}
|
||||
|
||||
switch (widget.tileSource) {
|
||||
case TileSource.openStreetMap:
|
||||
return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
case TileSource.ignPlan:
|
||||
// IGN Plan IGN v2 - Carte routière française
|
||||
// Source: https://data.geopf.fr/wmts
|
||||
return 'https://data.geopf.fr/wmts?'
|
||||
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
|
||||
'&TILEMATRIXSET=PM'
|
||||
'&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2'
|
||||
'&STYLE=normal'
|
||||
'&FORMAT=image/png'
|
||||
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}';
|
||||
|
||||
case TileSource.ignOrtho:
|
||||
// IGN Ortho Photos - Photos aériennes
|
||||
// Source: https://data.geopf.fr/wmts
|
||||
return 'https://data.geopf.fr/wmts?'
|
||||
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
|
||||
'&TILEMATRIXSET=PM'
|
||||
'&LAYER=ORTHOIMAGERY.ORTHOPHOTOS'
|
||||
'&STYLE=normal'
|
||||
'&FORMAT=image/jpeg'
|
||||
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}';
|
||||
|
||||
case TileSource.mapbox:
|
||||
default:
|
||||
// Déterminer l'URL du template de tuiles Mapbox
|
||||
final String environment = ApiService.instance.getCurrentEnvironment();
|
||||
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
|
||||
|
||||
if (kIsWeb) {
|
||||
return 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
|
||||
} else {
|
||||
return 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le nom de la source de tuiles pour le debug
|
||||
String _getTileSourceName() {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
if (widget.useOpenStreetMap && widget.tileSource == TileSource.mapbox) {
|
||||
return 'OpenStreetMap (legacy)';
|
||||
}
|
||||
switch (widget.tileSource) {
|
||||
case TileSource.mapbox:
|
||||
return 'Mapbox';
|
||||
case TileSource.openStreetMap:
|
||||
return 'OpenStreetMap';
|
||||
case TileSource.ignPlan:
|
||||
return 'IGN Plan';
|
||||
case TileSource.ignOrtho:
|
||||
return 'IGN Ortho Photos';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String urlTemplate;
|
||||
|
||||
if (widget.useOpenStreetMap) {
|
||||
// Utiliser OpenStreetMap comme alternative
|
||||
urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
debugPrint('MapboxMap: Utilisation d\'OpenStreetMap');
|
||||
} else {
|
||||
// Déterminer l'URL du template de tuiles Mapbox
|
||||
// Utiliser l'environnement actuel pour obtenir la bonne clé API
|
||||
final String environment = ApiService.instance.getCurrentEnvironment();
|
||||
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
|
||||
|
||||
// Essayer différentes API Mapbox selon la plateforme
|
||||
if (kIsWeb) {
|
||||
// Sur web, on peut utiliser l'API styles
|
||||
urlTemplate = 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
|
||||
} else {
|
||||
// Sur mobile, utiliser l'API v4 qui fonctionne mieux avec les tokens standards
|
||||
// Format: mapbox.streets pour les rues, mapbox.satellite pour satellite
|
||||
urlTemplate = 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
|
||||
}
|
||||
}
|
||||
final urlTemplate = _getTileUrlTemplate();
|
||||
debugPrint('MapboxMap: Utilisation de ${_getTileSourceName()}');
|
||||
|
||||
// Afficher un indicateur pendant l'initialisation du cache
|
||||
if (!_cacheInitialized) {
|
||||
|
||||
@@ -124,66 +124,71 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
),
|
||||
),
|
||||
|
||||
// Corps avec le tableau
|
||||
// Corps avec le tableau - écoute membres ET passages pour rafraîchissement auto
|
||||
ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, membresBox, child) {
|
||||
final membres = membresBox.values.toList();
|
||||
return ValueListenableBuilder<Box<PassageModel>>(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, passagesBox, child) {
|
||||
final membres = membresBox.values.toList();
|
||||
|
||||
// Récupérer l'opération courante
|
||||
final currentOperation = _operationRepository.getCurrentOperation();
|
||||
if (currentOperation == null) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Text('Aucune opération en cours'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Trier les membres selon la colonne sélectionnée
|
||||
_sortMembers(membres, currentOperation.id);
|
||||
|
||||
// Construire les lignes : TOTAL en première position + détails membres
|
||||
final allRows = [
|
||||
_buildTotalRow(membres, currentOperation.id, theme),
|
||||
..._buildRows(membres, currentOperation.id, theme),
|
||||
];
|
||||
|
||||
// Afficher le tableau complet sans scroll interne
|
||||
return SizedBox(
|
||||
width: double.infinity, // Prendre toute la largeur disponible
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dataTableTheme: DataTableThemeData(
|
||||
headingRowColor: WidgetStateProperty.resolveWith<Color?>(
|
||||
(Set<WidgetState> states) {
|
||||
return theme.colorScheme.primary.withOpacity(0.08);
|
||||
},
|
||||
// Récupérer l'opération courante
|
||||
final currentOperation = _operationRepository.getCurrentOperation();
|
||||
if (currentOperation == null) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Text('Aucune opération en cours'),
|
||||
),
|
||||
dataRowColor: WidgetStateProperty.resolveWith<Color?>(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return theme.colorScheme.primary.withOpacity(0.08);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Trier les membres selon la colonne sélectionnée
|
||||
_sortMembers(membres, currentOperation.id);
|
||||
|
||||
// Construire les lignes : TOTAL en première position + détails membres
|
||||
final allRows = [
|
||||
_buildTotalRow(membres, currentOperation.id, theme),
|
||||
..._buildRows(membres, currentOperation.id, theme),
|
||||
];
|
||||
|
||||
// Afficher le tableau complet sans scroll interne
|
||||
return SizedBox(
|
||||
width: double.infinity, // Prendre toute la largeur disponible
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dataTableTheme: DataTableThemeData(
|
||||
headingRowColor: WidgetStateProperty.resolveWith<Color?>(
|
||||
(Set<WidgetState> states) {
|
||||
return theme.colorScheme.primary.withOpacity(0.08);
|
||||
},
|
||||
),
|
||||
dataRowColor: WidgetStateProperty.resolveWith<Color?>(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return theme.colorScheme.primary.withOpacity(0.08);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
child: DataTable(
|
||||
columnSpacing: 4, // Espacement minimal entre colonnes
|
||||
horizontalMargin: 4, // Marges horizontales minimales
|
||||
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
|
||||
dataRowMinHeight: 42,
|
||||
dataRowMaxHeight: 42,
|
||||
// Utiliser les flèches natives de DataTable
|
||||
sortColumnIndex: _sortColumnIndex,
|
||||
sortAscending: _sortAscending,
|
||||
columns: _buildColumns(theme),
|
||||
rows: allRows,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: DataTable(
|
||||
columnSpacing: 4, // Espacement minimal entre colonnes
|
||||
horizontalMargin: 4, // Marges horizontales minimales
|
||||
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
|
||||
dataRowMinHeight: 42,
|
||||
dataRowMaxHeight: 42,
|
||||
// Utiliser les flèches natives de DataTable
|
||||
sortColumnIndex: _sortColumnIndex,
|
||||
sortAscending: _sortAscending,
|
||||
columns: _buildColumns(theme),
|
||||
rows: allRows,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -79,6 +79,16 @@ class MembreRowWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Tournée (sectName) - masqué en mobile
|
||||
if (!isMobile)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
membre.sectName ?? '',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
|
||||
// Email - masqué en mobile
|
||||
if (!isMobile)
|
||||
Expanded(
|
||||
|
||||
@@ -113,6 +113,19 @@ class MembreTableWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Tournée (sectName) - masqué en mobile
|
||||
if (!isMobile)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
'Tournée',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Email - masqué en mobile
|
||||
if (!isMobile)
|
||||
Expanded(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
@@ -15,6 +15,7 @@ import 'package:geosector_app/core/services/stripe_connect_service.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/location_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/presentation/widgets/form_section.dart';
|
||||
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
|
||||
@@ -88,13 +89,17 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
|
||||
// Helpers de validation
|
||||
String? _validateNumero(String? value) {
|
||||
debugPrint('🔍 [VALIDATOR] _validateNumero appelé avec: "$value"');
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
debugPrint('❌ [VALIDATOR] Numéro vide -> retourne erreur');
|
||||
return 'Le numéro est obligatoire';
|
||||
}
|
||||
final numero = int.tryParse(value.trim());
|
||||
if (numero == null || numero <= 0) {
|
||||
debugPrint('❌ [VALIDATOR] Numéro invalide: $value -> retourne erreur');
|
||||
return 'Numéro invalide';
|
||||
}
|
||||
debugPrint('✅ [VALIDATOR] Numéro valide: $numero');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -329,48 +334,102 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
|
||||
void _handleSubmit() async {
|
||||
if (_isSubmitting) return;
|
||||
debugPrint('🔵 [SUBMIT] Début _handleSubmit');
|
||||
|
||||
// ✅ Validation intégrée avec focus automatique sur erreur
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
// Le focus est automatiquement mis sur le premier champ en erreur
|
||||
// Les bordures rouges et messages d'erreur sont affichés automatiquement
|
||||
if (_isSubmitting) {
|
||||
debugPrint('⚠️ [SUBMIT] Déjà en cours de soumission, abandon');
|
||||
return;
|
||||
}
|
||||
|
||||
// Toujours sauvegarder le passage en premier
|
||||
debugPrint('🔵 [SUBMIT] Vérification de l\'état du formulaire');
|
||||
debugPrint('🔵 [SUBMIT] _formKey: $_formKey');
|
||||
debugPrint('🔵 [SUBMIT] _formKey.currentState: ${_formKey.currentState}');
|
||||
|
||||
// Validation avec protection contre le null
|
||||
if (_formKey.currentState == null) {
|
||||
debugPrint('❌ [SUBMIT] ERREUR: _formKey.currentState est null !');
|
||||
if (mounted) {
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: "Erreur d'initialisation du formulaire",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🔵 [SUBMIT] Validation du formulaire...');
|
||||
final isValid = _formKey.currentState!.validate();
|
||||
debugPrint('🔵 [SUBMIT] Résultat validation: $isValid');
|
||||
|
||||
if (!isValid) {
|
||||
debugPrint('⚠️ [SUBMIT] Validation échouée, abandon');
|
||||
|
||||
// Afficher un dialog d'erreur clair à l'utilisateur
|
||||
if (mounted) {
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: 'Veuillez vérifier tous les champs marqués comme obligatoires',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('✅ [SUBMIT] Validation OK, appel _savePassage()');
|
||||
await _savePassage();
|
||||
debugPrint('🔵 [SUBMIT] Fin _handleSubmit');
|
||||
}
|
||||
|
||||
Future<void> _savePassage() async {
|
||||
if (_isSubmitting) return;
|
||||
debugPrint('🟢 [SAVE] Début _savePassage');
|
||||
|
||||
if (_isSubmitting) {
|
||||
debugPrint('⚠️ [SAVE] Déjà en cours de soumission, abandon');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🟢 [SAVE] Mise à jour état _isSubmitting = true');
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
// Afficher l'overlay de chargement
|
||||
debugPrint('🟢 [SAVE] Affichage overlay de chargement');
|
||||
final overlay = LoadingSpinOverlayUtils.show(
|
||||
context: context,
|
||||
message: 'Enregistrement en cours...',
|
||||
);
|
||||
|
||||
try {
|
||||
debugPrint('🟢 [SAVE] Récupération utilisateur actuel');
|
||||
final currentUser = widget.userRepository.getCurrentUser();
|
||||
debugPrint('🟢 [SAVE] currentUser: ${currentUser?.id} - ${currentUser?.name}');
|
||||
|
||||
if (currentUser == null) {
|
||||
debugPrint('❌ [SAVE] ERREUR: Utilisateur non connecté');
|
||||
throw Exception("Utilisateur non connecté");
|
||||
}
|
||||
|
||||
debugPrint('🟢 [SAVE] Récupération opération active');
|
||||
final currentOperation = widget.operationRepository.getCurrentOperation();
|
||||
debugPrint('🟢 [SAVE] currentOperation: ${currentOperation?.id} - ${currentOperation?.name}');
|
||||
|
||||
if (currentOperation == null && widget.passage == null) {
|
||||
debugPrint('❌ [SAVE] ERREUR: Aucune opération active trouvée');
|
||||
throw Exception("Aucune opération active trouvée");
|
||||
}
|
||||
|
||||
// Déterminer les valeurs de montant et type de règlement selon le type de passage
|
||||
debugPrint('🟢 [SAVE] Calcul des valeurs finales');
|
||||
debugPrint('🟢 [SAVE] _selectedPassageType: $_selectedPassageType');
|
||||
|
||||
final String finalMontant =
|
||||
(_selectedPassageType == 1 || _selectedPassageType == 5)
|
||||
? _montantController.text.trim().replaceAll(',', '.')
|
||||
: '0';
|
||||
debugPrint('🟢 [SAVE] finalMontant: $finalMontant');
|
||||
|
||||
// Déterminer le type de règlement final selon le type de passage
|
||||
final int finalTypeReglement;
|
||||
if (_selectedPassageType == 1 || _selectedPassageType == 5) {
|
||||
@@ -380,6 +439,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
// Pour tous les autres types, forcer "Non renseigné"
|
||||
finalTypeReglement = 4;
|
||||
}
|
||||
debugPrint('🟢 [SAVE] finalTypeReglement: $finalTypeReglement');
|
||||
|
||||
// Déterminer la valeur de nbPassages selon le type de passage
|
||||
final int finalNbPassages;
|
||||
@@ -397,6 +457,31 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
// Nouveau passage : toujours 1
|
||||
finalNbPassages = 1;
|
||||
}
|
||||
debugPrint('🟢 [SAVE] finalNbPassages: $finalNbPassages');
|
||||
|
||||
// Récupérer les coordonnées GPS pour un nouveau passage
|
||||
String finalGpsLat = '0.0';
|
||||
String finalGpsLng = '0.0';
|
||||
if (widget.passage == null) {
|
||||
// Nouveau passage : tenter de récupérer la position GPS actuelle
|
||||
debugPrint('🟢 [SAVE] Récupération de la position GPS...');
|
||||
try {
|
||||
final position = await LocationService.getCurrentPosition();
|
||||
if (position != null) {
|
||||
finalGpsLat = position.latitude.toString();
|
||||
finalGpsLng = position.longitude.toString();
|
||||
debugPrint('🟢 [SAVE] GPS récupéré: lat=$finalGpsLat, lng=$finalGpsLng');
|
||||
} else {
|
||||
debugPrint('🟢 [SAVE] GPS non disponible, utilisation de 0.0 (l\'API utilisera le géocodage)');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SAVE] Erreur récupération GPS: $e - l\'API utilisera le géocodage');
|
||||
}
|
||||
} else {
|
||||
// Modification : conserver les coordonnées existantes
|
||||
finalGpsLat = widget.passage!.gpsLat;
|
||||
finalGpsLng = widget.passage!.gpsLng;
|
||||
}
|
||||
|
||||
final passageData = widget.passage?.copyWith(
|
||||
fkType: _selectedPassageType!,
|
||||
@@ -422,7 +507,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
PassageModel(
|
||||
id: 0, // Nouveau passage
|
||||
fkOperation: currentOperation!.id, // Opération active
|
||||
fkSector: 0, // Secteur par défaut
|
||||
fkSector: 0, // Secteur par défaut (sera déterminé par l'API)
|
||||
fkUser: currentUser.id, // Utilisateur actuel
|
||||
fkType: _selectedPassageType!,
|
||||
fkAdresse: "0", // Adresse par défaut pour nouveau passage
|
||||
@@ -435,8 +520,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
fkHabitat: _fkHabitat,
|
||||
appt: _apptController.text.trim(),
|
||||
niveau: _niveauController.text.trim(),
|
||||
gpsLat: '0.0', // GPS par défaut
|
||||
gpsLng: '0.0', // GPS par défaut
|
||||
gpsLat: finalGpsLat, // GPS de l'utilisateur ou 0.0 si non disponible
|
||||
gpsLng: finalGpsLng, // GPS de l'utilisateur ou 0.0 si non disponible
|
||||
nomRecu: _nameController.text.trim(),
|
||||
remarque: _remarqueController.text.trim(),
|
||||
montant: finalMontant,
|
||||
@@ -453,32 +538,37 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
);
|
||||
|
||||
// Sauvegarder le passage d'abord
|
||||
debugPrint('🟢 [SAVE] Préparation sauvegarde passage');
|
||||
PassageModel? savedPassage;
|
||||
if (widget.passage == null || widget.passage!.id == 0) {
|
||||
// Création d'un nouveau passage (passage null OU id=0)
|
||||
debugPrint('🟢 [SAVE] Création d\'un nouveau passage');
|
||||
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
|
||||
debugPrint('🟢 [SAVE] Passage créé avec ID: ${savedPassage?.id}');
|
||||
|
||||
if (savedPassage == null) {
|
||||
debugPrint('❌ [SAVE] ERREUR: savedPassage est null après création');
|
||||
throw Exception("Échec de la création du passage");
|
||||
}
|
||||
} else {
|
||||
// Mise à jour d'un passage existant
|
||||
final success = await widget.passageRepository.updatePassage(passageData);
|
||||
if (success) {
|
||||
savedPassage = passageData;
|
||||
}
|
||||
}
|
||||
|
||||
if (savedPassage == null) {
|
||||
throw Exception(widget.passage == null || widget.passage!.id == 0
|
||||
? "Échec de la création du passage"
|
||||
: "Échec de la mise à jour du passage");
|
||||
debugPrint('🟢 [SAVE] Mise à jour passage existant ID: ${widget.passage!.id}');
|
||||
await widget.passageRepository.updatePassage(passageData);
|
||||
debugPrint('🟢 [SAVE] Mise à jour réussie');
|
||||
savedPassage = passageData;
|
||||
}
|
||||
|
||||
// Garantir le type non-nullable après la vérification
|
||||
final confirmedPassage = savedPassage;
|
||||
debugPrint('✅ [SAVE] Passage sauvegardé avec succès ID: ${confirmedPassage.id}');
|
||||
|
||||
// Mémoriser l'adresse pour la prochaine création de passage
|
||||
debugPrint('🟢 [SAVE] Mémorisation adresse');
|
||||
await _saveLastPassageAddress();
|
||||
|
||||
// Propager la résidence aux autres passages de l'immeuble si nécessaire
|
||||
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
|
||||
debugPrint('🟢 [SAVE] Propagation résidence à l\'immeuble');
|
||||
await _propagateResidenceToBuilding(confirmedPassage);
|
||||
}
|
||||
|
||||
@@ -514,17 +604,30 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
// Lancer le flow Tap to Pay
|
||||
final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant);
|
||||
|
||||
if (!paymentSuccess) {
|
||||
debugPrint('⚠️ Paiement Tap to Pay échoué pour le passage ${confirmedPassage.id}');
|
||||
if (paymentSuccess) {
|
||||
// Fermer le formulaire en cas de succès
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ Paiement Tap to Pay échoué - formulaire reste ouvert');
|
||||
// Ne pas fermer le formulaire en cas d'échec
|
||||
// L'utilisateur peut réessayer ou annuler
|
||||
}
|
||||
},
|
||||
onQRCodeCompleted: () {
|
||||
// Pour QR Code: fermer le formulaire après l'affichage du QR
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Fermer le formulaire après le choix de paiement
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
// NOTE: Le formulaire n'est plus fermé systématiquement ici
|
||||
// Il est fermé dans onQRCodeCompleted pour QR Code
|
||||
// ou dans onTapToPaySelected en cas de succès Tap to Pay
|
||||
}
|
||||
} else {
|
||||
// Stripe non activé pour cette amicale
|
||||
@@ -563,30 +666,44 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e, stackTrace) {
|
||||
// Masquer le loading
|
||||
debugPrint('❌ [SAVE] ERREUR CAPTURÉE');
|
||||
debugPrint('❌ [SAVE] Type erreur: ${e.runtimeType}');
|
||||
debugPrint('❌ [SAVE] Message erreur: $e');
|
||||
debugPrint('❌ [SAVE] Stack trace:\n$stackTrace');
|
||||
|
||||
LoadingSpinOverlayUtils.hideSpecific(overlay);
|
||||
|
||||
// Afficher l'erreur
|
||||
final errorMessage = ApiException.fromError(e).message;
|
||||
debugPrint('❌ [SAVE] Message d\'erreur formaté: $errorMessage');
|
||||
|
||||
if (mounted) {
|
||||
await ResultDialog.show(
|
||||
context: context,
|
||||
success: false,
|
||||
message: ApiException.fromError(e).message,
|
||||
message: errorMessage,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
debugPrint('🟢 [SAVE] Bloc finally - Nettoyage');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
debugPrint('🟢 [SAVE] _isSubmitting = false');
|
||||
}
|
||||
debugPrint('🟢 [SAVE] Fin _savePassage');
|
||||
}
|
||||
}
|
||||
|
||||
/// Mémoriser l'adresse du passage pour la prochaine création
|
||||
Future<void> _saveLastPassageAddress() async {
|
||||
try {
|
||||
debugPrint('🟡 [ADDRESS] Début mémorisation adresse');
|
||||
debugPrint('🟡 [ADDRESS] _settingsBox.isOpen: ${_settingsBox.isOpen}');
|
||||
|
||||
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
|
||||
await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim());
|
||||
await _settingsBox.put('lastPassageRue', _rueController.text.trim());
|
||||
@@ -596,20 +713,28 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
await _settingsBox.put('lastPassageAppt', _apptController.text.trim());
|
||||
await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim());
|
||||
|
||||
debugPrint('✅ Adresse mémorisée pour la prochaine création de passage');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de la mémorisation de l\'adresse: $e');
|
||||
debugPrint('✅ [ADDRESS] Adresse mémorisée avec succès');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ [ADDRESS] Erreur lors de la mémorisation: $e');
|
||||
debugPrint('❌ [ADDRESS] Stack trace:\n$stackTrace');
|
||||
}
|
||||
}
|
||||
|
||||
/// Propager la résidence aux autres passages de l'immeuble (fkType=2, même adresse, résidence vide)
|
||||
Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async {
|
||||
try {
|
||||
debugPrint('🟡 [PROPAGATE] Début propagation résidence');
|
||||
|
||||
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
debugPrint('🟡 [PROPAGATE] passagesBox.isOpen: ${passagesBox.isOpen}');
|
||||
debugPrint('🟡 [PROPAGATE] passagesBox.length: ${passagesBox.length}');
|
||||
|
||||
final residence = _residenceController.text.trim();
|
||||
debugPrint('🟡 [PROPAGATE] résidence: "$residence"');
|
||||
|
||||
// Clé d'adresse du passage sauvegardé
|
||||
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
|
||||
debugPrint('🟡 [PROPAGATE] addressKey: "$addressKey"');
|
||||
|
||||
int updatedCount = 0;
|
||||
|
||||
@@ -625,6 +750,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
passageAddressKey == addressKey && // Même adresse
|
||||
passage.residence.trim().isEmpty) { // Résidence vide
|
||||
|
||||
debugPrint('🟡 [PROPAGATE] Mise à jour passage ID: ${passage.id}');
|
||||
// Mettre à jour la résidence dans Hive
|
||||
final updatedPassage = passage.copyWith(residence: residence);
|
||||
await passagesBox.put(passage.key, updatedPassage);
|
||||
@@ -634,10 +760,13 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
debugPrint('✅ Résidence propagée à $updatedCount passage(s) de l\'immeuble');
|
||||
debugPrint('✅ [PROPAGATE] Résidence propagée à $updatedCount passage(s)');
|
||||
} else {
|
||||
debugPrint('✅ [PROPAGATE] Aucun passage à mettre à jour');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de la propagation de la résidence: $e');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ [PROPAGATE] Erreur lors de la propagation: $e');
|
||||
debugPrint('❌ [PROPAGATE] Stack trace:\n$stackTrace');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1819,9 +1948,46 @@ class _TapToPayFlowDialogState extends State<_TapToPayFlowDialog> {
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Analyser le type d'erreur pour afficher un message clair
|
||||
final errorMsg = e.toString().toLowerCase();
|
||||
|
||||
String userMessage;
|
||||
bool shouldCancelPayment = true;
|
||||
|
||||
if (errorMsg.contains('canceled') || errorMsg.contains('cancelled')) {
|
||||
// Annulation volontaire par l'utilisateur
|
||||
userMessage = 'Paiement annulé';
|
||||
|
||||
} else if (errorMsg.contains('cardreadtimedout') || errorMsg.contains('timed out')) {
|
||||
// Timeout de lecture NFC
|
||||
userMessage = 'Lecture de la carte impossible.\n\n'
|
||||
'Conseils :\n'
|
||||
'• Maintenez la carte contre le dos du téléphone\n'
|
||||
'• Ne bougez pas jusqu\'à confirmation\n'
|
||||
'• Retirez la coque si nécessaire\n'
|
||||
'• Essayez différentes positions sur le téléphone';
|
||||
|
||||
} else if (errorMsg.contains('already') && errorMsg.contains('payment')) {
|
||||
// PaymentIntent existe déjà
|
||||
userMessage = 'Un paiement est déjà en cours pour ce passage.\n'
|
||||
'Veuillez réessayer dans quelques instants.';
|
||||
shouldCancelPayment = false; // Déjà en cours, pas besoin d'annuler
|
||||
|
||||
} else {
|
||||
// Autre erreur technique
|
||||
userMessage = 'Erreur lors du paiement.\n\n$e';
|
||||
}
|
||||
|
||||
// Annuler le PaymentIntent si créé pour permettre une nouvelle tentative
|
||||
if (shouldCancelPayment && _paymentIntentId != null) {
|
||||
StripeTapToPayService.instance.cancelPayment(_paymentIntentId!).catchError((cancelError) {
|
||||
debugPrint('⚠️ Erreur annulation PaymentIntent: $cancelError');
|
||||
});
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentState = 'error';
|
||||
_errorMessage = e.toString();
|
||||
_errorMessage = userMessage;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
|
||||
|
||||
/// Dialog de sélection de la méthode de paiement CB
|
||||
/// Affiche les options QR Code et/ou Tap to Pay selon la compatibilité
|
||||
class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
class PaymentMethodSelectionDialog extends StatefulWidget {
|
||||
final PassageModel passage;
|
||||
final double amount;
|
||||
final String habitantName;
|
||||
final StripeConnectService stripeConnectService;
|
||||
final PassageRepository? passageRepository;
|
||||
final VoidCallback? onTapToPaySelected;
|
||||
final VoidCallback? onQRCodeCompleted;
|
||||
|
||||
const PaymentMethodSelectionDialog({
|
||||
super.key,
|
||||
@@ -24,12 +25,61 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
required this.stripeConnectService,
|
||||
this.passageRepository,
|
||||
this.onTapToPaySelected,
|
||||
this.onQRCodeCompleted,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentMethodSelectionDialog> createState() => _PaymentMethodSelectionDialogState();
|
||||
|
||||
/// Afficher le dialog de sélection de méthode de paiement
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required PassageModel passage,
|
||||
required double amount,
|
||||
required String habitantName,
|
||||
required StripeConnectService stripeConnectService,
|
||||
PassageRepository? passageRepository,
|
||||
VoidCallback? onTapToPaySelected,
|
||||
VoidCallback? onQRCodeCompleted,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false, // Ne peut pas fermer en cliquant à côté
|
||||
builder: (context) => PaymentMethodSelectionDialog(
|
||||
passage: passage,
|
||||
amount: amount,
|
||||
habitantName: habitantName,
|
||||
stripeConnectService: stripeConnectService,
|
||||
passageRepository: passageRepository,
|
||||
onTapToPaySelected: onTapToPaySelected,
|
||||
onQRCodeCompleted: onQRCodeCompleted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentMethodSelectionDialogState extends State<PaymentMethodSelectionDialog> {
|
||||
String? _tapToPayUnavailableReason;
|
||||
bool _isCheckingNFC = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkTapToPayAvailability();
|
||||
}
|
||||
|
||||
Future<void> _checkTapToPayAvailability() async {
|
||||
final reason = await DeviceInfoService.instance.getTapToPayUnavailableReasonAsync();
|
||||
setState(() {
|
||||
_tapToPayUnavailableReason = reason;
|
||||
_isCheckingNFC = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
|
||||
final amountEuros = amount.toStringAsFixed(2);
|
||||
final canUseTapToPay = !_isCheckingNFC && _tapToPayUnavailableReason == null;
|
||||
final amountEuros = widget.amount.toStringAsFixed(2);
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -42,21 +92,12 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// En-tête
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Règlement CB',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
const Text(
|
||||
'Règlement CB',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -76,7 +117,7 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
habitantName,
|
||||
widget.habitantName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -128,23 +169,28 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
description: 'Le client scanne le code avec son téléphone',
|
||||
onPressed: () => _handleQRCodePayment(context),
|
||||
color: Colors.blue,
|
||||
isEnabled: true,
|
||||
),
|
||||
|
||||
if (canUseTapToPay) ...[
|
||||
const SizedBox(height: 12),
|
||||
// Bouton Tap to Pay
|
||||
_buildPaymentButton(
|
||||
context: context,
|
||||
icon: Icons.contactless,
|
||||
label: 'Tap to Pay',
|
||||
description: 'Paiement sans contact sur cet appareil',
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onTapToPaySelected?.call();
|
||||
},
|
||||
color: Colors.green,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Bouton Tap to Pay (toujours affiché, désactivé si non disponible)
|
||||
_buildPaymentButton(
|
||||
context: context,
|
||||
icon: Icons.contactless,
|
||||
label: _isCheckingNFC ? 'Tap to Pay (vérification...)' : 'Tap to Pay',
|
||||
description: canUseTapToPay
|
||||
? 'Paiement sans contact sur cet appareil'
|
||||
: _tapToPayUnavailableReason ?? 'Vérification en cours...',
|
||||
onPressed: canUseTapToPay
|
||||
? () {
|
||||
Navigator.of(context).pop();
|
||||
widget.onTapToPaySelected?.call();
|
||||
}
|
||||
: null,
|
||||
color: Colors.green,
|
||||
isEnabled: canUseTapToPay,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -179,18 +225,24 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String description,
|
||||
required VoidCallback onPressed,
|
||||
required VoidCallback? onPressed,
|
||||
required Color color,
|
||||
required bool isEnabled,
|
||||
}) {
|
||||
// Couleurs selon l'état activé/désactivé
|
||||
final effectiveColor = isEnabled ? color : Colors.grey;
|
||||
final backgroundColor = isEnabled ? color.withOpacity(0.1) : Colors.grey.shade100;
|
||||
final borderColor = isEnabled ? color.withOpacity(0.3) : Colors.grey.shade300;
|
||||
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
onTap: isEnabled ? onPressed : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
border: Border.all(color: color.withOpacity(0.3), width: 2),
|
||||
color: backgroundColor,
|
||||
border: Border.all(color: borderColor, width: 2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
@@ -198,36 +250,69 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.2),
|
||||
color: isEnabled ? color.withOpacity(0.2) : Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 32),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: effectiveColor,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: effectiveColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isEnabled)
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
color: Colors.grey.shade600,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isEnabled) ...[
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange.shade700,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isEnabled ? Colors.grey.shade700 : Colors.orange.shade700,
|
||||
fontWeight: isEnabled ? FontWeight.normal : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.arrow_forward_ios, color: color, size: 20),
|
||||
if (isEnabled)
|
||||
Icon(Icons.arrow_forward_ios, color: effectiveColor, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -238,6 +323,7 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
Future<void> _handleQRCodePayment(BuildContext context) async {
|
||||
// Sauvegarder le navigator avant de fermer les dialogs
|
||||
final navigator = Navigator.of(context);
|
||||
bool loaderDisplayed = false;
|
||||
|
||||
try {
|
||||
// Afficher un loader
|
||||
@@ -248,19 +334,20 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
loaderDisplayed = true;
|
||||
|
||||
// Créer le Payment Link
|
||||
final amountInCents = (amount * 100).round();
|
||||
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${passage.id}');
|
||||
final amountInCents = (widget.amount * 100).round();
|
||||
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${widget.passage.id}');
|
||||
|
||||
final paymentLink = await stripeConnectService.createPaymentLink(
|
||||
final paymentLink = await widget.stripeConnectService.createPaymentLink(
|
||||
amountInCents: amountInCents,
|
||||
passageId: passage.id,
|
||||
description: 'Calendrier pompiers - ${habitantName}',
|
||||
passageId: widget.passage.id,
|
||||
description: 'Calendrier pompiers - ${widget.habitantName}',
|
||||
metadata: {
|
||||
'passage_id': passage.id.toString(),
|
||||
'habitant_name': habitantName,
|
||||
'adresse': '${passage.numero} ${passage.rue}, ${passage.ville}',
|
||||
'passage_id': widget.passage.id.toString(),
|
||||
'habitant_name': widget.habitantName,
|
||||
'adresse': '${widget.passage.numero} ${widget.passage.rue}, ${widget.passage.ville}',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -270,22 +357,18 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
debugPrint(' ID: ${paymentLink.paymentLinkId}');
|
||||
}
|
||||
|
||||
// Fermer le loader
|
||||
navigator.pop();
|
||||
debugPrint('🔵 Loader fermé');
|
||||
|
||||
if (paymentLink == null) {
|
||||
throw Exception('Impossible de créer le lien de paiement');
|
||||
}
|
||||
|
||||
// Sauvegarder l'URL du Payment Link dans le passage
|
||||
if (passageRepository != null) {
|
||||
if (widget.passageRepository != null) {
|
||||
try {
|
||||
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
|
||||
final updatedPassage = passage.copyWith(
|
||||
final updatedPassage = widget.passage.copyWith(
|
||||
stripePaymentLinkUrl: paymentLink.url,
|
||||
);
|
||||
await passageRepository!.updatePassage(updatedPassage);
|
||||
await widget.passageRepository!.updatePassage(updatedPassage);
|
||||
debugPrint('✅ URL du Payment Link sauvegardée');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e');
|
||||
@@ -293,7 +376,12 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Fermer le dialog de sélection
|
||||
// Fermer le loader
|
||||
navigator.pop();
|
||||
loaderDisplayed = false;
|
||||
debugPrint('🔵 Loader fermé');
|
||||
|
||||
// Fermer le dialog de sélection (seulement en cas de succès)
|
||||
navigator.pop();
|
||||
debugPrint('🔵 Dialog de sélection fermé');
|
||||
|
||||
@@ -311,43 +399,25 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
|
||||
);
|
||||
debugPrint('🔵 Dialog QR Code affiché');
|
||||
|
||||
// Notifier que le QR Code est complété
|
||||
widget.onQRCodeCompleted?.call();
|
||||
debugPrint('✅ Callback onQRCodeCompleted appelé');
|
||||
|
||||
} catch (e, stack) {
|
||||
debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
|
||||
debugPrint(' Stack: $stack');
|
||||
|
||||
// Fermer le loader si encore ouvert
|
||||
try {
|
||||
navigator.pop();
|
||||
} catch (_) {}
|
||||
if (loaderDisplayed) {
|
||||
try {
|
||||
navigator.pop();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Afficher l'erreur
|
||||
// Afficher l'erreur (le dialogue de sélection reste ouvert)
|
||||
if (context.mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Afficher le dialog de sélection de méthode de paiement
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required PassageModel passage,
|
||||
required double amount,
|
||||
required String habitantName,
|
||||
required StripeConnectService stripeConnectService,
|
||||
PassageRepository? passageRepository,
|
||||
VoidCallback? onTapToPaySelected,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => PaymentMethodSelectionDialog(
|
||||
passage: passage,
|
||||
amount: amount,
|
||||
habitantName: habitantName,
|
||||
stripeConnectService: stripeConnectService,
|
||||
passageRepository: passageRepository,
|
||||
onTapToPaySelected: onTapToPaySelected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,7 +818,7 @@ class _UserFormState extends State<UserForm> {
|
||||
),
|
||||
helperText: widget.user?.id != 0
|
||||
? "Laissez vide pour conserver le mot de passe actuel"
|
||||
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
|
||||
: "8 à 64 caractères, alphanumériques et caractères spéciaux",
|
||||
helperMaxLines: 3,
|
||||
validator: _validatePassword,
|
||||
),
|
||||
@@ -897,7 +897,7 @@ class _UserFormState extends State<UserForm> {
|
||||
),
|
||||
helperText: widget.user?.id != 0
|
||||
? "Laissez vide pour conserver le mot de passe actuel"
|
||||
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
|
||||
: "8 à 64 caractères, alphanumériques et caractères spéciaux",
|
||||
helperMaxLines: 3,
|
||||
validator: _validatePassword,
|
||||
),
|
||||
|
||||
@@ -240,7 +240,7 @@ class _UserFormDialogState extends State<UserFormDialog> {
|
||||
user: widget.user,
|
||||
readOnly: widget.readOnly,
|
||||
allowUsernameEdit: widget.allowUsernameEdit,
|
||||
allowSectNameEdit: widget.allowUsernameEdit,
|
||||
allowSectNameEdit: widget.isAdmin, // Toujours éditable pour un admin
|
||||
amicale: widget.amicale, // Passer l'amicale
|
||||
isAdmin: widget.isAdmin, // Passer isAdmin
|
||||
onSubmit: null, // Pas besoin de callback
|
||||
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/battery_plus-6.0.3/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-6.0.3/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/connectivity_plus-6.0.5/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-6.0.5/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/device_info_plus-11.3.0/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-11.3.0/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/file_selector_linux-0.9.3+2/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.3+2/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/flutter_local_notifications_linux-6.0.0/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-6.0.0/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/image_picker_linux-0.2.1+2/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.1+2/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/path_provider_linux-2.2.1/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/url_launcher_linux-3.2.1/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.1/
|
||||
@@ -1,10 +1,10 @@
|
||||
// This is a generated file; do not edit or check into version control.
|
||||
FLUTTER_ROOT=/opt/flutter
|
||||
FLUTTER_ROOT=/home/pierre/.local/flutter
|
||||
FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=3.5.2
|
||||
FLUTTER_BUILD_NUMBER=352
|
||||
FLUTTER_BUILD_NAME=3.6.3
|
||||
FLUTTER_BUILD_NUMBER=363
|
||||
DART_OBFUSCATION=false
|
||||
TRACK_WIDGET_CREATION=true
|
||||
TREE_SHAKE_ICONS=false
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/bin/sh
|
||||
# This is a generated file; do not edit or check into version control.
|
||||
export "FLUTTER_ROOT=/opt/flutter"
|
||||
export "FLUTTER_ROOT=/home/pierre/.local/flutter"
|
||||
export "FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=3.5.2"
|
||||
export "FLUTTER_BUILD_NUMBER=352"
|
||||
export "FLUTTER_BUILD_NAME=3.6.3"
|
||||
export "FLUTTER_BUILD_NUMBER=363"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
|
||||
@@ -130,10 +130,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
|
||||
sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.0"
|
||||
version: "8.12.3"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -222,6 +222,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cookie_jar:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cookie_jar
|
||||
sha256: a6ac027d3ed6ed756bfce8f3ff60cb479e266f3b0fdabd6242b804b6765e52de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.8"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -318,6 +326,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
dio_cookie_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio_cookie_manager
|
||||
sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -395,6 +411,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_compass:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_compass
|
||||
sha256: "1b4d7e6c95a675ec8482b5c9c9ccf1ebf0ced3dbec59dce28ad609da953de850"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.1"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -630,10 +654,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -654,10 +678,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.4"
|
||||
version: "4.7.2"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1435,10 +1459,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: geosector_app
|
||||
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
||||
publish_to: 'none'
|
||||
version: 3.5.2+352
|
||||
version: 3.6.3+363
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -22,6 +22,8 @@ dependencies:
|
||||
|
||||
# API & Réseau
|
||||
dio: ^5.3.3
|
||||
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
|
||||
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
|
||||
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||
retry: ^3.1.2
|
||||
|
||||
@@ -46,7 +48,7 @@ dependencies:
|
||||
geolocator: ^13.0.3 # ⬇️ Downgrade depuis 14.0.2 (Flutter 3.24.5 LTS)
|
||||
geolocator_android: 4.6.1 # ✅ Force version sans toARGB32()
|
||||
universal_html: ^2.2.4 # Pour accéder à la localisation du navigateur (detection env)
|
||||
# sensors_plus: ^3.1.0 # ❌ SUPPRIMÉ - Mode boussole retiré (feature optionnelle peu utilisée) (13/10/2025)
|
||||
flutter_compass: ^0.8.1 # Mode boussole pour le mode terrain (Android/iOS uniquement)
|
||||
|
||||
# Chat et notifications
|
||||
# mqtt5_client: ^4.11.0
|
||||
|
||||
113
app/pubspec.yaml.backup
Executable file
113
app/pubspec.yaml.backup
Executable file
@@ -0,0 +1,113 @@
|
||||
name: geosector_app
|
||||
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
||||
publish_to: 'none'
|
||||
version: 3.5.9+359
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.6
|
||||
|
||||
# Navigation
|
||||
go_router: ^15.1.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 16.0.0)
|
||||
|
||||
# État et gestion des données
|
||||
hive: ^2.2.3
|
||||
hive_flutter: ^1.1.0
|
||||
|
||||
# API & Réseau
|
||||
dio: ^5.3.3
|
||||
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
|
||||
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
|
||||
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||
retry: ^3.1.2
|
||||
|
||||
# UI et animations
|
||||
google_fonts: ^6.1.0
|
||||
flutter_svg: ^2.0.9
|
||||
# package_info_plus: ^4.2.0 # ❌ SUPPRIMÉ - Remplacé par AppInfoService auto-généré (13/10/2025)
|
||||
|
||||
# Utilitaires
|
||||
intl: 0.19.0 # Version requise par flutter_localizations Flutter 3.24.5 LTS
|
||||
uuid: ^4.2.1
|
||||
syncfusion_flutter_charts: 27.2.5 # ⬇️ Compatible Flutter 3.24.5 LTS (sweet spot)
|
||||
# shared_preferences: ^2.3.3 # Remplacé par Hive pour cohérence architecturale
|
||||
|
||||
# Cartes et géolocalisation
|
||||
url_launcher: ^6.1.14 # ⬇️ Version stable (depuis 6.3.1)
|
||||
flutter_map: ^7.0.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 8.2.2)
|
||||
flutter_map_cache: ^1.5.1 # Compatible Flutter 3.24.5 LTS (Dart 3.5.4)
|
||||
dio_cache_interceptor_hive_store: ^3.2.1 # ✅ Compatible flutter_map_cache 1.5.1
|
||||
path_provider: ^2.1.2 # Requis pour le cache
|
||||
latlong2: ^0.9.1
|
||||
geolocator: ^13.0.3 # ⬇️ Downgrade depuis 14.0.2 (Flutter 3.24.5 LTS)
|
||||
geolocator_android: 4.6.1 # ✅ Force version sans toARGB32()
|
||||
universal_html: ^2.2.4 # Pour accéder à la localisation du navigateur (detection env)
|
||||
# sensors_plus: ^3.1.0 # ❌ SUPPRIMÉ - Mode boussole retiré (feature optionnelle peu utilisée) (13/10/2025)
|
||||
|
||||
# Chat et notifications
|
||||
# mqtt5_client: ^4.11.0
|
||||
flutter_local_notifications: ^19.0.1
|
||||
|
||||
# Upload d'images
|
||||
image_picker: ^0.8.9 # ⬇️ Avant SwiftPM (depuis 1.1.2)
|
||||
|
||||
# Configuration YAML
|
||||
yaml: ^3.1.2
|
||||
|
||||
# Stripe Terminal et détection device (V2)
|
||||
device_info_plus: ^11.3.0 # ✅ Compatible Flutter 3.24.5 LTS (depuis 12.1.0)
|
||||
battery_plus: 6.0.3 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||
# network_info_plus: ^4.1.0 # ❌ SUPPRIMÉ - Remplacé par NetworkInterface natif Dart (13/10/2025)
|
||||
nfc_manager: 3.3.0 # ✅ Version stable - Nécessite patch AndroidManifest (fix-nfc-manager.sh)
|
||||
mek_stripe_terminal: ^4.6.0
|
||||
flutter_stripe: ^11.5.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 12.0.0)
|
||||
permission_handler: ^12.0.1
|
||||
qr_flutter: ^4.1.0 # Génération de QR codes pour paiements Stripe
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 6.0.0)
|
||||
hive_generator: ^2.0.1
|
||||
build_runner: ^2.4.6
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: 'assets/images/icons/icon-1024.png'
|
||||
min_sdk_android: 21
|
||||
adaptive_icon_background: '#FFFFFF'
|
||||
adaptive_icon_foreground: 'assets/images/icons/icon-1024.png'
|
||||
remove_alpha_ios: true
|
||||
web:
|
||||
generate: true
|
||||
image_path: 'assets/images/icons/icon-1024.png'
|
||||
background_color: '#FFFFFF'
|
||||
theme_color: '#4B77BE'
|
||||
windows:
|
||||
generate: true
|
||||
image_path: 'assets/images/icons/icon-1024.png'
|
||||
icon_size: 48
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/icons/
|
||||
- assets/animations/
|
||||
- lib/chat/chat_config.yaml
|
||||
|
||||
fonts:
|
||||
- family: Inter
|
||||
fonts:
|
||||
- asset: assets/fonts/InterVariable.ttf
|
||||
- asset: assets/fonts/InterVariable-Italic.ttf
|
||||
style: italic
|
||||
113
app/pubspec.yaml.bak
Executable file
113
app/pubspec.yaml.bak
Executable file
@@ -0,0 +1,113 @@
|
||||
name: geosector_app
|
||||
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
||||
publish_to: 'none'
|
||||
version: 3.6.3+363
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.6
|
||||
|
||||
# Navigation
|
||||
go_router: ^15.1.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 16.0.0)
|
||||
|
||||
# État et gestion des données
|
||||
hive: ^2.2.3
|
||||
hive_flutter: ^1.1.0
|
||||
|
||||
# API & Réseau
|
||||
dio: ^5.3.3
|
||||
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
|
||||
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
|
||||
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||
retry: ^3.1.2
|
||||
|
||||
# UI et animations
|
||||
google_fonts: ^6.1.0
|
||||
flutter_svg: ^2.0.9
|
||||
# package_info_plus: ^4.2.0 # ❌ SUPPRIMÉ - Remplacé par AppInfoService auto-généré (13/10/2025)
|
||||
|
||||
# Utilitaires
|
||||
intl: 0.19.0 # Version requise par flutter_localizations Flutter 3.24.5 LTS
|
||||
uuid: ^4.2.1
|
||||
syncfusion_flutter_charts: 27.2.5 # ⬇️ Compatible Flutter 3.24.5 LTS (sweet spot)
|
||||
# shared_preferences: ^2.3.3 # Remplacé par Hive pour cohérence architecturale
|
||||
|
||||
# Cartes et géolocalisation
|
||||
url_launcher: ^6.1.14 # ⬇️ Version stable (depuis 6.3.1)
|
||||
flutter_map: ^7.0.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 8.2.2)
|
||||
flutter_map_cache: ^1.5.1 # Compatible Flutter 3.24.5 LTS (Dart 3.5.4)
|
||||
dio_cache_interceptor_hive_store: ^3.2.1 # ✅ Compatible flutter_map_cache 1.5.1
|
||||
path_provider: ^2.1.2 # Requis pour le cache
|
||||
latlong2: ^0.9.1
|
||||
geolocator: ^13.0.3 # ⬇️ Downgrade depuis 14.0.2 (Flutter 3.24.5 LTS)
|
||||
geolocator_android: 4.6.1 # ✅ Force version sans toARGB32()
|
||||
universal_html: ^2.2.4 # Pour accéder à la localisation du navigateur (detection env)
|
||||
flutter_compass: ^0.8.1 # Mode boussole pour le mode terrain (Android/iOS uniquement)
|
||||
|
||||
# Chat et notifications
|
||||
# mqtt5_client: ^4.11.0
|
||||
flutter_local_notifications: ^19.0.1
|
||||
|
||||
# Upload d'images
|
||||
image_picker: ^0.8.9 # ⬇️ Avant SwiftPM (depuis 1.1.2)
|
||||
|
||||
# Configuration YAML
|
||||
yaml: ^3.1.2
|
||||
|
||||
# Stripe Terminal et détection device (V2)
|
||||
device_info_plus: ^11.3.0 # ✅ Compatible Flutter 3.24.5 LTS (depuis 12.1.0)
|
||||
battery_plus: 6.0.3 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
|
||||
# network_info_plus: ^4.1.0 # ❌ SUPPRIMÉ - Remplacé par NetworkInterface natif Dart (13/10/2025)
|
||||
nfc_manager: 3.3.0 # ✅ Version stable - Nécessite patch AndroidManifest (fix-nfc-manager.sh)
|
||||
mek_stripe_terminal: ^4.6.0
|
||||
flutter_stripe: ^11.5.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 12.0.0)
|
||||
permission_handler: ^12.0.1
|
||||
qr_flutter: ^4.1.0 # Génération de QR codes pour paiements Stripe
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 6.0.0)
|
||||
hive_generator: ^2.0.1
|
||||
build_runner: ^2.4.6
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: 'assets/images/icons/icon-1024.png'
|
||||
min_sdk_android: 21
|
||||
adaptive_icon_background: '#FFFFFF'
|
||||
adaptive_icon_foreground: 'assets/images/icons/icon-1024.png'
|
||||
remove_alpha_ios: true
|
||||
web:
|
||||
generate: true
|
||||
image_path: 'assets/images/icons/icon-1024.png'
|
||||
background_color: '#FFFFFF'
|
||||
theme_color: '#4B77BE'
|
||||
windows:
|
||||
generate: true
|
||||
image_path: 'assets/images/icons/icon-1024.png'
|
||||
icon_size: 48
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/icons/
|
||||
- assets/animations/
|
||||
- lib/chat/chat_config.yaml
|
||||
|
||||
fonts:
|
||||
- family: Inter
|
||||
fonts:
|
||||
- asset: assets/fonts/InterVariable.ttf
|
||||
- asset: assets/fonts/InterVariable-Italic.ttf
|
||||
style: italic
|
||||
BIN
app/store_assets/geosector-1024x500.png
Normal file
BIN
app/store_assets/geosector-1024x500.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user