Files
citrus-cms/app/Services/NaksSyncService.php
T
2026-04-28 21:14:25 +03:00

700 lines
23 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\File;
use Carbon\Carbon;
/**
* NAKS Multi-Module Synchronization Service
*
* Synchronizes NAKS data across multiple project sites for various modules:
* - NAKS Technology (naks_certificates)
* - NAKS Welder (naks_welders)
* - NAKS Consumables (naks_consumables)
* - NAKS Expert (register_of_experts)
* - NAKS Equipment (welding_equipment)
*
* Each site runs this service to pull data from other sites.
*/
class NaksSyncService
{
protected string $basePdfPath = 'documents/003_Welding_Database';
protected int $timeout = 30;
protected int $batchSize = 100;
protected ?string $adminEmail = null;
protected ?string $adminPassword = null;
protected ?string $currentSiteUrl = null;
/**
* Module configurations
* Each module defines its table, unique keys, and PDF folder
*/
protected array $modules = [
'technology' => [
'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();
}
}