Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CarRental/CarRental/.github/workflows/dotnet-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: .NET Tests

on:
push:
branches: [ main, lab_1 ]
pull_request:
branches: [ main, lab_1 ]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x

- name: Restore dependencies
run: dotnet restore ./CarRental/CarRental.sln

- name: Build
run: dotnet build ./CarRental/CarRental.sln --no-restore --configuration Release

- name: Test
run: dotnet test ./CarRental/CarRental.Tests/CarRental.Tests.csproj --configuration Release --verbosity normal
25 changes: 25 additions & 0 deletions CarRental/CarRental/CarRental.API/CarRental.API.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer" Version="9.3.1" />
<PackageReference Include="Aspire.RabbitMQ.Client" Version="9.3.1" />
<PackageReference Include="AutoMapper" Version="15.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.13">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CarRental.Application.Contracts\CarRental.Application.Contracts.csproj" />
<ProjectReference Include="..\CarRental.Infrastructure\CarRental.Infrastructure.csproj" />
<ProjectReference Include="..\CarRental.Infrastructure.Messaging\CarRental.Infrastructure.Messaging.csproj" />
<ProjectReference Include="..\CarRental.ServiceDefaults\CarRental.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
164 changes: 164 additions & 0 deletions CarRental/CarRental/CarRental.API/Controllers/AnalyticsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
using AutoMapper;
using CarRental.Application.Contracts.Dto;
using CarRental.Domain.Entities;
using CarRental.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace CarRental.API.Controllers;

/// <summary>
/// Аналитические запросы по данным проката
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class AnalyticsController(
IRepository<Rental> rentalRepo,
IRepository<Car> carRepo,
IRepository<Client> clientRepo,
IRepository<ModelGeneration> generationRepo,
IMapper mapper) : ControllerBase
{
/// <summary>
/// Клиенты, арендовавшие ТС указанной модели, отсортированные по ФИО
/// </summary>
[HttpGet("clients-by-model")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ClientGetDto>>> GetClientsByModel([FromQuery] string modelName)
{
var query = rentalRepo.GetQueryable(q => q
.Include(r => r.Car)
.ThenInclude(c => c!.ModelGeneration)
.ThenInclude(mg => mg!.Model)
.Include(r => r.Client));

var clients = await query
.Where(r => r.Car!.ModelGeneration!.Model!.Name == modelName)
.Select(r => r.Client)
.Distinct()
.OrderBy(c => c!.FullName)
.ToListAsync();

return Ok(clients.Select(mapper.Map<ClientGetDto>));
}

/// <summary>
/// Автомобили, находящиеся в аренде на указанный момент
/// </summary>
[HttpGet("currently-rented")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<CarGetDto>>> GetCurrentlyRented([FromQuery] DateTime currentDate)
{
var activeCarIds = await rentalRepo.GetQueryable()
.Where(r => r.RentalDate.AddHours(r.RentalHours) > currentDate)
.Select(r => r.CarId)
.Distinct()
.ToListAsync();

var cars = await carRepo.GetQueryable()
.Where(c => activeCarIds.Contains(c.Id))
.Include(c => c.ModelGeneration)
.ThenInclude(mg => mg!.Model)
.ToListAsync();

return Ok(cars.Select(mapper.Map<CarGetDto>));
}

/// <summary>
/// Топ-5 наиболее часто арендуемых автомобилей
/// </summary>
[HttpGet("top-rented-cars")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<CarRentalCountDto>>> GetTopRentedCars()
{
var stats = await rentalRepo.GetQueryable()
.GroupBy(r => r.CarId)
.Select(g => new { CarId = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count)
.Take(5)
.ToListAsync();

var ids = stats.Select(s => s.CarId).ToList();
var cars = await carRepo.GetQueryable()
.Where(c => ids.Contains(c.Id))
.Include(c => c.ModelGeneration)
.ThenInclude(mg => mg!.Model)
.ToListAsync();

var dict = cars.ToDictionary(c => c.Id);
var result = stats
.Where(s => dict.ContainsKey(s.CarId))
.Select(s => new CarRentalCountDto(mapper.Map<CarGetDto>(dict[s.CarId]), s.Count));

return Ok(result);
}

/// <summary>
/// Число аренд для каждого автомобиля в парке
/// </summary>
[HttpGet("rentals-per-car")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<CarRentalCountDto>>> GetRentalsPerCar()
{
var counts = await rentalRepo.GetQueryable()
.GroupBy(r => r.CarId)
.Select(g => new { CarId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.CarId, x => x.Count);

var cars = await carRepo.GetQueryable()
.Include(c => c.ModelGeneration)
.ThenInclude(mg => mg!.Model)
.ToListAsync();

var result = cars
.Select(c => new CarRentalCountDto(
mapper.Map<CarGetDto>(c),
counts.GetValueOrDefault(c.Id, 0)))
.OrderByDescending(x => x.RentalCount);

return Ok(result);
}

/// <summary>
/// Топ-5 клиентов по суммарной стоимости аренды
/// </summary>
[HttpGet("top-clients-by-amount")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ClientRentalAmountDto>>> GetTopClientsByAmount()
{
var rentals = await rentalRepo.GetQueryable()
.Select(r => new { r.ClientId, r.CarId, r.RentalHours })
.ToListAsync();

var carPrices = await carRepo.GetQueryable()
.Join(generationRepo.GetQueryable(),
c => c.ModelGenerationId,
g => g.Id,
(c, g) => new { CarId = c.Id, g.RentalPricePerHour })
.ToDictionaryAsync(x => x.CarId, x => x.RentalPricePerHour);

var topStats = rentals
.GroupBy(r => r.ClientId)
.Select(g => new
{
ClientId = g.Key,
TotalAmount = g.Sum(r => r.RentalHours * carPrices.GetValueOrDefault(r.CarId, 0))
})
.OrderByDescending(x => x.TotalAmount)
.Take(5)
.ToList();

var topIds = topStats.Select(s => s.ClientId).ToList();
var clients = await clientRepo.GetQueryable()
.Where(c => topIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id);

var result = topStats
.Where(s => clients.ContainsKey(s.ClientId))
.Select(s => new ClientRentalAmountDto(
mapper.Map<ClientGetDto>(clients[s.ClientId]),
s.TotalAmount));

return Ok(result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using AutoMapper;
using CarRental.Application.Contracts.Dto;
using CarRental.Domain.Entities;
using CarRental.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace CarRental.API.Controllers;

/// <summary>
/// CRUD-операции над справочником моделей автомобилей
/// </summary>
[ApiController]
[Route("api/car-models")]
public class CarModelsController(
IRepository<CarModel> repo,
IMapper mapper) : ControllerBase
{
/// <summary>Получить список всех моделей</summary>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<CarModelGetDto>>> GetAll()
{
var items = await repo.GetAllAsync();
return Ok(items.Select(mapper.Map<CarModelGetDto>));
}

/// <summary>Получить модель по идентификатору</summary>
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CarModelGetDto>> GetById(int id)
{
var item = await repo.GetByIdAsync(id);
return item is null ? NotFound() : Ok(mapper.Map<CarModelGetDto>(item));
}

/// <summary>Создать новую модель</summary>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
public async Task<ActionResult<CarModelGetDto>> Create([FromBody] CarModelEditDto dto)
{
var entity = mapper.Map<CarModel>(dto);
var created = await repo.AddAsync(entity);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, mapper.Map<CarModelGetDto>(created));
}

/// <summary>Обновить модель</summary>
[HttpPut("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CarModelGetDto>> Update(int id, [FromBody] CarModelEditDto dto)
{
var existing = await repo.GetByIdAsync(id);
if (existing is null) return NotFound();

mapper.Map(dto, existing);
var updated = await repo.UpdateAsync(existing);
return Ok(mapper.Map<CarModelGetDto>(updated));
}

/// <summary>Удалить модель</summary>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
var deleted = await repo.DeleteAsync(id);
return deleted ? NoContent() : NotFound();
}
}
75 changes: 75 additions & 0 deletions CarRental/CarRental/CarRental.API/Controllers/CarsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using AutoMapper;
using CarRental.Application.Contracts.Dto;
using CarRental.Domain.Entities;
using CarRental.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace CarRental.API.Controllers;

/// <summary>
/// CRUD-операции над транспортными средствами
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class CarsController(
IRepository<Car> repo,
IMapper mapper) : ControllerBase
{
/// <summary>Получить список всех ТС с деталями поколения и модели</summary>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<CarGetDto>>> GetAll()
{
var items = await repo.GetAllAsync(q => q
.Include(c => c.ModelGeneration)
.ThenInclude(mg => mg!.Model));
return Ok(items.Select(mapper.Map<CarGetDto>));
}

/// <summary>Получить ТС по идентификатору</summary>
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CarGetDto>> GetById(int id)
{
var item = await repo.GetByIdAsync(id, q => q
.Include(c => c.ModelGeneration)
.ThenInclude(mg => mg!.Model));
return item is null ? NotFound() : Ok(mapper.Map<CarGetDto>(item));
}

/// <summary>Добавить новое ТС</summary>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
public async Task<ActionResult<CarGetDto>> Create([FromBody] CarEditDto dto)
{
var entity = mapper.Map<Car>(dto);
var created = await repo.AddAsync(entity);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, mapper.Map<CarGetDto>(created));
}

/// <summary>Обновить данные ТС</summary>
[HttpPut("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CarGetDto>> Update(int id, [FromBody] CarEditDto dto)
{
var existing = await repo.GetByIdAsync(id);
if (existing is null) return NotFound();

mapper.Map(dto, existing);
var updated = await repo.UpdateAsync(existing);
return Ok(mapper.Map<CarGetDto>(updated));
}

/// <summary>Удалить ТС</summary>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
var deleted = await repo.DeleteAsync(id);
return deleted ? NoContent() : NotFound();
}
}
Loading