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

4633 lines
172 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.
*/
// MSIN-4574 — vitesse 2026 : 1 POST CT = jusquà 100 entries AVEC dossard.
// 2e passe bib uniquement pour conflits (pas 1300 PUT systématiques).
define('MSIN_API_CHRONOTRACK_SYNC_BATCH_SIZE', 100);
define('MSIN_API_CHRONOTRACK_SYNC_CHUNK_SIZE', 100);
define('MSIN_API_CHRONOTRACK_SYNC_BIB_CHUNK_SIZE', 40);
/**
* 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;
}
/**
* MSIN-4574 — table ISO-3166 alpha-2 → alpha-3 (alignée sql/MSIN-inscriptions-pays-iso3.sql).
* Enrichie au runtime avec pay_iso3 en BD si la colonne existe.
*/
function fxChronotrackApiSyncIso2ToIso3Map() {
static $tabMap = null;
if ($tabMap !== null) {
return $tabMap;
}
$tabMap = array(
'AD' => 'AND',
'AE' => 'ARE',
'AF' => 'AFG',
'AG' => 'ATG',
'AI' => 'AIA',
'AL' => 'ALB',
'AM' => 'ARM',
'AO' => 'AGO',
'AQ' => 'ATA',
'AR' => 'ARG',
'AS' => 'ASM',
'AT' => 'AUT',
'AU' => 'AUS',
'AW' => 'ABW',
'AX' => 'ALA',
'AZ' => 'AZE',
'BA' => 'BIH',
'BB' => 'BRB',
'BD' => 'BGD',
'BE' => 'BEL',
'BF' => 'BFA',
'BG' => 'BGR',
'BH' => 'BHR',
'BI' => 'BDI',
'BJ' => 'BEN',
'BL' => 'BLM',
'BM' => 'BMU',
'BN' => 'BRN',
'BO' => 'BOL',
'BQ' => 'BES',
'BR' => 'BRA',
'BS' => 'BHS',
'BT' => 'BTN',
'BV' => 'BVT',
'BW' => 'BWA',
'BY' => 'BLR',
'BZ' => 'BLZ',
'CA' => 'CAN',
'CC' => 'CCK',
'CD' => 'COD',
'CF' => 'CAF',
'CG' => 'COG',
'CH' => 'CHE',
'CI' => 'CIV',
'CK' => 'COK',
'CL' => 'CHL',
'CM' => 'CMR',
'CN' => 'CHN',
'CO' => 'COL',
'CR' => 'CRI',
'CU' => 'CUB',
'CV' => 'CPV',
'CW' => 'CUW',
'CX' => 'CXR',
'CY' => 'CYP',
'CZ' => 'CZE',
'DE' => 'DEU',
'DJ' => 'DJI',
'DK' => 'DNK',
'DM' => 'DMA',
'DO' => 'DOM',
'DZ' => 'DZA',
'EC' => 'ECU',
'EE' => 'EST',
'EG' => 'EGY',
'EH' => 'ESH',
'ER' => 'ERI',
'ES' => 'ESP',
'ET' => 'ETH',
'FI' => 'FIN',
'FJ' => 'FJI',
'FK' => 'FLK',
'FM' => 'FSM',
'FO' => 'FRO',
'FR' => 'FRA',
'GA' => 'GAB',
'GB' => 'GBR',
'GD' => 'GRD',
'GE' => 'GEO',
'GF' => 'GUF',
'GG' => 'GGY',
'GH' => 'GHA',
'GI' => 'GIB',
'GL' => 'GRL',
'GM' => 'GMB',
'GN' => 'GIN',
'GP' => 'GLP',
'GQ' => 'GNQ',
'GR' => 'GRC',
'GS' => 'SGS',
'GT' => 'GTM',
'GU' => 'GUM',
'GW' => 'GNB',
'GY' => 'GUY',
'HK' => 'HKG',
'HM' => 'HMD',
'HN' => 'HND',
'HR' => 'HRV',
'HT' => 'HTI',
'HU' => 'HUN',
'ID' => 'IDN',
'IE' => 'IRL',
'IL' => 'ISR',
'IM' => 'IMN',
'IN' => 'IND',
'IO' => 'IOT',
'IQ' => 'IRQ',
'IR' => 'IRN',
'IS' => 'ISL',
'IT' => 'ITA',
'JE' => 'JEY',
'JM' => 'JAM',
'JO' => 'JOR',
'JP' => 'JPN',
'KE' => 'KEN',
'KG' => 'KGZ',
'KH' => 'KHM',
'KI' => 'KIR',
'KM' => 'COM',
'KN' => 'KNA',
'KP' => 'PRK',
'KR' => 'KOR',
'KW' => 'KWT',
'KY' => 'CYM',
'KZ' => 'KAZ',
'LA' => 'LAO',
'LB' => 'LBN',
'LC' => 'LCA',
'LI' => 'LIE',
'LK' => 'LKA',
'LR' => 'LBR',
'LS' => 'LSO',
'LT' => 'LTU',
'LU' => 'LUX',
'LV' => 'LVA',
'LY' => 'LBY',
'MA' => 'MAR',
'MC' => 'MCO',
'MD' => 'MDA',
'ME' => 'MNE',
'MF' => 'MAF',
'MG' => 'MDG',
'MH' => 'MHL',
'MK' => 'MKD',
'ML' => 'MLI',
'MM' => 'MMR',
'MN' => 'MNG',
'MO' => 'MAC',
'MP' => 'MNP',
'MQ' => 'MTQ',
'MR' => 'MRT',
'MS' => 'MSR',
'MT' => 'MLT',
'MU' => 'MUS',
'MV' => 'MDV',
'MW' => 'MWI',
'MX' => 'MEX',
'MY' => 'MYS',
'MZ' => 'MOZ',
'NA' => 'NAM',
'NC' => 'NCL',
'NE' => 'NER',
'NF' => 'NFK',
'NG' => 'NGA',
'NI' => 'NIC',
'NL' => 'NLD',
'NO' => 'NOR',
'NP' => 'NPL',
'NR' => 'NRU',
'NU' => 'NIU',
'NZ' => 'NZL',
'OM' => 'OMN',
'PA' => 'PAN',
'PE' => 'PER',
'PF' => 'PYF',
'PG' => 'PNG',
'PH' => 'PHL',
'PK' => 'PAK',
'PL' => 'POL',
'PM' => 'SPM',
'PN' => 'PCN',
'PR' => 'PRI',
'PS' => 'PSE',
'PT' => 'PRT',
'PW' => 'PLW',
'PY' => 'PRY',
'QA' => 'QAT',
'RE' => 'REU',
'RO' => 'ROU',
'RS' => 'SRB',
'RU' => 'RUS',
'RW' => 'RWA',
'SA' => 'SAU',
'SB' => 'SLB',
'SC' => 'SYC',
'SD' => 'SDN',
'SE' => 'SWE',
'SG' => 'SGP',
'SH' => 'SHN',
'SI' => 'SVN',
'SJ' => 'SJM',
'SK' => 'SVK',
'SL' => 'SLE',
'SM' => 'SMR',
'SN' => 'SEN',
'SO' => 'SOM',
'SR' => 'SUR',
'SS' => 'SSD',
'ST' => 'STP',
'SV' => 'SLV',
'SX' => 'SXM',
'SY' => 'SYR',
'SZ' => 'SWZ',
'TC' => 'TCA',
'TD' => 'TCD',
'TF' => 'ATF',
'TG' => 'TGO',
'TH' => 'THA',
'TJ' => 'TJK',
'TK' => 'TKL',
'TL' => 'TLS',
'TM' => 'TKM',
'TN' => 'TUN',
'TO' => 'TON',
'TR' => 'TUR',
'TT' => 'TTO',
'TV' => 'TUV',
'TW' => 'TWN',
'TZ' => 'TZA',
'UA' => 'UKR',
'UG' => 'UGA',
'UM' => 'UMI',
'US' => 'USA',
'UY' => 'URY',
'UZ' => 'UZB',
'VA' => 'VAT',
'VC' => 'VCT',
'VE' => 'VEN',
'VG' => 'VGB',
'VI' => 'VIR',
'VN' => 'VNM',
'VU' => 'VUT',
'WF' => 'WLF',
'WS' => 'WSM',
'YE' => 'YEM',
'YT' => 'MYT',
'ZA' => 'ZAF',
'ZM' => 'ZMB',
'ZW' => 'ZWE',
'AN' => 'ANT',
'UK' => 'GBR',
'XK' => 'XKX',
);
// Enrichir / corriger depuis BD (source de vérité après script pay_iso3)
if (function_exists('fxChronotrackApiSyncPaysHasIso3Column') && fxChronotrackApiSyncPaysHasIso3Column()) {
global $objDatabase;
if (isset($objDatabase) && is_object($objDatabase)) {
$arrRows = $objDatabase->fxGetResults(
"SELECT pay_iso, pay_iso3 FROM inscriptions_pays"
. " WHERE pay_iso IS NOT NULL AND TRIM(pay_iso) <> ''"
. " AND pay_iso3 IS NOT NULL AND TRIM(pay_iso3) <> ''"
);
if (is_array($arrRows)) {
for ($i = 1; $i <= count($arrRows); $i++) {
$str2 = strtoupper(trim((string)($arrRows[$i]['pay_iso'] ?? '')));
$str3 = strtoupper(trim((string)($arrRows[$i]['pay_iso3'] ?? '')));
if (strlen($str2) === 2 && strlen($str3) === 3) {
$tabMap[$str2] = $str3;
}
}
}
}
}
return $tabMap;
}
function fxChronotrackApiSyncIso2ToIso3($strIso2) {
$strIso2 = strtoupper(trim((string)$strIso2));
if ($strIso2 === '') {
return '';
}
if (strlen($strIso2) === 3 && ctype_alpha($strIso2)) {
return $strIso2;
}
if (strlen($strIso2) !== 2) {
return '';
}
$tabMap = fxChronotrackApiSyncIso2ToIso3Map();
return isset($tabMap[$strIso2]) ? $tabMap[$strIso2] : '';
}
/**
* MSIN-4574 — ISO-2 pays (pay_iso BD).
* Preuve calibrage 2026-08-11 : CT Athlete Info / export stockent location_country + COUNTRY_CODE = « CA » (pas CAN).
*/
function fxChronotrackApiSyncResolveCountryIso2(array $arrRow) {
$strIso2 = strtoupper(trim((string)($arrRow['country_iso2'] ?? '')));
if (strlen($strIso2) === 2 && ctype_alpha($strIso2)) {
return $strIso2;
}
return '';
}
/**
* MSIN-4574 — ISO-3 (pay_iso3 / map) — utile logs / exports résultats CTLIVE.
* Pour lécriture entry Athlete Info : préférer ResolveCountryIso2().
*/
function fxChronotrackApiSyncResolveCountryCode(array $arrRow) {
$strIso3 = strtoupper(trim((string)($arrRow['country_iso3'] ?? '')));
if (strlen($strIso3) === 3 && ctype_alpha($strIso3)) {
return $strIso3;
}
$strIso2 = fxChronotrackApiSyncResolveCountryIso2($arrRow);
if ($strIso2 === '') {
$strIso2 = strtoupper(trim((string)($arrRow['country_iso2'] ?? '')));
}
if (strlen($strIso2) === 3 && ctype_alpha($strIso2)) {
return $strIso2;
}
return fxChronotrackApiSyncIso2ToIso3($strIso2);
}
/**
* MSIN-4574 — libellé pays (country_name CT / export COUNTRY_NAME).
*/
function fxChronotrackApiSyncResolveCountryLabel(array $arrRow) {
$strName = trim((string)($arrRow['country_name'] ?? ''));
if ($strName !== '') {
return $strName;
}
$strIso2 = fxChronotrackApiSyncResolveCountryIso2($arrRow);
static $tabByIso2 = array(
'CA' => 'Canada',
'US' => 'United States',
'MX' => 'Mexico',
'FR' => 'France',
'GB' => 'United Kingdom',
'BE' => 'Belgium',
'CH' => 'Switzerland',
'DE' => 'Germany',
'ES' => 'Spain',
'IT' => 'Italy',
'AU' => 'Australia',
'NZ' => 'New Zealand',
'BR' => 'Brazil',
'JP' => 'Japan',
'CN' => 'China',
'NL' => 'Netherlands',
'IE' => 'Ireland',
'PT' => 'Portugal',
'PL' => 'Poland',
'AT' => 'Austria',
'SE' => 'Sweden',
'NO' => 'Norway',
'DK' => 'Denmark',
'FI' => 'Finland',
);
if ($strIso2 !== '' && isset($tabByIso2[$strIso2])) {
return $tabByIso2[$strIso2];
}
$strIso3 = fxChronotrackApiSyncResolveCountryCode($arrRow);
static $tabByIso3 = array(
'CAN' => 'Canada',
'USA' => 'United States',
'FRA' => 'France',
);
if ($strIso3 !== '' && isset($tabByIso3[$strIso3])) {
return $tabByIso3[$strIso3];
}
return $strIso2 !== '' ? $strIso2 : $strIso3;
}
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';
}
}
/**
* MSIN-4567 — MS1 par_sexe → ChronoTrack athlete_sex (enum API /meta).
* CT : M=Male, F=Female, NB=Non-Binary, NOT SPECIFIED=Unspecified.
*/
function fxChronotrackApiSyncNormalizeSex($strSexe) {
$strSexe = strtolower(trim((string)$strSexe));
if ($strSexe === 'f') {
return 'F';
}
if ($strSexe === 'h' || $strSexe === 'm') {
return 'M';
}
if ($strSexe === 'n') {
return 'NB';
}
if ($strSexe === '' || $strSexe === 'a') {
return 'NOT SPECIFIED';
}
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;
}
/**
* MSIN-4574 — comparer dossards CT/MS1 (2342 == 02342).
*/
function fxChronotrackApiSyncBibNorm($strBib) {
$str = trim((string)$strBib);
if ($str === '') {
return '';
}
if (ctype_digit($str)) {
return (string)intval($str);
}
return strtolower($str);
}
/**
* MSIN-4574 — conflit bib : trouver qui détient déjà ce dossard sur CT et le vider.
* Sans ça, on ne libère que « nous » → boucle swap max. Automatique, pas manuel.
*
* @return array{cleared:int,holders:array,message:string}
*/
function fxChronotrackApiSyncClearOtherBibHolderOnCt(
$intCtEventId,
$strBib,
$strExceptExternalId = '',
$strExceptEntryId = '',
$intEveId = 0,
$blnForceRefresh = false
) {
$intCtEventId = intval($intCtEventId);
$intEveId = intval($intEveId);
$strBibWanted = fxChronotrackApiSyncBibNorm($strBib);
$strExceptExternalId = trim((string)$strExceptExternalId);
$strExceptEntryId = trim((string)$strExceptEntryId);
$arrOut = array('cleared' => 0, 'holders' => array(), 'message' => '');
if ($intCtEventId <= 0 || $strBibWanted === '') {
$arrOut['message'] = 'paramètres manquants';
return $arrOut;
}
static $tabEntitiesByEvent = array();
$strCacheKey = (string)$intCtEventId;
if ($blnForceRefresh || !isset($tabEntitiesByEvent[$strCacheKey])) {
$arrFetch = fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId);
if (($arrFetch['state'] ?? '') !== 'ok') {
$arrOut['message'] = (string)($arrFetch['message'] ?? 'lecture CT échouée');
return $arrOut;
}
$tabEntitiesByEvent[$strCacheKey] = is_array($arrFetch['entities'] ?? null)
? $arrFetch['entities']
: array();
}
$tabEntities = &$tabEntitiesByEvent[$strCacheKey];
foreach ($tabEntities as $intIdx => $arrEntity) {
if (!is_array($arrEntity)) {
continue;
}
$strTheirBib = fxChronotrackApiSyncBibNorm(
fxChronotrackApiRaceField($arrEntity, array('bib', 'bib_number', 'entry_bib'))
);
if ($strTheirBib === '' || $strTheirBib !== $strBibWanted) {
continue;
}
$strTheirEntryId = fxChronotrackApiEntityId($arrEntity);
$strTheirExt = fxChronotrackApiEntityExternalId($arrEntity);
if ($strExceptEntryId !== '' && $strTheirEntryId !== '' && $strTheirEntryId === $strExceptEntryId) {
continue;
}
if ($strExceptExternalId !== '' && $strTheirExt !== '' && $strTheirExt === $strExceptExternalId) {
continue;
}
if ($strTheirEntryId === '') {
continue;
}
$arrClearPayload = array(
'entry_id' => $strTheirEntryId,
'external_id' => $strTheirExt !== '' ? $strTheirExt : '0',
'event_id' => $intCtEventId,
'bib' => '',
);
$intRaceId = intval(fxChronotrackApiRaceField($arrEntity, array('race_id', 'ct_race_id')));
if ($intRaceId > 0) {
$arrClearPayload['race_id'] = $intRaceId;
}
$strStatus = trim((string)fxChronotrackApiRaceField(
$arrEntity,
array('entry_status', 'status', 'reg_status')
));
if ($strStatus !== '') {
$arrClearPayload['entry_status'] = $strStatus;
$arrClearPayload['status'] = $strStatus;
}
$arrWrite = fxChronotrackApiSyncPutEntry($strTheirEntryId, $arrClearPayload);
$arrOut['holders'][] = array(
'entry_id' => $strTheirEntryId,
'external_id' => $strTheirExt,
'name' => fxChronotrackApiEntityEntryLabel($arrEntity),
'ok' => (($arrWrite['state'] ?? '') === 'ok'),
'message' => (string)($arrWrite['message'] ?? ''),
);
if (($arrWrite['state'] ?? '') === 'ok') {
$arrOut['cleared']++;
// Cache : dossard libéré pour les prochains conflits du même job
$tabEntities[$intIdx]['bib'] = '';
if ($intEveId > 0) {
fxChronotrackApiSyncLog(
$intEveId,
0,
'bib_holder_clear',
'ok',
'dossard=' . $strBibWanted
. ' retiré de entry_id=' . $strTheirEntryId
. ' external_id=' . $strTheirExt
. ' nom=' . fxChronotrackApiEntityEntryLabel($arrEntity)
);
}
} elseif ($intEveId > 0) {
fxChronotrackApiSyncLog(
$intEveId,
0,
'bib_holder_clear',
'error',
'dossard=' . $strBibWanted
. ' entry_id=' . $strTheirEntryId
. ' — ' . ($arrWrite['message'] ?? 'échec')
);
}
}
$arrOut['message'] = ($arrOut['cleared'] > 0)
? ('détenteur(s) libéré(s)=' . $arrOut['cleared'])
: 'aucun autre détenteur trouvé';
return $arrOut;
}
/**
* MSIN-4574 — message déchec bib lisible (UI / logs), pas du jargon RETRY_BIB.
*/
function fxChronotrackApiSyncHumanBibBlockedMessage(array $arrMeta, $strBib) {
$strName = trim((string)($arrMeta['name'] ?? ''));
$strExt = trim((string)($arrMeta['external_id'] ?? ''));
$strBib = trim((string)$strBib);
$str = 'Dossard ' . ($strBib !== '' ? $strBib : '?')
. ' encore pris après libération auto du détenteur ChronoTrack';
if ($strName !== '') {
$str .= ' — ' . $strName;
}
if ($strExt !== '') {
$str .= ' (ext ' . $strExt . ')';
}
$str .= '. Relancer Tout renvoyer — le système réessaie automatiquement.';
return $str;
}
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);
}
/**
* MSIN-4574 — code pays présent dans un payload CT (location_country prioritaire).
* Calibrage : CT stocke ISO-2 dans location_country (ex. CA).
*/
function fxChronotrackApiSyncCountryFromPayload(array $arrPayload) {
$str = trim((string)($arrPayload['location_country'] ?? ''));
if ($str !== '') {
return $str;
}
return trim((string)($arrPayload['country_code'] ?? ''));
}
/**
* MSIN-4574 — couverture pays sur les transférables (warning, pas blocage).
*
* @param array $tabTransferable items classify (row + payload optionnel)
* @return array{total:int,with_country:int,without_country:int,no_pay_id:int}
*/
function fxChronotrackApiSyncCountryCoverageFromTransferable(array $tabTransferable) {
$intTotal = count($tabTransferable);
$intWith = 0;
$intNoPay = 0;
foreach ($tabTransferable as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$arrRow = is_array($arrItem['row'] ?? null) ? $arrItem['row'] : array();
if (intval($arrRow['pay_id'] ?? 0) <= 0) {
$intNoPay++;
}
$strCode = '';
if (is_array($arrItem['payload'] ?? null)) {
$strCode = fxChronotrackApiSyncCountryFromPayload($arrItem['payload']);
}
if ($strCode === '') {
$strCode = fxChronotrackApiSyncResolveCountryCode($arrRow);
}
if ($strCode !== '') {
$intWith++;
}
}
return array(
'total' => $intTotal,
'with_country' => $intWith,
'without_country' => max(0, $intTotal - $intWith),
'no_pay_id' => $intNoPay,
);
}
/**
* MSIN-4574 — fragment log « pays=CAN » / « pays=— ».
*/
function fxChronotrackApiSyncLogCountryFragment(array $arrPayload = array(), array $arrRow = array()) {
$str = fxChronotrackApiSyncCountryFromPayload($arrPayload);
if ($str === '' && count($arrRow) > 0) {
$str = fxChronotrackApiSyncResolveCountryCode($arrRow);
}
return 'pays=' . ($str !== '' ? $str : '—');
}
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, $blnBuildPayloads = true) {
$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 = null;
if ($blnBuildPayloads) {
$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 = null;
if ($blnBuildPayloads) {
$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,
'country_coverage' => fxChronotrackApiSyncCountryCoverageFromTransferable($tabTransferable),
'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,"
. " (SELECT pay_nom_en FROM inscriptions_pays WHERE pay_id = p.pay_id) AS country_name";
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,"
. " (SELECT pay_nom_en FROM inscriptions_pays WHERE pay_id = p.pay_id) AS country_name";
}
$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.pay_id, p.pro_id,"
. " 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;
}
// MSIN-4574 — pay_iso (ISO-2) = source pour location_country CT
$strCountry = fxChronotrackApiSyncResolveCountryIso2($arrRow);
$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;
}
// MSIN-4574 — meta CT entry : location_* (pas street/city/country_code du format résultats).
// On envoie les clés meta + alias courts (CT accepte souvent les deux pour identité).
$strStreet = trim((string)($arrRow['par_adresse'] ?? ''));
if ($strStreet !== '') {
$arrEntry['location_street'] = $strStreet;
$arrEntry['street'] = $strStreet;
}
$strCity = trim((string)($arrRow['par_ville'] ?? ''));
if ($strCity !== '') {
$arrEntry['location_city'] = $strCity;
$arrEntry['city'] = $strCity;
}
$strPostal = trim((string)($arrRow['par_codepostal'] ?? ''));
if ($strPostal !== '') {
$arrEntry['location_postal_code'] = $strPostal;
$arrEntry['postal_code'] = $strPostal;
}
if ($strState !== '') {
$arrEntry['location_region'] = $strState;
$arrEntry['state_code'] = $strState;
}
// MSIN-4574 — pays / province (calibrage sonde lecture + export confirmed entries 2026-08-11) :
// location_country = ISO-2 (CA), location_region = pro_iso (QC),
// country_name = libellé, country_code = ISO-2 (comme export COUNTRY_CODE).
$strIso2 = fxChronotrackApiSyncResolveCountryIso2($arrRow);
$strCountryName = fxChronotrackApiSyncResolveCountryLabel($arrRow);
if ($strIso2 !== '') {
$arrEntry['location_country'] = $strIso2;
$arrEntry['country_code'] = $strIso2;
}
if ($strCountryName !== '') {
$arrEntry['country_name'] = $strCountryName;
}
$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
. ' ' . fxChronotrackApiSyncLogCountryFragment($arrPayload)
. ($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 le détenteur CT, réessayer, sinon clear + 2e passe
if ($strBibWanted !== '' && fxChronotrackApiSyncIsBibConflictMessage($strErr)) {
// MSIN-4574 — auto : vider le dossard chez QUI le détient déjà sur CT (pas seulement chez nous)
fxChronotrackApiSyncClearOtherBibHolderOnCt(
$intCtEventId,
$strBibWanted,
(string)($arrMeta['external_id'] ?? ''),
$strEntryId,
$intEveId,
false
);
// Réessai immédiat avec le dossard voulu (souvent OK si le détenteur était un orphelin / autre)
if ($strEntryId !== '') {
$arrAfterHolder = fxChronotrackApiSyncPutEntry($strEntryId, $arrPayload);
} else {
$arrAfterHolder = fxChronotrackApiSyncPostEntries(
$intCtEventId,
array(fxChronotrackApiSyncPayloadForPost($arrPayload))
);
}
if (($arrAfterHolder['state'] ?? '') === 'ok') {
if ($intEveId > 0) {
if ($strMethod === 'POST' && $strEntryId === '') {
fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrAfterHolder['json'] ?? null, array($arrMeta));
}
fxChronotrackApiSyncLogPushEntryOk(
$intEveId,
$arrMeta,
$arrPayload,
$strMethod . '+holder_clear'
);
}
return array('ok' => 1, 'err' => 0, 'bib_retry' => array(), 'method' => $strMethod);
}
$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'] ?? '')
. ' ' . fxChronotrackApiSyncLogCountryFragment($arrPayload)
. ' — ' . $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-4574 — dabord libérer le détenteur CT (auto)
$strEntryIdOne = trim((string)($arrPayload['entry_id'] ?? ''));
fxChronotrackApiSyncClearOtherBibHolderOnCt(
$intCtEventId,
$strBibWanted,
(string)($arrM['external_id'] ?? ''),
$strEntryIdOne,
$intEveId,
false
);
$arrAfterHolder = ($strEntryIdOne !== '')
? fxChronotrackApiSyncPutEntry($strEntryIdOne, $arrPayload)
: fxChronotrackApiSyncPostEntries(
$intCtEventId,
array(fxChronotrackApiSyncPayloadForPost($arrPayload))
);
if (($arrAfterHolder['state'] ?? '') === 'ok') {
if ($intEveId > 0) {
if ($strEntryIdOne === '') {
fxChronotrackApiSyncApplyEntryIdsFromApiJson($arrAfterHolder['json'] ?? null, array($arrM));
}
fxChronotrackApiSyncLogPushEntryOk($intEveId, $arrM, $arrPayload, 'POST+holder_clear');
}
return array('ok' => 1, 'err' => 0, 'bib_retry' => array());
}
// MSIN-4328 — conflit sans entry_id : tenter clear bib='' puis sans champ bib
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'] ?? '')
. ' ' . fxChronotrackApiSyncLogCountryFragment($arrPayload)
. ' — ' . $strOneErr
);
}
return array('ok' => 0, 'err' => 1, 'bib_retry' => array());
}
// Sous-lots : 100 → 25 → 5 → 1 (échecs / conflits seulement)
$intSubSize = ($intCount > 100) ? 100 : (($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 / MSIN-4574 — 2e passe dossards (swaps) avec gestion de conflit.
* Plusieurs tours : clear → réassign, pour les échanges croisés (A↔B).
*
* @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);
$tabPending = array();
foreach ($tabBibRetry as $arrItem) {
if (!is_array($arrItem) || !is_array($arrItem['payload'] ?? null)) {
continue;
}
$strBib = isset($arrItem['payload']['bib']) ? trim((string)$arrItem['payload']['bib']) : '';
if ($strBib === '') {
continue;
}
$tabPending[] = $arrItem;
}
// MSIN-4574 — jusquà 5 tours (swap circulaire / dossard encore tenu)
$intMaxRounds = 5;
for ($intRound = 1; $intRound <= $intMaxRounds && count($tabPending) > 0; $intRound++) {
$tabNext = array();
$intRoundOk = 0;
$intRoundQueued = 0;
foreach ($tabPending as $arrItem) {
$arrPayload = $arrItem['payload'];
$arrMeta = is_array($arrItem['meta'] ?? null) ? $arrItem['meta'] : array();
$arrRes = fxChronotrackApiSyncPushOneEntryWithBibConflictHandling(
$intCtEventId,
$arrPayload,
$arrMeta,
$intEveId
);
if (intval($arrRes['ok'] ?? 0) > 0 && empty($arrRes['bib_retry'])) {
$intOk++;
$intRoundOk++;
continue;
}
if (!empty($arrRes['bib_retry']) && is_array($arrRes['bib_retry'])) {
foreach ($arrRes['bib_retry'] as $arrR) {
if (is_array($arrR)) {
$tabNext[] = $arrR;
$intRoundQueued++;
}
}
continue;
}
$intErr++;
$strBib = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : '';
$strMsg = 'RETRY_BIB round=' . $intRound
. ' external_id=' . ($arrMeta['external_id'] ?? '')
. ' dossard=' . $strBib
. ' — échec';
if (count($tabErrors) < 20) {
$tabErrors[] = $strMsg;
}
if ($intEveId > 0) {
fxChronotrackApiSyncLog(
$intEveId,
intval($arrMeta['par_id'] ?? 0),
'push_entry',
'error',
$strMsg
);
}
}
if ($intEveId > 0) {
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_bib_retry',
'ok',
'round=' . $intRound
. ' ok=' . $intRoundOk
. ' queued=' . $intRoundQueued
. ' pending_next=' . count($tabNext)
);
}
// Aucun assign OK et aucun clear/queue → plus de progrès
if (count($tabNext) > 0 && $intRoundOk === 0 && $intRoundQueued === 0) {
foreach ($tabNext as $arrLeft) {
$intErr++;
$arrMetaL = is_array($arrLeft['meta'] ?? null) ? $arrLeft['meta'] : array();
$strBibL = isset($arrLeft['payload']['bib']) ? trim((string)$arrLeft['payload']['bib']) : '';
$strMsgL = 'RETRY_BIB bloqué (swap) external_id='
. ($arrMetaL['external_id'] ?? '')
. ' dossard=' . $strBibL;
if (count($tabErrors) < 20) {
$tabErrors[] = $strMsgL;
}
}
$tabPending = array();
break;
}
$tabPending = $tabNext;
}
// Reste après max tours
foreach ($tabPending as $arrLeft) {
$intErr++;
$arrMetaL = is_array($arrLeft['meta'] ?? null) ? $arrLeft['meta'] : array();
$strBibL = isset($arrLeft['payload']['bib']) ? trim((string)$arrLeft['payload']['bib']) : '';
$strMsgL = 'RETRY_BIB max tours external_id='
. ($arrMetaL['external_id'] ?? '')
. ' dossard=' . $strBibL;
if (count($tabErrors) < 20) {
$tabErrors[] = $strMsgL;
}
}
return array('ok' => $intOk, 'err' => $intErr, 'errors' => $tabErrors);
}
/**
* MSIN-4574 — sonde lecture seule pays/province CT (GET entry, aucun PUT/POST).
* Pour calibrer le format stocké quand lUI Athlete Info est correcte (sans écraser).
*
* @param int $intEveId
* @param array $arrOptions external_id optionnel pour cibler un participant
*/
function fxChronotrackApiSyncProbePaysRead($intEveId, $arrOptions = array()) {
$intEveId = intval($intEveId);
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
}
if (!fxChronotrackApiSettingsConfigured()) {
return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage());
}
$intCtEventId = intval($arrConfig['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
$arrClass = fxChronotrackApiSyncClassifyParticipants(
$tabParticipants,
$arrRaceMap,
$intCtEventId,
false
);
if (count($arrClass['transferable']) === 0) {
return array('state' => 'error', 'message' => 'Aucun participant transférable pour la lecture.');
}
$arrFirst = null;
$strWantExt = trim((string)($arrOptions['external_id'] ?? ''));
$strWantBib = trim((string)($arrOptions['bib'] ?? ''));
$strWantName = trim((string)($arrOptions['name'] ?? ''));
if ($strWantExt !== '') {
foreach ($arrClass['transferable'] as $arrItem) {
$arrRowTry = $arrItem['row'] ?? array();
$strExtTry = trim((string)($arrRowTry['par_id_original'] ?? ''));
if ($strExtTry === $strWantExt) {
$arrFirst = $arrItem;
break;
}
}
if ($arrFirst === null) {
return array(
'state' => 'error',
'message' => 'external_id ' . $strWantExt . ' introuvable parmi les éligibles MS1.',
);
}
} elseif ($strWantBib !== '' && $strWantBib !== '0') {
foreach ($arrClass['transferable'] as $arrItem) {
$arrRowTry = $arrItem['row'] ?? array();
$strBibTry = fxChronotrackApiSyncExtractBib($arrRowTry);
if ($strBibTry !== '' && $strBibTry === $strWantBib) {
$arrFirst = $arrItem;
break;
}
}
if ($arrFirst === null) {
return array(
'state' => 'error',
'message' => 'Dossard ' . $strWantBib . ' introuvable parmi les éligibles MS1.',
);
}
} elseif ($strWantName !== '') {
$strWantNameLow = function_exists('mb_strtolower')
? mb_strtolower($strWantName)
: strtolower($strWantName);
foreach ($arrClass['transferable'] as $arrItem) {
$arrRowTry = $arrItem['row'] ?? array();
$strNom = trim((string)(($arrRowTry['par_prenom'] ?? '') . ' ' . ($arrRowTry['par_nom'] ?? '')));
$strNomLow = function_exists('mb_strtolower') ? mb_strtolower($strNom) : strtolower($strNom);
if ($strNom !== '' && (
(function_exists('mb_strpos') && mb_strpos($strNomLow, $strWantNameLow) !== false)
|| (!function_exists('mb_strpos') && strpos($strNomLow, $strWantNameLow) !== false)
)) {
$arrFirst = $arrItem;
break;
}
}
if ($arrFirst === null) {
return array(
'state' => 'error',
'message' => 'Aucun éligible dont le nom contient « ' . $strWantName . ' ».',
);
}
} else {
// Défaut : participant avec pays + déjà lié CT (sinon premier avec pays)
foreach ($arrClass['transferable'] as $arrItem) {
$arrRowTry = $arrItem['row'] ?? array();
if (fxChronotrackApiSyncResolveCountryCode($arrRowTry) === '') {
continue;
}
if (intval($arrRowTry['ct_entry_id'] ?? 0) > 0) {
$arrFirst = $arrItem;
break;
}
if ($arrFirst === null) {
$arrFirst = $arrItem;
}
}
if ($arrFirst === null) {
$arrFirst = $arrClass['transferable'][0];
}
}
$arrRow = $arrFirst['row'] ?? array();
$strExt = trim((string)($arrRow['par_id_original'] ?? ''));
if ($strExt === '' || $strExt === '0') {
$strExt = (string)intval($arrRow['par_id_original'] ?? 0);
}
$intCtEntryId = intval($arrRow['ct_entry_id'] ?? 0);
if ($intCtEntryId <= 0 && $strExt !== '' && $strExt !== '0') {
$arrCtFetch = fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId);
if (($arrCtFetch['state'] ?? '') === 'ok') {
$arrEnt = $arrCtFetch['entries'][$strExt] ?? null;
if (is_array($arrEnt)) {
$intCtEntryId = intval(fxChronotrackApiEntityId($arrEnt));
}
}
}
if ($intCtEntryId <= 0) {
return array(
'state' => 'error',
'message' => 'Pas de ct_entry_id pour external_id=' . $strExt
. ' — impossible de lire sans écrire. Liez dabord via un push, ou saisissez un entry_id connu.',
'external_id' => $strExt,
'par_id' => intval($arrRow['par_id'] ?? 0),
);
}
$arrGet = fxChronotrackApiOAuthApiGet('entry/' . $intCtEntryId);
if (($arrGet['state'] ?? '') !== 'ok') {
return array(
'state' => 'error',
'message' => $arrGet['message'] ?? 'Erreur GET entry',
'ct_entry_id' => $intCtEntryId,
'external_id' => $strExt,
'http_code' => intval($arrGet['http']['http_code'] ?? 0),
);
}
$arrRb = is_array($arrGet['json'] ?? null) ? $arrGet['json'] : array();
if (isset($arrRb['entry']) && is_array($arrRb['entry'])) {
$arrRb = $arrRb['entry'];
} elseif (isset($arrRb['event_entry'][0]) && is_array($arrRb['event_entry'][0])) {
$arrRb = $arrRb['event_entry'][0];
}
$arrLocation = array(
'location_country' => trim((string)($arrRb['location_country'] ?? '')),
'location_region' => trim((string)($arrRb['location_region'] ?? '')),
'location_city' => trim((string)($arrRb['location_city'] ?? '')),
'location_postal_code' => trim((string)($arrRb['location_postal_code'] ?? '')),
'location_street' => trim((string)($arrRb['location_street'] ?? '')),
'country_code' => trim((string)($arrRb['country_code'] ?? '')),
'country_name' => trim((string)($arrRb['country_name'] ?? '')),
'state_code' => trim((string)($arrRb['state_code'] ?? '')),
'state_name' => trim((string)($arrRb['state_name'] ?? '')),
'city' => trim((string)($arrRb['city'] ?? '')),
'postal_code' => trim((string)($arrRb['postal_code'] ?? '')),
'first_name' => trim((string)($arrRb['first_name'] ?? '')),
'last_name' => trim((string)($arrRb['last_name'] ?? '')),
'bib' => trim((string)($arrRb['bib'] ?? '')),
);
$strNameMs1 = trim((string)(($arrRow['par_prenom'] ?? '') . ' ' . ($arrRow['par_nom'] ?? '')));
$strBibMs1 = fxChronotrackApiSyncExtractBib($arrRow);
$strNameCt = trim((string)(
($arrLocation['first_name'] !== '' || $arrLocation['last_name'] !== '')
? ($arrLocation['first_name'] . ' ' . $arrLocation['last_name'])
: ($arrRb['entry_name'] ?? $arrRb['name'] ?? '')
));
return array(
'state' => 'ok',
'message' => 'Lecture seule OK — aucune écriture CT.',
'read_only' => true,
'ct_event_id' => $intCtEventId,
'ct_entry_id' => $intCtEntryId,
'external_id' => $strExt,
'par_id' => intval($arrRow['par_id'] ?? 0),
'name' => $strNameMs1,
'bib' => $strBibMs1,
'how' => 'GET entry/' . $intCtEntryId,
// Bloc identité lisible (humain)
'who' => array(
'name_ms1' => $strNameMs1,
'name_ct' => $strNameCt,
'bib_ms1' => $strBibMs1,
'bib_ct' => $arrLocation['bib'],
'city_ms1' => trim((string)($arrRow['par_ville'] ?? '')),
'street_ms1' => trim((string)($arrRow['par_adresse'] ?? '')),
'postal_ms1'=> trim((string)($arrRow['par_codepostal'] ?? '')),
),
'ms1' => array(
'pay_id' => intval($arrRow['pay_id'] ?? 0),
'pro_id' => intval($arrRow['pro_id'] ?? 0),
'country_iso2' => trim((string)($arrRow['country_iso2'] ?? '')),
'country_iso3' => trim((string)($arrRow['country_iso3'] ?? '')),
'country_code' => fxChronotrackApiSyncResolveCountryCode($arrRow),
'country_label' => fxChronotrackApiSyncResolveCountryLabel($arrRow),
'state_iso' => strtoupper(trim((string)($arrRow['state_iso'] ?? ''))),
),
'ct_location' => $arrLocation,
'http_code' => intval($arrGet['http']['http_code'] ?? 0),
'raw_keys' => array_keys($arrRb),
);
}
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).');
}
// MSIN-4574 — préférer un participant avec pays MS1 pour valider location_country
$arrFirst = null;
foreach ($arrClass['transferable'] as $arrItem) {
$arrRowTry = $arrItem['row'] ?? array();
if (fxChronotrackApiSyncResolveCountryCode($arrRowTry) !== '') {
$arrFirst = $arrItem;
break;
}
}
if ($arrFirst === null) {
$arrFirst = $arrClass['transferable'][0];
}
$arrPayload = $arrFirst['payload'];
$arrRow = $arrFirst['row'];
$arrMeta = array(
'par_id' => intval($arrRow['par_id'] ?? 0),
'external_id' => (string)($arrPayload['external_id'] ?? ''),
'name' => trim((string)(($arrRow['par_prenom'] ?? '') . ' ' . ($arrRow['par_nom'] ?? ''))),
);
$arrCtFetch = fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId);
if ($arrCtFetch['state'] === 'ok') {
$strExt = $arrPayload['external_id'];
$arrPayload = fxChronotrackApiSyncEnrichPayloadWithCtEntry(
$arrPayload,
$arrCtFetch['entries'][$strExt] ?? null
);
}
// MSIN-4574 — sonde pays : ne pas échouer sur conflit dossard (cause typique « Erreur » + CT inchangé)
$strEntryId = trim((string)($arrPayload['entry_id'] ?? ''));
if ($strEntryId !== '') {
$arrLocOnly = array(
'external_id' => (string)($arrPayload['external_id'] ?? ''),
'first_name' => (string)($arrPayload['first_name'] ?? ''),
'last_name' => (string)($arrPayload['last_name'] ?? ''),
'sex' => (string)($arrPayload['sex'] ?? ''),
'entry_status' => (string)($arrPayload['entry_status'] ?? 'CONF'),
'status' => (string)($arrPayload['status'] ?? 'CONF'),
);
if (!empty($arrPayload['race_id'])) {
$arrLocOnly['race_id'] = $arrPayload['race_id'];
}
if (!empty($arrPayload['event_id'])) {
$arrLocOnly['event_id'] = $arrPayload['event_id'];
}
if (!empty($arrPayload['location_street'])) {
$arrLocOnly['location_street'] = $arrPayload['location_street'];
}
if (!empty($arrPayload['location_city'])) {
$arrLocOnly['location_city'] = $arrPayload['location_city'];
}
if (!empty($arrPayload['location_postal_code'])) {
$arrLocOnly['location_postal_code'] = $arrPayload['location_postal_code'];
}
if (!empty($arrPayload['location_region'])) {
$arrLocOnly['location_region'] = $arrPayload['location_region'];
}
if (!empty($arrPayload['location_country'])) {
$arrLocOnly['location_country'] = $arrPayload['location_country'];
}
if (!empty($arrPayload['country_code'])) {
$arrLocOnly['country_code'] = $arrPayload['country_code'];
}
if (!empty($arrPayload['country_name'])) {
$arrLocOnly['country_name'] = $arrPayload['country_name'];
}
$arrPost = fxChronotrackApiSyncPutEntry($strEntryId, $arrLocOnly);
$arrPost['method'] = 'PUT_location';
// payload affiché = ce qui a été vraiment tenté pour le pays
$arrPayload = array_merge($arrPayload, $arrLocOnly);
$arrPayload['entry_id'] = $strEntryId;
} else {
$arrPost = fxChronotrackApiSyncPushOneEntryWithBibConflictHandling(
$intCtEventId,
$arrPayload,
$arrMeta,
$intEveId
);
// normaliser forme retour (ok/err → state)
if (!isset($arrPost['state'])) {
$arrPost['state'] = (intval($arrPost['ok'] ?? 0) > 0) ? 'ok' : 'error';
$arrPost['message'] = ($arrPost['state'] === 'ok')
? 'OK'
: (isset($arrPost['errors'][0]) ? $arrPost['errors'][0] : 'Échec sonde');
$arrPost['method'] = 'POST_bib_safe';
$arrPost['http'] = array('http_code' => 0, 'body' => '');
}
}
// Relecture pays CT (meta = location_country)
$arrCountryReadback = array(
'location_country' => '',
'location_region' => '',
'country_code' => '',
'country_name' => '',
'how' => '',
);
$intCtEntryId = intval($arrPayload['entry_id'] ?? $arrRow['ct_entry_id'] ?? 0);
if ($intCtEntryId <= 0 && ($arrPost['state'] ?? '') === 'ok') {
$arrCtFetch2 = fxChronotrackApiSyncFetchCtEntriesIndexed($intCtEventId);
if (($arrCtFetch2['state'] ?? '') === 'ok') {
$arrEnt = $arrCtFetch2['entries'][(string)$arrPayload['external_id']] ?? null;
if (is_array($arrEnt)) {
$intCtEntryId = intval(fxChronotrackApiEntityId($arrEnt));
}
}
}
if ($intCtEntryId > 0) {
$arrGet = fxChronotrackApiOAuthApiGet('entry/' . $intCtEntryId);
$arrCountryReadback['how'] = 'GET entry/' . $intCtEntryId;
if (($arrGet['state'] ?? '') === 'ok' && is_array($arrGet['json'] ?? null)) {
$arrRb = $arrGet['json'];
if (isset($arrRb['entry']) && is_array($arrRb['entry'])) {
$arrRb = $arrRb['entry'];
} elseif (isset($arrRb['event_entry'][0]) && is_array($arrRb['event_entry'][0])) {
$arrRb = $arrRb['event_entry'][0];
}
$arrCountryReadback['location_country'] = trim((string)(
$arrRb['location_country'] ?? ''
));
$arrCountryReadback['location_region'] = trim((string)(
$arrRb['location_region'] ?? $arrRb['state_code'] ?? ''
));
$arrCountryReadback['country_code'] = trim((string)($arrRb['country_code'] ?? ''));
$arrCountryReadback['country_name'] = trim((string)($arrRb['country_name'] ?? ''));
}
}
$intWithCountry = 0;
$intNoPayId = 0;
foreach ($arrClass['transferable'] as $arrItem) {
$arrR = $arrItem['row'] ?? array();
if (intval($arrR['pay_id'] ?? 0) <= 0) {
$intNoPayId++;
}
if (fxChronotrackApiSyncResolveCountryCode($arrR) !== '') {
$intWithCountry++;
}
}
$strSentLabel = trim((string)($arrPayload['location_country'] ?? ''));
$strRbCountry = trim((string)($arrCountryReadback['location_country'] ?? ''));
$blnPaysMatch = ($strSentLabel !== '' && strcasecmp($strSentLabel, $strRbCountry) === 0);
$strState = ($arrPost['state'] ?? '') === 'ok'
? ($blnPaysMatch ? 'ok' : 'partial')
: 'error';
$strMessage = trim((string)($arrPost['message'] ?? ''));
if ($strMessage === '') {
$strMessage = ($strState === 'ok') ? 'OK' : 'Erreur';
}
if ($strState === 'partial') {
$strMessage = 'Écriture OK mais pays CT ≠ envoyé (envoyé « ' . $strSentLabel
. ' », CT « ' . ($strRbCountry !== '' ? $strRbCountry : 'vide') . ' »)';
}
return array(
'state' => $strState,
'message' => $strMessage,
'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-4574 — diagnostic pays
'ms1_pay_id' => intval($arrRow['pay_id'] ?? 0),
'ms1_country_iso2' => trim((string)($arrRow['country_iso2'] ?? '')),
'ms1_country_iso3' => trim((string)($arrRow['country_iso3'] ?? '')),
'ms1_country_resolved' => fxChronotrackApiSyncResolveCountryCode($arrRow),
'ms1_country_iso2_sent'=> fxChronotrackApiSyncResolveCountryIso2($arrRow),
'ms1_country_label' => fxChronotrackApiSyncResolveCountryLabel($arrRow),
'sent_location_country'=> $strSentLabel,
'sent_country_code' => trim((string)($arrPayload['country_code'] ?? '')),
'sent_location_region' => trim((string)($arrPayload['location_region'] ?? '')),
'ct_country_readback' => $arrCountryReadback,
'pays_match' => $blnPaysMatch,
'transferable_with_country' => $intWithCountry,
'transferable_total' => count($arrClass['transferable']),
'transferable_no_pay_id' => $intNoPayId,
);
}
/**
* 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 = 200;
$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);
// MSIN-4328 — preview rapide : pas de build payloads / entry_notes (seulement compteurs + exclus)
$arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId, false);
$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'],
// Pas de liste plate « blocked » (doublon lourd) — lUI utilise blocked_by_type
'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'],
'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,
'country_coverage' => $arrClass['country_coverage'] ?? array(),
'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);
}
// Garder entry_id en meta/job pour retry dossard ; lenvoi lot utilise POST upsert (pas PUT unitaire).
$tabBatch = array();
$tabMeta = array();
foreach ($tabPending as $arrItem) {
$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';
$arrCountryCov = $arrClass['country_coverage'] ?? array();
$strPaysLog = ' pays_ok=' . intval($arrCountryCov['with_country'] ?? 0)
. '/' . intval($arrCountryCov['total'] ?? count($arrClass['transferable']))
. ' sans_pays=' . intval($arrCountryCov['without_country'] ?? 0)
. ' sans_pay_id=' . intval($arrCountryCov['no_pay_id'] ?? 0);
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_prepare',
'ok',
$strMode . ' — job prêt — pending=' . count($tabBatch)
. ' éligibles=' . count($arrClass['transferable'])
. ' exclus=' . intval($arrClass['blocked_count'])
. $strPaysLog
);
// MSIN-4574 — alerte log si beaucoup sans pays (ne bloque pas le push)
if (intval($arrCountryCov['without_country'] ?? 0) > 0) {
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_prepare',
'warn',
'Sans pays MS1 (pay_id/iso) : ' . intval($arrCountryCov['without_country'])
. ' sur ' . intval($arrCountryCov['total'] ?? 0)
. ' — CT restera sans location_country pour ces lignes. Corriger pay_id ou mapping pays.'
);
}
$arrJob = array(
'ct_event_id' => $intCtEventId,
'batch' => $tabBatch,
'meta' => $tabMeta,
'blocked_count' => $arrClass['blocked_count'],
'country_coverage' => $arrCountryCov,
'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'],
'country_coverage' => $arrCountryCov,
'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 — toujours POST en lot (upsert par external_id).
// Avant : PUT unitaire si ct_entry_id → ~100 appels/lot = 12 min figé à 0/N.
// entry_id reste sur le payload pour la 2e passe dossard ; retiré seulement à lappel POST.
//
// MSIN-4574 — envoi AVEC dossard dans le POST bulk (rapide).
// Ancien modèle : strip systématique → 2e passe 1 PUT/personne = ~10 min pour 1300.
// Maintenant : conflit bib seulement → adaptive / file bib (minorité des cas).
$tabPostEntries = array();
$tabPostMeta = array();
foreach ($tabBatch as $intI => $arrPayload) {
if (!is_array($arrPayload)) {
continue;
}
$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;
$blnIdentityDone = ($intNextOffset >= $intTotal);
$blnLastPushSaved = false;
$intBibRetryOk = 0;
$intBibRetryErr = 0;
$intBibPending = count($tabBibRetryJob);
// MSIN-4574 — NE PAS enchaîner 1300 PUT dossards dans la même requête PHP que le dernier lot.
// Cétait la cause typique du HTTP 503 HTML (timeout hébergeur) après 810 min.
if ($blnIdentityDone) {
$arrJob['phase'] = ($intBibPending > 0) ? 'bibs' : 'done';
$arrJob['identity_done_at'] = time();
if ($intBibPending === 0) {
if ($intErrSum === 0) {
$blnLastPushSaved = fxChronotrackApiConfigSetLastPush($intEveId, $intOkSum);
}
// résumé final (sans passe bib)
$intPaysSent = 0;
$intPaysMiss = 0;
foreach ($tabBatchAll as $arrP) {
if (!is_array($arrP)) {
continue;
}
if (fxChronotrackApiSyncCountryFromPayload($arrP) !== '') {
$intPaysSent++;
} else {
$intPaysMiss++;
}
}
$arrCovJob = is_array($arrJob['country_coverage'] ?? null) ? $arrJob['country_coverage'] : array();
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_done',
($intErrSum === 0) ? 'ok' : 'error',
'fin identité — ok=' . $intOkSum . ' err=' . $intErrSum
. ' exclus=' . $intSkipped
. ' payload_avec_pays=' . $intPaysSent
. ' payload_sans_pays=' . $intPaysMiss
. (intval($arrCovJob['no_pay_id'] ?? 0) > 0
? (' ms1_sans_pay_id=' . intval($arrCovJob['no_pay_id']))
: '')
. (count($tabErrors) > 0
? (' — erreurs: ' . implode(' | ', array_slice($tabErrors, 0, 5)))
: '')
);
fxChronotrackApiSyncPushJobClear($intEveId);
} else {
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_chunk',
'ok',
'identité terminée — passe dossards séparée pending=' . $intBibPending
);
fxChronotrackApiSyncPushJobSave($intEveId, $arrJob);
}
} else {
$arrJob['phase'] = 'identity';
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.');
if ($blnIdentityDone && $intBibPending > 0) {
$strMessage .= ' Identité OK — dossards en file (' . $intBibPending . ').';
}
return array(
'state' => $strState,
'message' => $strMessage,
'phase' => ($blnIdentityDone && $intBibPending > 0) ? 'bibs' : (($blnIdentityDone) ? 'done' : 'identity'),
'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' => ($blnIdentityDone && $intBibPending === 0),
'identity_done' => $blnIdentityDone,
'bib_pending' => $intBibPending,
'bib_chunk_size' => MSIN_API_CHRONOTRACK_SYNC_BIB_CHUNK_SIZE,
);
}
/**
* MSIN-4574 — passe dossards découpée (après identité), un sous-lot par requête AJAX.
*/
function fxChronotrackApiSyncPushBibsPhase($intEveId, $arrOptions = array()) {
$intEveId = intval($intEveId);
$intLimit = max(1, intval($arrOptions['limit'] ?? 0));
if ($intLimit <= 0) {
$intLimit = MSIN_API_CHRONOTRACK_SYNC_BIB_CHUNK_SIZE;
}
$arrJob = fxChronotrackApiSyncPushJobLoad($intEveId);
if (!is_array($arrJob)) {
return array(
'state' => 'error',
'message' => 'Aucun job dossards en cours (relancer Tout renvoyer).',
'done' => true,
'bib_pending' => 0,
);
}
$intCtEventId = intval($arrJob['ct_event_id'] ?? 0);
$tabBibRetryJob = isset($arrJob['bib_retry']) && is_array($arrJob['bib_retry'])
? $arrJob['bib_retry']
: array();
$intPendingBefore = count($tabBibRetryJob);
if ($intCtEventId <= 0) {
fxChronotrackApiSyncPushJobClear($intEveId);
return array('state' => 'error', 'message' => 'ct_event_id manquant dans le job', 'done' => true);
}
if ($intPendingBefore === 0) {
$intOkSum = intval($arrJob['ok_sum'] ?? 0);
$intErrSum = intval($arrJob['err_sum'] ?? 0);
if ($intErrSum === 0) {
fxChronotrackApiConfigSetLastPush($intEveId, $intOkSum);
}
fxChronotrackApiSyncLog($intEveId, 0, 'push_done', ($intErrSum === 0) ? 'ok' : 'error',
'fin — ok=' . $intOkSum . ' err=' . $intErrSum . ' (pas de dossards en file)');
fxChronotrackApiSyncPushJobClear($intEveId);
return array(
'state' => 'ok',
'message' => 'Aucun dossard en file — terminé.',
'done' => true,
'bib_pending' => 0,
'pushed_ok' => 0,
'pushed_error' => 0,
'phase' => 'done',
);
}
$tabSlice = array_slice($tabBibRetryJob, 0, $intLimit);
$tabRest = array_slice($tabBibRetryJob, $intLimit);
$tabErrors = array();
$intOk = 0;
$intErr = 0;
$tabRequeue = array();
foreach ($tabSlice as $arrItem) {
if (!is_array($arrItem) || !is_array($arrItem['payload'] ?? null)) {
continue;
}
$arrPayload = $arrItem['payload'];
$arrMeta = is_array($arrItem['meta'] ?? null) ? $arrItem['meta'] : array();
$strBib = isset($arrPayload['bib']) ? trim((string)$arrPayload['bib']) : '';
if ($strBib === '') {
continue;
}
$arrRes = fxChronotrackApiSyncPushOneEntryWithBibConflictHandling(
$intCtEventId,
$arrPayload,
$arrMeta,
$intEveId
);
if (intval($arrRes['ok'] ?? 0) > 0 && empty($arrRes['bib_retry'])) {
$intOk++;
continue;
}
if (!empty($arrRes['bib_retry']) && is_array($arrRes['bib_retry'])) {
foreach ($arrRes['bib_retry'] as $arrR) {
if (is_array($arrR)) {
$tabRequeue[] = $arrR;
}
}
continue;
}
$intErr++;
$strMsg = 'RETRY_BIB external_id=' . ($arrMeta['external_id'] ?? '')
. ' dossard=' . $strBib . ' — échec';
if (count($tabErrors) < 12) {
$tabErrors[] = $strMsg;
}
}
// Réessayer les conflits en fin de file (swaps) — max tours gérés par UI via job.bib_round
$intBibRound = intval($arrJob['bib_round'] ?? 0);
$tabNewPending = array_merge($tabRest, $tabRequeue);
if (count($tabRequeue) > 0 && count($tabRest) === 0) {
$intBibRound++;
$arrJob['bib_round'] = $intBibRound;
}
// Après trop de tours sans drain : 1 dernière passe (refresh CT + clear détenteur), sinon erreur claire
if ($intBibRound >= 5 && count($tabRequeue) > 0 && count($tabRest) === 0) {
$tabStillBlocked = array();
foreach ($tabRequeue as $arrLeft) {
if (!is_array($arrLeft) || !is_array($arrLeft['payload'] ?? null)) {
continue;
}
$arrPayloadL = $arrLeft['payload'];
$arrMetaL = is_array($arrLeft['meta'] ?? null) ? $arrLeft['meta'] : array();
$strBibL = isset($arrPayloadL['bib']) ? trim((string)$arrPayloadL['bib']) : '';
$strEntryL = trim((string)($arrPayloadL['entry_id'] ?? ''));
if ($strBibL !== '') {
fxChronotrackApiSyncClearOtherBibHolderOnCt(
$intCtEventId,
$strBibL,
(string)($arrMetaL['external_id'] ?? ''),
$strEntryL,
$intEveId,
true
);
$arrLast = fxChronotrackApiSyncPushOneEntryWithBibConflictHandling(
$intCtEventId,
$arrPayloadL,
$arrMetaL,
$intEveId
);
if (intval($arrLast['ok'] ?? 0) > 0 && empty($arrLast['bib_retry'])) {
$intOk++;
continue;
}
}
$intErr++;
$tabStillBlocked[] = $arrLeft;
if (count($tabErrors) < 12) {
$tabErrors[] = fxChronotrackApiSyncHumanBibBlockedMessage($arrMetaL, $strBibL);
}
}
$tabNewPending = array();
unset($tabStillBlocked);
}
$arrJob['bib_retry'] = $tabNewPending;
$arrJob['ok_sum'] = intval($arrJob['ok_sum'] ?? 0) + $intOk;
$arrJob['err_sum'] = intval($arrJob['err_sum'] ?? 0) + $intErr;
$intPendingAfter = count($tabNewPending);
$blnDone = ($intPendingAfter === 0);
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_bib_retry',
($intErr === 0) ? 'ok' : 'error',
'slice n=' . count($tabSlice)
. ' ok=' . $intOk . ' err=' . $intErr
. ' requeue=' . count($tabRequeue)
. ' pending=' . $intPendingAfter
. ' round=' . $intBibRound
);
if ($blnDone) {
$intOkSum = intval($arrJob['ok_sum'] ?? 0);
$intErrSum = intval($arrJob['err_sum'] ?? 0);
if ($intErrSum === 0) {
fxChronotrackApiConfigSetLastPush($intEveId, $intOkSum);
}
fxChronotrackApiSyncLog(
$intEveId,
0,
'push_done',
($intErrSum === 0) ? 'ok' : 'error',
'fin — ok=' . $intOkSum . ' err=' . $intErrSum . ' (identité + dossards)'
);
fxChronotrackApiSyncPushJobClear($intEveId);
} else {
$arrJob['phase'] = 'bibs';
fxChronotrackApiSyncPushJobSave($intEveId, $arrJob);
}
return array(
'state' => ($intErr === 0) ? 'ok' : (($intOk > 0) ? 'partial' : 'error'),
'message' => 'Dossards : ' . $intOk . ' OK'
. ($intErr > 0 ? (', ' . $intErr . ' erreur(s)') : '')
. ' — reste ' . $intPendingAfter,
'phase' => $blnDone ? 'done' : 'bibs',
'pushed_ok' => $intOk,
'pushed_post' => $intOk,
'pushed_put' => 0,
'pushed_error' => $intErr,
'job_ok_sum' => intval($arrJob['ok_sum'] ?? 0),
'job_err_sum' => intval($arrJob['err_sum'] ?? 0),
'errors' => array_slice($tabErrors, 0, 20),
'done' => $blnDone,
'identity_done' => true,
'bib_pending' => $intPendingAfter,
'bib_chunk_size' => $intLimit,
'bib_before' => $intPendingBefore,
);
}
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).
* MSIN-4574 — version légère : pas de build payloads/notes (sinon gèle à ~1300).
*/
function fxChronotrackApiSyncListCtOrphans($intEveId) {
$intEveId = intval($intEveId);
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
}
if (!fxChronotrackApiSettingsConfigured()) {
return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage());
}
$intCtEventId = intval($arrConfig['ct_event_id']);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
// Pas de payloads / entry_notes — uniquement les clés external_id éligibles
$arrClass = fxChronotrackApiSyncClassifyParticipants(
$tabParticipants,
$arrRaceMap,
$intCtEventId,
false
);
$tabEligibleKeys = array();
foreach ($arrClass['transferable'] as $arrItem) {
if (!is_array($arrItem)) {
continue;
}
$strExt = trim((string)($arrItem['summary']['external_id'] ?? ''));
if ($strExt === '' || $strExt === '0') {
$strExt = (string)intval($arrItem['row']['par_id_original'] ?? 0);
}
if ($strExt !== '' && $strExt !== '0') {
$tabEligibleKeys[$strExt] = true;
}
}
$floatStart = microtime(true);
$arrFetch = fxChronotrackApiSyncFetchAllCtEntryEntities($intCtEventId);
$floatMs = (int)round((microtime(true) - $floatStart) * 1000);
if ($arrFetch['state'] !== 'ok') {
return array(
'state' => 'error',
'message' => $arrFetch['message'] ?? 'Erreur lecture entries CT',
'elapsed_ms' => $floatMs,
);
}
$tabOrphans = fxChronotrackApiSyncComputeCtOrphanRows($arrFetch['entities'], $tabEligibleKeys);
$intCtTotal = intval($arrFetch['count'] ?? count($arrFetch['entities']));
fxChronotrackApiSyncLog(
$intEveId,
0,
'reconcile',
'ok',
'list orphans=' . count($tabOrphans)
. ' ct_total=' . $intCtTotal
. ' eligible_keys=' . count($tabEligibleKeys)
. ' ms=' . $floatMs
);
return array(
'state' => 'ok',
'ct_event_id' => $intCtEventId,
'ct_total' => $intCtTotal,
'linked_count' => count($tabEligibleKeys),
'eligible_count' => count($arrClass['transferable']),
'count' => count($tabOrphans),
'orphans' => $tabOrphans,
'elapsed_ms' => $floatMs,
'message' => count($tabOrphans) . ' orphelin(s) sur ' . $intCtTotal . ' entries CT'
. ' (' . round($floatMs / 1000, 1) . ' s)',
);
}
/**
* MSIN-4574 — participants dune commande (y compris annulés / inactifs) pour WITHDRAW.
*/
function fxChronotrackApiSyncLoadParticipantsByPec($intEveId, $intPecId) {
global $objDatabase;
$intEveId = intval($intEveId);
$intPecId = intval($intPecId);
if ($intEveId <= 0 || $intPecId <= 0 || !isset($objDatabase) || !is_object($objDatabase)) {
return array();
}
$strCountryCols = " (SELECT pay_iso FROM inscriptions_pays WHERE pay_id = p.pay_id) AS country_iso2,"
. " (SELECT pay_nom_en FROM inscriptions_pays WHERE pay_id = p.pay_id) AS country_name";
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,"
. " (SELECT pay_nom_en FROM inscriptions_pays WHERE pay_id = p.pay_id) AS country_name";
}
$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 p.eve_id = " . $intEveId
. " AND p.pec_id = " . $intPecId
. " ORDER BY p.rol_id, 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-4574 — libère les dossards MS1 dune commande (disponibles à réassigner).
*/
function fxChronotrackApiSyncClearMs1BibsForPec($intPecId) {
global $objDatabase;
$intPecId = intval($intPecId);
if ($intPecId <= 0 || !isset($objDatabase) || !is_object($objDatabase)) {
return false;
}
$strNow = function_exists('fxGetDateTime') ? fxGetDateTime() : date('Y-m-d H:i:s');
$sql = "UPDATE resultats_participants SET"
. " no_bib = NULL,"
. " no_bib_remis = 0,"
. " no_bib_remis_date = NULL,"
. " no_bib_remis_par = NULL,"
. " par_date_bib = NULL,"
. " par_maj = '" . $objDatabase->fxEscape($strNow) . "'"
. " WHERE pec_id = " . $intPecId;
return (bool)$objDatabase->fxQuery($sql);
}
/**
* MSIN-4574 — PUT/POST WITHDRAWN + bib vide sur une entry CT.
* Ne crée pas dentry fantôme : exige entry_id (ct_entry_id) sauf si fourni explicitement.
*/
function fxChronotrackApiSyncWithdrawCtEntry($intEveId, array $arrRow, $intCtEventId = 0, array $arrRaceMap = null) {
$intEveId = intval($intEveId);
$strEntryId = preg_replace('/[^0-9]/', '', (string)($arrRow['ct_entry_id'] ?? $arrRow['entry_id'] ?? ''));
$strExternalId = trim((string)($arrRow['par_id_original'] ?? $arrRow['external_id'] ?? ''));
if ($strExternalId === '' || $strExternalId === '0') {
return array('state' => 'skip', 'message' => 'external_id manquant');
}
if ($strEntryId === '') {
return array('state' => 'skip', 'message' => 'ct_entry_id manquant — pas de WITHDRAW (éviter création)');
}
if ($intCtEventId <= 0) {
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null) {
return array('state' => 'skip', 'message' => 'Événement non lié à ChronoTrack');
}
$intCtEventId = intval($arrConfig['ct_event_id'] ?? 0);
}
if ($intCtEventId <= 0) {
return array('state' => 'skip', 'message' => 'ct_event_id manquant');
}
$arrPayload = array(
'external_id' => (string)intval($strExternalId),
'entry_status' => 'WITHDRAWN',
'status' => 'WITHDRAWN',
'bib' => '',
'entry_id' => $strEntryId,
);
$intCtRaceId = intval($arrRow['race_id'] ?? $arrRow['ct_race_id'] ?? 0);
if ($intCtRaceId <= 0) {
if ($arrRaceMap === null) {
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
}
$intEprId = intval($arrRow['epr_id'] ?? 0);
$intCtRaceId = intval($arrRaceMap[$intEprId]['ct_race_id'] ?? 0);
}
if ($intCtRaceId > 0) {
$arrPayload['race_id'] = $intCtRaceId;
}
$arrPayload['event_id'] = $intCtEventId;
$arrWrite = fxChronotrackApiSyncPutEntry($strEntryId, $arrPayload);
$intParId = intval($arrRow['par_id'] ?? 0);
if (($arrWrite['state'] ?? '') === 'ok') {
fxChronotrackApiSyncLog(
$intEveId,
$intParId,
'withdraw',
'ok',
'WITHDRAWN entry_id=' . $strEntryId . ' external_id=' . $arrPayload['external_id']
);
return array(
'state' => 'ok',
'entry_id' => $strEntryId,
'external_id' => $arrPayload['external_id'],
'message' => 'WITHDRAWN',
);
}
$strErr = (string)($arrWrite['message'] ?? 'erreur WITHDRAW');
fxChronotrackApiSyncLog($intEveId, $intParId, 'withdraw', 'error', $strErr);
return array('state' => 'error', 'message' => $strErr, 'entry_id' => $strEntryId);
}
/**
* MSIN-4574 — après annulation MS1 : free bib + WITHDRAWN CT (capitaine si équipe).
*/
function fxChronotrackApiSyncOnMs1Cancel($intEveId, $intPecId) {
$intEveId = intval($intEveId);
$intPecId = intval($intPecId);
$arrOut = array(
'state' => 'ok',
'bibs_cleared' => false,
'ct_attempted' => false,
'withdrawn' => 0,
'skipped' => 0,
'errors' => array(),
'message' => '',
);
$arrOut['bibs_cleared'] = fxChronotrackApiSyncClearMs1BibsForPec($intPecId);
if ($intEveId <= 0 || !function_exists('fxChronotrackApiConfigGet')) {
$arrOut['message'] = 'bibs MS1 seulement';
return $arrOut;
}
$arrConfig = fxChronotrackApiConfigGet($intEveId);
if ($arrConfig === null || !fxChronotrackApiSettingsConfigured()) {
$arrOut['message'] = 'bibs MS1 — pas de lien ChronoTrack';
return $arrOut;
}
$arrOut['ct_attempted'] = true;
$intCtEventId = intval($arrConfig['ct_event_id'] ?? 0);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabRows = fxChronotrackApiSyncLoadParticipantsByPec($intEveId, $intPecId);
if (count($tabRows) === 0) {
$arrOut['message'] = 'aucun participant pour pec';
return $arrOut;
}
$blnTeam = false;
foreach ($tabRows as $arrRow) {
if (fxChronotrackApiSyncIsTeamRow($arrRow)) {
$blnTeam = true;
break;
}
}
$tabTargets = array();
if ($blnTeam) {
$arrRep = fxChronotrackApiSyncPickTeamRepresentative($tabRows);
if (is_array($arrRep)) {
$tabTargets[] = $arrRep;
}
} else {
$tabTargets = $tabRows;
}
foreach ($tabTargets as $arrTarget) {
$arrW = fxChronotrackApiSyncWithdrawCtEntry($intEveId, $arrTarget, $intCtEventId, $arrRaceMap);
$strState = (string)($arrW['state'] ?? '');
if ($strState === 'ok') {
$arrOut['withdrawn']++;
} elseif ($strState === 'skip') {
$arrOut['skipped']++;
} else {
$arrOut['errors'][] = (string)($arrW['message'] ?? 'erreur');
}
}
if (count($arrOut['errors']) > 0) {
$arrOut['state'] = 'error';
$arrOut['message'] = 'WITHDRAW partiel — ' . implode(' ; ', $arrOut['errors']);
} else {
$arrOut['message'] = 'withdrawn=' . $arrOut['withdrawn']
. ' skipped=' . $arrOut['skipped']
. ' bibs=' . ($arrOut['bibs_cleared'] ? 'ok' : 'fail');
}
return $arrOut;
}
/**
* MSIN-4574 — rétablissement : bumper par_maj pour re-push CONF (dossard à réassigner).
*/
function fxChronotrackApiSyncOnMs1Restore($intEveId, $intPecId) {
global $objDatabase;
$intEveId = intval($intEveId);
$intPecId = intval($intPecId);
if ($intPecId <= 0 || !isset($objDatabase) || !is_object($objDatabase)) {
return array('state' => 'skip', 'message' => 'pec manquant');
}
$strNow = function_exists('fxGetDateTime') ? fxGetDateTime() : date('Y-m-d H:i:s');
$sql = "UPDATE resultats_participants SET par_maj = '" . $objDatabase->fxEscape($strNow) . "'"
. " WHERE pec_id = " . $intPecId;
$blnOk = (bool)$objDatabase->fxQuery($sql);
if ($blnOk && $intEveId > 0) {
fxChronotrackApiSyncLog($intEveId, 0, 'restore_bump', 'ok', 'pec_id=' . $intPecId . ' par_maj bumpé');
}
return array('state' => $blnOk ? 'ok' : 'error', 'message' => $blnOk ? 'par_maj bumpé' : 'SQL fail');
}
/**
* MSIN-4574 — retire (WITHDRAWN + clear bib) les orphelins CT hors éligibles MS1.
*/
function fxChronotrackApiSyncWithdrawCtOrphans($intEveId) {
$intEveId = intval($intEveId);
$arrList = fxChronotrackApiSyncListCtOrphans($intEveId);
if (($arrList['state'] ?? '') !== 'ok') {
return $arrList;
}
$intCtEventId = intval($arrList['ct_event_id'] ?? 0);
$arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
$tabOrphans = is_array($arrList['orphans'] ?? null) ? $arrList['orphans'] : array();
$intOk = 0;
$intSkip = 0;
$tabErrors = array();
$tabDone = array();
foreach ($tabOrphans as $arrOrphan) {
// hors_eligible = annulés / transferts / ex-membres ; doublons aussi à retirer
$strReason = (string)($arrOrphan['reason'] ?? '');
if ($strReason !== 'hors_eligible' && $strReason !== 'doublon') {
$intSkip++;
continue;
}
$arrRow = array(
'entry_id' => $arrOrphan['entry_id'] ?? '',
'ct_entry_id' => $arrOrphan['entry_id'] ?? '',
'external_id' => $arrOrphan['external_id'] ?? '',
'par_id_original' => $arrOrphan['external_id'] ?? '',
'race_id' => intval($arrOrphan['race_id'] ?? 0),
'epr_id' => 0,
'par_id' => 0,
);
$arrW = fxChronotrackApiSyncWithdrawCtEntry($intEveId, $arrRow, $intCtEventId, $arrRaceMap);
$strState = (string)($arrW['state'] ?? '');
if ($strState === 'ok') {
$intOk++;
$tabDone[] = array(
'entry_id' => $arrOrphan['entry_id'] ?? '',
'external_id' => $arrOrphan['external_id'] ?? '',
'name' => $arrOrphan['name'] ?? '',
);
} elseif ($strState === 'skip') {
$intSkip++;
} else {
$tabErrors[] = ($arrOrphan['name'] ?? '') . ': ' . ($arrW['message'] ?? 'erreur');
}
}
fxChronotrackApiSyncLog(
$intEveId,
0,
'reconcile',
count($tabErrors) > 0 ? 'error' : 'ok',
'orphans WITHDRAWN ok=' . $intOk . ' skip=' . $intSkip . ' err=' . count($tabErrors)
);
return array(
'state' => count($tabErrors) > 0 ? 'error' : 'ok',
'message' => 'WITHDRAWN ' . $intOk . ' orphelin(s)'
. ($intSkip > 0 ? ', ' . $intSkip . ' ignoré(s)' : '')
. (count($tabErrors) > 0 ? ' — erreurs: ' . implode(' ; ', $tabErrors) : ''),
'withdrawn_count' => $intOk,
'skipped_count' => $intSkip,
'errors' => $tabErrors,
'withdrawn' => $tabDone,
'orphan_count' => count($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;
}
// Identité finie → drain dossards en sous-lots
if (!empty($arrChunk['identity_done']) && intval($arrChunk['bib_pending'] ?? 0) > 0) {
$intBibLoops = 0;
while ($intBibLoops < $intMaxLoops) {
$intBibLoops++;
$arrBib = fxChronotrackApiSyncPushBibsPhase($intEveId, array(
'limit' => MSIN_API_CHRONOTRACK_SYNC_BIB_CHUNK_SIZE,
));
$intOkSum += intval($arrBib['pushed_ok'] ?? 0);
$intErrSum += intval($arrBib['pushed_error'] ?? 0);
if (!empty($arrBib['done'])) {
break;
}
if (($arrBib['state'] ?? '') === 'error' && intval($arrBib['pushed_ok'] ?? 0) === 0) {
break;
}
}
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'] ?? ''));
$strSyncStatus = trim((string)($arrCfg['sync_status'] ?? ''));
$intNow = time();
$strWould = 'inconnu';
if (!$blnColsOk) {
$strWould = 'bloqué: SQL auto_sync manquant';
} elseif ($strSyncStatus === MSIN_API_CHRONOTRACK_SYNC_FERME) {
$strWould = 'ne tourne pas: événement Fermé';
} elseif ($strSyncStatus !== MSIN_API_CHRONOTRACK_SYNC_ACTIF) {
// MSIN-4574 — Lié ≠ Actif pour le cron
$strWould = 'ne tourne pas: statut « '
. fxChronotrackApiConfigStatusLabel($strSyncStatus)
. ' » — passer en Actif (Cycle de vie)';
} 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,
'sync_status' => $strSyncStatus,
'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,
),
);
}