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

566 lines
21 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 — classify, preview, logs.
* MSIN-4574 : payload / push / read / lifecycle / cron / probe = fichiers dédiés.
*/
// 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-4574 — modules extraits (si sync chargé hors bootstrap).
if (!function_exists('fxChronotrackApiSyncBuildEntryPayload')) {
require_once __DIR__ . '/fx_chronotrack_payload.php';
}
if (!function_exists('fxChronotrackApiSyncPutEntry')) {
require_once __DIR__ . '/fx_chronotrack_push.php';
}
if (!function_exists('fxChronotrackApiSyncFetchAllCtEntryEntities')) {
require_once __DIR__ . '/fx_chronotrack_read.php';
}
if (!function_exists('fxChronotrackApiSyncWithdrawCtEntry')) {
require_once __DIR__ . '/fx_chronotrack_lifecycle.php';
}
if (!function_exists('fxChronotrackApiSyncRunAutoCron')) {
require_once __DIR__ . '/fx_chronotrack_cron.php';
}
if (!function_exists('fxChronotrackApiSyncProbePush')) {
require_once __DIR__ . '/fx_chronotrack_probe.php';
}
/**
* MSIN-4328 — URL admin ChronoTrack (fiche athlète).
*/
function fxChronotrackApiAdminEntryUrl($intCtEventId, $strCtEntryId) {
$intCtEventId = intval($intCtEventId);
$strCtEntryId = preg_replace('/[^0-9]/', '', (string)$strCtEntryId);
if ($intCtEventId <= 0 || $strCtEntryId === '') {
return '';
}
return 'https://admin.chronotrack.com/admin/entry/index/eventID/'
. $intCtEventId . '?entryID=' . $strCtEntryId;
}
function fxChronotrackApiSyncFormatTeamBlockMessage(array $arrRow) {
$arrParts = array('Nom d\'équipe manquant — corriger dans MS1');
$strNoEquipe = trim((string)($arrRow['no_equipe'] ?? ''));
if ($strNoEquipe !== '' && $strNoEquipe !== '0') {
$arrParts[] = 'no équipe #' . $strNoEquipe;
}
return implode(' · ', $arrParts);
}
function 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 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 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;
}
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'),
);
}