diff --git a/.editorconfig b/.editorconfig
index 0f3bba5ca..a8a58a0e7 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -43,6 +43,7 @@ csharp_style_var_for_built_in_types = true:error
csharp_style_var_when_type_is_apparent = true:error
csharp_style_var_elsewhere = false:silent
csharp_space_around_binary_operators = before_and_after
+
[*.{cs,vb}]
#### Naming styles ####
@@ -64,31 +65,31 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
-dotnet_naming_symbols.interface.required_modifiers =
+dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
-dotnet_naming_symbols.types.required_modifiers =
+dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
-dotnet_naming_symbols.non_field_members.required_modifiers =
+dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.begins_with_i.required_prefix = I
-dotnet_naming_style.begins_with_i.required_suffix =
-dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
-dotnet_naming_style.pascal_case.required_prefix =
-dotnet_naming_style.pascal_case.required_suffix =
-dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
-dotnet_naming_style.pascal_case.required_prefix =
-dotnet_naming_style.pascal_case.required_suffix =
-dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml
new file mode 100644
index 000000000..4f359414d
--- /dev/null
+++ b/.github/workflows/dotnet-tests.yml
@@ -0,0 +1,29 @@
+name: dotnet-tests.yml
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore BikeRental/BikeRental.sln
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release BikeRental/BikeRental.sln
+
+ - name: Run tests
+ run: dotnet test BikeRental/BikeRental.Tests/BikeRental.Tests.csproj --no-build --configuration Release
+
+
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 000000000..a013d3a07
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/projectSettingsUpdater.xml
+/modules.xml
+/.idea.enterprise-development.iml
+/contentModel.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/indexLayout.xml b/.idea/indexLayout.xml
new file mode 100644
index 000000000..7b08163ce
--- /dev/null
+++ b/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 000000000..830674470
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/BikeRental/.idea/.idea.BikeRental/.idea/.gitignore b/BikeRental/.idea/.idea.BikeRental/.idea/.gitignore
new file mode 100644
index 000000000..e9332b459
--- /dev/null
+++ b/BikeRental/.idea/.idea.BikeRental/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/.idea.BikeRental.iml
+/projectSettingsUpdater.xml
+/modules.xml
+/contentModel.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/BikeRental/.idea/.idea.BikeRental/.idea/dictionaries/project.xml b/BikeRental/.idea/.idea.BikeRental/.idea/dictionaries/project.xml
new file mode 100644
index 000000000..dfd4ac66a
--- /dev/null
+++ b/BikeRental/.idea/.idea.BikeRental/.idea/dictionaries/project.xml
@@ -0,0 +1,8 @@
+
+
+
+ арендатели
+ сyclist
+
+
+
\ No newline at end of file
diff --git a/BikeRental/.idea/.idea.BikeRental/.idea/indexLayout.xml b/BikeRental/.idea/.idea.BikeRental/.idea/indexLayout.xml
new file mode 100644
index 000000000..7b08163ce
--- /dev/null
+++ b/BikeRental/.idea/.idea.BikeRental/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/BikeRental/.idea/.idea.BikeRental/.idea/vcs.xml b/BikeRental/.idea/.idea.BikeRental/.idea/vcs.xml
new file mode 100644
index 000000000..6c0b86358
--- /dev/null
+++ b/BikeRental/.idea/.idea.BikeRental/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/BikeRental/AppHost/AppHost.csproj b/BikeRental/AppHost/AppHost.csproj
new file mode 100644
index 000000000..bbcf587dc
--- /dev/null
+++ b/BikeRental/AppHost/AppHost.csproj
@@ -0,0 +1,30 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ fa0efc8b-9978-4cb9-83d3-bbcd25eab020
+
+
+
+
+
+
+
+
+
+
+ appsettings.json
+
+
+
+
+
+
+
+
+
diff --git a/BikeRental/AppHost/Program.cs b/BikeRental/AppHost/Program.cs
new file mode 100644
index 000000000..e76e8ba22
--- /dev/null
+++ b/BikeRental/AppHost/Program.cs
@@ -0,0 +1,35 @@
+using Projects;
+
+IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
+
+IResourceBuilder nats = builder.AddContainer("nats", "nats:2.10")
+ .WithArgs("-js")
+ .WithEndpoint(4222, 4222);
+
+IResourceBuilder bikeRentalDbPassword = builder.AddParameter(
+ "bike-rental-db-password",
+ "1234512345Aa$",
+ secret: true);
+IResourceBuilder bikeRentalSql = builder.AddMySql("bike-rental-db",
+ bikeRentalDbPassword)
+ .WithAdminer()
+ .WithDataVolume("bike-rental-volume");
+
+IResourceBuilder bikeRentalDb =
+ bikeRentalSql.AddDatabase("bike-rental");
+
+builder.AddProject("bike-rental-nats-generator")
+ .WaitFor(nats)
+ .WithEnvironment("Nats__Url", "nats://localhost:4222")
+ .WithEnvironment("Nats__StreamName", "bike-rental-stream")
+ .WithEnvironment("Nats__SubjectName", "bike-rental.leases");
+
+builder.AddProject("bike-rental-api")
+ .WaitFor(bikeRentalDb)
+ .WaitFor(nats)
+ .WithReference(bikeRentalDb)
+ .WithEnvironment("Nats__Url", "nats://localhost:4222")
+ .WithEnvironment("Nats__StreamName", "bike-rental-stream")
+ .WithEnvironment("Nats__SubjectName", "bike-rental.leases");
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/BikeRental/AppHost/Properties/launchSettings.json b/BikeRental/AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..9103756f1
--- /dev/null
+++ b/BikeRental/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:17195;http://localhost:15246",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21053",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22270",
+ "DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS": "true"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15246",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19196",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20132",
+ "DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS": "true"
+ }
+ }
+ }
+}
diff --git a/BikeRental/AppHost/appsettings.Development.json b/BikeRental/AppHost/appsettings.Development.json
new file mode 100644
index 000000000..1b2d3bafd
--- /dev/null
+++ b/BikeRental/AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/AppHost/appsettings.json b/BikeRental/AppHost/appsettings.json
new file mode 100644
index 000000000..888f884e2
--- /dev/null
+++ b/BikeRental/AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/BikeRental.Api.csproj b/BikeRental/BikeRental.Api/BikeRental.Api.csproj
new file mode 100644
index 000000000..ed573c19b
--- /dev/null
+++ b/BikeRental/BikeRental.Api/BikeRental.Api.csproj
@@ -0,0 +1,57 @@
+
+
+
+ net8.0
+ enable
+ enable
+ Linux
+ bin\Debug\net8.0\BikeRental.Api.xml
+ ..\..
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ appsettings.json
+
+
+
+
+
diff --git a/BikeRental/BikeRental.Api/Controllers/BikeModelsController.cs b/BikeRental/BikeRental.Api/Controllers/BikeModelsController.cs
new file mode 100644
index 000000000..6d58c4b04
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Controllers/BikeModelsController.cs
@@ -0,0 +1,84 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Application.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BikeRental.Api.Controllers;
+
+///
+/// Контроллер описывает конечные точки для работы с
+/// ресурсом "BikeModel" (модель велосипеда)
+/// "bikeModelService" - сервис для работы с ресурсом BikeModel
+/// Зависимость от интерфейса, а не конкретной реализации в сервисе (SOLID - DIP)
+///
+[ApiController]
+[Route("bike-models")]
+public sealed class BikeModelsController(IBikeModelService bikeModelService) : ControllerBase
+{
+ ///
+ /// Получить все модели велосипедов
+ ///
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ IEnumerable models = await bikeModelService.GetAll();
+ var sortedModels = models.OrderBy(model => model.Id).ToList();
+ return Ok(sortedModels);
+ }
+
+ ///
+ /// Получить модель велосипеда по идентификатору
+ ///
+ [HttpGet("{id:int}")]
+ public async Task> GetById(int id)
+ {
+ BikeModelDto? model = await bikeModelService.GetById(id);
+ if (model is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(model);
+ }
+
+ ///
+ /// Создать новую модель велосипеда
+ /// "dto" - модель для создания модели велосипеда
+ ///
+ [HttpPost]
+ public async Task> Create([FromBody] BikeModelCreateUpdateDto dto)
+ {
+ BikeModelDto created = await bikeModelService.Create(dto);
+
+ return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
+ }
+
+ ///
+ /// Обновить существующую модель велосипеда
+ ///
+ [HttpPut("{id:int}")]
+ public async Task> Update(int id, [FromBody] BikeModelCreateUpdateDto dto)
+ {
+ BikeModelDto? updated = await bikeModelService.Update(id, dto);
+ if (updated is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(updated);
+ }
+
+ ///
+ /// Удалить модель велосипеда по идентификатору
+ ///
+ [HttpDelete("{id:int}")]
+ public async Task Delete(int id)
+ {
+ var deleted = await bikeModelService.Delete(id);
+ if (!deleted)
+ {
+ return NotFound();
+ }
+
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Controllers/BikesController.cs b/BikeRental/BikeRental.Api/Controllers/BikesController.cs
new file mode 100644
index 000000000..6faf806e7
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Controllers/BikesController.cs
@@ -0,0 +1,80 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Application.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BikeRental.Api.Controllers;
+
+///
+/// Контроллер описывает конечные точки для работы
+/// с ресурсом "Bike" (велосипед)
+///
+[ApiController]
+[Route("bikes")]
+public sealed class BikesController(IBikeService bikeService) : ControllerBase
+{
+ ///
+ /// Получить все ресурсы Bike
+ ///
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ IEnumerable bikes = await bikeService.GetAll();
+ var sortedBikes = bikes.OrderBy(bike => bike.Id).ToList();
+ return Ok(sortedBikes);
+ }
+
+ ///
+ /// Получить ресурс по идентификатору Bike
+ ///
+ [HttpGet("{id:int}")]
+ public async Task> GetById(int id)
+ {
+ BikeDto? bike = await bikeService.GetById(id);
+ if (bike is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(bike);
+ }
+
+ ///
+ /// Создать новый ресурс Bike
+ ///
+ [HttpPost]
+ public async Task> Create([FromBody] BikeCreateUpdateDto dto)
+ {
+ BikeDto created = await bikeService.Create(dto);
+ return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
+ }
+
+ ///
+ /// Обновить существующий ресурс Bike
+ ///
+ [HttpPut("{id:int}")]
+ public async Task> Update(int id, [FromBody] BikeCreateUpdateDto dto)
+ {
+ BikeDto? updated = await bikeService.Update(id, dto);
+ if (updated is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(updated);
+ }
+
+ ///
+ /// Удалить ресурс Bike
+ ///
+ [HttpDelete("{id:int}")]
+ public async Task Delete(int id)
+ {
+ var deleted = await bikeService.Delete(id);
+ if (!deleted)
+ {
+ return NotFound();
+ }
+
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Controllers/LeasesController.cs b/BikeRental/BikeRental.Api/Controllers/LeasesController.cs
new file mode 100644
index 000000000..acecd1cf8
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Controllers/LeasesController.cs
@@ -0,0 +1,80 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Application.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BikeRental.Api.Controllers;
+
+///
+/// Контроллер описывает конечные точки для работы с ресурсом
+/// "Lease" (договор на аренду велосипеда)
+///
+[ApiController]
+[Route("leases")]
+public sealed class LeasesController(ILeaseService leaseService) : ControllerBase
+{
+ ///
+ /// Получить все договора на аренду велосипедов
+ ///
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ IEnumerable leases = await leaseService.GetAll();
+ var sortedLeases = leases.OrderBy(l => l.Id).ToList();
+ return Ok(sortedLeases);
+ }
+
+ ///
+ /// Получить договор на аренду велосипеда по идентификатору
+ ///
+ [HttpGet("{id:int}")]
+ public async Task> GetById(int id)
+ {
+ LeaseDto? lease = await leaseService.GetById(id);
+ if (lease is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(lease);
+ }
+
+ ///
+ /// Создать договор на аренду велосипеда
+ ///
+ [HttpPost]
+ public async Task> Create([FromBody] LeaseCreateUpdateDto dto)
+ {
+ LeaseDto created = await leaseService.Create(dto);
+ return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
+ }
+
+ ///
+ /// Обновить состояние текущего договора на аренду велосипеда
+ ///
+ [HttpPut("{id:int}")]
+ public async Task> Update(int id, [FromBody] LeaseCreateUpdateDto dto)
+ {
+ LeaseDto? updated = await leaseService.Update(id, dto);
+ if (updated is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(updated);
+ }
+
+ ///
+ /// Удалить договор на аренду велосипеда по идентификатору
+ ///
+ [HttpDelete("{id:int}")]
+ public async Task Delete(int id)
+ {
+ var deleted = await leaseService.Delete(id);
+ if (!deleted)
+ {
+ return NotFound();
+ }
+
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Controllers/RentersController.cs b/BikeRental/BikeRental.Api/Controllers/RentersController.cs
new file mode 100644
index 000000000..7b68bf80e
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Controllers/RentersController.cs
@@ -0,0 +1,85 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Application.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BikeRental.Api.Controllers;
+
+///
+/// Контроллер описывает конечные точки для работы с ресурсом
+/// "Renter" (арендатор)
+///
+[ApiController]
+[Route("renters")]
+public sealed class RentersController(IRenterService renterService) : ControllerBase
+{
+ ///
+ /// Получить всех арендаторов
+ ///
+ [HttpGet]
+ public async Task>> GetAll()
+ {
+ IEnumerable renters = await renterService.GetAll();
+ var sortedRenters = renters.OrderBy(renter => renter.Id).ToList();
+ return Ok(sortedRenters);
+ }
+
+ ///
+ /// Получить арендатора по идентификатору
+ ///
+ ///
+ [HttpGet("{id:int}")]
+ public async Task> GetById(int id)
+ {
+ RenterDto? renter = await renterService.GetById(id);
+ if (renter is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(renter);
+ }
+
+ ///
+ /// Создать нового арендатора
+ ///
+ ///
+ [HttpPost]
+ public async Task> Create([FromBody] RenterCreateUpdateDto dto)
+ {
+ RenterDto created = await renterService.Create(dto);
+ return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
+ }
+
+ ///
+ /// Обновить существующего арендатора
+ ///
+ ///
+ ///
+ [HttpPut("{id:int}")]
+ public async Task> Update(int id, [FromBody] RenterCreateUpdateDto dto)
+ {
+ RenterDto? updated = await renterService.Update(id, dto);
+ if (updated is null)
+ {
+ return NotFound();
+ }
+
+ return Ok(updated);
+ }
+
+ ///
+ /// Удалить арендатора по идентификатору
+ ///
+ ///
+ [HttpDelete("{id:int}")]
+ public async Task Delete(int id)
+ {
+ var deleted = await renterService.Delete(id);
+ if (!deleted)
+ {
+ return NotFound();
+ }
+
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/DependencyInjection.cs b/BikeRental/BikeRental.Api/DependencyInjection.cs
new file mode 100644
index 000000000..3e9100829
--- /dev/null
+++ b/BikeRental/BikeRental.Api/DependencyInjection.cs
@@ -0,0 +1,117 @@
+using BikeRental.Api.Middleware;
+using BikeRental.Application.Interfaces;
+using BikeRental.Application.Services;
+using BikeRental.Domain.Interfaces;
+using BikeRental.Infrastructure.Database;
+using BikeRental.Infrastructure.Repositories;
+using BikeRental.Infrastructure.Services;
+using BikeRental.Infrastructure.Services.Impl;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Migrations;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+namespace BikeRental.Api;
+
+///
+/// Настройка зависимостей приложения
+///
+public static class DependencyInjection
+{
+ ///
+ /// Зарегистрировать и настроить сервисы контроллеров
+ ///
+ public static void AddControllers(this WebApplicationBuilder builder)
+ {
+ builder.Services.AddControllers(options =>
+ {
+ options.ReturnHttpNotAcceptable = false; // 406
+ });
+ }
+
+ ///
+ /// Зарегистрировать и настроить сервисы обработки ошибок
+ ///
+ public static void AddErrorHandling(this WebApplicationBuilder builder)
+ {
+ builder.Services.AddExceptionHandler();
+
+ builder.Services.AddProblemDetails();
+ }
+
+ ///
+ /// Зарегистрировать и настроить сервисы OpenTelemetry
+ ///
+ public static void AddObservability(this WebApplicationBuilder builder)
+ {
+ // Зарегистрировать сервис OpenTelemetry
+ builder.Services.AddOpenTelemetry()
+ // Добавить ресурс по наименованию приложения
+ .ConfigureResource(resource => resource.AddService(builder.Environment.ApplicationName))
+ // Настроить распределенную трассировку
+ .WithTracing(tracing => tracing
+ // Добавить инструментарии для HttpClient и ASP.NET Core
+ .AddHttpClientInstrumentation()
+ .AddAspNetCoreInstrumentation()
+ .AddEntityFrameworkCoreInstrumentation())
+ // Добавить метрики
+ .WithMetrics(metrics => metrics
+ // Добавить инструментарии для .NET Runtime, HttpClient и ASP.NET Core
+ .AddHttpClientInstrumentation()
+ .AddAspNetCoreInstrumentation()
+ .AddRuntimeInstrumentation())
+ // Настроить глобальный экспортер метрик
+ .UseOtlpExporter();
+
+ // Настроить ведение журнала OpenTelemetry
+ builder.Logging.AddOpenTelemetry(options =>
+ {
+ options.IncludeScopes = true; // Включить области
+ options.IncludeFormattedMessage = true; // Включить форматированные сообщения
+ });
+ }
+
+ ///
+ /// Зарегистрировать и настроить сервисы взаимодействия с базой данных
+ ///
+ public static void AddDatabase(this WebApplicationBuilder builder)
+ {
+ builder.Services.AddDbContext(options =>
+ options
+ .UseMySQL(
+ builder.Configuration.GetConnectionString("bike-rental") ?? throw new InvalidOperationException(),
+ npgsqlOptions => npgsqlOptions
+ // Настроить таблицу истории миграций
+ .MigrationsHistoryTable(HistoryRepository.DefaultTableName))
+ // Использовать соглашение именования snake_case
+ .UseSnakeCaseNamingConvention());
+ }
+
+ ///
+ /// Зарегистрировать репозитории
+ ///
+ public static void AddRepositories(this WebApplicationBuilder builder)
+ {
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ }
+
+ ///
+ /// Регистрация сервисов общего назначения
+ ///
+ public static void AddServices(this WebApplicationBuilder builder)
+ {
+ // Зарегистрировать сервис инициализации данных
+ builder.Services.AddScoped();
+
+ // Зарегистрировать сервисы прикладного уровня
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Extensions/DatabaseExtensions.cs b/BikeRental/BikeRental.Api/Extensions/DatabaseExtensions.cs
new file mode 100644
index 000000000..17286f012
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Extensions/DatabaseExtensions.cs
@@ -0,0 +1,38 @@
+using BikeRental.Infrastructure.Database;
+using Microsoft.EntityFrameworkCore;
+
+namespace BikeRental.Api.Extensions;
+
+///
+/// Предоставляет методы расширения для работы с базой данных
+///
+public static class DatabaseExtensions
+{
+ ///
+ /// Применить все миграции к базе данных
+ ///
+ ///
+ public static async Task ApplyMigrationsAsync(this WebApplication app)
+ {
+ // мы не в HTTP запросе тк это запуск приложения
+ // поэтому создаем Scope(один из уровней DI контейнера) вручную, как бы новую область видимости для DI
+ // Scope гарантирует, что все зависимости будут правильно созданы и уничтожены
+ using IServiceScope scope = app.Services.CreateScope();
+ await using ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService();
+ // scope.ServiceProvider - DI контейнер в рамках созданного Scope
+ // GetRequiredService() - получить сервис типа T
+ // Требует, чтобы сервис был зарегистрирован, иначе исключение
+ // DbContext реализует IAsyncDisposable (асинхронное освобождение ресурсов)
+
+ try
+ {
+ await dbContext.Database.MigrateAsync();
+ app.Logger.LogInformation("Database migrations applied successfully.");
+ }
+ catch (Exception e)
+ {
+ app.Logger.LogError(e, "An error occurred while applying database migrations.");
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Extensions/SeedDataExtensions.cs b/BikeRental/BikeRental.Api/Extensions/SeedDataExtensions.cs
new file mode 100644
index 000000000..4b761d2f9
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Extensions/SeedDataExtensions.cs
@@ -0,0 +1,21 @@
+using BikeRental.Infrastructure.Services;
+
+namespace BikeRental.Api.Extensions;
+
+///
+/// Предоставляет методы расширения для выполнения
+/// первичной инициализации данных в бд
+///
+public static class SeedDataExtensions
+{
+ ///
+ /// Проинициализировать данные в бд
+ ///
+ ///
+ public static async Task SeedData(this IApplicationBuilder app)
+ {
+ using IServiceScope scope = app.ApplicationServices.CreateScope();
+ ISeedDataService seedDataService = scope.ServiceProvider.GetRequiredService();
+ await seedDataService.SeedDataAsync();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Messaging/NatsConsumerSettings.cs b/BikeRental/BikeRental.Api/Messaging/NatsConsumerSettings.cs
new file mode 100644
index 000000000..6d5e73266
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Messaging/NatsConsumerSettings.cs
@@ -0,0 +1,17 @@
+namespace BikeRental.Api.Messaging;
+
+internal sealed class NatsConsumerSettings
+{
+ public string Url { get; init; } = "nats://localhost:4222";
+ public string StreamName { get; init; } = string.Empty;
+ public string SubjectName { get; init; } = string.Empty;
+ public string DurableName { get; init; } = "bike-rental-lease-consumer";
+ public int AckWaitSeconds { get; init; } = 30;
+ public long MaxDeliver { get; init; } = 5;
+ public int ConnectRetryAttempts { get; init; } = 5;
+ public int ConnectRetryDelayMs { get; init; } = 2000;
+ public double RetryBackoffFactor { get; init; } = 2;
+ public int ConsumeMaxMsgs { get; init; } = 100;
+ public int ConsumeExpiresSeconds { get; init; } = 30;
+ public int ConsumeRetryDelayMs { get; init; } = 2000;
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Messaging/NatsLeaseConsumer.cs b/BikeRental/BikeRental.Api/Messaging/NatsLeaseConsumer.cs
new file mode 100644
index 000000000..f5450db42
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Messaging/NatsLeaseConsumer.cs
@@ -0,0 +1,230 @@
+using System.Text.Json;
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Application.Interfaces;
+using BikeRental.Infrastructure.Database;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.Options;
+using NATS.Client.Core;
+using NATS.Client.JetStream;
+using NATS.Client.JetStream.Models;
+using NATS.Net;
+
+namespace BikeRental.Api.Messaging;
+
+internal sealed class NatsLeaseConsumer(
+ INatsConnection connection,
+ IOptions settings,
+ IServiceScopeFactory scopeFactory,
+ ILogger logger) : BackgroundService
+{
+ private readonly INatsDeserialize _deserializer = BuildDeserializer(connection);
+ private readonly NatsConsumerSettings _settings = settings.Value;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ ValidateSettings(_settings);
+
+ await ExecuteWithRetryAsync(
+ "connect to NATS",
+ _settings.ConnectRetryAttempts,
+ TimeSpan.FromMilliseconds(_settings.ConnectRetryDelayMs),
+ async () => await connection.ConnectAsync(),
+ stoppingToken);
+
+ INatsJSContext context = connection.CreateJetStreamContext();
+ var streamConfig = new StreamConfig(_settings.StreamName, [_settings.SubjectName]);
+
+ await ExecuteWithRetryAsync(
+ "create/update stream",
+ _settings.ConnectRetryAttempts,
+ TimeSpan.FromMilliseconds(_settings.ConnectRetryDelayMs),
+ async () => await context.CreateOrUpdateStreamAsync(streamConfig, stoppingToken),
+ stoppingToken);
+
+ var consumerConfig = new ConsumerConfig
+ {
+ Name = _settings.DurableName,
+ DurableName = _settings.DurableName,
+ AckPolicy = ConsumerConfigAckPolicy.Explicit,
+ DeliverPolicy = ConsumerConfigDeliverPolicy.All,
+ ReplayPolicy = ConsumerConfigReplayPolicy.Instant,
+ FilterSubject = _settings.SubjectName,
+ AckWait = TimeSpan.FromSeconds(Math.Max(1, _settings.AckWaitSeconds)),
+ MaxDeliver = Math.Max(1, _settings.MaxDeliver)
+ };
+
+ INatsJSConsumer consumer = await ExecuteWithRetryAsync(
+ "create/update consumer",
+ _settings.ConnectRetryAttempts,
+ TimeSpan.FromMilliseconds(_settings.ConnectRetryDelayMs),
+ async () => await context.CreateOrUpdateConsumerAsync(_settings.StreamName, consumerConfig, stoppingToken),
+ stoppingToken);
+
+ var consumeOptions = new NatsJSConsumeOpts
+ {
+ MaxMsgs = Math.Max(1, _settings.ConsumeMaxMsgs),
+ Expires = TimeSpan.FromSeconds(Math.Max(1, _settings.ConsumeExpiresSeconds))
+ };
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await foreach (INatsJSMsg msg in consumer.ConsumeAsync(_deserializer, consumeOptions,
+ stoppingToken))
+ {
+ await HandleMessageAsync(msg, stoppingToken);
+ }
+ }
+ catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
+ {
+ logger.LogError(ex, "Error while consuming leases from NATS. Retrying in {delay}ms.",
+ _settings.ConsumeRetryDelayMs);
+ await Task.Delay(Math.Max(0, _settings.ConsumeRetryDelayMs), stoppingToken);
+ }
+ }
+ }
+
+ private async Task HandleMessageAsync(INatsJSMsg msg, CancellationToken stoppingToken)
+ {
+ if (msg.Data is null || msg.Data.Length == 0)
+ {
+ logger.LogWarning("Received empty lease batch message.");
+ await msg.AckAsync(cancellationToken: stoppingToken);
+ return;
+ }
+
+ List? leases;
+ try
+ {
+ leases = JsonSerializer.Deserialize>(msg.Data);
+ }
+ catch (JsonException ex)
+ {
+ logger.LogError(ex, "Failed to deserialize lease batch payload.");
+ await msg.AckTerminateAsync(cancellationToken: stoppingToken);
+ return;
+ }
+
+ if (leases is null || leases.Count == 0)
+ {
+ logger.LogWarning("Received lease batch with no items.");
+ await msg.AckAsync(cancellationToken: stoppingToken);
+ return;
+ }
+
+ try
+ {
+ await SaveBatchAsync(leases, stoppingToken);
+ await msg.AckAsync(cancellationToken: stoppingToken);
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning(ex, "Lease batch contains invalid references. Message will be terminated.");
+ await msg.AckTerminateAsync(cancellationToken: stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to persist lease batch. Message will be retried.");
+ }
+ }
+
+ private async Task SaveBatchAsync(IReadOnlyList leases, CancellationToken stoppingToken)
+ {
+ await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
+ ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService();
+ ILeaseService leaseService = scope.ServiceProvider.GetRequiredService();
+
+ await using IDbContextTransaction transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken);
+ foreach (LeaseCreateUpdateDto lease in leases)
+ {
+ await leaseService.Create(lease);
+ }
+
+ await transaction.CommitAsync(stoppingToken);
+ }
+
+ private async Task ExecuteWithRetryAsync(
+ string operation,
+ int attempts,
+ TimeSpan baseDelay,
+ Func action,
+ CancellationToken stoppingToken)
+ {
+ _ = await ExecuteWithRetryAsync(
+ operation,
+ attempts,
+ baseDelay,
+ async () =>
+ {
+ await action();
+ return new object();
+ },
+ stoppingToken);
+ }
+
+ private async Task ExecuteWithRetryAsync(
+ string operation,
+ int attempts,
+ TimeSpan baseDelay,
+ Func> action,
+ CancellationToken stoppingToken)
+ {
+ var retries = Math.Max(1, attempts);
+ TimeSpan delay = baseDelay;
+ var backoff = _settings.RetryBackoffFactor <= 0 ? 2 : _settings.RetryBackoffFactor;
+
+ for (var attempt = 1; attempt <= retries; attempt++)
+ {
+ try
+ {
+ return await action();
+ }
+ catch (Exception ex) when (attempt < retries && !stoppingToken.IsCancellationRequested)
+ {
+ if (delay > TimeSpan.Zero)
+ {
+ logger.LogWarning(
+ ex,
+ "Failed to {operation} (attempt {attempt}/{retries}). Retrying in {delay}.",
+ operation,
+ attempt,
+ retries,
+ delay);
+ await Task.Delay(delay, stoppingToken);
+ delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * backoff);
+ }
+ else
+ {
+ logger.LogWarning(
+ ex,
+ "Failed to {operation} (attempt {attempt}/{retries}). Retrying immediately.",
+ operation,
+ attempt,
+ retries);
+ }
+ }
+ }
+
+ throw new InvalidOperationException($"Failed to {operation} after {retries} attempts.");
+ }
+
+ private static INatsDeserialize BuildDeserializer(INatsConnection connection)
+ {
+ INatsSerializerRegistry registry = connection.Opts.SerializerRegistry;
+ return registry.GetDeserializer();
+ }
+
+ private static void ValidateSettings(NatsConsumerSettings settings)
+ {
+ if (string.IsNullOrWhiteSpace(settings.StreamName))
+ {
+ throw new KeyNotFoundException("StreamName is not configured in Nats section.");
+ }
+
+ if (string.IsNullOrWhiteSpace(settings.SubjectName))
+ {
+ throw new KeyNotFoundException("SubjectName is not configured in Nats section.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Middleware/GlobalExceptionHandler.cs b/BikeRental/BikeRental.Api/Middleware/GlobalExceptionHandler.cs
new file mode 100644
index 000000000..b937180db
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Middleware/GlobalExceptionHandler.cs
@@ -0,0 +1,147 @@
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BikeRental.Api.Middleware;
+
+///
+/// Глобальный обработчик исключений с логированием
+///
+public sealed class GlobalExceptionHandler(
+ IProblemDetailsService problemDetailsService,
+ ILogger logger)
+ : IExceptionHandler
+{
+ ///
+ /// Попытаться обработать исключение
+ ///
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ // понятным сообщением сделать логи
+ LogExceptionWithSimpleMessage(httpContext, exception);
+
+ ProblemDetails problemDetails = CreateProblemDetails(exception);
+
+ return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
+ {
+ HttpContext = httpContext,
+ Exception = exception,
+ ProblemDetails = problemDetails
+ // 1. Пользователь кинул запрос например GET .../999
+ // 2. Контроллер отсылает в NullReferenceException - тк bikeModelService.GetById(id) вернет null.
+ // 3. ASP.NET Core ловит исключение
+ // 4. Вызывает GlobalExceptionHandler.TryHandleAsync()
+ // 5. логируем ошибку и создаем ProblemDetails, ProblemDetailsService генерирует JSON ответ
+
+ // Возвращает true, если исключение было успешно обработано, false - если нужно пробросить дальше
+ // TryWriteAsync возвращает false - клиент получает дефолтный ответ (500)
+ // клиент не узнаёт что именно не так
+ // но в консольке все выводится
+ });
+ }
+
+ ///
+ /// Логирование с короткими понятными сообщениями
+ ///
+ private void LogExceptionWithSimpleMessage(HttpContext httpContext, Exception exception)
+ {
+ PathString requestPath = httpContext.Request.Path;
+ var method = httpContext.Request.Method;
+ var exceptionType = exception.GetType().Name;
+
+ // Основное понятное сообщение
+ var message = exception switch
+ {
+ KeyNotFoundException keyEx => $"Resource not found: {keyEx.Message.Replace("not found", "")}",
+ ArgumentException argEx => $"Invalid input: {argEx.Message}",
+ InvalidOperationException opEx => $"Invalid operation: {opEx.Message}",
+ UnauthorizedAccessException => "Access denied",
+ _ => "Internal server error"
+ };
+
+ // Для 404 и 400 - Warning с кратким сообщением
+ if (exception is KeyNotFoundException or ArgumentException or InvalidOperationException)
+ {
+ logger.LogWarning(
+ "[{StatusCode}] {Method} {Path} - {Message}",
+ GetStatusCode(exception),
+ method,
+ requestPath,
+ message);
+ }
+ // Для остальных - Error с полным stack trace
+ else
+ {
+ logger.LogError(
+ exception,
+ "[{StatusCode}] {Method} {Path} - {ExceptionType}: {Message}",
+ GetStatusCode(exception),
+ method,
+ requestPath,
+ exceptionType,
+ message);
+ }
+ }
+
+ ///
+ /// Создание ProblemDetails
+ ///
+ private static ProblemDetails CreateProblemDetails(Exception exception)
+ {
+ var statusCode = GetStatusCode(exception);
+
+ return new ProblemDetails
+ {
+ Title = GetTitle(exception),
+ Detail = GetDetail(exception),
+ Status = statusCode
+ };
+ }
+
+ ///
+ /// Получение статус кода
+ ///
+ private static int GetStatusCode(Exception exception)
+ {
+ return exception switch
+ {
+ KeyNotFoundException => StatusCodes.Status404NotFound,
+ ArgumentException => StatusCodes.Status400BadRequest,
+ InvalidOperationException => StatusCodes.Status400BadRequest,
+ UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
+ _ => StatusCodes.Status500InternalServerError
+ };
+ }
+
+ ///
+ /// Получение заголовка
+ ///
+ private static string GetTitle(Exception exception)
+ {
+ return exception switch
+ {
+ KeyNotFoundException => "Resource not found",
+ ArgumentException => "Bad request",
+ InvalidOperationException => "Invalid operation",
+ UnauthorizedAccessException => "Unauthorized",
+ _ => "Internal server error"
+ };
+ }
+
+ ///
+ /// Получение деталей
+ ///
+ private static string GetDetail(Exception exception)
+ {
+ // Для клиентских ошибок показываем сообщение исключения
+ if (exception is KeyNotFoundException or ArgumentException or InvalidOperationException)
+ {
+ return exception.Message;
+ }
+
+ // Для серверных ошибок - общее сообщение
+ return "An error occurred while processing your request. Please try again later.";
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Program.cs b/BikeRental/BikeRental.Api/Program.cs
new file mode 100644
index 000000000..eeb8309db
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Program.cs
@@ -0,0 +1,86 @@
+using BikeRental.Api;
+using BikeRental.Api.Extensions;
+using BikeRental.Api.Messaging;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi.Models;
+using NATS.Client.Core;
+
+WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
+
+builder.AddControllers();
+builder.AddErrorHandling();
+
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(options =>
+{
+ options.SwaggerDoc("v1", new OpenApiInfo
+ {
+ Title = "BikeRental API",
+ Version = "v1",
+ Description = "API для управления сервисом проката велосипедов"
+ });
+
+ var basePath = AppContext.BaseDirectory;
+ var xmlPathApi = Path.Combine(basePath, "BikeRental.Api.xml");
+ options.IncludeXmlComments(xmlPathApi);
+});
+
+
+builder.AddObservability();
+builder.AddDatabase();
+builder.AddRepositories();
+builder.AddServices();
+
+IConfigurationSection natsSettingsSection = builder.Configuration.GetSection("NatsConsumerSettings");
+
+if (natsSettingsSection.Exists())
+{
+ Console.WriteLine($"Nats.Url: {natsSettingsSection["Url"]}");
+ Console.WriteLine($"Nats.StreamName: {natsSettingsSection["StreamName"]}");
+}
+
+builder.Services.Configure(builder.Configuration.GetSection("NatsConsumerSettings"));
+builder.Services.AddSingleton(sp =>
+{
+ NatsConsumerSettings settings = sp.GetRequiredService>().Value;
+ var connectRetryDelayMs = Math.Max(0, settings.ConnectRetryDelayMs);
+ var reconnectWaitMin = TimeSpan.FromMilliseconds(connectRetryDelayMs);
+ var reconnectWaitMax = TimeSpan.FromMilliseconds(
+ Math.Max(connectRetryDelayMs, connectRetryDelayMs * Math.Max(settings.RetryBackoffFactor, 1)));
+
+ var options = new NatsOpts
+ {
+ Url = settings.Url,
+ RetryOnInitialConnect = settings.ConnectRetryAttempts > 1,
+ MaxReconnectRetry = Math.Max(0, settings.ConnectRetryAttempts),
+ ReconnectWaitMin = reconnectWaitMin,
+ ReconnectWaitMax = reconnectWaitMax,
+ ReconnectJitter = TimeSpan.FromMilliseconds(connectRetryDelayMs * 0.2)
+ };
+ return new NatsConnection(options);
+});
+builder.Services.AddHostedService();
+
+WebApplication app = builder.Build();
+
+app.UseExceptionHandler();
+
+if (app.Environment.IsDevelopment())
+{
+ // https://localhost:/swagger
+ app.UseSwagger();
+ app.UseSwaggerUI(c =>
+ {
+ c.SwaggerEndpoint("/swagger/v1/swagger.json", "BikeRental API v1");
+ c.RoutePrefix = "swagger";
+ c.ShowCommonExtensions();
+ });
+
+ await app.ApplyMigrationsAsync();
+
+ await app.SeedData();
+}
+
+app.MapControllers();
+
+await app.RunAsync();
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/Properties/launchSettings.json b/BikeRental/BikeRental.Api/Properties/launchSettings.json
new file mode 100644
index 000000000..52cfc9a3d
--- /dev/null
+++ b/BikeRental/BikeRental.Api/Properties/launchSettings.json
@@ -0,0 +1,22 @@
+{
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "dotnetRunMessages": true,
+ "applicationUrl": "http://localhost:5043"
+ },
+ "Container (Dockerfile)": {
+ "commandName": "Docker",
+ "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
+ "environmentVariables": {
+ "ASPNETCORE_HTTP_PORTS": "8080"
+ },
+ "publishAllPorts": true,
+ "useSSL": false
+ }
+ },
+ "$schema": "https://json.schemastore.org/launchsettings.json"
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Api/appsettings.Development.json b/BikeRental/BikeRental.Api/appsettings.Development.json
new file mode 100644
index 000000000..72b97deee
--- /dev/null
+++ b/BikeRental/BikeRental.Api/appsettings.Development.json
@@ -0,0 +1,14 @@
+{
+ "ConnectionStrings": {
+ "bike-rental": "server=bike-rental-db;Database=bike-rental;User Id=root;Password=1234512345Aa$;"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
+ "Microsoft": "Error",
+ "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "None",
+ "BikeRental": "Information"
+ }
+ }
+}
diff --git a/BikeRental/BikeRental.Api/appsettings.json b/BikeRental/BikeRental.Api/appsettings.json
new file mode 100644
index 000000000..bb9d82ad1
--- /dev/null
+++ b/BikeRental/BikeRental.Api/appsettings.json
@@ -0,0 +1,28 @@
+{
+ "ConnectionStrings": {
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft": "Error",
+ "Microsoft.AspNetCore": "Error",
+ "Microsoft.EntityFrameworkCore": "Error",
+ "BikeRental": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "NatsConsumerSettings": {
+ "Url": "nats://localhost:4222",
+ "StreamName": "bike-rental-stream",
+ "SubjectName": "bike-rental.leases",
+ "DurableName": "bike-rental-lease-consumer",
+ "AckWaitSeconds": 30,
+ "MaxDeliver": 5,
+ "ConnectRetryAttempts": 5,
+ "ConnectRetryDelayMs": 2000,
+ "RetryBackoffFactor": 2,
+ "ConsumeMaxMsgs": 100,
+ "ConsumeExpiresSeconds": 30,
+ "ConsumeRetryDelayMs": 2000
+ }
+}
diff --git a/BikeRental/BikeRental.Application.Contracts/BikeRental.Application.Contracts.csproj b/BikeRental/BikeRental.Application.Contracts/BikeRental.Application.Contracts.csproj
new file mode 100644
index 000000000..e2354ae0c
--- /dev/null
+++ b/BikeRental/BikeRental.Application.Contracts/BikeRental.Application.Contracts.csproj
@@ -0,0 +1,12 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/BikeCreateUpdateDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeCreateUpdateDto.cs
new file mode 100644
index 000000000..7d3f0b25f
--- /dev/null
+++ b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeCreateUpdateDto.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace BikeRental.Application.Contracts.Dtos;
+
+public class BikeCreateUpdateDto
+{
+ ///
+ /// Bike's serial number
+ ///
+ [Required]
+ [StringLength(50, MinimumLength = 3, ErrorMessage = "Длина SerialNumber должна быть 3-50 символов.")]
+ public required string SerialNumber { get; set; }
+
+ ///
+ /// Bike's color
+ ///
+ [Required]
+ [StringLength(20, ErrorMessage = "Макс. длина Color 20 символов.")]
+ public required string Color { get; set; }
+
+ ///
+ /// Bike's model
+ ///
+ [Required]
+ [Range(1, int.MaxValue, ErrorMessage = "ModelId должно быть положительное число.")]
+ public required int ModelId { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/BikeDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeDto.cs
new file mode 100644
index 000000000..f4c0ac3c4
--- /dev/null
+++ b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeDto.cs
@@ -0,0 +1,27 @@
+namespace BikeRental.Application.Contracts.Dtos;
+
+///
+/// A class describing a bike for rent
+///
+public class BikeDto
+{
+ ///
+ /// Bike's unique id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Bike's serial number
+ ///
+ public required string SerialNumber { get; set; }
+
+ ///
+ /// Bike's color
+ ///
+ public required string Color { get; set; }
+
+ ///
+ /// Bike's model type
+ ///
+ public required string ModelType { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelCreateUpdateDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelCreateUpdateDto.cs
new file mode 100644
index 000000000..66d95ae6a
--- /dev/null
+++ b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelCreateUpdateDto.cs
@@ -0,0 +1,55 @@
+using System.ComponentModel.DataAnnotations;
+using BikeRental.Domain.Enum;
+
+namespace BikeRental.Application.Contracts.Dtos;
+
+public class BikeModelCreateUpdateDto
+{
+ ///
+ /// The type of bicycle: road, sport, mountain, hybrid
+ ///
+ [Required]
+ public required BikeType Type { get; set; }
+
+ ///
+ /// The size of the bicycle's wheels
+ ///
+ [Required]
+ [Range(12, 36, ErrorMessage = "Размер колес должен быть 12-36.")]
+ public required int WheelSize { get; set; }
+
+ ///
+ /// Maximum permissible cyclist weight
+ ///
+ [Required]
+ [Range(30, 200, ErrorMessage = "Вес человека должен быть 30-200 кг.")]
+ public required int MaxCyclistWeight { get; set; }
+
+ ///
+ /// Weight of the bike model
+ ///
+ [Required]
+ [Range(3.0, 50.0, ErrorMessage = "Вес байка 3-50 кг.")]
+ public required double Weight { get; set; }
+
+ ///
+ /// The type of braking system used in this model of bike
+ ///
+ [Required]
+ [StringLength(30, ErrorMessage = "Макс. длина 30 символов.")]
+ public required string BrakeType { get; set; }
+
+ ///
+ /// Year of manufacture of the bicycle model
+ ///
+ [Required]
+ [RegularExpression(@"^\d{4}$", ErrorMessage = "Год должен быть 4 цифры.")]
+ public required string YearOfManufacture { get; set; }
+
+ ///
+ /// Cost per hour rental
+ ///
+ [Required]
+ [Range(0.01, 1000, ErrorMessage = "Цена должна быть > 0.")]
+ public required decimal RentPrice { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelDto.cs
new file mode 100644
index 000000000..967f65f31
--- /dev/null
+++ b/BikeRental/BikeRental.Application.Contracts/Dtos/BikeModelDto.cs
@@ -0,0 +1,49 @@
+using BikeRental.Domain.Enum;
+
+namespace BikeRental.Application.Contracts.Dtos;
+
+///
+/// A class describing the models of bikes that can be rented
+///
+public class BikeModelDto
+{
+ ///
+ /// The unique id for bike model
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// The type of bicycle: road, sport, mountain, hybrid
+ ///
+ public required BikeType Type { get; set; }
+
+ ///
+ /// The size of the bicycle's wheels
+ ///
+ public required int WheelSize { get; set; }
+
+ ///
+ /// Maximum permissible cyclist weight
+ ///
+ public required int MaxСyclistWeight { get; set; }
+
+ ///
+ /// Weight of the bike model
+ ///
+ public required double Weight { get; set; }
+
+ ///
+ /// The type of braking system used in this model of bike
+ ///
+ public required string BrakeType { get; set; }
+
+ ///
+ /// Year of manufacture of the bicycle model
+ ///
+ public required string YearOfManufacture { get; set; }
+
+ ///
+ /// Cost per hour rental
+ ///
+ public required decimal RentPrice { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseCreateUpdateDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseCreateUpdateDto.cs
new file mode 100644
index 000000000..1022041a3
--- /dev/null
+++ b/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseCreateUpdateDto.cs
@@ -0,0 +1,33 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace BikeRental.Application.Contracts.Dtos;
+
+public class LeaseCreateUpdateDto
+{
+ ///
+ /// Person who rents a bike
+ ///
+ [Required]
+ [Range(1, int.MaxValue, ErrorMessage = "ID человека должно быть положительное число.")]
+ public required int RenterId { get; set; }
+
+ ///
+ /// Bike for rent
+ ///
+ [Required]
+ [Range(1, int.MaxValue, ErrorMessage = "ID велика должно быть положительное число.")]
+ public required int BikeId { get; set; }
+
+ ///
+ /// Rental start time
+ ///
+ [Required]
+ public required DateTime RentalStartTime { get; set; }
+
+ ///
+ /// Rental duration in hours
+ ///
+ [Required]
+ [Range(1, int.MaxValue, ErrorMessage = "Время должно быть от часа.")]
+ public required int RentalDuration { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseDto.cs
new file mode 100644
index 000000000..f82aed252
--- /dev/null
+++ b/BikeRental/BikeRental.Application.Contracts/Dtos/LeaseDto.cs
@@ -0,0 +1,32 @@
+namespace BikeRental.Application.Contracts.Dtos;
+
+///
+/// A class describing a lease agreement
+///
+public class LeaseDto
+{
+ ///
+ /// Lease ID
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Person who rents a bike
+ ///
+ public required int RenterId { get; set; }
+
+ ///
+ /// Bike for rent
+ ///
+ public required int BikeId { get; set; }
+
+ ///
+ /// Rental start time
+ ///
+ public required DateTime RentalStartTime { get; set; }
+
+ ///
+ /// Rental duration in hours
+ ///
+ public required int RentalDuration { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/RenterCreateUpdateDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/RenterCreateUpdateDto.cs
new file mode 100644
index 000000000..b0114b4e6
--- /dev/null
+++ b/BikeRental/BikeRental.Application.Contracts/Dtos/RenterCreateUpdateDto.cs
@@ -0,0 +1,20 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace BikeRental.Application.Contracts.Dtos;
+
+public class RenterCreateUpdateDto
+{
+ ///
+ /// Renter's full name
+ ///
+ [Required]
+ [StringLength(100, MinimumLength = 3, ErrorMessage = "Длина 3-100 символов.")]
+ public required string FullName { get; set; }
+
+ ///
+ /// Renter's phone number
+ ///
+ [Required]
+ [Phone(ErrorMessage = "Неверный формат телефона.")]
+ public required string PhoneNumber { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application.Contracts/Dtos/RenterDto.cs b/BikeRental/BikeRental.Application.Contracts/Dtos/RenterDto.cs
new file mode 100644
index 000000000..87507493a
--- /dev/null
+++ b/BikeRental/BikeRental.Application.Contracts/Dtos/RenterDto.cs
@@ -0,0 +1,22 @@
+namespace BikeRental.Application.Contracts.Dtos;
+
+///
+/// A class describing a renter
+///
+public class RenterDto
+{
+ ///
+ /// Renter's id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Renter's full name
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Renter's phone number
+ ///
+ public required string PhoneNumber { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/BikeRental.Application.csproj b/BikeRental/BikeRental.Application/BikeRental.Application.csproj
new file mode 100644
index 000000000..403374eaf
--- /dev/null
+++ b/BikeRental/BikeRental.Application/BikeRental.Application.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/BikeRental/BikeRental.Application/Interfaces/IBikeModelService.cs b/BikeRental/BikeRental.Application/Interfaces/IBikeModelService.cs
new file mode 100644
index 000000000..3d33f3452
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Interfaces/IBikeModelService.cs
@@ -0,0 +1,31 @@
+using BikeRental.Application.Contracts.Dtos;
+
+namespace BikeRental.Application.Interfaces;
+
+public interface IBikeModelService
+{
+ ///
+ /// Returns all bike models.
+ ///
+ public Task> GetAll();
+
+ ///
+ /// Returns a bike model by id.
+ ///
+ public Task GetById(int id);
+
+ ///
+ /// Creates a new bike model.
+ ///
+ public Task Create(BikeModelCreateUpdateDto dto);
+
+ ///
+ /// Updates an existing bike model.
+ ///
+ public Task Update(int id, BikeModelCreateUpdateDto dto);
+
+ ///
+ /// Deletes a bike model.
+ ///
+ public Task Delete(int id);
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Interfaces/IBikeService.cs b/BikeRental/BikeRental.Application/Interfaces/IBikeService.cs
new file mode 100644
index 000000000..501fd2ff5
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Interfaces/IBikeService.cs
@@ -0,0 +1,15 @@
+using BikeRental.Application.Contracts.Dtos;
+
+namespace BikeRental.Application.Interfaces;
+
+///
+/// Service for managing bikes.
+///
+public interface IBikeService
+{
+ public Task> GetAll();
+ public Task GetById(int id);
+ public Task Create(BikeCreateUpdateDto dto);
+ public Task Update(int id, BikeCreateUpdateDto dto);
+ public Task Delete(int id);
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Interfaces/ILeaseService.cs b/BikeRental/BikeRental.Application/Interfaces/ILeaseService.cs
new file mode 100644
index 000000000..2309dff05
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Interfaces/ILeaseService.cs
@@ -0,0 +1,15 @@
+using BikeRental.Application.Contracts.Dtos;
+
+namespace BikeRental.Application.Interfaces;
+
+///
+/// Service for managing bike leases.
+///
+public interface ILeaseService
+{
+ public Task> GetAll();
+ public Task GetById(int id);
+ public Task Create(LeaseCreateUpdateDto dto);
+ public Task Update(int id, LeaseCreateUpdateDto dto);
+ public Task Delete(int id);
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Interfaces/IRenterService.cs b/BikeRental/BikeRental.Application/Interfaces/IRenterService.cs
new file mode 100644
index 000000000..b3941b982
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Interfaces/IRenterService.cs
@@ -0,0 +1,15 @@
+using BikeRental.Application.Contracts.Dtos;
+
+namespace BikeRental.Application.Interfaces;
+
+///
+/// Service for managing renters.
+///
+public interface IRenterService
+{
+ public Task> GetAll();
+ public Task GetById(int id);
+ public Task Create(RenterCreateUpdateDto dto);
+ public Task Update(int id, RenterCreateUpdateDto dto);
+ public Task Delete(int id);
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Mappings/BikeMappings.cs b/BikeRental/BikeRental.Application/Mappings/BikeMappings.cs
new file mode 100644
index 000000000..a26b27700
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Mappings/BikeMappings.cs
@@ -0,0 +1,29 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Application.Mappings;
+
+internal static class BikeMappings
+{
+ public static BikeDto ToDto(this Bike entity)
+ {
+ return new BikeDto
+ {
+ Id = entity.Id,
+ SerialNumber = entity.SerialNumber,
+ Color = entity.Color,
+ ModelType = entity.Model.BrakeType
+ };
+ }
+
+ public static Bike ToEntity(this BikeCreateUpdateDto dto, BikeModel model)
+ {
+ return new Bike
+ {
+ SerialNumber = dto.SerialNumber,
+ Color = dto.Color,
+ ModelId = dto.ModelId,
+ Model = model
+ };
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Mappings/BikeModelMappings.cs b/BikeRental/BikeRental.Application/Mappings/BikeModelMappings.cs
new file mode 100644
index 000000000..4ea1bf9c3
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Mappings/BikeModelMappings.cs
@@ -0,0 +1,36 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Application.Mappings;
+
+internal static class BikeModelMappings
+{
+ public static BikeModelDto ToDto(this BikeModel entity)
+ {
+ return new BikeModelDto
+ {
+ Id = entity.Id,
+ Type = entity.Type,
+ WheelSize = entity.WheelSize,
+ MaxСyclistWeight = entity.MaxCyclistWeight,
+ Weight = entity.Weight,
+ BrakeType = entity.BrakeType,
+ YearOfManufacture = entity.YearOfManufacture,
+ RentPrice = entity.RentPrice
+ };
+ }
+
+ public static BikeModel ToEntity(this BikeModelCreateUpdateDto dto)
+ {
+ return new BikeModel
+ {
+ Type = dto.Type,
+ WheelSize = dto.WheelSize,
+ MaxCyclistWeight = dto.MaxCyclistWeight,
+ Weight = dto.Weight,
+ BrakeType = dto.BrakeType,
+ YearOfManufacture = dto.YearOfManufacture,
+ RentPrice = dto.RentPrice
+ };
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Mappings/LeaseMappings.cs b/BikeRental/BikeRental.Application/Mappings/LeaseMappings.cs
new file mode 100644
index 000000000..95e4da1d9
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Mappings/LeaseMappings.cs
@@ -0,0 +1,32 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Application.Mappings;
+
+internal static class LeaseMappings
+{
+ public static LeaseDto ToDto(this Lease entity)
+ {
+ return new LeaseDto
+ {
+ Id = entity.Id,
+ BikeId = entity.Bike.Id,
+ RenterId = entity.Renter.Id,
+ RentalStartTime = entity.RentalStartTime,
+ RentalDuration = entity.RentalDuration
+ };
+ }
+
+ public static Lease ToEntity(this LeaseCreateUpdateDto dto, Bike bike, Renter renter)
+ {
+ return new Lease
+ {
+ BikeId = bike.Id,
+ RenterId = renter.Id,
+ Bike = bike,
+ Renter = renter,
+ RentalStartTime = dto.RentalStartTime,
+ RentalDuration = dto.RentalDuration
+ };
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Mappings/RenterMappings.cs b/BikeRental/BikeRental.Application/Mappings/RenterMappings.cs
new file mode 100644
index 000000000..acfe644e3
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Mappings/RenterMappings.cs
@@ -0,0 +1,26 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Application.Mappings;
+
+internal static class RenterMappings
+{
+ public static RenterDto ToDto(this Renter entity)
+ {
+ return new RenterDto
+ {
+ Id = entity.Id,
+ FullName = entity.FullName,
+ PhoneNumber = entity.PhoneNumber
+ };
+ }
+
+ public static Renter ToEntity(this RenterCreateUpdateDto dto)
+ {
+ return new Renter
+ {
+ FullName = dto.FullName,
+ PhoneNumber = dto.PhoneNumber
+ };
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Services/BikeModelService.cs b/BikeRental/BikeRental.Application/Services/BikeModelService.cs
new file mode 100644
index 000000000..fc3dfdeec
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Services/BikeModelService.cs
@@ -0,0 +1,62 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Application.Interfaces;
+using BikeRental.Application.Mappings;
+using BikeRental.Domain.Interfaces;
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Application.Services;
+
+///
+/// Application-сервис для работы с моделями велосипедов. Инкапсулирует бизнес-логику и доступ к репозиторию.
+///
+public sealed class BikeModelService(IBikeModelRepository bikeModelRepository) : IBikeModelService
+{
+ public async Task> GetAll()
+ {
+ return (await bikeModelRepository.GetAll()).Select(bm => bm.ToDto());
+ }
+
+ public async Task GetById(int id)
+ {
+ return (await bikeModelRepository.GetById(id))?.ToDto();
+ }
+
+ public async Task Create(BikeModelCreateUpdateDto dto)
+ {
+ var id = await bikeModelRepository.Add(dto.ToEntity());
+ if (id > 0)
+ {
+ BikeModel? createdEntity = await bikeModelRepository.GetById(id);
+ if (createdEntity != null)
+ {
+ return createdEntity.ToDto();
+ }
+ }
+
+ throw new InvalidOperationException("Failed to create entity.");
+ }
+
+ public async Task Update(int id, BikeModelCreateUpdateDto dto)
+ {
+ _ = await bikeModelRepository.GetById(id)
+ ?? throw new KeyNotFoundException($"Entity with id {id} not found.");
+
+ BikeModel entityToUpdate = dto.ToEntity();
+ entityToUpdate.Id = id;
+ await bikeModelRepository.Update(entityToUpdate);
+ BikeModel? updatedEntity = await bikeModelRepository.GetById(id);
+ return updatedEntity!.ToDto();
+ }
+
+ public async Task Delete(int id)
+ {
+ BikeModel? entity = await bikeModelRepository.GetById(id);
+ if (entity == null)
+ {
+ return false;
+ }
+
+ await bikeModelRepository.Delete(entity);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Services/BikeService.cs b/BikeRental/BikeRental.Application/Services/BikeService.cs
new file mode 100644
index 000000000..b67a3f384
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Services/BikeService.cs
@@ -0,0 +1,70 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Application.Interfaces;
+using BikeRental.Application.Mappings;
+using BikeRental.Domain.Interfaces;
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Application.Services;
+
+///
+/// Application-сервис для работы с велосипедами. Инкапсулирует бизнес-логику и доступ к репозиторию.
+/// На текущем этапе является тонкой обёрткой над IBikeRepository.
+///
+public sealed class BikeService(IBikeRepository bikeRepository, IBikeModelRepository modelRepository) : IBikeService
+{
+ public async Task> GetAll()
+ {
+ return (await bikeRepository.GetAll()).Select(b => b.ToDto());
+ }
+
+ public async Task GetById(int id)
+ {
+ return (await bikeRepository.GetById(id))?.ToDto();
+ }
+
+ public async Task Create(BikeCreateUpdateDto dto)
+ {
+ BikeModel model = await modelRepository.GetById(dto.ModelId)
+ ?? throw new ArgumentException($"Model with id {dto.ModelId} not found.");
+
+ var id = await bikeRepository.Add(dto.ToEntity(model));
+
+ if (id > 0)
+ {
+ Bike? createdEntity = await bikeRepository.GetById(id);
+ if (createdEntity != null)
+ {
+ return createdEntity.ToDto();
+ }
+ }
+
+ throw new InvalidOperationException("Failed to create entity.");
+ }
+
+ public async Task Update(int id, BikeCreateUpdateDto dto)
+ {
+ _ = await bikeRepository.GetById(id)
+ ?? throw new KeyNotFoundException($"Bike with id {id} not found.");
+
+ BikeModel model = await modelRepository.GetById(dto.ModelId)
+ ?? throw new ArgumentException($"Model with id {dto.ModelId} not found.");
+
+ Bike entityToUpdate = dto.ToEntity(model);
+ entityToUpdate.Id = id;
+ await bikeRepository.Update(entityToUpdate);
+ Bike? updatedEntity = await bikeRepository.GetById(id);
+ return updatedEntity!.ToDto();
+ }
+
+ public async Task Delete(int id)
+ {
+ Bike? entity = await bikeRepository.GetById(id);
+ if (entity == null)
+ {
+ return false;
+ }
+
+ await bikeRepository.Delete(entity);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Services/LeaseService.cs b/BikeRental/BikeRental.Application/Services/LeaseService.cs
new file mode 100644
index 000000000..fbe567c12
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Services/LeaseService.cs
@@ -0,0 +1,79 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Application.Interfaces;
+using BikeRental.Application.Mappings;
+using BikeRental.Domain.Interfaces;
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Application.Services;
+
+///
+/// Application-сервис для работы с договорами аренды велосипедов.
+/// Инкапсулирует бизнес-логику и доступ к репозиторию.
+/// На текущем этапе является тонкой обёрткой над ILeaseRepository.
+///
+public sealed class LeaseService(
+ ILeaseRepository leaseRepository,
+ IBikeRepository bikeRepository,
+ IRenterRepository renterRepository) : ILeaseService
+{
+ public async Task> GetAll()
+ {
+ return (await leaseRepository.GetAll()).Select(l => l.ToDto());
+ }
+
+ public async Task GetById(int id)
+ {
+ return (await leaseRepository.GetById(id))?.ToDto();
+ }
+
+ public async Task Create(LeaseCreateUpdateDto dto)
+ {
+ Bike bike = await bikeRepository.GetById(dto.BikeId)
+ ?? throw new ArgumentException($"Bike with id {dto.BikeId} not found.");
+
+ Renter renter = await renterRepository.GetById(dto.RenterId)
+ ?? throw new ArgumentException($"Renter with id {dto.RenterId} not found.");
+
+ var id = await leaseRepository.Add(dto.ToEntity(bike, renter));
+ if (id > 0)
+ {
+ Lease? createdEntity = await leaseRepository.GetById(id);
+ if (createdEntity != null)
+ {
+ return createdEntity.ToDto();
+ }
+ }
+
+ throw new InvalidOperationException("Failed to create entity.");
+ }
+
+ public async Task Update(int id, LeaseCreateUpdateDto dto)
+ {
+ _ = await leaseRepository.GetById(id)
+ ?? throw new KeyNotFoundException($"Lease with id {id} not found.");
+
+ Bike bike = await bikeRepository.GetById(dto.BikeId)
+ ?? throw new ArgumentException($"Bike with id {dto.BikeId} not found.");
+
+ Renter renter = await renterRepository.GetById(dto.RenterId)
+ ?? throw new ArgumentException($"Renter with id {dto.RenterId} not found.");
+
+ Lease entityToUpdate = dto.ToEntity(bike, renter);
+ entityToUpdate.Id = id;
+ await leaseRepository.Update(entityToUpdate);
+ Lease? updatedEntity = await leaseRepository.GetById(id);
+ return updatedEntity!.ToDto();
+ }
+
+ public async Task Delete(int id)
+ {
+ Lease? entity = await leaseRepository.GetById(id);
+ if (entity == null)
+ {
+ return false;
+ }
+
+ await leaseRepository.Delete(entity);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Application/Services/RenterService.cs b/BikeRental/BikeRental.Application/Services/RenterService.cs
new file mode 100644
index 000000000..8912db5bf
--- /dev/null
+++ b/BikeRental/BikeRental.Application/Services/RenterService.cs
@@ -0,0 +1,63 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Application.Interfaces;
+using BikeRental.Application.Mappings;
+using BikeRental.Domain.Interfaces;
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Application.Services;
+
+///
+/// Application-сервис для работы с арендаторами. Инкапсулирует бизнес-логику и доступ к репозиторию.
+/// На текущем этапе является тонкой обёрткой над IRenterRepository.
+///
+public sealed class RenterService(IRenterRepository renterRepository) : IRenterService
+{
+ public async Task> GetAll()
+ {
+ return (await renterRepository.GetAll()).Select(r => r.ToDto());
+ }
+
+ public async Task GetById(int id)
+ {
+ return (await renterRepository.GetById(id))?.ToDto();
+ }
+
+ public async Task Create(RenterCreateUpdateDto dto)
+ {
+ var id = await renterRepository.Add(dto.ToEntity());
+ if (id > 0)
+ {
+ Renter? createdEntity = await renterRepository.GetById(id);
+ if (createdEntity != null)
+ {
+ return createdEntity.ToDto();
+ }
+ }
+
+ throw new InvalidOperationException("Failed to create entity.");
+ }
+
+ public async Task Update(int id, RenterCreateUpdateDto dto)
+ {
+ _ = await renterRepository.GetById(id)
+ ?? throw new KeyNotFoundException($"Entity with id {id} not found.");
+
+ Renter entityToUpdate = dto.ToEntity();
+ entityToUpdate.Id = id;
+ await renterRepository.Update(entityToUpdate);
+ Renter? updatedEntity = await renterRepository.GetById(id);
+ return updatedEntity!.ToDto();
+ }
+
+ public async Task Delete(int id)
+ {
+ Renter? entity = await renterRepository.GetById(id);
+ if (entity == null)
+ {
+ return false;
+ }
+
+ await renterRepository.Delete(entity);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/BikeRental.Domain.csproj b/BikeRental/BikeRental.Domain/BikeRental.Domain.csproj
new file mode 100644
index 000000000..3a6353295
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/BikeRental.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/BikeRental/BikeRental.Domain/Enum/BikeType.cs b/BikeRental/BikeRental.Domain/Enum/BikeType.cs
new file mode 100644
index 000000000..593baac91
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Enum/BikeType.cs
@@ -0,0 +1,27 @@
+namespace BikeRental.Domain.Enum;
+
+///
+/// A class describing the types of bikes that can be rented
+///
+public enum BikeType
+{
+ ///
+ /// Road bike
+ ///
+ Road,
+
+ ///
+ /// Sports bike
+ ///
+ Sport,
+
+ ///
+ /// Mountain bike
+ ///
+ Mountain,
+
+ ///
+ /// Hybrid bike - a bicycle that combines the qualities of a mountain bike and a road bike
+ ///
+ Hybrid
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/Interfaces/IBikeModelRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/IBikeModelRepository.cs
new file mode 100644
index 000000000..e5ee0c9d7
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Interfaces/IBikeModelRepository.cs
@@ -0,0 +1,8 @@
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Domain.Interfaces;
+
+///
+/// Интерфейс репозитория описывает контракт для работы с моделями велосипедов
+///
+public interface IBikeModelRepository : IRepository;
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/Interfaces/IBikeRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/IBikeRepository.cs
new file mode 100644
index 000000000..97cdc72b8
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Interfaces/IBikeRepository.cs
@@ -0,0 +1,8 @@
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Domain.Interfaces;
+
+///
+/// Интерфейс репозитория описывает контракт для работы с велосипедами
+///
+public interface IBikeRepository : IRepository;
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/Interfaces/ILeaseRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/ILeaseRepository.cs
new file mode 100644
index 000000000..cdd3a5155
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Interfaces/ILeaseRepository.cs
@@ -0,0 +1,8 @@
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Domain.Interfaces;
+
+///
+/// Интерфейс репозитория описывает контракт для работы с договорами на аренду велосипедов
+///
+public interface ILeaseRepository : IRepository;
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/Interfaces/IRenterRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/IRenterRepository.cs
new file mode 100644
index 000000000..d0720e85b
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Interfaces/IRenterRepository.cs
@@ -0,0 +1,8 @@
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Domain.Interfaces;
+
+///
+/// Интерфейс репозитория описывает контракт для работы с арендаторами
+///
+public interface IRenterRepository : IRepository;
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/Interfaces/IRepository.cs b/BikeRental/BikeRental.Domain/Interfaces/IRepository.cs
new file mode 100644
index 000000000..6423f5af1
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Interfaces/IRepository.cs
@@ -0,0 +1,33 @@
+namespace BikeRental.Domain.Interfaces;
+
+///
+/// Generic repository interface that defines basic CRUD operations.
+///
+public interface IRepository
+ where TEntity : class
+{
+ ///
+ /// Returns all entities.
+ ///
+ public Task> GetAll();
+
+ ///
+ /// Returns entity by id.
+ ///
+ public Task GetById(int id);
+
+ ///
+ /// Adds a new entity and returns its generated id.
+ ///
+ public Task Add(TEntity entity);
+
+ ///
+ /// Updates existing entity.
+ ///
+ public Task Update(TEntity entity);
+
+ ///
+ /// Deletes existing entity.
+ ///
+ public Task Delete(TEntity entity);
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/Models/Bike.cs b/BikeRental/BikeRental.Domain/Models/Bike.cs
new file mode 100644
index 000000000..6df10dd3c
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Models/Bike.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace BikeRental.Domain.Models;
+
+///
+/// A class describing a bike for rent
+///
+public class Bike
+{
+ ///
+ /// Bike's unique id
+ ///
+ public int Id { get; set; }
+
+ [ForeignKey(nameof(Model))] public required int ModelId { get; set; }
+
+ ///
+ /// Bike's serial number
+ ///
+ public required string SerialNumber { get; set; }
+
+ ///
+ /// Bike's color
+ ///
+ public required string Color { get; set; }
+
+ ///
+ /// Bike's model
+ ///
+ public required BikeModel Model { get; init; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/Models/BikeModel.cs b/BikeRental/BikeRental.Domain/Models/BikeModel.cs
new file mode 100644
index 000000000..209d2e311
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Models/BikeModel.cs
@@ -0,0 +1,49 @@
+using BikeRental.Domain.Enum;
+
+namespace BikeRental.Domain.Models;
+
+///
+/// A class describing the models of bikes that can be rented
+///
+public class BikeModel
+{
+ ///
+ /// The unique id for bike model
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// The type of bicycle: road, sport, mountain, hybrid
+ ///
+ public required BikeType Type { get; set; }
+
+ ///
+ /// The size of the bicycle's wheels
+ ///
+ public required int WheelSize { get; set; }
+
+ ///
+ /// Maximum permissible cyclist weight
+ ///
+ public required int MaxCyclistWeight { get; set; }
+
+ ///
+ /// Weight of the bike model
+ ///
+ public required double Weight { get; set; }
+
+ ///
+ /// The type of braking system used in this model of bike
+ ///
+ public required string BrakeType { get; set; }
+
+ ///
+ /// Year of manufacture of the bicycle model
+ ///
+ public required string YearOfManufacture { get; set; }
+
+ ///
+ /// Cost per hour rental
+ ///
+ public required decimal RentPrice { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/Models/Lease.cs b/BikeRental/BikeRental.Domain/Models/Lease.cs
new file mode 100644
index 000000000..dea90ee75
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Models/Lease.cs
@@ -0,0 +1,39 @@
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace BikeRental.Domain.Models;
+
+///
+/// A class describing a lease agreement
+///
+public class Lease
+{
+ ///
+ /// Lease ID
+ ///
+ public int Id { get; set; }
+
+ [ForeignKey(nameof(Bike))] public int BikeId { get; set; }
+
+ [ForeignKey(nameof(Renter))] public int RenterId { get; set; }
+
+ ///
+ /// Rental start time
+ ///
+ public required DateTime RentalStartTime { get; set; }
+
+ ///
+ /// Rental duration in hours
+ ///
+ public required int RentalDuration { get; set; }
+
+ ///
+ /// Person who rents a bike
+ ///
+ public required Renter Renter { get; set; }
+
+ ///
+ /// Bike for rent
+ ///
+ public required Bike Bike { get; set; }
+ // сделала required тогда их айди автоматически должны установиться EF core
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Domain/Models/Renter.cs b/BikeRental/BikeRental.Domain/Models/Renter.cs
new file mode 100644
index 000000000..e3acb0ad5
--- /dev/null
+++ b/BikeRental/BikeRental.Domain/Models/Renter.cs
@@ -0,0 +1,22 @@
+namespace BikeRental.Domain.Models;
+
+///
+/// A class describing a renter
+///
+public class Renter
+{
+ ///
+ /// Renter's id
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Renter's full name
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Renter's phone number
+ ///
+ public required string PhoneNumber { get; set; }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/BikeRental.Generator.Nats.Host.csproj b/BikeRental/BikeRental.Generator.Nats.Host/BikeRental.Generator.Nats.Host.csproj
new file mode 100644
index 000000000..1a06d9934
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/BikeRental.Generator.Nats.Host.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ enable
+ enable
+ dotnet-BikeRental.Generator.Nats.Host-bb308bf8-660f-4f89-810e-494ba2f57c24
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/BikeRentalNatsProducer.cs b/BikeRental/BikeRental.Generator.Nats.Host/BikeRentalNatsProducer.cs
new file mode 100644
index 000000000..9e7363ac2
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/BikeRentalNatsProducer.cs
@@ -0,0 +1,147 @@
+using System.Text.Json;
+using BikeRental.Application.Contracts.Dtos;
+using Microsoft.Extensions.Options;
+using NATS.Client.Core;
+using NATS.Client.JetStream;
+using NATS.Client.JetStream.Models;
+using NATS.Net;
+
+namespace BikeRental.Generator.Nats.Host;
+
+public sealed class BikeRentalNatsProducer(
+ IOptions settings,
+ INatsConnection connection,
+ ILogger logger)
+{
+ // для настройки повторных попыток - ретраи
+ private readonly int _connectRetryAttempts = Math.Max(1, settings.Value.ConnectRetryAttempts);
+
+ private readonly TimeSpan _connectRetryDelay =
+ TimeSpan.FromMilliseconds(Math.Max(0, settings.Value.ConnectRetryDelayMs));
+
+ private readonly int _publishRetryAttempts = Math.Max(1, settings.Value.PublishRetryAttempts);
+
+ private readonly TimeSpan _publishRetryDelay =
+ TimeSpan.FromMilliseconds(Math.Max(0, settings.Value.PublishRetryDelayMs));
+
+ private readonly double _retryBackoffFactor =
+ settings.Value.RetryBackoffFactor <= 0 ? 2 : settings.Value.RetryBackoffFactor;
+
+ private readonly string _streamName = GetRequired(settings.Value.StreamName, "StreamName");
+ private readonly string _subjectName = GetRequired(settings.Value.SubjectName, "SubjectName");
+
+
+ public async Task SendAsync(IList batch, CancellationToken cancellationToken)
+ {
+ if (batch.Count == 0)
+ {
+ logger.LogInformation("Skipping empty lease batch.");
+ return;
+ }
+
+ try
+ {
+ // await connection.ConnectAsync();
+ // вызов с повторными попытками
+ await ExecuteWithRetryAsync(
+ "connect to NATS",
+ _connectRetryAttempts, // сколько раз пытаться
+ _connectRetryDelay, // начальная задержка
+ async () => await connection.ConnectAsync(),
+ cancellationToken);
+
+ INatsJSContext context = connection.CreateJetStreamContext();
+
+ var streamConfig = new StreamConfig(_streamName, [_subjectName]);
+
+
+ // await context.CreateOrUpdateStreamAsync(streamConfig, cancellationToken);
+ await ExecuteWithRetryAsync(
+ "create/update stream",
+ _publishRetryAttempts,
+ _publishRetryDelay,
+ async () => await context.CreateOrUpdateStreamAsync(streamConfig, cancellationToken),
+ cancellationToken);
+
+ var payload = JsonSerializer.SerializeToUtf8Bytes(batch);
+
+ // await context.PublishAsync(subject: _subjectName, data: payload, cancellationToken: cancellationToken);
+ await ExecuteWithRetryAsync(
+ "publish batch",
+ _publishRetryAttempts,
+ _publishRetryDelay,
+ async () => await context.PublishAsync(
+ _subjectName,
+ payload,
+ cancellationToken: cancellationToken),
+ cancellationToken);
+
+ logger.LogInformation(
+ "Sent a batch of {count} leases to {subject} of {stream}",
+ batch.Count, _subjectName, _streamName);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ ex,
+ "Exception occurred during sending a batch of {count} leases to {stream}/{subject}",
+ batch.Count, _streamName, _subjectName);
+ }
+ }
+
+ // механизм повторных попыток
+ private async Task ExecuteWithRetryAsync(
+ string operation,
+ int attempts, // Максимальное количество попыток
+ TimeSpan baseDelay, // Начальная задержка
+ Func action, // Операция для выполнения
+ CancellationToken cancellationToken) // Токен отмены
+ {
+ TimeSpan delay = baseDelay;
+ for (var attempt = 1; attempt <= attempts; attempt++)
+ {
+ try
+ {
+ await action();
+ return;
+ }
+ catch (Exception ex) when (attempt < attempts && !cancellationToken.IsCancellationRequested)
+ {
+ // есть еще попытки, операцию не отменили
+ if (delay > TimeSpan.Zero)
+ {
+ logger.LogWarning(
+ ex,
+ "Failed to {operation} (attempt {attempt}/{attempts}). Retrying in {delay}.",
+ operation,
+ attempt,
+ attempts,
+ delay);
+ await Task.Delay(delay, cancellationToken);
+
+ // увеличить задержку
+ delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * _retryBackoffFactor);
+ }
+ else
+ {
+ logger.LogWarning(
+ ex,
+ "Failed to {operation} (attempt {attempt}/{attempts}). Retrying immediately.",
+ operation,
+ attempt,
+ attempts);
+ }
+ }
+ }
+ }
+
+ private static string GetRequired(string? value, string key) //мини проверка конфигов
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new KeyNotFoundException($"{key} is not configured in Nats section.");
+ }
+
+ return value;
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseBatchGenerator.cs b/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseBatchGenerator.cs
new file mode 100644
index 000000000..b9b66501b
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseBatchGenerator.cs
@@ -0,0 +1,78 @@
+using BikeRental.Application.Contracts.Dtos;
+using Bogus;
+
+namespace BikeRental.Generator.Nats.Host.Generator;
+
+public sealed class LeaseBatchGenerator
+{
+ public static IList GenerateBatch(LeaseGenerationOptions settings)
+ {
+ Validate(settings);
+
+ Faker faker = CreateFaker(settings);
+ return faker.Generate(settings.BatchSize);
+ }
+
+ private static Faker CreateFaker(
+ LeaseGenerationOptions settings)
+ {
+ return new Faker()
+ .RuleFor(x => x.RenterId, f =>
+ f.Random.Int(settings.RenterIdMin, settings.RenterIdMax))
+ .RuleFor(x => x.BikeId, f =>
+ f.Random.Int(settings.BikeIdMin, settings.BikeIdMax))
+ .RuleFor(x => x.RentalDuration, f =>
+ f.Random.Int(
+ settings.RentalDurationMinHours,
+ settings.RentalDurationMaxHours))
+ .RuleFor(x => x.RentalStartTime, f =>
+ GeneratePastStartTime(
+ settings.RentalStartDaysBackMax,
+ f));
+ }
+
+ private static DateTime GeneratePastStartTime(
+ int maxDaysBack,
+ Faker f)
+ {
+ var daysBack = f.Random.Int(0, maxDaysBack);
+ var hoursBack = f.Random.Int(0, 23);
+
+ return DateTime.UtcNow
+ .AddDays(-daysBack)
+ .AddHours(-hoursBack);
+ }
+
+ private static void Validate(LeaseGenerationOptions settings)
+ {
+ if (settings.BatchSize <= 0)
+ {
+ throw new InvalidOperationException("BatchSize must be > 0.");
+ }
+
+ if (settings.BikeIdMin > settings.BikeIdMax)
+ {
+ throw new InvalidOperationException(
+ "BikeIdMin must be <= BikeIdMax.");
+ }
+
+ if (settings.RenterIdMin > settings.RenterIdMax)
+ {
+ throw new InvalidOperationException(
+ "RenterIdMin must be <= RenterIdMax.");
+ }
+
+ if (settings.RentalDurationMinHours >
+ settings.RentalDurationMaxHours)
+ {
+ throw new InvalidOperationException(
+ "RentalDurationMinHours must be <= RentalDurationMaxHours.");
+ }
+
+ if (settings.RentalStartDaysBackMax < 0)
+ {
+ throw new InvalidOperationException(
+ "RentalStartDaysBackMax must be >= 0.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseGenerationOptions.cs b/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseGenerationOptions.cs
new file mode 100644
index 000000000..cadccb3a6
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/Generator/LeaseGenerationOptions.cs
@@ -0,0 +1,14 @@
+namespace BikeRental.Generator.Nats.Host.Generator;
+
+public sealed class LeaseGenerationOptions
+{
+ public int BatchSize { get; init; } = 10;
+ public int IntervalSeconds { get; init; } = 30;
+ public int BikeIdMin { get; init; } = 1;
+ public int BikeIdMax { get; init; } = 30;
+ public int RenterIdMin { get; init; } = 1;
+ public int RenterIdMax { get; init; } = 20;
+ public int RentalDurationMinHours { get; init; } = 1;
+ public int RentalDurationMaxHours { get; init; } = 72;
+ public int RentalStartDaysBackMax { get; init; } = 10;
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/LeaseBatchWorker.cs b/BikeRental/BikeRental.Generator.Nats.Host/LeaseBatchWorker.cs
new file mode 100644
index 000000000..f24215af2
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/LeaseBatchWorker.cs
@@ -0,0 +1,39 @@
+using BikeRental.Application.Contracts.Dtos;
+using BikeRental.Generator.Nats.Host.Generator;
+using Microsoft.Extensions.Options;
+
+namespace BikeRental.Generator.Nats.Host;
+
+public sealed class LeaseBatchWorker(
+ BikeRentalNatsProducer producer,
+ IOptions options,
+ ILogger logger) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ LeaseGenerationOptions settings = options.Value;
+ if (settings.BatchSize <= 0)
+ {
+ logger.LogError("LeaseGeneration.BatchSize must be greater than 0.");
+ return;
+ }
+
+ if (settings.IntervalSeconds <= 0)
+ {
+ await SendBatchAsync(settings, stoppingToken);
+ return;
+ }
+
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(settings.IntervalSeconds));
+ while (await timer.WaitForNextTickAsync(stoppingToken))
+ {
+ await SendBatchAsync(settings, stoppingToken);
+ }
+ }
+
+ private async Task SendBatchAsync(LeaseGenerationOptions settings, CancellationToken stoppingToken)
+ {
+ IList batch = LeaseBatchGenerator.GenerateBatch(settings);
+ await producer.SendAsync(batch, stoppingToken);
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/NatsSettings.cs b/BikeRental/BikeRental.Generator.Nats.Host/NatsSettings.cs
new file mode 100644
index 000000000..303b92fa8
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/NatsSettings.cs
@@ -0,0 +1,16 @@
+namespace BikeRental.Generator.Nats.Host;
+
+///
+/// Класс для типизированной конфигурации
+///
+public sealed class NatsSettings
+{
+ public string Url { get; init; } = "nats://localhost:4222";
+ public string StreamName { get; init; } = string.Empty;
+ public string SubjectName { get; init; } = string.Empty;
+ public int ConnectRetryAttempts { get; init; } = 5;
+ public int ConnectRetryDelayMs { get; init; } = 2000;
+ public int PublishRetryAttempts { get; init; } = 3;
+ public int PublishRetryDelayMs { get; init; } = 1000;
+ public double RetryBackoffFactor { get; init; } = 2;
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/Program.cs b/BikeRental/BikeRental.Generator.Nats.Host/Program.cs
new file mode 100644
index 000000000..e01c9a705
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/Program.cs
@@ -0,0 +1,45 @@
+using BikeRental.Generator.Nats.Host;
+using BikeRental.Generator.Nats.Host.Generator;
+using Microsoft.Extensions.Options;
+using NATS.Client.Core;
+
+HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
+
+
+IConfigurationSection natsSettingsSection = builder.Configuration.GetSection("NatsSettings");
+
+if (natsSettingsSection.Exists())
+{
+ Console.WriteLine($"Nats.Url: {natsSettingsSection["Url"]}");
+ Console.WriteLine($"Nats.StreamName: {natsSettingsSection["StreamName"]}");
+}
+
+builder.Services.Configure(builder.Configuration.GetSection("NatsSettings"));
+builder.Services.Configure(builder.Configuration.GetSection("LeaseGeneration"));
+
+builder.Services.AddSingleton(sp =>
+{
+ NatsSettings settings = sp.GetRequiredService>().Value;
+ var connectRetryDelayMs = Math.Max(0, settings.ConnectRetryDelayMs);
+ var reconnectWaitMin = TimeSpan.FromMilliseconds(connectRetryDelayMs);
+ var reconnectWaitMax = TimeSpan.FromMilliseconds(
+ Math.Max(connectRetryDelayMs, connectRetryDelayMs * Math.Max(settings.RetryBackoffFactor, 1)));
+
+ var options = new NatsOpts
+ {
+ Url = settings.Url,
+ RetryOnInitialConnect = settings.ConnectRetryAttempts > 1,
+ MaxReconnectRetry = Math.Max(0, settings.ConnectRetryAttempts),
+ ReconnectWaitMin = reconnectWaitMin,
+ ReconnectWaitMax = reconnectWaitMax,
+ ReconnectJitter = TimeSpan.FromMilliseconds(connectRetryDelayMs * 0.2)
+ };
+ return new NatsConnection(options);
+});
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+IHost host = builder.Build();
+await host.RunAsync();
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/Properties/launchSettings.json b/BikeRental/BikeRental.Generator.Nats.Host/Properties/launchSettings.json
new file mode 100644
index 000000000..f7da70958
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "BikeRental.Generator.Nats.Host": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/appsettings.Development.json b/BikeRental/BikeRental.Generator.Nats.Host/appsettings.Development.json
new file mode 100644
index 000000000..b2dcdb674
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/BikeRental/BikeRental.Generator.Nats.Host/appsettings.json b/BikeRental/BikeRental.Generator.Nats.Host/appsettings.json
new file mode 100644
index 000000000..fcc2ddf05
--- /dev/null
+++ b/BikeRental/BikeRental.Generator.Nats.Host/appsettings.json
@@ -0,0 +1,32 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "NatsSettings": {
+ "Url": "nats://localhost:4222",
+ "StreamName": "bike-rental-stream",
+ "SubjectName": "bike-rental.leases",
+ "ConnectRetryAttempts": 5,
+ "ConnectRetryDelayMs": 2000,
+ "PublishRetryAttempts": 3,
+ "PublishRetryDelayMs": 1000,
+ "RetryBackoffFactor": 2
+ },
+ "LeaseGeneration": {
+ "BatchSize": 10,
+ "IntervalSeconds": 0,
+ "BikeIdMin": 28,
+ "BikeIdMax": 30,
+ "RenterIdMin": 18,
+ "RenterIdMax": 20,
+ "RentalDurationMinHours": 1,
+ "RentalDurationMaxHours": 72,
+ "RentalStartDaysBackMax": 10,
+ "BikeIds": [],
+ "RenterIds": [],
+ "LogBatchSampleCount": 0
+ }
+}
diff --git a/BikeRental/BikeRental.Infrastructure/BikeRental.Infrastructure.csproj b/BikeRental/BikeRental.Infrastructure/BikeRental.Infrastructure.csproj
new file mode 100644
index 000000000..cb259a19e
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/BikeRental.Infrastructure.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
diff --git a/BikeRental/BikeRental.Infrastructure/Database/ApplicationDbContext.cs b/BikeRental/BikeRental.Infrastructure/Database/ApplicationDbContext.cs
new file mode 100644
index 000000000..811cb6e05
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Database/ApplicationDbContext.cs
@@ -0,0 +1,37 @@
+using BikeRental.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace BikeRental.Infrastructure.Database;
+
+///
+/// Контекст базы данных приложения
+///
+///
+public sealed class ApplicationDbContext(DbContextOptions options) : DbContext(options)
+{
+ ///
+ /// Набор сущностей "BikeModel" (Модель велосипеда)
+ ///
+ public DbSet BikeModels { get; set; }
+
+ ///
+ /// Набор сущностей "Bike" (Велосипед)
+ ///
+ public DbSet Bikes { get; set; }
+
+ ///
+ /// Набор сущностей "Renter" (Арендатор)
+ ///
+ public DbSet Renters { get; set; }
+
+ ///
+ /// Набор сущностей "Lease" (Договор аренды)
+ ///
+ public DbSet Leases { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ // Применить конфигурации из текущей сборки
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeConfiguration.cs b/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeConfiguration.cs
new file mode 100644
index 000000000..e38e019a6
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeConfiguration.cs
@@ -0,0 +1,43 @@
+using BikeRental.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BikeRental.Infrastructure.Database.Configurations;
+
+///
+/// Конфигурация сущности "Bike"
+///
+public class BikeConfiguration : IEntityTypeConfiguration
+{
+ ///
+ /// Настройка сущности "Bike"
+ ///
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Bikes");
+
+ builder.HasKey(b => b.Id);
+ builder.Property(b => b.Id)
+ .ValueGeneratedOnAdd();
+
+ builder.Property(b => b.SerialNumber)
+ .IsRequired()
+ .HasMaxLength(64);
+
+ builder.Property(b => b.Color)
+ .IsRequired()
+ .HasMaxLength(32);
+
+ builder.Property(b => b.ModelId)
+ .IsRequired();
+
+ builder.HasOne(b => b.Model)
+ .WithMany()
+ .HasForeignKey(b => b.ModelId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasIndex(b => b.SerialNumber)
+ .IsUnique();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeModelConfiguration.cs b/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeModelConfiguration.cs
new file mode 100644
index 000000000..51405335e
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Database/Configurations/BikeModelConfiguration.cs
@@ -0,0 +1,55 @@
+using BikeRental.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BikeRental.Infrastructure.Database.Configurations;
+
+///
+/// Конфигурация сущности "BikeModel"
+///
+public class BikeModelConfiguration : IEntityTypeConfiguration
+{
+ ///
+ /// Настройка сущности "BikeModel"
+ ///
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("BikeModels");
+
+ builder.HasKey(b => b.Id);
+ builder.Property(b => b.Id)
+ .ValueGeneratedOnAdd();
+
+ builder.Property(b => b.Type)
+ .IsRequired();
+
+ builder.Property(b => b.WheelSize)
+ .IsRequired();
+
+ builder.Property(b => b.MaxCyclistWeight)
+ .IsRequired();
+
+ builder.Property(b => b.Weight)
+ .IsRequired();
+
+ builder.Property(b => b.BrakeType)
+ .IsRequired()
+ .HasMaxLength(50);
+
+ builder.Property(b => b.YearOfManufacture)
+ .IsRequired()
+ .HasMaxLength(4);
+
+ builder.Property(b => b.RentPrice)
+ .IsRequired()
+ .HasColumnType("decimal(10,2)");
+
+ // Индексы для типичных сценариев выборки
+
+ // Индекс по типу велосипеда
+ builder.HasIndex(b => b.Type);
+ // Индекс по комбинации типа велосипеда и размера колеса
+ builder.HasIndex(b => new { b.Type, b.WheelSize });
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Database/Configurations/LeaseConfiguration.cs b/BikeRental/BikeRental.Infrastructure/Database/Configurations/LeaseConfiguration.cs
new file mode 100644
index 000000000..2fdb8e89b
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Database/Configurations/LeaseConfiguration.cs
@@ -0,0 +1,50 @@
+using BikeRental.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BikeRental.Infrastructure.Database.Configurations;
+
+///
+/// Конфигурация сущности "Lease"
+///
+public class LeaseConfiguration : IEntityTypeConfiguration
+{
+ ///
+ /// Настройка сущности "Lease"
+ ///
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Leases");
+
+ // Первичный ключ
+ builder.HasKey(l => l.Id);
+ builder.Property(l => l.Id)
+ .ValueGeneratedOnAdd();
+
+ builder.Property(l => l.RenterId)
+ .IsRequired();
+
+ // Связь с арендатором
+ builder.HasOne(l => l.Renter)
+ .WithMany()
+ .HasForeignKey(l => l.RenterId)
+ .IsRequired();
+
+
+ builder.Property(l => l.BikeId)
+ .IsRequired();
+
+ // Связь с велосипедом
+ builder.HasOne(l => l.Bike)
+ .WithMany()
+ .HasForeignKey(l => l.BikeId)
+ .IsRequired();
+
+ builder.Property(l => l.RentalStartTime)
+ .IsRequired();
+
+ builder.Property(l => l.RentalDuration)
+ .IsRequired();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Database/Configurations/RenterConfiguration.cs b/BikeRental/BikeRental.Infrastructure/Database/Configurations/RenterConfiguration.cs
new file mode 100644
index 000000000..985b99829
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Database/Configurations/RenterConfiguration.cs
@@ -0,0 +1,36 @@
+using BikeRental.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BikeRental.Infrastructure.Database.Configurations;
+
+///
+/// Конфигурация сущности "Renter"
+///
+public class RenterConfiguration : IEntityTypeConfiguration
+{
+ ///
+ /// Настройка сущности "Renter"
+ ///
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Renters");
+
+ builder.HasKey(r => r.Id);
+ builder.Property(r => r.Id)
+ .ValueGeneratedOnAdd();
+
+ builder.Property(r => r.FullName)
+ .IsRequired()
+ .HasMaxLength(200);
+
+ builder.Property(r => r.PhoneNumber)
+ .IsRequired()
+ .HasMaxLength(32);
+
+ // Уникальный индекс по номеру телефона
+ builder.HasIndex(r => r.PhoneNumber)
+ .IsUnique();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.Designer.cs b/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.Designer.cs
new file mode 100644
index 000000000..7f851f994
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.Designer.cs
@@ -0,0 +1,211 @@
+//
+using System;
+using BikeRental.Infrastructure.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace BikeRental.Infrastructure.Database.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20251215170139_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.22")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Bike", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("varchar(32)")
+ .HasColumnName("color");
+
+ b.Property("ModelId")
+ .HasColumnType("int")
+ .HasColumnName("model_id");
+
+ b.Property("SerialNumber")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)")
+ .HasColumnName("serial_number");
+
+ b.HasKey("Id")
+ .HasName("pk_bikes");
+
+ b.HasIndex("ModelId")
+ .HasDatabaseName("ix_bikes_model_id");
+
+ b.HasIndex("SerialNumber")
+ .IsUnique()
+ .HasDatabaseName("ix_bikes_serial_number");
+
+ b.ToTable("Bikes", (string)null);
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.BikeModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ b.Property("BrakeType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)")
+ .HasColumnName("brake_type");
+
+ b.Property("MaxCyclistWeight")
+ .HasColumnType("int")
+ .HasColumnName("max_cyclist_weight");
+
+ b.Property("RentPrice")
+ .HasColumnType("decimal(10,2)")
+ .HasColumnName("rent_price");
+
+ b.Property("Type")
+ .HasColumnType("int")
+ .HasColumnName("type");
+
+ b.Property("Weight")
+ .HasColumnType("double")
+ .HasColumnName("weight");
+
+ b.Property("WheelSize")
+ .HasColumnType("int")
+ .HasColumnName("wheel_size");
+
+ b.Property("YearOfManufacture")
+ .IsRequired()
+ .HasMaxLength(4)
+ .HasColumnType("varchar(4)")
+ .HasColumnName("year_of_manufacture");
+
+ b.HasKey("Id")
+ .HasName("pk_bike_models");
+
+ b.HasIndex("Type")
+ .HasDatabaseName("ix_bike_models_type");
+
+ b.HasIndex("Type", "WheelSize")
+ .HasDatabaseName("ix_bike_models_type_wheel_size");
+
+ b.ToTable("BikeModels", (string)null);
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Lease", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ b.Property("BikeId")
+ .HasColumnType("int")
+ .HasColumnName("bike_id");
+
+ b.Property("RentalDuration")
+ .HasColumnType("int")
+ .HasColumnName("rental_duration");
+
+ b.Property("RentalStartTime")
+ .HasColumnType("datetime(6)")
+ .HasColumnName("rental_start_time");
+
+ b.Property("RenterId")
+ .HasColumnType("int")
+ .HasColumnName("renter_id");
+
+ b.HasKey("Id")
+ .HasName("pk_leases");
+
+ b.HasIndex("BikeId")
+ .HasDatabaseName("ix_leases_bike_id");
+
+ b.HasIndex("RenterId")
+ .HasDatabaseName("ix_leases_renter_id");
+
+ b.ToTable("Leases", (string)null);
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Renter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)")
+ .HasColumnName("full_name");
+
+ b.Property("PhoneNumber")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("varchar(32)")
+ .HasColumnName("phone_number");
+
+ b.HasKey("Id")
+ .HasName("pk_renters");
+
+ b.HasIndex("PhoneNumber")
+ .IsUnique()
+ .HasDatabaseName("ix_renters_phone_number");
+
+ b.ToTable("Renters", (string)null);
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Bike", b =>
+ {
+ b.HasOne("BikeRental.Domain.Models.BikeModel", "Model")
+ .WithMany()
+ .HasForeignKey("ModelId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_bikes_bike_models_model_id");
+
+ b.Navigation("Model");
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Lease", b =>
+ {
+ b.HasOne("BikeRental.Domain.Models.Bike", "Bike")
+ .WithMany()
+ .HasForeignKey("BikeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_leases_bikes_bike_id");
+
+ b.HasOne("BikeRental.Domain.Models.Renter", "Renter")
+ .WithMany()
+ .HasForeignKey("RenterId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_leases_renters_renter_id");
+
+ b.Navigation("Bike");
+
+ b.Navigation("Renter");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.cs b/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.cs
new file mode 100644
index 000000000..ed4379f90
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Database/Migrations/20251215170139_Initial.cs
@@ -0,0 +1,159 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using MySql.EntityFrameworkCore.Metadata;
+
+#nullable disable
+
+namespace BikeRental.Infrastructure.Database.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterDatabase()
+ .Annotation("MySQL:Charset", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "BikeModels",
+ columns: table => new
+ {
+ id = table.Column(type: "int", nullable: false)
+ .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
+ type = table.Column(type: "int", nullable: false),
+ wheel_size = table.Column(type: "int", nullable: false),
+ max_cyclist_weight = table.Column(type: "int", nullable: false),
+ weight = table.Column(type: "double", nullable: false),
+ brake_type = table.Column(type: "varchar(50)", maxLength: 50, nullable: false),
+ year_of_manufacture = table.Column(type: "varchar(4)", maxLength: 4, nullable: false),
+ rent_price = table.Column(type: "decimal(10,2)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_bike_models", x => x.id);
+ })
+ .Annotation("MySQL:Charset", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "Renters",
+ columns: table => new
+ {
+ id = table.Column(type: "int", nullable: false)
+ .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
+ full_name = table.Column(type: "varchar(200)", maxLength: 200, nullable: false),
+ phone_number = table.Column(type: "varchar(32)", maxLength: 32, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_renters", x => x.id);
+ })
+ .Annotation("MySQL:Charset", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "Bikes",
+ columns: table => new
+ {
+ id = table.Column(type: "int", nullable: false)
+ .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
+ model_id = table.Column(type: "int", nullable: false),
+ serial_number = table.Column(type: "varchar(64)", maxLength: 64, nullable: false),
+ color = table.Column(type: "varchar(32)", maxLength: 32, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_bikes", x => x.id);
+ table.ForeignKey(
+ name: "fk_bikes_bike_models_model_id",
+ column: x => x.model_id,
+ principalTable: "BikeModels",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ })
+ .Annotation("MySQL:Charset", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "Leases",
+ columns: table => new
+ {
+ id = table.Column(type: "int", nullable: false)
+ .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
+ renter_id = table.Column(type: "int", nullable: false),
+ bike_id = table.Column(type: "int", nullable: false),
+ rental_start_time = table.Column(type: "datetime(6)", nullable: false),
+ rental_duration = table.Column(type: "int", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_leases", x => x.id);
+ table.ForeignKey(
+ name: "fk_leases_bikes_bike_id",
+ column: x => x.bike_id,
+ principalTable: "Bikes",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_leases_renters_renter_id",
+ column: x => x.renter_id,
+ principalTable: "Renters",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ })
+ .Annotation("MySQL:Charset", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_bike_models_type",
+ table: "BikeModels",
+ column: "type");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_bike_models_type_wheel_size",
+ table: "BikeModels",
+ columns: new[] { "type", "wheel_size" });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_bikes_model_id",
+ table: "Bikes",
+ column: "model_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_bikes_serial_number",
+ table: "Bikes",
+ column: "serial_number",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_leases_bike_id",
+ table: "Leases",
+ column: "bike_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_leases_renter_id",
+ table: "Leases",
+ column: "renter_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_renters_phone_number",
+ table: "Renters",
+ column: "phone_number",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Leases");
+
+ migrationBuilder.DropTable(
+ name: "Bikes");
+
+ migrationBuilder.DropTable(
+ name: "Renters");
+
+ migrationBuilder.DropTable(
+ name: "BikeModels");
+ }
+ }
+}
diff --git a/BikeRental/BikeRental.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs b/BikeRental/BikeRental.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs
new file mode 100644
index 000000000..c1905d249
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Database/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -0,0 +1,208 @@
+//
+using System;
+using BikeRental.Infrastructure.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace BikeRental.Infrastructure.Database.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ partial class ApplicationDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.22")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Bike", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("varchar(32)")
+ .HasColumnName("color");
+
+ b.Property("ModelId")
+ .HasColumnType("int")
+ .HasColumnName("model_id");
+
+ b.Property("SerialNumber")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)")
+ .HasColumnName("serial_number");
+
+ b.HasKey("Id")
+ .HasName("pk_bikes");
+
+ b.HasIndex("ModelId")
+ .HasDatabaseName("ix_bikes_model_id");
+
+ b.HasIndex("SerialNumber")
+ .IsUnique()
+ .HasDatabaseName("ix_bikes_serial_number");
+
+ b.ToTable("Bikes", (string)null);
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.BikeModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ b.Property("BrakeType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)")
+ .HasColumnName("brake_type");
+
+ b.Property("MaxCyclistWeight")
+ .HasColumnType("int")
+ .HasColumnName("max_cyclist_weight");
+
+ b.Property("RentPrice")
+ .HasColumnType("decimal(10,2)")
+ .HasColumnName("rent_price");
+
+ b.Property("Type")
+ .HasColumnType("int")
+ .HasColumnName("type");
+
+ b.Property("Weight")
+ .HasColumnType("double")
+ .HasColumnName("weight");
+
+ b.Property("WheelSize")
+ .HasColumnType("int")
+ .HasColumnName("wheel_size");
+
+ b.Property("YearOfManufacture")
+ .IsRequired()
+ .HasMaxLength(4)
+ .HasColumnType("varchar(4)")
+ .HasColumnName("year_of_manufacture");
+
+ b.HasKey("Id")
+ .HasName("pk_bike_models");
+
+ b.HasIndex("Type")
+ .HasDatabaseName("ix_bike_models_type");
+
+ b.HasIndex("Type", "WheelSize")
+ .HasDatabaseName("ix_bike_models_type_wheel_size");
+
+ b.ToTable("BikeModels", (string)null);
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Lease", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ b.Property("BikeId")
+ .HasColumnType("int")
+ .HasColumnName("bike_id");
+
+ b.Property("RentalDuration")
+ .HasColumnType("int")
+ .HasColumnName("rental_duration");
+
+ b.Property("RentalStartTime")
+ .HasColumnType("datetime(6)")
+ .HasColumnName("rental_start_time");
+
+ b.Property("RenterId")
+ .HasColumnType("int")
+ .HasColumnName("renter_id");
+
+ b.HasKey("Id")
+ .HasName("pk_leases");
+
+ b.HasIndex("BikeId")
+ .HasDatabaseName("ix_leases_bike_id");
+
+ b.HasIndex("RenterId")
+ .HasDatabaseName("ix_leases_renter_id");
+
+ b.ToTable("Leases", (string)null);
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Renter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasColumnName("id");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)")
+ .HasColumnName("full_name");
+
+ b.Property("PhoneNumber")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("varchar(32)")
+ .HasColumnName("phone_number");
+
+ b.HasKey("Id")
+ .HasName("pk_renters");
+
+ b.HasIndex("PhoneNumber")
+ .IsUnique()
+ .HasDatabaseName("ix_renters_phone_number");
+
+ b.ToTable("Renters", (string)null);
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Bike", b =>
+ {
+ b.HasOne("BikeRental.Domain.Models.BikeModel", "Model")
+ .WithMany()
+ .HasForeignKey("ModelId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_bikes_bike_models_model_id");
+
+ b.Navigation("Model");
+ });
+
+ modelBuilder.Entity("BikeRental.Domain.Models.Lease", b =>
+ {
+ b.HasOne("BikeRental.Domain.Models.Bike", "Bike")
+ .WithMany()
+ .HasForeignKey("BikeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_leases_bikes_bike_id");
+
+ b.HasOne("BikeRental.Domain.Models.Renter", "Renter")
+ .WithMany()
+ .HasForeignKey("RenterId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_leases_renters_renter_id");
+
+ b.Navigation("Bike");
+
+ b.Navigation("Renter");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/BikeRental/BikeRental.Infrastructure/Repositories/BikeModelRepository.cs b/BikeRental/BikeRental.Infrastructure/Repositories/BikeModelRepository.cs
new file mode 100644
index 000000000..945f27636
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Repositories/BikeModelRepository.cs
@@ -0,0 +1,47 @@
+using BikeRental.Domain.Interfaces;
+using BikeRental.Domain.Models;
+using BikeRental.Infrastructure.Database;
+using Microsoft.EntityFrameworkCore;
+
+namespace BikeRental.Infrastructure.Repositories;
+
+///
+/// Репозиторий для работы с моделями велосипедов.
+///
+public sealed class BikeModelRepository(ApplicationDbContext dbContext) : IBikeModelRepository
+{
+ public async Task> GetAll()
+ {
+ return await dbContext.BikeModels
+ .ToListAsync();
+ }
+
+ public async Task GetById(int id)
+ {
+ return await dbContext.BikeModels
+ .FirstOrDefaultAsync(x => x.Id == id);
+ }
+
+ public async Task Add(BikeModel entity)
+ {
+ dbContext.BikeModels.Add(entity);
+ await dbContext.SaveChangesAsync();
+ return entity.Id;
+ }
+
+ public async Task Update(BikeModel entity)
+ {
+ BikeModel existing = await dbContext.BikeModels.FindAsync(entity.Id)
+ ?? throw new KeyNotFoundException($"Model with id {entity.Id} not found.");
+
+ dbContext.Entry(existing).CurrentValues.SetValues(entity);
+
+ await dbContext.SaveChangesAsync();
+ }
+
+ public async Task Delete(BikeModel entity)
+ {
+ dbContext.BikeModels.Remove(entity);
+ await dbContext.SaveChangesAsync();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Repositories/BikeRepository.cs b/BikeRental/BikeRental.Infrastructure/Repositories/BikeRepository.cs
new file mode 100644
index 000000000..8d6c7322b
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Repositories/BikeRepository.cs
@@ -0,0 +1,46 @@
+using BikeRental.Domain.Interfaces;
+using BikeRental.Domain.Models;
+using BikeRental.Infrastructure.Database;
+using Microsoft.EntityFrameworkCore;
+
+namespace BikeRental.Infrastructure.Repositories;
+
+public sealed class BikeRepository(ApplicationDbContext dbContext) : IBikeRepository
+{
+ public async Task> GetAll()
+ {
+ return await dbContext.Bikes
+ .Include(l => l.Model)
+ .ToListAsync();
+ }
+
+ public async Task GetById(int id)
+ {
+ return await dbContext.Bikes
+ .Include(l => l.Model)
+ .FirstOrDefaultAsync(x => x.Id == id);
+ }
+
+ public async Task Add(Bike entity)
+ {
+ dbContext.Bikes.Add(entity);
+ await dbContext.SaveChangesAsync();
+ return entity.Id;
+ }
+
+ public async Task Update(Bike entity)
+ {
+ Bike existing = await dbContext.Bikes.FindAsync(entity.Id)
+ ?? throw new KeyNotFoundException($"Bike with id {entity.Id} not found.");
+
+ dbContext.Entry(existing).CurrentValues.SetValues(entity);
+
+ await dbContext.SaveChangesAsync();
+ }
+
+ public async Task Delete(Bike entity)
+ {
+ dbContext.Bikes.Remove(entity);
+ await dbContext.SaveChangesAsync();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Repositories/LeaseRepository.cs b/BikeRental/BikeRental.Infrastructure/Repositories/LeaseRepository.cs
new file mode 100644
index 000000000..6b31f93b4
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Repositories/LeaseRepository.cs
@@ -0,0 +1,46 @@
+using BikeRental.Domain.Interfaces;
+using BikeRental.Domain.Models;
+using BikeRental.Infrastructure.Database;
+using Microsoft.EntityFrameworkCore;
+
+namespace BikeRental.Infrastructure.Repositories;
+
+public sealed class LeaseRepository(ApplicationDbContext dbContext) : ILeaseRepository
+{
+ public async Task> GetAll()
+ {
+ return await dbContext.Leases
+ .Include(l => l.Bike)
+ .Include(l => l.Renter)
+ .ToListAsync();
+ }
+
+ public async Task GetById(int id)
+ {
+ return await dbContext.Leases
+ .Include(l => l.Bike)
+ .Include(l => l.Renter)
+ .FirstOrDefaultAsync(x => x.Id == id);
+ }
+
+ public async Task Add(Lease entity)
+ {
+ dbContext.Leases.Add(entity);
+ await dbContext.SaveChangesAsync();
+ return entity.Id;
+ }
+
+ public async Task Update(Lease entity)
+ {
+ Lease existing = await dbContext.Leases.FindAsync(entity.Id)
+ ?? throw new KeyNotFoundException($"Lease with id {entity.Id} not found.");
+ dbContext.Entry(existing).CurrentValues.SetValues(entity);
+ await dbContext.SaveChangesAsync();
+ }
+
+ public async Task Delete(Lease entity)
+ {
+ dbContext.Leases.Remove(entity);
+ await dbContext.SaveChangesAsync();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Repositories/RenterRepository.cs b/BikeRental/BikeRental.Infrastructure/Repositories/RenterRepository.cs
new file mode 100644
index 000000000..4f4740508
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Repositories/RenterRepository.cs
@@ -0,0 +1,44 @@
+using BikeRental.Domain.Interfaces;
+using BikeRental.Domain.Models;
+using BikeRental.Infrastructure.Database;
+using Microsoft.EntityFrameworkCore;
+
+namespace BikeRental.Infrastructure.Repositories;
+
+public sealed class RenterRepository(ApplicationDbContext dbContext) : IRenterRepository
+{
+ public async Task> GetAll()
+ {
+ return await dbContext.Renters
+ .ToListAsync();
+ }
+
+ public async Task GetById(int id)
+ {
+ return await dbContext.Renters
+ .FirstOrDefaultAsync(x => x.Id == id);
+ }
+
+ public async Task Add(Renter entity)
+ {
+ dbContext.Renters.Add(entity);
+ await dbContext.SaveChangesAsync();
+ return entity.Id;
+ }
+
+ public async Task Update(Renter entity)
+ {
+ Renter existing = await dbContext.Renters.FindAsync(entity.Id)
+ ?? throw new KeyNotFoundException($"Renter with id {entity.Id} not found.");
+
+ dbContext.Entry(existing).CurrentValues.SetValues(entity);
+
+ await dbContext.SaveChangesAsync();
+ }
+
+ public async Task Delete(Renter entity)
+ {
+ dbContext.Renters.Remove(entity);
+ await dbContext.SaveChangesAsync();
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Services/ISeedDataService.cs b/BikeRental/BikeRental.Infrastructure/Services/ISeedDataService.cs
new file mode 100644
index 000000000..47fcb7720
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Services/ISeedDataService.cs
@@ -0,0 +1,13 @@
+namespace BikeRental.Infrastructure.Services;
+
+///
+/// Интерфейс описывает сервис инициализации данных
+///
+public interface ISeedDataService
+{
+ ///
+ /// Выполнить инициализацию данных
+ ///
+ ///
+ public Task SeedDataAsync();
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Infrastructure/Services/Impl/SeedDataService.cs b/BikeRental/BikeRental.Infrastructure/Services/Impl/SeedDataService.cs
new file mode 100644
index 000000000..85dfeac43
--- /dev/null
+++ b/BikeRental/BikeRental.Infrastructure/Services/Impl/SeedDataService.cs
@@ -0,0 +1,109 @@
+using BikeRental.Domain.Enum;
+using BikeRental.Domain.Models;
+using BikeRental.Infrastructure.Database;
+using Bogus;
+using Microsoft.EntityFrameworkCore;
+
+namespace BikeRental.Infrastructure.Services.Impl;
+
+///
+/// Сервис инициализации данных
+///
+///
+public class SeedDataService(ApplicationDbContext dbContext) : ISeedDataService
+{
+ ///
+ /// Выполнить инициализацию данных
+ ///
+ public async Task SeedDataAsync()
+ {
+ // Подготовить генератор фейковых данных
+ var faker = new Faker("ru");
+
+ // Создать модели велосипедов, если они отсутствуют
+ if (!await dbContext.BikeModels.AnyAsync())
+ {
+ var bikeModels = new List();
+ for (var i = 0; i < 10; i++)
+ {
+ var bikeModel = new BikeModel
+ {
+ Type = faker.PickRandom(),
+ WheelSize = faker.Random.Int(20, 29),
+ MaxCyclistWeight = faker.Random.Int(60, 120),
+ Weight = Math.Round(faker.Random.Double(7.0, 15.0), 2),
+ BrakeType = faker.PickRandom("Road", "Sport", "Mountain", "Hybrid"),
+ YearOfManufacture = faker.Date.Past(10).Year.ToString(),
+ RentPrice = Math.Round(faker.Random.Decimal(5.0m, 20.0m), 2)
+ };
+ bikeModels.Add(bikeModel);
+ }
+
+ dbContext.BikeModels.AddRange(bikeModels);
+ await dbContext.SaveChangesAsync();
+ }
+
+ // Создать арендаторов, если они отсутствуют
+ if (!await dbContext.Renters.AnyAsync())
+ {
+ var renters = new List();
+ for (var i = 0; i < 20; i++)
+ {
+ var renter = new Renter
+ {
+ FullName = faker.Name.FullName(),
+ PhoneNumber = faker.Phone.PhoneNumber()
+ };
+ renters.Add(renter);
+ }
+
+ dbContext.Renters.AddRange(renters);
+ await dbContext.SaveChangesAsync();
+ }
+
+ // Создать велосипеды, если они отсутствуют
+ if (!await dbContext.Bikes.AnyAsync())
+ {
+ List bikeModels = await dbContext.BikeModels.ToListAsync();
+ var bikes = new List();
+ for (var i = 0; i < 30; i++)
+ {
+ var bike = new Bike
+ {
+ ModelId = faker.PickRandom(bikeModels).Id,
+ SerialNumber = faker.Random.AlphaNumeric(10).ToUpper(),
+ Color = faker.Commerce.Color(),
+ Model = faker.PickRandom(bikeModels)
+ };
+ bikes.Add(bike);
+ }
+
+ dbContext.Bikes.AddRange(bikes);
+ await dbContext.SaveChangesAsync();
+ }
+
+ // Создать договора аренды, если они отсутствуют для
+ // некоторых велосипедов
+ if (!await dbContext.Leases.AnyAsync())
+ {
+ List renters = await dbContext.Renters.ToListAsync();
+ List bikes = await dbContext.Bikes.ToListAsync();
+ var leases = new List();
+
+ for (var i = 0; i < 15; i++)
+ {
+ var lease = new Lease
+ {
+ Renter = faker.PickRandom(renters),
+ Bike = faker.PickRandom(bikes),
+ RentalStartTime = faker.Date.Recent(10),
+ RentalDuration = faker.Random.Int(1, 72)
+ };
+ leases.Add(lease);
+ }
+
+ dbContext.Leases.AddRange(leases);
+ await dbContext.SaveChangesAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Tests/BikeRental.Tests.csproj b/BikeRental/BikeRental.Tests/BikeRental.Tests.csproj
new file mode 100644
index 000000000..abce81240
--- /dev/null
+++ b/BikeRental/BikeRental.Tests/BikeRental.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
diff --git a/BikeRental/BikeRental.Tests/BikeRentalTests.cs b/BikeRental/BikeRental.Tests/BikeRentalTests.cs
new file mode 100644
index 000000000..e0da93d90
--- /dev/null
+++ b/BikeRental/BikeRental.Tests/BikeRentalTests.cs
@@ -0,0 +1,123 @@
+using BikeRental.Domain.Enum;
+
+namespace BikeRental.Tests;
+
+///
+/// Class for unit-tests
+///
+public class BikeRentalTests(RentalFixture fixture) : IClassFixture
+{
+ ///
+ /// Displays information about all sports bikes
+ ///
+ [Fact]
+ public void InfoAboutSportBikes()
+ {
+ var expected = new List { 3, 6, 9 };
+
+ var actual = fixture.Bikes
+ .Where(b => b.Model.Type == BikeType.Sport)
+ .Select(b => b.Id)
+ .ToList();
+
+ Assert.Equal(expected, actual);
+ }
+
+ ///
+ /// Displays the top 5 bike models ranked by rental revenue
+ ///
+ [Fact]
+ public void TopFiveModelsIncome()
+ {
+ var expected = new List { 5, 8, 2, 9, 3 }; /// 9,7,3 have same result (= 60)
+
+ var actual = fixture.Lease
+ .GroupBy(lease => lease.Bike.Model.Id)
+ .Select(modelsGroup => new
+ {
+ ModelId = modelsGroup.Key,
+ SumOfIncomes = modelsGroup.Sum(lease => lease.Bike.Model.RentPrice * lease.RentalDuration)
+ })
+ .OrderByDescending(models => models.SumOfIncomes)
+ .Select(models => models.ModelId)
+ .Take(5)
+ .ToList();
+ Assert.Equal(expected, actual);
+ }
+
+ ///
+ /// Displays the top 5 bike models ranked by rental duration
+ /// { 5, 8, 2, 7, 3 };
+
+ var actual = fixture.Lease
+ .GroupBy(lease => lease.Bike.Model.Id)
+ .Select(modelsGroup => new
+ {
+ ModelId = modelsGroup.Key,
+ SumOfDurations = modelsGroup.Sum(lease => lease.RentalDuration)
+ })
+ .OrderByDescending(models => models.SumOfDurations)
+ .Select(models => models.ModelId)
+ .Take(5)
+ .ToList();
+
+ Assert.Equal(expected, actual);
+ }
+
+ ///
+ /// Displays information about the minimum, maximum, and average rental time
+ ///
+ [Fact]
+ public void MinMaxAvgRental()
+ {
+ var expectedMinimum = 1;
+ var expectedMaximum = 8;
+ var expectedAverage = 4.4;
+
+ var durations = fixture.Lease.Select(rent => rent.RentalDuration).ToList();
+
+ Assert.Equal(expectedMinimum, durations.Min());
+ Assert.Equal(expectedMaximum, durations.Max());
+ Assert.Equal(expectedAverage, durations.Average());
+ }
+
+
+ ///
+ /// Displays the total rental time for each bike type
+ ///
+ [Theory]
+ [InlineData(BikeType.Road, 12)]
+ [InlineData(BikeType.Sport, 9)]
+ [InlineData(BikeType.Mountain, 8)]
+ [InlineData(BikeType.Hybrid, 15)]
+ public void TotalRentalTimeByType(BikeType type, int expected)
+ {
+ var actual = fixture.Lease
+ .Where(lease => lease.Bike.Model.Type == type)
+ .Sum(lease => lease.RentalDuration);
+
+ Assert.Equal(expected, actual);
+ }
+
+ ///
+ /// Displays information about customers who have rented bikes the most times
+ ///
+ [Fact]
+ public void TopThreeRenters()
+ {
+ var expected = new List { 6, 7, 1 };
+
+ var actual = fixture.Lease
+ .GroupBy(lease => lease.Renter.Id)
+ .OrderByDescending(group => group.Count())
+ .Select(group => group.Key)
+ .Take(3)
+ .ToList();
+
+ Assert.Equal(expected, actual);
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.Tests/RentalFixture.cs b/BikeRental/BikeRental.Tests/RentalFixture.cs
new file mode 100644
index 000000000..617bc80db
--- /dev/null
+++ b/BikeRental/BikeRental.Tests/RentalFixture.cs
@@ -0,0 +1,306 @@
+using BikeRental.Domain.Enum;
+using BikeRental.Domain.Models;
+
+namespace BikeRental.Tests;
+
+public class RentalFixture
+{
+ ///
+ /// A class for creating the data for testing
+ ///
+ public RentalFixture()
+ {
+ Models = GetBikeModels();
+ Renters = GetRenters();
+ Bikes = GetBikes(Models);
+ Lease = GetLeases(Bikes, Renters);
+ }
+
+ ///
+ /// A list of all bike models
+ ///
+ public List Models { get; }
+
+ ///
+ /// /// A list of all bikes for rent
+ ///
+ public List Bikes { get; }
+
+ ///
+ /// A list of all registered renters
+ ///
+ public List Renters { get; }
+
+ ///
+ /// A list of all leases
+ ///
+ public List Lease { get; }
+
+ private static List GetBikeModels()
+ {
+ return
+ [
+ new()
+ {
+ Id = 1, Type = BikeType.Mountain, WheelSize = 26, MaxCyclistWeight = 95, Weight = 8.2,
+ BrakeType = "Carbon", YearOfManufacture = "2024", RentPrice = 18
+ },
+ new()
+ {
+ Id = 2, Type = BikeType.Road, WheelSize = 27, MaxCyclistWeight = 115, Weight = 12.8,
+ BrakeType = "Hydraulic", YearOfManufacture = "2023", RentPrice = 25
+ },
+ new()
+ {
+ Id = 3, Type = BikeType.Sport, WheelSize = 28, MaxCyclistWeight = 85, Weight = 7.9,
+ BrakeType = "V-Brake", YearOfManufacture = "2024", RentPrice = 22
+ },
+ new()
+ {
+ Id = 4, Type = BikeType.Road, WheelSize = 29, MaxCyclistWeight = 105, Weight = 14.7,
+ BrakeType = "Mechanical", YearOfManufacture = "2023", RentPrice = 20
+ },
+ new()
+ {
+ Id = 5, Type = BikeType.Hybrid, WheelSize = 26, MaxCyclistWeight = 90, Weight = 6.8,
+ BrakeType = "Hydraulic", YearOfManufacture = "2024", RentPrice = 35
+ },
+ new()
+ {
+ Id = 6, Type = BikeType.Sport, WheelSize = 28, MaxCyclistWeight = 125, Weight = 13.5,
+ BrakeType = "Disc", YearOfManufacture = "2023", RentPrice = 28
+ },
+ new()
+ {
+ Id = 7, Type = BikeType.Mountain, WheelSize = 27, MaxCyclistWeight = 110, Weight = 12.2,
+ BrakeType = "V-Brake", YearOfManufacture = "2022", RentPrice = 16
+ },
+ new()
+ {
+ Id = 8, Type = BikeType.Hybrid, WheelSize = 29, MaxCyclistWeight = 100, Weight = 7.5,
+ BrakeType = "Carbon", YearOfManufacture = "2023", RentPrice = 32
+ },
+ new()
+ {
+ Id = 9, Type = BikeType.Sport, WheelSize = 26, MaxCyclistWeight = 130, Weight = 15.8,
+ BrakeType = "Hydraulic", YearOfManufacture = "2024", RentPrice = 24
+ },
+ new()
+ {
+ Id = 10, Type = BikeType.Road, WheelSize = 28, MaxCyclistWeight = 80, Weight = 9.3,
+ BrakeType = "Mechanical", YearOfManufacture = "2022", RentPrice = 19
+ }
+ ];
+ }
+
+ private static List GetRenters()
+ {
+ return
+ [
+ new() { Id = 1, FullName = "Алексеев Алексей", PhoneNumber = "+7 912 345 67 89" },
+ new() { Id = 2, FullName = "Васильев Василий", PhoneNumber = "+7 923 456 78 90" },
+ new() { Id = 3, FullName = "Григорьев Григорий", PhoneNumber = "+7 934 567 89 01" },
+ new() { Id = 4, FullName = "Дмитриева Ольга", PhoneNumber = "+7 945 678 90 12" },
+ new() { Id = 5, FullName = "Николаева Светлана", PhoneNumber = "+7 956 789 01 23" },
+ new() { Id = 6, FullName = "Михайлов Сергей", PhoneNumber = "+7 967 890 12 34" },
+ new() { Id = 7, FullName = "Романова Татьяна", PhoneNumber = "+7 978 901 23 45" },
+ new() { Id = 8, FullName = "Павлов Дмитрий", PhoneNumber = "+7 989 012 34 56" },
+ new() { Id = 9, FullName = "Фёдорова Екатерина", PhoneNumber = "+7 990 123 45 67" },
+ new() { Id = 10, FullName = "Андреева Наталья", PhoneNumber = "+7 901 234 56 78" }
+ ];
+ }
+
+ private static List GetBikes(List models)
+ {
+ return
+ [
+ new()
+ {
+ Id = 1,
+ SerialNumber = "R001",
+ Color = "Silver",
+ Model = models[0],
+ ModelId = 0
+ },
+ new()
+ {
+ Id = 2,
+ SerialNumber = "R002",
+ Color = "Navy",
+ Model = models[1],
+ ModelId = 0
+ },
+ new()
+ {
+ Id = 3,
+ SerialNumber = "R003",
+ Color = "Charcoal",
+ Model = models[2],
+ ModelId = 0
+ },
+ new()
+ {
+ Id = 4,
+ SerialNumber = "R004",
+ Color = "Beige",
+ Model = models[3],
+ ModelId = 0
+ },
+ new()
+ {
+ Id = 5,
+ SerialNumber = "R005",
+ Color = "Burgundy",
+ Model = models[4],
+ ModelId = 0
+ },
+ new()
+ {
+ Id = 6,
+ SerialNumber = "R006",
+ Color = "Teal",
+ Model = models[5],
+ ModelId = 0
+ },
+ new()
+ {
+ Id = 7,
+ SerialNumber = "R007",
+ Color = "Coral",
+ Model = models[6],
+ ModelId = 0
+ },
+ new()
+ {
+ Id = 8,
+ SerialNumber = "R008",
+ Color = "Indigo",
+ Model = models[7],
+ ModelId = 0
+ },
+ new()
+ {
+ Id = 9,
+ SerialNumber = "R009",
+ Color = "Bronze",
+ Model = models[8],
+ ModelId = 0
+ },
+ new()
+ {
+ Id = 10,
+ SerialNumber = "R010",
+ Color = "Lavender",
+ Model = models[9],
+ ModelId = 0
+ }
+ ];
+ }
+
+ private static List GetLeases(List bikes, List renters)
+ {
+ return
+ [
+ new()
+ {
+ Id = 1,
+ Bike = bikes[0],
+ Renter = renters[0],
+ RentalStartTime = DateTime.Now.AddHours(-12),
+ RentalDuration = 3,
+ BikeId = 0,
+ RenterId = 0
+ },
+ new()
+ {
+ Id = 2,
+ Bike = bikes[1],
+ Renter = renters[1],
+ RentalStartTime = DateTime.Now.AddHours(-8),
+ RentalDuration = 6,
+ BikeId = 0,
+ RenterId = 0
+ },
+ new()
+ {
+ Id = 3,
+ Bike = bikes[2],
+ Renter = renters[5],
+ RentalStartTime = DateTime.Now.AddHours(-15),
+ RentalDuration = 4,
+ BikeId = 0,
+ RenterId = 0
+ },
+ new()
+ {
+ Id = 4,
+ Bike = bikes[3],
+ Renter = renters[5],
+ RentalStartTime = DateTime.Now.AddHours(-5),
+ RentalDuration = 2,
+ BikeId = 0,
+ RenterId = 0
+ },
+ new()
+ {
+ Id = 5,
+ Bike = bikes[4],
+ Renter = renters[4],
+ RentalStartTime = DateTime.Now.AddHours(-20),
+ RentalDuration = 8,
+ BikeId = 0,
+ RenterId = 0
+ },
+ new()
+ {
+ Id = 6,
+ Bike = bikes[5],
+ Renter = renters[5],
+ RentalStartTime = DateTime.Now.AddHours(-3),
+ RentalDuration = 1,
+ BikeId = 0,
+ RenterId = 0
+ },
+ new()
+ {
+ Id = 7,
+ Bike = bikes[6],
+ Renter = renters[6],
+ RentalStartTime = DateTime.Now.AddHours(-18),
+ RentalDuration = 5,
+ BikeId = 0,
+ RenterId = 0
+ },
+ new()
+ {
+ Id = 8,
+ Bike = bikes[7],
+ Renter = renters[6],
+ RentalStartTime = DateTime.Now.AddHours(-7),
+ RentalDuration = 7,
+ BikeId = 0,
+ RenterId = 0
+ },
+ new()
+ {
+ Id = 9,
+ Bike = bikes[8],
+ Renter = renters[8],
+ RentalStartTime = DateTime.Now.AddHours(-10),
+ RentalDuration = 4,
+ BikeId = 0,
+ RenterId = 0
+ },
+ new()
+ {
+ Id = 10,
+ Bike = bikes[9],
+ Renter = renters[9],
+ RentalStartTime = DateTime.Now.AddHours(-2),
+ RentalDuration = 4,
+ BikeId = 0,
+ RenterId = 0
+ }
+ ];
+ }
+}
\ No newline at end of file
diff --git a/BikeRental/BikeRental.sln b/BikeRental/BikeRental.sln
new file mode 100644
index 000000000..33f144c78
--- /dev/null
+++ b/BikeRental/BikeRental.sln
@@ -0,0 +1,58 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Domain", "BikeRental.Domain\BikeRental.Domain.csproj", "{1004CD79-296D-41B4-99B7-6D1AEF3000C5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Tests", "BikeRental.Tests\BikeRental.Tests.csproj", "{383A3622-AE13-45D6-88D3-E8559613718E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Application", "BikeRental.Application\BikeRental.Application.csproj", "{B68A0E95-AEB2-4C87-A527-9F89EB8B0032}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Api", "BikeRental.Api\BikeRental.Api.csproj", "{F6A23387-A682-4FFC-A33F-E6354A4839CA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Infrastructure", "BikeRental.Infrastructure\BikeRental.Infrastructure.csproj", "{1EABEEC1-1941-44AC-B46F-42CD2381899C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Application.Contracts", "BikeRental.Application.Contracts\BikeRental.Application.Contracts.csproj", "{A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "AppHost\AppHost.csproj", "{FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BikeRental.Generator.Nats.Host", "BikeRental.Generator.Nats.Host\BikeRental.Generator.Nats.Host.csproj", "{3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {1004CD79-296D-41B4-99B7-6D1AEF3000C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1004CD79-296D-41B4-99B7-6D1AEF3000C5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1004CD79-296D-41B4-99B7-6D1AEF3000C5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1004CD79-296D-41B4-99B7-6D1AEF3000C5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {383A3622-AE13-45D6-88D3-E8559613718E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {383A3622-AE13-45D6-88D3-E8559613718E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {383A3622-AE13-45D6-88D3-E8559613718E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {383A3622-AE13-45D6-88D3-E8559613718E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B68A0E95-AEB2-4C87-A527-9F89EB8B0032}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B68A0E95-AEB2-4C87-A527-9F89EB8B0032}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B68A0E95-AEB2-4C87-A527-9F89EB8B0032}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B68A0E95-AEB2-4C87-A527-9F89EB8B0032}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F6A23387-A682-4FFC-A33F-E6354A4839CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F6A23387-A682-4FFC-A33F-E6354A4839CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F6A23387-A682-4FFC-A33F-E6354A4839CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F6A23387-A682-4FFC-A33F-E6354A4839CA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1EABEEC1-1941-44AC-B46F-42CD2381899C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1EABEEC1-1941-44AC-B46F-42CD2381899C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1EABEEC1-1941-44AC-B46F-42CD2381899C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1EABEEC1-1941-44AC-B46F-42CD2381899C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A637BFD7-2BE5-4B58-AB34-8330FE4D6B43}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FFC8D593-B70B-430D-8FC5-A9F3EE1DDE17}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3292CC61-FAAA-47C4-87BE-76E6CA5BB1C9}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/README.md b/README.md
index 39c9a8443..013504d3e 100644
--- a/README.md
+++ b/README.md
@@ -1,136 +1,196 @@
# Разработка корпоративных приложений
-[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1JD6aiOG6r7GrA79oJncjgUHWtfeW4g_YZ9ayNgxb_w0/edit?usp=sharing)
## Задание
+
### Цель
Реализация проекта сервисно-ориентированного приложения.
-### Задачи
-* Реализация объектно-ориентированной модели данных,
-* Изучение реализации серверных приложений на базе 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 запуск клиентского приложения для написанного ранее сервера. Клиент создается в рамках курса "Веб разработка".
-
-
-
-## Задание. Общая часть
-**Обязательно**:
-* Реализация серверной части на [.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 с информацией о задании, скриншоты приложения и прочая информация.
-
-**Факультативно**:
-* Реализация авторизации/аутентификации.
-* Реализация atomic batch publishing/atomic batch consumption для брокеров, поддерживающих такой функционал.
-* Реализация интеграционных тестов при помощи .NET Aspire.
-* Реализация клиента на Blazor WASM.
-
-Внимательно прочитайте [дискуссии](https://github.com/itsecd/enterprise-development/discussions/1) о том, как работает автоматическое распределение на ревью.
-Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю.
-
-По итогу работы в семестре должна получиться следующая информационная система:
-
-C4 диаграмма
-
-
-
-
-
-## Варианты заданий
-Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи.
-
-[Список вариантов](https://docs.google.com/document/d/1Wc8AvsKS_1JptpsxHO-cwfAxz2ghxvQRQ0fy4el2ZOc/edit?usp=sharing)
-[Список предметных областей](https://docs.google.com/document/d/15jWhXMwd2K8giFMKku_yrY_s2uQNEu4ugJXLYPvYJAE/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).
+### Предметная область «Пункт велопроката»
+В базе пункта проката хранятся сведения о велосипедах, их арендаторах и выданных в аренду транспортных средствах.
+
+**Велосипед** характеризуется:
+- Серийным номером
+- Моделью
+- Цветом
+
+**Модель велосипеда** является справочником, содержащим сведения о:
+- Типе велосипеда
+- Размере колес
+- Предельно допустимом весе пассажира
+- Весе велосипеда
+- Типе тормозов
+- Модельном годе
+- Цене часа аренды
+
+**Тип велосипеда** является перечислением.
+
+**Арендатор** характеризуется:
+- ФИО
+- Телефоном
+
+**Аренда (контракт):**
+- При выдаче велосипеда фиксируется время начала аренды
+- Отмечается продолжительность аренды в часах
+
+---
+
+## Задание на лабораторную работу №1
+
+### Подготовленная структура классов
+
+#### Класс `Bike` (Велосипед)
+| Поле | Описание |
+|------|----------|
+| `Id` | Уникальный идентификатор |
+| `SerialNumber` | Серийный номер |
+| `Model` | Модель |
+| `Color` | Цвет |
+
+#### Класс `BikeModel` (Модель велосипеда)
+| Поле | Описание |
+|------|----------|
+| `Id` | Уникальный идентификатор |
+| `Type` | Тип велосипеда |
+| `WheelSize` | Размер колес |
+| `MaxCyclistWeight` | Максимальный вес велосипедиста |
+| `Weight` | Вес велосипеда |
+| `BrakeType` | Тип тормозов |
+| `YearOfManufacture` | Год выпуска модели |
+| `RentPrice` | Цена часа аренды |
+
+#### Класс `Renter` (Арендатор)
+| Поле | Описание |
+|------|----------|
+| `Id` | Уникальный идентификатор |
+| `FullName` | ФИО |
+| `PhoneNumber` | Номер телефона |
+
+#### Класс `Lease` (Контракт аренды)
+| Поле | Описание |
+|------|----------|
+| `Id` | Уникальный идентификатор |
+| `Renter` | Арендатор |
+| `Bike` | Арендованный велосипед |
+| `RentalStartTime` | Время начала аренды |
+| `RentalDuration` | Продолжительность аренды (в часах) |
+
+---
+
+### Реализованные компоненты
+
+#### 1. **Датасет** (`RentalFixture`)
+- Включено по 10 экземпляров каждого класса
+
+#### 2. **Юнит-тесты**
+
+| Название теста | Описание |
+|----------------|----------|
+| `InfoAboutSportBikes` | Выводит информацию обо всех спортивных велосипедах |
+| `TopFiveModelsIncome` | Выводит топ-5 моделей велосипедов по прибыли от аренды |
+| `TopFiveModelsDuration` | Выводит топ-5 моделей велосипедов по длительности аренды |
+| `MinMaxAvgRental` | Выводит минимальное, максимальное и среднее время аренды |
+| `TotalRentalTimeByType` | Выводит суммарное время аренды велосипедов каждого типа |
+| `TopThreeRenters` | Выводит информацию о клиентах, бравших велосипеды на прокат больше всего раз |
+
+---
+- .NET (C#)
+- xUnit (для юнит-тестов)
+
+# Лабораторные работы №2-3: REST API + ORM + Aspire
+
+### **База данных MySQL + EF Core**
+- Доменные модели с внешними ключами:
+ ```csharp
+ public class Bike {
+ public int ModelId { get; set; } // FK to BikeModel
+ public virtual BikeModel Model { get; init; }
+ }
+
+* Репозитории с асинхронными методами
+* Миграции EF Core для создания схемы БД
+* Контекст ApplicationDbContext с конфигурациями
+#### **Генерация данных:**
+```csharp
+ new BikeModel
+ {
+ Type = faker.PickRandom(), // Случайный тип из enum
+ WheelSize = faker.Random.Int(20, 29), // Размер колёс 20-29"
+ MaxCyclistWeight = faker.Random.Int(60, 120), // Вес 60-120 кг
+ Weight = Math.Round(faker.Random.Double(7.0, 15.0), 2), // Вес велосипеда
+ BrakeType = faker.PickRandom("Road", "Sport", "Mountain", "Hybrid"),
+ YearOfManufacture = faker.Date.Past(10).Year.ToString(), // Год выпуска
+ RentPrice = Math.Round(faker.Random.Decimal(5.0m, 20.0m), 2) // Цена аренды
+ }
+```
+Bogus генерировал реалистичные тестовые данные:
+* Русские ФИО, телефоны
+* Параметры велосипедов (размеры, вес, цены)
+* Даты аренд
+* Связи между таблицами
+* `var faker = new Faker("ru");`
+### **REST API на ASP.NET Core**
+- CRUD-операции для всех сущностей (Bikes, BikeModels, Renters, Leases)
+- Тесты xUnit адаптированы
+
+#### Запуск и порты:
+- Запустить проект через AppHost
+- **Aspire Dashboard:** автоматически открывается при запуске
+- **Adminer для БД:** доступен через Dashboard
+- `https://localhost:21053` - для метрик
+- `https://localhost:17195`
+- `http://localhost:15246` - порты API
+- Используется `launchSettings.json` с http и https
+
+#### Администрирование БД через Adminer
+1. в Aspire Dashboard
+2. Найти контейнер `bike_rental_db_adminer`
+3. Войти с данными:
+* Система: MySQL
+* Сервер: `bike-rental-db`
+* Пользователь: `root`
+* Пароль: `1234512345Aa$`
+* База данных: bike-rental - не обязательно
+
+### Swagger
+- Для получения доступа к backend: `http://localhost:5043/swagger/index.html` - клиент для тестирования
+
+# Лабораторная 4
+
+## Основные компоненты
+
+### `BikeRental.Generator.Nats.Host/Program.cs`
+- Настройка конфигурации, подключение к NATS, регистрация генератора и воркера в DI
+- Использование `NatsConnection` с URL из конфигурации
+
+### `BikeRental.Generator.Nats.Host/BikeRentalNatsProducer.cs`
+- Отправка батчей аренды в NATS JetStream
+- Реализация механизма повторных попыток (retry) для подключения и публикации
+- Автоматическое создание/обновление stream при необходимости
+- Логирование операций и ошибок
+
+### `BikeRental.Api/Messaging/NatsLeaseConsumer.cs`
+- Фоновый сервис для чтения батчей аренды из NATS JetStream
+- Механизм подтверждения сообщений: `ack` только после успешной записи всего батча в БД
+- Обработка невалидных данных: при несуществующих `BikeId`/`RenterId` сообщение завершается (`AckTerminateAsync`), чтобы избежать зацикливания
+- Реализация повторных попыток подключения и обработки сообщений
+- Использование транзакций для атомарного сохранения всего батча
+
+### `BikeRental.Generator.Nats.Host/LeaseBatchGenerator.cs`
+- Генерация тестовых данных `LeaseCreateUpdateDto` с использованием Bogus
+- Диапазоны ID сделала для наглядности: арендаторы (18-20), велосипеды (28-30)
+
+### `BikeRental.Generator.Nats.Host/LeaseBatchWorker.cs`
+- Фоновый воркер для периодической генерации и отправки данных
+- Возможен только 1 запуск, если `IntervalSeconds ≤ 0` в конфигурации
+
+## Классы для типизированной конфигурации
+
+- `BikeRental.Generator.Nats.Host/NatsSettings.cs` - настройки подключения к NATS, имена stream/subject для генератора
+- `BikeRental.Api/Messaging/NatsConsumerSettings.cs` - настройки потребителя NATS в API
+- `BikeRental.Generator.Nats.Host/LeaseGenerationOptions.cs` - параметры генерации данных (диапазоны ID)
+
+## Конфигурация (`appsettings.json`)
+- `IntervalSeconds` = `0` или отрицательное значение - однократная отправка при старте (в воркере)
+
+
+## Проект запускается через Aspire AppHost