chore: build
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s

This commit is contained in:
Xor290
2026-08-06 12:06:05 +02:00
parent c034088bee
commit 22a8d5026c
174 changed files with 30315 additions and 16120 deletions
+18 -39
View File
@@ -3,17 +3,13 @@ package services
import (
"encoding/json"
"fmt"
"gestion/utils"
"io"
"math"
"net/http"
"net/url"
"strings"
"time"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
// ============================================
@@ -64,25 +60,24 @@ func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionServi
}
}
// ============================================
// POINT D'ENTRÉE PRINCIPAL
// ============================================
// ResolveAddress tente de géocoder une adresse avec correction automatique.
// Retourne toujours une suggestion, même approximative.
// Ordre de résolution :
// 1. Géocodage exact → succès immédiat
// 2. Nominatim fuzzy search (addressdetails + limit=5)
// 3. Décomposition structurée de l'adresse
// 4. Erreur explicite avec suggestions si dispo
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
rawAddress = strings.TrimSpace(rawAddress)
if rawAddress == "" {
return nil, fmt.Errorf("adresse vide")
}
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
if loc, err := acs.geoService.getFromCache(rawAddress); err == nil {
return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
Confidence: 1.0,
CorrectionApplied: false,
Source: "exact",
}, nil
}
if loc, err := acs.geoService.fetchFromNominatim(rawAddress); err == nil {
acs.geoService.saveToCache(rawAddress, loc)
return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
@@ -106,11 +101,6 @@ func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*Address
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
}
// ============================================
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
// ============================================
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
variants := buildAddressVariants(address)
@@ -131,7 +121,7 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
CorrectedAddress: corrected,
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
Confidence: confidence,
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)),
CorrectionApplied: !strings.EqualFold(utils.NormalizeAddress(address), utils.NormalizeAddress(corrected)),
Source: "fuzzy",
}, nil
}
@@ -140,7 +130,6 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
}
// queryNominatim exécute une requête vers l'API Nominatim
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
query = strings.TrimSpace(query)
if query == "" {
@@ -172,7 +161,7 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode)
return nil, fmt.Errorf("nominatim status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
@@ -196,7 +185,6 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
parts := parseAddressParts(address)
// Essai 1 : numéro + rue + ville (sans code postal)
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
@@ -206,7 +194,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
}
}
// Essai 2 : rue + code postal uniquement
if parts.streetName != "" && parts.postcode != "" {
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
if s, err := acs.nominatimFuzzySearch(q); err == nil {
@@ -216,7 +203,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
}
}
// Essai 3 : ville + code postal comme zone de repli
if parts.city != "" && parts.postcode != "" {
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
suggestions, err := acs.queryNominatim(q, 3)
@@ -243,7 +229,7 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
func buildAddressVariants(address string) []string {
variants := []string{address}
normalized := normalize(address)
normalized := utils.NormalizeAddress(address)
// Variante sans accents
if normalized != address {
@@ -387,8 +373,8 @@ func parseAddressParts(address string) addressParts {
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
origNorm := normalize(strings.ToLower(original))
suggNorm := normalize(strings.ToLower(suggested))
origNorm := utils.NormalizeAddress(strings.ToLower(original))
suggNorm := utils.NormalizeAddress(strings.ToLower(suggested))
// Score de similarité sur les mots communs
origWords := strings.Fields(origNorm)
@@ -449,13 +435,6 @@ func formatNominatimAddress(s NominatimSuggestion) string {
return strings.Join(parts, ", ")
}
// normalize supprime les accents et normalise les espaces
func normalize(s string) string {
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
result, _, _ := transform.String(t, s)
return strings.Join(strings.Fields(result), " ")
}
// isPostcode retourne true si le mot ressemble à un code postal français
func isPostcode(s string) bool {
if len(s) != 5 {
+4 -19
View File
@@ -72,19 +72,14 @@ func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
return location, nil
}
// 2. TomTom (primaire — plus fiable que Nominatim pour les adresses FR)
if location, err := GeocodeWithTomTom(address); err == nil {
gs.saveToCache(address, location)
return location, nil
}
// 3. Fallback Nominatim
// 2. Tentative directe via Nominatim
if location, err := gs.fetchFromNominatim(address); err == nil {
gs.saveToCache(address, location)
return location, nil
}
// 4. ── Correction automatique de l'adresse ────────────────────────────
// 3. ── NOUVEAU : correction automatique de l'adresse ──────────────────
// Déclenché uniquement si le géocodage direct a échoué.
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
suggestion, err := gs.correctionService.ResolveAddress(address)
@@ -216,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string {
return fmt.Sprintf("geocode:cache:%s", address)
}
// ============================================
// CALCULS GÉOGRAPHIQUES
// ============================================
// CalculateDistance calcule la distance entre deux points (formule Haversine)
func CalculateDistance(from, to Coordinates) float64 {
// Conversion en radians
@@ -228,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 {
lat2Rad := toRadians(to.Latitude)
lon2Rad := toRadians(to.Longitude)
// Différences
dLat := lat2Rad - lat1Rad
dLon := lon2Rad - lon1Rad
@@ -244,13 +234,10 @@ func CalculateDistance(from, to Coordinates) float64 {
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
func CalculateETA(distanceKm float64) int {
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
if distanceKm < 0.1 {
return MinETA // Très proche: minimum 3 minutes
return MinETA
}
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
// Plus réaliste que 30 km/h
travelTime := (distanceKm / 25.0) * 60.0
// Ajouter une marge pour le trafic (environ 20%)
@@ -267,8 +254,6 @@ func CalculateETA(distanceKm float64) int {
return totalMinutes
}
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
// Retourne (etaMinutes, distanceKm, error)
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
if len(tomTomKeys.keys) == 0 {
distance := CalculateDistance(from, to)
+2 -2
View File
@@ -42,7 +42,7 @@ func (s *LBTelegramService) IsConfigured() bool {
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
payload := map[string]interface{}{
payload := map[string]any{
"user_id": chatID,
"username": username,
"role": role,
@@ -68,7 +68,7 @@ func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) erro
// SendNotification envoie un message via la gateway LBTelegram.
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
payload := map[string]interface{}{
payload := map[string]any{
"user_id": userID,
"message": message,
}
+135
View File
@@ -0,0 +1,135 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
type S3Service struct {
client *s3.Client
bucketName string
}
type S3Credentials struct {
S3KeyId string
S3AccessKey string
}
// NewS3Service initialise le client S3 pointant vers RustFS (accessible via VPN).
func NewS3Service(region, bucketName, endpoint string, creds S3Credentials) (*S3Service, error) {
var cfg aws.Config
var err error
if creds.S3KeyId != "" && creds.S3AccessKey != "" {
cfg, err = config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(creds.S3KeyId, creds.S3AccessKey, ""),
),
)
} else {
cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
}
if err != nil {
return nil, fmt.Errorf("erreur chargement config AWS: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if endpoint != "" {
o.BaseEndpoint = aws.String(endpoint) // ex: http://10.x.x.x:9000 (IP interne VPN de RustFS)
o.UsePathStyle = true
}
})
return &S3Service{
client: client,
bucketName: bucketName,
}, nil
}
// UploadFile upload un fichier et renvoie sa clé S3 (pas d'URL publique, RustFS est privé).
func (s *S3Service) UploadFile(fileHeader *multipart.FileHeader, folder string) (key string, err error) {
ext := filepath.Ext(fileHeader.Filename)
fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext)
return s.UploadFileWithName(fileHeader, folder, fileName)
}
// UploadFileWithName upload un fichier avec un nom déjà déterminé et renvoie la clé S3.
func (s *S3Service) UploadFileWithName(fileHeader *multipart.FileHeader, folder, fileName string) (key string, err error) {
file, err := fileHeader.Open()
if err != nil {
return "", fmt.Errorf("erreur ouverture fichier: %w", err)
}
defer file.Close()
buf := bytes.NewBuffer(nil)
if _, err := buf.ReadFrom(file); err != nil {
return "", fmt.Errorf("erreur lecture fichier: %w", err)
}
key = fmt.Sprintf("%s/%s", folder, fileName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
contentType := fileHeader.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
Body: bytes.NewReader(buf.Bytes()),
ContentType: aws.String(contentType),
})
if err != nil {
return "", fmt.Errorf("erreur upload RustFS: %w", err)
}
return key, nil
}
// GetFile récupère un objet depuis RustFS (stream + content-type) pour le proxy.
// Le contexte doit rester actif pendant toute la lecture du body par l'appelant.
func (s *S3Service) GetFile(ctx context.Context, key string) (io.ReadCloser, string, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
})
if err != nil {
return nil, "", fmt.Errorf("erreur lecture RustFS: %w", err)
}
contentType := "application/octet-stream"
if out.ContentType != nil {
contentType = *out.ContentType
}
return out.Body, contentType, nil
}
// DeleteFile supprime un fichier à partir de sa clé S3.
func (s *S3Service) DeleteFile(key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("erreur suppression RustFS: %w", err)
}
return nil
}
+105
View File
@@ -0,0 +1,105 @@
package services
import (
"fmt"
"gestion/utils"
"io"
"mime/multipart"
"os"
"path/filepath"
)
// Storage abstrait l'emplacement de stockage des médias produits (local ou S3),
// pour que tous les points d'upload/suppression respectent le même driver.
type Storage interface {
// Upload sauvegarde le fichier et renvoie l'URL à persister en DB (models.Media.URL)
// et la clé interne (vide pour local, clé S3 sinon — models.Media.Key).
Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error)
// Delete supprime le fichier. url et key sont ceux stockés en DB pour ce média :
// chaque implémentation ignore celui qui ne la concerne pas.
Delete(url string, key string) error
}
// LocalStorage stocke les fichiers sur le disque local, sous baseDir (ex: "uploads").
type LocalStorage struct {
baseDir string
}
func NewLocalStorage(baseDir string) *LocalStorage {
return &LocalStorage{baseDir: baseDir}
}
func (s *LocalStorage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
destFolder := filepath.Join(s.baseDir, folder)
if err := os.MkdirAll(destFolder, 0750); err != nil {
return "", "", fmt.Errorf("erreur création dossier: %w", err)
}
filePath := filepath.Join(destFolder, fileName)
safeFilePath, err := utils.SanitizeFilePath(filePath)
if err != nil {
return "", "", fmt.Errorf("chemin invalide: %w", err)
}
src, err := fileHeader.Open()
if err != nil {
return "", "", fmt.Errorf("erreur ouverture fichier: %w", err)
}
defer src.Close()
dst, err := os.OpenFile(safeFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
if err != nil {
return "", "", fmt.Errorf("erreur création fichier: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
os.Remove(safeFilePath)
return "", "", fmt.Errorf("erreur écriture fichier: %w", err)
}
return "/" + filepath.ToSlash(safeFilePath), "", nil
}
func (s *LocalStorage) Delete(url string, key string) error {
filePath := ""
if len(url) > 0 && url[0] == '/' {
filePath = url[1:]
} else {
filePath = url
}
safeFilePath, err := utils.SanitizeFilePath(filePath)
if err != nil {
return fmt.Errorf("chemin invalide: %w", err)
}
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("erreur suppression: %w", err)
}
return nil
}
// S3Storage adapte le S3Service existant (RustFS) à l'interface Storage.
type S3Storage struct {
s3 *S3Service
}
func NewS3Storage(s3 *S3Service) *S3Storage {
return &S3Storage{s3: s3}
}
func (s *S3Storage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
key, err = s.s3.UploadFileWithName(fileHeader, folder, fileName)
if err != nil {
return "", "", err
}
return "/media/" + key, key, nil
}
func (s *S3Storage) Delete(url string, key string) error {
if key == "" {
return fmt.Errorf("clé S3 manquante pour suppression")
}
return s.s3.DeleteFile(key)
}
+4 -4
View File
@@ -64,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
return fmt.Errorf("telegram non configuré")
}
payload := map[string]interface{}{
payload := map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
@@ -107,11 +107,11 @@ func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, butt
row = append(row, map[string]string{"text": b[0], "url": b[1]})
}
payload := map[string]interface{}{
payload := map[string]any{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"reply_markup": map[string]interface{}{
"reply_markup": map[string]any{
"inline_keyboard": [][]map[string]string{row},
},
}
@@ -147,7 +147,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
return fmt.Errorf("telegram non configuré")
}
payload := map[string]interface{}{
payload := map[string]any{
"url": webhookURL,
"allowed_updates": []string{"message"},
}
-66
View File
@@ -15,72 +15,6 @@ import (
"time"
)
// GeocodeWithTomTom géocode une adresse via l'API TomTom Search.
func GeocodeWithTomTom(address string) (*GeoLocation, error) {
client := &http.Client{Timeout: 10 * time.Second}
buildReq := func(key string) (*http.Request, error) {
u := &url.URL{
Scheme: "https",
Host: "api.tomtom.com",
Path: fmt.Sprintf("/search/2/geocode/%s.json", url.PathEscape(address)),
}
q := url.Values{}
q.Set("key", key)
q.Set("countrySet", "FR")
q.Set("limit", "1")
u.RawQuery = q.Encode()
return http.NewRequest(http.MethodGet, u.String(), nil)
}
resp, err := tomTomKeys.Do(client, buildReq)
if err != nil {
return nil, fmt.Errorf("TomTom geocode: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("TomTom geocode %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("TomTom geocode lecture: %w", err)
}
var parsed struct {
Results []struct {
Position struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"position"`
Address struct {
FreeformAddress string `json:"freeformAddress"`
} `json:"address"`
MatchConfidence struct {
Score float64 `json:"score"`
} `json:"matchConfidence"`
} `json:"results"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("TomTom geocode parsing: %w", err)
}
if len(parsed.Results) == 0 {
return nil, fmt.Errorf("TomTom geocode: aucun résultat pour '%s'", address)
}
r := parsed.Results[0]
log.Printf("📍 [GEO] TomTom geocode '%s' → %s (%.6f, %.6f) conf=%.2f",
address, r.Address.FreeformAddress, r.Position.Lat, r.Position.Lon, r.MatchConfidence.Score)
return &GeoLocation{
Latitude: r.Position.Lat,
Longitude: r.Position.Lon,
DisplayName: r.Address.FreeformAddress,
}, nil
}
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
client := &http.Client{Timeout: 10 * time.Second}
+2 -5
View File
@@ -59,6 +59,7 @@ func (m *tomTomKeyManager) rotate(fromIdx int) {
}
// Do exécute la requête en rotant automatiquement sur 403/429.
// buildReq doit construire une nouvelle *http.Request pour la clé donnée.
func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) {
n := len(m.keys)
if n == 0 {
@@ -67,27 +68,23 @@ func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*h
_, startIdx := m.currentKey()
for attempt := 0; attempt < n; attempt++ {
for attempt := range n {
idx := (startIdx + attempt) % n
key := m.keys[idx]
req, err := buildReq(key)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
m.rotate(idx)
continue
}
return resp, nil
}