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

2923 lines
108 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 — même découpage que le bouton « Envoyer » (barre %).
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) !== '';
}
/**
* MSIN-4328 / MSIN-4444 — dossard ChronoTrack = toujours p.no_bib.
* Jamais ec.no_equipe (n° déquipe MS1) : en mode « 1 équipe = 1 dossard »
* les deux coïncident souvent au 1er assign, puis no_bib peut diverger (ex. 535 vs 507).
*/
function fxChronotrackApiSyncExtractBib(array $arrRow) {
$strBib = trim((string)($arrRow['no_bib'] ?? ''));
if ($strBib === '' || $strBib === '0') {
return '';
}
// Aligné affichage MS1 (fxShowBibNumber) : enlever préfixe epr_id-
$intEprId = intval($arrRow['epr_id'] ?? 0);
if ($intEprId > 0) {
$strBib = str_replace($intEprId . '-', '', $strBib);
}
$strBib = str_replace('-', '', $strBib);
$strBib = trim($strBib);
if ($strBib === '' || $strBib === '0') {
return '';
}
return $strBib;
}
/**
* MSIN-4328 — conflit CT « tags / bib already assigned » (swap dossards).
*/
function fxChronotrackApiSyncIsBibConflictMessage($strMessage) {
$str = strtolower((string)$strMessage);
if ($str === '') {
return false;
}
if (strpos($str, 'already assigned') !== false) {
return true;
}
if (strpos($str, 'tags are already') !== false) {
return true;
}
if (strpos($str, 'bib') !== false && strpos($str, 'already') !== false) {
return true;
}
return false;
}
function fxChronotrackApiSyncPayloadWithoutBib(array $arrPayload) {
$arrOut = $arrPayload;
unset($arrOut['bib']);
return $arrOut;
}
/**
* MSIN-4328 — libérer le dossard CT (swap) : bib vide.
*/
function fxChronotrackApiSyncPayloadClearBib(array $arrPayload) {
$arrOut = $arrPayload;
$arrOut['bib'] = '';
return $arrOut;
}
function fxChronotrackApiSyncParticipantLabel(array $arrRow) {
return trim(trim((string)($arrRow['par_prenom'] ?? '')) . ' ' . trim((string)($arrRow['par_nom'] ?? '')));
}
/**
* MSIN-4328 — libellé épreuve MS1 (type — nom), pour messages 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.eve_id, p.epr_id, p.pec_id, p.rol_id, p.par_equipe, p.par_nom_equipe,"
. " p.par_prenom, p.par_nom, p.par_sexe,"
. " p.par_naissance, p.no_bib, p.no_bib_remis, p.no_bib_remis_date, p.par_date_bib, p.par_statut_course,"
. " p.par_ville, p.par_adresse, p.par_codepostal,"
. " p.is_cancelled, p.par_maj, p.ct_entry_id,"
. " ec.pec_equipe, ec.no_equipe, ec.pec_nom_equipe,"
. " ie.epr_nom_fr AS epr_nom, ie.epr_type_fr AS epr_type,"
. " (SELECT pro_iso FROM inscriptions_provinces WHERE pro_id = p.pro_id) AS state_iso,"
. $strCountryCols
. " FROM resultats_participants p"
. " JOIN resultats_epreuves_commandees ec ON p.pec_id = ec.pec_id_original"
. " LEFT JOIN inscriptions_epreuves ie ON ie.epr_id = p.epr_id"
. " WHERE ec.pec_actif = 1 AND ec.is_cancelled = 0 AND p.is_cancelled = 0"
. " AND p.eve_id = " . $intEveId
. " ORDER BY p.par_id_original, p.par_id";
$tabRows = $objDatabase->fxGetResults($sql);
if (!is_array($tabRows)) {
return array();
}
$tabOut = array();
for ($i = 1; $i <= count($tabRows); $i++) {
$tabOut[] = $tabRows[$i];
}
return $tabOut;
}
/**
* MSIN-4461 — clé CT entry_note_{label} (support ChronoTrack).
*/
function fxChronotrackApiSyncEntryNoteKey($strLabel, $intQueId) {
$strLabel = trim((string)$strLabel);
$strLabel = preg_replace('/\s+/u', '_', $strLabel);
$strLabel = preg_replace('/["\\\\\x00-\x1F]+/u', '', $strLabel);
$strLabel = trim($strLabel, '_');
if ($strLabel === '') {
$strLabel = 'q' . intval($intQueId);
}
return 'entry_note_' . $strLabel;
}
/**
* MSIN-4461 — index des réponses questions dun événement (1 requête / eve_id).
*
* Périmètre actuel : TOUTES les questions actives de lévénement (pas seulement que_rapport).
* Si le payload devient trop lourd (beaucoup de questions / événements volumineux),
* restreindre ici (ex. que_rapport = 1, liste blanche, ou exclusion des validations techniques).
*
* @return array<int, array<int, array{par_id:int,que_id:int,label:string,value:string}>>
* indexé par pec_id
*/
function fxChronotrackApiSyncLoadMs1QuestionsIndex($intEveId) {
global $objDatabase;
static $tabCache = array();
$intEveId = intval($intEveId);
if ($intEveId <= 0) {
return array();
}
if (isset($tabCache[$intEveId])) {
return $tabCache[$intEveId];
}
$sql = "SELECT rq.pec_id, rq.par_id, rq.que_id,"
. " TRIM(COALESCE("
. " NULLIF(iq.que_rapport_label_fr, ''),"
. " NULLIF(rq.que_question_fr, ''),"
. " NULLIF(iq.que_question_fr, ''),"
. " '')) AS note_label,"
. " TRIM(COALESCE("
. " NULLIF(rq.que_choix_fr, ''),"
. " NULLIF(rq.pqu_note, ''),"
. " NULLIF(rq.que_choix_en, ''),"
. " '')) AS note_value"
. " FROM resultats_questions rq"
. " INNER JOIN inscriptions_questions iq ON iq.que_id = rq.que_id AND iq.eve_id = " . $intEveId
. " WHERE rq.que_actif = 1"
. " ORDER BY rq.pec_id, rq.que_id";
$tabRows = $objDatabase->fxGetResults($sql);
$tabIndex = array();
if (is_array($tabRows)) {
for ($i = 1; $i <= count($tabRows); $i++) {
$arrQ = $tabRows[$i];
$intPecId = intval($arrQ['pec_id'] ?? 0);
$strValue = trim((string)($arrQ['note_value'] ?? ''));
if ($intPecId <= 0 || $strValue === '') {
continue;
}
if (!isset($tabIndex[$intPecId])) {
$tabIndex[$intPecId] = array();
}
$tabIndex[$intPecId][] = array(
'par_id' => intval($arrQ['par_id'] ?? 0),
'que_id' => intval($arrQ['que_id'] ?? 0),
'label' => trim((string)($arrQ['note_label'] ?? '')),
'value' => $strValue,
);
}
}
$tabCache[$intEveId] = $tabIndex;
return $tabIndex;
}
/**
* MSIN-4461 — map entry_note_* pour un participant (réponses épreuve + participant).
* resultats_questions.par_id = par_id_original (legacy) ; par_id 0 = question niveau épreuve.
*/
function fxChronotrackApiSyncEntryNotesForParticipant(array $arrRow) {
$intEveId = intval($arrRow['eve_id'] ?? 0);
$intPecId = intval($arrRow['pec_id'] ?? 0);
if ($intEveId <= 0 || $intPecId <= 0) {
return array();
}
$tabIndex = fxChronotrackApiSyncLoadMs1QuestionsIndex($intEveId);
if (!isset($tabIndex[$intPecId]) || !is_array($tabIndex[$intPecId])) {
return array();
}
$intParOrig = intval($arrRow['par_id_original'] ?? 0);
$intParId = intval($arrRow['par_id'] ?? 0);
$tabNotes = array();
$tabUsedKeys = array();
foreach ($tabIndex[$intPecId] as $arrQ) {
$intQPar = intval($arrQ['par_id'] ?? 0);
// Question dépreuve (par_id vide) ou réponse de ce participant
if ($intQPar > 0 && $intQPar !== $intParOrig && $intQPar !== $intParId) {
continue;
}
$intQueId = intval($arrQ['que_id'] ?? 0);
$strKey = fxChronotrackApiSyncEntryNoteKey($arrQ['label'] ?? '', $intQueId);
if (isset($tabUsedKeys[$strKey])) {
$strKey = 'entry_note_q' . $intQueId;
}
$tabUsedKeys[$strKey] = true;
$tabNotes[$strKey] = (string)$arrQ['value'];
}
return $tabNotes;
}
function fxChronotrackApiSyncResolveRegChoiceIdForRace($intCtEventId, $intCtRaceId) {
static $tabCache = array();
$strKey = intval($intCtEventId) . ':' . intval($intCtRaceId);
if (array_key_exists($strKey, $tabCache)) {
return $tabCache[$strKey];
}
$tabCache[$strKey] = 0;
$arrRaces = fxChronotrackApiFetchRacesForEvent($intCtEventId);
if (($arrRaces['state'] ?? '') !== 'ok' || empty($arrRaces['races'])) {
return 0;
}
foreach ($arrRaces['races'] as $arrRace) {
if (intval($arrRace['ct_race_id'] ?? 0) !== intval($intCtRaceId)) {
continue;
}
$arrRaw = isset($arrRace['raw']) && is_array($arrRace['raw']) ? $arrRace['raw'] : array();
$arrChoices = fxChronotrackApiCollectRegChoiceEntities($arrRaw);
if (count($arrChoices) === 0 && isset($arrRaw['reg_choice_id'])) {
$tabCache[$strKey] = intval($arrRaw['reg_choice_id']);
return $tabCache[$strKey];
}
foreach ($arrChoices as $arrChoice) {
if (!is_array($arrChoice) || empty($arrChoice['reg_choice_id'])) {
continue;
}
$tabCache[$strKey] = intval($arrChoice['reg_choice_id']);
return $tabCache[$strKey];
}
}
return 0;
}
function fxChronotrackApiSyncBuildEntryPayload(array $arrRow, array $arrRaceMap, $intCtEventId = 0) {
$intEprId = intval($arrRow['epr_id'] ?? 0);
$intCtRaceId = intval($arrRaceMap[$intEprId]['ct_race_id'] ?? 0);
if ($intCtRaceId <= 0) {
return null;
}
$intExternalId = intval($arrRow['par_id_original'] ?? 0);
if ($intExternalId <= 0) {
return null;
}
$strCountry = strtoupper(trim((string)($arrRow['country_iso3'] ?? '')));
if ($strCountry === '') {
$strCountry = fxChronotrackApiSyncIso2ToIso3($arrRow['country_iso2'] ?? '');
}
$strState = strtoupper(trim((string)($arrRow['state_iso'] ?? '')));
$strSex = fxChronotrackApiSyncNormalizeSex($arrRow['par_sexe'] ?? '');
if ($strSex === '') {
return null;
}
$strBib = fxChronotrackApiSyncExtractBib($arrRow);
if ($strBib === '') {
return null;
}
$arrEntry = array(
'external_id' => (string)$intExternalId,
'race_id' => $intCtRaceId,
'first_name' => trim((string)($arrRow['par_prenom'] ?? '')),
'last_name' => trim((string)($arrRow['par_nom'] ?? '')),
'sex' => $strSex,
);
// MSIN-4444 — inscription équipe : prénom = EQ., nom = nom d'équipe
if (fxChronotrackApiSyncIsTeamRow($arrRow)) {
$strTeamName = fxChronotrackApiSyncTeamName($arrRow);
if ($strTeamName === '') {
return null;
}
$arrEntry['first_name'] = 'EQ.';
$arrEntry['last_name'] = $strTeamName;
}
if ($intCtEventId > 0) {
$arrEntry['event_id'] = $intCtEventId;
}
$intRegChoiceId = fxChronotrackApiSyncResolveRegChoiceIdForRace($intCtEventId, $intCtRaceId);
if ($intRegChoiceId > 0) {
$arrEntry['reg_choice_id'] = $intRegChoiceId;
}
$strDob = fxChronotrackApiSyncNormalizeBirthdate($arrRow['par_naissance'] ?? '');
if ($strDob !== '') {
$arrEntry['birthdate'] = $strDob;
}
$strStreet = trim((string)($arrRow['par_adresse'] ?? ''));
if ($strStreet !== '') {
$arrEntry['street'] = $strStreet;
}
$strCity = trim((string)($arrRow['par_ville'] ?? ''));
if ($strCity !== '') {
$arrEntry['city'] = $strCity;
}
$strPostal = trim((string)($arrRow['par_codepostal'] ?? ''));
if ($strPostal !== '') {
$arrEntry['postal_code'] = $strPostal;
}
if ($strState !== '') {
$arrEntry['state_code'] = $strState;
}
if ($strCountry !== '') {
$arrEntry['country_code'] = $strCountry;
}
$arrEntry['entry_status'] = fxChronotrackApiSyncMapEntryStatus(
$arrRow['par_statut_course'] ?? 'CONF',
intval($arrRow['is_cancelled'] ?? 0) === 1
);
$arrEntry['status'] = $arrEntry['entry_status'];
// MSIN-4328 — CT refuse « assign a bib » si lentry nest pas confirmed.
// Envoyer le dossard dès que CONF (création ET maj) — sinon un changement
// de dossard MS1 sur une entry déjà liée narrive jamais dans ChronoTrack.
if ($strBib !== '' && $arrEntry['entry_status'] === 'CONF') {
$arrEntry['bib'] = $strBib;
}
// MSIN-4328 — Dossard récupéré (MS1) → Check In Status CT (entry_check_in = "1" / null)
$arrEntry['entry_check_in'] = (intval($arrRow['no_bib_remis'] ?? 0) === 1) ? '1' : null;
// MSIN-4461 — questions MS1 → notes CT (entry_note_{label}).
// Toutes les réponses actives pour linstant ; filtrer plus tard si trop lourd (voir LoadMs1QuestionsIndex).
foreach (fxChronotrackApiSyncEntryNotesForParticipant($arrRow) as $strNoteKey => $strNoteValue) {
$arrEntry[$strNoteKey] = $strNoteValue;
}
return $arrEntry;
}
function fxChronotrackApiSyncPayloadForPost(array $arrPayload) {
$arrOut = $arrPayload;
unset($arrOut['entry_id']);
return $arrOut;
}
function fxChronotrackApiSyncPayloadForPut(array $arrPayload) {
$arrOut = $arrPayload;
unset($arrOut['entry_id']);
return $arrOut;
}
function fxChronotrackApiSyncPutEntry($strEntryId, array $arrPayload) {
$strEntryId = trim((string)$strEntryId);
if ($strEntryId === '') {
return array('state' => 'error', 'message' => 'entry_id manquant pour PUT');
}
$arrPut = fxChronotrackApiOAuthApiPut('entry/' . $strEntryId, fxChronotrackApiSyncPayloadForPut($arrPayload));
if ($arrPut['state'] === 'ok') {
$arrPut['method'] = 'PUT';
}
return $arrPut;
}
function fxChronotrackApiSyncPushOneEntry($intCtEventId, array $arrPayload) {
$intCtEventId = intval($intCtEventId);
$strEntryId = trim((string)($arrPayload['entry_id'] ?? ''));
if ($strEntryId !== '') {
$arrPut = fxChronotrackApiSyncPutEntry($strEntryId, $arrPayload);
$arrPut['method'] = 'PUT';
return $arrPut;
}
$arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, array(fxChronotrackApiSyncPayloadForPost($arrPayload)));
if ($arrPost['state'] === 'ok') {
$arrPost['method'] = 'POST';
}
return $arrPost;
}
function fxChronotrackApiSyncEnrichPayloadWithCtEntry(array $arrPayload, $arrCtEntity) {
if (!is_array($arrCtEntity)) {
return $arrPayload;
}
$strEntryId = fxChronotrackApiEntityId($arrCtEntity);
if ($strEntryId !== '') {
$arrPayload['entry_id'] = $strEntryId;
}
return $arrPayload;
}
function fxChronotrackApiSyncPostEntries($intCtEventId, array $tabEntries) {
$intCtEventId = intval($intCtEventId);
if ($intCtEventId <= 0 || count($tabEntries) === 0) {
return array('state' => 'error', 'message' => 'Aucune entry à envoyer');
}
// MSIN-4328 — mémoriser le format qui marche ; ne pas retenter le wrapper à chaque sous-lot
static $strBodyFormat = 'array';
static $blnWrapperTried = false;
if ($strBodyFormat === 'event_entry') {
$arrPost = fxChronotrackApiOAuthApiPost(
'event/' . $intCtEventId . '/entry',
array('event_entry' => $tabEntries)
);
if ($arrPost['state'] === 'ok') {
$arrPost['body_format'] = 'event_entry';
return $arrPost;
}
return $arrPost;
}
// Doc CT : POST body = tableau JSON d'entités entry (pas de wrapper event_entry).
$arrPost = fxChronotrackApiOAuthApiPost('event/' . $intCtEventId . '/entry', $tabEntries);
if ($arrPost['state'] === 'ok') {
$arrPost['body_format'] = 'array';
$strBodyFormat = 'array';
return $arrPost;
}
// Un seul essai wrapper pour toute la requête PHP (pas à chaque dichotomie)
if (!$blnWrapperTried) {
$blnWrapperTried = true;
$arrPostWrap = fxChronotrackApiOAuthApiPost(
'event/' . $intCtEventId . '/entry',
array('event_entry' => $tabEntries)
);
if ($arrPostWrap['state'] === 'ok') {
$strBodyFormat = 'event_entry';
$arrPostWrap['body_format'] = 'event_entry';
return $arrPostWrap;
}
$arrPost['fallback_error'] = $arrPostWrap['message'] ?? '';
}
$arrPost['body_format'] = 'array';
return $arrPost;
}
/**
* MSIN-4328 — logs push_entry un-pour-un (défaut = off, résumé lots seulement).
* @param bool|null $blnSet null = lire, bool = fixer pour la requête courante
*/
function fxChronotrackApiSyncVerboseEntryLogs($blnSet = null) {
static $blnVerbose = false;
if ($blnSet !== null) {
$blnVerbose = (bool)$blnSet;
}
return $blnVerbose;
}
/**
* MSIN-4328 — log push_entry (succès). Uniquement si logs détaillés cochés.
*/
function fxChronotrackApiSyncLogPushEntryOk($intEveId, array $arrM, array $arrPayload, $strPrefix = '') {
$intEveId = intval($intEveId);
if ($intEveId <= 0 || !fxChronotrackApiSyncVerboseEntryLogs()) {
return;
}
$strBibMs1 = trim((string)($arrM['bib_ms1'] ?? $arrM['bib'] ?? ''));
$strBibSent = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : '';
if ($strBibSent === '') {
$strBibSent = 'non';
}
$strNoEquipe = trim((string)($arrM['no_equipe'] ?? ''));
$strMsg = ($strPrefix !== '' ? ($strPrefix . ' ') : '')
. 'external_id=' . ($arrM['external_id'] ?? '')
. ' dossard=' . ($strBibMs1 !== '' ? $strBibMs1 : '—')
. ' envoi_bib=' . $strBibSent
. ($strNoEquipe !== '' && $strNoEquipe !== '0' ? (' no_equipe=' . $strNoEquipe) : '')
. ' nom=' . trim((string)($arrM['name'] ?? ''));
fxChronotrackApiSyncLog($intEveId, intval($arrM['par_id'] ?? 0), 'push_entry', 'ok', trim($strMsg));
}
/**
* MSIN-4328 — log push_entry détail (conflit bib, etc.) si mode verbose.
*/
function fxChronotrackApiSyncLogPushEntryDetail($intEveId, $intParId, $strStatus, $strMessage) {
$intEveId = intval($intEveId);
if ($intEveId <= 0 || !fxChronotrackApiSyncVerboseEntryLogs()) {
return;
}
fxChronotrackApiSyncLog($intEveId, intval($intParId), 'push_entry', $strStatus, $strMessage);
}
/**
* MSIN-4328 — 1 entry : tenter avec bib ; si conflit CT → clear + file pour 2e passe.
*
* @return array{ok:int,err:int,bib_retry:array,method:string}
*/
function fxChronotrackApiSyncPushOneEntryWithBibConflictHandling(
$intCtEventId,
array $arrPayload,
array $arrMeta,
$intEveId = 0
) {
$intCtEventId = intval($intCtEventId);
$intEveId = intval($intEveId);
$strEntryId = trim((string)($arrPayload['entry_id'] ?? ''));
$strBibWanted = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : '';
if ($strEntryId !== '') {
$arrFirst = fxChronotrackApiSyncPutEntry($strEntryId, $arrPayload);
$strMethod = 'PUT';
} else {
$arrFirst = fxChronotrackApiSyncPostEntries(
$intCtEventId,
array(fxChronotrackApiSyncPayloadForPost($arrPayload))
);
$strMethod = 'POST';
}
if (($arrFirst['state'] ?? '') === 'ok') {
if ($intEveId > 0) {
if ($strMethod === 'POST') {
fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrFirst['json'] ?? null, array($arrMeta));
}
fxChronotrackApiSyncLogPushEntryOk($intEveId, $arrMeta, $arrPayload, $strMethod);
}
return array('ok' => 1, 'err' => 0, 'bib_retry' => array(), 'method' => $strMethod);
}
$strErr = (string)($arrFirst['message'] ?? 'Erreur entry');
// Conflit dossard + on voulait en envoyer un → libérer puis 2e passe
if ($strBibWanted !== '' && fxChronotrackApiSyncIsBibConflictMessage($strErr)) {
$arrCleared = fxChronotrackApiSyncPayloadClearBib($arrPayload);
if ($strEntryId !== '') {
$arrClearRes = fxChronotrackApiSyncPutEntry($strEntryId, $arrCleared);
// Si CT refuse bib vide : maj sans champ bib, file quand même pour 2e passe
if (($arrClearRes['state'] ?? '') !== 'ok') {
$arrClearRes = fxChronotrackApiSyncPutEntry(
$strEntryId,
fxChronotrackApiSyncPayloadWithoutBib($arrPayload)
);
}
} else {
// Création : upsert sans bib (laisse lentry passer)
$arrClearRes = fxChronotrackApiSyncPostEntries(
$intCtEventId,
array(fxChronotrackApiSyncPayloadForPost(fxChronotrackApiSyncPayloadWithoutBib($arrPayload)))
);
}
if (($arrClearRes['state'] ?? '') === 'ok') {
if ($intEveId > 0) {
if ($strEntryId === '') {
fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrClearRes['json'] ?? null, array($arrMeta));
}
fxChronotrackApiSyncLogPushEntryDetail(
$intEveId,
intval($arrMeta['par_id'] ?? 0),
'ok',
'conflit_bib → sans dossard (retry plus tard) external_id='
. ($arrMeta['external_id'] ?? '')
. ' dossard_voulu=' . $strBibWanted
. ' nom=' . trim((string)($arrMeta['name'] ?? ''))
);
}
// Reprendre entry_id si la création vient de réussir
if ($strEntryId === '' && is_array($arrClearRes['json'] ?? null)) {
$arrTmp = $arrPayload;
// ApplyEntryIds a pu écrire en BD ; pour le retry on préfère entry_id si dispo dans la réponse
$arrEntities = fxChronotrackApiNormalizeEntityList($arrClearRes['json'], 'entry');
if (isset($arrEntities[0]) && is_array($arrEntities[0])) {
$strNewId = fxChronotrackApiEntityId($arrEntities[0]);
if ($strNewId !== '') {
$arrTmp['entry_id'] = $strNewId;
}
}
$arrPayload = $arrTmp;
}
$arrRetryPayload = $arrPayload;
$arrRetryPayload['bib'] = $strBibWanted;
return array(
'ok' => 1,
'err' => 0,
'bib_retry' => array(array(
'payload' => $arrRetryPayload,
'meta' => $arrMeta,
)),
'method' => $strMethod,
);
}
// Clear a aussi échoué → vraie erreur
$strErr = 'conflit_bib + clear échoué — ' . ($arrClearRes['message'] ?? $strErr);
}
if ($intEveId > 0) {
fxChronotrackApiSyncLog(
$intEveId,
intval($arrMeta['par_id'] ?? 0),
'push_entry',
'error',
'external_id=' . ($arrMeta['external_id'] ?? '') . ' — ' . $strErr
);
}
return array('ok' => 0, 'err' => 1, 'bib_retry' => array(), 'method' => $strMethod);
}
/**
* MSIN-4328 — payloads pour POST API (sans entry_id ; le PUT unitaire garde entry_id).
*
* @param array $tabEntries
* @return array
*/
function fxChronotrackApiSyncPayloadsForPostApi(array $tabEntries) {
$tabOut = array();
foreach ($tabEntries as $arrPayload) {
if (!is_array($arrPayload)) {
continue;
}
$tabOut[] = fxChronotrackApiSyncPayloadForPost($arrPayload);
}
return $tabOut;
}
/**
* POST entries avec sous-lots fixes si le lot échoue (évite explosion dappels).
* MSIN-4328 — en conflit bib sur 1 entry : clear + file pour 2e passe.
* Les payloads peuvent encore contenir entry_id (conservé pour le clear PUT) ;
* il est retiré uniquement à lappel POST API.
*
* @return array{ok:int,err:int,bib_retry:array}
*/
function fxChronotrackApiSyncPostEntriesAdaptive($intCtEventId, array $tabEntries, array &$tabErrors, $intEveId = 0, array $tabMeta = array()) {
$intCount = count($tabEntries);
if ($intCount === 0) {
return array('ok' => 0, 'err' => 0, 'bib_retry' => array());
}
// MSIN-4328 — 1 entry déjà liée CT : PUT (+ clear bib si swap), pas POST upsert.
if ($intCount === 1) {
$arrPayload0 = $tabEntries[0];
$strEntryId0 = is_array($arrPayload0) ? trim((string)($arrPayload0['entry_id'] ?? '')) : '';
if ($strEntryId0 !== '') {
return fxChronotrackApiSyncPushOneEntryWithBibConflictHandling(
$intCtEventId,
$arrPayload0,
$tabMeta[0] ?? array('par_id' => 0, 'external_id' => ''),
$intEveId
);
}
}
$arrPost = fxChronotrackApiSyncPostEntries(
$intCtEventId,
fxChronotrackApiSyncPayloadsForPostApi($tabEntries)
);
if ($arrPost['state'] === 'ok') {
if ($intEveId > 0 && count($tabMeta) > 0) {
fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrPost['json'] ?? null, $tabMeta);
foreach ($tabMeta as $intI => $arrM) {
if (!is_array($arrM)) {
continue;
}
$arrPayload = $tabEntries[$intI] ?? array();
fxChronotrackApiSyncLogPushEntryOk($intEveId, $arrM, $arrPayload, 'POST');
}
}
return array('ok' => $intCount, 'err' => 0, 'bib_retry' => array());
}
if ($intCount === 1) {
$arrPayload = $tabEntries[0];
$arrM = $tabMeta[0] ?? array('par_id' => 0, 'external_id' => '');
$strOneErr = $arrPost['message'] ?? 'Erreur POST entry';
$strBibWanted = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : '';
if ($strBibWanted !== '' && fxChronotrackApiSyncIsBibConflictMessage($strOneErr)) {
// MSIN-4328 — conflit sans entry_id : tenter clear bib='' puis sans champ bib
$strEntryIdOne = trim((string)($arrPayload['entry_id'] ?? ''));
if ($strEntryIdOne !== '') {
$arrClearRes = fxChronotrackApiSyncPutEntry(
$strEntryIdOne,
fxChronotrackApiSyncPayloadClearBib($arrPayload)
);
if (($arrClearRes['state'] ?? '') !== 'ok') {
$arrClearRes = fxChronotrackApiSyncPutEntry(
$strEntryIdOne,
fxChronotrackApiSyncPayloadWithoutBib($arrPayload)
);
}
} else {
// bib='' dabord (libérer le tag) ; fallback sans champ bib
$arrClearRes = fxChronotrackApiSyncPostEntries(
$intCtEventId,
array(fxChronotrackApiSyncPayloadForPost(
fxChronotrackApiSyncPayloadClearBib($arrPayload)
))
);
if (($arrClearRes['state'] ?? '') !== 'ok') {
$arrClearRes = fxChronotrackApiSyncPostEntries(
$intCtEventId,
array(fxChronotrackApiSyncPayloadForPost(
fxChronotrackApiSyncPayloadWithoutBib($arrPayload)
))
);
}
}
if (($arrClearRes['state'] ?? '') === 'ok') {
if ($intEveId > 0) {
if ($strEntryIdOne === '') {
fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrClearRes['json'] ?? null, array($arrM));
}
fxChronotrackApiSyncLogPushEntryDetail(
$intEveId,
intval($arrM['par_id'] ?? 0),
'ok',
'conflit_bib → sans dossard (retry plus tard) external_id='
. ($arrM['external_id'] ?? '')
. ' dossard_voulu=' . $strBibWanted
. ' nom=' . trim((string)($arrM['name'] ?? ''))
);
}
$arrRetryPayload = $arrPayload;
$arrRetryPayload['bib'] = $strBibWanted;
if ($strEntryIdOne !== '') {
$arrRetryPayload['entry_id'] = $strEntryIdOne;
} else {
$arrEntities = fxChronotrackApiNormalizeEntityList($arrClearRes['json'] ?? null, 'entry');
if (isset($arrEntities[0]) && is_array($arrEntities[0])) {
$strNewId = fxChronotrackApiEntityId($arrEntities[0]);
if ($strNewId !== '') {
$arrRetryPayload['entry_id'] = $strNewId;
}
}
}
return array(
'ok' => 1,
'err' => 0,
'bib_retry' => array(array(
'payload' => $arrRetryPayload,
'meta' => $arrM,
)),
);
}
$strOneErr = 'conflit_bib + envoi sans dossard échoué — '
. ($arrClearRes['message'] ?? $strOneErr);
}
if (count($tabErrors) < 12) {
$tabErrors[] = 'external_id=' . ($arrM['external_id'] ?? '') . ' — ' . $strOneErr;
}
if ($intEveId > 0) {
fxChronotrackApiSyncLog(
$intEveId,
intval($arrM['par_id'] ?? 0),
'push_entry',
'error',
'external_id=' . ($arrM['external_id'] ?? '') . ' — ' . $strOneErr
);
}
return array('ok' => 0, 'err' => 1, 'bib_retry' => array());
}
// Sous-lots fixes : 25 → 5 → 1
$intSubSize = ($intCount > 25) ? 25 : (($intCount > 5) ? 5 : 1);
$intOk = 0;
$intErr = 0;
$tabBibRetry = array();
for ($intI = 0; $intI < $intCount; $intI += $intSubSize) {
$arrRes = fxChronotrackApiSyncPostEntriesAdaptive(
$intCtEventId,
array_slice($tabEntries, $intI, $intSubSize),
$tabErrors,
$intEveId,
array_slice($tabMeta, $intI, $intSubSize)
);
$intOk += $arrRes['ok'];
$intErr += $arrRes['err'];
if (!empty($arrRes['bib_retry']) && is_array($arrRes['bib_retry'])) {
foreach ($arrRes['bib_retry'] as $arrR) {
$tabBibRetry[] = $arrR;
}
}
}
return array(
'ok' => $intOk,
'err' => $intErr,
'bib_retry' => $tabBibRetry,
);
}
/**
* MSIN-4328 — 2e passe : réassigner les dossards différés (swaps).
*
* @return array{ok:int,err:int,errors:array}
*/
function fxChronotrackApiSyncPushPendingBibs($intCtEventId, array $tabBibRetry, $intEveId = 0) {
$intOk = 0;
$intErr = 0;
$tabErrors = array();
$intEveId = intval($intEveId);
$intCtEventId = intval($intCtEventId);
foreach ($tabBibRetry as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$arrPayload = $arrItem['payload'] ?? null;
$arrMeta = $arrItem['meta'] ?? array();
if (!is_array($arrPayload)) {
continue;
}
$strBib = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : '';
if ($strBib === '') {
continue;
}
$strEntryId = trim((string)($arrPayload['entry_id'] ?? ''));
if ($strEntryId !== '') {
$arrRes = fxChronotrackApiSyncPutEntry($strEntryId, $arrPayload);
} else {
$arrRes = fxChronotrackApiSyncPostEntries(
$intCtEventId,
array(fxChronotrackApiSyncPayloadForPost($arrPayload))
);
}
if (($arrRes['state'] ?? '') === 'ok') {
$intOk++;
if ($intEveId > 0) {
fxChronotrackApiSyncLogPushEntryOk(
$intEveId,
$arrMeta,
$arrPayload,
'RETRY_BIB'
);
}
} else {
$intErr++;
$strErr = $arrRes['message'] ?? 'Retry dossard échoué';
$strMsg = 'RETRY_BIB external_id=' . ($arrMeta['external_id'] ?? '')
. ' dossard=' . $strBib . ' — ' . $strErr;
if (count($tabErrors) < 20) {
$tabErrors[] = $strMsg;
}
if ($intEveId > 0) {
fxChronotrackApiSyncLog(
$intEveId,
intval($arrMeta['par_id'] ?? 0),
'push_entry',
'error',
$strMsg
);
}
}
}
return array('ok' => $intOk, 'err' => $intErr, 'errors' => $tabErrors);
}
function fxChronotrackApiSyncProbePush($intEveId) {
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
}
$intCtEventId = intval($arrConfig['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId);
if (count($arrClass['transferable']) === 0) {
return array('state' => 'error', 'message' => 'Aucun participant transférable pour la sonde (voir anomalies).');
}
$arrFirst = $arrClass['transferable'][0];
$arrPayload = $arrFirst['payload'];
$arrRow = $arrFirst['row'];
$arrCtFetch = fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId);
if ($arrCtFetch['state'] === 'ok') {
$strExt = $arrPayload['external_id'];
$arrPayload = fxChronotrackApiSyncEnrichPayloadWithCtEntry(
$arrPayload,
$arrCtFetch['entries'][$strExt] ?? null
);
}
$arrPost = fxChronotrackApiSyncPushOneEntry($intCtEventId, $arrPayload);
return array(
'state' => $arrPost['state'],
'message' => $arrPost['message'] ?? '',
'ct_event_id' => $intCtEventId,
'par_id' => intval($arrRow['par_id'] ?? 0),
'payload' => $arrPayload,
'method' => $arrPost['method'] ?? '',
'body_format' => $arrPost['body_format'] ?? '',
'http_code' => intval($arrPost['http']['http_code'] ?? 0),
'response_body' => substr(trim((string)($arrPost['http']['body'] ?? '')), 0, 2000),
'fallback_error'=> $arrPost['fallback_error'] ?? '',
);
}
/**
* MSIN-4461 — extrait une valeur entry_note_* dun objet entry CT (clé plate ou entry_notes).
*/
function fxChronotrackApiSyncExtractEntryNoteValue($arrEntry, $strFieldKey) {
if (!is_array($arrEntry) || $strFieldKey === '') {
return array('present' => false, 'value' => null);
}
if (array_key_exists($strFieldKey, $arrEntry)) {
return array('present' => true, 'value' => $arrEntry[$strFieldKey]);
}
// Support CT : préfixe entry_note_ ; la relecture peut regrouper sous entry_notes
$strLabel = (strpos($strFieldKey, 'entry_note_') === 0)
? substr($strFieldKey, strlen('entry_note_'))
: $strFieldKey;
if (isset($arrEntry['entry_notes']) && is_array($arrEntry['entry_notes'])) {
$arrNotes = $arrEntry['entry_notes'];
if (array_key_exists($strFieldKey, $arrNotes)) {
return array('present' => true, 'value' => $arrNotes[$strFieldKey]);
}
if (array_key_exists($strLabel, $arrNotes)) {
return array('present' => true, 'value' => $arrNotes[$strLabel]);
}
foreach ($arrNotes as $mixNote) {
if (!is_array($mixNote)) {
continue;
}
$strName = trim((string)($mixNote['name'] ?? $mixNote['label'] ?? $mixNote['key'] ?? ''));
if ($strName === $strFieldKey || $strName === $strLabel || $strName === 'entry_note_' . $strLabel) {
return array(
'present' => true,
'value' => $mixNote['value'] ?? $mixNote['note'] ?? $mixNote['text'] ?? null,
);
}
}
}
return array('present' => false, 'value' => null);
}
/**
* MSIN-4461 — sonde entry_note_test (doc CT support : custom notes = préfixe entry_note_).
* Préfère PUT entry/{id} si ct_entry_id connu ; sinon POST event/.../entry.
*/
function fxChronotrackApiSyncProbeCustomField($intEveId) {
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
}
if (!fxChronotrackApiSettingsConfigured()) {
return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage());
}
$intCtEventId = intval($arrConfig['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId);
if (count($arrClass['transferable']) === 0) {
return array('state' => 'error', 'message' => 'Aucun participant transférable pour la sonde entry_note.');
}
// Préférer une entry déjà liée CT (PUT unitaire = chemin documenté par le support)
$arrPick = null;
foreach ($arrClass['transferable'] as $arrItem) {
if (intval($arrItem['row']['ct_entry_id'] ?? 0) > 0) {
$arrPick = $arrItem;
break;
}
}
if ($arrPick === null) {
$arrPick = $arrClass['transferable'][0];
}
$arrRow = $arrPick['row'];
$strExternalId = (string)($arrPick['payload']['external_id'] ?? '');
$intCtEntryId = intval($arrRow['ct_entry_id'] ?? 0);
$strFieldKey = 'entry_note_test';
$strValue = 'ms1-probe-' . date('YmdHis');
// Corps minimal : identifier + note custom (support CT 2026-07-22)
$arrProbePayload = array(
'external_id' => $strExternalId,
$strFieldKey => $strValue,
);
$strMethod = 'POST';
if ($intCtEntryId > 0) {
$arrProbePayload['entry_id'] = (string)$intCtEntryId;
$arrWrite = fxChronotrackApiSyncPutEntry((string)$intCtEntryId, $arrProbePayload);
$strMethod = 'PUT';
} else {
$arrWrite = fxChronotrackApiSyncPostEntries($intCtEventId, array($arrProbePayload));
$strMethod = 'POST';
}
$mixReadbackValue = null;
$blnFieldPresent = false;
$arrReadback = null;
$strReadbackHow = '';
if ($intCtEntryId > 0) {
$arrGet = fxChronotrackApiOAuthApiGet('entry/' . $intCtEntryId);
$strReadbackHow = 'GET entry/' . $intCtEntryId;
if (($arrGet['state'] ?? '') === 'ok' && is_array($arrGet['json'] ?? null)) {
$arrReadback = $arrGet['json'];
if (isset($arrReadback['entry']) && is_array($arrReadback['entry'])) {
$arrReadback = $arrReadback['entry'];
} elseif (isset($arrReadback['event_entry'][0]) && is_array($arrReadback['event_entry'][0])) {
$arrReadback = $arrReadback['event_entry'][0];
}
$arrFound = fxChronotrackApiSyncExtractEntryNoteValue($arrReadback, $strFieldKey);
if ($arrFound['present']) {
$blnFieldPresent = true;
$mixReadbackValue = $arrFound['value'];
}
}
}
if (!$blnFieldPresent) {
// Repli : 1re page entries et recherche par external_id
$arrList = fxChronotrackApiOAuthApiGet(
'event/' . $intCtEventId . '/entry',
array('page' => 1, 'page_size' => 50)
);
if ($strReadbackHow === '') {
$strReadbackHow = 'GET event/.../entry page1';
} else {
$strReadbackHow .= ' + list page1';
}
$tabEnt = array();
if (($arrList['state'] ?? '') === 'ok' && is_array($arrList['json'] ?? null)) {
$tabEnt = fxChronotrackApiNormalizeEntityList($arrList['json'], 'entry');
if (count($tabEnt) === 0 && isset($arrList['json']['event_entry']) && is_array($arrList['json']['event_entry'])) {
$tabEnt = $arrList['json']['event_entry'];
}
}
foreach ($tabEnt as $arrEnt) {
if (!is_array($arrEnt)) {
continue;
}
$strExt = trim((string)(
$arrEnt['entry_external_id'] ?? $arrEnt['external_id'] ?? ''
));
if ($strExt !== $strExternalId) {
continue;
}
$arrReadback = $arrEnt;
$arrFound = fxChronotrackApiSyncExtractEntryNoteValue($arrEnt, $strFieldKey);
if ($arrFound['present']) {
$blnFieldPresent = true;
$mixReadbackValue = $arrFound['value'];
}
break;
}
}
$strVerdict = $strMethod . ' ok mais ' . $strFieldKey . ' absent à la relecture.';
$strState = 'error';
if (($arrWrite['state'] ?? '') !== 'ok') {
$strVerdict = $strMethod . ' refusé — ' . (string)($arrWrite['message'] ?? 'erreur');
$strState = 'error';
} elseif ($blnFieldPresent && (string)$mixReadbackValue === $strValue) {
$strVerdict = 'OK — CT a accepté et conservé ' . $strFieldKey . ' via ' . $strMethod . '.';
$strState = 'ok';
} elseif ($blnFieldPresent) {
$strVerdict = $strMethod . ' ok, champ présent mais valeur différente: '
. json_encode($mixReadbackValue, JSON_UNESCAPED_UNICODE);
$strState = 'partial';
}
fxChronotrackApiSyncLog(
$intEveId,
intval($arrRow['par_id'] ?? 0),
'probe_entry_note',
($strState === 'ok') ? 'ok' : 'error',
$strVerdict
);
return array(
'state' => $strState,
'message' => $strVerdict,
'ct_event_id' => $intCtEventId,
'par_id' => intval($arrRow['par_id'] ?? 0),
'external_id' => $strExternalId,
'ct_entry_id' => $intCtEntryId,
'method' => $strMethod,
'field_key' => $strFieldKey,
'field_value_sent' => $strValue,
'field_present' => $blnFieldPresent,
'field_value_read' => $mixReadbackValue,
'payload' => $arrProbePayload,
'post_http_code' => intval($arrWrite['http']['http_code'] ?? 0),
'post_response' => substr(trim((string)($arrWrite['http']['body'] ?? '')), 0, 1500),
'readback_how' => $strReadbackHow,
'readback_sample' => is_array($arrReadback)
? array_intersect_key($arrReadback, array_flip(array(
'entry_id', 'entry_external_id', 'external_id', 'entry_notes', 'entry_note_test',
)))
: null,
);
}
/**
* Sonde perf — teste 10 / 50 / 100 / 200 entries (upsert, mêmes gens). MSIN-4328
* Évite un push complet pour calibrer la taille de lot.
*/
function fxChronotrackApiSyncProbeBatchPerf($intEveId) {
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
}
if (!fxChronotrackApiSettingsConfigured()) {
return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage());
}
$intCtEventId = intval($arrConfig['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId);
$intAvail = count($arrClass['transferable']);
if ($intAvail < 10) {
return array(
'state' => 'error',
'message' => 'Il faut au moins 10 éligibles pour la sonde lots (actuellement ' . $intAvail . ').',
);
}
$tabPayloads = array();
$intMax = min(200, $intAvail);
for ($i = 0; $i < $intMax; $i++) {
$tabPayloads[] = fxChronotrackApiSyncPayloadForPost($arrClass['transferable'][$i]['payload']);
}
$tabSizes = array(10, 50, 100, 200);
$tabResults = array();
$intBest = 10;
foreach ($tabSizes as $intSize) {
if ($intSize > count($tabPayloads)) {
break;
}
$tabSlice = array_slice($tabPayloads, 0, $intSize);
$floatStart = microtime(true);
$arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, $tabSlice);
$floatMs = round((microtime(true) - $floatStart) * 1000);
$blnOk = (($arrPost['state'] ?? '') === 'ok');
$tabResults[] = array(
'size' => $intSize,
'ok' => $blnOk,
'ms' => $floatMs,
'http_code' => intval($arrPost['http']['http_code'] ?? 0),
'message' => $blnOk ? 'OK' : ($arrPost['message'] ?? 'échec'),
'ms_per' => $blnOk && $intSize > 0 ? round($floatMs / $intSize, 1) : null,
);
if ($blnOk) {
$intBest = $intSize;
} else {
break;
}
}
$intEst2000 = ($intBest > 0)
? (int)ceil(2000 / $intBest) * max(1, intval($tabResults[count($tabResults) - 1]['ms'] ?? 1000))
: 0;
return array(
'state' => 'ok',
'message' => 'Plus grand lot OK : ' . $intBest
. ' — lot push actuel configuré : ' . MSIN_API_CHRONOTRACK_SYNC_BATCH_SIZE,
'recommended' => $intBest,
'configured' => MSIN_API_CHRONOTRACK_SYNC_BATCH_SIZE,
'results' => $tabResults,
'est_ms_2000' => $intEst2000,
'est_min_2000' => $intEst2000 > 0 ? round($intEst2000 / 60000, 1) : null,
'ct_event_id' => $intCtEventId,
);
}
/**
* Liste toutes les entries CT (pagination), dédupliquées par entry_id.
* Ne filtre pas sur external_id — décompte réel côté CT.
*/
function fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId) {
$intCtEventId = intval($intCtEventId);
$tabAll = array();
$tabSeenEntryIds = array();
$intPage = 1;
$intPageSize = 100;
$intMaxPages = 500;
while ($intPage <= $intMaxPages) {
$arrApi = fxChronotrackApiOAuthApiGet('event/' . $intCtEventId . '/entry', array(
'page' => $intPage,
'page_size' => $intPageSize,
));
if ($arrApi['state'] !== 'ok') {
return array(
'state' => 'error',
'message' => $arrApi['message'] ?? 'Erreur lecture entries CT',
'entities' => $tabAll,
);
}
$arrEntities = fxChronotrackApiNormalizeEntityList($arrApi['json'], 'entry');
if (count($arrEntities) === 0) {
break;
}
foreach ($arrEntities as $arrEntity) {
if (!is_array($arrEntity)) {
continue;
}
$strEntryId = fxChronotrackApiEntityId($arrEntity);
if ($strEntryId === '' || isset($tabSeenEntryIds[$strEntryId])) {
continue;
}
$tabSeenEntryIds[$strEntryId] = true;
$tabAll[] = $arrEntity;
}
if (count($arrEntities) < $intPageSize) {
break;
}
$intPage++;
}
return array(
'state' => 'ok',
'message' => '',
'entities' => $tabAll,
'count' => count($tabAll),
);
}
function fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId) {
$arrFetchAll = fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId);
if ($arrFetchAll['state'] !== 'ok') {
return array(
'state' => 'error',
'message' => $arrFetchAll['message'] ?? 'Erreur lecture entries CT',
'entries' => array(),
);
}
$tabIndexed = array();
foreach ($arrFetchAll['entities'] as $arrEntity) {
if (!is_array($arrEntity)) {
continue;
}
$strExt = fxChronotrackApiEntityExternalId($arrEntity);
if ($strExt === '') {
continue;
}
$tabIndexed[$strExt] = $arrEntity;
}
return array(
'state' => 'ok',
'message' => '',
'entries' => $tabIndexed,
'entities' => $arrFetchAll['entities'],
'total_entry_count' => intval($arrFetchAll['count'] ?? count($arrFetchAll['entities'])),
'without_external_id'=> max(0, intval($arrFetchAll['count'] ?? 0) - count($tabIndexed)),
);
}
function fxChronotrackApiSyncFetchCtEntryRows($intCtEventId) {
$arrFetch = fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId);
if ($arrFetch['state'] !== 'ok') {
return $arrFetch;
}
$tabRows = array();
foreach ($arrFetch['entities'] as $arrEntity) {
if (!is_array($arrEntity)) {
continue;
}
$strEntryId = fxChronotrackApiEntityId($arrEntity);
if ($strEntryId === '') {
continue;
}
$tabRows[] = array(
'entry_id' => $strEntryId,
'external_id' => fxChronotrackApiEntityExternalId($arrEntity),
'entity' => $arrEntity,
);
}
return array(
'state' => 'ok',
'message' => '',
'rows' => $tabRows,
'count' => count($tabRows),
);
}
function fxChronotrackApiSyncBuildPreview($intEveId) {
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
}
if (!fxChronotrackApiSettingsConfigured()) {
return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage());
}
$intCtEventId = intval($arrConfig['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$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']);
$blnVerboseLogs = !empty($arrOptions['verbose_logs']);
fxChronotrackApiSyncVerboseEntryLogs($blnVerboseLogs);
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
}
if (!fxChronotrackApiSettingsConfigured()) {
return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage());
}
$intCtEventId = intval($arrConfig['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId);
// MSIN-4328 — ne plus journaliser push_skip ici (spam à chaque prepare/auto).
// Les exclus restent dans 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) {
// Garder entry_id si déjà lié CT → push chunk fera PUT (maj dossard fiable).
$arrPayload = $arrItem['payload'];
$strCtEntryId = preg_replace('/[^0-9]/', '', (string)($arrItem['row']['ct_entry_id'] ?? ''));
if ($strCtEntryId !== '') {
$arrPayload['entry_id'] = $strCtEntryId;
}
$tabBatch[] = $arrPayload;
$strBibMs1 = fxChronotrackApiSyncExtractBib($arrItem['row'] ?? array());
$strBibSent = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : '';
$strNoEquipe = trim((string)($arrItem['row']['no_equipe'] ?? ''));
$tabMeta[] = array(
'par_id' => intval($arrItem['row']['par_id'] ?? 0),
'external_id' => $arrPayload['external_id'] ?? '',
'bib_ms1' => $strBibMs1,
'bib_sent' => $strBibSent,
'no_equipe' => $strNoEquipe,
'name' => trim(
trim((string)($arrPayload['first_name'] ?? ''))
. ' '
. trim((string)($arrPayload['last_name'] ?? ''))
),
);
}
if (count($tabBatch) === 0) {
fxChronotrackApiSyncPushJobClear($intEveId);
$strMsg = count($arrClass['transferable']) === 0
? ('Aucun participant transférable.'
. ($arrClass['blocked_count'] > 0 ? ' ' . $arrClass['blocked_count'] . ' exclus.' : ''))
: ('Aucun changement depuis le dernier push ('
. count($arrClass['transferable']) . ' éligibles à jour).');
fxChronotrackApiSyncLog($intEveId, 0, 'push_prepare', 'ok', 'idle — ' . $strMsg);
return array(
'state' => 'error',
'message' => $strMsg,
'blocked_count' => $arrClass['blocked_count'],
'transferable' => count($arrClass['transferable']),
'pending_push' => 0,
'total' => 0,
'force_full' => $blnForceFull ? 1 : 0,
);
}
$strMode = $blnForceFull ? 'FULL resync' : 'différentiel';
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_prepare',
'ok',
$strMode . ' — job prêt — pending=' . count($tabBatch)
. ' éligibles=' . count($arrClass['transferable'])
. ' exclus=' . intval($arrClass['blocked_count'])
);
$arrJob = array(
'ct_event_id' => $intCtEventId,
'batch' => $tabBatch,
'meta' => $tabMeta,
'blocked_count' => $arrClass['blocked_count'],
'force_full' => $blnForceFull ? 1 : 0,
'verbose_logs' => $blnVerboseLogs ? 1 : 0,
'ok_sum' => 0,
'err_sum' => 0,
'bib_retry' => array(),
'created_at' => time(),
);
if (!fxChronotrackApiSyncPushJobSave($intEveId, $arrJob)) {
return array(
'state' => 'error',
'message' => 'Impossible décrire le fichier temporaire du push (permissions /tmp).',
);
}
return array(
'state' => 'ok',
'message' => count($tabBatch) . ' prêts à envoyer'
. ($blnForceFull ? ' (resync complète)' : ''),
'total' => count($tabBatch),
'pending_push' => count($tabBatch),
'transferable' => count($arrClass['transferable']),
'blocked_count' => $arrClass['blocked_count'],
'chunk_size' => MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE,
'ct_event_id' => $intCtEventId,
'force_full' => $blnForceFull ? 1 : 0,
);
}
/**
* Push un slice du job préparé. MSIN-4328.
* Même mécanique que « Envoyer » : POST par lots + offset/total (barre %).
* « Tout renvoyer » = force_full à la préparation seulement — pas un autre pipeline.
*/
function fxChronotrackApiSyncPushEvent($intEveId, $arrOptions = array()) {
$intEveId = intval($intEveId);
$intOffset = max(0, intval($arrOptions['offset'] ?? 0));
$intLimit = max(0, intval($arrOptions['limit'] ?? 0));
if ($intLimit <= 0) {
$intLimit = MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE;
}
$arrJob = fxChronotrackApiSyncPushJobLoad($intEveId);
if (!is_array($arrJob) || empty($arrJob['batch'])) {
$arrPrep = fxChronotrackApiSyncPushPrepare($intEveId);
if ($arrPrep['state'] !== 'ok') {
return $arrPrep;
}
$arrJob = fxChronotrackApiSyncPushJobLoad($intEveId);
}
if (!is_array($arrJob) || empty($arrJob['batch'])) {
return array('state' => 'error', 'message' => 'Job de push introuvable — relancer');
}
$intCtEventId = intval($arrJob['ct_event_id']);
$tabBatchAll = $arrJob['batch'];
$tabMetaAll = $arrJob['meta'];
$intTotal = count($tabBatchAll);
$intSkipped = intval($arrJob['blocked_count'] ?? 0);
fxChronotrackApiSyncVerboseEntryLogs(!empty($arrJob['verbose_logs']));
$tabBatch = array_slice($tabBatchAll, $intOffset, $intLimit);
$tabMeta = array_slice($tabMetaAll, $intOffset, $intLimit);
$intChunkCount = count($tabBatch);
if ($intChunkCount === 0) {
fxChronotrackApiSyncPushJobClear($intEveId);
return array(
'state' => 'ok',
'message' => 'Rien à envoyer à cet offset',
'pushed_ok' => 0,
'pushed_put' => 0,
'pushed_post' => 0,
'pushed_error' => 0,
'total' => $intTotal,
'offset' => $intOffset,
'next_offset' => $intOffset,
'chunk_done' => 0,
'chunk_size' => $intLimit,
'done' => true,
'blocked_count' => $intSkipped,
);
}
$intOk = 0;
$intErr = 0;
$intPostOk = 0;
$intPutOk = 0;
$tabErrors = array();
$tabBibRetryChunk = array();
// MSIN-4328 — entries liées (ct_entry_id) : PUT unitaire (+ clear bib si swap).
// Sans entry_id : POST lot (upsert). Ne plus stripper entry_id avant adaptive.
$tabPostEntries = array();
$tabPostMeta = array();
foreach ($tabBatch as $intI => $arrPayload) {
if (!is_array($arrPayload)) {
continue;
}
$strEntryId = trim((string)($arrPayload['entry_id'] ?? ''));
if ($strEntryId !== '') {
$arrOne = fxChronotrackApiSyncPushOneEntryWithBibConflictHandling(
$intCtEventId,
$arrPayload,
$tabMeta[$intI] ?? array('par_id' => 0, 'external_id' => ''),
$intEveId
);
$intOk += intval($arrOne['ok'] ?? 0);
$intErr += intval($arrOne['err'] ?? 0);
$intPutOk += intval($arrOne['ok'] ?? 0);
if (!empty($arrOne['bib_retry']) && is_array($arrOne['bib_retry'])) {
foreach ($arrOne['bib_retry'] as $arrR) {
$tabBibRetryChunk[] = $arrR;
}
}
} else {
$tabPostEntries[] = $arrPayload;
$tabPostMeta[] = $tabMeta[$intI] ?? array('par_id' => 0, 'external_id' => '');
}
}
if (count($tabPostEntries) > 0) {
$arrRes = fxChronotrackApiSyncPostEntriesAdaptive(
$intCtEventId,
$tabPostEntries,
$tabErrors,
$intEveId,
$tabPostMeta
);
$intOk += $arrRes['ok'];
$intPostOk += $arrRes['ok'];
$intErr += $arrRes['err'];
if (!empty($arrRes['bib_retry']) && is_array($arrRes['bib_retry'])) {
foreach ($arrRes['bib_retry'] as $arrR) {
$tabBibRetryChunk[] = $arrR;
}
}
}
$tabBibRetryJob = isset($arrJob['bib_retry']) && is_array($arrJob['bib_retry'])
? $arrJob['bib_retry']
: array();
foreach ($tabBibRetryChunk as $arrR) {
$tabBibRetryJob[] = $arrR;
}
$arrJob['bib_retry'] = $tabBibRetryJob;
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_chunk',
($intErr === 0) ? 'ok' : 'error',
'offset=' . $intOffset . ' n=' . $intChunkCount
. ' ok=' . $intOk . ' err=' . $intErr
. ' PUT=' . $intPutOk . ' POST=' . $intPostOk
. ' bib_pending=' . count($tabBibRetryJob)
);
$intOkSum = intval($arrJob['ok_sum'] ?? 0) + $intOk;
$intErrSum = intval($arrJob['err_sum'] ?? 0) + $intErr;
$arrJob['ok_sum'] = $intOkSum;
$arrJob['err_sum'] = $intErrSum;
$intNextOffset = $intOffset + $intChunkCount;
$blnDone = ($intNextOffset >= $intTotal);
$blnLastPushSaved = false;
$intBibRetryOk = 0;
$intBibRetryErr = 0;
if ($blnDone) {
// 2e passe dossards (swaps) — interne, sans changer offset/total de la barre %
if (count($tabBibRetryJob) > 0) {
$arrBibPass = fxChronotrackApiSyncPushPendingBibs(
$intCtEventId,
$tabBibRetryJob,
$intEveId
);
$intBibRetryOk = intval($arrBibPass['ok'] ?? 0);
$intBibRetryErr = intval($arrBibPass['err'] ?? 0);
$intOkSum += $intBibRetryOk;
$intErrSum += $intBibRetryErr;
if (!empty($arrBibPass['errors']) && is_array($arrBibPass['errors'])) {
foreach ($arrBibPass['errors'] as $strBe) {
if (count($tabErrors) < 20) {
$tabErrors[] = $strBe;
}
}
}
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_bib_retry',
($intBibRetryErr === 0) ? 'ok' : 'error',
'fin job — retry dossards n=' . count($tabBibRetryJob)
. ' ok=' . $intBibRetryOk . ' err=' . $intBibRetryErr
);
}
if ($intErrSum === 0) {
$blnLastPushSaved = fxChronotrackApiConfigSetLastPush($intEveId, $intOkSum);
}
fxChronotrackApiSyncPushJobClear($intEveId);
} else {
fxChronotrackApiSyncPushJobSave($intEveId, $arrJob);
}
$strState = ($intErr === 0 && $intBibRetryErr === 0)
? 'ok'
: ((($intOk + $intBibRetryOk) > 0) ? 'partial' : 'error');
$strMessage = ($intErr === 0 && $intBibRetryErr === 0)
? ($intOk . ' envoyé(s) (offset ' . $intOffset . ').')
: ($intOk . ' OK, ' . $intErr . ' erreur(s) sur ce lot.'
. ($intBibRetryErr > 0 ? (' Retry dossards : ' . $intBibRetryErr . ' échec(s).') : ''));
return array(
'state' => $strState,
'message' => $strMessage,
'pushed_ok' => $intOk + $intBibRetryOk,
'pushed_put' => $intPutOk,
'pushed_post' => $intPostOk + $intBibRetryOk,
'pushed_error' => $intErr + $intBibRetryErr,
'job_ok_sum' => $intOkSum,
'job_err_sum' => $intErrSum,
'last_push_saved' => $blnLastPushSaved,
'skipped' => $intSkipped,
'blocked_count' => $intSkipped,
'errors' => array_slice($tabErrors, 0, 20),
'total' => $intTotal,
'offset' => $intOffset,
'next_offset' => $intNextOffset,
'chunk_done' => $intChunkCount,
'chunk_size' => $intLimit,
'done' => $blnDone,
);
}
function fxChronotrackApiSyncOrphanReasonLabel($strReason) {
switch ($strReason) {
case 'sans_external_id':
return 'Sans external ID';
case 'doublon':
return 'Doublon CT';
case 'hors_eligible':
default:
return 'Hors éligibles MS1';
}
}
function fxChronotrackApiSyncComputeCtOrphanRows(array $tabEntities, array $tabEligibleKeys) {
$tabSeenLinkedExt = array();
$tabOrphans = array();
foreach ($tabEntities as $arrEntity) {
if (!is_array($arrEntity)) {
continue;
}
$strEntryId = fxChronotrackApiEntityId($arrEntity);
if ($strEntryId === '') {
continue;
}
$strExt = fxChronotrackApiEntityExternalId($arrEntity);
$strReason = '';
if ($strExt === '') {
$strReason = 'sans_external_id';
} elseif (!isset($tabEligibleKeys[$strExt])) {
$strReason = 'hors_eligible';
} elseif (isset($tabSeenLinkedExt[$strExt])) {
$strReason = 'doublon';
} else {
$tabSeenLinkedExt[$strExt] = true;
continue;
}
$tabOrphans[] = array(
'entry_id' => $strEntryId,
'external_id' => $strExt,
'name' => fxChronotrackApiEntityEntryLabel($arrEntity),
'bib' => fxChronotrackApiRaceField($arrEntity, array('bib', 'bib_number', 'entry_bib')),
'entry_status' => fxChronotrackApiRaceField($arrEntity, array('entry_status', 'status')),
'race_id' => fxChronotrackApiRaceField($arrEntity, array('race_id', 'event_race_id')),
'reason' => $strReason,
'reason_label' => fxChronotrackApiSyncOrphanReasonLabel($strReason),
);
}
usort($tabOrphans, function ($a, $b) {
$strA = strtolower(($a['name'] ?? '') . ($a['external_id'] ?? '') . ($a['entry_id'] ?? ''));
$strB = strtolower(($b['name'] ?? '') . ($b['external_id'] ?? '') . ($b['entry_id'] ?? ''));
return strcmp($strA, $strB);
});
return $tabOrphans;
}
/**
* Entries CT présentes sur l'événement mais absentes du jeu éligible MS1 (lié, doublon, sans ext).
*/
function fxChronotrackApiSyncListCtOrphans($intEveId) {
$arrPreview = fxChronotrackApiSyncBuildPreview($intEveId);
if ($arrPreview['state'] !== 'ok') {
return $arrPreview;
}
$intCtEventId = intval($arrPreview['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId);
$tabEligibleKeys = array();
foreach ($arrClass['transferable'] as $arrItem) {
$tabEligibleKeys[$arrItem['payload']['external_id']] = true;
}
$arrFetch = fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId);
if ($arrFetch['state'] !== 'ok') {
return array(
'state' => 'error',
'message' => $arrFetch['message'] ?? 'Erreur lecture entries CT',
);
}
$tabOrphans = fxChronotrackApiSyncComputeCtOrphanRows($arrFetch['entities'], $tabEligibleKeys);
return array(
'state' => 'ok',
'ct_event_id' => $intCtEventId,
'ct_total' => intval($arrPreview['ct_total'] ?? 0),
'linked_count' => intval($arrPreview['already_in_ct'] ?? 0),
'count' => count($tabOrphans),
'orphans' => $tabOrphans,
);
}
/**
* MSIN-4328 — push différentiel complet pour un event (cron auto).
*/
function fxChronotrackApiSyncRunAutoPushForEvent($intEveId) {
$intEveId = intval($intEveId);
$arrPrep = fxChronotrackApiSyncPushPrepare($intEveId);
if (($arrPrep['state'] ?? '') !== 'ok') {
// Rien à pousser = succès « idle »
$strMsg = (string)($arrPrep['message'] ?? 'prepare failed');
if (stripos($strMsg, 'Aucun changement') !== false || intval($arrPrep['pending_push'] ?? -1) === 0) {
fxChronotrackApiSyncLog($intEveId, 0, 'auto_push', 'ok', 'idle — ' . $strMsg);
return array('state' => 'ok', 'message' => $strMsg, 'pushed_ok' => 0, 'idle' => true);
}
fxChronotrackApiSyncLog($intEveId, 0, 'auto_push', 'error', $strMsg);
return $arrPrep;
}
$intOffset = 0;
$intLimit = MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE;
$intOkSum = 0;
$intErrSum = 0;
$intLoops = 0;
$intMaxLoops = 500;
while ($intLoops < $intMaxLoops) {
$intLoops++;
$arrChunk = fxChronotrackApiSyncPushEvent($intEveId, array(
'offset' => $intOffset,
'limit' => $intLimit,
));
$intOkSum += intval($arrChunk['pushed_ok'] ?? 0);
$intErrSum += intval($arrChunk['pushed_error'] ?? 0);
if (!empty($arrChunk['done'])) {
break;
}
if (($arrChunk['state'] ?? '') === 'error' && intval($arrChunk['pushed_ok'] ?? 0) === 0) {
break;
}
$intOffset = intval($arrChunk['next_offset'] ?? ($intOffset + $intLimit));
}
$strState = ($intErrSum === 0) ? 'ok' : (($intOkSum > 0) ? 'partial' : 'error');
$strMsg = 'auto push ok=' . $intOkSum . ' err=' . $intErrSum
. ' total_job=' . intval($arrPrep['total'] ?? 0);
fxChronotrackApiSyncLog($intEveId, 0, 'auto_push', ($strState === 'ok') ? 'ok' : 'error', $strMsg);
return array(
'state' => $strState,
'message' => $strMsg,
'pushed_ok' => $intOkSum,
'pushed_error' => $intErrSum,
'idle' => false,
);
}
/**
* MSIN-4328 — passage cron : events auto ON, fréquence / fin respectées.
* Chaque décision (run / skip / expire) écrit un log pour lévénement — sinon
* « Voir les logs » reste vide alors que le CrowdJob tourne vraiment.
*/
function fxChronotrackApiSyncRunAutoCron() {
if (!fxChronotrackApiSettingsConfigured()) {
return array(
'state' => 'error',
'message' => fxChronotrackApiSettingsErrorMessage(),
'ran' => 0,
'skipped' => 0,
'enabled_configs' => 0,
);
}
$tabConfigs = fxChronotrackApiConfigListAutoSyncEnabled();
$intRan = 0;
$intSkipped = 0;
$intExpired = 0;
$tabDetails = array();
$intNow = time();
$intEnabled = count($tabConfigs);
foreach ($tabConfigs as $arrCfg) {
$intEveId = intval($arrCfg['eve_id'] ?? 0);
if ($intEveId <= 0) {
continue;
}
$strUntil = trim((string)($arrCfg['auto_sync_until'] ?? ''));
if ($strUntil !== '' && $strUntil !== '0000-00-00 00:00:00') {
$intUntil = strtotime($strUntil);
if ($intUntil !== false && $intNow > $intUntil) {
fxChronotrackApiConfigDisableAutoSync($intEveId);
fxChronotrackApiSyncLog(
$intEveId,
0,
'auto_sync',
'ok',
'Échéance atteinte (' . $strUntil . ') — sync auto désactivée'
);
$intExpired++;
$tabDetails[] = array('eve_id' => $intEveId, 'action' => 'expired');
continue;
}
} else {
// Pas de fin = on ne tourne pas (UI exige une fin à 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,
),
);
}