diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..d892c195b
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+* text=auto
+*.cs text encoding=UTF-8
+*.json text encoding=UTF-8
+*.xml text encoding=UTF-8
+*.config text encoding=UTF-8
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.Api.Host/Agency.Api.Host.csproj b/RealEstateAgency/Agency.Api.Host/Agency.Api.Host.csproj
new file mode 100644
index 000000000..21d0ba78d
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Agency.Api.Host.csproj
@@ -0,0 +1,24 @@
+
+
+ net8.0
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Protos\ContractRequest.proto
+
+
+
diff --git a/RealEstateAgency/Agency.Api.Host/Controllers/AnalyticsController.cs b/RealEstateAgency/Agency.Api.Host/Controllers/AnalyticsController.cs
new file mode 100644
index 000000000..650067a4d
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Controllers/AnalyticsController.cs
@@ -0,0 +1,176 @@
+using Agency.Application.Contracts;
+using Agency.Application.Contracts.Counterparties;
+using Agency.Domain.Model;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Agency.Api.Host.Controllers;
+
+[Route("api/[controller]")]
+[ApiController]
+public class AnalyticsController(IAnalyticsService service, ILogger logger) : ControllerBase
+{
+
+ ///
+ /// Получает список всех продавцов, которые оставили заявки на продажу за указанный период времени
+ ///
+ /// Начальная дата периода (включительно)
+ /// Конечная дата периода (включительно)
+ /// Список контрагентов-продавцов, оставивших заявки в указанный период
+ /// Успешное получение списка продавцов
+ /// Внутренняя ошибка сервера
+ [HttpGet("sellers-with-requests-in-period")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetSellersWithRequestsInPeriod([FromQuery] DateTime from, [FromQuery] DateTime to)
+ {
+ try
+ {
+ var result = await service.GetSellersWithRequestsInPeriod(from, to);
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in GetSellersWithRequestsInPeriod");
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Получает топ-5 клиентов по количеству заявок на покупку недвижимости
+ ///
+ /// Список пяти клиентов с наибольшим количеством заявок на покупку
+ /// Успешное получение списка топ-5 покупателей
+ /// Внутренняя ошибка сервера
+ [HttpGet("top5-buyers-by-request-count")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetTop5BuyersByRequestCount()
+ {
+ try
+ {
+ var result = await service.GetTop5BuyersByRequestCount();
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in GetTop5BuyersByRequestCount");
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Получает топ-5 клиентов по количеству заявок на продажу недвижимости
+ ///
+ /// Список пяти клиентов с наибольшим количеством заявок на продажу
+ /// Успешное получение списка топ-5 продавцов
+ /// Внутренняя ошибка сервера
+ [HttpGet("top5-sellers-by-request-count")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetTop5SellersByRequestCount()
+ {
+ try
+ {
+ var result = await service.GetTop5SellersByRequestCount();
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in GetTop5SellersByRequestCount");
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Получает статистику по количеству заявок для каждого типа недвижимости
+ ///
+ /// Словарь, где ключ - тип недвижимости, значение - количество заявок
+ /// Успешное получение статистики по типам недвижимости
+ /// Внутренняя ошибка сервера
+ [HttpGet("request-count-by-property-type")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetRequestCountByPropertyType()
+ {
+ try
+ {
+ var result = await service.GetRequestCountByPropertyType();
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in GetRequestCountByPropertyType");
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Получает список клиентов, которые открыли заявки с минимальной стоимостью
+ ///
+ /// Список клиентов, чьи заявки имеют минимальную сумму
+ /// Успешное получение списка клиентов с минимальными заявками
+ /// Внутренняя ошибка сервера
+ [HttpGet("clients-with-minimal-request-amount")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetClientsWithMinimalRequestAmount()
+ {
+ try
+ {
+ var result = await service.GetClientsWithMinimalRequestAmount();
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in GetClientsWithMinimalRequestAmount");
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Получает список всех клиентов, ищущих недвижимость заданного типа (покупка)
+ ///
+ /// Тип недвижимости для поиска
+ /// Список клиентов, ищущих недвижимость указанного типа, отсортированный по ФИО
+ /// Успешное получение списка клиентов по типу недвижимости
+ /// Внутренняя ошибка сервера
+ [HttpGet("clients-searching-for-property-type")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetClientsSearchingForPropertyType([FromQuery] RealEstateType propertyType)
+ {
+ try
+ {
+ var result = await service.GetClientsSearchingForPropertyType(propertyType);
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in GetClientsSearchingForPropertyType");
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Получает список всех клиентов, ищущих дома (тип недвижимости House), отсортированный по ФИО
+ ///
+ /// Список клиентов, ищущих дома, отсортированный по ФИО в алфавитном порядке
+ /// Успешное получение списка клиентов, ищущих дома
+ /// Внутренняя ошибка сервера
+ [HttpGet("clients-searching-for-houses-ordered-by-fullname")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetClientsSearchingForHousesOrderedByFullName()
+ {
+ try
+ {
+ var result = await service.GetClientsSearchingForHousesOrderedByFullName();
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in GetClientsSearchingForHousesOrderedByFullName");
+ return StatusCode(500, ex.Message);
+ }
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.Api.Host/Controllers/ContractRequestController.cs b/RealEstateAgency/Agency.Api.Host/Controllers/ContractRequestController.cs
new file mode 100644
index 000000000..ecf19d356
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Controllers/ContractRequestController.cs
@@ -0,0 +1,11 @@
+using Agency.Application.Contracts.ContractRequests;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Agency.Api.Host.Controllers;
+
+[Route("api/[controller]")]
+[ApiController]
+public class ContractRequestController(IContractRequestService service, ILogger logger)
+ : CrudControllerBase(service, logger)
+{
+}
diff --git a/RealEstateAgency/Agency.Api.Host/Controllers/CounterpartyController.cs b/RealEstateAgency/Agency.Api.Host/Controllers/CounterpartyController.cs
new file mode 100644
index 000000000..7c0b59b38
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Controllers/CounterpartyController.cs
@@ -0,0 +1,11 @@
+using Agency.Application.Contracts.Counterparties;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Agency.Api.Host.Controllers;
+
+[Route("api/[controller]")]
+[ApiController]
+public class CounterpartyController(ICounterpartyService service, ILogger logger)
+ : CrudControllerBase(service, logger)
+{
+}
diff --git a/RealEstateAgency/Agency.Api.Host/Controllers/CrudControllerBase.cs b/RealEstateAgency/Agency.Api.Host/Controllers/CrudControllerBase.cs
new file mode 100644
index 000000000..086df05d5
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Controllers/CrudControllerBase.cs
@@ -0,0 +1,155 @@
+using Agency.Application.Contracts;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Agency.Api.Host.Controllers;
+
+///
+/// Базовый CRUD контроллер, предоставляющий стандартные операции для работы с сущностями
+///
+/// Тип DTO для чтения/получения данных сущности
+/// Тип DTO для создания и обновления сущности
+/// Тип идентификатора сущности (int, Guid, long и т.д.)
+[Route("api/[controller]")]
+[ApiController]
+public abstract class CrudControllerBase(
+ IApplicationService appService,
+ ILogger> logger) : ControllerBase
+ where TDto : class
+ where TCreateUpdateDto : class
+ where TKey : struct
+{
+ ///
+ /// Создает новую сущность
+ ///
+ /// Данные для создания новой сущности
+ /// Созданная сущность с присвоенным идентификатором
+ /// Сущность успешно создана
+ /// Внутренняя ошибка сервера
+ [HttpPost]
+ [ProducesResponseType(201)]
+ [ProducesResponseType(500)]
+ public async Task> Create(TCreateUpdateDto newDto)
+ {
+ logger.LogInformation("{Method} of {Controller} called with {@Dto}", nameof(Create), GetType().Name, newDto);
+ try
+ {
+ var res = await appService.Create(newDto);
+ return CreatedAtAction(nameof(Create), res);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in {Method} of {Controller}", nameof(Create), GetType().Name);
+ return StatusCode(500, $"{ex.Message}\n{ex.InnerException?.Message}");
+ }
+ }
+
+ ///
+ /// Обновляет существующую сущность по идентификатору
+ ///
+ /// Идентификатор обновляемой сущности
+ /// Новые данные для сущности
+ /// Обновленная сущность
+ /// Сущность успешно обновлена
+ /// Сущность с указанным идентификатором не найдена
+ /// Внутренняя ошибка сервера
+ [HttpPut("{id}")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(500)]
+ public async Task> Edit(TKey id, TCreateUpdateDto newDto)
+ {
+ try
+ {
+ var res = await appService.Update(newDto, id);
+ return Ok(res);
+ }
+ catch (KeyNotFoundException)
+ {
+ return NotFound();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in {Method} of {Controller}", nameof(Edit), GetType().Name);
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Удаляет сущность по идентификатору
+ ///
+ /// Идентификатор удаляемой сущности
+ /// Статус выполнения операции
+ /// Сущность успешно удалена
+ /// Сущность не найдена (ничего не удалено)
+ /// Внутренняя ошибка сервера
+ [HttpDelete("{id}")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(204)]
+ [ProducesResponseType(500)]
+ public async Task Delete(TKey id)
+ {
+ try
+ {
+ var res = await appService.Delete(id);
+ return res ? Ok() : NoContent();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in {Method} of {Controller}", nameof(Delete), GetType().Name);
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Получает список всех сущностей
+ ///
+ /// Список всех сущностей
+ /// Успешное получение списка
+ /// Внутренняя ошибка сервера
+ [HttpGet]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetAll()
+ {
+ try
+ {
+ var res = await appService.GetAll();
+ return Ok(res);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in {Method} of {Controller}", nameof(GetAll), GetType().Name);
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Получает сущность по идентификатору
+ ///
+ /// Идентификатор запрашиваемой сущности
+ /// Сущность с указанным идентификатором
+ /// Сущность найдена
+ /// Сущность с указанным идентификатором не найдена
+ /// Внутренняя ошибка сервера
+ [HttpGet("{id}")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(500)]
+ public async Task> Get(TKey id)
+ {
+ try
+ {
+ var res = await appService.Get(id);
+ return res == null ? NotFound() : Ok(res);
+ }
+ catch (KeyNotFoundException)
+ {
+ return NotFound();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error in {Method} of {Controller}", nameof(Get), GetType().Name);
+ return StatusCode(500, ex.Message);
+ }
+ }
+}
diff --git a/RealEstateAgency/Agency.Api.Host/Controllers/RealEstateController.cs b/RealEstateAgency/Agency.Api.Host/Controllers/RealEstateController.cs
new file mode 100644
index 000000000..2c2a0b5aa
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Controllers/RealEstateController.cs
@@ -0,0 +1,11 @@
+using Agency.Application.Contracts.RealEstates;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Agency.Api.Host.Controllers;
+
+[Route("api/[controller]")]
+[ApiController]
+public class RealEstateController(IRealEstateService service, ILogger logger)
+ : CrudControllerBase(service, logger)
+{
+}
diff --git a/RealEstateAgency/Agency.Api.Host/Grpc/AgencyGrpcClient.cs b/RealEstateAgency/Agency.Api.Host/Grpc/AgencyGrpcClient.cs
new file mode 100644
index 000000000..8224c510a
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Grpc/AgencyGrpcClient.cs
@@ -0,0 +1,152 @@
+using Agency.Application.Contracts.ContractRequests;
+using Agency.Application.Contracts.Counterparties;
+using Agency.Application.Contracts.Protos;
+using Agency.Application.Contracts.RealEstates;
+using AutoMapper;
+using Grpc.Core;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Agency.Api.Host.Grpc;
+
+///
+/// Фоновый gRPC клиент для получения батчей ContractRequestCreateUpdateDto из bidirectional стрима и создания заявок в системе
+///
+public class AgencyGrpcClient(
+ ContractRequestGeneratorGrpcService.ContractRequestGeneratorGrpcServiceClient client,
+ IServiceScopeFactory scopeFactory,
+ IMapper mapper,
+ ILogger logger,
+ IConfiguration cfg,
+ IMemoryCache cache
+) : BackgroundService
+{
+ private static readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(10);
+
+ ///
+ /// Основной цикл фонового сервиса который подключается к gRPC серверу отправляет запрос генерации и обрабатывает входящие батчи
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var countPerRequest = cfg.GetValue("ContractRequestGenerator:CountPerRequest", 100);
+ var batchSize = cfg.GetValue("ContractRequestGenerator:BatchSize", 10);
+
+ logger.LogInformation("Connecting to ContractRequestGenerator gRPC bidirectional stream...");
+
+ using var call = client.ContractRequestStream(cancellationToken: stoppingToken);
+
+ var requestId = Guid.NewGuid().ToString("N");
+
+ var writerTask = Task.Run(async () =>
+ {
+ await call.RequestStream.WriteAsync(new ContractRequestGenerationRequest
+ {
+ RequestId = requestId,
+ Count = countPerRequest,
+ BatchSize = batchSize
+ });
+
+ await call.RequestStream.CompleteAsync();
+ }, stoppingToken);
+
+ await foreach (var msg in call.ResponseStream.ReadAllAsync(stoppingToken))
+ {
+ if (!string.Equals(msg.RequestId, requestId, StringComparison.Ordinal))
+ continue;
+
+ var dtos = msg.ContractRequests.Select(mapper.Map).ToList();
+
+ using var scope = scopeFactory.CreateScope();
+
+ var contractRequestService = scope.ServiceProvider.GetRequiredService();
+ var counterpartyService = scope.ServiceProvider.GetRequiredService();
+ var realEstateService = scope.ServiceProvider.GetRequiredService();
+
+ var valid = new List(dtos.Count);
+
+ foreach (var dto in dtos)
+ {
+ if (!await ExistsAsync(dto.CounterpartyId, "Counterparty", dto, counterpartyService.Get, stoppingToken))
+ continue;
+
+ if (!await ExistsAsync(dto.RealEstateId, "RealEstate", dto, realEstateService.Get, stoppingToken))
+ continue;
+
+ valid.Add(dto);
+ }
+
+ var created = 0;
+ foreach (var dto in valid)
+ {
+ await contractRequestService.Create(dto);
+ created++;
+ }
+
+ logger.LogInformation("Received batch: total={total}, valid={valid}, created={created}, isFinal={isFinal}", dtos.Count, valid.Count, created, msg.IsFinal);
+
+ if (msg.IsFinal)
+ break;
+ }
+
+ await writerTask;
+
+ logger.LogInformation("Finished receiving contract requests for request_id={requestId}", requestId);
+ await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
+ }
+ catch (RpcException ex) when (!stoppingToken.IsCancellationRequested)
+ {
+ logger.LogError(ex, "gRPC stream error: {code} - {status}", ex.StatusCode, ex.Status);
+ await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
+ }
+ catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
+ {
+ logger.LogError(ex, "Unexpected exception during receiving contract requests from gRPC stream");
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Проверка наличия сущности по идентификатору с использованием IMemoryCache чтобы не выполнять повторные запросы
+ ///
+ private async Task ExistsAsync(
+ int id,
+ string entityName,
+ ContractRequestCreateUpdateDto dto,
+ Func> readFunc,
+ CancellationToken ct)
+ where TEntity : class
+ {
+ var cacheKey = $"{entityName}:exists:{id}";
+
+ if (cache.TryGetValue(cacheKey, out bool cached))
+ return cached;
+
+ ct.ThrowIfCancellationRequested();
+
+ bool exists;
+ try
+ {
+ var entity = await readFunc(id);
+ exists = entity is not null;
+ }
+ catch (KeyNotFoundException)
+ {
+ exists = false;
+
+ logger.LogWarning(
+ "Skipping contract request dto because {entity} with id {id} was not found counterpartyId={counterpartyId} realEstateId={realEstateId}",
+ entityName, id, dto.CounterpartyId, dto.RealEstateId);
+ }
+
+ cache.Set(cacheKey, exists, new MemoryCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = _cacheTtl
+ });
+
+ return exists;
+ }
+}
diff --git a/RealEstateAgency/Agency.Api.Host/Grpc/AgencyGrpcProfile.cs b/RealEstateAgency/Agency.Api.Host/Grpc/AgencyGrpcProfile.cs
new file mode 100644
index 000000000..3a666eabd
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Grpc/AgencyGrpcProfile.cs
@@ -0,0 +1,22 @@
+using Agency.Application.Contracts.Protos;
+using Agency.Application.Contracts.ContractRequests;
+using AutoMapper;
+
+namespace Agency.Api.Host.Grpc;
+
+///
+/// Профиль AutoMapper для преобразования protobuf сообщений gRPC в контрактные DTO приложения
+///
+public class AgencyGrpcProfile : Profile
+{
+ ///
+ /// Настройка правил маппинга между сообщениями gRPC и DTO используемыми в прикладном слое
+ ///
+ public AgencyGrpcProfile()
+ {
+ CreateMap()
+ .ForCtorParam("ContractRequestType", o => o.MapFrom(s => (Domain.Model.ContractRequestType)s.ContractRequestType))
+ .ForCtorParam("Amount", o => o.MapFrom(s => (decimal)s.Amount))
+ .ForCtorParam("CreatedDate", o => o.MapFrom(s => DateTime.Parse(s.CreatedDate)));
+ }
+}
diff --git a/RealEstateAgency/Agency.Api.Host/Program.cs b/RealEstateAgency/Agency.Api.Host/Program.cs
new file mode 100644
index 000000000..e7b3f4979
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Program.cs
@@ -0,0 +1,102 @@
+using Agency.Api.Host.Grpc;
+using Agency.Application;
+using Agency.Application.Contracts;
+using Agency.Application.Contracts.ContractRequests;
+using Agency.Application.Contracts.Counterparties;
+using Agency.Application.Contracts.Protos;
+using Agency.Application.Contracts.RealEstates;
+using Agency.Application.Services;
+using Agency.Domain;
+using Agency.Domain.Data;
+using Agency.Domain.Model;
+using Agency.Infrastructure.EfCore;
+using Agency.Infrastructure.EfCore.Repositories;
+using Agency.ServiceDefaults;
+using Microsoft.EntityFrameworkCore;
+using MongoDB.Driver;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.Services.AddSingleton();
+builder.Services.AddAutoMapper(config =>
+{
+ config.AddProfile(new AgencyProfile());
+ config.AddProfile(new AgencyGrpcProfile());
+});
+
+builder.Services.AddTransient, CounterpartyRepository>();
+builder.Services.AddTransient, RealEstateRepository>();
+builder.Services.AddTransient, ContractRequestRepository>();
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Where(a => a.GetName().Name?.StartsWith("Agency") == true))
+ {
+ var xmlFile = $"{assembly.GetName().Name}.xml";
+ var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
+ if (File.Exists(xmlPath))
+ c.IncludeXmlComments(xmlPath);
+ }
+});
+
+builder.AddMongoDBClient("agencyClient");
+builder.Services.AddDbContext((services, o) =>
+{
+ var db = services.GetRequiredService();
+ o.UseMongoDB(db.Client, db.DatabaseNamespace.DatabaseName);
+});
+
+builder.Services.AddGrpc(options =>
+{
+ options.EnableDetailedErrors = builder.Environment.IsDevelopment();
+});
+
+builder.Services.AddGrpcClient(o =>
+{
+ var addr = builder.Configuration["ContractRequestGenerator:GrpcAddress"]
+ ?? throw new InvalidOperationException("ContractRequestGenerator:GrpcAddress is not configured");
+ o.Address = new Uri(addr);
+});
+
+builder.Services.AddMemoryCache();
+
+builder.Services.AddHostedService();
+
+var app = builder.Build();
+app.MapDefaultEndpoints();
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+using (var scope = app.Services.CreateScope())
+{
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ var dataSeed = scope.ServiceProvider.GetRequiredService();
+
+ if (!await dbContext.Counterparties.AnyAsync())
+ {
+ foreach (var c in dataSeed.Counterparties)
+ await dbContext.Counterparties.AddAsync(c);
+ foreach (var r in dataSeed.RealEstates)
+ await dbContext.RealEstates.AddAsync(r);
+ foreach (var req in dataSeed.ContractRequests)
+ await dbContext.ContractRequests.AddAsync(req);
+ await dbContext.SaveChangesAsync();
+ }
+}
+
+app.UseHttpsRedirection();
+app.UseAuthorization();
+app.MapControllers();
+app.Run();
diff --git a/RealEstateAgency/Agency.Api.Host/Properties/launchSettings.json b/RealEstateAgency/Agency.Api.Host/Properties/launchSettings.json
new file mode 100644
index 000000000..3c49ecf15
--- /dev/null
+++ b/RealEstateAgency/Agency.Api.Host/Properties/launchSettings.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17002;http://localhost:15077",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21295",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22035"
+ },
+ "launchUrl": "swagger"
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15077",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19199",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20119"
+ },
+ "launchUrl": "swagger"
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.AppHost/Agency.AppHost.csproj b/RealEstateAgency/Agency.AppHost/Agency.AppHost.csproj
new file mode 100644
index 000000000..207c2fe5d
--- /dev/null
+++ b/RealEstateAgency/Agency.AppHost/Agency.AppHost.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ realestate-agency-apphost
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency/Agency.AppHost/AppHost.cs b/RealEstateAgency/Agency.AppHost/AppHost.cs
new file mode 100644
index 000000000..7cac37559
--- /dev/null
+++ b/RealEstateAgency/Agency.AppHost/AppHost.cs
@@ -0,0 +1,19 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var db = builder.AddMongoDB("mongo").AddDatabase("db");
+
+var batchSize = builder.AddParameter("GeneratorBatchSize");
+var waitTime = builder.AddParameter("GeneratorWaitTime");
+
+var grpcServer = builder.AddProject("agency-generator-grpc-host")
+ .WithEnvironment("Generator:BatchSize", batchSize)
+ .WithEnvironment("Generator:WaitTime", waitTime);
+
+builder.AddProject("agency-api-host")
+ .WithReference(db, "agencyClient")
+ .WaitFor(db)
+ .WithReference(grpcServer)
+ .WithEnvironment("ContractRequestGenerator__GrpcAddress", grpcServer.GetEndpoint("https"))
+ .WaitFor(grpcServer);
+
+builder.Build().Run();
diff --git a/RealEstateAgency/Agency.AppHost/Properties/launchSettings.json b/RealEstateAgency/Agency.AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..ba0dc317d
--- /dev/null
+++ b/RealEstateAgency/Agency.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:17012;http://localhost:15087",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21305",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22045"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15087",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19209",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20129"
+ }
+ }
+ }
+}
+
diff --git a/RealEstateAgency/Agency.AppHost/appsettings.Development.json b/RealEstateAgency/Agency.AppHost/appsettings.Development.json
new file mode 100644
index 000000000..21b22dbb4
--- /dev/null
+++ b/RealEstateAgency/Agency.AppHost/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
+
diff --git a/RealEstateAgency/Agency.AppHost/appsettings.json b/RealEstateAgency/Agency.AppHost/appsettings.json
new file mode 100644
index 000000000..195a5f668
--- /dev/null
+++ b/RealEstateAgency/Agency.AppHost/appsettings.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ },
+ "Parameters": {
+ "GeneratorBatchSize": 4,
+ "GeneratorWaitTime": 2
+ }
+}
+
diff --git a/RealEstateAgency/Agency.Application.Contracts/Agency.Application.Contracts.csproj b/RealEstateAgency/Agency.Application.Contracts/Agency.Application.Contracts.csproj
new file mode 100644
index 000000000..fdf4a1940
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/Agency.Application.Contracts.csproj
@@ -0,0 +1,11 @@
+
+
+ net8.0
+ true
+ enable
+ enable
+
+
+
+
+
diff --git a/RealEstateAgency/Agency.Application.Contracts/ContractRequests/ContractRequestCreateUpdateDto.cs b/RealEstateAgency/Agency.Application.Contracts/ContractRequests/ContractRequestCreateUpdateDto.cs
new file mode 100644
index 000000000..92259f1a3
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/ContractRequests/ContractRequestCreateUpdateDto.cs
@@ -0,0 +1,23 @@
+using Agency.Domain.Model;
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Application.Contracts.ContractRequests;
+
+///
+/// DTO для создания новой заявки или обновления существующей
+/// Используется в HTTP POST (создание) и HTTP PUT (обновление) запросах
+///
+/// Идентификатор контрагента (клиента), связанного с заявкой. Должен существовать в системе
+/// Идентификатор объекта недвижимости, связанного с заявкой. Должен существовать в системе
+/// Тип заявки: Purchase (0) - покупка, Sale (1) - продажа
+/// Сумма сделки в рублях. Диапазон от 0 до 1 миллиарда рублей
+/// Дата и время создания заявки в формате ISO 8601. Обычно устанавливается сервером
+/// Текущий статус заявки. Максимальная длина 50 символов.
+public record ContractRequestCreateUpdateDto(
+ int CounterpartyId,
+ int RealEstateId,
+ ContractRequestType ContractRequestType,
+ [Range(0, 1_000_000_000)] decimal Amount,
+ DateTime CreatedDate,
+ [Required][StringLength(50)] string Status
+);
diff --git a/RealEstateAgency/Agency.Application.Contracts/ContractRequests/ContractRequestDto.cs b/RealEstateAgency/Agency.Application.Contracts/ContractRequests/ContractRequestDto.cs
new file mode 100644
index 000000000..f4a925298
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/ContractRequests/ContractRequestDto.cs
@@ -0,0 +1,25 @@
+using Agency.Domain.Model;
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Application.Contracts.ContractRequests;
+
+///
+/// DTO для получения информации о заявке
+/// Используется в HTTP GET запросах для возврата данных клиенту
+///
+/// Уникальный идентификатор заявки. Используется для ссылок на заявку, редактирования и удаления. Генерируется сервером при создании
+/// Идентификатор контрагента (клиента), связанного с заявкой. Должен существовать в системе
+/// Идентификатор объекта недвижимости, связанного с заявкой. Должен существовать в системе
+/// Тип заявки: Purchase (0) - покупка, Sale (1) - продажа
+/// Сумма сделки в рублях. Диапазон от 0 до 1 миллиарда рублей
+/// Дата и время создания заявки в формате ISO 8601. Обычно устанавливается сервером
+/// Текущий статус заявки. Максимальная длина 50 символов.
+public record ContractRequestDto(
+ int Id,
+ int CounterpartyId,
+ int RealEstateId,
+ ContractRequestType ContractRequestType,
+ decimal Amount,
+ DateTime CreatedDate,
+ [Required][StringLength(50)] string Status
+);
diff --git a/RealEstateAgency/Agency.Application.Contracts/ContractRequests/IContractRequestService.cs b/RealEstateAgency/Agency.Application.Contracts/ContractRequests/IContractRequestService.cs
new file mode 100644
index 000000000..9728daf68
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/ContractRequests/IContractRequestService.cs
@@ -0,0 +1,8 @@
+namespace Agency.Application.Contracts.ContractRequests;
+
+///
+/// Сервис для работы с заявками
+///
+public interface IContractRequestService : IApplicationService
+{
+}
diff --git a/RealEstateAgency/Agency.Application.Contracts/Counterparties/CounterpartyCreateUpdateDto.cs b/RealEstateAgency/Agency.Application.Contracts/Counterparties/CounterpartyCreateUpdateDto.cs
new file mode 100644
index 000000000..b0061e097
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/Counterparties/CounterpartyCreateUpdateDto.cs
@@ -0,0 +1,16 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Application.Contracts.Counterparties;
+
+///
+/// DTO для создания нового контрагента или обновления существующего
+/// Используется в HTTP POST (создание) и HTTP PUT (обновление) запросах
+///
+/// Полное имя контрагента в формате "Фамилия Имя Отчество". Максимальная длина 150 символов
+/// Номер паспорта в формате "XXXX XXXXXX" (серия и номер). Максимальная длина 20 символов
+/// Контактный телефон в международном формате, например "+7 (XXX) XXX-XX-XX". Максимальная длина 20 символов
+public record CounterpartyCreateUpdateDto(
+ [Required][StringLength(150)] string FullName,
+ [Required][StringLength(20)] string PassportNumber,
+ [Required][StringLength(20)][Phone] string PhoneNumber
+);
diff --git a/RealEstateAgency/Agency.Application.Contracts/Counterparties/CounterpartyDto.cs b/RealEstateAgency/Agency.Application.Contracts/Counterparties/CounterpartyDto.cs
new file mode 100644
index 000000000..06e530136
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/Counterparties/CounterpartyDto.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Application.Contracts.Counterparties;
+
+///
+/// DTO для получения информации о заявке
+/// Используется в HTTP GET запросах для возврата данных клиенту
+///
+/// Уникальный идентификатор контрагента. Используется для ссылок на заявку, редактирования и удаления. Генерируется сервером при создании
+/// Полное имя контрагента в формате "Фамилия Имя Отчество". Максимальная длина 150 символов
+/// Номер паспорта в формате "XXXX XXXXXX" (серия и номер). Максимальная длина 20 символов
+/// Контактный телефон в международном формате, например "+7 (XXX) XXX-XX-XX". Максимальная длина 20 символов
+public record CounterpartyDto(
+ int Id,
+ [Required][StringLength(150)] string FullName,
+ [Required][StringLength(20)] string PassportNumber,
+ [Required][StringLength(20)] string PhoneNumber
+);
diff --git a/RealEstateAgency/Agency.Application.Contracts/Counterparties/ICounterpartyService.cs b/RealEstateAgency/Agency.Application.Contracts/Counterparties/ICounterpartyService.cs
new file mode 100644
index 000000000..7b3356f6b
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/Counterparties/ICounterpartyService.cs
@@ -0,0 +1,8 @@
+namespace Agency.Application.Contracts.Counterparties;
+
+///
+/// Сервис для работы с контрагентами
+///
+public interface ICounterpartyService : IApplicationService
+{
+}
diff --git a/RealEstateAgency/Agency.Application.Contracts/IAnalyticsService.cs b/RealEstateAgency/Agency.Application.Contracts/IAnalyticsService.cs
new file mode 100644
index 000000000..19dbbc7a6
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/IAnalyticsService.cs
@@ -0,0 +1,45 @@
+using Agency.Application.Contracts.Counterparties;
+using Agency.Domain.Model;
+
+namespace Agency.Application.Contracts;
+
+///
+/// Сервис аналитики риэлторского агентства
+///
+public interface IAnalyticsService
+{
+ ///
+ /// Продавцы, оставившие заявки на продажу за заданный период
+ ///
+ Task> GetSellersWithRequestsInPeriod(DateTime from, DateTime to);
+
+ ///
+ /// Топ 5 покупателей по количеству заявок на покупку
+ ///
+ Task> GetTop5BuyersByRequestCount();
+
+ ///
+ /// Топ 5 продавцов по количеству заявок на продажу
+ ///
+ Task> GetTop5SellersByRequestCount();
+
+ ///
+ /// Количество заявок по каждому типу недвижимости
+ ///
+ Task> GetRequestCountByPropertyType();
+
+ ///
+ /// Клиенты с минимальной суммой заявки
+ ///
+ Task> GetClientsWithMinimalRequestAmount();
+
+ ///
+ /// Клиенты, ищущие недвижимость заданного типа (покупка), упорядоченные по ФИО
+ ///
+ Task> GetClientsSearchingForPropertyType(RealEstateType propertyType);
+
+ ///
+ /// Клиенты, ищущие дома (покупка), упорядоченные по ФИО
+ ///
+ Task> GetClientsSearchingForHousesOrderedByFullName();
+}
diff --git a/RealEstateAgency/Agency.Application.Contracts/IApplicationService.cs b/RealEstateAgency/Agency.Application.Contracts/IApplicationService.cs
new file mode 100644
index 000000000..021111af2
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/IApplicationService.cs
@@ -0,0 +1,16 @@
+namespace Agency.Application.Contracts;
+
+///
+/// Интерфейс службы приложения для CRUD операций
+///
+public interface IApplicationService
+ where TDto : class
+ where TCreateUpdateDto : class
+ where TKey : struct
+{
+ Task Create(TCreateUpdateDto dto);
+ Task Get(TKey dtoId);
+ Task> GetAll();
+ Task Update(TCreateUpdateDto dto, TKey dtoId);
+ Task Delete(TKey dtoId);
+}
diff --git a/RealEstateAgency/Agency.Application.Contracts/Protos/ContractRequest.proto b/RealEstateAgency/Agency.Application.Contracts/Protos/ContractRequest.proto
new file mode 100644
index 000000000..d42511de2
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/Protos/ContractRequest.proto
@@ -0,0 +1,30 @@
+syntax = "proto3";
+
+option csharp_namespace = "Agency.Application.Contracts.Protos";
+
+message ContractRequestGenerationRequest {
+ string request_id = 1;
+
+ int32 count = 2;
+
+ int32 batch_size = 3;
+}
+
+message ContractRequestCreateUpdateDtoMessage {
+ int32 counterparty_id = 1;
+ int32 real_estate_id = 2;
+ int32 contract_request_type = 3;
+ double amount = 4;
+ string created_date = 5;
+ string status = 6;
+}
+
+message ContractRequestBatchStreamMessage {
+ string request_id = 1;
+ repeated ContractRequestCreateUpdateDtoMessage contract_requests = 2;
+ bool is_final = 3;
+}
+
+service ContractRequestGeneratorGrpcService {
+ rpc ContractRequestStream (stream ContractRequestGenerationRequest) returns (stream ContractRequestBatchStreamMessage);
+}
diff --git a/RealEstateAgency/Agency.Application.Contracts/RealEstates/IRealEstateService.cs b/RealEstateAgency/Agency.Application.Contracts/RealEstates/IRealEstateService.cs
new file mode 100644
index 000000000..233a32f44
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/RealEstates/IRealEstateService.cs
@@ -0,0 +1,8 @@
+namespace Agency.Application.Contracts.RealEstates;
+
+///
+/// Сервис для работы с объектами недвижимости
+///
+public interface IRealEstateService : IApplicationService
+{
+}
diff --git a/RealEstateAgency/Agency.Application.Contracts/RealEstates/RealEstateCreateUpdateDto.cs b/RealEstateAgency/Agency.Application.Contracts/RealEstates/RealEstateCreateUpdateDto.cs
new file mode 100644
index 000000000..1a2e45508
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/RealEstates/RealEstateCreateUpdateDto.cs
@@ -0,0 +1,33 @@
+using Agency.Domain.Model;
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Application.Contracts.RealEstates;
+
+///
+/// DTO для создания нового объекта недвижимости или обновления существующего
+/// Используется в HTTP POST (создание) и HTTP PUT (обновление) запросах
+///
+/// Тип объекта недвижимости: Apartment (0) - квартира, House (1) - дом, LandPlot (2) - участок, Commercial (3) - коммерческая, Garage (4) - гараж
+/// Назначение объекта: Residential (0) - жилое, Commercial (1) - коммерческое, Industrial (2) - промышленное, Agricultural (3) - сельскохозяйственное
+/// Кадастровый номер объекта в формате "XX:XX:XXXXXXX:XXXX". Максимальная длина 50 символов
+/// Полный почтовый адрес объекта. Максимальная длина 200 символов
+/// Общее количество этажей в здании. Указывается для домов и зданий, для участков может быть null
+/// Общая площадь объекта в квадратных метрах. Допустимый диапазон от 1 до 10000 кв.м
+/// Количество комнат. Указывается для квартир и домов, для участков и коммерческой недвижимости может быть null
+/// Высота потолков в метрах. Указывается для помещений, для участков может быть null
+/// Этаж расположения. Указывается для квартир и помещений в многоэтажных зданиях
+/// Флаг наличия обременений: true - есть обременения (ипотека, арест, аренда), false - нет обременений
+/// Описание обременений, если HasEncumbrances = true. Необязательное поле, максимальная длина 500 символов
+public record RealEstateCreateUpdateDto(
+ RealEstateType Type,
+ RealEstatePurpose Purpose,
+ [Required][StringLength(50)] string CadastralNumber,
+ [Required][StringLength(200)] string Address,
+ int? TotalFloors,
+ [Range(1, 10000)] double TotalArea,
+ int? NumberOfRooms,
+ double? CeilingHeight,
+ int? Floor,
+ bool HasEncumbrances,
+ string? EncumbrancesDescription
+);
diff --git a/RealEstateAgency/Agency.Application.Contracts/RealEstates/RealEstateDto.cs b/RealEstateAgency/Agency.Application.Contracts/RealEstates/RealEstateDto.cs
new file mode 100644
index 000000000..a0a711841
--- /dev/null
+++ b/RealEstateAgency/Agency.Application.Contracts/RealEstates/RealEstateDto.cs
@@ -0,0 +1,35 @@
+using Agency.Domain.Model;
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Application.Contracts.RealEstates;
+
+///
+/// DTO для получения информации об объекте недвижимости
+/// Используется в HTTP GET запросах для возврата данных клиенту
+///
+/// Уникальный идентификатор объекта недвижимости. Генерируется сервером при создании
+/// Тип объекта недвижимости
+/// Назначение объекта
+/// Кадастровый номер объекта
+/// Полный почтовый адрес объекта
+/// Общее количество этажей в здании
+/// Общая площадь объекта в квадратных метрах
+/// Количество комнат
+/// Высота потолков в метрах
+/// Этаж расположения
+/// Флаг наличия обременений
+/// Описание обременений
+public record RealEstateDto(
+ int Id,
+ RealEstateType Type,
+ RealEstatePurpose Purpose,
+ [Required][StringLength(50)] string CadastralNumber,
+ [Required][StringLength(200)] string Address,
+ int? TotalFloors,
+ double TotalArea,
+ int? NumberOfRooms,
+ double? CeilingHeight,
+ int? Floor,
+ bool HasEncumbrances,
+ string? EncumbrancesDescription
+);
diff --git a/RealEstateAgency/Agency.Application/Agency.Application.csproj b/RealEstateAgency/Agency.Application/Agency.Application.csproj
new file mode 100644
index 000000000..8007986ae
--- /dev/null
+++ b/RealEstateAgency/Agency.Application/Agency.Application.csproj
@@ -0,0 +1,15 @@
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency/Agency.Application/AgencyProfile.cs b/RealEstateAgency/Agency.Application/AgencyProfile.cs
new file mode 100644
index 000000000..a4f47374b
--- /dev/null
+++ b/RealEstateAgency/Agency.Application/AgencyProfile.cs
@@ -0,0 +1,29 @@
+using Agency.Application.Contracts.ContractRequests;
+using Agency.Application.Contracts.Counterparties;
+using Agency.Application.Contracts.RealEstates;
+using Agency.Domain.Model;
+using AutoMapper;
+
+namespace Agency.Application;
+
+///
+/// Профиль маппинга AutoMapper для риэлторского агентства
+///
+public class AgencyProfile : Profile
+{
+ public AgencyProfile()
+ {
+ CreateMap();
+ CreateMap();
+
+ CreateMap();
+ CreateMap();
+
+ CreateMap()
+ .ForMember(d => d.CounterpartyId, o => o.MapFrom(s => s.CounterpartyId))
+ .ForMember(d => d.RealEstateId, o => o.MapFrom(s => s.RealEstateId));
+ CreateMap()
+ .ForMember(d => d.Counterparty, o => o.Ignore())
+ .ForMember(d => d.RealEstate, o => o.Ignore());
+ }
+}
diff --git a/RealEstateAgency/Agency.Application/Services/AnalyticsService.cs b/RealEstateAgency/Agency.Application/Services/AnalyticsService.cs
new file mode 100644
index 000000000..69c73e02d
--- /dev/null
+++ b/RealEstateAgency/Agency.Application/Services/AnalyticsService.cs
@@ -0,0 +1,96 @@
+using Agency.Application.Contracts;
+using Agency.Application.Contracts.Counterparties;
+using Agency.Domain;
+using Agency.Domain.Model;
+using AutoMapper;
+
+namespace Agency.Application.Services;
+
+///
+/// Сервис аналитики риэлторского агентства
+///
+public class AnalyticsService(
+ IRepository requestRepository,
+ IMapper mapper) : IAnalyticsService
+{
+ public async Task> GetSellersWithRequestsInPeriod(DateTime from, DateTime to)
+ {
+ var requests = await requestRepository.ReadAll();
+ var sellers = requests
+ .Where(r => r.ContractRequestType == ContractRequestType.Sale
+ && r.CreatedDate >= from
+ && r.CreatedDate <= to)
+ .Select(r => r.Counterparty)
+ .Distinct()
+ .OrderBy(c => c.FullName)
+ .ToList();
+ return mapper.Map>(sellers);
+ }
+
+ public async Task> GetTop5BuyersByRequestCount()
+ {
+ var requests = await requestRepository.ReadAll();
+ var buyersByCount = requests
+ .Where(r => r.ContractRequestType == ContractRequestType.Purchase)
+ .GroupBy(r => r.Counterparty)
+ .Select(g => new { Counterparty = g.Key, Count = g.Count() })
+ .OrderByDescending(x => x.Count)
+ .Take(5)
+ .Select(x => x.Counterparty)
+ .ToList();
+ return mapper.Map>(buyersByCount);
+ }
+
+ public async Task> GetTop5SellersByRequestCount()
+ {
+ var requests = await requestRepository.ReadAll();
+ var sellersByCount = requests
+ .Where(r => r.ContractRequestType == ContractRequestType.Sale)
+ .GroupBy(r => r.Counterparty)
+ .Select(g => new { Counterparty = g.Key, Count = g.Count() })
+ .OrderByDescending(x => x.Count)
+ .Take(5)
+ .Select(x => x.Counterparty)
+ .ToList();
+ return mapper.Map>(sellersByCount);
+ }
+
+ public async Task> GetRequestCountByPropertyType()
+ {
+ var requests = await requestRepository.ReadAll();
+ return requests
+ .GroupBy(r => r.RealEstate.Type)
+ .Select(g => new { PropertyType = g.Key, Count = g.Count() })
+ .ToDictionary(x => x.PropertyType, x => x.Count);
+ }
+
+ public async Task> GetClientsWithMinimalRequestAmount()
+ {
+ var requests = await requestRepository.ReadAll();
+ var minAmount = requests.Min(r => r.Amount);
+ var clients = requests
+ .Where(r => r.Amount == minAmount)
+ .Select(r => r.Counterparty)
+ .Distinct()
+ .OrderBy(c => c.FullName)
+ .ToList();
+ return mapper.Map>(clients);
+ }
+
+ public async Task> GetClientsSearchingForPropertyType(RealEstateType propertyType)
+ {
+ var requests = await requestRepository.ReadAll();
+ var clients = requests
+ .Where(r => r.ContractRequestType == ContractRequestType.Purchase && r.RealEstate.Type == propertyType)
+ .Select(r => r.Counterparty)
+ .Distinct()
+ .OrderBy(c => c.FullName)
+ .ToList();
+ return mapper.Map>(clients);
+ }
+
+ public async Task> GetClientsSearchingForHousesOrderedByFullName()
+ {
+ return await GetClientsSearchingForPropertyType(RealEstateType.House);
+ }
+}
diff --git a/RealEstateAgency/Agency.Application/Services/ContractRequestService.cs b/RealEstateAgency/Agency.Application/Services/ContractRequestService.cs
new file mode 100644
index 000000000..1863f3654
--- /dev/null
+++ b/RealEstateAgency/Agency.Application/Services/ContractRequestService.cs
@@ -0,0 +1,57 @@
+using Agency.Application.Contracts.ContractRequests;
+using Agency.Domain;
+using Agency.Domain.Model;
+using AutoMapper;
+
+namespace Agency.Application.Services;
+
+///
+/// Сервис для управления заявками
+///
+public class ContractRequestService(
+ IRepository requestRepository,
+ IRepository counterpartyRepository,
+ IRepository realEstateRepository,
+ IMapper mapper) : IContractRequestService
+{
+ public async Task Create(ContractRequestCreateUpdateDto dto)
+ {
+ var counterparty = await counterpartyRepository.Read(dto.CounterpartyId) ?? throw new KeyNotFoundException($"Контрагент с Id {dto.CounterpartyId} не найден");
+ var realEstate = await realEstateRepository.Read(dto.RealEstateId) ?? throw new KeyNotFoundException($"Объект недвижимости с Id {dto.RealEstateId} не найден");
+
+ var entity = mapper.Map(dto);
+ entity.Counterparty = counterparty;
+ entity.RealEstate = realEstate;
+ var all = await requestRepository.ReadAll();
+ entity.Id = all.Count > 0 ? all.Max(x => x.Id) + 1 : 1;
+ var result = await requestRepository.Create(entity);
+ return mapper.Map(result);
+ }
+
+ public async Task Delete(int dtoId) => await requestRepository.Delete(dtoId);
+
+ public async Task Get(int dtoId)
+ {
+ var entity = await requestRepository.Read(dtoId);
+ return entity == null ? null : mapper.Map(entity);
+ }
+
+ public async Task> GetAll()
+ {
+ var all = await requestRepository.ReadAll();
+ return mapper.Map>(all);
+ }
+
+ public async Task Update(ContractRequestCreateUpdateDto dto, int dtoId)
+ {
+ var entity = await requestRepository.Read(dtoId) ?? throw new KeyNotFoundException($"Заявка с Id {dtoId} не найдена");
+ var counterparty = await counterpartyRepository.Read(dto.CounterpartyId) ?? throw new KeyNotFoundException($"Контрагент с Id {dto.CounterpartyId} не найден");
+ var realEstate = await realEstateRepository.Read(dto.RealEstateId) ?? throw new KeyNotFoundException($"Объект недвижимости с Id {dto.RealEstateId} не найден");
+
+ mapper.Map(dto, entity);
+ entity.Counterparty = counterparty;
+ entity.RealEstate = realEstate;
+ var result = await requestRepository.Update(entity);
+ return mapper.Map(result);
+ }
+}
diff --git a/RealEstateAgency/Agency.Application/Services/CounterpartyService.cs b/RealEstateAgency/Agency.Application/Services/CounterpartyService.cs
new file mode 100644
index 000000000..d0fe96883
--- /dev/null
+++ b/RealEstateAgency/Agency.Application/Services/CounterpartyService.cs
@@ -0,0 +1,45 @@
+using Agency.Application.Contracts.Counterparties;
+using Agency.Domain;
+using Agency.Domain.Model;
+using AutoMapper;
+
+namespace Agency.Application.Services;
+
+///
+/// Сервис для управления контрагентами
+///
+public class CounterpartyService(
+ IRepository repository,
+ IMapper mapper) : ICounterpartyService
+{
+ public async Task Create(CounterpartyCreateUpdateDto dto)
+ {
+ var entity = mapper.Map(dto);
+ var all = await repository.ReadAll();
+ entity.Id = all.Count > 0 ? all.Max(x => x.Id) + 1 : 1;
+ var result = await repository.Create(entity);
+ return mapper.Map(result);
+ }
+
+ public async Task Delete(int dtoId) => await repository.Delete(dtoId);
+
+ public async Task Get(int dtoId)
+ {
+ var entity = await repository.Read(dtoId);
+ return entity == null ? null : mapper.Map(entity);
+ }
+
+ public async Task> GetAll()
+ {
+ var all = await repository.ReadAll();
+ return mapper.Map>(all);
+ }
+
+ public async Task Update(CounterpartyCreateUpdateDto dto, int dtoId)
+ {
+ var entity = await repository.Read(dtoId) ?? throw new KeyNotFoundException($"Контрагент с Id {dtoId} не найден");
+ mapper.Map(dto, entity);
+ var result = await repository.Update(entity);
+ return mapper.Map(result);
+ }
+}
diff --git a/RealEstateAgency/Agency.Application/Services/RealEstateService.cs b/RealEstateAgency/Agency.Application/Services/RealEstateService.cs
new file mode 100644
index 000000000..5877a85a6
--- /dev/null
+++ b/RealEstateAgency/Agency.Application/Services/RealEstateService.cs
@@ -0,0 +1,45 @@
+using Agency.Application.Contracts.RealEstates;
+using Agency.Domain;
+using Agency.Domain.Model;
+using AutoMapper;
+
+namespace Agency.Application.Services;
+
+///
+/// Сервис для управления объектами недвижимости
+///
+public class RealEstateService(
+ IRepository repository,
+ IMapper mapper) : IRealEstateService
+{
+ public async Task Create(RealEstateCreateUpdateDto dto)
+ {
+ var entity = mapper.Map(dto);
+ var all = await repository.ReadAll();
+ entity.Id = all.Count > 0 ? all.Max(x => x.Id) + 1 : 1;
+ var result = await repository.Create(entity);
+ return mapper.Map(result);
+ }
+
+ public async Task Delete(int dtoId) => await repository.Delete(dtoId);
+
+ public async Task Get(int dtoId)
+ {
+ var entity = await repository.Read(dtoId);
+ return entity == null ? null : mapper.Map(entity);
+ }
+
+ public async Task> GetAll()
+ {
+ var all = await repository.ReadAll();
+ return mapper.Map>(all);
+ }
+
+ public async Task Update(RealEstateCreateUpdateDto dto, int dtoId)
+ {
+ var entity = await repository.Read(dtoId) ?? throw new KeyNotFoundException($"Объект недвижимости с Id {dtoId} не найден");
+ mapper.Map(dto, entity);
+ var result = await repository.Update(entity);
+ return mapper.Map(result);
+ }
+}
diff --git a/RealEstateAgency/Agency.Domain/Agency.Domain.csproj b/RealEstateAgency/Agency.Domain/Agency.Domain.csproj
new file mode 100644
index 000000000..fa71b7ae6
--- /dev/null
+++ b/RealEstateAgency/Agency.Domain/Agency.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/RealEstateAgency/Agency.Domain/Data/DataSeeder.cs b/RealEstateAgency/Agency.Domain/Data/DataSeeder.cs
new file mode 100644
index 000000000..2de365d5d
--- /dev/null
+++ b/RealEstateAgency/Agency.Domain/Data/DataSeeder.cs
@@ -0,0 +1,429 @@
+using Agency.Domain.Model;
+
+namespace Agency.Domain.Data;
+
+///
+/// Данные для тестирования риэлторского агентства
+///
+public class DataSeeder
+{
+ ///
+ /// Коллекция контрагентов (клиентов)
+ ///
+ public List Counterparties { get; } = [];
+
+ ///
+ /// Коллекция объектов недвижимости
+ ///
+ public List RealEstates { get; } = [];
+
+ ///
+ /// Коллекция заявок
+ ///
+ public List ContractRequests { get; } = [];
+
+ ///
+ /// Конструктор, выполняющий инициализацию всех тестовых данных
+ ///
+ public DataSeeder()
+ {
+ // Инициализация контрагентов (клиентов)
+ Counterparties.AddRange(
+ [
+ new Counterparty
+ {
+ Id = 1,
+ FullName = "Иванов Иван Иванович",
+ PassportNumber = "4501 123456",
+ PhoneNumber = "+7 (901) 123-45-67"
+ },
+ new Counterparty
+ {
+ Id = 2,
+ FullName = "Петрова Анна Сергеевна",
+ PassportNumber = "4502 234567",
+ PhoneNumber = "+7 (902) 234-56-78"
+ },
+ new Counterparty
+ {
+ Id = 3,
+ FullName = "Сидоров Петр Алексеевич",
+ PassportNumber = "4503 345678",
+ PhoneNumber = "+7 (903) 345-67-89"
+ },
+ new Counterparty
+ {
+ Id = 4,
+ FullName = "Козлова Елена Дмитриевна",
+ PassportNumber = "4504 456789",
+ PhoneNumber = "+7 (904) 456-78-90"
+ },
+ new Counterparty
+ {
+ Id = 5,
+ FullName = "Смирнов Алексей Владимирович",
+ PassportNumber = "4505 567890",
+ PhoneNumber = "+7 (905) 567-89-01"
+ },
+ new Counterparty
+ {
+ Id = 6,
+ FullName = "Михайлова Ольга Игоревна",
+ PassportNumber = "4506 678901",
+ PhoneNumber = "+7 (906) 678-90-12"
+ },
+ new Counterparty
+ {
+ Id = 7,
+ FullName = "Федоров Денис Андреевич",
+ PassportNumber = "4507 789012",
+ PhoneNumber = "+7 (907) 789-01-23"
+ },
+ new Counterparty
+ {
+ Id = 8,
+ FullName = "Васильева Татьяна Павловна",
+ PassportNumber = "4508 890123",
+ PhoneNumber = "+7 (908) 890-12-34"
+ },
+ new Counterparty
+ {
+ Id = 9,
+ FullName = "Николаев Артем Борисович",
+ PassportNumber = "4509 901234",
+ PhoneNumber = "+7 (909) 901-23-45"
+ },
+ new Counterparty
+ {
+ Id = 10,
+ FullName = "Александрова Наталья Викторовна",
+ PassportNumber = "4510 012345",
+ PhoneNumber = "+7 (910) 012-34-56"
+ }
+ ]
+ );
+
+ // Инициализация объектов недвижимости
+ RealEstates.AddRange(
+ [
+ new RealEstate
+ {
+ Id = 101,
+ Type = RealEstateType.Apartment,
+ Purpose = RealEstatePurpose.Residential,
+ CadastralNumber = "77:01:0001012:1234",
+ Address = "г. Москва, ул. Ленина, д. 10, кв. 25",
+ TotalFloors = 9,
+ TotalArea = 65.5,
+ NumberOfRooms = 3,
+ CeilingHeight = 2.7,
+ Floor = 5,
+ HasEncumbrances = false
+ },
+ new RealEstate
+ {
+ Id = 102,
+ Type = RealEstateType.Apartment,
+ Purpose = RealEstatePurpose.Residential,
+ CadastralNumber = "77:01:0001012:5678",
+ Address = "г. Москва, ул. Ленина, д. 10, кв. 42",
+ TotalFloors = 9,
+ TotalArea = 45.0,
+ NumberOfRooms = 2,
+ CeilingHeight = 2.7,
+ Floor = 3,
+ HasEncumbrances = true,
+ EncumbrancesDescription = "Ипотека Сбербанк"
+ },
+ new RealEstate
+ {
+ Id = 103,
+ Type = RealEstateType.House,
+ Purpose = RealEstatePurpose.Residential,
+ CadastralNumber = "50:12:0030215:789",
+ Address = "Московская обл., Одинцовский р-н, д. Петрово, ул. Центральная, д. 15",
+ TotalFloors = 2,
+ TotalArea = 150.0,
+ NumberOfRooms = 5,
+ CeilingHeight = 3.2,
+ Floor = null,
+ HasEncumbrances = false
+ },
+ new RealEstate
+ {
+ Id = 104,
+ Type = RealEstateType.LandPlot,
+ Purpose = RealEstatePurpose.Agricultural,
+ CadastralNumber = "50:12:0030215:123",
+ Address = "Московская обл., Одинцовский р-н, уч. 45",
+ TotalFloors = null,
+ TotalArea = 1200.0,
+ NumberOfRooms = null,
+ CeilingHeight = null,
+ Floor = null,
+ HasEncumbrances = false
+ },
+ new RealEstate
+ {
+ Id = 105,
+ Type = RealEstateType.Commercial,
+ Purpose = RealEstatePurpose.Commercial,
+ CadastralNumber = "77:01:0003025:456",
+ Address = "г. Москва, ул. Тверская, д. 5, пом. 1",
+ TotalFloors = 3,
+ TotalArea = 250.0,
+ NumberOfRooms = 6,
+ CeilingHeight = 4.5,
+ Floor = 1,
+ HasEncumbrances = true,
+ EncumbrancesDescription = "Аренда до 2026 г."
+ },
+ new RealEstate
+ {
+ Id = 106,
+ Type = RealEstateType.Garage,
+ Purpose = RealEstatePurpose.Residential,
+ CadastralNumber = "77:01:0004018:789",
+ Address = "г. Москва, ГСК 'Автомобилист', бокс 12",
+ TotalFloors = 1,
+ TotalArea = 20.0,
+ NumberOfRooms = null,
+ CeilingHeight = 2.5,
+ Floor = 1,
+ HasEncumbrances = false
+ },
+ new RealEstate
+ {
+ Id = 107,
+ Type = RealEstateType.Apartment,
+ Purpose = RealEstatePurpose.Residential,
+ CadastralNumber = "78:01:0001034:567",
+ Address = "г. Санкт-Петербург, Невский пр., д. 25, кв. 12",
+ TotalFloors = 5,
+ TotalArea = 82.0,
+ NumberOfRooms = 4,
+ CeilingHeight = 3.0,
+ Floor = 2,
+ HasEncumbrances = false
+ },
+ new RealEstate
+ {
+ Id = 108,
+ Type = RealEstateType.Apartment,
+ Purpose = RealEstatePurpose.Residential,
+ CadastralNumber = "78:01:0001034:890",
+ Address = "г. Санкт-Петербург, Невский пр., д. 25, кв. 15",
+ TotalFloors = 5,
+ TotalArea = 55.0,
+ NumberOfRooms = 2,
+ CeilingHeight = 3.0,
+ Floor = 4,
+ HasEncumbrances = false
+ },
+ new RealEstate
+ {
+ Id = 109,
+ Type = RealEstateType.Commercial,
+ Purpose = RealEstatePurpose.Commercial,
+ CadastralNumber = "78:01:0002056:234",
+ Address = "г. Санкт-Петербург, ул. Рубинштейна, д. 10",
+ TotalFloors = 4,
+ TotalArea = 180.0,
+ NumberOfRooms = 4,
+ CeilingHeight = 3.5,
+ Floor = 1,
+ HasEncumbrances = false
+ },
+ new RealEstate
+ {
+ Id = 110,
+ Type = RealEstateType.House,
+ Purpose = RealEstatePurpose.Residential,
+ CadastralNumber = "47:14:0030215:456",
+ Address = "Ленинградская обл., Всеволожский р-н, д. Романовка, ул. Лесная, д. 7",
+ TotalFloors = 2,
+ TotalArea = 120.0,
+ NumberOfRooms = 4,
+ CeilingHeight = 2.8,
+ Floor = null,
+ HasEncumbrances = true,
+ EncumbrancesDescription = "Залог"
+ }
+ ]
+ );
+
+ // Инициализация заявок
+ ContractRequests.AddRange(
+ [
+ new ContractRequest
+ {
+ Id = 201,
+ CounterpartyId = 1,
+ Counterparty = Counterparties[0], // Иванов
+ RealEstateId = 101,
+ RealEstate = RealEstates[0], // Квартира на Ленина, 25
+ ContractRequestType = ContractRequestType.Sale,
+ Amount = 12500000,
+ CreatedDate = new DateTime(2025, 1, 15),
+ Status = "Closed"
+ },
+ new ContractRequest
+ {
+ Id = 202,
+ CounterpartyId = 2,
+ Counterparty = Counterparties[1], // Петрова
+ RealEstateId = 102,
+ RealEstate = RealEstates[1], // Квартира на Ленина, 42
+ ContractRequestType = ContractRequestType.Sale,
+ Amount = 9500000,
+ CreatedDate = new DateTime(2025, 1, 20),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 203,
+ CounterpartyId = 3,
+ Counterparty = Counterparties[2], // Сидоров
+ RealEstateId = 103,
+ RealEstate = RealEstates[2], // Дом в Петрово
+ ContractRequestType = ContractRequestType.Purchase,
+ Amount = 18500000,
+ CreatedDate = new DateTime(2025, 2, 5),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 204,
+ CounterpartyId = 1,
+ Counterparty = Counterparties[0], // Иванов (снова)
+ RealEstateId = 104,
+ RealEstate = RealEstates[3], // Участок
+ ContractRequestType = ContractRequestType.Purchase,
+ Amount = 3500000,
+ CreatedDate = new DateTime(2025, 2, 10),
+ Status = "Cancelled"
+ },
+ new ContractRequest
+ {
+ Id = 205,
+ CounterpartyId = 4,
+ Counterparty = Counterparties[3], // Козлова
+ RealEstateId = 105,
+ RealEstate = RealEstates[4], // Коммерческое на Тверской
+ ContractRequestType = ContractRequestType.Purchase,
+ Amount = 45000000,
+ CreatedDate = new DateTime(2025, 3, 1),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 206,
+ CounterpartyId = 5,
+ Counterparty = Counterparties[4], // Смирнов
+ RealEstateId = 106,
+ RealEstate = RealEstates[5], // Гараж
+ ContractRequestType = ContractRequestType.Purchase,
+ Amount = 1200000,
+ CreatedDate = new DateTime(2025, 3, 15),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 207,
+ CounterpartyId = 6,
+ Counterparty = Counterparties[5], // Михайлова
+ RealEstateId = 107,
+ RealEstate = RealEstates[6], // Квартира на Невском, 12
+ ContractRequestType = ContractRequestType.Sale,
+ Amount = 16500000,
+ CreatedDate = new DateTime(2025, 4, 2),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 208,
+ CounterpartyId = 3,
+ Counterparty = Counterparties[2], // Сидоров (снова)
+ RealEstateId = 108,
+ RealEstate = RealEstates[7], // Квартира на Невском, 15
+ ContractRequestType = ContractRequestType.Purchase,
+ Amount = 11000000,
+ CreatedDate = new DateTime(2025, 4, 18),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 209,
+ CounterpartyId = 7,
+ Counterparty = Counterparties[6], // Федоров
+ RealEstateId = 109,
+ RealEstate = RealEstates[8], // Коммерческое на Рубинштейна
+ ContractRequestType = ContractRequestType.Sale,
+ Amount = 28000000,
+ CreatedDate = new DateTime(2025, 5, 5),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 210,
+ CounterpartyId = 8,
+ Counterparty = Counterparties[7], // Васильева
+ RealEstateId = 110,
+ RealEstate = RealEstates[9], // Дом в Романовке
+ ContractRequestType = ContractRequestType.Purchase,
+ Amount = 9500000,
+ CreatedDate = new DateTime(2025, 5, 20),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 211,
+ CounterpartyId = 9,
+ Counterparty = Counterparties[8], // Николаев
+ RealEstateId = 101,
+ RealEstate = RealEstates[0], // Квартира на Ленина, 25
+ ContractRequestType = ContractRequestType.Purchase,
+ Amount = 12500000,
+ CreatedDate = new DateTime(2025, 6, 1),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 212,
+ CounterpartyId = 2,
+ Counterparty = Counterparties[1], // Петрова (снова)
+ RealEstateId = 103,
+ RealEstate = RealEstates[2], // Дом в Петрово
+ ContractRequestType = ContractRequestType.Purchase,
+ Amount = 18000000,
+ CreatedDate = new DateTime(2025, 6, 15),
+ Status = "Active"
+ },
+ new ContractRequest
+ {
+ Id = 213,
+ CounterpartyId = 10,
+ Counterparty = Counterparties[9], // Александрова
+ RealEstateId = 102,
+ RealEstate = RealEstates[1], // Квартира на Ленина, 42
+ ContractRequestType = ContractRequestType.Purchase,
+ Amount = 9200000,
+ CreatedDate = new DateTime(2025, 7, 1),
+ Status = "Active"
+ }
+ ]
+ );
+
+ // Устанавливаем навигационные свойства для каждого контрагента
+ foreach (var counterparty in Counterparties)
+ {
+ counterparty.Requests = ContractRequests.Where(r => r.Counterparty.Id == counterparty.Id).ToList();
+ }
+
+ // Устанавливаем навигационные свойства для каждого объекта недвижимости
+ foreach (var realEstate in RealEstates)
+ {
+ realEstate.Requests = ContractRequests.Where(r => r.RealEstate.Id == realEstate.Id).ToList();
+ }
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.Domain/IRepository.cs b/RealEstateAgency/Agency.Domain/IRepository.cs
new file mode 100644
index 000000000..0eefab0fb
--- /dev/null
+++ b/RealEstateAgency/Agency.Domain/IRepository.cs
@@ -0,0 +1,17 @@
+namespace Agency.Domain;
+
+///
+/// Интерфейс репозитория для CRUD операций
+///
+/// Тип сущности
+/// Тип идентификатора сущности
+public interface IRepository
+ where TEntity : class
+ where TKey : struct
+{
+ Task Create(TEntity entity);
+ Task Read(TKey entityId);
+ Task> ReadAll();
+ Task Update(TEntity entity);
+ Task Delete(TKey entityId);
+}
diff --git a/RealEstateAgency/Agency.Domain/Model/ContractRequest.cs b/RealEstateAgency/Agency.Domain/Model/ContractRequest.cs
new file mode 100644
index 000000000..7ecdc3476
--- /dev/null
+++ b/RealEstateAgency/Agency.Domain/Model/ContractRequest.cs
@@ -0,0 +1,58 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Domain.Model;
+
+///
+/// Заявка (контракт)
+///
+public class ContractRequest
+{
+ ///
+ /// Уникальный идентификатор заявки
+ ///
+ [Key]
+ public required int Id { get; set; }
+
+ ///
+ /// Идентификатор контрагента
+ ///
+ public int CounterpartyId { get; set; }
+
+ ///
+ /// Контрагент, связанный с заявкой
+ ///
+ public required Counterparty Counterparty { get; set; }
+
+ ///
+ /// Идентификатор объекта недвижимости
+ ///
+ public int RealEstateId { get; set; }
+
+ ///
+ /// Объект недвижимости, связанный с заявкой
+ ///
+ public required RealEstate RealEstate { get; set; }
+
+ ///
+ /// Тип заявки (покупка/продажа)
+ ///
+ public required ContractRequestType ContractRequestType { get; set; }
+
+ ///
+ /// Денежная сумма (в рублях)
+ ///
+ [Range(0, 1_000_000_000, ErrorMessage = "Сумма должна быть в диапазоне от 0 до 1 млрд рублей")]
+ public required decimal Amount { get; set; }
+
+ ///
+ /// Дата создания заявки
+ ///
+ [DataType(DataType.Date)]
+ public required DateTime CreatedDate { get; set; }
+
+ ///
+ /// Статус заявки
+ ///
+ [StringLength(50, ErrorMessage = "Статус не должен превышать 50 символов")]
+ public required string Status { get; set; }
+}
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.Domain/Model/ContractRequestType.cs b/RealEstateAgency/Agency.Domain/Model/ContractRequestType.cs
new file mode 100644
index 000000000..6065e8b43
--- /dev/null
+++ b/RealEstateAgency/Agency.Domain/Model/ContractRequestType.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Domain.Model;
+
+///
+/// Тип заявки
+///
+public enum ContractRequestType
+{
+ [Display(Name = "Покупка")]
+ Purchase,
+
+ [Display(Name = "Продажа")]
+ Sale
+}
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.Domain/Model/Counterparty.cs b/RealEstateAgency/Agency.Domain/Model/Counterparty.cs
new file mode 100644
index 000000000..17ef17cca
--- /dev/null
+++ b/RealEstateAgency/Agency.Domain/Model/Counterparty.cs
@@ -0,0 +1,39 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Domain.Model;
+
+///
+/// Контрагент (клиент агентства)
+///
+public class Counterparty
+{
+ ///
+ /// Уникальный идентификатор контрагента
+ ///
+ [Key]
+ public required int Id { get; set; }
+
+ ///
+ /// ФИО контрагента
+ ///
+ [StringLength(150, ErrorMessage = "ФИО не должно превышать 150 символов")]
+ public required string FullName { get; set; }
+
+ ///
+ /// Номер паспорта
+ ///
+ [StringLength(20, ErrorMessage = "Номер паспорта не должен превышать 20 символов")]
+ public required string PassportNumber { get; set; }
+
+ ///
+ /// Контактный телефон
+ ///
+ [StringLength(20, ErrorMessage = "Номер телефона не должен превышать 20 символов")]
+ [Phone(ErrorMessage = "Неверный формат номера телефона")]
+ public required string PhoneNumber { get; set; }
+
+ ///
+ /// Список заявок/контрактов, связанных с данным контрагентом
+ ///
+ public List? Requests { get; set; } = [];
+}
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.Domain/Model/RealEstate.cs b/RealEstateAgency/Agency.Domain/Model/RealEstate.cs
new file mode 100644
index 000000000..5bcf90b7a
--- /dev/null
+++ b/RealEstateAgency/Agency.Domain/Model/RealEstate.cs
@@ -0,0 +1,83 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Domain.Model;
+
+///
+/// Объект недвижимости
+///
+public class RealEstate
+{
+ ///
+ /// Уникальный идентификатор объекта недвижимости
+ ///
+ [Key]
+ public required int Id { get; set; }
+
+ ///
+ /// Тип объекта недвижимости
+ ///
+ public required RealEstateType Type { get; set; }
+
+ ///
+ /// Назначение объекта недвижимости
+ ///
+ public required RealEstatePurpose Purpose { get; set; }
+
+ ///
+ /// Кадастровый номер
+ ///
+ [StringLength(50, ErrorMessage = "Кадастровый номер не должен превышать 50 символов")]
+ public required string CadastralNumber { get; set; }
+
+ ///
+ /// Адрес объекта
+ ///
+ [StringLength(200, ErrorMessage = "Адрес не должен превышать 200 символов")]
+ public required string Address { get; set; }
+
+ ///
+ /// Этажность здания
+ ///
+ [Range(1, 200, ErrorMessage = "Этажность должна быть в диапазоне от 1 до 200")]
+ public int? TotalFloors { get; set; }
+
+ ///
+ /// Общая площадь (в кв. метрах)
+ ///
+ [Range(1, 10000, ErrorMessage = "Общая площадь должна быть в диапазоне от 1 до 10 000 кв. м")]
+ public required double TotalArea { get; set; }
+
+ ///
+ /// Количество комнат
+ ///
+ [Range(1, 50, ErrorMessage = "Количество комнат должно быть в диапазоне от 1 до 50")]
+ public int? NumberOfRooms { get; set; }
+
+ ///
+ /// Высота потолков (в метрах)
+ ///
+ [Range(1.5, 10, ErrorMessage = "Высота потолков должна быть в диапазоне от 1.5 до 10 метров")]
+ public double? CeilingHeight { get; set; }
+
+ ///
+ /// Этаж расположения
+ ///
+ [Range(1, 200, ErrorMessage = "Этаж расположения должен быть в диапазоне от 1 до 200")]
+ public int? Floor { get; set; }
+
+ ///
+ /// Наличие обременений
+ ///
+ public required bool HasEncumbrances { get; set; }
+
+ ///
+ /// Описание обременений
+ ///
+ [StringLength(500, ErrorMessage = "Описание обременений не должно превышать 500 символов")]
+ public string? EncumbrancesDescription { get; set; }
+
+ ///
+ /// Список заявок/контрактов, связанных с данным объектом недвижимости
+ ///
+ public List? Requests { get; set; } = [];
+}
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.Domain/Model/RealEstatePurpose.cs b/RealEstateAgency/Agency.Domain/Model/RealEstatePurpose.cs
new file mode 100644
index 000000000..1f2884bcc
--- /dev/null
+++ b/RealEstateAgency/Agency.Domain/Model/RealEstatePurpose.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Domain.Model;
+
+///
+/// Назначение объекта недвижимости
+///
+public enum RealEstatePurpose
+{
+ [Display(Name = "Жилое")]
+ Residential,
+
+ [Display(Name = "Коммерческое")]
+ Commercial,
+
+ [Display(Name = "Промышленное")]
+ Industrial,
+
+ [Display(Name = "Сельскохозяйственное")]
+ Agricultural
+}
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.Domain/Model/RealEstateType.cs b/RealEstateAgency/Agency.Domain/Model/RealEstateType.cs
new file mode 100644
index 000000000..4e6912aea
--- /dev/null
+++ b/RealEstateAgency/Agency.Domain/Model/RealEstateType.cs
@@ -0,0 +1,24 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Agency.Domain.Model;
+
+///
+/// Тип объекта недвижимости
+///
+public enum RealEstateType
+{
+ [Display(Name = "Квартира")]
+ Apartment,
+
+ [Display(Name = "Дом")]
+ House,
+
+ [Display(Name = "Земельный участок")]
+ LandPlot,
+
+ [Display(Name = "Коммерческая недвижимость")]
+ Commercial,
+
+ [Display(Name = "Гараж")]
+ Garage
+}
\ No newline at end of file
diff --git a/RealEstateAgency/Agency.Generator.Grpc.Host/Agency.Generator.Grpc.Host.csproj b/RealEstateAgency/Agency.Generator.Grpc.Host/Agency.Generator.Grpc.Host.csproj
new file mode 100644
index 000000000..c917f5c3b
--- /dev/null
+++ b/RealEstateAgency/Agency.Generator.Grpc.Host/Agency.Generator.Grpc.Host.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Protos\ContractRequest.proto
+
+
+
+
diff --git a/RealEstateAgency/Agency.Generator.Grpc.Host/Generator/ContractRequestGenerator.cs b/RealEstateAgency/Agency.Generator.Grpc.Host/Generator/ContractRequestGenerator.cs
new file mode 100644
index 000000000..af98a9c21
--- /dev/null
+++ b/RealEstateAgency/Agency.Generator.Grpc.Host/Generator/ContractRequestGenerator.cs
@@ -0,0 +1,44 @@
+using Agency.Application.Contracts.ContractRequests;
+using Agency.Domain.Model;
+using Bogus;
+
+namespace Agency.Generator.Grpc.Host.Generator;
+
+///
+/// Генератор тестовых ContractRequestCreateUpdateDto на основе Bogus
+///
+public static class ContractRequestGenerator
+{
+ ///
+ /// Генерация списка DTO для создания или обновления заявок
+ ///
+ public static IList Generate(int count)
+ {
+ var faker = new Faker("ru");
+
+ var list = new List(count);
+
+ var statuses = new[] { "Новая", "В обработке", "Одобрена", "Отклонена", "Завершена" };
+
+ for (var i = 0; i < count; i++)
+ {
+ var counterpartyId = faker.Random.Int(1, 10);
+ var realEstateId = faker.Random.Int(101, 110);
+ var contractRequestType = faker.PickRandom();
+ var amount = Math.Round((decimal)faker.Random.Double(100_000, 100_000_000), 2);
+ var createdDate = faker.Date.Between(DateTime.Now.AddYears(-2), DateTime.Now);
+ var status = faker.PickRandom(statuses);
+
+ list.Add(new ContractRequestCreateUpdateDto(
+ CounterpartyId: counterpartyId,
+ RealEstateId: realEstateId,
+ ContractRequestType: contractRequestType,
+ Amount: amount,
+ CreatedDate: createdDate,
+ Status: status
+ ));
+ }
+
+ return list;
+ }
+}
diff --git a/RealEstateAgency/Agency.Generator.Grpc.Host/Grpc/AgencyGeneratorGrpcProfile.cs b/RealEstateAgency/Agency.Generator.Grpc.Host/Grpc/AgencyGeneratorGrpcProfile.cs
new file mode 100644
index 000000000..16927dede
--- /dev/null
+++ b/RealEstateAgency/Agency.Generator.Grpc.Host/Grpc/AgencyGeneratorGrpcProfile.cs
@@ -0,0 +1,22 @@
+using Agency.Application.Contracts.Protos;
+using Agency.Application.Contracts.ContractRequests;
+using AutoMapper;
+
+namespace Agency.Generator.Grpc.Host.Grpc;
+
+///
+/// Профиль AutoMapper для преобразования контрактных DTO в protobuf сообщения gRPC
+///
+public sealed class AgencyGeneratorGrpcProfile : Profile
+{
+ ///
+ /// Настройка правил маппинга между DTO приложения и сообщениями gRPC
+ ///
+ public AgencyGeneratorGrpcProfile()
+ {
+ CreateMap()
+ .ForMember(d => d.ContractRequestType, o => o.MapFrom(s => (int)s.ContractRequestType))
+ .ForMember(d => d.Amount, o => o.MapFrom(s => (double)s.Amount))
+ .ForMember(d => d.CreatedDate, o => o.MapFrom(s => s.CreatedDate.ToString("o")));
+ }
+}
diff --git a/RealEstateAgency/Agency.Generator.Grpc.Host/Grpc/AgencyGrpcGeneratorService.cs b/RealEstateAgency/Agency.Generator.Grpc.Host/Grpc/AgencyGrpcGeneratorService.cs
new file mode 100644
index 000000000..8345864c8
--- /dev/null
+++ b/RealEstateAgency/Agency.Generator.Grpc.Host/Grpc/AgencyGrpcGeneratorService.cs
@@ -0,0 +1,104 @@
+using AutoMapper;
+using Agency.Application.Contracts.Protos;
+using Grpc.Core;
+using Agency.Generator.Grpc.Host.Generator;
+
+namespace Agency.Generator.Grpc.Host.Grpc;
+
+///
+/// Имплементация gRPC серверной части службы для генерации и отправки батчей заявок
+///
+/// Конфигурация
+/// Профиль маппинга
+/// Логгер
+public sealed class AgencyGrpcGeneratorService(
+ IConfiguration configuration,
+ IMapper mapper,
+ ILogger logger
+) : ContractRequestGeneratorGrpcService.ContractRequestGeneratorGrpcServiceBase
+{
+ private readonly string _defaultBatchSize =
+ configuration.GetSection("Generator")["BatchSize"] ?? throw new KeyNotFoundException("BatchSize section of Generator is missing");
+
+ private readonly string _waitTime =
+ configuration.GetSection("Generator")["WaitTime"] ?? throw new KeyNotFoundException("WaitTime section of Generator is missing");
+
+ ///
+ /// Служба bidirectional стриминга которая принимает запросы генерации и отправляет батчи контрактов клиенту
+ ///
+ /// Клиентский стрим запросов
+ /// Серверный стрим ответов
+ /// Контекст вызова
+ /// Если параметры конфигурации не парсятся
+ public override async Task ContractRequestStream(
+ IAsyncStreamReader requestStream,
+ IServerStreamWriter responseStream,
+ ServerCallContext context)
+ {
+ if (!int.TryParse(_defaultBatchSize, out var defaultBatchSize))
+ throw new FormatException("Unable to parse Generator:BatchSize");
+
+ if (!int.TryParse(_waitTime, out var waitTimeSeconds))
+ throw new FormatException("Unable to parse Generator:WaitTime");
+
+ logger.LogInformation("ContractRequest generator stream started peer={peer} defaultBatchSize={batch} waitTimeSec={wait}", context.Peer, defaultBatchSize, waitTimeSeconds);
+
+ await foreach (var req in requestStream.ReadAllAsync(context.CancellationToken))
+ {
+ if (context.CancellationToken.IsCancellationRequested)
+ break;
+
+ var requestId = req.RequestId ?? string.Empty;
+
+ if (req.Count <= 0)
+ {
+ logger.LogWarning("Skipping request with non positive count requestId={requestId} count={count}", requestId, req.Count);
+ continue;
+ }
+
+ var batchSize = req.BatchSize > 0 ? req.BatchSize : defaultBatchSize;
+
+ logger.LogInformation("Processing request requestId={requestId} count={count} batchSize={batchSize}", requestId, req.Count, batchSize);
+
+ var sent = 0;
+
+ while (sent < req.Count && !context.CancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ var take = Math.Min(batchSize, req.Count - sent);
+
+ var dtos = ContractRequestGenerator.Generate(take);
+
+ var payload = new ContractRequestBatchStreamMessage
+ {
+ RequestId = requestId,
+ IsFinal = false
+ };
+
+ payload.ContractRequests.AddRange(mapper.Map>(dtos));
+
+ sent += take;
+ payload.IsFinal = sent >= req.Count;
+
+ await responseStream.WriteAsync(payload);
+
+ logger.LogInformation("Sent batch requestId={requestId} batch={batch} sent={sent} total={total} isFinal={isFinal}", requestId, take, sent, req.Count, payload.IsFinal);
+
+ if (payload.IsFinal)
+ break;
+
+ if (waitTimeSeconds > 0)
+ await Task.Delay(waitTimeSeconds * 1000, context.CancellationToken);
+ }
+ catch (TaskCanceledException ex)
+ {
+ logger.LogWarning(ex, "Cancellation requested from client peer={peer} requestId={requestId}", context.Peer, requestId);
+ return;
+ }
+ }
+ }
+
+ logger.LogInformation("ContractRequest generator stream finished peer={peer}", context.Peer);
+ }
+}
diff --git a/RealEstateAgency/Agency.Generator.Grpc.Host/Program.cs b/RealEstateAgency/Agency.Generator.Grpc.Host/Program.cs
new file mode 100644
index 000000000..b43c66abc
--- /dev/null
+++ b/RealEstateAgency/Agency.Generator.Grpc.Host/Program.cs
@@ -0,0 +1,24 @@
+using Agency.Generator.Grpc.Host.Grpc;
+using Agency.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddAutoMapper(config =>
+{
+ config.AddProfile(new AgencyGeneratorGrpcProfile());
+});
+
+builder.Services.AddGrpc(options =>
+{
+ options.EnableDetailedErrors = builder.Environment.IsDevelopment();
+});
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+app.MapGrpcService();
+
+app.Run();
diff --git a/RealEstateAgency/Agency.Generator.Grpc.Host/Properties/launchSettings.json b/RealEstateAgency/Agency.Generator.Grpc.Host/Properties/launchSettings.json
new file mode 100644
index 000000000..b1798af6a
--- /dev/null
+++ b/RealEstateAgency/Agency.Generator.Grpc.Host/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7201;http://localhost:5201",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/RealEstateAgency/Agency.Generator.Grpc.Host/appsettings.Development.json b/RealEstateAgency/Agency.Generator.Grpc.Host/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/RealEstateAgency/Agency.Generator.Grpc.Host/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/RealEstateAgency/Agency.Generator.Grpc.Host/appsettings.json b/RealEstateAgency/Agency.Generator.Grpc.Host/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/RealEstateAgency/Agency.Generator.Grpc.Host/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/RealEstateAgency/Agency.Infrastructure.EfCore/Agency.Infrastructure.EfCore.csproj b/RealEstateAgency/Agency.Infrastructure.EfCore/Agency.Infrastructure.EfCore.csproj
new file mode 100644
index 000000000..4687c9c3b
--- /dev/null
+++ b/RealEstateAgency/Agency.Infrastructure.EfCore/Agency.Infrastructure.EfCore.csproj
@@ -0,0 +1,14 @@
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency/Agency.Infrastructure.EfCore/AgencyDbContext.cs b/RealEstateAgency/Agency.Infrastructure.EfCore/AgencyDbContext.cs
new file mode 100644
index 000000000..7194180dd
--- /dev/null
+++ b/RealEstateAgency/Agency.Infrastructure.EfCore/AgencyDbContext.cs
@@ -0,0 +1,61 @@
+using Agency.Domain.Model;
+using Microsoft.EntityFrameworkCore;
+using MongoDB.EntityFrameworkCore.Extensions;
+
+namespace Agency.Infrastructure.EfCore;
+
+///
+/// Контекст базы данных риэлторского агентства (MongoDB)
+///
+public class AgencyDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Counterparties => Set();
+ public DbSet RealEstates => Set();
+ public DbSet ContractRequests => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ Database.AutoTransactionBehavior = AutoTransactionBehavior.Never;
+
+ modelBuilder.Entity(builder =>
+ {
+ builder.ToCollection("counterparties");
+ builder.HasKey(c => c.Id);
+ builder.Property(c => c.Id).HasElementName("_id");
+ builder.Property(c => c.FullName).HasElementName("full_name");
+ builder.Property(c => c.PassportNumber).HasElementName("passport_number");
+ builder.Property(c => c.PhoneNumber).HasElementName("phone_number");
+ });
+
+ modelBuilder.Entity(builder =>
+ {
+ builder.ToCollection("real_estates");
+ builder.HasKey(r => r.Id);
+ builder.Property(r => r.Id).HasElementName("_id");
+ builder.Property(r => r.Type).HasElementName("type");
+ builder.Property(r => r.Purpose).HasElementName("purpose");
+ builder.Property(r => r.CadastralNumber).HasElementName("cadastral_number");
+ builder.Property(r => r.Address).HasElementName("address");
+ builder.Property(r => r.TotalFloors).HasElementName("total_floors");
+ builder.Property(r => r.TotalArea).HasElementName("total_area");
+ builder.Property(r => r.NumberOfRooms).HasElementName("number_of_rooms");
+ builder.Property(r => r.CeilingHeight).HasElementName("ceiling_height");
+ builder.Property(r => r.Floor).HasElementName("floor");
+ builder.Property(r => r.HasEncumbrances).HasElementName("has_encumbrances");
+ builder.Property(r => r.EncumbrancesDescription).HasElementName("encumbrances_description");
+ });
+
+ modelBuilder.Entity(builder =>
+ {
+ builder.ToCollection("contract_requests");
+ builder.HasKey(r => r.Id);
+ builder.Property(r => r.Id).HasElementName("_id");
+ builder.Property(r => r.CounterpartyId).HasElementName("counterparty_id");
+ builder.Property(r => r.RealEstateId).HasElementName("real_estate_id");
+ builder.Property(r => r.ContractRequestType).HasElementName("contract_request_type");
+ builder.Property(r => r.Amount).HasElementName("amount");
+ builder.Property(r => r.CreatedDate).HasElementName("created_date");
+ builder.Property(r => r.Status).HasElementName("status");
+ });
+ }
+}
diff --git a/RealEstateAgency/Agency.Infrastructure.EfCore/Repositories/ContractRequestRepository.cs b/RealEstateAgency/Agency.Infrastructure.EfCore/Repositories/ContractRequestRepository.cs
new file mode 100644
index 000000000..7fc32a8ed
--- /dev/null
+++ b/RealEstateAgency/Agency.Infrastructure.EfCore/Repositories/ContractRequestRepository.cs
@@ -0,0 +1,65 @@
+using Agency.Domain;
+using Agency.Domain.Model;
+using Microsoft.EntityFrameworkCore;
+
+namespace Agency.Infrastructure.EfCore.Repositories;
+
+///
+/// Репозиторий заявок (MongoDB — навигационные свойства заполняются вручную)
+///
+public class ContractRequestRepository(AgencyDbContext context) : IRepository
+{
+ public async Task Create(ContractRequest entity)
+ {
+ var entry = await context.ContractRequests.AddAsync(entity);
+ await context.SaveChangesAsync();
+ return entry.Entity;
+ }
+
+ public async Task Delete(int entityId)
+ {
+ var entity = await context.ContractRequests.FirstOrDefaultAsync(e => e.Id == entityId);
+ if (entity == null) return false;
+ context.ContractRequests.Remove(entity);
+ await context.SaveChangesAsync();
+ return true;
+ }
+
+ public async Task Read(int entityId)
+ {
+ var entity = await context.ContractRequests.FirstOrDefaultAsync(e => e.Id == entityId);
+ if (entity == null) return null;
+ await FillNavigationProperties(new[] { entity });
+ return entity;
+ }
+
+ public async Task> ReadAll()
+ {
+ var list = await context.ContractRequests.ToListAsync();
+ await FillNavigationProperties(list);
+ return list;
+ }
+
+ public async Task Update(ContractRequest entity)
+ {
+ context.ContractRequests.Update(entity);
+ await context.SaveChangesAsync();
+ return entity;
+ }
+
+ private async Task FillNavigationProperties(IList requests)
+ {
+ if (requests.Count == 0) return;
+ var counterpartyIds = requests.Select(r => r.CounterpartyId).Distinct().ToList();
+ var realEstateIds = requests.Select(r => r.RealEstateId).Distinct().ToList();
+ var counterparties = await context.Counterparties.Where(c => counterpartyIds.Contains(c.Id)).ToListAsync();
+ var realEstates = await context.RealEstates.Where(r => realEstateIds.Contains(r.Id)).ToListAsync();
+ var cpMap = counterparties.ToDictionary(c => c.Id);
+ var reMap = realEstates.ToDictionary(r => r.Id);
+ foreach (var r in requests)
+ {
+ if (cpMap.TryGetValue(r.CounterpartyId, out var cp)) r.Counterparty = cp;
+ if (reMap.TryGetValue(r.RealEstateId, out var re)) r.RealEstate = re;
+ }
+ }
+}
diff --git a/RealEstateAgency/Agency.Infrastructure.EfCore/Repositories/CounterpartyRepository.cs b/RealEstateAgency/Agency.Infrastructure.EfCore/Repositories/CounterpartyRepository.cs
new file mode 100644
index 000000000..4be239e97
--- /dev/null
+++ b/RealEstateAgency/Agency.Infrastructure.EfCore/Repositories/CounterpartyRepository.cs
@@ -0,0 +1,40 @@
+using Agency.Domain;
+using Agency.Domain.Model;
+using Microsoft.EntityFrameworkCore;
+
+namespace Agency.Infrastructure.EfCore.Repositories;
+
+///
+/// Репозиторий контрагентов
+///
+public class CounterpartyRepository(AgencyDbContext context) : IRepository
+{
+ public async Task Create(Counterparty entity)
+ {
+ var entry = await context.Counterparties.AddAsync(entity);
+ await context.SaveChangesAsync();
+ return entry.Entity;
+ }
+
+ public async Task Delete(int entityId)
+ {
+ var entity = await context.Counterparties.FirstOrDefaultAsync(e => e.Id == entityId);
+ if (entity == null) return false;
+ context.Counterparties.Remove(entity);
+ await context.SaveChangesAsync();
+ return true;
+ }
+
+ public async Task Read(int entityId) =>
+ await context.Counterparties.FirstOrDefaultAsync(e => e.Id == entityId);
+
+ public async Task> ReadAll() =>
+ await context.Counterparties.ToListAsync();
+
+ public async Task Update(Counterparty entity)
+ {
+ context.Counterparties.Update(entity);
+ await context.SaveChangesAsync();
+ return entity;
+ }
+}
diff --git a/RealEstateAgency/Agency.Infrastructure.EfCore/Repositories/RealEstateRepository.cs b/RealEstateAgency/Agency.Infrastructure.EfCore/Repositories/RealEstateRepository.cs
new file mode 100644
index 000000000..bbf75f11f
--- /dev/null
+++ b/RealEstateAgency/Agency.Infrastructure.EfCore/Repositories/RealEstateRepository.cs
@@ -0,0 +1,40 @@
+using Agency.Domain;
+using Agency.Domain.Model;
+using Microsoft.EntityFrameworkCore;
+
+namespace Agency.Infrastructure.EfCore.Repositories;
+
+///
+/// Репозиторий объектов недвижимости
+///
+public class RealEstateRepository(AgencyDbContext context) : IRepository
+{
+ public async Task Create(RealEstate entity)
+ {
+ var entry = await context.RealEstates.AddAsync(entity);
+ await context.SaveChangesAsync();
+ return entry.Entity;
+ }
+
+ public async Task Delete(int entityId)
+ {
+ var entity = await context.RealEstates.FirstOrDefaultAsync(e => e.Id == entityId);
+ if (entity == null) return false;
+ context.RealEstates.Remove(entity);
+ await context.SaveChangesAsync();
+ return true;
+ }
+
+ public async Task Read(int entityId) =>
+ await context.RealEstates.FirstOrDefaultAsync(e => e.Id == entityId);
+
+ public async Task> ReadAll() =>
+ await context.RealEstates.ToListAsync();
+
+ public async Task Update(RealEstate entity)
+ {
+ context.RealEstates.Update(entity);
+ await context.SaveChangesAsync();
+ return entity;
+ }
+}
diff --git a/RealEstateAgency/Agency.ServiceDefaults/Agency.ServiceDefaults.csproj b/RealEstateAgency/Agency.ServiceDefaults/Agency.ServiceDefaults.csproj
new file mode 100644
index 000000000..400c8c46c
--- /dev/null
+++ b/RealEstateAgency/Agency.ServiceDefaults/Agency.ServiceDefaults.csproj
@@ -0,0 +1,18 @@
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency/Agency.ServiceDefaults/Extensions.cs b/RealEstateAgency/Agency.ServiceDefaults/Extensions.cs
new file mode 100644
index 000000000..3b51efaf6
--- /dev/null
+++ b/RealEstateAgency/Agency.ServiceDefaults/Extensions.cs
@@ -0,0 +1,90 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Exporter.OpenTelemetryProtocol;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Agency.ServiceDefaults;
+
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+ builder.AddDefaultHealthChecks();
+ builder.Services.AddServiceDiscovery();
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ http.AddStandardResilienceHandler();
+ http.AddServiceDiscovery();
+ });
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath))
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapHealthChecks(HealthEndpointPath);
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+ return app;
+ }
+}
diff --git a/RealEstateAgency/Agency.Tests/Agency.Tests.csproj b/RealEstateAgency/Agency.Tests/Agency.Tests.csproj
new file mode 100644
index 000000000..acca8178f
--- /dev/null
+++ b/RealEstateAgency/Agency.Tests/Agency.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency/Agency.Tests/DomainTests.cs b/RealEstateAgency/Agency.Tests/DomainTests.cs
new file mode 100644
index 000000000..0c6d0174c
--- /dev/null
+++ b/RealEstateAgency/Agency.Tests/DomainTests.cs
@@ -0,0 +1,259 @@
+using Agency.Domain.Data;
+using Agency.Domain.Model;
+
+namespace Agency.Tests;
+
+///
+/// Тесты для доменной области риэлторского агентства
+///
+public class RealEstateTests(DataSeeder data) : IClassFixture
+{
+ ///
+ /// Проверяет, что корректно выводятся все продавцы, оставившие заявки на продажу за заданный период
+ ///
+ [Fact]
+ public void SellersWithRequestsInPeriod()
+ {
+ // Arrange
+ var from = new DateTime(2025, 1, 1);
+ var to = new DateTime(2025, 3, 31);
+
+ // Актуальные продавцы с заявками на продажу в 1 квартале 2025:
+ // - Иванов (Id: 1) - заявка 201 (продажа, 15.01.2025)
+ // - Петрова (Id: 2) - заявка 202 (продажа, 20.01.2025)
+ // - Козлова (Id: 4) - заявка 205 (покупка, не продажа) - НЕ ДОЛЖНА ПОПАСТЬ
+ // - Смирнов (Id: 5) - заявка 206 (покупка, не продажа) - НЕ ДОЛЖЕН ПОПАСТЬ
+ // - Михайлова (Id: 6) - заявка 207 (продажа, 02.04.2025) - после периода
+ // - Федоров (Id: 7) - заявка 209 (продажа, 05.05.2025) - после периода
+
+ var expectedSellerIds = new List { 1, 2 }; // Иванов и Петрова
+
+ // Act
+ var sellers = data.ContractRequests
+ .Where(r => r.ContractRequestType == ContractRequestType.Sale
+ && r.CreatedDate >= from
+ && r.CreatedDate <= to)
+ .Select(r => r.Counterparty)
+ .Distinct()
+ .OrderBy(c => c.FullName)
+ .ToList();
+
+ // Assert
+ Assert.Equal(expectedSellerIds.Count, sellers.Count);
+ Assert.All(expectedSellerIds, id => Assert.Contains(sellers, s => s.Id == id));
+ }
+
+ ///
+ /// Проверяет, что корректно возвращаются топ 5 клиентов по количеству заявок на покупку
+ ///
+ [Fact]
+ public void Top5BuyersByRequestCount()
+ {
+ // Подсчет заявок на покупку по клиентам:
+ // Иванов (Id: 1) - 1 покупка (заявка 204)
+ // Петрова (Id: 2) - 2 покупки (заявки 212, и еще одна?)
+ // Сидоров (Id: 3) - 2 покупки (заявки 203, 208)
+ // Козлова (Id: 4) - 1 покупка (заявка 205)
+ // Смирнов (Id: 5) - 1 покупка (заявка 206)
+ // Васильева (Id: 8) - 1 покупка (заявка 210)
+ // Николаев (Id: 9) - 1 покупка (заявка 211)
+ // Александрова (Id: 10) - 1 покупка (заявка 213)
+
+ var buyersByCount = data.ContractRequests
+ .Where(r => r.ContractRequestType == ContractRequestType.Purchase)
+ .GroupBy(r => r.Counterparty)
+ .Select(g => new { Counterparty = g.Key, Count = g.Count() })
+ .OrderByDescending(x => x.Count)
+ .ToList();
+
+ // Топ-5 Id клиентов по количеству покупок
+ var expectedBuyerIds = buyersByCount
+ .Take(5)
+ .Select(x => x.Counterparty.Id)
+ .ToList();
+
+ // Act
+ var topBuyers = buyersByCount
+ .Take(5)
+ .Select(x => x.Counterparty)
+ .ToList();
+
+ // Assert
+ Assert.Equal(5, topBuyers.Count);
+ Assert.Equal(expectedBuyerIds, topBuyers.Select(b => b.Id).ToList());
+ }
+
+ ///
+ /// Проверяет, что корректно возвращаются топ 5 клиентов по количеству заявок на продажу
+ ///
+ [Fact]
+ public void Top5SellersByRequestCount()
+ {
+ // Подсчет заявок на продажу по клиентам:
+ // Иванов (Id: 1) - 1 продажа (заявка 201)
+ // Петрова (Id: 2) - 1 продажа (заявка 202)
+ // Михайлова (Id: 6) - 1 продажа (заявка 207)
+ // Федоров (Id: 7) - 1 продажа (заявка 209)
+
+ var sellersByCount = data.ContractRequests
+ .Where(r => r.ContractRequestType == ContractRequestType.Sale)
+ .GroupBy(r => r.Counterparty)
+ .Select(g => new { Counterparty = g.Key, Count = g.Count() })
+ .OrderByDescending(x => x.Count)
+ .ToList();
+
+ // Топ Id клиентов по количеству продаж
+ var expectedSellerIds = sellersByCount
+ .Select(x => x.Counterparty.Id)
+ .ToList();
+
+ // Act
+ var topSellers = sellersByCount
+ .Take(5)
+ .Select(x => x.Counterparty)
+ .ToList();
+
+ // Assert
+ Assert.True(topSellers.Count <= 5);
+ Assert.Equal(expectedSellerIds, topSellers.Select(s => s.Id).ToList());
+ }
+
+ ///
+ /// Проверяет, что корректно выводится информация о количестве заявок по каждому типу недвижимости
+ ///
+ [Fact]
+ public void RequestCountByPropertyType()
+ {
+ // Подсчет заявок по типам недвижимости:
+ var expectedCounts = new Dictionary
+ {
+ { RealEstateType.Apartment, 6 }, // ID 101,102,107,108 - заявки: 201,202,207,208,211,213 (6 заявок)
+ { RealEstateType.House, 3 }, // ID 103,110 - заявки: 203,210,212 (3 заявки)
+ { RealEstateType.LandPlot, 1 }, // ID 104 - заявка: 204 (1 заявка)
+ { RealEstateType.Commercial, 2 }, // ID 105,109 - заявки: 205,209 (2 заявки)
+ { RealEstateType.Garage, 1 } // ID 106 - заявка: 206 (1 заявка)
+ };
+
+ // Act
+ var requestsByType = data.ContractRequests
+ .GroupBy(r => r.RealEstate.Type)
+ .Select(g => new { PropertyType = g.Key, Count = g.Count() })
+ .ToDictionary(x => x.PropertyType, x => x.Count);
+
+ // Assert
+ Assert.Equal(expectedCounts.Count, requestsByType.Count);
+
+ foreach (var type in expectedCounts.Keys)
+ {
+ Assert.True(requestsByType.ContainsKey(type), $"Отсутствует тип {type}");
+ Assert.Equal(expectedCounts[type], requestsByType[type]);
+ }
+ }
+
+ ///
+ /// Проверяет, что корректно выводится информация о клиентах, открывших заявки с минимальной стоимостью
+ ///
+ [Fact]
+ public void ClientsWithMinimalRequestAmount()
+ {
+ // Минимальная сумма заявки - 1 200 000 (гараж, заявка 206, клиент Смирнов Id: 5)
+ var minAmount = data.ContractRequests.Min(r => r.Amount);
+ var expectedAmount = 1200000m;
+ var expectedClientIds = new List { 5 }; // Смирнов
+
+ // Act
+ var clients = data.ContractRequests
+ .Where(r => r.Amount == minAmount)
+ .Select(r => r.Counterparty)
+ .Distinct()
+ .OrderBy(c => c.FullName)
+ .ToList();
+
+ // Assert
+ Assert.Equal(expectedAmount, minAmount);
+ Assert.Equal(expectedClientIds.Count, clients.Count);
+ Assert.All(expectedClientIds, id => Assert.Contains(clients, c => c.Id == id));
+ }
+
+ ///
+ /// Проверяет, что корректно выводятся сведения о всех клиентах, ищущих недвижимость заданного типа, упорядоченные по ФИО
+ ///
+ [Fact]
+ public void ClientsSearchingForSpecificPropertyType()
+ {
+ // Arrange
+ var propertyType = RealEstateType.Apartment;
+
+ // Клиенты, ищущие квартиры (покупка):
+ // - Иванов (Id: 1) - заявка 204 (участок, не квартира) - НЕ ДОЛЖЕН ПОПАСТЬ
+ // - Сидоров (Id: 3) - заявка 208 (квартира)
+ // - Петрова (Id: 2) - заявка 212 (дом, не квартира) - НЕ ДОЛЖНА ПОПАСТЬ
+ // - Николаев (Id: 9) - заявка 211 (квартира)
+ // - Александрова (Id: 10) - заявка 213 (квартира)
+ // - Васильева (Id: 8) - заявка 210 (дом, не квартира) - НЕ ДОЛЖНА ПОПАСТЬ
+
+ var expectedClientIds = new List { 3, 9, 10 }; // Сидоров, Николаев, Александрова
+
+ // Act
+ var clients = data.ContractRequests
+ .Where(r => r.ContractRequestType == ContractRequestType.Purchase
+ && r.RealEstate.Type == propertyType)
+ .Select(r => r.Counterparty)
+ .Distinct()
+ .OrderBy(c => c.FullName)
+ .ToList();
+
+ // Assert
+ Assert.Equal(expectedClientIds.Count, clients.Count);
+
+ // Проверяем сортировку по ФИО
+ var sortedNames = clients.Select(c => c.FullName).OrderBy(n => n).ToList();
+ var actualNames = clients.Select(c => c.FullName).ToList();
+ Assert.Equal(sortedNames, actualNames);
+
+ // Проверяем, что все ожидаемые клиенты присутствуют
+ Assert.All(expectedClientIds, id => Assert.Contains(clients, c => c.Id == id));
+ }
+
+ ///
+ /// Проверяет, что корректно возвращаются клиенты, ищущие дома, упорядоченные по ФИО
+ ///
+ [Fact]
+ public void ClientsSearchingForHousesOrderedByFullName()
+ {
+ // Arrange
+ var propertyType = RealEstateType.House;
+
+ // Клиенты, ищущие дома (покупка):
+ // - Петрова (Id: 2) - заявка 212 (дом)
+ // - Сидоров (Id: 3) - заявка 203 (дом)
+ // - Васильева (Id: 8) - заявка 210 (дом)
+
+ var expectedClientIds = new List { 2, 3, 8 }; // Петрова, Сидоров, Васильева
+ var expectedSortedNames = new List
+ {
+ "Васильева Татьяна Павловна",
+ "Петрова Анна Сергеевна",
+ "Сидоров Петр Алексеевич"
+ };
+
+ // Act
+ var clients = data.ContractRequests
+ .Where(r => r.ContractRequestType == ContractRequestType.Purchase
+ && r.RealEstate.Type == propertyType)
+ .Select(r => r.Counterparty)
+ .Distinct()
+ .OrderBy(c => c.FullName)
+ .ToList();
+
+ // Assert
+ Assert.Equal(expectedClientIds.Count, clients.Count);
+
+ // Проверяем сортировку по ФИО
+ var actualNames = clients.Select(c => c.FullName).ToList();
+ Assert.Equal(expectedSortedNames, actualNames);
+
+ // Проверяем, что все ожидаемые клиенты присутствуют
+ Assert.All(expectedClientIds, id => Assert.Contains(clients, c => c.Id == id));
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency/RealEstateAgency.sln b/RealEstateAgency/RealEstateAgency.sln
new file mode 100644
index 000000000..3807eb0da
--- /dev/null
+++ b/RealEstateAgency/RealEstateAgency.sln
@@ -0,0 +1,149 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36616.10
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agency.Domain", "Agency.Domain\Agency.Domain.csproj", "{4D9D2410-8028-4C70-B261-29992356D91C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agency.Tests", "Agency.Tests\Agency.Tests.csproj", "{F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agency.Application.Contracts", "Agency.Application.Contracts\Agency.Application.Contracts.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567801}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agency.Application", "Agency.Application\Agency.Application.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567802}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agency.Infrastructure.EfCore", "Agency.Infrastructure.EfCore\Agency.Infrastructure.EfCore.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567803}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agency.Api.Host", "Agency.Api.Host\Agency.Api.Host.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567804}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agency.ServiceDefaults", "Agency.ServiceDefaults\Agency.ServiceDefaults.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567805}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agency.AppHost", "Agency.AppHost\Agency.AppHost.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567806}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agency.Generator.Grpc.Host", "Agency.Generator.Grpc.Host\Agency.Generator.Grpc.Host.csproj", "{A0B6687C-6065-439C-BFDE-35B7FB3B8938}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Debug|x64.Build.0 = Debug|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Debug|x86.Build.0 = Debug|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Release|x64.ActiveCfg = Release|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Release|x64.Build.0 = Release|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Release|x86.ActiveCfg = Release|Any CPU
+ {4D9D2410-8028-4C70-B261-29992356D91C}.Release|x86.Build.0 = Release|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Debug|x64.Build.0 = Debug|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Debug|x86.Build.0 = Debug|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Release|x64.ActiveCfg = Release|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Release|x64.Build.0 = Release|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Release|x86.ActiveCfg = Release|Any CPU
+ {F461EB8F-D9AE-4CE0-B5C4-60DC139B5CF9}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567801}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567802}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567803}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567804}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567805}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567806}.Release|x86.Build.0 = Release|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Debug|x64.Build.0 = Debug|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Debug|x86.Build.0 = Debug|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Release|x64.ActiveCfg = Release|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Release|x64.Build.0 = Release|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Release|x86.ActiveCfg = Release|Any CPU
+ {A0B6687C-6065-439C-BFDE-35B7FB3B8938}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {B0B699E6-16DB-42D7-83FB-638821B87C80}
+ EndGlobalSection
+EndGlobal