W kolejnym odcinku naszego cyklu poświęconego budowie aplikacji w Laravelu spróbujemy zaimplementować jedną z podstawowych funkcjonalności systemu – wypożyczenia. Zapraszam do lektury!
Prace jak zwykle rozpoczynamy od modyfikacji bazy, musimy stworzyć nową tabelę do przechowywania informacji o wypożyczeniach, tabela będzie zawierać:
- id wypożyczenia
- datę początku wypożyczenia
- datę zwrotu sprzętu
- dane wypożyczającego
- id pojazdu wypożyczanego
- cenę wypożyczenia
- status opłaty
- status wypożyczenia
W tym celu stwórzmy więc nową migrację:
1 |
php artisan make:migration create_rents_table |
I uzupełnijmy ją odpowiednimi polami:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
<?php use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; class CreateRentsTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('rents', function (Blueprint $table) { $table->increments('id'); $table->dateTime('start'); $table->dateTime('end'); $table->string('renter'); $table->integer('vehicle_id'); $table->float('price')->nullable(); $table->tinyInteger('payment_status')->default("0"); $table->tinyInteger('status')->default("1"); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('rents'); } } |
Mając migrację utwórzmy model:
app\models\rent
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Rent extends Model { /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'start', 'end', 'renter', 'vehicle_id','price','payment_status','status' ]; public function vehicle() { return $this->belongsTo('App\Models\Vehicle'); } } |
Analogocznie jak w przypadku innych schematów, do modelu tworzymy repozytorium:
app\repositories\RentRepository.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
<?php namespace App\Repositories; use App\Models\Rent; use Carbon\Carbon; /** * Class BanRepo. * * @package App\Repository */ class RentRepository extends BaseRepository { public function __construct(Rent $model) { $this->model = $model; } public function findDatesByVehicle($idVehicle) { $rents = $this->model->with('vehicle')->where('vehicle_id','=',$idVehicle)->get(); $seasonStart = Carbon::createFromDate(2017, 4, 1, 'Europe/Warsaw'); $seasonEnd = Carbon::createFromDate(2017, 11, 1, 'Europe/Warsaw'); /** * Iterujemy po dniach */ for($date = $seasonStart; $date->lte($seasonEnd); $date->addDay()) { $dates[$date->format('Y-m-d')] = $date->format('Y-m-d'); foreach($rents as $rent){ if($rent->status == 2) { $rentStartDate = Carbon::createFromFormat("Y-m-d H:i:s",$rent->start); $rentEndDate = Carbon::createFromFormat("Y-m-d H:i:s",$rent->end); if($rentStartDate->lte($date) && $rentEndDate->gte($date)) { $dates[$date->format('Y-m-d')] = array($date->format('Y-m-d'),$rent->toArray()); } } } } return $dates; } } |
W tym miejscu widzimy, iż nasze repozytorium posiada jedną metodę findDatesByVehicle(idVehicle). W metodzie tej kolejno:
- dla konkretnego ID pojazdu pobieramy wszystkie rekordy z tabeli wypożyczenia
- definiujemy początek i koniec sezonu wypożyczeń – od 1 kwietnia do 1 listopada, do obsługi dat używamy przy tym znakomitej klasy Carbon
- Następnie tworzymy pętle iterującą po wszystkich dniach sezonu, oznacza to, iż przejdziemy po kolei, dzień po dniu każdy dzień od kwietnia do listopada
- dla każdego z dni sezonu, sprawdzimy czy nie ma już zajętego sprzętu, musimy więc iterować po wypożyczeniach, czyli porównujemy każdy dzień z pobranych wypożyczeń z każdym dniem sezonu, jeśli dni się pokrywają, to będzie oznaczać iż sprzęt jest zajęty
- wynikiem działania funkcji będzie lista dat od 1 kwietnia do 1 listopada, w przypadku gdy w danym dniu nie będzie wypożyczenia lista będzie zawierała jedynie datę, np:
$dates[“2017-06-17”]=> “2017-06-17” - W przypadku gdy porównanie przyniesie informację, iż w danym dniu już istnieje wypożyczenie, oprócz daty system powinien zwrócić informacje także o samym wypożyczeniu, np:
$dates[“2017-06-17”]=> “2017-07-16”,
array(
“start” => “2017-07-16 00:00:00”,
“end” => “2017-07-18 23:59:59”,
“renter” => “Renter 1”,
“vehicle_id” => “1”,
“price” => “2000”,
“payment_status” => “1”,
“status” => “2”
) - oczywiście pobieramy tylko te rekordy z statusem = 2, co oznacza wypożyczenia zaakceptowane i potwierdzone przez przyszłego administratora systemu
W kolejnym kroku dane z repozytorium musimy umieścić w widoku, modyfikujemy nasz router, tak by obsługiwał pobieranie listy wypożyczeń (jako domyślny ustawiamy pojazd nr 1), dodatkowo od razu tworzymy routing do funkcji tworzącej nowe wypożyczenie.
routes/web.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
Route::get('/', function () { return view('welcome'); }); Auth::routes(); Route::get('/', 'HomeController@index')->name('home'); Auth::routes(); Route::get('/home', 'HomeController@index')->name('home'); Auth::routes(); Route::get('/home', 'HomeController@index')->name('home'); Auth::routes(); Route::get('/rents/{id?}', 'RentController@show', function ($id = null) { return $id; })->name('rents'); Route::post('/rents', 'RentController@create')->name('rents.create'); |
Zgodnie z zapisem w web.php tworzymy kontroler Rent:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Carbon\Carbon; use App\Repositories\RentRepository; use App\Requests\CreateRentRequest; class RentController extends Controller { /** * Create a new controller instance. * * @return void */ public function __construct() { //$this->middleware('auth'); } /** * Show the application rents. * * @return \Illuminate\Http\Response */ public function show(RentRepository $rent, $id = 1 ) { $dates = $rent->findDatesByVehicle($id); return view('rents', ['dates' => $dates, 'vehicle_id' => $id]); } /** * Create rent. * * @return \Illuminate\Http\Response */ public function create(RentRepository $rent, CreateRentRequest $request) { $arrayData = $request->filtered(); $rent->create($arrayData); $request->session()->flash('status', 'Złożono rezerwację!'); return redirect()->route('rents', ['id' => $request->input('vehicle_id')]); } } |
Kontroler nie robi nic odkrywczego, z repozytorium pobierana jest lista dat z wypożyczeniami i przekazywana do widoku rents. Oprócz tego przyda nam się również id pojazdu, więc ona również zostaje przekazana do widoku. Metoda create przyda nam się natomiast później.
Sam widok prezentuje się następująco:
layouts/rent.blade.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
@extends('layouts.app') @section('content') @isset($dates) <div class="container"> <div class="row"> <div class="col-md-8 col-md-offset-2"> <table class="vehicle-list display table-hover table"> <thead> <tr> <th></th> <th></th> <th></th> </tr> </thead> @foreach ($dates as $k => $v) <tr> @if (is_array($v)) <td> {{$v[0]}} </td> <td> </td> <td> Zarezerwowane </td> @else <td> {{$v}} </td> <td> <input type="checkbox" name="dates[]" class="rent-dates" value="{{$v}} "/> </td> <td> Zarezerwuj! </td> @endif </tr> @endforeach </table> </div> </div> </div> @endisset @endsection <button class="rent-button btn btn-primary btn-lg" style="display:none" data-toggle="modal" data-target="#myModal">BUtton</button> <!-- Modal --> <div class="modal fade" id="myModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel"> <div class="modal-dialog" role="document"> <div class="modal-content"> <form method="POST" action="{{ route('rents.create') }}"> <div class="modal-header"> <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> <h4 class="modal-title" id="myModalLabel">Modal title</h4> </div> <div class="modal-body"> <input type="hidden" name="_token" value="{{ csrf_token() }}"> <input type="hidden" name="vehicle_id" value="{{ $vehicle_id }}"> <input type="hidden" name="dates_input" class="dates_input"> Wybrane dni: <div class="rent-list"></div> <br/>Dane najmującego:<br/> <textarea name="renter" class="renter modal-body__renterTextArea"></textarea> </div> <div class="modal-footer"> <button type="button" class="btn btn-default" data-dismiss="modal">Anuluj</button> <button type="submit" class="btn btn-primary">Zarezerwuj</button> </div> </form> </div> </div> </div> |
Pierwsza część widoku to klasyczna tabela, w której to za pomocą pętli for umieszczamy pobrane dane w postaci wierszy. Każdy wiersz to przede wszystkim konkretna data, jako, że zajęte daty są tablicami, sprawdzamy czy możemy bezkarnie wypisać datę, czy także musimy dodać wpis informujący, że dany termin jest zajęty. Każdy wiersz dodatkowo kończy checkbox, który służy do zaznaczania terminu nowego wypożyczenia.
Tak stworzony kod powinien być już w pełni funkcjonalny, spróbujmy uruchomić naszą stronę, /rents/1
Lista wyświetlana jest poprawnie. Czas przyjrzeć się funkcjonalności dodawania nowego wypożyczenia, a czynność tą rozpoczniemy od analizy wyżej zaprezentowanego widoku. Mianowicie zawiera on dwa dodatkowe elementy, niewidoczne gołym okiem. Są to:
- przycisk ‘Zarezerwuj’, który to będzie pojawiał się w momencie gdy ktoś wybierze checkbox z konkretną datą
- okienko modal z formularzem, którego wysłanie będzie równało się złożeniu rezerwacji. Okno to będzie pojawiało się dopiero w momencie wyboru przycisku ‘Zarezerwuj’. Formularz zawiera aż 3 pola ukryte – jedno z tokenem Laravela, drugie z identyfikatorem pojazdu, który jest wynajmowany i wreszcie trzecie z datami w formie listy. Lista ta jest mało poręczna dla użytkownika, dlatego też ją ukrywamy. Oczywiście adresem docelowym formularza jest nasz routing wybrany z web.php
W tym miejscu możemy wrócić do naszego kontrolera Rent i przyjrzeć się metodzie create, za parametry przyjmuje ona obiekt repository, a także samo rządanie, które widzimy jest klasy CreateRentRequest. Musimy więc taką klasę stworzyć:
1 |
php artisan make:request createRentRequest |
Sama klasa definiuje przede wszystkim funkcję rules, w której podajemy wymagane pola. Co więcej, w klasie CreateRentRequest tworzymy również funkcję filtered. Funkcja ta zamienia listę dat, w których ma nastąpić wypożyczenie, na datę startu i datę końca wypożyczenia. Ponadto sprawdzamy, czy dni podane przez użytkownika tworzą ciąg – w naszym systemie jedna rezerwacja będzie mogła odnosić się do jednego zakresu dat, np: 17-07-2015 – 20-07-2017.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
<?php namespace App\Requests; use Illuminate\Foundation\Http\FormRequest; use Carbon\Carbon; class CreateRentRequest extends FormRequest { /** * Determine if the user is authorized to make this request. * * @return bool */ public function authorize() { return true; } /** * Get the validation rules that apply to the request. * * @return array */ public function rules() { return [ 'dates_input' => 'required', 'renter' => 'required', 'vehicle_id' => 'required' ]; } /** * Filter given input data. * * @return array */ public function filtered() { $data = $this->all(); $dates = explode(',',$data['dates_input']); $numberOfDays = count($dates); $data['start'] = $dates[0]; if($numberOfDays== 1){ $data['end'] = $dates[0] .' 23:59:59'; } else { $data['end'] = $dates[$numberOfDays-1]; } unset($data['dates_input']); $data['start'] = str_replace(' ', '', $data['start']); $data['end'] = str_replace(' ', '', $data['end']); $rentStartDate = Carbon::createFromFormat("Y-m-d",$data['start']); $rentEndDate = Carbon::createFromFormat("Y-m-d",$data['end']); if(($rentEndDate->diffInDays($rentStartDate)+1) == $numberOfDays) return $data; else return back()->with('error', 'Jedna rezerwacja musi zawierać ciąg dni!'); } } |
Ostatni element układanki to kod JavaScript. W pliku app.js umieszczamy kod poniżej:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
$(document).ready(function() { var rent = (function(){ var inputBoxes = $('.rent-dates'); var rentChoose = function(){ var dates = []; inputBoxesChecked = $('.rent-dates:checked'); count = $('.rent-dates:checked').length; if(count > 0) { inputBoxesChecked.each(function(){ dates.push($(this).val()); }); if(count == 1) { $('.rent-button').html("Rezerwuj "+ count + " dzień"); } else { $('.rent-button').html("Rezerwuj "+ count + " dni"); } $('.rent-button').show(); } else { $('.rent-button').hide(); } $('.rent-list').html(dates.toString()); $('.dates_input').val(dates.toString()); }; var bindFunctions = function() { inputBoxes.on("change", rentChoose); }; var init = function() { rentChoose(); bindFunctions(); }; return { init:init }; })(); rent.init(); }); |
Cóż on robi? Przede wszystkim implementuje wzorzec module w jQuery, a w nim:
- przy każdym wybraniu checkboxa z datą następuje dodanie wybranej daty do listy, jeśli lista zawiera wybrane dni następuje pokazanie przycisku ‘zarezerwuj’. Analogicznie gdy lista z datami będzie pusta, przycisk znika.
- daty przetrzymywane są w zmiennej dates
- inicjalizacja kodu to również wywołanie zmiany checkboxów – przez to przeładowanie strony powoduje wyświetlenie bądź zniknięcie przycisku zależnie od zapisanego stanu dat
- Lista dat zachowywana jest w ukrytym polu formularza z selektorem “.dates_input”
Uruchamiamy aplikację – wszystko powinno działać. Spróbujmy dodać nową rezerwację – wynik powinien zostać poprawnie zapisany w bazie.
Ostatnia sprawa – testy!
Przeanalizujmy co tak naprawdę chcemy przetestować:
- pobieranie listy wszystkich wypożyczeń
- tworzenie nowego wypożyczenia
- pobieranie dat wolnych i dat z zajętymi wypożyczeniami dla konkretnego pojazdu
Najpierw – klasa factory:
1 2 3 4 5 6 7 8 |
<?php use App\Models\Rent; use Faker\Generator; $factory->define(Rent::class, function (Generator $faker) { return []; }); |
Następnie tworzenie samego testu Rent Repository:
1 |
php artisan make:test Repositories/RentRepositoryTest --unit |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
<?php namespace Tests\Unit\Repositories; use Tests\TestCase; use App\Models\Rent; use App\Repositories\RentRepository; use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\DatabaseTransactions; class RentRepositoryTest extends TestCase { use DatabaseTransactions; /** * Get All Test. * * @return void */ public function testGetAll() { $data = $this->getCreateData(); $rentsAdded = array(); $rentsAdded[] = factory(Rent::class)->create($data[0]); $rentsAdded[] = factory(Rent::class)->create($data[1]); $rentsAdded[] = factory(Rent::class)->create($data[2]); $all = $this->getFilteredResponse(); $this->assertContains($data[0], $all); $this->assertContains($data[1], $all); $this->assertContains($data[2], $all); } /** * Create Test. * * @return void */ public function testCreate() { $data = $this->getCreateData(); $rentsAdded = array(); $rentsAdded[] = factory(Rent::class)->create($data[0]); $all = $this->getFilteredResponse(); $this->assertContains($data[0], $all); } /** * Get Filtered response for create and get all test. * * @return void */ private function getFilteredResponse(){ $vehicleRepository = \App::make(RentRepository::class); $all = app()->make(RentRepository::class)->getAll()->toArray(); foreach($all as &$single){ unset($single['created_at']); unset($single['updated_at']); unset($single['id']); } return $all; } /** * Get static data for create and get all test. * * @return void */ private function getCreateData(){ return [ [ "start" => "2017-07-16 00:00:00", "end" => "2017-07-18 23:59:59", "renter" => "Renter 1", "vehicle_id" => "1", "price" => "2000", "payment_status" => "1", "status" => "2" ], [ "start" => "2017-07-15 19:39:33", "end" => "2017-07-17 19:39:33", "renter" => "Renter 2", "vehicle_id" => "2", "price" => "15555", "payment_status" => "0", "status" => "1" ], [ "start" => "2017-06-12 19:39:33", "end" => "2017-06-13 19:39:33", "renter" => "Renter 3", "vehicle_id" => "1", "price" => "1234", "payment_status" => "1", "status" => "0" ] ]; } public function testFindDatesByVehicle() { $data = $this->getCreateData(); $rentsAdded = array(); $rentsAdded[] = factory(Rent::class)->create($data[0]); $all = $this->getFindDatesByVehicleFilteredResponse(); $data = $this->getShowData(); $this->assertEquals($data[0], $all["2017-07-16"]); $this->assertEquals($data[1], $all["2017-07-17"]); $this->assertEquals($data[2], $all["2017-07-18"]); } private function getFindDatesByVehicleFilteredResponse(){ $vehicleRepository = \App::make(RentRepository::class); $all = app()->make(RentRepository::class)->findDatesByVehicle(1); foreach($all as &$single){ if(isset($single[1]) && is_array($single[1])) { unset($single[1]['created_at']); unset($single[1]['updated_at']); unset($single[1]['id']); unset($single[1]['vehicle']); } } return $all; } /** * Get actual data for show test. * * @return void */ private function getShowData(){ return [ [ "2017-07-16", array( "start" => "2017-07-16 00:00:00", "end" => "2017-07-18 23:59:59", "renter" => "Renter 1", "vehicle_id" => "1", "price" => "2000", "payment_status" => "1", "status" => "2" ) ], [ "2017-07-17", array( "start" => "2017-07-16 00:00:00", "end" => "2017-07-18 23:59:59", "renter" => "Renter 1", "vehicle_id" => "1", "price" => "2000", "payment_status" => "1", "status" => "2" ) ], [ "2017-07-18", array( "start" => "2017-07-16 00:00:00", "end" => "2017-07-18 23:59:59", "renter" => "Renter 1", "vehicle_id" => "1", "price" => "2000", "payment_status" => "1", "status" => "2" ) ] ]; } } |
Klasa RentRepositoryTest składa się z 3 testów:
- testGetAll – pobieranie wszystkich wypożyczeń, w niej kolejno tworzymy 3 wypożyczenia (dane statyczne pobieramy za pomocą getCreateData()), następnie pobieramy odpowiedź serwera za pomocą getFilteredResponse(), w której to usuwamy metadane created_at, updated_at i id. Na koniec oczywiście porównujemy dodane dane, a także dane zwrócone przez aplikację. Oczywiście dodane dane muszą zawierać się wynikach pobranych z serwera.
- testCreate – tworzenie nowego wypożyczenia, podobnie jak w poprzednim przypadku dodajemy jedno z statycznych danych, a następnie sprawdzamy czy serwer zwraca dane, w których znajduje się nasz świeżo stworzony wpis
- testFindDatesByVehicle – najciekawszy test, który sprawdza, czy zwracane przez system daty faktycznie zawierają informacje czy dany dzień jest już zajęty (i w takiej sytuacji system powinien zwrócić informacje o wypożyczeniu), czy też jest wolny. Na początku, standardowo dodajemy nowe wypożyczenie testowe. Nastepnie za pomocą funkcji getFindDatesByVehicleFilteredResponse pobieramy faktyczną odpowiedź serwera, z odpowiedzi tej usuwamy meta dane created_at, updated_at i id. Usuwamy także informacje o pojeździe, dla ułatwienia testujemy jedynie wypożyczenie i dane osoby wypożyczającej. Faktyczną odpowiedź serwera porównujemy z danymi statycznymi
Druga porcja testów dotyczy kontrolera. Nasz kontroler pokazujący listę wypożyczeń powinien jednorazowo uruchomić funkcję findDatesByVehicle, a takżedo widoku przekazać listę dat (dates), a także id pojazdu (vehicle_id), całość prezentuje się następująco:
1 |
php artisan make:test Rent/ShowTest |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
<?php namespace Tests\Feature\Rent; use Tests\TestCase; use App\Models\Rent; use App\Repositories\RentRepository; use Illuminate\Foundation\Testing\WithoutMiddleware; use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\DatabaseTransactions; class ShowTest extends TestCase { /** * A basic test example. * * @return void */ public function testShowView(){ $mock = \Mockery::mock('App\Repositories\RentRepository'); $this->app->instance('App\Repositories\RentRepository', $mock); $mock->shouldReceive('findDatesByVehicle')->once(); $response = $this->call('GET', 'rents'); $response->assertViewHas('dates'); $response->assertViewHas('vehicle_id'); } } |
I tyle. Wywołanie testów powinno dać pozytywny rezultat:
Mamy już 6 testów i 13 porównań! 🙂 W tym momencie przerwiemy etap tworzenia interfejsu klienta, a skierujemy się do panelu administracyjnego. Ktoś w końcu musi zatwierdzać dodane wypożyczenia. Oczywiście nasz system kliencki można rozbudować – przydałyby się e-maile wysyłane do administratorów, a także klientów którzy złożyli zamówienie (forma potwierdzenia przyjęcia rezerwacji). Kolejny odcinek już niedługo!