pdo = $pdo ?? Database::connection(); if ($privateRoot !== null) { $this->privateRoot = rtrim($privateRoot, '/\\'); } else { $dr = isset($_SERVER['DOCUMENT_ROOT']) ? rtrim((string) $_SERVER['DOCUMENT_ROOT'], '/\\') : dirname(__DIR__, 3); $this->privateRoot = dirname($dr) . '/sogr_private'; } $this->sync = new Phase7CSyncService($this->pdo, $this->privateRoot); } /** @return array */ public function status(): array { $snapshot = $this->snapshot(); $bridge = $this->bridgeInspection($snapshot); $gate = $this->gate($snapshot, $bridge); return [ 'phase' => self::PHASE, 'version' => self::VERSION, 'mode' => self::MODE, 'valid' => $gate['valid'], 'writesPerformed' => false, 'mysqlWrites' => 0, 'googleSheetsWrites' => 0, 'target' => $this->targetSummary($snapshot), 'dateTransport' => $this->dateTransport($snapshot), 'bridge' => $bridge, 'gate' => $gate, 'confirmationRequired' => self::CONFIRMATION, 'nextStep' => $gate['alreadyRecovered'] ? 'DOWNLOAD_ALREADY_RECOVERED_RESULT' : ($gate['valid'] ? 'CONFIRM_CONTROLLED_QUEUE_10_RECOVERY' : 'STOP_AND_REVIEW_R3_R7_GATE'), 'checkedAtUtc' => gmdate('c'), ]; } /** @return array */ public function recover(string $confirmation): array { if (!hash_equals(self::CONFIRMATION, trim($confirmation))) { throw new ApiException('R3_R7_CONFIRMATION_INVALID', 'Potvrda za kontrolisani recovery nije ispravna.', 400); } $lockName = 'sogr-r3-r7-queue-10'; $lockStmt = $this->pdo->prepare('SELECT GET_LOCK(:name,5)'); $lockStmt->execute([':name' => $lockName]); if ((int) $lockStmt->fetchColumn() !== 1) { throw new ApiException('R3_R7_LOCK_BUSY', 'Druga recovery operacija je već u toku.', 409); } $before = []; $processResult = null; try { $before = $this->snapshot(); $beforeBridge = $this->bridgeInspection($before); $beforeGate = $this->gate($before, $beforeBridge); if (($beforeGate['alreadyRecovered'] ?? false) === true) { $result = $this->successResult($before, $beforeBridge, $before, $beforeBridge, [ 'ok' => true, 'alreadyDone' => true, 'queueId' => self::QUEUE_ID, ], true); $this->writeEvidence($result); return $result; } if (($beforeGate['valid'] ?? false) !== true) { throw new ApiException('R3_R7_GATE_NOT_READY', 'Recovery kapija nije čista; ništa nije pokrenuto.', 409, ['gate' => $beforeGate]); } $processResult = $this->sync->processQueueId(self::QUEUE_ID); $after = $this->snapshot(); $afterBridge = $this->bridgeInspection($after); $afterGate = $this->gate($after, $afterBridge); $success = $this->isRecovered($after, $afterBridge); $result = $success ? $this->successResult($before, $beforeBridge, $after, $afterBridge, $processResult, false) : $this->failureResult($before, $beforeBridge, $after, $afterBridge, $processResult); $this->writeEvidence($result); return $result; } finally { try { $release = $this->pdo->prepare('SELECT RELEASE_LOCK(:name)'); $release->execute([':name' => $lockName]); } catch (Throwable) { } } } /** @return array */ private function snapshot(): array { $paymentStmt = $this->pdo->prepare( "SELECT p.id,p.request_id,p.receipt_number,p.payment_date,p.membership_total,p.donation_total, p.payment_status,p.row_version,r.id AS receipt_id,r.receipt_status,r.receipt_sheet_year, r.receipt_sheet_row,r.receipt_link,r.row_version AS receipt_row_version FROM payments p JOIN receipts r ON r.payment_id=p.id WHERE p.id=:payment AND p.receipt_number=:receipt LIMIT 1" ); $paymentStmt->execute([':payment' => self::PAYMENT_ID, ':receipt' => self::RECEIPT_NUMBER]); $payment = $paymentStmt->fetch(PDO::FETCH_ASSOC) ?: null; $queueStmt = $this->pdo->prepare( "SELECT id,direction,entity_type,entity_id,operation_name,dedupe_key,payload_json,queue_status, priority,attempts,next_attempt_at,locked_at,locked_by,last_error,completed_at,created_at,updated_at FROM sync_queue WHERE id=:id LIMIT 1" ); $queueStmt->execute([':id' => self::QUEUE_ID]); $queue = $queueStmt->fetch(PDO::FETCH_ASSOC) ?: null; if (is_array($queue)) { $queue['payload'] = $this->decode((string) ($queue['payload_json'] ?? '')); unset($queue['payload_json']); } $hyStmt = $this->pdo->prepare( "SELECT hy.id,hy.year,hy.first_name,hy.last_name,hy.sheet_row,hy.row_version, apl.payment_item_id,pi.payment_id,pi.item_status FROM household_years hy LEFT JOIN active_payment_links apl ON apl.household_year_id=hy.id LEFT JOIN payment_items pi ON pi.id=apl.payment_item_id WHERE hy.id=:id LIMIT 1" ); $hyStmt->execute([':id' => self::HOUSEHOLD_YEAR_ID]); $householdYear = $hyStmt->fetch(PDO::FETCH_ASSOC) ?: null; $sequence = (int) $this->pdo->query("SELECT current_value FROM number_sequences WHERE sequence_name='receipt'")->fetchColumn(); $problemCount = (int) $this->pdo->query( "SELECT COUNT(*) FROM sync_queue WHERE direction='DB_TO_SHEETS' AND queue_status IN ('PENDING','PROCESSING','FAILED','CONFLICT')" )->fetchColumn(); $duplicate = (int) $this->pdo->query( "SELECT COUNT(*) FROM payments WHERE receipt_number=105 AND payment_status='ACTIVE'" )->fetchColumn(); $receiptLinkStmt = $this->pdo->prepare( "SELECT srl.id,srl.sheet_row,srl.last_source,srl.last_synced_at FROM sheet_row_links srl WHERE srl.entity_type='RECEIPT' AND srl.entity_id=:id ORDER BY srl.id DESC" ); $receiptLinkStmt->execute([':id' => is_array($payment) ? (int) ($payment['receipt_id'] ?? 0) : 0]); $receiptLinks = $receiptLinkStmt->fetchAll(PDO::FETCH_ASSOC); $idempotencyStmt = $this->pdo->prepare( 'SELECT request_status,response_json FROM idempotency_requests WHERE request_id=:request LIMIT 1' ); $idempotencyStmt->execute([':request' => self::REQUEST_ID]); $idempotency = $idempotencyStmt->fetch(PDO::FETCH_ASSOC) ?: null; if (is_array($idempotency)) { $idempotency['response'] = $this->decode((string) ($idempotency['response_json'] ?? '')); unset($idempotency['response_json']); } return [ 'payment' => $payment, 'queue' => $queue, 'householdYear' => $householdYear, 'sequence' => $sequence, 'problemQueueCount' => $problemCount, 'activeReceipt105Count' => $duplicate, 'receiptSheetLinks' => $receiptLinks, 'idempotency' => $idempotency, 'fingerprint' => hash('sha256', self::json([$payment,$queue,$householdYear,$sequence,$problemCount,$duplicate,$receiptLinks,$idempotency])), ]; } /** @param array $snapshot @return array */ private function bridgeInspection(array $snapshot): array { $health = $this->sync->bridgeHealth(); $job = $this->job($snapshot); $normalized = Phase7CSyncService::normalizeJobForBridge($job); $preflight = $this->sync->preflightJob($job); return [ 'health' => $health, 'preflight' => $preflight, 'originalDate' => (string) ($job['payload']['datum'] ?? ''), 'normalizedDate' => (string) ($normalized['payload']['datum'] ?? ''), 'normalizationApplied' => (string) ($job['payload']['datum'] ?? '') !== (string) ($normalized['payload']['datum'] ?? ''), ]; } /** @param array $snapshot @return array */ private function job(array $snapshot): array { $job = $snapshot['queue']['payload']['job'] ?? null; if (!is_array($job)) { throw new ApiException('R3_R7_JOB_MISSING', 'Queue 10 nema ispravan job payload.', 500); } return $job; } /** @param array $s @param array $b @return array */ private function gate(array $s, array $b): array { $p = $s['payment'] ?? null; $q = $s['queue'] ?? null; $hy = $s['householdYear'] ?? null; $pf = $b['preflight'] ?? []; $already = $this->isRecovered($s, $b); $checks = [ 'paymentExact' => is_array($p) && (int) ($p['id'] ?? 0) === self::PAYMENT_ID && (string) ($p['request_id'] ?? '') === self::REQUEST_ID && (int) ($p['receipt_number'] ?? 0) === self::RECEIPT_NUMBER && (string) ($p['payment_status'] ?? '') === 'ACTIVE' && (string) ($p['receipt_status'] ?? '') === 'ACTIVE', 'amountExact' => is_array($p) && (float) ($p['membership_total'] ?? -1) === 25.0 && (float) ($p['donation_total'] ?? -1) === 0.0, 'householdYearLinked' => is_array($hy) && (int) ($hy['id'] ?? 0) === self::HOUSEHOLD_YEAR_ID && (int) ($hy['sheet_row'] ?? 0) === self::SOURCE_ROW && (int) ($hy['payment_id'] ?? 0) === self::PAYMENT_ID && (string) ($hy['item_status'] ?? '') === 'ACTIVE', 'sequence105' => (int) ($s['sequence'] ?? 0) === self::RECEIPT_NUMBER, 'oneActiveReceipt105' => (int) ($s['activeReceipt105Count'] ?? 0) === 1, 'idempotencyDone' => (string) ($s['idempotency']['request_status'] ?? '') === 'DONE', 'queueExact' => is_array($q) && (int) ($q['id'] ?? 0) === self::QUEUE_ID && (string) ($q['direction'] ?? '') === 'DB_TO_SHEETS' && (string) ($q['entity_type'] ?? '') === 'PAYMENT' && (int) ($q['entity_id'] ?? 0) === self::PAYMENT_ID, 'queueStatusRecoverable' => is_array($q) && in_array((string) ($q['queue_status'] ?? ''), ['FAILED','PENDING','DONE'], true), 'onlyTargetProblemQueue' => $already || (int) ($s['problemQueueCount'] ?? 0) === 1, 'bridgeHealthy' => ($b['health']['ok'] ?? false) === true && ($b['health']['ready'] ?? false) === true, 'dateNormalized' => ($b['normalizationApplied'] ?? false) === true && (string) ($b['originalDate'] ?? '') === '2026-07-24' && (string) ($b['normalizedDate'] ?? '') === '24.07.2026.', 'bridgePreflightReady' => ($pf['ok'] ?? false) === true && (($pf['ready'] ?? false) === true || ($pf['alreadySynced'] ?? false) === true), ]; return [ 'valid' => !in_array(false, $checks, true), 'alreadyRecovered' => $already, 'checks' => $checks, ]; } /** @param array $s @param array $b */ private function isRecovered(array $s, array $b): bool { $p = $s['payment'] ?? []; $q = $s['queue'] ?? []; $pf = $b['preflight'] ?? []; return (string) ($q['queue_status'] ?? '') === 'DONE' && (int) ($p['receipt_sheet_row'] ?? 0) > 0 && count($s['receiptSheetLinks'] ?? []) === 1 && (int) ($b['health']['maxReceiptNumber'] ?? 0) >= self::RECEIPT_NUMBER && (($pf['alreadySynced'] ?? false) === true || is_array($pf['existingReceipt'] ?? null)) && (int) ($s['problemQueueCount'] ?? -1) === 0; } /** @param array $s */ private function targetSummary(array $s): array { $p = $s['payment'] ?? []; $q = $s['queue'] ?? []; return [ 'queueId' => self::QUEUE_ID, 'paymentId' => self::PAYMENT_ID, 'receiptNumber' => '00105', 'requestId' => self::REQUEST_ID, 'payer' => 'Ilver Džaferi', 'sourceRow' => self::SOURCE_ROW, 'paymentStatus' => $p['payment_status'] ?? null, 'receiptStatus' => $p['receipt_status'] ?? null, 'queueStatus' => $q['queue_status'] ?? null, 'attempts' => isset($q['attempts']) ? (int) $q['attempts'] : null, 'lastError' => $q['last_error'] ?? null, 'receiptSheetRow' => isset($p['receipt_sheet_row']) ? (int) $p['receipt_sheet_row'] : null, ]; } /** @param array $s */ private function dateTransport(array $s): array { $job = $this->job($s); $normalized = Phase7CSyncService::normalizeJobForBridge($job); return [ 'mysqlDate' => (string) ($s['payment']['payment_date'] ?? ''), 'queueOriginalDate' => (string) ($job['payload']['datum'] ?? ''), 'bridgeTransportDate' => (string) ($normalized['payload']['datum'] ?? ''), 'mysqlDateChanged' => false, ]; } /** @return array */ private function successResult(array $before, array $beforeBridge, array $after, array $afterBridge, array $process, bool $already): array { return [ 'phase' => self::PHASE, 'version' => self::VERSION, 'mode' => self::MODE, 'valid' => true, 'recoveryCompleted' => true, 'alreadyRecovered' => $already, 'paymentCommitRepeated' => false, 'voidCalled' => false, 'targetQueueProcessed' => !$already, 'mysqlFinancialValuesChanged' => false, 'mysqlRecoveryMetadataWritten' => !$already, 'googleSheetsWrites' => (!$already && (int) ($beforeBridge['health']['maxReceiptNumber'] ?? 0) < 105) ? 1 : 0, 'processResult' => $process, 'before' => ['target'=>$this->targetSummary($before),'bridge'=>$beforeBridge,'fingerprint'=>$before['fingerprint']], 'after' => ['target'=>$this->targetSummary($after),'bridge'=>$afterBridge,'fingerprint'=>$after['fingerprint']], 'checks' => [ 'queueDone' => (string) ($after['queue']['queue_status'] ?? '') === 'DONE', 'sheetsReceipt105Present' => (int) ($afterBridge['health']['maxReceiptNumber'] ?? 0) >= 105, 'receiptSheetRowStored' => (int) ($after['payment']['receipt_sheet_row'] ?? 0) > 0, 'receiptSheetLinkPresent' => count($after['receiptSheetLinks'] ?? []) === 1, 'noProblemQueues' => (int) ($after['problemQueueCount'] ?? -1) === 0, 'paymentStillActive' => (string) ($after['payment']['payment_status'] ?? '') === 'ACTIVE', 'receiptStillActive' => (string) ($after['payment']['receipt_status'] ?? '') === 'ACTIVE', 'sequenceStill105' => (int) ($after['sequence'] ?? 0) === 105, ], 'message' => 'USPEŠNO: queue 10 je sinhronizovan. Priznanica 00105 postoji u MySQL-u i Google Sheets-u.', 'readyForNextPhase' => true, 'nextStep' => 'READY_FOR_7C_A6_R2_R4_RECEIPT_PDF_PRINT_AND_NORMAL_APP_VALIDATION', 'checkedAtUtc' => gmdate('c'), ]; } /** @return array */ private function failureResult(array $before, array $beforeBridge, array $after, array $afterBridge, ?array $process): array { return [ 'phase' => self::PHASE, 'version' => self::VERSION, 'mode' => self::MODE, 'valid' => false, 'recoveryCompleted' => false, 'paymentCommitRepeated' => false, 'voidCalled' => false, 'processResult' => $process, 'before' => ['target'=>$this->targetSummary($before),'bridge'=>$beforeBridge,'fingerprint'=>$before['fingerprint']], 'after' => ['target'=>$this->targetSummary($after),'bridge'=>$afterBridge,'fingerprint'=>$after['fingerprint']], 'message' => 'Recovery nije potpuno potvrđen. Ne ponavljajte COMMIT i ne koristite VOID.', 'readyForNextPhase' => false, 'nextStep' => 'STOP_AND_SEND_R3_R7_RESULT', 'checkedAtUtc' => gmdate('c'), ]; } /** @param array $result */ private function writeEvidence(array $result): void { $dir = $this->privateRoot . '/backups/phase7c-a6-r2-r3-r7'; if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) return; @chmod($dir, 0700); $path = $dir . '/recovery-' . gmdate('Ymd-His') . '-' . substr(hash('sha256', self::json($result)), 0, 12) . '.json'; @file_put_contents($path, self::json($result), LOCK_EX); @chmod($path, 0600); } /** @return array */ private function decode(string $value): array { if (trim($value) === '') return []; try { $d = json_decode($value, true, 512, JSON_THROW_ON_ERROR); return is_array($d) ? $d : []; } catch (Throwable) { return []; } } private static function json(mixed $value): string { return json_encode($value, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR); } }