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. + +

+ +
+ +
Glissez-déposez votre fichier ici
+
ou cliquez pour parcourir · CSV, XLS, XLSX
+ +
+ + + + +
+ + 'importResultatsCollapse')); +} diff --git a/sql/MSIN-4482-import-resultats-templates.sql b/sql/MSIN-4482-import-resultats-templates.sql new file mode 100644 index 0000000..b2c4e2c --- /dev/null +++ b/sql/MSIN-4482-import-resultats-templates.sql @@ -0,0 +1,63 @@ +-- MSIN-4482 — Templates mapping import participants (hors transaction) +-- Contexte : UI import fiche événement superadm ; upsert sur clé fichier +-- Notes : exécution UNIQUEMENT sur dev préprod ; autres env = Navicat structure + sync_static_db + +SET @db := DATABASE(); + +-- Templates de mapping colonnes (réutilisables par empreinte d'en-têtes) +CREATE TABLE IF NOT EXISTS `inscriptions_import_templates` ( + `tpl_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `eve_id` int(10) unsigned NOT NULL DEFAULT 0, + `com_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'MSIN-4482 — 0 = lié événement ; >0 = réutilisable côté client', + `tpl_name` varchar(120) NOT NULL DEFAULT '', + `tpl_header_sig` char(40) NOT NULL DEFAULT '' COMMENT 'sha1 en-têtes normalisés', + `tpl_headers_json` mediumtext NOT NULL, + `tpl_mapping_json` mediumtext NOT NULL COMMENT 'colonnes + valeurs épreuve + questions', + `tpl_actif` tinyint(1) unsigned NOT NULL DEFAULT 1, + `tpl_usa_id` int(10) unsigned NOT NULL DEFAULT 0, + `tpl_creation` datetime NOT NULL, + `tpl_maj` datetime NOT NULL, + PRIMARY KEY (`tpl_id`), + KEY `idx_import_tpl_eve_sig` (`eve_id`, `tpl_header_sig`), + KEY `idx_import_tpl_com_sig` (`com_id`, `tpl_header_sig`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Clé unique fichier (= external_id ChronoTrack) + origine d'entrée +SET @col_ext := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'resultats_participants' AND COLUMN_NAME = 'par_external_id' +); +SET @sql_ext := IF( + @col_ext = 0, + 'ALTER TABLE resultats_participants ADD COLUMN par_external_id VARCHAR(64) NULL DEFAULT NULL COMMENT ''MSIN-4482 — clé unique import / CT external_id'' AFTER par_id_original', + 'SELECT ''par_external_id already exists'' AS note' +); +PREPARE stmt_ext FROM @sql_ext; +EXECUTE stmt_ext; +DEALLOCATE PREPARE stmt_ext; + +SET @col_ori := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'resultats_participants' AND COLUMN_NAME = 'par_origine' +); +SET @sql_ori := IF( + @col_ori = 0, + 'ALTER TABLE resultats_participants ADD COLUMN par_origine VARCHAR(32) NULL DEFAULT NULL COMMENT ''MSIN-4482 — import_fichier|saisie_rapide|inscription_en_ligne'' AFTER par_external_id', + 'SELECT ''par_origine already exists'' AS note' +); +PREPARE stmt_ori FROM @sql_ori; +EXECUTE stmt_ori; +DEALLOCATE PREPARE stmt_ori; + +SET @idx_ext := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'resultats_participants' AND INDEX_NAME = 'idx_par_external_eve' +); +SET @sql_idx := IF( + @idx_ext = 0, + 'ALTER TABLE resultats_participants ADD KEY idx_par_external_eve (eve_id, par_external_id)', + 'SELECT ''idx_par_external_eve already exists'' AS note' +); +PREPARE stmt_idx FROM @sql_idx; +EXECUTE stmt_idx; +DEALLOCATE PREPARE stmt_idx; diff --git a/superadm/ajax_import_resultats.php b/superadm/ajax_import_resultats.php new file mode 100644 index 0000000..31bf43a --- /dev/null +++ b/superadm/ajax_import_resultats.php @@ -0,0 +1,70 @@ + 'error', 'message' => 'action_invalide'); + +switch ($strAction) { + case 'upload': + if ($intEveId <= 0) { + $tabRetour = array('state' => 'error', 'message' => 'eve_id manquant'); + break; + } + if (empty($_FILES['file'])) { + $tabRetour = array('state' => 'error', 'message' => 'Fichier manquant'); + break; + } + $tabRetour = fxImportResultatsSaveUpload($intEveId, $_FILES['file']); + break; + + case 'save_template': + if ($intEveId <= 0) { + $tabRetour = array('state' => 'error', 'message' => 'eve_id manquant'); + break; + } + $strToken = trim((string)($_REQUEST['token'] ?? '')); + $strName = trim((string)($_REQUEST['tpl_name'] ?? '')); + $arrMapping = $_REQUEST['mapping'] ?? array(); + if (is_string($arrMapping)) { + $arrDecoded = json_decode($arrMapping, true); + $arrMapping = is_array($arrDecoded) ? $arrDecoded : array(); + } + if (!is_array($arrMapping)) { + $arrMapping = array(); + } + $tabRetour = fxImportResultatsSaveTemplate($intEveId, $strToken, $strName, $arrMapping, $intUsaId, true); + break; + + case 'validate': + if ($intEveId <= 0) { + $tabRetour = array('state' => 'error', 'message' => 'eve_id manquant'); + break; + } + $strToken = trim((string)($_REQUEST['token'] ?? '')); + $arrMapping = $_REQUEST['mapping'] ?? array(); + if (is_string($arrMapping)) { + $arrDecoded = json_decode($arrMapping, true); + $arrMapping = is_array($arrDecoded) ? $arrDecoded : array(); + } + if (!is_array($arrMapping)) { + $arrMapping = array(); + } + $tabRetour = fxImportResultatsValidateMapping($intEveId, $strToken, $arrMapping); + break; + + default: + $tabRetour = array('state' => 'error', 'message' => 'action_invalide'); + break; +} + +echo json_encode($tabRetour, JSON_UNESCAPED_UNICODE); diff --git a/superadm/css/style_blue.css b/superadm/css/style_blue.css index 76f9372..2f94c6f 100644 --- a/superadm/css/style_blue.css +++ b/superadm/css/style_blue.css @@ -396,4 +396,76 @@ table.rapport tr {background-color: #190d7394;}*/ .superadm-v2-panel__body .custom-control-label { color: #1e293b; font-weight: 500; +} + +/* MSIN-4482 — Import participants (hors transaction) */ +.ms1-import__drop { + align-items: center; + background: linear-gradient(160deg, #f0f9fc 0%, #e4f3f8 100%); + border: 2px dashed #045a6e; + border-radius: 12px; + color: #045a6e; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.35rem; + justify-content: center; + min-height: 9.5rem; + padding: 1.25rem 1rem; + text-align: center; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; +} +.ms1-import__drop:hover, +.ms1-import__drop:focus { + background: #e8f6fb; + box-shadow: 0 4px 14px rgba(4, 90, 110, 0.12); + outline: none; +} +.ms1-import__drop.is-drag { + background: #dff3f9; + border-color: #0284a8; + border-style: solid; + box-shadow: 0 0 0 3px rgba(2, 132, 168, 0.2); + transform: scale(1.01); +} +.ms1-import__drop.is-busy { + cursor: wait; + opacity: 0.72; + pointer-events: none; +} +.ms1-import__drop.is-ready { + border-style: solid; + min-height: 4.5rem; + padding: 0.65rem; +} +.ms1-import__drop.is-ready .ms1-import__drop-sub { + display: none; +} +.ms1-import__drop-icon { + font-size: 2rem; + line-height: 1; + opacity: 0.9; +} +.ms1-import__drop-title { + font-size: 1.05rem; + font-weight: 700; +} +.ms1-import__drop-sub { + color: #64748b; + font-size: 0.875rem; +} +.ms1-import__h { + color: #045a6e; + font-weight: 700; + margin-top: 0.35rem; +} +.ms1-import__status.alert { + margin-bottom: 0.75rem; +} +.ms1-import__epr, +.ms1-import__que { + background: #f8fafc; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 0.75rem 0.85rem; } \ No newline at end of file diff --git a/superadm/php/inc_form.php b/superadm/php/inc_form.php index 3731370..1549e22 100644 --- a/superadm/php/inc_form.php +++ b/superadm/php/inc_form.php @@ -128,6 +128,10 @@ if ($strAction == 'mod') { )); fxChronotrackApiAdminRenderEventPage($intEveIdForm, $strLangue, true, true); fxSuperadmV2PanelClose(array('collapse_id' => 'chronotrackApiCollapse')); + + // MSIN-4482 — Import participants hors transaction (UI + templates) + require_once(dirname(__FILE__) . '/../../php/inc_fx_import_resultats.php'); + fxImportResultatsRenderEventPanel($intEveIdForm); } } @@ -153,6 +157,10 @@ if ($strAction == 'mod') { onclick="if (window.jQuery) { jQuery('#chronotrackApiCollapse').collapse('show'); }"> ChronoTrack + + Import + Champs supplémentaires