Files
ms1inscription-v5/php/chronotrack_api/fx_chronotrack_sync.php
stephan 903feebcdb Add synchronization functionality to ChronoTrack API
This commit introduces new synchronization features to the ChronoTrack API, including the ability to preview and push participant data to ChronoTrack. A new push panel is added to the admin interface, allowing users to view eligible participants and initiate synchronization. Additionally, backend functions are implemented to handle synchronization requests, enhancing the overall data management capabilities of the API. These changes aim to streamline the process of updating participant information within the ChronoTrack system.
2026-07-03 09:10:57 -04:00

416 lines
13 KiB
PHP

<?php
/**
* MSIN API ChronoTrack — Phase 2a : lecture MS1, diff, push manuel entries.
*/
define('MSIN_API_CHRONOTRACK_SYNC_BATCH_SIZE', 25);
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 fxChronotrackApiSyncBuildEntryPayload(array $arrRow, array $arrRaceMap) {
$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'] ?? '')));
$arrEntry = array(
'external_id' => (string)$intExternalId,
'race_id' => $intCtRaceId,
'first_name' => trim((string)($arrRow['par_prenom'] ?? '')),
'last_name' => trim((string)($arrRow['par_nom'] ?? '')),
'sex' => fxChronotrackApiSyncNormalizeSex($arrRow['par_sexe'] ?? ''),
'entry_status' => fxChronotrackApiSyncMapEntryStatus(
$arrRow['par_statut_course'] ?? '',
intval($arrRow['is_cancelled'] ?? 0) === 1
),
);
$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 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 ($arrConfig['sync_status'] === MSIN_API_CHRONOTRACK_SYNC_FERME) {
return array('state' => 'error', 'message' => 'Sync fermée pour cet événement');
}
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);
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);
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 = fxChronotrackApiOAuthApiPost(
'event/' . $intCtEventId . '/entry',
array('event_entry' => $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';
$intErr += count($tabSlice);
$tabErrors[] = $strErrMsg;
foreach ($tabSliceMeta as $arrM) {
fxChronotrackApiSyncLog(
$intEveId,
$arrM['par_id'],
'push_entry',
'error',
'external_id=' . $arrM['external_id'] . ' — ' . $strErrMsg
);
}
}
$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, 5),
'preview' => $arrPreview,
);
}