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

2313 lines
84 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-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 (nincré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) {
$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 = 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 = 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.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;
}
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 lentry nest pas confirmed.
// Un simple DQ (ou autre statut) ne doit pas renvoyer le dossard.
// Bib envoyé seulement à la 1re synchro (pas encore de ct_entry_id) + statut CONF.
$blnLinkedCt = (intval($arrRow['ct_entry_id'] ?? 0) > 0);
if (!$blnLinkedCt && $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;
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;
}
/**
* POST entries avec sous-lots fixes si le lot échoue (évite explosion dappels).
* 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);
// MSIN-4328 — log précis de chaque envoi réussi (contrôle / audit)
foreach ($tabMeta as $arrM) {
if (!is_array($arrM)) {
continue;
}
$strMsg = 'external_id=' . ($arrM['external_id'] ?? '')
. ' bib=' . ($arrM['bib'] ?? '')
. ' ' . trim((string)($arrM['name'] ?? ''));
fxChronotrackApiSyncLog(
$intEveId,
intval($arrM['par_id'] ?? 0),
'push_entry',
'ok',
trim($strMsg)
);
}
}
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);
}
// Sous-lots fixes : 25 → 5 → 1 (beaucoup moins dappels quune dichotomie pure)
$intSubSize = ($intCount > 25) ? 25 : (($intCount > 5) ? 5 : 1);
$intOk = 0;
$intErr = 0;
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'];
}
return array(
'ok' => $intOk,
'err' => $intErr,
);
}
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-4328 — sonde « custom field » : tente comme un import Custom
* (POST entry avec custom_Ms1Probe*) + lit lentry, + teste endpoints question.
*/
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 custom.');
}
// Préférer une entry déjà liée CT (maj légère, pas de création complète)
$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 = 'custom_Ms1Probe';
$strValue = 'ms1-probe-' . date('YmdHis');
$tabQuestionEndpoints = array();
foreach (array('question', 'custom_question', 'survey_question', 'reg_question') as $strEp) {
$arrG = fxChronotrackApiOAuthApiGet('event/' . $intCtEventId . '/' . $strEp);
$tabQuestionEndpoints[] = array(
'path' => 'event/' . $intCtEventId . '/' . $strEp,
'http_code' => intval($arrG['http']['http_code'] ?? 0),
'state' => $arrG['state'] ?? 'error',
'message' => isset($arrG['message']) ? substr((string)$arrG['message'], 0, 200) : '',
);
}
// Corps minimal : identifier + champ custom (mimique import « Custom »)
$arrProbePayload = array(
'external_id' => $strExternalId,
$strFieldKey => $strValue,
);
if ($intCtEntryId > 0) {
// Certaines maj CT acceptent lid numérique
$arrProbePayload['entry_id'] = (string)$intCtEntryId;
}
$arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, array($arrProbePayload));
$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'];
// CT peut wrapper { entry: {...} } ou renvoyer lobjet direct
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];
}
if (array_key_exists($strFieldKey, $arrReadback)) {
$blnFieldPresent = true;
$mixReadbackValue = $arrReadback[$strFieldKey];
}
}
}
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;
if (array_key_exists($strFieldKey, $arrEnt)) {
$blnFieldPresent = true;
$mixReadbackValue = $arrEnt[$strFieldKey];
}
break;
}
}
$strVerdict = 'POST ok mais champ absent au relecture (CT na pas créé le custom comme à limport).';
$strState = 'error';
if (($arrPost['state'] ?? '') !== 'ok') {
$strVerdict = 'POST refusé — ' . (string)($arrPost['message'] ?? 'erreur');
$strState = 'error';
} elseif ($blnFieldPresent && (string)$mixReadbackValue === $strValue) {
$strVerdict = 'OK — CT a accepté et conservé ' . $strFieldKey . ' (création / slot custom plausible).';
$strState = 'ok';
} elseif ($blnFieldPresent) {
$strVerdict = 'POST ok, champ présent mais valeur différente: '
. json_encode($mixReadbackValue, JSON_UNESCAPED_UNICODE);
$strState = 'partial';
}
fxChronotrackApiSyncLog(
$intEveId,
intval($arrRow['par_id'] ?? 0),
'probe_custom',
($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,
'field_key' => $strFieldKey,
'field_value_sent' => $strValue,
'field_present' => $blnFieldPresent,
'field_value_read' => $mixReadbackValue,
'payload' => $arrProbePayload,
'post_http_code' => intval($arrPost['http']['http_code'] ?? 0),
'post_response' => substr(trim((string)($arrPost['http']['body'] ?? '')), 0, 1500),
'readback_how' => $strReadbackHow,
'question_endpoints' => $tabQuestionEndpoints,
);
}
/**
* 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.
*
* @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']);
$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 lUI « 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);
}
// Plus de scan CT à la préparation : POST upsert (crée ou maj) sans entry_id.
$tabBatch = array();
$tabMeta = array();
foreach ($tabPending as $arrItem) {
$arrPayload = fxChronotrackApiSyncPayloadForPost($arrItem['payload']);
$tabBatch[] = $arrPayload;
$tabMeta[] = array(
'par_id' => intval($arrItem['row']['par_id'] ?? 0),
'external_id' => $arrPayload['external_id'] ?? '',
'bib' => isset($arrPayload['bib']) ? (string)$arrPayload['bib'] : '',
'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,
'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'
. ($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.
*/
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.
* 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 à lenregistrement)
$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 dintervalle (é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 dessai-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 dintervalle (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,
),
);
}