Files
ms1inscription-v5/php/chronotrack_api/fx_chronotrack_sync.php

1835 lines
64 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* MSIN API ChronoTrack — Phase 2a : lecture MS1, diff, push manuel entries.
*/
// Lot POST ChronoTrack — taille prudente jusquà validation par « Sonde lots » (MSIN-4328).
define('MSIN_API_CHRONOTRACK_SYNC_BATCH_SIZE', 100);
define('MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE', 100);
/**
* MSIN-4328 — persiste ct_entry_id sur resultats_participants.
* Important : figé par_maj (sinon ON UPDATE CURRENT_TIMESTAMP → faux différentiel).
*/
function fxChronotrackApiSyncSaveCtEntryId($intParId, $strCtEntryId) {
global $objDatabase;
$intParId = intval($intParId);
$strCtEntryId = preg_replace('/[^0-9]/', '', (string)$strCtEntryId);
if ($intParId <= 0 || $strCtEntryId === '') {
return false;
}
// par_maj = par_maj : empêche MySQL davancer le timestamp auto sur cette ligne
$sql = "UPDATE resultats_participants SET ct_entry_id = " . intval($strCtEntryId)
. ", par_maj = par_maj"
. " WHERE par_id = " . $intParId
. " AND (ct_entry_id IS NULL OR ct_entry_id <> " . 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) !== '';
}
function fxChronotrackApiSyncExtractBib(array $arrRow) {
$strBib = trim((string)($arrRow['no_bib'] ?? ''));
if ($strBib === '' || $strBib === '0') {
return '';
}
return $strBib;
}
function fxChronotrackApiSyncParticipantLabel(array $arrRow) {
return trim(trim((string)($arrRow['par_prenom'] ?? '')) . ' ' . trim((string)($arrRow['par_nom'] ?? '')));
}
/**
* MSIN-4328 — libellé épreuve MS1 (type — nom), pour messages dexclusion.
*/
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-4328 — éligibles dont par_maj > dernier push (ou tous si jamais poussé).
*/
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;
}
}
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('Inscription équipe — transfert individuel seulement (traitement équipe à venir)');
$strTeamName = trim((string)($arrRow['pec_nom_equipe'] ?? $arrRow['par_nom_equipe'] ?? ''));
if ($strTeamName !== '') {
$arrParts[] = '« ' . $strTeamName . ' »';
}
$strNoEquipe = trim((string)($arrRow['no_equipe'] ?? ''));
if ($strNoEquipe !== '' && $strNoEquipe !== '0') {
$arrParts[] = 'no équipe #' . $strNoEquipe;
}
return implode(' · ', $arrParts);
}
function fxChronotrackApiSyncParticipantSummary(array $arrRow) {
return array(
'par_id' => intval($arrRow['par_id'] ?? 0),
'external_id' => (string)intval($arrRow['par_id_original'] ?? 0),
'name' => fxChronotrackApiSyncParticipantLabel($arrRow),
'no_bib' => fxChronotrackApiSyncExtractBib($arrRow),
'par_sexe' => trim((string)($arrRow['par_sexe'] ?? '')),
'pec_id' => intval($arrRow['pec_id'] ?? 0),
'is_team' => fxChronotrackApiSyncIsTeamRow($arrRow),
'team_name' => trim((string)($arrRow['pec_nom_equipe'] ?? $arrRow['par_nom_equipe'] ?? '')),
'no_equipe' => trim((string)($arrRow['no_equipe'] ?? '')),
'role_label' => fxChronotrackApiSyncFormatRoleLabel($arrRow['rol_id'] ?? 0),
);
}
function fxChronotrackApiSyncGroupBlockedByType(array $tabBlocked) {
$tabTypeLabels = array(
'team' => 'Inscriptions équipe',
'duplicate_bib' => 'Dossard en double (individuel)',
'no_bib' => 'Dossard manquant',
'no_sex' => 'Sexe non reconnu',
'no_first_name' => 'Prénom manquant',
'no_last_name' => 'Nom manquant',
'no_race' => 'Épreuve non mappée',
'no_external' => 'External ID manquant',
);
$tabTypeOrder = array(
'team', '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) {
$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;
$strBib = fxChronotrackApiSyncExtractBib($arrRow);
if ($strBib !== '') {
if (!isset($tabBibCounts[$strBib])) {
$tabBibCounts[$strBib] = 0;
}
$tabBibCounts[$strBib]++;
}
}
$tabDuplicateBibs = array();
foreach ($tabBibCounts as $strBib => $intCount) {
if ($intCount > 1) {
$tabDuplicateBibs[$strBib] = true;
}
}
$tabTransferable = array();
$intBlockedTeam = 0;
$intBlockedNoBib = 0;
$intBlockedNoSex = 0;
$intBlockedNoFirstName = 0;
$intBlockedNoLastName = 0;
$intBlockedDuplicateBib = 0;
foreach ($tabCandidates as $arrRow) {
$arrSummary = fxChronotrackApiSyncParticipantSummary($arrRow);
if (fxChronotrackApiSyncIsTeamRow($arrRow)) {
$intBlockedTeam++;
$tabBlocked[] = array(
'code' => 'team',
'message' => fxChronotrackApiSyncFormatTeamBlockMessage($arrRow),
) + $arrSummary;
continue;
}
$strBib = $arrSummary['no_bib'];
if ($strBib !== '' && isset($tabDuplicateBibs[$strBib])) {
$intBlockedDuplicateBib++;
$tabBlocked[] = array(
'code' => 'duplicate_bib',
'message' => 'Dossard #' . $strBib . ' en double — corriger dans MS1',
'bib' => $strBib,
) + $arrSummary;
continue;
}
if ($strBib === '') {
$intBlockedNoBib++;
$tabBlocked[] = array(
'code' => 'no_bib',
'message' => 'Dossard manquant — corriger dans MS1',
) + $arrSummary;
continue;
}
if (!fxChronotrackApiSyncHasValidSex($arrRow['par_sexe'] ?? '')) {
$intBlockedNoSex++;
$strSexeMs1 = trim((string)($arrRow['par_sexe'] ?? ''));
$tabBlocked[] = array(
'code' => 'no_sex',
'message' => 'Sexe « ' . $strSexeMs1 . ' » — mapping CT à définir',
) + $arrSummary;
continue;
}
// 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 = fxChronotrackApiSyncBuildEntryPayload($arrRow, $arrRaceMap, $intCtEventId);
if ($arrPayload === null) {
continue;
}
$tabTransferable[] = array(
'row' => $arrRow,
'payload' => $arrPayload,
'summary' => $arrSummary,
);
}
$intBlockedNoRace = 0;
$intBlockedNoExternal = 0;
foreach ($tabBlocked as $arrItem) {
if (($arrItem['code'] ?? '') === 'no_race') {
$intBlockedNoRace++;
} elseif (($arrItem['code'] ?? '') === 'no_external') {
$intBlockedNoExternal++;
}
}
return array(
'transferable' => $tabTransferable,
'blocked' => $tabBlocked,
'blocked_by_type' => fxChronotrackApiSyncGroupBlockedByType($tabBlocked),
'blocked_count' => count($tabBlocked),
'skipped_no_race' => $intBlockedNoRace,
'skipped_no_external' => $intBlockedNoExternal,
'blocked_no_bib' => $intBlockedNoBib,
'blocked_no_sex' => $intBlockedNoSex,
'blocked_no_first_name' => $intBlockedNoFirstName,
'blocked_no_last_name' => $intBlockedNoLastName,
'blocked_duplicate_bib' => $intBlockedDuplicateBib,
'blocked_team' => $intBlockedTeam,
'duplicate_bib_numbers' => array_keys($tabDuplicateBibs),
// Alias rétrocompat (compteurs)
'anomalies' => $tabBlocked,
'anomaly_no_bib' => $intBlockedNoBib,
'anomaly_no_sex' => $intBlockedNoSex,
'anomaly_duplicate_bib' => $intBlockedDuplicateBib,
);
}
function fxChronotrackApiSyncNormalizeBirthdate($strDob) {
$strDob = trim((string)$strDob);
if ($strDob === '' || $strDob === '0000-00-00') {
return '';
}
$intTs = strtotime($strDob);
if ($intTs === false) {
return '';
}
return date('Y-m-d', $intTs);
}
function fxChronotrackApiSyncLog($intEveId, $intParId, $strAction, $strStatus, $strMessage) {
global $objDatabase;
$sql = "INSERT INTO api_chronotrack_sync_log SET eve_id = " . intval($intEveId)
. ", par_id = " . ($intParId > 0 ? intval($intParId) : 'NULL')
. ", action = '" . $objDatabase->fxEscape(substr(trim($strAction), 0, 32)) . "'"
. ", status = '" . $objDatabase->fxEscape($strStatus === 'ok' ? 'ok' : 'error') . "'"
. ", message = '" . $objDatabase->fxEscape(substr(trim($strMessage), 0, 65000)) . "'"
. ", created_at = '" . fxGetDateTime() . "'";
$objDatabase->fxQuery($sql);
}
/**
* 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.epr_id, p.pec_id, p.rol_id, p.par_equipe, p.par_nom_equipe,"
. " p.par_prenom, p.par_nom, p.par_sexe,"
. " p.par_naissance, p.no_bib, p.par_statut_course, p.par_ville, p.par_adresse, p.par_codepostal,"
. " p.is_cancelled, 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;
}
function fxChronotrackApiSyncResolveRegChoiceIdForRace($intCtEventId, $intCtRaceId) {
static $tabCache = array();
$strKey = intval($intCtEventId) . ':' . intval($intCtRaceId);
if (array_key_exists($strKey, $tabCache)) {
return $tabCache[$strKey];
}
$tabCache[$strKey] = 0;
$arrRaces = fxChronotrackApiFetchRacesForEvent($intCtEventId);
if (($arrRaces['state'] ?? '') !== 'ok' || empty($arrRaces['races'])) {
return 0;
}
foreach ($arrRaces['races'] as $arrRace) {
if (intval($arrRace['ct_race_id'] ?? 0) !== intval($intCtRaceId)) {
continue;
}
$arrRaw = isset($arrRace['raw']) && is_array($arrRace['raw']) ? $arrRace['raw'] : array();
$arrChoices = fxChronotrackApiCollectRegChoiceEntities($arrRaw);
if (count($arrChoices) === 0 && isset($arrRaw['reg_choice_id'])) {
$tabCache[$strKey] = intval($arrRaw['reg_choice_id']);
return $tabCache[$strKey];
}
foreach ($arrChoices as $arrChoice) {
if (!is_array($arrChoice) || empty($arrChoice['reg_choice_id'])) {
continue;
}
$tabCache[$strKey] = intval($arrChoice['reg_choice_id']);
return $tabCache[$strKey];
}
}
return 0;
}
function fxChronotrackApiSyncBuildEntryPayload(array $arrRow, array $arrRaceMap, $intCtEventId = 0) {
$intEprId = intval($arrRow['epr_id'] ?? 0);
$intCtRaceId = intval($arrRaceMap[$intEprId]['ct_race_id'] ?? 0);
if ($intCtRaceId <= 0) {
return null;
}
$intExternalId = intval($arrRow['par_id_original'] ?? 0);
if ($intExternalId <= 0) {
return null;
}
$strCountry = strtoupper(trim((string)($arrRow['country_iso3'] ?? '')));
if ($strCountry === '') {
$strCountry = fxChronotrackApiSyncIso2ToIso3($arrRow['country_iso2'] ?? '');
}
$strState = strtoupper(trim((string)($arrRow['state_iso'] ?? '')));
$strSex = fxChronotrackApiSyncNormalizeSex($arrRow['par_sexe'] ?? '');
if ($strSex === '') {
return null;
}
$strBib = fxChronotrackApiSyncExtractBib($arrRow);
if ($strBib === '') {
return null;
}
$arrEntry = array(
'external_id' => (string)$intExternalId,
'race_id' => $intCtRaceId,
'first_name' => trim((string)($arrRow['par_prenom'] ?? '')),
'last_name' => trim((string)($arrRow['par_nom'] ?? '')),
'sex' => $strSex,
'bib' => $strBib,
);
if ($intCtEventId > 0) {
$arrEntry['event_id'] = $intCtEventId;
}
$intRegChoiceId = fxChronotrackApiSyncResolveRegChoiceIdForRace($intCtEventId, $intCtRaceId);
if ($intRegChoiceId > 0) {
$arrEntry['reg_choice_id'] = $intRegChoiceId;
}
$strDob = fxChronotrackApiSyncNormalizeBirthdate($arrRow['par_naissance'] ?? '');
if ($strDob !== '') {
$arrEntry['birthdate'] = $strDob;
}
$strStreet = trim((string)($arrRow['par_adresse'] ?? ''));
if ($strStreet !== '') {
$arrEntry['street'] = $strStreet;
}
$strCity = trim((string)($arrRow['par_ville'] ?? ''));
if ($strCity !== '') {
$arrEntry['city'] = $strCity;
}
$strPostal = trim((string)($arrRow['par_codepostal'] ?? ''));
if ($strPostal !== '') {
$arrEntry['postal_code'] = $strPostal;
}
if ($strState !== '') {
$arrEntry['state_code'] = $strState;
}
if ($strCountry !== '') {
$arrEntry['country_code'] = $strCountry;
}
$arrEntry['entry_status'] = fxChronotrackApiSyncMapEntryStatus(
$arrRow['par_statut_course'] ?? 'CONF',
intval($arrRow['is_cancelled'] ?? 0) === 1
);
$arrEntry['status'] = $arrEntry['entry_status'];
return $arrEntry;
}
function fxChronotrackApiSyncPayloadForPost(array $arrPayload) {
$arrOut = $arrPayload;
unset($arrOut['entry_id']);
return $arrOut;
}
function fxChronotrackApiSyncPayloadForPut(array $arrPayload) {
$arrOut = $arrPayload;
unset($arrOut['entry_id']);
return $arrOut;
}
function fxChronotrackApiSyncPutEntry($strEntryId, array $arrPayload) {
$strEntryId = trim((string)$strEntryId);
if ($strEntryId === '') {
return array('state' => 'error', 'message' => 'entry_id manquant pour PUT');
}
$arrPut = fxChronotrackApiOAuthApiPut('entry/' . $strEntryId, fxChronotrackApiSyncPayloadForPut($arrPayload));
if ($arrPut['state'] === 'ok') {
$arrPut['method'] = 'PUT';
}
return $arrPut;
}
function fxChronotrackApiSyncPushOneEntry($intCtEventId, array $arrPayload) {
$intCtEventId = intval($intCtEventId);
$strEntryId = trim((string)($arrPayload['entry_id'] ?? ''));
if ($strEntryId !== '') {
$arrPut = fxChronotrackApiSyncPutEntry($strEntryId, $arrPayload);
$arrPut['method'] = 'PUT';
return $arrPut;
}
$arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, array(fxChronotrackApiSyncPayloadForPost($arrPayload)));
if ($arrPost['state'] === 'ok') {
$arrPost['method'] = 'POST';
}
return $arrPost;
}
function fxChronotrackApiSyncEnrichPayloadWithCtEntry(array $arrPayload, $arrCtEntity) {
if (!is_array($arrCtEntity)) {
return $arrPayload;
}
$strEntryId = fxChronotrackApiEntityId($arrCtEntity);
if ($strEntryId !== '') {
$arrPayload['entry_id'] = $strEntryId;
}
return $arrPayload;
}
function fxChronotrackApiSyncPostEntries($intCtEventId, array $tabEntries) {
$intCtEventId = intval($intCtEventId);
if ($intCtEventId <= 0 || count($tabEntries) === 0) {
return array('state' => 'error', 'message' => 'Aucune entry à envoyer');
}
static $strBodyFormat = 'array';
if ($strBodyFormat === 'event_entry') {
$arrPost = fxChronotrackApiOAuthApiPost(
'event/' . $intCtEventId . '/entry',
array('event_entry' => $tabEntries)
);
if ($arrPost['state'] === 'ok') {
$arrPost['body_format'] = 'event_entry';
return $arrPost;
}
return $arrPost;
}
// Doc CT : POST body = tableau JSON d'entités entry (pas de wrapper event_entry).
$arrPost = fxChronotrackApiOAuthApiPost('event/' . $intCtEventId . '/entry', $tabEntries);
if ($arrPost['state'] === 'ok') {
$arrPost['body_format'] = 'array';
$strBodyFormat = 'array';
return $arrPost;
}
// Essai wrapper une seule fois (mémoire du format si succès).
$arrPostWrap = fxChronotrackApiOAuthApiPost(
'event/' . $intCtEventId . '/entry',
array('event_entry' => $tabEntries)
);
if ($arrPostWrap['state'] === 'ok') {
$strBodyFormat = 'event_entry';
$arrPostWrap['body_format'] = 'event_entry';
return $arrPostWrap;
}
$arrPost['body_format'] = 'array';
$arrPost['fallback_error'] = $arrPostWrap['message'] ?? '';
return $arrPost;
}
/**
* POST entries avec découpage dichotomique si le lot échoue (évite 250 appels unitaires).
* MSIN-4328
*/
function fxChronotrackApiSyncPostEntriesAdaptive($intCtEventId, array $tabEntries, array &$tabErrors, $intEveId = 0, array $tabMeta = array()) {
$intCount = count($tabEntries);
if ($intCount === 0) {
return array('ok' => 0, 'err' => 0);
}
$arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, $tabEntries);
if ($arrPost['state'] === 'ok') {
// MSIN-4328 — récupérer entry_id CT pour lien admin
if ($intEveId > 0 && count($tabMeta) > 0) {
fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrPost['json'] ?? null, $tabMeta);
}
return array('ok' => $intCount, 'err' => 0);
}
if ($intCount === 1) {
$arrM = $tabMeta[0] ?? array('par_id' => 0, 'external_id' => '');
$strOneErr = $arrPost['message'] ?? 'Erreur POST entry';
if (count($tabErrors) < 12) {
$tabErrors[] = 'external_id=' . ($arrM['external_id'] ?? '') . ' — ' . $strOneErr;
}
if ($intEveId > 0) {
fxChronotrackApiSyncLog($intEveId, intval($arrM['par_id'] ?? 0), 'push_entry', 'error',
'external_id=' . ($arrM['external_id'] ?? '') . ' — ' . $strOneErr);
}
return array('ok' => 0, 'err' => 1);
}
$intMid = (int)floor($intCount / 2);
$arrLeft = fxChronotrackApiSyncPostEntriesAdaptive(
$intCtEventId,
array_slice($tabEntries, 0, $intMid),
$tabErrors,
$intEveId,
array_slice($tabMeta, 0, $intMid)
);
$arrRight = fxChronotrackApiSyncPostEntriesAdaptive(
$intCtEventId,
array_slice($tabEntries, $intMid),
$tabErrors,
$intEveId,
array_slice($tabMeta, $intMid)
);
return array(
'ok' => $arrLeft['ok'] + $arrRight['ok'],
'err' => $arrLeft['err'] + $arrRight['err'],
);
}
function fxChronotrackApiSyncProbePush($intEveId) {
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
}
$intCtEventId = intval($arrConfig['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId);
if (count($arrClass['transferable']) === 0) {
return array('state' => 'error', 'message' => 'Aucun participant transférable pour la sonde (voir anomalies).');
}
$arrFirst = $arrClass['transferable'][0];
$arrPayload = $arrFirst['payload'];
$arrRow = $arrFirst['row'];
$arrCtFetch = fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId);
if ($arrCtFetch['state'] === 'ok') {
$strExt = $arrPayload['external_id'];
$arrPayload = fxChronotrackApiSyncEnrichPayloadWithCtEntry(
$arrPayload,
$arrCtFetch['entries'][$strExt] ?? null
);
}
$arrPost = fxChronotrackApiSyncPushOneEntry($intCtEventId, $arrPayload);
return array(
'state' => $arrPost['state'],
'message' => $arrPost['message'] ?? '',
'ct_event_id' => $intCtEventId,
'par_id' => intval($arrRow['par_id'] ?? 0),
'payload' => $arrPayload,
'method' => $arrPost['method'] ?? '',
'body_format' => $arrPost['body_format'] ?? '',
'http_code' => intval($arrPost['http']['http_code'] ?? 0),
'response_body' => substr(trim((string)($arrPost['http']['body'] ?? '')), 0, 2000),
'fallback_error'=> $arrPost['fallback_error'] ?? '',
);
}
/**
* 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);
$arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId);
$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'],
'blocked' => $arrClass['blocked'],
'eligible' => $intTransferable,
'skipped_no_race' => $arrClass['skipped_no_race'],
'skipped_no_external' => $arrClass['skipped_no_external'],
'blocked_no_bib' => $arrClass['blocked_no_bib'],
'blocked_no_sex' => $arrClass['blocked_no_sex'],
'blocked_duplicate_bib' => $arrClass['blocked_duplicate_bib'],
'blocked_team' => $arrClass['blocked_team'],
'anomaly_no_bib' => $arrClass['blocked_no_bib'],
'anomaly_no_sex' => $arrClass['blocked_no_sex'],
'anomaly_duplicate_bib' => $arrClass['blocked_duplicate_bib'],
'duplicate_bib_numbers' => $arrClass['duplicate_bib_numbers'],
'anomalies' => $arrClass['blocked'],
'ct_total' => 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 dabord 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.
*/
function fxChronotrackApiSyncPushPrepare($intEveId) {
$intEveId = intval($intEveId);
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
}
if (!fxChronotrackApiSettingsConfigured()) {
return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage());
}
$intCtEventId = intval($arrConfig['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId);
$intSkipLogged = 0;
foreach ($arrClass['blocked'] as $arrBlocked) {
if ($intSkipLogged >= 50) {
break;
}
fxChronotrackApiSyncLog(
$intEveId,
intval($arrBlocked['par_id'] ?? 0),
'push_skip',
'error',
($arrBlocked['code'] ?? 'blocked') . ' — external_id=' . ($arrBlocked['external_id'] ?? '')
. ' — ' . ($arrBlocked['message'] ?? '')
);
$intSkipLogged++;
}
if ($arrClass['blocked_count'] > $intSkipLogged) {
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_skip',
'error',
'… +' . ($arrClass['blocked_count'] - $intSkipLogged) . ' exclus non détaillés'
);
}
// MSIN-4328 — ne pousser que les éligibles modifiés depuis last_push_at
$strLastPushAt = trim((string)($arrConfig['last_push_at'] ?? ''));
$tabPending = fxChronotrackApiSyncFilterChangedSincePush($arrClass['transferable'], $strLastPushAt);
// Plus de scan CT à la préparation : POST upsert (crée ou maj) sans entry_id.
$tabBatch = array();
$tabMeta = array();
foreach ($tabPending as $arrItem) {
$tabBatch[] = fxChronotrackApiSyncPayloadForPost($arrItem['payload']);
$tabMeta[] = array(
'par_id' => intval($arrItem['row']['par_id'] ?? 0),
'external_id' => $arrItem['payload']['external_id'],
);
}
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).');
return array(
'state' => 'error',
'message' => $strMsg,
'blocked_count' => $arrClass['blocked_count'],
'transferable' => count($arrClass['transferable']),
'pending_push' => 0,
'total' => 0,
);
}
$arrJob = array(
'ct_event_id' => $intCtEventId,
'batch' => $tabBatch,
'meta' => $tabMeta,
'blocked_count' => $arrClass['blocked_count'],
'ok_sum' => 0,
'err_sum' => 0,
'created_at' => time(),
);
if (!fxChronotrackApiSyncPushJobSave($intEveId, $arrJob)) {
return array(
'state' => 'error',
'message' => 'Impossible décrire le fichier temporaire du push (permissions /tmp).',
);
}
return array(
'state' => 'ok',
'message' => count($tabBatch) . ' prêts à envoyer',
'total' => count($tabBatch),
'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,
);
}
/**
* Push un slice du job préparé. MSIN-4328.
*/
function fxChronotrackApiSyncPushEvent($intEveId, $arrOptions = array()) {
$intEveId = intval($intEveId);
$intOffset = max(0, intval($arrOptions['offset'] ?? 0));
$intLimit = max(0, intval($arrOptions['limit'] ?? 0));
if ($intLimit <= 0) {
$intLimit = MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE;
}
$arrJob = fxChronotrackApiSyncPushJobLoad($intEveId);
if (!is_array($arrJob) || empty($arrJob['batch'])) {
$arrPrep = fxChronotrackApiSyncPushPrepare($intEveId);
if ($arrPrep['state'] !== 'ok') {
return $arrPrep;
}
$arrJob = fxChronotrackApiSyncPushJobLoad($intEveId);
}
if (!is_array($arrJob) || empty($arrJob['batch'])) {
return array('state' => 'error', 'message' => 'Job de push introuvable — relancer');
}
$intCtEventId = intval($arrJob['ct_event_id']);
$tabBatchAll = $arrJob['batch'];
$tabMetaAll = $arrJob['meta'];
$intTotal = count($tabBatchAll);
$intSkipped = intval($arrJob['blocked_count'] ?? 0);
$tabBatch = array_slice($tabBatchAll, $intOffset, $intLimit);
$tabMeta = array_slice($tabMetaAll, $intOffset, $intLimit);
$intChunkCount = count($tabBatch);
if ($intChunkCount === 0) {
fxChronotrackApiSyncPushJobClear($intEveId);
return array(
'state' => 'ok',
'message' => 'Rien à envoyer à cet offset',
'pushed_ok' => 0,
'pushed_put' => 0,
'pushed_post' => 0,
'pushed_error' => 0,
'total' => $intTotal,
'offset' => $intOffset,
'next_offset' => $intOffset,
'chunk_done' => 0,
'done' => true,
'blocked_count' => $intSkipped,
);
}
$intOk = 0;
$intErr = 0;
$intPutOk = 0;
$intPostOk = 0;
$tabErrors = array();
$tabCreates = array();
$tabCreatesMeta = array();
$tabUpdates = array();
$tabUpdatesMeta = array();
foreach ($tabBatch as $intIdx => $arrPayload) {
$arrMeta = $tabMeta[$intIdx];
if (trim((string)($arrPayload['entry_id'] ?? '')) !== '') {
$tabUpdates[] = $arrPayload;
$tabUpdatesMeta[] = $arrMeta;
} else {
$tabCreates[] = fxChronotrackApiSyncPayloadForPost($arrPayload);
$tabCreatesMeta[] = $arrMeta;
}
}
// Créations + maj via POST lot (upsert) — découpage dichotomique si échec.
if (count($tabCreates) > 0) {
$arrRes = fxChronotrackApiSyncPostEntriesAdaptive(
$intCtEventId,
$tabCreates,
$tabErrors,
$intEveId,
$tabCreatesMeta
);
$intOk += $arrRes['ok'];
$intPostOk += $arrRes['ok'];
$intErr += $arrRes['err'];
}
if (count($tabUpdates) > 0) {
$tabUpdatesForPost = array();
foreach ($tabUpdates as $arrPayload) {
$tabUpdatesForPost[] = fxChronotrackApiSyncPayloadForPost($arrPayload);
}
$arrRes = fxChronotrackApiSyncPostEntriesAdaptive(
$intCtEventId,
$tabUpdatesForPost,
$tabErrors,
$intEveId,
$tabUpdatesMeta
);
$intOk += $arrRes['ok'];
$intPostOk += $arrRes['ok'];
$intErr += $arrRes['err'];
}
// Si des MAJ restent en erreur (POST na pas pris le statut), tenter PUT unitaire
// uniquement pour ce petit sous-ensemble déjà en erreur — trop coûteux de re-PUT tout.
// Les erreurs sont déjà dans $tabErrors / logs.
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_chunk',
($intErr === 0) ? 'ok' : 'error',
'offset=' . $intOffset . ' n=' . $intChunkCount
. ' ok=' . $intOk . ' err=' . $intErr
. ' POST=' . $intPostOk . ' PUT=' . $intPutOk
);
$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;
if ($blnDone) {
// MSIN-4328 — ne figer last_push_at que si tout le job a réussi
if ($intErrSum === 0) {
$blnLastPushSaved = fxChronotrackApiConfigSetLastPush($intEveId, $intOkSum);
}
fxChronotrackApiSyncPushJobClear($intEveId);
} else {
fxChronotrackApiSyncPushJobSave($intEveId, $arrJob);
}
$strState = ($intErr === 0) ? 'ok' : (($intOk > 0) ? 'partial' : 'error');
$strMessage = ($intErr === 0)
? ($intOk . ' envoyé(s) (offset ' . $intOffset . ').')
: ($intOk . ' OK, ' . $intErr . ' erreur(s) sur ce lot.');
return array(
'state' => $strState,
'message' => $strMessage,
'pushed_ok' => $intOk,
'pushed_put' => $intPutOk,
'pushed_post' => $intPostOk,
'pushed_error' => $intErr,
'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.
*/
function fxChronotrackApiSyncRunAutoCron() {
if (!fxChronotrackApiSettingsConfigured()) {
return array(
'state' => 'error',
'message' => fxChronotrackApiSettingsErrorMessage(),
'ran' => 0,
'skipped' => 0,
);
}
$tabConfigs = fxChronotrackApiConfigListAutoSyncEnabled();
$intRan = 0;
$intSkipped = 0;
$intExpired = 0;
$tabDetails = array();
$intNow = time();
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 à lenregistrement)
$intSkipped++;
$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++;
$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),
);
}
return array(
'state' => 'ok',
'message' => 'cron auto — ran=' . $intRan . ' skipped=' . $intSkipped . ' expired=' . $intExpired,
'ran' => $intRan,
'skipped' => $intSkipped,
'expired' => $intExpired,
'details' => $tabDetails,
);
}