diff --git a/.gitignore b/.gitignore index e20764f..d65e69f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ playwright-report/ test-results/ .agents/* skills-lock.json -secure \ No newline at end of file +secure +.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json index 240c6ba..0a4473b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -32,6 +32,35 @@ "BANK_MOCK_PORT": "10300" } }, + { + "name": "Debug ORIS Mock Server", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "runtimeExecutable": "node", + "runtimeArgs": [ + "--import", + "tsx" + ], + "args": [ + "mocks/oris/src/server.ts" + ], + "console": "integratedTerminal", + "skipFiles": [ + "/**" + ], + "env": { + "ORIS_MOCK_HOST": "0.0.0.0", + "ORIS_MOCK_PORT": "10301", + "ORIS_MOCK_DB_HOST": "db", + "ORIS_MOCK_DB_PORT": "3306", + "ORIS_MOCK_DB_USER": "root", + "ORIS_MOCK_DB_PASSWORD": "dev4password", + "ORIS_MOCK_DB_NAME": "oris_mock", + "ORIS_MOCK_DEFAULT_CLUB_ID": "205", + "ORIS_MOCK_DEFAULT_CLUB_ABBR": "ZBM" + } + }, { "name": "Debug Bank Mock Server (Watch)", "type": "node", @@ -57,18 +86,63 @@ } }, { - "name": "Start Bank Mock Server", + "name": "Debug ORIS Mock Server (Watch)", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "runtimeExecutable": "node", + "runtimeArgs": [ + "--import", + "tsx", + "--watch" + ], + "args": [ + "mocks/oris/src/server.ts" + ], + "console": "integratedTerminal", + "restart": true, + "skipFiles": [ + "/**" + ], + "env": { + "ORIS_MOCK_HOST": "0.0.0.0", + "ORIS_MOCK_PORT": "10301", + "ORIS_MOCK_DB_HOST": "db", + "ORIS_MOCK_DB_PORT": "3306", + "ORIS_MOCK_DB_USER": "root", + "ORIS_MOCK_DB_PASSWORD": "dev4password", + "ORIS_MOCK_DB_NAME": "oris_mock", + "ORIS_MOCK_DEFAULT_CLUB_ID": "205", + "ORIS_MOCK_DEFAULT_CLUB_ABBR": "ZBM" + } + }, + { + "name": "Run Bank Mock Server", "type": "node-terminal", "request": "launch", "command": "npm run mock:bank", "cwd": "${workspaceFolder}" }, { - "name": "Start Bank Mock Server (Watch)", + "name": "Run ORIS Mock Server", + "type": "node-terminal", + "request": "launch", + "command": "npm run mock:oris", + "cwd": "${workspaceFolder}" + }, + { + "name": "Run Bank Mock Server (Watch)", "type": "node-terminal", "request": "launch", "command": "npm run mock:bank:watch", "cwd": "${workspaceFolder}" + }, + { + "name": "Run ORIS Mock Server (Watch)", + "type": "node-terminal", + "request": "launch", + "command": "npm run mock:oris:watch", + "cwd": "${workspaceFolder}" } ] } diff --git a/Docs/Development.md b/Docs/Development.md index b5adfc7..8a6d9c4 100644 --- a/Docs/Development.md +++ b/Docs/Development.md @@ -38,10 +38,21 @@ Bank mock pro vývoj: docker compose -p members-dev -f docker-compose.dev.yml exec web npm run mock:bank ``` -* admin UI: [http://127.0.0.1:10300/__admin](http://127.0.0.1:10300/__admin) +* admin UI: [http://127.0.0.1:10300/__testbench](http://127.0.0.1:10300/__testbench) * API endpoint pro PHP konektor: `http://127.0.0.1:10300/rbcz/premium/api` * vývojová konfigurace v `docker/config/dev/_cfg.php` už používá `RaiffeisenbankMockConnector` +ORIS mock pro vývoj: + +```bash +docker compose -p members-dev -f docker-compose.dev.yml exec web npm run mock:oris +``` + +* admin UI: [http://127.0.0.1:10301/__testbench](http://127.0.0.1:10301/__testbench) +* API endpoint pro ORIS mock: `http://127.0.0.1:10301/` +* vývojová konfigurace v `docker/config/dev/_cfg.php` už používá mock server `http://127.0.0.1:10301/` + + ## Automatické testy V kořenovém adresáři `members` spusť: @@ -53,19 +64,24 @@ docker compose -p members-autotest -f docker-compose.autotest.yml exec web npm r docker compose -p members-autotest -f docker-compose.autotest.yml down ``` -Přepínač `--wait` počká, až budou kontejnery označené jako `healthy`. V autotest stacku to znamená, že je připravená databáze, běží web a bank mock odpovídá na health endpoint, takže testy můžeš spustit hned po startu bez ruční prodlevy. +Přepínač `--wait` počká, až budou kontejnery označené jako `healthy`. V autotest stacku to znamená, že je připravená databáze, běží web a bank a ORIS mock odpovídají na health endpoint, takže testy můžeš spustit hned po startu bez další prodlevy. + +## Manuální testy -Pro ruční pozorování běžící aplikace a databáze přidej override soubor `docker-compose.autotest.observe.yml`: +Pro manuální testy nebo analýzu automatických testů přidej override soubor `docker-compose.autotest.observe.yml`: ```bash docker compose -p members-autotest -f docker-compose.autotest.yml -f docker-compose.autotest.observe.yml up -d --build --wait +... ``` +Je potřeba ručně přenastavit _cfg.php na $g_baseadr='http://127.0.0.1:10090/members/'; Dostupné služby: * [members](http://127.0.0.1:10090/members) * [phpMyAdmin](http://127.0.0.1:10091) -* [bank](http://127.0.0.1:10093/_admin) +* [bank](http://127.0.0.1:10093/__testbench) +* [ORIS](http://127.0.0.1:10094/__testbench) ## Minimální konfigurace diff --git a/Docs/X_cfg.php b/Docs/X_cfg.php index c0d95d0..a1f1317 100644 --- a/Docs/X_cfg.php +++ b/Docs/X_cfg.php @@ -102,6 +102,9 @@ $g_external_race_transport_default = 0; $g_external_race_accommodation_default = 0; +$g_oris_base_url = 'https://oris.ceskyorientak.cz/'; +$g_oris_club_key = ''; + $g_custom_entry_list_text = ''; ?> diff --git a/_SQL/zmeny.sql.php b/_SQL/zmeny.sql.php index edafc49..d5dba19 100644 --- a/_SQL/zmeny.sql.php +++ b/_SQL/zmeny.sql.php @@ -1,5 +1,5 @@ diff --git a/ads_oris.inc.php b/ads_oris.inc.php index 8b672aa..4950359 100644 --- a/ads_oris.inc.php +++ b/ads_oris.inc.php @@ -51,21 +51,25 @@ function startsWith( $haystack, $needle ) { return substr( $haystack, 0, $length ) === $needle; } -$json = file_get_contents('https://oris.orientacnisporty.cz/API/?format=json&method=getRegistration&sport=1&year='.$ORIS_year); -$obj = json_decode($json); +require_once './lib/OrisIntegrationService.php'; +$service = OrisIntegrationServiceFactory::create(); +$orisWriteEnabled = $service->hasClubKey(); +$obj = $service->getRegistration(1, $ORIS_year); $arr_oris = array(); -foreach ($obj->Data as $key=>$value) -{ - $user = new User(); - $user->create($value->UserID, $value->FirstName, $value->LastName, $value->RegNo, $value->SI, $value->ClubID); - - $reg = $value->RegNo; - if (startsWith($reg, $g_shortcut)) +if (is_array($obj) || is_object($obj)) { + foreach ($obj as $key=>$value) { - $arr_oris["user"] [$reg]= $user; - $arr_oris["members"][$reg] = 0; + $user = new User(); + $user->create($value['UserID'], $value['FirstName'], $value['LastName'], $value['RegNo'], $value['SI'], $value['ClubID']); + + $reg = $value['RegNo']; + if (startsWith($reg, $g_shortcut)) + { + $arr_oris["user"] [$reg]= $user; + $arr_oris["members"][$reg] = 0; + } } } //konec nahrani dat z orisu @@ -138,7 +142,9 @@ function startsWith( $haystack, $needle ) { $oris_user_id = $arr_oris["user"][$fullreg]->getUserId(); $local_si = $zaznam['si']; $local_id = $zaznam['id']; - $oris_si .= " [<<]"; + if ($orisWriteEnabled) { + $oris_si .= " [<<]"; + } } $row[] = $oris_si; $arr_oris["members"][$fullreg]=1; diff --git a/ads_oris_si_sync.php b/ads_oris_si_sync.php index c5b7173..371b47e 100644 --- a/ads_oris_si_sync.php +++ b/ads_oris_si_sync.php @@ -8,6 +8,7 @@ require_once ("./sess.inc.php"); require_once ("./cfg/_cfg.php"); require_once ("./common.inc.php"); // For GetFirstEmail +require_once ("./lib/OrisIntegrationService.php"); if (!IsLoggedSmallAdmin()) { @@ -27,11 +28,7 @@ if ($zaznam) { $params = array( - 'format' => 'json', - 'method' => 'editPerson', 'userid' => $oris_id, - 'si' => $zaznam['si_chip'], - 'clubkey' => $g_oris_club_key, 'firstname' => $zaznam['jmeno'], 'lastname' => $zaznam['prijmeni'], 'email' => GetFirstEmail($zaznam['email']), @@ -40,27 +37,24 @@ 'zip' => $zaznam['psc'], 'country' => (!empty($zaznam['narodnost']) ? $zaznam['narodnost'] : 'CZ') ); - - $url = "https://oris.ceskyorientak.cz/API/?" . http_build_query($params); - - // Use file_get_contents - $response = file_get_contents($url); - $result = json_decode($response); - - if ($result && $result->Status == 'OK') { + if (!empty($zaznam['si_chip']) && $zaznam['si_chip'] != 0 && $zaznam['si_chip'] !== '0') { + $params['si'] = $zaznam['si_chip']; + } + + $service = OrisIntegrationServiceFactory::create(); + + try { + $service->editPerson($params); // Success header("location: ".$g_baseadr."index.php?id="._SMALL_ADMIN_GROUP_ID_."&subid=2"); - } else { + } catch (OrisException $e) { // Error require_once ("./header.inc.php"); DrawPageTitle('Chyba synchronizace s ORIS'); echo "Nepodařilo se aktualizovat údaje v systému ORIS.
"; echo "Debug info:
"; - echo "URL: " . $url . "
"; - echo "Raw response: " . htmlspecialchars($response) . "
"; - echo "Odpověď serveru: " . ($result ? $result->Status : "Unknown error") . "
"; - if (isset($result->Message)) echo "Zpráva: " . $result->Message . "
"; - if (!isset($g_oris_club_key) || empty($g_oris_club_key)) echo "Warning: API key is empty!
"; + echo "API URL: " . htmlspecialchars($service->getApiUrl()) . "
"; + echo "Zpráva: " . htmlspecialchars($e->getMessage()) . "
"; echo "
Zpět"; HTML_Footer(); } diff --git a/cfg/_cfg.php.default b/cfg/_cfg.php.default index f88910f..a5f6a8e 100644 --- a/cfg/_cfg.php.default +++ b/cfg/_cfg.php.default @@ -127,7 +127,8 @@ $g_external_is_club_id = ''; $g_external_race_transport_default = 0; $g_external_race_accommodation_default = 0; -$g_oris_club_key = 'orisClubKey'; +$g_oris_base_url = 'https://oris.ceskyorientak.cz/'; +$g_oris_club_key = ''; // Identifikator C oblasti v informacnim systemu e.g. JM $g_external_is_region_C = ''; diff --git a/common_race.inc.php b/common_race.inc.php index 4a2c653..08cb6d3 100644 --- a/common_race.inc.php +++ b/common_race.inc.php @@ -116,25 +116,43 @@ function CreateNotifyTypeNumber(&$notify) return $result; } -function CreateRaceTypeNumber(&$racetype) -{ - global $g_racetype_cnt; - global $g_racetype; - - $result = 0; +function CreateRaceTypeNumber(&$racetype) +{ + global $g_racetype_cnt; + global $g_racetype; + + $result = 0; for($ii=0; $ii<$g_racetype_cnt; $ii++) { if(isset($racetype[$ii]) && $racetype[$ii] == 1) $result += $g_racetype [$ii]['id']; - } - return $result; -} - -function CreateModifyFlag(&$mflags) -{ - global $g_modify_flag_cnt; - global $g_modify_flag; - + } + return $result; +} + +function EntryStart2Sql($text) +{ + $text = trim((string)$text); + if ($text === '') { + return ''; + } + + $formats = ['d.m.Y H:i:s', 'j.n.Y H:i:s', 'Y-m-d H:i:s']; + foreach ($formats as $format) { + $date = DateTime::createFromFormat($format, $text); + if ($date !== false && $date->format($format) === $text) { + return $date->format('Y-m-d H:i:s'); + } + } + + return $text; +} + +function CreateModifyFlag(&$mflags) +{ + global $g_modify_flag_cnt; + global $g_modify_flag; + $result = 0; for($ii=0; $ii<$g_modify_flag_cnt; $ii++) { diff --git a/connectors.php b/connectors.php index c0ffd9a..8a568c5 100644 --- a/connectors.php +++ b/connectors.php @@ -1,401 +1,288 @@ -ext_id = $data['ext_id'] ?? null; - $this->datum = $data['datum'] ?? null; - $this->datum2 = $data['datum2'] ?? null; - $this->nazev = $data['nazev'] ?? null; - $this->misto = $data['misto'] ?? null; - $this->oblasti = $data['oblasti'] ?? null; - $this->typ = $data['typ'] ?? null; - $this->zebricek2 = $data['zebricek2'] ?? null; - $this->ranking = $data['ranking'] ?? null; - $this->odkaz = $data['odkaz'] ?? null; - $this->prihlasky = $data['prihlasky'] ?? null; - $this->prihlasky1 = $data['prihlasky1'] ?? null; - $this->prihlasky2 = $data['prihlasky2'] ?? null; - $this->prihlasky3 = $data['prihlasky3'] ?? null; - $this->prihlasky4 = $data['prihlasky4'] ?? null; - $this->prihlasky5 = $data['prihlasky5'] ?? null; - $this->koeficient1 = $data['koeficient1'] ?? null; - $this->koeficient2 = $data['koeficient2'] ?? null; - $this->etap = $data['etap'] ?? null; - $this->poznamka = $data['poznamka'] ?? null; - $this->vicedenni = $data['vicedenni'] ?? null; - $this->oddil = $data['oddil'] ?? null; - $this->modify_flag = $data['modify_flag'] ?? null; - $this->kategorie = $data['kategorie'] ?? null; - $this->startovne = $data['startovne'] ?? null; - $this->cancelled = $data['cancelled'] ?? null; + RegNo => User */ + public array $participants = []; + + public function __construct(int $raceId) { + $this->raceId = $raceId; + $this->overview = new RaceOverview(); + } + + public function addPatricipant(RaceParticipant $user): void { + $this->participants[$user->regNo] = $user; + } + + public function addCategory(string $name, int $feeTier, int $fee ): void { + $this->overview->addCategory($name,$feeTier,$fee); + } + + public function addService(string $name, int $fee, int $count ): void { + $this->overview->addService( $name, $fee, $count ); } } - -class RacePayement { - public int $raceId; - public RaceOverview $overview; - /** @var array RegNo => User */ - public array $participants = []; - - public function __construct(int $raceId) { - $this->raceId = $raceId; - $this->overview = new RaceOverview(); - } - - public function addPatricipant(RaceParticipant $user): void { - $this->participants[$user->regNo] = $user; - } - - public function addCategory(string $name, int $feeTier, int $fee ): void { - $this->overview->addCategory($name,$feeTier,$fee); - } - - public function addService(string $name, int $fee, int $count ): void { - $this->overview->addService( $name, $fee, $count ); - } -} - -class RaceOverview { - /** @var array> Category => [EntryStop => Fee] */ - public array $categories = []; - - /** @var array> ServiceName => [ Fee => Count ] */ - public array $services = []; - - /** @var array feeTier => exist */ - public array $feeTiers = []; - - public function addCategory(string $name, int $feeTier, int $fee ): void { - - if (!isset($this->categories[$name])) { - $this->categories[$name] = []; - } - $this->categories[$name][$feeTier] = $fee; - - // Store only unique sorted feeTier values - $this->feeTiers[$feeTier] = true; - } - - public function addService(string $name, int $fee, int $count ): void { - if ( isset ( $this->services[$name][$fee] ) ) { - $this->services[$name][$fee] += $count; - } else { - $this->services[$name][$fee] = $count; - } - } -} - -class RaceParticipant { - public string $regNo; - public string $classDesc; - public string $name; - public bool $rentSI; - public string $licence; - public $fee; - public int $feeTier; - - public function __construct(string $regNo, string $classDesc, string $name, bool $rentSI, string|null $licence, int $fee, int $feeTier) { - $this->regNo = $regNo; - $this->classDesc = $classDesc; - $this->name = $name; - $this->rentSI = $rentSI; - $this->licence = $licence ?? ''; - $this->fee = $fee; - $this->feeTier = $feeTier; - } -} - -interface ConnectorInterface { - public function getSystemName(): string; - public function getRaceURL(string $id): string; - public function getRaceInfo(string $id) : ?RaceInfo; - public function getRacesList($fromDate, $toDate); - public function getRacePayement(string $id) : ?RacePayement; -} - -class OrisCZConnector implements ConnectorInterface { - private $sourceUrl = 'https://oris.orientacnisporty.cz/'; - private $apiUrl; - - public function __construct() { - $this->apiUrl = $this->sourceUrl . 'API/'; - } - - // Method to get the system name - public function getSystemName(): string { - return "Oris"; - } - - // RaceInfo URL - public function getRaceURL($raceId) : string { - return $this->sourceUrl . 'Zavod?id=' . $raceId; - } - - private function mapLevelToZebricek2($levelId) { - $map = [ - 1 => 17, // MCR - 3 => 6, // ZB - 4 => 8, // OZ - 7 => 33, // CPS - 8 => 1, // CP - 11 => 24, // OM - 17 => 17 // VET - ]; - return $map[$levelId] ?? 0x0080; // Default to 0x80 if not found - } - - private function mapSport($sportId) { - //sport ID from ORIS : 1=OB, 2=LOB, 3=MTBO, 4=TRAIL - $map = [ - 1 => 1, // OB - 2 => 4, // LOB - 3 => 2, // MTBO - 4 => 8, // TRAIL - ]; - return $map[$sportId] ?? 1; // Default is OB - } - - private function getClubs(&$raceData) { - - $oddily = []; - if (isset($raceData['Org1']['Abbr'])) { - $oddily[] = $raceData['Org1']['Abbr']; - } - if (isset($raceData['Org2']['Abbr'])) { - $oddily[] = $raceData['Org2']['Abbr']; - } - return implode('+', $oddily); - } - - // Method to get race date based on race ID or provided response - public function getRaceDate($raceId, $response = null) { - // If no response provided, fetch it from API - if ($response === null) { - $url = $this->apiUrl . '?format=json&method=getEvent&id=' . $raceId; - $response = $this->makeRequest($url); - } - - if ($response && isset($response['Status']) && $response['Status'] === "OK") { - $raceData = $response['Data']; - return String2DateDMY(formatDate($raceData['Date'])); - } - - return ''; // Return empty string if race not found or error - } - - // Method to get detailed race information based on race ID - public function getRaceInfo($raceId) : RaceInfo { - $url = $this->apiUrl . '?format=json&method=getEvent&id=' . $raceId; - - $response = $this->makeRequest($url); - - if ($response && $response['Status'] == "OK") { - $raceData = $response['Data']; - - $classFees = []; - if (isset($raceData['Classes'])) { - foreach ($raceData['Classes'] as $class) { - if (isset($class['Name'])) { - $name = $class['Name']; - $fee = $class['Fee'] ?? null; // default to null if missing - $classFees[$name] = $fee; - } - } - } - - ksort ( $classFees ); - - $oddily = $this->getClubs($raceData); - $oblasti = []; - if (isset($raceData['Regions'])) { - foreach ($raceData['Regions'] as $tag => $region) { - $oblasti[] = $region['ID']; - } - } - - // Get last Stage date if multistage event - $date2 = ($raceData['Stages'] > 1) ? $this->getRaceDate($raceData['Stage'.$raceData['Stages']], $response) : 0; - // Use associative array to pass data to constructor - return new RaceInfo([ - 'ext_id' => $raceData['ID'], - 'datum' => String2DateDMY(formatDate($raceData['Date'])), - 'datum2' => $date2, - 'nazev' => $raceData['Name'], - 'misto' => $raceData['Place'], -// 'category' => $raceData['Category'], - //typ0 => Typ akce - 'oblasti' => $oblasti, - 'typ0' => 'Z', - 'typ' => $this->mapSport($raceData['Sport']['ID']), - 'zebricek2' => $this->mapLevelToZebricek2($raceData['Level']['ID']), - 'ranking' => $raceData['Ranking'], - 'odkaz' => $this->getRaceURL($raceData['ID']), - 'prihlasky' => strtotime($raceData['EntryDate1']), - 'prihlasky1' => strtotime($raceData['EntryDate2']), - 'prihlasky2' => strtotime($raceData['EntryDate3']), -// 'prihlasky3' => '', -// 'prihlasky4' => '', -// 'prihlasky5' => '', - 'koeficient1' => $raceData['EntryKoef2'], - 'koeficient2' => $raceData['EntryKoef3'], - 'etap' => $raceData['Stages'], -// 'poznamka' => $poznamka, + +class RaceOverview { + /** @var array> Category => [EntryStop => Fee] */ + public array $categories = []; + + /** @var array> ServiceName => [ Fee => Count ] */ + public array $services = []; + + /** @var array feeTier => exist */ + public array $feeTiers = []; + + public function addCategory(string $name, int $feeTier, int $fee ): void { + + if (!isset($this->categories[$name])) { + $this->categories[$name] = []; + } + $this->categories[$name][$feeTier] = $fee; + + // Store only unique sorted feeTier values + $this->feeTiers[$feeTier] = true; + } + + public function addService(string $name, int $fee, int $count ): void { + if ( isset ( $this->services[$name][$fee] ) ) { + $this->services[$name][$fee] += $count; + } else { + $this->services[$name][$fee] = $count; + } + } +} + +class RaceParticipant { + public string $regNo; + public string $classDesc; + public string $name; + public bool $rentSI; + public string $licence; + public $fee; + public int $feeTier; + + public function __construct(string $regNo, string $classDesc, string $name, bool $rentSI, string|null $licence, int $fee, int $feeTier) { + $this->regNo = $regNo; + $this->classDesc = $classDesc; + $this->name = $name; + $this->rentSI = $rentSI; + $this->licence = $licence ?? ''; + $this->fee = $fee; + $this->feeTier = $feeTier; + } +} + +interface ConnectorInterface { + public function getSystemName(): string; + public function getRaceURL(string $id): string; + public function getRaceInfo(string $id); + public function getRacesList($fromDate, $toDate); + public function getRacePayement(string $id) : ?RacePayement; +} + +class OrisCZConnector implements ConnectorInterface { + private $service; + + public function __construct() { + $this->service = OrisIntegrationServiceFactory::create(); + } + + public function getSystemName(): string { + return "Oris"; + } + + public function getRaceURL($raceId) : string { + return OrisIntegrationServiceFactory::getConfiguredBaseUrl() . 'Zavod?id=' . $raceId; + } + + private function mapLevelToZebricek2($levelId) { + $map = [ + 1 => 17, // MCR + 3 => 6, // ZB + 4 => 8, // OZ + 7 => 33, // CPS + 8 => 1, // CP + 11 => 24, // OM + 17 => 17 // VET + ]; + return $map[$levelId] ?? 0x0080; // Default to 0x80 if not found + } + + private function mapSport($sportId) { + $map = [ + 1 => 1, // OB + 2 => 4, // LOB + 3 => 2, // MTBO + 4 => 8, // TRAIL + ]; + return $map[$sportId] ?? 1; // Default is OB + } + + private function getClubs(&$raceData) { + $oddily = []; + if (isset($raceData['Org1']['Abbr'])) { + $oddily[] = $raceData['Org1']['Abbr']; + } + if (isset($raceData['Org2']['Abbr'])) { + $oddily[] = $raceData['Org2']['Abbr']; + } + return implode('+', $oddily); + } + + public function getRaceDate($raceId) { + try { + $raceData = $this->service->getEvent($raceId); + if (isset($raceData['Date'])) { + return String2DateDMY(formatDate($raceData['Date'])); + } + } catch (OrisException $e) { + // fallback + } + return ''; + } + + public function getRaceInfo($raceId) { + try { + $raceData = $this->service->getEvent($raceId); + + $classFees = []; + if (isset($raceData['Classes'])) { + foreach ($raceData['Classes'] as $class) { + if (isset($class['Name'])) { + $name = $class['Name']; + $fee = $class['Fee'] ?? null; + $classFees[$name] = $fee; + } + } + } + + ksort($classFees); + + $oddily = $this->getClubs($raceData); + $oblasti = []; + if (isset($raceData['Regions'])) { + foreach ($raceData['Regions'] as $tag => $region) { + $oblasti[] = $region['ID']; + } + } + + $date2 = ($raceData['Stages'] > 1) ? $this->getRaceDate($raceData['Stage'.$raceData['Stages']]) : 0; + + return new RaceDTO([ + 'ext_id' => $raceData['ID'], + 'datum' => String2DateDMY(formatDate($raceData['Date'])), + 'datum2' => $date2, + 'nazev' => $raceData['Name'], + 'misto' => $raceData['Place'], + 'oblasti' => $oblasti, + 'typ0' => 'Z', + 'typ' => $this->mapSport($raceData['Sport']['ID']), + 'zebricek2' => $this->mapLevelToZebricek2($raceData['Level']['ID']), + 'ranking' => $raceData['Ranking'], + 'odkaz' => $this->getRaceURL($raceData['ID']), + 'prihlasky' => strtotime($raceData['EntryDate1']), + 'prihlasky1' => strtotime($raceData['EntryDate2']), + 'prihlasky2' => strtotime($raceData['EntryDate3']), + 'koeficient1' => $raceData['EntryKoef2'], + 'koeficient2' => $raceData['EntryKoef3'], + 'etap' => $raceData['Stages'], 'vicedenni' => ($raceData['Stages']>1?1:0), 'oddil' => $oddily, 'modify_flag' => 0, - 'kategorie' => implode(';', array_keys ( $classFees ) ), + 'kategorie' => implode(';', array_keys($classFees)), 'startovne' => $classFees, - 'cancelled' => (!empty($raceData['Cancelled']) || !empty($raceData['Canceled']) || !empty($raceData['cancelled']) || !empty($raceData['canceled'])) ? 1 : 0 - ]); - } else { - return null; // Return null if race not found or error - } - } - - // Helper method to make HTTP requests - private function makeRequest($url) { - $response = file_get_contents($url); - - // Decode JSON response - return json_decode($response, true); - } - - private function makeRequestCurl($url) { - $ch = curl_init($url); - - // Set curl options - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - $response = curl_exec($ch); - - // Check for errors - if (curl_errno($ch)) { - echo 'Curl error: ' . curl_error($ch); - return null; - } - - curl_close($ch); - - // Decode JSON response - return json_decode($response, true); - } - - function getRacesList($fromDate, $toDate) { - $url = $this->apiUrl.'?format=json&method=getEventList&all=1&datefrom='.$fromDate.'&dateto='.$toDate; -// echo($url.'
'); - $response = $this->makeRequest($url); - - if ($response && $response['Status'] == "OK") { - $racesData = $response['Data']; - $rows = array(); - foreach($racesData as $oneRace) { - $oddily = $this->getClubs($oneRace); - - $row = array(); - $row[] = $oneRace['ID']; - $row[] = $oneRace['Date']; - $row[] = $oneRace['Name']; - $row[] = $oddily; - $rows[] = $row; - } - return $rows; - } else { - return null; // Return null if race not found or error - } - } - - // Method to get detailed race information based on race ID - public function getRacePayement($raceId) : ?RacePayement { - - global $g_external_is_club_id; - - if ( !IsSet ($g_external_is_club_id) || $g_external_is_club_id === '' ) return null; - - $url = $this->apiUrl . '?format=json&method=getEventEntries&clubid=' . $g_external_is_club_id . '&eventid=' . $raceId; - - $response = $this->makeRequest($url); - $racePayement = null; - - if ($response && $response['Status'] == "OK") { - $racePayement = new RacePayement($raceId); - - foreach ($response['Data'] as $entry) { - if (isset($entry['Fee'])) { - if (isset($entry['RegNo']) ) { - $racePayement->addPatricipant( - new RaceParticipant($entry['RegNo'], $entry['ClassDesc'], $entry['Name'], - $entry['RentSI'], $entry['Licence'], (int)$entry['Fee'], $entry['EntryStop'])); - } - if (isset($entry['ClassDesc'])&&isset($entry['ClassDesc'])) { - $racePayement->addCategory($entry['ClassDesc'], $entry['EntryStop'], (int)$entry['Fee']); - } - } - } - } - - $url = $this->apiUrl . '?format=json&method=getEventServiceEntries&clubid=' . $g_external_is_club_id . '&eventid=' . $raceId; - - $response = $this->makeRequest($url); - - if ($response && $response['Status'] == "OK") { - if ( $racePayement === null ) $racePayement = new RacePayement($raceId); - foreach ($response['Data'] as $entry) { - if ( isset ( $entry['Service'] ) ) { - $racePayement->addService($entry['Service']['NameCZ'] ?? 'Name?', $entry['Service']['UnitPrice'] , $entry['Quantity'] ); - } else { - if ( isset ( $entry['Quantity'] ) && isset ( $entry['TotalFee'] ) ) { - $racePayement->addService('Name?', $entry['TotalFee'] / $entry['Quantity'], $entry['Quantity'] ); - } - } - } - } - - //print_r ($racePayement); - - return $racePayement; - } -} - -class ConnectorFactory { - public static function create(): ?ConnectorInterface { - global $g_external_is_connector; - - if ( $g_external_is_connector && class_exists( $g_external_is_connector)) { - return new $g_external_is_connector(); - } - - return null; // Return null explicitly if no valid connector is found - } -} + 'cancelled' => (!empty($raceData['Cancelled']) || !empty($raceData['Canceled']) || !empty($raceData['cancelled']) || !empty($raceData['canceled'])) ? 1 : 0, + 'entry_start' => !empty($raceData['EntryStart']) ? $raceData['EntryStart'] : null, + ]); + } catch (OrisException $e) { + return null; + } + } + + function getRacesList($fromDate, $toDate) { + try { + $racesData = $this->service->getEventList($fromDate, $toDate, 1); + $rows = array(); + foreach($racesData as $oneRace) { + $oddily = $this->getClubs($oneRace); + + $row = array(); + $row[] = $oneRace['ID']; + $row[] = $oneRace['Date']; + $row[] = $oneRace['Name']; + $row[] = $oddily; + $rows[] = $row; + } + return $rows; + } catch (OrisException $e) { + return null; + } + } + + public function getRacePayement($raceId) : ?RacePayement { + + global $g_external_is_club_id; + + if ( !IsSet ($g_external_is_club_id) || $g_external_is_club_id === '' ) return null; + + $racePayement = null; + + try { + $entries = $this->service->getEventEntries($raceId, $g_external_is_club_id); + $racePayement = new RacePayement($raceId); + foreach ($entries as $entry) { + if (isset($entry['Fee'])) { + if (isset($entry['RegNo'])) { + $racePayement->addPatricipant( + new RaceParticipant($entry['RegNo'], $entry['ClassDesc'], $entry['Name'], + $entry['RentSI'], $entry['Licence'], (int)$entry['Fee'], $entry['EntryStop'])); + } + if (isset($entry['ClassDesc'])) { + $racePayement->addCategory($entry['ClassDesc'], $entry['EntryStop'], (int)$entry['Fee']); + } + } + } + } catch (OrisException $e) { + // Ignore partial ORIS failures and keep current null/partial behavior. + } + + try { + $serviceEntries = $this->service->getEventServiceEntries($raceId, $g_external_is_club_id); + if ( $racePayement === null ) $racePayement = new RacePayement($raceId); + foreach ($serviceEntries as $entry) { + if ( isset ( $entry['Service'] ) ) { + $racePayement->addService($entry['Service']['NameCZ'] ?? 'Name?', $entry['Service']['UnitPrice'] , $entry['Quantity'] ); + } else { + if (isset($entry['Quantity']) && isset($entry['TotalFee'])) { + $racePayement->addService('Name?', $entry['TotalFee'] / $entry['Quantity'], $entry['Quantity']); + } + } + } + } catch (OrisException $e) { + // Ignore partial ORIS failures and keep current null/partial behavior. + } + + return $racePayement; + } +} + +class ConnectorFactory { + public static function create(): ?ConnectorInterface { + global $g_external_is_connector; + + if ( $g_external_is_connector && class_exists( $g_external_is_connector)) { + return new $g_external_is_connector(); + } + + return null; // Return null explicitly if no valid connector is found + } +} diff --git a/ct_renderer_race.inc.php b/ct_renderer_race.inc.php index 93c51b6..f9c5364 100644 --- a/ct_renderer_race.inc.php +++ b/ct_renderer_race.inc.php @@ -5,6 +5,43 @@ require_once("ctable.inc.php"); require_once("common_race.inc.php"); require_once("ct_renderer.inc.php"); +require_once("connectors.php"); + +function RenderRaceSyncStatusSymbol($sync_status, bool $oris_entry_future): string { + return match ($sync_status) { + 'SYNCED' => '✔', + 'PENDING_CREATE', 'PENDING_UPDATE', 'PENDING_DELETE' => $oris_entry_future ? '🕒' : '⟳', + 'FAILED_CREATE', 'FAILED_UPDATE', 'FAILED_DELETE' => '⚠', + default => '', + }; +} + +// create and return new sync column if required +function CreateRaceSyncStatusColumn($race): ?TableColumn { + $connector = ConnectorFactory::create(); + // only for not performed races + // $race_date = is_array($race) ? (int)($race['datum'] ?? 0) : 0; + // $show_sync_status = is_array($race) && $race_date > GetCurrentDate() && !empty($race['ext_id']) && !empty($oris_club_key) && $connector !== null; + global $g_oris_club_key; + $show_sync_status = !empty($race['ext_id']) && !empty($g_oris_club_key) && $connector !== null; + + if (!$show_sync_status) { + return null; + } + + $entry_start_future = false; + if (is_array($race) && !empty($race['entry_start'])) { + $entry_start = strtotime($race['entry_start']); + $entry_start_future = ($entry_start !== false && $entry_start > time()); + } + + return RaceRendererFactory::createColumn( + new HelpHeaderRenderer('⇄', ALIGN_CENTER, 'Synchronizace s '.$connector->getSystemName()), + new CallbackRenderer(function (RowData $row, array $options) use ($entry_start_future): string { + return RenderRaceSyncStatusSymbol($row->rec['sync_status'] ?? '', $entry_start_future); + }) + ); +} class LimitBreakDetector implements IBreakRowDetector { private int $limit; @@ -76,6 +113,9 @@ public static function createColRenderer(string $column_name): IColumnContentRen 'kat' => new FormatFieldRenderer($column_name, function($kat) { return ''.htmlspecialchars($kat).''; }), 'transport', 'ubytovani' => new FormatFieldRenderer($column_name, function ( $bl ) : string { return $bl ? '' : ''; } ), + 'sync_status' => new CallbackRenderer(function ( RowData $row, array $options ) : string { + return RenderRaceSyncStatusSymbol($row->rec['sync_status'] ?? '', (bool)($options['oris_entry_future'] ?? false)); + }), 'sedadel' => new CallbackRenderer(function ( RowData $row, array $options ) : string { $dummy = 0; return GetSharedTransportValue($row->rec["transport"], $row->rec["sedadel"], $dummy ); @@ -94,6 +134,7 @@ public static function createHeaderRenderer(string $column_name): IColumnHeaderR 'transport' => new HelpHeaderRenderer('SD',ALIGN_CENTER,"Společná"), 'sedadel' => new HelpHeaderRenderer('🚗',ALIGN_CENTER,'Nabízených sedadel'), 'ubytovani' => new HelpHeaderRenderer('SU',ALIGN_CENTER,"Společné ubytování"), + 'sync_status' => new HelpHeaderRenderer('⇄', ALIGN_CENTER, 'Synchronizace'), 'termin' => new DefaultHeaderRenderer('Termín',ALIGN_CENTER), 'pozn' => new DefaultHeaderRenderer('Pozn.'), 'pozn_in' => new DefaultHeaderRenderer('Pozn.(i)'), @@ -102,4 +143,4 @@ public static function createHeaderRenderer(string $column_name): IColumnHeaderR } } -?> \ No newline at end of file +?> diff --git a/docker-compose.autotest.observe.yml b/docker-compose.autotest.observe.yml index b1a6713..5e4f5b5 100644 --- a/docker-compose.autotest.observe.yml +++ b/docker-compose.autotest.observe.yml @@ -3,6 +3,7 @@ services: ports: - "10090:10100" # this line maps your pc port to the container port - "10093:10300" # this line maps your pc port to the container port + - "10094:10301" # ORIS mock server phpmyadmin: image: phpmyadmin/phpmyadmin ports: diff --git a/docker-compose.autotest.yml b/docker-compose.autotest.yml index 876bd0e..1bd1f4f 100644 --- a/docker-compose.autotest.yml +++ b/docker-compose.autotest.yml @@ -1,8 +1,8 @@ services: web: build: - context: ./docker - dockerfile: Dockerfile.srv + context: . + dockerfile: docker/Dockerfile.srv target: autotest working_dir: /var/www/html/members depends_on: @@ -16,8 +16,18 @@ services: BANK_MOCK_DB_NAME: rb_mock BANK_MOCK_PORT: 10300 BANK_MOCK_START: auto + ORIS_MOCK_DB_HOST: db + ORIS_MOCK_DB_PORT: 3306 + ORIS_MOCK_DB_USER: root + ORIS_MOCK_DB_PASSWORD: lkj234@sf!--+KLM + ORIS_MOCK_DB_NAME: oris_mock + ORIS_MOCK_PORT: 10301 + ORIS_MOCK_START: auto + ORIS_MOCK_DEFAULT_CLUB_ID: 205 + ORIS_MOCK_DEFAULT_CLUB_ABBR: ZBM volumes: - ./:/var/www/html/members #this line maps the content of ./html in your pc to the /var/www/html of the container + - members_node_modules:/var/www/html/members/node_modules #for using node modules from container, not host - ./logs/autotest/:/var/www/html/members/logs # override configuration, modifiable in original place only - ./docker/config/autotest/_cfg.php:/var/www/html/members/cfg/_cfg.php:ro @@ -27,7 +37,7 @@ services: test: [ "CMD-SHELL", - "node -e \"const http=require('http');const urls=['http://127.0.0.1:10100/members/','http://127.0.0.1:10300/health'];let pending=urls.length;let done=false;const finish=(code)=>{if(!done){done=true;process.exit(code);}};const timer=setTimeout(()=>finish(1),3000);for(const url of urls){http.get(url,res=>{res.resume();if(res.statusCode>=200&&res.statusCode<500){if(--pending===0){clearTimeout(timer);finish(0);}}else{clearTimeout(timer);finish(1);}}).on('error',()=>{clearTimeout(timer);finish(1);});}\"" + "node -e \"const http=require('http');const urls=['http://127.0.0.1:10100/members/','http://127.0.0.1:10300/health','http://127.0.0.1:10301/health'];let pending=urls.length;let done=false;const finish=(code)=>{if(!done){done=true;process.exit(code);}};const timer=setTimeout(()=>finish(1),3000);for(const url of urls){http.get(url,res=>{res.resume();if(res.statusCode>=200&&res.statusCode<500){if(--pending===0){clearTimeout(timer);finish(0);}}else{clearTimeout(timer);finish(1);}}).on('error',()=>{clearTimeout(timer);finish(1);});}\"" ] interval: 2s timeout: 5s @@ -49,3 +59,6 @@ services: volumes: # initial database content - ./docker/db_init/d235220_members_latest.sql:/docker-entrypoint-initdb.d/d235220_members.sql + +volumes: + members_node_modules: #for using node modules from container, not host diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml old mode 100644 new mode 100755 index cb70d6f..3dcd03e --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,14 +1,14 @@ services: web: build: - context: ./docker - dockerfile: Dockerfile.srv + context: . + dockerfile: docker/Dockerfile.srv target: dev working_dir: /var/www/html/members ports: - "10100:10100" #this line maps your pc port to the container port - "10300:10300" # bank mock server UI and API - - "10301:10301" # reserved for a second mock server + - "10301:10301" # ORIS mock server depends_on: - db #this line links this container to the db container environment: @@ -18,8 +18,18 @@ services: BANK_MOCK_DB_PASSWORD: dev4password BANK_MOCK_DB_NAME: rb_mock BANK_MOCK_PORT: 10300 + ORIS_MOCK_DB_HOST: db + ORIS_MOCK_DB_PORT: 3306 + ORIS_MOCK_DB_USER: root + ORIS_MOCK_DB_PASSWORD: dev4password + ORIS_MOCK_DB_NAME: oris_mock + ORIS_MOCK_PORT: 10301 + ORIS_MOCK_DEFAULT_CLUB_ID: 205 + ORIS_MOCK_DEFAULT_CLUB_ABBR: ZBM volumes: - ./:/var/www/html/members #this line maps the content of ./html in your pc to the /var/www/html of the container + - members_node_modules:/var/www/html/members/node_modules #for using node modules from container, not host + - ./logs/autotest/:/var/www/html/members/logs # override configuration, modifiable in original place only - ./docker/config/dev/_cfg.php:/var/www/html/members/cfg/_cfg.php:ro - ./docker/config/dev/_globals.php:/var/www/html/members/cfg/_globals.php:ro @@ -44,3 +54,7 @@ services: - db #this line links this container to the db container environment: PMA_HOST: db + + +volumes: + members_node_modules: #for using node modules from container, not host \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml old mode 100644 new mode 100755 index 5472511..4b9d90a --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ services: web: build: - context: ./docker - dockerfile: Dockerfile.srv + context: . + dockerfile: docker/Dockerfile.srv target: base ports: - "80:10100" #this line maps your pc port to the container port diff --git a/docker/Dockerfile.srv b/docker/Dockerfile.srv index fb01cd0..3561ee4 100644 --- a/docker/Dockerfile.srv +++ b/docker/Dockerfile.srv @@ -29,11 +29,16 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright # Install Playwright CLI and Chromium for browser-based tests. RUN apt-get update \ && apt-get install -y --no-install-recommends nodejs npm ca-certificates \ - && npm install -g playwright \ - && playwright install --with-deps chromium \ && rm -rf /var/lib/apt/lists/* -COPY entrypoint.sh /usr/local/bin/members-entrypoint.sh +WORKDIR /var/www/html/members + +COPY package.json package-lock.json ./ + +RUN npm ci \ + && npx playwright install --with-deps chromium webkit + +COPY docker/entrypoint.sh /usr/local/bin/members-entrypoint.sh RUN chmod +x /usr/local/bin/members-entrypoint.sh ENTRYPOINT ["/usr/local/bin/members-entrypoint.sh"] @@ -41,6 +46,7 @@ ENTRYPOINT ["/usr/local/bin/members-entrypoint.sh"] FROM autotest as dev # Install common VS Code Container Tools dependencies. +# bubblewrap is required for codex RUN apt-get update \ && apt-get install -y --no-install-recommends \ curl \ @@ -50,8 +56,9 @@ RUN apt-get update \ procps \ unzip \ wget \ + bubblewrap \ && pecl install xdebug \ && docker-php-ext-enable xdebug \ && rm -rf /var/lib/apt/lists/* -COPY xdebug.ini /usr/local/etc/php/conf.d/zz-xdebug.ini +COPY docker/xdebug.ini /usr/local/etc/php/conf.d/zz-xdebug.ini diff --git a/docker/config/autotest/_cfg.php b/docker/config/autotest/_cfg.php index 4ceb2a4..9232e8b 100644 --- a/docker/config/autotest/_cfg.php +++ b/docker/config/autotest/_cfg.php @@ -98,12 +98,14 @@ $g_race_accommodation_default = 1; $g_enable_race_capacity = true; -// Externi informacni system - podporovane hodnoty 'OrisCZConnector' a '' -$g_external_is_connector = 'OrisCZConnector'; -// Identifikator oddilu v informacnim systemu -$g_external_is_club_id = '205'; - -$g_custom_entry_list_text = ''; +// Externi informacni system - podporovane hodnoty 'OrisCZConnector' a '' +$g_external_is_connector = 'OrisCZConnector'; +// Identifikator oddilu v informacnim systemu +$g_external_is_club_id = '205'; +$g_oris_base_url = 'http://127.0.0.1:10301/'; +$g_oris_club_key = 'mockClubKey'; + +$g_custom_entry_list_text = ''; //================================================================== // bank API diff --git a/docker/config/dev/_cfg.php b/docker/config/dev/_cfg.php index 8758fcb..47758c4 100644 --- a/docker/config/dev/_cfg.php +++ b/docker/config/dev/_cfg.php @@ -98,12 +98,14 @@ $g_race_accommodation_default = 1; $g_enable_race_capacity = true; -// Externi informacni system - podporovane hodnoty 'OrisCZConnector' a '' -$g_external_is_connector = 'OrisCZConnector'; -// Identifikator oddilu v informacnim systemu -$g_external_is_club_id = '205'; - -$g_custom_entry_list_text = ''; +// Externi informacni system - podporovane hodnoty 'OrisCZConnector' a '' +$g_external_is_connector = 'OrisCZConnector'; +// Identifikator oddilu v informacnim systemu +$g_external_is_club_id = '205'; +$g_oris_base_url = 'http://127.0.0.1:10301/'; +$g_oris_club_key = 'mockClubKey'; + +$g_custom_entry_list_text = ''; //================================================================== // bank API diff --git a/docker/db_init/d235220_members_latest.sql b/docker/db_init/d235220_members_latest.sql index 2028fc8..5544ac0 100644 --- a/docker/db_init/d235220_members_latest.sql +++ b/docker/db_init/d235220_members_latest.sql @@ -1087,7 +1087,8 @@ INSERT INTO `tst_users` (`id`, `prijmeni`, `jmeno`, `datum`, `adresa`, `mesto`, (34, 'Gross', 'Michal', '0000-00-00', '', '', '', '', '', '', '', 6946, 0, 0, 'Gross Michal', 'H', 'C', '-', '-', 0, NULL, 0, '', 'CZ', 0, NULL, 0), (36, 'Štěrbák', 'Řehoř', '0000-00-00', '', '', '', '', '', '', 'arnost@eob.cz', 8006, 0, 0, 'Štěrbák Řehoř', 'H', 'C', '-', '-', 0, NULL, 0, '', 'CZ', 0, NULL, 0), (37, 'Mocvesely', 'Osvald', '1944-01-01', '', '', '', '', '', '', '', 4400, 0, 0, 'Mocvesely Osvald', 'H', 'C', '-', '-', 0, NULL, 0, '', 'BW', 1, NULL, 8), -(38, 'Matula', 'Petr', '1982-05-17', '', '', '', '', '', '', '', 8202, 2042531, 0, 'Matula Petr', 'H', 'C', '-', '-', 0, NULL, 0, '', 'CZ', 0, NULL, 5); +(38, 'Matula', 'Petr', '1982-05-17', '', '', '', '', '', '', '', 8202, 2042531, 0, 'Matula Petr', 'H', 'C', '-', '-', 0, NULL, 0, '', 'CZ', 0, NULL, 5), +(39, 'Coufalová', 'Rea', '2009-11-21', 'Na Louce 27', '', '', '544903118', '531228640', '728305417', 'rea.coufalova.0953@example.test', 0953, 123, 0, 'Coufalová Rea', 'D', 'A', '-', '-', 0, '', 0, '0953066978', 'CZ', 0, NULL, 0); -- -------------------------------------------------------- @@ -1162,7 +1163,8 @@ CREATE TABLE `tst_zavod` ( `ubytovani` tinyint(1) DEFAULT NULL, `kapacita` smallint(6) DEFAULT NULL, `prihlasenych` smallint(6) NOT NULL DEFAULT 0, - `cancelled` tinyint(1) NOT NULL DEFAULT 0 + `cancelled` tinyint(1) NOT NULL DEFAULT 0, + `entry_start` datetime DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_czech_ci COMMENT='tabulka popisu zavodu'; -- @@ -1216,7 +1218,8 @@ INSERT INTO `tst_zavod` (`id`, `ext_id`, `datum`, `datum2`, `nazev`, `misto`, `t (47, NULL, 1615590000, 0, 'Pokus', 'XXX', 'ob', 'Z', 0, 1, '1', '', 2, 1607727600, 1610406000, 0, 0, 0, 1, 'D10N;D12C;D14C;D16C;D18C;D21C;D21D;D35C;D45C;D55C;H10N;H12C;H14C;H16C;H18C;H21C;H21D;H35C;H45C;H55C;HDR', '', 0, 0, 'kkk', 1, 2, 0, 0, NULL, 5, 0), (48, NULL, 1658354400, 0, 'Letni test', 'Brno - Salingrad', 'jine', 'Z', 0, 17, '0', '', 2, 1658095200, 1658268000, 0, 0, 0, 1, 'D16A;D18A;D20A;D21A;D21E;H16A;H18A;H20A;H21A;H21E', '', 0, 0, 'FAK', 0, 2, 2, 2, NULL, 9, 0), (49, NULL, 1675378800, 0, 'MČR Klasika', 'Dolní Lhota', 'ob', 'Z', 0, 1, '1', '', 2, 1674169200, 1674255600, 0, 0, 0, 1, 'H18D;H18N;H20B;H20C;H21E;H21A;D10N;D12;D14;D16;D18;D21C;D21D;D35;D45;D55;H10N;H12;H14;H16;H18;H21C;H21D;H35;H45;H55;HDR;D16A;D18A;D20A;D21A;D21E;H16A;H18A;H20A', '', 0, 0, 'GBM', 0, 2, 1, 2, NULL, 1, 0), -(50, NULL, 1699311600, 0, 'Kamenec pod Vtáčnikom Middle sobota', 'Kamenec pod Vtáčnikom', 'ob', 'Z', 0, 1, '1', 'is.orienteering.sk/competitions/1759', 3, 1696111200, 1696370400, 1698793200, 0, 0, 1, 'D10N;D12;D14;D16;D18;D21C;D21D;D35;D45;D55;H10N;H12;H14;H16;H18;H21C;H21D;H35;H45;H55;HDR', 'Slovenský rebríček jednotlivcov - E1 07.10.2023 Stredná trať 205\r\nSlovenský rebríček jednotlivcov - E2 08.10.2023 Stredná trať 212', 0, 0, 'SKS', 0, 2, 0, 0, NULL, 2, 0); +(50, NULL, 1699311600, 0, 'Kamenec pod Vtáčnikom Middle sobota', 'Kamenec pod Vtáčnikom', 'ob', 'Z', 0, 1, '1', 'is.orienteering.sk/competitions/1759', 3, 1696111200, 1696370400, 1698793200, 0, 0, 1, 'D10N;D12;D14;D16;D18;D21C;D21D;D35;D45;D55;H10N;H12;H14;H16;H18;H21C;H21D;H35;H45;H55;HDR', 'Slovenský rebríček jednotlivcov - E1 07.10.2023 Stredná trať 205\r\nSlovenský rebríček jednotlivcov - E2 08.10.2023 Stredná trať 212', 0, 0, 'SKS', 0, 2, 0, 0, NULL, 2, 0), +(51, '8971', 1780790400, 0, 'Mistrovství oblasti na klasické trati', 'Bunkr - bývalý vojenský areál, Mokrá - Horákov', 'ob', 'Z', 0, 24, '1', 'http://127.0.0.1/Zavod?id=8971', 2, 1780272000, 1780444800, 0, 0, 0, 1, 'D10;D10N;D12;D14;D16;D18;D21C;D21D;D35;D45;D55;D65;D70;H10;H10N;H12;H14;H16;H18;H21C;H21D;H35;H45;H55;H65;H70;HDR;P', '', 0, 0, 'PBM', 0, 2, 1, 1, NULL, 2, 0); -- -------------------------------------------------------- @@ -1237,7 +1240,11 @@ CREATE TABLE `tst_zavxus` ( `sedadel` tinyint(1) DEFAULT NULL, `ubytovani` tinyint(1) DEFAULT NULL, `participated` tinyint(1) DEFAULT NULL, - `add_by_fin` tinyint(1) DEFAULT NULL + `add_by_fin` tinyint(1) DEFAULT NULL, + `entry_id` int(10) UNSIGNED DEFAULT NULL, + `sync_status` enum('LOCAL_ONLY','SYNCED','PENDING_CREATE','PENDING_UPDATE','PENDING_DELETE','FAILED_CREATE','FAILED_UPDATE','FAILED_DELETE') NOT NULL DEFAULT 'LOCAL_ONLY', + `sync_timestamp` datetime DEFAULT NULL, + `sync_error_payload` text DEFAULT NULL ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_czech_ci COMMENT='tabulka prihlasek - clovek X zavod'; -- @@ -1346,7 +1353,9 @@ INSERT INTO `tst_zavxus` (`id`, `id_user`, `id_zavod`, `kat`, `pozn`, `pozn_in`, (130, 4, 50, 'H45', '', '', 3, 0, 0, NULL, 0, NULL, NULL), (131, 6, 13, 'D21C', '', '', 1, 0, 0, NULL, 0, NULL, NULL), (132, 4, 11, 'H', '', '', 1, 0, 0, NULL, 0, NULL, NULL), -(133, 4, 13, 'H', '', '', 1, 0, 0, NULL, 0, NULL, NULL); +(133, 4, 13, 'H', '', '', 1, 0, 0, NULL, 0, NULL, NULL), +(134, 11, 51, 'H21', '', '', 1, 0, 1, NULL, 1, NULL, NULL), +(135, 39, 51, 'D16', '', '', 1, 0, 1, NULL, 1, NULL, NULL); -- -- Indexes for dumped tables @@ -1444,7 +1453,8 @@ ALTER TABLE `tst_zavod` ADD FULLTEXT KEY `misto` (`misto`); ALTER TABLE `tst_zavxus` ADD PRIMARY KEY (`id`), ADD KEY `id_termin` (`termin`,`id`), - ADD KEY `id_user` (`id_user`); + ADD KEY `id_user` (`id_user`), + ADD KEY `sync_status` (`sync_status`); -- -- AUTO_INCREMENT for dumped tables diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 982d505..68cce29 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -46,8 +46,33 @@ if [ -n "${BANK_MOCK_DB_NAME:-}" ] && [ -f "package.json" ]; then done fi +if [ -n "${ORIS_MOCK_DB_NAME:-}" ] && [ -f "package.json" ]; then + + attempt=1 + max_attempts=30 + + while [ "${attempt}" -le "${max_attempts}" ]; do + if npm run --silent mock:oris:init >/tmp/mock-oris-init.log 2>&1; then + break + fi + + if [ "${attempt}" -eq "${max_attempts}" ]; then + cat /tmp/mock-oris-init.log >&2 + echo "ORIS mock DB initialization failed after ${max_attempts} attempts." >&2 + exit 1 + fi + + attempt=$((attempt + 1)) + sleep 1 + done +fi + if [ "${BANK_MOCK_START:-}" = "auto" ] && [ -f "package.json" ]; then npm run --silent mock:bank >logs/mock-bank.log 2>&1 & fi +if [ "${ORIS_MOCK_START:-}" = "auto" ] && [ -f "package.json" ]; then + npm run --silent mock:oris >logs/mock-oris.log 2>&1 & +fi + exec apache2-foreground diff --git a/index.php b/index.php index 2e82eab..17ebd30 100644 --- a/index.php +++ b/index.php @@ -209,6 +209,9 @@ case 4: require_once "./rg_ad_races_edit.inc.php"; break; + case 5: + require_once "./rg_oris_sync_log.inc.php"; + break; default: require_once "./news.inc.php"; } diff --git a/lib/OrisDTOs.php b/lib/OrisDTOs.php new file mode 100644 index 0000000..c943ff6 --- /dev/null +++ b/lib/OrisDTOs.php @@ -0,0 +1,105 @@ +ext_id = $data['ext_id'] ?? null; + $this->datum = $data['datum'] ?? null; + $this->datum2 = $data['datum2'] ?? null; + $this->nazev = $data['nazev'] ?? null; + $this->misto = $data['misto'] ?? null; + $this->oblasti = $data['oblasti'] ?? null; + $this->typ = $data['typ'] ?? null; + $this->zebricek2 = $data['zebricek2'] ?? null; + $this->ranking = $data['ranking'] ?? null; + $this->odkaz = $data['odkaz'] ?? null; + $this->prihlasky = $data['prihlasky'] ?? null; + $this->prihlasky1 = $data['prihlasky1'] ?? null; + $this->prihlasky2 = $data['prihlasky2'] ?? null; + $this->prihlasky3 = $data['prihlasky3'] ?? null; + $this->prihlasky4 = $data['prihlasky4'] ?? null; + $this->prihlasky5 = $data['prihlasky5'] ?? null; + $this->koeficient1 = $data['koeficient1'] ?? null; + $this->koeficient2 = $data['koeficient2'] ?? null; + $this->etap = $data['etap'] ?? null; + $this->poznamka = $data['poznamka'] ?? null; + $this->vicedenni = $data['vicedenni'] ?? null; + $this->oddil = $data['oddil'] ?? null; + $this->modify_flag = $data['modify_flag'] ?? null; + $this->kategorie = $data['kategorie'] ?? null; + $this->startovne = $data['startovne'] ?? null; + $this->cancelled = $data['cancelled'] ?? null; + $this->entry_start = $data['entry_start'] ?? null; + $this->typ0 = $data['typ0'] ?? null; + } +} + +class OrisEntryRequestDTO { + public ?string $clubuser; + public ?string $classId; + public ?string $si; + public int $rentSi; + public ?string $note; + public ?string $entryId; + + public function __construct( + ?string $clubuser, + ?string $classId, + ?string $si, + bool $rentSi = false, + ?string $note = null, + ?string $entryId = null + ) { + $this->clubuser = $clubuser; + $this->classId = $classId; + $this->si = $si; + $this->rentSi = $rentSi ? 1 : 0; + $this->note = $note; + $this->entryId = $entryId; + } + + public function toArray(): array { + $data = []; + if ($this->clubuser !== null) $data['clubuser'] = $this->clubuser; + if ($this->classId !== null) $data['class'] = $this->classId; + if ($this->si !== null && $this->si !== '' && $this->si !== '0' && $this->si !== 0) { + $data['si'] = $this->si; + } + $data['rent_si'] = $this->rentSi ? 1 : 0; + if ($this->note !== null && $this->note !== '') $data['note'] = $this->note; + if ($this->entryId !== null) $data['entryid'] = $this->entryId; + return $data; + } +} diff --git a/lib/OrisExceptions.php b/lib/OrisExceptions.php new file mode 100644 index 0000000..4cda17f --- /dev/null +++ b/lib/OrisExceptions.php @@ -0,0 +1,26 @@ +apiStatus = $apiStatus; + $this->apiData = $apiData; + } + + public function getApiStatus() { + return $this->apiStatus; + } + + public function getApiData() { + return $this->apiData; + } +} + +class OrisValidationException extends OrisException {} diff --git a/lib/OrisIntegrationService.php b/lib/OrisIntegrationService.php new file mode 100644 index 0000000..0676f07 --- /dev/null +++ b/lib/OrisIntegrationService.php @@ -0,0 +1,283 @@ + true, + 'updateEntry' => true, + 'deleteEntry' => true, + 'createServiceEntry' => true, + 'updateServiceEntry' => true, + 'deleteServiceEntry' => true, + 'getClubEntryRights' => true, + 'setClubEntryRights' => true, + 'getClubUserList' => true, + 'createPerson' => true, + 'editPerson' => true, + 'createClubUser' => true, + 'editClubUser' => true, + 'createUserLogin' => true, + ]; + + private $apiUrl; + private $clubKey; + + public function __construct($clubKey = null, $apiUrl = null) { + $this->clubKey = empty($clubKey) ? null : $clubKey; + $this->apiUrl = self::normalizeApiUrl($apiUrl ?? (self::DEFAULT_BASE_URL . 'API/')); + } + + public function hasClubKey() { + return !empty($this->clubKey); + } + + public function getApiUrl() { + return $this->apiUrl; + } + + public static function normalizeApiUrl($apiUrl) { + $apiUrl = trim((string)$apiUrl); + if ($apiUrl === '') { + return self::DEFAULT_BASE_URL . 'API/'; + } + + $apiUrl = rtrim($apiUrl, '/'); + if (preg_match('#/API$#i', $apiUrl)) { + return $apiUrl . '/'; + } + + return $apiUrl . '/API/'; + } + + private function log(string $msg): void { + $line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n"; + file_put_contents(__DIR__ . '/../logs/oris_sync.log', $line, FILE_APPEND | LOCK_EX); + } + + /** + * Internal generic HTTP request method. + */ + private function makeRequest($method, $params = [], $isPost = false) { + $requiresClubKey = isset(self::CLUB_KEY_REQUIRED_METHODS[$method]); + if ($requiresClubKey && !$this->hasClubKey()) { + throw new OrisValidationException("ORIS method '{$method}' requires configured clubkey."); + } + + $params['method'] = $method; + $params['format'] = 'json'; + if ($requiresClubKey) { + $params['clubkey'] = $this->clubKey; + } + + // Log outgoing request — skip API meta-params, show key=value pairs + $logParams = $params; + unset($logParams['method'], $logParams['format'], $logParams['clubkey']); + $paramStr = ''; + foreach ($logParams as $k => $v) { + $paramStr .= " $k=$v"; + } + $this->log(($isPost ? 'POST' : 'GET') . ' ' . $method . $paramStr); + + $ch = curl_init(); + + if ($isPost) { + $postData = http_build_query($params); + curl_setopt($ch, CURLOPT_URL, $this->apiUrl); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $postData); + } else { + $url = $this->apiUrl . '?' . http_build_query($params); + curl_setopt($ch, CURLOPT_URL, $url); + } + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + + $response = curl_exec($ch); + + if(curl_errno($ch)){ + $error = curl_error($ch); + curl_close($ch); + $this->log(' << cURL error: ' . $error); + throw new OrisNetworkException('cURL Error: ' . $error); + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $decoded = json_decode($response, true); + + // Log response in a readable way — show Status + compact Data summary + $logStatus = ($decoded !== null) ? ($decoded['Status'] ?? '?') : '(invalid JSON)'; + $logData = ($decoded !== null) ? ($decoded['Data'] ?? null) : null; + $dataStr = ''; + if (is_string($logData) && $logData !== '') { + $dataStr = ' data="' . $logData . '"'; + } elseif (is_array($logData)) { + if (isset($logData[0])) { + $dataStr = ' data=[' . count($logData) . ' items]'; + } else { + $pairs = []; + $shown = 0; + foreach ($logData as $k => $v) { + if ($shown++ >= 6) { $pairs[] = '…'; break; } + $pairs[] = $k . '=' . (is_scalar($v) ? $v : '[…]'); + } + $dataStr = ' data={' . implode(', ', $pairs) . '}'; + } + } + $this->log(' << HTTP ' . $httpCode . ' status=' . $logStatus . $dataStr); + + if ($httpCode >= 200 && $httpCode < 300 && isset($decoded['Status']) && $decoded['Status'] === 'OK') { + return $decoded['Data'] ?? $decoded; + } else { + $apiStatus = $decoded['Status'] ?? 'Unknown'; + $apiData = $decoded['Data'] ?? null; + $msg = "API Error or HTTP {$httpCode}. Status: {$apiStatus}"; + if ($isPost) { + $msg .= "\nPOST Data sent: " . print_r($params, true); + } + if (is_string($apiData)) { + $msg .= " - " . $apiData; + } + throw new OrisApiException($msg, $apiStatus, $apiData); + } + } + + // --- Write/Mutating Operations (Phase C) --- + + public function createEntry(OrisEntryRequestDTO $dto) { + return $this->makeRequest('createEntry', $dto->toArray(), true); + } + + public function updateEntry(OrisEntryRequestDTO $dto) { + return $this->makeRequest('updateEntry', $dto->toArray(), true); + } + + public function deleteEntry($entryId) { + return $this->makeRequest('deleteEntry', ['entryid' => $entryId], true); + } + + public function createPerson(array $params) { + return $this->makeRequest('createPerson', $params, true); + } + + public function editPerson(array $params) { + return $this->makeRequest('editPerson', $params, true); + } + + public function createClubUser(array $params) { + return $this->makeRequest('createClubUser', $params, true); + } + + public function editClubUser(array $params) { + return $this->makeRequest('editClubUser', $params, true); + } + + // --- Read-Only and Protected Read Endpoints (Phase A & B) --- + + public function getUser($rgnum) { + return $this->makeRequest('getUser', ['rgnum' => $rgnum]); + } + + public function getClubUsers($userId) { + return $this->makeRequest('getClubUsers', ['user' => $userId]); + } + + public function getEventEntries($eventId, $clubId = null) { + $params = ['eventid' => $eventId]; + if (!empty($clubId)) { + $params['clubid'] = $clubId; + } + return $this->makeRequest('getEventEntries', $params); + } + + public function getEventServiceEntries($eventId, $clubId = null) { + $params = ['eventid' => $eventId]; + if (!empty($clubId)) { + $params['clubid'] = $clubId; + } + return $this->makeRequest('getEventServiceEntries', $params); + } + + public function getEvent($eventId) { + return $this->makeRequest('getEvent', ['id' => $eventId]); + } + + public function getEventList($fromDate, $toDate, $all = 1) { + return $this->makeRequest('getEventList', [ + 'all' => $all, + 'datefrom' => $fromDate, + 'dateto' => $toDate + ]); + } + + public function getRegistration($sport, $year) { + return $this->makeRequest('getRegistration', [ + 'sport' => $sport, + 'year' => $year + ]); + } +} + +class OrisIntegrationServiceFactory { + + public static function create() { + return new OrisIntegrationService( + self::getConfiguredClubKey(), + self::getApiUrl(self::getConfiguredBaseUrl()) + ); + } + + public static function getConfiguredClubKey() { + global $g_oris_club_key; + + return empty($g_oris_club_key) ? null : $g_oris_club_key; + } + + public static function getConfiguredBaseUrl() { + global $g_oris_base_url, $g_oris_api_url; + + if (!empty($g_oris_base_url)) { + return self::normalizeBaseUrl($g_oris_base_url); + } + + if (!empty($g_oris_api_url)) { + return self::normalizeBaseUrl(preg_replace('#/API/?$#i', '', $g_oris_api_url)); + } + + return OrisIntegrationService::DEFAULT_BASE_URL; + } + + public static function getConfiguredApiUrl() { + return self::getApiUrl(self::getConfiguredBaseUrl()); + } + + private static function getApiUrl($baseUrl = null) { + if ($baseUrl === null || trim((string)$baseUrl) === '') { + return OrisIntegrationService::DEFAULT_BASE_URL . 'API/'; + } + + return OrisIntegrationService::normalizeApiUrl($baseUrl); + } + + private static function normalizeBaseUrl($baseUrl) { + $baseUrl = trim((string)$baseUrl); + if ($baseUrl === '') { + return OrisIntegrationService::DEFAULT_BASE_URL; + } + + $baseUrl = preg_replace('#/API/?$#i', '', $baseUrl); + + return rtrim($baseUrl, '/') . '/'; + } +} diff --git a/lib/oris_sync.inc.php b/lib/oris_sync.inc.php new file mode 100644 index 0000000..43cc647 --- /dev/null +++ b/lib/oris_sync.inc.php @@ -0,0 +1,297 @@ +getUser($rgnum); + if (is_array($userApiRes) && isset($userApiRes['ID'])) { + $globalUserId = $userApiRes['ID']; + + try { + $clubUsersRes = $service->getClubUsers($globalUserId); + if (is_array($clubUsersRes)) { + $clubUsers = $clubUsersRes; + if (isset($clubUsers['ID'])) { + $clubUsers = [$clubUsers]; + } + + global $g_external_is_club_id; + $clubId = $g_external_is_club_id ?? null; + + foreach ($clubUsers as $cu) { + if (isset($cu['ID'])) { + if ($clubId && isset($cu['ClubID']) && $cu['ClubID'] == $clubId) { + $clubuser = $cu['ID']; + break; + } + if (empty($clubuser)) { + $clubuser = $cu['ID']; + } + } + } + } + } catch (Exception $e) { + $errMsg = $e->getMessage(); + if ($e instanceof OrisNetworkException || ($e instanceof OrisApiException && strpos($errMsg, 'API Error or HTTP 5') !== false)) { + return 'queued'; + } + } + } + } catch (Exception $e) { + $errMsg = $e->getMessage(); + if ($e instanceof OrisNetworkException || ($e instanceof OrisApiException && strpos($errMsg, 'API Error or HTTP 5') !== false)) { + return 'queued'; + } + } + } + } + } + + // Get Race ORIS ID + $raceQuery = "SELECT * FROM `" . TBL_RACE . "` WHERE `id` = " . (int)$raceId; + $raceRes = query_db($raceQuery); + $raceRow = mysqli_fetch_assoc($raceRes); + + $comp = $raceRow['ext_id'] ?? null; + if (empty($comp)) { + query_db("UPDATE `" . TBL_ZAVXUS . "` SET `sync_status` = 'LOCAL_ONLY' WHERE `id` = " . (int)$id); + return; + } + + // Ignore relays + if ($raceRow && isset($raceRow['typ0']) && $raceRow['typ0'] === 'S') { + query_db("UPDATE `" . TBL_ZAVXUS . "` SET `sync_status` = 'LOCAL_ONLY' WHERE `id` = " . (int)$id); + return; + } + + // Check if ORIS entries are open yet + $entryStart = $raceRow['entry_start'] ?? null; + if (!empty($entryStart)) { + $entryStartTs = strtotime($entryStart); + if ($entryStartTs !== false && $entryStartTs > time()) { + return 'not_open'; + } + } + + // Map category name to ORIS class ID + $classId = null; + $katName = $row['kat']; + if (!empty($comp)) { + try { + $eventRes = $service->getEvent($comp); + if (is_array($eventRes) && isset($eventRes['Classes'])) { + $classes = is_array($eventRes['Classes']) ? $eventRes['Classes'] : []; + foreach ($classes as $cls) { + if (!is_array($cls)) continue; + if (trim($cls['Name'] ?? '') === trim($katName)) { + $classId = $cls['ID'] ?? null; + break; + } + } + } + } catch (Exception $e) { + $errMsg = $e->getMessage(); + if ($e instanceof OrisNetworkException || ($e instanceof OrisApiException && strpos($errMsg, 'API Error or HTTP 5') !== false)) { + return 'queued'; + } + } + } + + if (empty($classId)) { + $errorPayload = correct_sql_string(json_encode(['status' => 'error', 'message' => "Nelze spárovat kategorii '$katName' s ORISem."])); + $failedStatus = 'FAILED_' . strtoupper($action); + $res = query_db("UPDATE `" . TBL_ZAVXUS . "` SET `sync_status` = '$failedStatus', `sync_error_payload` = '$errorPayload' WHERE `id` = " . (int)$id); + if (!$res) { + // FAILED_* not yet in ENUM (migration pending) — save at least the error payload + query_db("UPDATE `" . TBL_ZAVXUS . "` SET `sync_error_payload` = '$errorPayload' WHERE `id` = " . (int)$id); + } + return false; + } + + $si = $row['si_chip']; + if (empty($si) && !empty($userRow['si_chip'])) { + $si = $userRow['si_chip']; + } + $rentSi = $row['rent_si'] ?? 0; + $note = $row['pozn'] ?? ''; + + if (empty($clubuser)) { + $errorPayload = correct_sql_string(json_encode(['status' => 'error', 'message' => 'Chybí ORIS ID uživatele v klubu (clubuser).'])); + $failedStatus = 'FAILED_' . strtoupper($action); + $res = query_db("UPDATE `" . TBL_ZAVXUS . "` SET `sync_status` = '$failedStatus', `sync_error_payload` = '$errorPayload' WHERE `id` = " . (int)$id); + if (!$res) { + query_db("UPDATE `" . TBL_ZAVXUS . "` SET `sync_error_payload` = '$errorPayload' WHERE `id` = " . (int)$id); + } + return false; + } + + try { + $response = []; + + if ($action === 'create') { + $existingEntryId = null; + if (!empty($comp) && !empty($clubuser)) { + try { + $entriesRes = $service->getEventEntries($comp); + if (is_array($entriesRes)) { + foreach ($entriesRes as $entry) { + if (isset($entry['ClubUserID']) && $entry['ClubUserID'] == $clubuser) { + $existingEntryId = $entry['ID']; + break; + } + } + } + } catch (Exception $e) { + // proceed with create + } + } + + if (!empty($existingEntryId)) { + $updateDto = new OrisEntryRequestDTO($clubuser, $classId, $si, (bool)$rentSi, $note, $existingEntryId); + $response = $service->updateEntry($updateDto); + $action = 'update'; + } else { + $createDto = new OrisEntryRequestDTO($clubuser, $classId, $si, (bool)$rentSi, $note); + $response = $service->createEntry($createDto); + } + } elseif ($action === 'update') { + $entryIdToUpdate = $row['entry_id'] ?? null; + + if (empty($entryIdToUpdate) && !empty($comp) && !empty($clubuser)) { + try { + $entriesRes = $service->getEventEntries($comp); + if (is_array($entriesRes)) { + foreach ($entriesRes as $entry) { + if (isset($entry['ClubUserID']) && $entry['ClubUserID'] == $clubuser) { + $entryIdToUpdate = $entry['ID']; + break; + } + } + } + } catch (Exception $e) { + // ignore + } + } + + if (empty($entryIdToUpdate)) { + // Fall back to create if no existing entry found + $createDto = new OrisEntryRequestDTO($clubuser, $classId, $si, (bool)$rentSi, $note); + $response = $service->createEntry($createDto); + } else { + $updateDto = new OrisEntryRequestDTO($clubuser, $classId, $si, (bool)$rentSi, $note, $entryIdToUpdate); + $response = $service->updateEntry($updateDto); + } + } elseif ($action === 'delete') { + $entryIdToDelete = $row['entry_id'] ?? null; + + if (empty($entryIdToDelete) && !empty($comp) && !empty($clubuser)) { + try { + $entriesRes = $service->getEventEntries($comp); + if (is_array($entriesRes)) { + foreach ($entriesRes as $entry) { + if (isset($entry['ClubUserID']) && $entry['ClubUserID'] == $clubuser) { + $entryIdToDelete = $entry['ID']; + break; + } + } + } + } catch (Exception $e) { + // ignore + } + } + + if (empty($entryIdToDelete)) { + $response = ['ID' => null]; + } else { + $response = $service->deleteEntry($entryIdToDelete); + } + } + + $entryId = $response['Entry']['ID'] ?? $response['ID'] ?? null; + if (empty($entryId)) { + if ($action === 'update' && !empty($entryIdToUpdate)) { + $entryId = $entryIdToUpdate; + } elseif ($action === 'update' && !empty($existingEntryId)) { + $entryId = $existingEntryId; + } else { + $entryId = $row['entry_id']; + } + } + + if ($action === 'delete') { + query_db("DELETE FROM `" . TBL_ZAVXUS . "` WHERE `id` = " . (int)$id); + } else { + query_db("UPDATE `" . TBL_ZAVXUS . "` SET `sync_status` = 'SYNCED', `entry_id` = " . ($entryId ? (int)$entryId : "NULL") . ", `sync_timestamp` = NOW(), `sync_error_payload` = NULL WHERE `id` = " . (int)$id); + } + return true; + + } catch (Exception $e) { + $errMsg = $e->getMessage(); + + if ($e instanceof OrisNetworkException || ($e instanceof OrisApiException && strpos($errMsg, 'API Error or HTTP 5') !== false)) { + return 'queued'; + } + + $errorData = [ + 'status' => 'error', + 'message' => $errMsg, + 'api_status' => ($e instanceof OrisApiException) ? $e->getApiStatus() : 'Exception', + 'api_data' => ($e instanceof OrisApiException) ? $e->getApiData() : null, + ]; + $errorPayload = correct_sql_string(json_encode($errorData)); + $failedStatus = 'FAILED_' . strtoupper($action); + $res = query_db("UPDATE `" . TBL_ZAVXUS . "` SET `sync_status` = '$failedStatus', `sync_error_payload` = '$errorPayload' WHERE `id` = " . (int)$id); + if (!$res) { + query_db("UPDATE `" . TBL_ZAVXUS . "` SET `sync_error_payload` = '$errorPayload' WHERE `id` = " . (int)$id); + } + return false; + } +} + +function getOrisSyncError($id) { + $q = query_db("SELECT sync_error_payload FROM `" . TBL_ZAVXUS . "` WHERE `id` = " . (int)$id); + if ($q && $r = mysqli_fetch_assoc($q)) { + if (!empty($r['sync_error_payload'])) { + $err = json_decode($r['sync_error_payload'], true); + // Prefer a human-readable ORIS API string (e.g. "Neplatná kategorie") + if (!empty($err['api_data']) && is_string($err['api_data'])) { + return $err['api_data']; + } + // Fall back to the API status (also readable, e.g. "Neplatná kategorie") + if (!empty($err['api_status']) && is_string($err['api_status']) + && $err['api_status'] !== 'Exception') { + return $err['api_status']; + } + return $err['message'] ?? 'Neznámá chyba'; + } + } + return 'Neznámá chyba'; +} +?> diff --git a/mocks/bank/README.md b/mocks/bank/README.md index d329fba..bd6b83d 100644 --- a/mocks/bank/README.md +++ b/mocks/bank/README.md @@ -8,8 +8,8 @@ Development-only Node.js + TypeScript mock for the members bank integration. - stores transactions in its own MariaDB database - seeds an initial relative transaction history when the database is empty - keeps the newest seeded transaction at least 3 days old by default -- provides an admin UI at `/__admin` -- provides admin JSON endpoints for automatic tests +- provides a testbench UI at `/__testbench` and keeps the legacy admin UI at `/__admin` +- provides JSON endpoints for automatic tests under both `/__testbench/api/*` and `/__admin/api/*` - supports fault modes: `normal`, `force_client_error`, `delay`, `hang`, `close_connection` - supports configurable `4xx` responses, including the common API cases `400`, `401`, `403`, `404`, and `429` - auto-generates a variable symbol when one is not supplied @@ -24,17 +24,19 @@ The server listens on port `10300` by default. Set `BANK_MOCK_INITIAL_TRANSACTION_MIN_AGE_DAYS` to change how old the newest seeded transaction must be. -## Admin API +## Testbench API -- `GET /__admin/api/settings` -- `POST /__admin/api/settings` -- `GET /__admin/api/transactions` -- `POST /__admin/api/transactions` +- `GET /__testbench/api/settings` +- `POST /__testbench/api/settings` +- `GET /__testbench/api/transactions` +- `POST /__testbench/api/transactions` + +The legacy `/__admin/api/*` paths are still supported. Example forced `429`: ```bash -curl -X POST http://127.0.0.1:10300/__admin/api/settings \ +curl -X POST http://127.0.0.1:10300/__testbench/api/settings \ -H 'Content-Type: application/json' \ -d '{ "mode": "force_client_error", diff --git a/mocks/bank/src/server.ts b/mocks/bank/src/server.ts index 4a2560b..e0eb816 100644 --- a/mocks/bank/src/server.ts +++ b/mocks/bank/src/server.ts @@ -49,6 +49,8 @@ type CreateTransactionInput = { counterpartyName?: string; }; +const ADMIN_BASE_PATHS = ['/__admin', '/__testbench']; + const config = { host: process.env.BANK_MOCK_HOST ?? '0.0.0.0', port: parseInt(process.env.BANK_MOCK_PORT ?? '10300', 10), @@ -65,6 +67,14 @@ const config = { let pool: Pool; +function adminPaths(suffix = ''): string[] { + return ADMIN_BASE_PATHS.map((basePath) => `${basePath}${suffix}`); +} + +function adminBasePath(req: Request): string { + return req.path.startsWith('/__testbench') ? '/__testbench' : '/__admin'; +} + function toDateTimeLocalValue(value?: string): string { const date = value ? new Date(value) : new Date(); if (Number.isNaN(date.getTime())) { @@ -433,7 +443,7 @@ async function maybeApplyFaultMode(req: Request, res: Response): Promise { const symbols = [ @@ -602,7 +612,7 @@ function renderAdminPage(settings: MockSettings, transactions: TransactionRow[])

Create transaction

Omit the date to create a payment at the current time.

-
+