700 lines
23 KiB
PHP
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();
|
|
}
|
|
}
|