diff --git a/php/chronotrack_api/fx_chronotrack_admin.php b/php/chronotrack_api/fx_chronotrack_admin.php
index 24c6d3b..2618c65 100644
--- a/php/chronotrack_api/fx_chronotrack_admin.php
+++ b/php/chronotrack_api/fx_chronotrack_admin.php
@@ -372,6 +372,9 @@ function fxChronotrackApiAdminRenderDiagnosticPanel($intCtEventId) {
echo ' Lancer le diagnostic API ';
echo ' ';
+ echo ' ';
echo '';
echo '
Cliquez sur « Lancer le diagnostic API » ou « Sonde ».
';
@@ -874,6 +877,57 @@ function fxChronotrackApiAdminRenderPageScript($intEveId, $arrConfig, $blnClosed
});
});
+ // MSIN-4328 — sonde custom_Ms1Probe (mimique import Custom)
+ $('#ct_api_btn_probe_custom').on('click', function () {
+ var $btn = $(this);
+ var $out = $('#ct_api_diagnostic_out');
+ $btn.prop('disabled', true);
+ $out.html(waitHtml('Sonde custom field…'));
+ $('#ct_api_btn_diagnostic_copy').addClass('d-none');
+ $.post(strAjaxUrl, { a: 'sync_probe_custom', eve_id: intEveId }, function (res) {
+ if (rejectIfSessionLost(res)) {
+ return;
+ }
+ var strClass = (res && res.state === 'ok')
+ ? 'text-success'
+ : ((res && res.state === 'partial') ? 'text-warning' : 'text-danger');
+ var strHtml = 'Sonde custom — '
+ + escHtml((res && res.message) ? res.message : 'Erreur') + '
';
+ if (res) {
+ strHtml += 'field='
+ + escHtml(res.field_key || '') + ' sent='
+ + escHtml(String(res.field_value_sent || ''))
+ + ' present=' + (res.field_present ? 'oui' : 'non')
+ + ' read=' + escHtml(JSON.stringify(res.field_value_read))
+ + ' · ext=' + escHtml(String(res.external_id || ''))
+ + '
';
+ }
+ if (res && res.question_endpoints && res.question_endpoints.length) {
+ strHtml += 'Endpoints question (GET)';
+ $.each(res.question_endpoints, function (_, ep) {
+ strHtml += '' + escHtml(ep.path) + ' → HTTP '
+ + escHtml(String(ep.http_code || '?'))
+ + ' (' + escHtml(ep.state || '') + ') ';
+ });
+ strHtml += '
';
+ }
+ if (res && res.payload) {
+ strHtml += ''
+ + escHtml(JSON.stringify(res.payload, null, 2)) + '
';
+ }
+ if (res && res.post_response) {
+ strHtml += ''
+ + escHtml(res.post_response) + '
';
+ }
+ $out.html(strHtml);
+ $('#ct_api_btn_diagnostic_copy').removeClass('d-none');
+ }, 'json').fail(function () {
+ $out.html('Erreur réseau (sonde custom).
');
+ }).always(function () {
+ $btn.prop('disabled', false);
+ });
+ });
+
var objPushPreview = null;
function renderBlockedPanel(res) {
diff --git a/php/chronotrack_api/fx_chronotrack_sync.php b/php/chronotrack_api/fx_chronotrack_sync.php
index 5438e48..9bb98f4 100644
--- a/php/chronotrack_api/fx_chronotrack_sync.php
+++ b/php/chronotrack_api/fx_chronotrack_sync.php
@@ -915,6 +915,170 @@ function fxChronotrackApiSyncProbePush($intEveId) {
);
}
+/**
+ * MSIN-4328 — sonde « custom field » : tente comme un import Custom
+ * (POST entry avec custom_Ms1Probe*) + lit l’entry, + teste endpoints question.
+ */
+function fxChronotrackApiSyncProbeCustomField($intEveId) {
+ $arrConfig = fxChronotrackApiConfigGet($intEveId);
+ if ($arrConfig === null) {
+ return array('state' => 'error', 'message' => 'Événement non lié à ChronoTrack');
+ }
+ if (!fxChronotrackApiSettingsConfigured()) {
+ return array('state' => 'error', 'message' => fxChronotrackApiSettingsErrorMessage());
+ }
+
+ $intCtEventId = intval($arrConfig['ct_event_id']);
+ $arrRaceMap = fxChronotrackApiRaceMapGetForEvent($intEveId);
+ $tabParticipants = fxChronotrackApiSyncLoadMs1Participants($intEveId);
+ $arrClass = fxChronotrackApiSyncClassifyParticipants($tabParticipants, $arrRaceMap, $intCtEventId);
+
+ if (count($arrClass['transferable']) === 0) {
+ return array('state' => 'error', 'message' => 'Aucun participant transférable pour la sonde custom.');
+ }
+
+ // Préférer une entry déjà liée CT (maj légère, pas de création complète)
+ $arrPick = null;
+ foreach ($arrClass['transferable'] as $arrItem) {
+ if (intval($arrItem['row']['ct_entry_id'] ?? 0) > 0) {
+ $arrPick = $arrItem;
+ break;
+ }
+ }
+ if ($arrPick === null) {
+ $arrPick = $arrClass['transferable'][0];
+ }
+
+ $arrRow = $arrPick['row'];
+ $strExternalId = (string)($arrPick['payload']['external_id'] ?? '');
+ $intCtEntryId = intval($arrRow['ct_entry_id'] ?? 0);
+ $strFieldKey = 'custom_Ms1Probe';
+ $strValue = 'ms1-probe-' . date('YmdHis');
+
+ $tabQuestionEndpoints = array();
+ foreach (array('question', 'custom_question', 'survey_question', 'reg_question') as $strEp) {
+ $arrG = fxChronotrackApiOAuthApiGet('event/' . $intCtEventId . '/' . $strEp);
+ $tabQuestionEndpoints[] = array(
+ 'path' => 'event/' . $intCtEventId . '/' . $strEp,
+ 'http_code' => intval($arrG['http']['http_code'] ?? 0),
+ 'state' => $arrG['state'] ?? 'error',
+ 'message' => isset($arrG['message']) ? substr((string)$arrG['message'], 0, 200) : '',
+ );
+ }
+
+ // Corps minimal : identifier + champ custom (mimique import « Custom »)
+ $arrProbePayload = array(
+ 'external_id' => $strExternalId,
+ $strFieldKey => $strValue,
+ );
+ if ($intCtEntryId > 0) {
+ // Certaines maj CT acceptent l’id numérique
+ $arrProbePayload['entry_id'] = (string)$intCtEntryId;
+ }
+
+ $arrPost = fxChronotrackApiSyncPostEntries($intCtEventId, array($arrProbePayload));
+
+ $mixReadbackValue = null;
+ $blnFieldPresent = false;
+ $arrReadback = null;
+ $strReadbackHow = '';
+
+ if ($intCtEntryId > 0) {
+ $arrGet = fxChronotrackApiOAuthApiGet('entry/' . $intCtEntryId);
+ $strReadbackHow = 'GET entry/' . $intCtEntryId;
+ if (($arrGet['state'] ?? '') === 'ok' && is_array($arrGet['json'] ?? null)) {
+ $arrReadback = $arrGet['json'];
+ // CT peut wrapper { entry: {...} } ou renvoyer l’objet direct
+ if (isset($arrReadback['entry']) && is_array($arrReadback['entry'])) {
+ $arrReadback = $arrReadback['entry'];
+ } elseif (isset($arrReadback['event_entry'][0]) && is_array($arrReadback['event_entry'][0])) {
+ $arrReadback = $arrReadback['event_entry'][0];
+ }
+ if (array_key_exists($strFieldKey, $arrReadback)) {
+ $blnFieldPresent = true;
+ $mixReadbackValue = $arrReadback[$strFieldKey];
+ }
+ }
+ }
+
+ if (!$blnFieldPresent) {
+ // Repli : 1re page entries et recherche par external_id
+ $arrList = fxChronotrackApiOAuthApiGet(
+ 'event/' . $intCtEventId . '/entry',
+ array('page' => 1, 'page_size' => 50)
+ );
+ if ($strReadbackHow === '') {
+ $strReadbackHow = 'GET event/.../entry page1';
+ } else {
+ $strReadbackHow .= ' + list page1';
+ }
+ $tabEnt = array();
+ if (($arrList['state'] ?? '') === 'ok' && is_array($arrList['json'] ?? null)) {
+ $tabEnt = fxChronotrackApiNormalizeEntityList($arrList['json'], 'entry');
+ if (count($tabEnt) === 0 && isset($arrList['json']['event_entry']) && is_array($arrList['json']['event_entry'])) {
+ $tabEnt = $arrList['json']['event_entry'];
+ }
+ }
+ foreach ($tabEnt as $arrEnt) {
+ if (!is_array($arrEnt)) {
+ continue;
+ }
+ $strExt = trim((string)(
+ $arrEnt['entry_external_id'] ?? $arrEnt['external_id'] ?? ''
+ ));
+ if ($strExt !== $strExternalId) {
+ continue;
+ }
+ $arrReadback = $arrEnt;
+ if (array_key_exists($strFieldKey, $arrEnt)) {
+ $blnFieldPresent = true;
+ $mixReadbackValue = $arrEnt[$strFieldKey];
+ }
+ break;
+ }
+ }
+
+ $strVerdict = 'POST ok mais champ absent au relecture (CT n’a pas créé le custom comme à l’import).';
+ $strState = 'error';
+ if (($arrPost['state'] ?? '') !== 'ok') {
+ $strVerdict = 'POST refusé — ' . (string)($arrPost['message'] ?? 'erreur');
+ $strState = 'error';
+ } elseif ($blnFieldPresent && (string)$mixReadbackValue === $strValue) {
+ $strVerdict = 'OK — CT a accepté et conservé ' . $strFieldKey . ' (création / slot custom plausible).';
+ $strState = 'ok';
+ } elseif ($blnFieldPresent) {
+ $strVerdict = 'POST ok, champ présent mais valeur différente: '
+ . json_encode($mixReadbackValue, JSON_UNESCAPED_UNICODE);
+ $strState = 'partial';
+ }
+
+ fxChronotrackApiSyncLog(
+ $intEveId,
+ intval($arrRow['par_id'] ?? 0),
+ 'probe_custom',
+ ($strState === 'ok') ? 'ok' : 'error',
+ $strVerdict
+ );
+
+ return array(
+ 'state' => $strState,
+ 'message' => $strVerdict,
+ 'ct_event_id' => $intCtEventId,
+ 'par_id' => intval($arrRow['par_id'] ?? 0),
+ 'external_id' => $strExternalId,
+ 'ct_entry_id' => $intCtEntryId,
+ 'field_key' => $strFieldKey,
+ 'field_value_sent' => $strValue,
+ 'field_present' => $blnFieldPresent,
+ 'field_value_read' => $mixReadbackValue,
+ 'payload' => $arrProbePayload,
+ 'post_http_code' => intval($arrPost['http']['http_code'] ?? 0),
+ 'post_response' => substr(trim((string)($arrPost['http']['body'] ?? '')), 0, 1500),
+ 'readback_how' => $strReadbackHow,
+ 'question_endpoints' => $tabQuestionEndpoints,
+ );
+}
+
/**
* Sonde perf — teste 10 / 50 / 100 / 200 entries (upsert, mêmes gens). MSIN-4328
* Évite un push complet pour calibrer la taille de lot.
diff --git a/superadm/ajax_chronotrack_api.php b/superadm/ajax_chronotrack_api.php
index 49e3329..1245a90 100644
--- a/superadm/ajax_chronotrack_api.php
+++ b/superadm/ajax_chronotrack_api.php
@@ -170,6 +170,16 @@ switch ($strAction) {
$tabRetour = fxChronotrackApiSyncProbePush($intEveId);
break;
+ case 'sync_probe_custom':
+ // MSIN-4328 — sonde création champ custom (comme import Custom)
+ if ($intEveId <= 0) {
+ $tabRetour = array('state' => 'error', 'message' => 'eve_id manquant');
+ break;
+ }
+ set_time_limit(120);
+ $tabRetour = fxChronotrackApiSyncProbeCustomField($intEveId);
+ break;
+
case 'sync_push_prepare':
if ($intEveId <= 0) {
$tabRetour = array('state' => 'error', 'message' => 'eve_id manquant');