pdo = $pdo ?? Database::connection(); $this->documentRoot = $documentRoot !== null ? rtrim($documentRoot, '/\\') : rtrim((string) ($_SERVER['DOCUMENT_ROOT'] ?? dirname(__DIR__, 3)), '/\\'); $this->privateRoot = $privateRoot !== null ? rtrim($privateRoot, '/\\') : dirname($this->documentRoot) . '/sogr_private'; $this->readService = new Phase7BReadService($this->pdo); $this->syncService = new Phase7CSyncService($this->pdo, $this->privateRoot); $this->writeService = new Phase7CWriteService($this->pdo, $this->syncService); } /** @return array */ public function status(): array { $snapshot = $this->snapshot(true); return [ 'phase' => self::PHASE, 'version' => self::VERSION, 'mode' => self::MODE, 'valid' => (bool) ($snapshot['valid'] ?? false), 'ready' => (bool) ($snapshot['valid'] ?? false), 'writesPerformed' => false, 'productionFilesChanged' => false, 'productionBusinessTablesWritten' => false, 'mysqlBusinessWrites' => 0, 'googleSheetsCalls' => 1, 'googleSheetsWrites' => 0, 'snapshot' => $snapshot, 'nextStep' => ($snapshot['valid'] ?? false) ? 'RUN_POST_CUTOVER_READ_ONLY_SMOKE_TEST' : 'STOP_AND_REVIEW_POST_CUTOVER_GATE', 'checkedAtUtc' => gmdate('c'), ]; } /** @return array */ public function run(): array { $before = $this->snapshot(true); if (($before['valid'] ?? false) !== true) { throw new ApiException( 'POST_CUTOVER_GATE_NOT_READY', 'Post-cutover početna kapija nije potpuno čista. Smoke test je zaustavljen bez upisa.', 409, ['before' => $before] ); } $readSmoke = $this->runReadSmoke(); $clientStatic = $this->clientStaticChecks(); $after = $this->snapshot(true); $businessFingerprintUnchanged = hash_equals( (string) ($before['database']['businessFingerprint'] ?? ''), (string) ($after['database']['businessFingerprint'] ?? '') ); $fileFingerprintUnchanged = hash_equals( (string) ($before['files']['fingerprint'] ?? ''), (string) ($after['files']['fingerprint'] ?? '') ); $settingsFingerprintUnchanged = hash_equals( (string) ($before['settings']['fingerprint'] ?? ''), (string) ($after['settings']['fingerprint'] ?? '') ); $queueFingerprintUnchanged = hash_equals( (string) ($before['database']['queueFingerprint'] ?? ''), (string) ($after['database']['queueFingerprint'] ?? '') ); $checks = [ 'beforeGateValid' => ($before['valid'] ?? false) === true, 'allReadRoutesHealthy' => ($readSmoke['valid'] ?? false) === true, 'clientStaticContractHealthy' => ($clientStatic['valid'] ?? false) === true, 'afterGateValid' => ($after['valid'] ?? false) === true, 'businessFingerprintUnchanged' => $businessFingerprintUnchanged, 'queueFingerprintUnchanged' => $queueFingerprintUnchanged, 'fileFingerprintUnchanged' => $fileFingerprintUnchanged, 'settingsFingerprintUnchanged' => $settingsFingerprintUnchanged, 'sequenceStill104' => (int) ($after['database']['values']['sequence'] ?? -1) === 104, 'maxReceiptStill104' => (int) ($after['database']['values']['maxReceipt'] ?? -1) === 104, 'nextReceiptStill105' => (int) ($after['sync']['bridge']['nextReceiptNumber'] ?? -1) === 105, 'pendingFailedConflictZero' => (int) ($after['database']['values']['pendingOrProblemSync'] ?? -1) === 0, ]; $valid = !in_array(false, $checks, true); return [ 'phase' => self::PHASE, 'version' => self::VERSION, 'mode' => self::MODE, 'valid' => $valid, 'smokeTestCompleted' => true, 'writesPerformed' => false, 'productionFilesChanged' => false, 'productionApplicationChanged' => false, 'productionBusinessTablesWritten' => false, 'mysqlBusinessWrites' => 0, 'googleSheetsCalls' => 2, 'googleSheetsWrites' => 0, 'googleDriveWrites' => 0, 'checks' => $checks, 'before' => $before, 'readSmoke' => $readSmoke, 'clientStatic' => $clientStatic, 'after' => $after, 'readyForNextPhase' => $valid, 'nextStep' => $valid ? 'READY_FOR_7C_A6_R2_R3_CONTROLLED_FIRST_TRANSACTION_CANARY' : 'STOP_AND_REVIEW_POST_CUTOVER_SMOKE_RESULT', 'importantNote' => 'R2-R2 nije pozvao nijednu poslovnu write akciju. Sekvenca 00105 ostala je slobodna za kontrolisani canary u sledećoj fazi.', 'checkedAtUtc' => gmdate('c'), ]; } /** @return array */ private function snapshot(bool $callBridge): array { $files = $this->fileGate(); $settings = $this->settingsGate(); $database = $this->databaseState(); $sync = $this->syncService->status($callBridge); $write = $this->writeService->status(); $activation = $this->latestActivationResult(); $bridge = is_array($sync['bridge'] ?? null) ? $sync['bridge'] : []; $syncReady = ($sync['ready'] ?? false) === true && ($bridge['ok'] ?? false) === true && ($bridge['ready'] ?? false) === true && (int) ($bridge['maxReceiptNumber'] ?? -1) === 104 && (int) ($bridge['nextReceiptNumber'] ?? -1) === 105; $checks = [ 'filesExact' => ($files['valid'] ?? false) === true, 'settingsActive' => ($settings['valid'] ?? false) === true, 'databaseClean' => ($database['valid'] ?? false) === true, 'writeServiceReady' => ($write['ready'] ?? false) === true && (int) ($write['actionCount'] ?? 0) === 10, 'syncServiceReady' => $syncReady, 'activationEvidencePresent' => ($activation['valid'] ?? false) === true, ]; return [ 'valid' => !in_array(false, $checks, true), 'checks' => $checks, 'files' => $files, 'settings' => $settings, 'database' => $database, 'write' => $write, 'sync' => $sync, 'activationEvidence' => $activation, 'checkedAtUtc' => gmdate('c'), ]; } /** @return array */ private function fileGate(): array { $paths = [ 'productionIndex' => [$this->documentRoot . '/api/v1/index.php', self::EXPECTED_INDEX_SHA256, true], 'productionClient' => [$this->documentRoot . '/evidencija.html', self::EXPECTED_CLIENT_SHA256, true], 'writeService' => [$this->privateRoot . '/src/Service/Phase7CWriteService.php', self::EXPECTED_WRITE_SHA256, true], 'syncService' => [$this->privateRoot . '/src/Service/Phase7CSyncService.php', self::EXPECTED_SYNC_SHA256, true], 'readService' => [$this->privateRoot . '/src/Service/Phase7BReadService.php', self::EXPECTED_READ_SHA256, false], 'cutoverEndpoint' => [$this->documentRoot . '/api/v1/phase7c-a6-r2-r1.php', self::EXPECTED_R1_ENDPOINT_SHA256, true], 'a6r2Endpoint' => [$this->documentRoot . '/api/v1/phase7c-a6-r2.php', self::EXPECTED_A6R2_ENDPOINT_SHA256, true], 'a6r2Service' => [$this->privateRoot . '/src/Service/Phase7CA6R2CutoverPreflightService.php', self::EXPECTED_A6R2_SERVICE_SHA256, true], ]; $checks = []; $files = []; foreach ($paths as $name => [$path, $expected, $strict]) { $actual = is_file($path) ? (string) hash_file('sha256', $path) : ''; $readable = $actual !== '' && is_readable($path); $exact = $readable && hash_equals($expected, $actual); $files[$name] = [ 'path' => $path, 'expectedSha256' => $expected, 'actualSha256' => $actual, 'sizeBytes' => is_file($path) ? filesize($path) : null, 'strict' => $strict, 'exact' => $exact, ]; $checks[$name . ($strict ? 'Exact' : 'Readable')] = $strict ? $exact : $readable; } $fingerprintInput = []; foreach ($files as $name => $file) $fingerprintInput[$name] = $file['actualSha256']; return [ 'valid' => !in_array(false, $checks, true), 'checks' => $checks, 'files' => $files, 'readServiceKnownHashExact' => (bool) ($files['readService']['exact'] ?? false), 'fingerprint' => hash('sha256', self::json($fingerprintInput)), ]; } /** @return array */ private function settingsGate(): array { $keys = [ 'phase_7c_status','primary_data_store','phase_7c_activated_at','phase_7c_version', 'financial_migration_status','financial_migration_locked','phase_7a_status','schema_version' ]; $placeholders = implode(',', array_fill(0, count($keys), '?')); $stmt = $this->pdo->prepare("SELECT setting_key,setting_value FROM app_settings WHERE setting_key IN ($placeholders)"); $stmt->execute($keys); $values = array_fill_keys($keys, ''); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { $values[(string) $row['setting_key']] = (string) ($row['setting_value'] ?? ''); } $checks = [ 'phase7cWriteActive' => $values['phase_7c_status'] === 'WRITE_ACTIVE', 'primaryStoreMysql' => $values['primary_data_store'] === 'MYSQL', 'activationTimestampPresent' => trim($values['phase_7c_activated_at']) !== '', 'phaseVersionExact' => $values['phase_7c_version'] === '7C-A6-R2-R1.1', 'migrationCompleted' => $values['financial_migration_status'] === 'COMPLETED', 'migrationLocked' => $values['financial_migration_locked'] === '1', 'shadowReadReady' => $values['phase_7a_status'] === 'SHADOW_READ_READY', 'schemaVersionExact' => $values['schema_version'] === '1.1.0', ]; return [ 'valid' => !in_array(false, $checks, true), 'checks' => $checks, 'values' => $values, 'fingerprint' => hash('sha256', self::json($values)), ]; } /** @return array */ private function databaseState(): array { $scalar = function (string $sql): mixed { return $this->pdo->query($sql)->fetchColumn(); }; $queue = ['DONE'=>0,'PENDING'=>0,'PROCESSING'=>0,'FAILED'=>0,'CONFLICT'=>0]; foreach ($this->pdo->query('SELECT queue_status,COUNT(*) AS c FROM sync_queue GROUP BY queue_status')->fetchAll(PDO::FETCH_ASSOC) as $row) { $queue[(string) $row['queue_status']] = (int) $row['c']; } $values = [ 'sequence' => (int) $scalar("SELECT current_value FROM number_sequences WHERE sequence_name='receipt'"), 'maxReceipt' => (int) $scalar('SELECT COALESCE(MAX(receipt_number),0) FROM payments'), 'maxActiveReceipt' => (int) $scalar("SELECT COALESCE(MAX(receipt_number),0) FROM payments WHERE payment_status='ACTIVE'"), 'payments' => (int) $scalar('SELECT COUNT(*) FROM payments'), 'activePayments' => (int) $scalar("SELECT COUNT(*) FROM payments WHERE payment_status='ACTIVE'"), 'paymentItems' => (int) $scalar('SELECT COUNT(*) FROM payment_items'), 'receipts' => (int) $scalar('SELECT COUNT(*) FROM receipts'), 'activeReceipts' => (int) $scalar("SELECT COUNT(*) FROM receipts WHERE receipt_status='ACTIVE'"), 'expenses' => (int) $scalar('SELECT COUNT(*) FROM expenses'), 'transfers' => (int) $scalar('SELECT COUNT(*) FROM transfers'), 'extraIncome' => (int) $scalar('SELECT COUNT(*) FROM extra_income'), 'boardAdjustments' => (int) $scalar('SELECT COUNT(*) FROM board_adjustments'), 'membershipTotal' => (string) $scalar("SELECT CAST(COALESCE(SUM(membership_total),0) AS CHAR) FROM payments WHERE payment_status='ACTIVE'"), 'donationTotal' => (string) $scalar("SELECT CAST(COALESCE(SUM(donation_total),0) AS CHAR) FROM payments WHERE payment_status='ACTIVE'"), 'expenseTotal' => (string) $scalar("SELECT CAST(COALESCE(SUM(amount),0) AS CHAR) FROM expenses WHERE expense_status='ACTIVE'"), 'transferTotal' => (string) $scalar("SELECT CAST(COALESCE(SUM(amount),0) AS CHAR) FROM transfers WHERE transfer_status='ACTIVE'"), 'extraIncomeTotal' => (string) $scalar("SELECT CAST(COALESCE(SUM(amount),0) AS CHAR) FROM extra_income WHERE income_status='ACTIVE'"), 'pendingOrProblemSync' => $queue['PENDING'] + $queue['PROCESSING'] + $queue['FAILED'] + $queue['CONFLICT'], 'openConflicts' => (int) $scalar("SELECT COUNT(*) FROM sync_conflicts WHERE conflict_status='OPEN'"), 'stuckIdempotency' => (int) $scalar("SELECT COUNT(*) FROM idempotency_requests WHERE request_status='PROCESSING' AND updated_at (int) $scalar("SELECT COUNT(*) FROM payments p JOIN receipts r ON r.payment_id=p.id WHERE p.receipt_number=104 AND p.payment_status='ACTIVE' AND r.receipt_status='ACTIVE'"), 'duplicateActiveReceipts' => (int) $scalar("SELECT COUNT(*) FROM (SELECT receipt_number FROM payments WHERE payment_status='ACTIVE' AND receipt_number IS NOT NULL GROUP BY receipt_number HAVING COUNT(*)>1) d"), 'activeHouseholdYears' => (int) $scalar("SELECT COUNT(*) FROM household_years hy JOIN households h ON h.id=hy.household_id WHERE hy.record_status='ACTIVE' AND h.is_active=1"), ]; $checks = [ 'sequenceIs104' => $values['sequence'] === 104, 'maxReceiptIs104' => $values['maxReceipt'] === 104, 'maxActiveReceiptIs104' => $values['maxActiveReceipt'] === 104, 'noPendingFailedOrConflictSync' => $values['pendingOrProblemSync'] === 0, 'noOpenConflicts' => $values['openConflicts'] === 0, 'noStuckIdempotency' => $values['stuckIdempotency'] === 0, 'receipt104Healthy' => $values['receipt104Healthy'] === 1, 'noDuplicateActiveReceipts' => $values['duplicateActiveReceipts'] === 0, 'activeHouseholdYearsExact' => $values['activeHouseholdYears'] === self::EXPECTED_ACTIVE_HOUSEHOLD_YEARS, ]; $businessInput = $values; unset($businessInput['pendingOrProblemSync'], $businessInput['openConflicts'], $businessInput['stuckIdempotency']); return [ 'valid' => !in_array(false, $checks, true), 'checks' => $checks, 'values' => $values, 'queue' => $queue, 'businessFingerprint' => hash('sha256', self::json($businessInput)), 'queueFingerprint' => hash('sha256', self::json([ 'queue'=>$queue, 'openConflicts'=>$values['openConflicts'], 'stuckIdempotency'=>$values['stuckIdempotency'], ])), ]; } /** @return array */ private function latestActivationResult(): array { $files = glob($this->privateRoot . '/backups/phase7c-a6-r2-r1/*/activation-result.json') ?: []; usort($files, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a)); foreach ($files as $path) { try { $data = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); if (!is_array($data)) continue; $valid = (string) ($data['phase'] ?? '') === '7C-A6-R2-R1' && (string) ($data['version'] ?? '') === '7C-A6-R2-R1.1'; if ($valid) { return [ 'valid' => true, 'path' => $path, 'sha256' => (string) hash_file('sha256', $path), 'phase' => $data['phase'], 'version' => $data['version'], 'activatedAtUtc' => $data['activatedAtUtc'] ?? null, 'backupDir' => $data['backupDir'] ?? dirname($path), ]; } } catch (Throwable) { } } return ['valid'=>false,'path'=>null,'sha256'=>'']; } /** @return array */ private function runReadSmoke(): array { $actions = []; $errors = []; $totalActiveRows = 0; $receipt104Seen = false; $legacyFallbackYears = []; $measure = function (string $name, callable $call) use (&$actions, &$errors): mixed { $start = microtime(true); try { $summary = $call(); $actions[$name] = [ 'ok' => true, 'durationMs' => (int) round((microtime(true) - $start) * 1000), 'summary' => $summary, ]; return $summary; } catch (Throwable $e) { $actions[$name] = [ 'ok' => false, 'durationMs' => (int) round((microtime(true) - $start) * 1000), 'error' => $e->getMessage(), 'class' => get_class($e), ]; $errors[] = $name . ': ' . $e->getMessage(); return null; } }; $measure('readStatus', function (): array { $data = $this->readService->status(); if (($data['ready'] ?? false) !== true || ($data['source'] ?? '') !== 'MYSQL') { throw new \RuntimeException('Read servis nije spreman ili izvor nije MYSQL.'); } return ['ready'=>true,'source'=>$data['source'],'pendingSync'=>$data['pendingSync'],'openConflicts'=>$data['openConflicts']]; }); foreach (range(2020, 2026) as $year) { $yearSummary = $measure('getYear:' . $year, function () use ($year): array { $data = $this->readService->getYear($year, 'ACTIVE'); $rows = is_array($data['rows'] ?? null) ? $data['rows'] : []; if (($data['source'] ?? '') !== 'MYSQL' || ($data['readOnly'] ?? false) !== true || count($rows) < 1) { throw new \RuntimeException('Godišnji pregled nema očekivanu MYSQL/read-only strukturu.'); } return [ 'year'=>$year,'count'=>count($rows),'source'=>$data['source'], 'firstRow'=>$rows[0]['row'] ?? null,'lastRow'=>$rows[count($rows)-1]['row'] ?? null, ]; }); if (is_array($yearSummary)) $totalActiveRows += (int) ($yearSummary['count'] ?? 0); $receiptSummary = $measure('getReceipts:' . $year, function () use ($year): array { $data = $this->readService->getReceipts($year); $rows = is_array($data['rows'] ?? null) ? $data['rows'] : []; if (($data['source'] ?? '') !== 'MYSQL' || ($data['readOnly'] ?? false) !== true) { throw new \RuntimeException('Pregled priznanica nema očekivanu MYSQL/read-only strukturu.'); } $numbers = array_map(static fn(array $row): string => (string) ($row['broj'] ?? ''), $rows); return [ 'year'=>$year,'count'=>count($rows),'activePaymentCount'=>(int)($data['activePaymentCount']??0), 'legacyFallbackRecommended'=>($data['legacyReceiptFallbackRecommended']??false)===true, 'has00104'=>in_array('00104',$numbers,true), ]; }); if (is_array($receiptSummary)) { if (($receiptSummary['legacyFallbackRecommended'] ?? false) === true) $legacyFallbackYears[] = $year; if (($receiptSummary['has00104'] ?? false) === true) $receipt104Seen = true; } $measure('getExpenses:' . $year, function () use ($year): array { $data = $this->readService->getExpenses($year); if (($data['sourceType'] ?? '') !== 'MYSQL' || ($data['readOnly'] ?? false) !== true) { throw new \RuntimeException('Pregled troškova nema očekivanu MYSQL/read-only strukturu.'); } return ['year'=>$year,'count'=>(int)($data['totals']['brojStavki']??0),'total'=>$data['totals']['ukupno']??0]; }); } $measure('getBoard:2026', function (): array { $data = $this->readService->getBoard(2026); $rows = is_array($data['rows'] ?? null) ? $data['rows'] : []; if (($data['sourceType'] ?? '') !== 'MYSQL' || ($data['readOnly'] ?? false) !== true || count($rows) < 1) { throw new \RuntimeException('Pregled odbora nema očekivanu MYSQL/read-only strukturu.'); } return ['year'=>2026,'members'=>count($rows),'totals'=>$data['totals']??[]]; }); $measure('getAllExpenses', function (): array { $data = $this->readService->getAllExpenses(); if (($data['sourceType'] ?? '') !== 'MYSQL' || ($data['readOnly'] ?? false) !== true || count($data['years'] ?? []) !== 7) { throw new \RuntimeException('Objedinjeni troškovi nemaju očekivanu strukturu za sedam godina.'); } return ['years'=>count($data['years']),'totals'=>$data['totals']??[]]; }); $measure('getSummary', function (): array { $data = $this->readService->getSummary(); if (($data['source'] ?? '') !== 'MYSQL' || ($data['readOnly'] ?? false) !== true || count($data['godine'] ?? []) !== 7) { throw new \RuntimeException('Summary nema očekivanu MYSQL strukturu za sedam godina.'); } return ['years'=>array_keys($data['godine'])]; }); $measure('getCumulativeStatistics:2026', function (): array { $data = $this->readService->getCumulativeStatistics(2026); if (($data['source'] ?? '') !== 'MYSQL' || ($data['readOnly'] ?? false) !== true || count($data['years'] ?? []) !== 7) { throw new \RuntimeException('Kumulativna statistika nema očekivanu MYSQL strukturu za sedam godina.'); } $last = $data['years'][count($data['years']) - 1] ?? []; return ['throughYear'=>$data['throughYear']??null,'years'=>count($data['years']),'last'=>$last]; }); $memberCode = (string) $this->pdo->query("SELECT code FROM board_members WHERE handles_money=1 ORDER BY sort_order,id LIMIT 1")->fetchColumn(); $measure('getBoardPayments:2026:' . $memberCode, function () use ($memberCode): array { if ($memberCode === '') throw new \RuntimeException('Nije pronađen referentni član odbora.'); $data = $this->readService->getBoardPayments(2026, $memberCode); if (($data['sourceType'] ?? '') !== 'MYSQL' || ($data['readOnly'] ?? false) !== true) { throw new \RuntimeException('Uplate člana odbora nemaju očekivanu MYSQL/read-only strukturu.'); } return ['member'=>$data['member']??$memberCode,'count'=>$data['count']??0,'total'=>$data['total']??0]; }); $sourceRow = (int) $this->pdo->query( "SELECT MIN(srl.sheet_row) FROM payments p JOIN payment_items pi ON pi.payment_id=p.id AND pi.item_status='ACTIVE' JOIN household_years hy ON hy.id=pi.household_year_id JOIN sheet_row_links srl ON srl.entity_type='HOUSEHOLD_YEAR' AND srl.entity_id=hy.id WHERE p.receipt_number=104 AND p.payment_status='ACTIVE'" )->fetchColumn(); $receiptSourceSummary = $measure('getReceiptForSource:2026:' . $sourceRow, function () use ($sourceRow): array { if ($sourceRow < 2) throw new \RuntimeException('Izvorni red priznanice 00104 nije pronađen.'); $data = $this->readService->getReceiptForSource(2026, $sourceRow); $receipt = is_array($data['receipt'] ?? null) ? $data['receipt'] : []; if (($data['source'] ?? '') !== 'MYSQL' || ($data['readOnly'] ?? false) !== true || ($receipt['broj'] ?? '') !== '00104') { throw new \RuntimeException('Priznanica 00104 nije pronađena preko izvornog reda.'); } return ['sourceRow'=>$sourceRow,'broj'=>$receipt['broj'],'status'=>$receipt['status']??null]; }); if (is_array($receiptSourceSummary) && ($receiptSourceSummary['broj'] ?? '') === '00104') $receipt104Seen = true; $checks = [ 'noReadErrors' => $errors === [], 'all2300ActiveRowsReturned' => $totalActiveRows === self::EXPECTED_ACTIVE_HOUSEHOLD_YEARS, 'receipt00104VisibleFromMysql' => $receipt104Seen, 'legacyFallbackPolicyAvailableForHistoricalYears' => count($legacyFallbackYears) >= 1, 'allExpectedActionCallsCompleted' => count($actions) === 28, ]; return [ 'valid' => !in_array(false, $checks, true), 'writesPerformed' => false, 'mysqlBusinessWrites' => 0, 'googleSheetsCalls' => 0, 'googleSheetsWrites' => 0, 'checks' => $checks, 'totalActiveRows' => $totalActiveRows, 'legacyFallbackYears' => $legacyFallbackYears, 'receipt00104Seen' => $receipt104Seen, 'actionCount' => count($actions), 'actions' => $actions, 'errors' => $errors, ]; } /** @return array */ private function clientStaticChecks(): array { $path = $this->documentRoot . '/evidencija.html'; $content = is_file($path) ? (string) file_get_contents($path) : ''; preg_match('/(.*?)<\\/title>/is', $content, $titleMatch); $title = trim(strip_tags((string) ($titleMatch[1] ?? ''))); $writeActions = ['pay','payMultiple','storno','updateMember','saveExpense','deleteExpense','saveTransfer','saveExtraIncome','deleteExtraIncome','saveReceiptPdf']; $missing = []; foreach ($writeActions as $action) { if (!str_contains($content, "'" . $action . "'")) $missing[] = $action; } $checks = [ 'fileReadable' => $content !== '', 'productionHashExact' => $content !== '' && hash_equals(self::EXPECTED_CLIENT_SHA256, hash('sha256', $content)), 'mysqlApiUrlPresent' => str_contains($content, "const MYSQL_API_URL = '/api/v1/index.php';"), 'mysqlPrimaryFlagTrue' => str_contains($content, 'const PHASE_7C_MYSQL_PRIMARY = true;'), 'readOnlyFlagFalse' => str_contains($content, 'const PHASE_7B_READ_ONLY = false;'), 'writeActionContractComplete' => $missing === [], 'legacyReceiptFallbackPresent' => str_contains($content, 'legacyReceiptFallbackRecommended'), 'requestIdProtectionPresent' => str_contains($content, 'phase7cOsigurajRequestId'), 'bearerTokenTransportPresent' => str_contains($content, "headers.Authorization = 'Bearer ' + token"), ]; $warnings = []; if (stripos($title, 'MYSQL TEST') !== false) { $warnings[] = 'Naslov browser kartice još sadrži oznaku MYSQL TEST 7B V2. Ovo ne utiče na transport ni podatke; ukloniće se u završnoj UI cleanup fazi.'; } return [ 'valid' => !in_array(false, $checks, true), 'checks' => $checks, 'title' => $title, 'missingWriteActions' => $missing, 'warnings' => $warnings, ]; } private static function json(mixed $value): string { return json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR ); } }