query('SELECT DATABASE() AS database_name, VERSION() AS database_version')->fetch(); $migration = $pdo->query( 'SELECT version, description, applied_at FROM schema_migrations ORDER BY applied_at DESC LIMIT 1' )->fetch(); $status = $readService->status(); if (($status['ready'] ?? false) !== true) { throw new ApiException('PHASE_7B_NOT_READY', 'Faza 7B read API još nije spremna.', 503, ['status' => $status]); } return [ 'application' => (string) Config::get('app.name', 'Seoski odbor Gornje Rapče'), 'service' => 'SOGR API', 'status' => 'healthy', 'phase' => '7C', 'version' => Phase7CWriteService::VERSION, 'mode' => 'MYSQL_PRIMARY_WRITES_ACTIVE', 'legacyReceiptReadPolicy' => 'MYSQL_PRIMARY_APPS_SCRIPT_READ_FALLBACK_FOR_2020_2025', 'environment' => (string) Config::get('app.environment', 'production'), 'php' => ['version' => PHP_VERSION, 'sapi' => PHP_SAPI], 'database' => [ 'connected' => true, 'name' => is_array($databaseInfo) ? ($databaseInfo['database_name'] ?? null) : null, 'serverVersion' => is_array($databaseInfo) ? ($databaseInfo['database_version'] ?? null) : null, 'schemaVersion' => is_array($migration) ? ($migration['version'] ?? null) : null, 'schemaDescription' => is_array($migration) ? ($migration['description'] ?? null) : null, 'schemaValid' => true, ], 'readGate' => $status, 'writeGate' => $writeService->status(), 'syncGate' => $syncService->status(false), ]; } catch (ApiException $exception) { throw $exception; } catch (PDOException $exception) { throw new ApiException('DATABASE_UNAVAILABLE', 'Veza sa bazom trenutno nije dostupna.', 503, [], $exception); } }; $router->get('healthCheck', $healthCheckHandler); $router->post('healthCheck', $healthCheckHandler); /* Sesije. Login/logout mogu menjati samo tehničku tabelu sesija — nikad poslovne podatke. */ $router->post('login', static fn (Request $request): array => (new AuthService())->login($request)); $router->post('logout', static fn (Request $request): array => (new AuthService())->logout($request)); $router->get('me', static fn (Request $request): array => (new AuthService())->me($request)); $router->post('me', static fn (Request $request): array => (new AuthService())->me($request)); /* Read-only šifarnici. */ foreach (['getYears', 'getMembershipRates'] as $action) { $handler = static function (Request $request) use ($catalogService, $action): array { return $action === 'getYears' ? $catalogService->getYears() : $catalogService->getMembershipRates(); }; $router->get($action, $handler); $router->post($action, $handler); } $getReferenceData = static function (Request $request) use ($catalogService): array { $year = Validator::year($request->input('year', null), 'year', false); return $catalogService->getReferenceData($year); }; $router->get('getReferenceData', $getReferenceData); $router->post('getReferenceData', $getReferenceData); /* Sve poslovne akcije Faze 7B su isključivo READ. */ $require = static function (Request $request) use ($authService): void { $authService->requireUser($request); }; $getYear = static function (Request $request) use ($require, $readService): array { $require($request); $year = Validator::year($request->input('year', null), 'year', true); $status = Validator::enum($request->input('status', 'ACTIVE'), 'status', ['ACTIVE','PASSIVE','ALL'], true); return $readService->getYear($year, $status); }; $router->get('getYear', $getYear); $router->post('getYear', $getYear); $getReceipts = static function (Request $request) use ($require, $readService): array { $require($request); return $readService->getReceipts(Validator::year($request->input('year', null), 'year', true)); }; $router->get('getReceipts', $getReceipts); $router->post('getReceipts', $getReceipts); $getReceiptForSource = static function (Request $request) use ($require, $readService): array { $require($request); $year = Validator::year($request->input('year', null), 'year', true); $row = (int) $request->input('row', 0); return $readService->getReceiptForSource($year, $row); }; $router->get('getReceiptForSource', $getReceiptForSource); $router->post('getReceiptForSource', $getReceiptForSource); $getBoard = static function (Request $request) use ($require, $readService): array { $require($request); return $readService->getBoard(Validator::year($request->input('year', null), 'year', true)); }; $router->get('getBoard', $getBoard); $router->post('getBoard', $getBoard); $getBoardPayments = static function (Request $request) use ($require, $readService): array { $require($request); $year = Validator::year($request->input('year', null), 'year', true); $member = trim((string) $request->input('member', '')); return $readService->getBoardPayments($year, $member); }; $router->get('getBoardPayments', $getBoardPayments); $router->post('getBoardPayments', $getBoardPayments); $getExpenses = static function (Request $request) use ($require, $readService): array { $require($request); return $readService->getExpenses(Validator::year($request->input('year', null), 'year', true)); }; $router->get('getExpenses', $getExpenses); $router->post('getExpenses', $getExpenses); $getAllExpenses = static function (Request $request) use ($require, $readService): array { $require($request); return $readService->getAllExpenses(); }; $router->get('getAllExpenses', $getAllExpenses); $router->post('getAllExpenses', $getAllExpenses); $getSummary = static function (Request $request) use ($require, $readService): array { $require($request); return $readService->getSummary(); }; $router->get('getSummary', $getSummary); $router->post('getSummary', $getSummary); $getCumulativeStatistics = static function (Request $request) use ($require, $readService): array { $require($request); $year = Validator::year( $request->input('throughYear', $request->input('year', null)), 'throughYear', true ); return $readService->getCumulativeStatistics($year); }; $router->get('getCumulativeStatistics', $getCumulativeStatistics); $router->post('getCumulativeStatistics', $getCumulativeStatistics); /* |-------------------------------------------------------------------------- | Faza 7B-R2 PREVIEW — samo vlasnik, strogo READ-ONLY |-------------------------------------------------------------------------- */ $requireBackendOwner = static function (Request $request) use ($authService): array { $session = $authService->requireUser($request); $canManageBackend = (($session['user']['permissions']['canManageBackend'] ?? false) === true); if (!$canManageBackend) { throw new ApiException( 'FORBIDDEN', 'Samo vlasnik sistema može pokrenuti Fazu 7B-R2.', 403 ); } return $session; }; $phase7bR2Status = static function (Request $request) use ($requireBackendOwner): array { $requireBackendOwner($request); return (new Phase7BR2ReconciliationService())->status(); }; $router->get('phase7bR2Status', $phase7bR2Status); $router->post('phase7bR2Status', $phase7bR2Status); $phase7bR2Preview = static function (Request $request) use ($requireBackendOwner): array { $requireBackendOwner($request); $dryRun = $request->input('dryRun', true); if ($dryRun !== true) { throw new ApiException( 'PHASE_7B_R2_PREVIEW_REQUIRES_DRY_RUN_TRUE', 'Ovaj paket podržava samo dryRun=true. COMMIT akcija nije uključena.', 400 ); } return (new Phase7BR2ReconciliationService())->preview(); }; $router->post('phase7bR2Preview', $phase7bR2Preview); /* |-------------------------------------------------------------------------- | Faza 7C — MySQL primarni poslovni upisi + kontrolisana DB_TO_SHEETS sinhronizacija |-------------------------------------------------------------------------- */ $writePayloadFields = [ 'pay' => ['year','row','ime','prezime','iznos','donacija','mesto','odbor','primioPuno','datum','requestId'], 'payMultiple' => ['ime','prezime','mahalo','items','donacija','mesto','odbor','datum','requestId'], 'storno' => ['year','row','ime','prezime','requestId'], 'updateMember' => ['year','row','original','ime','prezime','mahalo','clanova','scope','changedFields','requestId'], 'saveExpense' => ['year','datum','namena','majstor','odbor','cena','requestId'], 'deleteExpense' => ['year','id','expectedAmount','expectedDescription','expectedPayer','expectedSourceRow','requestId'], 'saveTransfer' => ['year','datum','odClana','clanu','iznos','napomena','requestId'], 'saveExtraIncome' => ['year','datum','od','clan','iznos','requestId'], 'deleteExtraIncome' => ['year','id','expectedClan','expectedAmount','requestId'], 'saveReceiptPdf' => ['year','broj','receiptRow','sourceRow','pdfBase64','requestId'], ]; foreach (Phase7CWriteService::ACTIONS as $writeAction) { $router->post($writeAction, static function (Request $request) use ( $authService, $writeService, $writePayloadFields, $writeAction ): array { $session = $authService->requireUser($request); $payload = []; foreach ($writePayloadFields[$writeAction] as $field) { $payload[$field] = $request->input($field, null); } return $writeService->execute($writeAction, $payload, $session); }); } $requireSyncOwner = static function (Request $request) use ($authService): array { $session = $authService->requireUser($request); if (($session['user']['permissions']['canManageBackend'] ?? false) !== true) { throw new ApiException('FORBIDDEN', 'Samo vlasnik sistema može upravljati redom za sinhronizaciju.', 403); } return $session; }; $syncStatusHandler = static function (Request $request) use ($requireSyncOwner, $syncService): array { $requireSyncOwner($request); return $syncService->status(true); }; $router->get('phase7cSyncStatus', $syncStatusHandler); $router->post('phase7cSyncStatus', $syncStatusHandler); $router->post('phase7cSyncRetry', static function (Request $request) use ($requireSyncOwner, $syncService): array { $requireSyncOwner($request); $limit = max(1, min(50, (int) $request->input('limit', 10))); return $syncService->processPending($limit); }); $router->dispatch($request);