diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml
new file mode 100644
index 000000000..1aeb4f5e5
--- /dev/null
+++ b/.github/workflows/dotnet-tests.yml
@@ -0,0 +1,27 @@
+name: .NET Tests Simple
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Run tests directly
+ run: |
+ cd RealEstateAgency.tests
+ dotnet restore
+ dotnet build
+ dotnet test --verbosity normal
\ No newline at end of file
diff --git a/README.md b/README.md
index 39c9a8443..bd2d3fe8f 100644
--- a/README.md
+++ b/README.md
@@ -1,136 +1,83 @@
# Разработка корпоративных приложений
[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1JD6aiOG6r7GrA79oJncjgUHWtfeW4g_YZ9ayNgxb_w0/edit?usp=sharing)
-## Задание
-### Цель
-Реализация проекта сервисно-ориентированного приложения.
-
-### Задачи
-* Реализация объектно-ориентированной модели данных,
-* Изучение реализации серверных приложений на базе WebAPI/OpenAPI,
-* Изучение работы с брокерами сообщений,
-* Изучение паттернов проектирования,
-* Изучение работы со средствами оркестрации на примере .NET Aspire,
-* Повторение основ работы с системами контроля версий,
-* Unit-тестирование.
-
-### Лабораторные работы
-
-1. «Классы» - Реализация объектной модели данных и unit-тестов
-
-В рамках первой лабораторной работы необходимо подготовить структуру классов, описывающих предметную область, определяемую в задании. В каждом из заданий присутствует часть, связанная с обработкой данных, представленная в разделе «Unit-тесты». Данную часть необходимо реализовать в виде unit-тестов: подготовить тестовые данные, выполнить запрос с использованием LINQ, проверить результаты.
-
-Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций.
-Необходимо включить **как минимум 10** экземпляров каждого класса в датасид.
-
-
-
-2. «Сервер» - Реализация серверного приложения с использованием REST API
-
-Во второй лабораторной работе необходимо реализовать серверное приложение, которое должно:
-- Осуществлять базовые CRUD-операции с реализованными в первой лабораторной сущностями
-- Предоставлять результаты аналитических запросов (раздел «Unit-тесты» задания)
-
-Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций.
-
-
-
-3. «ORM» - Реализация объектно-реляционной модели. Подключение к базе данных и настройка оркестрации
-
-В третьей лабораторной работе хранение должно быть переделано c инмемори коллекций на базу данных.
-Должны быть созданы миграции для создания таблиц в бд и их первоначального заполнения.
-
-Также необходимо настроить оркестратор Aspire на запуск сервера и базы данных.
-
-
-
-4. «Инфраструктура» - Реализация сервиса генерации данных и его интеграция с сервером
-
-В четвертой лабораторной работе необходимо имплементировать сервис, который генерировал бы контракты. Контракты далее передаются в сервер и сохраняются в бд.
-Сервис должен представлять из себя отдельное приложение без референсов к серверным проектам за исключением библиотеки с контрактами.
-Отправка контрактов при помощи gRPC должна выполняться в потоковом виде.
-При использовании брокеров сообщений, необходимо предусмотреть ретраи при подключении к брокеру.
-
-Также необходимо добавить в конфигурацию Aspire запуск генератора и (если того требует вариант) брокера сообщений.
-
-
-
-5. «Клиент» - Интеграция клиентского приложения с оркестратором
-
-В пятой лабораторной необходимо добавить в конфигурацию Aspire запуск клиентского приложения для написанного ранее сервера. Клиент создается в рамках курса "Веб разработка".
-
-
-
-## Задание. Общая часть
-**Обязательно**:
-* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview).
-* Реализация серверной части на [ASP.NET](https://dotnet.microsoft.com/ru-ru/apps/aspnet).
-* Реализация unit-тестов с использованием [xUnit](https://xunit.net/?tabs=cs).
-* Использование хранения данных в базе данных согласно варианту задания.
-* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview)
-* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus) и его взаимодейсвие с сервером согласно варианту задания.
-* Автоматизация тестирования на уровне репозитория через [GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions).
-* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация.
-
-**Факультативно**:
-* Реализация авторизации/аутентификации.
-* Реализация atomic batch publishing/atomic batch consumption для брокеров, поддерживающих такой функционал.
-* Реализация интеграционных тестов при помощи .NET Aspire.
-* Реализация клиента на Blazor WASM.
-
-Внимательно прочитайте [дискуссии](https://github.com/itsecd/enterprise-development/discussions/1) о том, как работает автоматическое распределение на ревью.
-Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю.
-
-По итогу работы в семестре должна получиться следующая информационная система:
-
-C4 диаграмма
-
-
-
-
-
-## Варианты заданий
-Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи.
-
-[Список вариантов](https://docs.google.com/document/d/1Wc8AvsKS_1JptpsxHO-cwfAxz2ghxvQRQ0fy4el2ZOc/edit?usp=sharing)
-[Список предметных областей](https://docs.google.com/document/d/15jWhXMwd2K8giFMKku_yrY_s2uQNEu4ugJXLYPvYJAE/edit?usp=sharing)
-
-## Схема сдачи
-
-На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests).
-
-Общая схема:
-1. Сделать форк данного репозитория
-2. Выполнить задание
-3. Сделать PR в данный репозиторий
-4. Исправить замечания после code review
-5. Получить approve
-6. Прийти на занятие и защитить работу
-
-## Критерии оценивания
-
-Конкурентный принцип.
-Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки:
-1. Скорость разработки
-2. Качество разработки
-3. Полнота выполнения задания
-
-Быстрее делаете PR - у вас преимущество.
-Быстрее получаете Approve - у вас преимущество.
-Выполните нечто немного выходящее за рамки проекта - у вас преимущество.
-
-### Шкала оценивания
-
-- **3 балла** за качество кода, из них:
- - 2 балла - базовая оценка
- - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов:
- - Реализация факультативного функционала
- - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл
-- **3 балла** за защиту: при сдаче лабораторной работы вам задается 3 вопроса, за каждый правильный ответ - 1 балл
-
-У вас 2 попытки пройти ревью (первичное ревью, ревью по результатам исправления). Если замечания по итогу не исправлены, то снимается один балл за код лабораторной работы.
-
-## Вопросы и обратная связь по курсу
-
-Чтобы задать вопрос по лабораторной, воспользуйтесь [соотвествующим разделом дискуссий](https://github.com/itsecd/enterprise-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/enterprise-development/issues/new).
-Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/enterprise-development/discussions/categories/ideas).
+## Задание "Риэлторское агенство"
+
+В агентстве хранится информация об объектах недвижимости, контрагентах и заявках от них.
+
+Объект недвижимости характеризуется типом, назначением, кадастровым номером, адресом, этажностью, общей площадью, числом комнат, высотой потолков, этажом расположения, наличием обременений.
+Тип объекта недвижимости является перечислением.
+Назначение объекта недвижимости является перечислением.
+
+Контрагент характеризуется ФИО, номером паспорта, контактным телефоном.
+
+Заявка содержит информацию о контрагенте, объекте недвижимости, типе заявки- покупка/продажа, денежной сумме.
+Используется в качестве контракта.
+
+### Функциональные возможности
+* PropertyPurpose - Назначение недвижимости
+* PropertyType - Тип объекта
+* RequestType - Тип заявки
+* Counterparty - Клиент агентства
+* RealEstateProperty - Объект недвижимости с полным описанием характеристик
+* Request - Заявка на операцию с недвижимостью
+
+### Тестирование
+* GetSellersInPeriodReturnsCorrectSellers() - Поиск продавцов за указанный период
+* Top5ClientsByRequestCountReturnsSeparateTop5() - Топ-5 клиентов по количеству заявок (покупка/продажа отдельно)
+* RequestCountByPropertyTypeReturnsCorrectStatistics() - Статистика заявок по типам недвижимости
+* ClientsWithMinAmountAreFoundCorrectly() - Клиенты с заявками минимальной стоимости
+* ClientsSeekingPropertyTypeAreReturnedOrdered() - Поиск клиентов по типу недвижимости с сортировкой
+
+### Структура решения
+* RealEstateAgency.AppHost/ - .NET Aspire оркестратор
+* RealEstateAgency.WebApi/ - ASP.NET Core API с NATS Subscriber Service
+* RealEstateAgency.Generator/ - Сервис генерации тестовых данных с отправкой через NATS
+* RealEstateAgency.Application/ - Бизнес-логика и сервисы
+* RealEstateAgency.Contracts/ - DTO и интерфейсы сервисов
+* RealEstateAgency.Domain/ - Сущности и интерфейсы репозиториев
+* RealEstateAgency.Infrastructure/ - Репозитории и контекст БД (MongoDB)
+* RealEstateAgency.ServiceDefaults/ - Общие настройки Aspire
+* RealEstateAgency.Generator.Tests/ - Тесты генератора данных
+* RealEstateAgency.WebApi.Tests/ - Интеграционные тесты
+
+### Функциональные возможности
+
+#### CRUD операции
+- Полное управление контрагентами, объектами недвижимости и заявками
+- Валидация связанных сущностей при создании/обновлении заявок
+- Асинхронное получение данных через NATS
+
+#### Аналитические запросы
+* Продавцы за указанный период
+* Топ-5 клиентов по количеству заявок (покупка и продажа отдельно)
+* Статистика заявок по типам недвижимости
+* Клиенты с минимальной суммой заявок
+* Поиск клиентов по интересующему типу недвижимости
+
+### Особенности реализации
+* Двойные репозитории: InMemory для тестов, MongoDB для продакшена
+* Автоматическое заполнение БД: 12 контрагентов, 13 объектов, 15 заявок при первом запуске
+* Умная конфигурация: Автоматическое переключение между MongoDB и InMemory режимами
+* Асинхронная обработка: NATS Subscriber Service для фонового получения сообщений
+* Docker-оркестрация: Полная контейнеризация через .NET Aspire AppHost
+
+### Распределенные возможности
+* Потоковая передача данных: Generator → NATS → WebAPI → MongoDB
+* Мониторинг: Визуализация работы системы через Aspire Dashboard
+* Масштабируемость: Независимое развертывание компонентов
+
+### Асинхронная коммуникация через NATS
+- Generator публикует сообщения в топики:
+* realestate.counterparty.created - создание контрагента
+* realestate.property.created - создание объекта недвижимости
+- WebAPI подписывается на сообщения и сохраняет данные в MongoDB
+- Пакетная отправка: Generator отправляет данные пачками по 10 записей каждые 5 секунд
+
+### Мониторинг через .NET Aspire
+* Aspire Dashboard: Визуальный мониторинг состояния всех сервисов
+* Логирование операций: Детальное логирование всех операций с MongoDB
+* Health checks: Проверка здоровья всех компонентов системы
+
+
+
diff --git a/RealEstateAgency.AppHost/Program.cs b/RealEstateAgency.AppHost/Program.cs
new file mode 100644
index 000000000..57b61b902
--- /dev/null
+++ b/RealEstateAgency.AppHost/Program.cs
@@ -0,0 +1,28 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+// MongoDB
+var mongodb = builder.AddMongoDB("mongodb")
+ .WithDataVolume("mongodb-data");
+
+var mongoDatabase = mongodb.AddDatabase("realestatedb");
+
+// NATS
+var nats = builder.AddNats("nats")
+ .WithJetStream()
+ .WithDataVolume("nats-data");
+
+// WebApi
+builder.AddProject("webapi")
+ .WithReference(mongoDatabase)
+ .WithReference(nats)
+ .WaitFor(mongoDatabase)
+ .WaitFor(nats)
+ .WithExternalHttpEndpoints();
+
+// Generator
+builder.AddProject("generator")
+ .WithReference(nats)
+ .WaitFor(nats)
+ .WaitFor(mongoDatabase);
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/RealEstateAgency.AppHost/Properties/launchSettings.json b/RealEstateAgency.AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..ac767f5f2
--- /dev/null
+++ b/RealEstateAgency.AppHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17197;http://localhost:15247",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21093",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22056"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15247",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19133",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20054"
+ }
+ }
+ }
+}
diff --git a/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj
new file mode 100644
index 000000000..8d9fb5a3c
--- /dev/null
+++ b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ e80bf9ea-04bf-4190-adb9-7d9a244a2049
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency.AppHost/appsettings.Development.json b/RealEstateAgency.AppHost/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/RealEstateAgency.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/RealEstateAgency.AppHost/appsettings.json b/RealEstateAgency.AppHost/appsettings.json
new file mode 100644
index 000000000..31c092aa4
--- /dev/null
+++ b/RealEstateAgency.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/RealEstateAgency.Application/Mapping/MappingProfile.cs b/RealEstateAgency.Application/Mapping/MappingProfile.cs
new file mode 100644
index 000000000..7c87dc167
--- /dev/null
+++ b/RealEstateAgency.Application/Mapping/MappingProfile.cs
@@ -0,0 +1,30 @@
+using AutoMapper;
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Application.Mapping;
+
+///
+/// Профиль AutoMapper для маппинга сущностей и DTO
+///
+public class MappingProfile : Profile
+{
+ public MappingProfile()
+ {
+ CreateMap();
+ CreateMap()
+ .ForMember(dest => dest.Id, opt => opt.Ignore());
+ CreateMap()
+ .ForMember(dest => dest.Id, opt => opt.Ignore());
+
+ CreateMap();
+ CreateMap()
+ .ForMember(dest => dest.Id, opt => opt.Ignore());
+ CreateMap()
+ .ForMember(dest => dest.Id, opt => opt.Ignore());
+
+ CreateMap()
+ .ForMember(dest => dest.CounterpartyId, opt => opt.MapFrom(src => src.Counterparty.Id))
+ .ForMember(dest => dest.PropertyId, opt => opt.MapFrom(src => src.Property.Id));
+ }
+}
diff --git a/RealEstateAgency.Application/RealEstateAgency.Application.csproj b/RealEstateAgency.Application/RealEstateAgency.Application.csproj
new file mode 100644
index 000000000..54221f41c
--- /dev/null
+++ b/RealEstateAgency.Application/RealEstateAgency.Application.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency.Application/Services/AnalyticsService.cs b/RealEstateAgency.Application/Services/AnalyticsService.cs
new file mode 100644
index 000000000..5c013387d
--- /dev/null
+++ b/RealEstateAgency.Application/Services/AnalyticsService.cs
@@ -0,0 +1,148 @@
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Contracts.Interfaces;
+using RealEstateAgency.Domain.Enums;
+using RealEstateAgency.Domain.Interfaces;
+
+namespace RealEstateAgency.Application.Services;
+
+///
+/// Реализация сервиса аналитики
+///
+public class AnalyticsService(
+ IRequestRepository requestRepository,
+ ICounterpartyRepository counterpartyRepository,
+ IRealEstatePropertyRepository propertyRepository) : IAnalyticsService
+{
+
+ ///
+ /// Получить продавцов за указанный период
+ ///
+ public async Task> GetSellersInPeriodAsync(DateTime startDate, DateTime endDate)
+ {
+ var requests = (await requestRepository.GetAllAsync()).ToList();
+ var counterparties = (await counterpartyRepository.GetAllAsync()).ToDictionary(c => c.Id);
+
+ return
+ [
+ .. requests
+ .Where(r => r.Type == RequestType.Sale &&
+ r.Date >= startDate &&
+ r.Date <= endDate)
+ .Select(r => counterparties.TryGetValue(r.CounterpartyId, out var c) ? c.FullName : r.Counterparty?.FullName)
+ .Where(name => !string.IsNullOrEmpty(name))
+ .Distinct()
+ .Order()
+ ];
+ }
+
+ ///
+ /// Получить топ-5 клиентов по количеству заявок
+ ///
+ public async Task GetTop5ClientsByRequestCountAsync()
+ {
+ var requests = (await requestRepository.GetAllAsync()).ToList();
+ var counterparties = (await counterpartyRepository.GetAllAsync()).ToDictionary(c => c.Id);
+
+ var topPurchaseClients = requests
+ .Where(r => r.Type == RequestType.Purchase)
+ .GroupBy(r => r.CounterpartyId)
+ .Select(g => new TopClientDto
+ {
+ FullName = counterparties.TryGetValue(g.Key, out var c) ? c.FullName : g.First().Counterparty?.FullName ?? "",
+ RequestCount = g.Count()
+ })
+ .Where(x => !string.IsNullOrEmpty(x.FullName))
+ .OrderByDescending(x => x.RequestCount)
+ .ThenBy(x => x.FullName)
+ .Take(5);
+
+ var topSaleClients = requests
+ .Where(r => r.Type == RequestType.Sale)
+ .GroupBy(r => r.CounterpartyId)
+ .Select(g => new TopClientDto
+ {
+ FullName = counterparties.TryGetValue(g.Key, out var c) ? c.FullName : g.First().Counterparty?.FullName ?? "",
+ RequestCount = g.Count()
+ })
+ .Where(x => !string.IsNullOrEmpty(x.FullName))
+ .OrderByDescending(x => x.RequestCount)
+ .ThenBy(x => x.FullName)
+ .Take(5);
+
+ return new Top5ClientsResultDto
+ {
+ TopPurchaseClients = [.. topPurchaseClients],
+ TopSaleClients = [.. topSaleClients]
+ };
+ }
+
+ ///
+ /// Получить статистику заявок по типам недвижимости
+ ///
+ public async Task> GetRequestCountByPropertyTypeAsync()
+ {
+ var requests = (await requestRepository.GetAllAsync()).ToList();
+ var properties = (await propertyRepository.GetAllAsync()).ToDictionary(p => p.Id);
+
+ return
+ [
+ .. requests
+ .Select(r => new
+ {
+ PropertyType = properties.TryGetValue(r.PropertyId, out var p) ? p.Type : r.Property?.Type ?? default
+ })
+ .GroupBy(x => x.PropertyType)
+ .Select(g => new PropertyTypeStatisticsDto
+ {
+ PropertyType = g.Key,
+ RequestCount = g.Count()
+ })
+ .OrderBy(x => x.PropertyType)
+ ];
+ }
+
+ ///
+ /// Получить клиентов с минимальной суммой заявки
+ ///
+ public async Task GetClientsWithMinAmountAsync()
+ {
+ var requests = (await requestRepository.GetAllAsync()).ToList();
+ var counterparties = (await counterpartyRepository.GetAllAsync()).ToDictionary(c => c.Id);
+
+ var minAmount = requests.Min(r => r.Amount);
+
+ var clients = requests
+ .Where(r => r.Amount == minAmount)
+ .Select(r => counterparties.TryGetValue(r.CounterpartyId, out var c) ? c.FullName : r.Counterparty?.FullName)
+ .Where(name => !string.IsNullOrEmpty(name))
+ .Distinct()
+ .Order();
+
+ return new ClientWithMinAmountDto
+ {
+ FullName = string.Join(", ", clients),
+ MinAmount = minAmount
+ };
+ }
+
+ ///
+ /// Получить клиентов, ищущих определённый тип недвижимости
+ ///
+ public async Task> GetClientsSeekingPropertyTypeAsync(PropertyType propertyType)
+ {
+ var requests = (await requestRepository.GetAllAsync()).ToList();
+ var counterparties = (await counterpartyRepository.GetAllAsync()).ToDictionary(c => c.Id);
+ var properties = (await propertyRepository.GetAllAsync()).ToDictionary(p => p.Id);
+
+ return
+ [
+ .. requests
+ .Where(r => r.Type == RequestType.Purchase &&
+ (properties.TryGetValue(r.PropertyId, out var p) ? p.Type : r.Property?.Type ?? default) == propertyType)
+ .Select(r => counterparties.TryGetValue(r.CounterpartyId, out var c) ? c.FullName : r.Counterparty?.FullName)
+ .Where(name => !string.IsNullOrEmpty(name))
+ .Distinct()
+ .Order()
+ ];
+ }
+}
diff --git a/RealEstateAgency.Application/Services/CounterpartyService.cs b/RealEstateAgency.Application/Services/CounterpartyService.cs
new file mode 100644
index 000000000..97e34bc3d
--- /dev/null
+++ b/RealEstateAgency.Application/Services/CounterpartyService.cs
@@ -0,0 +1,57 @@
+using AutoMapper;
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Contracts.Interfaces;
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Application.Services;
+
+///
+/// Сервис контрагентов
+///
+public class CounterpartyService(ICounterpartyRepository repository, IMapper mapper) : ICounterpartyService
+{
+ ///
+ public async Task> GetAllAsync()
+ {
+ var counterparties = await repository.GetAllAsync();
+ return mapper.Map>(counterparties);
+ }
+
+ ///
+ public async Task GetByIdAsync(Guid id)
+ {
+ var counterparty = await repository.GetByIdAsync(id);
+ return counterparty == null ? null : mapper.Map(counterparty);
+ }
+
+ ///
+ public async Task CreateAsync(CreateCounterpartyDto dto)
+ {
+ var counterparty = mapper.Map(dto);
+ var created = await repository.AddAsync(counterparty);
+ return mapper.Map(created);
+ }
+
+ ///
+ public async Task UpdateAsync(Guid id, CreateCounterpartyDto dto)
+ {
+ var counterparty = mapper.Map(dto);
+ var updated = await repository.UpdateAsync(id, counterparty);
+ return updated == null ? null : mapper.Map(updated);
+ }
+
+ ///
+ public async Task UpdateAsync(Guid id, UpdateCounterpartyDto dto)
+ {
+ var counterparty = mapper.Map(dto);
+ var updated = await repository.UpdateAsync(id, counterparty);
+ return updated == null ? null : mapper.Map(updated);
+ }
+
+ ///
+ public async Task DeleteAsync(Guid id)
+ {
+ return await repository.DeleteAsync(id);
+ }
+}
diff --git a/RealEstateAgency.Application/Services/RealEstatePropertyService.cs b/RealEstateAgency.Application/Services/RealEstatePropertyService.cs
new file mode 100644
index 000000000..266fd0efc
--- /dev/null
+++ b/RealEstateAgency.Application/Services/RealEstatePropertyService.cs
@@ -0,0 +1,57 @@
+using AutoMapper;
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Contracts.Interfaces;
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Application.Services;
+
+///
+/// Сервис объектов недвижимости
+///
+public class RealEstatePropertyService(IRealEstatePropertyRepository repository, IMapper mapper) : IRealEstatePropertyService
+{
+ ///
+ public async Task> GetAllAsync()
+ {
+ var properties = await repository.GetAllAsync();
+ return mapper.Map>(properties);
+ }
+
+ ///
+ public async Task GetByIdAsync(Guid id)
+ {
+ var property = await repository.GetByIdAsync(id);
+ return property == null ? null : mapper.Map(property);
+ }
+
+ ///
+ public async Task CreateAsync(CreateRealEstatePropertyDto dto)
+ {
+ var property = mapper.Map(dto);
+ var created = await repository.AddAsync(property);
+ return mapper.Map(created);
+ }
+
+ ///
+ public async Task UpdateAsync(Guid id, CreateRealEstatePropertyDto dto)
+ {
+ var property = mapper.Map(dto);
+ var updated = await repository.UpdateAsync(id, property);
+ return updated == null ? null : mapper.Map(updated);
+ }
+
+ ///
+ public async Task UpdateAsync(Guid id, UpdateRealEstatePropertyDto dto)
+ {
+ var property = mapper.Map(dto);
+ var updated = await repository.UpdateAsync(id, property);
+ return updated == null ? null : mapper.Map(updated);
+ }
+
+ ///
+ public async Task DeleteAsync(Guid id)
+ {
+ return await repository.DeleteAsync(id);
+ }
+}
diff --git a/RealEstateAgency.Application/Services/RequestService.cs b/RealEstateAgency.Application/Services/RequestService.cs
new file mode 100644
index 000000000..aed6775be
--- /dev/null
+++ b/RealEstateAgency.Application/Services/RequestService.cs
@@ -0,0 +1,95 @@
+using AutoMapper;
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Contracts.Interfaces;
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Application.Services;
+
+///
+/// Сервис заявок
+///
+public class RequestService(
+ IRequestRepository requestRepository,
+ ICounterpartyRepository counterpartyRepository,
+ IRealEstatePropertyRepository propertyRepository,
+ IMapper mapper) : IRequestService
+{
+ ///
+ public async Task> GetAllAsync()
+ {
+ var requests = await requestRepository.GetAllAsync();
+ return mapper.Map>(requests);
+ }
+
+ ///
+ public async Task GetByIdAsync(Guid id)
+ {
+ var request = await requestRepository.GetByIdAsync(id);
+ return request == null ? null : mapper.Map(request);
+ }
+
+ ///
+ public async Task<(RequestDto? Result, string? Error)> CreateAsync(CreateRequestDto dto)
+ {
+ var counterparty = await counterpartyRepository.GetByIdAsync(dto.CounterpartyId);
+ if (counterparty == null)
+ return (null, $"Контрагент с ID {dto.CounterpartyId} не найден");
+
+ var property = await propertyRepository.GetByIdAsync(dto.PropertyId);
+ if (property == null)
+ return (null, $"Объект недвижимости с ID {dto.PropertyId} не найден");
+
+ var request = new Request
+ {
+ Id = Guid.Empty,
+ CounterpartyId = counterparty.Id,
+ Counterparty = counterparty,
+ PropertyId = property.Id,
+ Property = property,
+ Type = dto.Type,
+ Amount = dto.Amount,
+ Date = dto.Date
+ };
+
+ var created = await requestRepository.AddAsync(request);
+ return (mapper.Map(created), null);
+ }
+
+ ///
+ public async Task<(RequestDto? Result, string? Error)> UpdateAsync(Guid id, UpdateRequestDto dto)
+ {
+ var existingRequest = await requestRepository.GetByIdAsync(id);
+ if (existingRequest == null)
+ return (null, $"Заявка с ID {id} не найдена");
+
+ var counterparty = await counterpartyRepository.GetByIdAsync(dto.CounterpartyId);
+ if (counterparty == null)
+ return (null, $"Контрагент с ID {dto.CounterpartyId} не найден");
+
+ var property = await propertyRepository.GetByIdAsync(dto.PropertyId);
+ if (property == null)
+ return (null, $"Объект недвижимости с ID {dto.PropertyId} не найден");
+
+ var request = new Request
+ {
+ Id = id,
+ CounterpartyId = counterparty.Id,
+ Counterparty = counterparty,
+ PropertyId = property.Id,
+ Property = property,
+ Type = dto.Type,
+ Amount = dto.Amount,
+ Date = dto.Date
+ };
+
+ var updated = await requestRepository.UpdateAsync(id, request);
+ return (mapper.Map(updated), null);
+ }
+
+ ///
+ public async Task DeleteAsync(Guid id)
+ {
+ return await requestRepository.DeleteAsync(id);
+ }
+}
diff --git a/RealEstateAgency.Contracts/Dto/ClientWithMinAmountDto.cs b/RealEstateAgency.Contracts/Dto/ClientWithMinAmountDto.cs
new file mode 100644
index 000000000..658886b65
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/ClientWithMinAmountDto.cs
@@ -0,0 +1,17 @@
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для клиента с минимальной суммой заявки
+///
+public class ClientWithMinAmountDto
+{
+ ///
+ /// ФИО клиента
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Минимальная сумма
+ ///
+ public decimal MinAmount { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/CounterpartyDto.cs b/RealEstateAgency.Contracts/Dto/CounterpartyDto.cs
new file mode 100644
index 000000000..754f4c73b
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/CounterpartyDto.cs
@@ -0,0 +1,27 @@
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для отображения контрагента
+///
+public class CounterpartyDto
+{
+ ///
+ /// Уникальный идентификатор
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// ФИО контрагента
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Номер паспорта
+ ///
+ public required string PassportNumber { get; set; }
+
+ ///
+ /// Контактный телефон
+ ///
+ public required string PhoneNumber { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/CreateCounterpartyDto.cs b/RealEstateAgency.Contracts/Dto/CreateCounterpartyDto.cs
new file mode 100644
index 000000000..9c8cc9333
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/CreateCounterpartyDto.cs
@@ -0,0 +1,30 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для создания контрагента
+///
+public class CreateCounterpartyDto
+{
+ ///
+ /// ФИО контрагента
+ ///
+ [Required(ErrorMessage = "ФИО обязательно")]
+ [StringLength(200, MinimumLength = 2, ErrorMessage = "ФИО должно содержать от 2 до 200 символов")]
+ public required string FullName { get; set; }
+
+ ///
+ /// Номер паспорта
+ ///
+ [Required(ErrorMessage = "Номер паспорта обязателен")]
+ [StringLength(20, MinimumLength = 6, ErrorMessage = "Номер паспорта должен содержать от 6 до 20 символов")]
+ public required string PassportNumber { get; set; }
+
+ ///
+ /// Контактный телефон
+ ///
+ [Required(ErrorMessage = "Номер телефона обязателен")]
+ [Phone(ErrorMessage = "Некорректный формат номера телефона")]
+ public required string PhoneNumber { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/CreateRealEstatePropertyDto.cs b/RealEstateAgency.Contracts/Dto/CreateRealEstatePropertyDto.cs
new file mode 100644
index 000000000..ee2d67db8
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/CreateRealEstatePropertyDto.cs
@@ -0,0 +1,72 @@
+using System.ComponentModel.DataAnnotations;
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для создания объекта недвижимости
+///
+public class CreateRealEstatePropertyDto
+{
+ ///
+ /// Тип недвижимости
+ ///
+ [Required(ErrorMessage = "Тип недвижимости обязателен")]
+ public PropertyType Type { get; set; }
+
+ ///
+ /// Назначение недвижимости
+ ///
+ [Required(ErrorMessage = "Назначение недвижимости обязательно")]
+ public PropertyPurpose Purpose { get; set; }
+
+ ///
+ /// Кадастровый номер
+ ///
+ [Required(ErrorMessage = "Кадастровый номер обязателен")]
+ [StringLength(50, MinimumLength = 5, ErrorMessage = "Кадастровый номер должен содержать от 5 до 50 символов")]
+ public required string CadastralNumber { get; set; }
+
+ ///
+ /// Адрес
+ ///
+ [Required(ErrorMessage = "Адрес обязателен")]
+ [StringLength(500, MinimumLength = 5, ErrorMessage = "Адрес должен содержать от 5 до 500 символов")]
+ public required string Address { get; set; }
+
+ ///
+ /// Количество этажей в здании
+ ///
+ [Range(1, 200, ErrorMessage = "Количество этажей должно быть от 1 до 200")]
+ public int? TotalFloors { get; set; }
+
+ ///
+ /// Общая площадь (кв.м)
+ ///
+ [Required(ErrorMessage = "Общая площадь обязательна")]
+ [Range(0.1, 1000000, ErrorMessage = "Площадь должна быть от 0.1 до 1000000 кв.м")]
+ public double TotalArea { get; set; }
+
+ ///
+ /// Количество комнат
+ ///
+ [Range(1, 100, ErrorMessage = "Количество комнат должно быть от 1 до 100")]
+ public int? RoomsCount { get; set; }
+
+ ///
+ /// Высота потолков (м)
+ ///
+ [Range(1.5, 20, ErrorMessage = "Высота потолков должна быть от 1.5 до 20 м")]
+ public double? CeilingHeight { get; set; }
+
+ ///
+ /// Этаж расположения
+ ///
+ [Range(-5, 200, ErrorMessage = "Этаж должен быть от -5 до 200")]
+ public int? Floor { get; set; }
+
+ ///
+ /// Наличие обременений
+ ///
+ public bool? HasEncumbrances { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/CreateRequestDto.cs b/RealEstateAgency.Contracts/Dto/CreateRequestDto.cs
new file mode 100644
index 000000000..3582dfa27
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/CreateRequestDto.cs
@@ -0,0 +1,41 @@
+using System.ComponentModel.DataAnnotations;
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для создания заявки
+///
+public class CreateRequestDto
+{
+ ///
+ /// Идентификатор контрагента
+ ///
+ [Required(ErrorMessage = "Идентификатор контрагента обязателен")]
+ public Guid CounterpartyId { get; set; }
+
+ ///
+ /// Идентификатор объекта недвижимости
+ ///
+ [Required(ErrorMessage = "Идентификатор объекта недвижимости обязателен")]
+ public Guid PropertyId { get; set; }
+
+ ///
+ /// Тип заявки (покупка/продажа)
+ ///
+ [Required(ErrorMessage = "Тип заявки обязателен")]
+ public RequestType Type { get; set; }
+
+ ///
+ /// Сумма сделки
+ ///
+ [Required(ErrorMessage = "Сумма сделки обязательна")]
+ [Range(0.01, double.MaxValue, ErrorMessage = "Сумма сделки должна быть положительной")]
+ public decimal Amount { get; set; }
+
+ ///
+ /// Дата подачи заявки
+ ///
+ [Required(ErrorMessage = "Дата подачи заявки обязательна")]
+ public DateTime Date { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/PropertyTypeStatisticsDto.cs b/RealEstateAgency.Contracts/Dto/PropertyTypeStatisticsDto.cs
new file mode 100644
index 000000000..8df7155da
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/PropertyTypeStatisticsDto.cs
@@ -0,0 +1,19 @@
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для статистики по типу недвижимости
+///
+public class PropertyTypeStatisticsDto
+{
+ ///
+ /// Тип недвижимости
+ ///
+ public PropertyType PropertyType { get; set; }
+
+ ///
+ /// Количество заявок
+ ///
+ public int RequestCount { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/RealEstatePropertyDto.cs b/RealEstateAgency.Contracts/Dto/RealEstatePropertyDto.cs
new file mode 100644
index 000000000..b716ad0ca
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/RealEstatePropertyDto.cs
@@ -0,0 +1,64 @@
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для отображения объекта недвижимости
+///
+public class RealEstatePropertyDto
+{
+ ///
+ /// Уникальный идентификатор
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Тип недвижимости
+ ///
+ public PropertyType Type { get; set; }
+
+ ///
+ /// Назначение недвижимости
+ ///
+ public PropertyPurpose Purpose { get; set; }
+
+ ///
+ /// Кадастровый номер
+ ///
+ public required string CadastralNumber { get; set; }
+
+ ///
+ /// Адрес
+ ///
+ public required string Address { get; set; }
+
+ ///
+ /// Количество этажей в здании
+ ///
+ public int? TotalFloors { get; set; }
+
+ ///
+ /// Общая площадь (кв.м)
+ ///
+ public double TotalArea { get; set; }
+
+ ///
+ /// Количество комнат
+ ///
+ public int? RoomsCount { get; set; }
+
+ ///
+ /// Высота потолков (м)
+ ///
+ public double? CeilingHeight { get; set; }
+
+ ///
+ /// Этаж расположения
+ ///
+ public int? Floor { get; set; }
+
+ ///
+ /// Наличие обременений
+ ///
+ public bool? HasEncumbrances { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/RequestDto.cs b/RealEstateAgency.Contracts/Dto/RequestDto.cs
new file mode 100644
index 000000000..47207ef1c
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/RequestDto.cs
@@ -0,0 +1,49 @@
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для отображения заявки
+///
+public class RequestDto
+{
+ ///
+ /// Уникальный идентификатор заявки
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Идентификатор контрагента
+ ///
+ public Guid CounterpartyId { get; set; }
+
+ ///
+ /// Данные контрагента
+ ///
+ public CounterpartyDto? Counterparty { get; set; }
+
+ ///
+ /// Идентификатор объекта недвижимости
+ ///
+ public Guid PropertyId { get; set; }
+
+ ///
+ /// Данные объекта недвижимости
+ ///
+ public RealEstatePropertyDto? Property { get; set; }
+
+ ///
+ /// Тип заявки (покупка/продажа)
+ ///
+ public RequestType Type { get; set; }
+
+ ///
+ /// Сумма сделки
+ ///
+ public decimal Amount { get; set; }
+
+ ///
+ /// Дата подачи заявки
+ ///
+ public DateTime Date { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/Top5ClientsResultDto.cs b/RealEstateAgency.Contracts/Dto/Top5ClientsResultDto.cs
new file mode 100644
index 000000000..ff704bbdf
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/Top5ClientsResultDto.cs
@@ -0,0 +1,17 @@
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO результата топ-5 клиентов (покупка и продажа)
+///
+public class Top5ClientsResultDto
+{
+ ///
+ /// Топ-5 покупателей
+ ///
+ public List TopPurchaseClients { get; set; } = [];
+
+ ///
+ /// Топ-5 продавцов
+ ///
+ public List TopSaleClients { get; set; } = [];
+}
diff --git a/RealEstateAgency.Contracts/Dto/TopClientDto.cs b/RealEstateAgency.Contracts/Dto/TopClientDto.cs
new file mode 100644
index 000000000..2665abfd2
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/TopClientDto.cs
@@ -0,0 +1,17 @@
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для топ клиентов по количеству заявок
+///
+public class TopClientDto
+{
+ ///
+ /// ФИО клиента
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Количество заявок
+ ///
+ public int RequestCount { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/UpdateCounterpartyDto.cs b/RealEstateAgency.Contracts/Dto/UpdateCounterpartyDto.cs
new file mode 100644
index 000000000..f7f24c66d
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/UpdateCounterpartyDto.cs
@@ -0,0 +1,30 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для обновления контрагента
+///
+public class UpdateCounterpartyDto
+{
+ ///
+ /// ФИО контрагента
+ ///
+ [Required(ErrorMessage = "ФИО обязательно")]
+ [StringLength(200, MinimumLength = 2, ErrorMessage = "ФИО должно содержать от 2 до 200 символов")]
+ public required string FullName { get; set; }
+
+ ///
+ /// Номер паспорта
+ ///
+ [Required(ErrorMessage = "Номер паспорта обязателен")]
+ [StringLength(20, MinimumLength = 6, ErrorMessage = "Номер паспорта должен содержать от 6 до 20 символов")]
+ public required string PassportNumber { get; set; }
+
+ ///
+ /// Контактный телефон
+ ///
+ [Required(ErrorMessage = "Номер телефона обязателен")]
+ [Phone(ErrorMessage = "Некорректный формат номера телефона")]
+ public required string PhoneNumber { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/UpdateRealEstatePropertyDto.cs b/RealEstateAgency.Contracts/Dto/UpdateRealEstatePropertyDto.cs
new file mode 100644
index 000000000..0a5d82484
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/UpdateRealEstatePropertyDto.cs
@@ -0,0 +1,72 @@
+using System.ComponentModel.DataAnnotations;
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для обновления объекта недвижимости
+///
+public class UpdateRealEstatePropertyDto
+{
+ ///
+ /// Тип недвижимости
+ ///
+ [Required(ErrorMessage = "Тип недвижимости обязателен")]
+ public PropertyType Type { get; set; }
+
+ ///
+ /// Назначение недвижимости
+ ///
+ [Required(ErrorMessage = "Назначение недвижимости обязательно")]
+ public PropertyPurpose Purpose { get; set; }
+
+ ///
+ /// Кадастровый номер
+ ///
+ [Required(ErrorMessage = "Кадастровый номер обязателен")]
+ [StringLength(50, MinimumLength = 5, ErrorMessage = "Кадастровый номер должен содержать от 5 до 50 символов")]
+ public required string CadastralNumber { get; set; }
+
+ ///
+ /// Адрес
+ ///
+ [Required(ErrorMessage = "Адрес обязателен")]
+ [StringLength(500, MinimumLength = 5, ErrorMessage = "Адрес должен содержать от 5 до 500 символов")]
+ public required string Address { get; set; }
+
+ ///
+ /// Количество этажей в здании
+ ///
+ [Range(1, 200, ErrorMessage = "Количество этажей должно быть от 1 до 200")]
+ public int? TotalFloors { get; set; }
+
+ ///
+ /// Общая площадь (кв.м)
+ ///
+ [Required(ErrorMessage = "Общая площадь обязательна")]
+ [Range(0.1, 1000000, ErrorMessage = "Площадь должна быть от 0.1 до 1000000 кв.м")]
+ public double TotalArea { get; set; }
+
+ ///
+ /// Количество комнат
+ ///
+ [Range(1, 100, ErrorMessage = "Количество комнат должно быть от 1 до 100")]
+ public int? RoomsCount { get; set; }
+
+ ///
+ /// Высота потолков (м)
+ ///
+ [Range(1.5, 20, ErrorMessage = "Высота потолков должна быть от 1.5 до 20 м")]
+ public double? CeilingHeight { get; set; }
+
+ ///
+ /// Этаж расположения
+ ///
+ [Range(-5, 200, ErrorMessage = "Этаж должен быть от -5 до 200")]
+ public int? Floor { get; set; }
+
+ ///
+ /// Наличие обременений
+ ///
+ public bool? HasEncumbrances { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Dto/UpdateRequestDto.cs b/RealEstateAgency.Contracts/Dto/UpdateRequestDto.cs
new file mode 100644
index 000000000..fdd1c2d5c
--- /dev/null
+++ b/RealEstateAgency.Contracts/Dto/UpdateRequestDto.cs
@@ -0,0 +1,41 @@
+using System.ComponentModel.DataAnnotations;
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Contracts.Dto;
+
+///
+/// DTO для обновления заявки
+///
+public class UpdateRequestDto
+{
+ ///
+ /// Идентификатор контрагента
+ ///
+ [Required(ErrorMessage = "Идентификатор контрагента обязателен")]
+ public Guid CounterpartyId { get; set; }
+
+ ///
+ /// Идентификатор объекта недвижимости
+ ///
+ [Required(ErrorMessage = "Идентификатор объекта недвижимости обязателен")]
+ public Guid PropertyId { get; set; }
+
+ ///
+ /// Тип заявки (покупка/продажа)
+ ///
+ [Required(ErrorMessage = "Тип заявки обязателен")]
+ public RequestType Type { get; set; }
+
+ ///
+ /// Сумма сделки
+ ///
+ [Required(ErrorMessage = "Сумма сделки обязательна")]
+ [Range(0.01, double.MaxValue, ErrorMessage = "Сумма сделки должна быть положительной")]
+ public decimal Amount { get; set; }
+
+ ///
+ /// Дата подачи заявки
+ ///
+ [Required(ErrorMessage = "Дата подачи заявки обязательна")]
+ public DateTime Date { get; set; }
+}
diff --git a/RealEstateAgency.Contracts/Interfaces/IAnalyticsService.cs b/RealEstateAgency.Contracts/Interfaces/IAnalyticsService.cs
new file mode 100644
index 000000000..1cc612cc6
--- /dev/null
+++ b/RealEstateAgency.Contracts/Interfaces/IAnalyticsService.cs
@@ -0,0 +1,35 @@
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Contracts.Interfaces;
+
+///
+/// Интерфейс сервиса аналитики
+///
+public interface IAnalyticsService
+{
+ ///
+ /// Получить продавцов за указанный период
+ ///
+ public Task> GetSellersInPeriodAsync(DateTime startDate, DateTime endDate);
+
+ ///
+ /// Получить топ-5 клиентов по количеству заявок (покупка и продажа отдельно)
+ ///
+ public Task GetTop5ClientsByRequestCountAsync();
+
+ ///
+ /// Получить статистику заявок по типам недвижимости
+ ///
+ public Task> GetRequestCountByPropertyTypeAsync();
+
+ ///
+ /// Получить клиентов с заявками минимальной стоимости
+ ///
+ public Task GetClientsWithMinAmountAsync();
+
+ ///
+ /// Получить клиентов, ищущих определённый тип недвижимости
+ ///
+ public Task> GetClientsSeekingPropertyTypeAsync(PropertyType propertyType);
+}
diff --git a/RealEstateAgency.Contracts/Interfaces/IApplicationService.cs b/RealEstateAgency.Contracts/Interfaces/IApplicationService.cs
new file mode 100644
index 000000000..bad7bf655
--- /dev/null
+++ b/RealEstateAgency.Contracts/Interfaces/IApplicationService.cs
@@ -0,0 +1,35 @@
+namespace RealEstateAgency.Contracts.Interfaces;
+
+///
+/// Базовый интерфейс сервиса приложения
+///
+/// DTO для отображения
+/// DTO для создания/обновления
+/// Тип идентификатора
+public interface IApplicationService
+{
+ ///
+ /// Получить все сущности
+ ///
+ public Task> GetAllAsync();
+
+ ///
+ /// Получить сущность по идентификатору
+ ///
+ public Task GetByIdAsync(TKey id);
+
+ ///
+ /// Создать сущность
+ ///
+ public Task CreateAsync(TCreateUpdateDto dto);
+
+ ///
+ /// Обновить сущность
+ ///
+ public Task UpdateAsync(TKey id, TCreateUpdateDto dto);
+
+ ///
+ /// Удалить сущность
+ ///
+ public Task DeleteAsync(TKey id);
+}
diff --git a/RealEstateAgency.Contracts/Interfaces/ICounterpartyService.cs b/RealEstateAgency.Contracts/Interfaces/ICounterpartyService.cs
new file mode 100644
index 000000000..d19b92f4b
--- /dev/null
+++ b/RealEstateAgency.Contracts/Interfaces/ICounterpartyService.cs
@@ -0,0 +1,14 @@
+using RealEstateAgency.Contracts.Dto;
+
+namespace RealEstateAgency.Contracts.Interfaces;
+
+///
+/// Интерфейс сервиса контрагентов
+///
+public interface ICounterpartyService : IApplicationService
+{
+ ///
+ /// Обновить контрагента
+ ///
+ public Task UpdateAsync(Guid id, UpdateCounterpartyDto dto);
+}
diff --git a/RealEstateAgency.Contracts/Interfaces/IRealEstatePropertyService.cs b/RealEstateAgency.Contracts/Interfaces/IRealEstatePropertyService.cs
new file mode 100644
index 000000000..4cc461e7e
--- /dev/null
+++ b/RealEstateAgency.Contracts/Interfaces/IRealEstatePropertyService.cs
@@ -0,0 +1,14 @@
+using RealEstateAgency.Contracts.Dto;
+
+namespace RealEstateAgency.Contracts.Interfaces;
+
+///
+/// Интерфейс сервиса недвижимости
+///
+public interface IRealEstatePropertyService : IApplicationService
+{
+ ///
+ /// Обновить объект недвижимости
+ ///
+ public Task UpdateAsync(Guid id, UpdateRealEstatePropertyDto dto);
+}
diff --git a/RealEstateAgency.Contracts/Interfaces/IRequestService.cs b/RealEstateAgency.Contracts/Interfaces/IRequestService.cs
new file mode 100644
index 000000000..a1f23bd1e
--- /dev/null
+++ b/RealEstateAgency.Contracts/Interfaces/IRequestService.cs
@@ -0,0 +1,36 @@
+using RealEstateAgency.Contracts.Dto;
+
+namespace RealEstateAgency.Contracts.Interfaces;
+
+///
+/// Интерфейс сервиса заявок
+///
+public interface IRequestService
+{
+ ///
+ /// Получить все заявки
+ ///
+ public Task> GetAllAsync();
+
+ ///
+ /// Получить заявку по идентификатору
+ ///
+ public Task GetByIdAsync(Guid id);
+
+ ///
+ /// Создать заявку
+ ///
+ /// Созданная заявка или null если контрагент или недвижимость не найдены
+ public Task<(RequestDto? Result, string? Error)> CreateAsync(CreateRequestDto dto);
+
+ ///
+ /// Обновить заявку
+ ///
+ /// Обновленная заявка или null если не найдена
+ public Task<(RequestDto? Result, string? Error)> UpdateAsync(Guid id, UpdateRequestDto dto);
+
+ ///
+ /// Удалить заявку
+ ///
+ public Task DeleteAsync(Guid id);
+}
diff --git a/RealEstateAgency.Contracts/RealEstateAgency.Contracts.csproj b/RealEstateAgency.Contracts/RealEstateAgency.Contracts.csproj
new file mode 100644
index 000000000..a84d1c6ef
--- /dev/null
+++ b/RealEstateAgency.Contracts/RealEstateAgency.Contracts.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency.Domain/Enums/PropertyPurpose.cs b/RealEstateAgency.Domain/Enums/PropertyPurpose.cs
new file mode 100644
index 000000000..c0316813c
--- /dev/null
+++ b/RealEstateAgency.Domain/Enums/PropertyPurpose.cs
@@ -0,0 +1,23 @@
+namespace RealEstateAgency.Domain.Enums;
+
+///
+/// Назначение объекта недвижимости
+/// Определяет основную цель использования объекта недвижимости
+///
+public enum PropertyPurpose
+{
+ ///
+ /// Жилое назначение - для проживания людей
+ ///
+ Residential,
+
+ ///
+ /// Коммерческое назначение - для осуществления предпринимательской деятельности
+ ///
+ Commercial,
+
+ ///
+ /// Промышленное использование - для производственной деятельности
+ ///
+ Industrial
+}
diff --git a/RealEstateAgency.Domain/Enums/PropertyType.cs b/RealEstateAgency.Domain/Enums/PropertyType.cs
new file mode 100644
index 000000000..191f303e0
--- /dev/null
+++ b/RealEstateAgency.Domain/Enums/PropertyType.cs
@@ -0,0 +1,38 @@
+namespace RealEstateAgency.Domain.Enums;
+
+///
+/// Тип недвижимости
+/// Классифицирует имущество по физическим характеристикам
+///
+public enum PropertyType
+{
+ ///
+ /// Квартира в многоквартирном доме
+ ///
+ Apartment,
+
+ ///
+ /// Отдельно стоящее многоквартирное здание
+ ///
+ House,
+
+ ///
+ /// Блокированный многоквартирный дом с отдельными входами
+ ///
+ Townhouse,
+
+ ///
+ /// Коммерческие помещения для ведения бизнеса
+ ///
+ Commercial,
+
+ ///
+ /// Складские или производственные помещения
+ ///
+ Warehouse,
+
+ ///
+ /// Место для парковки транспортных средств
+ ///
+ ParkingSpace
+}
diff --git a/RealEstateAgency.Domain/Enums/RequestType.cs b/RealEstateAgency.Domain/Enums/RequestType.cs
new file mode 100644
index 000000000..c460891a2
--- /dev/null
+++ b/RealEstateAgency.Domain/Enums/RequestType.cs
@@ -0,0 +1,18 @@
+namespace RealEstateAgency.Domain.Enums;
+
+///
+/// Тип заявления в агентство недвижимости
+/// Определяет направление сделки с недвижимостью
+///
+public enum RequestType
+{
+ ///
+ /// Заявка на покупку недвижимости
+ ///
+ Purchase,
+
+ ///
+ /// Заявка на продажу недвижимости
+ ///
+ Sale
+}
diff --git a/RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs b/RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs
new file mode 100644
index 000000000..e79189668
--- /dev/null
+++ b/RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs
@@ -0,0 +1,9 @@
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Domain.Interfaces;
+
+///
+/// Интерфейс репозитория контрагентов
+///
+public interface ICounterpartyRepository : IRepository;
+
diff --git a/RealEstateAgency.Domain/Interfaces/IRealEstatePropertyRepository.cs b/RealEstateAgency.Domain/Interfaces/IRealEstatePropertyRepository.cs
new file mode 100644
index 000000000..7c0248d34
--- /dev/null
+++ b/RealEstateAgency.Domain/Interfaces/IRealEstatePropertyRepository.cs
@@ -0,0 +1,8 @@
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Domain.Interfaces;
+
+///
+/// Интерфейс репозитория объектов недвижимости
+///
+public interface IRealEstatePropertyRepository : IRepository;
diff --git a/RealEstateAgency.Domain/Interfaces/IRepository.cs b/RealEstateAgency.Domain/Interfaces/IRepository.cs
new file mode 100644
index 000000000..4b8c073a0
--- /dev/null
+++ b/RealEstateAgency.Domain/Interfaces/IRepository.cs
@@ -0,0 +1,33 @@
+namespace RealEstateAgency.Domain.Interfaces;
+
+///
+/// Базовый интерфейс репозитория
+///
+/// Тип сущности
+public interface IRepository
+{
+ ///
+ /// Получить все сущности
+ ///
+ public Task> GetAllAsync();
+
+ ///
+ /// Получить сущность по идентификатору
+ ///
+ public Task GetByIdAsync(Guid id);
+
+ ///
+ /// Добавить сущность
+ ///
+ public Task AddAsync(T entity);
+
+ ///
+ /// Обновить сущность
+ ///
+ public Task UpdateAsync(Guid id, T entity);
+
+ ///
+ /// Удалить сущность
+ ///
+ public Task DeleteAsync(Guid id);
+}
diff --git a/RealEstateAgency.Domain/Interfaces/IRequestRepository.cs b/RealEstateAgency.Domain/Interfaces/IRequestRepository.cs
new file mode 100644
index 000000000..0c8b3c09c
--- /dev/null
+++ b/RealEstateAgency.Domain/Interfaces/IRequestRepository.cs
@@ -0,0 +1,8 @@
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Domain.Interfaces;
+
+///
+/// Интерфейс репозитория заявок
+///
+public interface IRequestRepository : IRepository;
diff --git a/RealEstateAgency.Domain/Models/Counterparty.cs b/RealEstateAgency.Domain/Models/Counterparty.cs
new file mode 100644
index 000000000..d131c2c96
--- /dev/null
+++ b/RealEstateAgency.Domain/Models/Counterparty.cs
@@ -0,0 +1,28 @@
+namespace RealEstateAgency.Domain.Models;
+
+///
+/// Контрагент агентства недвижимости
+/// Физическое лицо, участвующее в сделках с недвижимостью
+///
+public class Counterparty
+{
+ ///
+ /// Уникальный идентификатор контрагента
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Полное наименование контрагента в формате "Фамилия, имя, отчество"
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Номер паспорта для идентификации личности
+ ///
+ public required string PassportNumber { get; set; }
+
+ ///
+ /// Контактный телефон для связи
+ ///
+ public required string PhoneNumber { get; set; }
+}
diff --git a/RealEstateAgency.Domain/Models/RealEstateProperty.cs b/RealEstateAgency.Domain/Models/RealEstateProperty.cs
new file mode 100644
index 000000000..18ec96b03
--- /dev/null
+++ b/RealEstateAgency.Domain/Models/RealEstateProperty.cs
@@ -0,0 +1,65 @@
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Domain.Models;
+
+///
+/// Объект недвижимости
+/// Описывает физические характеристики объекта недвижимости
+///
+public class RealEstateProperty
+{
+ ///
+ /// Уникальный идентификатор объекта
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Тип недвижимости
+ ///
+ public required PropertyType Type { get; set; }
+
+ ///
+ /// Назначение объекта недвижимости
+ ///
+ public required PropertyPurpose Purpose { get; set; }
+
+ ///
+ /// Уникальный идентификатор в государственном реестре
+ ///
+ public required string CadastralNumber { get; set; }
+
+ ///
+ /// Физический адрес местоположения объекта
+ ///
+ public required string Address { get; set; }
+
+ ///
+ /// Общее количество этажей в здании
+ ///
+ public int? TotalFloors { get; set; }
+
+ ///
+ /// Общая площадь объекта в квадратных метрах
+ ///
+ public required double TotalArea { get; set; }
+
+ ///
+ /// Количество комнат в комплексе
+ ///
+ public int? RoomsCount { get; set; }
+
+ ///
+ /// Высота потолков в метрах
+ ///
+ public double? CeilingHeight { get; set; }
+
+ ///
+ /// Этаж расположения объекта
+ ///
+ public int? Floor { get; set; }
+
+ ///
+ /// Наличие юридических обременений (залог, арест, ипотека)
+ ///
+ public bool? HasEncumbrances { get; set; }
+}
diff --git a/RealEstateAgency.Domain/Models/Request.cs b/RealEstateAgency.Domain/Models/Request.cs
new file mode 100644
index 000000000..acdcb7143
--- /dev/null
+++ b/RealEstateAgency.Domain/Models/Request.cs
@@ -0,0 +1,50 @@
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Domain.Models;
+
+///
+/// Заявка на совершение сделки с недвижимостью
+/// Это соглашение между контрагентом и агентством.
+///
+public class Request
+{
+ ///
+ /// Уникальный идентификатор приложения
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Идентификатор контрагента (внешний ключ)
+ ///
+ public Guid CounterpartyId { get; set; }
+
+ ///
+ /// Контрагент, подавший заявку
+ ///
+ public required Counterparty Counterparty { get; set; } = null!;
+
+ ///
+ /// Идентификатор объекта недвижимости (внешний ключ)
+ ///
+ public Guid PropertyId { get; set; }
+
+ ///
+ /// Объект недвижимости, связанный с приложением
+ ///
+ public required RealEstateProperty Property { get; set; } = null!;
+
+ ///
+ /// Тип операции: покупка или продажа
+ ///
+ public required RequestType Type { get; set; }
+
+ ///
+ /// Денежная сумма для подачи заявки в рублях
+ ///
+ public required decimal Amount { get; set; }
+
+ ///
+ /// Дата подачи заявки
+ ///
+ public required DateTime Date { get; set; }
+}
diff --git a/RealEstateAgency.Domain/RealEstateAgency.Domain.csproj b/RealEstateAgency.Domain/RealEstateAgency.Domain.csproj
new file mode 100644
index 000000000..fa71b7ae6
--- /dev/null
+++ b/RealEstateAgency.Domain/RealEstateAgency.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs b/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs
new file mode 100644
index 000000000..e1d12f7df
--- /dev/null
+++ b/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs
@@ -0,0 +1,298 @@
+using FluentAssertions;
+using RealEstateAgency.Domain.Enums;
+using RealEstateAgency.Generator.Generators;
+
+namespace RealEstateAgency.Generator.Tests;
+
+///
+/// Тесты генератора контрактов
+///
+public class ContractGeneratorTests
+{
+
+ [Fact]
+ public void GenerateCounterparty_ShouldReturnValidCounterparty()
+ {
+ var counterparty = ContractGenerator.GenerateCounterparty();
+
+ counterparty.Should().NotBeNull();
+ counterparty.FullName.Should().NotBeNullOrEmpty();
+ counterparty.PassportNumber.Should().NotBeNullOrEmpty();
+ counterparty.PhoneNumber.Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public void GenerateCounterparty_FullName_ShouldContainThreeParts()
+ {
+ var counterparty = ContractGenerator.GenerateCounterparty();
+
+ var nameParts = counterparty.FullName.Split(' ');
+ nameParts.Should().HaveCount(3, "ФИО должно состоять из фамилии, имени и отчества");
+ }
+
+ [Fact]
+ public void GenerateCounterparty_PassportNumber_ShouldHaveValidFormat()
+ {
+ var counterparty = ContractGenerator.GenerateCounterparty();
+
+ counterparty.PassportNumber.Should().MatchRegex(@"^\d{4}\s\d{6}$",
+ "Номер паспорта должен быть в формате 'XXXX XXXXXX'");
+ }
+
+ [Fact]
+ public void GenerateCounterparty_PhoneNumber_ShouldStartWithPlus7()
+ {
+ var counterparty = ContractGenerator.GenerateCounterparty();
+
+ counterparty.PhoneNumber.Should().StartWith("+7",
+ "Российский номер должен начинаться с +7");
+
+ var cleanNumber = counterparty.PhoneNumber.Replace("+7", "").Replace("(", "").Replace(")", "").Replace("-", "").Replace(" ", "");
+ cleanNumber.Should().MatchRegex(@"^[0-9]{10}$",
+ "Номер должен содержать 10 цифр после +7");
+ }
+
+ [Fact]
+ public void GenerateCounterparty_ShouldGenerateUniqueData()
+ {
+ var counterparties = Enumerable.Range(0, 100)
+ .Select(_ => ContractGenerator.GenerateCounterparty())
+ .ToList();
+
+ var uniquePassports = counterparties.Select(c => c.PassportNumber).Distinct().Count();
+ uniquePassports.Should().BeGreaterThan(90,
+ "Большинство номеров паспортов должны быть уникальными");
+ }
+
+
+ [Fact]
+ public void GenerateProperty_ShouldReturnValidProperty()
+ {
+ var property = ContractGenerator.GenerateProperty();
+
+ property.Should().NotBeNull();
+ property.CadastralNumber.Should().NotBeNullOrEmpty();
+ property.Address.Should().NotBeNullOrEmpty();
+ property.TotalArea.Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void GenerateProperty_CadastralNumber_ShouldHaveValidFormat()
+ {
+ var property = ContractGenerator.GenerateProperty();
+
+ property.CadastralNumber.Should().MatchRegex(@"^\d{2}:\d{2}:\d{7}:\d{3}$",
+ "Кадастровый номер должен быть в формате XX:XX:XXXXXXX:XXX");
+ }
+
+ [Fact]
+ public void GenerateProperty_Address_ShouldContainCity()
+ {
+ var property = ContractGenerator.GenerateProperty();
+
+ property.Address.Should().StartWith("г.",
+ "Адрес должен начинаться с 'г.' (город)");
+ }
+
+ [Fact]
+ public void GenerateProperty_TotalArea_ShouldBeInValidRange()
+ {
+ var properties = Enumerable.Range(0, 100)
+ .Select(_ => ContractGenerator.GenerateProperty())
+ .ToList();
+
+ foreach (var property in properties)
+ {
+ property.TotalArea.Should().BeGreaterThan(0);
+ property.TotalArea.Should().BeLessThanOrEqualTo(5000,
+ "Площадь не должна превышать 5000 кв.м");
+ }
+ }
+
+ [Fact]
+ public void GenerateProperty_ShouldGenerateAllPropertyTypes()
+ {
+ var properties = Enumerable.Range(0, 500)
+ .Select(_ => ContractGenerator.GenerateProperty())
+ .ToList();
+
+ var propertyTypes = properties.Select(p => p.Type).Distinct().ToList();
+ propertyTypes.Should().Contain(PropertyType.Apartment);
+ propertyTypes.Should().Contain(PropertyType.House);
+ }
+
+ [Fact]
+ public void GenerateProperty_Apartment_ShouldHaveValidFloorAndTotalFloors()
+ {
+ for (var i = 0; i < 100; i++)
+ {
+ var property = ContractGenerator.GenerateProperty();
+
+ if (property.Type == PropertyType.Apartment &&
+ property.Floor.HasValue &&
+ property.TotalFloors.HasValue)
+ {
+ property.Floor.Value.Should().BeLessThanOrEqualTo(property.TotalFloors.Value,
+ "Этаж не должен превышать количество этажей в доме");
+ property.Floor.Value.Should().BeGreaterThanOrEqualTo(1);
+ }
+ }
+ }
+
+ [Fact]
+ public void GenerateProperty_ResidentialTypes_ShouldHaveResidentialPurpose()
+ {
+ var properties = Enumerable.Range(0, 200)
+ .Select(_ => ContractGenerator.GenerateProperty())
+ .Where(p => p.Type == PropertyType.Apartment ||
+ p.Type == PropertyType.House ||
+ p.Type == PropertyType.Townhouse)
+ .ToList();
+
+ foreach (var property in properties)
+ {
+ property.Purpose.Should().Be(PropertyPurpose.Residential,
+ $"Тип {property.Type} должен иметь жилое назначение");
+ }
+ }
+
+ [Fact]
+ public void GenerateProperty_Commercial_ShouldHaveCommercialPurpose()
+ {
+ var properties = Enumerable.Range(0, 200)
+ .Select(_ => ContractGenerator.GenerateProperty())
+ .Where(p => p.Type == PropertyType.Commercial)
+ .ToList();
+
+ foreach (var property in properties)
+ {
+ property.Purpose.Should().Be(PropertyPurpose.Commercial,
+ "Коммерческая недвижимость должна иметь коммерческое назначение");
+ }
+ }
+
+ [Fact]
+ public void GenerateProperty_Warehouse_ShouldHaveIndustrialPurpose()
+ {
+ var properties = Enumerable.Range(0, 200)
+ .Select(_ => ContractGenerator.GenerateProperty())
+ .Where(p => p.Type == PropertyType.Warehouse)
+ .ToList();
+
+ foreach (var property in properties)
+ {
+ property.Purpose.Should().Be(PropertyPurpose.Industrial,
+ "Склад должен иметь промышленное назначение");
+ }
+ }
+
+ [Fact]
+ public void GenerateProperty_CeilingHeight_ShouldBeInValidRange()
+ {
+ var properties = Enumerable.Range(0, 100)
+ .Select(_ => ContractGenerator.GenerateProperty())
+ .Where(p => p.CeilingHeight.HasValue)
+ .ToList();
+
+ foreach (var property in properties)
+ {
+ property.CeilingHeight!.Value.Should().BeGreaterThanOrEqualTo(2.5);
+ property.CeilingHeight!.Value.Should().BeLessThanOrEqualTo(4.0);
+ }
+ }
+
+
+ [Fact]
+ public void GenerateRequest_ShouldReturnValidRequest()
+ {
+ var counterpartyId = Guid.NewGuid();
+ var propertyId = Guid.NewGuid();
+
+ var request = ContractGenerator.GenerateRequest(counterpartyId, propertyId);
+
+ request.Should().NotBeNull();
+ request.CounterpartyId.Should().Be(counterpartyId);
+ request.PropertyId.Should().Be(propertyId);
+ request.Amount.Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public void GenerateRequest_Amount_ShouldBeInValidRange()
+ {
+ var counterpartyId = Guid.NewGuid();
+ var propertyId = Guid.NewGuid();
+
+ var requests = Enumerable.Range(0, 100)
+ .Select(_ => ContractGenerator.GenerateRequest(counterpartyId, propertyId))
+ .ToList();
+
+ foreach (var request in requests)
+ {
+ request.Amount.Should().BeGreaterThanOrEqualTo(1_000_000m,
+ "Минимальная сумма сделки должна быть 1 000 000");
+ request.Amount.Should().BeLessThanOrEqualTo(50_000_000m,
+ "Максимальная сумма сделки должна быть 50 000 000");
+ }
+ }
+
+ [Fact]
+ public void GenerateRequest_Date_ShouldBeWithinLastTwoYears()
+ {
+ var counterpartyId = Guid.NewGuid();
+ var propertyId = Guid.NewGuid();
+ var twoYearsAgo = DateTime.Now.AddYears(-2);
+
+ var requests = Enumerable.Range(0, 100)
+ .Select(_ => ContractGenerator.GenerateRequest(counterpartyId, propertyId))
+ .ToList();
+
+ foreach (var request in requests)
+ {
+ request.Date.Should().BeAfter(twoYearsAgo);
+ request.Date.Should().BeOnOrBefore(DateTime.Now);
+ }
+ }
+
+ [Fact]
+ public void GenerateRequest_ShouldGenerateBothRequestTypes()
+ {
+ var counterpartyId = Guid.NewGuid();
+ var propertyId = Guid.NewGuid();
+
+ var requests = Enumerable.Range(0, 100)
+ .Select(_ => ContractGenerator.GenerateRequest(counterpartyId, propertyId))
+ .ToList();
+
+ var requestTypes = requests.Select(r => r.Type).Distinct().ToList();
+ requestTypes.Should().Contain(RequestType.Purchase);
+ requestTypes.Should().Contain(RequestType.Sale);
+ }
+
+
+ [Fact]
+ public void GenerateDataPackage_ShouldReturnValidPackage()
+ {
+ var package = ContractGenerator.GenerateDataPackage();
+
+ package.Should().NotBeNull();
+ package.Counterparty.Should().NotBeNull();
+ package.Property.Should().NotBeNull();
+ }
+
+ [Fact]
+ public void GenerateDataPackage_ShouldGenerateUniquePackages()
+ {
+ var packages = Enumerable.Range(0, 50)
+ .Select(_ => ContractGenerator.GenerateDataPackage())
+ .ToList();
+
+ var uniquePassports = packages
+ .Select(p => p.Counterparty.PassportNumber)
+ .Distinct()
+ .Count();
+
+ uniquePassports.Should().BeGreaterThan(40,
+ "Большинство пакетов должны содержать уникальные данные");
+ }
+
+}
diff --git a/RealEstateAgency.Generator.Tests/DataGeneratorServiceTests.cs b/RealEstateAgency.Generator.Tests/DataGeneratorServiceTests.cs
new file mode 100644
index 000000000..d8f873306
--- /dev/null
+++ b/RealEstateAgency.Generator.Tests/DataGeneratorServiceTests.cs
@@ -0,0 +1,344 @@
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using NATS.Client.Core;
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Generator.Services;
+using System.Text.Json;
+using Testcontainers.Nats;
+
+namespace RealEstateAgency.Generator.Tests;
+
+///
+/// Интеграционные тесты сервиса генерации данных
+///
+[Collection("NatsIntegration")]
+public class DataGeneratorServiceTests : IAsyncLifetime
+{
+ private static readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly NatsContainer _natsContainer;
+ private NatsPublisher? _publisher;
+ private readonly Mock> _publisherLoggerMock;
+ private readonly Mock> _serviceLoggerMock;
+ private readonly DataGeneratorSettings _testSettings;
+
+ public DataGeneratorServiceTests()
+ {
+ _natsContainer = new NatsBuilder()
+ .WithImage("nats:latest")
+ .Build();
+
+ _publisherLoggerMock = new Mock>();
+ _serviceLoggerMock = new Mock>();
+
+ _testSettings = new DataGeneratorSettings
+ {
+ BatchSize = 10,
+ DelayBetweenBatchesMs = 5000,
+ DelayBetweenMessagesMs = 100,
+ CounterpartyTopic = "realestate.counterparty.created",
+ PropertyTopic = "realestate.property.created",
+ RequestTopic = "realestate.request.created"
+ };
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _natsContainer.StartAsync();
+ _publisher = new NatsPublisher(_natsContainer.GetConnectionString(), _publisherLoggerMock.Object);
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (_publisher != null)
+ {
+ await _publisher.DisposeAsync();
+ }
+ await _natsContainer.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ShouldPublishCounterpartiesToCorrectTopic()
+ {
+ var receivedCounterparties = new List();
+ var allReceived = new TaskCompletionSource();
+
+ var options = new NatsOpts { Url = _natsContainer.GetConnectionString() };
+ await using var subscriber = new NatsConnection(options);
+ await subscriber.ConnectAsync();
+
+ var subscription = Task.Run(async () =>
+ {
+ await foreach (var msg in subscriber.SubscribeAsync(_testSettings.CounterpartyTopic))
+ {
+ if (!string.IsNullOrEmpty(msg.Data))
+ {
+ var dto = JsonSerializer.Deserialize(msg.Data, _jsonOptions);
+ if (dto != null)
+ {
+ receivedCounterparties.Add(dto);
+ if (receivedCounterparties.Count >= 3)
+ {
+ allReceived.TrySetResult(true);
+ }
+ }
+ }
+ }
+ });
+
+ await Task.Delay(100);
+
+ var serviceOptions = Options.Create(new DataGeneratorSettings
+ {
+ BatchSize = 3,
+ DelayBetweenBatchesMs = 100,
+ DelayBetweenMessagesMs = 100,
+ CounterpartyTopic = _testSettings.CounterpartyTopic,
+ PropertyTopic = _testSettings.PropertyTopic,
+ RequestTopic = _testSettings.RequestTopic
+ });
+
+ var service = new DataGeneratorService(
+ _publisher!,
+ _serviceLoggerMock.Object,
+ serviceOptions);
+
+ using var cts = new CancellationTokenSource();
+
+ var serviceTask = service.StartAsync(cts.Token);
+
+ await allReceived.Task.WaitAsync(TimeSpan.FromSeconds(30));
+
+ cts.Cancel();
+ await service.StopAsync(CancellationToken.None);
+
+ receivedCounterparties.Should().HaveCountGreaterThanOrEqualTo(3);
+ receivedCounterparties.Should().AllSatisfy(c =>
+ {
+ c.FullName.Should().NotBeNullOrEmpty();
+ c.PassportNumber.Should().NotBeNullOrEmpty();
+ c.PhoneNumber.Should().NotBeNullOrEmpty();
+ });
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ShouldPublishPropertiesToCorrectTopic()
+ {
+ var receivedProperties = new List();
+ var allReceived = new TaskCompletionSource();
+
+ var options = new NatsOpts { Url = _natsContainer.GetConnectionString() };
+ await using var subscriber = new NatsConnection(options);
+ await subscriber.ConnectAsync();
+
+ var subscription = Task.Run(async () =>
+ {
+ await foreach (var msg in subscriber.SubscribeAsync(_testSettings.PropertyTopic))
+ {
+ if (!string.IsNullOrEmpty(msg.Data))
+ {
+ var dto = JsonSerializer.Deserialize(msg.Data, _jsonOptions);
+ if (dto != null)
+ {
+ receivedProperties.Add(dto);
+ if (receivedProperties.Count >= 3)
+ {
+ allReceived.TrySetResult(true);
+ }
+ }
+ }
+ }
+ });
+
+ await Task.Delay(100);
+
+ var serviceOptions = Options.Create(new DataGeneratorSettings
+ {
+ BatchSize = 3,
+ DelayBetweenBatchesMs = 100,
+ DelayBetweenMessagesMs = 100,
+ CounterpartyTopic = _testSettings.CounterpartyTopic,
+ PropertyTopic = _testSettings.PropertyTopic,
+ RequestTopic = _testSettings.RequestTopic
+ });
+
+ var service = new DataGeneratorService(
+ _publisher!,
+ _serviceLoggerMock.Object,
+ serviceOptions);
+
+ using var cts = new CancellationTokenSource();
+
+ var serviceTask = service.StartAsync(cts.Token);
+
+ await allReceived.Task.WaitAsync(TimeSpan.FromSeconds(30));
+
+ cts.Cancel();
+ await service.StopAsync(CancellationToken.None);
+
+ receivedProperties.Should().HaveCountGreaterThanOrEqualTo(3);
+ receivedProperties.Should().AllSatisfy(p =>
+ {
+ p.Address.Should().NotBeNullOrEmpty();
+ p.CadastralNumber.Should().NotBeNullOrEmpty();
+ p.TotalArea.Should().BeGreaterThan(0);
+ });
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ShouldPublishBothCounterpartiesAndProperties()
+ {
+ var counterpartyCount = 0;
+ var propertyCount = 0;
+ var allReceived = new TaskCompletionSource();
+
+ var options = new NatsOpts { Url = _natsContainer.GetConnectionString() };
+ await using var subscriber = new NatsConnection(options);
+ await subscriber.ConnectAsync();
+
+ var counterpartySub = Task.Run(async () =>
+ {
+ await foreach (var msg in subscriber.SubscribeAsync(_testSettings.CounterpartyTopic))
+ {
+ if (!string.IsNullOrEmpty(msg.Data))
+ {
+ Interlocked.Increment(ref counterpartyCount);
+ CheckCompletion();
+ }
+ }
+ });
+
+ var propertySub = Task.Run(async () =>
+ {
+ await foreach (var msg in subscriber.SubscribeAsync(_testSettings.PropertyTopic))
+ {
+ if (!string.IsNullOrEmpty(msg.Data))
+ {
+ Interlocked.Increment(ref propertyCount);
+ CheckCompletion();
+ }
+ }
+ });
+
+ void CheckCompletion()
+ {
+ if (counterpartyCount >= 5 && propertyCount >= 5)
+ {
+ allReceived.TrySetResult(true);
+ }
+ }
+
+ await Task.Delay(100);
+
+ var serviceOptions = Options.Create(new DataGeneratorSettings
+ {
+ BatchSize = 5,
+ DelayBetweenBatchesMs = 100,
+ DelayBetweenMessagesMs = 100,
+ CounterpartyTopic = _testSettings.CounterpartyTopic,
+ PropertyTopic = _testSettings.PropertyTopic,
+ RequestTopic = _testSettings.RequestTopic
+ });
+
+ var service = new DataGeneratorService(
+ _publisher!,
+ _serviceLoggerMock.Object,
+ serviceOptions);
+
+ using var cts = new CancellationTokenSource();
+
+ var serviceTask = service.StartAsync(cts.Token);
+
+ await allReceived.Task.WaitAsync(TimeSpan.FromSeconds(30));
+
+ cts.Cancel();
+ await service.StopAsync(CancellationToken.None);
+
+ counterpartyCount.Should().BeGreaterThanOrEqualTo(5);
+ propertyCount.Should().BeGreaterThanOrEqualTo(5);
+ }
+
+ [Fact]
+ public async Task StopAsync_ShouldStopGracefully()
+ {
+ var serviceOptions = Options.Create(new DataGeneratorSettings
+ {
+ BatchSize = 10,
+ DelayBetweenBatchesMs = 1000,
+ DelayBetweenMessagesMs = 100,
+ CounterpartyTopic = _testSettings.CounterpartyTopic,
+ PropertyTopic = _testSettings.PropertyTopic,
+ RequestTopic = _testSettings.RequestTopic
+ });
+
+ var service = new DataGeneratorService(
+ _publisher!,
+ _serviceLoggerMock.Object,
+ serviceOptions);
+
+ using var cts = new CancellationTokenSource();
+
+ var serviceTask = service.StartAsync(cts.Token);
+
+ await Task.Delay(500);
+
+ cts.Cancel();
+
+ var act = async () => await service.StopAsync(CancellationToken.None);
+
+ await act.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public void Constructor_ShouldAcceptCustomParameters()
+ {
+ var serviceOptions = Options.Create(new DataGeneratorSettings
+ {
+ BatchSize = 100,
+ DelayBetweenBatchesMs = 10000,
+ DelayBetweenMessagesMs = 100,
+ CounterpartyTopic = _testSettings.CounterpartyTopic,
+ PropertyTopic = _testSettings.PropertyTopic,
+ RequestTopic = _testSettings.RequestTopic
+ });
+
+ var service = new DataGeneratorService(
+ _publisher!,
+ _serviceLoggerMock.Object,
+ serviceOptions);
+
+ service.Should().NotBeNull();
+ }
+}
+
+///
+/// Тесты проверки топиков
+///
+public class DataGeneratorServiceTopicsTests
+{
+ [Fact]
+ public void CounterpartyTopic_ShouldHaveCorrectValue()
+ {
+ var settings = new DataGeneratorSettings();
+ settings.CounterpartyTopic.Should().Be("realestate.counterparty.created");
+ }
+
+ [Fact]
+ public void PropertyTopic_ShouldHaveCorrectValue()
+ {
+ var settings = new DataGeneratorSettings();
+ settings.PropertyTopic.Should().Be("realestate.property.created");
+ }
+
+ [Fact]
+ public void RequestTopic_ShouldHaveCorrectValue()
+ {
+ var settings = new DataGeneratorSettings();
+ settings.RequestTopic.Should().Be("realestate.request.created");
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Generator.Tests/NatsIntegrationTests.cs b/RealEstateAgency.Generator.Tests/NatsIntegrationTests.cs
new file mode 100644
index 000000000..39764b945
--- /dev/null
+++ b/RealEstateAgency.Generator.Tests/NatsIntegrationTests.cs
@@ -0,0 +1,253 @@
+using System.Text.Json;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NATS.Client.Core;
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Generator.Generators;
+using RealEstateAgency.Generator.Services;
+using Testcontainers.Nats;
+
+namespace RealEstateAgency.Generator.Tests;
+
+///
+/// Интеграционные тесты NATS с использованием Testcontainers
+///
+[Collection("NatsIntegration")]
+public class NatsIntegrationTests : IAsyncLifetime
+{
+ private static readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly NatsContainer _natsContainer;
+ private NatsPublisher? _publisher;
+ private readonly Mock> _loggerMock;
+
+ public NatsIntegrationTests()
+ {
+ _natsContainer = new NatsBuilder()
+ .WithImage("nats:latest")
+ .Build();
+
+ _loggerMock = new Mock>();
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _natsContainer.StartAsync();
+ _publisher = new NatsPublisher(_natsContainer.GetConnectionString(), _loggerMock.Object);
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (_publisher != null)
+ {
+ await _publisher.DisposeAsync();
+ }
+ await _natsContainer.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task ConnectAsync_ShouldConnectToNats()
+ {
+ await _publisher!.ConnectAsync();
+
+ _loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Information,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("Успешное подключение")),
+ null,
+ It.IsAny>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task PublishAsync_ShouldPublishCounterparty()
+ {
+ var counterparty = ContractGenerator.GenerateCounterparty();
+ var receivedData = new TaskCompletionSource();
+
+ await _publisher!.ConnectAsync();
+
+ var options = new NatsOpts { Url = _natsContainer.GetConnectionString() };
+ await using var subscriber = new NatsConnection(options);
+ await subscriber.ConnectAsync();
+
+ var testTopic = "test.counterparty.topic";
+
+ var subscription = Task.Run(async () =>
+ {
+ await foreach (var msg in subscriber.SubscribeAsync(testTopic))
+ {
+ if (!string.IsNullOrEmpty(msg.Data))
+ {
+ var dto = JsonSerializer.Deserialize(msg.Data, _jsonOptions);
+ if (dto != null)
+ {
+ receivedData.SetResult(dto);
+ break;
+ }
+ }
+ }
+ });
+
+ await Task.Delay(100);
+
+ await _publisher.PublishAsync(testTopic, counterparty);
+
+ var received = await receivedData.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ received.FullName.Should().Be(counterparty.FullName);
+ received.PassportNumber.Should().Be(counterparty.PassportNumber);
+ received.PhoneNumber.Should().Be(counterparty.PhoneNumber);
+ }
+
+ [Fact]
+ public async Task PublishAsync_ShouldPublishProperty()
+ {
+ var property = ContractGenerator.GenerateProperty();
+ var receivedData = new TaskCompletionSource();
+
+ await _publisher!.ConnectAsync();
+
+ var options = new NatsOpts { Url = _natsContainer.GetConnectionString() };
+ await using var subscriber = new NatsConnection(options);
+ await subscriber.ConnectAsync();
+
+ var subscription = Task.Run(async () =>
+ {
+ await foreach (var msg in subscriber.SubscribeAsync("test.property"))
+ {
+ if (!string.IsNullOrEmpty(msg.Data))
+ {
+ var dto = JsonSerializer.Deserialize(msg.Data, _jsonOptions);
+ if (dto != null)
+ {
+ receivedData.SetResult(dto);
+ break;
+ }
+ }
+ }
+ });
+
+ await Task.Delay(100);
+
+ await _publisher.PublishAsync("test.property", property);
+
+ var received = await receivedData.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ received.Address.Should().Be(property.Address);
+ received.CadastralNumber.Should().Be(property.CadastralNumber);
+ received.Type.Should().Be(property.Type);
+ }
+
+ [Fact]
+ public async Task PublishStreamAsync_ShouldPublishMultipleMessages()
+ {
+ var messages = new List
+ {
+ ContractGenerator.GenerateCounterparty(),
+ ContractGenerator.GenerateCounterparty(),
+ ContractGenerator.GenerateCounterparty()
+ };
+
+ var receivedMessages = new List();
+ var allReceived = new TaskCompletionSource();
+
+ await _publisher!.ConnectAsync();
+
+ var options = new NatsOpts { Url = _natsContainer.GetConnectionString() };
+ await using var subscriber = new NatsConnection(options);
+ await subscriber.ConnectAsync();
+
+ var subscription = Task.Run(async () =>
+ {
+ await foreach (var msg in subscriber.SubscribeAsync("test.stream"))
+ {
+ if (!string.IsNullOrEmpty(msg.Data))
+ {
+ var dto = JsonSerializer.Deserialize(msg.Data, _jsonOptions);
+ if (dto != null)
+ {
+ receivedMessages.Add(dto);
+ if (receivedMessages.Count >= 3)
+ {
+ allReceived.SetResult(true);
+ break;
+ }
+ }
+ }
+ }
+ });
+
+ await Task.Delay(100);
+
+ await _publisher.PublishStreamAsync("test.stream", ToAsyncEnumerable(messages));
+
+ await allReceived.Task.WaitAsync(TimeSpan.FromSeconds(10));
+ receivedMessages.Should().HaveCount(3);
+ }
+
+ private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable source)
+ {
+ foreach (var item in source)
+ {
+ await Task.Yield();
+ yield return item;
+ }
+ }
+}
+
+///
+/// Тесты NatsPublisher с мокированием (без реального NATS)
+///
+public class NatsPublisherUnitTests
+{
+ [Fact]
+ public async Task ConnectAsync_WithInvalidUrl_ShouldRetryAndEventuallyFail()
+ {
+ var loggerMock = new Mock>();
+ var publisher = new NatsPublisher("nats://invalid-host:9999", loggerMock.Object);
+
+ var act = async () =>
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ await publisher.ConnectAsync(cts.Token);
+ };
+
+ await act.Should().ThrowAsync();
+
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((v, t) => v.ToString()!.Contains("Попытка подключения")),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.AtLeastOnce);
+
+ await publisher.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task DisposeAsync_ShouldNotThrow()
+ {
+ var loggerMock = new Mock>();
+ var publisher = new NatsPublisher("nats://localhost:4222", loggerMock.Object);
+
+ var act = async () => await publisher.DisposeAsync();
+ await act.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public async Task DisposeAsync_CalledTwice_ShouldNotThrow()
+ {
+ var loggerMock = new Mock>();
+ var publisher = new NatsPublisher("nats://localhost:4222", loggerMock.Object);
+
+ await publisher.DisposeAsync();
+ var act = async () => await publisher.DisposeAsync();
+ await act.Should().NotThrowAsync();
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Generator.Tests/RealEstateAgency.Generator.Tests.csproj b/RealEstateAgency.Generator.Tests/RealEstateAgency.Generator.Tests.csproj
new file mode 100644
index 000000000..07204b653
--- /dev/null
+++ b/RealEstateAgency.Generator.Tests/RealEstateAgency.Generator.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency.Generator.Tests/TestSettingsHelper.cs b/RealEstateAgency.Generator.Tests/TestSettingsHelper.cs
new file mode 100644
index 000000000..d3be754d3
--- /dev/null
+++ b/RealEstateAgency.Generator.Tests/TestSettingsHelper.cs
@@ -0,0 +1,47 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using RealEstateAgency.Generator.Services;
+
+namespace RealEstateAgency.Generator.Tests;
+
+///
+/// Вспомогательный класс для настроек тестов
+///
+public static class TestSettingsHelper
+{
+ public const string CounterpartyTopic = "realestate.counterparty.created";
+ public const string PropertyTopic = "realestate.property.created";
+ public const string RequestTopic = "realestate.request.created";
+
+ ///
+ /// Создает настройки для тестов
+ ///
+ public static IOptions CreateTestOptions(
+ int batchSize = 10,
+ int delayBetweenBatchesMs = 5000,
+ int delayBetweenMessagesMs = 100)
+ {
+ return Options.Create(new DataGeneratorSettings
+ {
+ BatchSize = batchSize,
+ DelayBetweenBatchesMs = delayBetweenBatchesMs,
+ DelayBetweenMessagesMs = delayBetweenMessagesMs,
+ CounterpartyTopic = CounterpartyTopic,
+ PropertyTopic = PropertyTopic,
+ RequestTopic = RequestTopic
+ });
+ }
+
+ ///
+ /// Создает экземпляр DataGeneratorService для тестов
+ ///
+ public static DataGeneratorService CreateTestService(
+ NatsPublisher publisher,
+ ILogger logger,
+ int batchSize = 10,
+ int delayBetweenBatchesMs = 5000)
+ {
+ var options = CreateTestOptions(batchSize, delayBetweenBatchesMs);
+ return new DataGeneratorService(publisher, logger, options);
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Generator/Generators/ContractGenerator.cs b/RealEstateAgency.Generator/Generators/ContractGenerator.cs
new file mode 100644
index 000000000..25cf19ade
--- /dev/null
+++ b/RealEstateAgency.Generator/Generators/ContractGenerator.cs
@@ -0,0 +1,156 @@
+using Bogus;
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Domain.Enums;
+
+namespace RealEstateAgency.Generator.Generators;
+
+///
+/// Генератор контрактов недвижимости с использованием Bogus
+///
+public static class ContractGenerator
+{
+ private static readonly string[] _russianFirstNames =
+ [
+ "Александр", "Дмитрий", "Максим", "Сергей", "Андрей", "Алексей", "Артём", "Илья", "Кирилл", "Михаил",
+ "Никита", "Матвей", "Роман", "Егор", "Арсений", "Иван", "Денис", "Евгений", "Даниил", "Тимофей",
+ "Анна", "Мария", "Елена", "Дарья", "Алина", "Ирина", "Екатерина", "Ольга", "Татьяна", "Наталья",
+ "Юлия", "Виктория", "Марина", "Светлана", "Анастасия", "Полина", "Софья", "Валерия", "Ксения", "Вера"
+ ];
+
+ private static readonly string[] _russianLastNames =
+ [
+ "Иванов", "Смирнов", "Кузнецов", "Попов", "Васильев", "Петров", "Соколов", "Михайлов", "Новиков", "Федоров",
+ "Морозов", "Волков", "Алексеев", "Лебедев", "Семёнов", "Егоров", "Павлов", "Козлов", "Степанов", "Николаев",
+ "Орлов", "Андреев", "Макаров", "Никитин", "Захаров", "Зайцев", "Соловьёв", "Борисов", "Яковлев", "Григорьев"
+ ];
+
+ private static readonly string[] _russianPatronymics =
+ [
+ "Александрович", "Дмитриевич", "Сергеевич", "Андреевич", "Алексеевич", "Михайлович", "Владимирович", "Николаевич",
+ "Александровна", "Дмитриевна", "Сергеевна", "Андреевна", "Алексеевна", "Михайловна", "Владимировна", "Николаевна"
+ ];
+
+ private static readonly Faker _faker = new("ru");
+
+ ///
+ /// Генерирует DTO контрагента
+ ///
+ public static CreateCounterpartyDto GenerateCounterparty()
+ {
+ var firstName = _faker.PickRandom(_russianFirstNames);
+ var lastName = _faker.PickRandom(_russianLastNames);
+ var patronymic = _faker.PickRandom(_russianPatronymics);
+
+ return new CreateCounterpartyDto
+ {
+ FullName = $"{lastName} {firstName} {patronymic}",
+ PassportNumber = $"{_faker.Random.Number(1000, 9999)} {_faker.Random.Number(100000, 999999)}",
+ PhoneNumber = _faker.Phone.PhoneNumber("+7(9##)###-##-##")
+ };
+ }
+
+ ///
+ /// Генерирует DTO объекта недвижимости
+ ///
+ public static CreateRealEstatePropertyDto GenerateProperty()
+ {
+ var propertyType = _faker.PickRandom();
+ var purpose = propertyType switch
+ {
+ PropertyType.Apartment or PropertyType.House or PropertyType.Townhouse => PropertyPurpose.Residential,
+ PropertyType.Commercial => PropertyPurpose.Commercial,
+ PropertyType.Warehouse => PropertyPurpose.Industrial,
+ PropertyType.ParkingSpace => _faker.PickRandom(),
+ _ => PropertyPurpose.Residential
+ };
+
+ var address = _faker.Address;
+ var city = address.City();
+ var street = address.StreetName();
+ var houseNumber = address.BuildingNumber();
+
+ var apartment = propertyType == PropertyType.Apartment
+ ? $", кв. {_faker.Random.Number(1, 500)}"
+ : "";
+
+ var cadastralNumber = $"{_faker.Random.Number(10, 99)}:{_faker.Random.Number(10, 99)}:" +
+ $"{_faker.Random.Number(1000000, 9999999)}:{_faker.Random.Number(100, 999)}";
+
+ var totalFloors = propertyType switch
+ {
+ PropertyType.Apartment => _faker.Random.Number(5, 25),
+ PropertyType.House => _faker.Random.Number(1, 4),
+ PropertyType.Townhouse => _faker.Random.Number(2, 4),
+ PropertyType.Commercial => _faker.Random.Number(1, 10),
+ PropertyType.Warehouse => _faker.Random.Number(1, 3),
+ PropertyType.ParkingSpace => (int?)null,
+ _ => _faker.Random.Number(1, 5)
+ };
+
+ var floor = totalFloors.HasValue ? _faker.Random.Number(1, totalFloors.Value) : (int?)null;
+
+ var roomsCount = propertyType switch
+ {
+ PropertyType.Apartment => _faker.Random.Number(1, 5),
+ PropertyType.House => _faker.Random.Number(3, 10),
+ PropertyType.Townhouse => _faker.Random.Number(3, 8),
+ _ => (int?)null
+ };
+
+ var totalArea = propertyType switch
+ {
+ PropertyType.Apartment => _faker.Random.Double(25, 150),
+ PropertyType.House => _faker.Random.Double(80, 500),
+ PropertyType.Townhouse => _faker.Random.Double(100, 300),
+ PropertyType.Commercial => _faker.Random.Double(50, 1000),
+ PropertyType.Warehouse => _faker.Random.Double(200, 5000),
+ PropertyType.ParkingSpace => _faker.Random.Double(12, 30),
+ _ => _faker.Random.Double(30, 100)
+ };
+
+ return new CreateRealEstatePropertyDto
+ {
+ Type = propertyType,
+ Purpose = purpose,
+ CadastralNumber = cadastralNumber,
+ Address = $"г. {city}, ул. {street}, д. {houseNumber}{apartment}",
+ TotalFloors = totalFloors,
+ TotalArea = Math.Round(totalArea, 2),
+ RoomsCount = roomsCount,
+ CeilingHeight = _faker.Random.Bool(0.7f) ? Math.Round(_faker.Random.Double(2.5, 4.0), 2) : null,
+ Floor = floor,
+ HasEncumbrances = _faker.Random.Bool(0.1f)
+ };
+ }
+
+ ///
+ /// Генерирует DTO заявки
+ ///
+ public static CreateRequestDto GenerateRequest(Guid counterpartyId, Guid propertyId)
+ {
+ var requestType = _faker.PickRandom();
+
+ var baseAmount = _faker.Random.Decimal(1_000_000, 50_000_000);
+
+ return new CreateRequestDto
+ {
+ CounterpartyId = counterpartyId,
+ PropertyId = propertyId,
+ Type = requestType,
+ Amount = Math.Round(baseAmount, 2),
+ Date = _faker.Date.Between(DateTime.Now.AddYears(-2), DateTime.Now)
+ };
+ }
+
+ ///
+ /// Генерирует пакет данных: контрагент + недвижимость + заявка
+ ///
+ public static GeneratedDataPackage GenerateDataPackage()
+ {
+ return new GeneratedDataPackage
+ {
+ Counterparty = GenerateCounterparty(),
+ Property = GenerateProperty()
+ };
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Generator/Generators/GeneratedDataPackage.cs b/RealEstateAgency.Generator/Generators/GeneratedDataPackage.cs
new file mode 100644
index 000000000..29ee80f50
--- /dev/null
+++ b/RealEstateAgency.Generator/Generators/GeneratedDataPackage.cs
@@ -0,0 +1,12 @@
+using RealEstateAgency.Contracts.Dto;
+
+namespace RealEstateAgency.Generator.Generators;
+
+///
+/// Пакет сгенерированных данных для отправки
+///
+public class GeneratedDataPackage
+{
+ public required CreateCounterpartyDto Counterparty { get; init; }
+ public required CreateRealEstatePropertyDto Property { get; init; }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Generator/Program.cs b/RealEstateAgency.Generator/Program.cs
new file mode 100644
index 000000000..c118d4540
--- /dev/null
+++ b/RealEstateAgency.Generator/Program.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.Options;
+using RealEstateAgency.Generator.Services;
+using RealEstateAgency.ServiceDefaults;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+builder.AddServiceDefaults();
+
+var natsConnectionString = "nats://localhost:4222";
+
+builder.Services.Configure(
+ builder.Configuration.GetSection(DataGeneratorSettings.SectionName));
+
+builder.Services.AddSingleton(sp =>
+ new NatsPublisher(
+ natsConnectionString,
+ sp.GetRequiredService>()));
+
+builder.Services.AddHostedService();
+
+var host = builder.Build();
+
+var settings = host.Services.GetRequiredService>().Value;
+var logger = host.Services.GetRequiredService>();
+
+logger.LogInformation("=== RealEstateAgency Data Generator ===");
+logger.LogInformation("NATS Connection: {Connection}", natsConnectionString);
+logger.LogInformation("Batch Size: {BatchSize}", settings.BatchSize);
+logger.LogInformation("Delay between batches: {Delay}ms", settings.DelayBetweenBatchesMs);
+
+await host.RunAsync();
\ No newline at end of file
diff --git a/RealEstateAgency.Generator/Properties/launchSettings.json b/RealEstateAgency.Generator/Properties/launchSettings.json
new file mode 100644
index 000000000..2c916ffa0
--- /dev/null
+++ b/RealEstateAgency.Generator/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "RealEstateAgency.Generator": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/RealEstateAgency.Generator/RealEstateAgency.Generator.csproj b/RealEstateAgency.Generator/RealEstateAgency.Generator.csproj
new file mode 100644
index 000000000..8d445e263
--- /dev/null
+++ b/RealEstateAgency.Generator/RealEstateAgency.Generator.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ enable
+ enable
+ dotnet-RealEstateAgency.Generator-ad549a50-c7d7-453f-848d-247ffdc6c738
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency.Generator/Services/DataGeneratorService.cs b/RealEstateAgency.Generator/Services/DataGeneratorService.cs
new file mode 100644
index 000000000..d9ecf23fe
--- /dev/null
+++ b/RealEstateAgency.Generator/Services/DataGeneratorService.cs
@@ -0,0 +1,106 @@
+using Microsoft.Extensions.Options;
+using RealEstateAgency.Generator.Generators;
+
+namespace RealEstateAgency.Generator.Services;
+
+///
+/// Фоновый сервис для потоковой генерации и отправки контрактов в NATS
+///
+public class DataGeneratorService(
+ NatsPublisher natsPublisher,
+ ILogger logger,
+ IOptions options) : BackgroundService
+{
+ private readonly ILogger _logger = logger;
+ private readonly DataGeneratorSettings _settings = options.Value;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation(
+ "Запуск сервиса генерации данных. Размер пакета: {BatchSize}, задержка: {Delay}мс",
+ _settings.BatchSize,
+ _settings.DelayBetweenBatchesMs);
+
+ try
+ {
+ await natsPublisher.ConnectAsync(stoppingToken);
+
+ _logger.LogInformation("Начало потоковой генерации контрактов");
+
+ var totalGenerated = 0;
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await GenerateAndSendBatchAsync(_settings.BatchSize, stoppingToken);
+
+ totalGenerated += _settings.BatchSize;
+ _logger.LogInformation(
+ "Сгенерировано и отправлено {BatchSize} контрактов. Всего: {Total}",
+ _settings.BatchSize,
+ totalGenerated);
+
+ await Task.Delay(_settings.DelayBetweenBatchesMs, stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Ошибка при генерации пакета данных");
+ await Task.Delay(5000, stoppingToken);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("Сервис генерации данных остановлен");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogCritical(ex, "Критическая ошибка в сервисе генерации данных");
+ throw;
+ }
+ }
+
+ ///
+ /// Генерация и отправка пакета данных в потоковом режиме
+ ///
+ private async Task GenerateAndSendBatchAsync(int count, CancellationToken cancellationToken)
+ {
+ await foreach (var data in GenerateDataStreamAsync(count).WithCancellation(cancellationToken))
+ {
+ await natsPublisher.PublishAsync(_settings.CounterpartyTopic, data.Counterparty, cancellationToken);
+ _logger.LogDebug("Отправлен контрагент: {FullName}", data.Counterparty.FullName);
+
+ await Task.Delay(_settings.DelayBetweenMessagesMs, cancellationToken);
+
+ await natsPublisher.PublishAsync(_settings.PropertyTopic, data.Property, cancellationToken);
+ _logger.LogDebug("Отправлена недвижимость: {Address}", data.Property.Address);
+
+ await Task.Delay(_settings.DelayBetweenMessagesMs, cancellationToken);
+ }
+ }
+
+ ///
+ /// Асинхронный генератор данных (потоковая генерация)
+ ///
+ private static async IAsyncEnumerable GenerateDataStreamAsync(int count)
+ {
+ for (var i = 0; i < count; i++)
+ {
+ await Task.Yield();
+
+ yield return ContractGenerator.GenerateDataPackage();
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Остановка сервиса генерации данных...");
+ await base.StopAsync(cancellationToken);
+ await natsPublisher.DisposeAsync();
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Generator/Services/DataGeneratorSettings.cs b/RealEstateAgency.Generator/Services/DataGeneratorSettings.cs
new file mode 100644
index 000000000..02178154b
--- /dev/null
+++ b/RealEstateAgency.Generator/Services/DataGeneratorSettings.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.Options;
+
+namespace RealEstateAgency.Generator.Services;
+
+///
+/// Настройки сервиса генерации данных
+///
+public class DataGeneratorSettings
+{
+ public const string SectionName = "DataGenerator";
+
+ public int BatchSize { get; set; } = 10;
+ public int DelayBetweenBatchesMs { get; set; } = 5000;
+ public int DelayBetweenMessagesMs { get; set; } = 100;
+
+ public string CounterpartyTopic { get; set; } = "realestate.counterparty.created";
+ public string PropertyTopic { get; set; } = "realestate.property.created";
+ public string RequestTopic { get; set; } = "realestate.request.created";
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Generator/Services/NatsPublisher.cs b/RealEstateAgency.Generator/Services/NatsPublisher.cs
new file mode 100644
index 000000000..892af2fdf
--- /dev/null
+++ b/RealEstateAgency.Generator/Services/NatsPublisher.cs
@@ -0,0 +1,145 @@
+using NATS.Client.Core;
+using Polly;
+using Polly.Retry;
+using System.Text.Json;
+
+namespace RealEstateAgency.Generator.Services;
+
+///
+/// Сервис публикации сообщений в NATS с поддержкой ретраев
+///
+public class NatsPublisher : IAsyncDisposable
+{
+ private static readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly ILogger _logger;
+ private readonly string _connectionString;
+ private NatsConnection? _connection;
+ private readonly ResiliencePipeline _retryPipeline;
+ private readonly SemaphoreSlim _connectionLock = new(1, 1);
+ private bool _disposed;
+
+ public NatsPublisher(string connectionString, ILogger logger)
+ {
+ _connectionString = connectionString;
+ _logger = logger;
+
+ _retryPipeline = new ResiliencePipelineBuilder()
+ .AddRetry(new RetryStrategyOptions
+ {
+ MaxRetryAttempts = 10,
+ Delay = TimeSpan.FromSeconds(2),
+ BackoffType = DelayBackoffType.Exponential,
+ MaxDelay = TimeSpan.FromMinutes(2),
+ OnRetry = args =>
+ {
+ _logger.LogWarning(
+ "Попытка подключения к NATS #{AttemptNumber} не удалась. " +
+ "Следующая попытка через {Delay}. Ошибка: {Error}",
+ args.AttemptNumber + 1,
+ args.RetryDelay,
+ args.Outcome.Exception?.Message);
+ return ValueTask.CompletedTask;
+ }
+ })
+ .Build();
+ }
+
+ ///
+ /// Подключение к NATS с ретраями
+ ///
+ public async Task ConnectAsync(CancellationToken cancellationToken = default)
+ {
+ await _connectionLock.WaitAsync(cancellationToken);
+ try
+ {
+ if (_connection != null)
+ return;
+
+ await _retryPipeline.ExecuteAsync(async ct =>
+ {
+ _logger.LogInformation("Подключение к NATS: {ConnectionString}", _connectionString);
+
+ var options = new NatsOpts
+ {
+ Url = _connectionString,
+ Name = "RealEstateAgency.Generator",
+ ConnectTimeout = TimeSpan.FromSeconds(10)
+ };
+
+ _connection = new NatsConnection(options);
+ await _connection.ConnectAsync();
+
+ _logger.LogInformation("Успешное подключение к NATS");
+ }, cancellationToken);
+ }
+ finally
+ {
+ _connectionLock.Release();
+ }
+ }
+
+ ///
+ /// Публикация сообщения с гарантией доставки
+ ///
+ public async Task PublishAsync(string subject, T data, CancellationToken cancellationToken = default)
+ {
+ if (_connection == null)
+ {
+ await ConnectAsync(cancellationToken);
+ }
+
+ await _retryPipeline.ExecuteAsync(async ct =>
+ {
+ if (_connection == null)
+ throw new InvalidOperationException("Нет подключения к NATS");
+
+ var jsonData = JsonSerializer.Serialize(data, _jsonOptions);
+
+ await _connection.PublishAsync(subject, jsonData, cancellationToken: ct);
+
+ _logger.LogDebug("Опубликовано сообщение в {Subject}", subject);
+ }, cancellationToken);
+ }
+
+ ///
+ /// Потоковая публикация последовательности сообщений
+ ///
+ public async Task PublishStreamAsync(
+ string subject,
+ IAsyncEnumerable dataStream,
+ CancellationToken cancellationToken = default)
+ {
+ if (_connection == null)
+ {
+ await ConnectAsync(cancellationToken);
+ }
+
+ await foreach (var data in dataStream.WithCancellation(cancellationToken))
+ {
+ await PublishAsync(subject, data, cancellationToken);
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+
+ if (_connection != null)
+ {
+ await _connection.DisposeAsync();
+ _connection = null;
+ }
+
+ _connectionLock.Dispose();
+
+ GC.SuppressFinalize(this);
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Generator/appsettings.Development.json b/RealEstateAgency.Generator/appsettings.Development.json
new file mode 100644
index 000000000..b2dcdb674
--- /dev/null
+++ b/RealEstateAgency.Generator/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/RealEstateAgency.Generator/appsettings.json b/RealEstateAgency.Generator/appsettings.json
new file mode 100644
index 000000000..e4bc18a7e
--- /dev/null
+++ b/RealEstateAgency.Generator/appsettings.json
@@ -0,0 +1,19 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "DataGenerator": {
+ "BatchSize": 10,
+ "DelayBetweenBatchesMs": 5000,
+ "DelayBetweenMessagesMs": 100,
+ "CounterpartyTopic": "realestate.counterparty.created",
+ "PropertyTopic": "realestate.property.created",
+ "RequestTopic": "realestate.request.created"
+ },
+ "Nats": {
+ "Url": "nats://nats:4222"
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs b/RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs
new file mode 100644
index 000000000..05489fb54
--- /dev/null
+++ b/RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs
@@ -0,0 +1,97 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using RealEstateAgency.Domain.Enums;
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Infrastructure.Persistence;
+
+///
+/// Сервис для начального заполнения базы данных
+///
+public class DatabaseSeeder(RealEstateDbContext context, ILogger logger)
+{
+
+ ///
+ /// Заполняет базу данных начальными данными, если она пуста
+ ///
+ public async Task SeedAsync()
+ {
+ var counterpartiesCount = await context.Counterparties.CountAsync();
+ if (counterpartiesCount > 0)
+ {
+ logger.LogInformation("База данных уже содержит данные, seed пропущен");
+ return;
+ }
+
+ logger.LogInformation("Начало заполнения базы данных...");
+
+ var counterparties = GenerateCounterparties();
+ await context.Counterparties.AddRangeAsync(counterparties);
+ await context.SaveChangesAsync();
+ logger.LogInformation("Добавлено {Count} контрагентов", counterparties.Count);
+
+ var properties = GenerateProperties();
+ await context.Properties.AddRangeAsync(properties);
+ await context.SaveChangesAsync();
+ logger.LogInformation("Добавлено {Count} объектов недвижимости", properties.Count);
+
+ var requests = GenerateRequests(counterparties, properties);
+ await context.Requests.AddRangeAsync(requests);
+ await context.SaveChangesAsync();
+ logger.LogInformation("Добавлено {Count} заявок", requests.Count);
+
+ logger.LogInformation("Заполнение базы данных завершено");
+ }
+
+ private static List GenerateCounterparties() =>
+ [
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-000000000001"), FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-000000000002"), FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-000000000003"), FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-000000000004"), FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-000000000005"), FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-000000000006"), FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-000000000007"), FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-000000000008"), FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-000000000009"), FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-00000000000a"), FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-00000000000b"), FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" },
+ new() { Id = Guid.Parse("00000000-0000-0000-0000-00000000000c"), FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" }
+ ];
+
+ private static List GenerateProperties() =>
+ [
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), Type = PropertyType.Apartment, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:01:0001001:101", Address = "ул. Тверская, 15, кв. 34", TotalFloors = 9, TotalArea = 75.5, RoomsCount = 3, CeilingHeight = 2.7, Floor = 5, HasEncumbrances = false },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), Type = PropertyType.Apartment, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:01:0001002:102", Address = "ул. Арбат, 25, кв. 12", TotalFloors = 5, TotalArea = 45.0, RoomsCount = 2, CeilingHeight = 2.5, Floor = 3, HasEncumbrances = true },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-000000000003"), Type = PropertyType.Apartment, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:01:0001003:103", Address = "пр-т Мира, 10, кв. 78", TotalFloors = 12, TotalArea = 90.0, RoomsCount = 4, CeilingHeight = 2.8, Floor = 8, HasEncumbrances = false },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-000000000004"), Type = PropertyType.House, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:02:0002001:201", Address = "Московская обл., коттеджный поселок 'Лесной', д. 12", TotalFloors = 2, TotalArea = 150.0, RoomsCount = 6, CeilingHeight = 3.0, Floor = null, HasEncumbrances = false },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-000000000005"), Type = PropertyType.House, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:02:0002002:202", Address = "Московская обл., д. Пушкино, ул. Садовая, 5", TotalFloors = 1, TotalArea = 80.0, RoomsCount = 4, CeilingHeight = 2.6, Floor = null, HasEncumbrances = true },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-000000000006"), Type = PropertyType.Townhouse, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:03:0003001:301", Address = "пос. Рублево, таунхаусный комплекс 'Резиденция', к. 7", TotalFloors = 3, TotalArea = 120.0, RoomsCount = 5, CeilingHeight = 2.7, Floor = null, HasEncumbrances = false },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-000000000007"), Type = PropertyType.Townhouse, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:03:0003002:302", Address = "пос. Барвиха, таунхаусный комплекс 'Престиж', к. 3", TotalFloors = 2, TotalArea = 95.0, RoomsCount = 4, CeilingHeight = 2.8, Floor = null, HasEncumbrances = false },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-000000000008"), Type = PropertyType.Commercial, Purpose = PropertyPurpose.Commercial, CadastralNumber = "77:05:0005001:501", Address = "ул. Новый Арбат, 15, офис 300", TotalFloors = 10, TotalArea = 60.0, RoomsCount = 2, CeilingHeight = 2.8, Floor = 3, HasEncumbrances = false },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-000000000009"), Type = PropertyType.Commercial, Purpose = PropertyPurpose.Commercial, CadastralNumber = "77:05:0005002:502", Address = "ул. Тверская-Ямская, 8, магазин", TotalFloors = 3, TotalArea = 85.0, RoomsCount = 1, CeilingHeight = 3.2, Floor = 1, HasEncumbrances = true },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-00000000000a"), Type = PropertyType.ParkingSpace, Purpose = PropertyPurpose.Commercial, CadastralNumber = "77:06:0006001:601", Address = "ул. Садовая-Кудринская, 1, подземный паркинг, место А-15", TotalFloors = null, TotalArea = 12.5, RoomsCount = null, CeilingHeight = 2.2, Floor = -1, HasEncumbrances = true },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-00000000000b"), Type = PropertyType.ParkingSpace, Purpose = PropertyPurpose.Commercial, CadastralNumber = "77:06:0006002:602", Address = "ул. Мясницкая, 20, паркинг, место Б-07", TotalFloors = null, TotalArea = 13.0, RoomsCount = null, CeilingHeight = 2.3, Floor = -2, HasEncumbrances = false },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-00000000000c"), Type = PropertyType.Warehouse, Purpose = PropertyPurpose.Industrial, CadastralNumber = "77:07:0007001:701", Address = "промзона 'Южные Ворота', складской комплекс №3", TotalFloors = 1, TotalArea = 500.0, RoomsCount = null, CeilingHeight = 6.0, Floor = null, HasEncumbrances = false },
+ new() { Id = Guid.Parse("10000000-0000-0000-0000-00000000000d"), Type = PropertyType.Warehouse, Purpose = PropertyPurpose.Industrial, CadastralNumber = "77:07:0007002:702", Address = "промзона 'Северная', склад №5", TotalFloors = 2, TotalArea = 350.0, RoomsCount = null, CeilingHeight = 5.5, Floor = null, HasEncumbrances = true }
+ ];
+
+ private static List GenerateRequests(List counterparties, List properties) =>
+ [
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000001"), CounterpartyId = counterparties[0].Id, Counterparty = counterparties[0], PropertyId = properties[0].Id, Property = properties[0], Type = RequestType.Sale, Amount = 25000000.00m, Date = new DateTime(2024, 1, 15) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000002"), CounterpartyId = counterparties[1].Id, Counterparty = counterparties[1], PropertyId = properties[1].Id, Property = properties[1], Type = RequestType.Sale, Amount = 18000000.00m, Date = new DateTime(2024, 2, 20) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000003"), CounterpartyId = counterparties[3].Id, Counterparty = counterparties[3], PropertyId = properties[3].Id, Property = properties[3], Type = RequestType.Sale, Amount = 42000000.00m, Date = new DateTime(2024, 3, 10) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000004"), CounterpartyId = counterparties[6].Id, Counterparty = counterparties[6], PropertyId = properties[5].Id, Property = properties[5], Type = RequestType.Sale, Amount = 35000000.00m, Date = new DateTime(2024, 4, 5) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000005"), CounterpartyId = counterparties[8].Id, Counterparty = counterparties[8], PropertyId = properties[7].Id, Property = properties[7], Type = RequestType.Sale, Amount = 32000000.00m, Date = new DateTime(2024, 5, 12) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000006"), CounterpartyId = counterparties[10].Id, Counterparty = counterparties[10], PropertyId = properties[9].Id, Property = properties[9], Type = RequestType.Sale, Amount = 1500000.00m, Date = new DateTime(2024, 6, 8) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000007"), CounterpartyId = counterparties[11].Id, Counterparty = counterparties[11], PropertyId = properties[11].Id, Property = properties[11], Type = RequestType.Sale, Amount = 85000000.00m, Date = new DateTime(2024, 7, 25) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000008"), CounterpartyId = counterparties[2].Id, Counterparty = counterparties[2], PropertyId = properties[2].Id, Property = properties[2], Type = RequestType.Purchase, Amount = 22000000.00m, Date = new DateTime(2024, 1, 20) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000009"), CounterpartyId = counterparties[4].Id, Counterparty = counterparties[4], PropertyId = properties[4].Id, Property = properties[4], Type = RequestType.Purchase, Amount = 15000000.00m, Date = new DateTime(2024, 2, 25) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000a"), CounterpartyId = counterparties[5].Id, Counterparty = counterparties[5], PropertyId = properties[6].Id, Property = properties[6], Type = RequestType.Purchase, Amount = 28000000.00m, Date = new DateTime(2024, 3, 15) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000b"), CounterpartyId = counterparties[7].Id, Counterparty = counterparties[7], PropertyId = properties[8].Id, Property = properties[8], Type = RequestType.Purchase, Amount = 25000000.00m, Date = new DateTime(2024, 4, 18) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000c"), CounterpartyId = counterparties[9].Id, Counterparty = counterparties[9], PropertyId = properties[10].Id, Property = properties[10], Type = RequestType.Purchase, Amount = 1800000.00m, Date = new DateTime(2024, 5, 22) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000d"), CounterpartyId = counterparties[2].Id, Counterparty = counterparties[2], PropertyId = properties[12].Id, Property = properties[12], Type = RequestType.Purchase, Amount = 60000000.00m, Date = new DateTime(2024, 6, 30) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000e"), CounterpartyId = counterparties[1].Id, Counterparty = counterparties[1], PropertyId = properties[0].Id, Property = properties[0], Type = RequestType.Purchase, Amount = 24000000.00m, Date = new DateTime(2024, 8, 10) },
+ new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000f"), CounterpartyId = counterparties[3].Id, Counterparty = counterparties[3], PropertyId = properties[1].Id, Property = properties[1], Type = RequestType.Sale, Amount = 19000000.00m, Date = new DateTime(2024, 9, 5) }
+ ];
+}
diff --git a/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs b/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs
new file mode 100644
index 000000000..f1b7922e6
--- /dev/null
+++ b/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs
@@ -0,0 +1,61 @@
+using Microsoft.EntityFrameworkCore;
+using MongoDB.EntityFrameworkCore.Extensions;
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Infrastructure.Persistence;
+
+///
+/// Контекст базы данных для работы с MongoDB через EF Core
+///
+public class RealEstateDbContext : DbContext
+{
+ public RealEstateDbContext(DbContextOptions options)
+ : base(options)
+ {
+ Database.AutoTransactionBehavior = AutoTransactionBehavior.Never;
+ }
+
+ ///
+ /// Коллекция контрагентов
+ ///
+ public DbSet Counterparties { get; set; } = null!;
+
+ ///
+ /// Коллекция объектов недвижимости
+ ///
+ public DbSet Properties { get; set; } = null!;
+
+ ///
+ /// Коллекция заявок
+ ///
+ public DbSet Requests { get; set; } = null!;
+
+ ///
+ /// Конфигурация моделей для MongoDB
+ ///
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToCollection("counterparties");
+ entity.HasKey(c => c.Id);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToCollection("properties");
+ entity.HasKey(p => p.Id);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToCollection("requests");
+ entity.HasKey(r => r.Id);
+
+ entity.Ignore(r => r.Counterparty);
+ entity.Ignore(r => r.Property);
+ });
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj b/RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj
new file mode 100644
index 000000000..c978f1c76
--- /dev/null
+++ b/RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency.Infrastructure/Repositories/InMemoryCounterpartyRepository.cs b/RealEstateAgency.Infrastructure/Repositories/InMemoryCounterpartyRepository.cs
new file mode 100644
index 000000000..32b9c279d
--- /dev/null
+++ b/RealEstateAgency.Infrastructure/Repositories/InMemoryCounterpartyRepository.cs
@@ -0,0 +1,69 @@
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Infrastructure.Repositories;
+
+///
+/// In-memory реализация репозитория контрагентов
+///
+public class InMemoryCounterpartyRepository : ICounterpartyRepository
+{
+ private readonly List _counterparties = [];
+
+ public InMemoryCounterpartyRepository()
+ {
+ SeedData();
+ }
+
+ private void SeedData()
+ {
+ var seedData = new[]
+ {
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-000000000001"), FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-000000000002"), FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-000000000003"), FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-000000000004"), FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-000000000005"), FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-000000000006"), FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-000000000007"), FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-000000000008"), FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-000000000009"), FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-00000000000a"), FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-00000000000b"), FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" },
+ new Counterparty { Id = Guid.Parse("00000000-0000-0000-0000-00000000000c"), FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" }
+ };
+
+ _counterparties.AddRange(seedData);
+ }
+
+ public Task> GetAllAsync() => Task.FromResult>(_counterparties);
+
+ public Task GetByIdAsync(Guid id) => Task.FromResult(_counterparties.FirstOrDefault(c => c.Id == id));
+
+ public Task AddAsync(Counterparty counterparty)
+ {
+ counterparty.Id = Guid.NewGuid();
+ _counterparties.Add(counterparty);
+ return Task.FromResult(counterparty);
+ }
+
+ public Task UpdateAsync(Guid id, Counterparty counterparty)
+ {
+ var existing = _counterparties.FirstOrDefault(c => c.Id == id);
+ if (existing == null) return Task.FromResult(null);
+
+ existing.FullName = counterparty.FullName;
+ existing.PassportNumber = counterparty.PassportNumber;
+ existing.PhoneNumber = counterparty.PhoneNumber;
+ return Task.FromResult(existing);
+ }
+
+ public Task DeleteAsync(Guid id)
+ {
+ var counterparty = _counterparties.FirstOrDefault(c => c.Id == id);
+ if (counterparty == null) return Task.FromResult(false);
+
+ _counterparties.Remove(counterparty);
+ return Task.FromResult(true);
+ }
+}
diff --git a/RealEstateAgency.Infrastructure/Repositories/InMemoryRealEstatePropertyRepository.cs b/RealEstateAgency.Infrastructure/Repositories/InMemoryRealEstatePropertyRepository.cs
new file mode 100644
index 000000000..088cbc737
--- /dev/null
+++ b/RealEstateAgency.Infrastructure/Repositories/InMemoryRealEstatePropertyRepository.cs
@@ -0,0 +1,78 @@
+using RealEstateAgency.Domain.Enums;
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Infrastructure.Repositories;
+
+///
+/// In-memory реализация репозитория объектов недвижимости
+///
+public class InMemoryRealEstatePropertyRepository : IRealEstatePropertyRepository
+{
+ private readonly List _properties = [];
+
+ public InMemoryRealEstatePropertyRepository()
+ {
+ SeedData();
+ }
+
+ private void SeedData()
+ {
+ var seedData = new[]
+ {
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), Type = PropertyType.Apartment, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:01:0001001:101", Address = "ул. Тверская, 15, кв. 34", TotalFloors = 9, TotalArea = 75.5, RoomsCount = 3, CeilingHeight = 2.7, Floor = 5, HasEncumbrances = false },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), Type = PropertyType.Apartment, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:01:0001002:102", Address = "ул. Арбат, 25, кв. 12", TotalFloors = 5, TotalArea = 45.0, RoomsCount = 2, CeilingHeight = 2.5, Floor = 3, HasEncumbrances = true },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-000000000003"), Type = PropertyType.Apartment, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:01:0001003:103", Address = "пр-т Мира, 10, кв. 78", TotalFloors = 12, TotalArea = 90.0, RoomsCount = 4, CeilingHeight = 2.8, Floor = 8, HasEncumbrances = false },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-000000000004"), Type = PropertyType.House, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:02:0002001:201", Address = "Московская обл., коттеджный поселок 'Лесной', д. 12", TotalFloors = 2, TotalArea = 150.0, RoomsCount = 6, CeilingHeight = 3.0, Floor = null, HasEncumbrances = false },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-000000000005"), Type = PropertyType.House, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:02:0002002:202", Address = "Московская обл., д. Пушкино, ул. Садовая, 5", TotalFloors = 1, TotalArea = 80.0, RoomsCount = 4, CeilingHeight = 2.6, Floor = null, HasEncumbrances = true },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-000000000006"), Type = PropertyType.Townhouse, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:03:0003001:301", Address = "пос. Рублево, таунхаусный комплекс 'Резиденция', к. 7", TotalFloors = 3, TotalArea = 120.0, RoomsCount = 5, CeilingHeight = 2.7, Floor = null, HasEncumbrances = false },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-000000000007"), Type = PropertyType.Townhouse, Purpose = PropertyPurpose.Residential, CadastralNumber = "77:03:0003002:302", Address = "пос. Барвиха, таунхаусный комплекс 'Престиж', к. 3", TotalFloors = 2, TotalArea = 95.0, RoomsCount = 4, CeilingHeight = 2.8, Floor = null, HasEncumbrances = false },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-000000000008"), Type = PropertyType.Commercial, Purpose = PropertyPurpose.Commercial, CadastralNumber = "77:05:0005001:501", Address = "ул. Новый Арбат, 15, офис 300", TotalFloors = 10, TotalArea = 60.0, RoomsCount = 2, CeilingHeight = 2.8, Floor = 3, HasEncumbrances = false },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-000000000009"), Type = PropertyType.Commercial, Purpose = PropertyPurpose.Commercial, CadastralNumber = "77:05:0005002:502", Address = "ул. Тверская-Ямская, 8, магазин", TotalFloors = 3, TotalArea = 85.0, RoomsCount = 1, CeilingHeight = 3.2, Floor = 1, HasEncumbrances = true },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-00000000000a"), Type = PropertyType.ParkingSpace, Purpose = PropertyPurpose.Commercial, CadastralNumber = "77:06:0006001:601", Address = "ул. Садовая-Кудринская, 1, подземный паркинг, место А-15", TotalFloors = null, TotalArea = 12.5, RoomsCount = null, CeilingHeight = 2.2, Floor = -1, HasEncumbrances = true },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-00000000000b"), Type = PropertyType.ParkingSpace, Purpose = PropertyPurpose.Commercial, CadastralNumber = "77:06:0006002:602", Address = "ул. Мясницкая, 20, паркинг, место Б-07", TotalFloors = null, TotalArea = 13.0, RoomsCount = null, CeilingHeight = 2.3, Floor = -2, HasEncumbrances = false },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-00000000000c"), Type = PropertyType.Warehouse, Purpose = PropertyPurpose.Industrial, CadastralNumber = "77:07:0007001:701", Address = "промзона 'Южные Ворота', складской комплекс №3", TotalFloors = 1, TotalArea = 500.0, RoomsCount = null, CeilingHeight = 6.0, Floor = null, HasEncumbrances = false },
+ new RealEstateProperty { Id = Guid.Parse("10000000-0000-0000-0000-00000000000d"), Type = PropertyType.Warehouse, Purpose = PropertyPurpose.Industrial, CadastralNumber = "77:07:0007002:702", Address = "промзона 'Северная', склад №5", TotalFloors = 2, TotalArea = 350.0, RoomsCount = null, CeilingHeight = 5.5, Floor = null, HasEncumbrances = true }
+ };
+
+ _properties.AddRange(seedData);
+ }
+
+ public Task> GetAllAsync() => Task.FromResult>(_properties);
+
+ public Task GetByIdAsync(Guid id) => Task.FromResult(_properties.FirstOrDefault(p => p.Id == id));
+
+ public Task AddAsync(RealEstateProperty property)
+ {
+ property.Id = Guid.NewGuid();
+ _properties.Add(property);
+ return Task.FromResult(property);
+ }
+
+ public Task UpdateAsync(Guid id, RealEstateProperty property)
+ {
+ var existing = _properties.FirstOrDefault(p => p.Id == id);
+ if (existing == null) return Task.FromResult(null);
+
+ existing.Type = property.Type;
+ existing.Purpose = property.Purpose;
+ existing.CadastralNumber = property.CadastralNumber;
+ existing.Address = property.Address;
+ existing.TotalFloors = property.TotalFloors;
+ existing.TotalArea = property.TotalArea;
+ existing.RoomsCount = property.RoomsCount;
+ existing.CeilingHeight = property.CeilingHeight;
+ existing.Floor = property.Floor;
+ existing.HasEncumbrances = property.HasEncumbrances;
+ return Task.FromResult(existing);
+ }
+
+ public Task DeleteAsync(Guid id)
+ {
+ var property = _properties.FirstOrDefault(p => p.Id == id);
+ if (property == null) return Task.FromResult(false);
+
+ _properties.Remove(property);
+ return Task.FromResult(true);
+ }
+}
diff --git a/RealEstateAgency.Infrastructure/Repositories/InMemoryRequestRepository.cs b/RealEstateAgency.Infrastructure/Repositories/InMemoryRequestRepository.cs
new file mode 100644
index 000000000..b24e80e3e
--- /dev/null
+++ b/RealEstateAgency.Infrastructure/Repositories/InMemoryRequestRepository.cs
@@ -0,0 +1,94 @@
+using RealEstateAgency.Domain.Enums;
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Domain.Models;
+
+namespace RealEstateAgency.Infrastructure.Repositories;
+
+///
+/// In-memory реализация репозитория заявок
+///
+public class InMemoryRequestRepository(
+ ICounterpartyRepository counterpartyRepository,
+ IRealEstatePropertyRepository propertyRepository) : IRequestRepository
+{
+ private readonly List _requests = [];
+ private bool _seeded;
+
+ private async Task EnsureSeededAsync()
+ {
+ if (_seeded) return;
+ _seeded = true;
+ await SeedDataAsync();
+ }
+
+ private async Task SeedDataAsync()
+ {
+ var counterparties = (await counterpartyRepository.GetAllAsync()).ToList();
+ var properties = (await propertyRepository.GetAllAsync()).ToList();
+
+ var seedData = new[]
+ {
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-000000000001"), CounterpartyId = counterparties[0].Id, Counterparty = counterparties[0], PropertyId = properties[0].Id, Property = properties[0], Type = RequestType.Sale, Amount = 25000000.00m, Date = new DateTime(2024, 1, 15) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-000000000002"), CounterpartyId = counterparties[1].Id, Counterparty = counterparties[1], PropertyId = properties[1].Id, Property = properties[1], Type = RequestType.Sale, Amount = 18000000.00m, Date = new DateTime(2024, 2, 20) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-000000000003"), CounterpartyId = counterparties[3].Id, Counterparty = counterparties[3], PropertyId = properties[3].Id, Property = properties[3], Type = RequestType.Sale, Amount = 42000000.00m, Date = new DateTime(2024, 3, 10) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-000000000004"), CounterpartyId = counterparties[6].Id, Counterparty = counterparties[6], PropertyId = properties[5].Id, Property = properties[5], Type = RequestType.Sale, Amount = 35000000.00m, Date = new DateTime(2024, 4, 5) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-000000000005"), CounterpartyId = counterparties[8].Id, Counterparty = counterparties[8], PropertyId = properties[7].Id, Property = properties[7], Type = RequestType.Sale, Amount = 32000000.00m, Date = new DateTime(2024, 5, 12) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-000000000006"), CounterpartyId = counterparties[10].Id, Counterparty = counterparties[10], PropertyId = properties[9].Id, Property = properties[9], Type = RequestType.Sale, Amount = 1500000.00m, Date = new DateTime(2024, 6, 8) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-000000000007"), CounterpartyId = counterparties[11].Id, Counterparty = counterparties[11], PropertyId = properties[11].Id, Property = properties[11], Type = RequestType.Sale, Amount = 85000000.00m, Date = new DateTime(2024, 7, 25) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-000000000008"), CounterpartyId = counterparties[2].Id, Counterparty = counterparties[2], PropertyId = properties[2].Id, Property = properties[2], Type = RequestType.Purchase, Amount = 22000000.00m, Date = new DateTime(2024, 1, 20) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-000000000009"), CounterpartyId = counterparties[4].Id, Counterparty = counterparties[4], PropertyId = properties[4].Id, Property = properties[4], Type = RequestType.Purchase, Amount = 15000000.00m, Date = new DateTime(2024, 2, 25) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-00000000000a"), CounterpartyId = counterparties[5].Id, Counterparty = counterparties[5], PropertyId = properties[6].Id, Property = properties[6], Type = RequestType.Purchase, Amount = 28000000.00m, Date = new DateTime(2024, 3, 15) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-00000000000b"), CounterpartyId = counterparties[7].Id, Counterparty = counterparties[7], PropertyId = properties[8].Id, Property = properties[8], Type = RequestType.Purchase, Amount = 25000000.00m, Date = new DateTime(2024, 4, 18) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-00000000000c"), CounterpartyId = counterparties[9].Id, Counterparty = counterparties[9], PropertyId = properties[10].Id, Property = properties[10], Type = RequestType.Purchase, Amount = 1800000.00m, Date = new DateTime(2024, 5, 22) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-00000000000d"), CounterpartyId = counterparties[2].Id, Counterparty = counterparties[2], PropertyId = properties[12].Id, Property = properties[12], Type = RequestType.Purchase, Amount = 60000000.00m, Date = new DateTime(2024, 6, 30) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-00000000000e"), CounterpartyId = counterparties[1].Id, Counterparty = counterparties[1], PropertyId = properties[0].Id, Property = properties[0], Type = RequestType.Purchase, Amount = 24000000.00m, Date = new DateTime(2024, 8, 10) },
+ new Request { Id = Guid.Parse("20000000-0000-0000-0000-00000000000f"), CounterpartyId = counterparties[3].Id, Counterparty = counterparties[3], PropertyId = properties[1].Id, Property = properties[1], Type = RequestType.Sale, Amount = 19000000.00m, Date = new DateTime(2024, 9, 5) }
+ };
+
+ _requests.AddRange(seedData);
+ }
+
+ public async Task> GetAllAsync()
+ {
+ await EnsureSeededAsync();
+ return _requests;
+ }
+
+ public async Task GetByIdAsync(Guid id)
+ {
+ await EnsureSeededAsync();
+ return _requests.FirstOrDefault(r => r.Id == id);
+ }
+
+ public async Task AddAsync(Request request)
+ {
+ await EnsureSeededAsync();
+ request.Id = Guid.NewGuid();
+ _requests.Add(request);
+ return request;
+ }
+
+ public async Task UpdateAsync(Guid id, Request request)
+ {
+ var existing = await GetByIdAsync(id);
+ if (existing == null) return null;
+
+ existing.CounterpartyId = request.CounterpartyId;
+ existing.Counterparty = request.Counterparty;
+ existing.PropertyId = request.PropertyId;
+ existing.Property = request.Property;
+ existing.Type = request.Type;
+ existing.Amount = request.Amount;
+ existing.Date = request.Date;
+ return existing;
+ }
+
+ public async Task DeleteAsync(Guid id)
+ {
+ var request = await GetByIdAsync(id);
+ if (request == null) return false;
+
+ _requests.Remove(request);
+ return true;
+ }
+}
diff --git a/RealEstateAgency.Infrastructure/Repositories/MongoCounterpartyRepository.cs b/RealEstateAgency.Infrastructure/Repositories/MongoCounterpartyRepository.cs
new file mode 100644
index 000000000..cfc462b8a
--- /dev/null
+++ b/RealEstateAgency.Infrastructure/Repositories/MongoCounterpartyRepository.cs
@@ -0,0 +1,56 @@
+using Microsoft.EntityFrameworkCore;
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Domain.Models;
+using RealEstateAgency.Infrastructure.Persistence;
+
+namespace RealEstateAgency.Infrastructure.Repositories;
+
+///
+/// MongoDB реализация репозитория контрагентов с использованием EF Core
+///
+public class MongoCounterpartyRepository(RealEstateDbContext context) : ICounterpartyRepository
+{
+ public async Task> GetAllAsync()
+ {
+ return await context.Counterparties.ToListAsync();
+ }
+
+ public async Task GetByIdAsync(Guid id)
+ {
+ return await context.Counterparties.FirstOrDefaultAsync(c => c.Id == id);
+ }
+
+ public async Task AddAsync(Counterparty counterparty)
+ {
+ counterparty.Id = Guid.NewGuid();
+
+ context.Counterparties.Add(counterparty);
+ await context.SaveChangesAsync();
+ return counterparty;
+ }
+
+ public async Task UpdateAsync(Guid id, Counterparty counterparty)
+ {
+ var existing = await context.Counterparties.FirstOrDefaultAsync(c => c.Id == id);
+ if (existing == null)
+ return null;
+
+ existing.FullName = counterparty.FullName;
+ existing.PassportNumber = counterparty.PassportNumber;
+ existing.PhoneNumber = counterparty.PhoneNumber;
+
+ await context.SaveChangesAsync();
+ return existing;
+ }
+
+ public async Task DeleteAsync(Guid id)
+ {
+ var counterparty = await context.Counterparties.FirstOrDefaultAsync(c => c.Id == id);
+ if (counterparty == null)
+ return false;
+
+ context.Counterparties.Remove(counterparty);
+ await context.SaveChangesAsync();
+ return true;
+ }
+}
diff --git a/RealEstateAgency.Infrastructure/Repositories/MongoRealEstatePropertyRepository.cs b/RealEstateAgency.Infrastructure/Repositories/MongoRealEstatePropertyRepository.cs
new file mode 100644
index 000000000..b47a83f97
--- /dev/null
+++ b/RealEstateAgency.Infrastructure/Repositories/MongoRealEstatePropertyRepository.cs
@@ -0,0 +1,63 @@
+using Microsoft.EntityFrameworkCore;
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Domain.Models;
+using RealEstateAgency.Infrastructure.Persistence;
+
+namespace RealEstateAgency.Infrastructure.Repositories;
+
+///
+/// MongoDB реализация репозитория объектов недвижимости с использованием EF Core
+///
+public class MongoRealEstatePropertyRepository(RealEstateDbContext context) : IRealEstatePropertyRepository
+{
+ public async Task> GetAllAsync()
+ {
+ return await context.Properties.ToListAsync();
+ }
+
+ public async Task GetByIdAsync(Guid id)
+ {
+ return await context.Properties.FirstOrDefaultAsync(p => p.Id == id);
+ }
+
+ public async Task AddAsync(RealEstateProperty property)
+ {
+ property.Id = Guid.NewGuid();
+
+ context.Properties.Add(property);
+ await context.SaveChangesAsync();
+ return property;
+ }
+
+ public async Task UpdateAsync(Guid id, RealEstateProperty property)
+ {
+ var existing = await context.Properties.FirstOrDefaultAsync(p => p.Id == id);
+ if (existing == null)
+ return null;
+
+ existing.Type = property.Type;
+ existing.Purpose = property.Purpose;
+ existing.CadastralNumber = property.CadastralNumber;
+ existing.Address = property.Address;
+ existing.TotalFloors = property.TotalFloors;
+ existing.TotalArea = property.TotalArea;
+ existing.RoomsCount = property.RoomsCount;
+ existing.CeilingHeight = property.CeilingHeight;
+ existing.Floor = property.Floor;
+ existing.HasEncumbrances = property.HasEncumbrances;
+
+ await context.SaveChangesAsync();
+ return existing;
+ }
+
+ public async Task DeleteAsync(Guid id)
+ {
+ var property = await context.Properties.FirstOrDefaultAsync(p => p.Id == id);
+ if (property == null)
+ return false;
+
+ context.Properties.Remove(property);
+ await context.SaveChangesAsync();
+ return true;
+ }
+}
diff --git a/RealEstateAgency.Infrastructure/Repositories/MongoRequestRepository.cs b/RealEstateAgency.Infrastructure/Repositories/MongoRequestRepository.cs
new file mode 100644
index 000000000..504ac8d44
--- /dev/null
+++ b/RealEstateAgency.Infrastructure/Repositories/MongoRequestRepository.cs
@@ -0,0 +1,103 @@
+using Microsoft.EntityFrameworkCore;
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Domain.Models;
+using RealEstateAgency.Infrastructure.Persistence;
+
+namespace RealEstateAgency.Infrastructure.Repositories;
+
+///
+/// MongoDB реализация репозитория заявок с использованием EF Core
+///
+public class MongoRequestRepository(RealEstateDbContext context) : IRequestRepository
+{
+ public async Task> GetAllAsync()
+ {
+ var requests = await context.Requests.ToListAsync();
+ await PopulateNavigationPropertiesAsync(requests);
+ return requests;
+ }
+
+ public async Task GetByIdAsync(Guid id)
+ {
+ var request = await context.Requests.FirstOrDefaultAsync(r => r.Id == id);
+ if (request != null)
+ {
+ await PopulateNavigationPropertiesAsync([request]);
+ }
+ return request;
+ }
+
+ public async Task AddAsync(Request request)
+ {
+ request.Id = Guid.NewGuid();
+
+ if (request.CounterpartyId == Guid.Empty && request.Counterparty != null)
+ request.CounterpartyId = request.Counterparty.Id;
+ if (request.PropertyId == Guid.Empty && request.Property != null)
+ request.PropertyId = request.Property.Id;
+
+ context.Requests.Add(request);
+ await context.SaveChangesAsync();
+ return request;
+ }
+
+ public async Task UpdateAsync(Guid id, Request request)
+ {
+ var existing = await context.Requests.FirstOrDefaultAsync(r => r.Id == id);
+ if (existing == null)
+ return null;
+
+ existing.CounterpartyId = request.CounterpartyId;
+ existing.PropertyId = request.PropertyId;
+ existing.Type = request.Type;
+ existing.Amount = request.Amount;
+ existing.Date = request.Date;
+
+ await context.SaveChangesAsync();
+
+ existing.Counterparty = request.Counterparty;
+ existing.Property = request.Property;
+
+ return existing;
+ }
+
+ public async Task DeleteAsync(Guid id)
+ {
+ var request = await context.Requests.FirstOrDefaultAsync(r => r.Id == id);
+ if (request == null)
+ return false;
+
+ context.Requests.Remove(request);
+ await context.SaveChangesAsync();
+ return true;
+ }
+
+ ///
+ /// Загружает связанные Counterparty и Property для списка заявок
+ ///
+ private async Task PopulateNavigationPropertiesAsync(IEnumerable requests)
+ {
+ var requestList = requests.ToList();
+ if (requestList.Count == 0) return;
+
+ var counterpartyIds = requestList.Select(r => r.CounterpartyId).Distinct().ToList();
+ var propertyIds = requestList.Select(r => r.PropertyId).Distinct().ToList();
+
+ var counterparties = await context.Counterparties
+ .Where(c => counterpartyIds.Contains(c.Id))
+ .ToDictionaryAsync(c => c.Id);
+
+ var properties = await context.Properties
+ .Where(p => propertyIds.Contains(p.Id))
+ .ToDictionaryAsync(p => p.Id);
+
+ foreach (var request in requestList)
+ {
+ if (counterparties.TryGetValue(request.CounterpartyId, out var counterparty))
+ request.Counterparty = counterparty;
+
+ if (properties.TryGetValue(request.PropertyId, out var property))
+ request.Property = property;
+ }
+ }
+}
diff --git a/RealEstateAgency.ServiceDefaults/Extensions.cs b/RealEstateAgency.ServiceDefaults/Extensions.cs
new file mode 100644
index 000000000..0a280597e
--- /dev/null
+++ b/RealEstateAgency.ServiceDefaults/Extensions.cs
@@ -0,0 +1,167 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace RealEstateAgency.ServiceDefaults;
+
+///
+/// Aspire
+///
+public static class Extensions
+{
+ ///
+ /// Aspire (Web )
+ ///
+ public static WebApplicationBuilder AddServiceDefaults(this WebApplicationBuilder builder)
+ {
+ builder.ConfigureOpenTelemetry();
+ builder.AddDefaultHealthChecks();
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ http.AddStandardResilienceHandler();
+ http.AddServiceDiscovery();
+ });
+
+ return builder;
+ }
+
+ ///
+ /// OpenTelemetry (Web )
+ ///
+ public static WebApplicationBuilder ConfigureOpenTelemetry(this WebApplicationBuilder builder)
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ ///
+ /// health checks (Web )
+ ///
+ public static WebApplicationBuilder AddDefaultHealthChecks(this WebApplicationBuilder builder)
+ {
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ ///
+ /// Hosted Service ( )
+ ///
+ public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
+ {
+ builder.ConfigureOpenTelemetry();
+ builder.AddDefaultHealthChecks();
+ builder.Services.AddServiceDiscovery();
+
+ return builder;
+ }
+
+ ///
+ /// OpenTelemetry Hosted Service
+ ///
+ public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ ///
+ /// health checks Hosted Service
+ ///
+ public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder)
+ {
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+
+ private static WebApplicationBuilder AddOpenTelemetryExporters(this WebApplicationBuilder builder)
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ return builder;
+ }
+
+ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ return builder;
+ }
+
+ ///
+ /// health checks
+ ///
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ app.MapHealthChecks("/health");
+
+ app.MapHealthChecks("/alive", new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+
+ return app;
+ }
+}
\ No newline at end of file
diff --git a/RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj b/RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj
new file mode 100644
index 000000000..92525a4b6
--- /dev/null
+++ b/RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs b/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs
new file mode 100644
index 000000000..1f3588603
--- /dev/null
+++ b/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs
@@ -0,0 +1,155 @@
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Domain.Enums;
+using System.Net.Http.Json;
+
+namespace RealEstateAgency.WebApi.Tests;
+
+///
+///
+///
+public class AnalyticsControllerTests(RealEstateWebApplicationFactory factory)
+ : IClassFixture
+{
+ private readonly HttpClient _client = factory.CreateClient();
+
+ ///
+ /// :
+ ///
+ [Fact]
+ public async Task GetSellersInPeriod_ReturnsCorrectSellers()
+ {
+ var startDate = "2024-03-01";
+ var endDate = "2024-06-30";
+ List expectedSellers =
+ [
+ " ",
+ " ",
+ " ",
+ " "
+ ];
+
+ var response = await _client.GetAsync(
+ $"/api/analytics/sellers?startDate={startDate}&endDate={endDate}");
+
+ response.EnsureSuccessStatusCode();
+ var actualSellers = await response.Content.ReadFromJsonAsync>(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(actualSellers);
+ Assert.Equal(expectedSellers, actualSellers);
+ }
+
+ ///
+ /// : -5
+ ///
+ [Fact]
+ public async Task GetTop5Clients_ReturnsCorrectTop5()
+ {
+ List expectedTopPurchaseClients =
+ [
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ];
+
+ List expectedTopSaleClients =
+ [
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ];
+
+ var response = await _client.GetAsync("/api/analytics/top-clients");
+
+ response.EnsureSuccessStatusCode();
+ var result = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(result);
+
+ var actualPurchaseNames = result.TopPurchaseClients.Select(c => c.FullName).ToList();
+ var actualSaleNames = result.TopSaleClients.Select(c => c.FullName).ToList();
+
+ Assert.Equal(expectedTopPurchaseClients, actualPurchaseNames);
+ Assert.Equal(expectedTopSaleClients, actualSaleNames);
+ }
+
+ ///
+ /// :
+ ///
+ [Fact]
+ public async Task GetPropertyTypeStatistics_ReturnsCorrectStatistics()
+ {
+ var expectedStats = new Dictionary
+ {
+ [PropertyType.Apartment] = 5,
+ [PropertyType.House] = 2,
+ [PropertyType.Townhouse] = 2,
+ [PropertyType.Commercial] = 2,
+ [PropertyType.ParkingSpace] = 2,
+ [PropertyType.Warehouse] = 2
+ };
+
+ var response = await _client.GetAsync("/api/analytics/property-type-statistics");
+
+ response.EnsureSuccessStatusCode();
+ var actualStats = await response.Content.ReadFromJsonAsync>(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(actualStats);
+ Assert.Equal(expectedStats.Count, actualStats.Count);
+
+ foreach (var expected in expectedStats)
+ {
+ var actual = actualStats.First(x => x.PropertyType == expected.Key);
+ Assert.Equal(expected.Value, actual.RequestCount);
+ }
+ }
+
+ ///
+ /// :
+ ///
+ [Fact]
+ public async Task GetClientsWithMinAmount_ReturnsCorrectClients()
+ {
+ var expectedMinAmount = 1500000.00m;
+ var expectedClient = " ";
+
+ var response = await _client.GetAsync("/api/analytics/min-amount-clients");
+
+ response.EnsureSuccessStatusCode();
+ var result = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(result);
+ Assert.Equal(expectedMinAmount, result.MinAmount);
+ Assert.Equal(expectedClient, result.FullName);
+ }
+
+ ///
+ /// : ,
+ ///
+ [Fact]
+ public async Task GetClientsByPropertyType_Apartment_ReturnsCorrectClients()
+ {
+ List expectedClients =
+ [
+ " ",
+ " "
+ ];
+
+ var response = await _client.GetAsync(
+ "/api/analytics/clients-by-property-type?propertyType=Apartment");
+
+ response.EnsureSuccessStatusCode();
+ var actualClients = await response.Content.ReadFromJsonAsync>(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(actualClients);
+ Assert.Equal(expectedClients, actualClients);
+ }
+}
diff --git a/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs b/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs
new file mode 100644
index 000000000..3ca0a5d25
--- /dev/null
+++ b/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs
@@ -0,0 +1,130 @@
+using RealEstateAgency.Contracts.Dto;
+using System.Net;
+using System.Net.Http.Json;
+
+namespace RealEstateAgency.WebApi.Tests;
+
+///
+/// Тесты CRUD операций для контрагентов
+///
+public class CounterpartiesControllerTests(RealEstateWebApplicationFactory factory)
+ : IClassFixture
+{
+ private readonly HttpClient _client = factory.CreateClient();
+ private static readonly Guid _testCounterpartyId = Guid.Parse("00000000-0000-0000-0000-000000000001");
+
+ ///
+ /// GET /api/counterparties — получение всех контрагентов
+ ///
+ [Fact]
+ public async Task GetAll_ReturnsAllCounterparties()
+ {
+ var response = await _client.GetAsync("/api/counterparties");
+
+ response.EnsureSuccessStatusCode();
+ var counterparties = await response.Content.ReadFromJsonAsync>(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(counterparties);
+ Assert.True(counterparties.Count >= 12);
+ }
+
+ ///
+ /// GET /api/counterparties/{id} — получение контрагента по ID
+ ///
+ [Fact]
+ public async Task GetById_ExistingId_ReturnsCounterparty()
+ {
+ var response = await _client.GetAsync($"/api/counterparties/{_testCounterpartyId}");
+
+ response.EnsureSuccessStatusCode();
+ var counterparty = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(counterparty);
+ Assert.Equal(_testCounterpartyId, counterparty.Id);
+ Assert.Equal("Иванов Иван Иванович", counterparty.FullName);
+ }
+
+ ///
+ /// GET /api/counterparties/{id} — несуществующий ID возвращает 404
+ ///
+ [Fact]
+ public async Task GetById_NonExistingId_ReturnsNotFound()
+ {
+ var response = await _client.GetAsync($"/api/counterparties/{Guid.NewGuid()}");
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ ///
+ /// POST /api/counterparties — создание нового контрагента
+ ///
+ [Fact]
+ public async Task Create_ValidData_ReturnsCreatedCounterparty()
+ {
+ var newCounterparty = new CreateCounterpartyDto
+ {
+ FullName = "Тестов Тест Тестович",
+ PassportNumber = "1234 567890",
+ PhoneNumber = "+7-999-000-00-00"
+ };
+
+ var response = await _client.PostAsJsonAsync("/api/counterparties", newCounterparty);
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ var created = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(created);
+ Assert.NotEqual(Guid.Empty, created.Id);
+ Assert.Equal("Тестов Тест Тестович", created.FullName);
+ }
+
+ ///
+ /// PUT /api/counterparties/{id} — обновление контрагента
+ ///
+ [Fact]
+ public async Task Update_ExistingId_ReturnsUpdatedCounterparty()
+ {
+ var updateData = new UpdateCounterpartyDto
+ {
+ FullName = "Иванов Иван Петрович",
+ PassportNumber = "4501 123456",
+ PhoneNumber = "+7-999-111-22-33"
+ };
+
+ var response = await _client.PutAsJsonAsync($"/api/counterparties/{_testCounterpartyId}", updateData);
+
+ response.EnsureSuccessStatusCode();
+ var updated = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(updated);
+ Assert.Equal("Иванов Иван Петрович", updated.FullName);
+ }
+
+ ///
+ /// DELETE /api/counterparties/{id} — удаление контрагента
+ ///
+ [Fact]
+ public async Task Delete_ExistingId_ReturnsNoContent()
+ {
+ var newCounterparty = new CreateCounterpartyDto
+ {
+ FullName = "Удаляемый Клиент",
+ PassportNumber = "0000 000000",
+ PhoneNumber = "+7-000-000-00-00"
+ };
+ var createResponse = await _client.PostAsJsonAsync("/api/counterparties", newCounterparty);
+ var created = await createResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var response = await _client.DeleteAsync($"/api/counterparties/{created!.Id}");
+
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+
+ var getResponse = await _client.GetAsync($"/api/counterparties/{created.Id}");
+ Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
+ }
+}
diff --git a/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs b/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs
new file mode 100644
index 000000000..9b6233e23
--- /dev/null
+++ b/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs
@@ -0,0 +1,223 @@
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Domain.Enums;
+using System.Net.Http.Json;
+
+namespace RealEstateAgency.WebApi.Tests;
+
+///
+/// Интеграционные тесты аналитических запросов с реальной MongoDB
+///
+[Collection("MongoDB")]
+public class MongoAnalyticsTests(MongoDbWebApplicationFactory factory) : IClassFixture
+{
+ private readonly HttpClient _client = factory.CreateClient();
+
+ ///
+ /// Тест аналитики продавцов за период с MongoDB
+ ///
+ [Fact]
+ public async Task GetSellersInPeriod_WorksWithMongoDB()
+ {
+ var seller = new CreateCounterpartyDto
+ {
+ FullName = "Продавец для MongoDB теста",
+ PassportNumber = "5555 666666",
+ PhoneNumber = "+7-555-666-77-88"
+ };
+ var sellerResponse = await _client.PostAsJsonAsync("/api/counterparties", seller);
+ var createdSeller = await sellerResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var property = new CreateRealEstatePropertyDto
+ {
+ Type = PropertyType.Apartment,
+ Purpose = PropertyPurpose.Residential,
+ CadastralNumber = "77:55:5555555:555",
+ Address = "ул. Аналитическая, д. 1",
+ TotalArea = 60.0
+ };
+ var propertyResponse = await _client.PostAsJsonAsync("/api/properties", property);
+ var createdProperty = await propertyResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var saleRequest = new CreateRequestDto
+ {
+ CounterpartyId = createdSeller!.Id,
+ PropertyId = createdProperty!.Id,
+ Type = RequestType.Sale,
+ Amount = 8000000.00m,
+ Date = new DateTime(2024, 5, 15)
+ };
+ await _client.PostAsJsonAsync("/api/requests", saleRequest);
+
+ var response = await _client.GetAsync(
+ "/api/analytics/sellers?startDate=2024-01-01&endDate=2024-12-31");
+
+ response.EnsureSuccessStatusCode();
+ var sellers = await response.Content.ReadFromJsonAsync>(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(sellers);
+ Assert.Contains("Продавец для MongoDB теста", sellers);
+ }
+
+ ///
+ /// Тест статистики по типам недвижимости с MongoDB
+ ///
+ [Fact]
+ public async Task GetPropertyTypeStatistics_WorksWithMongoDB()
+ {
+ var client = new CreateCounterpartyDto
+ {
+ FullName = "Клиент для статистики MongoDB",
+ PassportNumber = "6666 777777",
+ PhoneNumber = "+7-666-777-88-99"
+ };
+ var clientResponse = await _client.PostAsJsonAsync("/api/counterparties", client);
+ var createdClient = await clientResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var propertyTypes = new[] { PropertyType.Apartment, PropertyType.House, PropertyType.Commercial };
+
+ foreach (var propertyType in propertyTypes)
+ {
+ var property = new CreateRealEstatePropertyDto
+ {
+ Type = propertyType,
+ Purpose = propertyType == PropertyType.Commercial ? PropertyPurpose.Commercial : PropertyPurpose.Residential,
+ CadastralNumber = $"77:66:666666{(int)propertyType}:666",
+ Address = $"Адрес для статистики {propertyType}",
+ TotalArea = 100.0
+ };
+ var propResponse = await _client.PostAsJsonAsync("/api/properties", property);
+ var createdProp = await propResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var request = new CreateRequestDto
+ {
+ CounterpartyId = createdClient!.Id,
+ PropertyId = createdProp!.Id,
+ Type = RequestType.Purchase,
+ Amount = 5000000.00m,
+ Date = DateTime.Now
+ };
+ await _client.PostAsJsonAsync("/api/requests", request);
+ }
+
+ var response = await _client.GetAsync("/api/analytics/property-type-statistics");
+
+ response.EnsureSuccessStatusCode();
+ var stats = await response.Content.ReadFromJsonAsync>(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(stats);
+ Assert.True(stats.Count > 0);
+
+ Assert.Contains(stats, s => s.PropertyType == PropertyType.Apartment);
+ Assert.Contains(stats, s => s.PropertyType == PropertyType.House);
+ Assert.Contains(stats, s => s.PropertyType == PropertyType.Commercial);
+ }
+
+ ///
+ /// Тест поиска клиентов по типу недвижимости с MongoDB
+ ///
+ [Fact]
+ public async Task GetClientsByPropertyType_WorksWithMongoDB()
+ {
+ var buyer = new CreateCounterpartyDto
+ {
+ FullName = "Покупатель квартиры MongoDB",
+ PassportNumber = "7777 888888",
+ PhoneNumber = "+7-777-888-99-00"
+ };
+ var buyerResponse = await _client.PostAsJsonAsync("/api/counterparties", buyer);
+ var createdBuyer = await buyerResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var apartment = new CreateRealEstatePropertyDto
+ {
+ Type = PropertyType.Apartment,
+ Purpose = PropertyPurpose.Residential,
+ CadastralNumber = "77:77:7777777:777",
+ Address = "ул. Квартирная MongoDB, д. 1",
+ TotalArea = 55.0
+ };
+ var apartmentResponse = await _client.PostAsJsonAsync("/api/properties", apartment);
+ var createdApartment = await apartmentResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var purchaseRequest = new CreateRequestDto
+ {
+ CounterpartyId = createdBuyer!.Id,
+ PropertyId = createdApartment!.Id,
+ Type = RequestType.Purchase,
+ Amount = 6000000.00m,
+ Date = DateTime.Now
+ };
+ await _client.PostAsJsonAsync("/api/requests", purchaseRequest);
+
+ var response = await _client.GetAsync(
+ "/api/analytics/clients-by-property-type?propertyType=Apartment");
+
+ response.EnsureSuccessStatusCode();
+ var clients = await response.Content.ReadFromJsonAsync>(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(clients);
+ Assert.Contains("Покупатель квартиры MongoDB", clients);
+ }
+
+ ///
+ /// Тест топ-5 клиентов с MongoDB
+ ///
+ [Fact]
+ public async Task GetTop5Clients_WorksWithMongoDB()
+ {
+ var activeBuyer = new CreateCounterpartyDto
+ {
+ FullName = "Активный покупатель MongoDB",
+ PassportNumber = "8888 999999",
+ PhoneNumber = "+7-888-999-00-11"
+ };
+ var buyerResponse = await _client.PostAsJsonAsync("/api/counterparties", activeBuyer);
+ var createdBuyer = await buyerResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ for (var i = 1; i <= 3; i++)
+ {
+ var property = new CreateRealEstatePropertyDto
+ {
+ Type = PropertyType.Apartment,
+ Purpose = PropertyPurpose.Residential,
+ CadastralNumber = $"77:88:888888{i}:888",
+ Address = $"ул. Топовая MongoDB, д. {i}",
+ TotalArea = 50.0 + i * 10
+ };
+ var propResponse = await _client.PostAsJsonAsync("/api/properties", property);
+ var createdProp = await propResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var request = new CreateRequestDto
+ {
+ CounterpartyId = createdBuyer!.Id,
+ PropertyId = createdProp!.Id,
+ Type = RequestType.Purchase,
+ Amount = 5000000.00m + i * 1000000,
+ Date = DateTime.Now.AddDays(-i)
+ };
+ await _client.PostAsJsonAsync("/api/requests", request);
+ }
+
+ var response = await _client.GetAsync("/api/analytics/top-clients");
+
+ response.EnsureSuccessStatusCode();
+ var result = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(result);
+ Assert.NotNull(result.TopPurchaseClients);
+
+ var purchaseClientNames = result.TopPurchaseClients.Select(c => c.FullName).ToList();
+ Assert.Contains("Активный покупатель MongoDB", purchaseClientNames);
+ }
+}
diff --git a/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs b/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs
new file mode 100644
index 000000000..59718ef08
--- /dev/null
+++ b/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs
@@ -0,0 +1,97 @@
+using RealEstateAgency.Contracts.Dto;
+using System.Net;
+using System.Net.Http.Json;
+
+namespace RealEstateAgency.WebApi.Tests;
+
+///
+/// Интеграционные тесты CRUD операций для контрагентов с реальной MongoDB
+///
+[Collection("MongoDB")]
+public class MongoCounterpartiesTests(MongoDbWebApplicationFactory factory)
+ : IClassFixture
+{
+ private readonly HttpClient _client = factory.CreateClient();
+
+ ///
+ /// Полный CRUD цикл для контрагента в MongoDB
+ ///
+ [Fact]
+ public async Task Counterparty_FullCrudCycle_WorksWithMongoDB()
+ {
+ var newCounterparty = new CreateCounterpartyDto
+ {
+ FullName = "Тестов Тест Тестович (MongoDB)",
+ PassportNumber = "9999 888777",
+ PhoneNumber = "+7-999-888-77-66"
+ };
+
+ var createResponse = await _client.PostAsJsonAsync("/api/counterparties", newCounterparty);
+ Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
+
+ var created = await createResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+ Assert.NotNull(created);
+ Assert.NotEqual(Guid.Empty, created.Id);
+ Assert.Equal("Тестов Тест Тестович (MongoDB)", created.FullName);
+
+ var createdId = created.Id;
+
+ var getResponse = await _client.GetAsync($"/api/counterparties/{createdId}");
+ Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
+
+ var fetched = await getResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+ Assert.NotNull(fetched);
+ Assert.Equal(createdId, fetched.Id);
+ Assert.Equal("Тестов Тест Тестович (MongoDB)", fetched.FullName);
+
+ var updateData = new UpdateCounterpartyDto
+ {
+ FullName = "Обновлённый Тест (MongoDB)",
+ PassportNumber = "9999 888777",
+ PhoneNumber = "+7-999-888-77-55"
+ };
+
+ var updateResponse = await _client.PutAsJsonAsync($"/api/counterparties/{createdId}", updateData);
+ Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
+
+ var updated = await updateResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+ Assert.NotNull(updated);
+ Assert.Equal("Обновлённый Тест (MongoDB)", updated.FullName);
+
+ var deleteResponse = await _client.DeleteAsync($"/api/counterparties/{createdId}");
+ Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
+
+ var getDeletedResponse = await _client.GetAsync($"/api/counterparties/{createdId}");
+ Assert.Equal(HttpStatusCode.NotFound, getDeletedResponse.StatusCode);
+ }
+
+ ///
+ /// Получение списка контрагентов из MongoDB
+ ///
+ [Fact]
+ public async Task GetAll_ReturnsListFromMongoDB()
+ {
+ for (var i = 1; i <= 3; i++)
+ {
+ var counterparty = new CreateCounterpartyDto
+ {
+ FullName = $"Контрагент MongoDB #{i}",
+ PassportNumber = $"000{i} 00000{i}",
+ PhoneNumber = $"+7-000-000-00-0{i}"
+ };
+ await _client.PostAsJsonAsync("/api/counterparties", counterparty);
+ }
+
+ var response = await _client.GetAsync("/api/counterparties");
+
+ response.EnsureSuccessStatusCode();
+ var counterparties = await response.Content.ReadFromJsonAsync>(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(counterparties);
+ Assert.True(counterparties.Count >= 3);
+ }
+}
diff --git a/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs b/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs
new file mode 100644
index 000000000..44de81cea
--- /dev/null
+++ b/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs
@@ -0,0 +1,35 @@
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization;
+using MongoDB.Bson.Serialization.Serializers;
+using System.Runtime.CompilerServices;
+
+namespace RealEstateAgency.WebApi.Tests;
+
+///
+/// Инициализатор модуля - выполняется при загрузке сборки
+///
+file static class MongoDbInitializer
+{
+ [ModuleInitializer]
+ public static void Initialize()
+ {
+#pragma warning disable CS0618
+ BsonDefaults.GuidRepresentationMode = GuidRepresentationMode.V3;
+#pragma warning restore CS0618
+ try
+ {
+ BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
+ }
+ catch (BsonSerializationException)
+ {
+ // Сериализатор уже зарегистрирован
+ }
+ }
+}
+
+///
+/// Определение коллекции для MongoDB тестов
+/// Все тесты в этой коллекции будут использовать один экземпляр MongoDbWebApplicationFactory
+///
+[CollectionDefinition("MongoDB")]
+public class MongoDbCollection : ICollectionFixture;
diff --git a/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs b/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs
new file mode 100644
index 000000000..000113c06
--- /dev/null
+++ b/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs
@@ -0,0 +1,68 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using MongoDB.Driver;
+using RealEstateAgency.Domain.Interfaces;
+using RealEstateAgency.Infrastructure.Persistence;
+using RealEstateAgency.Infrastructure.Repositories;
+using Testcontainers.MongoDb;
+
+namespace RealEstateAgency.WebApi.Tests;
+
+///
+/// Фабрика для интеграционных тестов с реальной MongoDB (через Testcontainers)
+///
+public class MongoDbWebApplicationFactory : WebApplicationFactory, IAsyncLifetime
+{
+ private readonly MongoDbContainer _mongoDbContainer = new MongoDbBuilder()
+ .WithImage("mongo:7.0")
+ .Build();
+
+ public string ConnectionString => _mongoDbContainer.GetConnectionString();
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ {
+ var descriptorsToRemove = services
+ .Where(d => d.ServiceType == typeof(ICounterpartyRepository) ||
+ d.ServiceType == typeof(IRealEstatePropertyRepository) ||
+ d.ServiceType == typeof(IRequestRepository) ||
+ d.ServiceType == typeof(IMongoClient) ||
+ d.ServiceType == typeof(IMongoDatabase) ||
+ d.ServiceType == typeof(RealEstateDbContext) ||
+ d.ServiceType == typeof(DbContextOptions))
+ .ToList();
+
+ foreach (var descriptor in descriptorsToRemove)
+ {
+ services.Remove(descriptor);
+ }
+
+ var mongoClient = new MongoClient(ConnectionString);
+ services.AddSingleton(mongoClient);
+
+ services.AddDbContext(options =>
+ {
+ options.UseMongoDB(mongoClient, "realestatedb_test");
+ });
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ });
+
+ builder.UseEnvironment("Testing");
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _mongoDbContainer.StartAsync();
+ }
+
+ public new async Task DisposeAsync()
+ {
+ await _mongoDbContainer.DisposeAsync();
+ }
+}
diff --git a/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs b/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs
new file mode 100644
index 000000000..e277cbcc8
--- /dev/null
+++ b/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs
@@ -0,0 +1,119 @@
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Domain.Enums;
+using System.Net;
+using System.Net.Http.Json;
+
+namespace RealEstateAgency.WebApi.Tests;
+
+///
+/// Интеграционные тесты CRUD операций для объектов недвижимости с реальной MongoDB
+///
+[Collection("MongoDB")]
+public class MongoPropertiesTests(MongoDbWebApplicationFactory factory) : IClassFixture
+{
+ private readonly HttpClient _client = factory.CreateClient();
+
+ ///
+ /// Полный CRUD цикл для объекта недвижимости в MongoDB
+ ///
+ [Fact]
+ public async Task Property_FullCrudCycle_WorksWithMongoDB()
+ {
+ var newProperty = new CreateRealEstatePropertyDto
+ {
+ Type = PropertyType.Apartment,
+ Purpose = PropertyPurpose.Residential,
+ CadastralNumber = "77:00:0000000:001",
+ Address = "ул. MongoDB, д. 1, кв. 1",
+ TotalFloors = 10,
+ TotalArea = 65.5,
+ RoomsCount = 2,
+ CeilingHeight = 2.8,
+ Floor = 5,
+ HasEncumbrances = false
+ };
+
+ var createResponse = await _client.PostAsJsonAsync("/api/properties", newProperty);
+ Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
+
+ var created = await createResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+ Assert.NotNull(created);
+ Assert.NotEqual(Guid.Empty, created.Id);
+ Assert.Equal("ул. MongoDB, д. 1, кв. 1", created.Address);
+ Assert.Equal(PropertyType.Apartment, created.Type);
+
+ var createdId = created.Id;
+
+ var getResponse = await _client.GetAsync($"/api/properties/{createdId}");
+ Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
+
+ var fetched = await getResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+ Assert.NotNull(fetched);
+ Assert.Equal(createdId, fetched.Id);
+ Assert.Equal(65.5, fetched.TotalArea);
+
+ var updateData = new UpdateRealEstatePropertyDto
+ {
+ Type = PropertyType.Apartment,
+ Purpose = PropertyPurpose.Residential,
+ CadastralNumber = "77:00:0000000:001",
+ Address = "ул. MongoDB, д. 1, кв. 1 (обновлено)",
+ TotalFloors = 10,
+ TotalArea = 70.0,
+ RoomsCount = 3,
+ CeilingHeight = 2.8,
+ Floor = 5,
+ HasEncumbrances = false
+ };
+
+ var updateResponse = await _client.PutAsJsonAsync($"/api/properties/{createdId}", updateData);
+ Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
+
+ var updated = await updateResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+ Assert.NotNull(updated);
+ Assert.Equal(70.0, updated.TotalArea);
+ Assert.Equal(3, updated.RoomsCount);
+
+ var deleteResponse = await _client.DeleteAsync($"/api/properties/{createdId}");
+ Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
+
+ var getDeletedResponse = await _client.GetAsync($"/api/properties/{createdId}");
+ Assert.Equal(HttpStatusCode.NotFound, getDeletedResponse.StatusCode);
+ }
+
+ ///
+ /// Тест сохранения всех типов недвижимости в MongoDB
+ ///
+ [Theory]
+ [InlineData(PropertyType.Apartment, PropertyPurpose.Residential)]
+ [InlineData(PropertyType.House, PropertyPurpose.Residential)]
+ [InlineData(PropertyType.Townhouse, PropertyPurpose.Residential)]
+ [InlineData(PropertyType.Commercial, PropertyPurpose.Commercial)]
+ [InlineData(PropertyType.Warehouse, PropertyPurpose.Industrial)]
+ [InlineData(PropertyType.ParkingSpace, PropertyPurpose.Commercial)]
+ public async Task Create_AllPropertyTypes_SavedCorrectlyInMongoDB(
+ PropertyType type, PropertyPurpose purpose)
+ {
+ var property = new CreateRealEstatePropertyDto
+ {
+ Type = type,
+ Purpose = purpose,
+ CadastralNumber = $"77:00:000000{(int)type}:{(int)purpose}",
+ Address = $"Тестовый адрес для {type}",
+ TotalArea = 100.0
+ };
+
+ var response = await _client.PostAsJsonAsync("/api/properties", property);
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ var created = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(created);
+ Assert.Equal(type, created.Type);
+ Assert.Equal(purpose, created.Purpose);
+ }
+}
diff --git a/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs b/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs
new file mode 100644
index 000000000..25c0b7c5b
--- /dev/null
+++ b/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs
@@ -0,0 +1,178 @@
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Domain.Enums;
+using System.Net;
+using System.Net.Http.Json;
+
+namespace RealEstateAgency.WebApi.Tests;
+
+///
+/// Интеграционные тесты CRUD операций для заявок с реальной MongoDB
+///
+[Collection("MongoDB")]
+public class MongoRequestsTests(MongoDbWebApplicationFactory factory) : IClassFixture
+{
+ private readonly HttpClient _client = factory.CreateClient();
+
+ ///
+ /// Полный CRUD цикл для заявки в MongoDB
+ ///
+ [Fact]
+ public async Task Request_FullCrudCycle_WorksWithMongoDB()
+ {
+ var counterparty = new CreateCounterpartyDto
+ {
+ FullName = "Клиент для заявки MongoDB",
+ PassportNumber = "1111 222222",
+ PhoneNumber = "+7-111-222-33-44"
+ };
+ var counterpartyResponse = await _client.PostAsJsonAsync("/api/counterparties", counterparty);
+ var createdCounterparty = await counterpartyResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var property = new CreateRealEstatePropertyDto
+ {
+ Type = PropertyType.Apartment,
+ Purpose = PropertyPurpose.Residential,
+ CadastralNumber = "77:11:1111111:111",
+ Address = "ул. Заявочная, д. 1",
+ TotalArea = 50.0
+ };
+ var propertyResponse = await _client.PostAsJsonAsync("/api/properties", property);
+ var createdProperty = await propertyResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var newRequest = new CreateRequestDto
+ {
+ CounterpartyId = createdCounterparty!.Id,
+ PropertyId = createdProperty!.Id,
+ Type = RequestType.Sale,
+ Amount = 5000000.00m,
+ Date = new DateTime(2024, 6, 15)
+ };
+
+ var createResponse = await _client.PostAsJsonAsync("/api/requests", newRequest);
+ Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
+
+ var created = await createResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+ Assert.NotNull(created);
+ Assert.NotEqual(Guid.Empty, created.Id);
+ Assert.Equal(5000000.00m, created.Amount);
+ Assert.Equal(RequestType.Sale, created.Type);
+
+ var createdId = created.Id;
+
+ var getResponse = await _client.GetAsync($"/api/requests/{createdId}");
+ Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
+
+ var fetched = await getResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+ Assert.NotNull(fetched);
+ Assert.Equal(createdId, fetched.Id);
+ Assert.Equal(5000000.00m, fetched.Amount);
+
+ var updateData = new UpdateRequestDto
+ {
+ CounterpartyId = createdCounterparty.Id,
+ PropertyId = createdProperty.Id,
+ Type = RequestType.Sale,
+ Amount = 5500000.00m,
+ Date = new DateTime(2024, 6, 20)
+ };
+
+ var updateResponse = await _client.PutAsJsonAsync($"/api/requests/{createdId}", updateData);
+ Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
+
+ var updated = await updateResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+ Assert.NotNull(updated);
+ Assert.Equal(5500000.00m, updated.Amount);
+
+ var deleteResponse = await _client.DeleteAsync($"/api/requests/{createdId}");
+ Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
+
+ var getDeletedResponse = await _client.GetAsync($"/api/requests/{createdId}");
+ Assert.Equal(HttpStatusCode.NotFound, getDeletedResponse.StatusCode);
+ }
+
+ ///
+ /// Тест создания заявок разных типов в MongoDB
+ ///
+ [Theory]
+ [InlineData(RequestType.Sale)]
+ [InlineData(RequestType.Purchase)]
+ public async Task Create_BothRequestTypes_SavedCorrectlyInMongoDB(RequestType requestType)
+ {
+ var counterparty = new CreateCounterpartyDto
+ {
+ FullName = $"Клиент для {requestType}",
+ PassportNumber = $"000{(int)requestType} 000000",
+ PhoneNumber = $"+7-000-000-00-0{(int)requestType}"
+ };
+ var counterpartyResponse = await _client.PostAsJsonAsync("/api/counterparties", counterparty);
+ var createdCounterparty = await counterpartyResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var property = new CreateRealEstatePropertyDto
+ {
+ Type = PropertyType.House,
+ Purpose = PropertyPurpose.Residential,
+ CadastralNumber = $"77:22:222222{(int)requestType}:222",
+ Address = $"Адрес для {requestType}",
+ TotalArea = 150.0
+ };
+ var propertyResponse = await _client.PostAsJsonAsync("/api/properties", property);
+ var createdProperty = await propertyResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var request = new CreateRequestDto
+ {
+ CounterpartyId = createdCounterparty!.Id,
+ PropertyId = createdProperty!.Id,
+ Type = requestType,
+ Amount = 10000000.00m,
+ Date = DateTime.Now
+ };
+
+ var response = await _client.PostAsJsonAsync("/api/requests", request);
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ var created = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(created);
+ Assert.Equal(requestType, created.Type);
+ }
+
+ ///
+ /// Тест валидации - несуществующий контрагент
+ ///
+ [Fact]
+ public async Task Create_NonExistingCounterparty_ReturnsNotFound()
+ {
+ var property = new CreateRealEstatePropertyDto
+ {
+ Type = PropertyType.Apartment,
+ Purpose = PropertyPurpose.Residential,
+ CadastralNumber = "77:33:3333333:333",
+ Address = "Адрес без контрагента",
+ TotalArea = 40.0
+ };
+ var propertyResponse = await _client.PostAsJsonAsync("/api/properties", property);
+ var createdProperty = await propertyResponse.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ var request = new CreateRequestDto
+ {
+ CounterpartyId = Guid.NewGuid(),
+ PropertyId = createdProperty!.Id,
+ Type = RequestType.Sale,
+ Amount = 1000000.00m,
+ Date = DateTime.Now
+ };
+
+ var response = await _client.PostAsJsonAsync("/api/requests", request);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+}
diff --git a/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs b/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs
new file mode 100644
index 000000000..36fd16fb9
--- /dev/null
+++ b/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs
@@ -0,0 +1,145 @@
+using RealEstateAgency.Contracts.Dto;
+using RealEstateAgency.Domain.Enums;
+using System.Net;
+using System.Net.Http.Json;
+
+namespace RealEstateAgency.WebApi.Tests;
+
+///
+/// Тесты CRUD операций для объектов недвижимости
+///
+public class PropertiesControllerTests(RealEstateWebApplicationFactory factory)
+ : IClassFixture
+{
+ private readonly HttpClient _client = factory.CreateClient();
+ private static readonly Guid _testPropertyId = Guid.Parse("10000000-0000-0000-0000-000000000001");
+
+ ///
+ /// GET /api/properties — получение всех объектов
+ ///
+ [Fact]
+ public async Task GetAll_ReturnsAllProperties()
+ {
+ var response = await _client.GetAsync("/api/properties");
+
+ response.EnsureSuccessStatusCode();
+ var properties = await response.Content.ReadFromJsonAsync>(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(properties);
+ Assert.True(properties.Count >= 13);
+ }
+
+ ///
+ /// GET /api/properties/{id} — получение объекта по ID
+ ///
+ [Fact]
+ public async Task GetById_ExistingId_ReturnsProperty()
+ {
+ var response = await _client.GetAsync($"/api/properties/{_testPropertyId}");
+
+ response.EnsureSuccessStatusCode();
+ var property = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(property);
+ Assert.Equal(_testPropertyId, property.Id);
+ Assert.Equal(PropertyType.Apartment, property.Type);
+ Assert.Contains("ул. Тверская, 15, кв. 34", property.Address);
+ }
+
+ ///
+ /// GET /api/properties/{id} — несуществующий ID возвращает 404
+ ///
+ [Fact]
+ public async Task GetById_NonExistingId_ReturnsNotFound()
+ {
+ var response = await _client.GetAsync($"/api/properties/{Guid.NewGuid()}");
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ ///
+ /// POST /api/properties — создание нового объекта
+ ///
+ [Fact]
+ public async Task Create_ValidData_ReturnsCreatedProperty()
+ {
+ var newProperty = new CreateRealEstatePropertyDto
+ {
+ Type = PropertyType.Apartment,
+ Purpose = PropertyPurpose.Residential,
+ CadastralNumber = "77:99:0009999:999",
+ Address = "ул. Тестовая, 1, кв. 1",
+ TotalFloors = 10,
+ TotalArea = 50.0,
+ RoomsCount = 2,
+ CeilingHeight = 2.7,
+ Floor = 5,
+ HasEncumbrances = false
+ };
+
+ var response = await _client.PostAsJsonAsync("/api/properties", newProperty);
+
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ var created = await response.Content.ReadFromJsonAsync(
+ RealEstateWebApplicationFactory.JsonOptions);
+
+ Assert.NotNull(created);
+ Assert.NotEqual(Guid.Empty, created.Id);
+ Assert.Equal("ул. Тестовая, 1, кв. 1", created.Address);
+ }
+
+ ///