diff --git a/.env.testing b/.env.testing
new file mode 100644
index 0000000..d154f99
--- /dev/null
+++ b/.env.testing
@@ -0,0 +1,14 @@
+APP_ENV=testing
+APP_KEY=base64:Ftjcs9gvv7IgMLvSzaCQIaqIPBFO2fezCFUb+owUSCE=
+DB_CONNECTION=mysql
+DB_DATABASE=d2dTest
+
+SZUNETNAPOK_API_KEY=test_api_key
+SZUNETNAPOK_API_URL=https://szunetnapok.hu/api/
+DB_HOST=127.0.0.1
+DB_PORT=3306
+DB_USERNAME=root
+DB_PASSWORD=
+CACHE_DRIVER=array
+SESSION_DRIVER=array
+QUEUE_CONNECTION=sync
diff --git a/app/Console/Commands/CheckTestDb.php b/app/Console/Commands/CheckTestDb.php
new file mode 100644
index 0000000..3be27f6
--- /dev/null
+++ b/app/Console/Commands/CheckTestDb.php
@@ -0,0 +1,58 @@
+newLine();
+ $this->info('--- Adatbázis Kapcsolat Ellenőrzése ---');
+ $this->info('APP_ENV: ' . app()->environment());
+ $this->info('DB_CONNECTION: ' . config('database.default'));
+ $this->info('DB_DATABASE (config): ' . config('database.connections.mysql.database'));
+ $this->info('DB_DATABASE (env): ' . env('DB_DATABASE'));
+
+ try {
+ $dbName = DB::select('SELECT DATABASE() as db')[0]->db;
+ $this->info('TÉNYLEGES ADATBÁZIS (MySQL select): ' . $dbName);
+
+ if (app()->environment() === 'testing' && strtolower($dbName) !== strtolower('d2dTest')) {
+ $this->error('VIGYÁZAT: Teszt környezetben nem a d2dTest adatbázist használod! (Kapcsolódva: ' . $dbName . ')');
+ } elseif (app()->environment() === 'testing' && strtolower($dbName) === strtolower('d2dTest')) {
+ $this->success('Minden rendben: Teszt környezetben a d2dTest adatbázist használod.');
+ }
+ } catch (\Exception $e) {
+ $this->error('Hiba az adatbázishoz való csatlakozáskor: ' . $e->getMessage());
+ }
+ $this->newLine();
+ }
+
+ /**
+ * Success message override
+ */
+ protected function success(string $message)
+ {
+ $this->line("{$message}");
+ }
+}
diff --git a/app/Console/Commands/SyncWorkCalendar.php b/app/Console/Commands/SyncWorkCalendar.php
new file mode 100644
index 0000000..99e2cef
--- /dev/null
+++ b/app/Console/Commands/SyncWorkCalendar.php
@@ -0,0 +1,44 @@
+argument('year') ? (int) $this->argument('year') : now()->year;
+
+ $this->info("Szinkronizálás indítása {$year} évre...");
+
+ if ($service->syncFromApi($year)) {
+ $this->info('A szinkronizálás sikeresen befejeződött.');
+ return self::SUCCESS;
+ }
+
+ $this->error('A szinkronizálás során hiba történt:');
+ foreach ($service->getError() as $error) {
+ $this->error("- " . implode(', ', (array) $error));
+ }
+
+ return self::FAILURE;
+ }
+}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index e6b9960..627d3e6 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -13,6 +13,7 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
+ // $schedule->command('app:sync-work-calendar')->quarterly();
}
/**
diff --git a/app/Enums/DataSource.php b/app/Enums/DataSource.php
new file mode 100644
index 0000000..d26d892
--- /dev/null
+++ b/app/Enums/DataSource.php
@@ -0,0 +1,17 @@
+ 'API',
+ self::Manual => 'Kézi rögzítés',
+ };
+ }
+}
diff --git a/app/Enums/WorkDayType.php b/app/Enums/WorkDayType.php
new file mode 100644
index 0000000..72f9c53
--- /dev/null
+++ b/app/Enums/WorkDayType.php
@@ -0,0 +1,25 @@
+ 'Munkaszüneti nap',
+ self::WorkingDay => 'Áthelyezett munkanap',
+ };
+ }
+
+ public function color(): string
+ {
+ return match($this) {
+ self::Holiday => 'danger',
+ self::WorkingDay => 'success',
+ };
+ }
+}
diff --git a/app/Models/WorkCalendar.php b/app/Models/WorkCalendar.php
new file mode 100644
index 0000000..8d8a3ad
--- /dev/null
+++ b/app/Models/WorkCalendar.php
@@ -0,0 +1,34 @@
+ 'date',
+ 'type' => WorkDayType::class,
+ 'data_source' => DataSource::class,
+ ];
+
+ /**
+ * Meghatározza, hogy az adott nap munkaszüneti nap-e az adatbázis szerint.
+ */
+ public function isHoliday(): bool
+ {
+ return $this->type === WorkDayType::Holiday;
+ }
+
+ /**
+ * Meghatározza, hogy az adott nap plusz munkanap-e az adatbázis szerint.
+ */
+ public function isWorkingDay(): bool
+ {
+ return $this->type === WorkDayType::WorkingDay;
+ }
+}
diff --git a/app/Services/WorkCalendarService.php b/app/Services/WorkCalendarService.php
new file mode 100644
index 0000000..1abc7e9
--- /dev/null
+++ b/app/Services/WorkCalendarService.php
@@ -0,0 +1,136 @@
+year;
+ $config = config('services.szunetnapok');
+ $apiKey = $config['key'] ?? null;
+ $apiUrl = rtrim($config['url'] ?? 'https://szunetnapok.hu/api/', '/');
+ $verify = $config['verify'] ?? true;
+
+ Log::channel('work_calendar')->info('WorkCalendar sync started.', [
+ 'year' => $year,
+ 'api_url' => $apiUrl,
+ 'has_api_key' => !empty($apiKey),
+ 'ssl_verify' => $verify,
+ ]);
+
+ if (empty($apiKey)) {
+ $this->error->add('api', 'Hiányzó szunetnapok.hu API kulcs.');
+ Log::channel('work_calendar')->error('WorkCalendar sync failed: Missing API key.');
+ return false;
+ }
+
+ try {
+ $response = Http::withOptions(['verify' => $verify])
+ ->get("{$apiUrl}/{$apiKey}/{$year}/json");
+
+ Log::channel('work_calendar')->info('WorkCalendar API response.', [
+ 'status' => $response->status(),
+ 'body' => $response->body(),
+ ]);
+
+ if ($response->failed()) {
+ $this->error->add('api', 'Nem sikerült elérni a szunetnapok.hu API-t.');
+ return false;
+ }
+ } catch (\Exception $e) {
+ $this->error->add('api', 'Hálózati hiba az API hívás közben: ' . $e->getMessage());
+ Log::channel('work_calendar')->error('WorkCalendar sync API error (cURL/SSL):', [
+ 'message' => $e->getMessage(),
+ 'exception' => get_class($e),
+ ]);
+ return false;
+ }
+
+ $data = $response->json();
+
+ if (($data['response'] ?? '') !== 'OK') {
+ $this->error->add('api', $data['message'] ?? 'Hibás válasz az API-tól.');
+ return false;
+ }
+
+ if (!isset($data['days']) || !is_array($data['days'])) {
+ Log::channel('work_calendar')->info('WorkCalendar sync: No days to process.');
+ return true;
+ }
+
+ $savedRecords = [];
+
+ foreach ($data['days'] as $day) {
+ $type = ($day['type'] == 1) ? WorkDayType::Holiday : WorkDayType::WorkingDay;
+
+ $record = WorkCalendar::updateOrCreate(
+ ['date' => $day['date']],
+ [
+ 'type' => $type,
+ 'description' => $day['name'] ?? '',
+ 'data_source' => DataSource::Api,
+ ]
+ );
+
+ $savedRecords[] = $record->toArray();
+ }
+
+ Log::channel('work_calendar')->info('WorkCalendar sync completed.', [
+ 'count' => count($savedRecords),
+ 'records' => $savedRecords,
+ ]);
+
+ return true;
+ }
+
+ /**
+ * Megállapítja, hogy az adott nap munkanap-e.
+ */
+ public function isWorkDay(Carbon $date): bool
+ {
+ $record = WorkCalendar::where('date', $date->toDateString())->first();
+
+ if ($record) {
+ return $record->type === WorkDayType::WorkingDay;
+ }
+
+ // Ha nincs az adatbázisban, a hétvégék alapján döntünk
+ return !$date->isWeekend();
+ }
+
+ /**
+ * Kiszámítja a következő érvényes munkanapot.
+ */
+ public function getNextWorkDay(Carbon $date): Carbon
+ {
+ $nextDay = $date->copy()->addDay();
+
+ while (!$this->isWorkDay($nextDay)) {
+ $nextDay->addDay();
+ }
+
+ return $nextDay;
+ }
+
+ /**
+ * Visszaadja a megadott időszak munkaszüneti napjait és plusz munkanapjait.
+ */
+ public function getCalendarEntries(Carbon $start, Carbon $end): Collection
+ {
+ return WorkCalendar::whereBetween('date', [$start->toDateString(), $end->toDateString()])
+ ->orderBy('date')
+ ->get();
+ }
+}
diff --git a/config/logging.php b/config/logging.php
index c473c48..574f1d1 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -81,6 +81,13 @@
// 'formatter'=>\Monolog\Formatter\HtmlFormatter::class,
],
+ 'work_calendar' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/WorkCalendarAPI.log'),
+ 'level' => env('LOG_LEVEL', 'debug'),
+ 'days' => 14,
+ ],
+
'BrowserConsole' => [
'driver' => 'monolog',
'handler' => \Monolog\Handler\BrowserConsoleHandler::class,
diff --git a/config/services.php b/config/services.php
index 6a90eb8..9b85fa7 100644
--- a/config/services.php
+++ b/config/services.php
@@ -35,4 +35,10 @@
],
],
+ 'szunetnapok' => [
+ 'key' => env('SZUNETNAPOK_API_KEY'),
+ 'url' => env('SZUNETNAPOK_API_URL', 'https://szunetnapok.hu/api/'),
+ 'verify' => env('SZUNETNAPOK_API_VERIFY', true),
+ ],
+
];
diff --git a/database/migrations/2026_04_08_184130_create_work_calendars_table.php b/database/migrations/2026_04_08_184130_create_work_calendars_table.php
new file mode 100644
index 0000000..9235bbe
--- /dev/null
+++ b/database/migrations/2026_04_08_184130_create_work_calendars_table.php
@@ -0,0 +1,39 @@
+id();
+ $table->date('date')->unique();
+ $table->enum('type', array_column(\App\Enums\WorkDayType::cases(), 'value'));
+ $table->enum('data_source', array_column(\App\Enums\DataSource::cases(), 'value'))->default(\App\Enums\DataSource::Manual->value);
+ $table->text('description')->nullable();
+
+ // Audit mezők a BaseAuditable-hoz
+ $table->unsignedBigInteger('created_by')->nullable();
+ $table->unsignedBigInteger('updated_by')->nullable();
+ $table->unsignedBigInteger('deleted_by')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->index('date');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('work_calendars');
+ }
+};
diff --git a/phpunit.xml b/phpunit.xml
index d703241..461fb27 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -23,8 +23,8 @@
-
-
+
+
diff --git a/tests/Feature/WorkCalendarServiceTest.php b/tests/Feature/WorkCalendarServiceTest.php
new file mode 100644
index 0000000..e565a70
--- /dev/null
+++ b/tests/Feature/WorkCalendarServiceTest.php
@@ -0,0 +1,173 @@
+isWorkDay($saturday))->toBeFalse();
+
+ // 2026-04-13 Hétfő (Alapesetben munkanap)
+ $monday = Carbon::parse('2026-04-13');
+ expect($service->isWorkDay($monday))->toBeTrue();
+
+ // Rögzítsünk egy szünnapot a hétfőre
+ WorkCalendar::create([
+ 'date' => '2026-04-13',
+ 'type' => WorkDayType::Holiday,
+ 'description' => 'Test Holiday'
+ ]);
+
+ expect($service->isWorkDay($monday))->toBeFalse();
+
+ // Rögzítsünk egy munkanapot a szombatra
+ WorkCalendar::create([
+ 'date' => '2026-04-11',
+ 'type' => WorkDayType::WorkingDay,
+ 'description' => 'Test Working Saturday'
+ ]);
+
+ expect($service->isWorkDay($saturday))->toBeTrue();
+});
+
+test('it calculates next working day correctly', function () {
+ $service = new WorkCalendarService();
+
+ // 2026-04-10 Péntek
+ $friday = Carbon::parse('2026-04-10');
+
+ // Alapesetben a hétvége miatt hétfő lesz a következő munkanap
+ expect($service->getNextWorkDay($friday)->toDateString())->toBe('2026-04-13');
+
+ // Ha a szombat munkanap lesz
+ WorkCalendar::create([
+ 'date' => '2026-04-11',
+ 'type' => WorkDayType::WorkingDay,
+ 'description' => 'Working Saturday'
+ ]);
+
+ expect($service->getNextWorkDay($friday)->toDateString())->toBe('2026-04-11');
+
+ // Ha a hétfő szünnap lesz
+ WorkCalendar::create([
+ 'date' => '2026-04-13',
+ 'type' => WorkDayType::Holiday,
+ 'description' => 'Monday Holiday'
+ ]);
+
+ // Péntek után szombat munkanap, vasárnap nem, hétfő szünnap -> kedd (04-14)
+ expect($service->getNextWorkDay($friday)->toDateString())->toBe('2026-04-11');
+
+ $saturday = Carbon::parse('2026-04-11');
+ expect($service->getNextWorkDay($saturday)->toDateString())->toBe('2026-04-14');
+});
+
+test('it can sync holidays from API', function () {
+ $service = new WorkCalendarService();
+
+ Http::fake([
+ 'szunetnapok.hu/api/*' => Http::response([
+ 'response' => 'OK',
+ 'year' => 2026,
+ 'days' => [
+ [
+ 'date' => '2026-01-01',
+ 'name' => 'Új Év napja',
+ 'type' => 1,
+ 'weekday' => 5
+ ],
+ [
+ 'date' => '2026-05-10',
+ 'name' => 'Áthelyezett munkanap',
+ 'type' => 2,
+ 'weekday' => 6
+ ]
+ ]
+ ], 200)
+ ]);
+
+ $result = $service->syncFromApi(2026);
+
+ expect($result)->toBeTrue();
+ expect(WorkCalendar::count())->toBe(2);
+
+ $holiday = WorkCalendar::where('date', '2026-01-01')->first();
+ expect($holiday->type)->toBe(WorkDayType::Holiday);
+ expect($holiday->data_source)->toBe(\App\Enums\DataSource::Api);
+
+ $workingDay = WorkCalendar::where('date', '2026-05-10')->first();
+ expect($workingDay->type)->toBe(WorkDayType::WorkingDay);
+ expect($workingDay->data_source)->toBe(\App\Enums\DataSource::Api);
+});
+
+test('it handles API errors during sync', function () {
+ $service = new WorkCalendarService();
+
+ Http::fake([
+ 'szunetnapok.hu/api/*' => Http::response([
+ 'response' => 'Error',
+ 'message' => 'Nincs API kulcs megadva'
+ ], 200)
+ ]);
+
+ $result = $service->syncFromApi(2026);
+
+ expect($result)->toBeFalse();
+ expect($service->getError()['api'][0])->toBe('Nincs API kulcs megadva');
+});
+
+test('it logs API calls and database entries during sync', function () {
+ $service = new WorkCalendarService();
+
+ Http::fake([
+ 'szunetnapok.hu/api/*' => Http::response([
+ 'response' => 'OK',
+ 'days' => [
+ [
+ 'date' => '2026-01-01',
+ 'name' => 'Új Év',
+ 'type' => 1
+ ]
+ ]
+ ], 200)
+ ]);
+
+ $mock = Log::partialMock();
+ $mock->shouldReceive('channel')->with('work_calendar')->andReturnSelf();
+ $mock->shouldReceive('info')->atLeast()->once();
+
+ $service->syncFromApi(2026);
+});
+
+test('it logs cURL/SSL exceptions during sync', function () {
+ $service = new WorkCalendarService();
+
+ Http::fake([
+ 'szunetnapok.hu/api/*' => function () {
+ throw new \Exception('cURL error 60: SSL certificate problem');
+ }
+ ]);
+
+ $mock = Log::partialMock();
+ $mock->shouldReceive('channel')->with('work_calendar')->andReturnSelf();
+ $mock->shouldReceive('info')->once(); // Start log
+ $mock->shouldReceive('error')->once()->withArgs(function ($message, $context) {
+ return str_contains($message, 'cURL/SSL') &&
+ str_contains($context['message'], 'SSL certificate problem');
+ });
+
+ $result = $service->syncFromApi(2026);
+
+ expect($result)->toBeFalse();
+ expect($service->getError()['api'][0])->toContain('Hálózati hiba');
+});
diff --git a/tests/TestCase.php b/tests/TestCase.php
index fe1ffc2..89b2649 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -6,5 +6,14 @@
abstract class TestCase extends BaseTestCase
{
- //
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $currentDb = config('database.connections.mysql.database');
+
+ if (strtolower($currentDb) !== strtolower('d2dTest') && env('APP_ENV') === 'testing') {
+ throw new \Exception('KRITIKUS HIBA: A tesztek NEM a d2dTest adatbázison futnak! Aktuális adatbázis: ' . $currentDb);
+ }
+ }
}