diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml new file mode 100644 index 000000000..513be352c --- /dev/null +++ b/.github/workflows/dotnet-tests.yml @@ -0,0 +1,28 @@ +name: .NET Tests + +on: + push: + branches: ["main", "lab2"] + pull_request: + branches: ["main", "lab2"] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - 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: Test + run: dotnet test --no-build --verbosity normal --configuration Release diff --git a/CarRentalService.Api/CarRentalService.Api.csproj b/CarRentalService.Api/CarRentalService.Api.csproj new file mode 100644 index 000000000..7d29e4e19 --- /dev/null +++ b/CarRentalService.Api/CarRentalService.Api.csproj @@ -0,0 +1,31 @@ + + + net8.0 + enable + enable + true + $(NoWarn);1591 + CarRentalService.Api.xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CarRentalService.Api/Configuration/RentalGeneratorOptions.cs b/CarRentalService.Api/Configuration/RentalGeneratorOptions.cs new file mode 100644 index 000000000..7a8a3ae96 --- /dev/null +++ b/CarRentalService.Api/Configuration/RentalGeneratorOptions.cs @@ -0,0 +1,29 @@ +namespace CarRentalService.Api.Configuration; + +/// +/// Configuration options for the rental generator service +/// +public class RentalGeneratorOptions +{ + public const string SectionName = "RentalGenerator"; + + /// + /// Default number of rental contracts to generate + /// + public int Count { get; set; } = 100; + + /// + /// Batch size for streaming rental contracts + /// + public int BatchSize { get; set; } = 10; + + /// + /// Retry delay in seconds for reconnection attempts + /// + public int RetryDelay { get; set; } = 5; + + /// + /// gRPC service address for the rental generator + /// + public string? GrpcAddress { get; set; } +} diff --git a/CarRentalService.Api/Controllers/AnalyticsController.cs b/CarRentalService.Api/Controllers/AnalyticsController.cs new file mode 100644 index 000000000..fdc79c785 --- /dev/null +++ b/CarRentalService.Api/Controllers/AnalyticsController.cs @@ -0,0 +1,93 @@ +using CarRentalService.Application.Contracts; +using CarRentalService.Application.Contracts.Analytics; +using Microsoft.AspNetCore.Mvc; + +namespace CarRentalService.Api.Controllers; + +/// +/// Controller for analytics and business intelligence endpoints +/// +/// Analytics service dependency +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class AnalyticsController(IAnalyticsService analyticsService) : ControllerBase +{ + /// + /// Retrieves customers who rented cars of a specific model name + /// + /// Car model name to filter by + /// List of customer names + [HttpGet("clients-by-model-name")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetClientsByModelName([FromQuery] string modelName) + { + var result = await analyticsService.ReadCustomersByModelName(modelName); + return Ok(result); + } + + /// + /// Retrieves customers who rented cars of a specific model ID + /// + /// Car model ID to filter by + /// List of customer names + [HttpGet("clients-by-model-id/{modelId:int}")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetClientsByModelId(int modelId) + { + var result = await analyticsService.ReadCustomersByModelId(modelId); + return Ok(result); + } + + /// + /// Retrieves cars currently in rent at a specific time + /// + /// Time to check for active rentals (default: current time) + /// List of currently rented cars + [HttpGet("cars-in-rent")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetCarsInRent([FromQuery] DateTime? atTime = null) + { + var checkTime = atTime ?? DateTime.Now; + var result = await analyticsService.ReadCarsInRent(checkTime); + return Ok(result); + } + + /// + /// Retrieves top N most frequently rented cars + /// + /// Number of top cars to return (default: 5) + /// List of top rented cars with statistics + [HttpGet("top-rented-cars")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetTopRentedCars([FromQuery] int count = 5) + { + var result = await analyticsService.ReadTopMostRentedCars(count); + return Ok(result); + } + + /// + /// Retrieves rental count for all cars + /// + /// List of all cars with their rental counts + [HttpGet("all-cars-with-rental-count")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetAllCarsWithRentalCount() + { + var result = await analyticsService.ReadAllCarsWithRentalCount(); + return Ok(result); + } + + /// + /// Retrieves top N customers by total rental revenue + /// + /// Number of top customers to return (default: 5) + /// List of top customers by revenue + [HttpGet("top-customers-by-revenue")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetTopCustomersByRevenue([FromQuery] int count = 5) + { + var result = await analyticsService.ReadTopCustomersByTotalAmount(count); + return Ok(result); + } +} diff --git a/CarRentalService.Api/Controllers/CarModelGenerationsController.cs b/CarRentalService.Api/Controllers/CarModelGenerationsController.cs new file mode 100644 index 000000000..c1cdce486 --- /dev/null +++ b/CarRentalService.Api/Controllers/CarModelGenerationsController.cs @@ -0,0 +1,106 @@ +using CarRentalService.Application.Contracts.CarModelGeneration; +using Microsoft.AspNetCore.Mvc; + +namespace CarRentalService.Api.Controllers; + +/// +/// Controller for managing car model generations +/// +/// Car model generation service dependency +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class CarModelGenerationsController(ICarModelGenerationService service) : ControllerBase +{ + /// + /// Retrieves all car model generations + /// + /// List of all car model generations + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetAll() + { + var result = await service.GetAll(); + return Ok(result); + } + + /// + /// Retrieves a specific car model generation by ID + /// + /// Car model generation identifier + /// Car model generation details + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(CarModelGenerationDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task> GetById(int id) + { + var generation = await service.Get(id); + if (generation == null) + { + return NotFound($"CarModelGeneration with ID {id} not found."); + } + return Ok(generation); + } + + /// + /// Creates a new car model generation + /// + /// Car model generation creation data + /// Created car model generation + [HttpPost] + [ProducesResponseType(typeof(CarModelGenerationDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + public async Task> Create([FromBody] CarModelGenerationCreateUpdateDto dto) + { + try + { + var createdGeneration = await service.Create(dto); + return CreatedAtAction(nameof(GetById), new { id = createdGeneration.Id }, createdGeneration); + } + catch (Exception ex) + { + return BadRequest($"Invalid car model generation data: {ex.Message}"); + } + } + + /// + /// Updates an existing car model generation + /// + /// Car model generation identifier + /// Car model generation update data + /// No content on success + [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task Update(int id, [FromBody] CarModelGenerationCreateUpdateDto dto) + { + try + { + await service.Update(dto, id); + return NoContent(); + } + catch (KeyNotFoundException) + { + return NotFound($"CarModelGeneration with ID {id} not found."); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a car model generation + /// + /// Car model generation identifier + /// No content on success + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + var result = await service.Delete(id); + return result ? NoContent() : NotFound(); + } +} diff --git a/CarRentalService.Api/Controllers/CarModelsController.cs b/CarRentalService.Api/Controllers/CarModelsController.cs new file mode 100644 index 000000000..4a7266c6c --- /dev/null +++ b/CarRentalService.Api/Controllers/CarModelsController.cs @@ -0,0 +1,120 @@ +using CarRentalService.Application.Contracts.CarModel; +using CarRentalService.Application.Contracts.Cars; +using Microsoft.AspNetCore.Mvc; + +namespace CarRentalService.Api.Controllers; + +/// +/// Controller for managing car models +/// +/// Car model service dependency +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class CarModelsController(ICarModelService service) : ControllerBase +{ + /// + /// Retrieves all car models + /// + /// List of all car models + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetAll() + { + var result = await service.GetAll(); + return Ok(result); + } + + /// + /// Retrieves a specific car model by ID + /// + /// Car model identifier + /// Car model details + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(CarModelDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task> GetById(int id) + { + var carModel = await service.Get(id); + if (carModel == null) + { + return NotFound($"CarModel with ID {id} not found."); + } + return Ok(carModel); + } + + /// + /// Creates a new car model + /// + /// Car model creation data + /// Created car model + [HttpPost] + [ProducesResponseType(typeof(CarModelDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + public async Task> Create([FromBody] CarModelCreateUpdateDto dto) + { + try + { + var createdCarModel = await service.Create(dto); + return CreatedAtAction(nameof(GetById), new { id = createdCarModel.Id }, createdCarModel); + } + catch (Exception ex) + { + return BadRequest($"Invalid car model data: {ex.Message}"); + } + } + + /// + /// Updates an existing car model + /// + /// Car model identifier + /// Car model update data + /// No content on success + [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task Update(int id, [FromBody] CarModelCreateUpdateDto dto) + { + try + { + await service.Update(dto, id); + return NoContent(); + } + catch (KeyNotFoundException) + { + return NotFound($"CarModel with ID {id} not found."); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a car model + /// + /// Car model identifier + /// No content on success + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + var result = await service.Delete(id); + return result ? NoContent() : NotFound(); + } + + /// + /// Retrieves all cars associated with a specific car model + /// + /// Car model identifier + /// List of cars belonging to the specified model + [HttpGet("{id:int}/cars")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetCarsByModel(int id) + { + var result = await service.GetCarsByModelAsync(id); + return Ok(result); + } +} diff --git a/CarRentalService.Api/Controllers/CarsController.cs b/CarRentalService.Api/Controllers/CarsController.cs new file mode 100644 index 000000000..63b72bfe4 --- /dev/null +++ b/CarRentalService.Api/Controllers/CarsController.cs @@ -0,0 +1,106 @@ +using CarRentalService.Application.Contracts.Cars; +using Microsoft.AspNetCore.Mvc; + +namespace CarRentalService.Api.Controllers; + +/// +/// Controller for managing cars +/// +/// Car service dependency +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class CarsController(ICarService service) : ControllerBase +{ + /// + /// Retrieves all cars + /// + /// List of all cars + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetAll() + { + var result = await service.GetAll(); + return Ok(result); + } + + /// + /// Retrieves a specific car by ID + /// + /// Car identifier + /// Car details + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(CarDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task> GetById(int id) + { + var car = await service.Get(id); + if (car == null) + { + return NotFound($"Car with ID {id} not found."); + } + return Ok(car); + } + + /// + /// Creates a new car + /// + /// Car creation data + /// Created car + [HttpPost] + [ProducesResponseType(typeof(CarDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + public async Task> Create([FromBody] CarCreateUpdateDto dto) + { + try + { + var createdCar = await service.Create(dto); + return CreatedAtAction(nameof(GetById), new { id = createdCar.Id }, createdCar); + } + catch (Exception ex) + { + return BadRequest($"Invalid car data: {ex.Message}"); + } + } + + /// + /// Updates an existing car + /// + /// Car identifier + /// Car update data + /// No content on success + [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task Update(int id, [FromBody] CarCreateUpdateDto dto) + { + try + { + var result = await service.Update(dto, id); + return NoContent(); + } + catch (KeyNotFoundException) + { + return NotFound($"Car with ID {id} not found."); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a car + /// + /// Car identifier + /// No content on success + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + var result = await service.Delete(id); + return result ? NoContent() : NotFound(); + } +} diff --git a/CarRentalService.Api/Controllers/ClientsController.cs b/CarRentalService.Api/Controllers/ClientsController.cs new file mode 100644 index 000000000..95c8ef3e1 --- /dev/null +++ b/CarRentalService.Api/Controllers/ClientsController.cs @@ -0,0 +1,101 @@ +using CarRentalService.Application.Contracts.Clients; +using Microsoft.AspNetCore.Mvc; + +namespace CarRentalService.Api.Controllers; + +/// +/// Controller for managing clients +/// +/// Client service dependency +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class ClientsController(IClientService service) : ControllerBase +{ + /// + /// Retrieves all clients + /// + /// List of all clients + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetAll() + { + var result = await service.GetAll(); + return Ok(result); + } + + /// + /// Retrieves a specific client by ID + /// + /// Client identifier + /// Client details + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(ClientDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task> GetById(int id) + { + var client = await service.Get(id); + if (client == null) + { + return NotFound($"Client with ID {id} not found."); + } + return Ok(client); + } + + /// + /// Creates a new client + /// + /// Client creation data + /// Created client + [HttpPost] + [ProducesResponseType(typeof(ClientDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + public async Task> Create([FromBody] ClientCreateUpdateDto dto) + { + try + { + var createdClient = await service.Create(dto); + return CreatedAtAction(nameof(GetById), new { id = createdClient.Id }, createdClient); + } + catch (Exception ex) + { + return BadRequest($"Invalid client data: {ex.Message}"); + } + } + + /// + /// Updates an existing client + /// + /// Client identifier + /// Client update data + /// No content on success + [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task Update(int id, [FromBody] ClientCreateUpdateDto dto) + { + try + { + var result = await service.Update(dto, id); + return NoContent(); + } + catch (KeyNotFoundException) + { + return NotFound($"Client with ID {id} not found."); + } + } + + /// + /// Deletes a client + /// + /// Client identifier + /// No content on success + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + var result = await service.Delete(id); + return result ? NoContent() : NotFound(); + } +} diff --git a/CarRentalService.Api/Controllers/GeneratorController.cs b/CarRentalService.Api/Controllers/GeneratorController.cs new file mode 100644 index 000000000..f11a16630 --- /dev/null +++ b/CarRentalService.Api/Controllers/GeneratorController.cs @@ -0,0 +1,162 @@ +using CarRentalService.Application.Contracts.Grpc; +using CarRentalService.Application.Contracts.Rents; +using CarRentalService.Application.Contracts.Cars; +using CarRentalService.Application.Contracts.Clients; +using Microsoft.AspNetCore.Mvc; +using AutoMapper; +using Grpc.Core; + +namespace CarRentalService.Api.Controllers; + +/// +/// Controller for managing rental contract generation via gRPC +/// +[ApiController] +[Route("api/[controller]")] +public class GeneratorController( + RentalIngestor.RentalIngestorClient grpcClient, + IRentService rentService, + ICarService carService, + IClientService clientService, + IMapper mapper, + ILogger logger) : ControllerBase +{ + + /// + /// Gets current system status and statistics + /// + [HttpGet("status")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task GetStatus() + { + try + { + var cars = await carService.GetAll(); + var clients = await clientService.GetAll(); + var rents = await rentService.GetAll(); + + return Ok(new + { + CarsCount = cars.Count, + ClientsCount = clients.Count, + RentsCount = rents.Count, + GeneratorAddress = "https://localhost:7000", + Status = "The system is working", + Timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + return StatusCode(500, new { Error = ex.Message }); + } + } + + /// + /// Manually triggers test data generation via gRPC + /// + /// Number of rental contracts to generate + /// Batch size for streaming + [HttpPost("generate-test")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task GenerateTestData([FromQuery] int count = 10, [FromQuery] int batchSize = 5) + { + if (count <= 0 || batchSize <= 0) + return BadRequest("Count and batchSize must be greater than 0"); + + try + { + logger.LogInformation("Starting manual generation of {Count} contracts (batchSize={BatchSize})", + count, batchSize); + + var requestId = Guid.NewGuid().ToString("N"); + var generated = 0; + var saved = 0; + + using var call = grpcClient.StreamRentals(); + + // Sending a request + await call.RequestStream.WriteAsync(new RentalGenerationRequest + { + RequestId = requestId, + Count = count, + BatchSize = batchSize + }); + await call.RequestStream.CompleteAsync(); + + // Get and save data + await foreach (var batch in call.ResponseStream.ReadAllAsync()) + { + if (batch.RequestId != requestId) + continue; + + logger.LogInformation("Received batch: {BatchCount} contracts", batch.Rentals.Count); + generated += batch.Rentals.Count; + + foreach (var rental in batch.Rentals) + { + try + { + var carExists = await carService.Get(rental.CarId) != null; + var clientExists = await clientService.Get(rental.CustomerId) != null; + + if (!carExists || !clientExists) + { + logger.LogWarning( + "Skipping the contract: CarId={CarId} (exists={CarExists}), ClientId={ClientId} (exists={ClientExists})", + rental.CarId, carExists, rental.CustomerId, clientExists); + continue; + } + + // Save + var dto = mapper.Map(rental); + await rentService.Create(dto); + saved++; + + logger.LogDebug("Saving the contract: CarId={CarId}, ClientId={ClientId}", + rental.CarId, rental.CustomerId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error saving the contract"); + } + } + + if (batch.IsFinal) + break; + } + + return Ok(new + { + RequestId = requestId, + Generated = generated, + Saved = saved, + Failed = generated - saved, + Message = "Generation completed successfully", + Timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in generating test data"); + return StatusCode(500, new { Error = ex.Message }); + } + } + + /// + /// Tests gRPC client connection status + /// + [HttpGet("test-grpc-connection")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult TestGrpcConnection() + { + try + { + return Ok("gRPC client is registered and ready to work"); + } + catch (Exception ex) + { + return BadRequest($"Error gRPC: {ex.Message}"); + } + } +} diff --git a/CarRentalService.Api/Controllers/RentsController.cs b/CarRentalService.Api/Controllers/RentsController.cs new file mode 100644 index 000000000..ea49bb1ac --- /dev/null +++ b/CarRentalService.Api/Controllers/RentsController.cs @@ -0,0 +1,131 @@ +using CarRentalService.Application.Contracts.Rents; +using Microsoft.AspNetCore.Mvc; + +namespace CarRentalService.Api.Controllers; + +/// +/// Controller for managing rental transactions +/// +/// Rent service dependency +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class RentsController(IRentService service) : ControllerBase +{ + /// + /// Retrieves all rental transactions + /// + /// List of all rental transactions + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetAll() + { + var result = await service.GetAll(); + return Ok(result); + } + + /// + /// Retrieves a specific rental transaction by ID + /// + /// Rent identifier + /// Rental transaction details + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(RentDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task> GetById(int id) + { + var rent = await service.Get(id); + if (rent == null) + { + return NotFound($"Rent with ID {id} not found."); + } + return Ok(rent); + } + + /// + /// Creates a new rental transaction + /// + /// Rental creation data + /// Created rental transaction + [HttpPost] + [ProducesResponseType(typeof(RentDto), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + public async Task> Create([FromBody] RentCreateUpdateDto dto) + { + try + { + var createdRent = await service.Create(dto); + return CreatedAtAction(nameof(GetById), new { id = createdRent.Id }, createdRent); + } + catch (Exception ex) + { + return BadRequest($"Invalid rent data: {ex.Message}"); + } + } + + /// + /// Updates an existing rental transaction + /// + /// Rent identifier + /// Rental update data + /// No content on success + [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + public async Task Update(int id, [FromBody] RentCreateUpdateDto dto) + { + try + { + var result = await service.Update(dto, id); + return NoContent(); + } + catch (KeyNotFoundException) + { + return NotFound($"Rent with ID {id} not found."); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a rental transaction + /// + /// Rent identifier + /// No content on success + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(int id) + { + var result = await service.Delete(id); + return result ? NoContent() : NotFound(); + } + + /// + /// Retrieves all rental transactions for a specific client + /// + /// Client identifier + /// List of rental transactions for the client + [HttpGet("by-client/{clientId:int}")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetByClient(int clientId) + { + var result = await service.GetRentalsByClientAsync(clientId); + return Ok(result); + } + + /// + /// Retrieves all rental transactions for a specific car + /// + /// Car identifier + /// List of rental transactions for the car + [HttpGet("by-car/{carId:int}")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetByCar(int carId) + { + var result = await service.GetRentalsByCarAsync(carId); + return Ok(result); + } +} diff --git a/CarRentalService.Api/Program.cs b/CarRentalService.Api/Program.cs new file mode 100644 index 000000000..b794afe22 --- /dev/null +++ b/CarRentalService.Api/Program.cs @@ -0,0 +1,168 @@ +using Microsoft.EntityFrameworkCore; +using CarRentalService.Domain; +using CarRentalService.Domain.Models; +using CarRentalService.Application.Contracts.Cars; +using CarRentalService.Application.Contracts.Clients; +using CarRentalService.Application.Contracts.Rents; +using CarRentalService.Application.Contracts.CarModel; +using CarRentalService.Application.Contracts.CarModelGeneration; +using CarRentalService.Application.Contracts; +using CarRentalService.Application; +using CarRentalService.Application.Services; +using CarRentalService.Infrastructure.EfCore.Repositories; +using CarRentalService.Infrastructure.EfCore; +using CarRentalService.Domain.TestData; +using CarRentalService.ServiceDefaults; +using MongoDB.Driver; +using System.Text.Json.Serialization; +using System.Reflection; +using CarRentalService.Api.Services; +using CarRentalService.Application.Contracts.Grpc; +using CarRentalService.Api.Configuration; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.Configure( + builder.Configuration.GetSection(RentalGeneratorOptions.SectionName)); + +// AutoMapper +builder.Services.AddAutoMapper(config => config.AddProfile()); + +// Test data +builder.Services.AddSingleton(); + +// Repository +builder.Services.AddScoped, CarEfCoreRepository>(); +builder.Services.AddScoped, CarModelEfCoreRepository>(); +builder.Services.AddScoped, CarModelGenerationEfCoreRepository>(); +builder.Services.AddScoped, CustomerEfCoreRepository>(); +builder.Services.AddScoped, RentEfCoreRepository>(); + +// Service +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.AddMongoDBClient("carrental"); + +builder.Services.AddDbContext((services, options) => +{ + var db = services.GetRequiredService(); + + options.UseMongoDB(db.Client, db.DatabaseNamespace.DatabaseName); +}); + +// gRPC client +builder.Services.AddGrpcClient((serviceProvider, o) => +{ + var options = serviceProvider.GetRequiredService>().Value; + var address = options.GrpcAddress ?? "https://localhost:7000"; + o.Address = new Uri(address); +}) +.ConfigureChannel(o => +{ + o.MaxReceiveMessageSize = 32 * 1024 * 1024; + o.MaxSendMessageSize = 32 * 1024 * 1024; +}); + +builder.Services.AddAutoMapper(typeof(CarRentalGrpcProfile)); +builder.Services.AddMemoryCache(); +builder.Services.AddHostedService(); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", + builder => + { + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +// Controller +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + +// Swagger +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + var apiXmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var apiXmlPath = Path.Combine(AppContext.BaseDirectory, apiXmlFile); + + var contractsXmlFile = "CarRentalService.Application.Contracts.xml"; + var contractsXmlPath = Path.Combine(AppContext.BaseDirectory, contractsXmlFile); + + if (File.Exists(apiXmlPath)) + { + c.IncludeXmlComments(apiXmlPath); + } + + if (File.Exists(contractsXmlPath)) + { + c.IncludeXmlComments(contractsXmlPath); + } +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.UseCors("AllowAll"); + +// Database seeding +try +{ + using var scope = app.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var testData = scope.ServiceProvider.GetRequiredService(); + + context.Database.EnsureCreated(); + + if (!context.CarModels.Any()) + { + Console.WriteLine("Database is empty, seeding with test data..."); + + context.CarModels.AddRange(testData.CarModels); + context.SaveChanges(); + context.CarModelGenerations.AddRange(testData.CarModelGenerations); + context.SaveChanges(); + context.Customers.AddRange(testData.Customers); + context.SaveChanges(); + context.Cars.AddRange(testData.Cars); + context.SaveChanges(); + context.Rents.AddRange(testData.Rents); + context.SaveChanges(); + Console.WriteLine("Database seeded successfully with test data!"); + } + else + { + Console.WriteLine("Database already contains data."); + } +} +catch (Exception ex) +{ + Console.WriteLine($"Error during database seeding: {ex.Message}"); +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/CarRentalService.Api/Properties/launchSettings.json b/CarRentalService.Api/Properties/launchSettings.json new file mode 100644 index 000000000..c5927d28b --- /dev/null +++ b/CarRentalService.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "profiles": { + "CarRentalService.API": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CarRentalService.Api/Services/CarRentalGrpcProfile.cs b/CarRentalService.Api/Services/CarRentalGrpcProfile.cs new file mode 100644 index 000000000..028782119 --- /dev/null +++ b/CarRentalService.Api/Services/CarRentalGrpcProfile.cs @@ -0,0 +1,23 @@ +using AutoMapper; +using CarRentalService.Application.Contracts.Grpc; +using CarRentalService.Application.Contracts.Rents; + +namespace CarRentalService.Api.Services; + +/// +/// AutoMapper profile for mapping gRPC messages to DTOs +/// +public class CarRentalGrpcProfile : Profile +{ + /// + /// Initializes a new instance of the CarRentalGrpcProfile class + /// + public CarRentalGrpcProfile() + { + CreateMap() + .ForMember(dest => dest.CarId, opt => opt.MapFrom(src => src.CarId)) + .ForMember(dest => dest.ClientId, opt => opt.MapFrom(src => src.CustomerId)) + .ForMember(dest => dest.StartTime, opt => opt.MapFrom(src => DateTime.Parse(src.StartTime))) + .ForMember(dest => dest.Duration, opt => opt.MapFrom(src => src.DurationHours)); + } +} diff --git a/CarRentalService.Api/Services/RentalIngestorClientService.cs b/CarRentalService.Api/Services/RentalIngestorClientService.cs new file mode 100644 index 000000000..aaade56a1 --- /dev/null +++ b/CarRentalService.Api/Services/RentalIngestorClientService.cs @@ -0,0 +1,172 @@ +using AutoMapper; +using CarRentalService.Application.Contracts.Grpc; +using CarRentalService.Application.Contracts.Rents; +using CarRentalService.Application.Contracts.Cars; +using CarRentalService.Application.Contracts.Clients; +using CarRentalService.Api.Configuration; +using Grpc.Core; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace CarRentalService.Api.Services; + +/// +/// Background gRPC client service for receiving rental contracts +/// +public class CarRentalGrpcClient( + RentalIngestor.RentalIngestorClient client, + IServiceScopeFactory scopeFactory, + IMapper mapper, + ILogger logger, + IOptions options, + IMemoryCache cache +) : BackgroundService +{ + private static readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(10); + private readonly RentalGeneratorOptions _options = options.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("CarRentalGrpcClient service starting..."); + + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ConnectAndProcessAsync(stoppingToken); + + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + catch (RpcException ex) when (!stoppingToken.IsCancellationRequested) + { + logger.LogError(ex, "gRPC stream error: {StatusCode} - {StatusDetail}", + ex.StatusCode, ex.Status.Detail); + await Task.Delay(TimeSpan.FromSeconds(_options.RetryDelay), stoppingToken); + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + logger.LogError(ex, "Unexpected error in CarRentalGrpcClient"); + await Task.Delay(TimeSpan.FromSeconds(_options.RetryDelay), stoppingToken); + } + } + } + + private async Task ConnectAndProcessAsync(CancellationToken stoppingToken) + { + var count = _options.Count; + var batchSize = _options.BatchSize; + + logger.LogInformation("Connecting to RentalGenerator gRPC service..."); + + using var call = client.StreamRentals(cancellationToken: stoppingToken); + var requestId = Guid.NewGuid().ToString("N"); + + var writerTask = Task.Run(async () => + { + await call.RequestStream.WriteAsync(new RentalGenerationRequest + { + RequestId = requestId, + Count = count, + BatchSize = batchSize + }, stoppingToken); + + await call.RequestStream.CompleteAsync(); + }, stoppingToken); + + await foreach (var batch in call.ResponseStream.ReadAllAsync(stoppingToken)) + { + if (batch.RequestId != requestId) + continue; + + await ProcessBatchAsync(batch, stoppingToken); + + if (batch.IsFinal) + { + logger.LogInformation("Finished receiving rentals for RequestId={RequestId}", requestId); + break; + } + } + + await writerTask; + } + + private async Task ProcessBatchAsync(RentalBatchStreamMessage batch, CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + + var rentalService = scope.ServiceProvider.GetRequiredService(); + var carService = scope.ServiceProvider.GetRequiredService(); + var clientService = scope.ServiceProvider.GetRequiredService(); + + var validRentals = new List(); + + foreach (var rental in batch.Rentals) + { + if (!await ValidateEntityExistsAsync(rental.CarId, "Car", + async (id, token) => await carService.Get(id) != null, ct)) + continue; + + if (!await ValidateEntityExistsAsync(rental.CustomerId, "Client", + async (id, token) => await clientService.Get(id) != null, ct)) + continue; + + var dto = mapper.Map(rental); + validRentals.Add(dto); + } + + var createdCount = 0; + foreach (var rentalDto in validRentals) + { + try + { + await rentalService.Create(rentalDto); + createdCount++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to create rental for CarId={CarId}, ClientId={ClientId}", + rentalDto.CarId, rentalDto.ClientId); + } + } + + logger.LogInformation( + "Processed batch: Total={Total}, Valid={Valid}, Created={Created}, IsFinal={IsFinal}", + batch.Rentals.Count, validRentals.Count, createdCount, batch.IsFinal); + } + + private async Task ValidateEntityExistsAsync( + TId id, + string entityName, + Func> existenceCheck, + CancellationToken ct) + { + var cacheKey = $"{entityName}:exists:{id}"; + + if (cache.TryGetValue(cacheKey, out bool cached)) + return cached; + + bool exists; + try + { + exists = await existenceCheck(id, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Error checking existence of {Entity} with id {Id}", entityName, id); + exists = false; + } + catch (OperationCanceledException) + { + throw; + } + + cache.Set(cacheKey, exists, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheTtl + }); + + return exists; + } +} diff --git a/CarRentalService.Api/appsettings.Development.json b/CarRentalService.Api/appsettings.Development.json new file mode 100644 index 000000000..88b8948eb --- /dev/null +++ b/CarRentalService.Api/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "CarRentalService.API.Services": "Information", + "CarRentalService.API.Controllers": "Information" + } + } +} diff --git a/CarRentalService.Api/appsettings.json b/CarRentalService.Api/appsettings.json new file mode 100644 index 000000000..9a029fa5d --- /dev/null +++ b/CarRentalService.Api/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "RentalGenerator": { + "GrpcAddress": "https://localhost:7000", + "Count": 50, + "BatchSize": 10, + "RetryDelay": 5, + "MaxRetries": 3 + }, + "AllowedHosts": "*" +} diff --git a/CarRentalService.AppHost/AppHost.cs b/CarRentalService.AppHost/AppHost.cs new file mode 100644 index 000000000..9bb35994d --- /dev/null +++ b/CarRentalService.AppHost/AppHost.cs @@ -0,0 +1,28 @@ +/// +/// Aspire AppHost for Car Rental Service application orchestration +/// +var builder = DistributedApplication.CreateBuilder(args); + +builder.Configuration["Logging:EventLog:LogLevel:Default"] = "None"; +builder.Configuration["Logging:EventLog:LogLevel:Microsoft.AspNetCore"] = "None"; + +// Add MongoDB +var mongo = builder.AddMongoDB("mongo").AddDatabase("carrental"); + +// Add generator with parameters +var batchSize = builder.AddParameter("GeneratorBatchSize"); +var waitTime = builder.AddParameter("GeneratorWaitTime"); + +var generator = builder.AddProject("generator") + .WithEnvironment("Generator:BatchSize", batchSize) + .WithEnvironment("Generator:WaitTime", waitTime); + +// Add main API +builder.AddProject("carrental-api") + .WithReference(mongo) + .WithReference(generator) + .WithEnvironment("RentalGenerator:GrpcAddress", generator.GetEndpoint("https")) + .WaitFor(mongo) + .WaitFor(generator); + +builder.Build().Run(); diff --git a/CarRentalService.AppHost/CarRentalService.AppHost.csproj b/CarRentalService.AppHost/CarRentalService.AppHost.csproj new file mode 100644 index 000000000..0953451b9 --- /dev/null +++ b/CarRentalService.AppHost/CarRentalService.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net8.0 + enable + enable + 53327de9-2e8f-4d99-9fe1-1cf49498fb5e + + + + + + + + + + + + + diff --git a/CarRentalService.AppHost/Properties/launchSettings.json b/CarRentalService.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..d3390a565 --- /dev/null +++ b/CarRentalService.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17217;http://localhost:15297", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21065", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23071", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22221" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15297", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19159", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18074", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20015" + } + } + } +} diff --git a/CarRentalService.AppHost/appsettings.json b/CarRentalService.AppHost/appsettings.json new file mode 100644 index 000000000..0cbabc4e2 --- /dev/null +++ b/CarRentalService.AppHost/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "Parameters": { + "GeneratorBatchSize": 10, + "GeneratorWaitTime": 2 + } +} diff --git a/CarRentalService.Application.Contracts/Analytics/CarRentalCountResponse.cs b/CarRentalService.Application.Contracts/Analytics/CarRentalCountResponse.cs new file mode 100644 index 000000000..3497b3a55 --- /dev/null +++ b/CarRentalService.Application.Contracts/Analytics/CarRentalCountResponse.cs @@ -0,0 +1,27 @@ +namespace CarRentalService.Application.Contracts.Analytics; + +/// +/// Response for car rental count analytics +/// +public class CarRentalCountResponse +{ + /// + /// Car identifier + /// + public int CarId { get; set; } + + /// + /// License plate number + /// + public string LicensePlate { get; set; } = string.Empty; + + /// + /// Car model name + /// + public string ModelName { get; set; } = string.Empty; + + /// + /// Number of rentals + /// + public int RentalCount { get; set; } +} diff --git a/CarRentalService.Application.Contracts/Analytics/CarRentalResponse.cs b/CarRentalService.Application.Contracts/Analytics/CarRentalResponse.cs new file mode 100644 index 000000000..f5b04ab5d --- /dev/null +++ b/CarRentalService.Application.Contracts/Analytics/CarRentalResponse.cs @@ -0,0 +1,42 @@ +namespace CarRentalService.Application.Contracts.Analytics; + +/// +/// Responses for analytics endpoints +/// +public class CarRentalResponse +{ + /// + /// Car identifier + /// + public int CarId { get; set; } + + /// + /// License plate number + /// + public string LicensePlate { get; set; } = string.Empty; + + /// + /// Car color + /// + public string Color { get; set; } = string.Empty; + + /// + /// Car model name + /// + public string ModelName { get; set; } = string.Empty; + + /// + /// Client name + /// + public string ClientName { get; set; } = string.Empty; + + /// + /// Rental start time + /// + public DateTime RentalStart { get; set; } + + /// + /// Rental end time + /// + public DateTime RentalEnd { get; set; } +} diff --git a/CarRentalService.Application.Contracts/Analytics/TopCarResponse.cs b/CarRentalService.Application.Contracts/Analytics/TopCarResponse.cs new file mode 100644 index 000000000..49c26dc1a --- /dev/null +++ b/CarRentalService.Application.Contracts/Analytics/TopCarResponse.cs @@ -0,0 +1,37 @@ +namespace CarRentalService.Application.Contracts.Analytics; + +/// +/// Response for top rented cars analytics +/// +public class TopCarResponse +{ + /// + /// Car identifier + /// + public int CarId { get; set; } + + /// + /// License plate number + /// + public string LicensePlate { get; set; } = string.Empty; + + /// + /// Car model name + /// + public string ModelName { get; set; } = string.Empty; + + /// + /// Number of rentals + /// + public int RentalCount { get; set; } + + /// + /// Total rental hours + /// + public double TotalHours { get; set; } + + /// + /// Total revenue + /// + public decimal TotalRevenue { get; set; } +} diff --git a/CarRentalService.Application.Contracts/Analytics/TopCustomerResponse.cs b/CarRentalService.Application.Contracts/Analytics/TopCustomerResponse.cs new file mode 100644 index 000000000..d454d55a6 --- /dev/null +++ b/CarRentalService.Application.Contracts/Analytics/TopCustomerResponse.cs @@ -0,0 +1,27 @@ +namespace CarRentalService.Application.Contracts.Analytics; + +/// +/// Response for top customers by revenue analytics +/// +public class TopCustomerResponse +{ + /// + /// Customer identifier + /// + public int CustomerId { get; set; } + + /// + /// Full name + /// + public string FullName { get; set; } = string.Empty; + + /// + /// Number of rentals + /// + public int RentalCount { get; set; } + + /// + /// Total revenue + /// + public decimal TotalRevenue { get; set; } +} diff --git a/CarRentalService.Application.Contracts/CarModel/CarModelCreateUpdateDto.cs b/CarRentalService.Application.Contracts/CarModel/CarModelCreateUpdateDto.cs new file mode 100644 index 000000000..3788d0c70 --- /dev/null +++ b/CarRentalService.Application.Contracts/CarModel/CarModelCreateUpdateDto.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRentalService.Application.Contracts.CarModel; + +/// +/// DTO for creating or updating a car model +/// +public class CarModelCreateUpdateDto +{ + /// + /// Model name + /// + [Required] + [StringLength(100)] + public required string Name { get; set; } + + /// + /// Drive type + /// + [Required] + [StringLength(50)] + public required string DriveType { get; set; } + + /// + /// Number of seats + /// + [Required] + [Range(1, 20)] + public required int SeatCount { get; set; } + + /// + /// Body type + /// + [Required] + [StringLength(50)] + public required string BodyType { get; set; } + + /// + /// Car class + /// + [Required] + [StringLength(50)] + public required string CarClass { get; set; } +} diff --git a/CarRentalService.Application.Contracts/CarModel/CarModelDto.cs b/CarRentalService.Application.Contracts/CarModel/CarModelDto.cs new file mode 100644 index 000000000..96c89ec50 --- /dev/null +++ b/CarRentalService.Application.Contracts/CarModel/CarModelDto.cs @@ -0,0 +1,37 @@ +namespace CarRentalService.Application.Contracts.CarModel; + +/// +/// DTO for car model responses +/// +public class CarModelDto +{ + /// + /// Model identifier + /// + public int Id { get; set; } + + /// + /// Model name + /// + public string Name { get; set; } = string.Empty; + + /// + /// Drive type + /// + public string DriveType { get; set; } = string.Empty; + + /// + /// Number of seats + /// + public int SeatCount { get; set; } + + /// + /// Body type + /// + public string BodyType { get; set; } = string.Empty; + + /// + /// Car class + /// + public string CarClass { get; set; } = string.Empty; +} diff --git a/CarRentalService.Application.Contracts/CarModel/ICarModelService.cs b/CarRentalService.Application.Contracts/CarModel/ICarModelService.cs new file mode 100644 index 000000000..4ee5707fc --- /dev/null +++ b/CarRentalService.Application.Contracts/CarModel/ICarModelService.cs @@ -0,0 +1,14 @@ +using CarRentalService.Application.Contracts.Cars; + +namespace CarRentalService.Application.Contracts.CarModel; + +/// +/// Car model service interface +/// +public interface ICarModelService : IApplicationService +{ + /// + /// Retrieves all cars associated with a specific car model + /// + public Task> GetCarsByModelAsync(int modelId); +} diff --git a/CarRentalService.Application.Contracts/CarModelGeneration/CarModelGenerationCreateUpdateDto.cs b/CarRentalService.Application.Contracts/CarModelGeneration/CarModelGenerationCreateUpdateDto.cs new file mode 100644 index 000000000..a13b84e4c --- /dev/null +++ b/CarRentalService.Application.Contracts/CarModelGeneration/CarModelGenerationCreateUpdateDto.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRentalService.Application.Contracts.CarModelGeneration; + +/// +/// DTO for creating or updating a car model generation +/// +public class CarModelGenerationCreateUpdateDto +{ + /// + /// Car model identifier + /// + [Required] + [Range(1, int.MaxValue)] + public required int CarModelId { get; set; } + + /// + /// Production year + /// + [Required] + [Range(1900, 2100)] + public required int ProductionYear { get; set; } + + /// + /// Engine volume in liters + /// + [Required] + [Range(0.5, 10.0)] + public required double EngineVolume { get; set; } + + /// + /// Transmission type + /// + [Required] + [StringLength(50)] + public required string TransmissionType { get; set; } + + /// + /// Rental cost per hour + /// + [Required] + [Range(0.01, 10000)] + public required decimal RentalCostPerHour { get; set; } +} diff --git a/CarRentalService.Application.Contracts/CarModelGeneration/CarModelGenerationDto.cs b/CarRentalService.Application.Contracts/CarModelGeneration/CarModelGenerationDto.cs new file mode 100644 index 000000000..1d65b3468 --- /dev/null +++ b/CarRentalService.Application.Contracts/CarModelGeneration/CarModelGenerationDto.cs @@ -0,0 +1,42 @@ +namespace CarRentalService.Application.Contracts.CarModelGeneration; + +/// +/// DTO for car model generation responses +/// +public class CarModelGenerationDto +{ + /// + /// Generation identifier + /// + public int Id { get; set; } + + /// + /// Car model identifier + /// + public int CarModelId { get; set; } + + /// + /// Car model name + /// + public string CarModelName { get; set; } = string.Empty; + + /// + /// Production year + /// + public int ProductionYear { get; set; } + + /// + /// Engine volume in liters + /// + public double EngineVolume { get; set; } + + /// + /// Transmission type + /// + public string TransmissionType { get; set; } = string.Empty; + + /// + /// Rental cost per hour + /// + public decimal RentalCostPerHour { get; set; } +} diff --git a/CarRentalService.Application.Contracts/CarModelGeneration/ICarModelGenerationService.cs b/CarRentalService.Application.Contracts/CarModelGeneration/ICarModelGenerationService.cs new file mode 100644 index 000000000..b28cdf19c --- /dev/null +++ b/CarRentalService.Application.Contracts/CarModelGeneration/ICarModelGenerationService.cs @@ -0,0 +1,8 @@ +namespace CarRentalService.Application.Contracts.CarModelGeneration; + +/// +/// Car model generation service interface +/// +public interface ICarModelGenerationService : IApplicationService +{ +} diff --git a/CarRentalService.Application.Contracts/CarRentalService.Application.Contracts.csproj b/CarRentalService.Application.Contracts/CarRentalService.Application.Contracts.csproj new file mode 100644 index 000000000..74b9a6a40 --- /dev/null +++ b/CarRentalService.Application.Contracts/CarRentalService.Application.Contracts.csproj @@ -0,0 +1,23 @@ + + + net8.0 + enable + enable + true + CarRentalService.Application.Contracts.xml + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/CarRentalService.Application.Contracts/Cars/CarCreateUpdateDto.cs b/CarRentalService.Application.Contracts/Cars/CarCreateUpdateDto.cs new file mode 100644 index 000000000..e38e665c1 --- /dev/null +++ b/CarRentalService.Application.Contracts/Cars/CarCreateUpdateDto.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRentalService.Application.Contracts.Cars; + +/// +/// DTO for creating or updating a car +/// +public class CarCreateUpdateDto +{ + /// + /// License plate number + /// + [Required] + [StringLength(20)] + public required string LicensePlate { get; set; } + + /// + /// Car color + /// + [Required] + [StringLength(50)] + public required string Color { get; set; } + + /// + /// Car model generation identifier + /// + [Required] + [Range(1, int.MaxValue)] + public required int CarModelGenerationId { get; set; } +} diff --git a/CarRentalService.Application.Contracts/Cars/CarDto.cs b/CarRentalService.Application.Contracts/Cars/CarDto.cs new file mode 100644 index 000000000..7b06d8723 --- /dev/null +++ b/CarRentalService.Application.Contracts/Cars/CarDto.cs @@ -0,0 +1,29 @@ +using CarRentalService.Application.Contracts.CarModelGeneration; + +namespace CarRentalService.Application.Contracts.Cars; + +/// +/// DTO for car responses +/// +public class CarDto +{ + /// + /// Car identifier + /// + public int Id { get; set; } + + /// + /// License plate number + /// + public string LicensePlate { get; set; } = string.Empty; + + /// + /// Car color + /// + public string Color { get; set; } = string.Empty; + + /// + /// Car model generation information + /// + public CarModelGenerationDto CarModelGeneration { get; set; } = null!; +} diff --git a/CarRentalService.Application.Contracts/Cars/ICarService.cs b/CarRentalService.Application.Contracts/Cars/ICarService.cs new file mode 100644 index 000000000..1d8a09b03 --- /dev/null +++ b/CarRentalService.Application.Contracts/Cars/ICarService.cs @@ -0,0 +1,8 @@ +namespace CarRentalService.Application.Contracts.Cars; + +/// +/// Car service interface +/// +public interface ICarService : IApplicationService +{ +} diff --git a/CarRentalService.Application.Contracts/Clients/ClientCreateUpdateDto.cs b/CarRentalService.Application.Contracts/Clients/ClientCreateUpdateDto.cs new file mode 100644 index 000000000..a2e6db42c --- /dev/null +++ b/CarRentalService.Application.Contracts/Clients/ClientCreateUpdateDto.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRentalService.Application.Contracts.Clients; + +/// +/// DTO for creating or updating a client +/// +public class ClientCreateUpdateDto +{ + /// + /// Driver license number + /// + [Required] + [StringLength(50)] + public required string DriverLicenseNumber { get; set; } + + /// + /// Full name + /// + [Required] + [StringLength(100)] + public required string FullName { get; set; } + + /// + /// Date of birth + /// + [Required] + public required DateTime DateOfBirth { get; set; } +} diff --git a/CarRentalService.Application.Contracts/Clients/ClientDto.cs b/CarRentalService.Application.Contracts/Clients/ClientDto.cs new file mode 100644 index 000000000..769aba86e --- /dev/null +++ b/CarRentalService.Application.Contracts/Clients/ClientDto.cs @@ -0,0 +1,32 @@ +namespace CarRentalService.Application.Contracts.Clients; + +/// +/// DTO for client responses +/// +public class ClientDto +{ + /// + /// Client identifier + /// + public int Id { get; set; } + + /// + /// Driver license number + /// + public string DriverLicenseNumber { get; set; } = string.Empty; + + /// + /// Full name + /// + public string FullName { get; set; } = string.Empty; + + /// + /// Date of birth + /// + public DateTime DateOfBirth { get; set; } + + /// + /// Age calculated from date of birth + /// + public int Age => DateTime.Now.Year - DateOfBirth.Year; +} diff --git a/CarRentalService.Application.Contracts/Clients/IClientService.cs b/CarRentalService.Application.Contracts/Clients/IClientService.cs new file mode 100644 index 000000000..6d1aba3f1 --- /dev/null +++ b/CarRentalService.Application.Contracts/Clients/IClientService.cs @@ -0,0 +1,8 @@ +namespace CarRentalService.Application.Contracts.Clients; + +/// +/// Client service interface +/// +public interface IClientService : IApplicationService +{ +} diff --git a/CarRentalService.Application.Contracts/IAnalyticsService.cs b/CarRentalService.Application.Contracts/IAnalyticsService.cs new file mode 100644 index 000000000..b05f1eb67 --- /dev/null +++ b/CarRentalService.Application.Contracts/IAnalyticsService.cs @@ -0,0 +1,39 @@ +using CarRentalService.Application.Contracts.Analytics; + +namespace CarRentalService.Application.Contracts; + +/// +/// Analytics service interface +/// +public interface IAnalyticsService +{ + /// + /// Get customers who rented cars of specific model name + /// + public Task> ReadCustomersByModelName(string modelName); + + /// + /// Get customers who rented cars of specific model ID + /// + public Task> ReadCustomersByModelId(int modelId); + + /// + /// Get currently rented cars + /// + public Task> ReadCarsInRent(DateTime atTime); + + /// + /// Get top N most rented cars + /// + public Task> ReadTopMostRentedCars(int count = 5); + + /// + /// Get rental count for all cars + /// + public Task> ReadAllCarsWithRentalCount(); + + /// + /// Get top N customers by total rental revenue + /// + public Task> ReadTopCustomersByTotalAmount(int count = 5); +} diff --git a/CarRentalService.Application.Contracts/IApplicationService.cs b/CarRentalService.Application.Contracts/IApplicationService.cs new file mode 100644 index 000000000..3c84f1397 --- /dev/null +++ b/CarRentalService.Application.Contracts/IApplicationService.cs @@ -0,0 +1,37 @@ +namespace CarRentalService.Application.Contracts; + +/// +/// Generic service interface defining basic CRUD operations for entities +/// +/// DTO type for entity representation +/// DTO type for create/update operations +/// Type of the entity's primary key +public interface IApplicationService + where TDto : class + where TCreateUpdateDto : class +{ + /// + /// Creates a new entity + /// + public Task Create(TCreateUpdateDto dto); + + /// + /// Retrieves a specific entity by its identifier + /// + public Task Get(TId id); + + /// + /// Retrieves all entities + /// + public Task> GetAll(); + + /// + /// Updates an existing entity + /// + public Task Update(TCreateUpdateDto dto, TId id); + + /// + /// Deletes an entity by its identifier + /// + public Task Delete(TId id); +} diff --git a/CarRentalService.Application.Contracts/Protos/Rental.proto b/CarRentalService.Application.Contracts/Protos/Rental.proto new file mode 100644 index 000000000..ebe4dab03 --- /dev/null +++ b/CarRentalService.Application.Contracts/Protos/Rental.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +option csharp_namespace = "CarRentalService.Application.Contracts.Grpc"; + +message RentalGenerationRequest { + string request_id = 1; + int32 count = 2; + int32 batch_size = 3; +} + +message RentalContractMessage { + string id = 1; + int32 car_id = 2; + int32 customer_id = 3; + string start_time = 4; + double duration_hours = 5; +} + +message RentalBatchStreamMessage { + string request_id = 1; + repeated RentalContractMessage rentals = 2; + bool is_final = 3; +} + +service RentalIngestor { + rpc StreamRentals (stream RentalGenerationRequest) returns (stream RentalBatchStreamMessage); +} diff --git a/CarRentalService.Application.Contracts/Rents/IRentService.cs b/CarRentalService.Application.Contracts/Rents/IRentService.cs new file mode 100644 index 000000000..fb6b1ca7d --- /dev/null +++ b/CarRentalService.Application.Contracts/Rents/IRentService.cs @@ -0,0 +1,17 @@ +namespace CarRentalService.Application.Contracts.Rents; + +/// +/// Rent service interface +/// +public interface IRentService : IApplicationService +{ + /// + /// Retrieves all rental transactions for a specific client + /// + public Task> GetRentalsByClientAsync(int clientId); + + /// + /// Retrieves all rental transactions for a specific car + /// + public Task> GetRentalsByCarAsync(int carId); +} diff --git a/CarRentalService.Application.Contracts/Rents/RentCreateUpdateDto.cs b/CarRentalService.Application.Contracts/Rents/RentCreateUpdateDto.cs new file mode 100644 index 000000000..651e278c3 --- /dev/null +++ b/CarRentalService.Application.Contracts/Rents/RentCreateUpdateDto.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRentalService.Application.Contracts.Rents; + +/// +/// DTO for creating or updating a rent +/// +public class RentCreateUpdateDto +{ + /// + /// Car identifier + /// + [Required] + [Range(1, int.MaxValue)] + public required int CarId { get; set; } + + /// + /// Client identifier + /// + [Required] + [Range(1, int.MaxValue)] + public required int ClientId { get; set; } + + /// + /// Rental start time + /// + [Required] + public required DateTime StartTime { get; set; } + + /// + /// Rental duration in hours (from 30 minutes to 30 days) + /// + [Required] + [Range(0.5, 720)] + public required double Duration { get; set; } +} diff --git a/CarRentalService.Application.Contracts/Rents/RentDto.cs b/CarRentalService.Application.Contracts/Rents/RentDto.cs new file mode 100644 index 000000000..cd56a69a8 --- /dev/null +++ b/CarRentalService.Application.Contracts/Rents/RentDto.cs @@ -0,0 +1,44 @@ +using CarRentalService.Application.Contracts.Shared; + +namespace CarRentalService.Application.Contracts.Rents; + +/// +/// DTO for rent responses +/// +public class RentDto +{ + /// + /// Rental identifier + /// + public int Id { get; set; } + + /// + /// Car information + /// + public CarSimpleDto Car { get; set; } = null!; + + /// + /// Client information + /// + public ClientSimpleDto Client { get; set; } = null!; + + /// + /// Rental start time + /// + public DateTime StartTime { get; set; } + + /// + /// Rental duration in hours + /// + public double Duration { get; set; } + + /// + /// Rental end time (calculated) + /// + public DateTime EndTime => StartTime.AddHours(Duration); + + /// + /// Total cost (calculated) + /// + public decimal TotalCost => (decimal)Duration * Car.RentalCostPerHour; +} diff --git a/CarRentalService.Application.Contracts/Shared/CarSimpleDto.cs b/CarRentalService.Application.Contracts/Shared/CarSimpleDto.cs new file mode 100644 index 000000000..fab3bdeee --- /dev/null +++ b/CarRentalService.Application.Contracts/Shared/CarSimpleDto.cs @@ -0,0 +1,27 @@ +namespace CarRentalService.Application.Contracts.Shared; + +/// +/// Simplified DTO for car information +/// +public class CarSimpleDto +{ + /// + /// Car identifier + /// + public int Id { get; set; } + + /// + /// License plate number + /// + public string LicensePlate { get; set; } = string.Empty; + + /// + /// Model name + /// + public string ModelName { get; set; } = string.Empty; + + /// + /// Rental cost per hour + /// + public decimal RentalCostPerHour { get; set; } +} diff --git a/CarRentalService.Application.Contracts/Shared/ClientSimpleDto.cs b/CarRentalService.Application.Contracts/Shared/ClientSimpleDto.cs new file mode 100644 index 000000000..7f003142d --- /dev/null +++ b/CarRentalService.Application.Contracts/Shared/ClientSimpleDto.cs @@ -0,0 +1,17 @@ +namespace CarRentalService.Application.Contracts.Shared; + +/// +/// Simplified DTO for client information +/// +public class ClientSimpleDto +{ + /// + /// Client identifier + /// + public int Id { get; set; } + + /// + /// Full name + /// + public string FullName { get; set; } = string.Empty; +} diff --git a/CarRentalService.Application/CarRentalProfile.cs b/CarRentalService.Application/CarRentalProfile.cs new file mode 100644 index 000000000..2c47f2912 --- /dev/null +++ b/CarRentalService.Application/CarRentalProfile.cs @@ -0,0 +1,70 @@ +using AutoMapper; +using CarRentalService.Application.Contracts.Cars; +using CarRentalService.Application.Contracts.Clients; +using CarRentalService.Application.Contracts.Rents; +using CarRentalService.Application.Contracts.CarModel; +using CarRentalService.Application.Contracts.CarModelGeneration; +using CarRentalService.Domain.Models; + +namespace CarRentalService.Application; + +/// +/// AutoMapper profile for Car Rental Service domain entities to DTOs mapping +/// +public class CarRentalProfile : Profile +{ + /// + /// Initializes a new instance of the CarRentalProfile class + /// + public CarRentalProfile() + { + // CarModel -> CarModelDto + CreateMap(); + + // CarModelCreateUpdateDto -> CarModel + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + + // CarModelGeneration -> CarModelGenerationDto + CreateMap() + .ForMember(dest => dest.CarModelName, opt => opt.MapFrom(src => src.CarModel.Name)); + + // CarModelGenerationCreateUpdateDto -> CarModelGeneration + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + + // Car -> CarDto + CreateMap() + .ForMember(dest => dest.CarModelGeneration, opt => opt.MapFrom(src => src.CarModelGeneration)); + + // CarCreateUpdateDto -> Car + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + + // Customer -> ClientDto + CreateMap(); + + // ClientCreateUpdateDto -> Customer + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + + // Rent -> RentDto + CreateMap() + .ForMember(dest => dest.Car, opt => opt.MapFrom(src => new Contracts.Shared.CarSimpleDto + { + Id = src.Car.Id, + LicensePlate = src.Car.LicensePlate, + ModelName = src.Car.CarModelGeneration.CarModel.Name, + RentalCostPerHour = src.Car.CarModelGeneration.RentalCostPerHour + })) + .ForMember(dest => dest.Client, opt => opt.MapFrom(src => new Contracts.Shared.ClientSimpleDto + { + Id = src.Customer.Id, + FullName = src.Customer.FullName + })); + + // RentCreateUpdateDto -> Rent + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + } +} diff --git a/CarRentalService.Application/CarRentalService.Application.csproj b/CarRentalService.Application/CarRentalService.Application.csproj new file mode 100644 index 000000000..96430d686 --- /dev/null +++ b/CarRentalService.Application/CarRentalService.Application.csproj @@ -0,0 +1,17 @@ + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/CarRentalService.Application/Services/AnalyticsService.cs b/CarRentalService.Application/Services/AnalyticsService.cs new file mode 100644 index 000000000..06272895f --- /dev/null +++ b/CarRentalService.Application/Services/AnalyticsService.cs @@ -0,0 +1,285 @@ +using CarRentalService.Application.Contracts; +using CarRentalService.Application.Contracts.Analytics; +using CarRentalService.Domain; +using CarRentalService.Domain.Models; + +namespace CarRentalService.Application.Services; + +/// +/// Analytics service implementation for car rental business intelligence +/// +/// Rent repository +/// Car repository +/// Customer repository +/// Car model repository +/// Car model generation repository +public class AnalyticsService( + IRepository rentRepository, + IRepository carRepository, + IRepository customerRepository, + IRepository carModelRepository, + IRepository generationRepository +) : IAnalyticsService +{ + /// + /// Get customers who rented cars of specific model name + /// + /// Car model name to filter by + /// List of customer full names ordered alphabetically + public async Task> ReadCustomersByModelName(string modelName) + { + var allRents = await rentRepository.ReadAll(); + var allCustomers = await customerRepository.ReadAll(); + var allCars = await carRepository.ReadAll(); + var allGenerations = await generationRepository.ReadAll(); + var allCarModels = await carModelRepository.ReadAll(); + + var targetModel = allCarModels.FirstOrDefault(m => + m.Name.Equals(modelName, StringComparison.OrdinalIgnoreCase)); + + if (targetModel == null) + return new List(); + + var modelGenerationIds = allGenerations + .Where(g => g.CarModelId == targetModel.Id) + .Select(g => g.Id) + .ToList(); + + var carIds = allCars + .Where(c => modelGenerationIds.Contains(c.CarModelGenerationId)) + .Select(c => c.Id) + .ToList(); + + var customerIds = allRents + .Where(r => carIds.Contains(r.CarId)) + .Select(r => r.CustomerId) + .Distinct() + .ToList(); + + return allCustomers + .Where(c => customerIds.Contains(c.Id)) + .Select(c => c.FullName) + .OrderBy(name => name) + .ToList(); + } + + /// + /// Get customers who rented cars of specific model ID + /// + /// /// Car model ID to filter by + /// List of customer full names ordered alphabetically + public async Task> ReadCustomersByModelId(int modelId) + { + var allRents = await rentRepository.ReadAll(); + var allCustomers = await customerRepository.ReadAll(); + var allCars = await carRepository.ReadAll(); + var allGenerations = await generationRepository.ReadAll(); + + var modelGenerationIds = allGenerations + .Where(g => g.CarModelId == modelId) + .Select(g => g.Id) + .ToList(); + + var carIds = allCars + .Where(c => modelGenerationIds.Contains(c.CarModelGenerationId)) + .Select(c => c.Id) + .ToList(); + + var customerIds = allRents + .Where(r => carIds.Contains(r.CarId)) + .Select(r => r.CustomerId) + .Distinct() + .ToList(); + + return allCustomers + .Where(c => customerIds.Contains(c.Id)) + .Select(c => c.FullName) + .OrderBy(name => name) + .ToList(); + } + + /// + /// Get currently rented cars at specific time + /// + /// Time to check for active rentals + /// List of currently rented cars with rental details + public async Task> ReadCarsInRent(DateTime atTime) + { + var allRents = await rentRepository.ReadAll(); + var allCars = await carRepository.ReadAll(); + var allCustomers = await customerRepository.ReadAll(); + var allGenerations = await generationRepository.ReadAll(); + var allCarModels = await carModelRepository.ReadAll(); + + var activeRentals = allRents + .Where(r => + { + var rentalEnd = r.StartTime.AddHours(r.Duration); + return r.StartTime <= atTime && atTime <= rentalEnd; + }) + .ToList(); + + var result = new List(); + + foreach (var rental in activeRentals) + { + var car = allCars.FirstOrDefault(c => c.Id == rental.CarId); + var customer = allCustomers.FirstOrDefault(c => c.Id == rental.CustomerId); + var generation = allGenerations.FirstOrDefault(g => g.Id == car?.CarModelGenerationId); + var carModel = allCarModels.FirstOrDefault(m => m.Id == generation?.CarModelId); + + if (car != null && customer != null && generation != null) + { + result.Add(new CarRentalResponse + { + CarId = car.Id, + LicensePlate = car.LicensePlate, + Color = car.Color, + ModelName = carModel?.Name ?? "Unknown", + ClientName = customer.FullName, + RentalStart = rental.StartTime, + RentalEnd = rental.StartTime.AddHours(rental.Duration) + }); + } + } + + return result + .DistinctBy(r => r.CarId) + .OrderBy(r => r.RentalStart) + .ToList(); + } + + /// + /// Get top N most rented cars + /// + /// Number of top cars to return (default: 5) + /// List of top rented cars with rental statistics + public async Task> ReadTopMostRentedCars(int count = 5) + { + var allRents = await rentRepository.ReadAll(); + var allCars = await carRepository.ReadAll(); + var allGenerations = await generationRepository.ReadAll(); + var allCarModels = await carModelRepository.ReadAll(); + + var carGroups = allRents + .GroupBy(r => r.CarId) + .Select(g => new + { + CarId = g.Key, + RentalCount = g.Count(), + TotalHours = g.Sum(r => r.Duration) + }); + + var carRentalStats = new List(); + + foreach (var group in carGroups) + { + var car = allCars.FirstOrDefault(c => c.Id == group.CarId); + if (car == null) continue; + + var generation = allGenerations.FirstOrDefault(g => g.Id == car.CarModelGenerationId); + if (generation == null) continue; + + var carModel = allCarModels.FirstOrDefault(m => m.Id == generation.CarModelId); + + carRentalStats.Add(new TopCarResponse + { + CarId = car.Id, + LicensePlate = car.LicensePlate, + ModelName = carModel?.Name ?? "Unknown", + RentalCount = group.RentalCount, + TotalHours = group.TotalHours, + TotalRevenue = (decimal)(group.TotalHours * (double)generation.RentalCostPerHour) + }); + } + + return carRentalStats + .OrderByDescending(x => x.RentalCount) + .ThenBy(x => x.LicensePlate) + .Take(count) + .ToList(); + } + + /// + /// Get rental count for all cars + /// + /// List of all cars with their rental counts + public async Task> ReadAllCarsWithRentalCount() + { + var allRents = await rentRepository.ReadAll(); + var allCars = await carRepository.ReadAll(); + var allGenerations = await generationRepository.ReadAll(); + var allCarModels = await carModelRepository.ReadAll(); + + var rentsByCar = allRents + .GroupBy(r => r.CarId) + .ToDictionary(g => g.Key, g => g.Count()); + + return allCars + .Select(car => + { + var generation = allGenerations.FirstOrDefault(g => g.Id == car.CarModelGenerationId); + var carModel = generation != null + ? allCarModels.FirstOrDefault(m => m.Id == generation.CarModelId) + : null; + + return new CarRentalCountResponse + { + CarId = car.Id, + LicensePlate = car.LicensePlate, + ModelName = carModel?.Name ?? "Unknown", + RentalCount = rentsByCar.GetValueOrDefault(car.Id, 0) + }; + }) + .OrderByDescending(x => x.RentalCount) + .ThenBy(x => x.CarId) + .ToList(); + } + + /// + /// Get top N customers by total rental revenue + /// + /// Number of top customers to return (default: 5) + /// List of top customers by total rental revenue + public async Task> ReadTopCustomersByTotalAmount(int count = 5) + { + var allRents = await rentRepository.ReadAll(); + var allCustomers = await customerRepository.ReadAll(); + var allCars = await carRepository.ReadAll(); + var allGenerations = await generationRepository.ReadAll(); + + return allRents + .Select(r => new + { + Rental = r, + Car = allCars.FirstOrDefault(c => c.Id == r.CarId), + Customer = allCustomers.FirstOrDefault(c => c.Id == r.CustomerId) + }) + .Where(x => x.Car != null && x.Customer != null) + .GroupBy(x => x.Customer!.Id) + .Select(g => + { + var customer = g.First().Customer!; + + var totalRevenue = g.Sum(x => + { + var generation = allGenerations.FirstOrDefault(gen => gen.Id == x.Car!.CarModelGenerationId); + return generation != null + ? (decimal)(x.Rental.Duration * (double)generation.RentalCostPerHour) + : 0; + }); + + return new TopCustomerResponse + { + CustomerId = customer.Id, + FullName = customer.FullName, + RentalCount = g.Count(), + TotalRevenue = totalRevenue + }; + }) + .OrderByDescending(x => x.TotalRevenue) + .ThenBy(x => x.FullName) + .Take(count) + .ToList(); + } +} diff --git a/CarRentalService.Application/Services/CarModelGenerationService.cs b/CarRentalService.Application/Services/CarModelGenerationService.cs new file mode 100644 index 000000000..f9a3c080e --- /dev/null +++ b/CarRentalService.Application/Services/CarModelGenerationService.cs @@ -0,0 +1,132 @@ +using AutoMapper; +using CarRentalService.Application.Contracts.CarModelGeneration; +using CarRentalService.Domain; +using CarRentalService.Domain.Models; + +namespace CarRentalService.Application.Services; + +/// +/// Service implementation for managing car model generations +/// +/// Car model generation repository +/// Car model repository +/// AutoMapper instance +public class CarModelGenerationService( + IRepository generationRepository, + IRepository carModelRepository, + IMapper mapper +) : ICarModelGenerationService +{ + /// + /// Creates a new car model generation + /// + /// Car model generation creation data + /// Created car model generation DTO + public async Task Create(CarModelGenerationCreateUpdateDto dto) + { + var allGenerations = await generationRepository.ReadAll(); + var maxId = allGenerations.Any() ? allGenerations.Max(g => g.Id) : 0; + + var carModel = await carModelRepository.Read(dto.CarModelId); + if (carModel == null) + throw new ArgumentException($"CarModel with id {dto.CarModelId} not found"); + + var generation = mapper.Map(dto); + generation.Id = maxId + 1; + generation.CarModelId = carModel.Id; + + var created = await generationRepository.Create(generation); + return await MapToDtoWithDetails(created); + } + + /// + /// Deletes a car model generation by its identifier + /// + /// Car model generation identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + => await generationRepository.Delete(id); + + /// + /// Retrieves a car model generation by its identifier + /// + /// Car model generation identifier + /// Car model generation DTO if found, null otherwise + public async Task Get(int id) + { + var generation = await generationRepository.Read(id); + return generation != null ? await MapToDtoWithDetails(generation) : null; + } + + /// + /// Retrieves all car model generations + /// + /// List of all car model generation DTOs + public async Task> GetAll() + { + var generations = await generationRepository.ReadAll(); + var carModels = await carModelRepository.ReadAll(); + + var result = new List(); + foreach (var generation in generations) + { + var carModel = carModels.FirstOrDefault(m => m.Id == generation.CarModelId); + result.Add(new CarModelGenerationDto + { + Id = generation.Id, + CarModelId = generation.CarModelId, + CarModelName = carModel?.Name ?? "Unknown", + ProductionYear = generation.ProductionYear, + EngineVolume = generation.EngineVolume, + TransmissionType = generation.TransmissionType, + RentalCostPerHour = generation.RentalCostPerHour + }); + } + + return result; + } + + /// + /// Updates an existing car model generation + /// + /// Car model generation update data + /// Car model generation identifier + /// Updated car model generation DTO + public async Task Update(CarModelGenerationCreateUpdateDto dto, int id) + { + var generation = await generationRepository.Read(id); + if (generation == null) + throw new KeyNotFoundException($"CarModelGeneration with id {id} not found"); + + var carModel = await carModelRepository.Read(dto.CarModelId); + if (carModel == null) + throw new ArgumentException($"CarModel with id {dto.CarModelId} not found"); + + mapper.Map(dto, generation); + generation.CarModelId = carModel.Id; + + var updated = await generationRepository.Update(generation); + return await MapToDtoWithDetails(updated); + } + + /// + /// Maps CarModelGeneration entity to DTO with additional details + /// + /// Car model generation entity + /// Car model generation DTO with details + private async Task MapToDtoWithDetails(CarModelGeneration generation) + { + var carModel = await carModelRepository.Read(generation.CarModelId); + + return new CarModelGenerationDto + { + Id = generation.Id, + CarModelId = generation.CarModelId, + CarModelName = carModel?.Name ?? "Unknown", + ProductionYear = generation.ProductionYear, + EngineVolume = generation.EngineVolume, + TransmissionType = generation.TransmissionType, + RentalCostPerHour = generation.RentalCostPerHour + }; + } +} diff --git a/CarRentalService.Application/Services/CarModelService.cs b/CarRentalService.Application/Services/CarModelService.cs new file mode 100644 index 000000000..ea5c553db --- /dev/null +++ b/CarRentalService.Application/Services/CarModelService.cs @@ -0,0 +1,100 @@ +using AutoMapper; +using CarRentalService.Application.Contracts.CarModel; +using CarRentalService.Application.Contracts.Cars; +using CarRentalService.Domain; +using CarRentalService.Domain.Models; + +namespace CarRentalService.Application.Services; + +/// +/// Service implementation for managing car models +/// +/// Car model repository +/// Car repository +/// Car model generation repository +/// AutoMapper instance +public class CarModelService( + IRepository carModelRepository, + IRepository carRepository, + IMapper mapper +) : ICarModelService +{ + /// + /// Creates a new car model + /// + /// Car model creation data + /// Created car model DTO + public async Task Create(CarModelCreateUpdateDto dto) + { + var allModels = await carModelRepository.ReadAll(); + var maxId = allModels.Any() ? allModels.Max(m => m.Id) : 0; + + var carModel = mapper.Map(dto); + carModel.Id = maxId + 1; + + var created = await carModelRepository.Create(carModel); + return mapper.Map(created); + } + + /// + /// Deletes a car model by its identifier + /// + /// Car model identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + => await carModelRepository.Delete(id); + + /// + /// Retrieves a car model by its identifier + /// + /// Car model identifier + /// Car model DTO if found, null otherwise + public async Task Get(int id) + { + var carModel = await carModelRepository.Read(id); + return carModel != null ? mapper.Map(carModel) : null; + } + + /// + /// Retrieves all car models + /// + /// List of all car model DTOs + public async Task> GetAll() + { + var carModels = await carModelRepository.ReadAll(); + return mapper.Map>(carModels); + } + + /// + /// Updates an existing car model + /// + /// Car model update data + /// Car model identifier + /// Updated car model DTO + public async Task Update(CarModelCreateUpdateDto dto, int id) + { + var carModel = await carModelRepository.Read(id); + if (carModel == null) + throw new KeyNotFoundException($"CarModel with id {id} not found"); + + mapper.Map(dto, carModel); + var updated = await carModelRepository.Update(carModel); + return mapper.Map(updated); + } + + /// + /// Retrieves all cars associated with a specific car model + /// + /// Car model identifier + /// List of cars belonging to the specified model + public async Task> GetCarsByModelAsync(int modelId) + { + var cars = await carRepository.ReadAll(); + + var carsForModel = cars + .Where(c => c.CarModelGeneration.CarModel.Id == modelId) + .ToList(); + + return mapper.Map>(carsForModel); + } +} diff --git a/CarRentalService.Application/Services/CarService.cs b/CarRentalService.Application/Services/CarService.cs new file mode 100644 index 000000000..a3bc4faa8 --- /dev/null +++ b/CarRentalService.Application/Services/CarService.cs @@ -0,0 +1,158 @@ +using AutoMapper; +using CarRentalService.Application.Contracts.Cars; +using CarRentalService.Application.Contracts.CarModelGeneration; +using CarRentalService.Domain; +using CarRentalService.Domain.Models; + +namespace CarRentalService.Application.Services; + +/// +/// Service implementation for managing cars +/// +/// Car repository +/// Car model repository +/// Car model generation repository +/// AutoMapper instance +public class CarService( + IRepository carRepository, + IRepository carModelRepository, + IRepository generationRepository, + IMapper mapper +) : ICarService +{ + /// + /// Creates a new car + /// + /// Car creation data + /// Created car DTO + public async Task Create(CarCreateUpdateDto dto) + { + var allCars = await carRepository.ReadAll(); + var maxId = allCars.Any() ? allCars.Max(c => c.Id) : 0; + + var generation = await generationRepository.Read(dto.CarModelGenerationId); + if (generation == null) + throw new ArgumentException($"CarModelGeneration with id {dto.CarModelGenerationId} not found"); + + var car = mapper.Map(dto); + car.Id = maxId + 1; + car.CarModelGenerationId = generation.Id; + + var created = await carRepository.Create(car); + return await MapToDtoWithDetails(created); + } + + /// + /// Deletes a car by its identifier + /// + /// Car identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + => await carRepository.Delete(id); + + /// + /// Retrieves a car by its identifier + /// + /// Car identifier + /// Car DTO if found, null otherwise + public async Task Get(int id) + { + var car = await carRepository.Read(id); + return car != null ? await MapToDtoWithDetails(car) : null; + } + + /// + /// Retrieves all cars + /// + /// List of all car DTOs + public async Task> GetAll() + { + var cars = await carRepository.ReadAll(); + var result = new List(); + + var allGenerations = await generationRepository.ReadAll(); + var allCarModels = await carModelRepository.ReadAll(); + + foreach (var car in cars) + { + var generation = allGenerations.FirstOrDefault(g => g.Id == car.CarModelGenerationId); + if (generation == null) continue; + + var carModel = allCarModels.FirstOrDefault(m => m.Id == generation.CarModelId); + + result.Add(new CarDto + { + Id = car.Id, + LicensePlate = car.LicensePlate, + Color = car.Color, + CarModelGeneration = new CarModelGenerationDto + { + Id = generation.Id, + CarModelId = generation.CarModelId, + CarModelName = carModel?.Name ?? "Unknown", + ProductionYear = generation.ProductionYear, + EngineVolume = generation.EngineVolume, + TransmissionType = generation.TransmissionType, + RentalCostPerHour = generation.RentalCostPerHour + } + }); + } + + return result; + } + + /// + /// Updates an existing car + /// + /// Car update data + /// Car identifier + /// Updated car DTO + public async Task Update(CarCreateUpdateDto dto, int id) + { + var car = await carRepository.Read(id); + if (car == null) + throw new KeyNotFoundException($"Car with id {id} not found"); + + var generation = await generationRepository.Read(dto.CarModelGenerationId); + if (generation == null) + throw new ArgumentException($"CarModelGeneration with id {dto.CarModelGenerationId} not found"); + + car.LicensePlate = dto.LicensePlate; + car.Color = dto.Color; + car.CarModelGenerationId = generation.Id; + + var updated = await carRepository.Update(car); + return await MapToDtoWithDetails(updated); + } + + /// + /// Maps Car entity to DTO with additional details + /// + /// Car entity + /// Car DTO with details + private async Task MapToDtoWithDetails(Car car) + { + var generation = await generationRepository.Read(car.CarModelGenerationId); + if (generation == null) + throw new InvalidOperationException($"CarModelGeneration {car.CarModelGenerationId} not found"); + + var carModel = await carModelRepository.Read(generation.CarModelId); + + return new CarDto + { + Id = car.Id, + LicensePlate = car.LicensePlate, + Color = car.Color, + CarModelGeneration = new CarModelGenerationDto + { + Id = generation.Id, + CarModelId = generation.CarModelId, + CarModelName = carModel?.Name ?? "Unknown", + ProductionYear = generation.ProductionYear, + EngineVolume = generation.EngineVolume, + TransmissionType = generation.TransmissionType, + RentalCostPerHour = generation.RentalCostPerHour + } + }; + } +} diff --git a/CarRentalService.Application/Services/ClientService.cs b/CarRentalService.Application/Services/ClientService.cs new file mode 100644 index 000000000..0bd0a72c9 --- /dev/null +++ b/CarRentalService.Application/Services/ClientService.cs @@ -0,0 +1,80 @@ +using AutoMapper; +using CarRentalService.Application.Contracts.Clients; +using CarRentalService.Domain; +using CarRentalService.Domain.Models; + +namespace CarRentalService.Application.Services; + +/// +/// Service implementation for managing clients +/// +/// Customer repository +/// AutoMapper instance +public class ClientService( + IRepository customerRepository, + IMapper mapper +) : IClientService +{ + /// + /// Creates a new client + /// + /// Client creation data + /// Created client DTO + public async Task Create(ClientCreateUpdateDto dto) + { + var allCustomers = await customerRepository.ReadAll(); + var maxId = allCustomers.Any() ? allCustomers.Max(c => c.Id) : 0; + + var customer = mapper.Map(dto); + customer.Id = maxId + 1; + + var created = await customerRepository.Create(customer); + return mapper.Map(created); + } + + /// + /// Deletes a client by its identifier + /// + /// Client identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + => await customerRepository.Delete(id); + + /// + /// Retrieves a client by its identifier + /// + /// Client identifier + /// Client DTO if found, null otherwise + public async Task Get(int id) + { + var customer = await customerRepository.Read(id); + return customer != null ? mapper.Map(customer) : null; + } + + /// + /// Retrieves all clients + /// + /// List of all client DTOs + public async Task> GetAll() + { + var customers = await customerRepository.ReadAll(); + return mapper.Map>(customers); + } + + /// + /// Updates an existing client + /// + /// Client update data + /// Client identifier + /// Updated client DTO + public async Task Update(ClientCreateUpdateDto dto, int id) + { + var customer = await customerRepository.Read(id); + if (customer == null) + throw new KeyNotFoundException($"Client with id {id} not found"); + + mapper.Map(dto, customer); + var updated = await customerRepository.Update(customer); + return mapper.Map(updated); + } +} diff --git a/CarRentalService.Application/Services/RentService.cs b/CarRentalService.Application/Services/RentService.cs new file mode 100644 index 000000000..969a1f863 --- /dev/null +++ b/CarRentalService.Application/Services/RentService.cs @@ -0,0 +1,192 @@ +using AutoMapper; +using CarRentalService.Application.Contracts.Rents; +using CarRentalService.Domain; +using CarRentalService.Domain.Models; + +namespace CarRentalService.Application.Services; + +/// +/// Service implementation for managing rent transactions +/// +/// Rent repository +/// Car repository +/// Customer repository +/// Car model generation repository +/// AutoMapper instance +public class RentService( + IRepository rentRepository, + IRepository carRepository, + IRepository customerRepository, + IRepository generationRepository, + IRepository carModelRepository, + IMapper mapper +) : IRentService +{ + /// + /// Creates a new rent transaction + /// + /// Rent creation data + /// Created rent DTO + public async Task Create(RentCreateUpdateDto dto) + { + var allRents = await rentRepository.ReadAll(); + var maxId = allRents.Any() ? allRents.Max(r => r.Id) : 0; + + var car = await carRepository.Read(dto.CarId); + if (car == null) + throw new ArgumentException($"Car with id {dto.CarId} not found"); + + var customer = await customerRepository.Read(dto.ClientId); + if (customer == null) + throw new ArgumentException($"Client with id {dto.ClientId} not found"); + + var rent = mapper.Map(dto); + rent.Id = maxId + 1; + rent.CarId = car.Id; + rent.CustomerId = customer.Id; + + var created = await rentRepository.Create(rent); + return await MapToDtoWithDetails(created); + } + + /// + /// Deletes a rent transaction by its identifier + /// + /// Rent identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + => await rentRepository.Delete(id); + + /// + /// Retrieves a rent transaction by its identifier + /// + /// Rent identifier + /// Rent DTO if found, null otherwise + public async Task Get(int id) + { + var rent = await rentRepository.Read(id); + return rent != null ? await MapToDtoWithDetails(rent) : null; + } + + /// + /// Retrieves all rent transactions + /// + /// List of all rent DTOs + public async Task> GetAll() + { + var rents = await rentRepository.ReadAll(); + var result = new List(); + + foreach (var rent in rents) + { + result.Add(await MapToDtoWithDetails(rent)); + } + + return result; + } + + /// + /// Updates an existing rent transaction + /// + /// Rent update data + /// Rent identifier + /// Updated rent DTO + public async Task Update(RentCreateUpdateDto dto, int id) + { + var rent = await rentRepository.Read(id); + if (rent == null) + throw new KeyNotFoundException($"Rent with id {id} not found"); + + var car = await carRepository.Read(dto.CarId); + if (car == null) + throw new ArgumentException($"Car with id {dto.CarId} not found"); + + var customer = await customerRepository.Read(dto.ClientId); + if (customer == null) + throw new ArgumentException($"Client with id {dto.ClientId} not found"); + + mapper.Map(dto, rent); + rent.CarId = car.Id; + rent.CustomerId = customer.Id; + + var updated = await rentRepository.Update(rent); + return await MapToDtoWithDetails(updated); + } + + /// + /// Retrieves all rental transactions for a specific client + /// + /// Client identifier + /// List of rent DTOs for the specified client + public async Task> GetRentalsByClientAsync(int clientId) + { + var rents = await rentRepository.ReadAll(); + var clientRents = rents.Where(r => r.CustomerId == clientId).ToList(); + + var result = new List(); + foreach (var rent in clientRents) + { + result.Add(await MapToDtoWithDetails(rent)); + } + + return result; + } + + /// + /// Retrieves all rental transactions for a specific car + /// + /// Car identifier + /// List of rent DTOs for the specified car + public async Task> GetRentalsByCarAsync(int carId) + { + var rents = await rentRepository.ReadAll(); + var carRents = rents.Where(r => r.CarId == carId).ToList(); + + var result = new List(); + foreach (var rent in carRents) + { + result.Add(await MapToDtoWithDetails(rent)); + } + + return result; + } + + /// + /// Maps Rent entity to DTO with additional details + /// + /// Rent entity + /// Rent DTO with details + private async Task MapToDtoWithDetails(Rent rent) + { + var car = await carRepository.Read(rent.CarId); + var customer = await customerRepository.Read(rent.CustomerId); + + if (car == null || customer == null) + throw new InvalidOperationException("Car or Customer not found for rent"); + + var generation = await generationRepository.Read(car.CarModelGenerationId); + if (generation == null) + throw new InvalidOperationException($"CarModelGeneration {car.CarModelGenerationId} not found"); + + var carModel = await carModelRepository.Read(generation.CarModelId); + + return new RentDto + { + Id = rent.Id, + Car = new Contracts.Shared.CarSimpleDto + { + Id = car.Id, + LicensePlate = car.LicensePlate, + ModelName = carModel?.Name ?? "Unknown", + RentalCostPerHour = generation.RentalCostPerHour + }, + Client = new Contracts.Shared.ClientSimpleDto + { + Id = customer.Id, + FullName = customer.FullName + }, + StartTime = rent.StartTime, + Duration = rent.Duration + }; + } +} diff --git a/CarRentalService.Domain/CarRentalService.Domain.csproj b/CarRentalService.Domain/CarRentalService.Domain.csproj new file mode 100644 index 000000000..80e861aed --- /dev/null +++ b/CarRentalService.Domain/CarRentalService.Domain.csproj @@ -0,0 +1,8 @@ + + + net8.0 + enable + enable + true + + diff --git a/CarRentalService.Domain/IRepository.cs b/CarRentalService.Domain/IRepository.cs new file mode 100644 index 000000000..3e3ddae98 --- /dev/null +++ b/CarRentalService.Domain/IRepository.cs @@ -0,0 +1,43 @@ +namespace CarRentalService.Domain; + +/// +/// Generic repository interface for basic CRUD operations +/// +/// Entity type +/// Entity identifier type +public interface IRepository where T : class +{ + /// + /// Creates a new entity in the repository + /// + /// Entity to create + /// Created entity + public Task Create(T entity); + + /// + /// Deletes an entity by its identifier + /// + /// Entity identifier + /// True if deletion was successful, false otherwise + public Task Delete(TId id); + + /// + /// Retrieves an entity by its identifier + /// + /// Entity identifier + /// Entity if found, null otherwise + public Task Read(TId id); + + /// + /// Retrieves all entities from the repository + /// + /// List of all entities + public Task> ReadAll(); + + /// + /// Updates an existing entity + /// + /// Entity with updated data + /// Updated entity + public Task Update(T entity); +} diff --git a/CarRentalService.Domain/Models/Car.cs b/CarRentalService.Domain/Models/Car.cs new file mode 100644 index 000000000..717090085 --- /dev/null +++ b/CarRentalService.Domain/Models/Car.cs @@ -0,0 +1,27 @@ +namespace CarRentalService.Domain.Models; +/// +/// Car in the rental fleet +/// +public class Car +{ + /// + /// Car identifier + /// + public int Id { get; set; } + /// + /// License plate number + /// + public required string LicensePlate { get; set; } + /// + /// Car color + /// + public required string Color { get; set; } + /// + /// Car model generation identifier + /// + public int CarModelGenerationId { get; set; } + /// + /// Car model generation + /// + public required CarModelGeneration CarModelGeneration { get; set; } +} diff --git a/CarRentalService.Domain/Models/CarModel.cs b/CarRentalService.Domain/Models/CarModel.cs new file mode 100644 index 000000000..aab78f9d7 --- /dev/null +++ b/CarRentalService.Domain/Models/CarModel.cs @@ -0,0 +1,31 @@ +namespace CarRentalService.Domain.Models; +/// +/// Car model reference +/// +public class CarModel +{ + /// + /// Model identifier + /// + public int Id { get; set; } + /// + /// Model name + /// + public required string Name { get; set; } + /// + /// Drive type + /// + public required string DriveType { get; set; } + /// + /// Number of seats + /// + public required int SeatCount { get; set; } + /// + /// Body type + /// + public required string BodyType { get; set; } + /// + /// Car class + /// + public required string CarClass { get; set; } +} diff --git a/CarRentalService.Domain/Models/CarModelGeneration.cs b/CarRentalService.Domain/Models/CarModelGeneration.cs new file mode 100644 index 000000000..31f9d13a2 --- /dev/null +++ b/CarRentalService.Domain/Models/CarModelGeneration.cs @@ -0,0 +1,35 @@ +namespace CarRentalService.Domain.Models; +/// +/// Car model generation reference +/// +public class CarModelGeneration +{ + /// + /// Generation identifier + /// + public int Id { get; set; } + /// + /// Car model identifier + /// + public int CarModelId { get; set; } + /// + /// Car model + /// + public required CarModel CarModel { get; set; } + /// + /// Production year + /// + public required int ProductionYear { get; set; } + /// + /// Engine volume + /// + public required double EngineVolume { get; set; } + /// + /// Transmission type + /// + public required string TransmissionType { get; set; } + /// + /// Rental cost per hour + /// + public required decimal RentalCostPerHour { get; set; } +} diff --git a/CarRentalService.Domain/Models/Customer.cs b/CarRentalService.Domain/Models/Customer.cs new file mode 100644 index 000000000..2eba74d5c --- /dev/null +++ b/CarRentalService.Domain/Models/Customer.cs @@ -0,0 +1,23 @@ +namespace CarRentalService.Domain.Models; +/// +/// Customer of the rental service +/// +public class Customer +{ + /// + /// Customer identifier + /// + public int Id { get; set; } + /// + /// Driver license number + /// + public required string DriverLicenseNumber { get; set; } + /// + /// Full name + /// + public required string FullName { get; set; } + /// + /// Date of birth + /// + public required DateTime DateOfBirth { get; set; } +} diff --git a/CarRentalService.Domain/Models/Rent.cs b/CarRentalService.Domain/Models/Rent.cs new file mode 100644 index 000000000..80a09eded --- /dev/null +++ b/CarRentalService.Domain/Models/Rent.cs @@ -0,0 +1,35 @@ +namespace CarRentalService.Domain.Models; +/// +/// Car rental transaction +/// +public class Rent +{ + /// + /// Rental identifier + /// + public int Id { get; set; } + /// + /// Rented car identifier + /// + public int CarId { get; set; } + /// + /// Customer identifier + /// + public int CustomerId { get; set; } + /// + /// Rented car + /// + public required Car Car { get; set; } + /// + /// Customer + /// + public required Customer Customer { get; set; } + /// + /// Rental start time + /// + public required DateTime StartTime { get; set; } + /// + /// Rental duration in hours + /// + public required double Duration { get; set; } +} diff --git a/CarRentalService.Domain/TestData/DataSeed.cs b/CarRentalService.Domain/TestData/DataSeed.cs new file mode 100644 index 000000000..99c238085 --- /dev/null +++ b/CarRentalService.Domain/TestData/DataSeed.cs @@ -0,0 +1,764 @@ +using CarRentalService.Domain.Models; + +namespace CarRentalService.Domain.TestData; +/// +/// Test data generator for car rental service +/// +public class TestData +{ + /// + /// List of car models + /// + public List CarModels { get; } + /// + /// List of customers + /// + public List Customers { get; } + /// + /// List of car model generations + /// + public List CarModelGenerations { get; } + /// + /// List of cars + /// + public List Cars { get; } + /// + /// List of rental transactions + /// + public List Rents { get; } + /// + /// Constructor for data initialization + /// + public TestData() + { + CarModels = + [ + new() + { + Id = 1, + Name = "Toyota Camry", + DriveType = "Front", + SeatCount = 5, + BodyType = "Sedan", + CarClass = "Business" + }, + new() + { + Id = 2, + Name = "BMW X5", + DriveType = "All", + SeatCount = 5, + BodyType = "SUV", + CarClass = "Premium" + }, + new() + { + Id = 3, + Name = "Volkswagen Golf", + DriveType = "Front", + SeatCount = 5, + BodyType = "Hatchback", + CarClass = "Economy" + }, + new() + { + Id = 4, + Name = "Mercedes-Benz E-Class", + DriveType = "Rear", + SeatCount = 5, + BodyType = "Sedan", + CarClass = "Business" + }, + new() + { + Id = 5, + Name = "Audi A6", + DriveType = "All", + SeatCount = 5, + BodyType = "Sedan", + CarClass = "Premium" + }, + new() + { + Id = 6, + Name = "Hyundai Solaris", + DriveType = "Front", + SeatCount = 5, + BodyType = "Sedan", + CarClass = "Economy" + }, + new() + { + Id = 7, + Name = "Kia Sportage", + DriveType = "All", + SeatCount = 5, + BodyType = "SUV", + CarClass = "Standard" + }, + new() + { + Id = 8, + Name = "Ford Focus", + DriveType = "Front", + SeatCount = 5, + BodyType = "Hatchback", + CarClass = "Standard" + }, + new() + { + Id = 9, + Name = "Skoda Octavia", + DriveType = "Front", + SeatCount = 5, + BodyType = "Liftback", + CarClass = "Standard" + }, + new() + { + Id = 10, + Name = "Lexus RX", + DriveType = "All", + SeatCount = 5, + BodyType = "SUV", + CarClass = "Premium" + }, + new() + { + Id = 11, + Name = "Volvo XC90", + DriveType = "All", + SeatCount = 7, + BodyType = "SUV", + CarClass = "Premium" + }, + new() + { + Id = 12, + Name = "Renault Logan", + DriveType = "Front", + SeatCount = 5, + BodyType = "Sedan", + CarClass = "Economy" + }, + new() + { + Id = 13, + Name = "Tesla Model 3", + DriveType = "All", + SeatCount = 5, + BodyType = "Sedan", + CarClass = "Premium" + }, + new() + { + Id = 14, + Name = "Honda CR-V", + DriveType = "All", + SeatCount = 5, + BodyType = "SUV", + CarClass = "Standard" + }, + new() + { + Id = 15, + Name = "Mazda CX-5", + DriveType = "All", + SeatCount = 5, + BodyType = "SUV", + CarClass = "Standard" + } + ]; + + CarModelGenerations = + [ + new() + { + Id = 1, + CarModelId = 1, + CarModel = CarModels[0], + ProductionYear = 2020, + EngineVolume = 2.5, + TransmissionType = "Automatic", + RentalCostPerHour = 1500 + }, + new() + { + Id = 2, + CarModelId = 1, + CarModel = CarModels[0], + ProductionYear = 2022, + EngineVolume = 2.5, + TransmissionType = "CVT", + RentalCostPerHour = 1700 + }, + new() + { + Id = 3, + CarModelId = 2, + CarModel = CarModels[1], + ProductionYear = 2021, + EngineVolume = 3.0, + TransmissionType = "Automatic", + RentalCostPerHour = 2500 + }, + new() + { + Id = 4, + CarModelId = 2, + CarModel = CarModels[1], + ProductionYear = 2023, + EngineVolume = 3.0, + TransmissionType = "Automatic", + RentalCostPerHour = 2800 + }, + new() + { + Id = 5, + CarModelId = 3, + CarModel = CarModels[2], + ProductionYear = 2020, + EngineVolume = 1.4, + TransmissionType = "Manual", + RentalCostPerHour = 800 + }, + new() + { + Id = 6, + CarModelId = 3, + CarModel = CarModels[2], + ProductionYear = 2022, + EngineVolume = 1.4, + TransmissionType = "Automatic", + RentalCostPerHour = 900 + }, + new() + { + Id = 7, + CarModelId = 4, + CarModel = CarModels[3], + ProductionYear = 2021, + EngineVolume = 2.0, + TransmissionType = "Automatic", + RentalCostPerHour = 2200 + }, + new() + { + Id = 8, + CarModelId = 5, + CarModel = CarModels[4], + ProductionYear = 2022, + EngineVolume = 2.0, + TransmissionType = "Automatic", + RentalCostPerHour = 2400 + }, + new() + { + Id = 9, + CarModelId = 6, + CarModel = CarModels[5], + ProductionYear = 2021, + EngineVolume = 1.6, + TransmissionType = "Automatic", + RentalCostPerHour = 750 + }, + new() + { + Id = 10, + CarModelId = 7, + CarModel = CarModels[6], + ProductionYear = 2022, + EngineVolume = 2.0, + TransmissionType = "Automatic", + RentalCostPerHour = 1200 + }, + new() + { + Id = 11, + CarModelId = 8, + CarModel = CarModels[7], + ProductionYear = 2020, + EngineVolume = 1.6, + TransmissionType = "Manual", + RentalCostPerHour = 850 + }, + new() + { + Id = 12, + CarModelId = 9, + CarModel = CarModels[8], + ProductionYear = 2021, + EngineVolume = 1.8, + TransmissionType = "Automatic", + RentalCostPerHour = 950 + }, + new() + { + Id = 13, + CarModelId = 10, + CarModel = CarModels[9], + ProductionYear = 2023, + EngineVolume = 3.5, + TransmissionType = "Automatic", + RentalCostPerHour = 3000 + }, + new() + { + Id = 14, + CarModelId = 11, + CarModel = CarModels[10], + ProductionYear = 2022, + EngineVolume = 2.0, + TransmissionType = "Automatic", + RentalCostPerHour = 2700 + }, + new() + { + Id = 15, + CarModelId = 12, + CarModel = CarModels[11], + ProductionYear = 2020, + EngineVolume = 1.6, + TransmissionType = "Manual", + RentalCostPerHour = 700 + } + ]; + + Cars = + [ + new() + { + Id = 1, + LicensePlate = "A123BC777", + Color = "Black", + CarModelGenerationId = 1, + CarModelGeneration = CarModelGenerations[0] + }, + new() + { + Id = 2, + LicensePlate = "B234CD777", + Color = "White", + CarModelGenerationId = 1, + CarModelGeneration = CarModelGenerations[0] + }, + new() + { + Id = 3, + LicensePlate = "C345DE777", + Color = "Silver", + CarModelGenerationId = 3, + CarModelGeneration = CarModelGenerations[2] + }, + new() + { + Id = 4, + LicensePlate = "D456EF777", + Color = "Blue", + CarModelGenerationId = 3, + CarModelGeneration = CarModelGenerations[2] + }, + new() + { + Id = 5, + LicensePlate = "E567FG777", + Color = "Red", + CarModelGenerationId = 5, + CarModelGeneration = CarModelGenerations[4] + }, + new() + { + Id = 6, + LicensePlate = "F678GH777", + Color = "Gray", + CarModelGenerationId = 5, + CarModelGeneration = CarModelGenerations[4] + }, + new() + { + Id = 7, + LicensePlate = "G789HI777", + Color = "Black", + CarModelGenerationId = 7, + CarModelGeneration = CarModelGenerations[6] + }, + new() + { + Id = 8, + LicensePlate = "H890IJ777", + Color = "White", + CarModelGenerationId = 7, + CarModelGeneration = CarModelGenerations[6] + }, + new() + { + Id = 9, + LicensePlate = "I901JK777", + Color = "Blue", + CarModelGenerationId = 9, + CarModelGeneration = CarModelGenerations[8] + }, + new() + { + Id = 10, + LicensePlate = "J012KL777", + Color = "Silver", + CarModelGenerationId = 9, + CarModelGeneration = CarModelGenerations[8] + }, + new() + { + Id = 11, + LicensePlate = "K123LM777", + Color = "Red", + CarModelGenerationId = 11, + CarModelGeneration = CarModelGenerations[10] + }, + new() + { + Id = 12, + LicensePlate = "L234MN777", + Color = "Black", + CarModelGenerationId = 11, + CarModelGeneration = CarModelGenerations[10] + }, + new() + { + Id = 13, + LicensePlate = "M345NO777", + Color = "White", + CarModelGenerationId = 13, + CarModelGeneration = CarModelGenerations[12] + }, + new() + { + Id = 14, + LicensePlate = "N456OP777", + Color = "Gray", + CarModelGenerationId = 13, + CarModelGeneration = CarModelGenerations[12] + }, + new() + { + Id = 15, + LicensePlate = "O567PQ777", + Color = "Blue", + CarModelGenerationId = 15, + CarModelGeneration = CarModelGenerations[14] + } + ]; + + Customers = + [ + new() + { + Id = 1, + DriverLicenseNumber = "77AA123456", + FullName = "Ivanov Ivan Ivanovich", + DateOfBirth = new DateTime(1990, 5, 15) + }, + new() + { + Id = 2, + DriverLicenseNumber = "77BB234567", + FullName = "Petrov Petr Petrovich", + DateOfBirth = new DateTime(1985, 8, 22) + }, + new() + { + Id = 3, + DriverLicenseNumber = "77CC345678", + FullName = "Sidorova Anna Sergeevna", + DateOfBirth = new DateTime(1992, 3, 10) + }, + new() + { + Id = 4, + DriverLicenseNumber = "77DD456789", + FullName = "Kuznetsov Alexey Vladimirovich", + DateOfBirth = new DateTime(1988, 11, 5) + }, + new() + { + Id = 5, + DriverLicenseNumber = "77EE567890", + FullName = "Smirnova Ekaterina Dmitrievna", + DateOfBirth = new DateTime(1995, 7, 18) + }, + new() + { + Id = 6, + DriverLicenseNumber = "77FF678901", + FullName = "Vasiliev Dmitry Andreevich", + DateOfBirth = new DateTime(1991, 2, 28) + }, + new() + { + Id = 7, + DriverLicenseNumber = "77GG789012", + FullName = "Nikolaeva Olga Igorevna", + DateOfBirth = new DateTime(1987, 9, 12) + }, + new() + { + Id = 8, + DriverLicenseNumber = "77HH890123", + FullName = "Morozov Sergey Viktorovich", + DateOfBirth = new DateTime(1993, 4, 25) + }, + new() + { + Id = 9, + DriverLicenseNumber = "77II901234", + FullName = "Pavlova Maria Alexandrovna", + DateOfBirth = new DateTime(1994, 6, 30) + }, + new() + { + Id = 10, + DriverLicenseNumber = "77JJ012345", + FullName = "Fedorov Andrey Nikolaevich", + DateOfBirth = new DateTime(1989, 12, 8) + }, + new() + { + Id = 11, + DriverLicenseNumber = "77KK123456", + FullName = "Lebedeva Tatyana Petrovna", + DateOfBirth = new DateTime(1996, 1, 14) + }, + new() + { + Id = 12, + DriverLicenseNumber = "77LL234567", + FullName = "Sokolov Igor Borisovich", + DateOfBirth = new DateTime(1990, 10, 20) + }, + new() + { + Id = 13, + DriverLicenseNumber = "77MM345678", + FullName = "Romanova Elena Andreevna", + DateOfBirth = new DateTime(1986, 7, 3) + }, + new() + { + Id = 14, + DriverLicenseNumber = "77NN456789", + FullName = "Kazakov Mikhail Sergeevich", + DateOfBirth = new DateTime(1997, 11, 15) + }, + new() + { + Id = 15, + DriverLicenseNumber = "77OO567890", + FullName = "Tikhonova Anastasia Pavlovna", + DateOfBirth = new DateTime(1998, 3, 22) + } + ]; + + Rents = + [ + new() + { + Id = 1, + CarId = 1, + Car = Cars[0], + CustomerId = 1, + Customer = Customers[0], + StartTime = new DateTime(2024, 3, 1, 10, 0, 0), + Duration = 48 + }, + new() + { + Id = 2, + CarId = 1, + Car = Cars[0], + CustomerId = 3, + Customer = Customers[2], + StartTime = new DateTime(2024, 2, 25, 14, 30, 0), + Duration = 72 + }, + new() + { + Id = 3, + CarId = 1, + Car = Cars[0], + CustomerId = 5, + Customer = Customers[4], + StartTime = new DateTime(2024, 2, 20, 9, 15, 0), + Duration = 24 + }, + new() + { + Id = 4, + CarId = 2, + Car = Cars[1], + CustomerId = 2, + Customer = Customers[1], + StartTime = new DateTime(2024, 2, 27, 11, 45, 0), + Duration = 96 + }, + new() + { + Id = 5, + CarId = 2, + Car = Cars[1], + CustomerId = 4, + Customer = Customers[3], + StartTime = new DateTime(2024, 2, 25, 16, 0, 0), + Duration = 120 + }, + new() + { + Id = 6, + CarId = 3, + Car = Cars[2], + CustomerId = 6, + Customer = Customers[5], + StartTime = new DateTime(2024, 2, 23, 13, 20, 0), + Duration = 72 + }, + new() + { + Id = 7, + CarId = 3, + Car = Cars[2], + CustomerId = 8, + Customer = Customers[7], + StartTime = new DateTime(2024, 2, 18, 10, 10, 0), + Duration = 48 + }, + new() + { + Id = 8, + CarId = 4, + Car = Cars[3], + CustomerId = 7, + Customer = Customers[6], + StartTime = new DateTime(2024, 2, 28, 8, 30, 0), + Duration = 36 + }, + new() + { + Id = 9, + CarId = 5, + Car = Cars[4], + CustomerId = 9, + Customer = Customers[8], + StartTime = new DateTime(2024, 2, 15, 12, 0, 0), + Duration = 96 + }, + new() + { + Id = 10, + CarId = 6, + Car = Cars[5], + CustomerId = 10, + Customer = Customers[9], + StartTime = new DateTime(2024, 2, 28, 7, 0, 0), + Duration = 168 + }, + new() + { + Id = 11, + CarId = 7, + Car = Cars[6], + CustomerId = 11, + Customer = Customers[10], + StartTime = new DateTime(2024, 2, 22, 15, 45, 0), + Duration = 72 + }, + new() + { + Id = 12, + CarId = 8, + Car = Cars[7], + CustomerId = 12, + Customer = Customers[11], + StartTime = new DateTime(2024, 2, 26, 9, 20, 0), + Duration = 48 + }, + new() + { + Id = 13, + CarId = 9, + Car = Cars[8], + CustomerId = 13, + Customer = Customers[12], + StartTime = new DateTime(2024, 2, 29, 22, 0, 0), + Duration = 60 + }, + new() + { + Id = 14, + CarId = 10, + Car = Cars[9], + CustomerId = 14, + Customer = Customers[13], + StartTime = new DateTime(2024, 2, 24, 11, 30, 0), + Duration = 96 + }, + new() + { + Id = 15, + CarId = 11, + Car = Cars[10], + CustomerId = 15, + Customer = Customers[14], + StartTime = new DateTime(2024, 2, 10, 14, 15, 0), + Duration = 120 + }, + new() + { + Id = 16, + CarId = 12, + Car = Cars[11], + CustomerId = 1, + Customer = Customers[0], + StartTime = new DateTime(2024, 2, 29, 14, 0, 0), + Duration = 48 + }, + new() + { + Id = 17, + CarId = 13, + Car = Cars[12], + CustomerId = 2, + Customer = Customers[1], + StartTime = new DateTime(2024, 2, 5, 16, 45, 0), + Duration = 72 + }, + new() + { + Id = 18, + CarId = 14, + Car = Cars[13], + CustomerId = 3, + Customer = Customers[2], + StartTime = new DateTime(2024, 2, 12, 10, 10, 0), + Duration = 36 + }, + new() + { + Id = 19, + CarId = 15, + Car = Cars[14], + CustomerId = 4, + Customer = Customers[3], + StartTime = new DateTime(2024, 2, 16, 13, 30, 0), + Duration = 84 + }, + new() + { + Id = 20, + CarId = 2, + Car = Cars[1], + CustomerId = 6, + Customer = Customers[5], + StartTime = new DateTime(2024, 3, 2, 9, 0, 0), + Duration = 24 + } + ]; + } +} diff --git a/CarRentalService.Generator.Grpc.Host/CarRentalService.Generator.Grpc.Host.csproj b/CarRentalService.Generator.Grpc.Host/CarRentalService.Generator.Grpc.Host.csproj new file mode 100644 index 000000000..f281b49cf --- /dev/null +++ b/CarRentalService.Generator.Grpc.Host/CarRentalService.Generator.Grpc.Host.csproj @@ -0,0 +1,21 @@ + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/CarRentalService.Generator.Grpc.Host/Generator/RentalGenerator.cs b/CarRentalService.Generator.Grpc.Host/Generator/RentalGenerator.cs new file mode 100644 index 000000000..3bd1ee111 --- /dev/null +++ b/CarRentalService.Generator.Grpc.Host/Generator/RentalGenerator.cs @@ -0,0 +1,42 @@ +using Bogus; +using CarRentalService.Application.Contracts.Grpc; + +namespace CarRentalService.Generator.Grpc.Host.Generator; + +/// +/// Static generator for creating rental data using Bogus library +/// +public static class RentalGenerator +{ + private static readonly Faker _faker = new("ru"); + + private static readonly int[] _carIds = + Enumerable.Range(1, 15).ToArray(); + + private static readonly int[] _customerIds = + Enumerable.Range(1, 15).ToArray(); + + /// + /// Generates a list of rental contracts + /// + /// Number of contracts to generate + /// List of generated rental contracts + public static IList Generate(int count) + { + var list = new List(count); + + for (var i = 0; i < count; i++) + { + list.Add(new RentalContractMessage + { + Id = Guid.NewGuid().ToString(), + CarId = _faker.PickRandom(_carIds), + CustomerId = _faker.PickRandom(_customerIds), + DurationHours = _faker.Random.Double(0.5, 720), + StartTime = _faker.Date.Recent(30).ToString("o") + }); + } + + return list; + } +} diff --git a/CarRentalService.Generator.Grpc.Host/Grpc/CarRentalGrpcGeneratorService.cs b/CarRentalService.Generator.Grpc.Host/Grpc/CarRentalGrpcGeneratorService.cs new file mode 100644 index 000000000..3e529edad --- /dev/null +++ b/CarRentalService.Generator.Grpc.Host/Grpc/CarRentalGrpcGeneratorService.cs @@ -0,0 +1,83 @@ +using CarRentalService.Application.Contracts.Grpc; +using CarRentalService.Generator.Grpc.Host.Generator; +using Grpc.Core; + +namespace CarRentalService.Generator.Grpc.Host.Grpc; + +/// +/// gRPC service for generating rental contracts +/// +/// Application configuration +/// Logger instance +public sealed class CarRentalGrpcGeneratorService( + IConfiguration configuration, + ILogger logger +) : RentalIngestor.RentalIngestorBase +{ + private readonly int _defaultBatchSize = configuration.GetValue("Generator:BatchSize", 10); + private readonly int _waitTimeSeconds = configuration.GetValue("Generator:WaitTime", 2); + private readonly bool _enableLogging = configuration.GetValue("Generator:EnableLogging", true); + + /// + /// Bidirectional gRPC stream method for generating rental contracts + /// + /// Incoming request stream + /// Outgoing response stream + /// Server call context + public override async Task StreamRentals( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context) + { + logger.LogInformation("Starting gRPC stream for rental generation"); + + await foreach (var req in requestStream.ReadAllAsync(context.CancellationToken)) + { + if (_enableLogging) + { + logger.LogInformation( + "Received generation request: RequestId={RequestId}, Count={Count}, BatchSize={BatchSize}", + req.RequestId, req.Count, req.BatchSize); + } + + var batchSize = req.BatchSize > 0 ? req.BatchSize : _defaultBatchSize; + var sent = 0; + + while (sent < req.Count && !context.CancellationToken.IsCancellationRequested) + { + var take = Math.Min(batchSize, req.Count - sent); + var rentals = RentalGenerator.Generate(take); + + var payload = new RentalBatchStreamMessage + { + RequestId = req.RequestId, + IsFinal = sent + take >= req.Count + }; + + payload.Rentals.AddRange(rentals); + + if (_enableLogging) + { + logger.LogDebug( + "Sending batch: RequestId={RequestId}, BatchSize={BatchSize}, TotalSent={Sent}, IsFinal={IsFinal}", + req.RequestId, take, sent + take, payload.IsFinal); + } + + await responseStream.WriteAsync(payload, context.CancellationToken); + sent += take; + + if (!payload.IsFinal && _waitTimeSeconds > 0) + { + await Task.Delay(TimeSpan.FromSeconds(_waitTimeSeconds), context.CancellationToken); + } + } + + if (_enableLogging) + { + logger.LogInformation( + "Completed generation for RequestId={RequestId}: TotalGenerated={Total}", + req.RequestId, sent); + } + } + } +} diff --git a/CarRentalService.Generator.Grpc.Host/Program.cs b/CarRentalService.Generator.Grpc.Host/Program.cs new file mode 100644 index 000000000..bafd8042a --- /dev/null +++ b/CarRentalService.Generator.Grpc.Host/Program.cs @@ -0,0 +1,20 @@ +using CarRentalService.Generator.Grpc.Host.Grpc; +using CarRentalService.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddGrpc(options => +{ + options.EnableDetailedErrors = builder.Environment.IsDevelopment(); + options.MaxReceiveMessageSize = 32 * 1024 * 1024; + options.MaxSendMessageSize = 32 * 1024 * 1024; +}); + +var app = builder.Build(); + +app.MapGrpcService(); +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/CarRentalService.Generator.Grpc.Host/Properties/launchSettings.json b/CarRentalService.Generator.Grpc.Host/Properties/launchSettings.json new file mode 100644 index 000000000..946800195 --- /dev/null +++ b/CarRentalService.Generator.Grpc.Host/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "CarRentalService.Generator.Grpc.Host": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7000;http://localhost:7001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CarRentalService.Generator.Grpc.Host/appsettings.json b/CarRentalService.Generator.Grpc.Host/appsettings.json new file mode 100644 index 000000000..cda8a7f90 --- /dev/null +++ b/CarRentalService.Generator.Grpc.Host/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Grpc": "Warning" + } + }, + "Generator": { + "BatchSize": 10, + "WaitTime": 2, + "EnableLogging": true + }, + "AllowedHosts": "*" +} diff --git a/CarRentalService.Infrastructure.EfCore/CarRentalService.Infrastructure.EfCore.csproj b/CarRentalService.Infrastructure.EfCore/CarRentalService.Infrastructure.EfCore.csproj new file mode 100644 index 000000000..55afc6609 --- /dev/null +++ b/CarRentalService.Infrastructure.EfCore/CarRentalService.Infrastructure.EfCore.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/CarRentalService.Infrastructure.EfCore/CarRentalServiceDbContext.cs b/CarRentalService.Infrastructure.EfCore/CarRentalServiceDbContext.cs new file mode 100644 index 000000000..39c3cd566 --- /dev/null +++ b/CarRentalService.Infrastructure.EfCore/CarRentalServiceDbContext.cs @@ -0,0 +1,128 @@ +using CarRentalService.Domain.Models; +using Microsoft.EntityFrameworkCore; +using MongoDB.EntityFrameworkCore.Extensions; + +namespace CarRentalService.Infrastructure.EfCore; + +/// +/// Database context for Car Rental Service using MongoDB +/// +public class CarRentalDbContext : DbContext +{ + /// + /// Initializes a new instance of the CarRentalDbContext + /// + /// DbContext options + public CarRentalDbContext(DbContextOptions options) : base(options) + { + Database.AutoTransactionBehavior = AutoTransactionBehavior.Never; + } + /// + /// Gets or sets the CarModels collection + /// + public DbSet CarModels { get; set; } = null!; + /// + /// Gets or sets the CarModelGenerations collection + /// + public DbSet CarModelGenerations { get; set; } = null!; + /// + /// Gets or sets the Cars collection + /// + public DbSet Cars { get; set; } = null!; + /// + /// Gets or sets the Customers collection + /// + public DbSet Customers { get; set; } = null!; + /// + /// Gets or sets the Rents collection + /// + public DbSet Rents { get; set; } = null!; + + /// + /// Configures the database context options + /// + /// Options builder + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.EnableThreadSafetyChecks(false); + } + + /// + /// Configures the entity models and relationships for MongoDB + /// + /// Model builder instance + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // CarModel configuration + modelBuilder.Entity(builder => + { + builder.ToCollection("car_models"); + builder.HasKey(cm => cm.Id); + builder.Property(cm => cm.Id).HasElementName("_id"); + builder.Property(cm => cm.Name).IsRequired().HasElementName("name"); + builder.Property(cm => cm.DriveType).IsRequired().HasElementName("drive_type"); + builder.Property(cm => cm.SeatCount).IsRequired().HasElementName("seat_count"); + builder.Property(cm => cm.BodyType).IsRequired().HasElementName("body_type"); + builder.Property(cm => cm.CarClass).IsRequired().HasElementName("car_class"); + }); + + // CarModelGeneration configuration + modelBuilder.Entity(builder => + { + builder.ToCollection("car_model_generations"); + builder.HasKey(cmg => cmg.Id); + builder.Property(cmg => cmg.Id).HasElementName("_id"); + builder.Property(cmg => cmg.CarModelId).IsRequired().HasElementName("car_model_id"); + builder.Property(cmg => cmg.ProductionYear).IsRequired().HasElementName("production_year"); + builder.Property(cmg => cmg.EngineVolume).IsRequired().HasElementName("engine_volume"); + builder.Property(cmg => cmg.TransmissionType).IsRequired().HasElementName("transmission_type"); + builder.Property(cmg => cmg.RentalCostPerHour).IsRequired().HasElementName("rental_cost_per_hour"); + builder.HasOne(cmg => cmg.CarModel) + .WithMany() + .HasForeignKey(cmg => cmg.CarModelId); + }); + + // Car configuration + modelBuilder.Entity(builder => + { + builder.ToCollection("cars"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasElementName("_id"); + builder.Property(c => c.LicensePlate).IsRequired().HasElementName("license_plate"); + builder.Property(c => c.Color).IsRequired().HasElementName("color"); + builder.Property(c => c.CarModelGenerationId).IsRequired().HasElementName("car_model_generation_id"); + builder.HasOne(c => c.CarModelGeneration) + .WithMany() + .HasForeignKey(c => c.CarModelGenerationId); + }); + + // Customer configuration + modelBuilder.Entity(builder => + { + builder.ToCollection("customers"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).HasElementName("_id"); + builder.Property(c => c.DriverLicenseNumber).IsRequired().HasElementName("driver_license_number"); + builder.Property(c => c.FullName).IsRequired().HasElementName("full_name"); + builder.Property(c => c.DateOfBirth).IsRequired().HasElementName("date_of_birth"); + }); + + // Rent configuration + modelBuilder.Entity(builder => + { + builder.ToCollection("rents"); + builder.HasKey(r => r.Id); + builder.Property(r => r.Id).HasElementName("_id"); + builder.Property(r => r.CarId).IsRequired().HasElementName("car_id"); + builder.Property(r => r.CustomerId).IsRequired().HasElementName("customer_id"); + builder.Property(r => r.StartTime).IsRequired().HasElementName("start_time"); + builder.Property(r => r.Duration).IsRequired().HasElementName("duration"); + builder.HasOne(r => r.Car) + .WithMany() + .HasForeignKey(r => r.CarId); + builder.HasOne(r => r.Customer) + .WithMany() + .HasForeignKey(r => r.CustomerId); + }); + } +} diff --git a/CarRentalService.Infrastructure.EfCore/Repositories/CarEfCoreRepository.cs b/CarRentalService.Infrastructure.EfCore/Repositories/CarEfCoreRepository.cs new file mode 100644 index 000000000..7f8436b56 --- /dev/null +++ b/CarRentalService.Infrastructure.EfCore/Repositories/CarEfCoreRepository.cs @@ -0,0 +1,67 @@ +using CarRentalService.Domain; +using CarRentalService.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace CarRentalService.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for Car entities using Entity Framework Core with MongoDB +/// +public class CarEfCoreRepository(CarRentalDbContext context) : IRepository +{ + private readonly DbSet _cars = context.Cars!; + + /// + /// Retrieves a car by its identifier + /// + /// Car identifier + /// Car if found, null otherwise + public async Task Read(int id) + => await _cars.FirstOrDefaultAsync(e => e.Id == id); + + /// + /// Retrieves all cars from the database + /// + /// List of all cars + public async Task> ReadAll() + => await _cars.ToListAsync(); + + /// + /// Creates a new car in the database + /// + /// Car entity to create + /// Created car entity + public async Task Create(Car entity) + { + var result = await _cars.AddAsync(entity); + await context.SaveChangesAsync(); + return result.Entity; + } + + /// + /// Updates an existing car in the database + /// + /// Car entity with updated data + /// Updated car entity + public async Task Update(Car entity) + { + _cars.Update(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Deletes a car by its identifier + /// + /// Car identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + { + var entity = await Read(id); + if (entity == null) return false; + + _cars.Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} diff --git a/CarRentalService.Infrastructure.EfCore/Repositories/CarModelEfCoreRepository.cs b/CarRentalService.Infrastructure.EfCore/Repositories/CarModelEfCoreRepository.cs new file mode 100644 index 000000000..42eeba6aa --- /dev/null +++ b/CarRentalService.Infrastructure.EfCore/Repositories/CarModelEfCoreRepository.cs @@ -0,0 +1,67 @@ +using CarRentalService.Domain; +using CarRentalService.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace CarRentalService.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for CarModel entities using Entity Framework Core with MongoDB +/// +public class CarModelEfCoreRepository(CarRentalDbContext context) : IRepository +{ + private readonly DbSet _carModels = context.CarModels!; + + /// + /// Retrieves a car model by its identifier + /// + /// Car model identifier + /// Car model if found, null otherwise + public async Task Read(int id) + => await _carModels.FindAsync(id); + + /// + /// Retrieves all car models from the database + /// + /// List of all car models + public async Task> ReadAll() + => await _carModels.ToListAsync(); + + /// + /// Creates a new car model in the database + /// + /// Car model entity to create + /// Created car model entity + public async Task Create(CarModel entity) + { + var result = await _carModels.AddAsync(entity); + await context.SaveChangesAsync(); + return result.Entity; + } + + /// + /// Updates an existing car model in the database + /// + /// Car model entity with updated data + /// Updated car model entity + public async Task Update(CarModel entity) + { + _carModels.Update(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Deletes a car model by its identifier + /// + /// Car model identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + { + var entity = await Read(id); + if (entity == null) return false; + + _carModels.Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} diff --git a/CarRentalService.Infrastructure.EfCore/Repositories/CarModelGenerationEfCoreRepository.cs b/CarRentalService.Infrastructure.EfCore/Repositories/CarModelGenerationEfCoreRepository.cs new file mode 100644 index 000000000..7c2acc9aa --- /dev/null +++ b/CarRentalService.Infrastructure.EfCore/Repositories/CarModelGenerationEfCoreRepository.cs @@ -0,0 +1,68 @@ +using CarRentalService.Domain; +using CarRentalService.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace CarRentalService.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for CarModelGeneration entities using Entity Framework Core with MongoDB +/// +public class CarModelGenerationEfCoreRepository(CarRentalDbContext context) + : IRepository +{ + private readonly DbSet _generations = context.CarModelGenerations!; + + /// + /// Retrieves a car model generation by its identifier + /// + /// Car model generation identifier + /// Car model generation if found, null otherwise + public async Task Read(int id) + => await _generations.FindAsync(id); + + /// + /// Retrieves all car model generations from the database + /// + /// List of all car model generations + public async Task> ReadAll() + => await _generations.ToListAsync(); + + /// + /// Creates a new car model generation in the database + /// + /// Car model generation entity to create + /// Created car model generation entity + public async Task Create(CarModelGeneration entity) + { + var result = await _generations.AddAsync(entity); + await context.SaveChangesAsync(); + return result.Entity; + } + + /// + /// Updates an existing car model generation in the database + /// + /// Car model generation entity with updated data + /// Updated car model generation entity + public async Task Update(CarModelGeneration entity) + { + _generations.Update(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Deletes a car model generation by its identifier + /// + /// Car model generation identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + { + var entity = await Read(id); + if (entity == null) return false; + + _generations.Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} diff --git a/CarRentalService.Infrastructure.EfCore/Repositories/CustomerEfCoreRepository.cs b/CarRentalService.Infrastructure.EfCore/Repositories/CustomerEfCoreRepository.cs new file mode 100644 index 000000000..fe247e102 --- /dev/null +++ b/CarRentalService.Infrastructure.EfCore/Repositories/CustomerEfCoreRepository.cs @@ -0,0 +1,67 @@ +using CarRentalService.Domain; +using CarRentalService.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace CarRentalService.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for Customer entities using Entity Framework Core with MongoDB +/// +public class CustomerEfCoreRepository(CarRentalDbContext context) : IRepository +{ + private readonly DbSet _customers = context.Customers!; + + /// + /// Retrieves a customer by its identifier + /// + /// Customer identifier + /// Customer if found, null otherwise + public async Task Read(int id) + => await _customers.FindAsync(id); + + /// + /// Retrieves all customers from the database + /// + /// List of all customers + public async Task> ReadAll() + => await _customers.ToListAsync(); + + /// + /// Creates a new customer in the database + /// + /// Customer entity to create + /// Created customer entity + public async Task Create(Customer entity) + { + var result = await _customers.AddAsync(entity); + await context.SaveChangesAsync(); + return result.Entity; + } + + /// + /// Updates an existing customer in the database + /// + /// Customer entity with updated data + /// Updated customer entity + public async Task Update(Customer entity) + { + _customers.Update(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Deletes a customer by its identifier + /// + /// Customer identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + { + var entity = await Read(id); + if (entity == null) return false; + + _customers.Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} diff --git a/CarRentalService.Infrastructure.EfCore/Repositories/RentEfCoreRepository.cs b/CarRentalService.Infrastructure.EfCore/Repositories/RentEfCoreRepository.cs new file mode 100644 index 000000000..ffca10ab6 --- /dev/null +++ b/CarRentalService.Infrastructure.EfCore/Repositories/RentEfCoreRepository.cs @@ -0,0 +1,67 @@ +using CarRentalService.Domain; +using CarRentalService.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace CarRentalService.Infrastructure.EfCore.Repositories; + +/// +/// Repository implementation for Rent entities using Entity Framework Core with MongoDB +/// +public class RentEfCoreRepository(CarRentalDbContext context) : IRepository +{ + private readonly DbSet _rents = context.Rents!; + + /// + /// Retrieves a rent transaction by its identifier + /// + /// Rent identifier + /// Rent transaction if found, null otherwise + public async Task Read(int id) + => await _rents.FirstOrDefaultAsync(e => e.Id == id); + + /// + /// Retrieves all rent transactions from the database + /// + /// List of all rent transactions + public async Task> ReadAll() + => await _rents.ToListAsync(); + + /// + /// Creates a new rent transaction in the database + /// + /// Rent entity to create + /// Created rent entity + public async Task Create(Rent entity) + { + var result = await _rents.AddAsync(entity); + await context.SaveChangesAsync(); + return result.Entity; + } + + /// + /// Updates an existing rent transaction in the database + /// + /// Rent entity with updated data + /// Updated rent entity + public async Task Update(Rent entity) + { + _rents.Update(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Deletes a rent transaction by its identifier + /// + /// Rent identifier + /// True if deletion was successful, false otherwise + public async Task Delete(int id) + { + var entity = await Read(id); + if (entity == null) return false; + + _rents.Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} diff --git a/CarRentalService.ServiceDefaults/CarRentalService.ServiceDefaults.csproj b/CarRentalService.ServiceDefaults/CarRentalService.ServiceDefaults.csproj new file mode 100644 index 000000000..158284d8c --- /dev/null +++ b/CarRentalService.ServiceDefaults/CarRentalService.ServiceDefaults.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + + diff --git a/CarRentalService.ServiceDefaults/Extensions.cs b/CarRentalService.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..b1ee2581b --- /dev/null +++ b/CarRentalService.ServiceDefaults/Extensions.cs @@ -0,0 +1,129 @@ +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 CarRentalService.ServiceDefaults; + +/// +/// Extension methods for configuring service defaults in Aspire applications +/// +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + /// + /// Adds default service configurations for Aspire applications + /// + /// Host application builder type + /// Host application builder + /// Configured host application builder + 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; + } + + /// + /// Configures OpenTelemetry instrumentation for the application + /// + /// Host application builder type + /// Host application builder + /// Configured host application 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; + } + + /// + /// Adds OpenTelemetry exporters based on configuration + /// + /// Host application builder type + /// Host application builder + /// Configured host application builder + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + /// + /// Adds default health checks for the application + /// + /// Host application builder type + /// Host application builder + /// Configured host application builder + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + /// + /// Maps default endpoints for health checks and monitoring + /// + /// Web application instance + /// Configured web application + 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; + } +} diff --git a/CarRentalService.Test/CarRentalService.Tests.csproj b/CarRentalService.Test/CarRentalService.Tests.csproj new file mode 100644 index 000000000..4e1eb9772 --- /dev/null +++ b/CarRentalService.Test/CarRentalService.Tests.csproj @@ -0,0 +1,26 @@ + + + net8.0 + enable + enable + false + true + True + + + + + + + + + + + + + + + + + + diff --git a/CarRentalService.Test/CarRentalTests.cs b/CarRentalService.Test/CarRentalTests.cs new file mode 100644 index 000000000..eadded5cd --- /dev/null +++ b/CarRentalService.Test/CarRentalTests.cs @@ -0,0 +1,224 @@ +using CarRentalService.Domain.TestData; + +namespace CarRentalService.Tests; + +/// +/// Unit tests for the car rental service +/// Tests various business logic scenarios, including customer requests, rental calculations, and data aggregation +/// +public class CarRentalServiceTests(TestData service) : IClassFixture +{ + /// + /// Checks whether customers who have rented a specific car model return and whether they make an order using their full name + /// Checks the correctness of the filtering and sorting logic + /// + [Fact] + public void GetCustomersByCarModel_ShouldReturnCustomersOrderedByFullName() + { + // Arrange + var targetModelId = 1; // Toyota Camry + var expectedCount = 6; + var expectedCustomers = new List + { + "Ivanov Ivan Ivanovich", + "Kuznetsov Alexey Vladimirovich", + "Petrov Petr Petrovich", + "Sidorova Anna Sergeevna", + "Smirnova Ekaterina Dmitrievna", + "Vasiliev Dmitry Andreevich" + }; + + // Act + var result = service.Rents + .Where(r => r.Car.CarModelGeneration.CarModel.Id == targetModelId) + .Select(r => r.Customer) + .Distinct() + .OrderBy(c => c.FullName) + .ToList(); + + // Assert + Assert.Equal(expectedCount, result.Count); + for (var i = 0; i < expectedCount; i++) + { + Assert.Equal(expectedCustomers[i], result[i].FullName); + } + } + + /// + /// Checks for the search of currently rented cars + /// Checks if only cars with active rental terms are returned + /// + [Fact] + public void GetCarsCurrentlyRented_ShouldReturnActiveRentals() + { + // Arrange + var currentTime = new DateTime(2024, 3, 3, 14, 0, 0); + var expectedCount = 1; + var expectedCarLicensePlate = "F678GH777"; + + // Act + var activeRentals = service.Rents + .Where(r => + { + var rentalEnd = r.StartTime.AddHours(r.Duration); + return r.StartTime <= currentTime && currentTime <= rentalEnd; + }) + .Select(r => r.Car) + .Distinct() + .ToList(); + + // Assert + Assert.Equal(expectedCount, activeRentals.Count); + Assert.Equal(expectedCarLicensePlate, activeRentals[0].LicensePlate); + } + + /// + /// Checks the search for the top 5 most frequently rented cars + /// Checks the correctness of the ordering by the number of rented cars in descending order + /// + [Fact] + public void GetTop5MostFrequentlyRentedCars_ShouldReturnExpectedCars() + { + // Arrange + var expectedTopCount = 5; + var expectedFirstCarLicensePlate = "A123BC777"; + var expectedFirstCarRentalCount = 3; + var expectedSecondCarLicensePlate = "B234CD777"; + var expectedSecondCarRentalCount = 3; + + // Act + var topCars = service.Rents + .GroupBy(r => r.Car) + .Select(g => new + { + Car = g.Key, + RentalCount = g.Count() + }) + .OrderByDescending(x => x.RentalCount) + .ThenBy(x => x.Car.LicensePlate) + .Take(5) + .ToList(); + + // Assert + Assert.Equal(expectedTopCount, topCars.Count); + Assert.Equal(expectedFirstCarLicensePlate, topCars[0].Car.LicensePlate); + Assert.Equal(expectedFirstCarRentalCount, topCars[0].RentalCount); + Assert.Equal(expectedSecondCarLicensePlate, topCars[1].Car.LicensePlate); + Assert.Equal(expectedSecondCarRentalCount, topCars[1].RentalCount); + } + + /// + /// Checks the calculation of the number of rented cars for each car in the fleet + /// Checks the compliance of aggregate indicators with the records of individual car rentals + /// + [Fact] + public void GetRentalCountPerCar_ShouldReturnCountForEachCar() + { + // Arrange + var rentals = service.Rents; + var cars = service.Cars; + + var expectedRentalCounts = new Dictionary + { + { 1, 3 }, // Car Id = 1 (A123BC777) - 3 rents + { 2, 3 }, // Car Id = 2 (B234CD777) - 3 rents + { 3, 2 }, // Car Id = 3 (C345DE777) - 2 rents + { 4, 1 }, // Car Id = 4 (D456EF777) - 1 rent + { 5, 1 }, // Car Id = 5 (E567FG777) - 1 rent + { 6, 1 }, // Car Id = 6 (F678GH777) - 1 rent + { 7, 1 }, // Car Id = 7 (G789HI777) - 1 rent + { 8, 1 }, // Car Id = 8 (H890IJ777) - 1 rent + { 9, 1 }, // Car Id = 9 (I901JK777) - 1 rent + { 10, 1 }, // Car Id = 10 (J012KL777) - 1 rent + { 11, 1 }, // Car Id = 11 (K123LM777) - 1 rent + { 12, 1 }, // Car Id = 12 (L234MN777) - 1 rent + { 13, 1 }, // Car Id = 13 (M345NO777) - 1 rent + { 14, 1 }, // Car Id = 14 (N456OP777) - 1 rent + { 15, 1 } // Car Id = 15 (O567PQ777) - 1 rent + }; + + // Act + var rentalCounts = rentals + .GroupBy(r => r.Car.Id) + .Select(g => new + { + CarId = g.Key, + RentalCount = g.Count(), + g.First().Car + }) + .ToList(); + + var totalRentals = rentalCounts.Sum(x => x.RentalCount); + + // Assert + Assert.Equal(rentals.Count, totalRentals); + Assert.Equal(expectedRentalCounts, rentalCounts.ToDictionary(x => x.CarId, x => x.RentalCount)); + } + + /// + /// Checks the search for the top 5 customers by total rental cost + /// Checks the correctness of the order by total cost in descending order + /// + [Fact] + public void GetTop5CustomersByRentalSum_ShouldReturnCorrectOrder() + { + // Arrange + var expectedTopCount = 5; + var expectedTopCustomerName = "Petrov Petr Petrovich"; // 360.000 + var expectedSecondCustomerName = "Kuznetsov Alexey Vladimirovich"; // 238.800 + var expectedThirdCustomerName = "Sidorova Anna Sergeevna"; // 216.000 + var expectedFourthCustomerName = "Vasiliev Dmitry Andreevich"; // 216.000 + var expectedFifthCustomerName = "Lebedeva Tatyana Petrovna"; // 158.400 + + // Act + var topCustomers = service.Rents + .GroupBy(r => r.Customer) + .Select(g => new + { + Customer = g.Key, + TotalRentalCost = g.Sum(r => (decimal)r.Duration * r.Car.CarModelGeneration.RentalCostPerHour) + }) + .OrderByDescending(x => x.TotalRentalCost) + .ThenBy(x => x.Customer.FullName) + .Take(5) + .ToList(); + + // Assert + Assert.Equal(expectedTopCount, topCustomers.Count); + Assert.Equal(expectedTopCustomerName, topCustomers[0].Customer.FullName); + Assert.Equal(expectedSecondCustomerName, topCustomers[1].Customer.FullName); + Assert.Equal(expectedThirdCustomerName, topCustomers[2].Customer.FullName); + Assert.Equal(expectedFourthCustomerName, topCustomers[3].Customer.FullName); + Assert.Equal(expectedFifthCustomerName, topCustomers[4].Customer.FullName); + Assert.True(topCustomers.All(c => c.TotalRentalCost > 0)); + } + + /// + /// Additional test: Get customers by car model name instead of ID + /// Alternative implementation using model name + /// + [Fact] + public void GetCustomersByCarModelName_ShouldReturnCorrectCustomers() + { + // Arrange + var targetModelName = "BMW X5"; + var expectedCount = 3; + var expectedCustomer1 = "Morozov Sergey Viktorovich"; + var expectedCustomer2 = "Nikolaeva Olga Igorevna"; + var expectedCustomer3 = "Vasiliev Dmitry Andreevich"; + + // Act + var customers = service.Rents + .Where(r => r.Car.CarModelGeneration.CarModel.Name == targetModelName) + .Select(r => r.Customer) + .Distinct() + .OrderBy(c => c.FullName) + .ToList(); + + // Assert + Assert.Equal(expectedCount, customers.Count); + Assert.Equal(expectedCustomer1, customers[0].FullName); + Assert.Equal(expectedCustomer2, customers[1].FullName); + Assert.Equal(expectedCustomer3, customers[2].FullName); + } +} diff --git a/CarRentalService.sln b/CarRentalService.sln new file mode 100644 index 000000000..5c8277b1c --- /dev/null +++ b/CarRentalService.sln @@ -0,0 +1,70 @@ + +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}") = "CarRentalService.Domain", "CarRentalService.Domain\CarRentalService.Domain.csproj", "{93B3FBEC-5D42-4B64-8A59-4D57E98DE397}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRentalService.Test", "CarRentalService.Test\CarRentalService.Tests.csproj", "{4DA134E4-D013-4CA9-97BB-C5DC5B4B78E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRentalService.Api", "CarRentalService.Api\CarRentalService.Api.csproj", "{A1B2C3D4-E5F6-7890-AB12-CD34EF567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRentalService.Application", "CarRentalService.Application\CarRentalService.Application.csproj", "{B2C3D4E5-F607-8901-BC23-DE45F6789012}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRentalService.Application.Contracts", "CarRentalService.Application.Contracts\CarRentalService.Application.Contracts.csproj", "{C3D4E5F6-0789-0123-CD45-EF6789012345}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRentalService.Infrastructure.EfCore", "CarRentalService.Infrastructure.EfCore\CarRentalService.Infrastructure.EfCore.csproj", "{55D01C34-C464-463B-B6EC-9F75F4F17E77}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRentalService.AppHost", "CarRentalService.AppHost\CarRentalService.AppHost.csproj", "{2BAC1581-5EEE-45AB-BAAE-C62B8A8012D0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRentalService.ServiceDefaults", "CarRentalService.ServiceDefaults\CarRentalService.ServiceDefaults.csproj", "{8622A85F-E3B3-4028-B83B-2375982907FB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRentalService.Generator.Grpc.Host", "CarRentalService.Generator.Grpc.Host\CarRentalService.Generator.Grpc.Host.csproj", "{0FEC7B89-9D58-410E-817A-D13D4F69923F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {93B3FBEC-5D42-4B64-8A59-4D57E98DE397}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93B3FBEC-5D42-4B64-8A59-4D57E98DE397}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93B3FBEC-5D42-4B64-8A59-4D57E98DE397}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93B3FBEC-5D42-4B64-8A59-4D57E98DE397}.Release|Any CPU.Build.0 = Release|Any CPU + {4DA134E4-D013-4CA9-97BB-C5DC5B4B78E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4DA134E4-D013-4CA9-97BB-C5DC5B4B78E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DA134E4-D013-4CA9-97BB-C5DC5B4B78E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4DA134E4-D013-4CA9-97BB-C5DC5B4B78E8}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-AB12-CD34EF567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-AB12-CD34EF567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-AB12-CD34EF567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-AB12-CD34EF567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F607-8901-BC23-DE45F6789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F607-8901-BC23-DE45F6789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F607-8901-BC23-DE45F6789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F607-8901-BC23-DE45F6789012}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-0789-0123-CD45-EF6789012345}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-0789-0123-CD45-EF6789012345}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-0789-0123-CD45-EF6789012345}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-0789-0123-CD45-EF6789012345}.Release|Any CPU.Build.0 = Release|Any CPU + {55D01C34-C464-463B-B6EC-9F75F4F17E77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55D01C34-C464-463B-B6EC-9F75F4F17E77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55D01C34-C464-463B-B6EC-9F75F4F17E77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55D01C34-C464-463B-B6EC-9F75F4F17E77}.Release|Any CPU.Build.0 = Release|Any CPU + {2BAC1581-5EEE-45AB-BAAE-C62B8A8012D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BAC1581-5EEE-45AB-BAAE-C62B8A8012D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BAC1581-5EEE-45AB-BAAE-C62B8A8012D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BAC1581-5EEE-45AB-BAAE-C62B8A8012D0}.Release|Any CPU.Build.0 = Release|Any CPU + {8622A85F-E3B3-4028-B83B-2375982907FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8622A85F-E3B3-4028-B83B-2375982907FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8622A85F-E3B3-4028-B83B-2375982907FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8622A85F-E3B3-4028-B83B-2375982907FB}.Release|Any CPU.Build.0 = Release|Any CPU + {0FEC7B89-9D58-410E-817A-D13D4F69923F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0FEC7B89-9D58-410E-817A-D13D4F69923F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0FEC7B89-9D58-410E-817A-D13D4F69923F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0FEC7B89-9D58-410E-817A-D13D4F69923F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 76afcbfdd..41f3be46c 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,24 @@ -# Разработка корпоративных приложений -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1JD6aiOG6r7GrA79oJncjgUHWtfeW4g_YZ9ayNgxb_w0/edit?usp=sharing) +# Лабораторная работа №4 +## «Инфраструктура» - Реализация сервиса генерации данных и его интеграция с сервером -## Задание -### Цель -Реализация проекта сервисно-ориентированного приложения. +В четвертой лабораторной работе имплементировала сервис, который генерирует контракты. Контракты передаются в сервер и сохраняются в бд, отправка контрактов выполняется при помощи gRPC в потоковом виде. Сервис представляет из себя отдельное приложение без референсов к серверным проектам. +Также добавила запуск генератора в конфигурацию Aspire. -### Задачи -* Реализация объектно-ориентированной модели данных, -* Изучение реализации серверных приложений на базе WebAPI/OpenAPI, -* Изучение работы с брокерами сообщений, -* Изучение паттернов проектирования, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Unit-тестирование. +### Предметная область - «Пункт проката автомобилей»: +В базе данных службы проката автомобилей содержится информация о парке транспортных средств, клиентах и аренде. -### Лабораторные работы -
-1. «Классы» - Реализация объектной модели данных и unit-тестов -
-В рамках первой лабораторной работы необходимо подготовить структуру классов, описывающих предметную область, определяемую в задании. В каждом из заданий присутствует часть, связанная с обработкой данных, представленная в разделе «Unit-тесты». Данную часть необходимо реализовать в виде unit-тестов: подготовить тестовые данные, выполнить запрос с использованием LINQ, проверить результаты. +Автомобиль характеризуется поколением модели, госномером, цветом. +Поколение модели является справочником и содержит сведения о годе выпуска, объеме двигателя, типе коробки передач, модели. Для каждого поколения модели указывается стоимость аренды в час. +Модель является справочником и содержит сведения о названии модели, типе привода, числе посадочных мест, типе кузова, классе автомобиля. -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -Необходимо включить **как минимум 10** экземпляров каждого класса в датасид. - -
-
-2. «Сервер» - Реализация серверного приложения с использованием REST API -
-Во второй лабораторной работе необходимо реализовать серверное приложение, которое должно: -- Осуществлять базовые CRUD-операции с реализованными в первой лабораторной сущностями -- Предоставлять результаты аналитических запросов (раздел «Unit-тесты» задания) +Клиент характеризуется номером водительского удостоверения, ФИО, датой рождения. -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -
-
-
-3. «ORM» - Реализация объектно-реляционной модели. Подключение к базе данных и настройка оркестрации -
-В третьей лабораторной работе хранение должно быть переделано c инмемори коллекций на базу данных. -Должны быть созданы миграции для создания таблиц в бд и их первоначального заполнения. -
-Также необходимо настроить оркестратор Aspire на запуск сервера и базы данных. -
-
-
-4. «Инфраструктура» - Реализация сервиса генерации данных и его интеграция с сервером -
-В четвертой лабораторной работе необходимо имплементировать сервис, который генерировал бы контракты. Контракты далее передаются в сервер и сохраняются в бд. -Сервис должен представлять из себя отдельное приложение без референсов к серверным проектам за исключением библиотеки с контрактами. -Отправка контрактов при помощи gRPC должна выполняться в потоковом виде. -При использовании брокеров сообщений, необходимо предусмотреть ретраи при подключении к брокеру. +При выдаче автомобиля клиенту фиксируется время выдачи и указывается время аренды в часах. -Также необходимо добавить в конфигурацию Aspire запуск генератора и (если того требует вариант) брокера сообщений. -
-
-
-5. «Клиент» - Интеграция клиентского приложения с оркестратором -
-В пятой лабораторной необходимо добавить в конфигурацию Aspire запуск клиентского приложения для написанного ранее сервера. Клиент создается в рамках курса "Веб разработка". -
-
+### Swagger работает корректно -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Реализация серверной части на [ASP.NET](https://dotnet.microsoft.com/ru-ru/apps/aspnet). -* Реализация unit-тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Использование хранения данных в базе данных согласно варианту задания. -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview) -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus) и его взаимодейсвие с сервером согласно варианту задания. -* Автоматизация тестирования на уровне репозитория через [GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. +![alt text](image.png) -**Факультативно**: -* Реализация авторизации/аутентификации. -* Реализация atomic batch publishing/atomic batch consumption для брокеров, поддерживающих такой функционал. -* Реализация интеграционных тестов при помощи .NET Aspire. -* Реализация клиента на Blazor WASM. +![alt text](image-1.png) -Внимательно прочитайте [дискуссии](https://github.com/itsecd/enterprise-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма - -image1 - -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1Wc8AvsKS_1JptpsxHO-cwfAxz2ghxvQRQ0fy4el2ZOc/edit?usp=sharing) -[Список предметных областей](https://docs.google.com/document/d/15jWhXMwd2K8giFMKku_yrY_s2uQNEu4ugJXLYPvYJAE/edit?usp=sharing) -[Вопросы к экзамену](https://docs.google.com/document/d/1bjfvtzjyMljJbcu8YCvC8DzDegDUAmDeNtBz9M6FQes/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve -6. Прийти на занятие и защитить работу - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл -- **3 балла** за защиту: при сдаче лабораторной работы вам задается 3 вопроса, за каждый правильный ответ - 1 балл - -У вас 2 попытки пройти ревью (первичное ревью, ревью по результатам исправления). Если замечания по итогу не исправлены, то снимается один балл за код лабораторной работы. - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соотвествующим разделом дискуссий](https://github.com/itsecd/enterprise-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/enterprise-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/enterprise-development/discussions/categories/ideas). +![alt text](image-2.png) diff --git a/image-1.png b/image-1.png new file mode 100644 index 000000000..ebfe6652c Binary files /dev/null and b/image-1.png differ diff --git a/image-2.png b/image-2.png new file mode 100644 index 000000000..9d990fca4 Binary files /dev/null and b/image-2.png differ diff --git a/image.png b/image.png new file mode 100644 index 000000000..ff9ca3ad2 Binary files /dev/null and b/image.png differ