" . intval($strCtEntryId) . ")"; return (bool)$objDatabase->fxQuery($sql); } /** * MSIN-4328 — mappe la réponse POST/GET entry → par_id (via external_id). */ function fxChronotrackApiSyncApplyEntryIdsFromApiJson($mixJson, array $tabMeta) { if (!is_array($mixJson) || count($tabMeta) === 0) { return 0; } $tabByExt = array(); foreach ($tabMeta as $arrM) { $strExt = trim((string)($arrM['external_id'] ?? '')); $intParId = intval($arrM['par_id'] ?? 0); if ($strExt !== '' && $intParId > 0) { $tabByExt[$strExt] = $intParId; } } if (count($tabByExt) === 0) { return 0; } $arrEntities = fxChronotrackApiNormalizeEntityList($mixJson, 'entry'); $intSaved = 0; foreach ($arrEntities as $arrEnt) { if (!is_array($arrEnt)) { continue; } $strStatus = strtoupper(trim((string)( $arrEnt['status'] ?? $arrEnt['entry_status'] ?? '' ))); if ($strStatus === 'FAILURE') { continue; } $strEntryId = fxChronotrackApiEntityId($arrEnt); $strExt = fxChronotrackApiEntityExternalId($arrEnt); if ($strExt === '' && isset($arrEnt['entry_external_id'])) { $strExt = trim((string)$arrEnt['entry_external_id']); } if ($strEntryId === '' || $strExt === '' || !isset($tabByExt[$strExt])) { continue; } if (fxChronotrackApiSyncSaveCtEntryId($tabByExt[$strExt], $strEntryId)) { $intSaved++; } } return $intSaved; } /** * MSIN-4328 — URL admin ChronoTrack (fiche athlète). */ function fxChronotrackApiAdminEntryUrl($intCtEventId, $strCtEntryId) { $intCtEventId = intval($intCtEventId); $strCtEntryId = preg_replace('/[^0-9]/', '', (string)$strCtEntryId); if ($intCtEventId <= 0 || $strCtEntryId === '') { return ''; } return 'https://admin.chronotrack.com/admin/entry/index/eventID/' . $intCtEventId . '?entryID=' . $strCtEntryId; } function fxChronotrackApiSyncIso2ToIso3($strIso2) { static $tabMap = null; if ($tabMap === null) { $tabMap = array( 'CA' => '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) !== ''; } /** * MSIN-4328 / MSIN-4444 — dossard ChronoTrack = toujours p.no_bib. * Jamais ec.no_equipe (n° d’équipe MS1) : en mode « 1 équipe = 1 dossard » * les deux coïncident souvent au 1er assign, puis no_bib peut diverger (ex. 535 vs 507). */ function fxChronotrackApiSyncExtractBib(array $arrRow) { $strBib = trim((string)($arrRow['no_bib'] ?? '')); if ($strBib === '' || $strBib === '0') { return ''; } // Aligné affichage MS1 (fxShowBibNumber) : enlever préfixe epr_id- $intEprId = intval($arrRow['epr_id'] ?? 0); if ($intEprId > 0) { $strBib = str_replace($intEprId . '-', '', $strBib); } $strBib = str_replace('-', '', $strBib); $strBib = trim($strBib); if ($strBib === '' || $strBib === '0') { return ''; } return $strBib; } /** * MSIN-4328 — conflit CT « tags / bib already assigned » (swap dossards). */ function fxChronotrackApiSyncIsBibConflictMessage($strMessage) { $str = strtolower((string)$strMessage); if ($str === '') { return false; } if (strpos($str, 'already assigned') !== false) { return true; } if (strpos($str, 'tags are already') !== false) { return true; } if (strpos($str, 'bib') !== false && strpos($str, 'already') !== false) { return true; } return false; } function fxChronotrackApiSyncPayloadWithoutBib(array $arrPayload) { $arrOut = $arrPayload; unset($arrOut['bib']); return $arrOut; } /** * MSIN-4328 — libérer le dossard CT (swap) : bib vide. */ function fxChronotrackApiSyncPayloadClearBib(array $arrPayload) { $arrOut = $arrPayload; $arrOut['bib'] = ''; return $arrOut; } function fxChronotrackApiSyncParticipantLabel(array $arrRow) { return trim(trim((string)($arrRow['par_prenom'] ?? '')) . ' ' . trim((string)($arrRow['par_nom'] ?? ''))); } /** * MSIN-4328 — libellé épreuve MS1 (type — nom), pour messages d’exclusion. */ function fxChronotrackApiSyncEpreuveLabel(array $arrRow) { $strNom = trim((string)($arrRow['epr_nom'] ?? '')); $strType = trim((string)($arrRow['epr_type'] ?? '')); if ($strType !== '' && $strNom !== '') { return $strType . ' — ' . $strNom; } if ($strNom !== '') { return $strNom; } return $strType; } function fxChronotrackApiSyncIsTeamRow(array $arrRow) { return intval($arrRow['pec_equipe'] ?? 0) === 1 || intval($arrRow['par_equipe'] ?? 0) === 1; } /** * MSIN-4444 — nom d’équipe MS1 (commande, sinon participant). */ function fxChronotrackApiSyncTeamName(array $arrRow) { $strName = trim((string)($arrRow['pec_nom_equipe'] ?? '')); if ($strName === '') { $strName = trim((string)($arrRow['par_nom_equipe'] ?? '')); } return $strName; } /** * MSIN-4444 — clé d’équipe = pec_id (commande). */ function fxChronotrackApiSyncTeamGroupKey(array $arrRow) { $intPecId = intval($arrRow['pec_id'] ?? 0); if ($intPecId > 0) { return 'pec:' . $intPecId; } return 'par:' . intval($arrRow['par_id'] ?? 0); } /** * MSIN-4444 — 1 entrée CT par équipe : capitaine (rol_id=1), sinon 1er membre. */ function fxChronotrackApiSyncPickTeamRepresentative(array $tabMembers) { $arrFirst = null; foreach ($tabMembers as $arrRow) { if (!is_array($arrRow)) { continue; } if ($arrFirst === null) { $arrFirst = $arrRow; } if (intval($arrRow['rol_id'] ?? 0) === 1) { return $arrRow; } } return $arrFirst; } /** * MSIN-4328 — éligibles dont par_maj > dernier push (ou tous si jamais poussé). * Inclut aussi no_bib_remis_date : le check-in « dossard récupéré » met souvent * par_maj = par_maj (n’incrémente pas) mais rafraîchit no_bib_remis_date. * Inclut aussi par_date_bib (filet si un chemin dossard oublie par_maj). */ function fxChronotrackApiSyncFilterChangedSincePush(array $tabTransferable, $strLastPushAt) { $strLastPushAt = trim((string)$strLastPushAt); if ($strLastPushAt === '' || $strLastPushAt === '0000-00-00 00:00:00') { return $tabTransferable; } $intLast = strtotime($strLastPushAt); if ($intLast === false) { return $tabTransferable; } $tabOut = array(); foreach ($tabTransferable as $arrItem) { $strMaj = trim((string)($arrItem['row']['par_maj'] ?? '')); if ($strMaj === '' || $strMaj === '0000-00-00 00:00:00') { $tabOut[] = $arrItem; continue; } $intMaj = strtotime($strMaj); if ($intMaj === false || $intMaj > $intLast) { $tabOut[] = $arrItem; continue; } // MSIN-4328 — dossard récupéré / check-in CT $strRemisDate = trim((string)($arrItem['row']['no_bib_remis_date'] ?? '')); if ($strRemisDate !== '' && $strRemisDate !== '0000-00-00 00:00:00') { $intRemis = strtotime($strRemisDate); if ($intRemis !== false && $intRemis > $intLast) { $tabOut[] = $arrItem; continue; } } // MSIN-4328 — assignation dossard (par_date_bib) sans par_maj $strDateBib = trim((string)($arrItem['row']['par_date_bib'] ?? '')); if ($strDateBib !== '' && $strDateBib !== '0000-00-00 00:00:00') { $intDateBib = strtotime($strDateBib); if ($intDateBib !== false && $intDateBib > $intLast) { $tabOut[] = $arrItem; } } } return $tabOut; } 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('Nom d\'équipe manquant — corriger dans MS1'); $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' => fxChronotrackApiSyncTeamName($arrRow), 'no_equipe' => trim((string)($arrRow['no_equipe'] ?? '')), 'role_label' => fxChronotrackApiSyncFormatRoleLabel($arrRow['rol_id'] ?? 0), ); } function fxChronotrackApiSyncGroupBlockedByType(array $tabBlocked) { $tabTypeLabels = array( 'team' => 'Équipes (nom manquant)', 'team_covered' => 'Membres équipe (couverts par EQ.)', 'duplicate_bib' => 'Dossard en double (individuel)', 'no_bib' => 'Dossard manquant', 'no_sex' => 'Sexe non reconnu', 'no_first_name' => 'Prénom manquant', 'no_last_name' => 'Nom manquant', 'no_race' => 'Épreuve non mappée', 'no_external' => 'External ID manquant', ); $tabTypeOrder = array( 'team', 'team_covered', 'duplicate_bib', 'no_bib', 'no_sex', 'no_first_name', 'no_last_name', '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, $blnBuildPayloads = true) { $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); $strEprLabel = fxChronotrackApiSyncEpreuveLabel($arrRow); // MSIN-4328 — nom d’épreuve en premier (epr_id en secours) if ($strEprLabel !== '') { $strMsg = '« ' . $strEprLabel . ' » (epr_id ' . $intEprId . ') non mappée vers ChronoTrack'; } else { $strMsg = 'Épreuve MS1 (epr_id ' . $intEprId . ') non mappée vers ChronoTrack'; } $tabBlocked[] = array( 'code' => 'no_race', 'message' => $strMsg, 'epr_id' => $intEprId, 'epr_nom' => $strEprLabel, ) + $arrSummary; continue; } $tabCandidates[] = $arrRow; } // MSIN-4444 — séparer solo / équipes ; 1 entry CT = capitaine (évite doublon dossard partagé) $tabSoloCandidates = array(); $tabTeamGroups = array(); foreach ($tabCandidates as $arrRow) { if (fxChronotrackApiSyncIsTeamRow($arrRow)) { $strTeamKey = fxChronotrackApiSyncTeamGroupKey($arrRow); if (!isset($tabTeamGroups[$strTeamKey])) { $tabTeamGroups[$strTeamKey] = array(); } $tabTeamGroups[$strTeamKey][] = $arrRow; } else { $tabSoloCandidates[] = $arrRow; } } $tabBibCounts = array(); foreach ($tabSoloCandidates as $arrRow) { $strBib = fxChronotrackApiSyncExtractBib($arrRow); if ($strBib !== '') { if (!isset($tabBibCounts[$strBib])) { $tabBibCounts[$strBib] = 0; } $tabBibCounts[$strBib]++; } } $tabTeamReps = array(); foreach ($tabTeamGroups as $strTeamKey => $tabMembers) { $arrRep = fxChronotrackApiSyncPickTeamRepresentative($tabMembers); if ($arrRep === null) { continue; } $tabTeamReps[$strTeamKey] = array( 'rep' => $arrRep, 'members' => $tabMembers, ); $strBib = fxChronotrackApiSyncExtractBib($arrRep); 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; $intBlockedTeamCovered = 0; $intBlockedNoBib = 0; $intBlockedNoSex = 0; $intBlockedNoFirstName = 0; $intBlockedNoLastName = 0; $intBlockedDuplicateBib = 0; foreach ($tabSoloCandidates as $arrRow) { $arrSummary = fxChronotrackApiSyncParticipantSummary($arrRow); $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; } // MSIN-4328 — CT refuse sans first_name / last_name $strPrenom = trim((string)($arrRow['par_prenom'] ?? '')); $strNom = trim((string)($arrRow['par_nom'] ?? '')); if ($strPrenom === '') { $intBlockedNoFirstName++; $tabBlocked[] = array( 'code' => 'no_first_name', 'message' => 'Prénom manquant — corriger dans MS1', ) + $arrSummary; continue; } if ($strNom === '') { $intBlockedNoLastName++; $tabBlocked[] = array( 'code' => 'no_last_name', 'message' => 'Nom manquant — corriger dans MS1', ) + $arrSummary; continue; } $arrPayload = null; if ($blnBuildPayloads) { $arrPayload = fxChronotrackApiSyncBuildEntryPayload($arrRow, $arrRaceMap, $intCtEventId); if ($arrPayload === null) { continue; } } $tabTransferable[] = array( 'row' => $arrRow, 'payload' => $arrPayload, 'summary' => $arrSummary, ); } // MSIN-4444 — équipes : prénom EQ. / nom = nom d'équipe (capitaine) foreach ($tabTeamReps as $arrGroup) { $arrRep = $arrGroup['rep']; $tabMembers = $arrGroup['members']; $intRepParId = intval($arrRep['par_id'] ?? 0); foreach ($tabMembers as $arrMember) { if (intval($arrMember['par_id'] ?? 0) === $intRepParId) { continue; } $intBlockedTeamCovered++; $tabBlocked[] = array( 'code' => 'team_covered', 'message' => 'Membre d\'équipe — couvert par EQ. / nom d\'équipe (capitaine)', ) + fxChronotrackApiSyncParticipantSummary($arrMember); } $arrSummary = fxChronotrackApiSyncParticipantSummary($arrRep); $strTeamName = fxChronotrackApiSyncTeamName($arrRep); if ($strTeamName === '') { $intBlockedTeam++; $tabBlocked[] = array( 'code' => 'team', 'message' => fxChronotrackApiSyncFormatTeamBlockMessage($arrRep), ) + $arrSummary; continue; } $arrSummary['name'] = 'EQ. ' . $strTeamName; $arrSummary['team_name'] = $strTeamName; $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 (équipe) — corriger dans MS1', ) + $arrSummary; continue; } if (!fxChronotrackApiSyncHasValidSex($arrRep['par_sexe'] ?? '')) { $intBlockedNoSex++; $strSexeMs1 = trim((string)($arrRep['par_sexe'] ?? '')); $tabBlocked[] = array( 'code' => 'no_sex', 'message' => 'Sexe capitaine « ' . $strSexeMs1 . ' » — mapping CT à définir', ) + $arrSummary; continue; } $arrPayload = null; if ($blnBuildPayloads) { $arrPayload = fxChronotrackApiSyncBuildEntryPayload($arrRep, $arrRaceMap, $intCtEventId); if ($arrPayload === null) { continue; } } $tabTransferable[] = array( 'row' => $arrRep, '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_no_first_name' => $intBlockedNoFirstName, 'blocked_no_last_name' => $intBlockedNoLastName, 'blocked_duplicate_bib' => $intBlockedDuplicateBib, 'blocked_team' => $intBlockedTeam + $intBlockedTeamCovered, '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; if (!isset($objDatabase) || !is_object($objDatabase)) { error_log('MSIN-4328 sync_log: $objDatabase absent — action=' . $strAction); return false; } $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() . "'"; $blnOk = (bool)$objDatabase->fxQuery($sql); if (!$blnOk) { // Sans ça, push/cron « OK » mais Voir les logs = vide (INSERT silencieux). error_log('MSIN-4328 sync_log INSERT failed eve=' . intval($intEveId) . ' action=' . $strAction . ' sql_err=' . (isset($objDatabase->con) ? mysqli_error($objDatabase->con) : '?')); } return $blnOk; } /** * MSIN-4328 — derniers logs sync pour un événement (écran admin). */ function fxChronotrackApiSyncLogList($intEveId, $intLimit = 50) { global $objDatabase; $intEveId = intval($intEveId); $intLimit = max(1, min(200, intval($intLimit))); if ($intEveId <= 0) { return array('state' => 'error', 'message' => 'eve_id manquant', 'logs' => array()); } $sql = "SELECT log_id, eve_id, par_id, action, status, message, created_at" . " FROM api_chronotrack_sync_log" . " WHERE eve_id = " . $intEveId . " ORDER BY log_id DESC" . " LIMIT " . $intLimit; $arrRows = $objDatabase->fxGetResults($sql); $tabOut = array(); if (is_array($arrRows)) { for ($i = 1; $i <= count($arrRows); $i++) { $tabOut[] = $arrRows[$i]; } } return array( 'state' => 'ok', 'logs' => $tabOut, 'count' => count($tabOut), ); } 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.eve_id, 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.no_bib_remis, p.no_bib_remis_date, p.par_date_bib, p.par_statut_course," . " p.par_ville, p.par_adresse, p.par_codepostal," . " p.is_cancelled, p.par_maj, p.ct_entry_id," . " ec.pec_equipe, ec.no_equipe, ec.pec_nom_equipe," . " ie.epr_nom_fr AS epr_nom, ie.epr_type_fr AS epr_type," . " (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" . " LEFT JOIN inscriptions_epreuves ie ON ie.epr_id = p.epr_id" . " 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; } /** * MSIN-4461 — clé CT entry_note_{label} (support ChronoTrack). */ function fxChronotrackApiSyncEntryNoteKey($strLabel, $intQueId) { $strLabel = trim((string)$strLabel); $strLabel = preg_replace('/\s+/u', '_', $strLabel); $strLabel = preg_replace('/["\\\\\x00-\x1F]+/u', '', $strLabel); $strLabel = trim($strLabel, '_'); if ($strLabel === '') { $strLabel = 'q' . intval($intQueId); } return 'entry_note_' . $strLabel; } /** * MSIN-4461 — index des réponses questions d’un événement (1 requête / eve_id). * * Périmètre actuel : TOUTES les questions actives de l’événement (pas seulement que_rapport). * Si le payload devient trop lourd (beaucoup de questions / événements volumineux), * restreindre ici (ex. que_rapport = 1, liste blanche, ou exclusion des validations techniques). * * @return array> * indexé par pec_id */ function fxChronotrackApiSyncLoadMs1QuestionsIndex($intEveId) { global $objDatabase; static $tabCache = array(); $intEveId = intval($intEveId); if ($intEveId <= 0) { return array(); } if (isset($tabCache[$intEveId])) { return $tabCache[$intEveId]; } $sql = "SELECT rq.pec_id, rq.par_id, rq.que_id," . " TRIM(COALESCE(" . " NULLIF(iq.que_rapport_label_fr, '')," . " NULLIF(rq.que_question_fr, '')," . " NULLIF(iq.que_question_fr, '')," . " '')) AS note_label," . " TRIM(COALESCE(" . " NULLIF(rq.que_choix_fr, '')," . " NULLIF(rq.pqu_note, '')," . " NULLIF(rq.que_choix_en, '')," . " '')) AS note_value" . " FROM resultats_questions rq" . " INNER JOIN inscriptions_questions iq ON iq.que_id = rq.que_id AND iq.eve_id = " . $intEveId . " WHERE rq.que_actif = 1" . " ORDER BY rq.pec_id, rq.que_id"; $tabRows = $objDatabase->fxGetResults($sql); $tabIndex = array(); if (is_array($tabRows)) { for ($i = 1; $i <= count($tabRows); $i++) { $arrQ = $tabRows[$i]; $intPecId = intval($arrQ['pec_id'] ?? 0); $strValue = trim((string)($arrQ['note_value'] ?? '')); if ($intPecId <= 0 || $strValue === '') { continue; } if (!isset($tabIndex[$intPecId])) { $tabIndex[$intPecId] = array(); } $tabIndex[$intPecId][] = array( 'par_id' => intval($arrQ['par_id'] ?? 0), 'que_id' => intval($arrQ['que_id'] ?? 0), 'label' => trim((string)($arrQ['note_label'] ?? '')), 'value' => $strValue, ); } } $tabCache[$intEveId] = $tabIndex; return $tabIndex; } /** * MSIN-4461 — map entry_note_* pour un participant (réponses épreuve + participant). * resultats_questions.par_id = par_id_original (legacy) ; par_id 0 = question niveau épreuve. */ function fxChronotrackApiSyncEntryNotesForParticipant(array $arrRow) { $intEveId = intval($arrRow['eve_id'] ?? 0); $intPecId = intval($arrRow['pec_id'] ?? 0); if ($intEveId <= 0 || $intPecId <= 0) { return array(); } $tabIndex = fxChronotrackApiSyncLoadMs1QuestionsIndex($intEveId); if (!isset($tabIndex[$intPecId]) || !is_array($tabIndex[$intPecId])) { return array(); } $intParOrig = intval($arrRow['par_id_original'] ?? 0); $intParId = intval($arrRow['par_id'] ?? 0); $tabNotes = array(); $tabUsedKeys = array(); foreach ($tabIndex[$intPecId] as $arrQ) { $intQPar = intval($arrQ['par_id'] ?? 0); // Question d’épreuve (par_id vide) ou réponse de ce participant if ($intQPar > 0 && $intQPar !== $intParOrig && $intQPar !== $intParId) { continue; } $intQueId = intval($arrQ['que_id'] ?? 0); $strKey = fxChronotrackApiSyncEntryNoteKey($arrQ['label'] ?? '', $intQueId); if (isset($tabUsedKeys[$strKey])) { $strKey = 'entry_note_q' . $intQueId; } $tabUsedKeys[$strKey] = true; $tabNotes[$strKey] = (string)$arrQ['value']; } return $tabNotes; } 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, ); // MSIN-4444 — inscription équipe : prénom = EQ., nom = nom d'équipe if (fxChronotrackApiSyncIsTeamRow($arrRow)) { $strTeamName = fxChronotrackApiSyncTeamName($arrRow); if ($strTeamName === '') { return null; } $arrEntry['first_name'] = 'EQ.'; $arrEntry['last_name'] = $strTeamName; } 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']; // MSIN-4328 — CT refuse « assign a bib » si l’entry n’est pas confirmed. // Envoyer le dossard dès que CONF (création ET maj) — sinon un changement // de dossard MS1 sur une entry déjà liée n’arrive jamais dans ChronoTrack. if ($strBib !== '' && $arrEntry['entry_status'] === 'CONF') { $arrEntry['bib'] = $strBib; } // MSIN-4328 — Dossard récupéré (MS1) → Check In Status CT (entry_check_in = "1" / null) $arrEntry['entry_check_in'] = (intval($arrRow['no_bib_remis'] ?? 0) === 1) ? '1' : null; // MSIN-4461 — questions MS1 → notes CT (entry_note_{label}). // Toutes les réponses actives pour l’instant ; filtrer plus tard si trop lourd (voir LoadMs1QuestionsIndex). foreach (fxChronotrackApiSyncEntryNotesForParticipant($arrRow) as $strNoteKey => $strNoteValue) { $arrEntry[$strNoteKey] = $strNoteValue; } 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'); } // MSIN-4328 — mémoriser le format qui marche ; ne pas retenter le wrapper à chaque sous-lot static $strBodyFormat = 'array'; static $blnWrapperTried = false; 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; } // Un seul essai wrapper pour toute la requête PHP (pas à chaque dichotomie) if (!$blnWrapperTried) { $blnWrapperTried = true; $arrPostWrap = fxChronotrackApiOAuthApiPost( 'event/' . $intCtEventId . '/entry', array('event_entry' => $tabEntries) ); if ($arrPostWrap['state'] === 'ok') { $strBodyFormat = 'event_entry'; $arrPostWrap['body_format'] = 'event_entry'; return $arrPostWrap; } $arrPost['fallback_error'] = $arrPostWrap['message'] ?? ''; } $arrPost['body_format'] = 'array'; return $arrPost; } /** * MSIN-4328 — logs push_entry un-pour-un (défaut = off, résumé lots seulement). * @param bool|null $blnSet null = lire, bool = fixer pour la requête courante */ function fxChronotrackApiSyncVerboseEntryLogs($blnSet = null) { static $blnVerbose = false; if ($blnSet !== null) { $blnVerbose = (bool)$blnSet; } return $blnVerbose; } /** * MSIN-4328 — log push_entry (succès). Uniquement si logs détaillés cochés. */ function fxChronotrackApiSyncLogPushEntryOk($intEveId, array $arrM, array $arrPayload, $strPrefix = '') { $intEveId = intval($intEveId); if ($intEveId <= 0 || !fxChronotrackApiSyncVerboseEntryLogs()) { return; } $strBibMs1 = trim((string)($arrM['bib_ms1'] ?? $arrM['bib'] ?? '')); $strBibSent = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : ''; if ($strBibSent === '') { $strBibSent = 'non'; } $strNoEquipe = trim((string)($arrM['no_equipe'] ?? '')); $strMsg = ($strPrefix !== '' ? ($strPrefix . ' ') : '') . 'external_id=' . ($arrM['external_id'] ?? '') . ' dossard=' . ($strBibMs1 !== '' ? $strBibMs1 : '—') . ' envoi_bib=' . $strBibSent . ($strNoEquipe !== '' && $strNoEquipe !== '0' ? (' no_equipe=' . $strNoEquipe) : '') . ' nom=' . trim((string)($arrM['name'] ?? '')); fxChronotrackApiSyncLog($intEveId, intval($arrM['par_id'] ?? 0), 'push_entry', 'ok', trim($strMsg)); } /** * MSIN-4328 — log push_entry détail (conflit bib, etc.) si mode verbose. */ function fxChronotrackApiSyncLogPushEntryDetail($intEveId, $intParId, $strStatus, $strMessage) { $intEveId = intval($intEveId); if ($intEveId <= 0 || !fxChronotrackApiSyncVerboseEntryLogs()) { return; } fxChronotrackApiSyncLog($intEveId, intval($intParId), 'push_entry', $strStatus, $strMessage); } /** * MSIN-4328 — 1 entry : tenter avec bib ; si conflit CT → clear + file pour 2e passe. * * @return array{ok:int,err:int,bib_retry:array,method:string} */ function fxChronotrackApiSyncPushOneEntryWithBibConflictHandling( $intCtEventId, array $arrPayload, array $arrMeta, $intEveId = 0 ) { $intCtEventId = intval($intCtEventId); $intEveId = intval($intEveId); $strEntryId = trim((string)($arrPayload['entry_id'] ?? '')); $strBibWanted = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : ''; if ($strEntryId !== '') { $arrFirst = fxChronotrackApiSyncPutEntry($strEntryId, $arrPayload); $strMethod = 'PUT'; } else { $arrFirst = fxChronotrackApiSyncPostEntries( $intCtEventId, array(fxChronotrackApiSyncPayloadForPost($arrPayload)) ); $strMethod = 'POST'; } if (($arrFirst['state'] ?? '') === 'ok') { if ($intEveId > 0) { if ($strMethod === 'POST') { fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrFirst['json'] ?? null, array($arrMeta)); } fxChronotrackApiSyncLogPushEntryOk($intEveId, $arrMeta, $arrPayload, $strMethod); } return array('ok' => 1, 'err' => 0, 'bib_retry' => array(), 'method' => $strMethod); } $strErr = (string)($arrFirst['message'] ?? 'Erreur entry'); // Conflit dossard + on voulait en envoyer un → libérer puis 2e passe if ($strBibWanted !== '' && fxChronotrackApiSyncIsBibConflictMessage($strErr)) { $arrCleared = fxChronotrackApiSyncPayloadClearBib($arrPayload); if ($strEntryId !== '') { $arrClearRes = fxChronotrackApiSyncPutEntry($strEntryId, $arrCleared); // Si CT refuse bib vide : maj sans champ bib, file quand même pour 2e passe if (($arrClearRes['state'] ?? '') !== 'ok') { $arrClearRes = fxChronotrackApiSyncPutEntry( $strEntryId, fxChronotrackApiSyncPayloadWithoutBib($arrPayload) ); } } else { // Création : upsert sans bib (laisse l’entry passer) $arrClearRes = fxChronotrackApiSyncPostEntries( $intCtEventId, array(fxChronotrackApiSyncPayloadForPost(fxChronotrackApiSyncPayloadWithoutBib($arrPayload))) ); } if (($arrClearRes['state'] ?? '') === 'ok') { if ($intEveId > 0) { if ($strEntryId === '') { fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrClearRes['json'] ?? null, array($arrMeta)); } fxChronotrackApiSyncLogPushEntryDetail( $intEveId, intval($arrMeta['par_id'] ?? 0), 'ok', 'conflit_bib → sans dossard (retry plus tard) external_id=' . ($arrMeta['external_id'] ?? '') . ' dossard_voulu=' . $strBibWanted . ' nom=' . trim((string)($arrMeta['name'] ?? '')) ); } // Reprendre entry_id si la création vient de réussir if ($strEntryId === '' && is_array($arrClearRes['json'] ?? null)) { $arrTmp = $arrPayload; // ApplyEntryIds a pu écrire en BD ; pour le retry on préfère entry_id si dispo dans la réponse $arrEntities = fxChronotrackApiNormalizeEntityList($arrClearRes['json'], 'entry'); if (isset($arrEntities[0]) && is_array($arrEntities[0])) { $strNewId = fxChronotrackApiEntityId($arrEntities[0]); if ($strNewId !== '') { $arrTmp['entry_id'] = $strNewId; } } $arrPayload = $arrTmp; } $arrRetryPayload = $arrPayload; $arrRetryPayload['bib'] = $strBibWanted; return array( 'ok' => 1, 'err' => 0, 'bib_retry' => array(array( 'payload' => $arrRetryPayload, 'meta' => $arrMeta, )), 'method' => $strMethod, ); } // Clear a aussi échoué → vraie erreur $strErr = 'conflit_bib + clear échoué — ' . ($arrClearRes['message'] ?? $strErr); } if ($intEveId > 0) { fxChronotrackApiSyncLog( $intEveId, intval($arrMeta['par_id'] ?? 0), 'push_entry', 'error', 'external_id=' . ($arrMeta['external_id'] ?? '') . ' — ' . $strErr ); } return array('ok' => 0, 'err' => 1, 'bib_retry' => array(), 'method' => $strMethod); } /** * MSIN-4328 — payloads pour POST API (sans entry_id ; le PUT unitaire garde entry_id). * * @param array $tabEntries * @return array */ function fxChronotrackApiSyncPayloadsForPostApi(array $tabEntries) { $tabOut = array(); foreach ($tabEntries as $arrPayload) { if (!is_array($arrPayload)) { continue; } $tabOut[] = fxChronotrackApiSyncPayloadForPost($arrPayload); } return $tabOut; } /** * POST entries avec sous-lots fixes si le lot échoue (évite explosion d’appels). * MSIN-4328 — en conflit bib sur 1 entry : clear + file pour 2e passe. * Les payloads peuvent encore contenir entry_id (conservé pour le clear PUT) ; * il est retiré uniquement à l’appel POST API. * * @return array{ok:int,err:int,bib_retry:array} */ function fxChronotrackApiSyncPostEntriesAdaptive($intCtEventId, array $tabEntries, array &$tabErrors, $intEveId = 0, array $tabMeta = array()) { $intCount = count($tabEntries); if ($intCount === 0) { return array('ok' => 0, 'err' => 0, 'bib_retry' => array()); } // MSIN-4328 — 1 entry déjà liée CT : PUT (+ clear bib si swap), pas POST upsert. if ($intCount === 1) { $arrPayload0 = $tabEntries[0]; $strEntryId0 = is_array($arrPayload0) ? trim((string)($arrPayload0['entry_id'] ?? '')) : ''; if ($strEntryId0 !== '') { return fxChronotrackApiSyncPushOneEntryWithBibConflictHandling( $intCtEventId, $arrPayload0, $tabMeta[0] ?? array('par_id' => 0, 'external_id' => ''), $intEveId ); } } $arrPost = fxChronotrackApiSyncPostEntries( $intCtEventId, fxChronotrackApiSyncPayloadsForPostApi($tabEntries) ); if ($arrPost['state'] === 'ok') { if ($intEveId > 0 && count($tabMeta) > 0) { fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrPost['json'] ?? null, $tabMeta); foreach ($tabMeta as $intI => $arrM) { if (!is_array($arrM)) { continue; } $arrPayload = $tabEntries[$intI] ?? array(); fxChronotrackApiSyncLogPushEntryOk($intEveId, $arrM, $arrPayload, 'POST'); } } return array('ok' => $intCount, 'err' => 0, 'bib_retry' => array()); } if ($intCount === 1) { $arrPayload = $tabEntries[0]; $arrM = $tabMeta[0] ?? array('par_id' => 0, 'external_id' => ''); $strOneErr = $arrPost['message'] ?? 'Erreur POST entry'; $strBibWanted = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : ''; if ($strBibWanted !== '' && fxChronotrackApiSyncIsBibConflictMessage($strOneErr)) { // MSIN-4328 — conflit sans entry_id : tenter clear bib='' puis sans champ bib $strEntryIdOne = trim((string)($arrPayload['entry_id'] ?? '')); if ($strEntryIdOne !== '') { $arrClearRes = fxChronotrackApiSyncPutEntry( $strEntryIdOne, fxChronotrackApiSyncPayloadClearBib($arrPayload) ); if (($arrClearRes['state'] ?? '') !== 'ok') { $arrClearRes = fxChronotrackApiSyncPutEntry( $strEntryIdOne, fxChronotrackApiSyncPayloadWithoutBib($arrPayload) ); } } else { // bib='' d’abord (libérer le tag) ; fallback sans champ bib $arrClearRes = fxChronotrackApiSyncPostEntries( $intCtEventId, array(fxChronotrackApiSyncPayloadForPost( fxChronotrackApiSyncPayloadClearBib($arrPayload) )) ); if (($arrClearRes['state'] ?? '') !== 'ok') { $arrClearRes = fxChronotrackApiSyncPostEntries( $intCtEventId, array(fxChronotrackApiSyncPayloadForPost( fxChronotrackApiSyncPayloadWithoutBib($arrPayload) )) ); } } if (($arrClearRes['state'] ?? '') === 'ok') { if ($intEveId > 0) { if ($strEntryIdOne === '') { fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrClearRes['json'] ?? null, array($arrM)); } fxChronotrackApiSyncLogPushEntryDetail( $intEveId, intval($arrM['par_id'] ?? 0), 'ok', 'conflit_bib → sans dossard (retry plus tard) external_id=' . ($arrM['external_id'] ?? '') . ' dossard_voulu=' . $strBibWanted . ' nom=' . trim((string)($arrM['name'] ?? '')) ); } $arrRetryPayload = $arrPayload; $arrRetryPayload['bib'] = $strBibWanted; if ($strEntryIdOne !== '') { $arrRetryPayload['entry_id'] = $strEntryIdOne; } else { $arrEntities = fxChronotrackApiNormalizeEntityList($arrClearRes['json'] ?? null, 'entry'); if (isset($arrEntities[0]) && is_array($arrEntities[0])) { $strNewId = fxChronotrackApiEntityId($arrEntities[0]); if ($strNewId !== '') { $arrRetryPayload['entry_id'] = $strNewId; } } } return array( 'ok' => 1, 'err' => 0, 'bib_retry' => array(array( 'payload' => $arrRetryPayload, 'meta' => $arrM, )), ); } $strOneErr = 'conflit_bib + envoi sans dossard échoué — ' . ($arrClearRes['message'] ?? $strOneErr); } 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, 'bib_retry' => array()); } // Sous-lots fixes : 25 → 5 → 1 $intSubSize = ($intCount > 25) ? 25 : (($intCount > 5) ? 5 : 1); $intOk = 0; $intErr = 0; $tabBibRetry = array(); for ($intI = 0; $intI < $intCount; $intI += $intSubSize) { $arrRes = fxChronotrackApiSyncPostEntriesAdaptive( $intCtEventId, array_slice($tabEntries, $intI, $intSubSize), $tabErrors, $intEveId, array_slice($tabMeta, $intI, $intSubSize) ); $intOk += $arrRes['ok']; $intErr += $arrRes['err']; if (!empty($arrRes['bib_retry']) && is_array($arrRes['bib_retry'])) { foreach ($arrRes['bib_retry'] as $arrR) { $tabBibRetry[] = $arrR; } } } return array( 'ok' => $intOk, 'err' => $intErr, 'bib_retry' => $tabBibRetry, ); } /** * MSIN-4328 — 2e passe : réassigner les dossards différés (swaps). * * @return array{ok:int,err:int,errors:array} */ function fxChronotrackApiSyncPushPendingBibs($intCtEventId, array $tabBibRetry, $intEveId = 0) { $intOk = 0; $intErr = 0; $tabErrors = array(); $intEveId = intval($intEveId); $intCtEventId = intval($intCtEventId); foreach ($tabBibRetry as $arrItem) { if (!is_array($arrItem)) { continue; } $arrPayload = $arrItem['payload'] ?? null; $arrMeta = $arrItem['meta'] ?? array(); if (!is_array($arrPayload)) { continue; } $strBib = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : ''; if ($strBib === '') { continue; } $strEntryId = trim((string)($arrPayload['entry_id'] ?? '')); if ($strEntryId !== '') { $arrRes = fxChronotrackApiSyncPutEntry($strEntryId, $arrPayload); } else { $arrRes = fxChronotrackApiSyncPostEntries( $intCtEventId, array(fxChronotrackApiSyncPayloadForPost($arrPayload)) ); } if (($arrRes['state'] ?? '') === 'ok') { $intOk++; if ($intEveId > 0) { fxChronotrackApiSyncLogPushEntryOk( $intEveId, $arrMeta, $arrPayload, 'RETRY_BIB' ); } } else { $intErr++; $strErr = $arrRes['message'] ?? 'Retry dossard échoué'; $strMsg = 'RETRY_BIB external_id=' . ($arrMeta['external_id'] ?? '') . ' dossard=' . $strBib . ' — ' . $strErr; if (count($tabErrors) < 20) { $tabErrors[] = $strMsg; } if ($intEveId > 0) { fxChronotrackApiSyncLog( $intEveId, intval($arrMeta['par_id'] ?? 0), 'push_entry', 'error', $strMsg ); } } } return array('ok' => $intOk, 'err' => $intErr, 'errors' => $tabErrors); } 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'] ?? '', ); } /** * MSIN-4461 — extrait une valeur entry_note_* d’un objet entry CT (clé plate ou entry_notes). */ function fxChronotrackApiSyncExtractEntryNoteValue($arrEntry, $strFieldKey) { if (!is_array($arrEntry) || $strFieldKey === '') { return array('present' => false, 'value' => null); } if (array_key_exists($strFieldKey, $arrEntry)) { return array('present' => true, 'value' => $arrEntry[$strFieldKey]); } // Support CT : préfixe entry_note_ ; la relecture peut regrouper sous entry_notes $strLabel = (strpos($strFieldKey, 'entry_note_') === 0) ? substr($strFieldKey, strlen('entry_note_')) : $strFieldKey; if (isset($arrEntry['entry_notes']) && is_array($arrEntry['entry_notes'])) { $arrNotes = $arrEntry['entry_notes']; if (array_key_exists($strFieldKey, $arrNotes)) { return array('present' => true, 'value' => $arrNotes[$strFieldKey]); } if (array_key_exists($strLabel, $arrNotes)) { return array('present' => true, 'value' => $arrNotes[$strLabel]); } foreach ($arrNotes as $mixNote) { if (!is_array($mixNote)) { continue; } $strName = trim((string)($mixNote['name'] ?? $mixNote['label'] ?? $mixNote['key'] ?? '')); if ($strName === $strFieldKey || $strName === $strLabel || $strName === 'entry_note_' . $strLabel) { return array( 'present' => true, 'value' => $mixNote['value'] ?? $mixNote['note'] ?? $mixNote['text'] ?? null, ); } } } return array('present' => false, 'value' => null); } /** * MSIN-4461 — sonde entry_note_test (doc CT support : custom notes = préfixe entry_note_). * Préfère PUT entry/{id} si ct_entry_id connu ; sinon POST event/.../entry. */ function fxChronotrackApiSyncProbeCustomField($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); if (count($arrClass['transferable']) === 0) { return array('state' => 'error', 'message' => 'Aucun participant transférable pour la sonde entry_note.'); } // Préférer une entry déjà liée CT (PUT unitaire = chemin documenté par le support) $arrPick = null; foreach ($arrClass['transferable'] as $arrItem) { if (intval($arrItem['row']['ct_entry_id'] ?? 0) > 0) { $arrPick = $arrItem; break; } } if ($arrPick === null) { $arrPick = $arrClass['transferable'][0]; } $arrRow = $arrPick['row']; $strExternalId = (string)($arrPick['payload']['external_id'] ?? ''); $intCtEntryId = intval($arrRow['ct_entry_id'] ?? 0); $strFieldKey = 'entry_note_test'; $strValue = 'ms1-probe-' . date('YmdHis'); // Corps minimal : identifier + note custom (support CT 2026-07-22) $arrProbePayload = array( 'external_id' => $strExternalId, $strFieldKey => $strValue, ); $strMethod = 'POST'; if ($intCtEntryId > 0) { $arrProbePayload['entry_id'] = (string)$intCtEntryId; $arrWrite = fxChronotrackApiSyncPutEntry((string)$intCtEntryId, $arrProbePayload); $strMethod = 'PUT'; } else { $arrWrite = fxChronotrackApiSyncPostEntries($intCtEventId, array($arrProbePayload)); $strMethod = 'POST'; } $mixReadbackValue = null; $blnFieldPresent = false; $arrReadback = null; $strReadbackHow = ''; if ($intCtEntryId > 0) { $arrGet = fxChronotrackApiOAuthApiGet('entry/' . $intCtEntryId); $strReadbackHow = 'GET entry/' . $intCtEntryId; if (($arrGet['state'] ?? '') === 'ok' && is_array($arrGet['json'] ?? null)) { $arrReadback = $arrGet['json']; if (isset($arrReadback['entry']) && is_array($arrReadback['entry'])) { $arrReadback = $arrReadback['entry']; } elseif (isset($arrReadback['event_entry'][0]) && is_array($arrReadback['event_entry'][0])) { $arrReadback = $arrReadback['event_entry'][0]; } $arrFound = fxChronotrackApiSyncExtractEntryNoteValue($arrReadback, $strFieldKey); if ($arrFound['present']) { $blnFieldPresent = true; $mixReadbackValue = $arrFound['value']; } } } if (!$blnFieldPresent) { // Repli : 1re page entries et recherche par external_id $arrList = fxChronotrackApiOAuthApiGet( 'event/' . $intCtEventId . '/entry', array('page' => 1, 'page_size' => 50) ); if ($strReadbackHow === '') { $strReadbackHow = 'GET event/.../entry page1'; } else { $strReadbackHow .= ' + list page1'; } $tabEnt = array(); if (($arrList['state'] ?? '') === 'ok' && is_array($arrList['json'] ?? null)) { $tabEnt = fxChronotrackApiNormalizeEntityList($arrList['json'], 'entry'); if (count($tabEnt) === 0 && isset($arrList['json']['event_entry']) && is_array($arrList['json']['event_entry'])) { $tabEnt = $arrList['json']['event_entry']; } } foreach ($tabEnt as $arrEnt) { if (!is_array($arrEnt)) { continue; } $strExt = trim((string)( $arrEnt['entry_external_id'] ?? $arrEnt['external_id'] ?? '' )); if ($strExt !== $strExternalId) { continue; } $arrReadback = $arrEnt; $arrFound = fxChronotrackApiSyncExtractEntryNoteValue($arrEnt, $strFieldKey); if ($arrFound['present']) { $blnFieldPresent = true; $mixReadbackValue = $arrFound['value']; } break; } } $strVerdict = $strMethod . ' ok mais ' . $strFieldKey . ' absent à la relecture.'; $strState = 'error'; if (($arrWrite['state'] ?? '') !== 'ok') { $strVerdict = $strMethod . ' refusé — ' . (string)($arrWrite['message'] ?? 'erreur'); $strState = 'error'; } elseif ($blnFieldPresent && (string)$mixReadbackValue === $strValue) { $strVerdict = 'OK — CT a accepté et conservé ' . $strFieldKey . ' via ' . $strMethod . '.'; $strState = 'ok'; } elseif ($blnFieldPresent) { $strVerdict = $strMethod . ' ok, champ présent mais valeur différente: ' . json_encode($mixReadbackValue, JSON_UNESCAPED_UNICODE); $strState = 'partial'; } fxChronotrackApiSyncLog( $intEveId, intval($arrRow['par_id'] ?? 0), 'probe_entry_note', ($strState === 'ok') ? 'ok' : 'error', $strVerdict ); return array( 'state' => $strState, 'message' => $strVerdict, 'ct_event_id' => $intCtEventId, 'par_id' => intval($arrRow['par_id'] ?? 0), 'external_id' => $strExternalId, 'ct_entry_id' => $intCtEntryId, 'method' => $strMethod, 'field_key' => $strFieldKey, 'field_value_sent' => $strValue, 'field_present' => $blnFieldPresent, 'field_value_read' => $mixReadbackValue, 'payload' => $arrProbePayload, 'post_http_code' => intval($arrWrite['http']['http_code'] ?? 0), 'post_response' => substr(trim((string)($arrWrite['http']['body'] ?? '')), 0, 1500), 'readback_how' => $strReadbackHow, 'readback_sample' => is_array($arrReadback) ? array_intersect_key($arrReadback, array_flip(array( 'entry_id', 'entry_external_id', 'external_id', 'entry_notes', 'entry_note_test', ))) : null, ); } /** * Sonde perf — teste 10 / 50 / 100 / 200 entries (upsert, mêmes gens). MSIN-4328 * Évite un push complet pour calibrer la taille de lot. */ function fxChronotrackApiSyncProbeBatchPerf($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); $intAvail = count($arrClass['transferable']); if ($intAvail < 10) { return array( 'state' => 'error', 'message' => 'Il faut au moins 10 éligibles pour la sonde lots (actuellement ' . $intAvail . ').', ); } $tabPayloads = array(); $intMax = min(200, $intAvail); for ($i = 0; $i < $intMax; $i++) { $tabPayloads[] = fxChronotrackApiSyncPayloadForPost($arrClass['transferable'][$i]['payload']); } $tabSizes = array(10, 50, 100, 200); $tabResults = array(); $intBest = 10; foreach ($tabSizes as $intSize) { if ($intSize > count($tabPayloads)) { break; } $tabSlice = array_slice($tabPayloads, 0, $intSize); $floatStart = microtime(true); $arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, $tabSlice); $floatMs = round((microtime(true) - $floatStart) * 1000); $blnOk = (($arrPost['state'] ?? '') === 'ok'); $tabResults[] = array( 'size' => $intSize, 'ok' => $blnOk, 'ms' => $floatMs, 'http_code' => intval($arrPost['http']['http_code'] ?? 0), 'message' => $blnOk ? 'OK' : ($arrPost['message'] ?? 'échec'), 'ms_per' => $blnOk && $intSize > 0 ? round($floatMs / $intSize, 1) : null, ); if ($blnOk) { $intBest = $intSize; } else { break; } } $intEst2000 = ($intBest > 0) ? (int)ceil(2000 / $intBest) * max(1, intval($tabResults[count($tabResults) - 1]['ms'] ?? 1000)) : 0; return array( 'state' => 'ok', 'message' => 'Plus grand lot OK : ' . $intBest . ' — lot push actuel configuré : ' . MSIN_API_CHRONOTRACK_SYNC_BATCH_SIZE, 'recommended' => $intBest, 'configured' => MSIN_API_CHRONOTRACK_SYNC_BATCH_SIZE, 'results' => $tabResults, 'est_ms_2000' => $intEst2000, 'est_min_2000' => $intEst2000 > 0 ? round($intEst2000 / 60000, 1) : null, 'ct_event_id' => $intCtEventId, ); } /** * 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); // MSIN-4328 — preview rapide : pas de build payloads / entry_notes (seulement compteurs + exclus) $arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId, false); $intMs1Total = count($tabParticipants); $intTransferable = count($arrClass['transferable']); // MSIN-4328 — preview MS1 seul (rapide). Plus de scan CT fiche-par-fiche : // le push upsert crée/maj sans avoir besoin du inventaire Live ici. $strLastPushAt = trim((string)($arrConfig['last_push_at'] ?? '')); $tabPending = fxChronotrackApiSyncFilterChangedSincePush($arrClass['transferable'], $strLastPushAt); $intPendingPush = count($tabPending); 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'], // Pas de liste plate « blocked » (doublon lourd) — l’UI utilise blocked_by_type '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'], 'ct_total' => null, 'ct_total_loaded' => false, 'already_in_ct' => null, 'will_update' => null, 'to_create' => null, 'will_create' => null, 'ct_orphans' => null, 'ct_without_external_id' => null, 'to_push' => $intPendingPush, 'pending_push' => $intPendingPush, 'last_push_at' => ($strLastPushAt !== '' && $strLastPushAt !== '0000-00-00 00:00:00') ? $strLastPushAt : null, 'last_push_ok_count' => intval($arrConfig['last_push_ok_count'] ?? 0), 'is_first_push' => ($strLastPushAt === '' || $strLastPushAt === '0000-00-00 00:00:00'), ); } /** * MSIN-4328 — total entries CT Live (sans construire le diff MS1↔CT). * Essaie d’abord un total dans la réponse API ; sinon compte par pagination légère. */ function fxChronotrackApiSyncExtractCtListTotal($mixJson) { if (!is_array($mixJson)) { return null; } $tabKeys = array( 'total', 'total_count', 'totalCount', 'count', 'entry_count', 'num_results', 'results_count', 'nb_results', 'total_entries', ); foreach ($tabKeys as $strKey) { if (isset($mixJson[$strKey]) && is_numeric($mixJson[$strKey])) { $intVal = intval($mixJson[$strKey]); if ($intVal >= 0) { return $intVal; } } } foreach (array('pagination', 'page', 'meta', 'paging') as $strNest) { if (!isset($mixJson[$strNest]) || !is_array($mixJson[$strNest])) { continue; } $intNested = fxChronotrackApiSyncExtractCtListTotal($mixJson[$strNest]); if ($intNested !== null) { return $intNested; } } return null; } /** * MSIN-4328 — total entries CT Live (+ backfill ct_entry_id si eve_id fourni). */ function fxChronotrackApiSyncFetchCtEntryCount($intCtEventId, $intEveId = 0) { global $objDatabase; $intCtEventId = intval($intCtEventId); $intEveId = intval($intEveId); if ($intCtEventId <= 0) { return array('state' => 'error', 'message' => 'ct_event_id manquant', 'ct_total' => null); } $tabByExt = array(); if ($intEveId > 0) { $sqlMap = "SELECT par_id, par_id_original FROM resultats_participants" . " WHERE eve_id = " . $intEveId . " AND is_cancelled = 0" . " AND IFNULL(par_id_original, 0) > 0"; $arrMapRows = $objDatabase->fxGetResults($sqlMap); if (is_array($arrMapRows)) { for ($i = 1; $i <= count($arrMapRows); $i++) { $strExt = (string)intval($arrMapRows[$i]['par_id_original']); $tabByExt[$strExt] = intval($arrMapRows[$i]['par_id']); } } } // Si pas de backfill et métadonnée total dispo → 1 appel. if (count($tabByExt) === 0) { $arrApi = fxChronotrackApiOAuthApiGet('event/' . $intCtEventId . '/entry', array( 'page' => 1, 'page_size' => 1, )); if ($arrApi['state'] !== 'ok') { return array( 'state' => 'error', 'message' => $arrApi['message'] ?? 'Erreur lecture entries CT', 'ct_total' => null, ); } $intFromMeta = fxChronotrackApiSyncExtractCtListTotal($arrApi['json'] ?? null); if ($intFromMeta !== null) { return array( 'state' => 'ok', 'message' => 'Total ChronoTrack Live (métadonnée API)', 'ct_total' => $intFromMeta, 'via' => 'meta', 'backfilled'=> 0, ); } } $intTotal = 0; $intBackfilled = 0; $intPage = 1; $intPageSize = 250; $intMaxPages = 200; while ($intPage <= $intMaxPages) { $arrPage = fxChronotrackApiOAuthApiGet('event/' . $intCtEventId . '/entry', array( 'page' => $intPage, 'page_size' => $intPageSize, )); if ($arrPage['state'] !== 'ok') { return array( 'state' => 'error', 'message' => $arrPage['message'] ?? 'Erreur lecture entries CT', 'ct_total' => ($intTotal > 0) ? $intTotal : null, 'backfilled' => $intBackfilled, ); } $arrEntities = fxChronotrackApiNormalizeEntityList($arrPage['json'], 'entry'); $intN = count($arrEntities); $intTotal += $intN; if (count($tabByExt) > 0) { $tabMetaPage = array(); foreach ($arrEntities as $arrEnt) { if (!is_array($arrEnt)) { continue; } $strExt = fxChronotrackApiEntityExternalId($arrEnt); if ($strExt === '' || !isset($tabByExt[$strExt])) { continue; } $tabMetaPage[] = array( 'par_id' => $tabByExt[$strExt], 'external_id' => $strExt, ); } if (count($tabMetaPage) > 0) { $intBackfilled += fxChronotrackApiSyncApplyEntryIdsFromApiJson( array('event_entry' => $arrEntities), $tabMetaPage ); } } if ($intN < $intPageSize) { break; } $intPage++; } return array( 'state' => 'ok', 'message' => 'Total ChronoTrack Live (comptage paginé)' . ($intBackfilled > 0 ? (' — ' . $intBackfilled . ' entry_id liés') : ''), 'ct_total' => $intTotal, 'via' => 'pages', 'backfilled' => $intBackfilled, ); } /** * 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. * * @param array $arrOptions force_full=true → tous les éligibles (ignore last_push_at) */ function fxChronotrackApiSyncPushPrepare($intEveId, $arrOptions = array()) { $intEveId = intval($intEveId); $blnForceFull = !empty($arrOptions['force_full']); $blnVerboseLogs = !empty($arrOptions['verbose_logs']); fxChronotrackApiSyncVerboseEntryLogs($blnVerboseLogs); $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); // MSIN-4328 — ne plus journaliser push_skip ici (spam à chaque prepare/auto). // Les exclus restent dans l’UI « Détails & exclus » (preview), jamais dans le lot poussé. // MSIN-4328 — différentiel par défaut ; force_full = tout renvoyer $strLastPushAt = trim((string)($arrConfig['last_push_at'] ?? '')); if ($blnForceFull) { $tabPending = $arrClass['transferable']; } else { $tabPending = fxChronotrackApiSyncFilterChangedSincePush($arrClass['transferable'], $strLastPushAt); } // Garder entry_id en meta/job pour retry dossard ; l’envoi lot utilise POST upsert (pas PUT unitaire). $tabBatch = array(); $tabMeta = array(); foreach ($tabPending as $arrItem) { $arrPayload = $arrItem['payload']; $strCtEntryId = preg_replace('/[^0-9]/', '', (string)($arrItem['row']['ct_entry_id'] ?? '')); if ($strCtEntryId !== '') { $arrPayload['entry_id'] = $strCtEntryId; } $tabBatch[] = $arrPayload; $strBibMs1 = fxChronotrackApiSyncExtractBib($arrItem['row'] ?? array()); $strBibSent = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : ''; $strNoEquipe = trim((string)($arrItem['row']['no_equipe'] ?? '')); $tabMeta[] = array( 'par_id' => intval($arrItem['row']['par_id'] ?? 0), 'external_id' => $arrPayload['external_id'] ?? '', 'bib_ms1' => $strBibMs1, 'bib_sent' => $strBibSent, 'no_equipe' => $strNoEquipe, 'name' => trim( trim((string)($arrPayload['first_name'] ?? '')) . ' ' . trim((string)($arrPayload['last_name'] ?? '')) ), ); } if (count($tabBatch) === 0) { fxChronotrackApiSyncPushJobClear($intEveId); $strMsg = count($arrClass['transferable']) === 0 ? ('Aucun participant transférable.' . ($arrClass['blocked_count'] > 0 ? ' ' . $arrClass['blocked_count'] . ' exclus.' : '')) : ('Aucun changement depuis le dernier push (' . count($arrClass['transferable']) . ' éligibles à jour).'); fxChronotrackApiSyncLog($intEveId, 0, 'push_prepare', 'ok', 'idle — ' . $strMsg); return array( 'state' => 'error', 'message' => $strMsg, 'blocked_count' => $arrClass['blocked_count'], 'transferable' => count($arrClass['transferable']), 'pending_push' => 0, 'total' => 0, 'force_full' => $blnForceFull ? 1 : 0, ); } $strMode = $blnForceFull ? 'FULL resync' : 'différentiel'; fxChronotrackApiSyncLog( $intEveId, 0, 'push_prepare', 'ok', $strMode . ' — job prêt — pending=' . count($tabBatch) . ' éligibles=' . count($arrClass['transferable']) . ' exclus=' . intval($arrClass['blocked_count']) ); $arrJob = array( 'ct_event_id' => $intCtEventId, 'batch' => $tabBatch, 'meta' => $tabMeta, 'blocked_count' => $arrClass['blocked_count'], 'force_full' => $blnForceFull ? 1 : 0, 'verbose_logs' => $blnVerboseLogs ? 1 : 0, 'ok_sum' => 0, 'err_sum' => 0, 'bib_retry' => array(), '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' . ($blnForceFull ? ' (resync complète)' : ''), 'total' => count($tabBatch), 'pending_push' => count($tabBatch), 'transferable' => count($arrClass['transferable']), 'blocked_count' => $arrClass['blocked_count'], 'chunk_size' => MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE, 'ct_event_id' => $intCtEventId, 'force_full' => $blnForceFull ? 1 : 0, ); } /** * Push un slice du job préparé. MSIN-4328. * Même mécanique que « Envoyer » : POST par lots + offset/total (barre %). * « Tout renvoyer » = force_full à la préparation seulement — pas un autre pipeline. */ 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); fxChronotrackApiSyncVerboseEntryLogs(!empty($arrJob['verbose_logs'])); $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, 'chunk_size' => $intLimit, 'done' => true, 'blocked_count' => $intSkipped, ); } $intOk = 0; $intErr = 0; $intPostOk = 0; $intPutOk = 0; $tabErrors = array(); $tabBibRetryChunk = array(); // MSIN-4328 — toujours POST en lot (upsert par external_id). // Avant : PUT unitaire si ct_entry_id → ~100 appels/lot = 1–2 min figé à 0/N. // entry_id reste sur le payload pour la 2e passe dossard ; retiré seulement à l’appel POST. $tabPostEntries = array(); $tabPostMeta = array(); foreach ($tabBatch as $intI => $arrPayload) { if (!is_array($arrPayload)) { continue; } $tabPostEntries[] = $arrPayload; $tabPostMeta[] = $tabMeta[$intI] ?? array('par_id' => 0, 'external_id' => ''); } if (count($tabPostEntries) > 0) { $arrRes = fxChronotrackApiSyncPostEntriesAdaptive( $intCtEventId, $tabPostEntries, $tabErrors, $intEveId, $tabPostMeta ); $intOk += $arrRes['ok']; $intPostOk += $arrRes['ok']; $intErr += $arrRes['err']; if (!empty($arrRes['bib_retry']) && is_array($arrRes['bib_retry'])) { foreach ($arrRes['bib_retry'] as $arrR) { $tabBibRetryChunk[] = $arrR; } } } $tabBibRetryJob = isset($arrJob['bib_retry']) && is_array($arrJob['bib_retry']) ? $arrJob['bib_retry'] : array(); foreach ($tabBibRetryChunk as $arrR) { $tabBibRetryJob[] = $arrR; } $arrJob['bib_retry'] = $tabBibRetryJob; fxChronotrackApiSyncLog( $intEveId, 0, 'push_chunk', ($intErr === 0) ? 'ok' : 'error', 'offset=' . $intOffset . ' n=' . $intChunkCount . ' ok=' . $intOk . ' err=' . $intErr . ' PUT=' . $intPutOk . ' POST=' . $intPostOk . ' bib_pending=' . count($tabBibRetryJob) ); $intOkSum = intval($arrJob['ok_sum'] ?? 0) + $intOk; $intErrSum = intval($arrJob['err_sum'] ?? 0) + $intErr; $arrJob['ok_sum'] = $intOkSum; $arrJob['err_sum'] = $intErrSum; $intNextOffset = $intOffset + $intChunkCount; $blnDone = ($intNextOffset >= $intTotal); $blnLastPushSaved = false; $intBibRetryOk = 0; $intBibRetryErr = 0; if ($blnDone) { // 2e passe dossards (swaps) — interne, sans changer offset/total de la barre % if (count($tabBibRetryJob) > 0) { $arrBibPass = fxChronotrackApiSyncPushPendingBibs( $intCtEventId, $tabBibRetryJob, $intEveId ); $intBibRetryOk = intval($arrBibPass['ok'] ?? 0); $intBibRetryErr = intval($arrBibPass['err'] ?? 0); $intOkSum += $intBibRetryOk; $intErrSum += $intBibRetryErr; if (!empty($arrBibPass['errors']) && is_array($arrBibPass['errors'])) { foreach ($arrBibPass['errors'] as $strBe) { if (count($tabErrors) < 20) { $tabErrors[] = $strBe; } } } fxChronotrackApiSyncLog( $intEveId, 0, 'push_bib_retry', ($intBibRetryErr === 0) ? 'ok' : 'error', 'fin job — retry dossards n=' . count($tabBibRetryJob) . ' ok=' . $intBibRetryOk . ' err=' . $intBibRetryErr ); } if ($intErrSum === 0) { $blnLastPushSaved = fxChronotrackApiConfigSetLastPush($intEveId, $intOkSum); } fxChronotrackApiSyncPushJobClear($intEveId); } else { fxChronotrackApiSyncPushJobSave($intEveId, $arrJob); } $strState = ($intErr === 0 && $intBibRetryErr === 0) ? 'ok' : ((($intOk + $intBibRetryOk) > 0) ? 'partial' : 'error'); $strMessage = ($intErr === 0 && $intBibRetryErr === 0) ? ($intOk . ' envoyé(s) (offset ' . $intOffset . ').') : ($intOk . ' OK, ' . $intErr . ' erreur(s) sur ce lot.' . ($intBibRetryErr > 0 ? (' Retry dossards : ' . $intBibRetryErr . ' échec(s).') : '')); return array( 'state' => $strState, 'message' => $strMessage, 'pushed_ok' => $intOk + $intBibRetryOk, 'pushed_put' => $intPutOk, 'pushed_post' => $intPostOk + $intBibRetryOk, 'pushed_error' => $intErr + $intBibRetryErr, 'job_ok_sum' => $intOkSum, 'job_err_sum' => $intErrSum, 'last_push_saved' => $blnLastPushSaved, '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, ); } /** * MSIN-4328 — push différentiel complet pour un event (cron auto). */ function fxChronotrackApiSyncRunAutoPushForEvent($intEveId) { $intEveId = intval($intEveId); $arrPrep = fxChronotrackApiSyncPushPrepare($intEveId); if (($arrPrep['state'] ?? '') !== 'ok') { // Rien à pousser = succès « idle » $strMsg = (string)($arrPrep['message'] ?? 'prepare failed'); if (stripos($strMsg, 'Aucun changement') !== false || intval($arrPrep['pending_push'] ?? -1) === 0) { fxChronotrackApiSyncLog($intEveId, 0, 'auto_push', 'ok', 'idle — ' . $strMsg); return array('state' => 'ok', 'message' => $strMsg, 'pushed_ok' => 0, 'idle' => true); } fxChronotrackApiSyncLog($intEveId, 0, 'auto_push', 'error', $strMsg); return $arrPrep; } $intOffset = 0; $intLimit = MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE; $intOkSum = 0; $intErrSum = 0; $intLoops = 0; $intMaxLoops = 500; while ($intLoops < $intMaxLoops) { $intLoops++; $arrChunk = fxChronotrackApiSyncPushEvent($intEveId, array( 'offset' => $intOffset, 'limit' => $intLimit, )); $intOkSum += intval($arrChunk['pushed_ok'] ?? 0); $intErrSum += intval($arrChunk['pushed_error'] ?? 0); if (!empty($arrChunk['done'])) { break; } if (($arrChunk['state'] ?? '') === 'error' && intval($arrChunk['pushed_ok'] ?? 0) === 0) { break; } $intOffset = intval($arrChunk['next_offset'] ?? ($intOffset + $intLimit)); } $strState = ($intErrSum === 0) ? 'ok' : (($intOkSum > 0) ? 'partial' : 'error'); $strMsg = 'auto push ok=' . $intOkSum . ' err=' . $intErrSum . ' total_job=' . intval($arrPrep['total'] ?? 0); fxChronotrackApiSyncLog($intEveId, 0, 'auto_push', ($strState === 'ok') ? 'ok' : 'error', $strMsg); return array( 'state' => $strState, 'message' => $strMsg, 'pushed_ok' => $intOkSum, 'pushed_error' => $intErrSum, 'idle' => false, ); } /** * MSIN-4328 — passage cron : events auto ON, fréquence / fin respectées. * Chaque décision (run / skip / expire) écrit un log pour l’événement — sinon * « Voir les logs » reste vide alors que le CrowdJob tourne vraiment. */ function fxChronotrackApiSyncRunAutoCron() { if (!fxChronotrackApiSettingsConfigured()) { return array( 'state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage(), 'ran' => 0, 'skipped' => 0, 'enabled_configs' => 0, ); } $tabConfigs = fxChronotrackApiConfigListAutoSyncEnabled(); $intRan = 0; $intSkipped = 0; $intExpired = 0; $tabDetails = array(); $intNow = time(); $intEnabled = count($tabConfigs); foreach ($tabConfigs as $arrCfg) { $intEveId = intval($arrCfg['eve_id'] ?? 0); if ($intEveId <= 0) { continue; } $strUntil = trim((string)($arrCfg['auto_sync_until'] ?? '')); if ($strUntil !== '' && $strUntil !== '0000-00-00 00:00:00') { $intUntil = strtotime($strUntil); if ($intUntil !== false && $intNow > $intUntil) { fxChronotrackApiConfigDisableAutoSync($intEveId); fxChronotrackApiSyncLog( $intEveId, 0, 'auto_sync', 'ok', 'Échéance atteinte (' . $strUntil . ') — sync auto désactivée' ); $intExpired++; $tabDetails[] = array('eve_id' => $intEveId, 'action' => 'expired'); continue; } } else { // Pas de fin = on ne tourne pas (UI exige une fin à l’enregistrement) $intSkipped++; fxChronotrackApiSyncLog( $intEveId, 0, 'auto_cron', 'error', 'Skip: date/heure de fin manquante (réenregistrer la sync auto)' ); $tabDetails[] = array('eve_id' => $intEveId, 'action' => 'skip_no_until'); continue; } $intInterval = intval($arrCfg['auto_sync_interval_min'] ?? 15); if (!in_array($intInterval, fxChronotrackApiConfigAutoSyncIntervals(), true)) { $intInterval = 15; } $strLast = trim((string)($arrCfg['last_auto_run_at'] ?? '')); if ($strLast !== '' && $strLast !== '0000-00-00 00:00:00') { $intLast = strtotime($strLast); if ($intLast !== false && ($intNow - $intLast) < ($intInterval * 60)) { $intSkipped++; // Trace légère: 1 seule fois par fenêtre d’intervalle (évite 1440 lignes/jour). // Déjà logué ailleurs au run — ici on ne spam pas. $tabDetails[] = array('eve_id' => $intEveId, 'action' => 'skip_interval'); continue; } } $arrRes = fxChronotrackApiSyncRunAutoPushForEvent($intEveId); fxChronotrackApiConfigTouchAutoRun($intEveId); $intRan++; $tabDetails[] = array( 'eve_id' => $intEveId, 'action' => 'run', 'result' => $arrRes['state'] ?? 'error', 'message' => $arrRes['message'] ?? '', 'ok' => intval($arrRes['pushed_ok'] ?? 0), ); } $strMsg = 'cron auto — configs_on=' . $intEnabled . ' ran=' . $intRan . ' skipped=' . $intSkipped . ' expired=' . $intExpired; if ($intEnabled === 0) { $strMsg .= ' (aucun event avec sync auto ON sur cette base)'; } return array( 'state' => 'ok', 'message' => $strMsg, 'ran' => $intRan, 'skipped' => $intSkipped, 'expired' => $intExpired, 'enabled_configs' => $intEnabled, 'details' => $tabDetails, ); } /** * MSIN-4328 — état sync auto + logs pour un événement (diag UI, pas d’essai-erreur SSL). */ function fxChronotrackApiSyncAutoDiag($intEveId) { global $objDatabase; $intEveId = intval($intEveId); $arrCfg = fxChronotrackApiConfigGet($intEveId); if ($arrCfg === null) { return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack'); } $blnColsOk = true; $strColNote = ''; $sqlCol = "SELECT COUNT(*) AS n FROM information_schema.COLUMNS" . " WHERE TABLE_SCHEMA = DATABASE()" . " AND TABLE_NAME = 'api_chronotrack_config'" . " AND COLUMN_NAME = 'auto_sync_enabled'"; $arrCol = $objDatabase->fxGetRow($sqlCol); if (intval($arrCol['n'] ?? 0) < 1) { $blnColsOk = false; $strColNote = 'Colonnes auto_sync absentes — exécuter sql/MSIN-4328-chronotrack-auto-sync-config.sql'; } $sqlLog = "SELECT COUNT(*) AS n FROM api_chronotrack_sync_log WHERE eve_id = " . $intEveId; $arrLogCnt = $objDatabase->fxGetRow($sqlLog); $intLogCnt = intval($arrLogCnt['n'] ?? 0); $arrLastLog = $objDatabase->fxGetRow( "SELECT log_id, action, status, message, created_at FROM api_chronotrack_sync_log" . " WHERE eve_id = " . $intEveId . " ORDER BY log_id DESC LIMIT 1" ); $blnOn = (intval($arrCfg['auto_sync_enabled'] ?? 0) === 1); $intInterval = intval($arrCfg['auto_sync_interval_min'] ?? 15); $strUntil = trim((string)($arrCfg['auto_sync_until'] ?? '')); $strLastRun = trim((string)($arrCfg['last_auto_run_at'] ?? '')); $intNow = time(); $strWould = 'inconnu'; if (!$blnColsOk) { $strWould = 'bloqué: SQL auto_sync manquant'; } elseif (!$blnOn) { $strWould = 'ne tourne pas: sync auto OFF'; } elseif ($strUntil === '' || $strUntil === '0000-00-00 00:00:00') { $strWould = 'ne tourne pas: date de fin manquante'; } elseif (strtotime($strUntil) !== false && $intNow > strtotime($strUntil)) { $strWould = 'ne tourne pas: date de fin dépassée (' . $strUntil . ')'; } elseif ($strLastRun !== '' && $strLastRun !== '0000-00-00 00:00:00' && strtotime($strLastRun) !== false && ($intNow - strtotime($strLastRun)) < ($intInterval * 60)) { $strWould = 'en attente d’intervalle (dernier passage ' . $strLastRun . ', fréquence ' . $intInterval . ' min)'; } else { $strWould = 'devrait pousser au prochain appel de auto_chronotrack_sync.php'; } return array( 'state' => 'ok', 'message' => $strWould, 'diag' => array( 'cols_ok' => $blnColsOk, 'cols_note' => $strColNote, 'auto_enabled' => $blnOn, 'interval_min' => $intInterval, 'until' => $strUntil !== '' ? $strUntil : null, 'last_auto_run_at' => $strLastRun !== '' ? $strLastRun : null, 'log_count' => $intLogCnt, 'last_log' => is_array($arrLastLog) ? $arrLastLog : null, 'host' => isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '', 'would' => $strWould, ), ); }