From 7732045a3a43dcb7253463fcb142fcc845766661 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Mon, 22 Sep 2025 17:11:09 +0400 Subject: [PATCH 01/31] add domain models and enums --- .../Enums/PropertyPurpose.cs | 8 ++++++ RealEstateAgency.Domain/Enums/PropertyType.cs | 11 ++++++++ RealEstateAgency.Domain/Enums/RequestType.cs | 7 ++++++ .../Models/Counterparty.cs | 9 +++++++ .../Models/RealEstateProperty.cs | 18 +++++++++++++ RealEstateAgency.Domain/Models/Request.cs | 12 +++++++++ .../RealEstateAgency.Domain.csproj | 9 +++++++ RealEstateAgency.sln | 25 +++++++++++++++++++ 8 files changed, 99 insertions(+) create mode 100644 RealEstateAgency.Domain/Enums/PropertyPurpose.cs create mode 100644 RealEstateAgency.Domain/Enums/PropertyType.cs create mode 100644 RealEstateAgency.Domain/Enums/RequestType.cs create mode 100644 RealEstateAgency.Domain/Models/Counterparty.cs create mode 100644 RealEstateAgency.Domain/Models/RealEstateProperty.cs create mode 100644 RealEstateAgency.Domain/Models/Request.cs create mode 100644 RealEstateAgency.Domain/RealEstateAgency.Domain.csproj create mode 100644 RealEstateAgency.sln diff --git a/RealEstateAgency.Domain/Enums/PropertyPurpose.cs b/RealEstateAgency.Domain/Enums/PropertyPurpose.cs new file mode 100644 index 000000000..7d977f113 --- /dev/null +++ b/RealEstateAgency.Domain/Enums/PropertyPurpose.cs @@ -0,0 +1,8 @@ +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..d2f10f9f9 --- /dev/null +++ b/RealEstateAgency.Domain/Enums/PropertyType.cs @@ -0,0 +1,11 @@ +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..deef58fb0 --- /dev/null +++ b/RealEstateAgency.Domain/Enums/RequestType.cs @@ -0,0 +1,7 @@ +namespace RealEstateAgency.Domain.Enums; + +public enum RequestType +{ + Purchase, + Sale +} diff --git a/RealEstateAgency.Domain/Models/Counterparty.cs b/RealEstateAgency.Domain/Models/Counterparty.cs new file mode 100644 index 000000000..0d5ad4f03 --- /dev/null +++ b/RealEstateAgency.Domain/Models/Counterparty.cs @@ -0,0 +1,9 @@ +namespace RealEstateAgency.Domain.Models; + +public class Counterparty +{ + public required int 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..9f1f315f2 --- /dev/null +++ b/RealEstateAgency.Domain/Models/RealEstateProperty.cs @@ -0,0 +1,18 @@ +using RealEstateAgency.Domain.Enums; + +namespace RealEstateAgency.Domain.Models; + +public class RealEstateProperty +{ + public required int 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..0f8f394c9 --- /dev/null +++ b/RealEstateAgency.Domain/Models/Request.cs @@ -0,0 +1,12 @@ +using RealEstateAgency.Domain.Enums; + +namespace RealEstateAgency.Domain.Models; + +public class Request +{ + public required int Id { get; set; } + public required Counterparty Counterparty { get; set; } + public required RealEstateProperty Property { get; set; } + public required RequestType Type { get; set; } + public required decimal Amount { 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.sln b/RealEstateAgency.sln new file mode 100644 index 000000000..5b69f4958 --- /dev/null +++ b/RealEstateAgency.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36511.14 d17.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 +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + 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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E24CBC08-6B7C-4EE7-808D-C12F305323B9} + EndGlobalSection +EndGlobal From d6d103d9b0f85ea2f77b57c56e31b8bfff5b339a Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Tue, 23 Sep 2025 20:01:26 +0400 Subject: [PATCH 02/31] Adding test data about a counterparty --- RealEstateAgency.Domain/Models/Request.cs | 1 + RealEstateAgency.sln | 8 ++++- .../RealEstateAgency.tests.csproj | 27 ++++++++++++++ .../RealEstateTestFixture.cs | 35 +++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 RealEstateAgency.tests/RealEstateAgency.tests.csproj create mode 100644 RealEstateAgency.tests/RealEstateTestFixture.cs diff --git a/RealEstateAgency.Domain/Models/Request.cs b/RealEstateAgency.Domain/Models/Request.cs index 0f8f394c9..0a33e4275 100644 --- a/RealEstateAgency.Domain/Models/Request.cs +++ b/RealEstateAgency.Domain/Models/Request.cs @@ -9,4 +9,5 @@ public class Request public required RealEstateProperty Property { get; set; } public required RequestType Type { get; set; } public required decimal Amount { get; set; } + public required DateTime Date { get; set; } } diff --git a/RealEstateAgency.sln b/RealEstateAgency.sln index 5b69f4958..81ea172aa 100644 --- a/RealEstateAgency.sln +++ b/RealEstateAgency.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36511.14 d17.14 +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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {1234F733-C5AE-4E94-ACE4-D101E5329F05}.Debug|Any CPU.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 + {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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE 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/RealEstateTestFixture.cs b/RealEstateAgency.tests/RealEstateTestFixture.cs new file mode 100644 index 000000000..fb2ea5a65 --- /dev/null +++ b/RealEstateAgency.tests/RealEstateTestFixture.cs @@ -0,0 +1,35 @@ +using RealEstateAgency.Domain.Models; +using RealEstateAgency.Domain.Enums; + +namespace RealEstateAgency.Tests; + +public class RealEstateTestFixture +{ + public List Counterparties { get; } + public List Properties { get; } + public List Requests { get; } + + public RealEstateTestFixture() + { + Counterparties = GenerateCounterparties(); + } + + private List GenerateCounterparties() + { + return new List + { + new() { Id = 1, FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" }, + new() { Id = 2, FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" }, + new() { Id = 3, FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" }, + new() { Id = 4, FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" }, + new() { Id = 5, FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" }, + new() { Id = 6, FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" }, + new() { Id = 7, FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" }, + new() { Id = 8, FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" }, + new() { Id = 9, FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" }, + new() { Id = 10, FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" }, + new() { Id = 11, FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" }, + new() { Id = 12, FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" } + }; + } +} \ No newline at end of file From ee1641c8702ff57cf2a48327527b04b45c6a11ce Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Tue, 23 Sep 2025 20:27:45 +0400 Subject: [PATCH 03/31] Adding test data about real estate and requests --- .../RealEstateTestFixture.cs | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) diff --git a/RealEstateAgency.tests/RealEstateTestFixture.cs b/RealEstateAgency.tests/RealEstateTestFixture.cs index fb2ea5a65..6e41cc1da 100644 --- a/RealEstateAgency.tests/RealEstateTestFixture.cs +++ b/RealEstateAgency.tests/RealEstateTestFixture.cs @@ -12,6 +12,8 @@ public class RealEstateTestFixture public RealEstateTestFixture() { Counterparties = GenerateCounterparties(); + Properties = GenerateProperties(); + Requests = GenerateRequests(Counterparties, Properties); } private List GenerateCounterparties() @@ -32,4 +34,313 @@ private List GenerateCounterparties() new() { Id = 12, FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" } }; } + + private List GenerateProperties() + { + return new List + { + new() { + Id = 1, + 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 = 2, + 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 = 3, + 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 = 4, + 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 = 5, + 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 = 6, + 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 = 7, + 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 = 8, + 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 = 9, + 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 = 10, + 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 = 11, + 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 = 12, + 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 = 13, + 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 List GenerateRequests(List counterparties, List properties) + { + return new List + { + new() { + Id = 1, + Counterparty = counterparties[0], + Property = properties[0], + Type = RequestType.Sale, + Amount = 25000000.00m, + Date = new DateTime(2024, 1, 15) + }, + new() { + Id = 2, + Counterparty = counterparties[1], + Property = properties[1], + Type = RequestType.Sale, + Amount = 18000000.00m, + Date = new DateTime(2024, 2, 20) + }, + new() { + Id = 3, + Counterparty = counterparties[3], + Property = properties[3], + Type = RequestType.Sale, + Amount = 42000000.00m, + Date = new DateTime(2024, 3, 10) + }, + new() { + Id = 4, + Counterparty = counterparties[6], + Property = properties[5], + Type = RequestType.Sale, + Amount = 35000000.00m, + Date = new DateTime(2024, 4, 5) + }, + new() { + Id = 5, + Counterparty = counterparties[8], + Property = properties[7], + Type = RequestType.Sale, + Amount = 32000000.00m, + Date = new DateTime(2024, 5, 12) + }, + new() { + Id = 6, + Counterparty = counterparties[10], + Property = properties[9], + Type = RequestType.Sale, + Amount = 1500000.00m, + Date = new DateTime(2024, 6, 8) + }, + new() { + Id = 7, + Counterparty = counterparties[11], + Property = properties[11], + Type = RequestType.Sale, + Amount = 85000000.00m, + Date = new DateTime(2024, 7, 25) + }, + + new() { + Id = 8, + Counterparty = counterparties[2], + Property = properties[2], + Type = RequestType.Purchase, + Amount = 22000000.00m, + Date = new DateTime(2024, 1, 20) + }, + new() { + Id = 9, + Counterparty = counterparties[4], + Property = properties[4], + Type = RequestType.Purchase, + Amount = 15000000.00m, + Date = new DateTime(2024, 2, 25) + }, + new() { + Id = 10, + Counterparty = counterparties[5], + Property = properties[6], + Type = RequestType.Purchase, + Amount = 28000000.00m, + Date = new DateTime(2024, 3, 15) + }, + new() { + Id = 11, + Counterparty = counterparties[7], + Property = properties[8], + Type = RequestType.Purchase, + Amount = 25000000.00m, + Date = new DateTime(2024, 4, 18) + }, + new() { + Id = 12, + Counterparty = counterparties[9], + Property = properties[10], + Type = RequestType.Purchase, + Amount = 1800000.00m, + Date = new DateTime(2024, 5, 22) + }, + new() { + Id = 13, + Counterparty = counterparties[2], + Property = properties[12], + Type = RequestType.Purchase, + Amount = 60000000.00m, + Date = new DateTime(2024, 6, 30) + }, + + new() { + Id = 14, + Counterparty = counterparties[1], + Property = properties[0], + Type = RequestType.Purchase, + Amount = 24000000.00m, + Date = new DateTime(2024, 8, 10) + }, + new() { + Id = 15, + Counterparty = counterparties[3], + Property = properties[1], + Type = RequestType.Sale, + Amount = 19000000.00m, + Date = new DateTime(2024, 9, 5) + } + }; + } } \ No newline at end of file From cdceb19bc5b7bb85562fad19866df91513252391 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 24 Sep 2025 20:06:38 +0400 Subject: [PATCH 04/31] Writing tests --- .../RealEstateQueriesTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 RealEstateAgency.tests/RealEstateQueriesTests.cs diff --git a/RealEstateAgency.tests/RealEstateQueriesTests.cs b/RealEstateAgency.tests/RealEstateQueriesTests.cs new file mode 100644 index 000000000..56c7899cb --- /dev/null +++ b/RealEstateAgency.tests/RealEstateQueriesTests.cs @@ -0,0 +1,153 @@ +using RealEstateAgency.Domain.Enums; + +namespace RealEstateAgency.Tests; + +public class RealEstateQueriesTests +{ + private readonly RealEstateTestFixture _fixture; + + public RealEstateQueriesTests() + { + _fixture = new RealEstateTestFixture(); + } + + [Fact] + public void GetSellersInPeriod_ReturnsCorrectSellers() + { + var startDate = new DateTime(2024, 3, 1); + var endDate = new DateTime(2024, 6, 30); + + var expectedSellers = _fixture.Requests + .Where(r => r.Type == RequestType.Sale && + r.Date >= startDate && + r.Date <= endDate) + .Select(r => r.Counterparty) + .Distinct() + .ToList(); + + var actualSellers = _fixture.Requests + .Where(r => r.Type == RequestType.Sale && + r.Date >= startDate && + r.Date <= endDate) + .Select(r => r.Counterparty) + .Distinct() + .ToList(); + + Assert.NotNull(actualSellers); + Assert.Equal(expectedSellers.Count, actualSellers.Count); + } + + [Fact] + public void Top5ClientsByRequestCount_ReturnsSeparateTop5() + { + 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) + .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) + .ToList(); + + Assert.NotNull(topPurchaseClients); + Assert.NotNull(topSaleClients); + Assert.True(topPurchaseClients.Count <= 5); + Assert.True(topSaleClients.Count <= 5); + } + + [Fact] + public void RequestCountByPropertyType_ReturnsStatistics() + { + var expectedStatistics = _fixture.Requests + .GroupBy(r => r.Property.Type) + .Select(g => new + { + PropertyType = g.Key, + RequestCount = g.Count() + }) + .OrderBy(x => x.PropertyType) + .ToList(); + + var actualStatistics = _fixture.Requests + .GroupBy(r => r.Property.Type) + .Select(g => new + { + PropertyType = g.Key, + RequestCount = g.Count() + }) + .OrderBy(x => x.PropertyType) + .ToList(); + + Assert.NotNull(actualStatistics); + Assert.Equal(expectedStatistics.Count, actualStatistics.Count); + } + + [Fact] + public void ClientsWithMinAmount_AreFoundCorrectly() + { + var expectedMinAmount = _fixture.Requests.Min(r => r.Amount); + + var expectedClients = _fixture.Requests + .Where(r => r.Amount == expectedMinAmount) + .Select(r => r.Counterparty) + .Distinct() + .OrderBy(c => c.FullName) + .ToList(); + + var minAmount = _fixture.Requests.Min(r => r.Amount); + var actualClients = _fixture.Requests + .Where(r => r.Amount == minAmount) + .Select(r => r.Counterparty) + .Distinct() + .OrderBy(c => c.FullName) + .ToList(); + + + Assert.NotNull(actualClients); + Assert.Equal(expectedMinAmount, minAmount); + Assert.Equal(expectedClients.Count, actualClients.Count); + } + + [Fact] + public void ClientsSeekingPropertyType_AreReturnedOrdered() + { + var targetType = PropertyType.Apartment; + + var expectedClients = _fixture.Requests + .Where(r => r.Type == RequestType.Purchase && + r.Property.Type == targetType) + .Select(r => r.Counterparty) + .Distinct() + .OrderBy(c => c.FullName) + .ToList(); + + var actualClients = _fixture.Requests + .Where(r => r.Type == RequestType.Purchase && + r.Property.Type == targetType) + .Select(r => r.Counterparty) + .Distinct() + .OrderBy(c => c.FullName) + .ToList(); + + + Assert.NotNull(actualClients); + Assert.Equal(expectedClients.Count, actualClients.Count); + } +} From b4726faf90346037467f10d17e2ff2fde6e1b5ba Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 24 Sep 2025 21:04:53 +0400 Subject: [PATCH 05/31] adding comments --- .../Enums/PropertyPurpose.cs | 15 ++++++ RealEstateAgency.Domain/Enums/PropertyType.cs | 27 +++++++++++ RealEstateAgency.Domain/Enums/RequestType.cs | 11 +++++ .../Models/Counterparty.cs | 19 ++++++++ .../Models/RealEstateProperty.cs | 47 +++++++++++++++++++ RealEstateAgency.Domain/Models/Request.cs | 27 +++++++++++ .../RealEstateQueriesTests.cs | 21 +++++++++ .../RealEstateTestFixture.cs | 17 +++++++ 8 files changed, 184 insertions(+) diff --git a/RealEstateAgency.Domain/Enums/PropertyPurpose.cs b/RealEstateAgency.Domain/Enums/PropertyPurpose.cs index 7d977f113..96eda9ba6 100644 --- a/RealEstateAgency.Domain/Enums/PropertyPurpose.cs +++ b/RealEstateAgency.Domain/Enums/PropertyPurpose.cs @@ -1,8 +1,23 @@ namespace RealEstateAgency.Domain.Enums; +/// +/// Purpose of the property +/// Defines the main purpose of using the property +/// public enum PropertyPurpose { + /// + /// Residential purpose - for human habitation + /// Residential, + + /// + /// Commercial purpose - for business activities + /// Commercial, + + /// + /// Industrial use - for production activities + /// Industrial } diff --git a/RealEstateAgency.Domain/Enums/PropertyType.cs b/RealEstateAgency.Domain/Enums/PropertyType.cs index d2f10f9f9..bd1b8d8b8 100644 --- a/RealEstateAgency.Domain/Enums/PropertyType.cs +++ b/RealEstateAgency.Domain/Enums/PropertyType.cs @@ -1,11 +1,38 @@ namespace RealEstateAgency.Domain.Enums; +/// +/// Property type +/// Classifies property by physical characteristics +/// public enum PropertyType { + /// + /// Apartment in an apartment building + /// Apartment, + + /// + /// Detached apartment building + /// House, + + /// + /// A blockaded apartment building with separate entrances + /// Townhouse, + + /// + /// Commercial premises for business + /// Commercial, + + /// + /// Warehouse or production premises + /// Warehouse, + + /// + /// A place for parking vehicles + /// ParkingSpace } diff --git a/RealEstateAgency.Domain/Enums/RequestType.cs b/RealEstateAgency.Domain/Enums/RequestType.cs index deef58fb0..283799a93 100644 --- a/RealEstateAgency.Domain/Enums/RequestType.cs +++ b/RealEstateAgency.Domain/Enums/RequestType.cs @@ -1,7 +1,18 @@ namespace RealEstateAgency.Domain.Enums; +/// +/// The type of application in the real estate agency +/// Determines the direction of the real estate transaction +/// public enum RequestType { + /// + /// Application for the purchase of real estate + /// Purchase, + + /// + /// Application for real estate sale + /// Sale } diff --git a/RealEstateAgency.Domain/Models/Counterparty.cs b/RealEstateAgency.Domain/Models/Counterparty.cs index 0d5ad4f03..9ab085202 100644 --- a/RealEstateAgency.Domain/Models/Counterparty.cs +++ b/RealEstateAgency.Domain/Models/Counterparty.cs @@ -1,9 +1,28 @@ namespace RealEstateAgency.Domain.Models; +/// +/// The counterparty of the real estate agency +/// An individual involved in real estate transactions +/// public class Counterparty { + /// + /// The unique identifier of the counterparty + /// public required int Id { get; set; } + + /// + /// The counterparty's full name in the "Last Name, First Name, Patronymic" format + /// public required string FullName { get; set; } + + /// + /// Passport number for identification + /// public required string PassportNumber { get; set; } + + /// + /// Contact phone number for communication + /// public required string PhoneNumber { get; set; } } diff --git a/RealEstateAgency.Domain/Models/RealEstateProperty.cs b/RealEstateAgency.Domain/Models/RealEstateProperty.cs index 9f1f315f2..872141580 100644 --- a/RealEstateAgency.Domain/Models/RealEstateProperty.cs +++ b/RealEstateAgency.Domain/Models/RealEstateProperty.cs @@ -2,17 +2,64 @@ namespace RealEstateAgency.Domain.Models; +/// +/// The real estate object +/// Describes the physical characteristics of the property +/// public class RealEstateProperty { + /// + /// The unique identifier of the object + /// public required int Id { get; set; } + + /// + /// Property type + /// public required PropertyType Type { get; set; } + + /// + /// Purpose of the property + /// public required PropertyPurpose Purpose { get; set; } + + /// + /// A unique identifier in the state registry + /// public required string CadastralNumber { get; set; } + + /// + /// The physical address of the object location + /// public required string Address { get; set; } + + /// + /// Total number of floors of the building + /// public int? TotalFloors { get; set; } + + /// + /// The total area of the facility in square meters + /// public required double TotalArea { get; set; } + + /// + /// Number of rooms in the facility + /// public int? RoomsCount { get; set; } + + /// + /// Ceiling height in meters + /// public double? CeilingHeight { get; set; } + + /// + /// The floor of the object location + /// public int? Floor { get; set; } + + /// + /// The presence of legal encumbrances (collateral, arrest, mortgage) + /// public bool? HasEncumbrances { get; set; } } diff --git a/RealEstateAgency.Domain/Models/Request.cs b/RealEstateAgency.Domain/Models/Request.cs index 0a33e4275..2b21410bb 100644 --- a/RealEstateAgency.Domain/Models/Request.cs +++ b/RealEstateAgency.Domain/Models/Request.cs @@ -2,12 +2,39 @@ namespace RealEstateAgency.Domain.Models; +/// +/// Application for a real estate transaction +/// It is an agreement between the counterparty and the agency. +/// public class Request { + /// + /// The unique identifier of the application + /// public required int Id { get; set; } + + /// + /// The counterparty who submitted the application + /// public required Counterparty Counterparty { get; set; } + + /// + /// The real estate object associated with the application + /// public required RealEstateProperty Property { get; set; } + + /// + /// Type of operation: purchase or sale + /// public required RequestType Type { get; set; } + + /// + /// The amount of money for the application in rubles + /// public required decimal Amount { get; set; } + + /// + /// Application submission date + /// public required DateTime Date { get; set; } } diff --git a/RealEstateAgency.tests/RealEstateQueriesTests.cs b/RealEstateAgency.tests/RealEstateQueriesTests.cs index 56c7899cb..05a79c84d 100644 --- a/RealEstateAgency.tests/RealEstateQueriesTests.cs +++ b/RealEstateAgency.tests/RealEstateQueriesTests.cs @@ -2,15 +2,24 @@ namespace RealEstateAgency.Tests; +/// +/// LINQ query tests for a real estate agency +/// public class RealEstateQueriesTests { private readonly RealEstateTestFixture _fixture; + /// + /// Initializes the test data before each test + /// public RealEstateQueriesTests() { _fixture = new RealEstateTestFixture(); } + /// + /// The test for the request: "Withdraw all sellers who submitted applications for a specified period" + /// [Fact] public void GetSellersInPeriod_ReturnsCorrectSellers() { @@ -37,6 +46,9 @@ public void GetSellersInPeriod_ReturnsCorrectSellers() Assert.Equal(expectedSellers.Count, actualSellers.Count); } + /// + /// The test for the request: "Bring out the top 5 clients by the number of requests (separately for purchase and sale)" + /// [Fact] public void Top5ClientsByRequestCount_ReturnsSeparateTop5() { @@ -72,6 +84,9 @@ public void Top5ClientsByRequestCount_ReturnsSeparateTop5() Assert.True(topSaleClients.Count <= 5); } + /// + /// The test for the request: "Display information on the number of applications for each type of property" + /// [Fact] public void RequestCountByPropertyType_ReturnsStatistics() { @@ -99,6 +114,9 @@ public void RequestCountByPropertyType_ReturnsStatistics() Assert.Equal(expectedStatistics.Count, actualStatistics.Count); } + /// + /// The test for the request: "Display information about clients who have opened applications with a minimum cost" + /// [Fact] public void ClientsWithMinAmount_AreFoundCorrectly() { @@ -125,6 +143,9 @@ public void ClientsWithMinAmount_AreFoundCorrectly() Assert.Equal(expectedClients.Count, actualClients.Count); } + /// + /// The test for the request: "Display information about all clients looking for a given type of property, sort by full name" + /// [Fact] public void ClientsSeekingPropertyType_AreReturnedOrdered() { diff --git a/RealEstateAgency.tests/RealEstateTestFixture.cs b/RealEstateAgency.tests/RealEstateTestFixture.cs index 6e41cc1da..de39edb44 100644 --- a/RealEstateAgency.tests/RealEstateTestFixture.cs +++ b/RealEstateAgency.tests/RealEstateTestFixture.cs @@ -3,12 +3,19 @@ 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(); @@ -16,6 +23,9 @@ public RealEstateTestFixture() Requests = GenerateRequests(Counterparties, Properties); } + /// + /// Generates test counterparties + /// private List GenerateCounterparties() { return new List @@ -35,6 +45,9 @@ private List GenerateCounterparties() }; } + /// + /// Generates test properties + /// private List GenerateProperties() { return new List @@ -215,6 +228,10 @@ private List GenerateProperties() } }; } + + /// + /// Generates test applications and connects them with contractors and facilities + /// private List GenerateRequests(List counterparties, List properties) { return new List From 9c210730384edd4d30e13c096423abf64f464641 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 24 Sep 2025 21:31:58 +0400 Subject: [PATCH 06/31] Adding results and descriptions --- RealEstateAgency.tests/Results/README_lab1.md | 32 ++++++++++++++++++ .../Results/results_tests.jpg | Bin 0 -> 63011 bytes 2 files changed, 32 insertions(+) create mode 100644 RealEstateAgency.tests/Results/README_lab1.md create mode 100644 RealEstateAgency.tests/Results/results_tests.jpg 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 0000000000000000000000000000000000000000..b020a0ddea3ab2176e19d4f8450d505c5576eccf GIT binary patch literal 63011 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

CC|+(ahv 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 Gv3~)V2q|I! literal 0 HcmV?d00001 From b47b7a068fbeca40f5b117c09b6e1222417fb0e7 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Tue, 30 Sep 2025 20:15:33 +0400 Subject: [PATCH 07/31] Making changes --- .github/workflows/dotnet-tests.yml | 29 ++ README.md | 146 +------ .../RealEstateQueriesTests.cs | 149 ++++--- .../RealEstateTestFixture.cs | 376 +++--------------- 4 files changed, 169 insertions(+), 531 deletions(-) create mode 100644 .github/workflows/dotnet-tests.yml diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml new file mode 100644 index 000000000..581520738 --- /dev/null +++ b/.github/workflows/dotnet-tests.yml @@ -0,0 +1,29 @@ +name: .NET Tests + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build-and-test: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore dependencies + run: dotnet restore RealEstateAgency.sln + + - name: Build solution + run: dotnet build RealEstateAgency.sln --no-restore + + - name: Run tests + run: dotnet test RealEstateAgency.Tests/RealEstateAgency.Tests.csproj --no-build \ No newline at end of file diff --git a/README.md b/README.md index 39c9a8443..54283eab4 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,32 @@ # Разработка корпоративных приложений [Таблица с успеваемостью](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 запуск клиентского приложения для написанного ранее сервера. Клиент создается в рамках курса "Веб разработка". -
-
+### Функциональные возможности +* PropertyPurpose - Назначение недвижимости +* PropertyType - Тип объекта +* RequestType - Тип заявки +* Counterparty - Клиент агентства +* RealEstateProperty - Объект недвижимости с полным описанием характеристик +* Request - Заявка на операцию с недвижимостью -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.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 с информацией о задании, скриншоты приложения и прочая информация. +### Тестирование +* GetSellersInPeriodReturnsCorrectSellers() - Поиск продавцов за указанный период +* Top5ClientsByRequestCountReturnsSeparateTop5() - Топ-5 клиентов по количеству заявок (покупка/продажа отдельно) +* RequestCountByPropertyTypeReturnsCorrectStatistics() - Статистика заявок по типам недвижимости +* ClientsWithMinAmountAreFoundCorrectly() - Клиенты с заявками минимальной стоимости +* ClientsSeekingPropertyTypeAreReturnedOrdered() - Поиск клиентов по типу недвижимости с сортировкой -**Факультативно**: -* Реализация авторизации/аутентификации. -* Реализация 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). diff --git a/RealEstateAgency.tests/RealEstateQueriesTests.cs b/RealEstateAgency.tests/RealEstateQueriesTests.cs index 05a79c84d..d56c00371 100644 --- a/RealEstateAgency.tests/RealEstateQueriesTests.cs +++ b/RealEstateAgency.tests/RealEstateQueriesTests.cs @@ -5,170 +5,157 @@ namespace RealEstateAgency.Tests; /// /// LINQ query tests for a real estate agency /// -public class RealEstateQueriesTests +public class RealEstateQueriesTests(RealEstateTestFixture fixture) : IClassFixture { - private readonly RealEstateTestFixture _fixture; - - /// - /// Initializes the test data before each test - /// - public RealEstateQueriesTests() - { - _fixture = new RealEstateTestFixture(); - } + private readonly RealEstateTestFixture _fixture = fixture; /// /// The test for the request: "Withdraw all sellers who submitted applications for a specified period" /// [Fact] - public void GetSellersInPeriod_ReturnsCorrectSellers() + public void GetSellersInPeriodReturnsCorrectSellers() { var startDate = new DateTime(2024, 3, 1); var endDate = new DateTime(2024, 6, 30); - var expectedSellers = _fixture.Requests - .Where(r => r.Type == RequestType.Sale && - r.Date >= startDate && - r.Date <= endDate) - .Select(r => r.Counterparty) - .Distinct() - .ToList(); + List expectedSellers = [ + "Зайцева Наталья Петровна", + "Козлова Мария Владимировна", + "Орлова Екатерина Дмитриевна", + "Семенова Ольга Игоревна" + ]; var actualSellers = _fixture.Requests .Where(r => r.Type == RequestType.Sale && r.Date >= startDate && r.Date <= endDate) - .Select(r => r.Counterparty) + .Select(r => r.Counterparty.FullName) .Distinct() + .Order() .ToList(); Assert.NotNull(actualSellers); - Assert.Equal(expectedSellers.Count, actualSellers.Count); + 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 Top5ClientsByRequestCount_ReturnsSeparateTop5() + 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() - }) + .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() - }) + .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.NotNull(topPurchaseClients); - Assert.NotNull(topSaleClients); - Assert.True(topPurchaseClients.Count <= 5); - Assert.True(topSaleClients.Count <= 5); + 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 RequestCountByPropertyType_ReturnsStatistics() + public void RequestCountByPropertyTypeReturnsCorrectStatistics() { - var expectedStatistics = _fixture.Requests - .GroupBy(r => r.Property.Type) - .Select(g => new - { - PropertyType = g.Key, - RequestCount = g.Count() - }) - .OrderBy(x => x.PropertyType) - .ToList(); + 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() - }) + .Select(g => new { PropertyType = g.Key, RequestCount = g.Count() }) .OrderBy(x => x.PropertyType) .ToList(); - Assert.NotNull(actualStatistics); - Assert.Equal(expectedStatistics.Count, actualStatistics.Count); + 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 ClientsWithMinAmount_AreFoundCorrectly() + public void ClientsWithMinAmountAreFoundCorrectly() { - var expectedMinAmount = _fixture.Requests.Min(r => r.Amount); - - var expectedClients = _fixture.Requests - .Where(r => r.Amount == expectedMinAmount) - .Select(r => r.Counterparty) - .Distinct() - .OrderBy(c => c.FullName) - .ToList(); + 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) + .Select(r => r.Counterparty.FullName) .Distinct() - .OrderBy(c => c.FullName) + .Order() .ToList(); - - Assert.NotNull(actualClients); Assert.Equal(expectedMinAmount, minAmount); - Assert.Equal(expectedClients.Count, actualClients.Count); + 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 ClientsSeekingPropertyType_AreReturnedOrdered() + public void ClientsSeekingPropertyTypeAreReturnedOrdered() { var targetType = PropertyType.Apartment; - - var expectedClients = _fixture.Requests - .Where(r => r.Type == RequestType.Purchase && - r.Property.Type == targetType) - .Select(r => r.Counterparty) - .Distinct() - .OrderBy(c => c.FullName) - .ToList(); + List expectedClients = [ + "Петрова Анна Сергеевна", + "Сидоров Алексей Петрович" + ]; var actualClients = _fixture.Requests .Where(r => r.Type == RequestType.Purchase && r.Property.Type == targetType) - .Select(r => r.Counterparty) + .Select(r => r.Counterparty.FullName) .Distinct() - .OrderBy(c => c.FullName) + .Order() .ToList(); - - Assert.NotNull(actualClients); - Assert.Equal(expectedClients.Count, actualClients.Count); + Assert.Equal(expectedClients, actualClients); } -} +} \ No newline at end of file diff --git a/RealEstateAgency.tests/RealEstateTestFixture.cs b/RealEstateAgency.tests/RealEstateTestFixture.cs index de39edb44..7243fa2f8 100644 --- a/RealEstateAgency.tests/RealEstateTestFixture.cs +++ b/RealEstateAgency.tests/RealEstateTestFixture.cs @@ -20,344 +20,70 @@ public RealEstateTestFixture() { Counterparties = GenerateCounterparties(); Properties = GenerateProperties(); - Requests = GenerateRequests(Counterparties, Properties); + Requests = GenerateRequests(); } /// /// Generates test counterparties /// - private List GenerateCounterparties() - { - return new List - { - new() { Id = 1, FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" }, - new() { Id = 2, FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" }, - new() { Id = 3, FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" }, - new() { Id = 4, FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" }, - new() { Id = 5, FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" }, - new() { Id = 6, FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" }, - new() { Id = 7, FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" }, - new() { Id = 8, FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" }, - new() { Id = 9, FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" }, - new() { Id = 10, FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" }, - new() { Id = 11, FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" }, - new() { Id = 12, FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" } - }; - } + private static List GenerateCounterparties() => + [ + new() { Id = 1, FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" }, + new() { Id = 2, FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" }, + new() { Id = 3, FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" }, + new() { Id = 4, FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" }, + new() { Id = 5, FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" }, + new() { Id = 6, FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" }, + new() { Id = 7, FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" }, + new() { Id = 8, FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" }, + new() { Id = 9, FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" }, + new() { Id = 10, FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" }, + new() { Id = 11, FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" }, + new() { Id = 12, FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" } + ]; /// /// Generates test properties /// - private List GenerateProperties() - { - return new List - { - new() { - Id = 1, - 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 = 2, - 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 = 3, - 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 = 4, - 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 = 5, - 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 = 6, - 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 = 7, - 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 = 8, - 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 = 9, - 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 = 10, - 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 = 11, - 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 = 12, - 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 = 13, - 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 GenerateProperties() => + [ + new() { Id = 1, 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 = 2, 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 = 3, 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 = 4, 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 = 5, 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 = 6, 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 = 7, 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 = 8, 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 = 9, 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 = 10, 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 = 11, 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 = 12, 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 = 13, 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(List counterparties, List properties) + private List GenerateRequests() { - return new List - { - new() { - Id = 1, - Counterparty = counterparties[0], - Property = properties[0], - Type = RequestType.Sale, - Amount = 25000000.00m, - Date = new DateTime(2024, 1, 15) - }, - new() { - Id = 2, - Counterparty = counterparties[1], - Property = properties[1], - Type = RequestType.Sale, - Amount = 18000000.00m, - Date = new DateTime(2024, 2, 20) - }, - new() { - Id = 3, - Counterparty = counterparties[3], - Property = properties[3], - Type = RequestType.Sale, - Amount = 42000000.00m, - Date = new DateTime(2024, 3, 10) - }, - new() { - Id = 4, - Counterparty = counterparties[6], - Property = properties[5], - Type = RequestType.Sale, - Amount = 35000000.00m, - Date = new DateTime(2024, 4, 5) - }, - new() { - Id = 5, - Counterparty = counterparties[8], - Property = properties[7], - Type = RequestType.Sale, - Amount = 32000000.00m, - Date = new DateTime(2024, 5, 12) - }, - new() { - Id = 6, - Counterparty = counterparties[10], - Property = properties[9], - Type = RequestType.Sale, - Amount = 1500000.00m, - Date = new DateTime(2024, 6, 8) - }, - new() { - Id = 7, - Counterparty = counterparties[11], - Property = properties[11], - Type = RequestType.Sale, - Amount = 85000000.00m, - Date = new DateTime(2024, 7, 25) - }, - - new() { - Id = 8, - Counterparty = counterparties[2], - Property = properties[2], - Type = RequestType.Purchase, - Amount = 22000000.00m, - Date = new DateTime(2024, 1, 20) - }, - new() { - Id = 9, - Counterparty = counterparties[4], - Property = properties[4], - Type = RequestType.Purchase, - Amount = 15000000.00m, - Date = new DateTime(2024, 2, 25) - }, - new() { - Id = 10, - Counterparty = counterparties[5], - Property = properties[6], - Type = RequestType.Purchase, - Amount = 28000000.00m, - Date = new DateTime(2024, 3, 15) - }, - new() { - Id = 11, - Counterparty = counterparties[7], - Property = properties[8], - Type = RequestType.Purchase, - Amount = 25000000.00m, - Date = new DateTime(2024, 4, 18) - }, - new() { - Id = 12, - Counterparty = counterparties[9], - Property = properties[10], - Type = RequestType.Purchase, - Amount = 1800000.00m, - Date = new DateTime(2024, 5, 22) - }, - new() { - Id = 13, - Counterparty = counterparties[2], - Property = properties[12], - Type = RequestType.Purchase, - Amount = 60000000.00m, - Date = new DateTime(2024, 6, 30) - }, - - new() { - Id = 14, - Counterparty = counterparties[1], - Property = properties[0], - Type = RequestType.Purchase, - Amount = 24000000.00m, - Date = new DateTime(2024, 8, 10) - }, - new() { - Id = 15, - Counterparty = counterparties[3], - Property = properties[1], - Type = RequestType.Sale, - Amount = 19000000.00m, - Date = new DateTime(2024, 9, 5) - } - }; + return + [ + new() { Id = 1, Counterparty = Counterparties[0], Property = Properties[0], Type = RequestType.Sale, Amount = 25000000.00m, Date = new DateTime(2024, 1, 15) }, + new() { Id = 2, Counterparty = Counterparties[1], Property = Properties[1], Type = RequestType.Sale, Amount = 18000000.00m, Date = new DateTime(2024, 2, 20) }, + new() { Id = 3, Counterparty = Counterparties[3], Property = Properties[3], Type = RequestType.Sale, Amount = 42000000.00m, Date = new DateTime(2024, 3, 10) }, + new() { Id = 4, Counterparty = Counterparties[6], Property = Properties[5], Type = RequestType.Sale, Amount = 35000000.00m, Date = new DateTime(2024, 4, 5) }, + new() { Id = 5, Counterparty = Counterparties[8], Property = Properties[7], Type = RequestType.Sale, Amount = 32000000.00m, Date = new DateTime(2024, 5, 12) }, + new() { Id = 6, Counterparty = Counterparties[10], Property = Properties[9], Type = RequestType.Sale, Amount = 1500000.00m, Date = new DateTime(2024, 6, 8) }, + new() { Id = 7, Counterparty = Counterparties[11], Property = Properties[11], Type = RequestType.Sale, Amount = 85000000.00m, Date = new DateTime(2024, 7, 25) }, + new() { Id = 8, Counterparty = Counterparties[2], Property = Properties[2], Type = RequestType.Purchase, Amount = 22000000.00m, Date = new DateTime(2024, 1, 20) }, + new() { Id = 9, Counterparty = Counterparties[4], Property = Properties[4], Type = RequestType.Purchase, Amount = 15000000.00m, Date = new DateTime(2024, 2, 25) }, + new() { Id = 10, Counterparty = Counterparties[5], Property = Properties[6], Type = RequestType.Purchase, Amount = 28000000.00m, Date = new DateTime(2024, 3, 15) }, + new() { Id = 11, Counterparty = Counterparties[7], Property = Properties[8], Type = RequestType.Purchase, Amount = 25000000.00m, Date = new DateTime(2024, 4, 18) }, + new() { Id = 12, Counterparty = Counterparties[9], Property = Properties[10], Type = RequestType.Purchase, Amount = 1800000.00m, Date = new DateTime(2024, 5, 22) }, + new() { Id = 13, Counterparty = Counterparties[2], Property = Properties[12], Type = RequestType.Purchase, Amount = 60000000.00m, Date = new DateTime(2024, 6, 30) }, + new() { Id = 14, Counterparty = Counterparties[1], Property = Properties[0], Type = RequestType.Purchase, Amount = 24000000.00m, Date = new DateTime(2024, 8, 10) }, + new() { Id = 15, Counterparty = Counterparties[3], Property = Properties[1], Type = RequestType.Sale, Amount = 19000000.00m, Date = new DateTime(2024, 9, 5) } + ]; } } \ No newline at end of file From d7880b50065b792237cbfb3e9df344a16e4bb9f7 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 1 Oct 2025 13:15:42 +0400 Subject: [PATCH 08/31] Correction of comments --- .github/workflows/dotnet-tests.yml | 2 +- RealEstateAgency.tests/RealEstateQueriesTests.cs | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml index 581520738..64e1c3819 100644 --- a/.github/workflows/dotnet-tests.yml +++ b/.github/workflows/dotnet-tests.yml @@ -8,7 +8,7 @@ on: jobs: build-and-test: - runs-on: windows-latest + runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/RealEstateAgency.tests/RealEstateQueriesTests.cs b/RealEstateAgency.tests/RealEstateQueriesTests.cs index d56c00371..dc77e84bc 100644 --- a/RealEstateAgency.tests/RealEstateQueriesTests.cs +++ b/RealEstateAgency.tests/RealEstateQueriesTests.cs @@ -7,8 +7,6 @@ namespace RealEstateAgency.Tests; /// public class RealEstateQueriesTests(RealEstateTestFixture fixture) : IClassFixture { - private readonly RealEstateTestFixture _fixture = fixture; - /// /// The test for the request: "Withdraw all sellers who submitted applications for a specified period" /// @@ -25,7 +23,7 @@ public void GetSellersInPeriodReturnsCorrectSellers() "Семенова Ольга Игоревна" ]; - var actualSellers = _fixture.Requests + var actualSellers = fixture.Requests .Where(r => r.Type == RequestType.Sale && r.Date >= startDate && r.Date <= endDate) @@ -60,7 +58,7 @@ public void Top5ClientsByRequestCountReturnsSeparateTop5() "Орлова Екатерина Дмитриевна" ]; - var topPurchaseClients = _fixture.Requests + var topPurchaseClients = fixture.Requests .Where(r => r.Type == RequestType.Purchase) .GroupBy(r => r.Counterparty) .Select(g => new { Counterparty = g.Key, Count = g.Count() }) @@ -70,7 +68,7 @@ public void Top5ClientsByRequestCountReturnsSeparateTop5() .Select(x => x.Counterparty.FullName) .ToList(); - var topSaleClients = _fixture.Requests + var topSaleClients = fixture.Requests .Where(r => r.Type == RequestType.Sale) .GroupBy(r => r.Counterparty) .Select(g => new { Counterparty = g.Key, Count = g.Count() }) @@ -100,7 +98,7 @@ public void RequestCountByPropertyTypeReturnsCorrectStatistics() [PropertyType.Warehouse] = 2 }; - var actualStatistics = _fixture.Requests + var actualStatistics = fixture.Requests .GroupBy(r => r.Property.Type) .Select(g => new { PropertyType = g.Key, RequestCount = g.Count() }) .OrderBy(x => x.PropertyType) @@ -124,8 +122,8 @@ public void ClientsWithMinAmountAreFoundCorrectly() var expectedMinAmount = 1500000.00m; List expectedClients = ["Зайцева Наталья Петровна"]; - var minAmount = _fixture.Requests.Min(r => r.Amount); - var actualClients = _fixture.Requests + var minAmount = fixture.Requests.Min(r => r.Amount); + var actualClients = fixture.Requests .Where(r => r.Amount == minAmount) .Select(r => r.Counterparty.FullName) .Distinct() @@ -148,7 +146,7 @@ public void ClientsSeekingPropertyTypeAreReturnedOrdered() "Сидоров Алексей Петрович" ]; - var actualClients = _fixture.Requests + var actualClients = fixture.Requests .Where(r => r.Type == RequestType.Purchase && r.Property.Type == targetType) .Select(r => r.Counterparty.FullName) From d16c29ac9d7315e0ab11bf8a398747a087d7a21f Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 1 Oct 2025 13:19:58 +0400 Subject: [PATCH 09/31] fix --- .github/workflows/dotnet-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml index 64e1c3819..6b469d28f 100644 --- a/.github/workflows/dotnet-tests.yml +++ b/.github/workflows/dotnet-tests.yml @@ -26,4 +26,4 @@ jobs: run: dotnet build RealEstateAgency.sln --no-restore - name: Run tests - run: dotnet test RealEstateAgency.Tests/RealEstateAgency.Tests.csproj --no-build \ No newline at end of file + run: dotnet test RealEstateAgency.tests/RealEstateAgency.Tests.csproj --no-build \ No newline at end of file From 35beeff711b44dc22a0772406160246411f086e2 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 1 Oct 2025 13:25:03 +0400 Subject: [PATCH 10/31] Setting up an action --- .github/workflows/dotnet-tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml index 6b469d28f..14b37f52d 100644 --- a/.github/workflows/dotnet-tests.yml +++ b/.github/workflows/dotnet-tests.yml @@ -20,10 +20,10 @@ jobs: dotnet-version: '8.0.x' - name: Restore dependencies - run: dotnet restore RealEstateAgency.sln + run: dotnet restore - name: Build solution - run: dotnet build RealEstateAgency.sln --no-restore + run: dotnet build --no-restore --configuration Release - name: Run tests - run: dotnet test RealEstateAgency.tests/RealEstateAgency.Tests.csproj --no-build \ No newline at end of file + run: dotnet test --no-build --verbosity normal --configuration Release \ No newline at end of file From 878d4c68dcac9837bf4a8188ac84610570e03357 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 1 Oct 2025 13:41:11 +0400 Subject: [PATCH 11/31] new yml --- .github/workflows/dotnet-tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml index 14b37f52d..65324cbb1 100644 --- a/.github/workflows/dotnet-tests.yml +++ b/.github/workflows/dotnet-tests.yml @@ -20,10 +20,10 @@ jobs: dotnet-version: '8.0.x' - name: Restore dependencies - run: dotnet restore + run: dotnet restore ./RealEstateAgency.sln - - name: Build solution - run: dotnet build --no-restore --configuration Release + - name: Build + run: dotnet build ./RealEstateAgency.sln --no-restore - name: Run tests - run: dotnet test --no-build --verbosity normal --configuration Release \ No newline at end of file + run: dotnet test ./RealEstateAgency.tests/RealEstateAgency.tests.csproj --no-build --verbosity normal \ No newline at end of file From a906e7fde399e75d5b0bbca812ce0deeaa535416 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 1 Oct 2025 15:33:03 +0400 Subject: [PATCH 12/31] error search --- .github/workflows/dotnet-tests.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml index 65324cbb1..f969525d7 100644 --- a/.github/workflows/dotnet-tests.yml +++ b/.github/workflows/dotnet-tests.yml @@ -14,6 +14,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Debug - Check solution file content + run: | + echo "=== Solution file content ===" + cat RealEstateAgency.sln + echo "=== Tests directory files ===" + ls -la RealEstateAgency.tests/ + - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -26,4 +33,4 @@ jobs: run: dotnet build ./RealEstateAgency.sln --no-restore - name: Run tests - run: dotnet test ./RealEstateAgency.tests/RealEstateAgency.tests.csproj --no-build --verbosity normal \ No newline at end of file + run: dotnet test --no-build --verbosity normal \ No newline at end of file From e183893f0db7a2c2c8796684b43121ec1ccf569f Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 1 Oct 2025 15:42:46 +0400 Subject: [PATCH 13/31] fix --- .github/workflows/dotnet-tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml index f969525d7..ff70dd275 100644 --- a/.github/workflows/dotnet-tests.yml +++ b/.github/workflows/dotnet-tests.yml @@ -14,12 +14,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Debug - Check solution file content + - name: Debug - Exact solution content run: | - echo "=== Solution file content ===" - cat RealEstateAgency.sln - echo "=== Tests directory files ===" - ls -la RealEstateAgency.tests/ + echo "=== Exact solution file content ===" + cat -A RealEstateAgency.sln + echo "=== Tests directory exact listing ===" + ls -la RealEstateAgency.tests/ | cat -A - name: Setup .NET uses: actions/setup-dotnet@v4 From 7482a48d19d35edfce578bad911546dd8f4bae69 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 1 Oct 2025 15:54:07 +0400 Subject: [PATCH 14/31] new yml --- .github/workflows/dotnet-tests.yml | 33 +++++++++++------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/.github/workflows/dotnet-tests.yml b/.github/workflows/dotnet-tests.yml index ff70dd275..1aeb4f5e5 100644 --- a/.github/workflows/dotnet-tests.yml +++ b/.github/workflows/dotnet-tests.yml @@ -1,4 +1,4 @@ -name: .NET Tests +name: .NET Tests Simple on: push: @@ -7,30 +7,21 @@ on: branches: [ "main" ] jobs: - build-and-test: + test: runs-on: ubuntu-latest steps: - - name: Checkout repository + - name: Checkout code uses: actions/checkout@v4 - - - name: Debug - Exact solution content - run: | - echo "=== Exact solution file content ===" - cat -A RealEstateAgency.sln - echo "=== Tests directory exact listing ===" - ls -la RealEstateAgency.tests/ | cat -A - + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' - - - name: Restore dependencies - run: dotnet restore ./RealEstateAgency.sln - - - name: Build - run: dotnet build ./RealEstateAgency.sln --no-restore - - - name: Run tests - run: dotnet test --no-build --verbosity normal \ No newline at end of file + 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 From 3282a22b1c34e8b8b740744412ad4ad136ff4199 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Sun, 7 Dec 2025 20:55:50 +0400 Subject: [PATCH 15/31] start for next lab --- RealEstateAgency.WebApi/Program.cs | 23 ++++++++++++++ .../Properties/launchSettings.json | 31 +++++++++++++++++++ .../RealEstateAgency.WebApi.csproj | 17 ++++++++++ .../RealEstateAgency.WebApi.http | 6 ++++ .../appsettings.Development.json | 8 +++++ RealEstateAgency.WebApi/appsettings.json | 9 ++++++ RealEstateAgency.sln | 6 ++++ 7 files changed, 100 insertions(+) create mode 100644 RealEstateAgency.WebApi/Program.cs create mode 100644 RealEstateAgency.WebApi/Properties/launchSettings.json create mode 100644 RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj create mode 100644 RealEstateAgency.WebApi/RealEstateAgency.WebApi.http create mode 100644 RealEstateAgency.WebApi/appsettings.Development.json create mode 100644 RealEstateAgency.WebApi/appsettings.json diff --git a/RealEstateAgency.WebApi/Program.cs b/RealEstateAgency.WebApi/Program.cs new file mode 100644 index 000000000..df2434ce3 --- /dev/null +++ b/RealEstateAgency.WebApi/Program.cs @@ -0,0 +1,23 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/RealEstateAgency.WebApi/Properties/launchSettings.json b/RealEstateAgency.WebApi/Properties/launchSettings.json new file mode 100644 index 000000000..1c417182a --- /dev/null +++ b/RealEstateAgency.WebApi/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$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": "swagger", + "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..0c56541ff --- /dev/null +++ b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj @@ -0,0 +1,17 @@ + + + + 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/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..10f68b8c8 --- /dev/null +++ b/RealEstateAgency.WebApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/RealEstateAgency.sln b/RealEstateAgency.sln index 81ea172aa..92d66e5c6 100644 --- a/RealEstateAgency.sln +++ b/RealEstateAgency.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.Domain", " 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {7765762D-A8C1-45D9-B0B4-78F8B9113164}.Debug|Any CPU.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 + {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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5622F52-8268-4A23-BAA9-14E126720CCE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 49f799c618baf9d1d3503e49ed6fc7886b430055 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Sun, 7 Dec 2025 21:42:28 +0400 Subject: [PATCH 16/31] DTO development --- RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs | 67 +++++++ .../DTOs/CounterpartyDto.cs | 69 +++++++ .../DTOs/RealEstatePropertyDto.cs | 176 ++++++++++++++++++ RealEstateAgency.WebApi/DTOs/RequestDto.cs | 111 +++++++++++ .../RealEstateAgency.WebApi.csproj | 5 + 5 files changed, 428 insertions(+) create mode 100644 RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs create mode 100644 RealEstateAgency.WebApi/DTOs/CounterpartyDto.cs create mode 100644 RealEstateAgency.WebApi/DTOs/RealEstatePropertyDto.cs create mode 100644 RealEstateAgency.WebApi/DTOs/RequestDto.cs diff --git a/RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs b/RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs new file mode 100644 index 000000000..9cf69907b --- /dev/null +++ b/RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs @@ -0,0 +1,67 @@ +using RealEstateAgency.Domain.Enums; + +namespace RealEstateAgency.WebApi.DTOs; + +/// +/// DTO для статистики по типу недвижимости +/// +public class PropertyTypeStatisticsDto +{ + /// + /// Тип недвижимости + /// + public PropertyType PropertyType { get; set; } + + /// + /// Количество заявок + /// + public int RequestCount { get; set; } +} + +/// +/// DTO для топ клиентов по количеству заявок +/// +public class TopClientDto +{ + /// + /// ФИО клиента + /// + public required string FullName { get; set; } + + /// + /// Количество заявок + /// + public int RequestCount { get; set; } +} + +/// +/// DTO для клиента с минимальной суммой заявки +/// +public class ClientWithMinAmountDto +{ + /// + /// ФИО клиента + /// + public required string FullName { get; set; } + + /// + /// Минимальная сумма + /// + public decimal MinAmount { get; set; } +} + +/// +/// DTO результата топ-5 клиентов (покупка и продажа) +/// +public class Top5ClientsResultDto +{ + /// + /// Топ-5 покупателей + /// + public List TopPurchaseClients { get; set; } = []; + + /// + /// Топ-5 продавцов + /// + public List TopSaleClients { get; set; } = []; +} diff --git a/RealEstateAgency.WebApi/DTOs/CounterpartyDto.cs b/RealEstateAgency.WebApi/DTOs/CounterpartyDto.cs new file mode 100644 index 000000000..f485c21cd --- /dev/null +++ b/RealEstateAgency.WebApi/DTOs/CounterpartyDto.cs @@ -0,0 +1,69 @@ +namespace RealEstateAgency.WebApi.DTOs; + +/// +/// DTO для отображения контрагента +/// +public class CounterpartyDto +{ + /// + /// Уникальный идентификатор + /// + public int Id { get; set; } + + /// + /// ФИО контрагента + /// + public required string FullName { get; set; } + + /// + /// Номер паспорта + /// + public required string PassportNumber { get; set; } + + /// + /// Контактный телефон + /// + public required string PhoneNumber { get; set; } +} + +/// +/// DTO для создания контрагента +/// +public class CreateCounterpartyDto +{ + /// + /// ФИО контрагента + /// + public required string FullName { get; set; } + + /// + /// Номер паспорта + /// + public required string PassportNumber { get; set; } + + /// + /// Контактный телефон + /// + public required string PhoneNumber { get; set; } +} + +/// +/// DTO для обновления контрагента +/// +public class UpdateCounterpartyDto +{ + /// + /// ФИО контрагента + /// + public required string FullName { get; set; } + + /// + /// Номер паспорта + /// + public required string PassportNumber { get; set; } + + /// + /// Контактный телефон + /// + public required string PhoneNumber { get; set; } +} diff --git a/RealEstateAgency.WebApi/DTOs/RealEstatePropertyDto.cs b/RealEstateAgency.WebApi/DTOs/RealEstatePropertyDto.cs new file mode 100644 index 000000000..7535f48ff --- /dev/null +++ b/RealEstateAgency.WebApi/DTOs/RealEstatePropertyDto.cs @@ -0,0 +1,176 @@ +using RealEstateAgency.Domain.Enums; + +namespace RealEstateAgency.WebApi.DTOs; + +/// +/// DTO для отображения объекта недвижимости +/// +public class RealEstatePropertyDto +{ + /// + /// Уникальный идентификатор + /// + public int 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; } +} + +/// +/// DTO для создания объекта недвижимости +/// +public class CreateRealEstatePropertyDto +{ + /// + /// Тип недвижимости + /// + 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; } +} + +/// +/// DTO для обновления объекта недвижимости +/// +public class UpdateRealEstatePropertyDto +{ + /// + /// Тип недвижимости + /// + 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.WebApi/DTOs/RequestDto.cs b/RealEstateAgency.WebApi/DTOs/RequestDto.cs new file mode 100644 index 000000000..93c9e4845 --- /dev/null +++ b/RealEstateAgency.WebApi/DTOs/RequestDto.cs @@ -0,0 +1,111 @@ +using RealEstateAgency.Domain.Enums; + +namespace RealEstateAgency.WebApi.DTOs; + +/// +/// DTO для отображения заявки +/// +public class RequestDto +{ + /// + /// Уникальный идентификатор заявки + /// + public int Id { get; set; } + + /// + /// Идентификатор контрагента + /// + public int CounterpartyId { get; set; } + + /// + /// Данные контрагента + /// + public CounterpartyDto? Counterparty { get; set; } + + /// + /// Идентификатор объекта недвижимости + /// + public int PropertyId { get; set; } + + /// + /// Данные объекта недвижимости + /// + public RealEstatePropertyDto? Property { get; set; } + + /// + /// Тип заявки (покупка/продажа) + /// + public RequestType Type { get; set; } + + /// + /// Сумма сделки + /// + public decimal Amount { get; set; } + + /// + /// Дата подачи заявки + /// + public DateTime Date { get; set; } +} + +/// +/// DTO для создания заявки +/// +public class CreateRequestDto +{ + /// + /// Идентификатор контрагента + /// + public int CounterpartyId { get; set; } + + /// + /// Идентификатор объекта недвижимости + /// + public int PropertyId { get; set; } + + /// + /// Тип заявки (покупка/продажа) + /// + public RequestType Type { get; set; } + + /// + /// Сумма сделки + /// + public decimal Amount { get; set; } + + /// + /// Дата подачи заявки + /// + public DateTime Date { get; set; } +} + +/// +/// DTO для обновления заявки +/// +public class UpdateRequestDto +{ + /// + /// Идентификатор контрагента + /// + public int CounterpartyId { get; set; } + + /// + /// Идентификатор объекта недвижимости + /// + public int PropertyId { get; set; } + + /// + /// Тип заявки (покупка/продажа) + /// + public RequestType Type { get; set; } + + /// + /// Сумма сделки + /// + public decimal Amount { get; set; } + + /// + /// Дата подачи заявки + /// + public DateTime Date { get; set; } +} diff --git a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj index 0c56541ff..1b0dbc749 100644 --- a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj +++ b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj @@ -7,6 +7,7 @@ + @@ -14,4 +15,8 @@ + + + + From b5659bcd31924d9a9543aa151794acb754c2aa5d Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Sun, 7 Dec 2025 23:25:59 +0400 Subject: [PATCH 17/31] Implementing repositories --- .../Controllers/AnalyticsController.cs | 1 + .../RealEstateAgency.WebApi.csproj | 6 +- .../Repositories/IRepositories.cs | 39 +++++++++ .../InMemoryCounterpartyRepository.cs | 71 ++++++++++++++++ .../InMemoryRealEstatePropertyRepository.cs | 79 +++++++++++++++++ .../Repositories/InMemoryRequestRepository.cs | 85 +++++++++++++++++++ .../Services/AnalyticsService.cs | 1 + 7 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 RealEstateAgency.WebApi/Controllers/AnalyticsController.cs create mode 100644 RealEstateAgency.WebApi/Repositories/IRepositories.cs create mode 100644 RealEstateAgency.WebApi/Repositories/InMemoryCounterpartyRepository.cs create mode 100644 RealEstateAgency.WebApi/Repositories/InMemoryRealEstatePropertyRepository.cs create mode 100644 RealEstateAgency.WebApi/Repositories/InMemoryRequestRepository.cs create mode 100644 RealEstateAgency.WebApi/Services/AnalyticsService.cs diff --git a/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs new file mode 100644 index 000000000..5f282702b --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj index 1b0dbc749..24e9d503d 100644 --- a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj +++ b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -11,10 +11,6 @@ - - - - diff --git a/RealEstateAgency.WebApi/Repositories/IRepositories.cs b/RealEstateAgency.WebApi/Repositories/IRepositories.cs new file mode 100644 index 000000000..0236e4813 --- /dev/null +++ b/RealEstateAgency.WebApi/Repositories/IRepositories.cs @@ -0,0 +1,39 @@ +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.WebApi.Repositories; + +/// +/// Интерфейс репозитория контрагентов +/// +public interface ICounterpartyRepository +{ + IEnumerable GetAll(); + Counterparty? GetById(int id); + Counterparty Add(Counterparty counterparty); + Counterparty? Update(int id, Counterparty counterparty); + bool Delete(int id); +} + +/// +/// Интерфейс репозитория объектов недвижимости +/// +public interface IRealEstatePropertyRepository +{ + IEnumerable GetAll(); + RealEstateProperty? GetById(int id); + RealEstateProperty Add(RealEstateProperty property); + RealEstateProperty? Update(int id, RealEstateProperty property); + bool Delete(int id); +} + +/// +/// Интерфейс репозитория заявок +/// +public interface IRequestRepository +{ + IEnumerable GetAll(); + Request? GetById(int id); + Request Add(Request request); + Request? Update(int id, Request request); + bool Delete(int id); +} diff --git a/RealEstateAgency.WebApi/Repositories/InMemoryCounterpartyRepository.cs b/RealEstateAgency.WebApi/Repositories/InMemoryCounterpartyRepository.cs new file mode 100644 index 000000000..675ed30f3 --- /dev/null +++ b/RealEstateAgency.WebApi/Repositories/InMemoryCounterpartyRepository.cs @@ -0,0 +1,71 @@ +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.WebApi.Repositories; + +/// +/// In-memory реализация репозитория контрагентов +/// +public class InMemoryCounterpartyRepository : ICounterpartyRepository +{ + private readonly List _counterparties = []; + private int _nextId = 1; + + public InMemoryCounterpartyRepository() + { + // Инициализация тестовыми данными + SeedData(); + } + + private void SeedData() + { + var seedData = new[] + { + new Counterparty { Id = 1, FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" }, + new Counterparty { Id = 2, FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" }, + new Counterparty { Id = 3, FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" }, + new Counterparty { Id = 4, FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" }, + new Counterparty { Id = 5, FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" }, + new Counterparty { Id = 6, FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" }, + new Counterparty { Id = 7, FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" }, + new Counterparty { Id = 8, FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" }, + new Counterparty { Id = 9, FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" }, + new Counterparty { Id = 10, FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" }, + new Counterparty { Id = 11, FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" }, + new Counterparty { Id = 12, FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" } + }; + + _counterparties.AddRange(seedData); + _nextId = seedData.Length + 1; + } + + public IEnumerable GetAll() => _counterparties; + + public Counterparty? GetById(int id) => _counterparties.FirstOrDefault(c => c.Id == id); + + public Counterparty Add(Counterparty counterparty) + { + counterparty.Id = _nextId++; + _counterparties.Add(counterparty); + return counterparty; + } + + public Counterparty? Update(int id, Counterparty counterparty) + { + var existing = GetById(id); + if (existing == null) return null; + + existing.FullName = counterparty.FullName; + existing.PassportNumber = counterparty.PassportNumber; + existing.PhoneNumber = counterparty.PhoneNumber; + return existing; + } + + public bool Delete(int id) + { + var counterparty = GetById(id); + if (counterparty == null) return false; + + _counterparties.Remove(counterparty); + return true; + } +} diff --git a/RealEstateAgency.WebApi/Repositories/InMemoryRealEstatePropertyRepository.cs b/RealEstateAgency.WebApi/Repositories/InMemoryRealEstatePropertyRepository.cs new file mode 100644 index 000000000..b57d0a84b --- /dev/null +++ b/RealEstateAgency.WebApi/Repositories/InMemoryRealEstatePropertyRepository.cs @@ -0,0 +1,79 @@ +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.WebApi.Repositories; + +/// +/// In-memory реализация репозитория объектов недвижимости +/// +public class InMemoryRealEstatePropertyRepository : IRealEstatePropertyRepository +{ + private readonly List _properties = []; + private int _nextId = 1; + + public InMemoryRealEstatePropertyRepository() + { + SeedData(); + } + + private void SeedData() + { + var seedData = new[] + { + new RealEstateProperty { Id = 1, 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 = 2, 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 = 3, 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 = 4, 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 = 5, 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 = 6, 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 = 7, 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 = 8, 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 = 9, 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 = 10, 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 = 11, 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 = 12, 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 = 13, 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); + _nextId = seedData.Length + 1; + } + + public IEnumerable GetAll() => _properties; + + public RealEstateProperty? GetById(int id) => _properties.FirstOrDefault(p => p.Id == id); + + public RealEstateProperty Add(RealEstateProperty property) + { + property.Id = _nextId++; + _properties.Add(property); + return property; + } + + public RealEstateProperty? Update(int id, RealEstateProperty property) + { + var existing = GetById(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; + return existing; + } + + public bool Delete(int id) + { + var property = GetById(id); + if (property == null) return false; + + _properties.Remove(property); + return true; + } +} diff --git a/RealEstateAgency.WebApi/Repositories/InMemoryRequestRepository.cs b/RealEstateAgency.WebApi/Repositories/InMemoryRequestRepository.cs new file mode 100644 index 000000000..e224a4710 --- /dev/null +++ b/RealEstateAgency.WebApi/Repositories/InMemoryRequestRepository.cs @@ -0,0 +1,85 @@ +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.WebApi.Repositories; + +/// +/// In-memory реализация репозитория заявок +/// +public class InMemoryRequestRepository : IRequestRepository +{ + private readonly List _requests = []; + private readonly ICounterpartyRepository _counterpartyRepository; + private readonly IRealEstatePropertyRepository _propertyRepository; + private int _nextId = 1; + + public InMemoryRequestRepository( + ICounterpartyRepository counterpartyRepository, + IRealEstatePropertyRepository propertyRepository) + { + _counterpartyRepository = counterpartyRepository; + _propertyRepository = propertyRepository; + SeedData(); + } + + private void SeedData() + { + var counterparties = _counterpartyRepository.GetAll().ToList(); + var properties = _propertyRepository.GetAll().ToList(); + + var seedData = new[] + { + new Request { Id = 1, Counterparty = counterparties[0], Property = properties[0], Type = RequestType.Sale, Amount = 25000000.00m, Date = new DateTime(2024, 1, 15) }, + new Request { Id = 2, Counterparty = counterparties[1], Property = properties[1], Type = RequestType.Sale, Amount = 18000000.00m, Date = new DateTime(2024, 2, 20) }, + new Request { Id = 3, Counterparty = counterparties[3], Property = properties[3], Type = RequestType.Sale, Amount = 42000000.00m, Date = new DateTime(2024, 3, 10) }, + new Request { Id = 4, Counterparty = counterparties[6], Property = properties[5], Type = RequestType.Sale, Amount = 35000000.00m, Date = new DateTime(2024, 4, 5) }, + new Request { Id = 5, Counterparty = counterparties[8], Property = properties[7], Type = RequestType.Sale, Amount = 32000000.00m, Date = new DateTime(2024, 5, 12) }, + new Request { Id = 6, Counterparty = counterparties[10], Property = properties[9], Type = RequestType.Sale, Amount = 1500000.00m, Date = new DateTime(2024, 6, 8) }, + new Request { Id = 7, Counterparty = counterparties[11], Property = properties[11], Type = RequestType.Sale, Amount = 85000000.00m, Date = new DateTime(2024, 7, 25) }, + new Request { Id = 8, Counterparty = counterparties[2], Property = properties[2], Type = RequestType.Purchase, Amount = 22000000.00m, Date = new DateTime(2024, 1, 20) }, + new Request { Id = 9, Counterparty = counterparties[4], Property = properties[4], Type = RequestType.Purchase, Amount = 15000000.00m, Date = new DateTime(2024, 2, 25) }, + new Request { Id = 10, Counterparty = counterparties[5], Property = properties[6], Type = RequestType.Purchase, Amount = 28000000.00m, Date = new DateTime(2024, 3, 15) }, + new Request { Id = 11, Counterparty = counterparties[7], Property = properties[8], Type = RequestType.Purchase, Amount = 25000000.00m, Date = new DateTime(2024, 4, 18) }, + new Request { Id = 12, Counterparty = counterparties[9], Property = properties[10], Type = RequestType.Purchase, Amount = 1800000.00m, Date = new DateTime(2024, 5, 22) }, + new Request { Id = 13, Counterparty = counterparties[2], Property = properties[12], Type = RequestType.Purchase, Amount = 60000000.00m, Date = new DateTime(2024, 6, 30) }, + new Request { Id = 14, Counterparty = counterparties[1], Property = properties[0], Type = RequestType.Purchase, Amount = 24000000.00m, Date = new DateTime(2024, 8, 10) }, + new Request { Id = 15, Counterparty = counterparties[3], Property = properties[1], Type = RequestType.Sale, Amount = 19000000.00m, Date = new DateTime(2024, 9, 5) } + }; + + _requests.AddRange(seedData); + _nextId = seedData.Length + 1; + } + + public IEnumerable GetAll() => _requests; + + public Request? GetById(int id) => _requests.FirstOrDefault(r => r.Id == id); + + public Request Add(Request request) + { + request.Id = _nextId++; + _requests.Add(request); + return request; + } + + public Request? Update(int id, Request request) + { + var existing = GetById(id); + if (existing == null) return null; + + existing.Counterparty = request.Counterparty; + existing.Property = request.Property; + existing.Type = request.Type; + existing.Amount = request.Amount; + existing.Date = request.Date; + return existing; + } + + public bool Delete(int id) + { + var request = GetById(id); + if (request == null) return false; + + _requests.Remove(request); + return true; + } +} diff --git a/RealEstateAgency.WebApi/Services/AnalyticsService.cs b/RealEstateAgency.WebApi/Services/AnalyticsService.cs new file mode 100644 index 000000000..5f282702b --- /dev/null +++ b/RealEstateAgency.WebApi/Services/AnalyticsService.cs @@ -0,0 +1 @@ + \ No newline at end of file From 4d67fd508e11d33cd5728c98b5917ce81eab7db6 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Mon, 8 Dec 2025 19:25:35 +0400 Subject: [PATCH 18/31] Add AutoMapper configuration --- .../Mapping/MappingProfile.cs | 33 ++++++++++++ RealEstateAgency.WebApi/Program.cs | 50 ++++++++++++++++--- .../Repositories/IRepositories.cs | 30 +++++------ 3 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 RealEstateAgency.WebApi/Mapping/MappingProfile.cs diff --git a/RealEstateAgency.WebApi/Mapping/MappingProfile.cs b/RealEstateAgency.WebApi/Mapping/MappingProfile.cs new file mode 100644 index 000000000..c203531b8 --- /dev/null +++ b/RealEstateAgency.WebApi/Mapping/MappingProfile.cs @@ -0,0 +1,33 @@ +using AutoMapper; +using RealEstateAgency.Domain.Models; +using RealEstateAgency.WebApi.DTOs; + +namespace RealEstateAgency.WebApi.Mapping; + +/// +/// Профиль AutoMapper для маппинга сущностей и DTO +/// +public class MappingProfile : Profile +{ + public MappingProfile() + { + // Counterparty mappings + CreateMap(); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + + // RealEstateProperty mappings + CreateMap(); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + CreateMap() + .ForMember(dest => dest.Id, opt => opt.Ignore()); + + // Request mappings + 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.WebApi/Program.cs b/RealEstateAgency.WebApi/Program.cs index df2434ce3..882f9f6ec 100644 --- a/RealEstateAgency.WebApi/Program.cs +++ b/RealEstateAgency.WebApi/Program.cs @@ -1,23 +1,57 @@ +using System.Text.Json.Serialization; +using RealEstateAgency.WebApi.Repositories; + + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. +// (In-Memory 2) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); -builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +// Add services to the container +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + +// Swagger/OpenAPI builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +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); + } +}); var app = builder.Build(); -// Configure the HTTP request pipeline. +// Configure the HTTP request pipeline if (app.Environment.IsDevelopment()) { app.UseSwagger(); - app.UseSwaggerUI(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Real Estate Agency API v1"); + options.RoutePrefix = string.Empty; // Swagger UI URL + }); } +app.UseHttpsRedirection(); app.UseAuthorization(); -app.MapControllers(); - app.Run(); + +// ( ) +public partial class Program { } \ No newline at end of file diff --git a/RealEstateAgency.WebApi/Repositories/IRepositories.cs b/RealEstateAgency.WebApi/Repositories/IRepositories.cs index 0236e4813..1253e8d3e 100644 --- a/RealEstateAgency.WebApi/Repositories/IRepositories.cs +++ b/RealEstateAgency.WebApi/Repositories/IRepositories.cs @@ -7,11 +7,11 @@ namespace RealEstateAgency.WebApi.Repositories; /// public interface ICounterpartyRepository { - IEnumerable GetAll(); - Counterparty? GetById(int id); - Counterparty Add(Counterparty counterparty); - Counterparty? Update(int id, Counterparty counterparty); - bool Delete(int id); + public IEnumerable GetAll(); + public Counterparty? GetById(int id); + public Counterparty Add(Counterparty counterparty); + public Counterparty? Update(int id, Counterparty counterparty); + public bool Delete(int id); } /// @@ -19,11 +19,11 @@ public interface ICounterpartyRepository /// public interface IRealEstatePropertyRepository { - IEnumerable GetAll(); - RealEstateProperty? GetById(int id); - RealEstateProperty Add(RealEstateProperty property); - RealEstateProperty? Update(int id, RealEstateProperty property); - bool Delete(int id); + public IEnumerable GetAll(); + public RealEstateProperty? GetById(int id); + public RealEstateProperty Add(RealEstateProperty property); + public RealEstateProperty? Update(int id, RealEstateProperty property); + public bool Delete(int id); } /// @@ -31,9 +31,9 @@ public interface IRealEstatePropertyRepository /// public interface IRequestRepository { - IEnumerable GetAll(); - Request? GetById(int id); - Request Add(Request request); - Request? Update(int id, Request request); - bool Delete(int id); + public IEnumerable GetAll(); + public Request? GetById(int id); + public Request Add(Request request); + public Request? Update(int id, Request request); + public bool Delete(int id); } From 9116445da7127b7ab071ee1067ea2abc4ab47945 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Mon, 8 Dec 2025 19:41:51 +0400 Subject: [PATCH 19/31] Creating controllers and a separate service for analytical queries --- .../Controllers/AnalyticsController.cs | 88 +++++++++- .../Controllers/CounterpartiesController.cs | 108 ++++++++++++ .../Controllers/PropertiesController.cs | 108 ++++++++++++ .../Controllers/RequestsController.cs | 151 +++++++++++++++++ .../Services/AnalyticsService.cs | 157 +++++++++++++++++- 5 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs create mode 100644 RealEstateAgency.WebApi/Controllers/PropertiesController.cs create mode 100644 RealEstateAgency.WebApi/Controllers/RequestsController.cs diff --git a/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs index 5f282702b..27872e29d 100644 --- a/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs +++ b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs @@ -1 +1,87 @@ - \ No newline at end of file +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.WebApi.DTOs; +using RealEstateAgency.WebApi.Services; + +namespace RealEstateAgency.WebApi.Controllers; + +/// +/// Контроллер для аналитических запросов +/// +[ApiController] +[Route("api/[controller]")] +public class AnalyticsController : ControllerBase +{ + private readonly IAnalyticsService _analyticsService; + + public AnalyticsController(IAnalyticsService analyticsService) + { + _analyticsService = analyticsService; + } + + /// + /// Получить продавцов за указанный период + /// + /// Начало периода + /// Конец периода + /// Список ФИО продавцов + [HttpGet("sellers")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public ActionResult> GetSellersInPeriod( + [FromQuery] DateTime startDate, + [FromQuery] DateTime endDate) + { + var sellers = _analyticsService.GetSellersInPeriod(startDate, endDate); + return Ok(sellers); + } + + /// + /// Получить топ-5 клиентов по количеству заявок (покупка и продажа отдельно) + /// + /// Топ-5 покупателей и топ-5 продавцов + [HttpGet("top-clients")] + [ProducesResponseType(typeof(Top5ClientsResultDto), StatusCodes.Status200OK)] + public ActionResult GetTop5Clients() + { + var result = _analyticsService.GetTop5ClientsByRequestCount(); + return Ok(result); + } + + /// + /// Получить статистику заявок по типам недвижимости + /// + /// Количество заявок по каждому типу недвижимости + [HttpGet("property-type-statistics")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public ActionResult> GetPropertyTypeStatistics() + { + var statistics = _analyticsService.GetRequestCountByPropertyType(); + return Ok(statistics); + } + + /// + /// Получить клиентов с заявками минимальной стоимости + /// + /// Информация о клиентах с минимальной суммой + [HttpGet("min-amount-clients")] + [ProducesResponseType(typeof(ClientWithMinAmountDto), StatusCodes.Status200OK)] + public ActionResult GetClientsWithMinAmount() + { + var result = _analyticsService.GetClientsWithMinAmount(); + return Ok(result); + } + + /// + /// Получить клиентов, ищущих определённый тип недвижимости + /// + /// Тип недвижимости + /// Список ФИО клиентов + [HttpGet("clients-by-property-type")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public ActionResult> GetClientsByPropertyType( + [FromQuery] PropertyType propertyType) + { + var clients = _analyticsService.GetClientsSeekingPropertyType(propertyType); + return Ok(clients); + } +} diff --git a/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs b/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs new file mode 100644 index 000000000..2a337c166 --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs @@ -0,0 +1,108 @@ +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Domain.Models; +using RealEstateAgency.WebApi.DTOs; +using RealEstateAgency.WebApi.Repositories; + +namespace RealEstateAgency.WebApi.Controllers; + +/// +/// Контроллер для работы с контрагентами +/// +[ApiController] +[Route("api/[controller]")] +public class CounterpartiesController : ControllerBase +{ + private readonly ICounterpartyRepository _repository; + private readonly IMapper _mapper; + + public CounterpartiesController(ICounterpartyRepository repository, IMapper mapper) + { + _repository = repository; + _mapper = mapper; + } + + /// + /// Получить всех контрагентов + /// + /// Список контрагентов + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public ActionResult> GetAll() + { + var counterparties = _repository.GetAll(); + return Ok(_mapper.Map>(counterparties)); + } + + /// + /// Получить контрагента по идентификатору + /// + /// Идентификатор контрагента + /// Контрагент + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetById(int id) + { + var counterparty = _repository.GetById(id); + if (counterparty == null) + return NotFound($"Контрагент с ID {id} не найден"); + + return Ok(_mapper.Map(counterparty)); + } + + /// + /// Создать нового контрагента + /// + /// Данные контрагента + /// Созданный контрагент + [HttpPost] + [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult Create([FromBody] CreateCounterpartyDto dto) + { + var counterparty = _mapper.Map(dto); + var created = _repository.Add(counterparty); + var resultDto = _mapper.Map(created); + + return CreatedAtAction(nameof(GetById), new { id = resultDto.Id }, resultDto); + } + + /// + /// Обновить контрагента + /// + /// Идентификатор контрагента + /// Новые данные контрагента + /// Результат операции + [HttpPut("{id:int}")] + [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult Update(int id, [FromBody] UpdateCounterpartyDto dto) + { + var counterparty = _mapper.Map(dto); + var updated = _repository.Update(id, counterparty); + + if (updated == null) + return NotFound($"Контрагент с ID {id} не найден"); + + return Ok(_mapper.Map(updated)); + } + + /// + /// Удалить контрагента + /// + /// Идентификатор контрагента + /// Результат операции + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult Delete(int id) + { + var deleted = _repository.Delete(id); + if (!deleted) + return NotFound($"Контрагент с ID {id} не найден"); + + return NoContent(); + } +} diff --git a/RealEstateAgency.WebApi/Controllers/PropertiesController.cs b/RealEstateAgency.WebApi/Controllers/PropertiesController.cs new file mode 100644 index 000000000..322b35cee --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/PropertiesController.cs @@ -0,0 +1,108 @@ +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Domain.Models; +using RealEstateAgency.WebApi.DTOs; +using RealEstateAgency.WebApi.Repositories; + +namespace RealEstateAgency.WebApi.Controllers; + +/// +/// Контроллер для работы с объектами недвижимости +/// +[ApiController] +[Route("api/[controller]")] +public class PropertiesController : ControllerBase +{ + private readonly IRealEstatePropertyRepository _repository; + private readonly IMapper _mapper; + + public PropertiesController(IRealEstatePropertyRepository repository, IMapper mapper) + { + _repository = repository; + _mapper = mapper; + } + + /// + /// Получить все объекты недвижимости + /// + /// Список объектов недвижимости + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public ActionResult> GetAll() + { + var properties = _repository.GetAll(); + return Ok(_mapper.Map>(properties)); + } + + /// + /// Получить объект недвижимости по идентификатору + /// + /// Идентификатор объекта + /// Объект недвижимости + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetById(int id) + { + var property = _repository.GetById(id); + if (property == null) + return NotFound($"Объект недвижимости с ID {id} не найден"); + + return Ok(_mapper.Map(property)); + } + + /// + /// Создать новый объект недвижимости + /// + /// Данные объекта + /// Созданный объект + [HttpPost] + [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult Create([FromBody] CreateRealEstatePropertyDto dto) + { + var property = _mapper.Map(dto); + var created = _repository.Add(property); + var resultDto = _mapper.Map(created); + + return CreatedAtAction(nameof(GetById), new { id = resultDto.Id }, resultDto); + } + + /// + /// Обновить объект недвижимости + /// + /// Идентификатор объекта + /// Новые данные объекта + /// Результат операции + [HttpPut("{id:int}")] + [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult Update(int id, [FromBody] UpdateRealEstatePropertyDto dto) + { + var property = _mapper.Map(dto); + var updated = _repository.Update(id, property); + + if (updated == null) + return NotFound($"Объект недвижимости с ID {id} не найден"); + + return Ok(_mapper.Map(updated)); + } + + /// + /// Удалить объект недвижимости + /// + /// Идентификатор объекта + /// Результат операции + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult Delete(int id) + { + var deleted = _repository.Delete(id); + if (!deleted) + return NotFound($"Объект недвижимости с ID {id} не найден"); + + return NoContent(); + } +} diff --git a/RealEstateAgency.WebApi/Controllers/RequestsController.cs b/RealEstateAgency.WebApi/Controllers/RequestsController.cs new file mode 100644 index 000000000..0b50b8900 --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/RequestsController.cs @@ -0,0 +1,151 @@ +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Domain.Models; +using RealEstateAgency.WebApi.DTOs; +using RealEstateAgency.WebApi.Repositories; + +namespace RealEstateAgency.WebApi.Controllers; + +/// +/// Контроллер для работы с заявками +/// +[ApiController] +[Route("api/[controller]")] +public class RequestsController : ControllerBase +{ + private readonly IRequestRepository _requestRepository; + private readonly ICounterpartyRepository _counterpartyRepository; + private readonly IRealEstatePropertyRepository _propertyRepository; + private readonly IMapper _mapper; + + public RequestsController( + IRequestRepository requestRepository, + ICounterpartyRepository counterpartyRepository, + IRealEstatePropertyRepository propertyRepository, + IMapper mapper) + { + _requestRepository = requestRepository; + _counterpartyRepository = counterpartyRepository; + _propertyRepository = propertyRepository; + _mapper = mapper; + } + + /// + /// Получить все заявки + /// + /// Список заявок + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public ActionResult> GetAll() + { + var requests = _requestRepository.GetAll(); + return Ok(_mapper.Map>(requests)); + } + + /// + /// Получить заявку по идентификатору + /// + /// Идентификатор заявки + /// Заявка + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(RequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetById(int id) + { + var request = _requestRepository.GetById(id); + if (request == null) + return NotFound($"Заявка с ID {id} не найдена"); + + return Ok(_mapper.Map(request)); + } + + /// + /// Создать новую заявку + /// + /// Данные заявки + /// Созданная заявка + [HttpPost] + [ProducesResponseType(typeof(RequestDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult Create([FromBody] CreateRequestDto dto) + { + var counterparty = _counterpartyRepository.GetById(dto.CounterpartyId); + if (counterparty == null) + return NotFound($"Контрагент с ID {dto.CounterpartyId} не найден"); + + var property = _propertyRepository.GetById(dto.PropertyId); + if (property == null) + return NotFound($"Объект недвижимости с ID {dto.PropertyId} не найден"); + + var request = new Request + { + Id = 0, + Counterparty = counterparty, + Property = property, + Type = dto.Type, + Amount = dto.Amount, + Date = dto.Date + }; + + var created = _requestRepository.Add(request); + var resultDto = _mapper.Map(created); + + return CreatedAtAction(nameof(GetById), new { id = resultDto.Id }, resultDto); + } + + /// + /// Обновить заявку + /// + /// Идентификатор заявки + /// Новые данные заявки + /// Результат операции + [HttpPut("{id:int}")] + [ProducesResponseType(typeof(RequestDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult Update(int id, [FromBody] UpdateRequestDto dto) + { + var existingRequest = _requestRepository.GetById(id); + if (existingRequest == null) + return NotFound($"Заявка с ID {id} не найдена"); + + var counterparty = _counterpartyRepository.GetById(dto.CounterpartyId); + if (counterparty == null) + return NotFound($"Контрагент с ID {dto.CounterpartyId} не найден"); + + var property = _propertyRepository.GetById(dto.PropertyId); + if (property == null) + return NotFound($"Объект недвижимости с ID {dto.PropertyId} не найден"); + + var request = new Request + { + Id = id, + Counterparty = counterparty, + Property = property, + Type = dto.Type, + Amount = dto.Amount, + Date = dto.Date + }; + + var updated = _requestRepository.Update(id, request); + return Ok(_mapper.Map(updated)); + } + + /// + /// Удалить заявку + /// + /// Идентификатор заявки + /// Результат операции + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult Delete(int id) + { + var deleted = _requestRepository.Delete(id); + if (!deleted) + return NotFound($"Заявка с ID {id} не найдена"); + + return NoContent(); + } +} diff --git a/RealEstateAgency.WebApi/Services/AnalyticsService.cs b/RealEstateAgency.WebApi/Services/AnalyticsService.cs index 5f282702b..a990c633c 100644 --- a/RealEstateAgency.WebApi/Services/AnalyticsService.cs +++ b/RealEstateAgency.WebApi/Services/AnalyticsService.cs @@ -1 +1,156 @@ - \ No newline at end of file +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.WebApi.DTOs; +using RealEstateAgency.WebApi.Repositories; + +namespace RealEstateAgency.WebApi.Services; + +/// +/// Интерфейс сервиса аналитики +/// +public interface IAnalyticsService +{ + /// + /// Получить продавцов за указанный период + /// + public IEnumerable GetSellersInPeriod(DateTime startDate, DateTime endDate); + + /// + /// Получить топ-5 клиентов по количеству заявок (покупка и продажа отдельно) + /// + public Top5ClientsResultDto GetTop5ClientsByRequestCount(); + + /// + /// Получить статистику заявок по типам недвижимости + /// + public IEnumerable GetRequestCountByPropertyType(); + + /// + /// Получить клиентов с заявками минимальной стоимости + /// + public ClientWithMinAmountDto GetClientsWithMinAmount(); + + /// + /// Получить клиентов, ищущих определённый тип недвижимости + /// + public IEnumerable GetClientsSeekingPropertyType(PropertyType propertyType); +} + +/// +/// Реализация сервиса аналитики +/// +public class AnalyticsService : IAnalyticsService +{ + private readonly IRequestRepository _requestRepository; + + public AnalyticsService(IRequestRepository requestRepository) + { + _requestRepository = requestRepository; + } + + /// + /// Получить продавцов за указанный период + /// + public IEnumerable GetSellersInPeriod(DateTime startDate, DateTime endDate) + { + return _requestRepository.GetAll() + .Where(r => r.Type == RequestType.Sale && + r.Date >= startDate && + r.Date <= endDate) + .Select(r => r.Counterparty.FullName) + .Distinct() + .Order() + .ToList(); + } + + /// + /// Получить топ-5 клиентов по количеству заявок + /// + public Top5ClientsResultDto GetTop5ClientsByRequestCount() + { + var requests = _requestRepository.GetAll().ToList(); + + var topPurchaseClients = requests + .Where(r => r.Type == RequestType.Purchase) + .GroupBy(r => r.Counterparty) + .Select(g => new TopClientDto + { + FullName = g.Key.FullName, + RequestCount = g.Count() + }) + .OrderByDescending(x => x.RequestCount) + .ThenBy(x => x.FullName) + .Take(5) + .ToList(); + + var topSaleClients = requests + .Where(r => r.Type == RequestType.Sale) + .GroupBy(r => r.Counterparty) + .Select(g => new TopClientDto + { + FullName = g.Key.FullName, + RequestCount = g.Count() + }) + .OrderByDescending(x => x.RequestCount) + .ThenBy(x => x.FullName) + .Take(5) + .ToList(); + + return new Top5ClientsResultDto + { + TopPurchaseClients = topPurchaseClients, + TopSaleClients = topSaleClients + }; + } + + /// + /// Получить статистику заявок по типам недвижимости + /// + public IEnumerable GetRequestCountByPropertyType() + { + return _requestRepository.GetAll() + .GroupBy(r => r.Property.Type) + .Select(g => new PropertyTypeStatisticsDto + { + PropertyType = g.Key, + RequestCount = g.Count() + }) + .OrderBy(x => x.PropertyType) + .ToList(); + } + + /// + /// Получить клиентов с минимальной суммой заявки + /// + public ClientWithMinAmountDto GetClientsWithMinAmount() + { + var requests = _requestRepository.GetAll().ToList(); + var minAmount = requests.Min(r => r.Amount); + + var clients = requests + .Where(r => r.Amount == minAmount) + .Select(r => r.Counterparty.FullName) + .Distinct() + .Order() + .ToList(); + + return new ClientWithMinAmountDto + { + FullName = string.Join(", ", clients), + MinAmount = minAmount + }; + } + + /// + /// Получить клиентов, ищущих определённый тип недвижимости + /// + public IEnumerable GetClientsSeekingPropertyType(PropertyType propertyType) + { + return _requestRepository.GetAll() + .Where(r => r.Type == RequestType.Purchase && + r.Property.Type == propertyType) + .Select(r => r.Counterparty.FullName) + .Distinct() + .Order() + .ToList(); + } +} From dfc09304cff6112f5cb30ce25c079bac624da48e Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Mon, 8 Dec 2025 22:56:20 +0400 Subject: [PATCH 20/31] developing tests and verifying the correctness of the results --- .../AnalyticsControllerTests.cs | 166 ++++++++++++++++++ .../CounterpartiesControllerTests.cs | 134 ++++++++++++++ .../PropertiesControllerTests.cs | 149 ++++++++++++++++ .../RealEstateAgency.WebApi.Tests.csproj | 29 +++ .../RealEstateWebApplicationFactory.cs | 48 +++++ .../RequestsControllerTests.cs | 159 +++++++++++++++++ RealEstateAgency.WebApi/Program.cs | 17 +- .../Properties/launchSettings.json | 8 +- RealEstateAgency.sln | 6 + 9 files changed, 710 insertions(+), 6 deletions(-) create mode 100644 RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs create mode 100644 RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs create mode 100644 RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs create mode 100644 RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj create mode 100644 RealEstateAgency.WebApi.Tests/RealEstateWebApplicationFactory.cs create mode 100644 RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs diff --git a/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs b/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs new file mode 100644 index 000000000..12ca06019 --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs @@ -0,0 +1,166 @@ +using System.Net.Http.Json; +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.WebApi.DTOs; +using Xunit; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// +/// API Unit- +/// +public class AnalyticsControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public AnalyticsControllerTests(RealEstateWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + /// + /// : " , " + /// RealEstateQueriesTests.GetSellersInPeriodReturnsCorrectSellers() + /// + [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 ( /)" + /// RealEstateQueriesTests.Top5ClientsByRequestCountReturnsSeparateTop5() + /// + [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); + } + + /// + /// : " " + /// RealEstateQueriesTests.RequestCountByPropertyTypeReturnsCorrectStatistics() + /// + [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); + } + } + + /// + /// : " , " + /// RealEstateQueriesTests.ClientsWithMinAmountAreFoundCorrectly() + /// + [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); + } + + /// + /// : " , " + /// RealEstateQueriesTests.ClientsSeekingPropertyTypeAreReturnedOrdered() + /// + [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..e0ae37052 --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Net.Http.Json; +using RealEstateAgency.WebApi.DTOs; +using Xunit; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Тесты CRUD операций для контрагентов +/// +public class CounterpartiesControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public CounterpartiesControllerTests(RealEstateWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + /// + /// 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/1"); + + response.EnsureSuccessStatusCode(); + var counterparty = await response.Content.ReadFromJsonAsync( + RealEstateWebApplicationFactory.JsonOptions); + + Assert.NotNull(counterparty); + Assert.Equal(1, 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/999"); + + 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.True(created.Id > 0); + 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/1", 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/PropertiesControllerTests.cs b/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs new file mode 100644 index 000000000..e9b1ad56f --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs @@ -0,0 +1,149 @@ +using System.Net; +using System.Net.Http.Json; +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.WebApi.DTOs; +using Xunit; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Тесты CRUD операций для объектов недвижимости +/// +public class PropertiesControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public PropertiesControllerTests(RealEstateWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + /// + /// 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/1"); + + response.EnsureSuccessStatusCode(); + var property = await response.Content.ReadFromJsonAsync( + RealEstateWebApplicationFactory.JsonOptions); + + Assert.NotNull(property); + Assert.Equal(1, 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/999"); + + 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.True(created.Id > 0); + 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/1", 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..9d8bc535d --- /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..06167ee49 --- /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 Microsoft.VisualStudio.TestPlatform.TestHost; +using RealEstateAgency.WebApi.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..1bd4f77bb --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Net.Http.Json; +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.WebApi.DTOs; +using Xunit; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Тесты CRUD операций для заявок +/// +public class RequestsControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public RequestsControllerTests(RealEstateWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + /// + /// 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/1"); + + response.EnsureSuccessStatusCode(); + var request = await response.Content.ReadFromJsonAsync( + RealEstateWebApplicationFactory.JsonOptions); + + Assert.NotNull(request); + Assert.Equal(1, 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/999"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + /// + /// POST /api/requests — создание новой заявки + /// + [Fact] + public async Task Create_ValidData_ReturnsCreatedRequest() + { + var newRequest = new CreateRequestDto + { + CounterpartyId = 1, + PropertyId = 1, + 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.True(created.Id > 0); + Assert.Equal(30000000.00m, created.Amount); + } + + /// + /// POST /api/requests — несуществующий контрагент возвращает 404 + /// + [Fact] + public async Task Create_InvalidCounterpartyId_ReturnsNotFound() + { + var newRequest = new CreateRequestDto + { + CounterpartyId = 999, + PropertyId = 1, + 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 = 1, + PropertyId = 1, + Type = RequestType.Sale, + Amount = 26000000.00m, + Date = new DateTime(2024, 1, 15) + }; + + var response = await _client.PutAsJsonAsync("/api/requests/1", 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 = 1, + PropertyId = 1, + 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/Program.cs b/RealEstateAgency.WebApi/Program.cs index 882f9f6ec..66fd171bb 100644 --- a/RealEstateAgency.WebApi/Program.cs +++ b/RealEstateAgency.WebApi/Program.cs @@ -1,14 +1,18 @@ using System.Text.Json.Serialization; +using RealEstateAgency.WebApi.Mapping; using RealEstateAgency.WebApi.Repositories; - +using RealEstateAgency.WebApi.Services; var builder = WebApplication.CreateBuilder(args); -// (In-Memory 2) +// (In-Memory) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// +builder.Services.AddScoped(); + // Add services to the container builder.Services.AddControllers() .AddJsonOptions(options => @@ -16,6 +20,9 @@ options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); +// AutoMapper +builder.Services.AddAutoMapper(typeof(MappingProfile)); + // Swagger/OpenAPI builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => @@ -44,14 +51,14 @@ app.UseSwaggerUI(options => { options.SwaggerEndpoint("/swagger/v1/swagger.json", "Real Estate Agency API v1"); - options.RoutePrefix = string.Empty; // Swagger UI URL + options.RoutePrefix = string.Empty; }); } -app.UseHttpsRedirection(); +//app.UseHttpsRedirection(); app.UseAuthorization(); +app.MapControllers(); app.Run(); -// ( ) public partial class Program { } \ No newline at end of file diff --git a/RealEstateAgency.WebApi/Properties/launchSettings.json b/RealEstateAgency.WebApi/Properties/launchSettings.json index 1c417182a..e6c0dba42 100644 --- a/RealEstateAgency.WebApi/Properties/launchSettings.json +++ b/RealEstateAgency.WebApi/Properties/launchSettings.json @@ -1,5 +1,6 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", + /* "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, @@ -8,6 +9,7 @@ "sslPort": 0 } }, + */ "profiles": { "http": { "commandName": "Project", @@ -18,7 +20,10 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, + } + } +}/*, + "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, @@ -29,3 +34,4 @@ } } } + */ diff --git a/RealEstateAgency.sln b/RealEstateAgency.sln index 92d66e5c6..ae69de070 100644 --- a/RealEstateAgency.sln +++ b/RealEstateAgency.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.Tests", "R 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {A5622F52-8268-4A23-BAA9-14E126720CCE}.Debug|Any CPU.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 + {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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0FDB0730-11C8-4AC7-91B1-90128F25329F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 3aebcedc9eca92151b8114ef07b785bd36234f85 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Tue, 9 Dec 2025 21:27:10 +0400 Subject: [PATCH 21/31] Configuring projects and enabling MongoDB --- RealEstateAgency.AppHost/Program.cs | 14 +++ .../Properties/launchSettings.json | 29 +++++ .../RealEstateAgency.AppHost.csproj | 22 ++++ .../appsettings.Development.json | 8 ++ RealEstateAgency.AppHost/appsettings.json | 9 ++ .../Extensions.cs | 118 ++++++++++++++++++ .../RealEstateAgency.ServiceDefaults.csproj | 22 ++++ .../RealEstateAgency.WebApi.csproj | 3 + RealEstateAgency.sln | 64 ++++++++++ 9 files changed, 289 insertions(+) create mode 100644 RealEstateAgency.AppHost/Program.cs create mode 100644 RealEstateAgency.AppHost/Properties/launchSettings.json create mode 100644 RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj create mode 100644 RealEstateAgency.AppHost/appsettings.Development.json create mode 100644 RealEstateAgency.AppHost/appsettings.json create mode 100644 RealEstateAgency.ServiceDefaults/Extensions.cs create mode 100644 RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj diff --git a/RealEstateAgency.AppHost/Program.cs b/RealEstateAgency.AppHost/Program.cs new file mode 100644 index 000000000..c3829018c --- /dev/null +++ b/RealEstateAgency.AppHost/Program.cs @@ -0,0 +1,14 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// MongoDB +var mongodb = builder.AddMongoDB("mongodb") + .WithDataVolume("mongodb-data"); + +var mongoDatabase = mongodb.AddDatabase("realestatedb"); + +// WebApi +builder.AddProject("webapi") + .WithReference(mongoDatabase) + .WithExternalHttpEndpoints(); + +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..2de765294 --- /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:17298;http://localhost:15289", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21287", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22263" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15289", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19005", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20096" + } + } + } +} diff --git a/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj new file mode 100644 index 000000000..2395d14b5 --- /dev/null +++ b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + true + 344f4440-2db5-4c49-bb8e-9856cf3d6a67 + + + + + + + + + + + + + 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.ServiceDefaults/Extensions.cs b/RealEstateAgency.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..2a3f4e074 --- /dev/null +++ b/RealEstateAgency.ServiceDefaults/Extensions.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + 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() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + 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(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj b/RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj new file mode 100644 index 000000000..9f4d04856 --- /dev/null +++ b/RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj index 24e9d503d..f8f03fbb4 100644 --- a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj +++ b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj @@ -7,12 +7,15 @@ + + + diff --git a/RealEstateAgency.sln b/RealEstateAgency.sln index ae69de070..42872deec 100644 --- a/RealEstateAgency.sln +++ b/RealEstateAgency.sln @@ -11,28 +11,92 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.WebApi", " 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.AppHost", "RealEstateAgency.AppHost\RealEstateAgency.AppHost.csproj", "{21B7597A-0E5F-45D7-92D3-721A578B6F0C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.ServiceDefaults", "RealEstateAgency.ServiceDefaults\RealEstateAgency.ServiceDefaults.csproj", "{594B532D-3208-489C-8ECD-268A92D92F3B}" +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 + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|x64.ActiveCfg = Debug|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|x64.Build.0 = Debug|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|x86.ActiveCfg = Debug|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|x86.Build.0 = Debug|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|Any CPU.Build.0 = Release|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|x64.ActiveCfg = Release|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|x64.Build.0 = Release|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|x86.ActiveCfg = Release|Any CPU + {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|x86.Build.0 = Release|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|x64.ActiveCfg = Debug|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|x64.Build.0 = Debug|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|x86.ActiveCfg = Debug|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|x86.Build.0 = Debug|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|Any CPU.Build.0 = Release|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|x64.ActiveCfg = Release|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|x64.Build.0 = Release|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|x86.ActiveCfg = Release|Any CPU + {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 86510cf8ac025e970fc615ff8866db589338552c Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 10 Dec 2025 12:28:17 +0400 Subject: [PATCH 22/31] Implementing MongoDB repositories --- .../MongoCounterpartyRepository.cs | 60 ++++++++++++++++ .../MongoRealEstatePropertyRepository.cs | 66 ++++++++++++++++++ .../Repositories/MongoRequestRepository.cs | 68 +++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 RealEstateAgency.WebApi/Repositories/MongoCounterpartyRepository.cs create mode 100644 RealEstateAgency.WebApi/Repositories/MongoRealEstatePropertyRepository.cs create mode 100644 RealEstateAgency.WebApi/Repositories/MongoRequestRepository.cs diff --git a/RealEstateAgency.WebApi/Repositories/MongoCounterpartyRepository.cs b/RealEstateAgency.WebApi/Repositories/MongoCounterpartyRepository.cs new file mode 100644 index 000000000..2ee0e8c51 --- /dev/null +++ b/RealEstateAgency.WebApi/Repositories/MongoCounterpartyRepository.cs @@ -0,0 +1,60 @@ +using MongoDB.Driver; +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.WebApi.Repositories; + +/// +/// MongoDB реализация репозитория контрагентов +/// +public class MongoCounterpartyRepository : ICounterpartyRepository +{ + private readonly IMongoCollection _collection; + + public MongoCounterpartyRepository(IMongoDatabase database) + { + _collection = database.GetCollection("counterparties"); + } + + public IEnumerable GetAll() + { + return _collection.Find(_ => true).ToList(); + } + + public Counterparty? GetById(int id) + { + return _collection.Find(c => c.Id == id).FirstOrDefault(); + } + + public Counterparty Add(Counterparty counterparty) + { + // Генерация нового ID + var maxId = _collection.Find(_ => true) + .SortByDescending(c => c.Id) + .FirstOrDefault()?.Id ?? 0; + counterparty.Id = maxId + 1; + + _collection.InsertOne(counterparty); + return counterparty; + } + + public Counterparty? Update(int id, Counterparty counterparty) + { + var filter = Builders.Filter.Eq(c => c.Id, id); + var update = Builders.Update + .Set(c => c.FullName, counterparty.FullName) + .Set(c => c.PassportNumber, counterparty.PassportNumber) + .Set(c => c.PhoneNumber, counterparty.PhoneNumber); + + var result = _collection.UpdateOne(filter, update); + if (result.ModifiedCount == 0) + return null; + + return GetById(id); + } + + public bool Delete(int id) + { + var result = _collection.DeleteOne(c => c.Id == id); + return result.DeletedCount > 0; + } +} diff --git a/RealEstateAgency.WebApi/Repositories/MongoRealEstatePropertyRepository.cs b/RealEstateAgency.WebApi/Repositories/MongoRealEstatePropertyRepository.cs new file mode 100644 index 000000000..916e420f1 --- /dev/null +++ b/RealEstateAgency.WebApi/Repositories/MongoRealEstatePropertyRepository.cs @@ -0,0 +1,66 @@ +using MongoDB.Driver; +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.WebApi.Repositories; + +/// +/// MongoDB реализация репозитория объектов недвижимости +/// +public class MongoRealEstatePropertyRepository : IRealEstatePropertyRepository +{ + private readonly IMongoCollection _collection; + + public MongoRealEstatePropertyRepository(IMongoDatabase database) + { + _collection = database.GetCollection("properties"); + } + + public IEnumerable GetAll() + { + return _collection.Find(_ => true).ToList(); + } + + public RealEstateProperty? GetById(int id) + { + return _collection.Find(p => p.Id == id).FirstOrDefault(); + } + + public RealEstateProperty Add(RealEstateProperty property) + { + var maxId = _collection.Find(_ => true) + .SortByDescending(p => p.Id) + .FirstOrDefault()?.Id ?? 0; + property.Id = maxId + 1; + + _collection.InsertOne(property); + return property; + } + + public RealEstateProperty? Update(int id, RealEstateProperty property) + { + var filter = Builders.Filter.Eq(p => p.Id, id); + var update = Builders.Update + .Set(p => p.Type, property.Type) + .Set(p => p.Purpose, property.Purpose) + .Set(p => p.CadastralNumber, property.CadastralNumber) + .Set(p => p.Address, property.Address) + .Set(p => p.TotalFloors, property.TotalFloors) + .Set(p => p.TotalArea, property.TotalArea) + .Set(p => p.RoomsCount, property.RoomsCount) + .Set(p => p.CeilingHeight, property.CeilingHeight) + .Set(p => p.Floor, property.Floor) + .Set(p => p.HasEncumbrances, property.HasEncumbrances); + + var result = _collection.UpdateOne(filter, update); + if (result.ModifiedCount == 0) + return null; + + return GetById(id); + } + + public bool Delete(int id) + { + var result = _collection.DeleteOne(p => p.Id == id); + return result.DeletedCount > 0; + } +} diff --git a/RealEstateAgency.WebApi/Repositories/MongoRequestRepository.cs b/RealEstateAgency.WebApi/Repositories/MongoRequestRepository.cs new file mode 100644 index 000000000..32e649772 --- /dev/null +++ b/RealEstateAgency.WebApi/Repositories/MongoRequestRepository.cs @@ -0,0 +1,68 @@ +using MongoDB.Driver; +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.WebApi.Repositories; + +/// +/// MongoDB реализация репозитория заявок +/// +public class MongoRequestRepository : IRequestRepository +{ + private readonly IMongoCollection _collection; + private readonly ICounterpartyRepository _counterpartyRepository; + private readonly IRealEstatePropertyRepository _propertyRepository; + + public MongoRequestRepository( + IMongoDatabase database, + ICounterpartyRepository counterpartyRepository, + IRealEstatePropertyRepository propertyRepository) + { + _collection = database.GetCollection("requests"); + _counterpartyRepository = counterpartyRepository; + _propertyRepository = propertyRepository; + } + + public IEnumerable GetAll() + { + return _collection.Find(_ => true).ToList(); + } + + public Request? GetById(int id) + { + return _collection.Find(r => r.Id == id).FirstOrDefault(); + } + + public Request Add(Request request) + { + var maxId = _collection.Find(_ => true) + .SortByDescending(r => r.Id) + .FirstOrDefault()?.Id ?? 0; + request.Id = maxId + 1; + + _collection.InsertOne(request); + return request; + } + + public Request? Update(int id, Request request) + { + var filter = Builders.Filter.Eq(r => r.Id, id); + var update = Builders.Update + .Set(r => r.Counterparty, request.Counterparty) + .Set(r => r.Property, request.Property) + .Set(r => r.Type, request.Type) + .Set(r => r.Amount, request.Amount) + .Set(r => r.Date, request.Date); + + var result = _collection.UpdateOne(filter, update); + if (result.ModifiedCount == 0) + return null; + + return GetById(id); + } + + public bool Delete(int id) + { + var result = _collection.DeleteOne(r => r.Id == id); + return result.DeletedCount > 0; + } +} From 0675297eb73efb3e95486e809494897d57e3807b Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 10 Dec 2025 12:34:20 +0400 Subject: [PATCH 23/31] Creating a service to fill the database with initial data --- .../Services/DatabaseSeeder.cs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 RealEstateAgency.WebApi/Services/DatabaseSeeder.cs diff --git a/RealEstateAgency.WebApi/Services/DatabaseSeeder.cs b/RealEstateAgency.WebApi/Services/DatabaseSeeder.cs new file mode 100644 index 000000000..c9b27e95f --- /dev/null +++ b/RealEstateAgency.WebApi/Services/DatabaseSeeder.cs @@ -0,0 +1,109 @@ +using MongoDB.Driver; +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.WebApi.Services; + +/// +/// Сервис для начального заполнения базы данных +/// +public class DatabaseSeeder +{ + private readonly IMongoDatabase _database; + private readonly ILogger _logger; + + public DatabaseSeeder(IMongoDatabase database, ILogger logger) + { + _database = database; + _logger = logger; + } + + /// + /// Заполняет базу данных начальными данными, если она пуста + /// + public async Task SeedAsync() + { + var counterpartiesCollection = _database.GetCollection("counterparties"); + var propertiesCollection = _database.GetCollection("properties"); + var requestsCollection = _database.GetCollection("requests"); + + // Проверяем, есть ли уже данные + var counterpartiesCount = await counterpartiesCollection.CountDocumentsAsync(_ => true); + if (counterpartiesCount > 0) + { + _logger.LogInformation("База данных уже содержит данные, seed пропущен"); + return; + } + + _logger.LogInformation("Начало заполнения базы данных..."); + + // Создаём контрагентов + var counterparties = GenerateCounterparties(); + await counterpartiesCollection.InsertManyAsync(counterparties); + _logger.LogInformation("Добавлено {Count} контрагентов", counterparties.Count); + + // Создаём объекты недвижимости + var properties = GenerateProperties(); + await propertiesCollection.InsertManyAsync(properties); + _logger.LogInformation("Добавлено {Count} объектов недвижимости", properties.Count); + + // Создаём заявки + var requests = GenerateRequests(counterparties, properties); + await requestsCollection.InsertManyAsync(requests); + _logger.LogInformation("Добавлено {Count} заявок", requests.Count); + + _logger.LogInformation("Заполнение базы данных завершено"); + } + + private static List GenerateCounterparties() => + [ + new() { Id = 1, FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" }, + new() { Id = 2, FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" }, + new() { Id = 3, FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" }, + new() { Id = 4, FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" }, + new() { Id = 5, FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" }, + new() { Id = 6, FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" }, + new() { Id = 7, FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" }, + new() { Id = 8, FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" }, + new() { Id = 9, FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" }, + new() { Id = 10, FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" }, + new() { Id = 11, FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" }, + new() { Id = 12, FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" } + ]; + + private static List GenerateProperties() => + [ + new() { Id = 1, 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 = 2, 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 = 3, 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 = 4, 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 = 5, 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 = 6, 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 = 7, 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 = 8, 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 = 9, 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 = 10, 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 = 11, 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 = 12, 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 = 13, 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 = 1, Counterparty = counterparties[0], Property = properties[0], Type = RequestType.Sale, Amount = 25000000.00m, Date = new DateTime(2024, 1, 15) }, + new() { Id = 2, Counterparty = counterparties[1], Property = properties[1], Type = RequestType.Sale, Amount = 18000000.00m, Date = new DateTime(2024, 2, 20) }, + new() { Id = 3, Counterparty = counterparties[3], Property = properties[3], Type = RequestType.Sale, Amount = 42000000.00m, Date = new DateTime(2024, 3, 10) }, + new() { Id = 4, Counterparty = counterparties[6], Property = properties[5], Type = RequestType.Sale, Amount = 35000000.00m, Date = new DateTime(2024, 4, 5) }, + new() { Id = 5, Counterparty = counterparties[8], Property = properties[7], Type = RequestType.Sale, Amount = 32000000.00m, Date = new DateTime(2024, 5, 12) }, + new() { Id = 6, Counterparty = counterparties[10], Property = properties[9], Type = RequestType.Sale, Amount = 1500000.00m, Date = new DateTime(2024, 6, 8) }, + new() { Id = 7, Counterparty = counterparties[11], Property = properties[11], Type = RequestType.Sale, Amount = 85000000.00m, Date = new DateTime(2024, 7, 25) }, + new() { Id = 8, Counterparty = counterparties[2], Property = properties[2], Type = RequestType.Purchase, Amount = 22000000.00m, Date = new DateTime(2024, 1, 20) }, + new() { Id = 9, Counterparty = counterparties[4], Property = properties[4], Type = RequestType.Purchase, Amount = 15000000.00m, Date = new DateTime(2024, 2, 25) }, + new() { Id = 10, Counterparty = counterparties[5], Property = properties[6], Type = RequestType.Purchase, Amount = 28000000.00m, Date = new DateTime(2024, 3, 15) }, + new() { Id = 11, Counterparty = counterparties[7], Property = properties[8], Type = RequestType.Purchase, Amount = 25000000.00m, Date = new DateTime(2024, 4, 18) }, + new() { Id = 12, Counterparty = counterparties[9], Property = properties[10], Type = RequestType.Purchase, Amount = 1800000.00m, Date = new DateTime(2024, 5, 22) }, + new() { Id = 13, Counterparty = counterparties[2], Property = properties[12], Type = RequestType.Purchase, Amount = 60000000.00m, Date = new DateTime(2024, 6, 30) }, + new() { Id = 14, Counterparty = counterparties[1], Property = properties[0], Type = RequestType.Purchase, Amount = 24000000.00m, Date = new DateTime(2024, 8, 10) }, + new() { Id = 15, Counterparty = counterparties[3], Property = properties[1], Type = RequestType.Sale, Amount = 19000000.00m, Date = new DateTime(2024, 9, 5) } + ]; +} From 5ec4c07bba46ed7c43846340b7d1b01ca62b19b9 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 10 Dec 2025 12:49:32 +0400 Subject: [PATCH 24/31] Support for two operating modes --- RealEstateAgency.WebApi/Program.cs | 66 ++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/RealEstateAgency.WebApi/Program.cs b/RealEstateAgency.WebApi/Program.cs index 66fd171bb..fafff998f 100644 --- a/RealEstateAgency.WebApi/Program.cs +++ b/RealEstateAgency.WebApi/Program.cs @@ -1,29 +1,51 @@ using System.Text.Json.Serialization; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using MongoDB.Driver; using RealEstateAgency.WebApi.Mapping; using RealEstateAgency.WebApi.Repositories; using RealEstateAgency.WebApi.Services; var builder = WebApplication.CreateBuilder(args); -// (In-Memory) -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +var useMongoDB = builder.Configuration.GetConnectionString("realestatedb") != null + || Environment.GetEnvironmentVariable("ConnectionStrings__realestatedb") != null; -// -builder.Services.AddScoped(); +if (useMongoDB) +{ + builder.AddServiceDefaults(); + + BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); + BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); + BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); + + builder.AddMongoDBClient("realestatedb"); + + builder.Services.AddScoped(sp => + { + var client = sp.GetRequiredService(); + return client.GetDatabase("realestatedb"); + }); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); +} +else +{ + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); +} -// Add services to the container builder.Services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); -// AutoMapper -builder.Services.AddAutoMapper(typeof(MappingProfile)); - -// Swagger/OpenAPI builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { @@ -42,23 +64,35 @@ } }); +builder.Services.AddAutoMapper(typeof(MappingProfile)); + +builder.Services.AddScoped(); + var app = builder.Build(); -// Configure the HTTP request pipeline +if (useMongoDB) +{ + app.MapDefaultEndpoints(); + + using var scope = app.Services.CreateScope(); + var seeder = scope.ServiceProvider.GetRequiredService(); + await seeder.SeedAsync(); +} + if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(options => { options.SwaggerEndpoint("/swagger/v1/swagger.json", "Real Estate Agency API v1"); - options.RoutePrefix = string.Empty; + options.RoutePrefix = string.Empty; }); } -//app.UseHttpsRedirection(); +app.UseHttpsRedirection(); app.UseAuthorization(); -app.MapControllers(); +app.MapControllers(); app.Run(); -public partial class Program { } \ No newline at end of file +public partial class Program { } From 961ca89de2512d00e925215dc3df723cfec07e24 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Wed, 10 Dec 2025 15:47:56 +0400 Subject: [PATCH 25/31] Integration tests for MongoDB and verification of results --- README.md | 30 +++ .../Extensions.cs | 68 ++---- .../MongoAnalyticsTests.cs | 229 ++++++++++++++++++ .../MongoCounterpartiesTests.cs | 102 ++++++++ .../MongoDbCollection.cs | 12 + .../MongoDbWebApplicationFactory.cs | 62 +++++ .../MongoPropertiesTests.cs | 125 ++++++++++ .../MongoRequestsTests.cs | 184 ++++++++++++++ .../RealEstateAgency.WebApi.Tests.csproj | 1 + ...\202\321\213_\321\200\320\272\320\277.jpg" | Bin 0 -> 145463 bytes ...202\321\213_\321\200\320\272\320\2772.jpg" | Bin 0 -> 75317 bytes RealEstateAgency.WebApi/Program.cs | 1 + 12 files changed, 772 insertions(+), 42 deletions(-) create mode 100644 RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs create mode 100644 RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs create mode 100644 RealEstateAgency.WebApi.Tests/MongoDbCollection.cs create mode 100644 RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs create mode 100644 RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs create mode 100644 RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs create mode 100644 "RealEstateAgency.WebApi.Tests/result_2_3labs/\321\202\320\265\321\201\321\202\321\213_\321\200\320\272\320\277.jpg" create mode 100644 "RealEstateAgency.WebApi.Tests/result_2_3labs/\321\202\320\265\321\201\321\202\321\213_\321\200\320\272\320\2772.jpg" diff --git a/README.md b/README.md index 54283eab4..84c4b5903 100644 --- a/README.md +++ b/README.md @@ -29,4 +29,34 @@ * ClientsWithMinAmountAreFoundCorrectly() - Клиенты с заявками минимальной стоимости * ClientsSeekingPropertyTypeAreReturnedOrdered() - Поиск клиентов по типу недвижимости с сортировкой +### RealEstateAgency.WebApi +Основной Web API проект с REST эндпоинтами и бизнес-логикой. + +#### Контроллеры +**CounterpartiesController** - CRUD операции для управления контрагентами +**PropertiesController** - CRUD операции для управления объектами недвижимости +**RequestsController** - CRUD операции для управления заявками +**AnalyticsController** - аналитические запросы для бизнес-анализа + +#### Сервисы +**AnalyticsService** - сервис для выполнения сложных аналитических запросов +**DatabaseSeeder** - сервис для инициализации базы данных тестовыми данными + +#### Репозитории +**In-Memory** реализации для локальной разработки и тестирования +**MongoDB** реализации для production использования + +#### DTO +Отдельные классы для создания, обновления и чтения каждой сущности + +#### RealEstateAgency.ServiceDefaults +**Назначение**: Общие настройки и конфигурации для всех сервисов в экосистеме .NET Aspire. + +#### RealEstateAgency.AppHost +**Назначение**: Оркестратор приложения, который управляет запуском всех компонентов (Web API и MongoDB) через .NET Aspire. + +#### RealEstateAgency.WebApi.Tests +**Назначение**: Интеграционные тесты для проверки REST API эндпоинтов. + + diff --git a/RealEstateAgency.ServiceDefaults/Extensions.cs b/RealEstateAgency.ServiceDefaults/Extensions.cs index 2a3f4e074..2c7eda142 100644 --- a/RealEstateAgency.ServiceDefaults/Extensions.cs +++ b/RealEstateAgency.ServiceDefaults/Extensions.cs @@ -3,45 +3,40 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; -namespace Microsoft.Extensions.Hosting; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. -// This project should be referenced by each service project in your solution. -// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +namespace RealEstateAgency.ServiceDefaults; + +/// +/// Aspire +/// public static class Extensions { - public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + /// + /// Aspire + /// + public static WebApplicationBuilder AddServiceDefaults(this WebApplicationBuilder builder) { builder.ConfigureOpenTelemetry(); - builder.AddDefaultHealthChecks(); - builder.Services.AddServiceDiscovery(); builder.Services.ConfigureHttpClientDefaults(http => { - // Turn on resilience by default http.AddStandardResilienceHandler(); - - // Turn on service discovery by default http.AddServiceDiscovery(); }); - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); - return builder; } - public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + /// + /// OpenTelemetry + /// + public static WebApplicationBuilder ConfigureOpenTelemetry(this WebApplicationBuilder builder) { builder.Logging.AddOpenTelemetry(logging => { @@ -59,8 +54,6 @@ public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicati .WithTracing(tracing => { tracing.AddAspNetCoreInstrumentation() - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() .AddHttpClientInstrumentation(); }); @@ -69,7 +62,7 @@ public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicati return builder; } - private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + private static WebApplicationBuilder AddOpenTelemetryExporters(this WebApplicationBuilder builder) { var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); @@ -78,40 +71,31 @@ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostAppli builder.Services.AddOpenTelemetry().UseOtlpExporter(); } - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} - return builder; } - public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + /// + /// health checks + /// + public static WebApplicationBuilder AddDefaultHealthChecks(this WebApplicationBuilder builder) { builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); return builder; } + /// + /// health checks + /// public static WebApplication MapDefaultEndpoints(this WebApplication app) { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + app.MapHealthChecks("/health"); - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); return app; } diff --git a/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs b/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs new file mode 100644 index 000000000..b9864c765 --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs @@ -0,0 +1,229 @@ +using System.Net.Http.Json; +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.WebApi.DTOs; +using Xunit; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Интеграционные тесты аналитических запросов с реальной MongoDB +/// +[Collection("MongoDB")] +public class MongoAnalyticsTests : IClassFixture +{ + private readonly HttpClient _client; + + public MongoAnalyticsTests(MongoDbWebApplicationFactory factory) + { + _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..679744364 --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Net.Http.Json; +using RealEstateAgency.WebApi.DTOs; +using Xunit; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Интеграционные тесты CRUD операций для контрагентов с реальной MongoDB +/// +[Collection("MongoDB")] +public class MongoCounterpartiesTests : IClassFixture +{ + private readonly HttpClient _client; + + public MongoCounterpartiesTests(MongoDbWebApplicationFactory factory) + { + _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.True(created.Id > 0); + 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..2f7f0a2b7 --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs @@ -0,0 +1,12 @@ +using Xunit; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Определение коллекции для 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..252c67656 --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using RealEstateAgency.WebApi.Repositories; +using Testcontainers.MongoDb; +using Xunit; + +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)) + .ToList(); + + foreach (var descriptor in descriptorsToRemove) + { + services.Remove(descriptor); + } + + var mongoClient = new MongoClient(ConnectionString); + var database = mongoClient.GetDatabase("realestatedb_test"); + + services.AddSingleton(mongoClient); + services.AddSingleton(database); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + + 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..69408922e --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http.Json; +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.WebApi.DTOs; +using Xunit; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Интеграционные тесты CRUD операций для объектов недвижимости с реальной MongoDB +/// +[Collection("MongoDB")] +public class MongoPropertiesTests : IClassFixture +{ + private readonly HttpClient _client; + + public MongoPropertiesTests(MongoDbWebApplicationFactory factory) + { + _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.True(created.Id > 0); + 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..63166bc72 --- /dev/null +++ b/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Net.Http.Json; +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.WebApi.DTOs; +using Xunit; + +namespace RealEstateAgency.WebApi.Tests; + +/// +/// Интеграционные тесты CRUD операций для заявок с реальной MongoDB +/// +[Collection("MongoDB")] +public class MongoRequestsTests : IClassFixture +{ + private readonly HttpClient _client; + + public MongoRequestsTests(MongoDbWebApplicationFactory factory) + { + _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.True(created.Id > 0); + 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 = 99999, + 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/RealEstateAgency.WebApi.Tests.csproj b/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj index 9d8bc535d..7b2d4e461 100644 --- a/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj +++ b/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj @@ -13,6 +13,7 @@ + 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 0000000000000000000000000000000000000000..acf6c455f1e9ddf0b365ce5d443a8e8698cffd48 GIT binary patch literal 145463 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%0DgE$9yP zm0*>SQv+yPd-D49KD|n;oj~p0GB?|$VwZ5jblh0NxOeCCRv3jI%Alc3Y7P8sm<6Lrcx3_B_y$B{F zR8yl4APm8@zxWKL@SWJA_{Hd;7PGqPAW8dxB=EXmg77+j3U?UY{EZ~-M>C;ECX%GI zg}Kb{!M4QE4@IQt2ZX|`;!nb3K6o{2+)DOCN8ebB{wQ#8MP0anr{zuj%$@6K{O{{Yt-#eH;P!jKKhyMal_AS;B~pWBkv`7WNGki zB7Za@I)sO;QbPilp2e;1j*YEKKAAJL>3cfJ0&+`wI(9CJ>h~30F(sC{D4caOpNdu> zmVA9;UxR@Z1^zBae>!a^+HcbEK45!t&(J2)?4_C66njS7NKGq&B`&theiZE-WCMxJ z5DN>|TynoCo{q5enbmIZ*3b8Uz0-T&uv*)rtVRFZOT|01@{grVjE_SV$bYHiXVQf8 z2gfPrVacl2QHS8l_Yjd(G&s+e1hc9`5_#d#Y&BybkpUBff$#6Em}T-IL)|iO)E{a& zLz@r%CduO9N;&Jf2w<*Nab*1@J1c)!E_qr{{Buke@pgm6dCJz8lII-M39>PbW8exj zOU_2?E_S8m1lIPKN;JTX70;Eni7$1n4;2;bq;NM(1j{-f4uW*vulhA1Ry1{zFU9GY zKd)G6z98^khZ93A{_$JtU;!&v&&Nfe;LD>uwEnl)PscAq!N60nn-;=lUrSJ7{ufwl@ z_NdW`6iG6-xN?U&0T5O98R{2@4*B_o@)Kt$f<*hwqIs9pRR7fLk33!>8p8bz(`X87 zQ)4Fxxt@!yC*Lm?0g0}IBRJmd+ZHkl%?-;yXJ=;fY6YrloR>dFxh@VfNUml`AIyr# zN4qlA1nAK}+`Hgid*iTMn@UL7d#+AyX9{D1t|A!h;3UtFt#wOst+QR%_jFG7nKPEI zxoAq~sprJW@9#xJzv91w4+=goC-cu3$^1)~A-$e$tQA;ac6tv#8C6mQR%ZXSon{sN zpb0SsFOYT(5cLxd*ByPFRNg$A{-(e=>Q5^#nRx`$gTy|muK3d`70xSe?jNC>y~S8D zSSRwim4tikAw=V$jh*>w9^9YOUjx~;AZMJ8n)~ZF>+krHcaCJ+{#0rc_&>_E_%9lD zx?68x1}Efli=sIP;#&1m*ZPxJXc<$MR%v<5z+IqPg~r98EttN#AYr=%$9rWE!9950 zs38lkRr7XGFg46UScvu(I&{wOuryE{jjvpY1b$~4T0uwc!&9qMC_6{zB}OAH#b+)U zu6&Xm+=KZ{NMTG>qU)+Q_s8V^J^w9%ly0p6Kp#N?2CvbYK`n}%O$<#{)eXNaIw(G? z$GH(+p@|$HwzQwe3cUQr^F~I0)?GbnhX`Ej@;g>=G(lsKhw=a4&Sq^{cx8op=5QVA zMYi5$i<9J1_`;oT#i6b7=F!KCx4WLcZ}Uq0ipe7zjFayz@M^XrAq#`b7~7_CyMs#F zx#6H?OT*Qw$T%q)AllDLMkb{E{Mm8N@K5p%5YeLKL@`e5#2MQoh)5&CVMH!exo^0= z2@-)a^mjNbDTO)B*emPs^e7Ov8nR8wh0aYEpL9{b8&)X`=Eu~JXyw%;SWN|TudYcE`ln>Fc`>4Yc z8B$mmOCNFnH{VO4_p(g+Cii}>4qD$QT zsgDH8au09o9^yfsKvD1uNfz#Q4cdAvG)6W^2zY{F+jM#m_co5>fIC8INv&*Q%ey^r zINVaItZ>$Kr70v&FrADSMUl0y#L0|7?D5qrFLzHkPM;VSBgWT%pD-O3ofQg_`sK4@ zbGsz?cxyfnx4uAZJGMRjy-6iTb&^5Ix^ABNV6t3Z#Y0MBTc|N!V@{hv!`SpYd_e8y z;>&z}ecsP^epT(KaDivlaCqF>JGsA9JivaCE%r*Qs!{3m%oiX1G8e6s!ifP#QSUdR z(zm74g>E0;)WcrpxVM4QEF&p)gtC&Z!J%<>-dj!PiHAQW_NQH5MeRAx#3Z8P1vx@} zIiJ_OQlz>z++Sy&Dbin90TK5G`f!hfW;R0iJgjF`jsau&N%p!%H-rwlJKW)~E*EcI zJ5k4IvVA5r>W<5x|FhP3suvlPl>0V9qPe|yH!(5MPBi6Ihh&Yh2N0VOFI+tR42T4` z^O$vn5j`r*z`mK*E_PO9P-8Sy?RVjhXby;^K~5&C z2+bfMv7vkuo53Er^7`V<^K9{~F1z%)MLLdqejT2wE4w{I1vTtDZ50}kATA5s#1Op6 zIZoA^vAgvrdxtVSpSM2NRVYQK%vR4AkxQdw=8{h>o}{oqKF~6{U1O)QAVMmLUiE(i zeB?IB9%M%?dNDgk8tA2dQf7}P>!_y$6j&MH<*cu?6m2MiRG+*pziXRxU$R$;@8DUR z@?WYBbT~z_nkX4pj5E4wzaDjDZk}eX%62!ii|U8n*zU8H@Z(HsF&7SjuK`*BhPl-5 zm6h6X5GGU!`pmYTcs4zSzqZjTH@>MwzQyy9qf)3LPCq;rxN$T*>`&>db>QGE<3jdS zGU|jZ;8QZ~iCL5|c*l291dd)M$o-)>$Fyhdo{5$9l1Q{i;^$jbx8FWd56=74YV&5_ z|1=9TJKk^juB<4>hH`s&_w+z*w`WP`16d7HF}1z~6x~~iz7e1DQ&;;qcwa=$F2|-g zd!bj`6Fh(SPLGwX19NY6vx=6avhS8tAhr3(1F)Tro=)T?P1Ymq`N4>V#CN;fYkQUS zc|{qfSa9*NR;aO--X8G9?LqclpRC!B$qh5PXmA9(mbipU5h~^*_gBNYHCWd1j?8wp zPm)yGwukIDSL{B}7=HR#b&q2KAswgAO4QB)u*aqmR<$DscGq=k73&kUB)eK>V_x`Q zGAOOl?t!)?KDpzSE&k(AVPE}v9S#9nv(wXw#`CZ5XQHc1iXfZSlN? zY2iMW`@N1t?Xnj|syk~2l$V#CsowL8m}WJ$iG{yj!4#032}hmk*Bi1mwocDGoe~`5aV&~cPVqn19L_c$bd?KcyI;e=ZS{~*85?@*uj?t zG|>W}t6ghz^)GX4<%yGr9w^CF3cE=O%4jLybv6N_cA>AodJ)LBBL_?*Be}sKd!ir0 zq0+TJ)epN`kT?gDNO+u-MO0moc-=Mn8ohdD)v0Ew3W60D6q{IIx~+nVDH_Lv;Pvs4w}9AxShlm=|&a>pxo^mq_HgZuRv0B zmg8R$=DW~LR)1P$$ECZGZW%|PHhnGOb=6$M5E_}jZL?@{%{#%AZfzZ5UwF6?R_I|h z(K$?BJHnLc-j#)zO+~=n6slY?Cs}pGOOHH+0{rWAWkiy+oy2IVs6lGRR^7M7Q{0i1 z$N9&C)9#HxZRi7Rkc^z=s}_qv-2n6xcy&&~N9oFTDrw<(Q_{hb(PhAnJw+5Vu5)+V=}J3fc750fc1i7K%chB4-Ro?gKYFyN znj5ZKO`Y@kPibI53ydXg8Ew~5ai&2F8z$GQ$Beh$gVI4h=@aSAB03#9lU;_30hjIM zh+5uErsiugy^QBwbYbcisdp^N%Tip!@6j&9^dyH4WwQ)hXy)Un`TLDy+o*d5(?2qN zfjF%y%ss>Cp0(5Srzz0g0tR)KTgRnQCbs0>1M}mc`F|~K0`b5uNn-()M9EZ91omw~ zhl;=zd|45Tu1@pVsokVZ%*)qDV_WHW=`&3OS$m9ZqQYfJRD2bAZ#eqmI5fp*Ld&m8`pGLw>nK8mlJiarcBCd z*$N&bBjLE83U3pb?Szqc10xjhFzEe_^RMbI22x@Br@TbKP?Orrbv_{1q^a6F}{ zmpn-IhUiWrf)T1*0y^>A}PQY_jD)k&gLayvF~I!nfu60_?}mwO}amvo&8B zQyNW>{i$)HUigq;=w4v*&;(C4?_1%3*^q05_`^w}SR06Tn&jVM&rComjFR_k^)GZ7 z`QG5^6}lX5c?P5-sHo_ui0DXl_J}tAYr)SJ`vVFZkYn;T73%j2sZ`3J$-RhE>ZQ*X z-)BE`CNM>`a+1|XIAiMq50UKIdwTIQCfa_t_Nl8Xhjuuj*=yZ%Qh?++i0A2?+tK*O zbsZ`}`G8lh95;otzEyYYDeZu4gBeLv3+PY$SISjcmokSpEA*e7la$7SZZ({iC&B3B zQgBYcHcazAOM~$aYuyzb6`G&~`;{>9l#f=3o!K+S_-Y(lPiYCGM71 zZ=8|V=g@am?Q(M%Ncm=CR>+XJRb9=%u$Sk^W&7e?IeekK!Pf|yQ-7>uE6#A*^Uk%1 z1byKo{-wm0Vc`rieiC7IFlfDJ+^7PVDsugLT0Q9ficrGmG;X{edNyPbo@IHfJ|jF8 zZ85W7Nk`IbQ-EFGMA_k@)nLXMcu=z5Ze8qSdb23u@LP&F{Q!UD(2R%ik7eI7KIOA? zvhiYwa~J<}8}RUzKmKTkke7!i*j`1BBr|R1zB?9;@E+fNCEV0lmV`T2ir5EuAW`>$Ld3XikqC4APD)a2qE=4{m;9NtpSd4WdZ{-=d3p{;=)v^70`@sP^S=9cGZ#Ry$yy(zB@-qER1Vt!9Kb~(h!+2 z77(hFR2U6mzFy_vv=sFxSTkr5eTh`IfQ&haXVr2LjaXDu{_`hqY;hZoLioqN@YQ7F zZ8dr2Gu?Y!8fdp(YPUrMO-|ZCM#B7g<^9lLvWDM|_ZmQ}4rV9DN7q5+C8Zz7*#sru zsfzZ~moRwBg__1g#}>|?XEyz?`}N9K zv+fz9$#lAOY7>ReyrI-i+ctA*8iPD&eAc5uU8zc~5ico>q|C*4jCXKM%AHw{Xn9o1 zY8M6h%G~q6t&_@YE!%(oQn{ML0wlIhO>WN4rTM)cgkiEC>1k^>t*3O>X9VG{tE$bW zN@Q7n_Y2n&Z%INdGSXU*YD-;D4&beT|59$y*`PigyQ(3l6*#WP5Uy(~bbCq7bo*GJ z=j||Gux;%x#F@A8NUN}mwNqjxsO@h3IGLA6x!}@x!GMPoTS;^8bDKAYLMnnl=RWak zD90Y8kh1cZiq@`9uEg=yo2~JaGv%|juYdkh)y79jsaiSYZ|tr&Jz`e-+1wIuQs2TR z4&hJtS}kC%)_Fv^h}9GD>g8xUB8$&HoZRSXKQW5F2zQKAp^wJn1IBiw&M`uG=tiq~ z-qz@ScsFc)FkKlvM>m21wDssrL!v@UsZK}{K-eG@<;Z`vFqdr?Vy?5e%zRd^z9y

FK~&08!X0;X@uO=Pq?( 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 K33Fus%={OJj#w}N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4648d96766dcf43bbfafc43688ad69329c668eb0 GIT binary patch literal 75317 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

GI1SWKbGPc2wPu@ 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 Date: Thu, 18 Dec 2025 06:29:56 +0400 Subject: [PATCH 26/31] Error Correction --- .../RealEstateAgency.AppHost.csproj | 3 +- .../Mapping/MappingProfile.cs | 7 +- .../RealEstateAgency.Application.csproj | 19 ++ .../Services/AnalyticsService.cs | 148 +++++++++++++++ .../Services/CounterpartyService.cs | 57 ++++++ .../Services/RealEstatePropertyService.cs | 57 ++++++ .../Services/RequestService.cs | 91 +++++++++ .../Dto/ClientWithMinAmountDto.cs | 17 ++ .../Dto/CounterpartyDto.cs | 27 +++ .../Dto/CreateCounterpartyDto.cs | 30 +++ .../Dto/CreateRealEstatePropertyDto.cs | 72 +++++++ .../Dto/CreateRequestDto.cs | 41 ++++ .../Dto/PropertyTypeStatisticsDto.cs | 19 ++ .../Dto/RealEstatePropertyDto.cs | 64 +++++++ RealEstateAgency.Contracts/Dto/RequestDto.cs | 49 +++++ .../Dto/Top5ClientsResultDto.cs | 17 ++ .../Dto/TopClientDto.cs | 17 ++ .../Dto/UpdateCounterpartyDto.cs | 30 +++ .../Dto/UpdateRealEstatePropertyDto.cs | 72 +++++++ .../Dto/UpdateRequestDto.cs | 41 ++++ .../Interfaces/IAnalyticsService.cs | 35 ++++ .../Interfaces/IApplicationService.cs | 35 ++++ .../Interfaces/ICounterpartyService.cs | 14 ++ .../Interfaces/IRealEstatePropertyService.cs | 14 ++ .../Interfaces/IRequestService.cs | 36 ++++ .../RealEstateAgency.Contracts.csproj | 17 ++ .../Interfaces/ICounterpartyRepository.cs | 10 + .../IRealEstatePropertyRepository.cs | 10 + .../Interfaces/IRepository.cs | 33 ++++ .../Interfaces/IRequestRepository.cs | 10 + .../Models/Counterparty.cs | 2 +- .../Models/RealEstateProperty.cs | 2 +- RealEstateAgency.Domain/Models/Request.cs | 2 +- .../Persistence/DatabaseSeeder.cs | 97 ++++++++++ .../Persistence/RealEstateDbContext.cs | 59 ++++++ .../RealEstateAgency.Infrastructure.csproj | 19 ++ .../InMemoryCounterpartyRepository.cs | 69 +++++++ .../InMemoryRealEstatePropertyRepository.cs | 78 ++++++++ .../Repositories/InMemoryRequestRepository.cs | 92 +++++++++ .../MongoCounterpartyRepository.cs | 56 ++++++ .../MongoRealEstatePropertyRepository.cs | 63 +++++++ .../Repositories/MongoRequestRepository.cs | 58 ++++++ .../AnalyticsControllerTests.cs | 21 +-- .../CounterpartiesControllerTests.cs | 18 +- .../MongoAnalyticsTests.cs | 7 +- .../MongoCounterpartiesTests.cs | 7 +- .../MongoDbCollection.cs | 4 +- .../MongoDbWebApplicationFactory.cs | 28 ++- .../MongoPropertiesTests.cs | 9 +- .../MongoRequestsTests.cs | 11 +- .../PropertiesControllerTests.cs | 22 +-- .../RealEstateWebApplicationFactory.cs | 4 +- .../RequestsControllerTests.cs | 38 ++-- .../Controllers/AnalyticsController.cs | 40 ++-- .../Controllers/BaseCrudController.cs | 100 ++++++++++ .../Controllers/CounterpartiesController.cs | 92 +++++---- .../Controllers/PropertiesController.cs | 92 +++++---- .../Controllers/RequestsController.cs | 128 ++++++------- RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs | 67 ------- .../DTOs/CounterpartyDto.cs | 69 ------- .../DTOs/RealEstatePropertyDto.cs | 176 ------------------ RealEstateAgency.WebApi/DTOs/RequestDto.cs | 111 ----------- RealEstateAgency.WebApi/Program.cs | 32 ++-- .../RealEstateAgency.WebApi.csproj | 5 +- .../Repositories/IRepositories.cs | 39 ---- .../InMemoryCounterpartyRepository.cs | 71 ------- .../InMemoryRealEstatePropertyRepository.cs | 79 -------- .../Repositories/InMemoryRequestRepository.cs | 85 --------- .../MongoCounterpartyRepository.cs | 60 ------ .../MongoRealEstatePropertyRepository.cs | 66 ------- .../Repositories/MongoRequestRepository.cs | 68 ------- .../Services/AnalyticsService.cs | 156 ---------------- .../Services/DatabaseSeeder.cs | 109 ----------- RealEstateAgency.sln | 42 +++++ .../RealEstateQueriesTests.cs | 12 +- .../RealEstateTestFixture.cs | 86 ++++----- 76 files changed, 2161 insertions(+), 1482 deletions(-) rename {RealEstateAgency.WebApi => RealEstateAgency.Application}/Mapping/MappingProfile.cs (86%) create mode 100644 RealEstateAgency.Application/RealEstateAgency.Application.csproj create mode 100644 RealEstateAgency.Application/Services/AnalyticsService.cs create mode 100644 RealEstateAgency.Application/Services/CounterpartyService.cs create mode 100644 RealEstateAgency.Application/Services/RealEstatePropertyService.cs create mode 100644 RealEstateAgency.Application/Services/RequestService.cs create mode 100644 RealEstateAgency.Contracts/Dto/ClientWithMinAmountDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/CounterpartyDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/CreateCounterpartyDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/CreateRealEstatePropertyDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/CreateRequestDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/PropertyTypeStatisticsDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/RealEstatePropertyDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/RequestDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/Top5ClientsResultDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/TopClientDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/UpdateCounterpartyDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/UpdateRealEstatePropertyDto.cs create mode 100644 RealEstateAgency.Contracts/Dto/UpdateRequestDto.cs create mode 100644 RealEstateAgency.Contracts/Interfaces/IAnalyticsService.cs create mode 100644 RealEstateAgency.Contracts/Interfaces/IApplicationService.cs create mode 100644 RealEstateAgency.Contracts/Interfaces/ICounterpartyService.cs create mode 100644 RealEstateAgency.Contracts/Interfaces/IRealEstatePropertyService.cs create mode 100644 RealEstateAgency.Contracts/Interfaces/IRequestService.cs create mode 100644 RealEstateAgency.Contracts/RealEstateAgency.Contracts.csproj create mode 100644 RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs create mode 100644 RealEstateAgency.Domain/Interfaces/IRealEstatePropertyRepository.cs create mode 100644 RealEstateAgency.Domain/Interfaces/IRepository.cs create mode 100644 RealEstateAgency.Domain/Interfaces/IRequestRepository.cs create mode 100644 RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs create mode 100644 RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs create mode 100644 RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj create mode 100644 RealEstateAgency.Infrastructure/Repositories/InMemoryCounterpartyRepository.cs create mode 100644 RealEstateAgency.Infrastructure/Repositories/InMemoryRealEstatePropertyRepository.cs create mode 100644 RealEstateAgency.Infrastructure/Repositories/InMemoryRequestRepository.cs create mode 100644 RealEstateAgency.Infrastructure/Repositories/MongoCounterpartyRepository.cs create mode 100644 RealEstateAgency.Infrastructure/Repositories/MongoRealEstatePropertyRepository.cs create mode 100644 RealEstateAgency.Infrastructure/Repositories/MongoRequestRepository.cs create mode 100644 RealEstateAgency.WebApi/Controllers/BaseCrudController.cs delete mode 100644 RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs delete mode 100644 RealEstateAgency.WebApi/DTOs/CounterpartyDto.cs delete mode 100644 RealEstateAgency.WebApi/DTOs/RealEstatePropertyDto.cs delete mode 100644 RealEstateAgency.WebApi/DTOs/RequestDto.cs delete mode 100644 RealEstateAgency.WebApi/Repositories/IRepositories.cs delete mode 100644 RealEstateAgency.WebApi/Repositories/InMemoryCounterpartyRepository.cs delete mode 100644 RealEstateAgency.WebApi/Repositories/InMemoryRealEstatePropertyRepository.cs delete mode 100644 RealEstateAgency.WebApi/Repositories/InMemoryRequestRepository.cs delete mode 100644 RealEstateAgency.WebApi/Repositories/MongoCounterpartyRepository.cs delete mode 100644 RealEstateAgency.WebApi/Repositories/MongoRealEstatePropertyRepository.cs delete mode 100644 RealEstateAgency.WebApi/Repositories/MongoRequestRepository.cs delete mode 100644 RealEstateAgency.WebApi/Services/AnalyticsService.cs delete mode 100644 RealEstateAgency.WebApi/Services/DatabaseSeeder.cs diff --git a/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj index 2395d14b5..ecc71f0ba 100644 --- a/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj +++ b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj @@ -15,7 +15,8 @@ - + diff --git a/RealEstateAgency.WebApi/Mapping/MappingProfile.cs b/RealEstateAgency.Application/Mapping/MappingProfile.cs similarity index 86% rename from RealEstateAgency.WebApi/Mapping/MappingProfile.cs rename to RealEstateAgency.Application/Mapping/MappingProfile.cs index c203531b8..7c87dc167 100644 --- a/RealEstateAgency.WebApi/Mapping/MappingProfile.cs +++ b/RealEstateAgency.Application/Mapping/MappingProfile.cs @@ -1,8 +1,8 @@ using AutoMapper; +using RealEstateAgency.Contracts.Dto; using RealEstateAgency.Domain.Models; -using RealEstateAgency.WebApi.DTOs; -namespace RealEstateAgency.WebApi.Mapping; +namespace RealEstateAgency.Application.Mapping; ///

/// Профиль AutoMapper для маппинга сущностей и DTO @@ -11,21 +11,18 @@ public class MappingProfile : Profile { public MappingProfile() { - // Counterparty mappings CreateMap(); CreateMap() .ForMember(dest => dest.Id, opt => opt.Ignore()); CreateMap() .ForMember(dest => dest.Id, opt => opt.Ignore()); - // RealEstateProperty mappings CreateMap(); CreateMap() .ForMember(dest => dest.Id, opt => opt.Ignore()); CreateMap() .ForMember(dest => dest.Id, opt => opt.Ignore()); - // Request mappings 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..56b156484 --- /dev/null +++ b/RealEstateAgency.Application/RealEstateAgency.Application.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/RealEstateAgency.Application/Services/AnalyticsService.cs b/RealEstateAgency.Application/Services/AnalyticsService.cs new file mode 100644 index 000000000..e94c12d7a --- /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.Counterparty.Id, 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.Counterparty.Id) + .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.Counterparty.Id) + .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.Property.Id, out var p) ? p.Type : r.Property.Type + }) + .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.Counterparty.Id, 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.Property.Id, out var p) ? p.Type : r.Property.Type) == propertyType) + .Select(r => counterparties.TryGetValue(r.Counterparty.Id, 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..3751a1513 --- /dev/null +++ b/RealEstateAgency.Application/Services/RequestService.cs @@ -0,0 +1,91 @@ +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, + Counterparty = counterparty, + 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, + Counterparty = counterparty, + 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..7d484236c --- /dev/null +++ b/RealEstateAgency.Contracts/Dto/CreateRealEstatePropertyDto.cs @@ -0,0 +1,72 @@ +using RealEstateAgency.Domain.Enums; +using System.ComponentModel.DataAnnotations; + +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..f6361bc75 --- /dev/null +++ b/RealEstateAgency.Contracts/Dto/CreateRequestDto.cs @@ -0,0 +1,41 @@ +using RealEstateAgency.Domain.Enums; +using System.ComponentModel.DataAnnotations; + +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..2a0bc8a48 --- /dev/null +++ b/RealEstateAgency.Contracts/Dto/UpdateRealEstatePropertyDto.cs @@ -0,0 +1,72 @@ +using RealEstateAgency.Domain.Enums; +using System.ComponentModel.DataAnnotations; + +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..05f483741 --- /dev/null +++ b/RealEstateAgency.Contracts/Dto/UpdateRequestDto.cs @@ -0,0 +1,41 @@ +using RealEstateAgency.Domain.Enums; +using System.ComponentModel.DataAnnotations; + +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/Interfaces/ICounterpartyRepository.cs b/RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs new file mode 100644 index 000000000..0ed6cd14e --- /dev/null +++ b/RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs @@ -0,0 +1,10 @@ +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..b91358780 --- /dev/null +++ b/RealEstateAgency.Domain/Interfaces/IRealEstatePropertyRepository.cs @@ -0,0 +1,10 @@ +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..f6bdf0e83 --- /dev/null +++ b/RealEstateAgency.Domain/Interfaces/IRequestRepository.cs @@ -0,0 +1,10 @@ +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 index 9ab085202..fb68ef3e8 100644 --- a/RealEstateAgency.Domain/Models/Counterparty.cs +++ b/RealEstateAgency.Domain/Models/Counterparty.cs @@ -9,7 +9,7 @@ public class Counterparty /// /// The unique identifier of the counterparty /// - public required int Id { get; set; } + public Guid Id { get; set; } /// /// The counterparty's full name in the "Last Name, First Name, Patronymic" format diff --git a/RealEstateAgency.Domain/Models/RealEstateProperty.cs b/RealEstateAgency.Domain/Models/RealEstateProperty.cs index 872141580..cb8fa30e3 100644 --- a/RealEstateAgency.Domain/Models/RealEstateProperty.cs +++ b/RealEstateAgency.Domain/Models/RealEstateProperty.cs @@ -11,7 +11,7 @@ public class RealEstateProperty /// /// The unique identifier of the object /// - public required int Id { get; set; } + public Guid Id { get; set; } /// /// Property type diff --git a/RealEstateAgency.Domain/Models/Request.cs b/RealEstateAgency.Domain/Models/Request.cs index 2b21410bb..edad0379a 100644 --- a/RealEstateAgency.Domain/Models/Request.cs +++ b/RealEstateAgency.Domain/Models/Request.cs @@ -11,7 +11,7 @@ public class Request /// /// The unique identifier of the application /// - public required int Id { get; set; } + public Guid Id { get; set; } /// /// The counterparty who submitted the application diff --git a/RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs b/RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs new file mode 100644 index 000000000..99f71f85d --- /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"), 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.Infrastructure/Persistence/RealEstateDbContext.cs b/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs new file mode 100644 index 000000000..4397c0d4a --- /dev/null +++ b/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using MongoDB.EntityFrameworkCore.Extensions; +using RealEstateAgency.Domain.Models; + +namespace RealEstateAgency.Infrastructure.Persistence; + +/// +/// Контекст базы данных для работы с MongoDB через EF Core +/// +public class RealEstateDbContext : DbContext +{ + /// + /// Коллекция контрагентов + /// + public DbSet Counterparties { get; set; } = null!; + + /// + /// Коллекция объектов недвижимости + /// + public DbSet Properties { get; set; } = null!; + + /// + /// Коллекция заявок + /// + public DbSet Requests { get; set; } = null!; + + /// + /// Конструктор контекста + /// + public RealEstateDbContext(DbContextOptions options) : base(options) + { + } + + /// + /// Конфигурация моделей для 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); + }); + } +} diff --git a/RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj b/RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj new file mode 100644 index 000000000..df30da1f2 --- /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..aba6be9ac --- /dev/null +++ b/RealEstateAgency.Infrastructure/Repositories/InMemoryRequestRepository.cs @@ -0,0 +1,92 @@ +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"), Counterparty = counterparties[0], 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"), Counterparty = counterparties[1], 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"), Counterparty = counterparties[3], 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"), Counterparty = counterparties[6], 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"), Counterparty = counterparties[8], 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"), Counterparty = counterparties[10], 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"), Counterparty = counterparties[11], 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"), Counterparty = counterparties[2], 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"), Counterparty = counterparties[4], 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"), Counterparty = counterparties[5], 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"), Counterparty = counterparties[7], 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"), Counterparty = counterparties[9], 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"), Counterparty = counterparties[2], 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"), Counterparty = counterparties[1], 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"), Counterparty = counterparties[3], 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.Counterparty = request.Counterparty; + 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..a87713cae --- /dev/null +++ b/RealEstateAgency.Infrastructure/Repositories/MongoRequestRepository.cs @@ -0,0 +1,58 @@ +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() + { + return await context.Requests.ToListAsync(); + } + + public async Task GetByIdAsync(Guid id) + { + return await context.Requests.FirstOrDefaultAsync(r => r.Id == id); + } + + public async Task AddAsync(Request request) + { + request.Id = Guid.NewGuid(); + + 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.Counterparty = request.Counterparty; + existing.Property = request.Property; + existing.Type = request.Type; + existing.Amount = request.Amount; + existing.Date = request.Date; + + await context.SaveChangesAsync(); + 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; + } +} diff --git a/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs b/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs index 12ca06019..adaff3070 100644 --- a/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs +++ b/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs @@ -1,13 +1,11 @@ -using System.Net.Http.Json; +using RealEstateAgency.Contracts.Dto; using RealEstateAgency.Domain.Enums; -using RealEstateAgency.WebApi.DTOs; -using Xunit; +using System.Net.Http.Json; namespace RealEstateAgency.WebApi.Tests; /// /// -/// API Unit- /// public class AnalyticsControllerTests : IClassFixture { @@ -19,8 +17,7 @@ public AnalyticsControllerTests(RealEstateWebApplicationFactory factory) } /// - /// : " , " - /// RealEstateQueriesTests.GetSellersInPeriodReturnsCorrectSellers() + /// : /// [Fact] public async Task GetSellersInPeriod_ReturnsCorrectSellers() @@ -47,8 +44,7 @@ public async Task GetSellersInPeriod_ReturnsCorrectSellers() } /// - /// : " -5 ( /)" - /// RealEstateQueriesTests.Top5ClientsByRequestCountReturnsSeparateTop5() + /// : -5 /// [Fact] public async Task GetTop5Clients_ReturnsCorrectTop5() @@ -87,8 +83,7 @@ public async Task GetTop5Clients_ReturnsCorrectTop5() } /// - /// : " " - /// RealEstateQueriesTests.RequestCountByPropertyTypeReturnsCorrectStatistics() + /// : /// [Fact] public async Task GetPropertyTypeStatistics_ReturnsCorrectStatistics() @@ -120,8 +115,7 @@ public async Task GetPropertyTypeStatistics_ReturnsCorrectStatistics() } /// - /// : " , " - /// RealEstateQueriesTests.ClientsWithMinAmountAreFoundCorrectly() + /// : /// [Fact] public async Task GetClientsWithMinAmount_ReturnsCorrectClients() @@ -141,8 +135,7 @@ public async Task GetClientsWithMinAmount_ReturnsCorrectClients() } /// - /// : " , " - /// RealEstateQueriesTests.ClientsSeekingPropertyTypeAreReturnedOrdered() + /// : , /// [Fact] public async Task GetClientsByPropertyType_Apartment_ReturnsCorrectClients() diff --git a/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs b/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs index e0ae37052..a53ec8b54 100644 --- a/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs +++ b/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs @@ -1,7 +1,6 @@ -using System.Net; +using RealEstateAgency.Contracts.Dto; +using System.Net; using System.Net.Http.Json; -using RealEstateAgency.WebApi.DTOs; -using Xunit; namespace RealEstateAgency.WebApi.Tests; @@ -11,6 +10,7 @@ namespace RealEstateAgency.WebApi.Tests; public class CounterpartiesControllerTests : IClassFixture { private readonly HttpClient _client; + private static readonly Guid _testCounterpartyId = Guid.Parse("00000000-0000-0000-0000-000000000001"); public CounterpartiesControllerTests(RealEstateWebApplicationFactory factory) { @@ -30,7 +30,7 @@ public async Task GetAll_ReturnsAllCounterparties() RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(counterparties); - Assert.True(counterparties.Count >= 12); + Assert.True(counterparties.Count >= 12); } /// @@ -39,14 +39,14 @@ public async Task GetAll_ReturnsAllCounterparties() [Fact] public async Task GetById_ExistingId_ReturnsCounterparty() { - var response = await _client.GetAsync("/api/counterparties/1"); + var response = await _client.GetAsync($"/api/counterparties/{_testCounterpartyId}"); response.EnsureSuccessStatusCode(); var counterparty = await response.Content.ReadFromJsonAsync( RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(counterparty); - Assert.Equal(1, counterparty.Id); + Assert.Equal(_testCounterpartyId, counterparty.Id); Assert.Equal("Иванов Иван Иванович", counterparty.FullName); } @@ -56,7 +56,7 @@ public async Task GetById_ExistingId_ReturnsCounterparty() [Fact] public async Task GetById_NonExistingId_ReturnsNotFound() { - var response = await _client.GetAsync("/api/counterparties/999"); + var response = await _client.GetAsync($"/api/counterparties/{Guid.NewGuid()}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -81,7 +81,7 @@ public async Task Create_ValidData_ReturnsCreatedCounterparty() RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(created); - Assert.True(created.Id > 0); + Assert.NotEqual(Guid.Empty, created.Id); Assert.Equal("Тестов Тест Тестович", created.FullName); } @@ -98,7 +98,7 @@ public async Task Update_ExistingId_ReturnsUpdatedCounterparty() PhoneNumber = "+7-999-111-22-33" }; - var response = await _client.PutAsJsonAsync("/api/counterparties/1", updateData); + var response = await _client.PutAsJsonAsync($"/api/counterparties/{_testCounterpartyId}", updateData); response.EnsureSuccessStatusCode(); var updated = await response.Content.ReadFromJsonAsync( diff --git a/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs b/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs index b9864c765..8f72cde83 100644 --- a/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs +++ b/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs @@ -1,7 +1,6 @@ -using System.Net.Http.Json; +using RealEstateAgency.Contracts.Dto; using RealEstateAgency.Domain.Enums; -using RealEstateAgency.WebApi.DTOs; -using Xunit; +using System.Net.Http.Json; namespace RealEstateAgency.WebApi.Tests; @@ -52,7 +51,7 @@ public async Task GetSellersInPeriod_WorksWithMongoDB() PropertyId = createdProperty!.Id, Type = RequestType.Sale, Amount = 8000000.00m, - Date = new DateTime(2024, 5, 15) + Date = new DateTime(2024, 5, 15) }; await _client.PostAsJsonAsync("/api/requests", saleRequest); diff --git a/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs b/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs index 679744364..b1924a2e0 100644 --- a/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs +++ b/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs @@ -1,7 +1,6 @@ -using System.Net; +using RealEstateAgency.Contracts.Dto; +using System.Net; using System.Net.Http.Json; -using RealEstateAgency.WebApi.DTOs; -using Xunit; namespace RealEstateAgency.WebApi.Tests; @@ -37,7 +36,7 @@ public async Task Counterparty_FullCrudCycle_WorksWithMongoDB() var created = await createResponse.Content.ReadFromJsonAsync( RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(created); - Assert.True(created.Id > 0); + Assert.NotEqual(Guid.Empty, created.Id); Assert.Equal("Тестов Тест Тестович (MongoDB)", created.FullName); var createdId = created.Id; diff --git a/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs b/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs index 2f7f0a2b7..b0907f267 100644 --- a/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs +++ b/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs @@ -1,6 +1,4 @@ -using Xunit; - -namespace RealEstateAgency.WebApi.Tests; +namespace RealEstateAgency.WebApi.Tests; /// /// Определение коллекции для MongoDB тестов diff --git a/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs b/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs index 252c67656..a5891cc51 100644 --- a/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs +++ b/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs @@ -1,10 +1,12 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; -using RealEstateAgency.WebApi.Repositories; +using RealEstateAgency.Domain.Interfaces; +using RealEstateAgency.Infrastructure.Persistence; +using RealEstateAgency.Infrastructure.Repositories; using Testcontainers.MongoDb; -using Xunit; namespace RealEstateAgency.WebApi.Tests; @@ -23,12 +25,15 @@ 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(IMongoDatabase) || + d.ServiceType == typeof(RealEstateDbContext) || + d.ServiceType == typeof(DbContextOptions)) .ToList(); foreach (var descriptor in descriptorsToRemove) @@ -36,15 +41,20 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.Remove(descriptor); } + // Регистрируем MongoDB клиент var mongoClient = new MongoClient(ConnectionString); - var database = mongoClient.GetDatabase("realestatedb_test"); - services.AddSingleton(mongoClient); - services.AddSingleton(database); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // Регистрируем DbContext с MongoDB провайдером + services.AddDbContext(options => + { + options.UseMongoDB(mongoClient, "realestatedb_test"); + }); + + // Регистрируем репозитории + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); }); builder.UseEnvironment("Testing"); diff --git a/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs b/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs index 69408922e..5c65ff1b8 100644 --- a/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs +++ b/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs @@ -1,8 +1,7 @@ -using System.Net; -using System.Net.Http.Json; +using RealEstateAgency.Contracts.Dto; using RealEstateAgency.Domain.Enums; -using RealEstateAgency.WebApi.DTOs; -using Xunit; +using System.Net; +using System.Net.Http.Json; namespace RealEstateAgency.WebApi.Tests; @@ -45,7 +44,7 @@ public async Task Property_FullCrudCycle_WorksWithMongoDB() var created = await createResponse.Content.ReadFromJsonAsync( RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(created); - Assert.True(created.Id > 0); + Assert.NotEqual(Guid.Empty, created.Id); Assert.Equal("ул. MongoDB, д. 1, кв. 1", created.Address); Assert.Equal(PropertyType.Apartment, created.Type); diff --git a/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs b/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs index 63166bc72..cd554a326 100644 --- a/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs +++ b/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs @@ -1,8 +1,7 @@ -using System.Net; -using System.Net.Http.Json; +using RealEstateAgency.Contracts.Dto; using RealEstateAgency.Domain.Enums; -using RealEstateAgency.WebApi.DTOs; -using Xunit; +using System.Net; +using System.Net.Http.Json; namespace RealEstateAgency.WebApi.Tests; @@ -62,7 +61,7 @@ public async Task Request_FullCrudCycle_WorksWithMongoDB() var created = await createResponse.Content.ReadFromJsonAsync( RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(created); - Assert.True(created.Id > 0); + Assert.NotEqual(Guid.Empty, created.Id); Assert.Equal(5000000.00m, created.Amount); Assert.Equal(RequestType.Sale, created.Type); @@ -170,7 +169,7 @@ public async Task Create_NonExistingCounterparty_ReturnsNotFound() var request = new CreateRequestDto { - CounterpartyId = 99999, + CounterpartyId = Guid.NewGuid(), PropertyId = createdProperty!.Id, Type = RequestType.Sale, Amount = 1000000.00m, diff --git a/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs b/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs index e9b1ad56f..0590834bf 100644 --- a/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs +++ b/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs @@ -1,8 +1,7 @@ -using System.Net; -using System.Net.Http.Json; +using RealEstateAgency.Contracts.Dto; using RealEstateAgency.Domain.Enums; -using RealEstateAgency.WebApi.DTOs; -using Xunit; +using System.Net; +using System.Net.Http.Json; namespace RealEstateAgency.WebApi.Tests; @@ -12,6 +11,7 @@ namespace RealEstateAgency.WebApi.Tests; public class PropertiesControllerTests : IClassFixture { private readonly HttpClient _client; + private static readonly Guid _testPropertyId = Guid.Parse("10000000-0000-0000-0000-000000000001"); public PropertiesControllerTests(RealEstateWebApplicationFactory factory) { @@ -31,7 +31,7 @@ public async Task GetAll_ReturnsAllProperties() RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(properties); - Assert.True(properties.Count >= 13); + Assert.True(properties.Count >= 13); } /// @@ -40,16 +40,16 @@ public async Task GetAll_ReturnsAllProperties() [Fact] public async Task GetById_ExistingId_ReturnsProperty() { - var response = await _client.GetAsync("/api/properties/1"); + var response = await _client.GetAsync($"/api/properties/{_testPropertyId}"); response.EnsureSuccessStatusCode(); var property = await response.Content.ReadFromJsonAsync( RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(property); - Assert.Equal(1, property.Id); + Assert.Equal(_testPropertyId, property.Id); Assert.Equal(PropertyType.Apartment, property.Type); - Assert.Contains("ул. Тверская, 15, кв. 34", property.Address); + Assert.Contains("ул. Тверская, 15, кв. 34", property.Address); } /// @@ -58,7 +58,7 @@ public async Task GetById_ExistingId_ReturnsProperty() [Fact] public async Task GetById_NonExistingId_ReturnsNotFound() { - var response = await _client.GetAsync("/api/properties/999"); + var response = await _client.GetAsync($"/api/properties/{Guid.NewGuid()}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -90,7 +90,7 @@ public async Task Create_ValidData_ReturnsCreatedProperty() RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(created); - Assert.True(created.Id > 0); + Assert.NotEqual(Guid.Empty, created.Id); Assert.Equal("ул. Тестовая, 1, кв. 1", created.Address); } @@ -114,7 +114,7 @@ public async Task Update_ExistingId_ReturnsUpdatedProperty() HasEncumbrances = false }; - var response = await _client.PutAsJsonAsync("/api/properties/1", updateData); + var response = await _client.PutAsJsonAsync($"/api/properties/{_testPropertyId}", updateData); response.EnsureSuccessStatusCode(); var updated = await response.Content.ReadFromJsonAsync( diff --git a/RealEstateAgency.WebApi.Tests/RealEstateWebApplicationFactory.cs b/RealEstateAgency.WebApi.Tests/RealEstateWebApplicationFactory.cs index 06167ee49..ad1f10d18 100644 --- a/RealEstateAgency.WebApi.Tests/RealEstateWebApplicationFactory.cs +++ b/RealEstateAgency.WebApi.Tests/RealEstateWebApplicationFactory.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.VisualStudio.TestPlatform.TestHost; -using RealEstateAgency.WebApi.Repositories; +using RealEstateAgency.Domain.Interfaces; +using RealEstateAgency.Infrastructure.Repositories; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs b/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs index 1bd4f77bb..b680194ba 100644 --- a/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs +++ b/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs @@ -1,8 +1,7 @@ -using System.Net; -using System.Net.Http.Json; +using RealEstateAgency.Contracts.Dto; using RealEstateAgency.Domain.Enums; -using RealEstateAgency.WebApi.DTOs; -using Xunit; +using System.Net; +using System.Net.Http.Json; namespace RealEstateAgency.WebApi.Tests; @@ -12,6 +11,9 @@ namespace RealEstateAgency.WebApi.Tests; public class RequestsControllerTests : IClassFixture { private readonly HttpClient _client; + 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"); public RequestsControllerTests(RealEstateWebApplicationFactory factory) { @@ -31,7 +33,7 @@ public async Task GetAll_ReturnsAllRequests() RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(requests); - Assert.True(requests.Count >= 15); + Assert.True(requests.Count >= 15); } /// @@ -40,14 +42,14 @@ public async Task GetAll_ReturnsAllRequests() [Fact] public async Task GetById_ExistingId_ReturnsRequest() { - var response = await _client.GetAsync("/api/requests/1"); + var response = await _client.GetAsync($"/api/requests/{_testRequestId}"); response.EnsureSuccessStatusCode(); var request = await response.Content.ReadFromJsonAsync( RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(request); - Assert.Equal(1, request.Id); + Assert.Equal(_testRequestId, request.Id); Assert.Equal(RequestType.Sale, request.Type); Assert.Equal(25000000.00m, request.Amount); } @@ -58,7 +60,7 @@ public async Task GetById_ExistingId_ReturnsRequest() [Fact] public async Task GetById_NonExistingId_ReturnsNotFound() { - var response = await _client.GetAsync("/api/requests/999"); + var response = await _client.GetAsync($"/api/requests/{Guid.NewGuid()}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -71,8 +73,8 @@ public async Task Create_ValidData_ReturnsCreatedRequest() { var newRequest = new CreateRequestDto { - CounterpartyId = 1, - PropertyId = 1, + CounterpartyId = _testCounterpartyId, + PropertyId = _testPropertyId, Type = RequestType.Purchase, Amount = 30000000.00m, Date = new DateTime(2024, 10, 15) @@ -85,7 +87,7 @@ public async Task Create_ValidData_ReturnsCreatedRequest() RealEstateWebApplicationFactory.JsonOptions); Assert.NotNull(created); - Assert.True(created.Id > 0); + Assert.NotEqual(Guid.Empty, created.Id); Assert.Equal(30000000.00m, created.Amount); } @@ -97,8 +99,8 @@ public async Task Create_InvalidCounterpartyId_ReturnsNotFound() { var newRequest = new CreateRequestDto { - CounterpartyId = 999, - PropertyId = 1, + CounterpartyId = Guid.NewGuid(), + PropertyId = _testPropertyId, Type = RequestType.Sale, Amount = 1000000.00m, Date = DateTime.Now @@ -117,14 +119,14 @@ public async Task Update_ExistingId_ReturnsUpdatedRequest() { var updateData = new UpdateRequestDto { - CounterpartyId = 1, - PropertyId = 1, + CounterpartyId = _testCounterpartyId, + PropertyId = _testPropertyId, Type = RequestType.Sale, Amount = 26000000.00m, Date = new DateTime(2024, 1, 15) }; - var response = await _client.PutAsJsonAsync("/api/requests/1", updateData); + var response = await _client.PutAsJsonAsync($"/api/requests/{_testRequestId}", updateData); response.EnsureSuccessStatusCode(); var updated = await response.Content.ReadFromJsonAsync( @@ -142,8 +144,8 @@ public async Task Delete_ExistingId_ReturnsNoContent() { var newRequest = new CreateRequestDto { - CounterpartyId = 1, - PropertyId = 1, + CounterpartyId = _testCounterpartyId, + PropertyId = _testPropertyId, Type = RequestType.Purchase, Amount = 100.00m, Date = DateTime.Now diff --git a/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs index 27872e29d..d07335321 100644 --- a/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs +++ b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Contracts.Interfaces; using RealEstateAgency.Domain.Enums; -using RealEstateAgency.WebApi.DTOs; -using RealEstateAgency.WebApi.Services; namespace RealEstateAgency.WebApi.Controllers; @@ -10,15 +10,8 @@ namespace RealEstateAgency.WebApi.Controllers; /// [ApiController] [Route("api/[controller]")] -public class AnalyticsController : ControllerBase +public class AnalyticsController(IAnalyticsService analyticsService, ILogger logger) : ControllerBase { - private readonly IAnalyticsService _analyticsService; - - public AnalyticsController(IAnalyticsService analyticsService) - { - _analyticsService = analyticsService; - } - /// /// Получить продавцов за указанный период /// @@ -27,11 +20,13 @@ public AnalyticsController(IAnalyticsService analyticsService) /// Список ФИО продавцов [HttpGet("sellers")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public ActionResult> GetSellersInPeriod( + public async Task>> GetSellersInPeriod( [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { - var sellers = _analyticsService.GetSellersInPeriod(startDate, endDate); + logger.LogInformation("Запрос продавцов за период {StartDate} - {EndDate}", startDate, endDate); + var sellers = await analyticsService.GetSellersInPeriodAsync(startDate, endDate); + logger.LogInformation("Найдено {Count} продавцов", sellers.Count()); return Ok(sellers); } @@ -41,9 +36,10 @@ public ActionResult> GetSellersInPeriod( /// Топ-5 покупателей и топ-5 продавцов [HttpGet("top-clients")] [ProducesResponseType(typeof(Top5ClientsResultDto), StatusCodes.Status200OK)] - public ActionResult GetTop5Clients() + public async Task> GetTop5Clients() { - var result = _analyticsService.GetTop5ClientsByRequestCount(); + logger.LogInformation("Запрос топ-5 клиентов"); + var result = await analyticsService.GetTop5ClientsByRequestCountAsync(); return Ok(result); } @@ -53,9 +49,10 @@ public ActionResult GetTop5Clients() /// Количество заявок по каждому типу недвижимости [HttpGet("property-type-statistics")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public ActionResult> GetPropertyTypeStatistics() + public async Task>> GetPropertyTypeStatistics() { - var statistics = _analyticsService.GetRequestCountByPropertyType(); + logger.LogInformation("Запрос статистики по типам недвижимости"); + var statistics = await analyticsService.GetRequestCountByPropertyTypeAsync(); return Ok(statistics); } @@ -65,9 +62,10 @@ public ActionResult> GetPropertyTypeStati /// Информация о клиентах с минимальной суммой [HttpGet("min-amount-clients")] [ProducesResponseType(typeof(ClientWithMinAmountDto), StatusCodes.Status200OK)] - public ActionResult GetClientsWithMinAmount() + public async Task> GetClientsWithMinAmount() { - var result = _analyticsService.GetClientsWithMinAmount(); + logger.LogInformation("Запрос клиентов с минимальной суммой заявки"); + var result = await analyticsService.GetClientsWithMinAmountAsync(); return Ok(result); } @@ -78,10 +76,12 @@ public ActionResult GetClientsWithMinAmount() /// Список ФИО клиентов [HttpGet("clients-by-property-type")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public ActionResult> GetClientsByPropertyType( + public async Task>> GetClientsByPropertyType( [FromQuery] PropertyType propertyType) { - var clients = _analyticsService.GetClientsSeekingPropertyType(propertyType); + logger.LogInformation("Запрос клиентов, ищущих недвижимость типа {PropertyType}", propertyType); + var clients = await analyticsService.GetClientsSeekingPropertyTypeAsync(propertyType); + logger.LogInformation("Найдено {Count} клиентов", clients.Count()); return Ok(clients); } } diff --git a/RealEstateAgency.WebApi/Controllers/BaseCrudController.cs b/RealEstateAgency.WebApi/Controllers/BaseCrudController.cs new file mode 100644 index 000000000..52eadd3c0 --- /dev/null +++ b/RealEstateAgency.WebApi/Controllers/BaseCrudController.cs @@ -0,0 +1,100 @@ +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)] + public virtual async Task>> GetAll() + { + Logger.LogInformation("Запрос на получение всех сущностей"); + var entities = await Service.GetAllAsync(); + Logger.LogInformation("Возвращено {Count} сущностей", entities.Count()); + return Ok(entities); + } + + /// + /// Получить сущность по идентификатору + /// + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public virtual async Task> GetById(Guid id) + { + 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); + } + + /// + /// Создать новую сущность + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public virtual async Task> Create([FromBody] TCreateDto dto) + { + 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); + } + + /// + /// Удалить сущность + /// + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public virtual async Task Delete(Guid id) + { + 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(); + } + + /// + /// Получить идентификатор сущности из DTO + /// + protected abstract Guid GetEntityId(TDto entity); +} diff --git a/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs b/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs index 2a337c166..8bb2fe929 100644 --- a/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs +++ b/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs @@ -1,26 +1,19 @@ -using AutoMapper; -using Microsoft.AspNetCore.Mvc; -using RealEstateAgency.Domain.Models; -using RealEstateAgency.WebApi.DTOs; -using RealEstateAgency.WebApi.Repositories; +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Contracts.Interfaces; namespace RealEstateAgency.WebApi.Controllers; /// /// Контроллер для работы с контрагентами /// -[ApiController] -[Route("api/[controller]")] -public class CounterpartiesController : ControllerBase +public class CounterpartiesController( + ICounterpartyService service, + ILogger logger) + : BaseCrudController(service, logger) { - private readonly ICounterpartyRepository _repository; - private readonly IMapper _mapper; - - public CounterpartiesController(ICounterpartyRepository repository, IMapper mapper) - { - _repository = repository; - _mapper = mapper; - } + /// + protected override Guid GetEntityId(CounterpartyDto entity) => entity.Id; /// /// Получить всех контрагентов @@ -28,10 +21,12 @@ public CounterpartiesController(ICounterpartyRepository repository, IMapper mapp /// Список контрагентов [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public ActionResult> GetAll() + public override async Task>> GetAll() { - var counterparties = _repository.GetAll(); - return Ok(_mapper.Map>(counterparties)); + Logger.LogInformation("Запрос на получение всех контрагентов"); + var counterparties = await Service.GetAllAsync(); + Logger.LogInformation("Возвращено {Count} контрагентов", counterparties.Count()); + return Ok(counterparties); } /// @@ -39,16 +34,20 @@ public ActionResult> GetAll() /// /// Идентификатор контрагента /// Контрагент - [HttpGet("{id:int}")] + [HttpGet("{id:guid}")] [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetById(int id) + public override async Task> GetById(Guid id) { - var counterparty = _repository.GetById(id); + 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(_mapper.Map(counterparty)); + return Ok(counterparty); } /// @@ -59,13 +58,19 @@ public ActionResult GetById(int id) [HttpPost] [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public ActionResult Create([FromBody] CreateCounterpartyDto dto) + public override async Task> Create([FromBody] CreateCounterpartyDto dto) { - var counterparty = _mapper.Map(dto); - var created = _repository.Add(counterparty); - var resultDto = _mapper.Map(created); + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при создании контрагента: {Errors}", ModelState); + return BadRequest(ModelState); + } - return CreatedAtAction(nameof(GetById), new { id = resultDto.Id }, resultDto); + 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); } /// @@ -74,19 +79,29 @@ public ActionResult Create([FromBody] CreateCounterpartyDto dto /// Идентификатор контрагента /// Новые данные контрагента /// Результат операции - [HttpPut("{id:int}")] + [HttpPut("{id:guid}")] [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public ActionResult Update(int id, [FromBody] UpdateCounterpartyDto dto) + public async Task> Update(Guid id, [FromBody] UpdateCounterpartyDto dto) { - var counterparty = _mapper.Map(dto); - var updated = _repository.Update(id, counterparty); + 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} не найден"); + } - return Ok(_mapper.Map(updated)); + Logger.LogInformation("Контрагент с ID {Id} успешно обновлен", id); + return Ok(updated); } /// @@ -94,15 +109,20 @@ public ActionResult Update(int id, [FromBody] UpdateCounterpart /// /// Идентификатор контрагента /// Результат операции - [HttpDelete("{id:int}")] + [HttpDelete("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult Delete(int id) + public override async Task Delete(Guid id) { - var deleted = _repository.Delete(id); + 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(); } } diff --git a/RealEstateAgency.WebApi/Controllers/PropertiesController.cs b/RealEstateAgency.WebApi/Controllers/PropertiesController.cs index 322b35cee..a12846df7 100644 --- a/RealEstateAgency.WebApi/Controllers/PropertiesController.cs +++ b/RealEstateAgency.WebApi/Controllers/PropertiesController.cs @@ -1,26 +1,19 @@ -using AutoMapper; -using Microsoft.AspNetCore.Mvc; -using RealEstateAgency.Domain.Models; -using RealEstateAgency.WebApi.DTOs; -using RealEstateAgency.WebApi.Repositories; +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Contracts.Interfaces; namespace RealEstateAgency.WebApi.Controllers; /// /// Контроллер для работы с объектами недвижимости /// -[ApiController] -[Route("api/[controller]")] -public class PropertiesController : ControllerBase +public class PropertiesController( + IRealEstatePropertyService service, + ILogger logger) + : BaseCrudController(service, logger) { - private readonly IRealEstatePropertyRepository _repository; - private readonly IMapper _mapper; - - public PropertiesController(IRealEstatePropertyRepository repository, IMapper mapper) - { - _repository = repository; - _mapper = mapper; - } + /// + protected override Guid GetEntityId(RealEstatePropertyDto entity) => entity.Id; /// /// Получить все объекты недвижимости @@ -28,10 +21,12 @@ public PropertiesController(IRealEstatePropertyRepository repository, IMapper ma /// Список объектов недвижимости [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public ActionResult> GetAll() + public override async Task>> GetAll() { - var properties = _repository.GetAll(); - return Ok(_mapper.Map>(properties)); + Logger.LogInformation("Запрос на получение всех объектов недвижимости"); + var properties = await Service.GetAllAsync(); + Logger.LogInformation("Возвращено {Count} объектов недвижимости", properties.Count()); + return Ok(properties); } /// @@ -39,16 +34,20 @@ public ActionResult> GetAll() /// /// Идентификатор объекта /// Объект недвижимости - [HttpGet("{id:int}")] + [HttpGet("{id:guid}")] [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetById(int id) + public override async Task> GetById(Guid id) { - var property = _repository.GetById(id); + 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(_mapper.Map(property)); + return Ok(property); } /// @@ -59,13 +58,19 @@ public ActionResult GetById(int id) [HttpPost] [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public ActionResult Create([FromBody] CreateRealEstatePropertyDto dto) + public override async Task> Create([FromBody] CreateRealEstatePropertyDto dto) { - var property = _mapper.Map(dto); - var created = _repository.Add(property); - var resultDto = _mapper.Map(created); + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при создании объекта недвижимости: {Errors}", ModelState); + return BadRequest(ModelState); + } - return CreatedAtAction(nameof(GetById), new { id = resultDto.Id }, resultDto); + 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); } /// @@ -74,19 +79,29 @@ public ActionResult Create([FromBody] CreateRealEstatePro /// Идентификатор объекта /// Новые данные объекта /// Результат операции - [HttpPut("{id:int}")] + [HttpPut("{id:guid}")] [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public ActionResult Update(int id, [FromBody] UpdateRealEstatePropertyDto dto) + public async Task> Update(Guid id, [FromBody] UpdateRealEstatePropertyDto dto) { - var property = _mapper.Map(dto); - var updated = _repository.Update(id, property); + 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} не найден"); + } - return Ok(_mapper.Map(updated)); + Logger.LogInformation("Объект недвижимости с ID {Id} успешно обновлен", id); + return Ok(updated); } /// @@ -94,15 +109,20 @@ public ActionResult Update(int id, [FromBody] UpdateRealE /// /// Идентификатор объекта /// Результат операции - [HttpDelete("{id:int}")] + [HttpDelete("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult Delete(int id) + public override async Task Delete(Guid id) { - var deleted = _repository.Delete(id); + 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(); } } diff --git a/RealEstateAgency.WebApi/Controllers/RequestsController.cs b/RealEstateAgency.WebApi/Controllers/RequestsController.cs index 0b50b8900..586f4c05e 100644 --- a/RealEstateAgency.WebApi/Controllers/RequestsController.cs +++ b/RealEstateAgency.WebApi/Controllers/RequestsController.cs @@ -1,8 +1,6 @@ -using AutoMapper; -using Microsoft.AspNetCore.Mvc; -using RealEstateAgency.Domain.Models; -using RealEstateAgency.WebApi.DTOs; -using RealEstateAgency.WebApi.Repositories; +using Microsoft.AspNetCore.Mvc; +using RealEstateAgency.Contracts.Dto; +using RealEstateAgency.Contracts.Interfaces; namespace RealEstateAgency.WebApi.Controllers; @@ -11,35 +9,20 @@ namespace RealEstateAgency.WebApi.Controllers; /// [ApiController] [Route("api/[controller]")] -public class RequestsController : ControllerBase +public class RequestsController(IRequestService service, ILogger logger) : ControllerBase { - private readonly IRequestRepository _requestRepository; - private readonly ICounterpartyRepository _counterpartyRepository; - private readonly IRealEstatePropertyRepository _propertyRepository; - private readonly IMapper _mapper; - - public RequestsController( - IRequestRepository requestRepository, - ICounterpartyRepository counterpartyRepository, - IRealEstatePropertyRepository propertyRepository, - IMapper mapper) - { - _requestRepository = requestRepository; - _counterpartyRepository = counterpartyRepository; - _propertyRepository = propertyRepository; - _mapper = mapper; - } - /// /// Получить все заявки /// /// Список заявок [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public ActionResult> GetAll() + public async Task>> GetAll() { - var requests = _requestRepository.GetAll(); - return Ok(_mapper.Map>(requests)); + logger.LogInformation("Запрос на получение всех заявок"); + var requests = await service.GetAllAsync(); + logger.LogInformation("Возвращено {Count} заявок", requests.Count()); + return Ok(requests); } /// @@ -47,16 +30,20 @@ public ActionResult> GetAll() /// /// Идентификатор заявки /// Заявка - [HttpGet("{id:int}")] + [HttpGet("{id:guid}")] [ProducesResponseType(typeof(RequestDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult GetById(int id) + public async Task> GetById(Guid id) { - var request = _requestRepository.GetById(id); + 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(_mapper.Map(request)); + return Ok(request); } /// @@ -68,30 +55,25 @@ public ActionResult GetById(int id) [ProducesResponseType(typeof(RequestDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult Create([FromBody] CreateRequestDto dto) + public async Task> Create([FromBody] CreateRequestDto dto) { - var counterparty = _counterpartyRepository.GetById(dto.CounterpartyId); - if (counterparty == null) - return NotFound($"Контрагент с ID {dto.CounterpartyId} не найден"); + if (!ModelState.IsValid) + { + logger.LogWarning("Ошибка валидации при создании заявки: {Errors}", ModelState); + return BadRequest(ModelState); + } - var property = _propertyRepository.GetById(dto.PropertyId); - if (property == null) - return NotFound($"Объект недвижимости с ID {dto.PropertyId} не найден"); + logger.LogInformation("Создание заявки для контрагента {CounterpartyId}", dto.CounterpartyId); + var (result, error) = await service.CreateAsync(dto); - var request = new Request + if (result == null) { - Id = 0, - Counterparty = counterparty, - Property = property, - Type = dto.Type, - Amount = dto.Amount, - Date = dto.Date - }; + logger.LogWarning("Ошибка при создании заявки: {Error}", error); + return NotFound(error); + } - var created = _requestRepository.Add(request); - var resultDto = _mapper.Map(created); - - return CreatedAtAction(nameof(GetById), new { id = resultDto.Id }, resultDto); + logger.LogInformation("Заявка создана с ID {Id}", result.Id); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); } /// @@ -100,36 +82,29 @@ public ActionResult Create([FromBody] CreateRequestDto dto) /// Идентификатор заявки /// Новые данные заявки /// Результат операции - [HttpPut("{id:int}")] + [HttpPut("{id:guid}")] [ProducesResponseType(typeof(RequestDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public ActionResult Update(int id, [FromBody] UpdateRequestDto dto) + public async Task> Update(Guid id, [FromBody] UpdateRequestDto dto) { - var existingRequest = _requestRepository.GetById(id); - if (existingRequest == null) - return NotFound($"Заявка с ID {id} не найдена"); - - var counterparty = _counterpartyRepository.GetById(dto.CounterpartyId); - if (counterparty == null) - return NotFound($"Контрагент с ID {dto.CounterpartyId} не найден"); + if (!ModelState.IsValid) + { + logger.LogWarning("Ошибка валидации при обновлении заявки {Id}: {Errors}", id, ModelState); + return BadRequest(ModelState); + } - var property = _propertyRepository.GetById(dto.PropertyId); - if (property == null) - return NotFound($"Объект недвижимости с ID {dto.PropertyId} не найден"); + logger.LogInformation("Обновление заявки с ID {Id}", id); + var (result, error) = await service.UpdateAsync(id, dto); - var request = new Request + if (result == null) { - Id = id, - Counterparty = counterparty, - Property = property, - Type = dto.Type, - Amount = dto.Amount, - Date = dto.Date - }; + logger.LogWarning("Ошибка при обновлении заявки {Id}: {Error}", id, error); + return NotFound(error); + } - var updated = _requestRepository.Update(id, request); - return Ok(_mapper.Map(updated)); + logger.LogInformation("Заявка с ID {Id} успешно обновлена", id); + return Ok(result); } /// @@ -137,15 +112,20 @@ public ActionResult Update(int id, [FromBody] UpdateRequestDto dto) /// /// Идентификатор заявки /// Результат операции - [HttpDelete("{id:int}")] + [HttpDelete("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public IActionResult Delete(int id) + public async Task Delete(Guid id) { - var deleted = _requestRepository.Delete(id); + 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(); } } diff --git a/RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs b/RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs deleted file mode 100644 index 9cf69907b..000000000 --- a/RealEstateAgency.WebApi/DTOs/AnalyticsDto.cs +++ /dev/null @@ -1,67 +0,0 @@ -using RealEstateAgency.Domain.Enums; - -namespace RealEstateAgency.WebApi.DTOs; - -/// -/// DTO для статистики по типу недвижимости -/// -public class PropertyTypeStatisticsDto -{ - /// - /// Тип недвижимости - /// - public PropertyType PropertyType { get; set; } - - /// - /// Количество заявок - /// - public int RequestCount { get; set; } -} - -/// -/// DTO для топ клиентов по количеству заявок -/// -public class TopClientDto -{ - /// - /// ФИО клиента - /// - public required string FullName { get; set; } - - /// - /// Количество заявок - /// - public int RequestCount { get; set; } -} - -/// -/// DTO для клиента с минимальной суммой заявки -/// -public class ClientWithMinAmountDto -{ - /// - /// ФИО клиента - /// - public required string FullName { get; set; } - - /// - /// Минимальная сумма - /// - public decimal MinAmount { get; set; } -} - -/// -/// DTO результата топ-5 клиентов (покупка и продажа) -/// -public class Top5ClientsResultDto -{ - /// - /// Топ-5 покупателей - /// - public List TopPurchaseClients { get; set; } = []; - - /// - /// Топ-5 продавцов - /// - public List TopSaleClients { get; set; } = []; -} diff --git a/RealEstateAgency.WebApi/DTOs/CounterpartyDto.cs b/RealEstateAgency.WebApi/DTOs/CounterpartyDto.cs deleted file mode 100644 index f485c21cd..000000000 --- a/RealEstateAgency.WebApi/DTOs/CounterpartyDto.cs +++ /dev/null @@ -1,69 +0,0 @@ -namespace RealEstateAgency.WebApi.DTOs; - -/// -/// DTO для отображения контрагента -/// -public class CounterpartyDto -{ - /// - /// Уникальный идентификатор - /// - public int Id { get; set; } - - /// - /// ФИО контрагента - /// - public required string FullName { get; set; } - - /// - /// Номер паспорта - /// - public required string PassportNumber { get; set; } - - /// - /// Контактный телефон - /// - public required string PhoneNumber { get; set; } -} - -/// -/// DTO для создания контрагента -/// -public class CreateCounterpartyDto -{ - /// - /// ФИО контрагента - /// - public required string FullName { get; set; } - - /// - /// Номер паспорта - /// - public required string PassportNumber { get; set; } - - /// - /// Контактный телефон - /// - public required string PhoneNumber { get; set; } -} - -/// -/// DTO для обновления контрагента -/// -public class UpdateCounterpartyDto -{ - /// - /// ФИО контрагента - /// - public required string FullName { get; set; } - - /// - /// Номер паспорта - /// - public required string PassportNumber { get; set; } - - /// - /// Контактный телефон - /// - public required string PhoneNumber { get; set; } -} diff --git a/RealEstateAgency.WebApi/DTOs/RealEstatePropertyDto.cs b/RealEstateAgency.WebApi/DTOs/RealEstatePropertyDto.cs deleted file mode 100644 index 7535f48ff..000000000 --- a/RealEstateAgency.WebApi/DTOs/RealEstatePropertyDto.cs +++ /dev/null @@ -1,176 +0,0 @@ -using RealEstateAgency.Domain.Enums; - -namespace RealEstateAgency.WebApi.DTOs; - -/// -/// DTO для отображения объекта недвижимости -/// -public class RealEstatePropertyDto -{ - /// - /// Уникальный идентификатор - /// - public int 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; } -} - -/// -/// DTO для создания объекта недвижимости -/// -public class CreateRealEstatePropertyDto -{ - /// - /// Тип недвижимости - /// - 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; } -} - -/// -/// DTO для обновления объекта недвижимости -/// -public class UpdateRealEstatePropertyDto -{ - /// - /// Тип недвижимости - /// - 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.WebApi/DTOs/RequestDto.cs b/RealEstateAgency.WebApi/DTOs/RequestDto.cs deleted file mode 100644 index 93c9e4845..000000000 --- a/RealEstateAgency.WebApi/DTOs/RequestDto.cs +++ /dev/null @@ -1,111 +0,0 @@ -using RealEstateAgency.Domain.Enums; - -namespace RealEstateAgency.WebApi.DTOs; - -/// -/// DTO для отображения заявки -/// -public class RequestDto -{ - /// - /// Уникальный идентификатор заявки - /// - public int Id { get; set; } - - /// - /// Идентификатор контрагента - /// - public int CounterpartyId { get; set; } - - /// - /// Данные контрагента - /// - public CounterpartyDto? Counterparty { get; set; } - - /// - /// Идентификатор объекта недвижимости - /// - public int PropertyId { get; set; } - - /// - /// Данные объекта недвижимости - /// - public RealEstatePropertyDto? Property { get; set; } - - /// - /// Тип заявки (покупка/продажа) - /// - public RequestType Type { get; set; } - - /// - /// Сумма сделки - /// - public decimal Amount { get; set; } - - /// - /// Дата подачи заявки - /// - public DateTime Date { get; set; } -} - -/// -/// DTO для создания заявки -/// -public class CreateRequestDto -{ - /// - /// Идентификатор контрагента - /// - public int CounterpartyId { get; set; } - - /// - /// Идентификатор объекта недвижимости - /// - public int PropertyId { get; set; } - - /// - /// Тип заявки (покупка/продажа) - /// - public RequestType Type { get; set; } - - /// - /// Сумма сделки - /// - public decimal Amount { get; set; } - - /// - /// Дата подачи заявки - /// - public DateTime Date { get; set; } -} - -/// -/// DTO для обновления заявки -/// -public class UpdateRequestDto -{ - /// - /// Идентификатор контрагента - /// - public int CounterpartyId { get; set; } - - /// - /// Идентификатор объекта недвижимости - /// - public int PropertyId { get; set; } - - /// - /// Тип заявки (покупка/продажа) - /// - public RequestType Type { get; set; } - - /// - /// Сумма сделки - /// - public decimal Amount { get; set; } - - /// - /// Дата подачи заявки - /// - public DateTime Date { get; set; } -} diff --git a/RealEstateAgency.WebApi/Program.cs b/RealEstateAgency.WebApi/Program.cs index 45355fc9b..133b19b44 100644 --- a/RealEstateAgency.WebApi/Program.cs +++ b/RealEstateAgency.WebApi/Program.cs @@ -1,12 +1,13 @@ -using System.Text.Json.Serialization; -using MongoDB.Bson; -using MongoDB.Bson.Serialization; -using MongoDB.Bson.Serialization.Serializers; +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.Mapping; -using RealEstateAgency.WebApi.Repositories; -using RealEstateAgency.WebApi.Services; +using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); @@ -17,16 +18,12 @@ { builder.AddServiceDefaults(); - BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); - BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); - BsonSerializer.RegisterSerializer(new EnumSerializer(BsonType.String)); - builder.AddMongoDBClient("realestatedb"); - builder.Services.AddScoped(sp => + builder.Services.AddDbContext((serviceProvider, options) => { - var client = sp.GetRequiredService(); - return client.GetDatabase("realestatedb"); + var mongoClient = serviceProvider.GetRequiredService(); + options.UseMongoDB(mongoClient, "realestatedb"); }); builder.Services.AddScoped(); @@ -41,6 +38,11 @@ builder.Services.AddSingleton(); } +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddControllers() .AddJsonOptions(options => { @@ -67,8 +69,6 @@ builder.Services.AddAutoMapper(typeof(MappingProfile)); -builder.Services.AddScoped(); - var app = builder.Build(); if (useMongoDB) diff --git a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj index f8f03fbb4..83636a8b3 100644 --- a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj +++ b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj @@ -8,13 +8,16 @@ - + + + + diff --git a/RealEstateAgency.WebApi/Repositories/IRepositories.cs b/RealEstateAgency.WebApi/Repositories/IRepositories.cs deleted file mode 100644 index 1253e8d3e..000000000 --- a/RealEstateAgency.WebApi/Repositories/IRepositories.cs +++ /dev/null @@ -1,39 +0,0 @@ -using RealEstateAgency.Domain.Models; - -namespace RealEstateAgency.WebApi.Repositories; - -/// -/// Интерфейс репозитория контрагентов -/// -public interface ICounterpartyRepository -{ - public IEnumerable GetAll(); - public Counterparty? GetById(int id); - public Counterparty Add(Counterparty counterparty); - public Counterparty? Update(int id, Counterparty counterparty); - public bool Delete(int id); -} - -/// -/// Интерфейс репозитория объектов недвижимости -/// -public interface IRealEstatePropertyRepository -{ - public IEnumerable GetAll(); - public RealEstateProperty? GetById(int id); - public RealEstateProperty Add(RealEstateProperty property); - public RealEstateProperty? Update(int id, RealEstateProperty property); - public bool Delete(int id); -} - -/// -/// Интерфейс репозитория заявок -/// -public interface IRequestRepository -{ - public IEnumerable GetAll(); - public Request? GetById(int id); - public Request Add(Request request); - public Request? Update(int id, Request request); - public bool Delete(int id); -} diff --git a/RealEstateAgency.WebApi/Repositories/InMemoryCounterpartyRepository.cs b/RealEstateAgency.WebApi/Repositories/InMemoryCounterpartyRepository.cs deleted file mode 100644 index 675ed30f3..000000000 --- a/RealEstateAgency.WebApi/Repositories/InMemoryCounterpartyRepository.cs +++ /dev/null @@ -1,71 +0,0 @@ -using RealEstateAgency.Domain.Models; - -namespace RealEstateAgency.WebApi.Repositories; - -/// -/// In-memory реализация репозитория контрагентов -/// -public class InMemoryCounterpartyRepository : ICounterpartyRepository -{ - private readonly List _counterparties = []; - private int _nextId = 1; - - public InMemoryCounterpartyRepository() - { - // Инициализация тестовыми данными - SeedData(); - } - - private void SeedData() - { - var seedData = new[] - { - new Counterparty { Id = 1, FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" }, - new Counterparty { Id = 2, FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" }, - new Counterparty { Id = 3, FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" }, - new Counterparty { Id = 4, FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" }, - new Counterparty { Id = 5, FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" }, - new Counterparty { Id = 6, FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" }, - new Counterparty { Id = 7, FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" }, - new Counterparty { Id = 8, FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" }, - new Counterparty { Id = 9, FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" }, - new Counterparty { Id = 10, FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" }, - new Counterparty { Id = 11, FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" }, - new Counterparty { Id = 12, FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" } - }; - - _counterparties.AddRange(seedData); - _nextId = seedData.Length + 1; - } - - public IEnumerable GetAll() => _counterparties; - - public Counterparty? GetById(int id) => _counterparties.FirstOrDefault(c => c.Id == id); - - public Counterparty Add(Counterparty counterparty) - { - counterparty.Id = _nextId++; - _counterparties.Add(counterparty); - return counterparty; - } - - public Counterparty? Update(int id, Counterparty counterparty) - { - var existing = GetById(id); - if (existing == null) return null; - - existing.FullName = counterparty.FullName; - existing.PassportNumber = counterparty.PassportNumber; - existing.PhoneNumber = counterparty.PhoneNumber; - return existing; - } - - public bool Delete(int id) - { - var counterparty = GetById(id); - if (counterparty == null) return false; - - _counterparties.Remove(counterparty); - return true; - } -} diff --git a/RealEstateAgency.WebApi/Repositories/InMemoryRealEstatePropertyRepository.cs b/RealEstateAgency.WebApi/Repositories/InMemoryRealEstatePropertyRepository.cs deleted file mode 100644 index b57d0a84b..000000000 --- a/RealEstateAgency.WebApi/Repositories/InMemoryRealEstatePropertyRepository.cs +++ /dev/null @@ -1,79 +0,0 @@ -using RealEstateAgency.Domain.Enums; -using RealEstateAgency.Domain.Models; - -namespace RealEstateAgency.WebApi.Repositories; - -/// -/// In-memory реализация репозитория объектов недвижимости -/// -public class InMemoryRealEstatePropertyRepository : IRealEstatePropertyRepository -{ - private readonly List _properties = []; - private int _nextId = 1; - - public InMemoryRealEstatePropertyRepository() - { - SeedData(); - } - - private void SeedData() - { - var seedData = new[] - { - new RealEstateProperty { Id = 1, 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 = 2, 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 = 3, 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 = 4, 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 = 5, 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 = 6, 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 = 7, 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 = 8, 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 = 9, 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 = 10, 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 = 11, 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 = 12, 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 = 13, 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); - _nextId = seedData.Length + 1; - } - - public IEnumerable GetAll() => _properties; - - public RealEstateProperty? GetById(int id) => _properties.FirstOrDefault(p => p.Id == id); - - public RealEstateProperty Add(RealEstateProperty property) - { - property.Id = _nextId++; - _properties.Add(property); - return property; - } - - public RealEstateProperty? Update(int id, RealEstateProperty property) - { - var existing = GetById(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; - return existing; - } - - public bool Delete(int id) - { - var property = GetById(id); - if (property == null) return false; - - _properties.Remove(property); - return true; - } -} diff --git a/RealEstateAgency.WebApi/Repositories/InMemoryRequestRepository.cs b/RealEstateAgency.WebApi/Repositories/InMemoryRequestRepository.cs deleted file mode 100644 index e224a4710..000000000 --- a/RealEstateAgency.WebApi/Repositories/InMemoryRequestRepository.cs +++ /dev/null @@ -1,85 +0,0 @@ -using RealEstateAgency.Domain.Enums; -using RealEstateAgency.Domain.Models; - -namespace RealEstateAgency.WebApi.Repositories; - -/// -/// In-memory реализация репозитория заявок -/// -public class InMemoryRequestRepository : IRequestRepository -{ - private readonly List _requests = []; - private readonly ICounterpartyRepository _counterpartyRepository; - private readonly IRealEstatePropertyRepository _propertyRepository; - private int _nextId = 1; - - public InMemoryRequestRepository( - ICounterpartyRepository counterpartyRepository, - IRealEstatePropertyRepository propertyRepository) - { - _counterpartyRepository = counterpartyRepository; - _propertyRepository = propertyRepository; - SeedData(); - } - - private void SeedData() - { - var counterparties = _counterpartyRepository.GetAll().ToList(); - var properties = _propertyRepository.GetAll().ToList(); - - var seedData = new[] - { - new Request { Id = 1, Counterparty = counterparties[0], Property = properties[0], Type = RequestType.Sale, Amount = 25000000.00m, Date = new DateTime(2024, 1, 15) }, - new Request { Id = 2, Counterparty = counterparties[1], Property = properties[1], Type = RequestType.Sale, Amount = 18000000.00m, Date = new DateTime(2024, 2, 20) }, - new Request { Id = 3, Counterparty = counterparties[3], Property = properties[3], Type = RequestType.Sale, Amount = 42000000.00m, Date = new DateTime(2024, 3, 10) }, - new Request { Id = 4, Counterparty = counterparties[6], Property = properties[5], Type = RequestType.Sale, Amount = 35000000.00m, Date = new DateTime(2024, 4, 5) }, - new Request { Id = 5, Counterparty = counterparties[8], Property = properties[7], Type = RequestType.Sale, Amount = 32000000.00m, Date = new DateTime(2024, 5, 12) }, - new Request { Id = 6, Counterparty = counterparties[10], Property = properties[9], Type = RequestType.Sale, Amount = 1500000.00m, Date = new DateTime(2024, 6, 8) }, - new Request { Id = 7, Counterparty = counterparties[11], Property = properties[11], Type = RequestType.Sale, Amount = 85000000.00m, Date = new DateTime(2024, 7, 25) }, - new Request { Id = 8, Counterparty = counterparties[2], Property = properties[2], Type = RequestType.Purchase, Amount = 22000000.00m, Date = new DateTime(2024, 1, 20) }, - new Request { Id = 9, Counterparty = counterparties[4], Property = properties[4], Type = RequestType.Purchase, Amount = 15000000.00m, Date = new DateTime(2024, 2, 25) }, - new Request { Id = 10, Counterparty = counterparties[5], Property = properties[6], Type = RequestType.Purchase, Amount = 28000000.00m, Date = new DateTime(2024, 3, 15) }, - new Request { Id = 11, Counterparty = counterparties[7], Property = properties[8], Type = RequestType.Purchase, Amount = 25000000.00m, Date = new DateTime(2024, 4, 18) }, - new Request { Id = 12, Counterparty = counterparties[9], Property = properties[10], Type = RequestType.Purchase, Amount = 1800000.00m, Date = new DateTime(2024, 5, 22) }, - new Request { Id = 13, Counterparty = counterparties[2], Property = properties[12], Type = RequestType.Purchase, Amount = 60000000.00m, Date = new DateTime(2024, 6, 30) }, - new Request { Id = 14, Counterparty = counterparties[1], Property = properties[0], Type = RequestType.Purchase, Amount = 24000000.00m, Date = new DateTime(2024, 8, 10) }, - new Request { Id = 15, Counterparty = counterparties[3], Property = properties[1], Type = RequestType.Sale, Amount = 19000000.00m, Date = new DateTime(2024, 9, 5) } - }; - - _requests.AddRange(seedData); - _nextId = seedData.Length + 1; - } - - public IEnumerable GetAll() => _requests; - - public Request? GetById(int id) => _requests.FirstOrDefault(r => r.Id == id); - - public Request Add(Request request) - { - request.Id = _nextId++; - _requests.Add(request); - return request; - } - - public Request? Update(int id, Request request) - { - var existing = GetById(id); - if (existing == null) return null; - - existing.Counterparty = request.Counterparty; - existing.Property = request.Property; - existing.Type = request.Type; - existing.Amount = request.Amount; - existing.Date = request.Date; - return existing; - } - - public bool Delete(int id) - { - var request = GetById(id); - if (request == null) return false; - - _requests.Remove(request); - return true; - } -} diff --git a/RealEstateAgency.WebApi/Repositories/MongoCounterpartyRepository.cs b/RealEstateAgency.WebApi/Repositories/MongoCounterpartyRepository.cs deleted file mode 100644 index 2ee0e8c51..000000000 --- a/RealEstateAgency.WebApi/Repositories/MongoCounterpartyRepository.cs +++ /dev/null @@ -1,60 +0,0 @@ -using MongoDB.Driver; -using RealEstateAgency.Domain.Models; - -namespace RealEstateAgency.WebApi.Repositories; - -/// -/// MongoDB реализация репозитория контрагентов -/// -public class MongoCounterpartyRepository : ICounterpartyRepository -{ - private readonly IMongoCollection _collection; - - public MongoCounterpartyRepository(IMongoDatabase database) - { - _collection = database.GetCollection("counterparties"); - } - - public IEnumerable GetAll() - { - return _collection.Find(_ => true).ToList(); - } - - public Counterparty? GetById(int id) - { - return _collection.Find(c => c.Id == id).FirstOrDefault(); - } - - public Counterparty Add(Counterparty counterparty) - { - // Генерация нового ID - var maxId = _collection.Find(_ => true) - .SortByDescending(c => c.Id) - .FirstOrDefault()?.Id ?? 0; - counterparty.Id = maxId + 1; - - _collection.InsertOne(counterparty); - return counterparty; - } - - public Counterparty? Update(int id, Counterparty counterparty) - { - var filter = Builders.Filter.Eq(c => c.Id, id); - var update = Builders.Update - .Set(c => c.FullName, counterparty.FullName) - .Set(c => c.PassportNumber, counterparty.PassportNumber) - .Set(c => c.PhoneNumber, counterparty.PhoneNumber); - - var result = _collection.UpdateOne(filter, update); - if (result.ModifiedCount == 0) - return null; - - return GetById(id); - } - - public bool Delete(int id) - { - var result = _collection.DeleteOne(c => c.Id == id); - return result.DeletedCount > 0; - } -} diff --git a/RealEstateAgency.WebApi/Repositories/MongoRealEstatePropertyRepository.cs b/RealEstateAgency.WebApi/Repositories/MongoRealEstatePropertyRepository.cs deleted file mode 100644 index 916e420f1..000000000 --- a/RealEstateAgency.WebApi/Repositories/MongoRealEstatePropertyRepository.cs +++ /dev/null @@ -1,66 +0,0 @@ -using MongoDB.Driver; -using RealEstateAgency.Domain.Models; - -namespace RealEstateAgency.WebApi.Repositories; - -/// -/// MongoDB реализация репозитория объектов недвижимости -/// -public class MongoRealEstatePropertyRepository : IRealEstatePropertyRepository -{ - private readonly IMongoCollection _collection; - - public MongoRealEstatePropertyRepository(IMongoDatabase database) - { - _collection = database.GetCollection("properties"); - } - - public IEnumerable GetAll() - { - return _collection.Find(_ => true).ToList(); - } - - public RealEstateProperty? GetById(int id) - { - return _collection.Find(p => p.Id == id).FirstOrDefault(); - } - - public RealEstateProperty Add(RealEstateProperty property) - { - var maxId = _collection.Find(_ => true) - .SortByDescending(p => p.Id) - .FirstOrDefault()?.Id ?? 0; - property.Id = maxId + 1; - - _collection.InsertOne(property); - return property; - } - - public RealEstateProperty? Update(int id, RealEstateProperty property) - { - var filter = Builders.Filter.Eq(p => p.Id, id); - var update = Builders.Update - .Set(p => p.Type, property.Type) - .Set(p => p.Purpose, property.Purpose) - .Set(p => p.CadastralNumber, property.CadastralNumber) - .Set(p => p.Address, property.Address) - .Set(p => p.TotalFloors, property.TotalFloors) - .Set(p => p.TotalArea, property.TotalArea) - .Set(p => p.RoomsCount, property.RoomsCount) - .Set(p => p.CeilingHeight, property.CeilingHeight) - .Set(p => p.Floor, property.Floor) - .Set(p => p.HasEncumbrances, property.HasEncumbrances); - - var result = _collection.UpdateOne(filter, update); - if (result.ModifiedCount == 0) - return null; - - return GetById(id); - } - - public bool Delete(int id) - { - var result = _collection.DeleteOne(p => p.Id == id); - return result.DeletedCount > 0; - } -} diff --git a/RealEstateAgency.WebApi/Repositories/MongoRequestRepository.cs b/RealEstateAgency.WebApi/Repositories/MongoRequestRepository.cs deleted file mode 100644 index 32e649772..000000000 --- a/RealEstateAgency.WebApi/Repositories/MongoRequestRepository.cs +++ /dev/null @@ -1,68 +0,0 @@ -using MongoDB.Driver; -using RealEstateAgency.Domain.Models; - -namespace RealEstateAgency.WebApi.Repositories; - -/// -/// MongoDB реализация репозитория заявок -/// -public class MongoRequestRepository : IRequestRepository -{ - private readonly IMongoCollection _collection; - private readonly ICounterpartyRepository _counterpartyRepository; - private readonly IRealEstatePropertyRepository _propertyRepository; - - public MongoRequestRepository( - IMongoDatabase database, - ICounterpartyRepository counterpartyRepository, - IRealEstatePropertyRepository propertyRepository) - { - _collection = database.GetCollection("requests"); - _counterpartyRepository = counterpartyRepository; - _propertyRepository = propertyRepository; - } - - public IEnumerable GetAll() - { - return _collection.Find(_ => true).ToList(); - } - - public Request? GetById(int id) - { - return _collection.Find(r => r.Id == id).FirstOrDefault(); - } - - public Request Add(Request request) - { - var maxId = _collection.Find(_ => true) - .SortByDescending(r => r.Id) - .FirstOrDefault()?.Id ?? 0; - request.Id = maxId + 1; - - _collection.InsertOne(request); - return request; - } - - public Request? Update(int id, Request request) - { - var filter = Builders.Filter.Eq(r => r.Id, id); - var update = Builders.Update - .Set(r => r.Counterparty, request.Counterparty) - .Set(r => r.Property, request.Property) - .Set(r => r.Type, request.Type) - .Set(r => r.Amount, request.Amount) - .Set(r => r.Date, request.Date); - - var result = _collection.UpdateOne(filter, update); - if (result.ModifiedCount == 0) - return null; - - return GetById(id); - } - - public bool Delete(int id) - { - var result = _collection.DeleteOne(r => r.Id == id); - return result.DeletedCount > 0; - } -} diff --git a/RealEstateAgency.WebApi/Services/AnalyticsService.cs b/RealEstateAgency.WebApi/Services/AnalyticsService.cs deleted file mode 100644 index a990c633c..000000000 --- a/RealEstateAgency.WebApi/Services/AnalyticsService.cs +++ /dev/null @@ -1,156 +0,0 @@ -using RealEstateAgency.Domain.Enums; -using RealEstateAgency.WebApi.DTOs; -using RealEstateAgency.WebApi.Repositories; - -namespace RealEstateAgency.WebApi.Services; - -/// -/// Интерфейс сервиса аналитики -/// -public interface IAnalyticsService -{ - /// - /// Получить продавцов за указанный период - /// - public IEnumerable GetSellersInPeriod(DateTime startDate, DateTime endDate); - - /// - /// Получить топ-5 клиентов по количеству заявок (покупка и продажа отдельно) - /// - public Top5ClientsResultDto GetTop5ClientsByRequestCount(); - - /// - /// Получить статистику заявок по типам недвижимости - /// - public IEnumerable GetRequestCountByPropertyType(); - - /// - /// Получить клиентов с заявками минимальной стоимости - /// - public ClientWithMinAmountDto GetClientsWithMinAmount(); - - /// - /// Получить клиентов, ищущих определённый тип недвижимости - /// - public IEnumerable GetClientsSeekingPropertyType(PropertyType propertyType); -} - -/// -/// Реализация сервиса аналитики -/// -public class AnalyticsService : IAnalyticsService -{ - private readonly IRequestRepository _requestRepository; - - public AnalyticsService(IRequestRepository requestRepository) - { - _requestRepository = requestRepository; - } - - /// - /// Получить продавцов за указанный период - /// - public IEnumerable GetSellersInPeriod(DateTime startDate, DateTime endDate) - { - return _requestRepository.GetAll() - .Where(r => r.Type == RequestType.Sale && - r.Date >= startDate && - r.Date <= endDate) - .Select(r => r.Counterparty.FullName) - .Distinct() - .Order() - .ToList(); - } - - /// - /// Получить топ-5 клиентов по количеству заявок - /// - public Top5ClientsResultDto GetTop5ClientsByRequestCount() - { - var requests = _requestRepository.GetAll().ToList(); - - var topPurchaseClients = requests - .Where(r => r.Type == RequestType.Purchase) - .GroupBy(r => r.Counterparty) - .Select(g => new TopClientDto - { - FullName = g.Key.FullName, - RequestCount = g.Count() - }) - .OrderByDescending(x => x.RequestCount) - .ThenBy(x => x.FullName) - .Take(5) - .ToList(); - - var topSaleClients = requests - .Where(r => r.Type == RequestType.Sale) - .GroupBy(r => r.Counterparty) - .Select(g => new TopClientDto - { - FullName = g.Key.FullName, - RequestCount = g.Count() - }) - .OrderByDescending(x => x.RequestCount) - .ThenBy(x => x.FullName) - .Take(5) - .ToList(); - - return new Top5ClientsResultDto - { - TopPurchaseClients = topPurchaseClients, - TopSaleClients = topSaleClients - }; - } - - /// - /// Получить статистику заявок по типам недвижимости - /// - public IEnumerable GetRequestCountByPropertyType() - { - return _requestRepository.GetAll() - .GroupBy(r => r.Property.Type) - .Select(g => new PropertyTypeStatisticsDto - { - PropertyType = g.Key, - RequestCount = g.Count() - }) - .OrderBy(x => x.PropertyType) - .ToList(); - } - - /// - /// Получить клиентов с минимальной суммой заявки - /// - public ClientWithMinAmountDto GetClientsWithMinAmount() - { - var requests = _requestRepository.GetAll().ToList(); - var minAmount = requests.Min(r => r.Amount); - - var clients = requests - .Where(r => r.Amount == minAmount) - .Select(r => r.Counterparty.FullName) - .Distinct() - .Order() - .ToList(); - - return new ClientWithMinAmountDto - { - FullName = string.Join(", ", clients), - MinAmount = minAmount - }; - } - - /// - /// Получить клиентов, ищущих определённый тип недвижимости - /// - public IEnumerable GetClientsSeekingPropertyType(PropertyType propertyType) - { - return _requestRepository.GetAll() - .Where(r => r.Type == RequestType.Purchase && - r.Property.Type == propertyType) - .Select(r => r.Counterparty.FullName) - .Distinct() - .Order() - .ToList(); - } -} diff --git a/RealEstateAgency.WebApi/Services/DatabaseSeeder.cs b/RealEstateAgency.WebApi/Services/DatabaseSeeder.cs deleted file mode 100644 index c9b27e95f..000000000 --- a/RealEstateAgency.WebApi/Services/DatabaseSeeder.cs +++ /dev/null @@ -1,109 +0,0 @@ -using MongoDB.Driver; -using RealEstateAgency.Domain.Enums; -using RealEstateAgency.Domain.Models; - -namespace RealEstateAgency.WebApi.Services; - -/// -/// Сервис для начального заполнения базы данных -/// -public class DatabaseSeeder -{ - private readonly IMongoDatabase _database; - private readonly ILogger _logger; - - public DatabaseSeeder(IMongoDatabase database, ILogger logger) - { - _database = database; - _logger = logger; - } - - /// - /// Заполняет базу данных начальными данными, если она пуста - /// - public async Task SeedAsync() - { - var counterpartiesCollection = _database.GetCollection("counterparties"); - var propertiesCollection = _database.GetCollection("properties"); - var requestsCollection = _database.GetCollection("requests"); - - // Проверяем, есть ли уже данные - var counterpartiesCount = await counterpartiesCollection.CountDocumentsAsync(_ => true); - if (counterpartiesCount > 0) - { - _logger.LogInformation("База данных уже содержит данные, seed пропущен"); - return; - } - - _logger.LogInformation("Начало заполнения базы данных..."); - - // Создаём контрагентов - var counterparties = GenerateCounterparties(); - await counterpartiesCollection.InsertManyAsync(counterparties); - _logger.LogInformation("Добавлено {Count} контрагентов", counterparties.Count); - - // Создаём объекты недвижимости - var properties = GenerateProperties(); - await propertiesCollection.InsertManyAsync(properties); - _logger.LogInformation("Добавлено {Count} объектов недвижимости", properties.Count); - - // Создаём заявки - var requests = GenerateRequests(counterparties, properties); - await requestsCollection.InsertManyAsync(requests); - _logger.LogInformation("Добавлено {Count} заявок", requests.Count); - - _logger.LogInformation("Заполнение базы данных завершено"); - } - - private static List GenerateCounterparties() => - [ - new() { Id = 1, FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" }, - new() { Id = 2, FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" }, - new() { Id = 3, FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" }, - new() { Id = 4, FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" }, - new() { Id = 5, FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" }, - new() { Id = 6, FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" }, - new() { Id = 7, FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" }, - new() { Id = 8, FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" }, - new() { Id = 9, FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" }, - new() { Id = 10, FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" }, - new() { Id = 11, FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" }, - new() { Id = 12, FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" } - ]; - - private static List GenerateProperties() => - [ - new() { Id = 1, 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 = 2, 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 = 3, 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 = 4, 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 = 5, 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 = 6, 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 = 7, 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 = 8, 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 = 9, 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 = 10, 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 = 11, 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 = 12, 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 = 13, 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 = 1, Counterparty = counterparties[0], Property = properties[0], Type = RequestType.Sale, Amount = 25000000.00m, Date = new DateTime(2024, 1, 15) }, - new() { Id = 2, Counterparty = counterparties[1], Property = properties[1], Type = RequestType.Sale, Amount = 18000000.00m, Date = new DateTime(2024, 2, 20) }, - new() { Id = 3, Counterparty = counterparties[3], Property = properties[3], Type = RequestType.Sale, Amount = 42000000.00m, Date = new DateTime(2024, 3, 10) }, - new() { Id = 4, Counterparty = counterparties[6], Property = properties[5], Type = RequestType.Sale, Amount = 35000000.00m, Date = new DateTime(2024, 4, 5) }, - new() { Id = 5, Counterparty = counterparties[8], Property = properties[7], Type = RequestType.Sale, Amount = 32000000.00m, Date = new DateTime(2024, 5, 12) }, - new() { Id = 6, Counterparty = counterparties[10], Property = properties[9], Type = RequestType.Sale, Amount = 1500000.00m, Date = new DateTime(2024, 6, 8) }, - new() { Id = 7, Counterparty = counterparties[11], Property = properties[11], Type = RequestType.Sale, Amount = 85000000.00m, Date = new DateTime(2024, 7, 25) }, - new() { Id = 8, Counterparty = counterparties[2], Property = properties[2], Type = RequestType.Purchase, Amount = 22000000.00m, Date = new DateTime(2024, 1, 20) }, - new() { Id = 9, Counterparty = counterparties[4], Property = properties[4], Type = RequestType.Purchase, Amount = 15000000.00m, Date = new DateTime(2024, 2, 25) }, - new() { Id = 10, Counterparty = counterparties[5], Property = properties[6], Type = RequestType.Purchase, Amount = 28000000.00m, Date = new DateTime(2024, 3, 15) }, - new() { Id = 11, Counterparty = counterparties[7], Property = properties[8], Type = RequestType.Purchase, Amount = 25000000.00m, Date = new DateTime(2024, 4, 18) }, - new() { Id = 12, Counterparty = counterparties[9], Property = properties[10], Type = RequestType.Purchase, Amount = 1800000.00m, Date = new DateTime(2024, 5, 22) }, - new() { Id = 13, Counterparty = counterparties[2], Property = properties[12], Type = RequestType.Purchase, Amount = 60000000.00m, Date = new DateTime(2024, 6, 30) }, - new() { Id = 14, Counterparty = counterparties[1], Property = properties[0], Type = RequestType.Purchase, Amount = 24000000.00m, Date = new DateTime(2024, 8, 10) }, - new() { Id = 15, Counterparty = counterparties[3], Property = properties[1], Type = RequestType.Sale, Amount = 19000000.00m, Date = new DateTime(2024, 9, 5) } - ]; -} diff --git a/RealEstateAgency.sln b/RealEstateAgency.sln index 42872deec..e863b7960 100644 --- a/RealEstateAgency.sln +++ b/RealEstateAgency.sln @@ -15,6 +15,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.AppHost", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.ServiceDefaults", "RealEstateAgency.ServiceDefaults\RealEstateAgency.ServiceDefaults.csproj", "{594B532D-3208-489C-8ECD-268A92D92F3B}" 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -97,6 +103,42 @@ Global {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|x64.Build.0 = Release|Any CPU {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|x86.ActiveCfg = Release|Any CPU {594B532D-3208-489C-8ECD-268A92D92F3B}.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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/RealEstateAgency.tests/RealEstateQueriesTests.cs b/RealEstateAgency.tests/RealEstateQueriesTests.cs index dc77e84bc..fab25ccd6 100644 --- a/RealEstateAgency.tests/RealEstateQueriesTests.cs +++ b/RealEstateAgency.tests/RealEstateQueriesTests.cs @@ -44,18 +44,18 @@ public void Top5ClientsByRequestCountReturnsSeparateTop5() { List expectedTopPurchaseClients = [ "Сидоров Алексей Петрович", - "Волков Павел Александрович", - "Морозов Андрей Сергеевич", + "Волков Павел Александрович", + "Морозов Андрей Сергеевич", "Николаев Дмитрий Олегович", "Петрова Анна Сергеевна" ]; List expectedTopSaleClients = [ "Козлова Мария Владимировна", - "Белов Игорь Васильевич", - "Зайцева Наталья Петровна", - "Иванов Иван Иванович", - "Орлова Екатерина Дмитриевна" + "Белов Игорь Васильевич", + "Зайцева Наталья Петровна", + "Иванов Иван Иванович", + "Орлова Екатерина Дмитриевна" ]; var topPurchaseClients = fixture.Requests diff --git a/RealEstateAgency.tests/RealEstateTestFixture.cs b/RealEstateAgency.tests/RealEstateTestFixture.cs index 7243fa2f8..b83f2c78a 100644 --- a/RealEstateAgency.tests/RealEstateTestFixture.cs +++ b/RealEstateAgency.tests/RealEstateTestFixture.cs @@ -1,5 +1,5 @@ -using RealEstateAgency.Domain.Models; -using RealEstateAgency.Domain.Enums; +using RealEstateAgency.Domain.Enums; +using RealEstateAgency.Domain.Models; namespace RealEstateAgency.Tests; @@ -28,18 +28,18 @@ public RealEstateTestFixture() /// private static List GenerateCounterparties() => [ - new() { Id = 1, FullName = "Иванов Иван Иванович", PassportNumber = "4501 123456", PhoneNumber = "+7-999-111-22-33" }, - new() { Id = 2, FullName = "Петрова Анна Сергеевна", PassportNumber = "4501 123457", PhoneNumber = "+7-999-111-22-34" }, - new() { Id = 3, FullName = "Сидоров Алексей Петрович", PassportNumber = "4501 123458", PhoneNumber = "+7-999-111-22-35" }, - new() { Id = 4, FullName = "Козлова Мария Владимировна", PassportNumber = "4501 123459", PhoneNumber = "+7-999-111-22-36" }, - new() { Id = 5, FullName = "Николаев Дмитрий Олегович", PassportNumber = "4501 123460", PhoneNumber = "+7-999-111-22-37" }, - new() { Id = 6, FullName = "Федоров Сергей Викторович", PassportNumber = "4501 123461", PhoneNumber = "+7-999-111-22-38" }, - new() { Id = 7, FullName = "Орлова Екатерина Дмитриевна", PassportNumber = "4501 123462", PhoneNumber = "+7-999-111-22-39" }, - new() { Id = 8, FullName = "Волков Павел Александрович", PassportNumber = "4501 123463", PhoneNumber = "+7-999-111-22-40" }, - new() { Id = 9, FullName = "Семенова Ольга Игоревна", PassportNumber = "4501 123464", PhoneNumber = "+7-999-111-22-41" }, - new() { Id = 10, FullName = "Морозов Андрей Сергеевич", PassportNumber = "4501 123465", PhoneNumber = "+7-999-111-22-42" }, - new() { Id = 11, FullName = "Зайцева Наталья Петровна", PassportNumber = "4501 123466", PhoneNumber = "+7-999-111-22-43" }, - new() { Id = 12, FullName = "Белов Игорь Васильевич", PassportNumber = "4501 123467", PhoneNumber = "+7-999-111-22-44" } + 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" } ]; /// @@ -47,19 +47,19 @@ private static List GenerateCounterparties() => /// private static List GenerateProperties() => [ - new() { Id = 1, 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 = 2, 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 = 3, 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 = 4, 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 = 5, 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 = 6, 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 = 7, 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 = 8, 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 = 9, 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 = 10, 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 = 11, 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 = 12, 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 = 13, 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 } + 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 } ]; /// @@ -69,21 +69,21 @@ private List GenerateRequests() { return [ - new() { Id = 1, Counterparty = Counterparties[0], Property = Properties[0], Type = RequestType.Sale, Amount = 25000000.00m, Date = new DateTime(2024, 1, 15) }, - new() { Id = 2, Counterparty = Counterparties[1], Property = Properties[1], Type = RequestType.Sale, Amount = 18000000.00m, Date = new DateTime(2024, 2, 20) }, - new() { Id = 3, Counterparty = Counterparties[3], Property = Properties[3], Type = RequestType.Sale, Amount = 42000000.00m, Date = new DateTime(2024, 3, 10) }, - new() { Id = 4, Counterparty = Counterparties[6], Property = Properties[5], Type = RequestType.Sale, Amount = 35000000.00m, Date = new DateTime(2024, 4, 5) }, - new() { Id = 5, Counterparty = Counterparties[8], Property = Properties[7], Type = RequestType.Sale, Amount = 32000000.00m, Date = new DateTime(2024, 5, 12) }, - new() { Id = 6, Counterparty = Counterparties[10], Property = Properties[9], Type = RequestType.Sale, Amount = 1500000.00m, Date = new DateTime(2024, 6, 8) }, - new() { Id = 7, Counterparty = Counterparties[11], Property = Properties[11], Type = RequestType.Sale, Amount = 85000000.00m, Date = new DateTime(2024, 7, 25) }, - new() { Id = 8, Counterparty = Counterparties[2], Property = Properties[2], Type = RequestType.Purchase, Amount = 22000000.00m, Date = new DateTime(2024, 1, 20) }, - new() { Id = 9, Counterparty = Counterparties[4], Property = Properties[4], Type = RequestType.Purchase, Amount = 15000000.00m, Date = new DateTime(2024, 2, 25) }, - new() { Id = 10, Counterparty = Counterparties[5], Property = Properties[6], Type = RequestType.Purchase, Amount = 28000000.00m, Date = new DateTime(2024, 3, 15) }, - new() { Id = 11, Counterparty = Counterparties[7], Property = Properties[8], Type = RequestType.Purchase, Amount = 25000000.00m, Date = new DateTime(2024, 4, 18) }, - new() { Id = 12, Counterparty = Counterparties[9], Property = Properties[10], Type = RequestType.Purchase, Amount = 1800000.00m, Date = new DateTime(2024, 5, 22) }, - new() { Id = 13, Counterparty = Counterparties[2], Property = Properties[12], Type = RequestType.Purchase, Amount = 60000000.00m, Date = new DateTime(2024, 6, 30) }, - new() { Id = 14, Counterparty = Counterparties[1], Property = Properties[0], Type = RequestType.Purchase, Amount = 24000000.00m, Date = new DateTime(2024, 8, 10) }, - new() { Id = 15, Counterparty = Counterparties[3], Property = Properties[1], Type = RequestType.Sale, Amount = 19000000.00m, Date = new DateTime(2024, 9, 5) } + 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) } ]; } -} \ No newline at end of file +} From c923bb8abaca97601bb01b9b5d407e98f750181c Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Thu, 18 Dec 2025 07:07:06 +0400 Subject: [PATCH 27/31] Update Readme --- README.md | 51 +++++++++---------- .../RealEstateAgency.AppHost.csproj | 5 +- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 84c4b5903..a29d22c8e 100644 --- a/README.md +++ b/README.md @@ -29,34 +29,33 @@ * ClientsWithMinAmountAreFoundCorrectly() - Клиенты с заявками минимальной стоимости * ClientsSeekingPropertyTypeAreReturnedOrdered() - Поиск клиентов по типу недвижимости с сортировкой -### RealEstateAgency.WebApi -Основной Web API проект с REST эндпоинтами и бизнес-логикой. +### Структура решения +* RealEstateAgency.AppHost/ - .NET Aspire оркестратор +* RealEstateAgency.WebApi/ - ASP.NET Core API контроллеры +* RealEstateAgency.Application/ - Бизнес-логика и сервисы +* RealEstateAgency.Contracts/ - DTO и интерфейсы сервисов +* RealEstateAgency.Domain/ - Сущности и интерфейсы репозиториев +* RealEstateAgency.Infrastructure/ - Репозитории и контекст БД (MongoDB) +* RealEstateAgency.ServiceDefaults/ - Общие настройки Aspire +* RealEstateAgency.WebApi.Tests/ - Интеграционные тесты -#### Контроллеры -**CounterpartiesController** - CRUD операции для управления контрагентами -**PropertiesController** - CRUD операции для управления объектами недвижимости -**RequestsController** - CRUD операции для управления заявками -**AnalyticsController** - аналитические запросы для бизнес-анализа - -#### Сервисы -**AnalyticsService** - сервис для выполнения сложных аналитических запросов -**DatabaseSeeder** - сервис для инициализации базы данных тестовыми данными - -#### Репозитории -**In-Memory** реализации для локальной разработки и тестирования -**MongoDB** реализации для production использования - -#### DTO -Отдельные классы для создания, обновления и чтения каждой сущности - -#### RealEstateAgency.ServiceDefaults -**Назначение**: Общие настройки и конфигурации для всех сервисов в экосистеме .NET Aspire. - -#### RealEstateAgency.AppHost -**Назначение**: Оркестратор приложения, который управляет запуском всех компонентов (Web API и MongoDB) через .NET Aspire. +### Функциональные возможности -#### RealEstateAgency.WebApi.Tests -**Назначение**: Интеграционные тесты для проверки REST API эндпоинтов. +#### CRUD операции +- Полное управление контрагентами, объектами недвижимости и заявками +- Валидация связанных сущностей при создании/обновлении заявок + +#### Аналитические запросы +* Продавцы за указанный период +* Топ-5 клиентов по количеству заявок (покупка и продажа отдельно) +* Статистика заявок по типам недвижимости +* Клиенты с минимальной суммой заявок +* Поиск клиентов по интересующему типу недвижимости + +### Особенности реализации +* Двойные репозитории: InMemory для тестов, MongoDB для продакшена +* Автоматическое заполнение БД: 12 контрагентов, 13 объектов, 15 заявок при первом запуске +* Умная конфигурация: Автоматическое переключение между MongoDB и InMemory режимами diff --git a/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj index ecc71f0ba..1ad803c12 100644 --- a/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj +++ b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe @@ -15,8 +15,7 @@ - + From 9b57a917d7f056f0753df29218d9e07cfe2a659b Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Mon, 22 Dec 2025 16:40:32 +0400 Subject: [PATCH 28/31] Correction of comments --- RealEstateAgency.AppHost/Program.cs | 1 + .../Properties/launchSettings.json | 12 +- .../RealEstateAgency.AppHost.csproj | 11 +- .../RealEstateAgency.Application.csproj | 1 - .../Services/AnalyticsService.cs | 18 +-- .../Services/RequestService.cs | 4 + .../Dto/CreateRealEstatePropertyDto.cs | 4 +- .../Dto/CreateRequestDto.cs | 4 +- .../Dto/UpdateRealEstatePropertyDto.cs | 4 +- .../Dto/UpdateRequestDto.cs | 4 +- .../Enums/PropertyPurpose.cs | 10 +- RealEstateAgency.Domain/Enums/PropertyType.cs | 16 +-- RealEstateAgency.Domain/Enums/RequestType.cs | 8 +- .../Interfaces/ICounterpartyRepository.cs | 5 +- .../IRealEstatePropertyRepository.cs | 4 +- .../Interfaces/IRequestRepository.cs | 4 +- .../Models/Counterparty.cs | 12 +- .../Models/RealEstateProperty.cs | 26 ++-- RealEstateAgency.Domain/Models/Request.cs | 30 ++-- .../Persistence/DatabaseSeeder.cs | 30 ++-- .../Persistence/RealEstateDbContext.cs | 12 +- .../RealEstateAgency.Infrastructure.csproj | 4 +- .../Repositories/InMemoryRequestRepository.cs | 32 +++-- .../Repositories/MongoRequestRepository.cs | 53 ++++++- .../RealEstateAgency.ServiceDefaults.csproj | 4 +- .../AnalyticsControllerTests.cs | 10 +- .../CounterpartiesControllerTests.cs | 10 +- .../MongoAnalyticsTests.cs | 9 +- .../MongoCounterpartiesTests.cs | 10 +- .../MongoDbCollection.cs | 33 ++++- .../MongoDbWebApplicationFactory.cs | 4 - .../MongoPropertiesTests.cs | 9 +- .../MongoRequestsTests.cs | 9 +- .../PropertiesControllerTests.cs | 10 +- .../RealEstateAgency.WebApi.Tests.csproj | 1 - .../RequestsControllerTests.cs | 10 +- .../Controllers/AnalyticsController.cs | 79 ++++++++--- .../Controllers/BaseCrudController.cs | 90 ++++++++---- .../Controllers/CounterpartiesController.cs | 123 ++++++++++------ .../Controllers/PropertiesController.cs | 123 ++++++++++------ .../Controllers/RequestsController.cs | 131 ++++++++++++------ RealEstateAgency.WebApi/Program.cs | 6 +- .../RealEstateAgency.WebApi.csproj | 7 +- RealEstateAgency.sln | 56 ++++---- 44 files changed, 647 insertions(+), 396 deletions(-) diff --git a/RealEstateAgency.AppHost/Program.cs b/RealEstateAgency.AppHost/Program.cs index c3829018c..bd25102b6 100644 --- a/RealEstateAgency.AppHost/Program.cs +++ b/RealEstateAgency.AppHost/Program.cs @@ -9,6 +9,7 @@ // WebApi builder.AddProject("webapi") .WithReference(mongoDatabase) + .WaitFor(mongoDatabase) .WithExternalHttpEndpoints(); builder.Build().Run(); \ No newline at end of file diff --git a/RealEstateAgency.AppHost/Properties/launchSettings.json b/RealEstateAgency.AppHost/Properties/launchSettings.json index 2de765294..ac767f5f2 100644 --- a/RealEstateAgency.AppHost/Properties/launchSettings.json +++ b/RealEstateAgency.AppHost/Properties/launchSettings.json @@ -5,24 +5,24 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://localhost:17298;http://localhost:15289", + "applicationUrl": "https://localhost:17197;http://localhost:15247", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21287", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22263" + "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:15289", + "applicationUrl": "http://localhost:15247", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19005", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20096" + "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 index 1ad803c12..0a77e4bc9 100644 --- a/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj +++ b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj @@ -1,21 +1,22 @@  + + Exe net8.0 enable enable - true - 344f4440-2db5-4c49-bb8e-9856cf3d6a67 + e80bf9ea-04bf-4190-adb9-7d9a244a2049 - - + + - + diff --git a/RealEstateAgency.Application/RealEstateAgency.Application.csproj b/RealEstateAgency.Application/RealEstateAgency.Application.csproj index 56b156484..54221f41c 100644 --- a/RealEstateAgency.Application/RealEstateAgency.Application.csproj +++ b/RealEstateAgency.Application/RealEstateAgency.Application.csproj @@ -13,7 +13,6 @@ - diff --git a/RealEstateAgency.Application/Services/AnalyticsService.cs b/RealEstateAgency.Application/Services/AnalyticsService.cs index e94c12d7a..5c013387d 100644 --- a/RealEstateAgency.Application/Services/AnalyticsService.cs +++ b/RealEstateAgency.Application/Services/AnalyticsService.cs @@ -28,7 +28,7 @@ .. requests .Where(r => r.Type == RequestType.Sale && r.Date >= startDate && r.Date <= endDate) - .Select(r => counterparties.TryGetValue(r.Counterparty.Id, out var c) ? c.FullName : r.Counterparty.FullName) + .Select(r => counterparties.TryGetValue(r.CounterpartyId, out var c) ? c.FullName : r.Counterparty?.FullName) .Where(name => !string.IsNullOrEmpty(name)) .Distinct() .Order() @@ -45,10 +45,10 @@ public async Task GetTop5ClientsByRequestCountAsync() var topPurchaseClients = requests .Where(r => r.Type == RequestType.Purchase) - .GroupBy(r => r.Counterparty.Id) + .GroupBy(r => r.CounterpartyId) .Select(g => new TopClientDto { - FullName = counterparties.TryGetValue(g.Key, out var c) ? c.FullName : g.First().Counterparty.FullName, + FullName = counterparties.TryGetValue(g.Key, out var c) ? c.FullName : g.First().Counterparty?.FullName ?? "", RequestCount = g.Count() }) .Where(x => !string.IsNullOrEmpty(x.FullName)) @@ -58,10 +58,10 @@ public async Task GetTop5ClientsByRequestCountAsync() var topSaleClients = requests .Where(r => r.Type == RequestType.Sale) - .GroupBy(r => r.Counterparty.Id) + .GroupBy(r => r.CounterpartyId) .Select(g => new TopClientDto { - FullName = counterparties.TryGetValue(g.Key, out var c) ? c.FullName : g.First().Counterparty.FullName, + FullName = counterparties.TryGetValue(g.Key, out var c) ? c.FullName : g.First().Counterparty?.FullName ?? "", RequestCount = g.Count() }) .Where(x => !string.IsNullOrEmpty(x.FullName)) @@ -89,7 +89,7 @@ public async Task> GetRequestCountByPrope .. requests .Select(r => new { - PropertyType = properties.TryGetValue(r.Property.Id, out var p) ? p.Type : r.Property.Type + PropertyType = properties.TryGetValue(r.PropertyId, out var p) ? p.Type : r.Property?.Type ?? default }) .GroupBy(x => x.PropertyType) .Select(g => new PropertyTypeStatisticsDto @@ -113,7 +113,7 @@ public async Task GetClientsWithMinAmountAsync() var clients = requests .Where(r => r.Amount == minAmount) - .Select(r => counterparties.TryGetValue(r.Counterparty.Id, out var c) ? c.FullName : r.Counterparty.FullName) + .Select(r => counterparties.TryGetValue(r.CounterpartyId, out var c) ? c.FullName : r.Counterparty?.FullName) .Where(name => !string.IsNullOrEmpty(name)) .Distinct() .Order(); @@ -138,8 +138,8 @@ public async Task> GetClientsSeekingPropertyTypeAsync(Proper [ .. requests .Where(r => r.Type == RequestType.Purchase && - (properties.TryGetValue(r.Property.Id, out var p) ? p.Type : r.Property.Type) == propertyType) - .Select(r => counterparties.TryGetValue(r.Counterparty.Id, out var c) ? c.FullName : r.Counterparty.FullName) + (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/RequestService.cs b/RealEstateAgency.Application/Services/RequestService.cs index 3751a1513..aed6775be 100644 --- a/RealEstateAgency.Application/Services/RequestService.cs +++ b/RealEstateAgency.Application/Services/RequestService.cs @@ -43,7 +43,9 @@ public async Task> GetAllAsync() var request = new Request { Id = Guid.Empty, + CounterpartyId = counterparty.Id, Counterparty = counterparty, + PropertyId = property.Id, Property = property, Type = dto.Type, Amount = dto.Amount, @@ -72,7 +74,9 @@ public async Task> GetAllAsync() var request = new Request { Id = id, + CounterpartyId = counterparty.Id, Counterparty = counterparty, + PropertyId = property.Id, Property = property, Type = dto.Type, Amount = dto.Amount, diff --git a/RealEstateAgency.Contracts/Dto/CreateRealEstatePropertyDto.cs b/RealEstateAgency.Contracts/Dto/CreateRealEstatePropertyDto.cs index 7d484236c..ee2d67db8 100644 --- a/RealEstateAgency.Contracts/Dto/CreateRealEstatePropertyDto.cs +++ b/RealEstateAgency.Contracts/Dto/CreateRealEstatePropertyDto.cs @@ -1,5 +1,5 @@ -using RealEstateAgency.Domain.Enums; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using RealEstateAgency.Domain.Enums; namespace RealEstateAgency.Contracts.Dto; diff --git a/RealEstateAgency.Contracts/Dto/CreateRequestDto.cs b/RealEstateAgency.Contracts/Dto/CreateRequestDto.cs index f6361bc75..3582dfa27 100644 --- a/RealEstateAgency.Contracts/Dto/CreateRequestDto.cs +++ b/RealEstateAgency.Contracts/Dto/CreateRequestDto.cs @@ -1,5 +1,5 @@ -using RealEstateAgency.Domain.Enums; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using RealEstateAgency.Domain.Enums; namespace RealEstateAgency.Contracts.Dto; diff --git a/RealEstateAgency.Contracts/Dto/UpdateRealEstatePropertyDto.cs b/RealEstateAgency.Contracts/Dto/UpdateRealEstatePropertyDto.cs index 2a0bc8a48..0a5d82484 100644 --- a/RealEstateAgency.Contracts/Dto/UpdateRealEstatePropertyDto.cs +++ b/RealEstateAgency.Contracts/Dto/UpdateRealEstatePropertyDto.cs @@ -1,5 +1,5 @@ -using RealEstateAgency.Domain.Enums; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using RealEstateAgency.Domain.Enums; namespace RealEstateAgency.Contracts.Dto; diff --git a/RealEstateAgency.Contracts/Dto/UpdateRequestDto.cs b/RealEstateAgency.Contracts/Dto/UpdateRequestDto.cs index 05f483741..fdd1c2d5c 100644 --- a/RealEstateAgency.Contracts/Dto/UpdateRequestDto.cs +++ b/RealEstateAgency.Contracts/Dto/UpdateRequestDto.cs @@ -1,5 +1,5 @@ -using RealEstateAgency.Domain.Enums; -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using RealEstateAgency.Domain.Enums; namespace RealEstateAgency.Contracts.Dto; diff --git a/RealEstateAgency.Domain/Enums/PropertyPurpose.cs b/RealEstateAgency.Domain/Enums/PropertyPurpose.cs index 96eda9ba6..c0316813c 100644 --- a/RealEstateAgency.Domain/Enums/PropertyPurpose.cs +++ b/RealEstateAgency.Domain/Enums/PropertyPurpose.cs @@ -1,23 +1,23 @@ namespace RealEstateAgency.Domain.Enums; /// -/// Purpose of the property -/// Defines the main purpose of using the property +/// Назначение объекта недвижимости +/// Определяет основную цель использования объекта недвижимости /// public enum PropertyPurpose { /// - /// Residential purpose - for human habitation + /// Жилое назначение - для проживания людей /// Residential, /// - /// Commercial purpose - for business activities + /// Коммерческое назначение - для осуществления предпринимательской деятельности /// Commercial, /// - /// Industrial use - for production activities + /// Промышленное использование - для производственной деятельности /// Industrial } diff --git a/RealEstateAgency.Domain/Enums/PropertyType.cs b/RealEstateAgency.Domain/Enums/PropertyType.cs index bd1b8d8b8..191f303e0 100644 --- a/RealEstateAgency.Domain/Enums/PropertyType.cs +++ b/RealEstateAgency.Domain/Enums/PropertyType.cs @@ -1,38 +1,38 @@ namespace RealEstateAgency.Domain.Enums; /// -/// Property type -/// Classifies property by physical characteristics +/// Тип недвижимости +/// Классифицирует имущество по физическим характеристикам /// public enum PropertyType { /// - /// Apartment in an apartment building + /// Квартира в многоквартирном доме /// Apartment, /// - /// Detached apartment building + /// Отдельно стоящее многоквартирное здание /// House, /// - /// A blockaded apartment building with separate entrances + /// Блокированный многоквартирный дом с отдельными входами /// Townhouse, /// - /// Commercial premises for business + /// Коммерческие помещения для ведения бизнеса /// Commercial, /// - /// Warehouse or production premises + /// Складские или производственные помещения /// Warehouse, /// - /// A place for parking vehicles + /// Место для парковки транспортных средств /// ParkingSpace } diff --git a/RealEstateAgency.Domain/Enums/RequestType.cs b/RealEstateAgency.Domain/Enums/RequestType.cs index 283799a93..c460891a2 100644 --- a/RealEstateAgency.Domain/Enums/RequestType.cs +++ b/RealEstateAgency.Domain/Enums/RequestType.cs @@ -1,18 +1,18 @@ namespace RealEstateAgency.Domain.Enums; /// -/// The type of application in the real estate agency -/// Determines the direction of the real estate transaction +/// Тип заявления в агентство недвижимости +/// Определяет направление сделки с недвижимостью /// public enum RequestType { /// - /// Application for the purchase of real estate + /// Заявка на покупку недвижимости /// Purchase, /// - /// Application for real estate sale + /// Заявка на продажу недвижимости /// Sale } diff --git a/RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs b/RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs index 0ed6cd14e..e79189668 100644 --- a/RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs +++ b/RealEstateAgency.Domain/Interfaces/ICounterpartyRepository.cs @@ -5,6 +5,5 @@ namespace RealEstateAgency.Domain.Interfaces; /// /// Интерфейс репозитория контрагентов /// -public interface ICounterpartyRepository : IRepository -{ -} +public interface ICounterpartyRepository : IRepository; + diff --git a/RealEstateAgency.Domain/Interfaces/IRealEstatePropertyRepository.cs b/RealEstateAgency.Domain/Interfaces/IRealEstatePropertyRepository.cs index b91358780..7c0248d34 100644 --- a/RealEstateAgency.Domain/Interfaces/IRealEstatePropertyRepository.cs +++ b/RealEstateAgency.Domain/Interfaces/IRealEstatePropertyRepository.cs @@ -5,6 +5,4 @@ namespace RealEstateAgency.Domain.Interfaces; /// /// Интерфейс репозитория объектов недвижимости /// -public interface IRealEstatePropertyRepository : IRepository -{ -} +public interface IRealEstatePropertyRepository : IRepository; diff --git a/RealEstateAgency.Domain/Interfaces/IRequestRepository.cs b/RealEstateAgency.Domain/Interfaces/IRequestRepository.cs index f6bdf0e83..0c8b3c09c 100644 --- a/RealEstateAgency.Domain/Interfaces/IRequestRepository.cs +++ b/RealEstateAgency.Domain/Interfaces/IRequestRepository.cs @@ -5,6 +5,4 @@ namespace RealEstateAgency.Domain.Interfaces; /// /// Интерфейс репозитория заявок /// -public interface IRequestRepository : IRepository -{ -} +public interface IRequestRepository : IRepository; diff --git a/RealEstateAgency.Domain/Models/Counterparty.cs b/RealEstateAgency.Domain/Models/Counterparty.cs index fb68ef3e8..d131c2c96 100644 --- a/RealEstateAgency.Domain/Models/Counterparty.cs +++ b/RealEstateAgency.Domain/Models/Counterparty.cs @@ -1,28 +1,28 @@ namespace RealEstateAgency.Domain.Models; /// -/// The counterparty of the real estate agency -/// An individual involved in real estate transactions +/// Контрагент агентства недвижимости +/// Физическое лицо, участвующее в сделках с недвижимостью /// public class Counterparty { /// - /// The unique identifier of the counterparty + /// Уникальный идентификатор контрагента /// public Guid Id { get; set; } /// - /// The counterparty's full name in the "Last Name, First Name, Patronymic" format + /// Полное наименование контрагента в формате "Фамилия, имя, отчество" /// public required string FullName { get; set; } /// - /// Passport number for identification + /// Номер паспорта для идентификации личности /// public required string PassportNumber { get; set; } /// - /// Contact phone number for communication + /// Контактный телефон для связи /// public required string PhoneNumber { get; set; } } diff --git a/RealEstateAgency.Domain/Models/RealEstateProperty.cs b/RealEstateAgency.Domain/Models/RealEstateProperty.cs index cb8fa30e3..18ec96b03 100644 --- a/RealEstateAgency.Domain/Models/RealEstateProperty.cs +++ b/RealEstateAgency.Domain/Models/RealEstateProperty.cs @@ -3,63 +3,63 @@ namespace RealEstateAgency.Domain.Models; /// -/// The real estate object -/// Describes the physical characteristics of the property +/// Объект недвижимости +/// Описывает физические характеристики объекта недвижимости /// public class RealEstateProperty { /// - /// The unique identifier of the object + /// Уникальный идентификатор объекта /// public Guid Id { get; set; } /// - /// Property type + /// Тип недвижимости /// public required PropertyType Type { get; set; } /// - /// Purpose of the property + /// Назначение объекта недвижимости /// public required PropertyPurpose Purpose { get; set; } /// - /// A unique identifier in the state registry + /// Уникальный идентификатор в государственном реестре /// public required string CadastralNumber { get; set; } /// - /// The physical address of the object location + /// Физический адрес местоположения объекта /// public required string Address { get; set; } /// - /// Total number of floors of the building + /// Общее количество этажей в здании /// public int? TotalFloors { get; set; } /// - /// The total area of the facility in square meters + /// Общая площадь объекта в квадратных метрах /// public required double TotalArea { get; set; } /// - /// Number of rooms in the facility + /// Количество комнат в комплексе /// public int? RoomsCount { get; set; } /// - /// Ceiling height in meters + /// Высота потолков в метрах /// public double? CeilingHeight { get; set; } /// - /// The floor of the object location + /// Этаж расположения объекта /// public int? Floor { get; set; } /// - /// The presence of legal encumbrances (collateral, arrest, mortgage) + /// Наличие юридических обременений (залог, арест, ипотека) /// public bool? HasEncumbrances { get; set; } } diff --git a/RealEstateAgency.Domain/Models/Request.cs b/RealEstateAgency.Domain/Models/Request.cs index edad0379a..acdcb7143 100644 --- a/RealEstateAgency.Domain/Models/Request.cs +++ b/RealEstateAgency.Domain/Models/Request.cs @@ -3,38 +3,48 @@ namespace RealEstateAgency.Domain.Models; /// -/// Application for a real estate transaction -/// It is an agreement between the counterparty and the agency. +/// Заявка на совершение сделки с недвижимостью +/// Это соглашение между контрагентом и агентством. /// public class Request { /// - /// The unique identifier of the application + /// Уникальный идентификатор приложения /// public Guid Id { get; set; } /// - /// The counterparty who submitted the application + /// Идентификатор контрагента (внешний ключ) /// - public required Counterparty Counterparty { get; set; } + public Guid CounterpartyId { get; set; } /// - /// The real estate object associated with the application + /// Контрагент, подавший заявку /// - public required RealEstateProperty Property { get; set; } + public required Counterparty Counterparty { get; set; } = null!; /// - /// Type of operation: purchase or sale + /// Идентификатор объекта недвижимости (внешний ключ) + /// + public Guid PropertyId { get; set; } + + /// + /// Объект недвижимости, связанный с приложением + /// + public required RealEstateProperty Property { get; set; } = null!; + + /// + /// Тип операции: покупка или продажа /// public required RequestType Type { get; set; } /// - /// The amount of money for the application in rubles + /// Денежная сумма для подачи заявки в рублях /// public required decimal Amount { get; set; } /// - /// Application submission date + /// Дата подачи заявки /// public required DateTime Date { get; set; } } diff --git a/RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs b/RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs index 99f71f85d..05489fb54 100644 --- a/RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs +++ b/RealEstateAgency.Infrastructure/Persistence/DatabaseSeeder.cs @@ -78,20 +78,20 @@ private static List GenerateProperties() => private static List GenerateRequests(List counterparties, List properties) => [ - 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) } + 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 index 4397c0d4a..5e42ffd8b 100644 --- a/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs +++ b/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs @@ -7,7 +7,7 @@ namespace RealEstateAgency.Infrastructure.Persistence; /// /// Контекст базы данных для работы с MongoDB через EF Core /// -public class RealEstateDbContext : DbContext +public class RealEstateDbContext(DbContextOptions options) : DbContext(options) { /// /// Коллекция контрагентов @@ -24,13 +24,6 @@ public class RealEstateDbContext : DbContext /// public DbSet Requests { get; set; } = null!; - /// - /// Конструктор контекста - /// - public RealEstateDbContext(DbContextOptions options) : base(options) - { - } - /// /// Конфигурация моделей для MongoDB /// @@ -54,6 +47,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToCollection("requests"); entity.HasKey(r => r.Id); + + entity.Ignore(r => r.Counterparty); + entity.Ignore(r => r.Property); }); } } diff --git a/RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj b/RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj index df30da1f2..c978f1c76 100644 --- a/RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj +++ b/RealEstateAgency.Infrastructure/RealEstateAgency.Infrastructure.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -9,7 +9,7 @@ - + diff --git a/RealEstateAgency.Infrastructure/Repositories/InMemoryRequestRepository.cs b/RealEstateAgency.Infrastructure/Repositories/InMemoryRequestRepository.cs index aba6be9ac..b24e80e3e 100644 --- a/RealEstateAgency.Infrastructure/Repositories/InMemoryRequestRepository.cs +++ b/RealEstateAgency.Infrastructure/Repositories/InMemoryRequestRepository.cs @@ -28,21 +28,21 @@ private async Task SeedDataAsync() var seedData = new[] { - new Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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 Request { 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) } + 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); @@ -73,7 +73,9 @@ public async Task AddAsync(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; diff --git a/RealEstateAgency.Infrastructure/Repositories/MongoRequestRepository.cs b/RealEstateAgency.Infrastructure/Repositories/MongoRequestRepository.cs index a87713cae..504ac8d44 100644 --- a/RealEstateAgency.Infrastructure/Repositories/MongoRequestRepository.cs +++ b/RealEstateAgency.Infrastructure/Repositories/MongoRequestRepository.cs @@ -12,18 +12,30 @@ public class MongoRequestRepository(RealEstateDbContext context) : IRequestRepos { public async Task> GetAllAsync() { - return await context.Requests.ToListAsync(); + var requests = await context.Requests.ToListAsync(); + await PopulateNavigationPropertiesAsync(requests); + return requests; } public async Task GetByIdAsync(Guid id) { - return await context.Requests.FirstOrDefaultAsync(r => r.Id == 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; @@ -35,13 +47,17 @@ public async Task AddAsync(Request request) if (existing == null) return null; - existing.Counterparty = request.Counterparty; - existing.Property = request.Property; + 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; } @@ -55,4 +71,33 @@ public async Task DeleteAsync(Guid id) 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/RealEstateAgency.ServiceDefaults.csproj b/RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj index 9f4d04856..92525a4b6 100644 --- a/RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj +++ b/RealEstateAgency.ServiceDefaults/RealEstateAgency.ServiceDefaults.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs b/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs index adaff3070..1f3588603 100644 --- a/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs +++ b/RealEstateAgency.WebApi.Tests/AnalyticsControllerTests.cs @@ -7,14 +7,10 @@ namespace RealEstateAgency.WebApi.Tests; /// /// /// -public class AnalyticsControllerTests : IClassFixture +public class AnalyticsControllerTests(RealEstateWebApplicationFactory factory) + : IClassFixture { - private readonly HttpClient _client; - - public AnalyticsControllerTests(RealEstateWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } + private readonly HttpClient _client = factory.CreateClient(); /// /// : diff --git a/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs b/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs index a53ec8b54..3ca0a5d25 100644 --- a/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs +++ b/RealEstateAgency.WebApi.Tests/CounterpartiesControllerTests.cs @@ -7,16 +7,12 @@ namespace RealEstateAgency.WebApi.Tests; /// /// Тесты CRUD операций для контрагентов /// -public class CounterpartiesControllerTests : IClassFixture +public class CounterpartiesControllerTests(RealEstateWebApplicationFactory factory) + : IClassFixture { - private readonly HttpClient _client; + private readonly HttpClient _client = factory.CreateClient(); private static readonly Guid _testCounterpartyId = Guid.Parse("00000000-0000-0000-0000-000000000001"); - public CounterpartiesControllerTests(RealEstateWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - /// /// GET /api/counterparties — получение всех контрагентов /// diff --git a/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs b/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs index 8f72cde83..9b6233e23 100644 --- a/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs +++ b/RealEstateAgency.WebApi.Tests/MongoAnalyticsTests.cs @@ -8,14 +8,9 @@ namespace RealEstateAgency.WebApi.Tests; /// Интеграционные тесты аналитических запросов с реальной MongoDB /// [Collection("MongoDB")] -public class MongoAnalyticsTests : IClassFixture +public class MongoAnalyticsTests(MongoDbWebApplicationFactory factory) : IClassFixture { - private readonly HttpClient _client; - - public MongoAnalyticsTests(MongoDbWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } + private readonly HttpClient _client = factory.CreateClient(); /// /// Тест аналитики продавцов за период с MongoDB diff --git a/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs b/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs index b1924a2e0..59718ef08 100644 --- a/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs +++ b/RealEstateAgency.WebApi.Tests/MongoCounterpartiesTests.cs @@ -8,14 +8,10 @@ namespace RealEstateAgency.WebApi.Tests; /// Интеграционные тесты CRUD операций для контрагентов с реальной MongoDB /// [Collection("MongoDB")] -public class MongoCounterpartiesTests : IClassFixture +public class MongoCounterpartiesTests(MongoDbWebApplicationFactory factory) + : IClassFixture { - private readonly HttpClient _client; - - public MongoCounterpartiesTests(MongoDbWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } + private readonly HttpClient _client = factory.CreateClient(); /// /// Полный CRUD цикл для контрагента в MongoDB diff --git a/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs b/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs index b0907f267..44de81cea 100644 --- a/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs +++ b/RealEstateAgency.WebApi.Tests/MongoDbCollection.cs @@ -1,10 +1,35 @@ -namespace RealEstateAgency.WebApi.Tests; +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 -{ -} +public class MongoDbCollection : ICollectionFixture; diff --git a/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs b/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs index a5891cc51..000113c06 100644 --- a/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs +++ b/RealEstateAgency.WebApi.Tests/MongoDbWebApplicationFactory.cs @@ -25,7 +25,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { - // Удаляем существующие регистрации var descriptorsToRemove = services .Where(d => d.ServiceType == typeof(ICounterpartyRepository) || d.ServiceType == typeof(IRealEstatePropertyRepository) || @@ -41,17 +40,14 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.Remove(descriptor); } - // Регистрируем MongoDB клиент var mongoClient = new MongoClient(ConnectionString); services.AddSingleton(mongoClient); - // Регистрируем DbContext с MongoDB провайдером services.AddDbContext(options => { options.UseMongoDB(mongoClient, "realestatedb_test"); }); - // Регистрируем репозитории services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs b/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs index 5c65ff1b8..e277cbcc8 100644 --- a/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs +++ b/RealEstateAgency.WebApi.Tests/MongoPropertiesTests.cs @@ -9,14 +9,9 @@ namespace RealEstateAgency.WebApi.Tests; /// Интеграционные тесты CRUD операций для объектов недвижимости с реальной MongoDB /// [Collection("MongoDB")] -public class MongoPropertiesTests : IClassFixture +public class MongoPropertiesTests(MongoDbWebApplicationFactory factory) : IClassFixture { - private readonly HttpClient _client; - - public MongoPropertiesTests(MongoDbWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } + private readonly HttpClient _client = factory.CreateClient(); /// /// Полный CRUD цикл для объекта недвижимости в MongoDB diff --git a/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs b/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs index cd554a326..25c0b7c5b 100644 --- a/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs +++ b/RealEstateAgency.WebApi.Tests/MongoRequestsTests.cs @@ -9,14 +9,9 @@ namespace RealEstateAgency.WebApi.Tests; /// Интеграционные тесты CRUD операций для заявок с реальной MongoDB /// [Collection("MongoDB")] -public class MongoRequestsTests : IClassFixture +public class MongoRequestsTests(MongoDbWebApplicationFactory factory) : IClassFixture { - private readonly HttpClient _client; - - public MongoRequestsTests(MongoDbWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } + private readonly HttpClient _client = factory.CreateClient(); /// /// Полный CRUD цикл для заявки в MongoDB diff --git a/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs b/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs index 0590834bf..36fd16fb9 100644 --- a/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs +++ b/RealEstateAgency.WebApi.Tests/PropertiesControllerTests.cs @@ -8,16 +8,12 @@ namespace RealEstateAgency.WebApi.Tests; /// /// Тесты CRUD операций для объектов недвижимости /// -public class PropertiesControllerTests : IClassFixture +public class PropertiesControllerTests(RealEstateWebApplicationFactory factory) + : IClassFixture { - private readonly HttpClient _client; + private readonly HttpClient _client = factory.CreateClient(); private static readonly Guid _testPropertyId = Guid.Parse("10000000-0000-0000-0000-000000000001"); - public PropertiesControllerTests(RealEstateWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - /// /// GET /api/properties — получение всех объектов /// diff --git a/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj b/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj index 7b2d4e461..7693bb9b0 100644 --- a/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj +++ b/RealEstateAgency.WebApi.Tests/RealEstateAgency.WebApi.Tests.csproj @@ -19,7 +19,6 @@ - diff --git a/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs b/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs index b680194ba..dc4f7ae7e 100644 --- a/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs +++ b/RealEstateAgency.WebApi.Tests/RequestsControllerTests.cs @@ -8,18 +8,14 @@ namespace RealEstateAgency.WebApi.Tests; /// /// Тесты CRUD операций для заявок /// -public class RequestsControllerTests : IClassFixture +public class RequestsControllerTests(RealEstateWebApplicationFactory factory) + : IClassFixture { - private readonly HttpClient _client; + 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"); - public RequestsControllerTests(RealEstateWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - /// /// GET /api/requests — получение всех заявок /// diff --git a/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs index d07335321..e7cb0ca71 100644 --- a/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs +++ b/RealEstateAgency.WebApi/Controllers/AnalyticsController.cs @@ -20,14 +20,23 @@ public class AnalyticsController(IAnalyticsService analyticsService, ILoggerСписок ФИО продавцов [HttpGet("sellers")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task>> GetSellersInPeriod( [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { - logger.LogInformation("Запрос продавцов за период {StartDate} - {EndDate}", startDate, endDate); - var sellers = await analyticsService.GetSellersInPeriodAsync(startDate, endDate); - logger.LogInformation("Найдено {Count} продавцов", sellers.Count()); - return Ok(sellers); + 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, "Внутренняя ошибка сервера"); + } } /// @@ -36,11 +45,20 @@ public async Task>> GetSellersInPeriod( /// Топ-5 покупателей и топ-5 продавцов [HttpGet("top-clients")] [ProducesResponseType(typeof(Top5ClientsResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> GetTop5Clients() { - logger.LogInformation("Запрос топ-5 клиентов"); - var result = await analyticsService.GetTop5ClientsByRequestCountAsync(); - return Ok(result); + try + { + logger.LogInformation("Запрос топ-5 клиентов"); + var result = await analyticsService.GetTop5ClientsByRequestCountAsync(); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении топ-5 клиентов"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -49,11 +67,20 @@ public async Task> GetTop5Clients() /// Количество заявок по каждому типу недвижимости [HttpGet("property-type-statistics")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task>> GetPropertyTypeStatistics() { - logger.LogInformation("Запрос статистики по типам недвижимости"); - var statistics = await analyticsService.GetRequestCountByPropertyTypeAsync(); - return Ok(statistics); + try + { + logger.LogInformation("Запрос статистики по типам недвижимости"); + var statistics = await analyticsService.GetRequestCountByPropertyTypeAsync(); + return Ok(statistics); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении статистики по типам недвижимости"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -62,11 +89,20 @@ public async Task>> GetPrope /// Информация о клиентах с минимальной суммой [HttpGet("min-amount-clients")] [ProducesResponseType(typeof(ClientWithMinAmountDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> GetClientsWithMinAmount() { - logger.LogInformation("Запрос клиентов с минимальной суммой заявки"); - var result = await analyticsService.GetClientsWithMinAmountAsync(); - return Ok(result); + try + { + logger.LogInformation("Запрос клиентов с минимальной суммой заявки"); + var result = await analyticsService.GetClientsWithMinAmountAsync(); + return Ok(result); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении клиентов с минимальной суммой заявки"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -76,12 +112,21 @@ public async Task> GetClientsWithMinAmount( /// Список ФИО клиентов [HttpGet("clients-by-property-type")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task>> GetClientsByPropertyType( [FromQuery] PropertyType propertyType) { - logger.LogInformation("Запрос клиентов, ищущих недвижимость типа {PropertyType}", propertyType); - var clients = await analyticsService.GetClientsSeekingPropertyTypeAsync(propertyType); - logger.LogInformation("Найдено {Count} клиентов", clients.Count()); - return Ok(clients); + 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 index 52eadd3c0..23bc528b0 100644 --- a/RealEstateAgency.WebApi/Controllers/BaseCrudController.cs +++ b/RealEstateAgency.WebApi/Controllers/BaseCrudController.cs @@ -25,12 +25,21 @@ public abstract class BaseCrudController /// [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public virtual async Task>> GetAll() { - Logger.LogInformation("Запрос на получение всех сущностей"); - var entities = await Service.GetAllAsync(); - Logger.LogInformation("Возвращено {Count} сущностей", entities.Count()); - return Ok(entities); + 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, "Внутренняя ошибка сервера"); + } } /// @@ -39,17 +48,26 @@ public virtual async Task>> GetAll() [HttpGet("{id:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public virtual async Task> GetById(Guid id) { - Logger.LogInformation("Запрос на получение сущности с ID {Id}", id); - var entity = await Service.GetByIdAsync(id); - if (entity == null) + try { - Logger.LogWarning("Сущность с ID {Id} не найдена", id); - return NotFound($"Сущность с ID {id} не найдена"); - } + 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); + return Ok(entity); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при получении сущности с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -58,19 +76,28 @@ public virtual async Task> GetById(Guid id) [HttpPost] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public virtual async Task> Create([FromBody] TCreateDto dto) { - if (!ModelState.IsValid) + try { - Logger.LogWarning("Ошибка валидации при создании сущности: {Errors}", ModelState); - return BadRequest(ModelState); - } + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при создании сущности: {Errors}", ModelState); + return BadRequest(ModelState); + } - Logger.LogInformation("Создание сущности"); - var created = await Service.CreateAsync(dto); - Logger.LogInformation("Сущность создана"); + Logger.LogInformation("Создание сущности"); + var created = await Service.CreateAsync(dto); + Logger.LogInformation("Сущность создана"); - return CreatedAtAction(nameof(GetById), new { id = GetEntityId(created) }, created); + return CreatedAtAction(nameof(GetById), new { id = GetEntityId(created) }, created); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при создании сущности"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -79,18 +106,27 @@ public virtual async Task> Create([FromBody] TCreateDto dto) [HttpDelete("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public virtual async Task Delete(Guid id) { - Logger.LogInformation("Удаление сущности с ID {Id}", id); - var deleted = await Service.DeleteAsync(id); - if (!deleted) + try { - Logger.LogWarning("Сущность с ID {Id} не найдена для удаления", id); - return NotFound($"Сущность с ID {id} не найдена"); - } + 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(); + 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/CounterpartiesController.cs b/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs index 8bb2fe929..0c4a4b9e3 100644 --- a/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs +++ b/RealEstateAgency.WebApi/Controllers/CounterpartiesController.cs @@ -21,12 +21,21 @@ public class CounterpartiesController( /// Список контрагентов [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public override async Task>> GetAll() { - Logger.LogInformation("Запрос на получение всех контрагентов"); - var counterparties = await Service.GetAllAsync(); - Logger.LogInformation("Возвращено {Count} контрагентов", counterparties.Count()); - return Ok(counterparties); + 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, "Внутренняя ошибка сервера"); + } } /// @@ -37,17 +46,26 @@ public override async Task>> GetAll() [HttpGet("{id:guid}")] [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public override async Task> GetById(Guid id) { - Logger.LogInformation("Запрос на получение контрагента с ID {Id}", id); - var counterparty = await Service.GetByIdAsync(id); - if (counterparty == null) + try { - Logger.LogWarning("Контрагент с ID {Id} не найден", id); - return NotFound($"Контрагент с ID {id} не найден"); - } + 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); + return Ok(counterparty); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при получении контрагента с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -58,19 +76,28 @@ public override async Task> GetById(Guid id) [HttpPost] [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public override async Task> Create([FromBody] CreateCounterpartyDto dto) { - if (!ModelState.IsValid) + try { - Logger.LogWarning("Ошибка валидации при создании контрагента: {Errors}", ModelState); - return BadRequest(ModelState); - } + 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); + 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); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при создании контрагента"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -83,25 +110,34 @@ public override async Task> Create([FromBody] Crea [ProducesResponseType(typeof(CounterpartyDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> Update(Guid id, [FromBody] UpdateCounterpartyDto dto) { - if (!ModelState.IsValid) + try { - Logger.LogWarning("Ошибка валидации при обновлении контрагента {Id}: {Errors}", id, ModelState); - return BadRequest(ModelState); - } + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при обновлении контрагента {Id}: {Errors}", id, ModelState); + return BadRequest(ModelState); + } - Logger.LogInformation("Обновление контрагента с ID {Id}", id); - var updated = await Service.UpdateAsync(id, dto); + Logger.LogInformation("Обновление контрагента с ID {Id}", id); + var updated = await Service.UpdateAsync(id, dto); - if (updated == null) + if (updated == null) + { + Logger.LogWarning("Контрагент с ID {Id} не найден для обновления", id); + return NotFound($"Контрагент с ID {id} не найден"); + } + + Logger.LogInformation("Контрагент с ID {Id} успешно обновлен", id); + return Ok(updated); + } + catch (Exception ex) { - Logger.LogWarning("Контрагент с ID {Id} не найден для обновления", id); - return NotFound($"Контрагент с ID {id} не найден"); + Logger.LogError(ex, "Ошибка при обновлении контрагента с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); } - - Logger.LogInformation("Контрагент с ID {Id} успешно обновлен", id); - return Ok(updated); } /// @@ -112,17 +148,26 @@ public async Task> Update(Guid id, [FromBody] Upda [HttpDelete("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public override async Task Delete(Guid id) { - Logger.LogInformation("Удаление контрагента с ID {Id}", id); - var deleted = await Service.DeleteAsync(id); - if (!deleted) + try { - Logger.LogWarning("Контрагент с ID {Id} не найден для удаления", id); - return NotFound($"Контрагент с ID {id} не найден"); - } + 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(); + 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 index a12846df7..f9ddeccc7 100644 --- a/RealEstateAgency.WebApi/Controllers/PropertiesController.cs +++ b/RealEstateAgency.WebApi/Controllers/PropertiesController.cs @@ -21,12 +21,21 @@ public class PropertiesController( /// Список объектов недвижимости [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public override async Task>> GetAll() { - Logger.LogInformation("Запрос на получение всех объектов недвижимости"); - var properties = await Service.GetAllAsync(); - Logger.LogInformation("Возвращено {Count} объектов недвижимости", properties.Count()); - return Ok(properties); + 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, "Внутренняя ошибка сервера"); + } } /// @@ -37,17 +46,26 @@ public override async Task>> Get [HttpGet("{id:guid}")] [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public override async Task> GetById(Guid id) { - Logger.LogInformation("Запрос на получение объекта недвижимости с ID {Id}", id); - var property = await Service.GetByIdAsync(id); - if (property == null) + try { - Logger.LogWarning("Объект недвижимости с ID {Id} не найден", id); - return NotFound($"Объект недвижимости с ID {id} не найден"); - } + 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); + return Ok(property); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при получении объекта недвижимости с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -58,19 +76,28 @@ public override async Task> GetById(Guid id) [HttpPost] [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public override async Task> Create([FromBody] CreateRealEstatePropertyDto dto) { - if (!ModelState.IsValid) + try { - Logger.LogWarning("Ошибка валидации при создании объекта недвижимости: {Errors}", ModelState); - return BadRequest(ModelState); - } + 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); + 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); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + catch (Exception ex) + { + Logger.LogError(ex, "Ошибка при создании объекта недвижимости"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -83,25 +110,34 @@ public override async Task> Create([FromBody [ProducesResponseType(typeof(RealEstatePropertyDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> Update(Guid id, [FromBody] UpdateRealEstatePropertyDto dto) { - if (!ModelState.IsValid) + try { - Logger.LogWarning("Ошибка валидации при обновлении объекта недвижимости {Id}: {Errors}", id, ModelState); - return BadRequest(ModelState); - } + if (!ModelState.IsValid) + { + Logger.LogWarning("Ошибка валидации при обновлении объекта недвижимости {Id}: {Errors}", id, ModelState); + return BadRequest(ModelState); + } - Logger.LogInformation("Обновление объекта недвижимости с ID {Id}", id); - var updated = await Service.UpdateAsync(id, dto); + Logger.LogInformation("Обновление объекта недвижимости с ID {Id}", id); + var updated = await Service.UpdateAsync(id, dto); - if (updated == null) + if (updated == null) + { + Logger.LogWarning("Объект недвижимости с ID {Id} не найден для обновления", id); + return NotFound($"Объект недвижимости с ID {id} не найден"); + } + + Logger.LogInformation("Объект недвижимости с ID {Id} успешно обновлен", id); + return Ok(updated); + } + catch (Exception ex) { - Logger.LogWarning("Объект недвижимости с ID {Id} не найден для обновления", id); - return NotFound($"Объект недвижимости с ID {id} не найден"); + Logger.LogError(ex, "Ошибка при обновлении объекта недвижимости с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); } - - Logger.LogInformation("Объект недвижимости с ID {Id} успешно обновлен", id); - return Ok(updated); } /// @@ -112,17 +148,26 @@ public async Task> Update(Guid id, [FromBody [HttpDelete("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public override async Task Delete(Guid id) { - Logger.LogInformation("Удаление объекта недвижимости с ID {Id}", id); - var deleted = await Service.DeleteAsync(id); - if (!deleted) + try { - Logger.LogWarning("Объект недвижимости с ID {Id} не найден для удаления", id); - return NotFound($"Объект недвижимости с ID {id} не найден"); - } + 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(); + 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 index 586f4c05e..00cc69f7d 100644 --- a/RealEstateAgency.WebApi/Controllers/RequestsController.cs +++ b/RealEstateAgency.WebApi/Controllers/RequestsController.cs @@ -17,12 +17,21 @@ public class RequestsController(IRequestService service, ILoggerСписок заявок [HttpGet] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task>> GetAll() { - logger.LogInformation("Запрос на получение всех заявок"); - var requests = await service.GetAllAsync(); - logger.LogInformation("Возвращено {Count} заявок", requests.Count()); - return Ok(requests); + 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, "Внутренняя ошибка сервера"); + } } /// @@ -33,17 +42,26 @@ public async Task>> GetAll() [HttpGet("{id:guid}")] [ProducesResponseType(typeof(RequestDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> GetById(Guid id) { - logger.LogInformation("Запрос на получение заявки с ID {Id}", id); - var request = await service.GetByIdAsync(id); - if (request == null) + try { - logger.LogWarning("Заявка с ID {Id} не найдена", id); - return NotFound($"Заявка с ID {id} не найдена"); - } + 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); + return Ok(request); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении заявки с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); + } } /// @@ -55,25 +73,34 @@ public async Task> GetById(Guid id) [ProducesResponseType(typeof(RequestDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> Create([FromBody] CreateRequestDto dto) { - if (!ModelState.IsValid) + try { - logger.LogWarning("Ошибка валидации при создании заявки: {Errors}", ModelState); - return BadRequest(ModelState); - } + if (!ModelState.IsValid) + { + logger.LogWarning("Ошибка валидации при создании заявки: {Errors}", ModelState); + return BadRequest(ModelState); + } + + logger.LogInformation("Создание заявки для контрагента {CounterpartyId}", dto.CounterpartyId); + var (result, error) = await service.CreateAsync(dto); - logger.LogInformation("Создание заявки для контрагента {CounterpartyId}", dto.CounterpartyId); - var (result, error) = await service.CreateAsync(dto); + if (result == null) + { + logger.LogWarning("Ошибка при создании заявки: {Error}", error); + return NotFound(error); + } - if (result == null) + logger.LogInformation("Заявка создана с ID {Id}", result.Id); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (Exception ex) { - logger.LogWarning("Ошибка при создании заявки: {Error}", error); - return NotFound(error); + logger.LogError(ex, "Ошибка при создании заявки"); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); } - - logger.LogInformation("Заявка создана с ID {Id}", result.Id); - return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); } /// @@ -86,25 +113,34 @@ public async Task> Create([FromBody] CreateRequestDto d [ProducesResponseType(typeof(RequestDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> Update(Guid id, [FromBody] UpdateRequestDto dto) { - if (!ModelState.IsValid) + try { - logger.LogWarning("Ошибка валидации при обновлении заявки {Id}: {Errors}", id, ModelState); - return BadRequest(ModelState); - } + 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); + logger.LogInformation("Обновление заявки с ID {Id}", id); + var (result, error) = await service.UpdateAsync(id, dto); - if (result == null) + if (result == null) + { + logger.LogWarning("Ошибка при обновлении заявки {Id}: {Error}", id, error); + return NotFound(error); + } + + logger.LogInformation("Заявка с ID {Id} успешно обновлена", id); + return Ok(result); + } + catch (Exception ex) { - logger.LogWarning("Ошибка при обновлении заявки {Id}: {Error}", id, error); - return NotFound(error); + logger.LogError(ex, "Ошибка при обновлении заявки с ID {Id}", id); + return StatusCode(StatusCodes.Status500InternalServerError, "Внутренняя ошибка сервера"); } - - logger.LogInformation("Заявка с ID {Id} успешно обновлена", id); - return Ok(result); } /// @@ -115,17 +151,26 @@ public async Task> Update(Guid id, [FromBody] UpdateReq [HttpDelete("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task Delete(Guid id) { - logger.LogInformation("Удаление заявки с ID {Id}", id); - var deleted = await service.DeleteAsync(id); - if (!deleted) + try { - logger.LogWarning("Заявка с ID {Id} не найдена для удаления", id); - return NotFound($"Заявка с ID {id} не найдена"); - } + 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(); + 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 index 133b19b44..4413590f2 100644 --- a/RealEstateAgency.WebApi/Program.cs +++ b/RealEstateAgency.WebApi/Program.cs @@ -11,10 +11,10 @@ var builder = WebApplication.CreateBuilder(args); -var useMongoDB = builder.Configuration.GetConnectionString("realestatedb") != null +var useMongoDb = builder.Configuration.GetConnectionString("realestatedb") != null || Environment.GetEnvironmentVariable("ConnectionStrings__realestatedb") != null; -if (useMongoDB) +if (useMongoDb) { builder.AddServiceDefaults(); @@ -71,7 +71,7 @@ var app = builder.Build(); -if (useMongoDB) +if (useMongoDb) { app.MapDefaultEndpoints(); diff --git a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj index 83636a8b3..eb2a408df 100644 --- a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj +++ b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -7,16 +7,13 @@ - + - - - diff --git a/RealEstateAgency.sln b/RealEstateAgency.sln index e863b7960..4116412d7 100644 --- a/RealEstateAgency.sln +++ b/RealEstateAgency.sln @@ -11,16 +11,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.WebApi", " 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.AppHost", "RealEstateAgency.AppHost\RealEstateAgency.AppHost.csproj", "{21B7597A-0E5F-45D7-92D3-721A578B6F0C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.ServiceDefaults", "RealEstateAgency.ServiceDefaults\RealEstateAgency.ServiceDefaults.csproj", "{594B532D-3208-489C-8ECD-268A92D92F3B}" -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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -79,30 +79,6 @@ Global {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 - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|x64.ActiveCfg = Debug|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|x64.Build.0 = Debug|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|x86.ActiveCfg = Debug|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Debug|x86.Build.0 = Debug|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|Any CPU.Build.0 = Release|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|x64.ActiveCfg = Release|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|x64.Build.0 = Release|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|x86.ActiveCfg = Release|Any CPU - {21B7597A-0E5F-45D7-92D3-721A578B6F0C}.Release|x86.Build.0 = Release|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|x64.ActiveCfg = Debug|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|x64.Build.0 = Debug|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|x86.ActiveCfg = Debug|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Debug|x86.Build.0 = Debug|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|Any CPU.Build.0 = Release|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|x64.ActiveCfg = Release|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|x64.Build.0 = Release|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.Release|x86.ActiveCfg = Release|Any CPU - {594B532D-3208-489C-8ECD-268A92D92F3B}.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 @@ -139,6 +115,30 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From a4a2927d1b46e265f45b2b21b843c6fd4a7fb41f Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Tue, 23 Dec 2025 12:39:42 +0400 Subject: [PATCH 29/31] Making edits to the RealEstateDbContext constructor --- .../Persistence/RealEstateDbContext.cs | 10 ++++++++-- RealEstateAgency.WebApi/Program.cs | 6 +++--- RealEstateAgency.WebApi/Properties/launchSettings.json | 2 +- RealEstateAgency.WebApi/appsettings.json | 8 ++++++-- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs b/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs index 5e42ffd8b..f1b7922e6 100644 --- a/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs +++ b/RealEstateAgency.Infrastructure/Persistence/RealEstateDbContext.cs @@ -7,8 +7,14 @@ namespace RealEstateAgency.Infrastructure.Persistence; /// /// Контекст базы данных для работы с MongoDB через EF Core /// -public class RealEstateDbContext(DbContextOptions options) : DbContext(options) +public class RealEstateDbContext : DbContext { + public RealEstateDbContext(DbContextOptions options) + : base(options) + { + Database.AutoTransactionBehavior = AutoTransactionBehavior.Never; + } + /// /// Коллекция контрагентов /// @@ -52,4 +58,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Ignore(r => r.Property); }); } -} +} \ No newline at end of file diff --git a/RealEstateAgency.WebApi/Program.cs b/RealEstateAgency.WebApi/Program.cs index 4413590f2..dd98c6239 100644 --- a/RealEstateAgency.WebApi/Program.cs +++ b/RealEstateAgency.WebApi/Program.cs @@ -11,14 +11,14 @@ var builder = WebApplication.CreateBuilder(args); -var useMongoDb = builder.Configuration.GetConnectionString("realestatedb") != null - || Environment.GetEnvironmentVariable("ConnectionStrings__realestatedb") != null; +var useMongoDb = builder.Configuration.GetConnectionString("MongoDB") != null + || Environment.GetEnvironmentVariable("ConnectionStrings__MongoDB") != null; if (useMongoDb) { builder.AddServiceDefaults(); - builder.AddMongoDBClient("realestatedb"); + builder.AddMongoDBClient("MongoDB"); builder.Services.AddDbContext((serviceProvider, options) => { diff --git a/RealEstateAgency.WebApi/Properties/launchSettings.json b/RealEstateAgency.WebApi/Properties/launchSettings.json index e6c0dba42..3d9805f7c 100644 --- a/RealEstateAgency.WebApi/Properties/launchSettings.json +++ b/RealEstateAgency.WebApi/Properties/launchSettings.json @@ -15,7 +15,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "", "applicationUrl": "http://localhost:5187", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/RealEstateAgency.WebApi/appsettings.json b/RealEstateAgency.WebApi/appsettings.json index 10f68b8c8..f6eaed61d 100644 --- a/RealEstateAgency.WebApi/appsettings.json +++ b/RealEstateAgency.WebApi/appsettings.json @@ -2,8 +2,12 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "RealEstateAgency": "Information" } }, + "ConnectionStrings": { + "MongoDB": "mongodb://localhost:27017/realestatedb" + }, "AllowedHosts": "*" -} +} \ No newline at end of file From eaab0d9cf34b7e976b69d8208faf55f13ca4c660 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Thu, 25 Dec 2025 17:50:48 +0400 Subject: [PATCH 30/31] lab4 done --- README.md | 24 +- RealEstateAgency.AppHost/Program.cs | 13 + .../RealEstateAgency.AppHost.csproj | 4 +- .../ContractGeneratorTests.cs | 296 ++++++++++++++++++ .../DataGeneratorServiceTests.cs | 284 +++++++++++++++++ .../NatsIntegrationTests.cs | 251 +++++++++++++++ .../RealEstateAgency.Generator.Tests.csproj | 30 ++ .../Generators/ContractGenerator.cs | 173 ++++++++++ RealEstateAgency.Generator/Program.cs | 43 +++ .../Properties/launchSettings.json | 12 + .../RealEstateAgency.Generator.csproj | 20 ++ .../Services/DataGeneratorService.cs | 109 +++++++ .../Services/NatsPublisher.cs | 145 +++++++++ .../appsettings.Development.json | 8 + RealEstateAgency.Generator/appsettings.json | 16 + RealEstateAgency.WebApi/Program.cs | 41 ++- .../RealEstateAgency.WebApi.csproj | 4 +- .../Services/NatsSubscriberService.cs | 182 +++++++++++ RealEstateAgency.sln | 28 ++ 19 files changed, 1675 insertions(+), 8 deletions(-) create mode 100644 RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs create mode 100644 RealEstateAgency.Generator.Tests/DataGeneratorServiceTests.cs create mode 100644 RealEstateAgency.Generator.Tests/NatsIntegrationTests.cs create mode 100644 RealEstateAgency.Generator.Tests/RealEstateAgency.Generator.Tests.csproj create mode 100644 RealEstateAgency.Generator/Generators/ContractGenerator.cs create mode 100644 RealEstateAgency.Generator/Program.cs create mode 100644 RealEstateAgency.Generator/Properties/launchSettings.json create mode 100644 RealEstateAgency.Generator/RealEstateAgency.Generator.csproj create mode 100644 RealEstateAgency.Generator/Services/DataGeneratorService.cs create mode 100644 RealEstateAgency.Generator/Services/NatsPublisher.cs create mode 100644 RealEstateAgency.Generator/appsettings.Development.json create mode 100644 RealEstateAgency.Generator/appsettings.json create mode 100644 RealEstateAgency.WebApi/Services/NatsSubscriberService.cs diff --git a/README.md b/README.md index a29d22c8e..bd2d3fe8f 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,14 @@ ### Структура решения * RealEstateAgency.AppHost/ - .NET Aspire оркестратор -* RealEstateAgency.WebApi/ - ASP.NET Core API контроллеры +* 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/ - Интеграционные тесты ### Функциональные возможности @@ -44,6 +46,7 @@ #### CRUD операции - Полное управление контрагентами, объектами недвижимости и заявками - Валидация связанных сущностей при создании/обновлении заявок +- Асинхронное получение данных через NATS #### Аналитические запросы * Продавцы за указанный период @@ -56,6 +59,25 @@ * Двойные репозитории: 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 index bd25102b6..57b61b902 100644 --- a/RealEstateAgency.AppHost/Program.cs +++ b/RealEstateAgency.AppHost/Program.cs @@ -6,10 +6,23 @@ 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/RealEstateAgency.AppHost.csproj b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj index 0a77e4bc9..8d9fb5a3c 100644 --- a/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj +++ b/RealEstateAgency.AppHost/RealEstateAgency.AppHost.csproj @@ -13,10 +13,12 @@ + - + + diff --git a/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs b/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs new file mode 100644 index 000000000..860e6ab4a --- /dev/null +++ b/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs @@ -0,0 +1,296 @@ +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"); + counterparty.PhoneNumber.Should().HaveLength(12, + "Номер телефона должен содержать 12 символов (+7XXXXXXXXXX)"); + } + + [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..384a94dc5 --- /dev/null +++ b/RealEstateAgency.Generator.Tests/DataGeneratorServiceTests.cs @@ -0,0 +1,284 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +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; + + public DataGeneratorServiceTests() + { + _natsContainer = new NatsBuilder() + .WithImage("nats:latest") + .Build(); + + _publisherLoggerMock = new Mock>(); + _serviceLoggerMock = new Mock>(); + } + + 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(DataGeneratorService.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 service = new DataGeneratorService( + _publisher!, + _serviceLoggerMock.Object, + batchSize: 3, + delayBetweenBatchesMs: 100); + + 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(DataGeneratorService.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 service = new DataGeneratorService( + _publisher!, + _serviceLoggerMock.Object, + batchSize: 3, + delayBetweenBatchesMs: 100); + + 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(DataGeneratorService.CounterpartyTopic)) + { + if (!string.IsNullOrEmpty(msg.Data)) + { + Interlocked.Increment(ref counterpartyCount); + CheckCompletion(); + } + } + }); + + var propertySub = Task.Run(async () => + { + await foreach (var msg in subscriber.SubscribeAsync(DataGeneratorService.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 service = new DataGeneratorService( + _publisher!, + _serviceLoggerMock.Object, + batchSize: 5, + delayBetweenBatchesMs: 100); + + 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 service = new DataGeneratorService( + _publisher!, + _serviceLoggerMock.Object, + batchSize: 10, + delayBetweenBatchesMs: 1000); + + 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 service = new DataGeneratorService( + _publisher!, + _serviceLoggerMock.Object, + batchSize: 100, + delayBetweenBatchesMs: 10000); + + service.Should().NotBeNull(); + } +} + +/// +/// Тесты проверки топиков +/// +public class DataGeneratorServiceTopicsTests +{ + [Fact] + public void CounterpartyTopic_ShouldHaveCorrectValue() + { + DataGeneratorService.CounterpartyTopic.Should().Be("realestate.counterparty.created"); + } + + [Fact] + public void PropertyTopic_ShouldHaveCorrectValue() + { + DataGeneratorService.PropertyTopic.Should().Be("realestate.property.created"); + } + + [Fact] + public void RequestTopic_ShouldHaveCorrectValue() + { + DataGeneratorService.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..4a3584552 --- /dev/null +++ b/RealEstateAgency.Generator.Tests/NatsIntegrationTests.cs @@ -0,0 +1,251 @@ +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 subscription = Task.Run(async () => + { + await foreach (var msg in subscriber.SubscribeAsync("test.counterparty")) + { + 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.counterparty", 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/Generators/ContractGenerator.cs b/RealEstateAgency.Generator/Generators/ContractGenerator.cs new file mode 100644 index 000000000..0041e5f4c --- /dev/null +++ b/RealEstateAgency.Generator/Generators/ContractGenerator.cs @@ -0,0 +1,173 @@ +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 string[] _russianCities = + [ + "Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург", "Казань", + "Нижний Новгород", "Челябинск", "Самара", "Омск", "Ростов-на-Дону" + ]; + + private static readonly string[] _russianStreets = + [ + "Ленина", "Мира", "Советская", "Гагарина", "Пушкина", "Комсомольская", + "Центральная", "Победы", "Октябрьская", "Молодёжная", "Садовая", "Парковая" + ]; + + 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 = $"+7{_faker.Random.Number(900, 999)}{_faker.Random.Number(1000000, 9999999)}" + }; + } + + /// + /// Генерирует 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 city = _faker.PickRandom(_russianCities); + var street = _faker.PickRandom(_russianStreets); + var houseNumber = _faker.Random.Number(1, 150); + 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() + }; + } +} + +/// +/// Пакет сгенерированных данных для отправки +/// +public class GeneratedDataPackage +{ + public required CreateCounterpartyDto Counterparty { get; init; } + public required CreateRealEstatePropertyDto Property { get; init; } +} diff --git a/RealEstateAgency.Generator/Program.cs b/RealEstateAgency.Generator/Program.cs new file mode 100644 index 000000000..a46feda6b --- /dev/null +++ b/RealEstateAgency.Generator/Program.cs @@ -0,0 +1,43 @@ +using RealEstateAgency.Generator.Services; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +builder.Logging.SetMinimumLevel(LogLevel.Information); + +var natsConnectionString = builder.Configuration["ConnectionStrings:nats"] + ?? Environment.GetEnvironmentVariable("ConnectionStrings__nats") + ?? "nats://localhost:4222"; + +var batchSize = int.TryParse( + builder.Configuration["Generator:BatchSize"] ?? + Environment.GetEnvironmentVariable("GENERATOR_BATCH_SIZE"), + out var bs) ? bs : 10; + +var delayMs = int.TryParse( + builder.Configuration["Generator:DelayMs"] ?? + Environment.GetEnvironmentVariable("GENERATOR_DELAY_MS"), + out var dm) ? dm : 5000; + +builder.Services.AddSingleton(sp => + new NatsPublisher( + natsConnectionString, + sp.GetRequiredService>())); + +builder.Services.AddHostedService(sp => + new DataGeneratorService( + sp.GetRequiredService(), + sp.GetRequiredService>(), + batchSize, + delayMs)); + +var host = builder.Build(); + +var logger = host.Services.GetRequiredService>(); +logger.LogInformation("=== RealEstateAgency Data Generator ==="); +logger.LogInformation("NATS Connection: {Connection}", natsConnectionString); +logger.LogInformation("Batch Size: {BatchSize}", batchSize); +logger.LogInformation("Delay between batches: {Delay}ms", delayMs); + +await host.RunAsync(); 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..38d5c8324 --- /dev/null +++ b/RealEstateAgency.Generator/RealEstateAgency.Generator.csproj @@ -0,0 +1,20 @@ + + + + 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..10f64ed56 --- /dev/null +++ b/RealEstateAgency.Generator/Services/DataGeneratorService.cs @@ -0,0 +1,109 @@ +using RealEstateAgency.Generator.Generators; + +namespace RealEstateAgency.Generator.Services; + +/// +/// Фоновый сервис для потоковой генерации и отправки контрактов в NATS +/// +public class DataGeneratorService( + NatsPublisher natsPublisher, + ILogger logger, + int batchSize = 10, + int delayBetweenBatchesMs = 5000) : BackgroundService +{ + private readonly ILogger _logger = logger; + private readonly int _batchSize = batchSize; + public const string CounterpartyTopic = "realestate.counterparty.created"; + public const string PropertyTopic = "realestate.property.created"; + public const string RequestTopic = "realestate.request.created"; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation( + "Запуск сервиса генерации данных. Размер пакета: {BatchSize}, задержка: {Delay}мс", + _batchSize, + delayBetweenBatchesMs); + + try + { + await natsPublisher.ConnectAsync(stoppingToken); + + _logger.LogInformation("Начало потоковой генерации контрактов"); + + var totalGenerated = 0; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await GenerateAndSendBatchAsync(_batchSize, stoppingToken); + + totalGenerated += _batchSize; + _logger.LogInformation( + "Сгенерировано и отправлено {BatchSize} контрактов. Всего: {Total}", + _batchSize, + totalGenerated); + + await Task.Delay(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(CounterpartyTopic, data.Counterparty, cancellationToken); + _logger.LogDebug("Отправлен контрагент: {FullName}", data.Counterparty.FullName); + + await Task.Delay(100, cancellationToken); + + await natsPublisher.PublishAsync(PropertyTopic, data.Property, cancellationToken); + _logger.LogDebug("Отправлена недвижимость: {Address}", data.Property.Address); + + await Task.Delay(100, 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(); + } +} 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..bb74edd98 --- /dev/null +++ b/RealEstateAgency.Generator/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "RealEstateAgency.Generator": "Debug" + } + }, + "ConnectionStrings": { + "nats": "nats://localhost:4222" + }, + "Generator": { + "BatchSize": 10, + "DelayMs": 5000 + } +} diff --git a/RealEstateAgency.WebApi/Program.cs b/RealEstateAgency.WebApi/Program.cs index dd98c6239..238b6d75e 100644 --- a/RealEstateAgency.WebApi/Program.cs +++ b/RealEstateAgency.WebApi/Program.cs @@ -7,18 +7,20 @@ 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.Configuration.GetConnectionString("MongoDB") != null - || Environment.GetEnvironmentVariable("ConnectionStrings__MongoDB") != null; +var useMongoDb = !builder.Environment.IsEnvironment("Testing") + && (builder.Configuration.GetConnectionString("realestatedb") != null + || Environment.GetEnvironmentVariable("ConnectionStrings__realestatedb") != null); if (useMongoDb) { builder.AddServiceDefaults(); - builder.AddMongoDBClient("MongoDB"); + builder.AddMongoDBClient("realestatedb"); builder.Services.AddDbContext((serviceProvider, options) => { @@ -43,6 +45,20 @@ 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 => { @@ -56,7 +72,7 @@ { Title = "Real Estate Agency API", Version = "v1", - Description = "API , " + Description = "API , " }); var xmlFilename = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; @@ -77,7 +93,22 @@ using var scope = app.Services.CreateScope(); var seeder = scope.ServiceProvider.GetRequiredService(); - await seeder.SeedAsync(); + 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()) diff --git a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj index eb2a408df..65df604e1 100644 --- a/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj +++ b/RealEstateAgency.WebApi/RealEstateAgency.WebApi.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -9,6 +9,8 @@ + + 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.sln b/RealEstateAgency.sln index 4116412d7..7333862e5 100644 --- a/RealEstateAgency.sln +++ b/RealEstateAgency.sln @@ -21,6 +21,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RealEstateAgency.AppHost", 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 @@ -139,6 +143,30 @@ Global {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 From 4726d10a4569c528d789fa6a9c65edcf68de4a80 Mon Sep 17 00:00:00 2001 From: AnyaVerkhovaia Date: Fri, 26 Dec 2025 18:39:53 +0400 Subject: [PATCH 31/31] fix --- .../ContractGeneratorTests.cs | 6 +- .../DataGeneratorServiceTests.cs | 94 +++++++++++++++---- .../NatsIntegrationTests.cs | 6 +- .../TestSettingsHelper.cs | 47 ++++++++++ .../Generators/ContractGenerator.cs | 37 ++------ .../Generators/GeneratedDataPackage.cs | 12 +++ RealEstateAgency.Generator/Program.cs | 36 +++---- .../RealEstateAgency.Generator.csproj | 1 + .../Services/DataGeneratorService.cs | 33 +++---- .../Services/DataGeneratorSettings.cs | 19 ++++ RealEstateAgency.Generator/appsettings.json | 19 ++-- .../Extensions.cs | 85 +++++++++++++++-- 12 files changed, 287 insertions(+), 108 deletions(-) create mode 100644 RealEstateAgency.Generator.Tests/TestSettingsHelper.cs create mode 100644 RealEstateAgency.Generator/Generators/GeneratedDataPackage.cs create mode 100644 RealEstateAgency.Generator/Services/DataGeneratorSettings.cs diff --git a/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs b/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs index 860e6ab4a..e1d12f7df 100644 --- a/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs +++ b/RealEstateAgency.Generator.Tests/ContractGeneratorTests.cs @@ -46,8 +46,10 @@ public void GenerateCounterparty_PhoneNumber_ShouldStartWithPlus7() counterparty.PhoneNumber.Should().StartWith("+7", "Российский номер должен начинаться с +7"); - counterparty.PhoneNumber.Should().HaveLength(12, - "Номер телефона должен содержать 12 символов (+7XXXXXXXXXX)"); + + var cleanNumber = counterparty.PhoneNumber.Replace("+7", "").Replace("(", "").Replace(")", "").Replace("-", "").Replace(" ", ""); + cleanNumber.Should().MatchRegex(@"^[0-9]{10}$", + "Номер должен содержать 10 цифр после +7"); } [Fact] diff --git a/RealEstateAgency.Generator.Tests/DataGeneratorServiceTests.cs b/RealEstateAgency.Generator.Tests/DataGeneratorServiceTests.cs index 384a94dc5..d8f873306 100644 --- a/RealEstateAgency.Generator.Tests/DataGeneratorServiceTests.cs +++ b/RealEstateAgency.Generator.Tests/DataGeneratorServiceTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Moq; using NATS.Client.Core; using RealEstateAgency.Contracts.Dto; @@ -24,6 +25,7 @@ public class DataGeneratorServiceTests : IAsyncLifetime private NatsPublisher? _publisher; private readonly Mock> _publisherLoggerMock; private readonly Mock> _serviceLoggerMock; + private readonly DataGeneratorSettings _testSettings; public DataGeneratorServiceTests() { @@ -33,6 +35,16 @@ public DataGeneratorServiceTests() _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() @@ -62,7 +74,7 @@ public async Task ExecuteAsync_ShouldPublishCounterpartiesToCorrectTopic() var subscription = Task.Run(async () => { - await foreach (var msg in subscriber.SubscribeAsync(DataGeneratorService.CounterpartyTopic)) + await foreach (var msg in subscriber.SubscribeAsync(_testSettings.CounterpartyTopic)) { if (!string.IsNullOrEmpty(msg.Data)) { @@ -81,11 +93,20 @@ public async Task ExecuteAsync_ShouldPublishCounterpartiesToCorrectTopic() 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, - batchSize: 3, - delayBetweenBatchesMs: 100); + serviceOptions); using var cts = new CancellationTokenSource(); @@ -117,7 +138,7 @@ public async Task ExecuteAsync_ShouldPublishPropertiesToCorrectTopic() var subscription = Task.Run(async () => { - await foreach (var msg in subscriber.SubscribeAsync(DataGeneratorService.PropertyTopic)) + await foreach (var msg in subscriber.SubscribeAsync(_testSettings.PropertyTopic)) { if (!string.IsNullOrEmpty(msg.Data)) { @@ -136,11 +157,20 @@ public async Task ExecuteAsync_ShouldPublishPropertiesToCorrectTopic() 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, - batchSize: 3, - delayBetweenBatchesMs: 100); + serviceOptions); using var cts = new CancellationTokenSource(); @@ -173,7 +203,7 @@ public async Task ExecuteAsync_ShouldPublishBothCounterpartiesAndProperties() var counterpartySub = Task.Run(async () => { - await foreach (var msg in subscriber.SubscribeAsync(DataGeneratorService.CounterpartyTopic)) + await foreach (var msg in subscriber.SubscribeAsync(_testSettings.CounterpartyTopic)) { if (!string.IsNullOrEmpty(msg.Data)) { @@ -185,7 +215,7 @@ public async Task ExecuteAsync_ShouldPublishBothCounterpartiesAndProperties() var propertySub = Task.Run(async () => { - await foreach (var msg in subscriber.SubscribeAsync(DataGeneratorService.PropertyTopic)) + await foreach (var msg in subscriber.SubscribeAsync(_testSettings.PropertyTopic)) { if (!string.IsNullOrEmpty(msg.Data)) { @@ -205,11 +235,20 @@ void CheckCompletion() 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, - batchSize: 5, - delayBetweenBatchesMs: 100); + serviceOptions); using var cts = new CancellationTokenSource(); @@ -227,11 +266,20 @@ void CheckCompletion() [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, - batchSize: 10, - delayBetweenBatchesMs: 1000); + serviceOptions); using var cts = new CancellationTokenSource(); @@ -249,11 +297,20 @@ public async Task StopAsync_ShouldStopGracefully() [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, - batchSize: 100, - delayBetweenBatchesMs: 10000); + serviceOptions); service.Should().NotBeNull(); } @@ -267,18 +324,21 @@ public class DataGeneratorServiceTopicsTests [Fact] public void CounterpartyTopic_ShouldHaveCorrectValue() { - DataGeneratorService.CounterpartyTopic.Should().Be("realestate.counterparty.created"); + var settings = new DataGeneratorSettings(); + settings.CounterpartyTopic.Should().Be("realestate.counterparty.created"); } [Fact] public void PropertyTopic_ShouldHaveCorrectValue() { - DataGeneratorService.PropertyTopic.Should().Be("realestate.property.created"); + var settings = new DataGeneratorSettings(); + settings.PropertyTopic.Should().Be("realestate.property.created"); } [Fact] public void RequestTopic_ShouldHaveCorrectValue() { - DataGeneratorService.RequestTopic.Should().Be("realestate.request.created"); + 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 index 4a3584552..39764b945 100644 --- a/RealEstateAgency.Generator.Tests/NatsIntegrationTests.cs +++ b/RealEstateAgency.Generator.Tests/NatsIntegrationTests.cs @@ -76,9 +76,11 @@ public async Task PublishAsync_ShouldPublishCounterparty() 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("test.counterparty")) + await foreach (var msg in subscriber.SubscribeAsync(testTopic)) { if (!string.IsNullOrEmpty(msg.Data)) { @@ -94,7 +96,7 @@ public async Task PublishAsync_ShouldPublishCounterparty() await Task.Delay(100); - await _publisher.PublishAsync("test.counterparty", counterparty); + await _publisher.PublishAsync(testTopic, counterparty); var received = await receivedData.Task.WaitAsync(TimeSpan.FromSeconds(5)); received.FullName.Should().Be(counterparty.FullName); 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 index 0041e5f4c..25cf19ade 100644 --- a/RealEstateAgency.Generator/Generators/ContractGenerator.cs +++ b/RealEstateAgency.Generator/Generators/ContractGenerator.cs @@ -30,18 +30,6 @@ public static class ContractGenerator "Александровна", "Дмитриевна", "Сергеевна", "Андреевна", "Алексеевна", "Михайловна", "Владимировна", "Николаевна" ]; - private static readonly string[] _russianCities = - [ - "Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург", "Казань", - "Нижний Новгород", "Челябинск", "Самара", "Омск", "Ростов-на-Дону" - ]; - - private static readonly string[] _russianStreets = - [ - "Ленина", "Мира", "Советская", "Гагарина", "Пушкина", "Комсомольская", - "Центральная", "Победы", "Октябрьская", "Молодёжная", "Садовая", "Парковая" - ]; - private static readonly Faker _faker = new("ru"); /// @@ -57,7 +45,7 @@ public static CreateCounterpartyDto GenerateCounterparty() { FullName = $"{lastName} {firstName} {patronymic}", PassportNumber = $"{_faker.Random.Number(1000, 9999)} {_faker.Random.Number(100000, 999999)}", - PhoneNumber = $"+7{_faker.Random.Number(900, 999)}{_faker.Random.Number(1000000, 9999999)}" + PhoneNumber = _faker.Phone.PhoneNumber("+7(9##)###-##-##") }; } @@ -76,10 +64,14 @@ public static CreateRealEstatePropertyDto GenerateProperty() _ => PropertyPurpose.Residential }; - var city = _faker.PickRandom(_russianCities); - var street = _faker.PickRandom(_russianStreets); - var houseNumber = _faker.Random.Number(1, 150); - var apartment = propertyType == PropertyType.Apartment ? $", кв. {_faker.Random.Number(1, 500)}" : ""; + 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)}"; @@ -161,13 +153,4 @@ public static GeneratedDataPackage GenerateDataPackage() Property = GenerateProperty() }; } -} - -/// -/// Пакет сгенерированных данных для отправки -/// -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/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 index a46feda6b..c118d4540 100644 --- a/RealEstateAgency.Generator/Program.cs +++ b/RealEstateAgency.Generator/Program.cs @@ -1,43 +1,31 @@ +using Microsoft.Extensions.Options; using RealEstateAgency.Generator.Services; +using RealEstateAgency.ServiceDefaults; var builder = Host.CreateApplicationBuilder(args); -builder.Logging.ClearProviders(); -builder.Logging.AddConsole(); -builder.Logging.SetMinimumLevel(LogLevel.Information); +builder.AddServiceDefaults(); -var natsConnectionString = builder.Configuration["ConnectionStrings:nats"] - ?? Environment.GetEnvironmentVariable("ConnectionStrings__nats") - ?? "nats://localhost:4222"; +var natsConnectionString = "nats://localhost:4222"; -var batchSize = int.TryParse( - builder.Configuration["Generator:BatchSize"] ?? - Environment.GetEnvironmentVariable("GENERATOR_BATCH_SIZE"), - out var bs) ? bs : 10; - -var delayMs = int.TryParse( - builder.Configuration["Generator:DelayMs"] ?? - Environment.GetEnvironmentVariable("GENERATOR_DELAY_MS"), - out var dm) ? dm : 5000; +builder.Services.Configure( + builder.Configuration.GetSection(DataGeneratorSettings.SectionName)); builder.Services.AddSingleton(sp => new NatsPublisher( natsConnectionString, sp.GetRequiredService>())); -builder.Services.AddHostedService(sp => - new DataGeneratorService( - sp.GetRequiredService(), - sp.GetRequiredService>(), - batchSize, - delayMs)); +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}", batchSize); -logger.LogInformation("Delay between batches: {Delay}ms", delayMs); +logger.LogInformation("Batch Size: {BatchSize}", settings.BatchSize); +logger.LogInformation("Delay between batches: {Delay}ms", settings.DelayBetweenBatchesMs); -await host.RunAsync(); +await host.RunAsync(); \ No newline at end of file diff --git a/RealEstateAgency.Generator/RealEstateAgency.Generator.csproj b/RealEstateAgency.Generator/RealEstateAgency.Generator.csproj index 38d5c8324..8d445e263 100644 --- a/RealEstateAgency.Generator/RealEstateAgency.Generator.csproj +++ b/RealEstateAgency.Generator/RealEstateAgency.Generator.csproj @@ -16,5 +16,6 @@ + diff --git a/RealEstateAgency.Generator/Services/DataGeneratorService.cs b/RealEstateAgency.Generator/Services/DataGeneratorService.cs index 10f64ed56..d9ecf23fe 100644 --- a/RealEstateAgency.Generator/Services/DataGeneratorService.cs +++ b/RealEstateAgency.Generator/Services/DataGeneratorService.cs @@ -1,4 +1,5 @@ -using RealEstateAgency.Generator.Generators; +using Microsoft.Extensions.Options; +using RealEstateAgency.Generator.Generators; namespace RealEstateAgency.Generator.Services; @@ -8,21 +9,17 @@ namespace RealEstateAgency.Generator.Services; public class DataGeneratorService( NatsPublisher natsPublisher, ILogger logger, - int batchSize = 10, - int delayBetweenBatchesMs = 5000) : BackgroundService + IOptions options) : BackgroundService { private readonly ILogger _logger = logger; - private readonly int _batchSize = batchSize; - public const string CounterpartyTopic = "realestate.counterparty.created"; - public const string PropertyTopic = "realestate.property.created"; - public const string RequestTopic = "realestate.request.created"; + private readonly DataGeneratorSettings _settings = options.Value; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation( "Запуск сервиса генерации данных. Размер пакета: {BatchSize}, задержка: {Delay}мс", - _batchSize, - delayBetweenBatchesMs); + _settings.BatchSize, + _settings.DelayBetweenBatchesMs); try { @@ -36,15 +33,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - await GenerateAndSendBatchAsync(_batchSize, stoppingToken); + await GenerateAndSendBatchAsync(_settings.BatchSize, stoppingToken); - totalGenerated += _batchSize; + totalGenerated += _settings.BatchSize; _logger.LogInformation( "Сгенерировано и отправлено {BatchSize} контрактов. Всего: {Total}", - _batchSize, + _settings.BatchSize, totalGenerated); - await Task.Delay(delayBetweenBatchesMs, stoppingToken); + await Task.Delay(_settings.DelayBetweenBatchesMs, stoppingToken); } catch (OperationCanceledException) { @@ -75,15 +72,15 @@ private async Task GenerateAndSendBatchAsync(int count, CancellationToken cancel { await foreach (var data in GenerateDataStreamAsync(count).WithCancellation(cancellationToken)) { - await natsPublisher.PublishAsync(CounterpartyTopic, data.Counterparty, cancellationToken); + await natsPublisher.PublishAsync(_settings.CounterpartyTopic, data.Counterparty, cancellationToken); _logger.LogDebug("Отправлен контрагент: {FullName}", data.Counterparty.FullName); - await Task.Delay(100, cancellationToken); + await Task.Delay(_settings.DelayBetweenMessagesMs, cancellationToken); - await natsPublisher.PublishAsync(PropertyTopic, data.Property, cancellationToken); + await natsPublisher.PublishAsync(_settings.PropertyTopic, data.Property, cancellationToken); _logger.LogDebug("Отправлена недвижимость: {Address}", data.Property.Address); - await Task.Delay(100, cancellationToken); + await Task.Delay(_settings.DelayBetweenMessagesMs, cancellationToken); } } @@ -106,4 +103,4 @@ public override async Task StopAsync(CancellationToken cancellationToken) 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/appsettings.json b/RealEstateAgency.Generator/appsettings.json index bb74edd98..e4bc18a7e 100644 --- a/RealEstateAgency.Generator/appsettings.json +++ b/RealEstateAgency.Generator/appsettings.json @@ -2,15 +2,18 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.Hosting.Lifetime": "Information", - "RealEstateAgency.Generator": "Debug" + "Microsoft.Hosting.Lifetime": "Information" } }, - "ConnectionStrings": { - "nats": "nats://localhost:4222" - }, - "Generator": { + "DataGenerator": { "BatchSize": 10, - "DelayMs": 5000 + "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.ServiceDefaults/Extensions.cs b/RealEstateAgency.ServiceDefaults/Extensions.cs index 2c7eda142..0a280597e 100644 --- a/RealEstateAgency.ServiceDefaults/Extensions.cs +++ b/RealEstateAgency.ServiceDefaults/Extensions.cs @@ -2,12 +2,12 @@ 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; /// @@ -16,7 +16,7 @@ namespace RealEstateAgency.ServiceDefaults; public static class Extensions { /// - /// Aspire + /// Aspire (Web ) /// public static WebApplicationBuilder AddServiceDefaults(this WebApplicationBuilder builder) { @@ -34,7 +34,7 @@ public static WebApplicationBuilder AddServiceDefaults(this WebApplicationBuilde } /// - /// OpenTelemetry + /// OpenTelemetry (Web ) /// public static WebApplicationBuilder ConfigureOpenTelemetry(this WebApplicationBuilder builder) { @@ -62,6 +62,70 @@ public static WebApplicationBuilder ConfigureOpenTelemetry(this WebApplicationBu 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"]); @@ -74,13 +138,14 @@ private static WebApplicationBuilder AddOpenTelemetryExporters(this WebApplicati return builder; } - /// - /// health checks - /// - public static WebApplicationBuilder AddDefaultHealthChecks(this WebApplicationBuilder builder) + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) { - builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } return builder; } @@ -99,4 +164,4 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) return app; } -} +} \ No newline at end of file