[ 'name' => 'NAKS Technology', 'table' => 'naks_certificates', 'unique_keys' => ['short_number', 'certificate_no'], 'pdf_folder' => '0000_Naks Technology', 'download_field' => 'download', ], 'welder' => [ 'name' => 'NAKS Welder', 'table' => 'naks_welders', 'unique_keys' => ['naks_certificate_no', 'welder_id'], 'pdf_folder' => '0003_Naks Welder', 'download_field' => 'download', ], 'consumables' => [ 'name' => 'NAKS Consumables', 'table' => 'naks_consumables', 'unique_keys' => ['naks_certificate_no', 'batch_number'], 'pdf_folder' => '0002_Naks Consumables', 'download_field' => 'download', ], 'expert' => [ 'name' => 'NAKS Expert', 'table' => 'register_of_experts', 'unique_keys' => ['certificate_no'], 'pdf_folder' => '0004_Naks Expert', 'download_field' => 'download', ], 'equipment' => [ 'name' => 'NAKS Equipment', 'table' => 'welding_equipment', 'unique_keys' => ['attestation'], 'pdf_folder' => '0001_Naks Equipment', 'download_field' => 'download', ], ]; protected array $stats = [ 'total_projects' => 0, 'successful_projects' => 0, 'failed_projects' => [], 'modules' => [], 'errors' => [], ]; public function __construct() { $this->adminEmail = env('SYNC_ADMIN_EMAIL'); $this->adminPassword = env('SYNC_ADMIN_PASSWORD'); $this->currentSiteUrl = rtrim(config('app.url'), '/'); } /** * Get available modules */ public function getModules(): array { return $this->modules; } /** * Get module configuration by key */ public function getModuleConfig(string $moduleKey): ?array { return $this->modules[$moduleKey] ?? null; } /** * Run the full synchronization process * * @param string $moduleFilter Module to sync ('all' or specific module key) * @param string|null $specificProject Filter to sync only a specific project * @param bool $dryRun If true, don't make any changes * @param bool $force If true, sync all records (ignore last_synced_id) * @return array Statistics about the sync operation */ public function sync( string $moduleFilter = 'all', ?string $specificProject = null, bool $dryRun = false, bool $force = false ): array { Log::info("🔄 NAKS Multi-Module Sync Started", [ 'module_filter' => $moduleFilter, 'specific_project' => $specificProject, 'dry_run' => $dryRun, 'force' => $force, ]); if (!$this->adminEmail || !$this->adminPassword) { $error = 'SYNC_ADMIN_EMAIL or SYNC_ADMIN_PASSWORD not configured in .env'; Log::error($error); $this->stats['errors'][] = $error; return $this->stats; } // Determine which modules to sync $modulesToSync = $this->getModulesToSync($moduleFilter); if (empty($modulesToSync)) { $error = "Invalid module: {$moduleFilter}. Available: " . implode(', ', array_keys($this->modules)); Log::error($error); $this->stats['errors'][] = $error; return $this->stats; } // Initialize module stats foreach ($modulesToSync as $moduleKey => $moduleConfig) { $this->stats['modules'][$moduleKey] = [ 'name' => $moduleConfig['name'], 'total_synced' => 0, 'total_inserted' => 0, 'total_updated' => 0, 'total_skipped' => 0, 'total_pdfs_downloaded' => 0, ]; } try { $projects = $this->getProjectUrls(); $this->stats['total_projects'] = count($projects); foreach ($projects as $project) { $projectUrl = rtrim($project['url'], '/'); $projectName = $project['project_name'] ?? $projectUrl; // Skip if filtering by specific project if ($specificProject && !$this->matchesProject($projectUrl, $projectName, $specificProject)) { continue; } // Skip current site (don't sync from ourselves) if ($this->isSameSite($projectUrl)) { Log::debug("Skipping current site: {$projectUrl}"); continue; } try { $this->syncFromProject($projectUrl, $projectName, $modulesToSync, $dryRun, $force); $this->stats['successful_projects']++; } catch (\Exception $e) { Log::error("Failed to sync from project: {$projectName}", [ 'url' => $projectUrl, 'error' => $e->getMessage(), ]); $this->stats['failed_projects'][] = [ 'name' => $projectName, 'url' => $projectUrl, 'error' => $e->getMessage(), ]; } } } catch (\Exception $e) { Log::error("NAKS Sync failed: " . $e->getMessage()); $this->stats['errors'][] = $e->getMessage(); } Log::info("🔄 NAKS Multi-Module Sync Completed", $this->stats); return $this->stats; } /** * Get modules to sync based on filter */ protected function getModulesToSync(string $filter): array { if ($filter === 'all') { return $this->modules; } if (isset($this->modules[$filter])) { return [$filter => $this->modules[$filter]]; } return []; } /** * Get list of project URLs from the API */ public function getProjectUrls(): array { $response = Http::timeout($this->timeout) ->get($this->currentSiteUrl . '/api/project-app-urls'); if (!$response->successful()) { throw new \Exception("Failed to fetch project URLs: " . $response->status()); } $data = $response->json(); if (!isset($data['data']) || !is_array($data['data'])) { throw new \Exception("Invalid project URLs response format"); } return $data['data']; } /** * Authenticate with a remote site and get bearer token */ public function authenticate(string $baseUrl): ?string { try { $response = Http::timeout($this->timeout) ->post(rtrim($baseUrl, '/') . '/api/login', [ 'email' => $this->adminEmail, 'password' => $this->adminPassword, ]); if (!$response->successful()) { Log::warning("Authentication failed for {$baseUrl}", [ 'status' => $response->status(), ]); return null; } $data = $response->json(); return $data['data']['access_token'] ?? null; } catch (\Exception $e) { Log::error("Authentication error for {$baseUrl}: " . $e->getMessage()); return null; } } /** * Fetch records from a remote site for a specific module */ public function fetchRecords(string $baseUrl, string $token, string $tableName, int $afterId = 0, int $take = 100): array { $url = rtrim($baseUrl, '/') . '/api/' . $tableName . '/read'; $params = [ 'take' => $take, 'sort' => json_encode([['selector' => 'id', 'desc' => false]]), ]; if ($afterId > 0) { $params['filter'] = json_encode(['id', '>', $afterId]); } $response = Http::timeout($this->timeout) ->withToken($token) ->get($url, $params); if (!$response->successful()) { throw new \Exception("Failed to fetch records from {$tableName}: " . $response->status()); } $data = $response->json(); return $data['data'] ?? []; } /** * Sync all modules from a specific project */ protected function syncFromProject( string $projectUrl, string $projectName, array $modulesToSync, bool $dryRun, bool $force ): void { Log::info("Syncing from project: {$projectName}", ['url' => $projectUrl]); // Authenticate once for all modules $token = $this->authenticate($projectUrl); if (!$token) { throw new \Exception("Authentication failed"); } // Sync each module foreach ($modulesToSync as $moduleKey => $moduleConfig) { try { $this->syncModuleFromProject( $projectUrl, $projectName, $token, $moduleKey, $moduleConfig, $dryRun, $force ); } catch (\Exception $e) { Log::error("Failed to sync module {$moduleKey} from {$projectName}: " . $e->getMessage()); $this->stats['errors'][] = "Module {$moduleKey} from {$projectName}: " . $e->getMessage(); } } } /** * Sync a specific module from a project */ protected function syncModuleFromProject( string $projectUrl, string $projectName, string $token, string $moduleKey, array $moduleConfig, bool $dryRun, bool $force ): void { $tableName = $moduleConfig['table']; Log::info("Syncing module: {$moduleConfig['name']} from {$projectName}"); // Get last synced ID $lastSyncedId = $force ? 0 : $this->getLastSyncedId($tableName, $projectUrl); Log::debug("Last synced ID for {$tableName} from {$projectName}: {$lastSyncedId}"); $moduleStats = [ 'synced' => 0, 'inserted' => 0, 'updated' => 0, 'skipped' => 0, 'pdfs' => 0, ]; $maxId = $lastSyncedId; $hasMore = true; while ($hasMore) { // Fetch batch of records $records = $this->fetchRecords($projectUrl, $token, $tableName, $maxId, $this->batchSize); if (empty($records)) { $hasMore = false; break; } foreach ($records as $record) { $recordId = $record['id'] ?? 0; $maxId = max($maxId, $recordId); try { $result = $this->syncRecord($record, $moduleConfig, $projectUrl, $projectName, $dryRun); $moduleStats['synced']++; switch ($result['action']) { case 'inserted': $moduleStats['inserted']++; break; case 'updated': $moduleStats['updated']++; break; case 'skipped': $moduleStats['skipped']++; break; } // Download PDF if needed $downloadField = $moduleConfig['download_field'] ?? 'download'; if ($result['action'] !== 'skipped' && !$dryRun && !empty($record[$downloadField])) { if ($this->downloadPdf($projectUrl, $record[$downloadField], $moduleConfig['pdf_folder'])) { $moduleStats['pdfs']++; } } } catch (\Exception $e) { Log::warning("Failed to sync record", [ 'module' => $moduleKey, 'id' => $recordId, 'error' => $e->getMessage(), ]); } } // If we got less than batch size, we're done if (count($records) < $this->batchSize) { $hasMore = false; } } // Update sync state if (!$dryRun && $maxId > $lastSyncedId) { $this->updateSyncState($tableName, $projectUrl, $maxId, $moduleStats['synced']); } // Update global stats $this->stats['modules'][$moduleKey]['total_synced'] += $moduleStats['synced']; $this->stats['modules'][$moduleKey]['total_inserted'] += $moduleStats['inserted']; $this->stats['modules'][$moduleKey]['total_updated'] += $moduleStats['updated']; $this->stats['modules'][$moduleKey]['total_skipped'] += $moduleStats['skipped']; $this->stats['modules'][$moduleKey]['total_pdfs_downloaded'] += $moduleStats['pdfs']; Log::info("Completed sync of {$moduleConfig['name']} from {$projectName}", $moduleStats); } /** * Sync a single record * * @param string $projectName The project name where the record is synced from (stored in source_project column) * @return array ['action' => 'inserted'|'updated'|'skipped', 'local_id' => int|null] */ public function syncRecord(array $record, array $moduleConfig, string $sourceUrl, string $projectName, bool $dryRun = false): array { $tableName = $moduleConfig['table']; $uniqueKeys = $moduleConfig['unique_keys']; // Build unique key conditions $conditions = []; foreach ($uniqueKeys as $key) { $value = $record[$key] ?? null; if ($value === null || $value === '') { return ['action' => 'skipped', 'local_id' => null, 'reason' => "missing_unique_key: {$key}"]; } $conditions[$key] = $value; } // Check if record exists locally $query = DB::table($tableName); foreach ($conditions as $field => $value) { $query->where($field, $value); } $existing = $query->first(); // Prepare data for insert/update $data = $this->prepareRecordData($record, $tableName); if ($existing) { // Compare updated_at to decide if we should update $remoteUpdatedAt = isset($record['updated_at']) ? Carbon::parse($record['updated_at']) : null; $localUpdatedAt = isset($existing->updated_at) ? Carbon::parse($existing->updated_at) : null; // Only update if remote is newer if ($remoteUpdatedAt && $localUpdatedAt && $remoteUpdatedAt <= $localUpdatedAt) { return ['action' => 'skipped', 'local_id' => $existing->id, 'reason' => 'local_is_newer']; } if (!$dryRun) { // Add source_project to track where the record came from $data['source_project'] = $projectName; DB::table($tableName) ->where('id', $existing->id) ->update($data); } return ['action' => 'updated', 'local_id' => $existing->id]; } else { if (!$dryRun) { // Add source_project to track where the record came from $data['source_project'] = $projectName; $newId = DB::table($tableName)->insertGetId($data); return ['action' => 'inserted', 'local_id' => $newId]; } return ['action' => 'inserted', 'local_id' => null]; } } /** * Prepare record data for database insert/update */ protected function prepareRecordData(array $record, string $tableName): array { // Remove fields that should not be copied $excludeFields = ['id', 'uid']; $data = []; $columns = \Schema::getColumnListing($tableName); foreach ($record as $key => $value) { if (in_array($key, $excludeFields)) { continue; } if (in_array($key, $columns)) { $data[$key] = $value; } } // Update timestamps $data['updated_at'] = now(); // If inserting, set created_at if (!isset($data['created_at'])) { $data['created_at'] = now(); } return $data; } /** * Download PDF file from remote site */ public function downloadPdf(string $baseUrl, string $remotePath, string $pdfFolder): bool { if (empty($remotePath)) { return false; } // Extract just the filename $filename = basename($remotePath); // Local path $localPath = storage_path($this->basePdfPath . '/' . $pdfFolder . '/' . $filename); // Skip if file already exists locally if (File::exists($localPath)) { return false; } try { // Construct download URL $downloadUrl = rtrim($baseUrl, '/') . '/' . ltrim($remotePath, '/'); $response = Http::timeout(60)->get($downloadUrl); if (!$response->successful()) { Log::warning("Failed to download PDF: {$downloadUrl}", [ 'status' => $response->status(), ]); return false; } // Ensure directory exists $directory = dirname($localPath); if (!File::isDirectory($directory)) { File::makeDirectory($directory, 0755, true); } // Save file File::put($localPath, $response->body()); Log::debug("Downloaded PDF: {$filename} to {$pdfFolder}"); return true; } catch (\Exception $e) { Log::warning("Error downloading PDF: " . $e->getMessage(), [ 'url' => $baseUrl, 'path' => $remotePath, ]); return false; } } /** * Get the last synced ID for a table and source URL */ public function getLastSyncedId(string $tableName, string $sourceUrl): int { $state = DB::table('sync_states') ->where('table_name', $tableName) ->where('source_url', $sourceUrl) ->first(); return $state ? (int) $state->last_synced_id : 0; } /** * Update the sync state for a table and source URL */ public function updateSyncState(string $tableName, string $sourceUrl, int $lastId, int $syncedCount = 0): void { $existing = DB::table('sync_states') ->where('table_name', $tableName) ->where('source_url', $sourceUrl) ->first(); if ($existing) { DB::table('sync_states') ->where('id', $existing->id) ->update([ 'last_synced_id' => $lastId, 'last_synced_at' => now(), 'synced_count' => $existing->synced_count + $syncedCount, 'updated_at' => now(), ]); } else { DB::table('sync_states')->insert([ 'table_name' => $tableName, 'source_url' => $sourceUrl, 'last_synced_id' => $lastId, 'last_synced_at' => now(), 'synced_count' => $syncedCount, 'created_at' => now(), 'updated_at' => now(), ]); } } /** * Check if a URL matches the current site */ protected function isSameSite(string $url): bool { $normalizedUrl = $this->normalizeUrl($url); $normalizedCurrent = $this->normalizeUrl($this->currentSiteUrl); return $normalizedUrl === $normalizedCurrent; } /** * Normalize URL for comparison */ protected function normalizeUrl(string $url): string { $parsed = parse_url($url); $host = $parsed['host'] ?? ''; // Remove common prefixes/suffixes $host = str_replace(['www.', '/admin'], '', $host); return strtolower($host); } /** * Check if a project matches the filter */ protected function matchesProject(string $url, string $name, string $filter): bool { $filter = strtolower($filter); return str_contains(strtolower($url), $filter) || str_contains(strtolower($name), $filter); } /** * Get sync statistics */ public function getStats(): array { return $this->stats; } /** * Reset sync state for a specific table/source or all */ public function resetSyncState(?string $tableName = null, ?string $sourceUrl = null): int { $query = DB::table('sync_states'); if ($tableName) { $query->where('table_name', $tableName); } if ($sourceUrl) { $query->where('source_url', $sourceUrl); } return $query->delete(); } /** * Get all sync states, optionally filtered by table */ public function getSyncStates(?string $tableName = null): array { $query = DB::table('sync_states'); if ($tableName) { $query->where('table_name', $tableName); } return $query->orderBy('table_name')->orderBy('source_url')->get()->toArray(); } }