diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..ec3e155b8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ "*" ] + pull_request: + branches: [ "main" ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Run unit tests + run: dotnet test --no-build --configuration Release --verbosity normal \ No newline at end of file diff --git a/CarRental.Api/CarRental.Api.csproj b/CarRental.Api/CarRental.Api.csproj new file mode 100644 index 000000000..6c96eaf1f --- /dev/null +++ b/CarRental.Api/CarRental.Api.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + True + + + + + + + + + + + + + + + diff --git a/CarRental.Api/Controllers/AnalyticsController.cs b/CarRental.Api/Controllers/AnalyticsController.cs new file mode 100644 index 000000000..894cebba2 --- /dev/null +++ b/CarRental.Api/Controllers/AnalyticsController.cs @@ -0,0 +1,126 @@ +using CarRental.Application.Contracts.Client; +using CarRental.Application.Contracts.Analytics; +using CarRental.Application.Contracts.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace CarRental.Api.Controllers; + +/// +/// Provides specialized API endpoints for data analytics and business reporting +/// +[ApiController] +[Route("api/[controller]")] +public class AnalyticsController(IAnalyticsService analyticsService, ILogger logger) : ControllerBase +{ + /// + /// Retrieves a list of clients who have rented cars associated with a specific model name + /// + /// The name of the car model to filter by + [HttpGet("clients-by-model")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetClientsByModel([FromQuery] string modelName) + { + logger.LogInformation("{method} method of {controller} is called with {string} parameter", nameof(GetClientsByModel), GetType().Name, modelName); + try + { + var result = await analyticsService.ReadClientsByModelName(modelName); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetClientsByModel), GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetClientsByModel), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Returns details of cars that are currently on lease at the specified date and time + /// + /// The point in time to check for active rentals + [HttpGet("cars-in-rent")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetCarsInRent([FromQuery] DateTime atTime) + { + logger.LogInformation("{method} method of {controller} is called with {parameterName} = {parameterValue}", nameof(GetCarsInRent), GetType().Name, nameof(atTime), atTime); + try + { + var result = await analyticsService.ReadCarsInRent(atTime); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetCarsInRent), GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetCarsInRent), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Returns the top 5 most popular cars based on total rental frequency + /// + [HttpGet("top-5-rented-cars")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetTop5Cars() + { + logger.LogInformation("{method} method of {controller} is called", nameof(GetTop5Cars), GetType().Name); + try + { + var result = await analyticsService.ReadTop5MostRentedCars(); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetTop5Cars), GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetTop5Cars), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Provides a comprehensive list of all cars and how many times each has been rented + /// + [HttpGet("all-cars-with-rental-count")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetAllCarsWithCount() + { + logger.LogInformation("{method} method of {controller} is called", nameof(GetAllCarsWithCount), GetType().Name); + try + { + var result = await analyticsService.ReadAllCarsWithRentalCount(); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetAllCarsWithCount), GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetAllCarsWithCount), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Returns the top 5 clients who have contributed the most to total revenue + /// + [HttpGet("top-5-clients-by-money")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetTop5Clients() + { + logger.LogInformation("{method} method of {controller} is called", nameof(GetTop5Clients), GetType().Name); + try + { + var result = await analyticsService.ReadTop5ClientsByTotalAmount(); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetTop5Clients), GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetTop5Clients), GetType().Name); + return StatusCode(500); + } + } +} \ No newline at end of file diff --git a/CarRental.Api/Controllers/CarControllers.cs b/CarRental.Api/Controllers/CarControllers.cs new file mode 100644 index 000000000..e48fc0769 --- /dev/null +++ b/CarRental.Api/Controllers/CarControllers.cs @@ -0,0 +1,135 @@ +using CarRental.Application.Contracts.Car; +using CarRental.Application.Contracts.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace CarRental.Api.Controllers; + +/// +/// API controller for managing the car fleet (CRUD operations) +/// +[ApiController] +[Route("api/[controller]")] +public class CarController(IApplicationService carService, ILogger logger) : ControllerBase +{ + /// + /// Retrieves a list of all cars available in the system + /// + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetAll() + { + logger.LogInformation("{method} method of {controller} is called", nameof(GetAll), GetType().Name); + try + { + var cars = await carService.ReadAll(); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetAll), GetType().Name); + return Ok(cars); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetAll), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Retrieves details of a specific car by its identifier + /// + /// The unique identifier of the car + [HttpGet("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + [ProducesResponseType(500)] + public async Task> Get(Guid id) + { + logger.LogInformation("{method} method of {controller} is called with {id} parameter", nameof(Get), GetType().Name, id); + try + { + var car = await carService.Read(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Get), GetType().Name); + return Ok(car); + } + catch (KeyNotFoundException ex) + { + logger.LogWarning(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Registers a new car in the fleet + /// + /// The data for the new car record + [HttpPost] + [ProducesResponseType(201)] + [ProducesResponseType(500)] + public async Task> Create([FromBody] CarCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {dto} parameter", nameof(Create), GetType().Name, dto); + try + { + var createdCar = await carService.Create(dto); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Create), GetType().Name); + return CreatedAtAction(nameof(this.Create), createdCar); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Create), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Updates an existing car's information + /// + /// The unique identifier of the car to update + /// The updated data + [HttpPut("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task> Update(Guid id, [FromBody] CarCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {key},{dto} parameters", nameof(Update), GetType().Name, id, dto); + try + { + var updatedCar = await carService.Update(dto, id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Update), GetType().Name); + return Ok(updatedCar); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Update), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Removes a car from the system + /// + /// The unique identifier of the car to delete + [HttpDelete("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(204)] + [ProducesResponseType(500)] + public async Task Delete(Guid id) + { + logger.LogInformation("{method} method of {controller} is called with {id} parameter", nameof(Delete), GetType().Name, id); + try + { + var result = await carService.Delete(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Delete), GetType().Name); + return result ? Ok() : NoContent(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Delete), GetType().Name); + return StatusCode(500); + } + } +} \ No newline at end of file diff --git a/CarRental.Api/Controllers/CarModelController.cs b/CarRental.Api/Controllers/CarModelController.cs new file mode 100644 index 000000000..e82727799 --- /dev/null +++ b/CarRental.Api/Controllers/CarModelController.cs @@ -0,0 +1,142 @@ +using CarRental.Application.Contracts.CarModel; +using CarRental.Application.Contracts.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace CarRental.Api.Controllers; + +/// +/// API Controller for managing car models +/// +/// The application service for car model operations +/// The logger instance for diagnostics and activity tracking +[ApiController] +[Route("api/[controller]")] +public class CarModelController(IApplicationService service, ILogger logger) : ControllerBase +{ + /// + /// Retrieves a list of all car models + /// + /// A collection of car model DTOs + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetAll() + { + logger.LogInformation("{method} method of {controller} is called", nameof(GetAll), GetType().Name); + try + { + var carModel = await service.ReadAll(); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetAll), GetType().Name); + return Ok(carModel); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetAll), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Retrieves a specific car model by its unique identifier + /// + /// The GUID of the car model + /// The requested car model DTO + [HttpGet("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + [ProducesResponseType(500)] + public async Task> Get(Guid id) + { + logger.LogInformation("{method} method of {controller} is called", nameof(Get), GetType().Name); + try + { + var result = await service.Read(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Get), GetType().Name); + return Ok(result); + } + catch (KeyNotFoundException ex) + { + logger.LogWarning(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Creates a new car model entry + /// + /// The data for the new car model + /// The created car model DTO + [HttpPost] + [ProducesResponseType(201)] + [ProducesResponseType(500)] + public async Task> Create(CarModelCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {dto} parameter", nameof(Create), GetType().Name, dto); + try + { + var result = await service.Create(dto); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Create), GetType().Name); + return CreatedAtAction(nameof(this.Create), result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Create), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Updates an existing car model + /// + /// The GUID of the car model to update + /// The updated information + /// The result of the update operation + [HttpPut("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task Update(Guid id, CarModelCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {key},{dto} parameters", nameof(Update), GetType().Name, id, dto); + try + { + var result = await service.Update(dto, id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Update), GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Update), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Deletes a car model from the system + /// + /// The GUID of the car model to remove + /// An OK result if deleted, or NoContent if not found + [HttpDelete("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(204)] + [ProducesResponseType(500)] + public async Task Delete(Guid id) + { + logger.LogInformation("{method} method of {controller} is called with {id} parameter", nameof(Delete), GetType().Name, id); + try + { + var result = await service.Delete(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Delete), GetType().Name); + return result ? Ok() : NoContent(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Delete), GetType().Name); + return StatusCode(500); + } + } +} \ No newline at end of file diff --git a/CarRental.Api/Controllers/CarModelGenerationController.cs b/CarRental.Api/Controllers/CarModelGenerationController.cs new file mode 100644 index 000000000..3a8668890 --- /dev/null +++ b/CarRental.Api/Controllers/CarModelGenerationController.cs @@ -0,0 +1,142 @@ +using CarRental.Application.Contracts.CarModelGeneration; +using CarRental.Application.Contracts.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace CarRental.Api.Controllers; + +/// +/// API Controller for managing car model generations +/// +/// The application service for car model generation logic +/// The logger instance for diagnostics +[ApiController] +[Route("api/[controller]")] +public class CarModelGenerationsController(IApplicationService service, ILogger logger) : ControllerBase +{ + /// + /// Retrieves all car model generations + /// + /// A list of car model generation DTOs + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetAll() + { + logger.LogInformation("{method} method of {controller} is called", nameof(GetAll), GetType().Name); + try + { + var carModel = await service.ReadAll(); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetAll), GetType().Name); + return Ok(carModel); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetAll), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Retrieves a specific car model generation by its identifier + /// + /// The unique identifier of the car model generation + /// The requested car model generation DTO + [HttpGet("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + [ProducesResponseType(500)] + public async Task> Get(Guid id) + { + logger.LogInformation("{method} method of {controller} is called", nameof(Get), GetType().Name); + try + { + var result = await service.Read(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Get), GetType().Name); + return Ok(result); + } + catch (KeyNotFoundException ex) + { + logger.LogWarning(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Creates a new car model generation + /// + /// The data transfer object containing car model generation details + /// The created car model generation DTO + [HttpPost] + [ProducesResponseType(201)] + [ProducesResponseType(500)] + public async Task> Create(CarModelGenerationCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {dto} parameter", nameof(Create), GetType().Name, dto); + try + { + var result = await service.Create(dto); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Create), GetType().Name); + return CreatedAtAction(nameof(this.Create), result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Create), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Updates an existing car model generation + /// + /// The unique identifier of the generation to update + /// The updated data for the car model generation + /// An IActionResult indicating the result of the operation + [HttpPut("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task Update(Guid id, CarModelGenerationCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {key},{dto} parameters", nameof(Update), GetType().Name, id, dto); + try + { + var result = await service.Update(dto, id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Update), GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Update), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Deletes a car model generation by its identifier + /// + /// The unique identifier of the generation to delete + /// An IActionResult indicating the result of the deletion + [HttpDelete("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(204)] + [ProducesResponseType(500)] + public async Task Delete(Guid id) + { + logger.LogInformation("{method} method of {controller} is called with {id} parameter", nameof(Delete), GetType().Name, id); + try + { + var result = await service.Delete(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Delete), GetType().Name); + return result ? Ok() : NoContent(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Delete), GetType().Name); + return StatusCode(500); + } + } +} \ No newline at end of file diff --git a/CarRental.Api/Controllers/ClientController.cs b/CarRental.Api/Controllers/ClientController.cs new file mode 100644 index 000000000..f78a440a7 --- /dev/null +++ b/CarRental.Api/Controllers/ClientController.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Mvc; +using CarRental.Application.Contracts.Interfaces; +using CarRental.Application.Contracts.Client; + +namespace CarRental.Api.Controllers; + +/// +/// API controller for managing client records and personal data (CRUD operations) +/// +[ApiController] +[Route("api/[controller]")] +public class ClientController(IApplicationService clientService, ILogger logger) : ControllerBase +{ + /// + /// Retrieves a list of all registered clients + /// + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetAll() + { + logger.LogInformation("{method} method of {controller} is called", nameof(GetAll), GetType().Name); + try + { + var result = await clientService.ReadAll(); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetAll), GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetAll), GetType().Name); + return StatusCode(500, $"{ex.Message}\n\r{ex.InnerException?.Message}"); + } + } + + /// + /// Retrieves a specific client by their unique identifier + /// + /// The unique identifier of the client + [HttpGet("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + [ProducesResponseType(500)] + public async Task> Get(Guid id) + { + logger.LogInformation("{method} method of {controller} is called with {id} parameter", nameof(Get), GetType().Name, id); + try + { + var client = await clientService.Read(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Get), GetType().Name); + return Ok(client); + } + catch (KeyNotFoundException ex) + { + logger.LogWarning(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Registers a new client and returns the created record + /// + /// The client information to create + [HttpPost] + [ProducesResponseType(201)] + [ProducesResponseType(500)] + public async Task> Create(ClientCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {dto} parameter", nameof(Create), GetType().Name, dto); + try + { + var createdClient = await clientService.Create(dto); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Create), GetType().Name); + return CreatedAtAction(nameof(this.Create), createdClient); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Create), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Updates an existing client's information + /// + /// The ID of the client to update + /// The updated client data + [HttpPut("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task Update(Guid id, ClientCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {key},{dto} parameters", nameof(Update), GetType().Name, id, dto); + try + { + var updatedClient = await clientService.Update(dto, id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Update), GetType().Name); + return Ok(updatedClient); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Update), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Removes a client from the system by their ID + /// + /// The unique identifier of the client to delete + [HttpDelete("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(204)] + [ProducesResponseType(500)] + public async Task Delete(Guid id) + { + logger.LogInformation("{method} method of {controller} is called with {id} parameter", nameof(Delete), GetType().Name, id); + try + { + var result = await clientService.Delete(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Delete), GetType().Name); + return result ? Ok() : NoContent(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Delete), GetType().Name); + return StatusCode(500); + } + } +} \ No newline at end of file diff --git a/CarRental.Api/Controllers/RentController.cs b/CarRental.Api/Controllers/RentController.cs new file mode 100644 index 000000000..13767876d --- /dev/null +++ b/CarRental.Api/Controllers/RentController.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Mvc; +using CarRental.Application.Contracts.Interfaces; +using CarRental.Application.Contracts.Rent; + +namespace CarRental.Api.Controllers; + +/// +/// API controller for managing car rental agreements and lease transactions +/// +[ApiController] +[Route("api/[controller]")] +public class RentController(IApplicationService rentService, ILogger logger) : ControllerBase +{ + /// + /// Retrieves a list of all rental records, including calculated costs and linked entity names + /// + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> GetAll() + { + logger.LogInformation("{method} method of {controller} is called", nameof(GetAll), GetType().Name); + try + { + var result = await rentService.ReadAll(); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(GetAll), GetType().Name); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(GetAll), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Retrieves a specific rental agreement by its identifier. + /// + /// The unique identifier of the rental record. + [HttpGet("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + [ProducesResponseType(500)] + public async Task> Get(Guid id) + { + logger.LogInformation("{method} method of {controller} is called with {id} parameter", nameof(Get), GetType().Name, id); + try + { + var rent = await rentService.Read(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Get), GetType().Name); + return Ok(rent); + } + catch (KeyNotFoundException ex) + { + logger.LogWarning(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return NotFound(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Get), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Creates a new rental agreement after verifying the existence of the client and car. + /// + /// The rental details, including CarId and ClientId. + [HttpPost] + [ProducesResponseType(201)] + [ProducesResponseType(500)] + public async Task> Create(RentCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {dto} parameter", nameof(Create), GetType().Name, dto); + try + { + var createdRent = await rentService.Create(dto); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Create), GetType().Name); + return CreatedAtAction(nameof(this.Create), createdRent); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Create), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Updates the details of an existing rental agreement. + /// + /// The ID of the rental to update. + /// The updated rental data. + [HttpPut("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task Update(Guid id, RentCreateUpdateDto dto) + { + logger.LogInformation("{method} method of {controller} is called with {key},{dto} parameters", nameof(Update), GetType().Name, id, dto); + try + { + var updatedRent = await rentService.Update(dto, id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Update), GetType().Name); + return Ok(updatedRent); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Update), GetType().Name); + return StatusCode(500); + } + } + + /// + /// Deletes a rental record from the system. + /// + /// The unique identifier of the rental to remove. + [HttpDelete("{id}")] + [ProducesResponseType(200)] + [ProducesResponseType(204)] + [ProducesResponseType(500)] + public async Task Delete(Guid id) + { + logger.LogInformation("{method} method of {controller} is called with {id} parameter", nameof(Delete), GetType().Name, id); + try + { + var result = await rentService.Delete(id); + logger.LogInformation("{method} method of {controller} executed successfully", nameof(Delete), GetType().Name); + return result ? Ok() : NoContent(); + } + catch (Exception ex) + { + logger.LogError(ex, "An exception happened during {method} method of {controller}", nameof(Delete), GetType().Name); + return StatusCode(500); + } + } +} \ No newline at end of file diff --git a/CarRental.Api/Program.cs b/CarRental.Api/Program.cs new file mode 100644 index 000000000..d34aa4d3a --- /dev/null +++ b/CarRental.Api/Program.cs @@ -0,0 +1,134 @@ +using CarRental.Api; +using CarRental.Application.Contracts.Car; +using CarRental.Application.Contracts.CarModel; +using CarRental.Application.Contracts.CarModelGeneration; +using CarRental.Application.Contracts.Client; +using CarRental.Application.Contracts.Interfaces; +using CarRental.Application.Contracts.Rent; +using CarRental.Application.Services; +using CarRental.Domain.DataModels; +using CarRental.Domain.DataSeed; +using CarRental.Domain.Interfaces; +using CarRental.Domain.InternalData.ComponentClasses; +using CarRental.Infrastructure; +using CarRental.Infrastructure.Repository; +using CarRental.ServiceDefaults; +using Mapster; +using MapsterMapper; +using Microsoft.EntityFrameworkCore; +using MongoDB.Driver; +using System.Reflection; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddMongoDBClient("CarRentalDb"); + +builder.Services.AddDbContext((serviceProvider, options) => +{ + var db = serviceProvider.GetRequiredService(); + options.UseMongoDB(db.Client, db.DatabaseNamespace.DatabaseName); +}); + +builder.Services.AddSingleton(sp => +{ + var client = sp.GetRequiredService(); + return client.GetDatabase("car-rental"); +}); + +var typeAdapterConfig = TypeAdapterConfig.GlobalSettings; +typeAdapterConfig.Scan(Assembly.GetExecutingAssembly()); +builder.Services.AddSingleton(typeAdapterConfig); +builder.Services.AddScoped(); + +builder.Services.AddSingleton(); + +builder.Services.AddScoped, DbCarModelRepository>(); +builder.Services.AddScoped, DbCarModelGenerationRepository>(); +builder.Services.AddScoped, DbCarRepository>(); +builder.Services.AddScoped, DbClientRepository>(); +builder.Services.AddScoped, DbRentRepository>(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped, CarService>(); +builder.Services.AddScoped, ClientService>(); +builder.Services.AddScoped, RentService>(); +builder.Services.AddScoped, CarModelService>(); +builder.Services.AddScoped, CarModelGenerationService>(); + +builder.Services.AddScoped(); + +builder.AddGeneratorService(); + +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles; + }); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + var assemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.GetName().Name!.StartsWith("CarRental")) + .Distinct(); + + foreach (var assembly in assemblies) + { + var xmlFile = $"{assembly.GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + c.IncludeXmlComments(xmlPath); + } +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + try + { + var context = services.GetRequiredService(); + var dataseed = services.GetRequiredService(); + + if (await context.CarModels.AnyAsync()) return; + + await context.CarModels.AddRangeAsync(dataseed.Models); + await context.SaveChangesAsync(); + + await context.ModelGenerations.AddRangeAsync(dataseed.Generations); + await context.SaveChangesAsync(); + + await context.Cars.AddRangeAsync(dataseed.Cars); + await context.SaveChangesAsync(); + + await context.Clients.AddRangeAsync(dataseed.Clients); + await context.SaveChangesAsync(); + + await context.Rents.AddRangeAsync(dataseed.Rents); + await context.SaveChangesAsync(); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred while seeding the database."); + } +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/CarRental.Api/Properties/launchSettings.json b/CarRental.Api/Properties/launchSettings.json new file mode 100644 index 000000000..6e8b0f447 --- /dev/null +++ b/CarRental.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:39741", + "sslPort": 44397 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5175", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7277;http://localhost:5175", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CarRental.Api/WebApplicationBuilderExtensions.cs b/CarRental.Api/WebApplicationBuilderExtensions.cs new file mode 100644 index 000000000..3e4987e76 --- /dev/null +++ b/CarRental.Api/WebApplicationBuilderExtensions.cs @@ -0,0 +1,32 @@ +using CarRental.Infrastructure.Kafka; +using Confluent.Kafka; + +namespace CarRental.Api; + +/// +/// Extension methods for registering generator service client in DI container +/// +internal static class WebApplicationBuilderExtensions +{ + /// + /// Registers Kafka consumer client for interacting with the data generator service + /// + /// Web application builder + /// Web application builder with registered Kafka services + public static WebApplicationBuilder AddGeneratorService(this WebApplicationBuilder builder) + { + builder.Services.AddHostedService(); + + builder.AddKafkaConsumer( + "car-rental-kafka", + configureSettings: settings => + { + settings.Config.GroupId = "car-rental-consumer-group"; + settings.Config.AutoOffsetReset = Confluent.Kafka.AutoOffsetReset.Earliest; + settings.Config.EnableAutoCommit = false; + } + ); + + return builder; + } +} \ No newline at end of file diff --git a/CarRental.Api/appsettings.Development.json b/CarRental.Api/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/CarRental.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CarRental.Api/appsettings.json b/CarRental.Api/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/CarRental.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/CarRental.AppHost/CarRental.AppHost.csproj b/CarRental.AppHost/CarRental.AppHost.csproj new file mode 100644 index 000000000..1ab807259 --- /dev/null +++ b/CarRental.AppHost/CarRental.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + Exe + net8.0 + enable + enable + true + 54701a95-76ef-4922-a1a8-8f3a20203073 + + + + + + + + + + + + + + diff --git a/CarRental.AppHost/Program.cs b/CarRental.AppHost/Program.cs new file mode 100644 index 000000000..21691b555 --- /dev/null +++ b/CarRental.AppHost/Program.cs @@ -0,0 +1,20 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var mongodb = builder.AddMongoDB("mongodb"); +mongodb.AddDatabase("car-rental"); + +var kafka = builder.AddKafka("car-rental-kafka") + .WithKafkaUI() + .WithEnvironment("KAFKA_AUTO_CREATE_TOPICS_ENABLE", "true"); + +builder.AddProject("carrental-api") + .WithReference(mongodb, "CarRentalDb") + .WithReference(kafka) + .WaitFor(mongodb) + .WaitFor(kafka); + +builder.AddProject("carrental-generator") + .WithReference(kafka) + .WaitFor(kafka); + +builder.Build().Run(); \ No newline at end of file diff --git a/CarRental.AppHost/Properties/launchSettings.json b/CarRental.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..3f58aa95b --- /dev/null +++ b/CarRental.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17106;http://localhost:15095", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21035", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22055" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15095", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19259", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20117" + } + } + } +} diff --git a/CarRental.AppHost/appsettings.Development.json b/CarRental.AppHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/CarRental.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CarRental.AppHost/appsettings.json b/CarRental.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/CarRental.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/CarRental.Application.Contracts/Analytics/CarInRentDto.cs b/CarRental.Application.Contracts/Analytics/CarInRentDto.cs new file mode 100644 index 000000000..d3233a040 --- /dev/null +++ b/CarRental.Application.Contracts/Analytics/CarInRentDto.cs @@ -0,0 +1,11 @@ +namespace CarRental.Application.Contracts.Analytics; + +/// +/// Data transfer object representing a car that is currently or was previously in an active rental state. +/// +/// The unique identifier of the car. +/// The descriptive name of the car model. +/// The vehicle's license plate number. +/// The exact start time of the rental period. +/// The length of the rental in hours. +public record CarInRentDto(Guid CarId, string ModelName, string NumberPlate, DateTime RentStartDate, int DurationHours); \ No newline at end of file diff --git a/CarRental.Application.Contracts/Analytics/CarWithRentalCountDto.cs b/CarRental.Application.Contracts/Analytics/CarWithRentalCountDto.cs new file mode 100644 index 000000000..a0066bda1 --- /dev/null +++ b/CarRental.Application.Contracts/Analytics/CarWithRentalCountDto.cs @@ -0,0 +1,10 @@ +namespace CarRental.Application.Contracts.Analytics; + +/// +/// Data transfer object for car statistics, including the total number of times it was rented. +/// +/// The unique identifier of the car. +/// The descriptive name of the car model. +/// The vehicle's license plate number. +/// Total number of rental agreements associated with this car. +public record CarWithRentalCountDto(Guid Id, string ModelName, string NumberPlate, int RentalCount); \ No newline at end of file diff --git a/CarRental.Application.Contracts/Analytics/ClientWithTotalAmountDto.cs b/CarRental.Application.Contracts/Analytics/ClientWithTotalAmountDto.cs new file mode 100644 index 000000000..eac012461 --- /dev/null +++ b/CarRental.Application.Contracts/Analytics/ClientWithTotalAmountDto.cs @@ -0,0 +1,12 @@ +namespace CarRental.Application.Contracts.Analytics; + +/// +/// Data transfer object for client financial statistics. +/// +/// The unique identifier of the client. +/// The first name of the client. +/// The last name of the client. +/// The patronymic of the client. +/// The sum of all rental costs paid by the client. +/// Total number of times the client has rented vehicles. +public record ClientWithTotalAmountDto(Guid Id, string FirstName, string LastName, string? Patronymic, decimal TotalSpentAmount, int TotalRentsCount); \ No newline at end of file diff --git a/CarRental.Application.Contracts/Car/CarCreateUpdateDto.cs b/CarRental.Application.Contracts/Car/CarCreateUpdateDto.cs new file mode 100644 index 000000000..53eb87b56 --- /dev/null +++ b/CarRental.Application.Contracts/Car/CarCreateUpdateDto.cs @@ -0,0 +1,9 @@ +namespace CarRental.Application.Contracts.Car; + +/// +/// Data transfer object for creating or updating a car record. +/// +/// The vehicle's license plate number. +/// The color of the car. +/// The unique identifier of the associated car model generation. +public record CarCreateUpdateDto(string NumberPlate, string Colour, Guid ModelGenerationId); \ No newline at end of file diff --git a/CarRental.Application.Contracts/Car/CarDto.cs b/CarRental.Application.Contracts/Car/CarDto.cs new file mode 100644 index 000000000..768f20b62 --- /dev/null +++ b/CarRental.Application.Contracts/Car/CarDto.cs @@ -0,0 +1,10 @@ +namespace CarRental.Application.Contracts.Car; + +/// +/// Data transfer object representing a car with its basic details for display. +/// +/// The unique identifier of the car. +/// The vehicle's license plate number. +/// The color of the car. +/// ID of the model generation. +public record CarDto(Guid Id, string NumberPlate, string Colour, Guid ModelGenerationId); \ No newline at end of file diff --git a/CarRental.Application.Contracts/CarModel/CarModelCreateUpdateDto.cs b/CarRental.Application.Contracts/CarModel/CarModelCreateUpdateDto.cs new file mode 100644 index 000000000..2c7a11c1d --- /dev/null +++ b/CarRental.Application.Contracts/CarModel/CarModelCreateUpdateDto.cs @@ -0,0 +1,11 @@ +namespace CarRental.Application.Contracts.CarModel; + +/// +/// Data transfer object for creating or updating a car model definition. +/// +/// The brand or specific model name. +/// The type of drivetrain (e.g., AWD). +/// The total passenger capacity. +/// The style of the vehicle body (e.g., Sedan, SUV). +/// The market segment or luxury class of the vehicle. +public record CarModelCreateUpdateDto(string Name, string? DriveType, int SeatsNumber, string BodyType, string? ClassType); \ No newline at end of file diff --git a/CarRental.Application.Contracts/CarModel/CarModelDto.cs b/CarRental.Application.Contracts/CarModel/CarModelDto.cs new file mode 100644 index 000000000..affb96206 --- /dev/null +++ b/CarRental.Application.Contracts/CarModel/CarModelDto.cs @@ -0,0 +1,12 @@ +namespace CarRental.Application.Contracts.CarModel; + +/// +/// Data transfer object representing a car model with its technical specifications. +/// +/// The unique identifier of the car model. +/// The brand or specific model name. +/// The type of drivetrain (e.g., AWD, FWD, RWD). +/// The total passenger capacity. +/// The style of the vehicle body (e.g., Sedan, SUV). +/// The market segment or luxury class of the vehicle. +public record CarModelDto(Guid Id, string Name, string? DriveType, int SeatsNumber, string BodyType, string? ClassType); \ No newline at end of file diff --git a/CarRental.Application.Contracts/CarModelGeneration/CarModelGenerationCreateUpdateDto.cs b/CarRental.Application.Contracts/CarModelGeneration/CarModelGenerationCreateUpdateDto.cs new file mode 100644 index 000000000..b08d19333 --- /dev/null +++ b/CarRental.Application.Contracts/CarModelGeneration/CarModelGenerationCreateUpdateDto.cs @@ -0,0 +1,10 @@ +namespace CarRental.Application.Contracts.CarModelGeneration; + +/// +/// Data transfer object for creating or updating a specific car model generation. +/// +/// The manufacturing year of the generation. +/// The type of transmission (e.g., Manual, Automatic). +/// The rental cost per hour for this generation. +/// The unique identifier of the parent car model. +public record CarModelGenerationCreateUpdateDto(int Year, string? TransmissionType, decimal HourCost, Guid ModelId); \ No newline at end of file diff --git a/CarRental.Application.Contracts/CarModelGeneration/CarModelGenerationDto.cs b/CarRental.Application.Contracts/CarModelGeneration/CarModelGenerationDto.cs new file mode 100644 index 000000000..9c04372f9 --- /dev/null +++ b/CarRental.Application.Contracts/CarModelGeneration/CarModelGenerationDto.cs @@ -0,0 +1,11 @@ +namespace CarRental.Application.Contracts.CarModelGeneration; + +/// +/// Data transfer object representing a specific car model generation with pricing and details. +/// +/// The unique identifier of the car model generation. +/// The manufacturing year of the generation. +/// The type of transmission used in this generation. +/// The rental cost per hour. +/// The identifier of the parent car model. +public record CarModelGenerationDto(Guid Id, int Year, string? TransmissionType, decimal HourCost, Guid ModelId); \ No newline at end of file diff --git a/CarRental.Application.Contracts/CarRental.Application.Contracts.csproj b/CarRental.Application.Contracts/CarRental.Application.Contracts.csproj new file mode 100644 index 000000000..fa71b7ae6 --- /dev/null +++ b/CarRental.Application.Contracts/CarRental.Application.Contracts.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/CarRental.Application.Contracts/Client/ClientCreateUpdateDto.cs b/CarRental.Application.Contracts/Client/ClientCreateUpdateDto.cs new file mode 100644 index 000000000..00be2c7d0 --- /dev/null +++ b/CarRental.Application.Contracts/Client/ClientCreateUpdateDto.cs @@ -0,0 +1,12 @@ +namespace CarRental.Application.Contracts.Client; + +/// +/// Data transfer object for creating or updating client information. +/// +/// The client's given name. +/// The client's family name. +/// The client's patronymic. +/// The client's contact phone number. +/// The unique identifier of the client's driving license. +/// The client's date of birth. +public record ClientCreateUpdateDto(string FirstName, string LastName, string? Patronymic, string PhoneNumber, string DriverLicenseId, DateOnly? BirthDate); \ No newline at end of file diff --git a/CarRental.Application.Contracts/Client/ClientDto.cs b/CarRental.Application.Contracts/Client/ClientDto.cs new file mode 100644 index 000000000..1dfa33235 --- /dev/null +++ b/CarRental.Application.Contracts/Client/ClientDto.cs @@ -0,0 +1,12 @@ +namespace CarRental.Application.Contracts.Client; + +/// +/// Data transfer object representing client details for display and identification. +/// +/// The unique identifier of the client record. +/// The identification number of the client's driver license. +/// The client's family name. +/// The client's first name. +/// The client's middle name (optional). +/// The client's date of birth (optional). +public record ClientDto(Guid Id, string DriverLicenseId, string LastName, string FirstName, string? Patronymic, DateOnly? BirthDate); \ No newline at end of file diff --git a/CarRental.Application.Contracts/Generator/GenerateRentalsRequest.cs b/CarRental.Application.Contracts/Generator/GenerateRentalsRequest.cs new file mode 100644 index 000000000..9e6104473 --- /dev/null +++ b/CarRental.Application.Contracts/Generator/GenerateRentalsRequest.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRental.Application.Contracts.Generator; + +/// +/// Request model for generating rental contracts +/// +public class GenerateRentalsRequest +{ + /// + /// Total number of rentals to generate + /// + [Required] + [Range(1, 1000, ErrorMessage = "TotalCount must be between 1 and 1000.")] + public int TotalCount { get; set; } + + /// + /// Number of rentals per batch + /// + [Required] + [Range(1, 100, ErrorMessage = "BatchSize must be between 1 and 100.")] + public int BatchSize { get; set; } + + /// + /// Delay between batches in milliseconds + /// + [Required] + [Range(100, 30000, ErrorMessage = "DelayMs must be between 100 and 30000.")] + public int DelayMs { get; set; } +} \ No newline at end of file diff --git a/CarRental.Application.Contracts/Generator/GenerateRentalsResponse.cs b/CarRental.Application.Contracts/Generator/GenerateRentalsResponse.cs new file mode 100644 index 000000000..dc51d7042 --- /dev/null +++ b/CarRental.Application.Contracts/Generator/GenerateRentalsResponse.cs @@ -0,0 +1,37 @@ +namespace CarRental.Application.Contracts.Generator; + +/// +/// Response model for rental generation operation +/// +public class GenerateRentalsResponse +{ + /// + /// Total number of items requested + /// + public int TotalRequested { get; set; } + + /// + /// Total number of items successfully sent + /// + public int TotalSent { get; set; } + + /// + /// Size of each batch + /// + public int BatchSize { get; set; } + + /// + /// Delay between batches in milliseconds + /// + public int DelayMs { get; set; } + + /// + /// Number of batches sent + /// + public int Batches { get; set; } + + /// + /// Whether the operation was canceled + /// + public bool Canceled { get; set; } +} \ No newline at end of file diff --git a/CarRental.Application.Contracts/Interfaces/IAnalyticsService.cs b/CarRental.Application.Contracts/Interfaces/IAnalyticsService.cs new file mode 100644 index 000000000..0ce7e6dd4 --- /dev/null +++ b/CarRental.Application.Contracts/Interfaces/IAnalyticsService.cs @@ -0,0 +1,35 @@ +using CarRental.Application.Contracts.Client; +using CarRental.Application.Contracts.Analytics; + +namespace CarRental.Application.Contracts.Interfaces; + +/// +/// Defines methods for business intelligence and data analysis across cars, clients, and rentals. +/// +public interface IAnalyticsService +{ + /// + /// Retrieves all clients who have rented a specific car model. + /// + public Task> ReadClientsByModelName(string modelName); + + /// + /// Lists all cars that are currently occupied at a specific point in time. + /// + public Task> ReadCarsInRent(DateTime atTime); + + /// + /// Returns the top 5 cars with the highest number of rental agreements. + /// + public Task> ReadTop5MostRentedCars(); + + /// + /// Returns a list of all cars along with their total rental frequency. + /// + public Task> ReadAllCarsWithRentalCount(); + + /// + /// Returns the top 5 clients who have spent the most money on rentals. + /// + public Task> ReadTop5ClientsByTotalAmount(); +} \ No newline at end of file diff --git a/CarRental.Application.Contracts/Interfaces/IApplicationService.cs b/CarRental.Application.Contracts/Interfaces/IApplicationService.cs new file mode 100644 index 000000000..f61d49e1c --- /dev/null +++ b/CarRental.Application.Contracts/Interfaces/IApplicationService.cs @@ -0,0 +1,38 @@ +namespace CarRental.Application.Contracts.Interfaces; + +/// +/// Defines a generic contract for application services handling mapping between entities and DTOs. +/// +/// The data transfer object used for output. +/// The data transfer object used for input operations. +/// The type of using key +public interface IApplicationService + where TDto : class + where TCreateUpdateDto : class + where TKey : struct +{ + /// + /// Creates a new record from the provided input DTO and returns the resulting output DTO. + /// + public Task Create(TCreateUpdateDto dto); + + /// + /// Retrieves a single record by its unique identifier, mapped to an output DTO. + /// + public Task Read(TKey id); + + /// + /// Retrieves all records mapped to a list of output DTOs. + /// + public Task> ReadAll(); + + /// + /// Updates an existing record identified by the given ID using the input DTO data. + /// + public Task Update(TCreateUpdateDto dto, TKey id); + + /// + /// Removes a record from the system by its unique identifier. + /// + public Task Delete(TKey id); +} \ No newline at end of file diff --git a/CarRental.Application.Contracts/Rent/RentCreateUpdateDto.cs b/CarRental.Application.Contracts/Rent/RentCreateUpdateDto.cs new file mode 100644 index 000000000..5e8b97378 --- /dev/null +++ b/CarRental.Application.Contracts/Rent/RentCreateUpdateDto.cs @@ -0,0 +1,10 @@ +namespace CarRental.Application.Contracts.Rent; + +/// +/// Data transfer object for creating or updating a car rental agreement. +/// +/// The scheduled date and time for the rental to begin. +/// The length of the rental period in hours. +/// The unique identifier of the car to be rented. +/// The unique identifier of the client renting the car. +public record RentCreateUpdateDto(DateTime StartDateTime, double Duration, Guid CarId, Guid ClientId); \ No newline at end of file diff --git a/CarRental.Application.Contracts/Rent/RentDto.cs b/CarRental.Application.Contracts/Rent/RentDto.cs new file mode 100644 index 000000000..b728f0b25 --- /dev/null +++ b/CarRental.Application.Contracts/Rent/RentDto.cs @@ -0,0 +1,11 @@ +namespace CarRental.Application.Contracts.Rent; + +/// +/// Data transfer object representing a rental agreement with calculated details and linked entity info. +/// +/// The unique identifier of the rental record. +/// The date and time when the rental period starts. +/// The length of the rental in hours. +/// The unique identifier of the rented car. +/// The unique identifier of the client. +public record RentDto(Guid Id, DateTime StartDateTime, double Duration, Guid CarId, Guid ClientId); \ No newline at end of file diff --git a/CarRental.Application/CarRental.Application.csproj b/CarRental.Application/CarRental.Application.csproj new file mode 100644 index 000000000..1b16d887b --- /dev/null +++ b/CarRental.Application/CarRental.Application.csproj @@ -0,0 +1,21 @@ + + + + + + + + + net8.0 + enable + enable + True + + + + + + + + + diff --git a/CarRental.Application/CarRentalMapsterConfig.cs b/CarRental.Application/CarRentalMapsterConfig.cs new file mode 100644 index 000000000..f07f700ad --- /dev/null +++ b/CarRental.Application/CarRentalMapsterConfig.cs @@ -0,0 +1,39 @@ +using Mapster; +using CarRental.Application.Contracts.Car; +using CarRental.Application.Contracts.CarModel; +using CarRental.Application.Contracts.CarModelGeneration; +using CarRental.Application.Contracts.Client; +using CarRental.Application.Contracts.Rent; +using CarRental.Domain.DataModels; +using CarRental.Domain.InternalData.ComponentClasses; + +namespace CarRental.Application; + +/// +/// This code defines a Mapster configuration class +/// used to automate object-to-object mapping within a Car Rental application +/// +public class CarRentalMapsterConfig : IRegister +{ + /// + /// Registers mapping rules for converting between domain entities and DTOs + /// to enable seamless data projection and transfer using Mapster + /// + public void Register(TypeAdapterConfig config) + { + config.NewConfig(); + config.NewConfig(); + + config.NewConfig(); + config.NewConfig(); + + config.NewConfig(); + config.NewConfig(); + + config.NewConfig(); + config.NewConfig(); + + config.NewConfig(); + config.NewConfig(); + } +} \ No newline at end of file diff --git a/CarRental.Application/Services/AnalyticsService.cs b/CarRental.Application/Services/AnalyticsService.cs new file mode 100644 index 000000000..4bfdf2056 --- /dev/null +++ b/CarRental.Application/Services/AnalyticsService.cs @@ -0,0 +1,219 @@ +using Mapster; +using CarRental.Application.Contracts.Analytics; +using CarRental.Application.Contracts.Client; +using CarRental.Application.Contracts.Interfaces; +using CarRental.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace CarRental.Application.Services; + +/// +/// Service for performing various analytical queries and reporting on car rental data +/// +/// Database context used for executing optimized LINQ queries against the car rental entities +public class AnalyticsService(CarRentalDbContext context) + : IAnalyticsService +{ + /// + /// Finds all clients who have rented a specific car model identified by its name + /// + /// The name (or part of the name) of the car model + /// A list of unique clients who rented the specified model, ordered by name + public async Task> ReadClientsByModelName(string modelName) + { + var modelIds = await context.CarModels + .AsNoTracking() + .Where(m => m.Name.Contains(modelName)) + .Select(m => m.Id) + .ToListAsync(); + if (!modelIds.Any()) return new List(); + var generationIds = await context.ModelGenerations + .AsNoTracking() + .Where(mg => modelIds.Contains(mg.ModelId)) + .Select(mg => mg.Id) + .ToListAsync(); + var carIds = await context.Cars + .AsNoTracking() + .Where(c => generationIds.Contains(c.ModelGenerationId)) + .Select(c => c.Id) + .ToListAsync(); + var clientIds = await context.Rents + .AsNoTracking() + .Where(r => carIds.Contains(r.CarId)) + .Select(r => r.ClientId) + .Distinct() + .ToListAsync(); + var clients = await context.Clients + .AsNoTracking() + .Where(cl => clientIds.Contains(cl.Id)) + .OrderBy(cl => cl.LastName) + .ThenBy(cl => cl.FirstName) + .ToListAsync(); + + return clients.Adapt>(); + } + + /// + /// Identifies all cars that are currently or were rented at a specific point in time + /// + /// The date and time to check for active rentals + /// A list of cars that were in rent at the specified time + public async Task> ReadCarsInRent(DateTime atTime) + { + var activeRents = await context.Rents + .AsNoTracking() + .Where(r => r.StartDateTime <= atTime) + .ToListAsync(); + var filteredRents = activeRents + .Where(r => r.StartDateTime.AddHours(r.Duration) > atTime) + .ToList(); + if (!filteredRents.Any()) return new List(); + var carIds = filteredRents.Select(r => r.CarId).Distinct().ToList(); + var cars = await context.Cars + .AsNoTracking() + .Where(c => carIds.Contains(c.Id)) + .ToListAsync(); + var generationIds = cars.Select(c => c.ModelGenerationId).Distinct().ToList(); + var generations = await context.ModelGenerations + .AsNoTracking() + .Where(mg => generationIds.Contains(mg.Id)) + .ToListAsync(); + var modelIds = generations.Select(mg => mg.ModelId).Distinct().ToList(); + var models = await context.CarModels + .AsNoTracking() + .Where(m => modelIds.Contains(m.Id)) + .ToListAsync(); + var result = filteredRents.Select(r => + { + var car = cars.First(c => c.Id == r.CarId); + var gen = generations.First(g => g.Id == car.ModelGenerationId); + var model = models.First(m => m.Id == gen.ModelId); + + return new CarInRentDto( + car.Id, + model.Name, + car.NumberPlate, + r.StartDateTime, + (int)r.Duration + ); + }) + .OrderBy(x => x.NumberPlate) + .ToList(); + + return result; + } + + /// + /// Retrieves the top 5 cars with the highest total number of rental transactions + /// + /// A list of the 5 most frequently rented cars with their rental counts + public async Task> ReadTop5MostRentedCars() + { + var allRentCarIds = await context.Rents + .AsNoTracking() + .Select(r => r.CarId) + .ToListAsync(); + if (!allRentCarIds.Any()) return new List(); + var topStats = allRentCarIds + .GroupBy(id => id) + .Select(g => new { CarId = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .Take(5) + .ToList(); + var topCarIds = topStats.Select(x => x.CarId).ToList(); + var cars = await context.Cars.AsNoTracking().Where(c => topCarIds.Contains(c.Id)).ToListAsync(); + var generationIds = cars.Select(c => c.ModelGenerationId).Distinct().ToList(); + var generations = await context.ModelGenerations.AsNoTracking().Where(mg => generationIds.Contains(mg.Id)).ToListAsync(); + var modelIds = generations.Select(mg => mg.ModelId).Distinct().ToList(); + var models = await context.CarModels.AsNoTracking().Where(m => modelIds.Contains(m.Id)).ToListAsync(); + + return topStats.Select(stat => + { + var car = cars.First(c => c.Id == stat.CarId); + var gen = generations.First(g => g.Id == car.ModelGenerationId); + var model = models.First(m => m.Id == gen.ModelId); + return new CarWithRentalCountDto(car.Id, model.Name, car.NumberPlate, stat.Count); + }).ToList(); + } + + /// + /// Calculates the total number of rentals for every car in the system + /// + /// A complete list of cars and how many times each has been rented + public async Task> ReadAllCarsWithRentalCount() + { + var allRentCarIds = await context.Rents + .AsNoTracking() + .Select(r => r.CarId) + .ToListAsync(); + var rentDict = allRentCarIds + .GroupBy(id => id) + .ToDictionary(g => g.Key, g => g.Count()); + var cars = await context.Cars.AsNoTracking().ToListAsync(); + var generations = await context.ModelGenerations.AsNoTracking().ToListAsync(); + var models = await context.CarModels.AsNoTracking().ToListAsync(); + + return cars.Select(car => + { + var gen = generations.First(g => g.Id == car.ModelGenerationId); + var model = models.First(m => m.Id == gen.ModelId); + rentDict.TryGetValue(car.Id, out var count); + + return new CarWithRentalCountDto(car.Id, model.Name, car.NumberPlate, count); + }) + .OrderBy(x => x.NumberPlate) + .ToList(); + } + + /// + /// Identifies the top 5 clients who have spent the most money on rentals based on duration and hourly cost + /// + /// A list of the 5 highest-paying clients with their total spent amounts + public async Task> ReadTop5ClientsByTotalAmount() + { + var rents = await context.Rents.AsNoTracking().ToListAsync(); + var cars = await context.Cars.AsNoTracking().ToListAsync(); + var generations = await context.ModelGenerations.AsNoTracking().ToListAsync(); + var clientStats = rents + .GroupBy(r => r.ClientId) + .Select(g => + { + var totalAmount = g.Sum(r => + { + var car = cars.FirstOrDefault(c => c.Id == r.CarId); + var gen = generations.FirstOrDefault(gn => gn.Id == car?.ModelGenerationId); + return (decimal)r.Duration * (gen?.HourCost ?? 0); + }); + + return new + { + ClientId = g.Key, + Amount = totalAmount, + Count = g.Count() + }; + }) + .OrderByDescending(x => x.Amount) + .Take(5) + .ToList(); + if (!clientStats.Any()) return new List(); + var topClientIds = clientStats.Select(x => x.ClientId).ToList(); + var clients = await context.Clients + .AsNoTracking() + .Where(c => topClientIds.Contains(c.Id)) + .ToListAsync(); + var result = clientStats.Select(stat => + { + var client = clients.First(c => c.Id == stat.ClientId); + return new ClientWithTotalAmountDto( + client.Id, + client.FirstName, + client.LastName, + client.Patronymic, + stat.Amount, + stat.Count + ); + }).ToList(); + + return result; + } +} diff --git a/CarRental.Application/Services/CarModelGenerationService.cs b/CarRental.Application/Services/CarModelGenerationService.cs new file mode 100644 index 000000000..c6786ea1c --- /dev/null +++ b/CarRental.Application/Services/CarModelGenerationService.cs @@ -0,0 +1,93 @@ +using Mapster; +using CarRental.Application.Contracts.CarModelGeneration; +using CarRental.Application.Contracts.Interfaces; +using CarRental.Domain.Interfaces; +using CarRental.Domain.InternalData.ComponentClasses; + +namespace CarRental.Application.Services; + +/// +/// Service for managing car model generations, including linking generations to parent car models +/// +/// The repository for model generation entities +/// The repository for car model entities +public class CarModelGenerationService( + IBaseRepository repository, + IBaseRepository modelRepository) + : IApplicationService +{ + /// + /// Creates a new model generation after validating that the associated car model exists + /// + /// The model generation data transfer object + /// The created model generation as a DTO + /// Thrown if the associated CarModel ID is invalid + public async Task Create(CarModelGenerationCreateUpdateDto dto) + { + var entity = dto.Adapt(); + var model = await modelRepository.Read(dto.ModelId) + ?? throw new KeyNotFoundException($"CarModel with Id {dto.ModelId} not found."); + entity.Model = model; + entity.ModelId = model.Id; + var id = await repository.Create(entity); + entity.Id = id; + return entity.Adapt(); + } + + /// + /// Retrieves a specific model generation by its unique identifier + /// + /// The unique identifier of the generation + /// The mapped generation DTO + /// Thrown if the generation record is not found + public async Task Read(Guid id) + { + var entity = await repository.Read(id) + ?? throw new KeyNotFoundException($"CarModelGeneration with Id {id} not found."); + return entity.Adapt(); + } + + /// + /// Retrieves all model generations and asynchronously populates their parent models + /// + /// A list of model generation DTOs with linked model data + public async Task> ReadAll() + { + var entities = await repository.ReadAll(); + foreach (var generation in entities) + { + if (generation.ModelId != Guid.Empty) + { + generation.Model = await modelRepository.Read(generation.ModelId); + } + } + return entities.Adapt>(); + } + + /// + /// Updates an existing model generation and refreshes its link to a car model + /// + /// The updated data for the generation + /// The identifier of the generation to update + /// True if the update was successful; otherwise, false + /// Thrown if the new parent CarModel is not found + public async Task Update(CarModelGenerationCreateUpdateDto dto, Guid id) + { + var existing = await repository.Read(id); + if (existing is null) return false; + dto.Adapt(existing); + var model = await modelRepository.Read(dto.ModelId) + ?? throw new KeyNotFoundException($"CarModel with Id {dto.ModelId} not found."); + existing.Model = model; + existing.ModelId = model.Id; + return await repository.Update(existing, id); + } + + /// + /// Deletes a car model generation record from the system + /// + /// The identifier of the generation to delete + /// True if the deletion was successful + public async Task Delete(Guid id) + => await repository.Delete(id); +} \ No newline at end of file diff --git a/CarRental.Application/Services/CarModelService.cs b/CarRental.Application/Services/CarModelService.cs new file mode 100644 index 000000000..30249e455 --- /dev/null +++ b/CarRental.Application/Services/CarModelService.cs @@ -0,0 +1,75 @@ +using Mapster; +using CarRental.Application.Contracts.CarModel; +using CarRental.Application.Contracts.Interfaces; +using CarRental.Domain.Interfaces; +using CarRental.Domain.InternalData.ComponentClasses; + +namespace CarRental.Application.Services; + +/// +/// Service for managing car model business logic and DTO mapping +/// +/// The car model data repository. +public class CarModelService( + IBaseRepository repository) + : IApplicationService +{ + /// + /// Creates a new car model and returns the result without re-querying the database + /// + /// The data transfer object for creating a car model + /// The newly created car model DTO. + public async Task Create(CarModelCreateUpdateDto dto) + { + var entity = dto.Adapt(); + var id = await repository.Create(entity); + entity.Id = id; + return entity.Adapt(); + } + + /// + /// Retrieves a specific car model by its unique identifier + /// + /// The unique identifier of the car model + /// The mapped car model DTO + /// Thrown if the car model is not found + public async Task Read(Guid id) + { + var entity = await repository.Read(id) + ?? throw new KeyNotFoundException($"CarModel with Id {id} not found."); + return entity.Adapt(); + } + + /// + /// Retrieves all car models from the repository + /// + /// A list of car model DTOs + public async Task> ReadAll() + { + var entities = await repository.ReadAll(); + return entities.Adapt>(); + } + + /// + /// Updates an existing car model's information + /// + /// The updated car model data + /// The identifier of the model to update + /// True if the update succeeded; otherwise, false + public async Task Update(CarModelCreateUpdateDto dto, Guid id) + { + var existing = await repository.Read(id); + if (existing is null) return false; + + dto.Adapt(existing); + return await repository.Update(existing, id); + } + + /// + /// Deletes a car model record by its identifier + /// + /// The unique identifier of the car model to remove + /// True if the deletion was successful + public async Task Delete(Guid id) + => await repository.Delete(id); +} diff --git a/CarRental.Application/Services/CarService.cs b/CarRental.Application/Services/CarService.cs new file mode 100644 index 000000000..e7a934e7c --- /dev/null +++ b/CarRental.Application/Services/CarService.cs @@ -0,0 +1,88 @@ +using Mapster; +using CarRental.Application.Contracts.Car; +using CarRental.Application.Contracts.Interfaces; +using CarRental.Domain.DataModels; +using CarRental.Domain.Interfaces; +using CarRental.Domain.InternalData.ComponentClasses; + +namespace CarRental.Application.Services; + +/// +/// Service for managing car business logic and coordinating data between repositories and DTOs +/// +/// The car data repository +/// The car model generation data repository +public class CarService( + IBaseRepository repository, + IBaseRepository generationRepository) + : IApplicationService +{ + /// + /// Retrieves all cars available in the system as DTOs + /// + /// A list of car data transfer objects + public async Task> ReadAll() + { + var entities = await repository.ReadAll(); + return entities.Adapt>(); + } + + /// + /// Retrieves a specific car by its unique identifier + /// + /// The unique identifier of the car + /// The found car DTO. + /// Thrown if the car with the specified ID does not exist + public async Task Read(Guid id) + { + var entity = await repository.Read(id) + ?? throw new KeyNotFoundException($"Car with Id {id} not found."); + return entity.Adapt(); + } + + /// + /// Creates a new car record after validating that the associated model generation exists + /// + /// The data for creating the new car + /// The created car as a DTO. + /// Thrown if the provided ModelGenerationId is invalid + /// Thrown if the car cannot be retrieved after creation + public async Task Create(CarCreateUpdateDto dto) + { + var generation = await generationRepository.Read(dto.ModelGenerationId) + ?? throw new KeyNotFoundException($"ModelGeneration with Id {dto.ModelGenerationId} not found."); + var entity = dto.Adapt(); + var id = await repository.Create(entity); + var savedEntity = await repository.Read(id) + ?? throw new InvalidOperationException("Created car was not found."); + return savedEntity.Adapt(); + } + + /// + /// Updates an existing car's data and validates the model generation if it has changed + /// + /// The updated car data. + /// The unique identifier of the car to update + /// True if the update was successful; otherwise, false + /// Thrown if the new ModelGenerationId does not exist + public async Task Update(CarCreateUpdateDto dto, Guid id) + { + var existing = await repository.Read(id); + if (existing is null) return false; + if (dto.ModelGenerationId != existing.ModelGenerationId) + { + var generation = await generationRepository.Read(dto.ModelGenerationId) + ?? throw new KeyNotFoundException($"ModelGeneration with Id {dto.ModelGenerationId} not found."); + } + dto.Adapt(existing); + return await repository.Update(existing, id); + } + + /// + /// Removes a car record from the system + /// + /// The unique identifier of the car to delete + /// True if the deletion was successful + public async Task Delete(Guid id) + => await repository.Delete(id); +} \ No newline at end of file diff --git a/CarRental.Application/Services/ClientService.cs b/CarRental.Application/Services/ClientService.cs new file mode 100644 index 000000000..51948bd7c --- /dev/null +++ b/CarRental.Application/Services/ClientService.cs @@ -0,0 +1,76 @@ +using Mapster; +using CarRental.Application.Contracts.Client; +using CarRental.Application.Contracts.Interfaces; +using CarRental.Domain.DataModels; +using CarRental.Domain.Interfaces; + +namespace CarRental.Application.Services; + +/// +/// Service for managing client-related business logic and DTO mapping. +/// +/// The client data repository. +public class ClientService( + IBaseRepository repository) + : IApplicationService +{ + /// + /// Retrieves all clients as a list of DTOs. + /// + /// A list of client data transfer objects. + public async Task> ReadAll() + { + var entities = await repository.ReadAll(); + return entities.Adapt>(); + } + + /// + /// Retrieves a specific client by their unique identifier + /// + /// The unique identifier of the client + /// The mapped client DTO + /// Thrown when no client exists with the given ID + public async Task Read(Guid id) + { + var entity = await repository.Read(id) + ?? throw new KeyNotFoundException($"Client with Id {id} not found."); + return entity.Adapt(); + } + + /// + /// Creates a new client record and returns the created entity as a DTO + /// + /// The client data for creation + /// The created client DTO + /// Thrown if the client cannot be retrieved after creation + public async Task Create(ClientCreateUpdateDto dto) + { + var entity = dto.Adapt(); + var id = await repository.Create(entity); + var savedEntity = await repository.Read(id) + ?? throw new InvalidOperationException("Created client was not found."); + return savedEntity.Adapt(); + } + + /// + /// Updates an existing client record using the provided data + /// + /// The updated client data + /// The identifier of the client to update + /// True if the update was successful; otherwise, false + public async Task Update(ClientCreateUpdateDto dto, Guid id) + { + var existing = await repository.Read(id); + if (existing is null) return false; + dto.Adapt(existing); + return await repository.Update(existing, id); + } + + /// + /// Deletes a client record from the system + /// + /// The unique identifier of the client to remove + /// True if the deletion was successful + public async Task Delete(Guid id) + => await repository.Delete(id); +} \ No newline at end of file diff --git a/CarRental.Application/Services/RentService.cs b/CarRental.Application/Services/RentService.cs new file mode 100644 index 000000000..a299f7b1b --- /dev/null +++ b/CarRental.Application/Services/RentService.cs @@ -0,0 +1,97 @@ +using Mapster; +using CarRental.Application.Contracts.Rent; +using CarRental.Application.Contracts.Interfaces; +using CarRental.Domain.DataModels; +using CarRental.Domain.Interfaces; + +namespace CarRental.Application.Services; + +/// +/// Service for managing rent business logic and mapping between entities and DTOs +/// +/// The rent data repository +/// The car data repository +/// The client data repository +public class RentService( + IBaseRepository repository, + IBaseRepository carRepository, + IBaseRepository clientRepository) + : IApplicationService +{ + /// + /// Retrieves all rent records as DTOs + /// + /// A list of rent data transfer objects + public async Task> ReadAll() + { + var rents = await repository.ReadAll(); + return rents.Adapt>(); + } + + /// + /// Retrieves a specific rent by its identifier + /// + /// The unique identifier of the rent + /// The found rent DTO + /// Thrown if rent is not found + public async Task Read(Guid id) + { + var entity = await repository.Read(id) + ?? throw new KeyNotFoundException($"Rent with Id {id} not found."); + return entity.Adapt(); + } + + /// + /// Creates a new rent record after validating car and client existence + /// + /// The rent data transfer object for creation + /// The created rent as a DTO + public async Task Create(RentCreateUpdateDto dto) + { + var car = await carRepository.Read(dto.CarId) + ?? throw new KeyNotFoundException($"Car with Id {dto.CarId} not found."); + var client = await clientRepository.Read(dto.ClientId) + ?? throw new KeyNotFoundException($"Client with Id {dto.ClientId} not found."); + var entity = dto.Adapt(); + entity.Car = car; + entity.Client = client; + var id = await repository.Create(entity); + var savedEntity = await repository.Read(id) + ?? throw new InvalidOperationException("Created rent was not found."); + return savedEntity.Adapt(); + } + + /// + /// Updates an existing rent record and refreshes car/client links if IDs have changed + /// + /// The updated rent data + /// The identifier of the rent to update + /// True if the update succeeded; otherwise, false + public async Task Update(RentCreateUpdateDto dto, Guid id) + { + var existing = await repository.Read(id); + if (existing is null) return false; + dto.Adapt(existing); + if (dto.CarId != existing.Car?.Id) + { + var car = await carRepository.Read(dto.CarId) + ?? throw new KeyNotFoundException($"Car with Id {dto.CarId} not found."); + existing.Car = car; + } + if (dto.ClientId != existing.Client?.Id) + { + var client = await clientRepository.Read(dto.ClientId) + ?? throw new KeyNotFoundException($"Client with Id {dto.ClientId} not found."); + existing.Client = client; + } + return await repository.Update(existing, id); + } + + /// + /// Deletes a rent record by its identifier + /// + /// The identifier of the rent to delete + /// True if the deletion succeeded + public async Task Delete(Guid id) + => await repository.Delete(id); +} \ No newline at end of file diff --git a/CarRental.Domain/CarRental.Domain.csproj b/CarRental.Domain/CarRental.Domain.csproj new file mode 100644 index 000000000..bd71603f5 --- /dev/null +++ b/CarRental.Domain/CarRental.Domain.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + True + + + + + + diff --git a/CarRental.Domain/DataModels/Car.cs b/CarRental.Domain/DataModels/Car.cs new file mode 100644 index 000000000..c31705644 --- /dev/null +++ b/CarRental.Domain/DataModels/Car.cs @@ -0,0 +1,34 @@ +using CarRental.Domain.InternalData.ComponentClasses; + +namespace CarRental.Domain.DataModels; + +/// +/// Represents a specific physical vehicle available for rental +/// +public class Car +{ + /// + /// Unique identifier of the car + /// + public required Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// The model generation id this car belongs to, defining its year, transmission type, and base rental cost + /// + public required Guid ModelGenerationId { get; set; } + + /// + /// The model generation this car belongs to, defining its year, transmission type, and base rental cost + /// + public required CarModelGeneration ModelGeneration { get; set; } + + /// + /// License plate number of the car + /// + public required string NumberPlate { get; set; } + + /// + /// Exterior colour of the car + /// + public required string Colour { get; set; } +} \ No newline at end of file diff --git a/CarRental.Domain/DataModels/Client.cs b/CarRental.Domain/DataModels/Client.cs new file mode 100644 index 000000000..c059c1a7d --- /dev/null +++ b/CarRental.Domain/DataModels/Client.cs @@ -0,0 +1,37 @@ +namespace CarRental.Domain.DataModels; + +/// +/// Represents a client (rental customer) with personal and identification information +/// +public class Client +{ + /// + /// Unique identifier of the client + /// + public required Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Unique identifier of the client's driver's license + /// + public required string DriverLicenseId { get; set; } + + /// + /// Client's last name (surname) + /// + public required string LastName { get; set; } + + /// + /// Client's first name (given name) + /// + public required string FirstName { get; set; } + + /// + /// Client's patronymic (middle name), if applicable + /// + public string? Patronymic { get; set; } + + /// + /// Client's date of birth + /// + public DateOnly? BirthDate { get; set; } +} \ No newline at end of file diff --git a/CarRental.Domain/DataModels/Rent.cs b/CarRental.Domain/DataModels/Rent.cs new file mode 100644 index 000000000..34a29d8ee --- /dev/null +++ b/CarRental.Domain/DataModels/Rent.cs @@ -0,0 +1,42 @@ +namespace CarRental.Domain.DataModels; + +/// +/// Represents a car rental agreement between a client and the rental company +/// +public class Rent +{ + /// + /// Unique identifier of the rental record + /// + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Date and time when the rental period starts + /// + public required DateTime StartDateTime { get; set; } + + /// + /// Duration of the rental in hours + /// + public required double Duration { get; set; } + + /// + /// The car ID that is being rented + /// + public required Guid CarId { get; set; } + + /// + /// The car that is being rented + /// + public required Car Car { get; set; } + + /// + /// The client ID who is renting the car + /// + public required Guid ClientId { get; set; } + + /// + /// The client who is renting the car + /// + public required Client Client { get; set; } +} diff --git a/CarRental.Domain/DataSeed/DataSeed.cs b/CarRental.Domain/DataSeed/DataSeed.cs new file mode 100644 index 000000000..4ab5d7485 --- /dev/null +++ b/CarRental.Domain/DataSeed/DataSeed.cs @@ -0,0 +1,188 @@ +using CarRental.Domain.DataModels; +using CarRental.Domain.InternalData.ComponentClasses; +using CarRental.Domain.InternalData.ComponentEnums; + +namespace CarRental.Domain.DataSeed; + +/// +/// Provides a fixed set of pre-initialized domain entities for testing and demonstration purposes +/// +public class DataSeed +{ + /// + /// List of physical vehicles available for rental + /// + public List Cars { get; } + + /// + /// List of registered clients + /// + public List Clients { get; } + + /// + /// List of rental agreements linking clients to specific cars + /// + public List Rents { get; } + + /// + /// List of car models representing vehicle + /// + public List Models { get; } + + /// + /// List of car model generations + /// + public List Generations { get; } + + /// + /// Constructor implementation + /// + public DataSeed() + { + Models = new List + { + new() { Id = Guid.NewGuid(), Name = "Fiat 500", DriveType = InternalData.ComponentEnums.DriveType.FrontWheel, SeatsNumber = 4, BodyType = BodyType.CityCar, ClassType = ClassType.A }, + new() { Id = Guid.NewGuid(), Name = "Subaru Outback", DriveType = InternalData.ComponentEnums.DriveType.AllWheel, SeatsNumber = 5, BodyType = BodyType.StationWagon, ClassType = ClassType.D }, + new() { Id = Guid.NewGuid(), Name = "Volkswagen Golf", DriveType = InternalData.ComponentEnums.DriveType.FrontWheel, SeatsNumber = 5, BodyType = BodyType.Hatchback, ClassType = ClassType.C }, + new() { Id = Guid.NewGuid(), Name = "Mazda CX-5", DriveType = InternalData.ComponentEnums.DriveType.AllWheel, SeatsNumber = 5, BodyType = BodyType.SportUtilityVehicle, ClassType = ClassType.C }, + new() { Id = Guid.NewGuid(), Name = "Nissan Qashqai", DriveType = InternalData.ComponentEnums.DriveType.AllWheel, SeatsNumber = 5, BodyType = BodyType.Crossover, ClassType = ClassType.C }, + new() { Id = Guid.NewGuid(), Name = "Volvo XC90", DriveType = InternalData.ComponentEnums.DriveType.AllWheel, SeatsNumber = 7, BodyType = BodyType.SportUtilityVehicle, ClassType = ClassType.E }, + new() { Id = Guid.NewGuid(), Name = "Audi A4", DriveType = InternalData.ComponentEnums.DriveType.FrontWheel, SeatsNumber = 5, BodyType = BodyType.Sedan, ClassType = ClassType.D }, + new() { Id = Guid.NewGuid(), Name = "Honda CR-V", DriveType = InternalData.ComponentEnums.DriveType.AllWheel, SeatsNumber = 5, BodyType = BodyType.SportUtilityVehicle, ClassType = ClassType.D }, + new() { Id = Guid.NewGuid(), Name = "Hyundai Tucson", DriveType = InternalData.ComponentEnums.DriveType.AllWheel, SeatsNumber = 5, BodyType = BodyType.SportUtilityVehicle, ClassType = ClassType.C }, + new() { Id = Guid.NewGuid(), Name = "Volkswagen Transporter", DriveType = InternalData.ComponentEnums.DriveType.RearWheel, SeatsNumber = 9, BodyType = BodyType.Van, ClassType = ClassType.F }, + new() { Id = Guid.NewGuid(), Name = "Mercedes E-Class", DriveType = InternalData.ComponentEnums.DriveType.RearWheel,SeatsNumber = 5, BodyType = BodyType.Sedan, ClassType = ClassType.E }, + new() { Id = Guid.NewGuid(), Name = "Ford Focus", DriveType = InternalData.ComponentEnums.DriveType.FrontWheel, SeatsNumber = 5, BodyType = BodyType.Hatchback, ClassType = ClassType.C }, + new() { Id = Guid.NewGuid(), Name = "Jaguar F-Type", DriveType = InternalData.ComponentEnums.DriveType.RearWheel, SeatsNumber = 2, BodyType = BodyType.Coupe, ClassType = ClassType.E }, + new() { Id = Guid.NewGuid(), Name = "Tesla Model 3", DriveType = InternalData.ComponentEnums.DriveType.AllWheel, SeatsNumber = 5, BodyType = BodyType.Sedan, ClassType = ClassType.D }, + new() { Id = Guid.NewGuid(), Name = "Toyota Camry", DriveType = InternalData.ComponentEnums.DriveType.FrontWheel, SeatsNumber = 5, BodyType = BodyType.Sedan, ClassType = ClassType.D }, + new() { Id = Guid.NewGuid(), Name = "Lexus LS", DriveType = InternalData.ComponentEnums.DriveType.AllWheel, SeatsNumber = 5, BodyType = BodyType.Sedan, ClassType = ClassType.F }, + new() { Id = Guid.NewGuid(), Name = "Porsche 911", DriveType = InternalData.ComponentEnums.DriveType.RearWheel, SeatsNumber = 2, BodyType = BodyType.SportsCar, ClassType = ClassType.E }, + new() { Id = Guid.NewGuid(), Name = "Renault Megane", DriveType = InternalData.ComponentEnums.DriveType.FrontWheel, SeatsNumber = 5, BodyType = BodyType.Hatchback, ClassType = ClassType.C }, + new() { Id = Guid.NewGuid(), Name = "BMW X5", DriveType = InternalData.ComponentEnums.DriveType.AllWheel, SeatsNumber = 5, BodyType = BodyType.SportUtilityVehicle, ClassType = ClassType.E }, + new() { Id = Guid.NewGuid(), Name = "Kia Rio", DriveType = InternalData.ComponentEnums.DriveType.FrontWheel, SeatsNumber = 5, BodyType = BodyType.Sedan, ClassType = ClassType.B } + }; + + Generations = new List + { + new() { Id = Guid.NewGuid(), Year = 2019, TransmissionType = TransmissionType.Manual, ModelId = Models[16].Id, Model = Models[16], HourCost = 160.00m }, // Porsche 911 + new() { Id = Guid.NewGuid(), Year = 2022, TransmissionType = TransmissionType.Automatic, ModelId = Models[0].Id, Model = Models[0], HourCost = 35.00m }, // Fiat 500 + new() { Id = Guid.NewGuid(), Year = 2021, TransmissionType = TransmissionType.Manual, ModelId = Models[11].Id, Model = Models[11], HourCost = 55.00m }, // Ford Focus + new() { Id = Guid.NewGuid(), Year = 2020, TransmissionType = TransmissionType.Variable, ModelId = Models[4].Id, Model = Models[4], HourCost = 70.00m }, // Nissan Qashqai + new() { Id = Guid.NewGuid(), Year = 2023, TransmissionType = TransmissionType.Automatic, ModelId = Models[18].Id, Model = Models[18], HourCost = 120.00m }, // BMW X5 + new() { Id = Guid.NewGuid(), Year = 2022, TransmissionType = TransmissionType.Automatic, ModelId = Models[15].Id, Model = Models[15], HourCost = 140.00m }, // Lexus LS + new() { Id = Guid.NewGuid(), Year = 2018, TransmissionType = TransmissionType.Manual, ModelId = Models[19].Id, Model = Models[19], HourCost = 40.00m }, // Kia Rio + new() { Id = Guid.NewGuid(), Year = 2021, TransmissionType = TransmissionType.Automatic, ModelId = Models[7].Id, Model = Models[7], HourCost = 85.00m }, // Honda CR-V + new() { Id = Guid.NewGuid(), Year = 2023, TransmissionType = TransmissionType.Automatic, ModelId = Models[12].Id, Model = Models[12], HourCost = 150.00m }, // Jaguar F-Type + new() { Id = Guid.NewGuid(), Year = 2020, TransmissionType = TransmissionType.Manual, ModelId = Models[9].Id, Model = Models[9], HourCost = 60.00m }, // VW Transporter + new() { Id = Guid.NewGuid(), Year = 2022, TransmissionType = TransmissionType.Automatic, ModelId = Models[1].Id, Model = Models[1], HourCost = 95.00m }, // Subaru Outback + new() { Id = Guid.NewGuid(), Year = 2021, TransmissionType = TransmissionType.Automatic, ModelId = Models[8].Id, Model = Models[8], HourCost = 75.00m }, // Hyundai Tucson + new() { Id = Guid.NewGuid(), Year = 2019, TransmissionType = TransmissionType.Manual, ModelId = Models[2].Id, Model = Models[2], HourCost = 50.00m }, // VW Golf + new() { Id = Guid.NewGuid(), Year = 2023, TransmissionType = TransmissionType.Automatic, ModelId = Models[13].Id, Model = Models[13], HourCost = 100.00m }, // Tesla Model 3 + new() { Id = Guid.NewGuid(), Year = 2022, TransmissionType = TransmissionType.Automatic, ModelId = Models[14].Id, Model = Models[14], HourCost = 80.00m }, // Toyota Camry + new() { Id = Guid.NewGuid(), Year = 2020, TransmissionType = TransmissionType.Automatic, ModelId = Models[6].Id, Model = Models[6], HourCost = 90.00m }, // Audi A4 + new() { Id = Guid.NewGuid(), Year = 2022, TransmissionType = TransmissionType.Automatic, ModelId = Models[5].Id, Model = Models[5], HourCost = 105.00m }, // Volvo XC90 + new() { Id = Guid.NewGuid(), Year = 2021, TransmissionType = TransmissionType.Manual, ModelId = Models[17].Id, Model = Models[17], HourCost = 55.00m }, // Renault Megane + new() { Id = Guid.NewGuid(), Year = 2023, TransmissionType = TransmissionType.Automatic, ModelId = Models[10].Id, Model = Models[10], HourCost = 110.00m }, // Mercedes E-Class + new() { Id = Guid.NewGuid(), Year = 2021, TransmissionType = TransmissionType.Automatic, ModelId = Models[3].Id, Model = Models[3], HourCost = 80.00m } // Mazda CX-5 + }; + + Cars = new List + { + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440001"), ModelGenerationId = Generations[5].Id, ModelGeneration = Generations[5], NumberPlate = "T890NO96", Colour = "Gray" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440002"), ModelGenerationId = Generations[14].Id, ModelGeneration = Generations[14], NumberPlate = "A123BC77", Colour = "Black" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440003"), ModelGenerationId = Generations[0].Id, ModelGeneration = Generations[0], NumberPlate = "M789ZA89", Colour = "Yellow" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440004"), ModelGenerationId = Generations[19].Id, ModelGeneration = Generations[19], NumberPlate = "D012HI80", Colour = "Blue" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440005"), ModelGenerationId = Generations[6].Id, ModelGeneration = Generations[6], NumberPlate = "E345JK81", Colour = "Red" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440006"), ModelGenerationId = Generations[16].Id, ModelGeneration = Generations[16], NumberPlate = "F678LM82", Colour = "Gray" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440007"), ModelGenerationId = Generations[7].Id, ModelGeneration = Generations[7], NumberPlate = "G901NO83", Colour = "Green" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440008"), ModelGenerationId = Generations[13].Id, ModelGeneration = Generations[13], NumberPlate = "H234PQ84", Colour = "Black" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440009"), ModelGenerationId = Generations[3].Id, ModelGeneration = Generations[3], NumberPlate = "I567RS85", Colour = "White" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440010"), ModelGenerationId = Generations[18].Id, ModelGeneration = Generations[18], NumberPlate = "J890TU86", Colour = "Silver" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440011"), ModelGenerationId = Generations[10].Id, ModelGeneration = Generations[10], NumberPlate = "K123VW87", Colour = "Blue" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440012"), ModelGenerationId = Generations[11].Id, ModelGeneration = Generations[11], NumberPlate = "L456XY88", Colour = "Red" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440013"), ModelGenerationId = Generations[8].Id, ModelGeneration = Generations[8], NumberPlate = "R234JK94", Colour = "Blue" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440014"), ModelGenerationId = Generations[9].Id, ModelGeneration = Generations[9], NumberPlate = "N012BC90", Colour = "White" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440015"), ModelGenerationId = Generations[1].Id, ModelGeneration = Generations[1], NumberPlate = "Q901HI93", Colour = "Red" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440016"), ModelGenerationId = Generations[15].Id, ModelGeneration = Generations[15], NumberPlate = "P678FG92", Colour = "Silver" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440017"), ModelGenerationId = Generations[2].Id, ModelGeneration = Generations[2], NumberPlate = "O345DE91", Colour = "Black" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440018"), ModelGenerationId = Generations[17].Id, ModelGeneration = Generations[17], NumberPlate = "S567LM95", Colour = "Green" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440019"), ModelGenerationId = Generations[4].Id, ModelGeneration = Generations[4], NumberPlate = "C789FG79", Colour = "Silver" }, + new() { Id = Guid.Parse("550e8400-e29b-41d4-a716-446655440020"), ModelGenerationId = Generations[12].Id, ModelGeneration = Generations[12], NumberPlate = "B456DE78", Colour = "White" } + }; + + Clients = new List + { + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440001"), DriverLicenseId = "DL990011223", LastName = "Belov", FirstName = "Roman", Patronymic = "Evgenievich", BirthDate = new DateOnly(1984, 9, 13) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440002"), DriverLicenseId = "DL112233445", LastName = "Lebedev", FirstName = "Artem", Patronymic = "Olegovich", BirthDate = new DateOnly(1994, 10, 21) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440003"), DriverLicenseId = "DL001122334", LastName = "Efimova", FirstName = "Daria", Patronymic = "Mikhailovna", BirthDate = new DateOnly(1999, 6, 22) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440004"), DriverLicenseId = "DL445566778", LastName = "Vinogradova", FirstName = "Polina", Patronymic = "Sergeevna", BirthDate = new DateOnly(1996, 12, 19) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440005"), DriverLicenseId = "DL567890123", LastName = "Smirnov", FirstName = "Dmitry", Patronymic = "Alexandrovich", BirthDate = new DateOnly(1985, 7, 12) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440006"), DriverLicenseId = "DL234567890", LastName = "Petrova", FirstName = "Maria", Patronymic = "Dmitrievna", BirthDate = new DateOnly(1988, 11, 3) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440007"), DriverLicenseId = "DL789012345", LastName = "Vasiliev", FirstName = "Sergey", Patronymic = "Nikolaevich", BirthDate = new DateOnly(1980, 12, 5) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440008"), DriverLicenseId = "DL890123456", LastName = "Fedorov", FirstName = "Andrey", Patronymic = null, BirthDate = new DateOnly(1993, 9, 27) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440009"), DriverLicenseId = "DL334455667", LastName = "Orlov", FirstName = "Maxim", Patronymic = "Igorevich", BirthDate = new DateOnly(1986, 8, 3) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440010"), DriverLicenseId = "DL012345678", LastName = "Nikolaev", FirstName = "Nikolay", Patronymic = "Pavlovich", BirthDate = new DateOnly(1987, 6, 9) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440011"), DriverLicenseId = "DL678901234", LastName = "Popova", FirstName = "Anna", Patronymic = "Ivanovna", BirthDate = new DateOnly(1997, 4, 18) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440012"), DriverLicenseId = "DL223344556", LastName = "Sokolova", FirstName = "Tatiana", Patronymic = null, BirthDate = new DateOnly(1989, 2, 11) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440013"), DriverLicenseId = "DL901234567", LastName = "Morozova", FirstName = "Olga", Patronymic = "Viktorovna", BirthDate = new DateOnly(1991, 3, 14) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440014"), DriverLicenseId = "DL123456789", LastName = "Ivanov", FirstName = "Alexey", Patronymic = "Sergeevich", BirthDate = new DateOnly(1990, 5, 15) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440015"), DriverLicenseId = "DL556677889", LastName = "Mikhailov", FirstName = "Kirill", Patronymic = null, BirthDate = new DateOnly(1990, 7, 25) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440016"), DriverLicenseId = "DL667788990", LastName = "Romanova", FirstName = "Victoria", Patronymic = "Andreevna", BirthDate = new DateOnly(1983, 11, 8) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440017"), DriverLicenseId = "DL778899001", LastName = "Karpov", FirstName = "Igor", Patronymic = "Valentinovich", BirthDate = new DateOnly(1982, 4, 17) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440018"), DriverLicenseId = "DL889900112", LastName = "Timofeeva", FirstName = "Natalia", Patronymic = null, BirthDate = new DateOnly(1998, 1, 29) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440019"), DriverLicenseId = "DL345678901", LastName = "Sidorov", FirstName = "Ivan", Patronymic = "Petrovich", BirthDate = new DateOnly(1995, 8, 22) }, + new() { Id = Guid.Parse("c11e4400-e29b-41d4-a716-446655440020"), DriverLicenseId = "DL456789012", LastName = "Kuznetsova", FirstName = "Elena", Patronymic = null, BirthDate = new DateOnly(1992, 1, 30) } + }; + + var baseTime = new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc); + Rents = new List + { + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-2), Duration = 6, CarId = Cars[13].Id, Car = Cars[13], ClientId = Clients[14].Id, Client = Clients[14] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(12), Duration = 6, CarId = Cars[19].Id, Car = Cars[19], ClientId = Clients[19].Id, Client = Clients[19] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-25), Duration = 48, CarId = Cars[2].Id, Car = Cars[2], ClientId = Clients[2].Id, Client = Clients[2] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(8), Duration = 24, CarId = Cars[17].Id, Car = Cars[17], ClientId = Clients[17].Id, Client = Clients[17] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-20), Duration = 72, CarId = Cars[4].Id, Car = Cars[4], ClientId = Clients[4].Id, Client = Clients[4] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(4), Duration = 72, CarId = Cars[15].Id, Car = Cars[15], ClientId = Clients[15].Id, Client = Clients[15] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-15), Duration = 168, CarId = Cars[6].Id, Car = Cars[6], ClientId = Clients[6].Id, Client = Clients[6] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-4), Duration = 48, CarId = Cars[11].Id, Car = Cars[11], ClientId = Clients[11].Id, Client = Clients[11] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-10), Duration = 36, CarId = Cars[8].Id, Car = Cars[8], ClientId = Clients[8].Id, Client = Clients[8] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime, Duration = 24, CarId = Cars[1].Id, Car = Cars[1], ClientId = Clients[0].Id, Client = Clients[0] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(2), Duration = 8, CarId = Cars[14].Id, Car = Cars[14], ClientId = Clients[13].Id, Client = Clients[13] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-8), Duration = 24, CarId = Cars[9].Id, Car = Cars[9], ClientId = Clients[9].Id, Client = Clients[9] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(6), Duration = 12, CarId = Cars[16].Id, Car = Cars[16], ClientId = Clients[16].Id, Client = Clients[16] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-6), Duration = 12, CarId = Cars[10].Id, Car = Cars[10], ClientId = Clients[10].Id, Client = Clients[10] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(10), Duration = 48, CarId = Cars[18].Id, Car = Cars[18], ClientId = Clients[18].Id, Client = Clients[18] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-28), Duration = 12, CarId = Cars[12].Id, Car = Cars[12], ClientId = Clients[12].Id, Client = Clients[12] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-22), Duration = 6, CarId = Cars[3].Id, Car = Cars[3], ClientId = Clients[3].Id, Client = Clients[3] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-18), Duration = 8, CarId = Cars[5].Id, Car = Cars[5], ClientId = Clients[5].Id, Client = Clients[5] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-12), Duration = 4, CarId = Cars[7].Id, Car = Cars[7], ClientId = Clients[7].Id, Client = Clients[7] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-30), Duration = 24, CarId = Cars[0].Id, Car = Cars[0], ClientId = Clients[1].Id, Client = Clients[1] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-25), Duration = 12, CarId = Cars[5].Id, Car = Cars[5], ClientId = Clients[0].Id, Client = Clients[0] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-10), Duration = 24, CarId = Cars[0].Id, Car = Cars[0], ClientId = Clients[1].Id, Client = Clients[1] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-5), Duration = 8, CarId = Cars[10].Id, Car = Cars[10], ClientId = Clients[1].Id, Client = Clients[1] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-20), Duration = 48, CarId = Cars[3].Id, Car = Cars[3], ClientId = Clients[2].Id, Client = Clients[2] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-15), Duration = 6, CarId = Cars[7].Id, Car = Cars[7], ClientId = Clients[2].Id, Client = Clients[2] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-8), Duration = 12, CarId = Cars[15].Id, Car = Cars[15], ClientId = Clients[2].Id, Client = Clients[2] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-22), Duration = 24, CarId = Cars[4].Id, Car = Cars[4], ClientId = Clients[3].Id, Client = Clients[3] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-18), Duration = 36, CarId = Cars[8].Id, Car = Cars[8], ClientId = Clients[3].Id, Client = Clients[3] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-12), Duration = 12, CarId = Cars[12].Id, Car = Cars[12], ClientId = Clients[3].Id, Client = Clients[3] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-6), Duration = 6, CarId = Cars[17].Id, Car = Cars[17], ClientId = Clients[3].Id, Client = Clients[3] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-28), Duration = 72, CarId = Cars[1].Id, Car = Cars[1], ClientId = Clients[4].Id, Client = Clients[4] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-24), Duration = 24, CarId = Cars[6].Id, Car = Cars[6], ClientId = Clients[4].Id, Client = Clients[4] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-20), Duration = 48, CarId = Cars[9].Id, Car = Cars[9], ClientId = Clients[4].Id, Client = Clients[4] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-16), Duration = 12, CarId = Cars[13].Id, Car = Cars[13], ClientId = Clients[4].Id, Client = Clients[4] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-10), Duration = 8, CarId = Cars[18].Id, Car = Cars[18], ClientId = Clients[4].Id, Client = Clients[4] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-30), Duration = 168, CarId = Cars[2].Id, Car = Cars[2], ClientId = Clients[5].Id, Client = Clients[5] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-26), Duration = 24, CarId = Cars[7].Id, Car = Cars[7], ClientId = Clients[5].Id, Client = Clients[5] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-22), Duration = 48, CarId = Cars[11].Id, Car = Cars[11], ClientId = Clients[5].Id, Client = Clients[5] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-18), Duration = 6, CarId = Cars[14].Id, Car = Cars[14], ClientId = Clients[5].Id, Client = Clients[5] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-14), Duration = 12, CarId = Cars[16].Id, Car = Cars[16], ClientId = Clients[5].Id, Client = Clients[5] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-10), Duration = 24, CarId = Cars[19].Id, Car = Cars[19], ClientId = Clients[5].Id, Client = Clients[5] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-3), Duration = 10, CarId = Cars[0].Id, Car = Cars[0], ClientId = Clients[6].Id, Client = Clients[6] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(-1), Duration = 5, CarId = Cars[2].Id, Car = Cars[2], ClientId = Clients[7].Id, Client = Clients[7] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(1), Duration = 7, CarId = Cars[5].Id, Car = Cars[5], ClientId = Clients[8].Id, Client = Clients[8] }, + new() { Id = Guid.NewGuid(), StartDateTime = baseTime.AddDays(3), Duration = 9, CarId = Cars[10].Id, Car = Cars[10], ClientId = Clients[9].Id, Client = Clients[9] } + }; + } +} diff --git a/CarRental.Domain/Interfaces/BaseRepository.cs b/CarRental.Domain/Interfaces/BaseRepository.cs new file mode 100644 index 000000000..73835cc3a --- /dev/null +++ b/CarRental.Domain/Interfaces/BaseRepository.cs @@ -0,0 +1,84 @@ +namespace CarRental.Domain.Interfaces; + +/// +/// Provides a base implementation for in-memory CRUD operations +/// +/// The type of the entity managed by the repository +public abstract class BaseRepository : IBaseRepository + where TEntity : class +{ + private readonly List _entities; + /// + /// Gets the unique identifier from the entity + /// + protected abstract Guid GetEntityId(TEntity entity); + + /// + /// Sets the unique identifier for the entity + /// + protected abstract void SetEntityId(TEntity entity, Guid id); + + /// + /// Initializes the repository and determines the starting ID based on existing data + /// + protected BaseRepository(List? entities = null) + { + _entities = entities ?? new List(); + } + + /// + /// Adds a new entity to the collection and assigns a unique ID + /// + public virtual Task Create(TEntity entity) + { + if (entity == null) + throw new ArgumentNullException(nameof(entity)); + var id = Guid.NewGuid(); + SetEntityId(entity, id); + _entities.Add(entity); + return Task.FromResult(id); + } + + /// + /// Retrieves an entity by its unique identifier + /// + public virtual Task Read(Guid id) + { + return Task.FromResult( + _entities.FirstOrDefault(e => GetEntityId(e) == id) + ); + } + + /// + /// Returns all entities in the collection + /// + public virtual Task> ReadAll() + { + return Task.FromResult(_entities.ToList()); + } + + /// + /// Replaces an existing entity at the specified ID + /// + public virtual async Task Update(TEntity entity, Guid id) + { + var existing = await Read(id); + if (existing == null) + return false; + + var index = _entities.IndexOf(existing); + SetEntityId(entity, id); + _entities[index] = entity; + + return true; + } + + /// + /// Removes an entity from the collection by its ID + /// + public virtual async Task Delete(Guid id) + { + var entity = await Read(id); + return entity != null && _entities.Remove(entity); + } +} \ No newline at end of file diff --git a/CarRental.Domain/Interfaces/IBaseRepository.cs b/CarRental.Domain/Interfaces/IBaseRepository.cs new file mode 100644 index 000000000..6742230ae --- /dev/null +++ b/CarRental.Domain/Interfaces/IBaseRepository.cs @@ -0,0 +1,36 @@ +namespace CarRental.Domain.Interfaces; + +/// +/// Defines the standard contract for a generic repository supporting CRUD operations. +/// +/// The type of the entity object. +/// The type of the key. +public interface IBaseRepository + where TEntity : class + where TKey : struct +{ + /// + /// Adds a new entity to the collection and returns a unique ID. + /// + public Task Create(TEntity entity); + + /// + /// Retrieves an entity by its unique identifier. + /// + public Task Read(TKey id); + + /// + /// Returns all entities in the collection. + /// + public Task> ReadAll(); + + /// + /// Replaces an existing entity at the specified ID. + /// + public Task Update(TEntity entity, TKey id); + + /// + /// Removes an entity from the collection by its ID. + /// + public Task Delete(TKey id); +} \ No newline at end of file diff --git a/CarRental.Domain/InternalData/ComponentClasses/CarModel.cs b/CarRental.Domain/InternalData/ComponentClasses/CarModel.cs new file mode 100644 index 000000000..9642fdc47 --- /dev/null +++ b/CarRental.Domain/InternalData/ComponentClasses/CarModel.cs @@ -0,0 +1,42 @@ +using CarRental.Domain.InternalData.ComponentEnums; +using DriveType = CarRental.Domain.InternalData.ComponentEnums.DriveType; + +namespace CarRental.Domain.InternalData.ComponentClasses; + +/// +/// Represents a specific car model with its key characteristics +/// such as name, body type, drive type, seating capacity, and vehicle class +/// +public class CarModel +{ + /// + /// Unique identifier of the car model + /// + public required Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Name of the car model (e.g., "Camry", "Golf", "Model 3") + /// + public required string Name { get; set; } + + /// + /// Type of drive system used by the car model (front-wheel, rear-wheel or all-wheel drive) + /// + public DriveType? DriveType { get; set; } + + /// + /// Number of passenger seats in the vehicle + /// + public required int SeatsNumber { get; set; } + + /// + /// Body style of the car model (e.g., sedan, SUV, hatchback) + /// + public required BodyType BodyType { get; set; } + + /// + /// Vehicle classification by size and market segment (A, B, C, D, E or F) + /// + public ClassType? ClassType { get; set; } +} + diff --git a/CarRental.Domain/InternalData/ComponentClasses/CarModelGeneration.cs b/CarRental.Domain/InternalData/ComponentClasses/CarModelGeneration.cs new file mode 100644 index 000000000..e12013c2a --- /dev/null +++ b/CarRental.Domain/InternalData/ComponentClasses/CarModelGeneration.cs @@ -0,0 +1,46 @@ +using CarRental.Domain.InternalData.ComponentClasses; +using CarRental.Domain.InternalData.ComponentEnums; + +namespace CarRental.Domain.InternalData.ComponentClasses; + +/// +/// Represents a specific generation of a car model, +/// including its production year, transmission type, +/// and rental cost per hour +/// +public class CarModelGeneration +{ + /// + /// Unique identifier of the car model generation + /// + public required Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Calendar year when this generation of the car model was produced + /// + public required int Year { get; set; } + + /// + /// Type of transmission used in this car model generation (manual, automatic, robotic or variable) + /// + public TransmissionType? TransmissionType { get; set; } + + /// + /// The car model ID to which this generation belongs (a class that describes + /// the main technical characteristics, such as the model name, + /// drive type, transmission type, body type, and vehicle class) + /// + public Guid ModelId { get; set; } + + /// + /// The car model to which this generation belongs (a class that describes + /// the main technical characteristics, such as the model name, + /// drive type, transmission type, body type, and vehicle class) + /// + public CarModel? Model { get; set; } + + /// + /// Rental cost per hour for vehicles of this model generation + /// + public required decimal HourCost { get; set; } +} diff --git a/CarRental.Domain/InternalData/ComponentEnums/BodyType.cs b/CarRental.Domain/InternalData/ComponentEnums/BodyType.cs new file mode 100644 index 000000000..16587ce1f --- /dev/null +++ b/CarRental.Domain/InternalData/ComponentEnums/BodyType.cs @@ -0,0 +1,92 @@ +namespace CarRental.Domain.InternalData.ComponentEnums; + +/// +/// Type of vehicle body style +/// +public enum BodyType +{ + /// + /// Ultra-small city car designed for maximum fuel efficiency and maneuverability + /// + CityCar, + + /// + /// Stylish car with a sloping roofline and typically two doors + /// + Coupe, + + /// + /// SUV-like vehicle built on a car platform, offering elevated ride height and versatility + /// + Crossover, + + /// + /// Car with a retractable soft or hard roof, offering open-air driving + /// + Cabriolet, + + /// + /// Traditional sedan with four side doors and a separate trunk compartment + /// + FourDoorSedan, + + /// + /// Sedan styled with a coupe-like roofline but four functional doors + /// + FourDoorCoupe, + + /// + /// Compact car with a rear door that opens upward, including the rear window + /// + Hatchback, + + /// + /// Extended luxury sedan with extra interior space, often chauffeur-driven + /// + Limousine, + + /// + /// Family-oriented van with car-like handling and flexible interior seating + /// + Minivan, + + /// + /// Rugged vehicle engineered for driving on unpaved and challenging terrain + /// + OffRoadCar, + + /// + /// Light-duty truck with an open cargo bed separate from the passenger cabin + /// + PickupTruck, + + /// + /// Lightweight two-seater sports car with a focus on driving dynamics + /// + Roadster, + + /// + /// Standard passenger car with a three-box configuration: engine, cabin, and trunk + /// + Sedan, + + /// + /// High-riding, versatile vehicle combining passenger and cargo capacity with off-road capability + /// + SportUtilityVehicle, + + /// + /// Low-slung, high-performance vehicle built for speed and agile handling + /// + SportsCar, + + /// + /// Passenger car with an extended roofline and large cargo area at the rear + /// + StationWagon, + + /// + /// Box-shaped vehicle designed primarily for transporting goods or multiple passengers + /// + Van +} \ No newline at end of file diff --git a/CarRental.Domain/InternalData/ComponentEnums/ClassType.cs b/CarRental.Domain/InternalData/ComponentEnums/ClassType.cs new file mode 100644 index 000000000..5c415b308 --- /dev/null +++ b/CarRental.Domain/InternalData/ComponentEnums/ClassType.cs @@ -0,0 +1,37 @@ +namespace CarRental.Domain.InternalData.ComponentEnums; + +/// +/// Vehicle classification based on size and segment +/// +public enum ClassType +{ + /// + /// Mini cars, the smallest urban vehicle class + /// + A, + + /// + /// Small cars, compact and economical for city driving + /// + B, + + /// + /// Medium cars, offering balanced space and comfort + /// + C, + + /// + /// Large family cars, with enhanced interior room and features + /// + D, + + /// + /// Executive cars, premium mid-size to large sedans + /// + E, + + /// + /// Luxury cars, high-end flagship vehicles with top-tier features + /// + F +} \ No newline at end of file diff --git a/CarRental.Domain/InternalData/ComponentEnums/DriveType.cs b/CarRental.Domain/InternalData/ComponentEnums/DriveType.cs new file mode 100644 index 000000000..d30a9f9ad --- /dev/null +++ b/CarRental.Domain/InternalData/ComponentEnums/DriveType.cs @@ -0,0 +1,22 @@ +namespace CarRental.Domain.InternalData.ComponentEnums; + +/// +/// The type of vehicle drive system +/// +public enum DriveType +{ + /// + /// Front-wheel drive, where power is delivered to the front wheels + /// + FrontWheel, + + /// + /// Rear-wheel drive, where power is delivered to the rear wheels + /// + RearWheel, + + /// + /// All-wheel drive, where power is distributed to all wheels + /// + AllWheel +} \ No newline at end of file diff --git a/CarRental.Domain/InternalData/ComponentEnums/TransmissionType.cs b/CarRental.Domain/InternalData/ComponentEnums/TransmissionType.cs new file mode 100644 index 000000000..50170c726 --- /dev/null +++ b/CarRental.Domain/InternalData/ComponentEnums/TransmissionType.cs @@ -0,0 +1,27 @@ +namespace CarRental.Domain.InternalData.ComponentEnums; + +/// +/// The type of vehicle transmission +/// +public enum TransmissionType +{ + /// + /// Manual gearbox with driver-operated gear shifting + /// + Manual, + + /// + /// Automatic gearbox requiring no driver input for shifting + /// + Automatic, + + /// + /// Robotic gearbox with automated clutch control + /// + Robotic, + + /// + /// Continuously variable transmission (CVT) with stepless gear ratio adjustment + /// + Variable +} \ No newline at end of file diff --git a/CarRental.Generator/CarRental.Generator.csproj b/CarRental.Generator/CarRental.Generator.csproj new file mode 100644 index 000000000..4d982f91f --- /dev/null +++ b/CarRental.Generator/CarRental.Generator.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/CarRental.Generator/Controller/GeneratorController.cs b/CarRental.Generator/Controller/GeneratorController.cs new file mode 100644 index 000000000..a9e418ecb --- /dev/null +++ b/CarRental.Generator/Controller/GeneratorController.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.Mvc; +using CarRental.Generator.Generation; +using CarRental.Application.Contracts.Rent; +using CarRental.Application.Contracts.Generator; + +namespace CarRental.Generator.Controller; + +/// +/// Controller for generating and publishing rental data to Kafka +/// +/// Kafka producer for sending messages +/// Service for generating rental contracts +/// Logger instance +[ApiController] +[Route("api/[controller]")] +public class GeneratorController( + KafkaProducer producer, + RentGeneratorService rentGenerator, + ILogger logger) : ControllerBase +{ + /// + /// Generates and publishes rental contracts to Kafka in batches + /// + /// Generation parameters + /// Cancellation token + /// Result with generation statistics + [HttpPost("rentals")] + public async Task> GenerateRentals( + [FromQuery] GenerateRentalsRequest request, + CancellationToken cancellationToken) + { + logger.LogInformation("Rental generation requested. TotalCount={TotalCount}, BatchSize={BatchSize}, DelayMs={DelayMs}", + request.TotalCount, request.BatchSize, request.DelayMs); + + var sent = 0; + var batches = 0; + + try + { + while (sent < request.TotalCount && !cancellationToken.IsCancellationRequested) + { + var remaining = request.TotalCount - sent; + var currentBatchSize = Math.Min(request.BatchSize, remaining); + + IList batch = rentGenerator.GenerateContract(currentBatchSize); + + await producer.ProduceMany(batch, cancellationToken); + + sent += currentBatchSize; + batches++; + + if (sent < request.TotalCount && request.DelayMs > 0) + { + await Task.Delay(request.DelayMs, cancellationToken); + } + } + + logger.LogInformation("Generation finished. TotalSent={TotalSent}, Batches={Batches}", sent, batches); + + return Ok(new GenerateRentalsResponse + { + TotalRequested = request.TotalCount, + TotalSent = sent, + BatchSize = request.BatchSize, + DelayMs = request.DelayMs, + Batches = batches, + Canceled = false + }); + } + catch (OperationCanceledException) + { + logger.LogInformation("Generation was canceled. TotalSent={TotalSent}/{TotalCount}", sent, request.TotalCount); + + return Ok(new GenerateRentalsResponse + { + TotalRequested = request.TotalCount, + TotalSent = sent, + BatchSize = request.BatchSize, + DelayMs = request.DelayMs, + Batches = batches, + Canceled = true + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error during generation/publishing. TotalSent={TotalSent}/{TotalCount}", sent, request.TotalCount); + return StatusCode(500, "An error occurred while generating and sending rentals"); + } + } +} \ No newline at end of file diff --git a/CarRental.Generator/Generation/GeneratorOptions.cs b/CarRental.Generator/Generation/GeneratorOptions.cs new file mode 100644 index 000000000..977c8d482 --- /dev/null +++ b/CarRental.Generator/Generation/GeneratorOptions.cs @@ -0,0 +1,67 @@ +namespace CarRental.Generator.Generation; + +/// +/// Configuration options for the rental data generator. +/// +public class GeneratorOptions +{ + private List? _carIdStrings; + private List? _clientIdStrings; + private List? _carIds; + private List? _clientIds; + + /// + /// List of available car IDs as strings (for configuration binding). + /// + public List CarIds + { + get => _carIdStrings ?? new(); + set + { + _carIdStrings = value; + _carIds = ParseGuids(value, "CarIds"); + } + } + + /// + /// List of available client IDs as strings (for configuration binding). + /// + public List ClientIds + { + get => _clientIdStrings ?? new(); + set + { + _clientIdStrings = value; + _clientIds = ParseGuids(value, "ClientIds"); + } + } + + /// + /// List of available car IDs as GUIDs (pre-parsed for performance). + /// + public List CarIdGuids => _carIds ?? new(); + + /// + /// List of available client IDs as GUIDs (pre-parsed for performance). + /// + public List ClientIdGuids => _clientIds ?? new(); + + private static List ParseGuids(List? values, string fieldName) + { + if (values == null) + return new List(); + var result = new List(); + for (var i = 0; i < values.Count; i++) + { + var value = values[i]; + if (string.IsNullOrWhiteSpace(value)) + throw new FormatException($"{fieldName}[{i}] is null or empty"); + if (!Guid.TryParse(value, out var guid)) + throw new FormatException($"{fieldName}[{i}] has invalid GUID format: {value}"); + + result.Add(guid); + } + + return result; + } +} \ No newline at end of file diff --git a/CarRental.Generator/Generation/RentGeneratorService.cs b/CarRental.Generator/Generation/RentGeneratorService.cs new file mode 100644 index 000000000..dd5280ef7 --- /dev/null +++ b/CarRental.Generator/Generation/RentGeneratorService.cs @@ -0,0 +1,37 @@ +using CarRental.Application.Contracts.Rent; +using Microsoft.Extensions.Options; +using Bogus; + +namespace CarRental.Generator.Generation; + +/// +/// Service for generating fake rental contract data +/// +/// Generator configuration options monitor +public class RentGeneratorService(IOptionsMonitor optionsMonitor) +{ + /// + /// Generates a specified number of fake rental contracts + /// + /// Number of contracts to generate + /// List of generated rental DTOs + public IList GenerateContract(int count) + { + var options = optionsMonitor.CurrentValue; + + if (!options.CarIdGuids.Any()) + throw new InvalidOperationException("CarIds list is empty. Check configuration."); + + if (!options.ClientIdGuids.Any()) + throw new InvalidOperationException("ClientIds list is empty. Check configuration."); + + var generatedRents = new Faker().CustomInstantiator(f => new RentCreateUpdateDto( + StartDateTime: f.Date.Soon(1), + Duration: f.Random.Double(1, 100), + CarId: f.PickRandom(options.CarIdGuids), + ClientId: f.PickRandom(options.ClientIdGuids) + )); + + return generatedRents.Generate(count); + } +} \ No newline at end of file diff --git a/CarRental.Generator/KafkaProducer.cs b/CarRental.Generator/KafkaProducer.cs new file mode 100644 index 000000000..224754060 --- /dev/null +++ b/CarRental.Generator/KafkaProducer.cs @@ -0,0 +1,105 @@ +using Confluent.Kafka; +using Microsoft.Extensions.Options; +using CarRental.Application.Contracts.Rent; +using System.Text.Json; + +namespace CarRental.Generator; + +/// +/// Kafka producer that serializes into JSON +/// and publishes messages to a configured topic +/// +/// Logger instance +/// Kafka producer +/// Producer settings +public class KafkaProducer( + ILogger logger, + IProducer producer, + IOptions options) +{ + private readonly KafkaProducerSettings _settings = options.Value + ?? throw new InvalidOperationException("KafkaProducerSettings must be configured."); + + /// + /// Sends a rent DTO as a JSON message to Kafka + /// + /// Rent DTO to send + /// Cancellation token + public async Task Produce(RentCreateUpdateDto dto, CancellationToken cancellationToken = default) + { + var payload = JsonSerializer.Serialize(dto); + + for (var attempt = 1; attempt <= _settings.MaxProduceAttempts; attempt++) + { + try + { + var result = await producer.ProduceAsync(_settings.TopicName, new Message { Value = payload }, cancellationToken); + + logger.LogInformation( + "Kafka message produced successfully. Topic={Topic}, Partition={Partition}, Offset={Offset}, " + + "CarId={CarId}, ClientId={ClientId}, StartDateTime={StartDateTime}, Duration={Duration}", + result.Topic, result.Partition.Value, result.Offset.Value, + dto.CarId, dto.ClientId, dto.StartDateTime, dto.Duration); + + return; + } + catch (ProduceException ex) when (attempt < _settings.MaxProduceAttempts) + { + logger.LogWarning(ex, + "Kafka produce attempt {Attempt}/{MaxAttempts} failed. Reason={Reason}. Retrying in {Delay}ms...", + attempt, _settings.MaxProduceAttempts, ex.Error.Reason, _settings.RetryDelayMs); + + await Task.Delay(_settings.RetryDelayMs, cancellationToken); + } + catch (ProduceException ex) + { + logger.LogError(ex, + "Kafka produce failed after {MaxAttempts} attempts. Reason={Reason}. CarId={CarId}, ClientId={ClientId}", + _settings.MaxProduceAttempts, ex.Error.Reason, dto.CarId, dto.ClientId); + + throw; + } + } + } + + /// + /// Sends a batch of rent DTOs as JSON messages to Kafka with controlled parallelism + /// + /// Rent DTOs to send + /// Cancellation token + public async Task ProduceMany(IList dtos, CancellationToken cancellationToken = default) + { + if (!dtos.Any()) + { + logger.LogWarning("No rent DTOs to produce to Kafka."); + return; + } + + logger.LogInformation("Starting to produce {Count} rent messages to Kafka topic: {Topic} with parallelism {MaxParallelism}", + dtos.Count, _settings.TopicName, _settings.MaxParallelism); + + using var semaphore = new SemaphoreSlim(_settings.MaxParallelism); + var tasks = new List(); + + foreach (var dto in dtos) + { + await semaphore.WaitAsync(cancellationToken); + + tasks.Add(Task.Run(async () => + { + try + { + await Produce(dto, cancellationToken); + } + finally + { + semaphore.Release(); + } + }, cancellationToken)); + } + + await Task.WhenAll(tasks); + + logger.LogInformation("Successfully produced all {Count} rent messages to Kafka", dtos.Count); + } +} \ No newline at end of file diff --git a/CarRental.Generator/KafkaProducerSettings.cs b/CarRental.Generator/KafkaProducerSettings.cs new file mode 100644 index 000000000..65160b56c --- /dev/null +++ b/CarRental.Generator/KafkaProducerSettings.cs @@ -0,0 +1,27 @@ +namespace CarRental.Generator; + +/// +/// Kafka settings used by the CarRental Kafka producer host +/// +public class KafkaProducerSettings +{ + /// + /// Kafka topic name used for producing messages + /// + public string TopicName { get; init; } = "car-rentals"; + + /// + /// Maximum number of attempts to send a message + /// + public int MaxProduceAttempts { get; init; } = 5; + + /// + /// Delay between produce retries in milliseconds + /// + public int RetryDelayMs { get; init; } = 1000; + + /// + /// Maximum number of parallel produce operations + /// + public int MaxParallelism { get; init; } = 10; +} \ No newline at end of file diff --git a/CarRental.Generator/Program.cs b/CarRental.Generator/Program.cs new file mode 100644 index 000000000..572ce8d9a --- /dev/null +++ b/CarRental.Generator/Program.cs @@ -0,0 +1,48 @@ +using CarRental.Generator; +using CarRental.Generator.Generation; +using CarRental.ServiceDefaults; +using Confluent.Kafka; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("Generator")); + +builder.AddServiceDefaults(); + +builder.AddKafkaProducer("car-rental-kafka"); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddControllers(); +builder.Services.AddAuthorization(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + var assemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.GetName().Name!.StartsWith("CarRental")) + .Distinct(); + + foreach (var assembly in assemblies) + { + var xmlFile = $"{assembly.GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + c.IncludeXmlComments(xmlPath); + } +}); + +var app = builder.Build(); +app.MapDefaultEndpoints(); +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/CarRental.Generator/Properties/launchSettings.json b/CarRental.Generator/Properties/launchSettings.json new file mode 100644 index 000000000..fe877566f --- /dev/null +++ b/CarRental.Generator/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52170", + "sslPort": 44384 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5112", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7160;http://localhost:5112", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CarRental.Generator/appsettings.Development.json b/CarRental.Generator/appsettings.Development.json new file mode 100644 index 000000000..770d3e931 --- /dev/null +++ b/CarRental.Generator/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "DetailedErrors": true, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CarRental.Generator/appsettings.json b/CarRental.Generator/appsettings.json new file mode 100644 index 000000000..4a1cde249 --- /dev/null +++ b/CarRental.Generator/appsettings.json @@ -0,0 +1,55 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Generator": { + "CarIds": [ + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002", + "550e8400-e29b-41d4-a716-446655440003", + "550e8400-e29b-41d4-a716-446655440004", + "550e8400-e29b-41d4-a716-446655440005", + "550e8400-e29b-41d4-a716-446655440006", + "550e8400-e29b-41d4-a716-446655440007", + "550e8400-e29b-41d4-a716-446655440008", + "550e8400-e29b-41d4-a716-446655440009", + "550e8400-e29b-41d4-a716-446655440010", + "550e8400-e29b-41d4-a716-446655440011", + "550e8400-e29b-41d4-a716-446655440012", + "550e8400-e29b-41d4-a716-446655440013", + "550e8400-e29b-41d4-a716-446655440014", + "550e8400-e29b-41d4-a716-446655440015", + "550e8400-e29b-41d4-a716-446655440016", + "550e8400-e29b-41d4-a716-446655440017", + "550e8400-e29b-41d4-a716-446655440018", + "550e8400-e29b-41d4-a716-446655440019", + "550e8400-e29b-41d4-a716-446655440020" + ], + "ClientIds": [ + "c11e4400-e29b-41d4-a716-446655440001", + "c11e4400-e29b-41d4-a716-446655440002", + "c11e4400-e29b-41d4-a716-446655440003", + "c11e4400-e29b-41d4-a716-446655440004", + "c11e4400-e29b-41d4-a716-446655440005", + "c11e4400-e29b-41d4-a716-446655440006", + "c11e4400-e29b-41d4-a716-446655440007", + "c11e4400-e29b-41d4-a716-446655440008", + "c11e4400-e29b-41d4-a716-446655440009", + "c11e4400-e29b-41d4-a716-446655440010", + "c11e4400-e29b-41d4-a716-446655440011", + "c11e4400-e29b-41d4-a716-446655440012", + "c11e4400-e29b-41d4-a716-446655440013", + "c11e4400-e29b-41d4-a716-446655440014", + "c11e4400-e29b-41d4-a716-446655440015", + "c11e4400-e29b-41d4-a716-446655440016", + "c11e4400-e29b-41d4-a716-446655440017", + "c11e4400-e29b-41d4-a716-446655440018", + "c11e4400-e29b-41d4-a716-446655440019", + "c11e4400-e29b-41d4-a716-446655440020" + ] + } +} diff --git a/CarRental.Infrastructure/CarRental.Infrastructure.csproj b/CarRental.Infrastructure/CarRental.Infrastructure.csproj new file mode 100644 index 000000000..81047227d --- /dev/null +++ b/CarRental.Infrastructure/CarRental.Infrastructure.csproj @@ -0,0 +1,19 @@ + + + net8.0 + enable + enable + True + + + + + + + + + + + + + diff --git a/CarRental.Infrastructure/CarRentalDbContext.cs b/CarRental.Infrastructure/CarRentalDbContext.cs new file mode 100644 index 000000000..f429c75fe --- /dev/null +++ b/CarRental.Infrastructure/CarRentalDbContext.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore; +using CarRental.Domain.DataModels; +using CarRental.Domain.InternalData.ComponentClasses; +using MongoDB.EntityFrameworkCore.Extensions; + +namespace CarRental.Infrastructure; +/// +/// Database context for managing car rental entities in MongoDB +/// +/// The options to be used by the DbContext +public class CarRentalDbContext(DbContextOptions options) : DbContext(options) +{ + /// + /// Gets the collection of cars + /// + public DbSet Cars { get; init; } + + /// + /// Gets the collection of clients + /// + public DbSet Clients { get; init; } + + /// + /// Gets the collection of rent records + /// + public DbSet Rents { get; init; } + + /// + /// Gets the collection of car models + /// + public DbSet CarModels { get; init; } + + /// + /// Gets the collection of car model generations + /// + public DbSet ModelGenerations { get; init; } + + /// + /// Configures the database schema and maps entities to MongoDB collections + /// + /// The builder being used to construct the model for this context + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + Database.AutoTransactionBehavior = AutoTransactionBehavior.Never; + + modelBuilder.Entity(builder => + { + builder.ToCollection("cars"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasElementName("_id"); + }); + + modelBuilder.Entity(builder => + { + builder.ToCollection("clients"); + builder.HasKey(cl => cl.Id); + builder.Property(cl => cl.Id).HasElementName("_id"); + }); + + modelBuilder.Entity(builder => + { + builder.ToCollection("rents"); + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).HasElementName("_id"); + builder.Property(r => r.CarId).HasElementName("car_id"); + builder.Property(r => r.ClientId).HasElementName("client_id"); + }); + + modelBuilder.Entity(builder => + { + builder.ToCollection("car_models"); + builder.HasKey(m => m.Id); + builder.Property(m => m.Id).HasElementName("_id"); + }); + + modelBuilder.Entity(builder => + { + builder.ToCollection("model_generations"); + builder.HasKey(g => g.Id); + builder.Property(g => g.Id).HasElementName("_id"); + }); + } +} diff --git a/CarRental.Infrastructure/DbInitializer.cs b/CarRental.Infrastructure/DbInitializer.cs new file mode 100644 index 000000000..5925fa8d2 --- /dev/null +++ b/CarRental.Infrastructure/DbInitializer.cs @@ -0,0 +1,71 @@ +using CarRental.Domain.DataSeed; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CarRental.Infrastructure; + +/// +/// Performs a conditional data seed by checking if the database is empty +/// and, if so, populating it with a predefined set of entities (from models to rents); +/// It ensures the system has necessary initial data while maintaining referential integrity through sequential updates +/// +public static class DbInitializer +{ + /// + /// Asynchronously seeds the database with initial car rental data if the CarModels table is empty + /// + /// The database context instance used to persist the seed data + /// The logger instance for capturing diagnostic information + /// A task representing the asynchronous seeding operation + public static async Task SeedData(CarRentalDbContext context, ILogger logger) + { + try + { + if (await context.CarModels.AnyAsync()) + { + logger.LogInformation("The database if already filled"); + return; + } + } + catch (Exception ex) + { + logger.LogCritical(ex, "Connection with database is not establised"); + } + + using var transaction = await context.Database.BeginTransactionAsync(); + try + { + var data = new DataSeed(); + logger.LogInformation("The process of database's filling is starting..."); + + await context.CarModels.AddRangeAsync(data.Models); + await context.SaveChangesAsync(); + logger.LogInformation("Car models were successfully uploaded"); + + await context.ModelGenerations.AddRangeAsync(data.Generations); + await context.SaveChangesAsync(); + logger.LogInformation("Model's generations were successfully uploaded"); + + await context.Cars.AddRangeAsync(data.Cars); + await context.SaveChangesAsync(); + logger.LogInformation("Cars were successfully uploaded"); + + await context.Clients.AddRangeAsync(data.Clients); + await context.SaveChangesAsync(); + logger.LogInformation("Clients were successfully uploaded"); + + await context.Rents.AddRangeAsync(data.Rents); + await context.SaveChangesAsync(); + logger.LogInformation("Rents were successfully uploaded"); + + await transaction.CommitAsync(); + logger.LogInformation("Database was successfully initialized!"); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + logger.LogError(ex, "The problem with filling database. Check logs for more information"); + throw; + } + } +} \ No newline at end of file diff --git a/CarRental.Infrastructure/InMemoryRepository/CarModelGenerationRepository.cs b/CarRental.Infrastructure/InMemoryRepository/CarModelGenerationRepository.cs new file mode 100644 index 000000000..4d08feb20 --- /dev/null +++ b/CarRental.Infrastructure/InMemoryRepository/CarModelGenerationRepository.cs @@ -0,0 +1,22 @@ +using CarRental.Domain.Interfaces; +using CarRental.Domain.InternalData.ComponentClasses; +using CarRental.Domain.DataSeed; + +namespace CarRental.Infrastructure.InMemoryRepository; + +/// +/// Repository for managing CarModelGeneration entities +/// Provides data access for vehicle model's generation (e.g., year) using the BaseRepository +/// +public class CarModelGenerationRepository(DataSeed data) : BaseRepository(data.Generations) +{ + /// + /// Gets the unique identifier from the specified CarModelGeneration entity + /// + protected override Guid GetEntityId(CarModelGeneration generation) => generation.Id; + + /// + /// Sets the unique identifier for the specified CarModelGeneration entity + /// + protected override void SetEntityId(CarModelGeneration generation, Guid id) => generation.Id = id; +} \ No newline at end of file diff --git a/CarRental.Infrastructure/InMemoryRepository/CarModelRepository.cs b/CarRental.Infrastructure/InMemoryRepository/CarModelRepository.cs new file mode 100644 index 000000000..3ec4a9b7d --- /dev/null +++ b/CarRental.Infrastructure/InMemoryRepository/CarModelRepository.cs @@ -0,0 +1,22 @@ +using CarRental.Domain.Interfaces; +using CarRental.Domain.InternalData.ComponentClasses; +using CarRental.Domain.DataSeed; + +namespace CarRental.Infrastructure.InMemoryRepository; + +/// +/// Repository for managing CarModel entities +/// Provides data access for vehicle models (e.g., model name) using the BaseRepository +/// +public class CarModelRepository(DataSeed data) : BaseRepository(data.Models) +{ + /// + /// Gets the unique identifier from the specified CarModel entity + /// + protected override Guid GetEntityId(CarModel model) => model.Id; + + /// + /// Sets the unique identifier for the specified CarModel entity + /// + protected override void SetEntityId(CarModel model, Guid id) => model.Id = id; +} \ No newline at end of file diff --git a/CarRental.Infrastructure/InMemoryRepository/CarRepository.cs b/CarRental.Infrastructure/InMemoryRepository/CarRepository.cs new file mode 100644 index 000000000..129b3e8c9 --- /dev/null +++ b/CarRental.Infrastructure/InMemoryRepository/CarRepository.cs @@ -0,0 +1,22 @@ +using CarRental.Domain.Interfaces; +using CarRental.Domain.DataModels; +using CarRental.Domain.DataSeed; + +namespace CarRental.Infrastructure.InMemoryRepository; + +/// +/// Repository for the Car entity +/// Inherits BaseRepository for in-memory CRUD operations +/// +public class CarRepository(DataSeed data) : BaseRepository(data.Cars) +{ + /// + /// Gets the unique identifier from the specified Car entity + /// + protected override Guid GetEntityId(Car car) => car.Id; + + /// + /// Sets the unique identifier for the specified Car entity + /// + protected override void SetEntityId(Car car, Guid id) => car.Id = id; +} \ No newline at end of file diff --git a/CarRental.Infrastructure/InMemoryRepository/ClientRepository.cs b/CarRental.Infrastructure/InMemoryRepository/ClientRepository.cs new file mode 100644 index 000000000..d9bf76d86 --- /dev/null +++ b/CarRental.Infrastructure/InMemoryRepository/ClientRepository.cs @@ -0,0 +1,22 @@ +using CarRental.Domain.Interfaces; +using CarRental.Domain.DataModels; +using CarRental.Domain.DataSeed; + +namespace CarRental.Infrastructure.InMemoryRepository; + +/// +/// Repository for the Client entity +/// Inherits BaseRepository for in-memory CRUD operations +/// +public class ClientRepository(DataSeed data) : BaseRepository(data.Clients) +{ + /// + /// Gets the unique identifier from the specified Client entity + /// + protected override Guid GetEntityId(Client client) => client.Id; + + /// + /// Sets the unique identifier for the specified Client entity + /// + protected override void SetEntityId(Client client, Guid id) => client.Id = id; +} \ No newline at end of file diff --git a/CarRental.Infrastructure/InMemoryRepository/RentRepository.cs b/CarRental.Infrastructure/InMemoryRepository/RentRepository.cs new file mode 100644 index 000000000..843f61603 --- /dev/null +++ b/CarRental.Infrastructure/InMemoryRepository/RentRepository.cs @@ -0,0 +1,22 @@ +using CarRental.Domain.Interfaces; +using CarRental.Domain.DataModels; +using CarRental.Domain.DataSeed; + +namespace CarRental.Infrastructure.InMemoryRepository; + +/// +/// Repository for the Rent entity +/// Inherits BaseRepository for in-memory CRUD operations +/// +public class RentRepository(DataSeed data) : BaseRepository(data.Rents) +{ + /// + /// Gets the unique identifier from the specified Rent entity + /// + protected override Guid GetEntityId(Rent rent) => rent.Id; + + /// + /// Sets the unique identifier for the specified Rent entity + /// + protected override void SetEntityId(Rent rent, Guid id) => rent.Id = id; +} \ No newline at end of file diff --git a/CarRental.Infrastructure/Kafka/Consumer.cs b/CarRental.Infrastructure/Kafka/Consumer.cs new file mode 100644 index 000000000..482ab5379 --- /dev/null +++ b/CarRental.Infrastructure/Kafka/Consumer.cs @@ -0,0 +1,163 @@ +using Confluent.Kafka; +using CarRental.Application.Contracts.Rent; +using CarRental.Application.Contracts.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace CarRental.Infrastructure.Kafka; + +/// +/// Background service that consumes rent messages from Kafka and saves them to database +/// +/// Logger for recording operations +/// Kafka consumer instance +/// Factory for creating service scopes +/// Consumer configuration settings +public class Consumer( + ILogger logger, + IConsumer consumer, + IServiceScopeFactory scopeFactory, + IOptions options) : BackgroundService +{ + private readonly ConsumerSettings _settings = options.Value; + + /// + /// Main execution loop that continuously consumes and processes messages from Kafka + /// + /// Cancellation token to stop the consumer + /// Task representing the asynchronous operation + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + consumer.Subscribe(_settings.TopicName); + logger.LogInformation("KafkaConsumer started on topic {TopicName}", _settings.TopicName); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to subscribe to topic {TopicName}", _settings.TopicName); + return; + } + + try + { + while (!stoppingToken.IsCancellationRequested) + { + ConsumeResult? message = null; + + try + { + message = consumer.Consume(TimeSpan.FromMilliseconds(_settings.ConsumeTimeoutMs)); + + if (message is null) + continue; + + if (message.IsPartitionEOF || message.Message == null) + { + logger.LogDebug("Reached end of partition or empty message"); + continue; + } + + var payload = message.Message?.Value; + + if (string.IsNullOrWhiteSpace(payload)) + { + logger.LogWarning("Empty payload. Topic={Topic}, Offset={Offset}", + message.Topic, message.Offset.Value); + continue; + } + + RentCreateUpdateDto? dto = null; + + for (var attempt = 1; attempt <= _settings.MaxDeserializeAttempts; attempt++) + { + try + { + dto = JsonSerializer.Deserialize(payload); + break; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Deserialization attempt {Attempt} failed", attempt); + } + } + + if (dto == null) + { + logger.LogError("Failed to deserialize after {MaxAttempts} attempts", + _settings.MaxDeserializeAttempts); + CommitIfNeeded(message); + continue; + } + + using var scope = scopeFactory.CreateScope(); + var rentService = scope.ServiceProvider.GetRequiredService>(); + + var savedRent = await rentService.Create(dto); + + CommitIfNeeded(message); + logger.LogInformation("Saved rent with Id: {RentId} from Kafka", savedRent.Id); + } + catch (ConsumeException ex) when (ex.Error.Code == ErrorCode.UnknownTopicOrPart) + { + logger.LogWarning("Topic not available, retrying..."); + await Task.Delay(1000, stoppingToken); + } + catch (ConsumeException ex) + { + logger.LogError(ex, "Consume error: {Reason}", ex.Error.Reason); + } + catch (OperationCanceledException) + { + logger.LogInformation("KafkaConsumer stopping"); + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error"); + } + } + } + finally + { + try + { + consumer.Unsubscribe(); + consumer.Close(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error closing consumer"); + } + logger.LogInformation("KafkaConsumer stopped"); + } + } + + /// + /// Commits the message offset if auto-commit is disabled + /// + /// The consumed message to commit + private void CommitIfNeeded(ConsumeResult message) + { + if (_settings.AutoCommitEnabled) + return; + + if (message == null || message.Message == null || message.IsPartitionEOF) + { + logger.LogDebug("Skipping commit for null/EOF message"); + return; + } + + try + { + consumer.Commit(message); + } + catch (KafkaException ex) + { + logger.LogWarning(ex, "Commit failed. Offset={Offset}", message.Offset.Value); + } + } +} \ No newline at end of file diff --git a/CarRental.Infrastructure/Kafka/ConsumerSettings.cs b/CarRental.Infrastructure/Kafka/ConsumerSettings.cs new file mode 100644 index 000000000..033cdacaf --- /dev/null +++ b/CarRental.Infrastructure/Kafka/ConsumerSettings.cs @@ -0,0 +1,33 @@ +namespace CarRental.Infrastructure.Kafka; + +/// +/// Kafka settings used by CarRental Kafka consumer +/// +public class ConsumerSettings +{ + /// + /// Kafka topic name used for consuming messages + /// + public string TopicName { get; init; } = "car-rentals"; + + /// + /// Consumer group ID for Kafka + /// + public string GroupId { get; init; } = "car-rental-consumer-group"; + + /// + /// Enables Kafka auto-commit for the consumer. + /// If false, the consumer commits offsets manually after successful processing + /// + public bool AutoCommitEnabled { get; init; } = false; + + /// + /// Poll timeout for consuming messages in milliseconds + /// + public int ConsumeTimeoutMs { get; init; } = 5000; + + /// + /// Maximum number of attempts to deserialize a message payload + /// + public int MaxDeserializeAttempts { get; init; } = 3; +} diff --git a/CarRental.Infrastructure/Repository/DbCarModelGenerationRepository.cs b/CarRental.Infrastructure/Repository/DbCarModelGenerationRepository.cs new file mode 100644 index 000000000..0b6fea4ec --- /dev/null +++ b/CarRental.Infrastructure/Repository/DbCarModelGenerationRepository.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using CarRental.Domain.InternalData.ComponentClasses; +using CarRental.Domain.Interfaces; + +namespace CarRental.Infrastructure.Repository; + +/// +/// Repository for managing car model generations in the database +/// +/// The database context for car rental data +public class DbCarModelGenerationRepository(CarRentalDbContext context) : IBaseRepository +{ + /// + /// Retrieves all model generations with their associated car models + /// + /// A list of all model generation entities + public async Task> ReadAll() + { + var generations = await context.ModelGenerations.ToListAsync(); + var modelIds = generations.Select(g => g.ModelId).Distinct().ToList(); + var models = await context.CarModels + .Where(m => modelIds.Contains(m.Id)) + .ToListAsync(); + foreach (var generation in generations) + { + generation.Model = models.FirstOrDefault(m => m.Id == generation.ModelId); + } + return generations; + } + + /// + /// Finds a specific model generation by id and loads its associated car model + /// + /// The unique identifier of the generation + /// The generation entity if found; otherwise, null + public async Task Read(Guid id) + { + var entity = await context.ModelGenerations.FirstOrDefaultAsync(x => x.Id == id); + if (entity != null) + { + entity.Model = await context.CarModels.FirstOrDefaultAsync(m => m.Id == entity.ModelId); + } + return entity; + } + + /// + /// Adds a new model generation to the database + /// + /// The model generation data to persist + /// The unique identifier of the created generation + public async Task Create(CarModelGeneration entity) + { + await context.ModelGenerations.AddAsync(entity); + await context.SaveChangesAsync(); + return entity.Id; + } + + /// + /// Updates an existing model generation record. + /// + /// The updated generation entity + /// The identifier of the generation to update + /// True if the changes were saved successfully; otherwise, false + public async Task Update(CarModelGeneration entity, Guid id) + { + context.ModelGenerations.Update(entity); + return await context.SaveChangesAsync() > 0; + } + + /// + /// Removes a model generation from the database by its identifier + /// + /// The unique identifier of the generation to delete + /// True if the deletion was successful; otherwise, false + public async Task Delete(Guid id) + { + var entity = await Read(id); + if (entity == null) return false; + context.ModelGenerations.Remove(entity); + return await context.SaveChangesAsync() > 0; + } +} \ No newline at end of file diff --git a/CarRental.Infrastructure/Repository/DbCarModelRepository.cs b/CarRental.Infrastructure/Repository/DbCarModelRepository.cs new file mode 100644 index 000000000..43ff6798c --- /dev/null +++ b/CarRental.Infrastructure/Repository/DbCarModelRepository.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using CarRental.Domain.InternalData.ComponentClasses; +using CarRental.Domain.Interfaces; + +namespace CarRental.Infrastructure.Repository; + +/// +/// Repository for managing car model entities in the database +/// +/// The database context for car rental data +public class DbCarModelRepository(CarRentalDbContext context) : IBaseRepository +{ + /// + /// Retrieves all car models from the database + /// + /// A list of all car model entities + public async Task> ReadAll() => await context.CarModels.ToListAsync(); + + /// + /// Finds a specific car model by its unique identifier + /// + /// The unique identifier of the car model + /// The car model entity if found; otherwise, null + public async Task Read(Guid id) => + (await context.CarModels.ToListAsync()).FirstOrDefault(x => x.Id == id); + + /// + /// Adds a new car model to the database + /// + /// The car model data to persist + /// The unique identifier of the created car model + public async Task Create(CarModel entity) + { + await context.CarModels.AddAsync(entity); + await context.SaveChangesAsync(); + return entity.Id; + } + + /// + /// Updates an existing car model record + /// + /// The updated car model entity + /// The identifier of the car model to update + /// True if the changes were saved successfully; otherwise, false + public async Task Update(CarModel entity, Guid id) + { + context.CarModels.Update(entity); + return await context.SaveChangesAsync() > 0; + } + + /// + /// Removes a car model from the database by its identifier + /// + /// The unique identifier of the car model to delete + /// True if the car model was deleted successfully; otherwise, false + public async Task Delete(Guid id) + { + var entity = await Read(id); + if (entity is null) return false; + context.CarModels.Remove(entity); + return await context.SaveChangesAsync() > 0; + } +} \ No newline at end of file diff --git a/CarRental.Infrastructure/Repository/DbCarRepository.cs b/CarRental.Infrastructure/Repository/DbCarRepository.cs new file mode 100644 index 000000000..ee7edb98e --- /dev/null +++ b/CarRental.Infrastructure/Repository/DbCarRepository.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using CarRental.Domain.DataModels; +using CarRental.Domain.Interfaces; + +namespace CarRental.Infrastructure.Repository; + +/// +/// Repository for managing car entities in the database +/// +/// The database context for car rental data +public class DbCarRepository(CarRentalDbContext context) : IBaseRepository +{ + /// + /// Retrieves all cars from the database + /// + /// A list of all car entities + public async Task> ReadAll() => await context.Cars.ToListAsync(); + + /// + /// Finds a specific car by its unique identifier + /// + /// The unique identifier of the car + /// The car entity if found; otherwise, null + public async Task Read(Guid id) => + (await context.Cars.ToListAsync()).FirstOrDefault(x => x.Id == id); + + /// + /// Adds a new car to the database + /// + /// The car data to persist + /// The unique identifier of the created car + public async Task Create(Car entity) + { + await context.Cars.AddAsync(entity); + await context.SaveChangesAsync(); + return entity.Id; + } + + /// + /// Updates an existing car record + /// + /// The updated car entity + /// The identifier of the car to update + /// True if the changes were saved successfully; otherwise, false + public async Task Update(Car entity, Guid id) + { + context.Cars.Update(entity); + return await context.SaveChangesAsync() > 0; + } + + /// + /// Removes a car from the database by its identifier + /// + /// The unique identifier of the car to delete + /// True if the car was deleted successfully; otherwise, false + public async Task Delete(Guid id) + { + var entity = await Read(id); + if (entity is null) return false; + context.Cars.Remove(entity); + return await context.SaveChangesAsync() > 0; + } +} \ No newline at end of file diff --git a/CarRental.Infrastructure/Repository/DbClientRepository.cs b/CarRental.Infrastructure/Repository/DbClientRepository.cs new file mode 100644 index 000000000..e974b6e9c --- /dev/null +++ b/CarRental.Infrastructure/Repository/DbClientRepository.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using CarRental.Domain.DataModels; +using CarRental.Domain.Interfaces; + +namespace CarRental.Infrastructure.Repository; + +/// +/// Repository for managing client entities in the database +/// +/// The database context for car rental data +public class DbClientRepository(CarRentalDbContext context) : IBaseRepository +{ + /// + /// Retrieves all clients from the database + /// + /// A list of all client entities + public async Task> ReadAll() => await context.Clients.ToListAsync(); + + /// + /// Finds a specific client by their unique identifier + /// + /// The unique identifier of the client + /// The client entity if found; otherwise, null + public async Task Read(Guid id) => + (await context.Clients.ToListAsync()).FirstOrDefault(x => x.Id == id); + + /// + /// Adds a new client to the database + /// + /// The client data to persist + /// The unique identifier of the created client + public async Task Create(Client entity) + { + await context.Clients.AddAsync(entity); + await context.SaveChangesAsync(); + return entity.Id; + } + + /// + /// Updates an existing client's information + /// + /// The updated client entity + /// The identifier of the client to update + /// True if the changes were saved successfully; otherwise, false + public async Task Update(Client entity, Guid id) + { + context.Clients.Update(entity); + return await context.SaveChangesAsync() > 0; + } + + /// + /// Removes a client from the database by their identifier + /// + /// The unique identifier of the client to delete + /// True if the client was deleted successfully; otherwise, false + public async Task Delete(Guid id) + { + var entity = await Read(id); + if (entity is null) return false; + context.Clients.Remove(entity); + return await context.SaveChangesAsync() > 0; + } +} \ No newline at end of file diff --git a/CarRental.Infrastructure/Repository/DbRentRepository.cs b/CarRental.Infrastructure/Repository/DbRentRepository.cs new file mode 100644 index 000000000..4c8089c5a --- /dev/null +++ b/CarRental.Infrastructure/Repository/DbRentRepository.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using CarRental.Domain.DataModels; +using CarRental.Domain.Interfaces; + +namespace CarRental.Infrastructure.Repository; + +/// +/// Repository for managing rent records in the database +/// +/// The database context for car rental data +public class DbRentRepository(CarRentalDbContext context) : IBaseRepository +{ + /// + /// Retrieves all rent records with populated car and client details + /// + /// A list of all rent entities + public async Task> ReadAll() => + (await context.Rents.ToListAsync()) + .Select(r => + { + r.Car = context.Cars.FirstOrDefault(c => c.Id == r.CarId)!; + r.Client = context.Clients.FirstOrDefault(c => c.Id == r.ClientId)!; + return r; + }).ToList(); + + /// + /// Retrieves a specific rent record by its identifier with linked data + /// + /// The unique identifier of the rent + /// The rent entity if found; otherwise, null + public async Task Read(Guid id) + { + var list = await context.Rents.ToListAsync(); + var entity = list.FirstOrDefault(r => r.Id == id); + if (entity != null) + { + entity.Car = context.Cars.FirstOrDefault(c => c.Id == entity.CarId)!; + entity.Client = context.Clients.FirstOrDefault(c => c.Id == entity.ClientId)!; + } + return entity; + } + + /// + /// Creates a new rent record in the database + /// + /// The rent entity to create + /// The identifier of the created rent + public async Task Create(Rent entity) + { + await context.Rents.AddAsync(entity); + await context.SaveChangesAsync(); + return entity.Id; + } + + /// + /// Updates an existing rent record + /// + /// The updated rent entity + /// The identifier of the rent to update + /// True if the update was successful + public async Task Update(Rent entity, Guid id) + { + context.Rents.Update(entity); + return await context.SaveChangesAsync() > 0; + } + + /// + /// Deletes a rent record by its identifier + /// + /// The identifier of the rent to delete + /// True if the deletion was successful + public async Task Delete(Guid id) + { + var entity = await Read(id); + if (entity == null) return false; + context.Rents.Remove(entity); + return await context.SaveChangesAsync() > 0; + } +} diff --git a/CarRental.ServiceDefaults/CarRental.ServiceDefaults.csproj b/CarRental.ServiceDefaults/CarRental.ServiceDefaults.csproj new file mode 100644 index 000000000..a98507561 --- /dev/null +++ b/CarRental.ServiceDefaults/CarRental.ServiceDefaults.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + + diff --git a/CarRental.ServiceDefaults/Extensions.cs b/CarRental.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..ecaded646 --- /dev/null +++ b/CarRental.ServiceDefaults/Extensions.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace CarRental.ServiceDefaults; + +/// +/// This code provides a set of .NET Aspire service defaults +/// that standardize microservice infrastructure by configuring logs, metrics, and traces, +/// liveness and readiness, and service discovery. It also integrates resilience policies +/// for HTTP clients and sets up OTLP exporters, ensuring all services +/// in the cluster have consistent observability and fault-tolerance out of the box +/// +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) && + !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)) + .AddHttpClientInstrumentation(); + }); + builder.AddOpenTelemetryExporters(); + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), new[] { "live" }); + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks(HealthEndpointPath); + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + return app; + } +} \ No newline at end of file diff --git a/CarRental.Tests/CarRental.Tests.csproj b/CarRental.Tests/CarRental.Tests.csproj new file mode 100644 index 000000000..5a9c2b32f --- /dev/null +++ b/CarRental.Tests/CarRental.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + True + + + + + + + + + + + + + + + + + + diff --git a/CarRental.Tests/DomainTests.cs b/CarRental.Tests/DomainTests.cs new file mode 100644 index 000000000..210b40602 --- /dev/null +++ b/CarRental.Tests/DomainTests.cs @@ -0,0 +1,166 @@ +using CarRental.Domain.DataSeed; +using Xunit.Abstractions; + +namespace CarRental.Tests; + +/// +/// Unit tests for rental domain analytics, initialized with shared test data and output helper. +/// The primary constructor accepts: +/// - : Pre-filled domain entities (clients, cars, models, rentals). +/// - : xUnit helper for diagnostic logging in test results. +/// +public class DomainTests( + DataSeed fixture, + ITestOutputHelper output) : IClassFixture +{ + /// + /// 1. Output of clients who rented vehicles of a specified model, + /// ordered by last name, first name, and patronymic + /// + [Fact] + public void GetClientsByModelName_WhenModelHasRentals_ReturnsClientsSortedByFullName() + { + var target = fixture.Models[9]; // Volkswagen Transporter + + var targetClients = fixture.Rents + .Where(r => r.Car?.ModelGeneration.Model?.Name == target.Name) + .Select(r => r.Client) + .Distinct() + .OrderBy(c => c.LastName) + .ThenBy(c => c.FirstName) + .ThenBy(c => c.Patronymic ?? string.Empty) + .ToList(); + foreach (var client in targetClients) + { + output.WriteLine($"{client.Id} {client.LastName} {client.FirstName} {client.Patronymic ?? ""} {client.BirthDate?.ToString() ?? ""}"); + } + + var sorted = targetClients + .OrderBy(c => c.LastName) + .ThenBy(c => c.FirstName) + .ThenBy(c => c.Patronymic ?? string.Empty) + .Select(c => c.Id) + .ToArray(); + + Assert.Equal(sorted, targetClients.Select(c => c.Id).ToArray()); + + } + + /// + /// 2. Output of vehicles currently in rental as of January 1, 2025, 10:00 + /// + [Fact] + public void GetCarsInRent_WhenCheckedAtBaseTime_ReturnsActiveRentalCars() + { + var now = new DateTime(2025, 1, 1, 10, 0, 0, DateTimeKind.Utc); + + var carsInRent = fixture.Rents + .Where(r => r.StartDateTime <= now && + now < r.StartDateTime.AddHours(r.Duration)) + .Select(r => r.Car) + .Distinct() + .OrderBy(c => c.NumberPlate) + .ToList(); + + foreach (var car in carsInRent) + { + output.WriteLine($"{car.Id} {car.ModelGeneration.Model?.Name ?? ""} {car.NumberPlate} {car.Colour}"); + } + + Assert.Single(carsInRent); + } + + /// + /// 3. Output of the top 5 most frequently rented vehicles, + /// sorted in descending order by rental count + /// + [Fact] + public void GetTopRentedCars_WhenAllRentalsExist_ReturnsTop5CarsOrderedByRentalCountDescending() + { + var topCars = fixture.Rents + .GroupBy(r => r.Car) + .Select(g => new { Car = g.Key, RentCount = g.Count() }) + .OrderByDescending(x => x.RentCount) + .ThenBy(x => x.Car.NumberPlate) + .Take(5) + .ToList(); + + foreach (var item in topCars) + { + output.WriteLine($"{item.Car.Id} {item.Car.ModelGeneration.Model?.Name ?? ""} {item.RentCount}"); + } + + Assert.Equal(5, topCars.Count); + + Assert.True( + topCars.SequenceEqual( + topCars.OrderByDescending(x => x.RentCount) + .ThenBy(x => x.Car.NumberPlate) + ) + ); + } + + /// + /// 4. Output of rental counts for every vehicle in the fleet, + /// including vehicles with zero rentals + /// + [Fact] + public void GetAllCars_WhenFleetIsInitialized_ReturnsAllCarsWithRentalCountIncludingZero() + { + var cars = fixture.Cars + .OrderBy(c => c.NumberPlate) + .ToList(); + + foreach (var car in cars) + { + var rentCount = fixture.Rents.Count(r => r.Car.Id == car.Id); + + output.WriteLine( + $"{car.Id} {car.ModelGeneration.Model?.Name ?? "Unknown"} " + + $"{car.NumberPlate} {car.Colour} {rentCount}" + ); + } + + Assert.Equal(20, cars.Count); + } + + /// + /// 5. Output of the top 5 clients with the highest total rental amount, + /// calculated as the sum of (duration × hourly cost) for all their rentals + /// + [Fact] + public void GetTopClientsByTotalRentalAmount_WhenRentalsHaveDurationAndCost_ReturnsTop5ClientsOrderedByAmountDescending() + { + var clientTotals = fixture.Rents + .GroupBy(r => r.Client) + .Select(g => new + { + Client = g.Key, + TotalAmount = g.Sum(r => + Convert.ToDecimal(r.Duration) * r.Car.ModelGeneration.HourCost) + }) + .OrderByDescending(x => x.TotalAmount) + .ThenBy(x => x.Client.LastName) + .ThenBy(x => x.Client.FirstName) + .Take(5) + .ToList(); + + foreach (var item in clientTotals) + { + output.WriteLine( + $"{item.Client.LastName} {item.Client.FirstName} " + + $"{item.Client.Id} {item.TotalAmount:F2}" + ); + } + + Assert.Equal(5, clientTotals.Count); + + Assert.True( + clientTotals.SequenceEqual( + clientTotals.OrderByDescending(x => x.TotalAmount) + .ThenBy(x => x.Client.LastName) + .ThenBy(x => x.Client.FirstName) + ) + ); + } +} diff --git a/CarRental.sln b/CarRental.sln new file mode 100644 index 000000000..4af6600c6 --- /dev/null +++ b/CarRental.sln @@ -0,0 +1,149 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Domain", "CarRental.Domain\CarRental.Domain.csproj", "{06B41FC1-BC46-473B-8E7E-394749D2A734}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Tests", "CarRental.Tests\CarRental.Tests.csproj", "{B253FF47-F3FD-4F60-934B-0A2649ACA810}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Api", "CarRental.Api\CarRental.Api.csproj", "{7B16F96C-0A36-4D10-A10A-5AD14619865F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Application", "CarRental.Application\CarRental.Application.csproj", "{D1CFC635-6F68-727F-E951-9D087D3A3DBF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Infrastructure", "CarRental.Infrastructure\CarRental.Infrastructure.csproj", "{BC450137-ECDC-D0A6-9C70-887579DD4AD0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.AppHost", "CarRental.AppHost\CarRental.AppHost.csproj", "{B8A65BF5-D8AA-4612-B694-388605683F4E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.ServiceDefaults", "CarRental.ServiceDefaults\CarRental.ServiceDefaults.csproj", "{0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Generator", "CarRental.Generator\CarRental.Generator.csproj", "{BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Application.Contracts", "CarRental.Application.Contracts\CarRental.Application.Contracts.csproj", "{F4DCE45C-696E-49E2-957B-97F6D57AC5F5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Debug|x64.ActiveCfg = Debug|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Debug|x64.Build.0 = Debug|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Debug|x86.ActiveCfg = Debug|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Debug|x86.Build.0 = Debug|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Release|Any CPU.Build.0 = Release|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Release|x64.ActiveCfg = Release|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Release|x64.Build.0 = Release|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Release|x86.ActiveCfg = Release|Any CPU + {06B41FC1-BC46-473B-8E7E-394749D2A734}.Release|x86.Build.0 = Release|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Debug|x64.ActiveCfg = Debug|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Debug|x64.Build.0 = Debug|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Debug|x86.ActiveCfg = Debug|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Debug|x86.Build.0 = Debug|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Release|Any CPU.Build.0 = Release|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Release|x64.ActiveCfg = Release|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Release|x64.Build.0 = Release|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Release|x86.ActiveCfg = Release|Any CPU + {B253FF47-F3FD-4F60-934B-0A2649ACA810}.Release|x86.Build.0 = Release|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Debug|x64.ActiveCfg = Debug|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Debug|x64.Build.0 = Debug|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Debug|x86.Build.0 = Debug|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Release|Any CPU.Build.0 = Release|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Release|x64.ActiveCfg = Release|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Release|x64.Build.0 = Release|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Release|x86.ActiveCfg = Release|Any CPU + {7B16F96C-0A36-4D10-A10A-5AD14619865F}.Release|x86.Build.0 = Release|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Debug|x64.Build.0 = Debug|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Debug|x86.Build.0 = Debug|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Release|Any CPU.Build.0 = Release|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Release|x64.ActiveCfg = Release|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Release|x64.Build.0 = Release|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Release|x86.ActiveCfg = Release|Any CPU + {D1CFC635-6F68-727F-E951-9D087D3A3DBF}.Release|x86.Build.0 = Release|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Debug|x64.ActiveCfg = Debug|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Debug|x64.Build.0 = Debug|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Debug|x86.ActiveCfg = Debug|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Debug|x86.Build.0 = Debug|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Release|Any CPU.Build.0 = Release|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Release|x64.ActiveCfg = Release|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Release|x64.Build.0 = Release|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Release|x86.ActiveCfg = Release|Any CPU + {BC450137-ECDC-D0A6-9C70-887579DD4AD0}.Release|x86.Build.0 = Release|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Debug|x64.ActiveCfg = Debug|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Debug|x64.Build.0 = Debug|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Debug|x86.ActiveCfg = Debug|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Debug|x86.Build.0 = Debug|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Release|Any CPU.Build.0 = Release|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Release|x64.ActiveCfg = Release|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Release|x64.Build.0 = Release|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Release|x86.ActiveCfg = Release|Any CPU + {B8A65BF5-D8AA-4612-B694-388605683F4E}.Release|x86.Build.0 = Release|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Debug|x64.ActiveCfg = Debug|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Debug|x64.Build.0 = Debug|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Debug|x86.ActiveCfg = Debug|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Debug|x86.Build.0 = Debug|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Release|Any CPU.Build.0 = Release|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Release|x64.ActiveCfg = Release|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Release|x64.Build.0 = Release|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Release|x86.ActiveCfg = Release|Any CPU + {0DCB1E82-BC4B-4CFB-A63F-FD0A292AE748}.Release|x86.Build.0 = Release|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Debug|x64.ActiveCfg = Debug|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Debug|x64.Build.0 = Debug|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Debug|x86.ActiveCfg = Debug|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Debug|x86.Build.0 = Debug|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Release|Any CPU.Build.0 = Release|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Release|x64.ActiveCfg = Release|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Release|x64.Build.0 = Release|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Release|x86.ActiveCfg = Release|Any CPU + {BE76D53D-F0C3-E6B6-15A8-337EEF8BC9EC}.Release|x86.Build.0 = Release|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Debug|x64.Build.0 = Debug|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Debug|x86.Build.0 = Debug|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Release|Any CPU.Build.0 = Release|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Release|x64.ActiveCfg = Release|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Release|x64.Build.0 = Release|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Release|x86.ActiveCfg = Release|Any CPU + {F4DCE45C-696E-49E2-957B-97F6D57AC5F5}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DDDF992B-860F-484F-9B9D-CE109A6F3417} + EndGlobalSection +EndGlobal