'CAN', 'US' => 'USA', 'MX' => 'MEX', 'FR' => 'FRA', 'GB' => 'GBR', 'UK' => 'GBR', 'DE' => 'DEU', 'ES' => 'ESP', 'IT' => 'ITA', 'BE' => 'BEL', 'CH' => 'CHE', 'AU' => 'AUS', 'JP' => 'JPN', 'CN' => 'CHN', 'BR' => 'BRA', 'NL' => 'NLD', 'SE' => 'SWE', 'NO' => 'NOR', 'DK' => 'DNK', 'FI' => 'FIN', 'IE' => 'IRL', 'PT' => 'PRT', 'PL' => 'POL', 'AT' => 'AUT', 'NZ' => 'NZL', 'IN' => 'IND', 'KR' => 'KOR', 'AD' => 'AND', 'AE' => 'ARE', 'AF' => 'AFG', ); } $strIso2 = strtoupper(trim((string)$strIso2)); if ($strIso2 === '') { return ''; } if (strlen($strIso2) === 3) { return $strIso2; } return isset($tabMap[$strIso2]) ? $tabMap[$strIso2] : ''; } function fxChronotrackApiSyncMapEntryStatus($strMs1Status, $blnCancelled = false) { if ($blnCancelled) { return 'WITHDRAWN'; } $strMs1Status = strtoupper(trim((string)$strMs1Status)); switch ($strMs1Status) { case 'DQ': return 'DQ'; case 'DNS': return 'DNS'; case 'NP': return 'NP'; case 'ABANDON': return 'DNF'; case 'UNRANKED': return 'UNRANKED'; case 'DEFERRED': return 'DEFERRED'; case 'CONF': default: return 'CONF'; } } function fxChronotrackApiSyncNormalizeSex($strSexe) { $strSexe = strtolower(trim((string)$strSexe)); if ($strSexe === 'f') { return 'F'; } if ($strSexe === 'h' || $strSexe === 'm') { return 'M'; } if ($strSexe === '' || $strSexe === 'n') { return 'NOT SPECIFIED'; } // TODO ChronoTrack : « a » (autre / non-binaire selon config épreuve) — confirmer option CT. return ''; } function fxChronotrackApiSyncHasValidSex($strSexe) { return fxChronotrackApiSyncNormalizeSex($strSexe) !== ''; } function fxChronotrackApiSyncExtractBib(array $arrRow) { $strBib = trim((string)($arrRow['no_bib'] ?? '')); if ($strBib === '' || $strBib === '0') { return ''; } return $strBib; } function fxChronotrackApiSyncParticipantLabel(array $arrRow) { return trim(trim((string)($arrRow['par_prenom'] ?? '')) . ' ' . trim((string)($arrRow['par_nom'] ?? ''))); } function fxChronotrackApiSyncIsTeamRow(array $arrRow) { return intval($arrRow['pec_equipe'] ?? 0) === 1 || intval($arrRow['par_equipe'] ?? 0) === 1; } function fxChronotrackApiSyncFormatRoleLabel($intRolId) { switch (intval($intRolId)) { case 1: return 'Capitaine'; case 2: return 'Membre'; default: return $intRolId > 0 ? ('Rôle ' . intval($intRolId)) : ''; } } function fxChronotrackApiSyncFormatTeamBlockMessage(array $arrRow) { $arrParts = array('Inscription équipe — transfert individuel seulement (traitement équipe à venir)'); $strTeamName = trim((string)($arrRow['pec_nom_equipe'] ?? $arrRow['par_nom_equipe'] ?? '')); if ($strTeamName !== '') { $arrParts[] = '« ' . $strTeamName . ' »'; } $strNoEquipe = trim((string)($arrRow['no_equipe'] ?? '')); if ($strNoEquipe !== '' && $strNoEquipe !== '0') { $arrParts[] = 'no équipe #' . $strNoEquipe; } return implode(' · ', $arrParts); } function fxChronotrackApiSyncParticipantSummary(array $arrRow) { return array( 'par_id' => intval($arrRow['par_id'] ?? 0), 'external_id' => (string)intval($arrRow['par_id_original'] ?? 0), 'name' => fxChronotrackApiSyncParticipantLabel($arrRow), 'no_bib' => fxChronotrackApiSyncExtractBib($arrRow), 'par_sexe' => trim((string)($arrRow['par_sexe'] ?? '')), 'pec_id' => intval($arrRow['pec_id'] ?? 0), 'is_team' => fxChronotrackApiSyncIsTeamRow($arrRow), 'team_name' => trim((string)($arrRow['pec_nom_equipe'] ?? $arrRow['par_nom_equipe'] ?? '')), 'no_equipe' => trim((string)($arrRow['no_equipe'] ?? '')), 'role_label' => fxChronotrackApiSyncFormatRoleLabel($arrRow['rol_id'] ?? 0), ); } function fxChronotrackApiSyncGroupBlockedByType(array $tabBlocked) { $tabTypeLabels = array( 'team' => 'Inscriptions équipe', 'duplicate_bib' => 'Dossard en double (individuel)', 'no_bib' => 'Dossard manquant', 'no_sex' => 'Sexe non reconnu', 'no_race' => 'Épreuve non mappée', 'no_external' => 'External ID manquant', ); $tabTypeOrder = array('team', 'duplicate_bib', 'no_bib', 'no_sex', 'no_race', 'no_external'); $tabGroups = array(); foreach ($tabBlocked as $arrItem) { $strCode = (string)($arrItem['code'] ?? 'other'); if (!isset($tabGroups[$strCode])) { $tabGroups[$strCode] = array( 'code' => $strCode, 'label' => isset($tabTypeLabels[$strCode]) ? $tabTypeLabels[$strCode] : $strCode, 'items' => array(), ); } $tabGroups[$strCode]['items'][] = $arrItem; } $tabOrdered = array(); foreach ($tabTypeOrder as $strCode) { if (isset($tabGroups[$strCode])) { $tabOrdered[] = $tabGroups[$strCode]; unset($tabGroups[$strCode]); } } foreach ($tabGroups as $arrGroup) { $tabOrdered[] = $arrGroup; } return $tabOrdered; } /** * Classe les participants MS1 : transférables vs exclus (dossard, sexe, doublons MS1, mapping). */ function fxChronotrackApiSyncClassifyParticipants(array $tabParticipants, array $arrRaceMap, $intCtEventId = 0) { $tabCandidates = array(); $tabBlocked = array(); $tabBibCounts = array(); foreach ($tabParticipants as $arrRow) { $intExternalId = intval($arrRow['par_id_original'] ?? 0); if ($intExternalId <= 0) { $tabBlocked[] = array( 'code' => 'no_external', 'message' => 'par_id_original manquant', 'par_id' => intval($arrRow['par_id'] ?? 0), 'external_id' => '', 'name' => fxChronotrackApiSyncParticipantLabel($arrRow), 'no_bib' => fxChronotrackApiSyncExtractBib($arrRow), 'par_sexe' => trim((string)($arrRow['par_sexe'] ?? '')), ); continue; } $intEprId = intval($arrRow['epr_id'] ?? 0); $intCtRaceId = intval($arrRaceMap[$intEprId]['ct_race_id'] ?? 0); if ($intCtRaceId <= 0) { $arrSummary = fxChronotrackApiSyncParticipantSummary($arrRow); $tabBlocked[] = array( 'code' => 'no_race', 'message' => 'Épreuve MS1 (epr_id ' . $intEprId . ') non mappée vers ChronoTrack', ) + $arrSummary; continue; } $tabCandidates[] = $arrRow; $strBib = fxChronotrackApiSyncExtractBib($arrRow); if ($strBib !== '') { if (!isset($tabBibCounts[$strBib])) { $tabBibCounts[$strBib] = 0; } $tabBibCounts[$strBib]++; } } $tabDuplicateBibs = array(); foreach ($tabBibCounts as $strBib => $intCount) { if ($intCount > 1) { $tabDuplicateBibs[$strBib] = true; } } $tabTransferable = array(); $intBlockedTeam = 0; $intBlockedNoBib = 0; $intBlockedNoSex = 0; $intBlockedDuplicateBib = 0; foreach ($tabCandidates as $arrRow) { $arrSummary = fxChronotrackApiSyncParticipantSummary($arrRow); if (fxChronotrackApiSyncIsTeamRow($arrRow)) { $intBlockedTeam++; $tabBlocked[] = array( 'code' => 'team', 'message' => fxChronotrackApiSyncFormatTeamBlockMessage($arrRow), ) + $arrSummary; continue; } $strBib = $arrSummary['no_bib']; if ($strBib !== '' && isset($tabDuplicateBibs[$strBib])) { $intBlockedDuplicateBib++; $tabBlocked[] = array( 'code' => 'duplicate_bib', 'message' => 'Dossard #' . $strBib . ' en double — corriger dans MS1', 'bib' => $strBib, ) + $arrSummary; continue; } if ($strBib === '') { $intBlockedNoBib++; $tabBlocked[] = array( 'code' => 'no_bib', 'message' => 'Dossard manquant — corriger dans MS1', ) + $arrSummary; continue; } if (!fxChronotrackApiSyncHasValidSex($arrRow['par_sexe'] ?? '')) { $intBlockedNoSex++; $strSexeMs1 = trim((string)($arrRow['par_sexe'] ?? '')); $tabBlocked[] = array( 'code' => 'no_sex', 'message' => 'Sexe « ' . $strSexeMs1 . ' » — mapping CT à définir', ) + $arrSummary; continue; } $arrPayload = fxChronotrackApiSyncBuildEntryPayload($arrRow, $arrRaceMap, $intCtEventId); if ($arrPayload === null) { continue; } $tabTransferable[] = array( 'row' => $arrRow, 'payload' => $arrPayload, 'summary' => $arrSummary, ); } $intBlockedNoRace = 0; $intBlockedNoExternal = 0; foreach ($tabBlocked as $arrItem) { if (($arrItem['code'] ?? '') === 'no_race') { $intBlockedNoRace++; } elseif (($arrItem['code'] ?? '') === 'no_external') { $intBlockedNoExternal++; } } return array( 'transferable' => $tabTransferable, 'blocked' => $tabBlocked, 'blocked_by_type' => fxChronotrackApiSyncGroupBlockedByType($tabBlocked), 'blocked_count' => count($tabBlocked), 'skipped_no_race' => $intBlockedNoRace, 'skipped_no_external' => $intBlockedNoExternal, 'blocked_no_bib' => $intBlockedNoBib, 'blocked_no_sex' => $intBlockedNoSex, 'blocked_duplicate_bib' => $intBlockedDuplicateBib, 'blocked_team' => $intBlockedTeam, 'duplicate_bib_numbers' => array_keys($tabDuplicateBibs), // Alias rétrocompat (compteurs) 'anomalies' => $tabBlocked, 'anomaly_no_bib' => $intBlockedNoBib, 'anomaly_no_sex' => $intBlockedNoSex, 'anomaly_duplicate_bib' => $intBlockedDuplicateBib, ); } function fxChronotrackApiSyncNormalizeBirthdate($strDob) { $strDob = trim((string)$strDob); if ($strDob === '' || $strDob === '0000-00-00') { return ''; } $intTs = strtotime($strDob); if ($intTs === false) { return ''; } return date('Y-m-d', $intTs); } function fxChronotrackApiSyncLog($intEveId, $intParId, $strAction, $strStatus, $strMessage) { global $objDatabase; $sql = "INSERT INTO api_chronotrack_sync_log SET eve_id = " . intval($intEveId) . ", par_id = " . ($intParId > 0 ? intval($intParId) : 'NULL') . ", action = '" . $objDatabase->fxEscape(substr(trim($strAction), 0, 32)) . "'" . ", status = '" . $objDatabase->fxEscape($strStatus === 'ok' ? 'ok' : 'error') . "'" . ", message = '" . $objDatabase->fxEscape(substr(trim($strMessage), 0, 65000)) . "'" . ", created_at = '" . fxGetDateTime() . "'"; $objDatabase->fxQuery($sql); } function fxChronotrackApiSyncPaysHasIso3Column() { static $blnHas = null; if ($blnHas !== null) { return $blnHas; } global $objDatabase; $arrRow = $objDatabase->fxGetRow("SHOW COLUMNS FROM inscriptions_pays LIKE 'pay_iso3'"); $blnHas = ($arrRow !== null); return $blnHas; } function fxChronotrackApiSyncLoadMs1Participants($intEveId) { global $objDatabase; $intEveId = intval($intEveId); if ($intEveId <= 0) { return array(); } $strCountryCols = " (SELECT pay_iso FROM inscriptions_pays WHERE pay_id = p.pay_id) AS country_iso2"; if (fxChronotrackApiSyncPaysHasIso3Column()) { $strCountryCols = " (SELECT pay_iso3 FROM inscriptions_pays WHERE pay_id = p.pay_id) AS country_iso3," . " (SELECT pay_iso FROM inscriptions_pays WHERE pay_id = p.pay_id) AS country_iso2"; } $sql = "SELECT p.par_id, p.par_id_original, p.epr_id, p.pec_id, p.rol_id, p.par_equipe, p.par_nom_equipe," . " p.par_prenom, p.par_nom, p.par_sexe," . " p.par_naissance, p.no_bib, p.par_statut_course, p.par_ville, p.par_adresse, p.par_codepostal," . " p.is_cancelled," . " ec.pec_equipe, ec.no_equipe, ec.pec_nom_equipe," . " (SELECT pro_iso FROM inscriptions_provinces WHERE pro_id = p.pro_id) AS state_iso," . $strCountryCols . " FROM resultats_participants p" . " JOIN resultats_epreuves_commandees ec ON p.pec_id = ec.pec_id_original" . " WHERE ec.pec_actif = 1 AND ec.is_cancelled = 0 AND p.is_cancelled = 0" . " AND p.eve_id = " . $intEveId . " ORDER BY p.par_id_original, p.par_id"; $tabRows = $objDatabase->fxGetResults($sql); if (!is_array($tabRows)) { return array(); } $tabOut = array(); for ($i = 1; $i <= count($tabRows); $i++) { $tabOut[] = $tabRows[$i]; } return $tabOut; } function fxChronotrackApiSyncResolveRegChoiceIdForRace($intCtEventId, $intCtRaceId) { static $tabCache = array(); $strKey = intval($intCtEventId) . ':' . intval($intCtRaceId); if (array_key_exists($strKey, $tabCache)) { return $tabCache[$strKey]; } $tabCache[$strKey] = 0; $arrRaces = fxChronotrackApiFetchRacesForEvent($intCtEventId); if (($arrRaces['state'] ?? '') !== 'ok' || empty($arrRaces['races'])) { return 0; } foreach ($arrRaces['races'] as $arrRace) { if (intval($arrRace['ct_race_id'] ?? 0) !== intval($intCtRaceId)) { continue; } $arrRaw = isset($arrRace['raw']) && is_array($arrRace['raw']) ? $arrRace['raw'] : array(); $arrChoices = fxChronotrackApiCollectRegChoiceEntities($arrRaw); if (count($arrChoices) === 0 && isset($arrRaw['reg_choice_id'])) { $tabCache[$strKey] = intval($arrRaw['reg_choice_id']); return $tabCache[$strKey]; } foreach ($arrChoices as $arrChoice) { if (!is_array($arrChoice) || empty($arrChoice['reg_choice_id'])) { continue; } $tabCache[$strKey] = intval($arrChoice['reg_choice_id']); return $tabCache[$strKey]; } } return 0; } function fxChronotrackApiSyncBuildEntryPayload(array $arrRow, array $arrRaceMap, $intCtEventId = 0) { $intEprId = intval($arrRow['epr_id'] ?? 0); $intCtRaceId = intval($arrRaceMap[$intEprId]['ct_race_id'] ?? 0); if ($intCtRaceId <= 0) { return null; } $intExternalId = intval($arrRow['par_id_original'] ?? 0); if ($intExternalId <= 0) { return null; } $strCountry = strtoupper(trim((string)($arrRow['country_iso3'] ?? ''))); if ($strCountry === '') { $strCountry = fxChronotrackApiSyncIso2ToIso3($arrRow['country_iso2'] ?? ''); } $strState = strtoupper(trim((string)($arrRow['state_iso'] ?? ''))); $strSex = fxChronotrackApiSyncNormalizeSex($arrRow['par_sexe'] ?? ''); if ($strSex === '') { return null; } $strBib = fxChronotrackApiSyncExtractBib($arrRow); if ($strBib === '') { return null; } $arrEntry = array( 'external_id' => (string)$intExternalId, 'race_id' => $intCtRaceId, 'first_name' => trim((string)($arrRow['par_prenom'] ?? '')), 'last_name' => trim((string)($arrRow['par_nom'] ?? '')), 'sex' => $strSex, 'bib' => $strBib, ); if ($intCtEventId > 0) { $arrEntry['event_id'] = $intCtEventId; } $intRegChoiceId = fxChronotrackApiSyncResolveRegChoiceIdForRace($intCtEventId, $intCtRaceId); if ($intRegChoiceId > 0) { $arrEntry['reg_choice_id'] = $intRegChoiceId; } $strDob = fxChronotrackApiSyncNormalizeBirthdate($arrRow['par_naissance'] ?? ''); if ($strDob !== '') { $arrEntry['birthdate'] = $strDob; } $strStreet = trim((string)($arrRow['par_adresse'] ?? '')); if ($strStreet !== '') { $arrEntry['street'] = $strStreet; } $strCity = trim((string)($arrRow['par_ville'] ?? '')); if ($strCity !== '') { $arrEntry['city'] = $strCity; } $strPostal = trim((string)($arrRow['par_codepostal'] ?? '')); if ($strPostal !== '') { $arrEntry['postal_code'] = $strPostal; } if ($strState !== '') { $arrEntry['state_code'] = $strState; } if ($strCountry !== '') { $arrEntry['country_code'] = $strCountry; } $arrEntry['entry_status'] = fxChronotrackApiSyncMapEntryStatus( $arrRow['par_statut_course'] ?? 'CONF', intval($arrRow['is_cancelled'] ?? 0) === 1 ); $arrEntry['status'] = $arrEntry['entry_status']; return $arrEntry; } function fxChronotrackApiSyncPayloadForPost(array $arrPayload) { $arrOut = $arrPayload; unset($arrOut['entry_id']); return $arrOut; } function fxChronotrackApiSyncPayloadForPut(array $arrPayload) { $arrOut = $arrPayload; unset($arrOut['entry_id']); return $arrOut; } function fxChronotrackApiSyncPutEntry($strEntryId, array $arrPayload) { $strEntryId = trim((string)$strEntryId); if ($strEntryId === '') { return array('state' => 'error', 'message' => 'entry_id manquant pour PUT'); } $arrPut = fxChronotrackApiOAuthApiPut('entry/' . $strEntryId, fxChronotrackApiSyncPayloadForPut($arrPayload)); if ($arrPut['state'] === 'ok') { $arrPut['method'] = 'PUT'; } return $arrPut; } function fxChronotrackApiSyncPushOneEntry($intCtEventId, array $arrPayload) { $intCtEventId = intval($intCtEventId); $strEntryId = trim((string)($arrPayload['entry_id'] ?? '')); if ($strEntryId !== '') { $arrPut = fxChronotrackApiSyncPutEntry($strEntryId, $arrPayload); $arrPut['method'] = 'PUT'; return $arrPut; } $arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, array(fxChronotrackApiSyncPayloadForPost($arrPayload))); if ($arrPost['state'] === 'ok') { $arrPost['method'] = 'POST'; } return $arrPost; } function fxChronotrackApiSyncEnrichPayloadWithCtEntry(array $arrPayload, $arrCtEntity) { if (!is_array($arrCtEntity)) { return $arrPayload; } $strEntryId = fxChronotrackApiEntityId($arrCtEntity); if ($strEntryId !== '') { $arrPayload['entry_id'] = $strEntryId; } return $arrPayload; } function fxChronotrackApiSyncPostEntries($intCtEventId, array $tabEntries) { $intCtEventId = intval($intCtEventId); if ($intCtEventId <= 0 || count($tabEntries) === 0) { return array('state' => 'error', 'message' => 'Aucune entry à envoyer'); } static $strBodyFormat = 'array'; if ($strBodyFormat === 'event_entry') { $arrPost = fxChronotrackApiOAuthApiPost( 'event/' . $intCtEventId . '/entry', array('event_entry' => $tabEntries) ); if ($arrPost['state'] === 'ok') { $arrPost['body_format'] = 'event_entry'; return $arrPost; } return $arrPost; } // Doc CT : POST body = tableau JSON d'entités entry (pas de wrapper event_entry). $arrPost = fxChronotrackApiOAuthApiPost('event/' . $intCtEventId . '/entry', $tabEntries); if ($arrPost['state'] === 'ok') { $arrPost['body_format'] = 'array'; $strBodyFormat = 'array'; return $arrPost; } // Essai wrapper une seule fois (mémoire du format si succès). $arrPostWrap = fxChronotrackApiOAuthApiPost( 'event/' . $intCtEventId . '/entry', array('event_entry' => $tabEntries) ); if ($arrPostWrap['state'] === 'ok') { $strBodyFormat = 'event_entry'; $arrPostWrap['body_format'] = 'event_entry'; return $arrPostWrap; } $arrPost['body_format'] = 'array'; $arrPost['fallback_error'] = $arrPostWrap['message'] ?? ''; return $arrPost; } /** * POST entries avec découpage dichotomique si le lot échoue (évite 250 appels unitaires). * MSIN-4328 */ function fxChronotrackApiSyncPostEntriesAdaptive($intCtEventId, array $tabEntries, array &$tabErrors, $intEveId = 0, array $tabMeta = array()) { $intCount = count($tabEntries); if ($intCount === 0) { return array('ok' => 0, 'err' => 0); } $arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, $tabEntries); if ($arrPost['state'] === 'ok') { return array('ok' => $intCount, 'err' => 0); } if ($intCount === 1) { $arrM = $tabMeta[0] ?? array('par_id' => 0, 'external_id' => ''); $strOneErr = $arrPost['message'] ?? 'Erreur POST entry'; if (count($tabErrors) < 12) { $tabErrors[] = 'external_id=' . ($arrM['external_id'] ?? '') . ' — ' . $strOneErr; } if ($intEveId > 0) { fxChronotrackApiSyncLog($intEveId, intval($arrM['par_id'] ?? 0), 'push_entry', 'error', 'external_id=' . ($arrM['external_id'] ?? '') . ' — ' . $strOneErr); } return array('ok' => 0, 'err' => 1); } $intMid = (int)floor($intCount / 2); $arrLeft = fxChronotrackApiSyncPostEntriesAdaptive( $intCtEventId, array_slice($tabEntries, 0, $intMid), $tabErrors, $intEveId, array_slice($tabMeta, 0, $intMid) ); $arrRight = fxChronotrackApiSyncPostEntriesAdaptive( $intCtEventId, array_slice($tabEntries, $intMid), $tabErrors, $intEveId, array_slice($tabMeta, $intMid) ); return array( 'ok' => $arrLeft['ok'] + $arrRight['ok'], 'err' => $arrLeft['err'] + $arrRight['err'], ); } function fxChronotrackApiSyncProbePush($intEveId) { $arrConfig = fxChronotrackApiConfigGet($intEveId); if ($arrConfig === null) { return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack'); } $intCtEventId = intval($arrConfig['ct_event_id']); $arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId); $tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId); $arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId); if (count($arrClass['transferable']) === 0) { return array('state' => 'error', 'message' => 'Aucun participant transférable pour la sonde (voir anomalies).'); } $arrFirst = $arrClass['transferable'][0]; $arrPayload = $arrFirst['payload']; $arrRow = $arrFirst['row']; $arrCtFetch = fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId); if ($arrCtFetch['state'] === 'ok') { $strExt = $arrPayload['external_id']; $arrPayload = fxChronotrackApiSyncEnrichPayloadWithCtEntry( $arrPayload, $arrCtFetch['entries'][$strExt] ?? null ); } $arrPost = fxChronotrackApiSyncPushOneEntry($intCtEventId, $arrPayload); return array( 'state' => $arrPost['state'], 'message' => $arrPost['message'] ?? '', 'ct_event_id' => $intCtEventId, 'par_id' => intval($arrRow['par_id'] ?? 0), 'payload' => $arrPayload, 'method' => $arrPost['method'] ?? '', 'body_format' => $arrPost['body_format'] ?? '', 'http_code' => intval($arrPost['http']['http_code'] ?? 0), 'response_body' => substr(trim((string)($arrPost['http']['body'] ?? '')), 0, 2000), 'fallback_error'=> $arrPost['fallback_error'] ?? '', ); } /** * Liste toutes les entries CT (pagination), dédupliquées par entry_id. * Ne filtre pas sur external_id — décompte réel côté CT. */ function fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId) { $intCtEventId = intval($intCtEventId); $tabAll = array(); $tabSeenEntryIds = array(); $intPage = 1; $intPageSize = 100; $intMaxPages = 500; while ($intPage <= $intMaxPages) { $arrApi = fxChronotrackApiOAuthApiGet('event/' . $intCtEventId . '/entry', array( 'page' => $intPage, 'page_size' => $intPageSize, )); if ($arrApi['state'] !== 'ok') { return array( 'state' => 'error', 'message' => $arrApi['message'] ?? 'Erreur lecture entries CT', 'entities' => $tabAll, ); } $arrEntities = fxChronotrackApiNormalizeEntityList($arrApi['json'], 'entry'); if (count($arrEntities) === 0) { break; } foreach ($arrEntities as $arrEntity) { if (!is_array($arrEntity)) { continue; } $strEntryId = fxChronotrackApiEntityId($arrEntity); if ($strEntryId === '' || isset($tabSeenEntryIds[$strEntryId])) { continue; } $tabSeenEntryIds[$strEntryId] = true; $tabAll[] = $arrEntity; } if (count($arrEntities) < $intPageSize) { break; } $intPage++; } return array( 'state' => 'ok', 'message' => '', 'entities' => $tabAll, 'count' => count($tabAll), ); } function fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId) { $arrFetchAll = fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId); if ($arrFetchAll['state'] !== 'ok') { return array( 'state' => 'error', 'message' => $arrFetchAll['message'] ?? 'Erreur lecture entries CT', 'entries' => array(), ); } $tabIndexed = array(); foreach ($arrFetchAll['entities'] as $arrEntity) { if (!is_array($arrEntity)) { continue; } $strExt = fxChronotrackApiEntityExternalId($arrEntity); if ($strExt === '') { continue; } $tabIndexed[$strExt] = $arrEntity; } return array( 'state' => 'ok', 'message' => '', 'entries' => $tabIndexed, 'entities' => $arrFetchAll['entities'], 'total_entry_count' => intval($arrFetchAll['count'] ?? count($arrFetchAll['entities'])), 'without_external_id'=> max(0, intval($arrFetchAll['count'] ?? 0) - count($tabIndexed)), ); } function fxChronotrackApiSyncFetchCtEntryRows($intCtEventId) { $arrFetch = fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId); if ($arrFetch['state'] !== 'ok') { return $arrFetch; } $tabRows = array(); foreach ($arrFetch['entities'] as $arrEntity) { if (!is_array($arrEntity)) { continue; } $strEntryId = fxChronotrackApiEntityId($arrEntity); if ($strEntryId === '') { continue; } $tabRows[] = array( 'entry_id' => $strEntryId, 'external_id' => fxChronotrackApiEntityExternalId($arrEntity), 'entity' => $arrEntity, ); } return array( 'state' => 'ok', 'message' => '', 'rows' => $tabRows, 'count' => count($tabRows), ); } function fxChronotrackApiSyncBuildPreview($intEveId) { $arrConfig = fxChronotrackApiConfigGet($intEveId); if ($arrConfig === null) { return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack'); } if (!fxChronotrackApiSettingsConfigured()) { return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage()); } $intCtEventId = intval($arrConfig['ct_event_id']); $arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId); $tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId); $arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId); $intMs1Total = count($tabParticipants); $intTransferable = count($arrClass['transferable']); $tabEligibleKeys = array(); foreach ($arrClass['transferable'] as $arrItem) { $tabEligibleKeys[$arrItem['payload']['external_id']] = true; } $arrCtFetch = fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId); if ($arrCtFetch['state'] !== 'ok') { return array( 'state' => 'error', 'message' => $arrCtFetch['message'], ); } $tabCtEntries = $arrCtFetch['entries']; $intCtTotal = intval($arrCtFetch['total_entry_count'] ?? count($tabCtEntries)); $intCtWithoutExt = intval($arrCtFetch['without_external_id'] ?? 0); $intAlreadyInCt = 0; $intToCreate = 0; foreach ($tabEligibleKeys as $strExt => $blnTrue) { if (isset($tabCtEntries[$strExt])) { $intAlreadyInCt++; } else { $intToCreate++; } } $intCtOrphans = 0; if (!empty($arrCtFetch['entities']) && is_array($arrCtFetch['entities'])) { $tabOrphanRows = fxChronotrackApiSyncComputeCtOrphanRows($arrCtFetch['entities'], $tabEligibleKeys); $intCtOrphans = count($tabOrphanRows); } else { foreach ($tabCtEntries as $strExt => $blnEntity) { if (!isset($tabEligibleKeys[$strExt])) { $intCtOrphans++; } } } return array( 'state' => 'ok', 'ct_event_id' => $intCtEventId, 'ms1_total' => $intMs1Total, 'transferable' => $intTransferable, 'blocked_count' => $arrClass['blocked_count'], 'blocked_by_type' => $arrClass['blocked_by_type'], 'blocked' => $arrClass['blocked'], 'eligible' => $intTransferable, 'skipped_no_race' => $arrClass['skipped_no_race'], 'skipped_no_external' => $arrClass['skipped_no_external'], 'blocked_no_bib' => $arrClass['blocked_no_bib'], 'blocked_no_sex' => $arrClass['blocked_no_sex'], 'blocked_duplicate_bib' => $arrClass['blocked_duplicate_bib'], 'blocked_team' => $arrClass['blocked_team'], 'anomaly_no_bib' => $arrClass['blocked_no_bib'], 'anomaly_no_sex' => $arrClass['blocked_no_sex'], 'anomaly_duplicate_bib' => $arrClass['blocked_duplicate_bib'], 'duplicate_bib_numbers' => $arrClass['duplicate_bib_numbers'], 'anomalies' => $arrClass['blocked'], 'ct_total' => $intCtTotal, 'already_in_ct' => $intAlreadyInCt, 'will_update' => $intAlreadyInCt, 'to_create' => $intToCreate, 'will_create' => $intToCreate, 'ct_orphans' => $intCtOrphans, 'ct_without_external_id' => $intCtWithoutExt, 'to_push' => $intTransferable, ); } /** * Stockage job push (fichier temp) — évite de saturer $_SESSION sur ~2000 entries. MSIN-4328 */ function fxChronotrackApiSyncPushJobPath($intEveId) { $strDir = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'ms1_ct_push'; if (!is_dir($strDir)) { @mkdir($strDir, 0700, true); } $strSid = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', (string)session_id()), 0, 40); if ($strSid === '') { $strSid = 'nosession'; } return $strDir . DIRECTORY_SEPARATOR . 'eve' . intval($intEveId) . '_' . $strSid . '.json'; } function fxChronotrackApiSyncPushJobLoad($intEveId) { $strPath = fxChronotrackApiSyncPushJobPath($intEveId); if (!is_file($strPath)) { return null; } $strRaw = @file_get_contents($strPath); if ($strRaw === false || $strRaw === '') { return null; } $arrJob = json_decode($strRaw, true); return is_array($arrJob) ? $arrJob : null; } function fxChronotrackApiSyncPushJobSave($intEveId, array $arrJob) { $strPath = fxChronotrackApiSyncPushJobPath($intEveId); $strJson = json_encode($arrJob, JSON_UNESCAPED_UNICODE); if (!is_string($strJson)) { return false; } return (@file_put_contents($strPath, $strJson, LOCK_EX) !== false); } function fxChronotrackApiSyncPushJobClear($intEveId) { $strPath = fxChronotrackApiSyncPushJobPath($intEveId); if (is_file($strPath)) { @unlink($strPath); } } /** * Prépare un job de push (liste éligibles + entry_id CT). MSIN-4328. */ function fxChronotrackApiSyncPushPrepare($intEveId) { $intEveId = intval($intEveId); $arrConfig = fxChronotrackApiConfigGet($intEveId); if ($arrConfig === null) { return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack'); } if (!fxChronotrackApiSettingsConfigured()) { return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage()); } $intCtEventId = intval($arrConfig['ct_event_id']); $arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId); $tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId); $arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId); $intSkipLogged = 0; foreach ($arrClass['blocked'] as $arrBlocked) { if ($intSkipLogged >= 50) { break; } fxChronotrackApiSyncLog( $intEveId, intval($arrBlocked['par_id'] ?? 0), 'push_skip', 'error', ($arrBlocked['code'] ?? 'blocked') . ' — external_id=' . ($arrBlocked['external_id'] ?? '') . ' — ' . ($arrBlocked['message'] ?? '') ); $intSkipLogged++; } if ($arrClass['blocked_count'] > $intSkipLogged) { fxChronotrackApiSyncLog( $intEveId, 0, 'push_skip', 'error', '… +' . ($arrClass['blocked_count'] - $intSkipLogged) . ' exclus non détaillés' ); } $tabCtEntries = array(); $arrCtFetch = fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId); if ($arrCtFetch['state'] === 'ok') { $tabCtEntries = $arrCtFetch['entries']; } $tabBatch = array(); $tabMeta = array(); foreach ($arrClass['transferable'] as $arrItem) { $arrPayload = fxChronotrackApiSyncEnrichPayloadWithCtEntry( $arrItem['payload'], $tabCtEntries[$arrItem['payload']['external_id']] ?? null ); $tabBatch[] = $arrPayload; $tabMeta[] = array( 'par_id' => intval($arrItem['row']['par_id'] ?? 0), 'external_id' => $arrItem['payload']['external_id'], ); } if (count($tabBatch) === 0) { fxChronotrackApiSyncPushJobClear($intEveId); return array( 'state' => 'error', 'message' => 'Aucun participant transférable.' . ($arrClass['blocked_count'] > 0 ? ' ' . $arrClass['blocked_count'] . ' exclus.' : ''), 'blocked_count' => $arrClass['blocked_count'], 'total' => 0, ); } $arrJob = array( 'ct_event_id' => $intCtEventId, 'batch' => $tabBatch, 'meta' => $tabMeta, 'blocked_count' => $arrClass['blocked_count'], 'created_at' => time(), ); if (!fxChronotrackApiSyncPushJobSave($intEveId, $arrJob)) { return array( 'state' => 'error', 'message' => 'Impossible d’écrire le fichier temporaire du push (permissions /tmp).', ); } return array( 'state' => 'ok', 'message' => count($tabBatch) . ' prêts à envoyer', 'total' => count($tabBatch), 'blocked_count' => $arrClass['blocked_count'], 'chunk_size' => MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE, 'ct_event_id' => $intCtEventId, ); } /** * Push un slice du job préparé. MSIN-4328. */ function fxChronotrackApiSyncPushEvent($intEveId, $arrOptions = array()) { $intEveId = intval($intEveId); $intOffset = max(0, intval($arrOptions['offset'] ?? 0)); $intLimit = max(0, intval($arrOptions['limit'] ?? 0)); if ($intLimit <= 0) { $intLimit = MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE; } $arrJob = fxChronotrackApiSyncPushJobLoad($intEveId); if (!is_array($arrJob) || empty($arrJob['batch'])) { $arrPrep = fxChronotrackApiSyncPushPrepare($intEveId); if ($arrPrep['state'] !== 'ok') { return $arrPrep; } $arrJob = fxChronotrackApiSyncPushJobLoad($intEveId); } if (!is_array($arrJob) || empty($arrJob['batch'])) { return array('state' => 'error', 'message' => 'Job de push introuvable — relancer'); } $intCtEventId = intval($arrJob['ct_event_id']); $tabBatchAll = $arrJob['batch']; $tabMetaAll = $arrJob['meta']; $intTotal = count($tabBatchAll); $intSkipped = intval($arrJob['blocked_count'] ?? 0); $tabBatch = array_slice($tabBatchAll, $intOffset, $intLimit); $tabMeta = array_slice($tabMetaAll, $intOffset, $intLimit); $intChunkCount = count($tabBatch); if ($intChunkCount === 0) { fxChronotrackApiSyncPushJobClear($intEveId); return array( 'state' => 'ok', 'message' => 'Rien à envoyer à cet offset', 'pushed_ok' => 0, 'pushed_put' => 0, 'pushed_post' => 0, 'pushed_error' => 0, 'total' => $intTotal, 'offset' => $intOffset, 'next_offset' => $intOffset, 'chunk_done' => 0, 'done' => true, 'blocked_count' => $intSkipped, ); } $intOk = 0; $intErr = 0; $intPutOk = 0; $intPostOk = 0; $tabErrors = array(); $tabCreates = array(); $tabCreatesMeta = array(); $tabUpdates = array(); $tabUpdatesMeta = array(); foreach ($tabBatch as $intIdx => $arrPayload) { $arrMeta = $tabMeta[$intIdx]; if (trim((string)($arrPayload['entry_id'] ?? '')) !== '') { $tabUpdates[] = $arrPayload; $tabUpdatesMeta[] = $arrMeta; } else { $tabCreates[] = fxChronotrackApiSyncPayloadForPost($arrPayload); $tabCreatesMeta[] = $arrMeta; } } // Créations + maj via POST lot (upsert) — découpage dichotomique si échec. if (count($tabCreates) > 0) { $arrRes = fxChronotrackApiSyncPostEntriesAdaptive( $intCtEventId, $tabCreates, $tabErrors, $intEveId, $tabCreatesMeta ); $intOk += $arrRes['ok']; $intPostOk += $arrRes['ok']; $intErr += $arrRes['err']; } if (count($tabUpdates) > 0) { $tabUpdatesForPost = array(); foreach ($tabUpdates as $arrPayload) { $tabUpdatesForPost[] = fxChronotrackApiSyncPayloadForPost($arrPayload); } $arrRes = fxChronotrackApiSyncPostEntriesAdaptive( $intCtEventId, $tabUpdatesForPost, $tabErrors, $intEveId, $tabUpdatesMeta ); $intOk += $arrRes['ok']; $intPostOk += $arrRes['ok']; $intErr += $arrRes['err']; } // Si des MAJ restent en erreur (POST n’a pas pris le statut), tenter PUT unitaire // uniquement pour ce petit sous-ensemble déjà en erreur — trop coûteux de re-PUT tout. // Les erreurs sont déjà dans $tabErrors / logs. fxChronotrackApiSyncLog( $intEveId, 0, 'push_chunk', ($intErr === 0) ? 'ok' : 'error', 'offset=' . $intOffset . ' n=' . $intChunkCount . ' ok=' . $intOk . ' err=' . $intErr . ' POST=' . $intPostOk . ' PUT=' . $intPutOk ); $intNextOffset = $intOffset + $intChunkCount; $blnDone = ($intNextOffset >= $intTotal); if ($blnDone) { fxChronotrackApiSyncPushJobClear($intEveId); } $strState = ($intErr === 0) ? 'ok' : (($intOk > 0) ? 'partial' : 'error'); $strMessage = ($intErr === 0) ? ($intOk . ' envoyé(s) (offset ' . $intOffset . ').') : ($intOk . ' OK, ' . $intErr . ' erreur(s) sur ce lot.'); return array( 'state' => $strState, 'message' => $strMessage, 'pushed_ok' => $intOk, 'pushed_put' => $intPutOk, 'pushed_post' => $intPostOk, 'pushed_error' => $intErr, 'skipped' => $intSkipped, 'blocked_count' => $intSkipped, 'errors' => array_slice($tabErrors, 0, 20), 'total' => $intTotal, 'offset' => $intOffset, 'next_offset' => $intNextOffset, 'chunk_done' => $intChunkCount, 'chunk_size' => $intLimit, 'done' => $blnDone, ); } function fxChronotrackApiSyncOrphanReasonLabel($strReason) { switch ($strReason) { case 'sans_external_id': return 'Sans external ID'; case 'doublon': return 'Doublon CT'; case 'hors_eligible': default: return 'Hors éligibles MS1'; } } function fxChronotrackApiSyncComputeCtOrphanRows(array $tabEntities, array $tabEligibleKeys) { $tabSeenLinkedExt = array(); $tabOrphans = array(); foreach ($tabEntities as $arrEntity) { if (!is_array($arrEntity)) { continue; } $strEntryId = fxChronotrackApiEntityId($arrEntity); if ($strEntryId === '') { continue; } $strExt = fxChronotrackApiEntityExternalId($arrEntity); $strReason = ''; if ($strExt === '') { $strReason = 'sans_external_id'; } elseif (!isset($tabEligibleKeys[$strExt])) { $strReason = 'hors_eligible'; } elseif (isset($tabSeenLinkedExt[$strExt])) { $strReason = 'doublon'; } else { $tabSeenLinkedExt[$strExt] = true; continue; } $tabOrphans[] = array( 'entry_id' => $strEntryId, 'external_id' => $strExt, 'name' => fxChronotrackApiEntityEntryLabel($arrEntity), 'bib' => fxChronotrackApiRaceField($arrEntity, array('bib', 'bib_number', 'entry_bib')), 'entry_status' => fxChronotrackApiRaceField($arrEntity, array('entry_status', 'status')), 'race_id' => fxChronotrackApiRaceField($arrEntity, array('race_id', 'event_race_id')), 'reason' => $strReason, 'reason_label' => fxChronotrackApiSyncOrphanReasonLabel($strReason), ); } usort($tabOrphans, function ($a, $b) { $strA = strtolower(($a['name'] ?? '') . ($a['external_id'] ?? '') . ($a['entry_id'] ?? '')); $strB = strtolower(($b['name'] ?? '') . ($b['external_id'] ?? '') . ($b['entry_id'] ?? '')); return strcmp($strA, $strB); }); return $tabOrphans; } /** * Entries CT présentes sur l'événement mais absentes du jeu éligible MS1 (lié, doublon, sans ext). */ function fxChronotrackApiSyncListCtOrphans($intEveId) { $arrPreview = fxChronotrackApiSyncBuildPreview($intEveId); if ($arrPreview['state'] !== 'ok') { return $arrPreview; } $intCtEventId = intval($arrPreview['ct_event_id']); $arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId); $tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId); $arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId); $tabEligibleKeys = array(); foreach ($arrClass['transferable'] as $arrItem) { $tabEligibleKeys[$arrItem['payload']['external_id']] = true; } $arrFetch = fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId); if ($arrFetch['state'] !== 'ok') { return array( 'state' => 'error', 'message' => $arrFetch['message'] ?? 'Erreur lecture entries CT', ); } $tabOrphans = fxChronotrackApiSyncComputeCtOrphanRows($arrFetch['entities'], $tabEligibleKeys); return array( 'state' => 'ok', 'ct_event_id' => $intCtEventId, 'ct_total' => intval($arrPreview['ct_total'] ?? 0), 'linked_count' => intval($arrPreview['already_in_ct'] ?? 0), 'count' => count($tabOrphans), 'orphans' => $tabOrphans, ); }