Files
ms1inscription-v5/php/chronotrack_api/fx_chronotrack_sync.php
stephan 411f2a4d8f Add probe push functionality and enhance error handling in ChronoTrack API
This commit introduces a new 'sync_probe_push' action to the ChronoTrack API, allowing for the probing of participant data synchronization. It includes a new button in the admin interface for initiating probe pushes, along with improved error handling in the API response messages. Additionally, the batch size for synchronization is adjusted, and the entry payload building process is refined to include event IDs and registration choices, enhancing the overall synchronization capabilities and user experience.
2026-07-03 09:21:40 -04:00

543 lines
18 KiB
PHP

<?php
/**
* MSIN API ChronoTrack — Phase 2a : lecture MS1, diff, push manuel entries.
*/
define('MSIN_API_CHRONOTRACK_SYNC_BATCH_SIZE', 10);
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' || $strSexe === 'F') {
return 'F';
}
if ($strSexe === 'h' || $strSexe === 'm' || $strSexe === 'M') {
return 'M';
}
if ($strSexe === '') {
return 'U';
}
return strtoupper(substr($strSexe, 0, 1));
}
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.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,"
. " (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'] ?? '');
$arrEntry = array(
'external_id' => (string)$intExternalId,
'race_id' => $intCtRaceId,
'first_name' => trim((string)($arrRow['par_prenom'] ?? '')),
'last_name' => trim((string)($arrRow['par_nom'] ?? '')),
);
if ($intCtEventId > 0) {
$arrEntry['event_id'] = $intCtEventId;
}
$intRegChoiceId = fxChronotrackApiSyncResolveRegChoiceIdForRace($intCtEventId, $intCtRaceId);
if ($intRegChoiceId > 0) {
$arrEntry['reg_choice_id'] = $intRegChoiceId;
}
if ($strSex !== 'U') {
$arrEntry['sex'] = $strSex;
}
$strDob = fxChronotrackApiSyncNormalizeBirthdate($arrRow['par_naissance'] ?? '');
if ($strDob !== '') {
$arrEntry['birthdate'] = $strDob;
}
$strBib = trim((string)($arrRow['no_bib'] ?? ''));
if ($strBib !== '' && $strBib !== '0') {
$arrEntry['bib'] = $strBib;
}
$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;
}
return $arrEntry;
}
function fxChronotrackApiSyncPostEntries($intCtEventId, array $tabEntries) {
$intCtEventId = intval($intCtEventId);
if ($intCtEventId <= 0 || count($tabEntries) === 0) {
return array('state' => 'error', 'message' => 'Aucune entry à envoyer');
}
// 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';
return $arrPost;
}
$arrPostWrap = fxChronotrackApiOAuthApiPost(
'event/' . $intCtEventId . '/entry',
array('event_entry' => $tabEntries)
);
if ($arrPostWrap['state'] === 'ok') {
$arrPostWrap['body_format'] = 'event_entry';
return $arrPostWrap;
}
$arrPost['body_format'] = 'array';
$arrPost['fallback_error'] = $arrPostWrap['message'] ?? '';
return $arrPost;
}
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);
$arrPayload = null;
$arrRow = null;
foreach ($tabParticipants as $arrCandidate) {
$arrPayload = fxChronotrackApiSyncBuildEntryPayload($arrCandidate, $arrRaceMap, $intCtEventId);
if ($arrPayload !== null) {
$arrRow = $arrCandidate;
break;
}
}
if ($arrPayload === null) {
return array('state' => 'error', 'message' => 'Aucun participant éligible pour la sonde');
}
$arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, array($arrPayload));
return array(
'state' => $arrPost['state'],
'message' => $arrPost['message'] ?? '',
'ct_event_id' => $intCtEventId,
'par_id' => intval($arrRow['par_id'] ?? 0),
'payload' => $arrPayload,
'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'] ?? '',
);
}
function fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId) {
$intCtEventId = intval($intCtEventId);
$tabIndexed = array();
$intPage = 1;
$intPageSize = 100;
$intMaxPages = 50;
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',
'entries' => $tabIndexed,
);
}
$arrEntities = fxChronotrackApiNormalizeEntityList($arrApi['json'], 'entry');
if (count($arrEntities) === 0) {
break;
}
foreach ($arrEntities as $arrEntity) {
if (!is_array($arrEntity)) {
continue;
}
$strExt = fxChronotrackApiEntityExternalId($arrEntity);
if ($strExt === '') {
continue;
}
$tabIndexed[$strExt] = $arrEntity;
}
if (count($arrEntities) < $intPageSize) {
break;
}
$intPage++;
}
return array(
'state' => 'ok',
'message' => '',
'entries' => $tabIndexed,
);
}
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);
$intMs1Total = count($tabParticipants);
$intEligible = 0;
$intSkippedNoRace = 0;
$intSkippedNoExternal = 0;
$tabEligibleKeys = array();
foreach ($tabParticipants as $arrRow) {
$arrPayload = fxChronotrackApiSyncBuildEntryPayload($arrRow, $arrRaceMap, $intCtEventId);
if ($arrPayload === null) {
if (intval($arrRow['par_id_original'] ?? 0) <= 0) {
$intSkippedNoExternal++;
} else {
$intSkippedNoRace++;
}
continue;
}
$intEligible++;
$tabEligibleKeys[$arrPayload['external_id']] = true;
}
$arrCtFetch = fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId);
if ($arrCtFetch['state'] !== 'ok') {
return array(
'state' => 'error',
'message' => $arrCtFetch['message'],
);
}
$tabCtEntries = $arrCtFetch['entries'];
$intCtTotal = count($tabCtEntries);
$intAlreadyInCt = 0;
$intToCreate = 0;
foreach ($tabEligibleKeys as $strExt => $blnTrue) {
if (isset($tabCtEntries[$strExt])) {
$intAlreadyInCt++;
} else {
$intToCreate++;
}
}
return array(
'state' => 'ok',
'ct_event_id' => $intCtEventId,
'ms1_total' => $intMs1Total,
'eligible' => $intEligible,
'skipped_no_race' => $intSkippedNoRace,
'skipped_no_external'=> $intSkippedNoExternal,
'ct_total' => $intCtTotal,
'already_in_ct' => $intAlreadyInCt,
'to_create' => $intToCreate,
'to_push' => $intEligible,
);
}
function fxChronotrackApiSyncPushEvent($intEveId) {
$arrPreview = fxChronotrackApiSyncBuildPreview($intEveId);
if ($arrPreview['state'] !== 'ok') {
return $arrPreview;
}
$intCtEventId = intval($arrPreview['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$tabBatch = array();
$tabMeta = array();
$intSkipped = 0;
foreach ($tabParticipants as $arrRow) {
$arrPayload = fxChronotrackApiSyncBuildEntryPayload($arrRow, $arrRaceMap, $intCtEventId);
if ($arrPayload === null) {
$intSkipped++;
continue;
}
$tabBatch[] = $arrPayload;
$tabMeta[] = array(
'par_id' => intval($arrRow['par_id']),
'external_id' => $arrPayload['external_id'],
);
}
if (count($tabBatch) === 0) {
return array(
'state' => 'error',
'message' => 'Aucun participant éligible (vérifiez le mapping des épreuves).',
);
}
$intOk = 0;
$intErr = 0;
$tabErrors = array();
$intBatchSize = MSIN_API_CHRONOTRACK_SYNC_BATCH_SIZE;
$intTotal = count($tabBatch);
for ($intOffset = 0; $intOffset < $intTotal; $intOffset += $intBatchSize) {
$tabSlice = array_slice($tabBatch, $intOffset, $intBatchSize);
$tabSliceMeta = array_slice($tabMeta, $intOffset, $intBatchSize);
$arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, $tabSlice);
if ($arrPost['state'] === 'ok') {
$intOk += count($tabSlice);
foreach ($tabSliceMeta as $arrM) {
fxChronotrackApiSyncLog(
$intEveId,
$arrM['par_id'],
'push_entry',
'ok',
'external_id=' . $arrM['external_id']
);
}
continue;
}
$strErrMsg = $arrPost['message'] ?? 'Erreur POST entry';
if (!empty($arrPost['fallback_error'])) {
$strErrMsg .= ' | wrapper: ' . $arrPost['fallback_error'];
}
foreach ($tabSlice as $intIdx => $arrEntry) {
$arrM = $tabSliceMeta[$intIdx];
$arrOne = fxChronotrackApiSyncPostEntries($intCtEventId, array($arrEntry));
if ($arrOne['state'] === 'ok') {
$intOk++;
fxChronotrackApiSyncLog(
$intEveId,
$arrM['par_id'],
'push_entry',
'ok',
'external_id=' . $arrM['external_id']
);
continue;
}
$intErr++;
$strOneErr = $arrOne['message'] ?? $strErrMsg;
if (count($tabErrors) < 8) {
$tabErrors[] = 'external_id=' . $arrM['external_id'] . ' — ' . $strOneErr;
}
fxChronotrackApiSyncLog(
$intEveId,
$arrM['par_id'],
'push_entry',
'error',
'external_id=' . $arrM['external_id'] . ' — ' . $strOneErr
);
}
}
$strState = ($intErr === 0) ? 'ok' : (($intOk > 0) ? 'partial' : 'error');
return array(
'state' => $strState,
'message' => ($intErr === 0)
? ($intOk . ' participant(s) envoyé(s) vers ChronoTrack.')
: ($intOk . ' OK, ' . $intErr . ' erreur(s).'),
'pushed_ok' => $intOk,
'pushed_error' => $intErr,
'skipped' => $intSkipped,
'errors' => array_slice($tabErrors, 0, 8),
'preview' => $arrPreview,
);
}