← Назад к вопросам

Сервис консультаций для абитуриентов

2.0 Middle🔥 71 комментариев
#API и веб-протоколы#Очереди и брокеры сообщений#Фреймворки

Условие

Приёмная комиссия проводит индивидуальные и групповые консультации для абитуриентов (очно и онлайн). Нужно сделать сервис для записи на консультации.

Функциональность

  • Хранение списка консультаций
  • Запись абитуриентов на консультации
  • Контроль количества мест
  • API для внешних сервисов

Сущности

Консультация:

  • Название, дата/время, формат (очно/онлайн), макс. количество мест

Запись:

  • ФИО абитуриента, email, телефон, консультация

API

  • GET /api/consultations - список доступных консультаций
  • POST /api/consultations/{id}/register - запись на консультацию
  • DELETE /api/registrations/{id} - отмена записи

Требования

  • Нельзя записаться, если мест нет
  • Email уведомление при записи
  • Напоминание за день до консультации

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение

1. Миграции

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void {
        // Таблица консультаций
        Schema::create("consultations", function (Blueprint $table) {
            $table->id();
            $table->string("title", 255);
            $table->text("description")->nullable();
            $table->dateTime("start_at");
            $table->dateTime("end_at");
            $table->enum("format", ["offline", "online"]);
            $table->integer("max_participants");
            $table->string("location", 255)->nullable(); // Для очных
            $table->string("meeting_link", 500)->nullable(); // Для онлайн
            $table->boolean("is_active")->default(true);
            $table->timestamps();
            $table->index("start_at");
            $table->index("is_active");
        });

        // Таблица записей на консультации
        Schema::create("registrations", function (Blueprint $table) {
            $table->id();
            $table->foreignId("consultation_id")->constrained("consultations")->cascadeOnDelete();
            $table->string("full_name", 255);
            $table->string("email", 255);
            $table->string("phone", 20);
            $table->text("comment")->nullable();
            $table->enum("status", ["active", "cancelled"])->default("active");
            $table->timestamp("registered_at");
            $table->timestamp("cancelled_at")->nullable();
            $table->timestamps();
            $table->unique(["consultation_id", "email"]);
            $table->index("consultation_id");
            $table->index("email");
            $table->index("status");
        });
    }

    public function down(): void {
        Schema::dropIfExists("registrations");
        Schema::dropIfExists("consultations");
    }
};

2. Модели

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Consultation extends Model {
    protected $fillable = [
        "title",
        "description",
        "start_at",
        "end_at",
        "format",
        "max_participants",
        "location",
        "meeting_link",
        "is_active",
    ];

    protected $casts = [
        "start_at" => "datetime",
        "end_at" => "datetime",
        "is_active" => "boolean",
    ];

    public function registrations(): HasMany {
        return $this->hasMany(Registration::class);
    }

    public function getAvailableSpotsAttribute(): int {
        $active = $this->registrations()
            ->where("status", "active")
            ->count();
        return $this->max_participants - $active;
    }

    public function isAvailable(): bool {
        return $this->is_active
            && $this->start_at > now()
            && $this->getAvailableSpotsAttribute() > 0;
    }
}

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Registration extends Model {
    protected $fillable = [
        "consultation_id",
        "full_name",
        "email",
        "phone",
        "comment",
        "status",
        "registered_at",
        "cancelled_at",
    ];

    protected $casts = [
        "registered_at" => "datetime",
        "cancelled_at" => "datetime",
    ];

    public function consultation(): BelongsTo {
        return $this->belongsTo(Consultation::class);
    }
}

3. Mail классы

<?php

namespace App\Mail;

use App\Models\Registration;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class RegistrationConfirmation extends Mailable {
    use Queueable, SerializesModels;

    public function __construct(private Registration $registration) {}

    public function envelope(): Envelope {
        return new Envelope(
            subject: "Подтверждение записи на консультацию: {$this->registration->consultation->title}",
        );
    }

    public function content(): Content {
        return new Content(
            view: "emails.registration_confirmation",
            with: [
                "registration" => $this->registration,
                "consultation" => $this->registration->consultation,
            ],
        );
    }
}

<?php

namespace App\Mail;

use App\Models\Consultation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class ConsultationReminder extends Mailable {
    use Queueable, SerializesModels;

    public function __construct(private Consultation $consultation) {}

    public function envelope(): Envelope {
        return new Envelope(
            subject: "Напоминание: завтра консультация '{$this->consultation->title}'",
        );
    }

    public function content(): Content {
        return new Content(
            view: "emails.consultation_reminder",
            with: ["consultation" => $this->consultation],
        );
    }
}

4. Service для управления записями

<?php

namespace App\Services;

use App\Mail\ConsultationReminder;
use App\Mail\RegistrationConfirmation;
use App\Models\Consultation;
use App\Models\Registration;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;

class ConsultationService {
    /**
     * Записать абитуриента на консультацию
     */
    public function register(Consultation $consultation, array $data): Registration {
        // Проверка доступности
        if (!$consultation->isAvailable()) {
            throw ValidationException::withMessages([
                "consultation" => "К сожалению, мест на эту консультацию больше нет."
            ]);
        }

        // Проверка дублирования
        $exists = Registration::where("consultation_id", $consultation->id)
            ->where("email", $data["email"])
            ->where("status", "active")
            ->exists();

        if ($exists) {
            throw ValidationException::withMessages([
                "email" => "Вы уже записаны на эту консультацию."
            ]);
        }

        // Создание записи
        $registration = Registration::create([
            "consultation_id" => $consultation->id,
            "full_name" => $data["full_name"],
            "email" => $data["email"],
            "phone" => $data["phone"],
            "comment" => $data["comment"] ?? null,
            "registered_at" => now(),
        ]);

        // Отправка email подтверждения
        Mail::queue(new RegistrationConfirmation($registration));

        return $registration;
    }

    /**
     * Отменить запись
     */
    public function cancel(Registration $registration): void {
        $registration->update([
            "status" => "cancelled",
            "cancelled_at" => now(),
        ]);
    }
}

5. API контроллер

<?php

namespace App\Http\Controllers\Api;

use App\Http\Requests\RegisterConsultationRequest;
use App\Models\Consultation;
use App\Models\Registration;
use App\Services\ConsultationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class ConsultationController extends Controller {
    protected $service;

    public function __construct(ConsultationService $service) {
        $this->service = $service;
    }

    /**
     * GET /api/consultations - список доступных консультаций
     */
    public function index(Request $request): JsonResponse {
        $query = Consultation::where("is_active", true)
            ->where("start_at", ">", now())
            ->orderBy("start_at");

        // Фильтр по формату
        if ($request->has("format")) {
            $query->where("format", $request->get("format"));
        }

        // Фильтр по датам
        if ($request->has("from")) {
            $query->where("start_at", ">", $request->get("from"));
        }
        if ($request->has("to")) {
            $query->where("start_at", "<", $request->get("to"));
        }

        $consultations = $query->paginate(20);

        return response()->json($consultations->map(function (Consultation $c) {
            return [
                "id" => $c->id,
                "title" => $c->title,
                "description" => $c->description,
                "start_at" => $c->start_at,
                "end_at" => $c->end_at,
                "format" => $c->format,
                "location" => $c->location,
                "meeting_link" => $c->meeting_link,
                "available_spots" => $c->available_spots,
                "max_participants" => $c->max_participants,
            ];
        }));
    }

    /**
     * POST /api/consultations/{id}/register - запись на консультацию
     */
    public function register(RegisterConsultationRequest $request, Consultation $consultation): JsonResponse {
        $registration = $this->service->register($consultation, $request->validated());

        return response()->json([
            "message" => "Вы успешно записались на консультацию",
            "registration" => [
                "id" => $registration->id,
                "consultation" => $consultation->title,
                "date" => $consultation->start_at->format("d.m.Y H:i"),
                "format" => $consultation->format,
                "confirmation_email" => $registration->email,
            ],
        ], 201);
    }

    /**
     * DELETE /api/registrations/{id} - отмена записи
     */
    public function cancel(Registration $registration): JsonResponse {
        $this->service->cancel($registration);

        return response()->json([
            "message" => "Ваша запись отменена",
        ]);
    }

    /**
     * GET /api/registrations/{id} - информация о записи
     */
    public function show(Registration $registration): JsonResponse {
        return response()->json([
            "id" => $registration->id,
            "full_name" => $registration->full_name,
            "email" => $registration->email,
            "phone" => $registration->phone,
            "consultation" => [
                "id" => $registration->consultation->id,
                "title" => $registration->consultation->title,
                "start_at" => $registration->consultation->start_at,
                "format" => $registration->consultation->format,
            ],
            "status" => $registration->status,
            "registered_at" => $registration->registered_at,
        ]);
    }
}

6. Form Request валидация

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class RegisterConsultationRequest extends FormRequest {
    public function authorize(): bool { return true; }

    public function rules(): array {
        return [
            "full_name" => "required|string|max:255",
            "email" => "required|email|max:255",
            "phone" => "required|string|max:20|regex:/^[+\\d\\s\\-\\(\\)]+$/",
            "comment" => "nullable|string|max:1000",
        ];
    }

    public function messages(): array {
        return [
            "full_name.required" => "ФИО обязательно",
            "email.required" => "Email обязателен",
            "phone.required" => "Телефон обязателен",
            "phone.regex" => "Некорректный формат телефона",
        ];
    }
}

7. Маршруты

<?php

use App\Http\Controllers\Api\ConsultationController;
use Illuminate\Support\Facades\Route;

Route::get("/consultations", [ConsultationController::class, "index"]);
Route::post("/consultations/{consultation}/register", [ConsultationController::class, "register"]);
Route::get("/registrations/{registration}", [ConsultationController::class, "show"]);
Route::delete("/registrations/{registration}", [ConsultationController::class, "cancel"]);

8. Scheduled job для напоминаний

<?php

namespace App\Console\Commands;

use App\Mail\ConsultationReminder;
use App\Models\Consultation;
use App\Models\Registration;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;

class SendConsultationReminders extends Command {
    protected $signature = "consultation:send-reminders";
    protected $description = "Send reminders for consultations tomorrow";

    public function handle(): void {
        // Консультации на завтра
        $tomorrow = now()->addDay()->startOfDay();
        $tomorrowEnd = now()->addDay()->endOfDay();

        $consultations = Consultation::whereBetween("start_at", [$tomorrow, $tomorrowEnd])
            ->get();

        foreach ($consultations as $consultation) {
            // Отправляем напоминание всем, кто записан
            $registrations = Registration::where("consultation_id", $consultation->id)
                ->where("status", "active")
                ->get();

            foreach ($registrations as $registration) {
                Mail::queue(new ConsultationReminder($consultation));
            }
        }

        $this->info("Reminders sent successfully");
    }
}

9. Конфигурация планировщика (app/Console/Kernel.php)

<?php

public function schedule(Schedule $schedule) {
    // Отправка напоминаний каждый день в 10:00
    $schedule->command("consultation:send-reminders")->dailyAt("10:00");
}

10. Email шаблоны

{{-- resources/views/emails/registration_confirmation.blade.php --}}
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body>
    <h2>Спасибо за запись!</h2>
    <p>{{ $registration->full_name }}, вы успешно записались на консультацию.</p>
    
    <h3>Детали:</h3>
    <ul>
        <li><strong>Консультация:</strong> {{ $consultation->title }}</li>
        <li><strong>Дата и время:</strong> {{ $consultation->start_at->format('d.m.Y H:i') }}</li>
        <li><strong>Формат:</strong> {{ $consultation->format === 'offline' ? 'Очно' : 'Онлайн' }}</li>
        @if($consultation->format === 'offline')
            <li><strong>Место:</strong> {{ $consultation->location }}</li>
        @else
            <li><strong>Ссылка на встречу:</strong> {{ $consultation->meeting_link }}</li>
        @endif
    </ul>
    
    <p>До скорого встречи!</p>
</body>
</html>

11. Тесты

<?php

namespace Tests\Feature;

use App\Models\Consultation;
use App\Models\Registration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ConsultationApiTest extends TestCase {
    use RefreshDatabase;

    public function test_can_see_available_consultations(): void {
        Consultation::factory()->create(["start_at" => now()->addDay()]);
        $response = $this->getJson("/api/consultations");
        $response->assertStatus(200);
    }

    public function test_can_register_for_consultation(): void {
        $consultation = Consultation::factory()->create();
        $response = $this->postJson("/api/consultations/{$consultation->id}/register", [
            "full_name" => "John Doe",
            "email" => "john@example.com",
            "phone" => "+7 (999) 123-45-67",
        ]);
        $response->assertStatus(201);
        $this->assertDatabaseHas("registrations", ["email" => "john@example.com"]);
    }

    public function test_cannot_register_without_available_spots(): void {
        $consultation = Consultation::factory()->create(["max_participants" => 1]);
        Registration::factory()->create(["consultation_id" => $consultation->id]);
        
        $response = $this->postJson("/api/consultations/{$consultation->id}/register", [
            "full_name" => "Jane Doe",
            "email" => "jane@example.com",
            "phone" => "+7 (999) 987-65-43",
        ]);
        $response->assertStatus(422);
    }

    public function test_can_cancel_registration(): void {
        $registration = Registration::factory()->create();
        $response = $this->deleteJson("/api/registrations/{$registration->id}");
        $response->assertStatus(200);
        $registration->refresh();
        $this->assertEquals("cancelled", $registration->status);
    }
}

Это полное решение с API, email уведомлениями, проверкой доступности и напоминаниями за день до консультации.