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 диаграмма - -image1 - -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1Wc8AvsKS_1JptpsxHO-cwfAxz2ghxvQRQ0fy4el2ZOc/edit?usp=sharing) -[Список предметных областей](https://docs.google.com/document/d/15jWhXMwd2K8giFMKku_yrY_s2uQNEu4ugJXLYPvYJAE/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [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); + } + + /// + /// PUT /api/properties/{id} — обновление объекта + /// + [Fact] + public async Task Update_ExistingId_ReturnsUpdatedProperty() + { + var updateData = new UpdateRealEstatePropertyDto + { + Type = PropertyType.Apartment, + Purpose = PropertyPurpose.Residential, + CadastralNumber = "77:01:0001001:101", + Address = "ул. Тверская, 15, кв. 34 (обновлено)", + TotalFloors = 9, + TotalArea = 80.0, + RoomsCount = 3, + CeilingHeight = 2.7, + Floor = 5, + HasEncumbrances = false + }; + + var response = await _client.PutAsJsonAsync($"/api/properties/{_testPropertyId}", updateData); + + response.EnsureSuccessStatusCode(); + var updated = await response.Content.ReadFromJsonAsync( + RealEstateWebApplicationFactory.JsonOptions); + + Assert.NotNull(updated); + Assert.Equal(80.0, updated.TotalArea); + } + + /// + /// DELETE /api/properties/{id} — удаление объекта + /// + [Fact] + public async Task Delete_ExistingId_ReturnsNoContent() + { + var newProperty = new CreateRealEstatePropertyDto + { + Type = PropertyType.ParkingSpace, + Purpose = PropertyPurpose.Commercial, + CadastralNumber = "00:00:0000000:000", + Address = "Удаляемый объект", + TotalArea = 10.0 + }; + var createResponse = await _client.PostAsJsonAsync("/api/properties", newProperty); + var created = await createResponse.Content.ReadFromJsonAsync( + RealEstateWebApplicationFactory.JsonOptions); + + var response = await _client.DeleteAsync($"/api/properties/{created!.Id}"); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } +} diff --git a/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj b/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj new file mode 100644 index 000000000..7693bb9b0 --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/RealEstateAgency.WebApi.Tests/RealEstateWebApplicationFactory.cs b/RealEstateAgency.WebApi.Tests/RealEstateWebApplicationFactory.cs new file mode 100644 index 000000000..ad1f10d18 --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/RealEstateWebApplicationFactory.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using RealEstateAgency.Domain.Interfaces; +using RealEstateAgency.Infrastructure.Repositories; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Фабрика для создания тестового сервера +/// Использует in-memory репозитории для изоляции тестов +/// +public class RealEstateWebApplicationFactory : WebApplicationFactory +{ + /// + /// Настройки JSON для десериализации ответов с enum как строками + /// + public static JsonSerializerOptions JsonOptions { get; } = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + 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)) + .ToList(); + + foreach (var descriptor in descriptorsToRemove) + { + services.Remove(descriptor); + } + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + + builder.UseEnvironment("Testing"); + } +} diff --git a/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs b/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs new file mode 100644 index 000000000..dc4f7ae7e --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs @@ -0,0 +1,157 @@ +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Domain.Enums; +using System.Net; +using System.Net.Http.Json; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Тесты CRUD операций для заявок +/// +public class RequestsControllerTests(RealEstateWebApplicationFactory factory) + : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + private static readonly Guid _testRequestId = Guid.Parse("20000000-0000-0000-0000-000000000001"); + private static readonly Guid _testCounterpartyId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + private static readonly Guid _testPropertyId = Guid.Parse("10000000-0000-0000-0000-000000000001"); + + /// + /// GET /api/requests — получение всех заявок + /// + [Fact] + public async Task GetAll_ReturnsAllRequests() + { + var response = await _client.GetAsync("/api/requests"); + + response.EnsureSuccessStatusCode(); + var requests = await response.Content.ReadFromJsonAsync>( + RealEstateWebApplicationFactory.JsonOptions); + + Assert.NotNull(requests); + Assert.True(requests.Count >= 15); + } + + /// + /// GET /api/requests/{id} — получение заявки по ID + /// + [Fact] + public async Task GetById_ExistingId_ReturnsRequest() + { + var response = await _client.GetAsync($"/api/requests/{_testRequestId}"); + + response.EnsureSuccessStatusCode(); + var request = await response.Content.ReadFromJsonAsync( + RealEstateWebApplicationFactory.JsonOptions); + + Assert.NotNull(request); + Assert.Equal(_testRequestId, request.Id); + Assert.Equal(RequestType.Sale, request.Type); + Assert.Equal(25000000.00m, request.Amount); + } + + /// + /// GET /api/requests/{id} — несуществующий ID возвращает 404 + /// + [Fact] + public async Task GetById_NonExistingId_ReturnsNotFound() + { + var response = await _client.GetAsync($"/api/requests/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + /// + /// POST /api/requests — создание новой заявки + /// + [Fact] + public async Task Create_ValidData_ReturnsCreatedRequest() + { + var newRequest = new CreateRequestDto + { + CounterpartyId = _testCounterpartyId, + PropertyId = _testPropertyId, + Type = RequestType.Purchase, + Amount = 30000000.00m, + Date = new DateTime(2024, 10, 15) + }; + + var response = await _client.PostAsJsonAsync("/api/requests", newRequest); + + 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(30000000.00m, created.Amount); + } + + /// + /// POST /api/requests — несуществующий контрагент возвращает 404 + /// + [Fact] + public async Task Create_InvalidCounterpartyId_ReturnsNotFound() + { + var newRequest = new CreateRequestDto + { + CounterpartyId = Guid.NewGuid(), + PropertyId = _testPropertyId, + Type = RequestType.Sale, + Amount = 1000000.00m, + Date = DateTime.Now + }; + + var response = await _client.PostAsJsonAsync("/api/requests", newRequest); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + /// + /// PUT /api/requests/{id} — обновление заявки + /// + [Fact] + public async Task Update_ExistingId_ReturnsUpdatedRequest() + { + var updateData = new UpdateRequestDto + { + CounterpartyId = _testCounterpartyId, + PropertyId = _testPropertyId, + Type = RequestType.Sale, + Amount = 26000000.00m, + Date = new DateTime(2024, 1, 15) + }; + + var response = await _client.PutAsJsonAsync($"/api/requests/{_testRequestId}", updateData); + + response.EnsureSuccessStatusCode(); + var updated = await response.Content.ReadFromJsonAsync( + RealEstateWebApplicationFactory.JsonOptions); + + Assert.NotNull(updated); + Assert.Equal(26000000.00m, updated.Amount); + } + + /// + /// DELETE /api/requests/{id} — удаление заявки + /// + [Fact] + public async Task Delete_ExistingId_ReturnsNoContent() + { + var newRequest = new CreateRequestDto + { + CounterpartyId = _testCounterpartyId, + PropertyId = _testPropertyId, + Type = RequestType.Purchase, + Amount = 100.00m, + Date = DateTime.Now + }; + var createResponse = await _client.PostAsJsonAsync("/api/requests", newRequest); + var created = await createResponse.Content.ReadFromJsonAsync( + RealEstateWebApplicationFactory.JsonOptions); + + var response = await _client.DeleteAsync($"/api/requests/{created!.Id}"); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } +} diff --git "a/RealEstateAgency.WebApi.Tests/result_2_3labs/\321\202\320\265\321\201\321\202\321\213_\321\200\320\272\320\277.jpg" "b/RealEstateAgency.WebApi.Tests/result_2_3labs/\321\202\320\265\321\201\321\202\321\213_\321\200\320\272\320\277.jpg" new file mode 100644 index 000000000..acf6c455f Binary files /dev/null and "b/RealEstateAgency.WebApi.Tests/result_2_3labs/\321\202\320\265\321\201\321\202\321\213_\321\200\320\272\320\277.jpg" differ diff --git "a/RealEstateAgency.WebApi.Tests/result_2_3labs/\321\202\320\265\321\201\321\202\321\213_\321\200\320\272\320\2772.jpg" "b/RealEstateAgency.WebApi.Tests/result_2_3labs/\321\202\320\265\321\201\321\202\321\213_\321\200\320\272\320\2772.jpg" new file mode 100644 index 000000000..4648d9676 Binary files /dev/null and "b/RealEstateAgency.WebApi.Tests/result_2_3labs/\321\202\320\265\321\201\321\202\321\213_\321\200\320\272\320\2772.jpg" differ diff --git a/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs new file mode 100644 index 000000000..e7cb0ca71 --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Contracts.Interfaces; +using RealEstateAgency.Domain.Enums; + +namespace RealEstateAgency.WebApi.Controllers; + +/// +/// Контроллер для аналитических запросов +/// +[ApiController] +[Route("api/[controller]")] +public class AnalyticsController(IAnalyticsService analyticsService, ILogger logger) : ControllerBase +{ + /// + /// Получить продавцов за указанный период + /// + /// Начало периода + /// Конец периода + /// Список ФИО продавцов + [HttpGet("sellers")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetSellersInPeriod( + [FromQuery] DateTime startDate, + [FromQuery] DateTime endDate) + { + try + { + logger.LogInformation("Запрос продавцов за период {StartDate} - {EndDate}", startDate, endDate); + var sellers = await analyticsService.GetSellersInPeriodAsync(startDate, endDate); + logger.LogInformation("Найдено {Count} продавцов", sellers.Count()); + return Ok(sellers); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении продавцов за период {StartDate} - {EndDate}", startDate, endDate); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Получить топ-5 клиентов по количеству заявок (покупка и продажа отдельно) + /// + /// Топ-5 покупателей и топ-5 продавцов + [HttpGet("top-clients")] + [ProducesResponseType(typeof(Top5ClientsResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetTop5Clients() + { + try + { + logger.LogInformation("Запрос топ-5 клиентов"); + var result = await analyticsService.GetTop5ClientsByRequestCountAsync(); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении топ-5 клиентов"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Получить статистику заявок по типам недвижимости + /// + /// Количество заявок по каждому типу недвижимости + [HttpGet("property-type-statistics")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetPropertyTypeStatistics() + { + try + { + logger.LogInformation("Запрос статистики по типам недвижимости"); + var statistics = await analyticsService.GetRequestCountByPropertyTypeAsync(); + return Ok(statistics); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении статистики по типам недвижимости"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Получить клиентов с заявками минимальной стоимости + /// + /// Информация о клиентах с минимальной суммой + [HttpGet("min-amount-clients")] + [ProducesResponseType(typeof(ClientWithMinAmountDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetClientsWithMinAmount() + { + try + { + logger.LogInformation("Запрос клиентов с минимальной суммой заявки"); + var result = await analyticsService.GetClientsWithMinAmountAsync(); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении клиентов с минимальной суммой заявки"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Получить клиентов, ищущих определённый тип недвижимости + /// + /// Тип недвижимости + /// Список ФИО клиентов + [HttpGet("clients-by-property-type")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetClientsByPropertyType( + [FromQuery] PropertyType propertyType) + { + try + { + logger.LogInformation("Запрос клиентов, ищущих недвижимость типа {PropertyType}", propertyType); + var clients = await analyticsService.GetClientsSeekingPropertyTypeAsync(propertyType); + logger.LogInformation("Найдено {Count} клиентов", clients.Count()); + return Ok(clients); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении клиентов, ищущих недвижимость типа {PropertyType}", propertyType); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } +} diff --git a/RealEstateAgency.WebApi/Controllers/BaseCrudController.cs b/RealEstateAgency.WebApi/Controllers/BaseCrudController.cs new file mode 100644 index 000000000..23bc528b0 --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/BaseCrudController.cs @@ -0,0 +1,136 @@ +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Contracts.Interfaces; + +namespace RealEstateAgency.WebApi.Controllers; + +/// +/// Базовый контроллер для CRUD операций +/// +/// DTO для отображения +/// DTO для создания +/// DTO для обновления +/// Тип сервиса +[ApiController] +[Route("api/[controller]")] +public abstract class BaseCrudController( + TService service, + ILogger logger) : ControllerBase + where TService : IApplicationService +{ + protected readonly TService Service = service; + protected readonly ILogger Logger = logger; + + /// + /// Получить все сущности + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task>> GetAll() + { + try + { + Logger.LogInformation("Запрос на получение всех сущностей"); + var entities = await Service.GetAllAsync(); + Logger.LogInformation("Возвращено {Count} сущностей", entities.Count()); + return Ok(entities); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при получении всех сущностей"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Получить сущность по идентификатору + /// + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task> GetById(Guid id) + { + try + { + Logger.LogInformation("Запрос на получение сущности с ID {Id}", id); + var entity = await Service.GetByIdAsync(id); + if (entity == null) + { + Logger.LogWarning("Сущность с ID {Id} не найдена", id); + return NotFound($"Сущность с ID {id} не найдена"); + } + + return Ok(entity); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при получении сущности с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Создать новую сущность + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task> Create([FromBody] TCreateDto dto) + { + try + { + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при создании сущности: {Errors}", ModelState); + return BadRequest(ModelState); + } + + Logger.LogInformation("Создание сущности"); + var created = await Service.CreateAsync(dto); + Logger.LogInformation("Сущность создана"); + + return CreatedAtAction(nameof(GetById), new { id = GetEntityId(created) }, created); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при создании сущности"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Удалить сущность + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task Delete(Guid id) + { + try + { + Logger.LogInformation("Удаление сущности с ID {Id}", id); + var deleted = await Service.DeleteAsync(id); + if (!deleted) + { + Logger.LogWarning("Сущность с ID {Id} не найдена для удаления", id); + return NotFound($"Сущность с ID {id} не найдена"); + } + + Logger.LogInformation("Сущность с ID {Id} успешно удалена", id); + return NoContent(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при удалении сущности с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Получить идентификатор сущности из DTO + /// + protected abstract Guid GetEntityId(TDto entity); +} diff --git a/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs b/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs new file mode 100644 index 000000000..0c4a4b9e3 --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs @@ -0,0 +1,173 @@ +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Contracts.Interfaces; + +namespace RealEstateAgency.WebApi.Controllers; + +/// +/// Контроллер для работы с контрагентами +/// +public class CounterpartiesController( + ICounterpartyService service, + ILogger logger) + : BaseCrudController(service, logger) +{ + /// + protected override Guid GetEntityId(CounterpartyDto entity) => entity.Id; + + /// + /// Получить всех контрагентов + /// + /// Список контрагентов + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public override async Task>> GetAll() + { + try + { + Logger.LogInformation("Запрос на получение всех контрагентов"); + var counterparties = await Service.GetAllAsync(); + Logger.LogInformation("Возвращено {Count} контрагентов", counterparties.Count()); + return Ok(counterparties); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при получении всех контрагентов"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Получить контрагента по идентификатору + /// + /// Идентификатор контрагента + /// Контрагент + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public override async Task> GetById(Guid id) + { + try + { + Logger.LogInformation("Запрос на получение контрагента с ID {Id}", id); + var counterparty = await Service.GetByIdAsync(id); + if (counterparty == null) + { + Logger.LogWarning("Контрагент с ID {Id} не найден", id); + return NotFound($"Контрагент с ID {id} не найден"); + } + + return Ok(counterparty); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при получении контрагента с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Создать нового контрагента + /// + /// Данные контрагента + /// Созданный контрагент + [HttpPost] + [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public override async Task> Create([FromBody] CreateCounterpartyDto dto) + { + try + { + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при создании контрагента: {Errors}", ModelState); + return BadRequest(ModelState); + } + + Logger.LogInformation("Создание контрагента: {FullName}", dto.FullName); + var created = await Service.CreateAsync(dto); + Logger.LogInformation("Контрагент создан с ID {Id}", created.Id); + + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при создании контрагента"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Обновить контрагента + /// + /// Идентификатор контрагента + /// Новые данные контрагента + /// Результат операции + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Update(Guid id, [FromBody] UpdateCounterpartyDto dto) + { + try + { + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при обновлении контрагента {Id}: {Errors}", id, ModelState); + return BadRequest(ModelState); + } + + Logger.LogInformation("Обновление контрагента с ID {Id}", id); + var updated = await Service.UpdateAsync(id, dto); + + if (updated == null) + { + Logger.LogWarning("Контрагент с ID {Id} не найден для обновления", id); + return NotFound($"Контрагент с ID {id} не найден"); + } + + Logger.LogInformation("Контрагент с ID {Id} успешно обновлен", id); + return Ok(updated); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при обновлении контрагента с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Удалить контрагента + /// + /// Идентификатор контрагента + /// Результат операции + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public override async Task Delete(Guid id) + { + try + { + Logger.LogInformation("Удаление контрагента с ID {Id}", id); + var deleted = await Service.DeleteAsync(id); + if (!deleted) + { + Logger.LogWarning("Контрагент с ID {Id} не найден для удаления", id); + return NotFound($"Контрагент с ID {id} не найден"); + } + + Logger.LogInformation("Контрагент с ID {Id} успешно удален", id); + return NoContent(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при удалении контрагента с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } +} diff --git a/RealEstateAgency.WebApi/Controllers/PropertiesController.cs b/RealEstateAgency.WebApi/Controllers/PropertiesController.cs new file mode 100644 index 000000000..f9ddeccc7 --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/PropertiesController.cs @@ -0,0 +1,173 @@ +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Contracts.Interfaces; + +namespace RealEstateAgency.WebApi.Controllers; + +/// +/// Контроллер для работы с объектами недвижимости +/// +public class PropertiesController( + IRealEstatePropertyService service, + ILogger logger) + : BaseCrudController(service, logger) +{ + /// + protected override Guid GetEntityId(RealEstatePropertyDto entity) => entity.Id; + + /// + /// Получить все объекты недвижимости + /// + /// Список объектов недвижимости + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public override async Task>> GetAll() + { + try + { + Logger.LogInformation("Запрос на получение всех объектов недвижимости"); + var properties = await Service.GetAllAsync(); + Logger.LogInformation("Возвращено {Count} объектов недвижимости", properties.Count()); + return Ok(properties); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при получении всех объектов недвижимости"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Получить объект недвижимости по идентификатору + /// + /// Идентификатор объекта + /// Объект недвижимости + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public override async Task> GetById(Guid id) + { + try + { + Logger.LogInformation("Запрос на получение объекта недвижимости с ID {Id}", id); + var property = await Service.GetByIdAsync(id); + if (property == null) + { + Logger.LogWarning("Объект недвижимости с ID {Id} не найден", id); + return NotFound($"Объект недвижимости с ID {id} не найден"); + } + + return Ok(property); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при получении объекта недвижимости с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Создать новый объект недвижимости + /// + /// Данные объекта + /// Созданный объект + [HttpPost] + [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public override async Task> Create([FromBody] CreateRealEstatePropertyDto dto) + { + try + { + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при создании объекта недвижимости: {Errors}", ModelState); + return BadRequest(ModelState); + } + + Logger.LogInformation("Создание объекта недвижимости: {Address}", dto.Address); + var created = await Service.CreateAsync(dto); + Logger.LogInformation("Объект недвижимости создан с ID {Id}", created.Id); + + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при создании объекта недвижимости"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Обновить объект недвижимости + /// + /// Идентификатор объекта + /// Новые данные объекта + /// Результат операции + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Update(Guid id, [FromBody] UpdateRealEstatePropertyDto dto) + { + try + { + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при обновлении объекта недвижимости {Id}: {Errors}", id, ModelState); + return BadRequest(ModelState); + } + + Logger.LogInformation("Обновление объекта недвижимости с ID {Id}", id); + var updated = await Service.UpdateAsync(id, dto); + + if (updated == null) + { + Logger.LogWarning("Объект недвижимости с ID {Id} не найден для обновления", id); + return NotFound($"Объект недвижимости с ID {id} не найден"); + } + + Logger.LogInformation("Объект недвижимости с ID {Id} успешно обновлен", id); + return Ok(updated); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при обновлении объекта недвижимости с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Удалить объект недвижимости + /// + /// Идентификатор объекта + /// Результат операции + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public override async Task Delete(Guid id) + { + try + { + Logger.LogInformation("Удаление объекта недвижимости с ID {Id}", id); + var deleted = await Service.DeleteAsync(id); + if (!deleted) + { + Logger.LogWarning("Объект недвижимости с ID {Id} не найден для удаления", id); + return NotFound($"Объект недвижимости с ID {id} не найден"); + } + + Logger.LogInformation("Объект недвижимости с ID {Id} успешно удален", id); + return NoContent(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при удалении объекта недвижимости с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } +} diff --git a/RealEstateAgency.WebApi/Controllers/RequestsController.cs b/RealEstateAgency.WebApi/Controllers/RequestsController.cs new file mode 100644 index 000000000..00cc69f7d --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/RequestsController.cs @@ -0,0 +1,176 @@ +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Contracts.Interfaces; + +namespace RealEstateAgency.WebApi.Controllers; + +/// +/// Контроллер для работы с заявками +/// +[ApiController] +[Route("api/[controller]")] +public class RequestsController(IRequestService service, ILogger logger) : ControllerBase +{ + /// + /// Получить все заявки + /// + /// Список заявок + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetAll() + { + try + { + logger.LogInformation("Запрос на получение всех заявок"); + var requests = await service.GetAllAsync(); + logger.LogInformation("Возвращено {Count} заявок", requests.Count()); + return Ok(requests); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении всех заявок"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Получить заявку по идентификатору + /// + /// Идентификатор заявки + /// Заявка + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(RequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetById(Guid id) + { + try + { + logger.LogInformation("Запрос на получение заявки с ID {Id}", id); + var request = await service.GetByIdAsync(id); + if (request == null) + { + logger.LogWarning("Заявка с ID {Id} не найдена", id); + return NotFound($"Заявка с ID {id} не найдена"); + } + + return Ok(request); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении заявки с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Создать новую заявку + /// + /// Данные заявки + /// Созданная заявка + [HttpPost] + [ProducesResponseType(typeof(RequestDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Create([FromBody] CreateRequestDto dto) + { + try + { + if (!ModelState.IsValid) + { + logger.LogWarning("Ошибка валидации при создании заявки: {Errors}", ModelState); + return BadRequest(ModelState); + } + + logger.LogInformation("Создание заявки для контрагента {CounterpartyId}", dto.CounterpartyId); + var (result, error) = await service.CreateAsync(dto); + + if (result == null) + { + logger.LogWarning("Ошибка при создании заявки: {Error}", error); + return NotFound(error); + } + + logger.LogInformation("Заявка создана с ID {Id}", result.Id); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при создании заявки"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Обновить заявку + /// + /// Идентификатор заявки + /// Новые данные заявки + /// Результат операции + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(RequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Update(Guid id, [FromBody] UpdateRequestDto dto) + { + try + { + if (!ModelState.IsValid) + { + logger.LogWarning("Ошибка валидации при обновлении заявки {Id}: {Errors}", id, ModelState); + return BadRequest(ModelState); + } + + logger.LogInformation("Обновление заявки с ID {Id}", id); + var (result, error) = await service.UpdateAsync(id, dto); + + if (result == null) + { + logger.LogWarning("Ошибка при обновлении заявки {Id}: {Error}", id, error); + return NotFound(error); + } + + logger.LogInformation("Заявка с ID {Id} успешно обновлена", id); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при обновлении заявки с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } + + /// + /// Удалить заявку + /// + /// Идентификатор заявки + /// Результат операции + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Delete(Guid id) + { + try + { + logger.LogInformation("Удаление заявки с ID {Id}", id); + var deleted = await service.DeleteAsync(id); + if (!deleted) + { + logger.LogWarning("Заявка с ID {Id} не найдена для удаления", id); + return NotFound($"Заявка с ID {id} не найдена"); + } + + logger.LogInformation("Заявка с ID {Id} успешно удалена", id); + return NoContent(); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при удалении заявки с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } + } +} diff --git a/RealEstateAgency.WebApi/Program.cs b/RealEstateAgency.WebApi/Program.cs new file mode 100644 index 000000000..238b6d75e --- /dev/null +++ b/RealEstateAgency.WebApi/Program.cs @@ -0,0 +1,130 @@ +using Microsoft.EntityFrameworkCore; +using MongoDB.Driver; +using RealEstateAgency.Application.Mapping; +using RealEstateAgency.Application.Services; +using RealEstateAgency.Contracts.Interfaces; +using RealEstateAgency.Domain.Interfaces; +using RealEstateAgency.Infrastructure.Persistence; +using RealEstateAgency.Infrastructure.Repositories; +using RealEstateAgency.ServiceDefaults; +using RealEstateAgency.WebApi.Services; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +var useMongoDb = !builder.Environment.IsEnvironment("Testing") + && (builder.Configuration.GetConnectionString("realestatedb") != null + || Environment.GetEnvironmentVariable("ConnectionStrings__realestatedb") != null); + +if (useMongoDb) +{ + builder.AddServiceDefaults(); + + builder.AddMongoDBClient("realestatedb"); + + builder.Services.AddDbContext((serviceProvider, options) => + { + var mongoClient = serviceProvider.GetRequiredService(); + options.UseMongoDB(mongoClient, "realestatedb"); + }); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); +} +else +{ + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); +} + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var natsConnectionString = builder.Environment.IsEnvironment("Testing") + ? null + : (builder.Configuration.GetConnectionString("nats") + ?? Environment.GetEnvironmentVariable("ConnectionStrings__nats")); + +if (!string.IsNullOrEmpty(natsConnectionString)) +{ + builder.Services.AddHostedService(sp => + new NatsSubscriberService( + sp, + sp.GetRequiredService>(), + natsConnectionString)); +} + +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new() + { + Title = "Real Estate Agency API", + Version = "v1", + Description = "API , " + }); + + var xmlFilename = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath); + } +}); + +builder.Services.AddAutoMapper(typeof(MappingProfile)); + +var app = builder.Build(); + +if (useMongoDb) +{ + app.MapDefaultEndpoints(); + + using var scope = app.Services.CreateScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + + const int maxRetries = 5; + for (var i = 0; i < maxRetries; i++) + { + try + { + await seeder.SeedAsync(); + break; + } + catch (Exception ex) when (i < maxRetries - 1) + { + logger.LogWarning(ex, " {Attempt}/{MaxRetries} seed , 3 ...", i + 1, maxRetries); + await Task.Delay(3000); + } + } +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Real Estate Agency API v1"); + options.RoutePrefix = string.Empty; + }); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); + +public partial class Program { } diff --git a/RealEstateAgency.WebApi/Properties/launchSettings.json b/RealEstateAgency.WebApi/Properties/launchSettings.json new file mode 100644 index 000000000..3d9805f7c --- /dev/null +++ b/RealEstateAgency.WebApi/Properties/launchSettings.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + /* + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:28338", + "sslPort": 0 + } + }, + */ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "http://localhost:5187", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +}/*, + + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} + */ diff --git a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj new file mode 100644 index 000000000..65df604e1 --- /dev/null +++ b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.http b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.http new file mode 100644 index 000000000..4ce74b3c5 --- /dev/null +++ b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.http @@ -0,0 +1,6 @@ +@RealEstateAgency.WebApi_HostAddress = http://localhost:5187 + +GET {{RealEstateAgency.WebApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/RealEstateAgency.WebApi/Services/NatsSubscriberService.cs b/RealEstateAgency.WebApi/Services/NatsSubscriberService.cs new file mode 100644 index 000000000..ed5d776d8 --- /dev/null +++ b/RealEstateAgency.WebApi/Services/NatsSubscriberService.cs @@ -0,0 +1,182 @@ +using NATS.Client.Core; +using Polly; +using Polly.Retry; +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Contracts.Interfaces; +using System.Text.Json; + +namespace RealEstateAgency.WebApi.Services; + +/// +/// Фоновый сервис для получения данных из NATS и сохранения в БД +/// +public class NatsSubscriberService : BackgroundService +{ + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly string _connectionString; + private readonly ResiliencePipeline _retryPipeline; + private NatsConnection? _connection; + + private const string CounterpartyTopic = "realestate.counterparty.created"; + private const string PropertyTopic = "realestate.property.created"; + + public NatsSubscriberService( + IServiceProvider serviceProvider, + ILogger logger, + string connectionString) + { + _serviceProvider = serviceProvider; + _logger = logger; + _connectionString = connectionString; + + _retryPipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = int.MaxValue, + 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(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Запуск NATS Subscriber Service"); + + try + { + await ConnectWithRetryAsync(stoppingToken); + + if (_connection == null) + { + _logger.LogError("Не удалось подключиться к NATS"); + return; + } + + var counterpartyTask = SubscribeToCounterpartiesAsync(stoppingToken); + var propertyTask = SubscribeToPropertiesAsync(stoppingToken); + + await Task.WhenAll(counterpartyTask, propertyTask); + } + catch (OperationCanceledException) + { + _logger.LogInformation("NATS Subscriber Service остановлен"); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Критическая ошибка в NATS Subscriber Service"); + throw; + } + } + + private async Task ConnectWithRetryAsync(CancellationToken cancellationToken) + { + await _retryPipeline.ExecuteAsync(async ct => + { + _logger.LogInformation("Подключение к NATS: {ConnectionString}", _connectionString); + + var options = new NatsOpts + { + Url = _connectionString, + Name = "RealEstateAgency.WebApi", + ConnectTimeout = TimeSpan.FromSeconds(10) + }; + + _connection = new NatsConnection(options); + await _connection.ConnectAsync(); + + _logger.LogInformation("Успешное подключение к NATS"); + }, cancellationToken); + } + + private async Task SubscribeToCounterpartiesAsync(CancellationToken cancellationToken) + { + if (_connection == null) return; + + _logger.LogInformation("Подписка на топик: {Topic}", CounterpartyTopic); + + await foreach (var msg in _connection.SubscribeAsync(CounterpartyTopic, cancellationToken: cancellationToken)) + { + try + { + if (string.IsNullOrEmpty(msg.Data)) + continue; + + var dto = JsonSerializer.Deserialize(msg.Data, _jsonOptions); + + if (dto == null) + continue; + + using var scope = _serviceProvider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + var result = await service.CreateAsync(dto); + _logger.LogDebug("Создан контрагент: {Id} - {FullName}", result.Id, result.FullName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при обработке сообщения контрагента"); + } + } + } + + private async Task SubscribeToPropertiesAsync(CancellationToken cancellationToken) + { + if (_connection == null) return; + + _logger.LogInformation("Подписка на топик: {Topic}", PropertyTopic); + + await foreach (var msg in _connection.SubscribeAsync(PropertyTopic, cancellationToken: cancellationToken)) + { + try + { + if (string.IsNullOrEmpty(msg.Data)) + continue; + + var dto = JsonSerializer.Deserialize(msg.Data, _jsonOptions); + + if (dto == null) + continue; + + using var scope = _serviceProvider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + + var result = await service.CreateAsync(dto); + _logger.LogDebug("Создан объект недвижимости: {Id} - {Address}", result.Id, result.Address); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при обработке сообщения недвижимости"); + } + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Остановка NATS Subscriber Service..."); + + if (_connection != null) + { + await _connection.DisposeAsync(); + } + + await base.StopAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/RealEstateAgency.WebApi/appsettings.Development.json b/RealEstateAgency.WebApi/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/RealEstateAgency.WebApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/RealEstateAgency.WebApi/appsettings.json b/RealEstateAgency.WebApi/appsettings.json new file mode 100644 index 000000000..f6eaed61d --- /dev/null +++ b/RealEstateAgency.WebApi/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "RealEstateAgency": "Information" + } + }, + "ConnectionStrings": { + "MongoDB": "mongodb://localhost:27017/realestatedb" + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/RealEstateAgency.sln b/RealEstateAgency.sln new file mode 100644 index 000000000..7333862e5 --- /dev/null +++ b/RealEstateAgency.sln @@ -0,0 +1,177 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36511.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.Domain", "RealEstateAgency.Domain\RealEstateAgency.Domain.csproj", "{1234F733-C5AE-4E94-ACE4-D101E5329F05}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.Tests", "RealEstateAgency.tests\RealEstateAgency.Tests.csproj", "{7765762D-A8C1-45D9-B0B4-78F8B9113164}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.WebApi", "RealEstateAgency.WebApi\RealEstateAgency.WebApi.csproj", "{A5622F52-8268-4A23-BAA9-14E126720CCE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.WebApi.Tests", "RealEstateAgency.WebApi.Tests\RealEstateAgency.WebApi.Tests.csproj", "{0FDB0730-11C8-4AC7-91B1-90128F25329F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.Application", "RealEstateAgency.Application\RealEstateAgency.Application.csproj", "{463065AB-BE70-4792-B69C-760FF120511E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.Contracts", "RealEstateAgency.Contracts\RealEstateAgency.Contracts.csproj", "{22705121-3E44-4EC1-B603-045FC1439CAA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.Infrastructure", "RealEstateAgency.Infrastructure\RealEstateAgency.Infrastructure.csproj", "{9E6593F4-A383-4E91-A1F9-CF12253C4360}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.AppHost", "RealEstateAgency.AppHost\RealEstateAgency.AppHost.csproj", "{4CBAA548-692F-4808-B77B-BEE3450E9FB4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.ServiceDefaults", "RealEstateAgency.ServiceDefaults\RealEstateAgency.ServiceDefaults.csproj", "{2F8DDCA1-79E9-47CC-81D7-9379C6719F44}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.Generator", "RealEstateAgency.Generator\RealEstateAgency.Generator.csproj", "{7786F34F-A135-552D-8A93-9E3F027A43E1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.Generator.Tests", "RealEstateAgency.Generator.Tests\RealEstateAgency.Generator.Tests.csproj", "{98F145AA-80E0-4FD8-8199-0C0DF5BE138C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Debug|x64.ActiveCfg = Debug|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Debug|x64.Build.0 = Debug|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Debug|x86.ActiveCfg = Debug|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Debug|x86.Build.0 = Debug|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Release|Any CPU.Build.0 = Release|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Release|x64.ActiveCfg = Release|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Release|x64.Build.0 = Release|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Release|x86.ActiveCfg = Release|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Release|x86.Build.0 = Release|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Debug|x64.ActiveCfg = Debug|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Debug|x64.Build.0 = Debug|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Debug|x86.ActiveCfg = Debug|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Debug|x86.Build.0 = Debug|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Release|Any CPU.Build.0 = Release|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Release|x64.ActiveCfg = Release|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Release|x64.Build.0 = Release|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Release|x86.ActiveCfg = Release|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Release|x86.Build.0 = Release|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Debug|x64.ActiveCfg = Debug|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Debug|x64.Build.0 = Debug|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Debug|x86.ActiveCfg = Debug|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Debug|x86.Build.0 = Debug|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Release|Any CPU.Build.0 = Release|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Release|x64.ActiveCfg = Release|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Release|x64.Build.0 = Release|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Release|x86.ActiveCfg = Release|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Release|x86.Build.0 = Release|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Debug|x64.ActiveCfg = Debug|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Debug|x64.Build.0 = Debug|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Debug|x86.ActiveCfg = Debug|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Debug|x86.Build.0 = Debug|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Release|Any CPU.Build.0 = Release|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Release|x64.ActiveCfg = Release|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Release|x64.Build.0 = Release|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Release|x86.ActiveCfg = Release|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Release|x86.Build.0 = Release|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Debug|x64.ActiveCfg = Debug|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Debug|x64.Build.0 = Debug|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Debug|x86.ActiveCfg = Debug|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Debug|x86.Build.0 = Debug|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Release|Any CPU.Build.0 = Release|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Release|x64.ActiveCfg = Release|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Release|x64.Build.0 = Release|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Release|x86.ActiveCfg = Release|Any CPU + {463065AB-BE70-4792-B69C-760FF120511E}.Release|x86.Build.0 = Release|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Debug|x64.ActiveCfg = Debug|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Debug|x64.Build.0 = Debug|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Debug|x86.ActiveCfg = Debug|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Debug|x86.Build.0 = Debug|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Release|Any CPU.Build.0 = Release|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Release|x64.ActiveCfg = Release|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Release|x64.Build.0 = Release|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Release|x86.ActiveCfg = Release|Any CPU + {22705121-3E44-4EC1-B603-045FC1439CAA}.Release|x86.Build.0 = Release|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Debug|x64.Build.0 = Debug|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Debug|x86.Build.0 = Debug|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Release|Any CPU.Build.0 = Release|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Release|x64.ActiveCfg = Release|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Release|x64.Build.0 = Release|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Release|x86.ActiveCfg = Release|Any CPU + {9E6593F4-A383-4E91-A1F9-CF12253C4360}.Release|x86.Build.0 = Release|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Debug|x64.ActiveCfg = Debug|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Debug|x64.Build.0 = Debug|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Debug|x86.ActiveCfg = Debug|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Debug|x86.Build.0 = Debug|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Release|Any CPU.Build.0 = Release|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Release|x64.ActiveCfg = Release|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Release|x64.Build.0 = Release|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Release|x86.ActiveCfg = Release|Any CPU + {4CBAA548-692F-4808-B77B-BEE3450E9FB4}.Release|x86.Build.0 = Release|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Debug|x64.ActiveCfg = Debug|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Debug|x64.Build.0 = Debug|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Debug|x86.Build.0 = Debug|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Release|Any CPU.Build.0 = Release|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Release|x64.ActiveCfg = Release|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Release|x64.Build.0 = Release|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Release|x86.ActiveCfg = Release|Any CPU + {2F8DDCA1-79E9-47CC-81D7-9379C6719F44}.Release|x86.Build.0 = Release|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Debug|x64.ActiveCfg = Debug|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Debug|x64.Build.0 = Debug|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Debug|x86.Build.0 = Debug|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Release|Any CPU.Build.0 = Release|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Release|x64.ActiveCfg = Release|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Release|x64.Build.0 = Release|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Release|x86.ActiveCfg = Release|Any CPU + {7786F34F-A135-552D-8A93-9E3F027A43E1}.Release|x86.Build.0 = Release|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Debug|x64.ActiveCfg = Debug|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Debug|x64.Build.0 = Debug|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Debug|x86.ActiveCfg = Debug|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Debug|x86.Build.0 = Debug|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Release|Any CPU.Build.0 = Release|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Release|x64.ActiveCfg = Release|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Release|x64.Build.0 = Release|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Release|x86.ActiveCfg = Release|Any CPU + {98F145AA-80E0-4FD8-8199-0C0DF5BE138C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E24CBC08-6B7C-4EE7-808D-C12F305323B9} + EndGlobalSection +EndGlobal diff --git a/RealEstateAgency.tests/RealEstateAgency.tests.csproj b/RealEstateAgency.tests/RealEstateAgency.tests.csproj new file mode 100644 index 000000000..f6f2b2bc8 --- /dev/null +++ b/RealEstateAgency.tests/RealEstateAgency.tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/RealEstateAgency.tests/RealEstateQueriesTests.cs b/RealEstateAgency.tests/RealEstateQueriesTests.cs new file mode 100644 index 000000000..fab25ccd6 --- /dev/null +++ b/RealEstateAgency.tests/RealEstateQueriesTests.cs @@ -0,0 +1,159 @@ +using RealEstateAgency.Domain.Enums; + +namespace RealEstateAgency.Tests; + +/// +/// LINQ query tests for a real estate agency +/// +public class RealEstateQueriesTests(RealEstateTestFixture fixture) : IClassFixture +{ + /// + /// The test for the request: "Withdraw all sellers who submitted applications for a specified period" + /// + [Fact] + public void GetSellersInPeriodReturnsCorrectSellers() + { + var startDate = new DateTime(2024, 3, 1); + var endDate = new DateTime(2024, 6, 30); + + List expectedSellers = [ + "Зайцева Наталья Петровна", + "Козлова Мария Владимировна", + "Орлова Екатерина Дмитриевна", + "Семенова Ольга Игоревна" + ]; + + var actualSellers = fixture.Requests + .Where(r => r.Type == RequestType.Sale && + r.Date >= startDate && + r.Date <= endDate) + .Select(r => r.Counterparty.FullName) + .Distinct() + .Order() + .ToList(); + + Assert.NotNull(actualSellers); + Assert.Equal(expectedSellers, actualSellers); + } + + /// + /// The test for the request: "Bring out the top 5 clients by the number of requests (separately for purchase and sale)" + /// + [Fact] + public void Top5ClientsByRequestCountReturnsSeparateTop5() + { + List expectedTopPurchaseClients = [ + "Сидоров Алексей Петрович", + "Волков Павел Александрович", + "Морозов Андрей Сергеевич", + "Николаев Дмитрий Олегович", + "Петрова Анна Сергеевна" + ]; + + List expectedTopSaleClients = [ + "Козлова Мария Владимировна", + "Белов Игорь Васильевич", + "Зайцева Наталья Петровна", + "Иванов Иван Иванович", + "Орлова Екатерина Дмитриевна" + ]; + + var topPurchaseClients = fixture.Requests + .Where(r => r.Type == RequestType.Purchase) + .GroupBy(r => r.Counterparty) + .Select(g => new { Counterparty = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.Counterparty.FullName) + .Take(5) + .Select(x => x.Counterparty.FullName) + .ToList(); + + var topSaleClients = fixture.Requests + .Where(r => r.Type == RequestType.Sale) + .GroupBy(r => r.Counterparty) + .Select(g => new { Counterparty = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.Counterparty.FullName) + .Take(5) + .Select(x => x.Counterparty.FullName) + .ToList(); + + Assert.Equal(expectedTopPurchaseClients, topPurchaseClients); + Assert.Equal(expectedTopSaleClients, topSaleClients); + } + + /// + /// The test for the request: "Display information on the number of applications for each type of property" + /// + [Fact] + public void RequestCountByPropertyTypeReturnsCorrectStatistics() + { + var expectedStats = new Dictionary + { + [PropertyType.Apartment] = 5, + [PropertyType.House] = 2, + [PropertyType.Townhouse] = 2, + [PropertyType.Commercial] = 2, + [PropertyType.ParkingSpace] = 2, + [PropertyType.Warehouse] = 2 + }; + + var actualStatistics = fixture.Requests + .GroupBy(r => r.Property.Type) + .Select(g => new { PropertyType = g.Key, RequestCount = g.Count() }) + .OrderBy(x => x.PropertyType) + .ToList(); + + Assert.Equal(expectedStats.Count, actualStatistics.Count); + + foreach (var expected in expectedStats) + { + var actual = actualStatistics.First(x => x.PropertyType == expected.Key); + Assert.Equal(expected.Value, actual.RequestCount); + } + } + + /// + /// The test for the request: "Display information about clients who have opened applications with a minimum cost" + /// + [Fact] + public void ClientsWithMinAmountAreFoundCorrectly() + { + var expectedMinAmount = 1500000.00m; + List expectedClients = ["Зайцева Наталья Петровна"]; + + var minAmount = fixture.Requests.Min(r => r.Amount); + var actualClients = fixture.Requests + .Where(r => r.Amount == minAmount) + .Select(r => r.Counterparty.FullName) + .Distinct() + .Order() + .ToList(); + + Assert.Equal(expectedMinAmount, minAmount); + Assert.Equal(expectedClients, actualClients); + } + + /// + /// The test for the request: "Display information about all clients looking for a given type of property, sort by full name" + /// + [Fact] + public void ClientsSeekingPropertyTypeAreReturnedOrdered() + { + var targetType = PropertyType.Apartment; + List expectedClients = [ + "Петрова Анна Сергеевна", + "Сидоров Алексей Петрович" + ]; + + var actualClients = fixture.Requests + .Where(r => r.Type == RequestType.Purchase && + r.Property.Type == targetType) + .Select(r => r.Counterparty.FullName) + .Distinct() + .Order() + .ToList(); + + Assert.Equal(expectedClients, actualClients); + } +} \ No newline at end of file diff --git a/RealEstateAgency.tests/RealEstateTestFixture.cs b/RealEstateAgency.tests/RealEstateTestFixture.cs new file mode 100644 index 000000000..b83f2c78a --- /dev/null +++ b/RealEstateAgency.tests/RealEstateTestFixture.cs @@ -0,0 +1,89 @@ +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.Tests; + +/// +/// The fixture for the real estate agency's test data +/// It contains collections of counterparties, real estate objects, and applications +/// +public class RealEstateTestFixture +{ + public List Counterparties { get; } + public List Properties { get; } + public List Requests { get; } + + /// + /// Initializes the test data + /// + public RealEstateTestFixture() + { + Counterparties = GenerateCounterparties(); + Properties = GenerateProperties(); + Requests = GenerateRequests(); + } + + /// + /// Generates test counterparties + /// + 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" } + ]; + + /// + /// Generates test properties + /// + 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 } + ]; + + /// + /// Generates test applications and connects them with contractors and facilities + /// + private List GenerateRequests() + { + return + [ + new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000001"), Counterparty = Counterparties[0], Property = Properties[0], Type = RequestType.Sale, Amount = 25000000.00m, Date = new DateTime(2024, 1, 15) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000002"), Counterparty = Counterparties[1], Property = Properties[1], Type = RequestType.Sale, Amount = 18000000.00m, Date = new DateTime(2024, 2, 20) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000003"), Counterparty = Counterparties[3], Property = Properties[3], Type = RequestType.Sale, Amount = 42000000.00m, Date = new DateTime(2024, 3, 10) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000004"), Counterparty = Counterparties[6], Property = Properties[5], Type = RequestType.Sale, Amount = 35000000.00m, Date = new DateTime(2024, 4, 5) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000005"), Counterparty = Counterparties[8], Property = Properties[7], Type = RequestType.Sale, Amount = 32000000.00m, Date = new DateTime(2024, 5, 12) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000006"), Counterparty = Counterparties[10], Property = Properties[9], Type = RequestType.Sale, Amount = 1500000.00m, Date = new DateTime(2024, 6, 8) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000007"), Counterparty = Counterparties[11], Property = Properties[11], Type = RequestType.Sale, Amount = 85000000.00m, Date = new DateTime(2024, 7, 25) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000008"), Counterparty = Counterparties[2], Property = Properties[2], Type = RequestType.Purchase, Amount = 22000000.00m, Date = new DateTime(2024, 1, 20) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-000000000009"), Counterparty = Counterparties[4], Property = Properties[4], Type = RequestType.Purchase, Amount = 15000000.00m, Date = new DateTime(2024, 2, 25) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000a"), Counterparty = Counterparties[5], Property = Properties[6], Type = RequestType.Purchase, Amount = 28000000.00m, Date = new DateTime(2024, 3, 15) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000b"), Counterparty = Counterparties[7], Property = Properties[8], Type = RequestType.Purchase, Amount = 25000000.00m, Date = new DateTime(2024, 4, 18) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000c"), Counterparty = Counterparties[9], Property = Properties[10], Type = RequestType.Purchase, Amount = 1800000.00m, Date = new DateTime(2024, 5, 22) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000d"), Counterparty = Counterparties[2], Property = Properties[12], Type = RequestType.Purchase, Amount = 60000000.00m, Date = new DateTime(2024, 6, 30) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000e"), Counterparty = Counterparties[1], Property = Properties[0], Type = RequestType.Purchase, Amount = 24000000.00m, Date = new DateTime(2024, 8, 10) }, + new() { Id = Guid.Parse("20000000-0000-0000-0000-00000000000f"), Counterparty = Counterparties[3], Property = Properties[1], Type = RequestType.Sale, Amount = 19000000.00m, Date = new DateTime(2024, 9, 5) } + ]; + } +} diff --git a/RealEstateAgency.tests/Results/README_lab1.md b/RealEstateAgency.tests/Results/README_lab1.md new file mode 100644 index 000000000..c6f329ec8 --- /dev/null +++ b/RealEstateAgency.tests/Results/README_lab1.md @@ -0,0 +1,32 @@ +# : + +## + unit- . + +** :** . + +## +### RealEstateAgency.Domain + -. + +#### Enums/PropertyPurpose.cs +**:** . +#### Enums/PropertyType.cs +**:** . +#### Enums/RequestType.cs +**:** . + +#### Models/Counterparty.cs +**:** ( ). +#### Models/RealEstateProperty.cs +**:** . +#### Models/Request.cs +**:** . + +### RealEstateAgency.Tests + -. + +#### RealEstateTestFixture.cs +**:** +#### RealEstateQueriesTests.cs +**:** LINQ-. \ No newline at end of file diff --git a/RealEstateAgency.tests/Results/results_tests.jpg b/RealEstateAgency.tests/Results/results_tests.jpg new file mode 100644 index 000000000..b020a0dde Binary files /dev/null and b/RealEstateAgency.tests/Results/results_tests.jpg differ