From bbe639b604263d71dcc69bb2ff6a44d34235345b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80?= <ваш@email.com> Date: Wed, 7 Jan 2026 11:55:53 +0000 Subject: [PATCH] coursework tasks 1 to 10 --- .../Controllers/AvailabilitiesController.php | 169 ++++++++++++++++++ app/Http/Controllers/BookingsController.php | 108 +++++++++++ app/Http/Controllers/ServicesController.php | 71 +++++++- app/Http/Controllers/UserController.php | 44 +++-- app/Http/Middleware/CheckRole.php | 16 ++ app/Models/Booking.php | 23 +++ app/Models/EmployeeAvailability.php | 10 ++ app/Models/Services.php | 19 +- app/Models/User.php | 32 ++-- bootstrap/app.php | 8 +- ...0_create_employee_availabilities_table.php | 24 +++ ...026_01_07_140000_create_bookings_table.php | 35 ++++ routes/api.php | 37 +++- 13 files changed, 545 insertions(+), 51 deletions(-) create mode 100644 app/Http/Controllers/AvailabilitiesController.php create mode 100644 app/Http/Controllers/BookingsController.php create mode 100644 app/Http/Middleware/CheckRole.php create mode 100644 app/Models/Booking.php create mode 100644 app/Models/EmployeeAvailability.php create mode 100644 database/migrations/2026_01_07_135000_create_employee_availabilities_table.php create mode 100644 database/migrations/2026_01_07_140000_create_bookings_table.php diff --git a/app/Http/Controllers/AvailabilitiesController.php b/app/Http/Controllers/AvailabilitiesController.php new file mode 100644 index 0000000..491b9da --- /dev/null +++ b/app/Http/Controllers/AvailabilitiesController.php @@ -0,0 +1,169 @@ +employee_id) { + $query->where('employee_id', $request->employee_id); + } + if ($request->date) { + $query->where('date', $request->date); + } + + $availabilities = $query->get(); + return response()->json($availabilities); + } + + // POST api/admin/availabilities - создать один слот + public function store(Request $request) + { + $request->validate([ + 'employee_id' => 'required|exists:users,id', + 'date' => 'required|date', + 'starttime' => 'required', + 'endtime' => 'required|after:starttime', + 'isavailable' => 'boolean' + ]); + + $availability = EmployeeAvailability::create($request->all()); + return response()->json($availability, 201); + } + + // POST api/admin/availabilities/bulk - создать несколько слотов + public function bulkStore(Request $request) + { + $request->validate([ + 'employee_id' => 'required|exists:users,id', + 'date' => 'required|date', + 'intervals' => 'required|array|min:1' + ]); + + $availabilities = []; + foreach ($request->intervals as $interval) { + $availability = EmployeeAvailability::create([ + 'employee_id' => $request->employee_id, + 'date' => $request->date, + 'starttime' => $interval['start'], + 'endtime' => $interval['end'], + 'isavailable' => true + ]); + $availabilities[] = $availability; + } + + return response()->json($availabilities, 201); + } + + // DELETE api/admin/availabilities/{id} - удалить слот (брони остаются!) + public function destroy($id) + { + $availability = EmployeeAvailability::findOrFail($id); + $availability->delete(); + + return response()->json(['message' => 'Слот удален из расписания (брони сохранены)']); + } + public function publicAvailability(Request $request) +{ + $serviceId = $request->query('service_id'); + $date = $request->query('date'); + + if (!$serviceId || !$date) { + return response()->json(['error' => 'service_id и date обязательны'], 400); + } + + // Найти услугу и получить длительность + $service = \App\Models\Services::find($serviceId); + if (!$service) { + return response()->json(['error' => 'Услуга не найдена'], 404); + } + + $durationMinutes = $service->durationminutes; + + // Найти сотрудников с расписанием на эту дату + $availabilities = \App\Models\EmployeeAvailability::where('date', $date) + ->where('isavailable', true) + ->with('employee') // связь с User + ->get(); + + $freeSlots = []; + + foreach ($availabilities as $availability) { + $employeeId = $availability->employee_id; + + // Найти занятые слоты этого сотрудника + $bookings = \App\Models\Booking::where('employee_id', $employeeId) + ->where('bookingdate', $date) + ->where('status', '!=', 'cancelled') + ->pluck('starttime', 'endtime'); + + // Генерировать возможные слоты с учетом duration + $start = new \DateTime($availability->starttime); + $end = new \DateTime($availability->endtime); + + $current = clone $start; + while ($current < $end) { + $slotEnd = clone $current; + $slotEnd->modify("+{$durationMinutes} minutes"); + + // Проверить пересечение с бронями + $isFree = true; + foreach ($bookings as $bookingStart => $bookingEnd) { + $bookingStartTime = new \DateTime($bookingStart); + $bookingEndTime = new \DateTime($bookingEnd); + + if ($current < $bookingEndTime && $slotEnd > $bookingStartTime) { + $isFree = false; + break; + } + } + + if ($isFree && $slotEnd <= $end) { + $freeSlots[] = [ + 'employee_id' => $employeeId, + 'start' => $current->format('H:i'), + 'end' => $slotEnd->format('H:i') + ]; + } + + $current->modify('+30 minutes'); // шаг 30 мин + } + } + + return response()->json($freeSlots); +} +public function cancel(Request $request, $id) +{ + $booking = Booking::findOrFail($id); + + // Проверка: только автор брони может отменить + if ($booking->client_id != auth()->id()) { + return response()->json(['error' => 'Можете отменить только свою бронь'], 403); + } + + // Проверка: нельзя отменить уже отмененную/выполненную + if ($booking->status != 'confirmed') { + return response()->json(['error' => 'Можно отменить только подтвержденные брони'], 400); + } + + // Обновить статус + $booking->update([ + 'status' => 'cancelled', + 'cancelledby' => 'client', + 'cancelreason' => $request->reason ?? null + ]); + + return response()->json([ + 'message' => 'Бронь отменена', + 'booking' => $booking + ]); +} +} \ No newline at end of file diff --git a/app/Http/Controllers/BookingsController.php b/app/Http/Controllers/BookingsController.php new file mode 100644 index 0000000..a291f38 --- /dev/null +++ b/app/Http/Controllers/BookingsController.php @@ -0,0 +1,108 @@ +validate([ + 'service_id' => 'required|exists:services,id', + 'employee_id' => 'required|exists:users,id', + 'date' => 'required|date', + 'starttime' => 'required' + ]); + + $clientId = auth()->id(); + if (!$clientId) { + return response()->json(['error' => 'Авторизация обязательна'], 401); + } + + // Проверить активную услугу + $service = Services::where('id', $request->service_id) + ->where('isactive', true) + ->first(); + if (!$service) { + return response()->json(['error' => 'Услуга неактивна или не найдена'], 400); + } + + $durationMinutes = $service->durationminutes; + $endtime = date('H:i:s', strtotime($request->starttime . " +{$durationMinutes} minutes")); + + // Проверить доступность сотрудника + $availability = EmployeeAvailability::where('employee_id', $request->employee_id) + ->where('date', $request->date) + ->where('starttime', '<=', $request->starttime) + ->where('endtime', '>=', $endtime) + ->where('isavailable', true) + ->first(); + + if (!$availability) { + return response()->json(['error' => 'Сотрудник недоступен в это время'], 400); + } + + // Проверить уникальность слота + $bookingExists = Booking::where('employee_id', $request->employee_id) + ->where('bookingdate', $request->date) + ->where('starttime', $request->starttime) + ->whereIn('status', ['confirmed', 'completed']) + ->exists(); + + if ($bookingExists) { + return response()->json(['error' => 'Слот уже забронирован'], 400); + } + + // Создать бронь + $bookingNumber = 'CL-' . date('Y') . '-' . str_pad(Booking::count() + 1, 4, '0', STR_PAD_LEFT); + + $booking = Booking::create([ + 'bookingnumber' => $bookingNumber, + 'client_id' => $clientId, + 'employee_id' => $request->employee_id, + 'service_id' => $request->service_id, + 'bookingdate' => $request->date, + 'starttime' => $request->starttime, + 'endtime' => $endtime, + 'status' => 'confirmed' + ]); + + return response()->json([ + 'booking' => $booking, + 'message' => 'Бронирование создано №' . $bookingNumber + ], 201); + } + + // POST api/bookings/{id}/cancel - отмена клиентом + public function cancel(Request $request, $id) + { + $booking = Booking::findOrFail($id); + + // Только автор брони может отменить + if ($booking->client_id != auth()->id()) { + return response()->json(['error' => 'Можете отменить только свою бронь'], 403); + } + + // Только confirmed брони + if ($booking->status != 'confirmed') { + return response()->json(['error' => 'Можно отменить только подтвержденные'], 400); + } + + $booking->update([ + 'status' => 'cancelled', + 'cancelledby' => 'client', + 'cancelreason' => $request->reason ?? null + ]); + + return response()->json([ + 'message' => 'Бронь отменена', + 'booking' => $booking + ]); + } +} diff --git a/app/Http/Controllers/ServicesController.php b/app/Http/Controllers/ServicesController.php index f06598e..f73f57a 100644 --- a/app/Http/Controllers/ServicesController.php +++ b/app/Http/Controllers/ServicesController.php @@ -2,9 +2,78 @@ namespace App\Http\Controllers; +use App\Models\Services; use Illuminate\Http\Request; class ServicesController extends Controller { - // + // GET api/admin/services - список активных услуг + public function index() + { + $services = Services::where('isactive', true)->get(); + return response()->json($services); + } + + // POST api/admin/services - создать услугу + public function store(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'required|string', + 'durationminutes' => 'required|integer|min:1|max:500', + 'price' => 'required|numeric|min:0' + ]); + + $service = Services::create([ + 'name' => $request->name, + 'description' => $request->description, + 'durationminutes' => $request->durationminutes, + 'price' => $request->price, + 'isactive' => true // по умолчанию активна + ]); + + return response()->json($service, 201); + } + + // PUT api/admin/services/{id} - обновить услугу + public function update(Request $request, $id) + { + $service = Services::findOrFail($id); + + $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'required|string', + 'durationminutes' => 'required|integer|min:1|max:500', + 'price' => 'required|numeric|min:0' + ]); + + $service->update([ + 'name' => $request->name, + 'description' => $request->description, + 'durationminutes' => $request->durationminutes, + 'price' => $request->price, + ]); + + return response()->json($service); + } + + // DELETE api/admin/services/{id} - только если нет активных броней +public function destroy($id) +{ + $service = Services::findOrFail($id); + + // ПРОВЕРКА: нельзя удалить услугу с активными бронями + $activeBookings = \App\Models\Booking::where('service_id', $id) + ->where('status', '!=', 'cancelled') + ->exists(); + + if ($activeBookings) { + return response()->json([ + 'error' => 'Нельзя удалить услугу с активными бронями' + ], 400); + } + + $service->delete(); + return response()->json(['message' => 'Услуга удалена']); +} } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 0386e00..ad9096b 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -5,32 +5,30 @@ namespace App\Http\Controllers; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; -use SebastianBergmann\CodeCoverage\Report\Xml\Project; class UserController extends Controller { - public function create(Request $request) - { - $user = new User(); - $name = $request->get(key:'name'); - $password = Hash::make($request->get(key:'password')); - $email = $request->get(key: 'email'); + // /api/register - ТОЛЬКО клиенты (role = client) + public function register(Request $request) +{ + $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users', + 'password' => 'required|min:6' + ]); - $user->name = $name; - $user->email = $email; - $user->password = $password; - $user->save(); + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + 'role' => 'client' // ТОЛЬКО клиенты + ]); - dispatch(function() use ($user) { - $project= new Project(); - $project->title = 'default project'; - $project->description = 'test'; - $project->creator_user_id = $user->id; - $project->save(); - - }); - - return ['toker' => $user->createToken('frotend')]; - - } + $token = $user->createToken('client-token')->plainTextToken; + + return response()->json([ + 'user' => $user, + 'token' => $token + ], 201); +} } diff --git a/app/Http/Middleware/CheckRole.php b/app/Http/Middleware/CheckRole.php new file mode 100644 index 0000000..9ba5f45 --- /dev/null +++ b/app/Http/Middleware/CheckRole.php @@ -0,0 +1,16 @@ +check() || !auth()->user()->isEmployeeOrAdmin()) { + return response()->json(['error' => 'Доступ запрещен'], 403); + } + return $next($request); + } +} + diff --git a/app/Models/Booking.php b/app/Models/Booking.php new file mode 100644 index 0000000..91a3807 --- /dev/null +++ b/app/Models/Booking.php @@ -0,0 +1,23 @@ +hasMany(Booking::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 91135d7..3597e3f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,43 +2,27 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable +class User extends Authenticatable // все пользователи системы { - /** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, Notifiable; - /** - * The attributes that are mass assignable. - * - * @var list - */ protected $fillable = [ 'name', 'email', 'password', + 'role', ]; - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ protected $hidden = [ 'password', 'remember_token', ]; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ @@ -46,4 +30,16 @@ class User extends Authenticatable 'password' => 'hashed', ]; } + + // Проверяет админ или сотрудник + public function isEmployeeOrAdmin() + { + return $this->role == 'employee' || $this->role == 'admin'; + } + + // Для запросов - все сотрудники и админы + public static function scopeEmployeeOrAdmin($query) + { + return $query->whereIn('role', ['employee', 'admin']); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..c00c4ac 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,9 +11,11 @@ return Application::configure(basePath: dirname(__DIR__)) commands: __DIR__.'/../routes/console.php', health: '/up', ) - ->withMiddleware(function (Middleware $middleware): void { - // - }) + ->withMiddleware(function (Middleware $middleware) { + $middleware->alias([ + 'role:employee' => \App\Http\Middleware\CheckRole::class, + ]); +}) ->withExceptions(function (Exceptions $exceptions): void { // })->create(); diff --git a/database/migrations/2026_01_07_135000_create_employee_availabilities_table.php b/database/migrations/2026_01_07_135000_create_employee_availabilities_table.php new file mode 100644 index 0000000..888d26b --- /dev/null +++ b/database/migrations/2026_01_07_135000_create_employee_availabilities_table.php @@ -0,0 +1,24 @@ +id(); + $table->unsignedBigInteger('employee_id'); + $table->date('date'); + $table->time('starttime'); + $table->time('endtime'); + $table->boolean('isavailable')->default(true); + $table->timestamps(); // автоматическое создание created_at и updated_at + + $table->foreign('employee_id')->references('id')->on('users'); + }); + } + + public function down() { + Schema::dropIfExists('employee_availabilities'); + } +}; diff --git a/database/migrations/2026_01_07_140000_create_bookings_table.php b/database/migrations/2026_01_07_140000_create_bookings_table.php new file mode 100644 index 0000000..990fcdf --- /dev/null +++ b/database/migrations/2026_01_07_140000_create_bookings_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('bookingnumber'); + $table->unsignedBigInteger('client_id'); + $table->unsignedBigInteger('employee_id'); + $table->unsignedBigInteger('service_id'); + $table->date('bookingdate'); + $table->time('starttime'); + $table->time('endtime'); + $table->enum('status', ['confirmed', 'cancelled', 'completed'])->default('confirmed'); + $table->enum('cancelledby', ['client', 'admin'])->nullable(); + $table->text('cancelreason')->nullable(); + $table->timestamps(); + + // Внешние ключи + $table->foreign('client_id')->references('id')->on('users'); + $table->foreign('employee_id')->references('id')->on('users'); + $table->foreign('service_id')->references('id')->on('services'); + + // УНИКАЛЬНЫЙ ИНДЕКС + $table->unique(['employee_id', 'bookingdate', 'starttime'], 'unique_booking_slot'); + }); + } + + public function down() { + Schema::dropIfExists('bookings'); + } +}; diff --git a/routes/api.php b/routes/api.php index d6012f0..71af76a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,16 +1,45 @@ user(); })->middleware('auth:sanctum'); +// РЕГИСТРАЦИЯ ТОЛЬКО КЛИЕНТОВ (публичный) +Route::post('/register', [UserController::class, 'register']); + +// Существующие роуты categories Route::get('/categories', [CategoriesController::class, 'index'])->middleware('auth:sanctum'); Route::get('/categories/{id}', [CategoriesController::class, 'show']); -Route::post( '/categories', [CategoriesController::class, 'create']); -Route::post( '/users', [UserController::class, 'create']); \ No newline at end of file +Route::post('/categories', [CategoriesController::class, 'create']); + +// ПУБЛИЧНЫЙ API доступности (без авторизации) +Route::get('/availability', [AvailabilitiesController::class, 'publicAvailability']); + +// КЛИЕНТСКИЕ РОУТЫ БРОНИРОВАНИЙ (auth:sanctum) +Route::middleware('auth:sanctum')->group(function () { + Route::post('/bookings', [BookingsController::class, 'store']); // Пункт 9 + Route::post('/bookings/{id}/cancel', [BookingsController::class, 'cancel']); // Пункт 10 +}); + +// АДМИН РОУТЫ - ТОЛЬКО employee/admin (role:employee) +Route::middleware(['auth:sanctum', 'role:employee'])->prefix('admin')->group(function () { + // CRUD услуги + Route::get('/services', [ServicesController::class, 'index']); + Route::post('/services', [ServicesController::class, 'store']); + Route::put('/services/{id}', [ServicesController::class, 'update']); + Route::delete('/services/{id}', [ServicesController::class, 'destroy']); + + // CRUD расписание + Route::get('/availabilities', [AvailabilitiesController::class, 'index']); + Route::post('/availabilities', [AvailabilitiesController::class, 'store']); + Route::post('/availabilities/bulk', [AvailabilitiesController::class, 'bulkStore']); + Route::delete('/availabilities/{id}', [AvailabilitiesController::class, 'destroy']); +});