diff --git a/.cursor/rules/projet/10-hors-transaction-import.mdc b/.cursor/rules/projet/10-hors-transaction-import.mdc index 4ffcc4e..5814f8a 100644 --- a/.cursor/rules/projet/10-hors-transaction-import.mdc +++ b/.cursor/rules/projet/10-hors-transaction-import.mdc @@ -11,26 +11,26 @@ Doc de cadrage — source de vérité pour le développement de ce chantier. ## Intention -Utiliser la suite MS1 (gestion, ChronoTrack, hub allégé, signature de dossier, accès par tâche) pour des clients **sans** tunnel d’inscription / paiement en ligne MS1. -Les participants arrivent par **import fichier** et/ou **saisie rapide** ; ensuite sync ChronoTrack. +Utiliser la suite MS1 (gestion, ChronoTrack, outils promoteur existants) pour des clients **sans** tunnel d’inscription / paiement en ligne MS1. +Les participants arrivent surtout par **import fichier** (puis saisie rapide plus tard) ; ensuite sync ChronoTrack. -Aussi utile le **jour J** sur une course **avec** inscription en ligne : ajout / correction rapide (sexe, âge, etc.) **sans** transaction financière. +Aussi utile plus tard le **jour J** sur une course **avec** inscription en ligne : correction / ajout rapide **sans** transaction financière. ## Décisions actées | Sujet | Décision | |---|---| -| Source terrain | Toujours **`resultats_*`** (`resultats_participants`, `resultats_epreuves_commandees`, `resultats_questions`) — pas le panier | +| Source terrain | Toujours **`resultats_*`** — **pas de panier / `no_panier`** pour ces lignes | | Type événement | Nouveau type / mode « sans inscription en ligne » (ex. timing) — à créer | -| Paiement MS1 | Hors scope pour ces lignes ; **interdit** le « panier à 0 $ » | -| Clé unique | ID du fichier client = **`external_id` ChronoTrack** au push (pas seulement `par_id_original`) ; même fichier = fallback import CT | -| Questions import | **Option C** : matcher les questions existantes ; non matchées → preview + **création confirmée** | -| Hub | **Mini-hub / hub allégé** (pas zéro hub) ; grossit avec les options | -| Opérateur | MS1 (Stéphane / équipe) = promoteur opérateur ; délégation = **kits limités par tâche**, pas kit promoteur client « total » | -| Signature de dossier | **Dans le scope hub** — à cartographier (module exact) ; **≠ waiver** | -| Waiver | **Hors scope** — ne pas assimiler « signature de dossier » au waiver | -| Édition fiches | Create/edit sur `resultats_participants` (+ questions) ; **même** chemin jour J sur événement en ligne | -| Finances | Marqueur d’**origine** obligatoire ; import / saisie rapide **exclus** des rapports paiement / facture | +| Paiement MS1 | Hors scope ; **interdit** le « panier à 0 $ » | +| Clé unique | ID du fichier client = **`external_id` ChronoTrack** au push ; même fichier = fallback import CT | +| Questions import | **Option C** : matcher ; non matchées → preview + **création confirmée** | +| Hub promoteur | **Ne pas toucher pour l’instant** — hub actuel tel quel | +| Qui est promoteur | Opérateur MS1 se met **lui-même** promoteur ; droits clients = kits **minimisés via l’existant** | +| Mini-hub | **Plus tard**, au fur et à mesure — pas un livrable immédiat | +| Waiver | **Hors scope** | +| Édition / saisie rapide | Après l’import (phase suivante) | +| Finances | Marqueur d’**origine** ; import / saisie rapide exclus des rapports paiement | ## Origines participant (marqueur) @@ -38,36 +38,60 @@ Aussi utile le **jour J** sur une course **avec** inscription en ligne : ajout / |---|---| | `inscription_en_ligne` | Flux payant actuel | | `import_fichier` | Bulk modèle fichier | -| `saisie_rapide` | Création unitaire / jour J | +| `saisie_rapide` | Création unitaire / jour J (plus tard) | -## Ancrages code existants +## Phases (réalignées) -- Sync CT : `php/chronotrack_api/` — push MS1→CT ; aujourd’hui `external_id` ≈ `par_id_original` → **à adapter** si clé import présente -- `ct_entry_id` : ID interne CT post-sync (MSIN-4328) — distinct de la clé fichier -- Questions → CT : `entry_note_*` (MSIN-4461) via `resultats_questions` + `inscriptions_questions` -- Hub / accès : `php/inc_fx_promoteur_hub.php`, `inscriptions_eve_acces*` -- Pas d’import générique participants aujourd’hui (seulement FNQ/TQ/PayPal spécialisés) +1. **UI import superadm** (en cours) — dropzone, mapping colonnes + épreuves, templates persistants +2. **Écriture `resultats_*`** — upsert clé unique + questions C (sans panier) +3. **ChronoTrack** — push avec `par_external_id` +4. **Saisie / édition** — fiche rapide (+ jour J) +5. **Hub minimisé** — seulement si/quand nécessaire -## Hors scope +## UI import (décisions) -- Waiver / `waiver_cleared` comme livrable de ce projet -- Kit promoteur client complet sur ce type d’événement -- Facturation / remboursements pour lignes import / saisie rapide -- Import inverse ChronoTrack → MS1 comme chemin nominal +- Emplacement v1 : **superadm fiche événement** (panneau sous ChronoTrack ; ancre « Import ») +- Colonne fichier pour l’épreuve → match vers `epr_id` de l’événement +- UX : dropzone ludique + mapping guidé +- Template mapping : table `inscriptions_import_templates` (empreinte en-têtes + eve/com) -## Phases +## Exemple fichier client (référence) -0. **Cadrage** — contrat fichier + unique ID ; stratégie questions C ; carto « signature de dossier » ; inventaire `no_panier` / finances -1. **Fondations** — type événement ; colonnes clé externe + origine ; coquille `pec` sans commande payante -2. **Import** — UI + upsert clé unique + questions (match / création confirmée) → `resultats_*` -3. **Saisie / édition** — fiche rapide ; ouvert aussi sur courses en ligne (jour J) -4. **CT + hub** — sync avec clé fichier ; mini-hub ; accès par tâche ; signature de dossier -5. **Ops** — tests mélange payant vs hors-transaction ; filet même fichier → CT +Fichier type : export ~Échappée (en-têtes déjà normalisés ; l’original client peut différer). -## Règles agent (pendant le chantier) +| Colonne exemple | Cible MS1 | +|---|---| +| Attendee # | `par_external_id` (clé unique / CT) | +| First Name / Last Name | `par_prenom` / `par_nom` | +| Ticket Type | `epr` (valeurs → épreuves MS1) | +| No dossard | `no_bib` | +| Gender (Male/Female/Other) | `par_sexe` (normaliser à l’écriture) | +| Age / Birth Date | `par_age` / `par_naissance` | +| Nationalité, urgence, allergies, condition médicale, langue… | questions (option C) | -- Changement minimal ; référencer **MSIN-4482** dans SQL / commentaires PHP. -- Ne pas brancher ces flux sur le panier / paiement. -- Ne pas réintroduire le waiver « pour faire la signature de dossier ». -- Préférer étendre gestion / hub existants plutôt que doubler les fiches participant. -- SQL : uniquement **dev préprod**, puis Navicat + sync outil (workflow projet). +~1800 lignes, ~10–12 valeurs distinctes de Ticket Type (distances / jours). + +## Fichiers + +| Rôle | Fichier | +|---|---| +| SQL | `sql/MSIN-4482-import-resultats-templates.sql` | +| Logique + UI | `php/inc_fx_import_resultats.php` | +| AJAX | `superadm/ajax_import_resultats.php` | +| Branche fiche eve | `superadm/php/inc_form.php` | +| CSS | `superadm/css/style_blue.css` | + +## Hors scope (maintenant) + +- Hub promoteur / mini-hub +- Waiver +- Écriture `resultats_*` (prochaine étape après UI) +- Import inverse CT → MS1 + +## Règles agent + +- Changement minimal ; référencer **MSIN-4482**. +- **Pas de panier** pour ces participants — écrire dans `resultats_*`. +- **Ne pas modifier le hub promoteur** tant que ce n’est pas redemandé. +- Ne pas inventer de waiver pour « signature de dossier ». +- SQL : **dev préprod** seulement, puis Navicat + sync outil. diff --git a/php/inc_fx_import_resultats.php b/php/inc_fx_import_resultats.php new file mode 100644 index 0000000..6da9809 --- /dev/null +++ b/php/inc_fx_import_resultats.php @@ -0,0 +1,1083 @@ + + */ +function fxImportResultatsTargetFields() { + return array( + array('key' => '', 'label' => '— Ignorer —', 'group' => ''), + array('key' => 'par_external_id', 'label' => 'Clé unique (ChronoTrack / fichier)', 'group' => 'Identité'), + array('key' => 'par_prenom', 'label' => 'Prénom', 'group' => 'Identité'), + array('key' => 'par_nom', 'label' => 'Nom', 'group' => 'Identité'), + array('key' => 'par_sexe', 'label' => 'Sexe', 'group' => 'Identité'), + array('key' => 'par_naissance', 'label' => 'Date de naissance', 'group' => 'Identité'), + array('key' => 'par_age', 'label' => 'Âge', 'group' => 'Identité'), + array('key' => 'par_courriel', 'label' => 'Courriel', 'group' => 'Contact'), + array('key' => 'par_telephone1', 'label' => 'Téléphone', 'group' => 'Contact'), + array('key' => 'par_adresse', 'label' => 'Adresse', 'group' => 'Contact'), + array('key' => 'par_ville', 'label' => 'Ville', 'group' => 'Contact'), + array('key' => 'par_codepostal', 'label' => 'Code postal', 'group' => 'Contact'), + array('key' => 'no_bib', 'label' => 'Dossard', 'group' => 'Course'), + array('key' => 'epr', 'label' => 'Épreuve (valeurs → épreuves MS1)', 'group' => 'Course'), + array('key' => 'par_nom_equipe', 'label' => 'Nom d\'équipe', 'group' => 'Équipe'), + array('key' => 'par_no_equipe', 'label' => 'No équipe', 'group' => 'Équipe'), + ); +} + +function fxImportResultatsEsc($str) { + return htmlspecialchars((string)$str, ENT_QUOTES, 'UTF-8'); +} + +function fxImportResultatsUploadDir() { + $strDir = dirname(__FILE__) . '/../upload/import_resultats'; + if (!is_dir($strDir)) { + @mkdir($strDir, 0755, true); + } + return $strDir; +} + +function fxImportResultatsNormalizeHeader($str) { + $str = trim((string)$str); + $str = preg_replace('/\s+/u', ' ', $str); + return mb_strtolower($str, 'UTF-8'); +} + +/** + * Empreinte stable des en-têtes (même fichier client = même sig). + */ +function fxImportResultatsHeaderSig(array $tabHeaders) { + $tabNorm = array(); + foreach ($tabHeaders as $strH) { + $tabNorm[] = fxImportResultatsNormalizeHeader($strH); + } + return sha1(implode("\n", $tabNorm)); +} + +/** + * Suggestion auto de champ selon le libellé d'en-tête. + * MSIN-4482 — priorise match exact / aiguille longue ; évite faux positifs sur questions bilingues. + */ +function fxImportResultatsGuessField($strHeader) { + $str = fxImportResultatsNormalizeHeader($strHeader); + $str = str_replace(array('-', '_'), ' ', $str); + + // MSIN-4482 — inclut libellés type export client (ex. Échappée / Eventbrite-like) + $tabRules = array( + 'par_external_id' => array( + 'attendee #', 'attendee id', 'external id', 'external_id', 'unique id', 'uniqueid', + 'entry id', 'id unique', 'ct id', 'chronotrack id', 'reg id', 'participant id', 'attendee', + ), + 'par_prenom' => array('first name', 'firstname', 'given name', 'prenom', 'prénom'), + 'par_nom' => array('last name', 'lastname', 'family name', 'surname'), + 'par_sexe' => array('gender', 'sexe', 'sex', 'genre'), + 'par_naissance' => array('birth date', 'date of birth', 'date naissance', 'naissance', 'birthday', 'dob'), + 'par_age' => array('age', 'âge'), + 'par_courriel' => array('courriel', 'e-mail', 'email', 'mail'), + 'par_telephone1' => array('telephone', 'téléphone', 'phone', 'mobile', 'cell', 'tel'), + 'par_adresse' => array('adresse', 'address', 'street'), + 'par_ville' => array('ville', 'city', 'town'), + 'par_codepostal' => array('code postal', 'postal', 'zipcode', 'zip'), + 'no_bib' => array('no dossard', 'bib number', 'dossard', 'no bib', 'bib#', 'bib'), + 'epr' => array( + 'ticket type', 'registration type', 'reg choice', 'event race', + 'epreuve', 'épreuve', 'distance', 'race', 'course', 'ticket', + ), + 'par_nom_equipe' => array('team name', 'nom equipe', 'nom équipe', 'equipe', 'équipe', 'team'), + 'par_no_equipe' => array('team number', 'team no', 'no equipe', 'no équipe', 'team id'), + ); + + // 1) égalité exacte + foreach ($tabRules as $strKey => $tabNeedles) { + foreach ($tabNeedles as $strNeedle) { + if ($str === $strNeedle) { + return $strKey; + } + } + } + + // Questions longues bilingues → pas de guess champ standard (resteront en step questions) + if (mb_strlen($str, 'UTF-8') > 40 || strpos($str, '?') !== false || strpos($str, ' / ') !== false) { + // Exceptions courtes déjà couvertes en exact ; ici on laisse vide + // sauf préfixes connus très spécifiques + if (strpos($str, 'attendee') === 0) { + return 'par_external_id'; + } + return ''; + } + + // 2) contient — aiguilles les plus longues d'abord + $tabCandidates = array(); + foreach ($tabRules as $strKey => $tabNeedles) { + foreach ($tabNeedles as $strNeedle) { + if ($strNeedle === '') { + continue; + } + // aiguilles 1–3 lettres : mot entier seulement + if (mb_strlen($strNeedle, 'UTF-8') <= 3) { + if (preg_match('/(^|\\s)' . preg_quote($strNeedle, '/') . '(\\s|$)/u', $str)) { + $tabCandidates[] = array('key' => $strKey, 'len' => mb_strlen($strNeedle, 'UTF-8')); + } + continue; + } + if (strpos($str, $strNeedle) !== false) { + $tabCandidates[] = array('key' => $strKey, 'len' => mb_strlen($strNeedle, 'UTF-8')); + } + } + } + if (count($tabCandidates) < 1) { + return ''; + } + usort($tabCandidates, function ($a, $b) { + return $b['len'] - $a['len']; + }); + return $tabCandidates[0]['key']; +} + +function fxImportResultatsComIdForEve($intEveId) { + global $objDatabase; + $intEveId = intval($intEveId); + if ($intEveId <= 0) { + return 0; + } + $sql = "SELECT com_id FROM inscriptions_evenements WHERE eve_id = " . $intEveId . " LIMIT 1"; + $tab = $objDatabase->fxGetResults($sql); + if (!is_array($tab) || empty($tab[1]['com_id'])) { + return 0; + } + return intval($tab[1]['com_id']); +} + +function fxImportResultatsEpreuvesList($intEveId) { + global $objDatabase; + $intEveId = intval($intEveId); + $sql = "SELECT epr_id, epr_nom_fr, epr_type_fr, epr_date" + . " FROM inscriptions_epreuves WHERE eve_id = " . $intEveId + . " ORDER BY epr_date, epr_nom_fr"; + $tab = $objDatabase->fxGetResults($sql); + $tabOut = array(); + if (!is_array($tab)) { + return $tabOut; + } + for ($i = 1; $i <= count($tab); $i++) { + $strLabel = trim((string)($tab[$i]['epr_type_fr'] ?? '')); + $strNom = trim((string)($tab[$i]['epr_nom_fr'] ?? '')); + if ($strLabel !== '' && $strNom !== '') { + $strLabel .= ' — ' . $strNom; + } elseif ($strNom !== '') { + $strLabel = $strNom; + } else { + $strLabel = 'Épreuve #' . intval($tab[$i]['epr_id']); + } + $tabOut[] = array( + 'epr_id' => intval($tab[$i]['epr_id']), + 'label' => $strLabel, + ); + } + return $tabOut; +} + +function fxImportResultatsQuestionsList($intEveId) { + global $objDatabase; + $intEveId = intval($intEveId); + $sql = "SELECT que_id, que_question_fr, que_rapport_label_fr, que_actif" + . " FROM inscriptions_questions WHERE eve_id = " . $intEveId + . " AND que_actif = 1 ORDER BY que_tri, que_id"; + $tab = $objDatabase->fxGetResults($sql); + $tabOut = array(); + if (!is_array($tab)) { + return $tabOut; + } + for ($i = 1; $i <= count($tab); $i++) { + $strLabel = trim((string)($tab[$i]['que_rapport_label_fr'] ?? '')); + if ($strLabel === '') { + $strLabel = trim((string)($tab[$i]['que_question_fr'] ?? '')); + } + $tabOut[] = array( + 'que_id' => intval($tab[$i]['que_id']), + 'label' => $strLabel !== '' ? $strLabel : ('Question #' . intval($tab[$i]['que_id'])), + ); + } + return $tabOut; +} + +/** + * Lit CSV ou Excel → en-têtes + lignes (tableaux indexés 0..n). + * @return array{state:string,message?:string,headers?:array,rows?:array} + */ +function fxImportResultatsParseFile($strPath, $strOriginalName = '') { + $strExt = strtolower(pathinfo($strOriginalName !== '' ? $strOriginalName : $strPath, PATHINFO_EXTENSION)); + if ($strExt === 'csv' || $strExt === 'txt') { + return fxImportResultatsParseCsv($strPath); + } + if (in_array($strExt, array('xls', 'xlsx'), true)) { + return fxImportResultatsParseExcel($strPath); + } + // Sniff CSV + $strSample = @file_get_contents($strPath, false, null, 0, 2048); + if (is_string($strSample) && (strpos($strSample, ',') !== false || strpos($strSample, ';') !== false)) { + return fxImportResultatsParseCsv($strPath); + } + return array('state' => 'error', 'message' => 'Format non supporté (CSV, XLS, XLSX).'); +} + +function fxImportResultatsDetectCsvDelimiter($strPath) { + $strLine = ''; + $fp = @fopen($strPath, 'rb'); + if ($fp) { + $strLine = (string)fgets($fp); + fclose($fp); + } + $intSemi = substr_count($strLine, ';'); + $intComma = substr_count($strLine, ','); + $intTab = substr_count($strLine, "\t"); + if ($intTab > $intSemi && $intTab > $intComma) { + return "\t"; + } + return ($intSemi > $intComma) ? ';' : ','; +} + +function fxImportResultatsParseCsv($strPath) { + $strDelim = fxImportResultatsDetectCsvDelimiter($strPath); + $fp = @fopen($strPath, 'rb'); + if (!$fp) { + return array('state' => 'error', 'message' => 'Impossible de lire le fichier.'); + } + // BOM UTF-8 + $strBom = fread($fp, 3); + if ($strBom !== "\xEF\xBB\xBF") { + rewind($fp); + } + $tabHeaders = fgetcsv($fp, 0, $strDelim); + if (!is_array($tabHeaders) || count($tabHeaders) < 1) { + fclose($fp); + return array('state' => 'error', 'message' => 'En-têtes manquants.'); + } + $tabHeaders = array_map('trim', $tabHeaders); + $tabRows = array(); + while (($tabRow = fgetcsv($fp, 0, $strDelim)) !== false) { + if (!is_array($tabRow)) { + continue; + } + // Ignorer lignes totalement vides + $blnEmpty = true; + foreach ($tabRow as $strCell) { + if (trim((string)$strCell) !== '') { + $blnEmpty = false; + break; + } + } + if ($blnEmpty) { + continue; + } + // Aligner largeur + while (count($tabRow) < count($tabHeaders)) { + $tabRow[] = ''; + } + if (count($tabRow) > count($tabHeaders)) { + $tabRow = array_slice($tabRow, 0, count($tabHeaders)); + } + $tabRows[] = $tabRow; + } + fclose($fp); + return array('state' => 'ok', 'headers' => $tabHeaders, 'rows' => $tabRows); +} + +function fxImportResultatsParseExcel($strPath) { + $strPhpExcel = dirname(__FILE__) . '/../superadm/php/Classes/PHPExcel.php'; + if (!is_file($strPhpExcel)) { + $strPhpExcel = dirname(__FILE__) . '/Classes/PHPExcel.php'; + } + if (!is_file($strPhpExcel)) { + return array('state' => 'error', 'message' => 'Lecteur Excel indisponible — utilisez un CSV.'); + } + require_once $strPhpExcel; + try { + $objBook = PHPExcel_IOFactory::load($strPath); + $objSheet = $objBook->getActiveSheet(); + $tabRaw = $objSheet->toArray(null, true, true, false); + } catch (Exception $ex) { + return array('state' => 'error', 'message' => 'Lecture Excel échouée : ' . $ex->getMessage()); + } + if (!is_array($tabRaw) || count($tabRaw) < 1) { + return array('state' => 'error', 'message' => 'Fichier Excel vide.'); + } + $tabHeaders = array_map(function ($v) { + return trim((string)$v); + }, $tabRaw[0]); + // Trim trailing empty headers + while (count($tabHeaders) > 0 && end($tabHeaders) === '') { + array_pop($tabHeaders); + } + if (count($tabHeaders) < 1) { + return array('state' => 'error', 'message' => 'En-têtes manquants.'); + } + $tabRows = array(); + for ($r = 1; $r < count($tabRaw); $r++) { + $tabRow = $tabRaw[$r]; + if (!is_array($tabRow)) { + continue; + } + $tabRow = array_slice($tabRow, 0, count($tabHeaders)); + while (count($tabRow) < count($tabHeaders)) { + $tabRow[] = ''; + } + $blnEmpty = true; + foreach ($tabRow as $strCell) { + if (trim((string)$strCell) !== '') { + $blnEmpty = false; + break; + } + } + if ($blnEmpty) { + continue; + } + $tabRows[] = array_map(function ($v) { + return trim((string)$v); + }, $tabRow); + } + return array('state' => 'ok', 'headers' => $tabHeaders, 'rows' => $tabRows); +} + +/** + * Valeurs distinctes par colonne (capées). + */ +function fxImportResultatsDistincts(array $tabRows, $intColCount, $intMaxPerCol = 40) { + $tabDistinct = array(); + for ($c = 0; $c < $intColCount; $c++) { + $tabDistinct[$c] = array(); + } + foreach ($tabRows as $tabRow) { + for ($c = 0; $c < $intColCount; $c++) { + $strVal = trim((string)($tabRow[$c] ?? '')); + if ($strVal === '') { + continue; + } + if (!isset($tabDistinct[$c][$strVal])) { + if (count($tabDistinct[$c]) >= $intMaxPerCol) { + continue; + } + $tabDistinct[$c][$strVal] = true; + } + } + } + $tabOut = array(); + for ($c = 0; $c < $intColCount; $c++) { + $tabOut[$c] = array_keys($tabDistinct[$c]); + sort($tabOut[$c], SORT_NATURAL | SORT_FLAG_CASE); + } + return $tabOut; +} + +function fxImportResultatsSaveUpload($intEveId, array $arrFile) { + $intEveId = intval($intEveId); + if ($intEveId <= 0) { + return array('state' => 'error', 'message' => 'eve_id manquant'); + } + if (empty($arrFile['tmp_name']) || !is_uploaded_file($arrFile['tmp_name'])) { + return array('state' => 'error', 'message' => 'Aucun fichier reçu'); + } + if (!empty($arrFile['error']) && intval($arrFile['error']) !== UPLOAD_ERR_OK) { + return array('state' => 'error', 'message' => 'Erreur upload (code ' . intval($arrFile['error']) . ')'); + } + $strName = (string)($arrFile['name'] ?? 'import.csv'); + $strToken = bin2hex(random_bytes(16)); + $strSafeExt = strtolower(pathinfo($strName, PATHINFO_EXTENSION)); + if (!in_array($strSafeExt, array('csv', 'txt', 'xls', 'xlsx'), true)) { + $strSafeExt = 'csv'; + } + $strDest = fxImportResultatsUploadDir() . '/' . $intEveId . '_' . $strToken . '.' . $strSafeExt; + if (!@move_uploaded_file($arrFile['tmp_name'], $strDest)) { + return array('state' => 'error', 'message' => 'Échec enregistrement temporaire'); + } + $arrParsed = fxImportResultatsParseFile($strDest, $strName); + if (($arrParsed['state'] ?? '') !== 'ok') { + @unlink($strDest); + return $arrParsed; + } + $tabHeaders = $arrParsed['headers']; + $tabRows = $arrParsed['rows']; + $strSig = fxImportResultatsHeaderSig($tabHeaders); + $tabGuess = array(); + foreach ($tabHeaders as $strH) { + $tabGuess[] = fxImportResultatsGuessField($strH); + } + $tabSamples = array_slice($tabRows, 0, 8); + $tabDistinct = fxImportResultatsDistincts($tabRows, count($tabHeaders)); + + // Meta JSON à côté du fichier + $arrMeta = array( + 'eve_id' => $intEveId, + 'token' => $strToken, + 'original_name' => $strName, + 'path' => $strDest, + 'header_sig' => $strSig, + 'headers' => $tabHeaders, + 'row_count' => count($tabRows), + 'created_at' => date('c'), + ); + @file_put_contents($strDest . '.meta.json', json_encode($arrMeta, JSON_UNESCAPED_UNICODE)); + + $tabTemplates = fxImportResultatsFindTemplates($intEveId, $strSig); + + return array( + 'state' => 'ok', + 'token' => $strToken, + 'original_name' => $strName, + 'header_sig' => $strSig, + 'headers' => $tabHeaders, + 'guess' => $tabGuess, + 'row_count' => count($tabRows), + 'samples' => $tabSamples, + 'distincts' => $tabDistinct, + 'templates' => $tabTemplates, + 'epreuves' => fxImportResultatsEpreuvesList($intEveId), + 'questions' => fxImportResultatsQuestionsList($intEveId), + 'fields' => fxImportResultatsTargetFields(), + ); +} + +function fxImportResultatsResolveTokenPath($intEveId, $strToken) { + $intEveId = intval($intEveId); + $strToken = preg_replace('/[^a-f0-9]/', '', strtolower((string)$strToken)); + if ($intEveId <= 0 || strlen($strToken) < 16) { + return null; + } + $strDir = fxImportResultatsUploadDir(); + $tabMatches = glob($strDir . '/' . $intEveId . '_' . $strToken . '.*'); + if (!is_array($tabMatches)) { + return null; + } + foreach ($tabMatches as $strPath) { + if (substr($strPath, -10) === '.meta.json') { + continue; + } + if (is_file($strPath)) { + return $strPath; + } + } + return null; +} + +function fxImportResultatsFindTemplates($intEveId, $strSig) { + global $objDatabase; + $intEveId = intval($intEveId); + $strSig = preg_replace('/[^a-f0-9]/', '', strtolower((string)$strSig)); + $intComId = fxImportResultatsComIdForEve($intEveId); + $sql = "SELECT tpl_id, eve_id, com_id, tpl_name, tpl_header_sig, tpl_mapping_json, tpl_maj" + . " FROM inscriptions_import_templates" + . " WHERE tpl_actif = 1 AND tpl_header_sig = '" . addslashes($strSig) . "'" + . " AND (eve_id = " . $intEveId + . ($intComId > 0 ? (" OR com_id = " . $intComId) : '') + . ")" + . " ORDER BY (eve_id = " . $intEveId . ") DESC, tpl_maj DESC LIMIT 20"; + $tab = $objDatabase->fxGetResults($sql); + $tabOut = array(); + if (!is_array($tab)) { + return $tabOut; + } + for ($i = 1; $i <= count($tab); $i++) { + $arrMap = json_decode((string)($tab[$i]['tpl_mapping_json'] ?? ''), true); + $tabOut[] = array( + 'tpl_id' => intval($tab[$i]['tpl_id']), + 'eve_id' => intval($tab[$i]['eve_id']), + 'com_id' => intval($tab[$i]['com_id']), + 'name' => (string)$tab[$i]['tpl_name'], + 'maj' => (string)$tab[$i]['tpl_maj'], + 'mapping' => is_array($arrMap) ? $arrMap : array(), + 'same_event' => (intval($tab[$i]['eve_id']) === $intEveId), + ); + } + return $tabOut; +} + +/** + * @param array $arrMapping { columns: string[], epr_values: {value: epr_id}, questions: {colIndex: que_id|"new"} } + */ +function fxImportResultatsSaveTemplate($intEveId, $strToken, $strName, array $arrMapping, $intUsaId, $blnShareCom = true) { + global $objDatabase; + + $intEveId = intval($intEveId); + $strPath = fxImportResultatsResolveTokenPath($intEveId, $strToken); + if ($strPath === null) { + return array('state' => 'error', 'message' => 'Fichier temporaire introuvable — réuploadez.'); + } + $strMetaPath = $strPath . '.meta.json'; + $arrMeta = array(); + if (is_file($strMetaPath)) { + $arrMeta = json_decode((string)@file_get_contents($strMetaPath), true); + if (!is_array($arrMeta)) { + $arrMeta = array(); + } + } + $tabHeaders = isset($arrMeta['headers']) && is_array($arrMeta['headers']) ? $arrMeta['headers'] : array(); + $strSig = isset($arrMeta['header_sig']) ? (string)$arrMeta['header_sig'] : fxImportResultatsHeaderSig($tabHeaders); + if ($tabHeaders === array()) { + $arrParsed = fxImportResultatsParseFile($strPath); + if (($arrParsed['state'] ?? '') !== 'ok') { + return $arrParsed; + } + $tabHeaders = $arrParsed['headers']; + $strSig = fxImportResultatsHeaderSig($tabHeaders); + } + + $strName = trim((string)$strName); + if ($strName === '') { + $strName = 'Template ' . date('Y-m-d H:i'); + } + $intComId = $blnShareCom ? fxImportResultatsComIdForEve($intEveId) : 0; + $strNow = date('Y-m-d H:i:s'); + $strHeadersJson = json_encode($tabHeaders, JSON_UNESCAPED_UNICODE); + $strMappingJson = json_encode($arrMapping, JSON_UNESCAPED_UNICODE); + if ($strHeadersJson === false || $strMappingJson === false) { + return array('state' => 'error', 'message' => 'JSON mapping invalide'); + } + + // Upsert soft : même eve + même sig + même nom → update + $sqlFind = "SELECT tpl_id FROM inscriptions_import_templates" + . " WHERE eve_id = " . $intEveId + . " AND tpl_header_sig = '" . addslashes($strSig) . "'" + . " AND tpl_name = '" . addslashes($strName) . "'" + . " AND tpl_actif = 1 LIMIT 1"; + $tabFind = $objDatabase->fxGetResults($sqlFind); + if (is_array($tabFind) && !empty($tabFind[1]['tpl_id'])) { + $intTplId = intval($tabFind[1]['tpl_id']); + $sql = "UPDATE inscriptions_import_templates SET" + . " com_id = " . intval($intComId) . "," + . " tpl_headers_json = '" . addslashes($strHeadersJson) . "'," + . " tpl_mapping_json = '" . addslashes($strMappingJson) . "'," + . " tpl_usa_id = " . intval($intUsaId) . "," + . " tpl_maj = '" . addslashes($strNow) . "'" + . " WHERE tpl_id = " . $intTplId; + $objDatabase->fxQuery($sql); + } else { + $sql = "INSERT INTO inscriptions_import_templates" + . " (eve_id, com_id, tpl_name, tpl_header_sig, tpl_headers_json, tpl_mapping_json, tpl_actif, tpl_usa_id, tpl_creation, tpl_maj)" + . " VALUES (" + . intval($intEveId) . ", " + . intval($intComId) . ", " + . "'" . addslashes($strName) . "', " + . "'" . addslashes($strSig) . "', " + . "'" . addslashes($strHeadersJson) . "', " + . "'" . addslashes($strMappingJson) . "', " + . "1, " + . intval($intUsaId) . ", " + . "'" . addslashes($strNow) . "', " + . "'" . addslashes($strNow) . "'" + . ")"; + $objDatabase->fxQuery($sql); + $intTplId = intval($objDatabase->fxGetLastId()); + } + + return array( + 'state' => 'ok', + 'tpl_id' => $intTplId, + 'message' => 'Template enregistré — le prochain fichier avec les mêmes colonnes le proposera.', + 'templates' => fxImportResultatsFindTemplates($intEveId, $strSig), + ); +} + +/** + * Validation mapping (sans écriture resultats). + */ +function fxImportResultatsValidateMapping($intEveId, $strToken, array $arrMapping) { + $intEveId = intval($intEveId); + $strPath = fxImportResultatsResolveTokenPath($intEveId, $strToken); + if ($strPath === null) { + return array('state' => 'error', 'message' => 'Fichier temporaire introuvable — réuploadez.'); + } + $arrParsed = fxImportResultatsParseFile($strPath); + if (($arrParsed['state'] ?? '') !== 'ok') { + return $arrParsed; + } + $tabColumns = isset($arrMapping['columns']) && is_array($arrMapping['columns']) ? $arrMapping['columns'] : array(); + $tabEprValues = isset($arrMapping['epr_values']) && is_array($arrMapping['epr_values']) ? $arrMapping['epr_values'] : array(); + + $blnHasExt = false; + $blnHasEpr = false; + $intEprCol = -1; + foreach ($tabColumns as $intIdx => $strKey) { + $strKey = (string)$strKey; + if ($strKey === 'par_external_id') { + $blnHasExt = true; + } + if ($strKey === 'epr') { + $blnHasEpr = true; + $intEprCol = intval($intIdx); + } + } + + $tabErrors = array(); + $tabWarn = array(); + if (!$blnHasExt) { + $tabErrors[] = 'Mappez une colonne « Clé unique » (indispensable pour ChronoTrack / réimport).'; + } + if (!$blnHasEpr) { + $tabErrors[] = 'Mappez une colonne « Épreuve ».'; + } else { + $tabDistinct = fxImportResultatsDistincts($arrParsed['rows'], count($arrParsed['headers'])); + $tabVals = $tabDistinct[$intEprCol] ?? array(); + $intUnmapped = 0; + foreach ($tabVals as $strVal) { + $intEpr = intval($tabEprValues[$strVal] ?? 0); + if ($intEpr <= 0) { + $intUnmapped++; + } + } + if ($intUnmapped > 0) { + $tabErrors[] = $intUnmapped . ' valeur(s) d\'épreuve sans correspondance MS1.'; + } + } + if (!in_array('par_prenom', $tabColumns, true) && !in_array('par_nom', $tabColumns, true)) { + $tabWarn[] = 'Ni prénom ni nom mappé — vérifiez le fichier.'; + } + + if (count($tabErrors) > 0) { + return array( + 'state' => 'error', + 'message' => implode(' ', $tabErrors), + 'errors' => $tabErrors, + 'warnings' => $tabWarn, + 'row_count' => count($arrParsed['rows']), + ); + } + + return array( + 'state' => 'ok', + 'message' => 'Mapping prêt — ' . count($arrParsed['rows']) . ' ligne(s). L\'écriture dans resultats_* arrive juste après cette interface.', + 'warnings' => $tabWarn, + 'row_count' => count($arrParsed['rows']), + 'commit_ready' => false, + ); +} + +/** + * Panneau UI fiche événement superadm. + */ +function fxImportResultatsRenderEventPanel($intEveId) { + $intEveId = intval($intEveId); + if ($intEveId <= 0) { + return; + } + $strAjax = 'ajax_import_resultats.php'; + $tabEpreuves = fxImportResultatsEpreuvesList($intEveId); + $intNbEpr = count($tabEpreuves); + + require_once dirname(__FILE__) . '/inc_fx_superadm_v2_ui.php'; + fxSuperadmV2PanelOpen('Import participants (hors transaction)', array( + 'collapse_id' => 'importResultatsCollapse', + 'zone_id' => 'import_resultats', + 'zone_class' => 'superadm-content-v2-zone mt-3 mb-2', + 'header_extra' => 'MSIN-4482', + )); + ?> +
+ Déposez le fichier client (CSV / Excel). Alignez les colonnes une fois —
+ le template est sauvegardé pour les prochains imports du même format.
+ Incluez une colonne épreuve et une clé unique (ChronoTrack).
+
+
Aucune épreuve sur cet événement — créez-en avant d'importer.
+
+
épreuve(s) disponible(s) pour le mapping.
+
+
| Colonne fichier | +Aperçu | +Champ MS1 | +
|---|
| Valeur dans le fichier | +Épreuve MS1 | +
|---|
Colonnes non mappées à un champ standard peuvent être liées à une question existante ou marquées « Créer à l'import ».
+| Colonne | +Question | +
|---|