Переписаны модули, тесты, обработка ошибок

This commit is contained in:
Халимов Рустам
2026-03-10 21:08:03 +03:00
parent 5c9c6ab975
commit 4e06e48657
126 changed files with 6549 additions and 803 deletions

View File

@@ -1,10 +1,11 @@
# Build stage
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
WORKDIR /src
# Copy solution and project files
COPY ["Nashel.sln", "./"]
COPY ["src/", "src/"]
COPY ["tests/", "tests/"]
# Restore dependencies
RUN dotnet restore "Nashel.sln"

View File

@@ -8,6 +8,7 @@ ENV PATH="${PATH}:/root/.dotnet/tools"
# Копируем решение и проекты
COPY ["Nashel.sln", "./"]
COPY ["src/", "src/"]
COPY ["tests/", "tests/"]
# Восстанавливаем зависимости
RUN dotnet restore "Nashel.sln"

View File

@@ -59,20 +59,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Reputation.D
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Reputation.Presentation", "src\Modules\Reputation\Presentation\Nashel.Modules.Reputation.Presentation.csproj", "{EEC2B03A-3172-4EC1-B7E3-8687E436072D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalog", "Catalog", "{729B03C7-1C3E-5EDD-EDB4-38F58E22BDF1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Collaboration", "Collaboration", "{347C27AA-80C2-BDA4-F5FF-AC11DEBD94E0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Geo", "Geo", "{5B9BABC8-CC62-A0F7-B659-A4FCC76A434C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{293C06CD-4E17-48BD-9F3B-B5ADDFFA9425}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Order", "Order", "{3266C089-BF45-8FB5-CBFB-520E7D591FF0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Reputation", "Reputation", "{6153DBFE-E924-7D0F-FF5C-84EC91E8B397}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Identity.Tests", "src\Modules\Identity\Tests\Nashel.Modules.Identity.Tests.csproj", "{401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}"
@@ -83,31 +69,39 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC44
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Order", "Order", "{2C821288-5B9D-778E-74EE-053FE87F223E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{7D435AA2-42F6-BE60-ABDD-B2A72FA358F7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{06FD8EF1-B9DE-B088-FC77-09DC201E1B21}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{159DA5B4-03CA-95B4-A9E1-B46977641578}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Presentation", "Presentation", "{D0003F1F-EE71-3913-8EC0-5F1638807A8D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{FB27511E-CBA6-E343-8CE0-54AAD98AFC9F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Order.Tests", "src\Modules\Order\Tests\Nashel.Modules.Order.Tests.csproj", "{DC1E99E0-6B92-4FBD-B74B-68D202961FB7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Collaboration", "Collaboration", "{0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "src\Modules\Collaboration\Tests\Tests.csproj", "{5E1F169E-6639-416B-8C9F-DE3C5785430F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Reputation", "Reputation", "{305CF822-D9CA-C4BE-7183-DD5447E36C7E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{6FECF8C1-5BB0-731D-B0FE-7CF5A5513390}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Reputation.Tests", "src\Modules\Reputation\Tests\Nashel.Modules.Reputation.Tests.csproj", "{B496E978-09C5-4EFA-9A2A-6984A3F89F77}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{E8C197F5-634A-BE56-FF9F-B070A9035A7F}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Host.Tests", "tests\Nashel.Host.Tests\Nashel.Host.Tests.csproj", "{087C0E53-90E0-415A-A394-6E31E12EF7BF}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{9F351DFB-97BE-6504-B56A-865D9FFC9EB2}"
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Catalog", "Catalog", "{1E1EF545-4C18-AEE2-B997-7EF431518D3F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{77959D31-1EA8-27CA-126F-40C5E927F925}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Catalog.Tests", "src\Modules\Catalog\Tests\Nashel.Modules.Catalog.Tests.csproj", "{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Geo", "Geo", "{1F785BA6-BCCC-EB5A-C0AC-017D258EF3FD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{9CB3DAB5-483B-9BA7-DF84-97D903318F73}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Geo.Tests", "src\Modules\Geo\Tests\Nashel.Modules.Geo.Tests.csproj", "{06992B63-6D24-4785-8A7D-138E9AC6D38E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Collaboration", "Collaboration", "{0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{04D8AB37-CCCF-08EA-5F4C-67D97B5A3FDE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Collaboration.Tests", "src\Modules\Collaboration\Tests\Nashel.Modules.Collaboration.Tests.csproj", "{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{F7A07CC8-D014-3EFC-E9E0-4BA33974CD0E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{9B135FC8-DBD1-6BD7-B3C5-FE40E7BAF0CB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -455,18 +449,6 @@ Global
{DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x64.Build.0 = Release|Any CPU
{DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x86.ActiveCfg = Release|Any CPU
{DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x86.Build.0 = Release|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x64.ActiveCfg = Debug|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x64.Build.0 = Debug|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x86.ActiveCfg = Debug|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x86.Build.0 = Debug|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|Any CPU.Build.0 = Release|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x64.ActiveCfg = Release|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x64.Build.0 = Release|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x86.ActiveCfg = Release|Any CPU
{5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x86.Build.0 = Release|Any CPU
{B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -479,6 +461,54 @@ Global
{B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Release|x64.Build.0 = Release|Any CPU
{B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Release|x86.ActiveCfg = Release|Any CPU
{B496E978-09C5-4EFA-9A2A-6984A3F89F77}.Release|x86.Build.0 = Release|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|x64.ActiveCfg = Debug|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|x64.Build.0 = Debug|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|x86.ActiveCfg = Debug|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Debug|x86.Build.0 = Debug|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|Any CPU.Build.0 = Release|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|x64.ActiveCfg = Release|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|x64.Build.0 = Release|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|x86.ActiveCfg = Release|Any CPU
{087C0E53-90E0-415A-A394-6E31E12EF7BF}.Release|x86.Build.0 = Release|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|x64.ActiveCfg = Debug|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|x64.Build.0 = Debug|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|x86.ActiveCfg = Debug|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Debug|x86.Build.0 = Debug|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|Any CPU.Build.0 = Release|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|x64.ActiveCfg = Release|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|x64.Build.0 = Release|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|x86.ActiveCfg = Release|Any CPU
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF}.Release|x86.Build.0 = Release|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|x64.ActiveCfg = Debug|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|x64.Build.0 = Debug|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|x86.ActiveCfg = Debug|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Debug|x86.Build.0 = Debug|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|Any CPU.Build.0 = Release|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|x64.ActiveCfg = Release|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|x64.Build.0 = Release|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|x86.ActiveCfg = Release|Any CPU
{06992B63-6D24-4785-8A7D-138E9AC6D38E}.Release|x86.Build.0 = Release|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|x64.ActiveCfg = Debug|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|x64.Build.0 = Debug|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|x86.ActiveCfg = Debug|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Debug|x86.Build.0 = Debug|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|Any CPU.Build.0 = Release|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|x64.ActiveCfg = Release|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|x64.Build.0 = Release|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|x86.ActiveCfg = Release|Any CPU
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -510,27 +540,25 @@ Global
{C6D6EB9D-D40E-4839-8F78-1024ED4964E1} = {7226ABEA-787E-1D09-10EA-A2ECF996D6A6}
{2010E82F-B42C-491F-AFD7-4486D4845737} = {7226ABEA-787E-1D09-10EA-A2ECF996D6A6}
{EEC2B03A-3172-4EC1-B7E3-8687E436072D} = {7226ABEA-787E-1D09-10EA-A2ECF996D6A6}
{729B03C7-1C3E-5EDD-EDB4-38F58E22BDF1} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}
{347C27AA-80C2-BDA4-F5FF-AC11DEBD94E0} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}
{5B9BABC8-CC62-A0F7-B659-A4FCC76A434C} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}
{293C06CD-4E17-48BD-9F3B-B5ADDFFA9425} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}
{3266C089-BF45-8FB5-CBFB-520E7D591FF0} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}
{6153DBFE-E924-7D0F-FF5C-84EC91E8B397} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}
{401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{2C821288-5B9D-778E-74EE-053FE87F223E} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{7D435AA2-42F6-BE60-ABDD-B2A72FA358F7} = {2C821288-5B9D-778E-74EE-053FE87F223E}
{06FD8EF1-B9DE-B088-FC77-09DC201E1B21} = {2C821288-5B9D-778E-74EE-053FE87F223E}
{159DA5B4-03CA-95B4-A9E1-B46977641578} = {2C821288-5B9D-778E-74EE-053FE87F223E}
{D0003F1F-EE71-3913-8EC0-5F1638807A8D} = {2C821288-5B9D-778E-74EE-053FE87F223E}
{FB27511E-CBA6-E343-8CE0-54AAD98AFC9F} = {2C821288-5B9D-778E-74EE-053FE87F223E}
{DC1E99E0-6B92-4FBD-B74B-68D202961FB7} = {FB27511E-CBA6-E343-8CE0-54AAD98AFC9F}
{0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{5E1F169E-6639-416B-8C9F-DE3C5785430F} = {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71}
{305CF822-D9CA-C4BE-7183-DD5447E36C7E} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{6FECF8C1-5BB0-731D-B0FE-7CF5A5513390} = {305CF822-D9CA-C4BE-7183-DD5447E36C7E}
{B496E978-09C5-4EFA-9A2A-6984A3F89F77} = {6FECF8C1-5BB0-731D-B0FE-7CF5A5513390}
{E8C197F5-634A-BE56-FF9F-B070A9035A7F} = {305CF822-D9CA-C4BE-7183-DD5447E36C7E}
{9F351DFB-97BE-6504-B56A-865D9FFC9EB2} = {305CF822-D9CA-C4BE-7183-DD5447E36C7E}
{087C0E53-90E0-415A-A394-6E31E12EF7BF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{1E1EF545-4C18-AEE2-B997-7EF431518D3F} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{77959D31-1EA8-27CA-126F-40C5E927F925} = {1E1EF545-4C18-AEE2-B997-7EF431518D3F}
{2C29F7A5-8927-4490-BC7A-50B6F5442EEF} = {77959D31-1EA8-27CA-126F-40C5E927F925}
{1F785BA6-BCCC-EB5A-C0AC-017D258EF3FD} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{9CB3DAB5-483B-9BA7-DF84-97D903318F73} = {1F785BA6-BCCC-EB5A-C0AC-017D258EF3FD}
{06992B63-6D24-4785-8A7D-138E9AC6D38E} = {9CB3DAB5-483B-9BA7-DF84-97D903318F73}
{0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
{04D8AB37-CCCF-08EA-5F4C-67D97B5A3FDE} = {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71}
{4786B22D-13B2-4CC5-8738-EAC9121EF4F0} = {04D8AB37-CCCF-08EA-5F4C-67D97B5A3FDE}
{F7A07CC8-D014-3EFC-E9E0-4BA33974CD0E} = {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71}
{9B135FC8-DBD1-6BD7-B3C5-FE40E7BAF0CB} = {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71}
EndGlobalSection
EndGlobal

170
README.md
View File

@@ -1,138 +1,112 @@
# 🔮 Nashel — Backend API
> **ASP.NET Core · Modular Monolith · PostgreSQL · Docker**
> **ASP.NET Core · Modular Monolith · PostgreSQL (PostGIS) · Docker**
Серверная часть платформы **Nashel** — маркетплейса для поиска и найма профессиональных исполнителей (мастеров, специалистов и компаний). Бэкенд построен по принципу **Модульного Монолита** с чёткой доменной изоляцией.
Серверная часть платформы **Nashel** инновационного маркетплейса для поиска и найма профессиональных исполнителей. Бэкенд построен по принципу **Модульного Монолита**, что обеспечивает идеальный баланс между скоростью разработки и чистотой архитектуры с четкой доменной изоляцией.
---
## 🎯 Какую проблему решает Nashel
## 🎯 Миссия Nashel
В России огромное количество мастеров и специалистов, которые работают «по сарафанному радио» — без нормальных инструментов для привлечения клиентов. А клиенты, в свою очередь, тратят время на поиск через знакомых или ненадёжные сайты объявлений.
Мы создаем прозрачную экосистему, где мастера получают профессиональный инструментарий для ведения бизнеса, а клиенты — надежный сервис поиска по геолокации, реальным отзывам и защищенным сделкам.
**Nashel** — это:
- Удобный и быстрый поиск исполнителей **рядом с тобой** на карте
- Прозрачная система услуг с ценами, фото и описанием
- Система репутации и отзывов
- Безопасное взаимодействие клиента и исполнителя
**Ключевые преимущества:**
- **Гео-центричность:** Поиск исполнителей в радиусе на карте.
- **Интеллектуальный статус:** Проверка доступности мастера в реальном времени.
- **Безопасность:** Проработанный жизненный цикл заказа с системой споров.
- **Репутация:** Честная система отзывов, привязанная к реальным сделкам.
---
## 👤 Роли пользователей
## 🧱 Архитектура и Технологии
| Роль | Описание |
|------|----------|
| **Клиент** | Ищет услуги на карте, просматривает профили исполнителей, оставляет заявки |
| **Кандидат в мастера** | Зарегистрировался как исполнитель, проходит верификацию |
| **Мастер** | Верифицированный исполнитель — частный специалист, самозанятый |
| **Компания** | Юридическое лицо, предоставляющее услуги через платформу |
Проект реализован как **Modular Monolith**. Каждый модуль — это изолированная единица со своей логикой, данными и API, взаимодействующая с другими через контракты BuildingBlocks.
---
## 🧱 Архитектура
Проект реализован как **Modular Monolith** — единое приложение, разделённое на изолированные модули с чёткими границами. Каждый модуль содержит:
### Технологический стек
- **Runtime:** .NET 8 / ASP.NET Core
- **Database:** PostgreSQL + **PostGIS** (гео-запросы)
- **ORM:** Entity Framework Core
- **Messaging:** MediatR (In-process commands/queries)
- **Security:** JWT Authentication, Role-based Access Control
- **Spatial:** NetTopologySuite
### Структура модуля
```
Modules/<Module>/
├── Application/ # Команды, запросы, DTO (MediatR)
├── Domain/ # Сущности, бизнес-логика, интерфейсы репозиториев
├── Infrastructure/ # Реализация репозиториев, EF Core DbContext
└── Presentation/ # HTTP Endpoints (Minimal API)
├── Domain/ # Сущности, Value Objects, Доменные события
├── Application/ # Use Cases (MediatR Handlers), DTOs, Mapping
├── Infrastructure/ # EF Core (Persistence), External Services
└── Presentation/ # Minimal API Endpoints
```
### Модули
---
| Модуль | Назначение |
|--------|------------|
| **Identity** | Регистрация, авторизация (JWT), профиль, роли, аватар, геолокация, расписание |
| **Catalog** | Управление услугами (CRUD), изображения, статусы, атрибуты |
| **Geo** | Геозоны, расчёт расстояний (PostGIS / NetTopologySuite) |
| **Search** | Полнотекстовый поиск по услугам и компетенциям с учётом геопозиции |
| **Reputation** | Отзывы и рейтинги (в разработке) |
| **Collaboration** | Заявки и взаимодействие клиент ↔ исполнитель (в разработке) |
| **Order** | Заказы и их жизненный цикл (в разработке) |
## 🚀 Реализованные Модули
### Host
`Host/` — точка входа приложения. Здесь настраиваются DI, middleware, маршрутизация и сборка всех модулей.
| Модуль | Статус | Функционал |
|--------|--------|------------|
| **Identity** | ✅ Ready | Auth (JWT), Профили, Аватары (Base64), Расписание, Статусы доступности |
| **Catalog** | ✅ Ready | Управление услугами (CRUD), Multi-image (до 10 фото), Атрибуты |
| **Search** | ✅ Ready | Full-text search, сортировка по Geo-дистанции и релевантности |
| **Geo** | ✅ Ready | Расчет расстояний, Геозоны, Индексация координат |
| **Order** | ✅ Ready | Заказы (Direct/Public), SLA таймеры, Система споров (Disputes), Отклики |
| **Reputation** | ✅ Ready | Отзывы, Рейтинги (User/Offer), Дополнения к отзывам |
| **Collaboration**| ✅ Ready | HR-инструментарий: Найм, Наложение вето на расписание, Проверки |
---
## ✅ Что реализовано
## ⚙️ Ключевая Логика
### Аутентификация и профиль
- Регистрация и вход по логину/паролю (JWT токены)
- Смена пароля, обновление профиля
- Загрузка аватара (хранится как base64 в БД)
- Переход в роль исполнителя: описание, компетенции, геолокация
### 1. Умная доступность (Smart Status)
Мастер может управлять своей доступностью двумя способами:
- **Расписание:** Настройка рабочих дней и часов.
- **Manual Toggle:** Ручное переключение статуса «Готов к заказу». Ручная активация имеет приоритет и действует до конца текущего дня, перекрывая стандартное расписание.
### Геолокация
- Привязка основного адреса и текущего местоположения
- Расчёт расстояния до исполнителей с помощью **PostGIS + NetTopologySuite**
- Геозоны для фильтрации исполнителей в радиусе
### 2. Жизненный цикл заказа (Order Flow)
Реализована сложная машина состояний:
1. **Создание:** Прямой заказ мастеру или публикация заявки в общий доступ.
2. **SLA:** Для прямых заказов действует 60-минутный таймер на принятие.
3. **Исполнение:** Статусы "В работе", "Выполнено", "Подтверждено".
4. **Споры (Disputes):** Многоэтапный процесс разрешения конфликтов (Открытие -> Ответ мастера -> Возражение клиента -> Принятие условий).
### Услуги (Catalog)
- Создание, редактирование, удаление (soft delete) услуг
- Приостановка услуги (пауза)
- Загрузка до 10 изображений (≤5 МБ каждое), хранение в БД
- Атрибуты услуги (произвольные ключ-значение пары)
- Первое изображение является обложкой
### Поиск
- Полнотекстовый поиск по: названию услуги, описанию, компетенциям
- Результаты сортируются с учётом расстояния и релевантности
- Исполнитель показывается в поиске только если у него нет найденных услуг (приоритет услуги над специалистом)
- Приостановленные и удалённые услуги не отображаются
- Возврат URL первого изображения услуги для превью в поиске
### Расписание и статус доступности
- Настройка рабочих дней и «всегда готов»
- Умная логика статуса: ручное включение статуса «Готов к заказу» переопределяет расписание до конца текущего дня
### 3. Поиск и Геолокация
- Поиск учитывает не только текст, но и **расстояние**.
- В приоритете — конкретные услуги. Если мастер не имеет услуг, он показывается как специалист.
- Интеграция с PostGIS позволяет делать сверхбыстрые выборки в радиусе.
---
## 🗄️ База данных
- **PostgreSQL** с расширением **PostGIS** (геопространственные запросы)
- **Entity Framework Core** (Code First, миграции)
- Структура: таблица `Accounts`, `Performers`, `Offers`, `Competencies` и др.
---
## 🐳 Запуск через Docker
## 🐳 Развертывание
### Docker (Рекомендуемо)
```bash
# Запуск базы данных и бэкенда
docker-compose up -d --build
```
Это запустит:
- Бэкенд (порт 5000)
- PostgreSQL + PostGIS (порт 5432)
Настройки в `docker-compose.yml` (строка подключения, порты, переменные окружения).
### Локальный запуск
1. Установите PostgreSQL и расширение PostGIS.
2. Обновите строку подключения в `appsettings.json`.
3. Примените миграции:
```bash
dotnet ef database update -p src/Host -s src/Host
```
4. Запустите Host:
```bash
dotnet run --project src/Host
```
---
## ⚙️ Локальный запуск
```bash
cd nashel-backend
# Применить миграции
dotnet ef database update -p src/Host -s src/Host
# Запустить
dotnet run --project src/Host
```
Swagger UI доступен по адресу: `http://localhost:5000/swagger`
Swagger доступен по адресу: `http://localhost:5000/swagger`
---
## 🔮 Планы развития
- [ ] Полный модуль **Collaboration**: чат между клиентом и исполнителем
- [ ] Модуль **Order**: жизненный цикл заказа, статусы, оплата
- [ ] Модуль **Reputation**: система отзывов и рейтингов (реальные данные вместо заглушек)
- [ ] Верификация документов исполнителя
- [ ] Push-уведомления
- [ ] Поддержка OAuth (VK, Google)
- [ ] Аналитика для исполнителей (просмотры, конверсии)
- [ ] **Real-time:** Интеграция WebSockets для чатов и уведомлений.
- [ ] **Verification:** Модуль проверки документов исполнителей.
- [ ] **Finances:** Интеграция платежных шлюзов.
- [ ] **Analytics:** Сбор метрик просмотров и конверсий для мастеров.
- [ ] **Mobile SDK:** API для нативных мобильных приложений.
- [ ] **Notifications:** Push и Email уведомления о статусах заказов.

View File

@@ -0,0 +1,55 @@
namespace Nashel.BuildingBlocks.Application.Configurations;
/// <summary>
/// Настройки работы с медиа-файлами.
/// </summary>
public class MediaSettings
{
public const string SectionName = "MediaSettings";
/// <summary>Максимальный размер файла в байтах.</summary>
public long MaxFileSizeInBytes { get; set; }
/// <summary>Максимальное количество изображений для одной услуги.</summary>
public int MaxImagesPerOffer { get; set; }
/// <summary>Список допустимых расширений файлов.</summary>
public string[] AllowedExtensions { get; set; } = Array.Empty<string>();
}
/// <summary>
/// Настройки модуля заказов.
/// </summary>
public class OrderSettings
{
public const string SectionName = "OrderSettings";
/// <summary>Время на принятие прямого заказа (SLA) в минутах.</summary>
public int DirectOrderSlaMinutes { get; set; }
/// <summary>Время жизни публичной заявки в минутах.</summary>
public int PublicOrderSlaMinutes { get; set; }
/// <summary>Максимальное время на ответ в споре (в часах).</summary>
public int MaxDisputeResponseHours { get; set; }
/// <summary>Максимальное число компетенций у исполнителя.</summary>
public int MaxCompetenciesPerPerformer { get; set; }
}
/// <summary>
/// Настройки модуля Identity.
/// </summary>
public class IdentitySettings
{
public const string SectionName = "IdentitySettings";
/// <summary>Максимальная длина описания профиля.</summary>
public int MaxDescriptionLength { get; set; }
/// <summary>Минимальная длина описания для роли исполнителя.</summary>
public int MinPerformerDescriptionLength { get; set; }
/// <summary>Максимальное число компетенций.</summary>
public int MaxCompetencies { get; set; }
}

View File

@@ -0,0 +1,54 @@
namespace Nashel.BuildingBlocks.Application.Exceptions;
/// <summary>
/// Базовый класс для всех доменных исключений и исключений приложения.
/// </summary>
public abstract class ApplicationExceptionBase : Exception
{
protected ApplicationExceptionBase(string message) : base(message) { }
}
/// <summary>
/// Исключение, выбрасываемое, когда сущность не найдена.
/// </summary>
public class NotFoundException : ApplicationExceptionBase
{
public NotFoundException(string name, object key)
: base($"Сущность \"{name}\" ({key}) не найдена.") { }
}
/// <summary>
/// Исключение валидации данных.
/// </summary>
public class ValidationException : ApplicationExceptionBase
{
public IDictionary<string, string[]> Errors { get; }
public ValidationException()
: base("Произошла одна или несколько ошибок валидации.")
{
Errors = new Dictionary<string, string[]>();
}
public ValidationException(IDictionary<string, string[]> errors)
: base("Произошла одна или несколько ошибок валидации.")
{
Errors = errors;
}
}
/// <summary>
/// Исключение, выбрасываемое при нарушении бизнес-правил или конфликтах.
/// </summary>
public class BusinessRuleException : ApplicationExceptionBase
{
public BusinessRuleException(string message) : base(message) { }
}
/// <summary>
/// Исключение отсутствия прав доступа.
/// </summary>
public class ForbiddenAccessException : ApplicationExceptionBase
{
public ForbiddenAccessException() : base("У вас недостаточно прав для выполнения этой операции.") { }
}

View File

@@ -0,0 +1,9 @@
namespace Nashel.BuildingBlocks.Domain;
/// <summary>
/// Базовый класс для исключений доменного слоя.
/// </summary>
public abstract class DomainException : Exception
{
protected DomainException(string message) : base(message) { }
}

View File

@@ -5,6 +5,7 @@ using Nashel.BuildingBlocks.Application.Abstractions;
using Nashel.Modules.Catalog.Infrastructure.Persistence;
using Nashel.Modules.Geo.Infrastructure.Persistence;
using Nashel.Modules.Identity.Infrastructure.Persistence;
using Nashel.Modules.Reputation.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
namespace Nashel.Host.Endpoints;
@@ -17,6 +18,7 @@ public static class SearchEndpoints
GeoDbContext geoDb,
IdentityDbContext identityDb,
CatalogDbContext catalogDb,
ReputationDbContext reputationDb,
ICurrentUserService currentUserService,
CancellationToken ct,
[FromQuery] double lat = 0, // Убрали обязательность, чтобы опциональные работали нормально или перенесли в конец
@@ -109,14 +111,31 @@ public static class SearchEndpoints
var accountsDict = accountsRaw.ToDictionary(a => a.Id, a => a.Roles.Select(r => r.ToString()).ToList());
// 4. Сборка результата и логика "Умного статуса"
// 4. Рейтинги (ReputationDbContext)
var performerRatings = await reputationDb.Reviews
.Where(r => profileIds.Contains(r.TargetId))
.GroupBy(r => r.TargetId)
.Select(g => new { TargetId = g.Key, Avg = g.Average(r => r.Rating), Count = g.Count() })
.ToDictionaryAsync(x => x.TargetId, x => new { x.Avg, x.Count }, ct);
var offerIds = matchedOffers.Select(o => o.Id).ToList();
var offerRatings = await reputationDb.Reviews
.Where(r => offerIds.Contains(r.OfferId))
.GroupBy(r => r.OfferId)
.Select(g => new { OfferId = g.Key, Avg = g.Average(r => r.Rating), Count = g.Count() })
.ToDictionaryAsync(x => x.OfferId, x => new { x.Avg, x.Count }, ct);
// 5. Сборка результата и логика "Умного статуса"
var currentDay = GetRussianDayOfWeek(DateTime.UtcNow.DayOfWeek);
var currentTime = DateTime.UtcNow.TimeOfDay;
var offersLookup = matchedOffers.GroupBy(o => o.PerformerId).ToDictionary(g => g.Key, g => g.ToList());
var result = profiles.Join(nearbyGeo, p => p.Id, g => g.Id, (p, g) =>
{
string fullName = !string.IsNullOrEmpty(p.CompanyName) ? p.CompanyName : $"{p.FirstName} {p.LastName}".Trim();
var names = new[] { p.FirstName, p.LastName }.Where(x => !string.IsNullOrWhiteSpace(x));
string fullName = string.Join(" ", names);
if (string.IsNullOrWhiteSpace(fullName))
fullName = p.CompanyName ?? "Мастер";
string finalStatus = CalculateSmartStatus(
g.State.ToString(), // Передаем реальный статус из Geo
@@ -127,16 +146,26 @@ public static class SearchEndpoints
currentTime
);
var matchedOffersList = offersLookup.TryGetValue(p.Id, out var o)
? o.Select(x => (object)x).ToList()
var matchedOffersList = offersLookup.TryGetValue(p.Id, out var oList)
? oList.Select(ox => (object)new
{
ox.Id,
ox.PerformerId,
ox.Title,
ox.Description,
ox.PriceAmount,
ox.PriceCurrency,
ox.ImageUrl,
Rating = offerRatings.TryGetValue(ox.Id, out var or) ? Math.Round(or.Avg, 1) : 0,
ReviewsCount = offerRatings.TryGetValue(ox.Id, out var orc) ? orc.Count : 0
}).ToList()
: new List<object>();
var roles = accountsDict.TryGetValue(p.Id, out var r) ? r : new List<string>();
string roleName = "";
string roleName = "Пользователь";
if (roles.Contains("Company")) roleName = "Компания";
else if (roles.Contains("Master")) roleName = "Мастер";
else if (roles.Contains("Candidate")) roleName = "Кандидат в мастера";
else roleName = "Пользователь";
else if (roles.Contains("Candidate")) roleName = "Кандидат";
return new
{
@@ -148,6 +177,8 @@ public static class SearchEndpoints
Status = finalStatus, // "Готов к заказу" или "Офлайн"
Distance = Math.Round(g.DistanceMeters),
Role = roleName,
Rating = performerRatings.TryGetValue(p.Id, out var pr) ? Math.Round(pr.Avg, 1) : 0,
ReviewsCount = performerRatings.TryGetValue(p.Id, out var prc) ? prc.Count : 0,
MatchedCompetencies = p.Competencies
.Where(c => string.IsNullOrWhiteSpace(qLower) || !inComp || c.Name.ToLower().Contains(qLower))
.Select(c => c.Name).ToList(),

View File

@@ -0,0 +1,102 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Nashel.BuildingBlocks.Application.Exceptions;
namespace Nashel.Host.Infrastructure.Exceptions;
/// <summary>
/// Глобальный обработчик исключений для всей системы.
/// Перехватывает ошибки и возвращает их в стандарте ProblemDetails (RFC 7807).
/// </summary>
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "Произошла необработанная ошибка: {Message}", exception.Message);
var (statusCode, title, detail, errors) = MapException(exception);
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = detail,
Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}"
};
if (errors != null)
{
problemDetails.Extensions["errors"] = errors;
}
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
private static (int StatusCode, string Title, string Detail, IDictionary<string, string[]>? Errors) MapException(Exception exception)
{
return exception switch
{
ValidationException validationException => (
StatusCodes.Status400BadRequest,
"Ошибка валидации",
"Один или несколько параметров запроса не прошли проверку.",
validationException.Errors),
NotFoundException notFoundException => (
StatusCodes.Status404NotFound,
"Ресурс не найден",
notFoundException.Message,
null),
Nashel.BuildingBlocks.Domain.DomainException domainException => (
StatusCodes.Status409Conflict,
"Ошибка в бизнес-логике",
domainException.Message,
null),
BusinessRuleException businessRuleException => (
StatusCodes.Status409Conflict,
"Нарушение бизнес-правила",
businessRuleException.Message,
null),
ForbiddenAccessException => (
StatusCodes.Status403Forbidden,
"Доступ запрещен",
"У вас недостаточно прав для выполнения этой операции.",
null),
UnauthorizedAccessException => (
StatusCodes.Status401Unauthorized,
"Неавторизован",
"Требуется авторизация для доступа к этому ресурсу.",
null),
// Обработка стандартных ArgumentException как 400 Bad Request
ArgumentException argumentException => (
StatusCodes.Status400BadRequest,
"Некорректный запрос",
argumentException.Message,
null),
_ => (
StatusCodes.Status500InternalServerError,
"Внутренняя ошибка сервера",
"Произошла непредвиденная ошибка на сервере. Пожалуйста, попробуйте позже.",
null)
};
}
}

View File

@@ -24,6 +24,14 @@ builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
// Настройка Options
builder.Services.Configure<Nashel.BuildingBlocks.Application.Configurations.MediaSettings>(
builder.Configuration.GetSection(Nashel.BuildingBlocks.Application.Configurations.MediaSettings.SectionName));
builder.Services.Configure<Nashel.BuildingBlocks.Application.Configurations.OrderSettings>(
builder.Configuration.GetSection(Nashel.BuildingBlocks.Application.Configurations.OrderSettings.SectionName));
builder.Services.Configure<Nashel.BuildingBlocks.Application.Configurations.IdentitySettings>(
builder.Configuration.GetSection(Nashel.BuildingBlocks.Application.Configurations.IdentitySettings.SectionName));
// Добавление сервисов в контейнер.
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
@@ -96,6 +104,10 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
};
});
// Регистрация глобального обработчика исключений
builder.Services.AddExceptionHandler<Nashel.Host.Infrastructure.Exceptions.GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
builder.Services.AddAuthorization();
builder.Services.AddCors(options =>
@@ -111,30 +123,8 @@ builder.Services.AddCors(options =>
var app = builder.Build();
// Middleware для обработки исключений
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var exception = context.Features.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
if (exception != null)
{
var error = new
{
Message = exception.Error.Message,
StackTrace = exception.Error.StackTrace
};
Console.WriteLine($"[ERROR] {exception.Error.Message}");
Console.WriteLine(exception.Error.StackTrace);
await context.Response.WriteAsJsonAsync(error);
}
});
});
// Глобальная обработка ошибок в формате ProblemDetails
app.UseExceptionHandler();
// Настройка конвейера HTTP-запросов.
if (app.Environment.IsDevelopment())

View File

@@ -13,5 +13,21 @@
"Secret": "super_secret_key_change_me_please_this_is_for_development_only_12345",
"Issuer": "Nashel",
"Audience": "Nashel"
},
"MediaSettings": {
"MaxFileSizeInBytes": 5242880,
"MaxImagesPerOffer": 10,
"AllowedExtensions": [ ".jpg", ".jpeg", ".png", ".webp" ]
},
"OrderSettings": {
"DirectOrderSlaMinutes": 60,
"PublicOrderSlaMinutes": 1440,
"MaxDisputeResponseHours": 72,
"MaxCompetenciesPerPerformer": 50
},
"IdentitySettings": {
"MaxDescriptionLength": 2048,
"MinPerformerDescriptionLength": 50,
"MaxCompetencies": 50
}
}

View File

@@ -1,6 +1,5 @@
using System.Text.Json;
using MediatR;
using MediatR;
using Nashel.BuildingBlocks.Application.Abstractions;
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Catalog.Domain.Aggregates;

View File

@@ -1,33 +1,47 @@
using MediatR;
using Microsoft.Extensions.Options;
using Nashel.BuildingBlocks.Application.Abstractions;
using Nashel.BuildingBlocks.Application.Configurations;
using Nashel.BuildingBlocks.Application.Exceptions;
namespace Nashel.Modules.Catalog.Application.Commands;
/// <summary>
/// Команда загрузки изображения услуги.
/// </summary>
public record UploadOfferImageCommand(byte[] Content, string FileName) : IRequest<string>;
public class UploadOfferImageCommandHandler : IRequestHandler<UploadOfferImageCommand, string>
{
private const long MaxFileSize = 5 * 1024 * 1024; // 5MB
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".webp" };
private readonly ICurrentUserService _currentUserService;
private readonly MediaSettings _settings;
public UploadOfferImageCommandHandler(ICurrentUserService currentUserService)
public UploadOfferImageCommandHandler(
ICurrentUserService currentUserService,
IOptions<MediaSettings> settings)
{
_currentUserService = currentUserService;
_settings = settings.Value;
}
public Task<string> Handle(UploadOfferImageCommand request, CancellationToken cancellationToken)
{
var userId = _currentUserService.UserId;
if (userId == null) throw new UnauthorizedAccessException();
if (userId == null)
{
throw new UnauthorizedAccessException();
}
if (request.Content.Length > MaxFileSize)
throw new InvalidOperationException("Размер файла не должен превышать 5 МБ");
if (request.Content.Length > _settings.MaxFileSizeInBytes)
{
throw new BusinessRuleException($"Размер файла слишком велик. Максимум: {_settings.MaxFileSizeInBytes / 1024 / 1024} МБ.");
}
var extension = Path.GetExtension(request.FileName).ToLowerInvariant();
if (Array.IndexOf(AllowedExtensions, extension) == -1)
throw new InvalidOperationException("Допустимые форматы: JPG, PNG, WEBP");
if (!_settings.AllowedExtensions.Contains(extension))
{
throw new BusinessRuleException($"Допустимые форматы: {string.Join(", ", _settings.AllowedExtensions)}.");
}
var base64String = Convert.ToBase64String(request.Content);

View File

@@ -95,7 +95,27 @@ public record OfferDto
/// </summary>
public List<string> Images { get; init; } = new();
public OfferDto(Guid id, Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive, List<string>? images = null)
/// <summary>
/// Имя исполнителя.
/// </summary>
public string? PerformerName { get; init; }
/// <summary>
/// Роль исполнителя (Мастер, Специалист и т.д.).
/// </summary>
public string? PerformerRole { get; init; }
/// <summary>
/// Средний рейтинг услуги.
/// </summary>
public double Rating { get; init; }
/// <summary>
/// Количество отзывов об услуге.
/// </summary>
public int ReviewsCount { get; init; }
public OfferDto(Guid id, Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive, List<string>? images = null, string? performerName = null, string? performerRole = null, double rating = 0, int reviewsCount = 0)
{
Id = id;
PerformerId = performerId;
@@ -109,6 +129,10 @@ public record OfferDto
{
Images.AddRange(images);
}
PerformerName = performerName;
PerformerRole = performerRole;
Rating = rating;
ReviewsCount = reviewsCount;
}
public OfferDto() { }

View File

@@ -2,6 +2,8 @@
<ItemGroup>
<ProjectReference Include="..\..\..\BuildingBlocks\Nashel.BuildingBlocks.csproj" />
<ProjectReference Include="..\Domain\Nashel.Modules.Catalog.Domain.csproj" />
<ProjectReference Include="..\..\Identity\Application\Nashel.Modules.Identity.Application.csproj" />
<ProjectReference Include="..\..\Reputation\Application\Nashel.Modules.Reputation.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="14.0.0" />

View File

@@ -13,16 +13,25 @@ public record GetOfferByIdQuery(Guid Id) : IRequest<Result<OfferDto>>;
public class GetOfferByIdQueryHandler : IRequestHandler<GetOfferByIdQuery, Result<OfferDto>>
{
private readonly IOfferRepository _repository;
private readonly IMediator _mediator;
public GetOfferByIdQueryHandler(IOfferRepository repository)
public GetOfferByIdQueryHandler(IOfferRepository repository, IMediator mediator)
{
_repository = repository;
_mediator = mediator;
}
public async Task<Result<OfferDto>> Handle(GetOfferByIdQuery request, CancellationToken cancellationToken)
{
var offer = await _repository.GetByIdAsync(request.Id, cancellationToken);
if (offer == null) return Result<OfferDto>.Failure("Offer not found");
if (offer == null)
{
throw new Nashel.BuildingBlocks.Application.Exceptions.NotFoundException("Offer", request.Id);
}
// Получаем данные исполнителя и рейтинги
var userDetailsResult = await _mediator.Send(new Nashel.Modules.Identity.Application.Queries.GetUserDetailsQuery(offer.PerformerId), cancellationToken);
var profileRatingResult = await _mediator.Send(new Nashel.Modules.Reputation.Application.Queries.GetProfileRatingQuery(offer.PerformerId), cancellationToken);
var dto = new OfferDto(
offer.Id,
@@ -33,7 +42,11 @@ public class GetOfferByIdQueryHandler : IRequestHandler<GetOfferByIdQuery, Resul
offer.Price,
offer.Attributes,
offer.IsActive,
offer.Images
offer.Images,
performerName: (userDetailsResult != null && userDetailsResult.IsSuccess && userDetailsResult.Value != null) ? userDetailsResult.Value.FullName : "Неизвестный исполнитель",
performerRole: (userDetailsResult != null && userDetailsResult.IsSuccess && userDetailsResult.Value != null) ? userDetailsResult.Value.PrimaryRole : "Пользователь",
rating: profileRatingResult?.AverageRating ?? 0,
reviewsCount: profileRatingResult?.TotalReviews ?? 0
);
return Result<OfferDto>.Success(dto);

View File

@@ -17,7 +17,7 @@ namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations
table: "Offers",
type: "text[]",
nullable: false,
defaultValue: new string[0]);
defaultValueSql: "'{}'");
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",

View File

@@ -0,0 +1,148 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Catalog.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(CatalogDbContext))]
[Migration("20260310145100_BackendUpdate_Catalog")]
partial class BackendUpdate_Catalog
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<JsonDocument>("AttributeSchema")
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ParentId");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Categories", "catalog");
});
modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<JsonDocument>("Attributes")
.HasColumnType("jsonb");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.PrimitiveCollection<List<string>>("Images")
.IsRequired()
.HasColumnType("text[]");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Attributes");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Attributes"), "gin");
b.ToTable("Offers", "catalog");
});
modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b =>
{
b.HasOne("Nashel.Modules.Catalog.Domain.Aggregates.Category", null)
.WithMany()
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b =>
{
b.OwnsOne("Nashel.Modules.Catalog.Domain.ValueObjects.Price", "Price", b1 =>
{
b1.Property<Guid>("OfferId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("PriceType");
b1.HasKey("OfferId");
b1.ToTable("Offers", "catalog");
b1.WithOwner()
.HasForeignKey("OfferId");
});
b.Navigation("Price")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BackendUpdate_Catalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -1,5 +1,5 @@
using FluentAssertions;
using Moq;
using NSubstitute;
using Nashel.Modules.Catalog.Application.Commands;
using Nashel.Modules.Catalog.Domain.Aggregates;
using Nashel.Modules.Catalog.Domain.Repositories;
@@ -9,19 +9,20 @@ namespace Nashel.Modules.Catalog.Tests.Application;
public class CreateCategoryCommandHandlerTests
{
private readonly Mock<ICategoryRepository> _categoryRepositoryMock;
private readonly ICategoryRepository _categoryRepository;
private readonly CreateCategoryCommandHandler _handler;
public CreateCategoryCommandHandlerTests()
{
_categoryRepositoryMock = new Mock<ICategoryRepository>();
_handler = new CreateCategoryCommandHandler(_categoryRepositoryMock.Object);
// Инициализация мока через NSubstitute
_categoryRepository = Substitute.For<ICategoryRepository>();
_handler = new CreateCategoryCommandHandler(_categoryRepository);
}
[Fact]
public async Task Handle_ShouldCreateCategory_WhenCommandIsValid()
{
// Arrange
// Arrange (Подготовка)
var command = new CreateCategoryCommand(
"Test Category",
"test-category",
@@ -29,16 +30,19 @@ public class CreateCategoryCommandHandlerTests
null
);
// Act
// Act (Действие)
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().Be(result); // Guid should be returned
_categoryRepositoryMock.Verify(r => r.AddAsync(It.Is<Category>(c =>
c.Title == command.Title &&
// Assert (Проверка)
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeEmpty();
// Проверяем вызов репозитория
await _categoryRepository.Received(1).AddAsync(Arg.Is<Category>(c =>
c.Name == command.Name &&
c.Slug == command.Slug &&
c.ParentId == command.ParentId &&
c.AttributeSchema == command.AttributeSchema
), It.IsAny<CancellationToken>()), Times.Once);
), Arg.Any<CancellationToken>());
}
}

View File

@@ -1,5 +1,6 @@
using FluentAssertions;
using Moq;
using NSubstitute;
using Nashel.BuildingBlocks.Application.Abstractions;
using Nashel.Modules.Catalog.Application.Commands;
using Nashel.Modules.Catalog.Domain.Aggregates;
using Nashel.Modules.Catalog.Domain.Enums;
@@ -12,42 +13,52 @@ namespace Nashel.Modules.Catalog.Tests.Application;
public class CreateOfferCommandHandlerTests
{
private readonly Mock<IOfferRepository> _offerRepositoryMock;
private readonly IOfferRepository _offerRepository;
private readonly ICurrentUserService _currentUserService;
private readonly CreateOfferCommandHandler _handler;
public CreateOfferCommandHandlerTests()
{
_offerRepositoryMock = new Mock<IOfferRepository>();
_handler = new CreateOfferCommandHandler(_offerRepositoryMock.Object);
// Инициализация моков через NSubstitute
_offerRepository = Substitute.For<IOfferRepository>();
_currentUserService = Substitute.For<ICurrentUserService>();
_handler = new CreateOfferCommandHandler(_offerRepository, _currentUserService);
}
[Fact]
public async Task Handle_ShouldCreateOffer_WhenCommandIsValid()
{
// Arrange
// Arrange (Подготовка)
var userId = Guid.NewGuid();
var categoryId = Guid.NewGuid();
var command = new CreateOfferCommand(
Guid.NewGuid(),
Guid.NewGuid(),
categoryId,
"Test Offer",
"Test Description",
new Price(100, OfferType.Fixed, "RUB"),
JsonDocument.Parse("{}")
JsonDocument.Parse("{}"),
null
);
// Act
_currentUserService.UserId.Returns(userId);
// Act (Действие)
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().Be(result); // Guid should be returned
_offerRepositoryMock.Verify(r => r.AddAsync(It.Is<Offer>(o =>
// Assert (Проверка)
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeEmpty();
// Проверяем, что метод AddAsync был вызван ровно один раз с правильными параметрами
await _offerRepository.Received(1).AddAsync(Arg.Is<Offer>(o =>
o.Title == command.Title &&
o.OwnerId == command.OwnerId &&
o.PerformerId == userId &&
o.CategoryId == command.CategoryId &&
o.Description == command.Description &&
o.Price.Amount == command.Price.Amount &&
o.Price.Type == command.Price.Type &&
o.Price.Currency == command.Price.Currency &&
o.Attributes == command.Attributes
), It.IsAny<CancellationToken>()), Times.Once);
), Arg.Any<CancellationToken>());
}
}

View File

@@ -1,5 +1,5 @@
using FluentAssertions;
using Moq;
using NSubstitute;
using Nashel.Modules.Catalog.Application.Common;
using Nashel.Modules.Catalog.Application.Queries;
using Nashel.Modules.Catalog.Domain.Aggregates;
@@ -11,57 +11,58 @@ namespace Nashel.Modules.Catalog.Tests.Application;
public class GetCategoriesQueryHandlerTests
{
private readonly Mock<ICategoryRepository> _categoryRepositoryMock;
private readonly ICategoryRepository _categoryRepository;
private readonly GetCategoriesQueryHandler _handler;
public GetCategoriesQueryHandlerTests()
{
_categoryRepositoryMock = new Mock<ICategoryRepository>();
_handler = new GetCategoriesQueryHandler(_categoryRepositoryMock.Object);
// Инициализация мока репозитория через NSubstitute
_categoryRepository = Substitute.For<ICategoryRepository>();
_handler = new GetCategoriesQueryHandler(_categoryRepository);
}
[Fact]
public async Task Handle_ShouldReturnCategories_WhenCategoriesExist()
{
// Arrange
// Arrange (Подготовка)
var categories = new List<Category>
{
new Category("Category 1", "category-1", null),
new Category("Category 2", "category-2", null)
};
_categoryRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(categories);
_categoryRepository.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(categories);
var query = new GetCategoriesQuery();
// Act
// Act (Действие)
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Should().HaveCount(2);
// Assert (Проверка)
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value.Should().HaveCount(2);
var expectedDto1 = new CategoryDto(categories[0].Id, categories[0].Title, categories[0].Slug, categories[0].ParentId, categories[0].AttributeSchema);
var expectedDto2 = new CategoryDto(categories[1].Id, categories[1].Title, categories[1].Slug, categories[1].ParentId, categories[1].AttributeSchema);
result.Should().ContainEquivalentOf(expectedDto1);
result.Should().ContainEquivalentOf(expectedDto2);
// Сверяем DTO с данными из доменных сущностей
result.Value.Should().ContainEquivalentOf(new { Name = "Category 1", Slug = "category-1" });
result.Value.Should().ContainEquivalentOf(new { Name = "Category 2", Slug = "category-2" });
}
[Fact]
public async Task Handle_ShouldReturnEmptyList_WhenNoCategoriesExist()
{
// Arrange
_categoryRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Category>());
// Arrange (Подготовка)
_categoryRepository.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(new List<Category>());
var query = new GetCategoriesQuery();
// Act
// Act (Действие)
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Should().BeEmpty();
// Assert (Проверка)
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value.Should().BeEmpty();
}
}

View File

@@ -1,11 +1,13 @@
using FluentAssertions;
using Moq;
using NSubstitute;
using MediatR;
using Nashel.Modules.Catalog.Application.Common;
using Nashel.Modules.Catalog.Application.Queries;
using Nashel.Modules.Catalog.Domain.Aggregates;
using Nashel.Modules.Catalog.Domain.Repositories;
using Nashel.Modules.Catalog.Domain.ValueObjects;
using Nashel.Modules.Catalog.Domain.Enums;
using Nashel.BuildingBlocks.Application.Exceptions;
using System.Text.Json;
using Xunit;
@@ -13,20 +15,21 @@ namespace Nashel.Modules.Catalog.Tests.Application;
public class GetOfferByIdQueryHandlerTests
{
private readonly Mock<IOfferRepository> _offerRepositoryMock;
private readonly IOfferRepository _offerRepository;
private readonly IMediator _mediator;
private readonly GetOfferByIdQueryHandler _handler;
public GetOfferByIdQueryHandlerTests()
{
_offerRepositoryMock = new Mock<IOfferRepository>();
_handler = new GetOfferByIdQueryHandler(_offerRepositoryMock.Object);
_offerRepository = Substitute.For<IOfferRepository>();
_mediator = Substitute.For<IMediator>();
_handler = new GetOfferByIdQueryHandler(_offerRepository, _mediator);
}
[Fact]
public async Task Handle_ShouldReturnOffer_WhenOfferExists()
{
// Arrange
var offerId = Guid.NewGuid();
// Arrange (Подготовка)
var offer = new Offer(
Guid.NewGuid(),
Guid.NewGuid(),
@@ -36,44 +39,40 @@ public class GetOfferByIdQueryHandlerTests
JsonDocument.Parse("{}")
);
// Reflection hack to set the ID as it's private set in constructor (Guid.NewGuid())
// But wait, the constructor sets a new GUID.
// We need to mock the repository to return the offer when GetByIdAsync is called with the offer's generated ID.
// Or cleaner: modify the repository setup to ignore the specific ID argument or capture it?
// No, let's just use the ID generated by the offer.
_offerRepository.GetByIdAsync(offer.Id, Arg.Any<CancellationToken>())
.Returns(offer);
_offerRepositoryMock.Setup(r => r.GetByIdAsync(offer.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(offer);
// Настройка мока Mediator для внутренних запросов
_mediator.Send(Arg.Any<Nashel.Modules.Identity.Application.Queries.GetUserDetailsQuery>(), Arg.Any<CancellationToken>())
.Returns(Nashel.BuildingBlocks.Domain.Result<Nashel.Modules.Identity.Application.Queries.UserDetailsDto>.Success(
new Nashel.Modules.Identity.Application.Queries.UserDetailsDto(offer.PerformerId, "Исполнитель", null, "Мастер")));
_mediator.Send(Arg.Any<Nashel.Modules.Reputation.Application.Queries.GetProfileRatingQuery>(), Arg.Any<CancellationToken>())
.Returns(new Nashel.Modules.Reputation.Application.Queries.ProfileRatingDto(4.5, 10));
var query = new GetOfferByIdQuery(offer.Id);
// Act
// Act (Действие)
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(offer.Id);
result.Title.Should().Be("Test Offer");
result.Description.Should().Be("Test Description");
result.Price.Should().BeEquivalentTo(offer.Price);
result.OwnerId.Should().Be(offer.OwnerId);
result.CategoryId.Should().Be(offer.CategoryId);
result.Attributes.Should().Be(offer.Attributes);
// Assert (Проверка)
result.IsSuccess.Should().BeTrue();
result.Value.Id.Should().Be(offer.Id);
result.Value.Title.Should().Be("Test Offer");
result.Value.Rating.Should().Be(4.5);
}
[Fact]
public async Task Handle_ShouldReturnNull_WhenOfferDoesNotExist()
public async Task Handle_ShouldThrowNotFoundException_WhenOfferDoesNotExist()
{
// Arrange
_offerRepositoryMock.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Offer?)null);
// Arrange (Подготовка)
_offerRepository.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns((Offer?)null);
var query = new GetOfferByIdQuery(Guid.NewGuid());
// Act
var result = await _handler.Handle(query, CancellationToken.None);
// Assert
result.Should().BeNull();
// Act & Assert (Действие и Проверка)
var act = async () => await _handler.Handle(query, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -10,30 +10,30 @@ public class CategoryTests
[Fact]
public void Constructor_ShouldSetPropertiesCorrectly()
{
// Assemble
// Arrange (Подготовка)
var title = "Test Category";
var slug = "test-category";
var parentId = Guid.NewGuid();
var jsonDoc = JsonDocument.Parse("{}");
// Act
// Act (Действие)
var category = new Category(title, slug, parentId, jsonDoc);
// Assert
// Assert (Проверка)
category.Id.Should().NotBeEmpty();
category.Title.Should().Be(title);
category.Name.Should().Be(title);
category.Slug.Should().Be(slug);
category.ParentId.Should().Be(parentId);
category.AttributeSchema.Should().Be(jsonDoc);
category.AttributeSchema.Should().NotBeNull();
}
[Fact]
public void Constructor_ShouldSetDefaultValues_WhenOptionalParametersAreNull()
{
// Act
// Act (Действие)
var category = new Category("Test", "test", null, null);
// Assert
// Assert (Проверка)
category.ParentId.Should().BeNull();
category.AttributeSchema.Should().BeNull();
}

View File

@@ -12,25 +12,25 @@ public class OfferTests
[Fact]
public void Constructor_ShouldSetPropertiesCorrectly()
{
// Assemble
var ownerId = Guid.NewGuid();
// Arrange (Подготовка)
var performerId = Guid.NewGuid();
var categoryId = Guid.NewGuid();
var title = "Test Offer";
var description = "Test Description";
var price = new Price(100, OfferType.Fixed, "RUB");
var attributes = JsonDocument.Parse("{}");
// Act
var offer = new Offer(ownerId, categoryId, title, description, price, attributes);
// Act (Действие)
var offer = new Offer(performerId, categoryId, title, description, price, attributes);
// Assert
// Assert (Проверка)
offer.Id.Should().NotBeEmpty();
offer.OwnerId.Should().Be(ownerId);
offer.PerformerId.Should().Be(performerId);
offer.CategoryId.Should().Be(categoryId);
offer.Title.Should().Be(title);
offer.Description.Should().Be(description);
offer.Price.Should().Be(price);
offer.Attributes.Should().Be(attributes);
offer.Price.Should().BeEquivalentTo(price);
offer.Attributes.Should().NotBeNull();
offer.IsActive.Should().BeTrue();
}
}

View File

@@ -10,15 +10,15 @@ public class PriceTests
[Fact]
public void Constructor_ShouldSetPropertiesCorrectly()
{
// Assemble
// Arrange (Подготовка)
var amount = 100m;
var type = OfferType.Fixed;
var currency = "USD";
// Act
// Act (Действие)
var price = new Price(amount, type, currency);
// Assert
// Assert (Проверка)
price.Amount.Should().Be(amount);
price.Type.Should().Be(type);
price.Currency.Should().Be(currency);
@@ -27,32 +27,32 @@ public class PriceTests
[Fact]
public void Constructor_ShouldSetDefaultCurrency_WhenNotProvided()
{
// Act
// Act (Действие)
var price = new Price(100m, OfferType.Fixed);
// Assert
// Assert (Проверка)
price.Currency.Should().Be("RUB");
}
[Fact]
public void Equality_ShouldBeTrue_WhenValuesAreSame()
{
// Arrange
// Arrange (Подготовка)
var price1 = new Price(100m, OfferType.Fixed, "USD");
var price2 = new Price(100m, OfferType.Fixed, "USD");
// Act & Assert
// Act & Assert (Действие и Проверка)
price1.Should().Be(price2);
}
[Fact]
public void Equality_ShouldBeFalse_WhenValuesAreDifferent()
{
// Arrange
// Arrange (Подготовка)
var price1 = new Price(100m, OfferType.Fixed, "USD");
var price2 = new Price(200m, OfferType.Fixed, "USD");
// Act & Assert
// Act & Assert (Действие и Проверка)
price1.Should().NotBe(price2);
}
}

View File

@@ -13,7 +13,7 @@
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,104 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Collaboration.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Collaboration.Infrastructure.Migrations
{
[DbContext(typeof(CollaborationDbContext))]
[Migration("20260310145106_BackendUpdate_Collaboration")]
partial class BackendUpdate_Collaboration
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("collaboration")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Collaboration.Domain.Entities.Contract", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("EmployeeId")
.HasColumnType("uuid");
b.Property<Guid>("EmployerId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Contracts", "collaboration");
});
modelBuilder.Entity("Nashel.Modules.Collaboration.Domain.Entities.Contract", b =>
{
b.OwnsMany("Nashel.Modules.Collaboration.Domain.Entities.VetoLock", "VetoLocks", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ContractId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ContractId");
b1.ToTable("VetoLocks", "collaboration");
b1.WithOwner()
.HasForeignKey("ContractId");
b1.OwnsOne("Nashel.Modules.Collaboration.Domain.ValueObjects.DateRange", "Period", b2 =>
{
b2.Property<Guid>("VetoLockId")
.HasColumnType("uuid");
b2.Property<DateTime>("End")
.HasColumnType("timestamp with time zone")
.HasColumnName("VetoEnd");
b2.Property<DateTime>("Start")
.HasColumnType("timestamp with time zone")
.HasColumnName("VetoStart");
b2.HasKey("VetoLockId");
b2.ToTable("VetoLocks", "collaboration");
b2.WithOwner()
.HasForeignKey("VetoLockId");
});
b1.Navigation("Period")
.IsRequired();
});
b.Navigation("VetoLocks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Collaboration.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BackendUpdate_Collaboration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -10,14 +10,14 @@ public class ContractTests
[Fact]
public void ImposeVeto_Should_AddLock_When_ContractIsActive()
{
// Arrange
// Arrange (Подготовка)
var contract = Contract.Create(Guid.NewGuid(), Guid.NewGuid(), "Прораб");
var period = new DateRange(DateTime.UtcNow, DateTime.UtcNow.AddDays(1));
// Act
// Act (Действие)
contract.ImposeVeto(period);
// Assert
// Assert (Проверка)
contract.VetoLocks.Should().ContainSingle()
.Which.Period.Should().Be(period);
}
@@ -25,15 +25,15 @@ public class ContractTests
[Fact]
public void ImposeVeto_Should_Fail_When_ContractIsTerminated()
{
// Arrange
// Arrange (Подготовка)
var contract = Contract.Create(Guid.NewGuid(), Guid.NewGuid(), "Прораб");
contract.Terminate();
var period = new DateRange(DateTime.UtcNow, DateTime.UtcNow.AddDays(1));
// Act
// Act (Действие)
var action = () => contract.ImposeVeto(period);
// Assert
// Assert (Проверка)
action.Should().Throw<InvalidOperationException>()
.WithMessage("Нельзя наложить вето на расторгнутый контракт.");
}
@@ -41,10 +41,10 @@ public class ContractTests
[Fact]
public void Contract_Should_Be_Active_Upon_Creation()
{
// Arrange & Act
// Arrange & Act (Подготовка и Действие)
var contract = Contract.Create(Guid.NewGuid(), Guid.NewGuid(), "Мастер");
// Assert
// Assert (Проверка)
contract.Status.Should().Be(Nashel.Modules.Collaboration.Domain.Enums.ContractStatus.Active);
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -11,7 +11,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

View File

@@ -1,5 +1,5 @@
using FluentAssertions;
using Moq;
using NSubstitute;
using Nashel.Modules.Collaboration.Domain.Entities;
using Nashel.Modules.Collaboration.Domain.Repositories;
using Nashel.Modules.Collaboration.Domain.Services;
@@ -10,19 +10,20 @@ namespace Nashel.Modules.Collaboration.Tests.Services;
public class ScheduleConflictServiceTests
{
private readonly Mock<IContractRepository> _repositoryMock;
private readonly IContractRepository _repository;
private readonly ScheduleConflictService _service;
public ScheduleConflictServiceTests()
{
_repositoryMock = new Mock<IContractRepository>();
_service = new ScheduleConflictService(_repositoryMock.Object);
// Инициализация мока через NSubstitute
_repository = Substitute.For<IContractRepository>();
_service = new ScheduleConflictService(_repository);
}
[Fact]
public async Task CheckAvailability_Should_ReturnFailure_When_VetoOverlaps()
{
// 1. Arrange
// Arrange (Подготовка)
var employeeId = Guid.NewGuid();
var employerId = Guid.NewGuid();
@@ -36,24 +37,24 @@ public class ScheduleConflictServiceTests
var contractB = Contract.Create(Guid.NewGuid(), employeeId, "Электрик");
// Мокаем репозиторий
_repositoryMock.Setup(r => r.GetAllByEmployeeIdAsync(employeeId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Contract> { contractA, contractB });
_repository.GetAllByEmployeeIdAsync(employeeId, Arg.Any<CancellationToken>())
.Returns(new List<Contract> { contractA, contractB });
// Период проверки: 03.05 (попадает в диапазон)
var requestedDate = new DateTime(2025, 5, 3);
var requestedPeriod = new DateRange(requestedDate, requestedDate.AddHours(4));
// 2. Act
// Act (Действие)
var isAvailable = await _service.CheckAvailability(employeeId, requestedPeriod);
// 3. Assert
// Assert (Проверка)
isAvailable.Should().BeFalse("Сотрудник должен быть занят из-за Вето");
}
[Fact]
public async Task CheckAvailability_Should_ReturnSuccess_When_NoVetoOverlap()
{
// 1. Arrange
// Arrange (Подготовка)
var employeeId = Guid.NewGuid();
var employerId = Guid.NewGuid();
@@ -63,17 +64,17 @@ public class ScheduleConflictServiceTests
var vetoEnd = new DateTime(2025, 5, 5);
contractA.ImposeVeto(new DateRange(vetoStart, vetoEnd));
_repositoryMock.Setup(r => r.GetAllByEmployeeIdAsync(employeeId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Contract> { contractA });
_repository.GetAllByEmployeeIdAsync(employeeId, Arg.Any<CancellationToken>())
.Returns(new List<Contract> { contractA });
// Период проверки: 06.05 (Свободен)
var requestedDate = new DateTime(2025, 5, 6);
var requestedPeriod = new DateRange(requestedDate, requestedDate.AddHours(4));
// 2. Act
// Act (Действие)
var isAvailable = await _service.CheckAvailability(employeeId, requestedPeriod);
// 3. Assert
// Assert (Проверка)
isAvailable.Should().BeTrue("Сотрудник должен быть доступен за пределами Вето");
}
}

View File

@@ -0,0 +1,43 @@
using MediatR;
using Nashel.Modules.Geo.Domain.Enums;
using Nashel.Modules.Geo.Domain.Repositories;
namespace Nashel.Modules.Geo.Application.Queries;
public record GetPerformerStatusQuery(Guid PerformerId) : IRequest<PerformerStatusDto>;
public record PerformerStatusDto(
Guid PerformerId,
string Status, // "Available", "Offline", "OnJob"
double? Latitude,
double? Longitude,
DateTime? LastUpdatedAt
);
public class GetPerformerStatusHandler : IRequestHandler<GetPerformerStatusQuery, PerformerStatusDto>
{
private readonly IGeoRepository _repository;
public GetPerformerStatusHandler(IGeoRepository repository)
{
_repository = repository;
}
public async Task<PerformerStatusDto> Handle(GetPerformerStatusQuery request, CancellationToken cancellationToken)
{
var status = await _repository.GetByPerformerIdAsync(request.PerformerId, cancellationToken);
if (status == null)
{
return new PerformerStatusDto(request.PerformerId, "Offline", null, null, null);
}
return new PerformerStatusDto(
status.Id,
status.State.ToString(),
status.Location?.Coordinate.Y,
status.Location?.Coordinate.X,
status.LastUpdatedAt
);
}
}

View File

@@ -0,0 +1,59 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Geo.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Geo.Infrastructure.Migrations
{
[DbContext(typeof(GeoDbContext))]
[Migration("20260310144949_BackendUpdate_Geo")]
partial class BackendUpdate_Geo
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Geo.Domain.Aggregates.LiveStatus", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Point>("Location")
.IsRequired()
.HasColumnType("geography (point)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("Location");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist");
b.ToTable("LiveStatuses", "geo");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Geo.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BackendUpdate_Geo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -1,5 +1,6 @@
using FluentAssertions;
using Moq;
using MediatR;
using NSubstitute;
using Nashel.Modules.Geo.Application.Commands;
using Nashel.Modules.Geo.Domain.Aggregates;
using Nashel.Modules.Geo.Domain.Enums;
@@ -8,60 +9,60 @@ using Xunit;
namespace Nashel.Modules.Geo.Tests.Application;
public class UpdateGeoCommandHandlerTests
public class UpdateStatusCommandHandlerTests
{
private readonly Mock<IGeoRepository> _repositoryMock;
private readonly UpdateGeoCommandHandler _handler;
private readonly IGeoRepository _repository;
private readonly UpdateStatusCommandHandler _handler;
public UpdateGeoCommandHandlerTests()
public UpdateStatusCommandHandlerTests()
{
_repositoryMock = new Mock<IGeoRepository>();
_handler = new UpdateGeoCommandHandler(_repositoryMock.Object);
_repository = Substitute.For<IGeoRepository>();
_handler = new UpdateStatusCommandHandler(_repository);
}
[Fact]
public async Task Handle_Should_CreateNew_When_RecordDoesNotExist()
{
// Arrange
var command = new UpdateGeoCommand(Guid.NewGuid(), 10.0, 20.0, PerformerStatus.Available);
// Arrange (Подготовка)
var command = new UpdateStatusCommand(Guid.NewGuid(), 10.0, 20.0, PerformerStatus.Available);
_repositoryMock.Setup(x => x.GetByPerformerIdAsync(command.PerformerId, It.IsAny<CancellationToken>()))
.ReturnsAsync((LiveStatus?)null);
_repository.GetByPerformerIdAsync(command.PerformerId, Arg.Any<CancellationToken>())
.Returns((LiveStatus?)null);
// Act
// Act (Действие)
await _handler.Handle(command, CancellationToken.None);
// Assert
_repositoryMock.Verify(x => x.AddAsync(
It.Is<LiveStatus>(s =>
// Assert (Проверка)
await _repository.Received(1).AddAsync(
Arg.Is<LiveStatus>(s =>
s.Id == command.PerformerId &&
s.Location.Y == command.Latitude &&
s.Location.X == command.Longitude),
It.IsAny<CancellationToken>()), Times.Once);
Arg.Any<CancellationToken>());
_repositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
await _repository.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Should_UpdateExisting_When_RecordExists()
{
// Arrange
// Arrange (Подготовка)
var performerId = Guid.NewGuid();
var existingStatus = new LiveStatus(performerId, 10.0, 20.0);
var command = new UpdateGeoCommand(performerId, 15.0, 25.0, PerformerStatus.Busy);
var command = new UpdateStatusCommand(performerId, 15.0, 25.0, PerformerStatus.OnJob);
_repositoryMock.Setup(x => x.GetByPerformerIdAsync(performerId, It.IsAny<CancellationToken>()))
.ReturnsAsync(existingStatus);
_repository.GetByPerformerIdAsync(performerId, Arg.Any<CancellationToken>())
.Returns(existingStatus);
// Act
// Act (Действие)
await _handler.Handle(command, CancellationToken.None);
// Assert
// Assert (Проверка)
existingStatus.Location.Y.Should().Be(15.0);
existingStatus.Location.X.Should().Be(25.0);
existingStatus.Status.Should().Be(PerformerStatus.Busy);
existingStatus.State.Should().Be(PerformerStatus.OnJob);
_repositoryMock.Verify(x => x.AddAsync(It.IsAny<LiveStatus>(), It.IsAny<CancellationToken>()), Times.Never);
_repositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
await _repository.DidNotReceive().AddAsync(Arg.Any<LiveStatus>(), Arg.Any<CancellationToken>());
await _repository.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
}

View File

@@ -10,34 +10,32 @@ public class LiveStatusTests
[Fact]
public void UpdateLocation_Should_ChangeCoordinates_And_UpdateTimestamp()
{
// Arrange
// Arrange (Подготовка)
var performerId = Guid.NewGuid();
var liveStatus = new LiveStatus(performerId, 10.0, 20.0);
var originalTimestamp = liveStatus.LastUpdated;
var originalTimestamp = liveStatus.LastUpdatedAt;
Thread.Sleep(50); // Small delay
Thread.Sleep(10); // Небольшая задержка
// Act
// Act (Действие)
liveStatus.UpdateLocation(15.0, 25.0);
// Assert
liveStatus.Location.Y.Should().Be(15.0); // Latitude
liveStatus.Location.X.Should().Be(25.0); // Longitude
liveStatus.LastUpdated.Should().BeAfter(originalTimestamp);
// Assert (Проверка)
liveStatus.Location.Y.Should().Be(15.0); // Широта
liveStatus.Location.X.Should().Be(25.0); // Долгота
liveStatus.LastUpdatedAt.Should().BeAfter(originalTimestamp);
}
[Fact]
public void SetBusy_Should_ChangeStatus_To_Busy()
public void SetState_Should_ChangeStatus()
{
// Arrange
// Arrange (Подготовка)
var liveStatus = new LiveStatus(Guid.NewGuid(), 10.0, 20.0);
var busyUntil = DateTime.UtcNow.AddHours(2);
// Act
liveStatus.SetBusy(busyUntil);
// Act (Действие)
liveStatus.SetState(PerformerStatus.OnJob);
// Assert
liveStatus.Status.Should().Be(PerformerStatus.Busy);
liveStatus.BusyUntil.Should().Be(busyUntil);
// Assert (Проверка)
liveStatus.State.Should().Be(PerformerStatus.OnJob);
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -11,7 +11,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />

View File

@@ -1,17 +1,34 @@
using MediatR;
using Microsoft.Extensions.Options;
using Nashel.BuildingBlocks.Application.Abstractions;
using Nashel.BuildingBlocks.Application.Configurations;
using Nashel.BuildingBlocks.Application.Exceptions;
using Nashel.Modules.Identity.Domain.Entities;
using Nashel.Modules.Identity.Domain.Repositories;
namespace Nashel.Modules.Identity.Application.Commands;
/// <summary>
/// Команда для перехода пользователя в роль исполнителя (мастера).
/// </summary>
public record BecomePerformerCommand : IRequest<Unit>
{
/// <summary>Описание деятельности исполнителя.</summary>
public string Description { get; init; } = default!;
/// <summary>Список названий компетенций.</summary>
public List<string> CompetencyNames { get; init; } = default!;
/// <summary>Текстовый адрес основного места работы.</summary>
public string? Location { get; init; }
/// <summary>Текстовое описание текущего местоположения.</summary>
public string? CurrentLocation { get; init; }
/// <summary>Флаг "Всегда готов" (игнорирует расписание).</summary>
public bool IsAlwaysReady { get; init; }
/// <summary>Рабочие дни (например: "1,2,3,4,5").</summary>
public string? WorkingDays { get; init; }
}
@@ -20,50 +37,59 @@ public class BecomePerformerCommandHandler : IRequestHandler<BecomePerformerComm
private readonly IAccountRepository _accountRepository;
private readonly ICurrentUserService _currentUserService;
private readonly ICompetencyRepository _competencyRepository;
private readonly IdentitySettings _settings;
public BecomePerformerCommandHandler(
IAccountRepository accountRepository,
ICurrentUserService currentUserService,
ICompetencyRepository competencyRepository)
ICompetencyRepository competencyRepository,
IOptions<IdentitySettings> settings)
{
_accountRepository = accountRepository;
_currentUserService = currentUserService;
_competencyRepository = competencyRepository;
_settings = settings.Value;
}
public async Task<Unit> Handle(BecomePerformerCommand request, CancellationToken cancellationToken)
{
Console.WriteLine($"[BecomePerformerCommand] Starting to process request for user");
var userId = _currentUserService.UserId;
if (userId == null)
{
Console.WriteLine("[BecomePerformerCommand] User is not authorized");
throw new Exception("Неавторизован");
throw new UnauthorizedAccessException();
}
Console.WriteLine($"[BecomePerformerCommand] UserId: {userId.Value}");
var account = await _accountRepository.GetByIdAsync(userId.Value, cancellationToken);
if (account == null)
{
Console.WriteLine($"[BecomePerformerCommand] Account not found for userId: {userId.Value}");
throw new Exception("Аккаунт не найден");
throw new NotFoundException("Account", userId.Value);
}
Console.WriteLine($"[BecomePerformerCommand] Account found: {account.Id}");
// Валидация описания
if (string.IsNullOrWhiteSpace(request.Description) || request.Description.Length < _settings.MinPerformerDescriptionLength)
{
throw new BusinessRuleException($"Описание должно содержать минимум {_settings.MinPerformerDescriptionLength} символов.");
}
if (request.Description.Length > _settings.MaxDescriptionLength)
{
throw new BusinessRuleException($"Описание не может превышать {_settings.MaxDescriptionLength} символов.");
}
// Валидация компетенций
if (request.CompetencyNames != null && request.CompetencyNames.Count > _settings.MaxCompetencies)
{
throw new BusinessRuleException($"Нельзя добавить более {_settings.MaxCompetencies} компетенций.");
}
// Получаем или создаем компетенции
var competencies = new List<Competency>();
if (request.CompetencyNames != null)
{
Console.WriteLine($"[BecomePerformerCommand] Processing {request.CompetencyNames.Count} competencies");
foreach (var name in request.CompetencyNames)
{
var competency = await _competencyRepository.GetByNameAsync(name, cancellationToken);
if (competency == null)
{
Console.WriteLine($"[BecomePerformerCommand] Creating new competency: {name}");
competency = Competency.Create(name);
await _competencyRepository.AddAsync(competency, cancellationToken);
}
@@ -74,22 +100,17 @@ public class BecomePerformerCommandHandler : IRequestHandler<BecomePerformerComm
WorkSchedule? workSchedule = null;
if (!request.IsAlwaysReady && !string.IsNullOrEmpty(request.WorkingDays))
{
Console.WriteLine($"[BecomePerformerCommand] Creating work schedule");
workSchedule = WorkSchedule.Create(
request.IsAlwaysReady,
request.WorkingDays
);
}
Console.WriteLine($"[BecomePerformerCommand] Updating performer data");
account.UpdatePerformerData(request.Description, competencies, request.Location, request.CurrentLocation, workSchedule);
account.BecomePerformer();
Console.WriteLine($"[BecomePerformerCommand] Saving to database");
await _accountRepository.UpdateAsync(account, cancellationToken);
Console.WriteLine($"[BecomePerformerCommand] Successfully completed");
return Unit.Value;
}
}

View File

@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\BuildingBlocks\Nashel.BuildingBlocks.csproj" />
<ProjectReference Include="..\Domain\Nashel.Modules.Identity.Domain.csproj" />
<ProjectReference Include="..\..\Geo\Application\Nashel.Modules.Geo.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="14.0.0" />

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using MediatR;
using Nashel.BuildingBlocks.Application.Abstractions;
using Nashel.Modules.Geo.Application.Queries;
using Nashel.Modules.Identity.Domain.Repositories;
namespace Nashel.Modules.Identity.Application.Queries.GetProfile;
@@ -14,8 +15,8 @@ public record WorkScheduleResponse(
);
public record TimePeriod(
[property: JsonPropertyName("start")] string Start,
[property: JsonPropertyName("end")] string End
[property: JsonPropertyName("Start")] string Start,
[property: JsonPropertyName("End")] string End
);
public record ProfileResponse(
@@ -33,17 +34,20 @@ public record ProfileResponse(
string? FullName,
string? AvatarUrl,
List<string> Competencies,
WorkScheduleResponse? WorkSchedule);
WorkScheduleResponse? WorkSchedule,
string? Status = "Offline");
public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, ProfileResponse>
{
private readonly IAccountRepository _accountRepository;
private readonly ICurrentUserService _currentUserService;
private readonly IMediator _mediator;
public GetProfileQueryHandler(IAccountRepository accountRepository, ICurrentUserService currentUserService)
public GetProfileQueryHandler(IAccountRepository accountRepository, ICurrentUserService currentUserService, IMediator mediator)
{
_accountRepository = accountRepository;
_currentUserService = currentUserService;
_mediator = mediator;
}
public async Task<ProfileResponse> Handle(GetProfileQuery request, CancellationToken cancellationToken)
@@ -89,6 +93,14 @@ public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, ProfileRe
);
}
// Получаем статус из модуля Geo
string? currentStatus = "Offline";
if (account.IsPerformer())
{
var statusResult = await _mediator.Send(new GetPerformerStatusQuery(account.Id), cancellationToken);
currentStatus = statusResult.Status;
}
return new ProfileResponse(
account.Id,
account.Phone,
@@ -104,6 +116,7 @@ public class GetProfileQueryHandler : IRequestHandler<GetProfileQuery, ProfileRe
fullName,
account.Profile.AvatarUrl,
account.Profile.Competencies.Select(c => c.Name).ToList(),
workScheduleResponse);
workScheduleResponse,
currentStatus);
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Identity.Domain.Enums;
using Nashel.Modules.Identity.Domain.Repositories;
namespace Nashel.Modules.Identity.Application.Queries;
public record UserDetailsDto(
Guid Id,
string? FullName,
string? AvatarUrl,
string PrimaryRole
);
public record GetUserDetailsQuery(Guid UserId) : IRequest<Result<UserDetailsDto>>;
public class GetUserDetailsHandler : IRequestHandler<GetUserDetailsQuery, Result<UserDetailsDto>>
{
private readonly IAccountRepository _repository;
public GetUserDetailsHandler(IAccountRepository repository)
{
_repository = repository;
}
public async Task<Result<UserDetailsDto>> Handle(GetUserDetailsQuery request, CancellationToken cancellationToken)
{
var account = await _repository.GetByIdAsync(request.UserId, cancellationToken);
if (account == null) return Result<UserDetailsDto>.Failure("User not found");
var names = new[] { account.Profile.FirstName, account.Profile.LastName }
.Where(x => !string.IsNullOrWhiteSpace(x));
var fullName = string.Join(" ", names);
if (string.IsNullOrWhiteSpace(fullName))
{
fullName = account.Profile.CompanyName ?? (account.IsPerformer() ? "Мастер" : "Пользователь");
}
string roleName = "Пользователь";
if (account.Roles.Contains(Role.Master)) roleName = "Мастер";
else if (account.Roles.Contains(Role.Candidate)) roleName = "Кандидат";
else if (account.Roles.Contains(Role.Company)) roleName = "Компания";
return Result<UserDetailsDto>.Success(new UserDetailsDto(
account.Id,
fullName,
account.Profile.AvatarUrl,
roleName
));
}
}

View File

@@ -0,0 +1,49 @@
using MediatR;
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Identity.Domain.Enums;
using Nashel.Modules.Identity.Domain.Repositories;
namespace Nashel.Modules.Identity.Application.Queries;
public record GetUsersDetailsQuery(List<Guid> UserIds) : IRequest<Result<List<UserDetailsDto>>>;
public class GetUsersDetailsHandler : IRequestHandler<GetUsersDetailsQuery, Result<List<UserDetailsDto>>>
{
private readonly IAccountRepository _repository;
public GetUsersDetailsHandler(IAccountRepository repository)
{
_repository = repository;
}
public async Task<Result<List<UserDetailsDto>>> Handle(GetUsersDetailsQuery request, CancellationToken cancellationToken)
{
var accounts = await _repository.GetByIdsAsync(request.UserIds, cancellationToken);
var dtos = accounts.Select(account =>
{
var names = new[] { account.Profile.FirstName, account.Profile.LastName }
.Where(x => !string.IsNullOrWhiteSpace(x));
var fullName = string.Join(" ", names);
if (string.IsNullOrWhiteSpace(fullName))
{
fullName = account.Profile.CompanyName ?? (account.IsPerformer() ? "Мастер" : "Пользователь");
}
string roleName = "Пользователь";
if (account.Roles.Contains(Role.Master)) roleName = "Специалист";
else if (account.Roles.Contains(Role.Candidate)) roleName = "Кандидат";
else if (account.Roles.Contains(Role.Company)) roleName = "Компания";
return new UserDetailsDto(
account.Id,
fullName,
account.Profile.AvatarUrl,
roleName
);
}).ToList();
return Result<List<UserDetailsDto>>.Success(dtos);
}
}

View File

@@ -6,6 +6,7 @@ public interface IAccountRepository
{
Task<Account?> GetByPhoneAsync(string phone, CancellationToken cancellationToken);
Task<Account?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<List<Account>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
Task AddAsync(Account account, CancellationToken cancellationToken);
Task UpdateAsync(Account account, CancellationToken cancellationToken);
Task<Account?> GetByPhoneIncludingDeletedAsync(string phone, CancellationToken cancellationToken);

View File

@@ -0,0 +1,204 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Identity.Infrastructure.Persistence;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(IdentityDbContext))]
[Migration("20260310145039_BackendUpdate_Identity")]
partial class BackendUpdate_Identity
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Roles")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Id");
b.HasIndex("Phone")
.IsUnique();
b.ToTable("Accounts", "identity");
});
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AvatarUrl")
.HasColumnType("text");
b.Property<string>("CompanyName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("CurrentLocation")
.HasColumnType("text");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("FirstName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Inn")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("LastName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Location")
.HasColumnType("text");
b.Property<string>("Patronymic")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.ToTable("UserProfiles", "identity");
});
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Entities.Competency", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("IX_Competencies_Name");
b.ToTable("Competencies", "identity");
});
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<bool>("IsAlwaysReady")
.HasColumnType("boolean");
b.Property<string>("WorkingDays")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.ToTable("WorkSchedules", "identity");
});
modelBuilder.Entity("UserProfileCompetencies", b =>
{
b.Property<Guid>("UserProfileId")
.HasColumnType("uuid");
b.Property<Guid>("CompetencyId")
.HasColumnType("uuid");
b.HasKey("UserProfileId", "CompetencyId");
b.HasIndex("CompetencyId");
b.ToTable("UserProfileCompetencies", "identity");
});
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b =>
{
b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.Account", null)
.WithOne("Profile")
.HasForeignKey("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", b =>
{
b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", null)
.WithOne("WorkSchedule")
.HasForeignKey("Nashel.Modules.Identity.Domain.Entities.WorkSchedule", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("UserProfileCompetencies", b =>
{
b.HasOne("Nashel.Modules.Identity.Domain.Entities.Competency", null)
.WithMany()
.HasForeignKey("CompetencyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", null)
.WithMany()
.HasForeignKey("UserProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b =>
{
b.Navigation("Profile")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.UserProfile", b =>
{
b.Navigation("WorkSchedule");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BackendUpdate_Identity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -42,6 +42,14 @@ public class AccountRepository : IAccountRepository
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
}
public async Task<List<Account>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken)
{
return await _context.Accounts
.Include(a => a.Profile)
.Where(a => ids.Contains(a.Id))
.ToListAsync(cancellationToken);
}
public async Task<Account?> GetByPhoneAsync(string phone, CancellationToken cancellationToken)
{
return await _context.Accounts

View File

@@ -148,6 +148,17 @@ public static class IdentityEndpoints
})
.WithName("UpdateSchedule")
.WithOpenApi(operation => new(operation) { Summary = "Обновить расписание" });
// Публичный профиль (базовая информация)
var publicProfileGroup = app.MapGroup("/api/profile/public").WithTags("Profile Public");
publicProfileGroup.MapGet("/{userId:guid}", async (Guid userId, ISender sender) =>
{
var result = await sender.Send(new GetUserDetailsQuery(userId));
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
})
.WithName("GetPublicProfile")
.WithOpenApi(operation => new(operation) { Summary = "Получить публичную информацию о пользователе" });
}
}

View File

@@ -1,7 +1,10 @@
using FluentAssertions;
using MediatR;
using Moq;
using NSubstitute;
using Microsoft.Extensions.Options;
using Nashel.BuildingBlocks.Application.Abstractions;
using Nashel.BuildingBlocks.Application.Configurations;
using Nashel.BuildingBlocks.Application.Exceptions;
using Nashel.Modules.Identity.Application.Commands;
using Nashel.Modules.Identity.Domain.Aggregates;
using Nashel.Modules.Identity.Domain.Enums;
@@ -12,54 +15,65 @@ namespace Nashel.Modules.Identity.Tests.Application;
public class BecomePerformerCommandHandlerTests
{
private readonly Mock<IAccountRepository> _mockRepo;
private readonly Mock<ICurrentUserService> _mockUserService;
private readonly Mock<ICompetencyRepository> _mockCompetencyRepo;
private readonly IAccountRepository _repo;
private readonly ICurrentUserService _userService;
private readonly ICompetencyRepository _competencyRepo;
private readonly IOptions<IdentitySettings> _settings;
private readonly BecomePerformerCommandHandler _handler;
public BecomePerformerCommandHandlerTests()
{
_mockRepo = new Mock<IAccountRepository>();
_mockUserService = new Mock<ICurrentUserService>();
_mockCompetencyRepo = new Mock<ICompetencyRepository>();
_repo = Substitute.For<IAccountRepository>();
_userService = Substitute.For<ICurrentUserService>();
_competencyRepo = Substitute.For<ICompetencyRepository>();
// Мокаем настройки
_settings = Options.Create(new IdentitySettings
{
MinPerformerDescriptionLength = 50,
MaxDescriptionLength = 2000,
MaxCompetencies = 10
});
_handler = new BecomePerformerCommandHandler(
_mockRepo.Object,
_mockUserService.Object,
_mockCompetencyRepo.Object);
_repo,
_userService,
_competencyRepo,
_settings);
}
[Fact]
public async Task Handle_Should_Add_Candidate_Role()
{
// Arrange
var account = Account.Create("123", "pass"); // Has User role only
// Arrange (Подготовка)
var account = Account.Create("123", "pass");
var profile = UserProfile.Create(account.Id, "TestName", "TestSurname", null, null, null, null);
account.SetProfile(profile);
var userId = account.Id;
_mockUserService.Setup(s => s.UserId).Returns(userId);
_mockRepo.Setup(r => r.GetByIdAsync(userId, It.IsAny<CancellationToken>()))
.ReturnsAsync(account);
_userService.UserId.Returns(userId);
_repo.GetByIdAsync(userId, Arg.Any<CancellationToken>())
.Returns(account);
// Act
// Act (Действие)
var command = new BecomePerformerCommand { Description = new string('x', 51) };
await _handler.Handle(command, CancellationToken.None);
// Assert
// Assert (Проверка)
account.Roles.Should().Contain(Role.Candidate);
_mockRepo.Verify(r => r.UpdateAsync(account, It.IsAny<CancellationToken>()), Times.Once);
await _repo.Received(1).UpdateAsync(account, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Should_Throw_If_Unauthorized()
{
// Arrange
_mockUserService.Setup(s => s.UserId).Returns((Guid?)null);
// Arrange (Подготовка)
_userService.UserId.Returns((Guid?)null);
// Act
// Act (Действие)
var act = async () => await _handler.Handle(new BecomePerformerCommand(), CancellationToken.None);
// Assert
await act.Should().ThrowAsync<Exception>().WithMessage("Неавторизован");
// Assert (Проверка)
await act.Should().ThrowAsync<UnauthorizedAccessException>();
}
}

View File

@@ -0,0 +1,64 @@
using FluentAssertions;
using NSubstitute;
using Nashel.Modules.Identity.Application.Queries;
using Nashel.Modules.Identity.Domain.Aggregates;
using Nashel.Modules.Identity.Domain.Enums;
using Nashel.Modules.Identity.Domain.Repositories;
using Xunit;
namespace Nashel.Modules.Identity.Tests.Application;
public class GetUserDetailsQueryTests
{
private readonly IAccountRepository _repository;
private readonly GetUserDetailsHandler _handler;
public GetUserDetailsQueryTests()
{
_repository = Substitute.For<IAccountRepository>();
_handler = new GetUserDetailsHandler(_repository);
}
[Fact]
public async Task Handle_Should_ReturnSuccess_WhenUserExists()
{
// Arrange (Подготовка)
var userId = Guid.NewGuid();
var account = Account.Create("123", "pass");
var profile = UserProfile.Create(account.Id, "Иван", "Иванов", null, null, null, null);
account.SetProfile(profile);
// Магия рефлексии для установки ID, так как он приватный (хотя в Account.Create он генерится)
// Но нам нужно, чтобы ID в account совпадал с тем, что мы запрашиваем.
// Account.Create(phone, pass) генерит новый GUID.
_repository.GetByIdAsync(account.Id, Arg.Any<CancellationToken>()).Returns(account);
var query = new GetUserDetailsQuery(account.Id);
// Act (Действие)
var result = await _handler.Handle(query, CancellationToken.None);
// Assert (Проверка)
result.IsSuccess.Should().BeTrue();
result.Value!.FullName.Should().Be("Иван Иванов");
result.Value!.PrimaryRole.Should().Be("Пользователь");
}
[Fact]
public async Task Handle_Should_ReturnFailure_WhenUserDoesNotExist()
{
// Arrange (Подготовка)
_repository.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns((Account?)null);
var query = new GetUserDetailsQuery(Guid.NewGuid());
// Act (Действие)
var result = await _handler.Handle(query, CancellationToken.None);
// Assert (Проверка)
result.IsSuccess.Should().BeFalse();
result.Error.Should().Be("User not found");
}
}

View File

@@ -1,5 +1,5 @@
using FluentAssertions;
using Moq;
using NSubstitute;
using Nashel.Modules.Identity.Application.Commands;
using Nashel.Modules.Identity.Domain.Aggregates;
using Nashel.Modules.Identity.Domain.Enums;
@@ -11,64 +11,64 @@ namespace Nashel.Modules.Identity.Tests.Application;
public class LoginCommandHandlerTests
{
private readonly Mock<IAccountRepository> _mockRepo;
private readonly Mock<IJwtTokenGenerator> _mockTokenGen;
private readonly Mock<IPasswordHasher> _mockPasswordHasher;
private readonly IAccountRepository _repo;
private readonly IJwtTokenGenerator _tokenGen;
private readonly IPasswordHasher _passwordHasher;
private readonly LoginCommandHandler _handler;
public LoginCommandHandlerTests()
{
_mockRepo = new Mock<IAccountRepository>();
_mockTokenGen = new Mock<IJwtTokenGenerator>();
_mockPasswordHasher = new Mock<IPasswordHasher>();
_repo = Substitute.For<IAccountRepository>();
_tokenGen = Substitute.For<IJwtTokenGenerator>();
_passwordHasher = Substitute.For<IPasswordHasher>();
_handler = new LoginCommandHandler(
_mockRepo.Object,
_mockTokenGen.Object,
_mockPasswordHasher.Object);
_repo,
_tokenGen,
_passwordHasher);
}
[Fact]
public async Task Handle_Should_Return_Token_On_Success()
{
// Arrange
// Arrange (Подготовка)
var phone = "1234567890";
var password = "password";
var account = Account.Create(phone, password);
_mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny<CancellationToken>()))
.ReturnsAsync(account);
_repo.GetByPhoneAsync(phone, Arg.Any<CancellationToken>())
.Returns(account);
_mockPasswordHasher.Setup(p => p.VerifyPassword(password, account.PasswordHash))
_passwordHasher.VerifyPassword(password, account.PasswordHash)
.Returns(true);
_mockTokenGen.Setup(t => t.GenerateToken(It.IsAny<Guid>(), It.IsAny<List<Role>>()))
_tokenGen.GenerateToken(Arg.Any<Guid>(), Arg.Any<List<Role>>())
.Returns("jwt_token");
// Act
// Act (Действие)
var result = await _handler.Handle(new LoginCommand(phone, password), CancellationToken.None);
// Assert
// Assert (Проверка)
result.Should().Be("jwt_token");
_mockTokenGen.Verify(t => t.GenerateToken(account.Id, account.Roles), Times.Once);
_tokenGen.Received(1).GenerateToken(account.Id, account.Roles);
}
[Fact]
public async Task Handle_Should_Throw_On_Invalid_Password()
{
// Arrange
// Arrange (Подготовка)
var phone = "1234567890";
var account = Account.Create(phone, "correct_hash");
_mockRepo.Setup(r => r.GetByPhoneAsync(phone, It.IsAny<CancellationToken>()))
.ReturnsAsync(account);
_repo.GetByPhoneAsync(phone, Arg.Any<CancellationToken>())
.Returns(account);
_mockPasswordHasher.Setup(p => p.VerifyPassword("wrong_password", account.PasswordHash))
_passwordHasher.VerifyPassword("wrong_password", account.PasswordHash)
.Returns(false);
// Act
// Act (Действие)
var act = async () => await _handler.Handle(new LoginCommand(phone, "wrong_password"), CancellationToken.None);
// Assert
// Assert (Проверка)
await act.Should().ThrowAsync<Exception>().WithMessage("Неверные учетные данные");
}
}

View File

@@ -1,5 +1,5 @@
using FluentAssertions;
using Moq;
using NSubstitute;
using Nashel.Modules.Identity.Application.Commands;
using Nashel.Modules.Identity.Domain.Aggregates;
using Nashel.Modules.Identity.Domain.Repositories;
@@ -10,29 +10,29 @@ namespace Nashel.Modules.Identity.Tests.Application;
public class RegisterUserCommandHandlerTests
{
private readonly Mock<IAccountRepository> _mockRepo;
private readonly Mock<IPasswordHasher> _mockPasswordHasher;
private readonly IAccountRepository _repo;
private readonly IPasswordHasher _passwordHasher;
private readonly RegisterUserCommandHandler _handler;
public RegisterUserCommandHandlerTests()
{
_mockRepo = new Mock<IAccountRepository>();
_mockPasswordHasher = new Mock<IPasswordHasher>();
_handler = new RegisterUserCommandHandler(_mockRepo.Object, _mockPasswordHasher.Object);
_repo = Substitute.For<IAccountRepository>();
_passwordHasher = Substitute.For<IPasswordHasher>();
_handler = new RegisterUserCommandHandler(_repo, _passwordHasher);
}
[Fact]
public async Task Handle_Should_Register_When_User_Not_Exists()
{
// Arrange
// Arrange (Подготовка)
var phone = "123";
_mockRepo.Setup(r => r.GetByPhoneIncludingDeletedAsync(phone, It.IsAny<CancellationToken>()))
.ReturnsAsync((Account?)null);
_repo.GetByPhoneIncludingDeletedAsync(phone, Arg.Any<CancellationToken>())
.Returns((Account?)null);
_mockPasswordHasher.Setup(p => p.HashPassword("pass"))
_passwordHasher.HashPassword("pass")
.Returns("hashed_pass");
// Act
// Act (Действие)
var result = await _handler.Handle(new RegisterUserCommand
{
Phone = phone,
@@ -41,20 +41,20 @@ public class RegisterUserCommandHandlerTests
LastName = "Ivanov"
}, CancellationToken.None);
// Assert
// Assert (Проверка)
result.Should().NotBeEmpty();
_mockRepo.Verify(r => r.AddAsync(It.IsAny<Account>(), It.IsAny<CancellationToken>()), Times.Once);
await _repo.Received(1).AddAsync(Arg.Any<Account>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Should_Throw_When_User_Exists()
{
// Arrange
// Arrange (Подготовка)
var phone = "123";
_mockRepo.Setup(r => r.GetByPhoneIncludingDeletedAsync(phone, It.IsAny<CancellationToken>()))
.ReturnsAsync(Account.Create(phone, "pass"));
_repo.GetByPhoneIncludingDeletedAsync(phone, Arg.Any<CancellationToken>())
.Returns(Account.Create(phone, "pass"));
// Assert
// Act (Действие)
var act = async () => await _handler.Handle(new RegisterUserCommand
{
Phone = phone,
@@ -63,8 +63,9 @@ public class RegisterUserCommandHandlerTests
LastName = "Ivanov"
}, CancellationToken.None);
// Assert (Проверка)
var ex = await Assert.ThrowsAsync<Exception>(act);
ex.Message.Should().Be("Пользователь с таким номером телефона уже существует");
_mockRepo.Verify(r => r.AddAsync(It.IsAny<Account>(), It.IsAny<CancellationToken>()), Times.Never);
await _repo.DidNotReceive().AddAsync(Arg.Any<Account>(), Arg.Any<CancellationToken>());
}
}

View File

@@ -10,12 +10,12 @@ public class AccountTests
[Fact]
public void Create_Should_Create_Account_With_User_Role()
{
// Arrange & Act
// Arrange & Act (Подготовка и Действие)
var phone = "1234567890";
var passwordHash = "hash";
var account = Account.Create(phone, passwordHash);
// Assert
// Assert (Проверка)
account.Should().NotBeNull();
account.Roles.Should().ContainSingle(r => r == Role.User);
account.Roles.Should().HaveCount(1);
@@ -24,13 +24,13 @@ public class AccountTests
[Fact]
public void BecomePerformer_Should_Add_Candidate_Role()
{
// Arrange
// Arrange (Подготовка)
var account = Account.Create("123", "hash");
// Act
// Act (Действие)
account.BecomePerformer();
// Assert
// Assert (Проверка)
account.Roles.Should().Contain(Role.Candidate);
account.Roles.Should().Contain(Role.User);
account.Roles.Should().HaveCount(2); // User + Candidate
@@ -39,14 +39,14 @@ public class AccountTests
[Fact]
public void BecomePerformer_Should_Do_Nothing_If_Already_Candidate()
{
// Arrange
// Arrange (Подготовка)
var account = Account.Create("123", "hash");
account.BecomePerformer();
// Act
// Act (Действие)
account.BecomePerformer();
// Assert
// Assert (Проверка)
account.Roles.Should().ContainSingle(r => r == Role.Candidate);
account.Roles.Should().HaveCount(2);
}
@@ -54,14 +54,14 @@ public class AccountTests
[Fact]
public void PromoteToMaster_Should_Replace_Candidate_With_Master()
{
// Arrange
// Arrange (Подготовка)
var account = Account.Create("123", "hash");
account.BecomePerformer(); // Has Candidate
// Act
// Act (Действие)
account.PromoteToMaster();
// Assert
// Assert (Проверка)
account.Roles.Should().NotContain(Role.Candidate);
account.Roles.Should().Contain(Role.Master);
account.Roles.Should().Contain(Role.User);

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -11,7 +11,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

View File

@@ -0,0 +1,34 @@
using MediatR;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
public record AcceptDisputeTermsCommand(
Guid OrderId,
Guid CustomerId) : IRequest<Unit>;
public class AcceptDisputeTermsHandler : IRequestHandler<AcceptDisputeTermsCommand, Unit>
{
private readonly IOrderRepository _repository;
public AcceptDisputeTermsHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> Handle(AcceptDisputeTermsCommand request, CancellationToken cancellationToken)
{
var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null) throw new KeyNotFoundException("Заказ не найден.");
if (order.CustomerId != request.CustomerId)
{
throw new UnauthorizedAccessException("Только заказчик может принять условия спора.");
}
order.AcceptDisputeTerms();
await _repository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -6,29 +6,37 @@ namespace Nashel.Modules.Order.Application.Commands;
/// <summary>
/// Команда принятия заказа исполнителем.
/// </summary>
public record AcceptOrderCommand(Guid OrderId, Guid PerformerId) : IRequest<Unit>;
public record AcceptOrderCommand(Guid OrderId) : IRequest<Unit>;
public class AcceptOrderHandler : IRequestHandler<AcceptOrderCommand, Unit>
{
private readonly IOrderRepository _repository;
private readonly Nashel.BuildingBlocks.Application.Abstractions.ICurrentUserService _currentUserService;
public AcceptOrderHandler(IOrderRepository repository)
public AcceptOrderHandler(IOrderRepository repository, Nashel.BuildingBlocks.Application.Abstractions.ICurrentUserService currentUserService)
{
_repository = repository;
_currentUserService = currentUserService;
}
public async Task<Unit> Handle(AcceptOrderCommand request, CancellationToken cancellationToken)
{
var userId = _currentUserService.UserId;
if (userId == null)
{
throw new UnauthorizedAccessException();
}
var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null)
{
throw new KeyNotFoundException($"Заказ {request.OrderId} не найден.");
throw new Nashel.BuildingBlocks.Application.Exceptions.NotFoundException("Order", request.OrderId);
}
// Проверяем, что именно этот исполнитель принимает заказ
if (order.SelectedPerformerId != request.PerformerId)
if (order.SelectedPerformerId != userId.Value)
{
throw new UnauthorizedAccessException("Этот заказ предназначен другому исполнителю.");
throw new Nashel.BuildingBlocks.Application.Exceptions.ForbiddenAccessException();
}
order.Accept();

View File

@@ -0,0 +1,35 @@
using MediatR;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
/// <summary>
/// Команда: заказчик подтвердил выполнение заказа.
/// </summary>
public record ConfirmOrderCompletionCommand(Guid OrderId, Guid CustomerId) : IRequest<Unit>;
public class ConfirmOrderCompletionHandler : IRequestHandler<ConfirmOrderCompletionCommand, Unit>
{
private readonly IOrderRepository _repository;
public ConfirmOrderCompletionHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> Handle(ConfirmOrderCompletionCommand request, CancellationToken cancellationToken)
{
var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null) throw new KeyNotFoundException("Заказ не найден.");
if (order.CustomerId != request.CustomerId)
{
throw new UnauthorizedAccessException("Только заказчик может подтвердить выполнение.");
}
order.ConfirmCompletion();
await _repository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,35 @@
using MediatR;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
/// <summary>
/// Команда: исполнитель пометил заказ как выполненный.
/// </summary>
public record FinishOrderCommand(Guid OrderId, Guid PerformerId) : IRequest<Unit>;
public class FinishOrderHandler : IRequestHandler<FinishOrderCommand, Unit>
{
private readonly IOrderRepository _repository;
public FinishOrderHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> Handle(FinishOrderCommand request, CancellationToken cancellationToken)
{
var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null) throw new KeyNotFoundException("Заказ не найден.");
if (order.SelectedPerformerId != request.PerformerId)
{
throw new UnauthorizedAccessException("Только исполнитель может завершить заказ.");
}
order.FinishByPerformer();
await _repository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,40 @@
using MediatR;
using Nashel.Modules.Order.Domain.Entities;
using Nashel.Modules.Order.Domain.Enums;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
public record OpenOrderDisputeCommand(
Guid OrderId,
Guid CustomerId,
DisputeReason Reason,
string Description,
string ProposedSolution,
List<string> Evidence) : IRequest<Unit>;
public class OpenOrderDisputeHandler : IRequestHandler<OpenOrderDisputeCommand, Unit>
{
private readonly IOrderRepository _repository;
public OpenOrderDisputeHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> Handle(OpenOrderDisputeCommand request, CancellationToken cancellationToken)
{
var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null) throw new KeyNotFoundException("Заказ не найден.");
if (order.CustomerId != request.CustomerId)
{
throw new UnauthorizedAccessException("Только заказчик может открыть спор.");
}
order.OpenDispute(request.Reason, request.Description, request.ProposedSolution, request.Evidence);
await _repository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,37 @@
using MediatR;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
public record RebutDisputeCommand(
Guid OrderId,
Guid CustomerId,
string Comment,
List<string> Evidence,
string? CounterSolution = null) : IRequest<Unit>;
public class RebutDisputeHandler : IRequestHandler<RebutDisputeCommand, Unit>
{
private readonly IOrderRepository _repository;
public RebutDisputeHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> Handle(RebutDisputeCommand request, CancellationToken cancellationToken)
{
var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null) throw new KeyNotFoundException("Заказ не найден.");
if (order.CustomerId != request.CustomerId)
{
throw new UnauthorizedAccessException("Только заказчик может возразить на ответ исполнителя.");
}
order.RebutDispute(request.Comment, request.Evidence, request.CounterSolution);
await _repository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,37 @@
using MediatR;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
public record RespondToOrderDisputeCommand(
Guid OrderId,
Guid PerformerId,
string Position,
List<string> Evidence,
string? CounterSolution = null) : IRequest<Unit>;
public class RespondToOrderDisputeHandler : IRequestHandler<RespondToOrderDisputeCommand, Unit>
{
private readonly IOrderRepository _repository;
public RespondToOrderDisputeHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> Handle(RespondToOrderDisputeCommand request, CancellationToken cancellationToken)
{
var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null) throw new KeyNotFoundException("Заказ не найден.");
if (order.SelectedPerformerId != request.PerformerId)
{
throw new UnauthorizedAccessException("Только исполнитель может ответить на спор.");
}
order.RespondToDispute(request.Position, request.Evidence, request.CounterSolution);
await _repository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -26,7 +26,26 @@ public record OrderDto(
DateTime? CompletedAt,
string? CancellationReason,
/// <summary>Секунды до истечения SLA. Null если не в статусе PendingAcceptance.</summary>
int? SlaSecondsLeft
int? SlaSecondsLeft,
DisputeDto? Dispute = null
);
public record DisputeDto(
string Reason,
string Status,
DateTime CreatedAt,
DateTime? ResolvedAt,
bool IsResolved,
List<DisputeMessageDto> Messages
);
public record DisputeMessageDto(
Guid Id,
Guid AuthorId,
string Text,
string? ProposedSolution,
List<string> Evidence,
DateTime CreatedAt
);
/// <summary>
@@ -44,32 +63,43 @@ public record GetMyOrdersAsPerformerQuery(Guid UserId) : IRequest<IReadOnlyList<
public class GetMyOrdersAsCustomerHandler : IRequestHandler<GetMyOrdersAsCustomerQuery, IReadOnlyList<OrderDto>>
{
private readonly IOrderRepository _repository;
private readonly Microsoft.Extensions.Logging.ILogger<GetMyOrdersAsCustomerHandler> _logger;
private readonly Nashel.BuildingBlocks.Application.Configurations.OrderSettings _settings;
public GetMyOrdersAsCustomerHandler(IOrderRepository repository)
public GetMyOrdersAsCustomerHandler(
IOrderRepository repository,
Microsoft.Extensions.Logging.ILogger<GetMyOrdersAsCustomerHandler> logger,
Microsoft.Extensions.Options.IOptions<Nashel.BuildingBlocks.Application.Configurations.OrderSettings> settings)
{
_repository = repository;
_logger = logger;
_settings = settings.Value;
}
public async Task<IReadOnlyList<OrderDto>> Handle(GetMyOrdersAsCustomerQuery request, CancellationToken cancellationToken)
{
var orders = await _repository.GetByCustomerIdAsync(request.UserId, cancellationToken);
return orders.Select(OrderMapper.ToDto).ToList();
return orders.Select(o => OrderMapper.ToDto(o, _settings)).ToList();
}
}
public class GetMyOrdersAsPerformerHandler : IRequestHandler<GetMyOrdersAsPerformerQuery, IReadOnlyList<OrderDto>>
{
private readonly IOrderRepository _repository;
private readonly Nashel.BuildingBlocks.Application.Configurations.OrderSettings _settings;
public GetMyOrdersAsPerformerHandler(IOrderRepository repository)
public GetMyOrdersAsPerformerHandler(
IOrderRepository repository,
Microsoft.Extensions.Options.IOptions<Nashel.BuildingBlocks.Application.Configurations.OrderSettings> settings)
{
_repository = repository;
_settings = settings.Value;
}
public async Task<IReadOnlyList<OrderDto>> Handle(GetMyOrdersAsPerformerQuery request, CancellationToken cancellationToken)
{
var orders = await _repository.GetByPerformerIdAsync(request.UserId, cancellationToken);
return orders.Select(OrderMapper.ToDto).ToList();
return orders.Select(o => OrderMapper.ToDto(o, _settings)).ToList();
}
}
@@ -77,15 +107,14 @@ public class GetMyOrdersAsPerformerHandler : IRequestHandler<GetMyOrdersAsPerfor
internal static class OrderMapper
{
private static readonly TimeSpan SlaTimeout = TimeSpan.FromMinutes(60);
public static OrderDto ToDto(OrderAggregate order)
public static OrderDto ToDto(OrderAggregate order, Nashel.BuildingBlocks.Application.Configurations.OrderSettings settings)
{
int? slaSecondsLeft = null;
if (order.Status == OrderStatus.PendingAcceptance)
{
var slaDeadline = order.CreatedAt.Add(SlaTimeout);
var slaTimeout = TimeSpan.FromMinutes(settings.DirectOrderSlaMinutes);
var slaDeadline = order.CreatedAt.Add(slaTimeout);
var secondsLeft = (int)(slaDeadline - DateTime.UtcNow).TotalSeconds;
slaSecondsLeft = Math.Max(0, secondsLeft);
}
@@ -107,7 +136,22 @@ internal static class OrderMapper
order.AcceptedAt,
order.CompletedAt,
order.CancellationReason,
slaSecondsLeft
slaSecondsLeft,
order.Dispute != null ? new DisputeDto(
order.Dispute.Reason.ToString(),
order.Dispute.Status.ToString(),
order.Dispute.CreatedAt,
order.Dispute.ResolvedAt,
order.Dispute.IsResolved,
order.Dispute.Messages.Select(m => new DisputeMessageDto(
m.Id,
m.AuthorId,
m.Text,
m.ProposedSolution,
m.Evidence,
m.CreatedAt
)).ToList()
) : null
);
}
}

View File

@@ -35,6 +35,8 @@ public class Order : AggregateRoot<Guid>
// Причина отмены
public string? CancellationReason { get; private set; }
public Dispute? Dispute { get; private set; }
// Optimistic Concurrency
public uint RowVersion { get; private set; }
@@ -47,15 +49,15 @@ public class Order : AggregateRoot<Guid>
/// Создание прямого заказа (заказчик выбирает конкретного мастера).
/// </summary>
public static Order CreateDirect(
Guid customerId,
Guid customerId,
string customerName,
Guid serviceId,
Guid serviceId,
string serviceTitle,
decimal priceAmount,
PriceType priceType,
Guid performerId,
Guid performerId,
string performerName,
OrderLocation location,
OrderLocation location,
DateTime? deadline)
{
return new Order
@@ -81,13 +83,13 @@ public class Order : AggregateRoot<Guid>
/// Создание публичной заявки (любой мастер может откликнуться).
/// </summary>
public static Order CreatePublic(
Guid customerId,
Guid customerId,
string customerName,
Guid serviceId,
Guid serviceId,
string serviceTitle,
decimal priceAmount,
PriceType priceType,
OrderLocation location,
OrderLocation location,
DateTime? deadline)
{
return new Order
@@ -114,7 +116,7 @@ public class Order : AggregateRoot<Guid>
{
if (Status != OrderStatus.PendingAcceptance)
{
throw new InvalidOperationException(
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException(
$"Принять заказ можно только если он ожидает подтверждения. Текущий статус: {Status}.");
}
@@ -123,20 +125,112 @@ public class Order : AggregateRoot<Guid>
}
/// <summary>
/// Заказ завершён (инициируется любой из сторон по договорённости).
/// Исполнитель помечает заказ как выполненный (запускает процесс подтверждения заказчиком).
/// </summary>
public void Complete()
public void FinishByPerformer()
{
if (Status != OrderStatus.InProgress)
{
throw new InvalidOperationException(
$"Завершить заказ можно только если он в работе. Текущий статус: {Status}.");
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException(
$"Пометить заказ как выполненный можно только из статуса 'В работе'. Текущий статус: {Status}.");
}
Status = OrderStatus.VerificationPending;
}
/// <summary>
/// Заказчик подтверждает выполнение заказа.
/// </summary>
public void ConfirmCompletion()
{
if (Status != OrderStatus.VerificationPending)
{
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException(
$"Подтвердить выполнение можно только если заказ ожидает проверки. Текущий статус: {Status}.");
}
Status = OrderStatus.Completed;
CompletedAt = DateTime.UtcNow;
}
/// <summary>
/// Заказчик открывает спор по заказу.
/// </summary>
public void OpenDispute(DisputeReason reason, string description, string proposedSolution, List<string> evidence)
{
if (Status != OrderStatus.VerificationPending && Status != OrderStatus.InProgress)
{
throw new InvalidOperationException(
$"Открыть спор можно только если заказ в работе или ожидает проверки. Текущий статус: {Status}.");
}
Dispute = new Dispute(CustomerId, reason, description, proposedSolution, evidence);
Status = OrderStatus.Disputed;
}
/// <summary>
/// Исполнитель отвечает на спор.
/// </summary>
public void RespondToDispute(string comment, List<string> evidence, string? counterSolution = null)
{
if (Status != OrderStatus.Disputed || Dispute == null)
{
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Спор по этому заказу не открыт.");
}
if (SelectedPerformerId == null)
{
throw new InvalidOperationException("Исполнитель не выбран.");
}
Dispute.AddPerformerResponse(SelectedPerformerId.Value, comment, evidence, counterSolution);
}
/// <summary>
/// Заказчик возражает на ответ исполнителя.
/// </summary>
public void RebutDispute(string comment, List<string> evidence, string? counterSolution = null)
{
if (Status != OrderStatus.Disputed || Dispute == null)
{
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Спор по этому заказу не открыт.");
}
Dispute.AddCustomerRebuttal(CustomerId, comment, evidence, counterSolution);
}
/// <summary>
/// Заказчик принимает условия исполнителя и закрывает спор.
/// </summary>
public void AcceptDisputeTerms()
{
if (Status != OrderStatus.Disputed || Dispute == null)
{
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Спор по этому заказу не открыт.");
}
Complete();
}
/// <summary>
/// Завершение заказа после решения спора или по соглашению.
/// </summary>
public void Complete()
{
if (Status != OrderStatus.InProgress && Status != OrderStatus.VerificationPending && Status != OrderStatus.Disputed)
{
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException(
$"Завершить заказ можно только из статусов 'В работе', 'Проверка' или 'Спор'. Текущий статус: {Status}.");
}
Status = OrderStatus.Completed;
CompletedAt = DateTime.UtcNow;
if (Dispute != null)
{
Dispute.Resolve();
}
}
/// <summary>
/// Отмена заказа с указанием причины.
/// </summary>
@@ -144,17 +238,17 @@ public class Order : AggregateRoot<Guid>
{
if (Status == OrderStatus.Completed)
{
throw new InvalidOperationException("Нельзя отменить уже завершённый заказ.");
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Нельзя отменить уже завершённый заказ.");
}
if (Status == OrderStatus.Cancelled)
{
throw new InvalidOperationException("Заказ уже отменён.");
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Заказ уже отменён.");
}
if (Status == OrderStatus.Expired)
{
throw new InvalidOperationException("Нельзя отменить заказ с истёкшим SLA-временем.");
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Нельзя отменить заказ с истёкшим SLA-временем.");
}
Status = OrderStatus.Cancelled;
@@ -182,12 +276,12 @@ public class Order : AggregateRoot<Guid>
{
if (Status != OrderStatus.Published)
{
throw new InvalidOperationException("Нельзя оставить отклик на заказ, который не опубликован.");
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Нельзя оставить отклик на заказ, который не опубликован.");
}
if (_applications.Any(a => a.PerformerId == performerId))
{
throw new InvalidOperationException("Исполнитель уже оставил отклик на этот заказ.");
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Вы уже оставили отклик на этот заказ.");
}
_applications.Add(new OrderApplication(performerId, price, comment));
@@ -200,13 +294,13 @@ public class Order : AggregateRoot<Guid>
{
if (Status != OrderStatus.Published)
{
throw new InvalidOperationException("Выбрать исполнителя можно только для опубликованного заказа.");
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Выбрать исполнителя можно только для опубликованного заказа.");
}
var app = _applications.FirstOrDefault(a => a.PerformerId == performerId);
if (app == null)
{
throw new InvalidOperationException("Выбранный исполнитель не оставлял отклик.");
throw new Nashel.Modules.Order.Domain.Exceptions.OrderDomainException("Выбранный исполнитель не оставлял отклик.");
}
SelectedPerformerId = performerId;

View File

@@ -0,0 +1,76 @@
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Order.Domain.Enums;
namespace Nashel.Modules.Order.Domain.Entities;
public class Dispute : Entity<Guid>
{
public DisputeReason Reason { get; private set; }
public DisputeStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? ResolvedAt { get; private set; }
public bool IsResolved { get; private set; }
private readonly List<DisputeMessage> _messages = new();
public IReadOnlyCollection<DisputeMessage> Messages => _messages.AsReadOnly();
private Dispute() { }
public Dispute(Guid customerId, DisputeReason reason, string description, string proposedSolution, List<string> evidence)
{
Id = Guid.NewGuid();
Reason = reason;
CreatedAt = DateTime.UtcNow;
Status = DisputeStatus.WaitingForPerformer;
_messages.Add(new DisputeMessage(customerId, description, evidence, proposedSolution));
}
public void AddPerformerResponse(Guid performerId, string comment, List<string> evidence, string? counterSolution = null)
{
if (IsResolved) throw new InvalidOperationException("Спор уже решен.");
if (Status != DisputeStatus.WaitingForPerformer)
throw new InvalidOperationException("Сейчас не очередь исполнителя отвечать.");
_messages.Add(new DisputeMessage(performerId, comment, evidence, counterSolution));
Status = DisputeStatus.WaitingForCustomer;
}
public void AddCustomerRebuttal(Guid customerId, string comment, List<string> evidence, string? counterSolution = null)
{
if (IsResolved) throw new InvalidOperationException("Спор уже решен.");
if (Status != DisputeStatus.WaitingForCustomer)
throw new InvalidOperationException("Сейчас не очередь заказчика отвечать.");
_messages.Add(new DisputeMessage(customerId, comment, evidence, counterSolution));
Status = DisputeStatus.WaitingForPerformer;
}
public void Resolve()
{
IsResolved = true;
ResolvedAt = DateTime.UtcNow;
Status = DisputeStatus.Resolved;
}
}
public class DisputeMessage : Entity<Guid>
{
public Guid AuthorId { get; private set; }
public string Text { get; private set; } = default!;
public string? ProposedSolution { get; private set; }
public List<string> Evidence { get; private set; } = new();
public DateTime CreatedAt { get; private set; }
private DisputeMessage() { }
public DisputeMessage(Guid authorId, string text, List<string> evidence, string? proposedSolution = null)
{
Id = Guid.NewGuid();
AuthorId = authorId;
Text = text;
Evidence = evidence;
ProposedSolution = proposedSolution;
CreatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,18 @@
namespace Nashel.Modules.Order.Domain.Enums;
/// <summary>
/// Причина спора по заказу.
/// </summary>
public enum DisputeReason
{
/// <summary>Работы не выполнены.</summary>
WorkNotPerformed,
/// <summary>Выполнены не в полном объеме.</summary>
NotFullVolume,
/// <summary>Выполнены некачественно.</summary>
PoorQuality,
/// <summary>Нарушен срок.</summary>
DeadlineViolated,
/// <summary>Увеличилась стоимость.</summary>
PriceIncreased
}

View File

@@ -0,0 +1,16 @@
namespace Nashel.Modules.Order.Domain.Enums;
/// <summary>
/// Статус спора.
/// </summary>
public enum DisputeStatus
{
/// <summary>Ожидает ответа исполнителя.</summary>
WaitingForPerformer,
/// <summary>Ожидает ответа заказчика.</summary>
WaitingForCustomer,
/// <summary>Спор решен.</summary>
Resolved,
/// <summary>Спор передан в арбитраж (поддержку).</summary>
InArbitration
}

View File

@@ -25,6 +25,11 @@ public enum OrderStatus
/// </summary>
InProgress,
/// <summary>
/// Ожидает подтверждения выполнения заказчиком (мастер пометил как выполненный).
/// </summary>
VerificationPending,
/// <summary>
/// Завершён успешно.
/// </summary>
@@ -38,5 +43,10 @@ public enum OrderStatus
/// <summary>
/// Истекло время ожидания (мастер не принял заказ в течение 60 минут).
/// </summary>
Expired
Expired,
/// <summary>
/// Оспорен заказчиком (открыт спор).
/// </summary>
Disputed
}

View File

@@ -0,0 +1,11 @@
using Nashel.BuildingBlocks.Domain;
namespace Nashel.Modules.Order.Domain.Exceptions;
/// <summary>
/// Исключение для ошибок в модуле заказов.
/// </summary>
public class OrderDomainException : DomainException
{
public OrderDomainException(string message) : base(message) { }
}

View File

@@ -0,0 +1,253 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Order.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
[DbContext(typeof(OrderDbContext))]
[Migration("20260309200524_AddOrderDisputes")]
partial class AddOrderDisputes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<string>("PerformerName")
.HasColumnType("text");
b.Property<decimal>("PriceAmount")
.HasColumnType("numeric");
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.Entities.Dispute", "Dispute", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b1.PrimitiveCollection<List<string>>("CustomerEvidence")
.IsRequired()
.HasColumnType("text[]");
b1.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("Id")
.HasColumnType("uuid");
b1.Property<bool>("IsResolved")
.HasColumnType("boolean");
b1.Property<string>("PerformerComment")
.HasColumnType("text");
b1.PrimitiveCollection<List<string>>("PerformerEvidence")
.IsRequired()
.HasColumnType("text[]");
b1.Property<string>("PerformerProposedSolution")
.HasColumnType("text");
b1.Property<string>("ProposedSolution")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");
b1.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b1.HasKey("OrderId");
b1.ToTable("OrderDisputes", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Dispute");
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddOrderDisputes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "OrderDisputes",
schema: "ordering",
columns: table => new
{
OrderId = table.Column<Guid>(type: "uuid", nullable: false),
Reason = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
ProposedSolution = table.Column<string>(type: "text", nullable: false),
CustomerEvidence = table.Column<List<string>>(type: "text[]", nullable: false),
PerformerComment = table.Column<string>(type: "text", nullable: true),
PerformerEvidence = table.Column<List<string>>(type: "text[]", nullable: false),
PerformerProposedSolution = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ResolvedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsResolved = table.Column<bool>(type: "boolean", nullable: false),
Id = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrderDisputes", x => x.OrderId);
table.ForeignKey(
name: "FK_OrderDisputes_Orders_OrderId",
column: x => x.OrderId,
principalSchema: "ordering",
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OrderDisputes",
schema: "ordering");
}
}
}

View File

@@ -0,0 +1,290 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Order.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
[DbContext(typeof(OrderDbContext))]
[Migration("20260309204912_UpdateDisputeHistory")]
partial class UpdateDisputeHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<string>("PerformerName")
.HasColumnType("text");
b.Property<decimal>("PriceAmount")
.HasColumnType("numeric");
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrderId")
.IsUnique();
b.ToTable("OrderDisputes", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DisputeId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("Evidence")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("ProposedSolution")
.HasColumnType("text");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("DisputeId");
b.ToTable("DisputeMessages", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithOne("Dispute")
.HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null)
.WithMany("Messages")
.HasForeignKey("DisputeId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
b.Navigation("Dispute");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class UpdateDisputeHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_OrderDisputes",
schema: "ordering",
table: "OrderDisputes");
migrationBuilder.DropColumn(
name: "CustomerEvidence",
schema: "ordering",
table: "OrderDisputes");
migrationBuilder.DropColumn(
name: "Description",
schema: "ordering",
table: "OrderDisputes");
migrationBuilder.DropColumn(
name: "PerformerComment",
schema: "ordering",
table: "OrderDisputes");
migrationBuilder.DropColumn(
name: "PerformerEvidence",
schema: "ordering",
table: "OrderDisputes");
migrationBuilder.DropColumn(
name: "PerformerProposedSolution",
schema: "ordering",
table: "OrderDisputes");
migrationBuilder.RenameColumn(
name: "ProposedSolution",
schema: "ordering",
table: "OrderDisputes",
newName: "Status");
migrationBuilder.AlterColumn<Guid>(
name: "OrderId",
schema: "ordering",
table: "OrderDisputes",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddPrimaryKey(
name: "PK_OrderDisputes",
schema: "ordering",
table: "OrderDisputes",
column: "Id");
migrationBuilder.CreateTable(
name: "DisputeMessages",
schema: "ordering",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AuthorId = table.Column<Guid>(type: "uuid", nullable: false),
Text = table.Column<string>(type: "text", nullable: false),
ProposedSolution = table.Column<string>(type: "text", nullable: true),
Evidence = table.Column<List<string>>(type: "text[]", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DisputeId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DisputeMessages", x => x.Id);
table.ForeignKey(
name: "FK_DisputeMessages_OrderDisputes_DisputeId",
column: x => x.DisputeId,
principalSchema: "ordering",
principalTable: "OrderDisputes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OrderDisputes_OrderId",
schema: "ordering",
table: "OrderDisputes",
column: "OrderId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_DisputeMessages_DisputeId",
schema: "ordering",
table: "DisputeMessages",
column: "DisputeId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DisputeMessages",
schema: "ordering");
migrationBuilder.DropPrimaryKey(
name: "PK_OrderDisputes",
schema: "ordering",
table: "OrderDisputes");
migrationBuilder.DropIndex(
name: "IX_OrderDisputes_OrderId",
schema: "ordering",
table: "OrderDisputes");
migrationBuilder.RenameColumn(
name: "Status",
schema: "ordering",
table: "OrderDisputes",
newName: "ProposedSolution");
migrationBuilder.AlterColumn<Guid>(
name: "OrderId",
schema: "ordering",
table: "OrderDisputes",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddColumn<List<string>>(
name: "CustomerEvidence",
schema: "ordering",
table: "OrderDisputes",
type: "text[]",
nullable: false);
migrationBuilder.AddColumn<string>(
name: "Description",
schema: "ordering",
table: "OrderDisputes",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "PerformerComment",
schema: "ordering",
table: "OrderDisputes",
type: "text",
nullable: true);
migrationBuilder.AddColumn<List<string>>(
name: "PerformerEvidence",
schema: "ordering",
table: "OrderDisputes",
type: "text[]",
nullable: false);
migrationBuilder.AddColumn<string>(
name: "PerformerProposedSolution",
schema: "ordering",
table: "OrderDisputes",
type: "text",
nullable: true);
migrationBuilder.AddPrimaryKey(
name: "PK_OrderDisputes",
schema: "ordering",
table: "OrderDisputes",
column: "OrderId");
}
}
}

View File

@@ -0,0 +1,290 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Order.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
[DbContext(typeof(OrderDbContext))]
[Migration("20260309210444_FixDisputeMappingAndHistory")]
partial class FixDisputeMappingAndHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<string>("PerformerName")
.HasColumnType("text");
b.Property<decimal>("PriceAmount")
.HasColumnType("numeric");
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrderId")
.IsUnique();
b.ToTable("OrderDisputes", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DisputeId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("Evidence")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("ProposedSolution")
.HasColumnType("text");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("DisputeId");
b.ToTable("DisputeMessages", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithOne("Dispute")
.HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null)
.WithMany("Messages")
.HasForeignKey("DisputeId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
b.Navigation("Dispute");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class FixDisputeMappingAndHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -0,0 +1,290 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Order.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
[DbContext(typeof(OrderDbContext))]
[Migration("20260309224622_UseIsRowVersion")]
partial class UseIsRowVersion
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<string>("PerformerName")
.HasColumnType("text");
b.Property<decimal>("PriceAmount")
.HasColumnType("numeric");
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrderId")
.IsUnique();
b.ToTable("OrderDisputes", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DisputeId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("Evidence")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("ProposedSolution")
.HasColumnType("text");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("DisputeId");
b.ToTable("DisputeMessages", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithOne("Dispute")
.HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null)
.WithMany("Messages")
.HasForeignKey("DisputeId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
b.Navigation("Dispute");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class UseIsRowVersion : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -0,0 +1,290 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Order.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
[DbContext(typeof(OrderDbContext))]
[Migration("20260310000000_FixDisputeData")]
partial class FixDisputeData
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<string>("PerformerName")
.HasColumnType("text");
b.Property<decimal>("PriceAmount")
.HasColumnType("numeric");
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrderId")
.IsUnique();
b.ToTable("OrderDisputes", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DisputeId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("Evidence")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("ProposedSolution")
.HasColumnType("text");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("DisputeId");
b.ToTable("DisputeMessages", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithOne("Dispute")
.HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null)
.WithMany("Messages")
.HasForeignKey("DisputeId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
b.Navigation("Dispute");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
public partial class FixDisputeData : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
UPDATE ordering.""OrderDisputes""
SET ""Status"" = 'WaitingForPerformer'
WHERE ""Status"" NOT IN ('WaitingForPerformer', 'WaitingForCustomer', 'Resolved', 'InArbitration');
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -0,0 +1,284 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Order.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
[DbContext(typeof(OrderDbContext))]
[Migration("20260310145112_BackendUpdate_Order")]
partial class BackendUpdate_Order
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<string>("PerformerName")
.HasColumnType("text");
b.Property<decimal>("PriceAmount")
.HasColumnType("numeric");
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrderId")
.IsUnique();
b.ToTable("OrderDisputes", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DisputeId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("Evidence")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("ProposedSolution")
.HasColumnType("text");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("DisputeId");
b.ToTable("DisputeMessages", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithOne("Dispute")
.HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null)
.WithMany("Messages")
.HasForeignKey("DisputeId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
b.Navigation("Dispute");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BackendUpdate_Order : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "xmin",
schema: "ordering",
table: "Orders");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<uint>(
name: "xmin",
schema: "ordering",
table: "Orders",
type: "xid",
rowVersion: true,
nullable: false,
defaultValue: 0u);
}
}
}

View File

@@ -1,5 +1,6 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -63,12 +64,6 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
@@ -94,6 +89,73 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsResolved")
.HasColumnType("boolean");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ResolvedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrderId")
.IsUnique();
b.ToTable("OrderDisputes", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DisputeId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("Evidence")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("ProposedSolution")
.HasColumnType("text");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("DisputeId");
b.ToTable("DisputeMessages", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
@@ -152,6 +214,22 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithOne("Dispute")
.HasForeignKey("Nashel.Modules.Order.Domain.Entities.Dispute", "OrderId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.DisputeMessage", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Entities.Dispute", null)
.WithMany("Messages")
.HasForeignKey("DisputeId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
@@ -189,6 +267,13 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
b.Navigation("Dispute");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.Dispute", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}

View File

@@ -12,12 +12,8 @@ public class OrderConfiguration : IEntityTypeConfiguration<Nashel.Modules.Order.
builder.HasKey(o => o.Id);
// Optimistic Concurrency через PostgreSQL системный столбец xmin
builder.Property(o => o.RowVersion)
.HasColumnName("xmin")
.HasColumnType("xid")
.ValueGeneratedOnAddOrUpdate()
.IsConcurrencyToken();
// Optimistic Concurrency temporarily disabled
builder.Ignore(o => o.RowVersion);
builder.Property(o => o.Type)
.HasConversion<string>();
@@ -51,11 +47,44 @@ public class OrderConfiguration : IEntityTypeConfiguration<Nashel.Modules.Order.
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
// Маппинг Спора (Используем связь 1-к-1, так как у спора сложная структура сообщений)
builder.HasOne(o => o.Dispute)
.WithOne()
.HasForeignKey<Dispute>("OrderId")
.OnDelete(DeleteBehavior.Cascade);
// Индекс для SLA-таймера (быстрый поиск просроченных заказов)
builder.HasIndex(o => new { o.Status, o.CreatedAt });
}
}
public class DisputeMessageConfiguration : IEntityTypeConfiguration<DisputeMessage>
{
public void Configure(EntityTypeBuilder<DisputeMessage> builder)
{
builder.ToTable("DisputeMessages", "ordering");
builder.HasKey(x => x.Id);
builder.Property(x => x.Evidence).HasColumnType("text[]");
}
}
public class DisputeConfiguration : IEntityTypeConfiguration<Dispute>
{
public void Configure(EntityTypeBuilder<Dispute> builder)
{
builder.ToTable("OrderDisputes", "ordering");
builder.HasKey(x => x.Id);
builder.Property(x => x.Reason).HasConversion<string>();
builder.Property(x => x.Status).HasConversion<string>();
builder.HasMany(x => x.Messages)
.WithOne()
.HasForeignKey("DisputeId")
.OnDelete(DeleteBehavior.Cascade);
}
}
public class OrderApplicationConfiguration : IEntityTypeConfiguration<OrderApplication>
{
public void Configure(EntityTypeBuilder<OrderApplication> builder)

View File

@@ -24,6 +24,8 @@ public class OrderRepository : IOrderRepository
{
return await _context.Orders
.Include(o => o.Applications)
.Include(o => o.Dispute)
.ThenInclude(d => d!.Messages)
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
@@ -31,6 +33,8 @@ public class OrderRepository : IOrderRepository
{
return await _context.Orders
.Include(o => o.Applications)
.Include(o => o.Dispute)
.ThenInclude(d => d!.Messages)
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken);
@@ -40,6 +44,8 @@ public class OrderRepository : IOrderRepository
{
return await _context.Orders
.Include(o => o.Applications)
.Include(o => o.Dispute)
.ThenInclude(d => d!.Messages)
.Where(o => o.SelectedPerformerId == performerId || o.Applications.Any(a => a.PerformerId == performerId))
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken);
@@ -62,8 +68,8 @@ public class OrderRepository : IOrderRepository
var activeStatuses = new[] { OrderStatus.PendingAcceptance, OrderStatus.InProgress };
return await _context.Orders
.Where(o => o.CustomerId == customerId &&
o.SelectedPerformerId == performerId &&
.Where(o => o.CustomerId == customerId &&
o.SelectedPerformerId == performerId &&
o.ServiceId == serviceId &&
activeStatuses.Contains(o.Status))
.FirstOrDefaultAsync(cancellationToken);

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Nashel.Modules.Order.Application.Commands;
using Nashel.Modules.Order.Application.Queries;
using Nashel.Modules.Order.Domain.Entities;
using Nashel.Modules.Order.Domain.Enums;
namespace Nashel.Modules.Order.Presentation;
@@ -78,9 +79,9 @@ public static class OrderEndpoints
});
// ─── Принять заказ (мастер) ──────────────────────────────────────────────────
group.MapPost("/{id:guid}/accept", async (Guid id, AcceptOrderRequest request, ISender sender) =>
group.MapPost("/{id:guid}/accept", async (Guid id, ISender sender) =>
{
var command = new AcceptOrderCommand(id, request.PerformerId);
var command = new AcceptOrderCommand(id);
await sender.Send(command);
return Results.Ok();
})
@@ -91,18 +92,104 @@ public static class OrderEndpoints
Description = "Исполнитель принимает заказ. Переводит заказ в статус «В работе»."
});
// ─── Завершить заказ ─────────────────────────────────────────────────────────
group.MapPost("/{id:guid}/complete", async (Guid id, CompleteOrderRequest request, ISender sender) =>
// ─── Пометить выполненным (мастер) ──────────────────────────────────────────
group.MapPost("/{id:guid}/finish", async (Guid id, FinishOrderRequest request, ISender sender) =>
{
var command = new CompleteOrderCommand(id, request.RequesterId);
var command = new FinishOrderCommand(id, request.PerformerId);
await sender.Send(command);
return Results.Ok();
})
.WithName("CompleteOrder")
.WithName("FinishOrder")
.WithOpenApi(op => new(op)
{
Summary = "Завершить заказ",
Description = "Заказчик или исполнитель завершает заказ. Переводит в статус «Завершён»."
Summary = "Завершить работу (мастер)",
Description = "Исполнитель помечает работу как выполненную. Заказ переходит в статус подтверждения заказчиком."
});
// ─── Подтвердить выполнение (заказчик) ───────────────────────────────────────
group.MapPost("/{id:guid}/confirm", async (Guid id, ConfirmOrderCompletionRequest request, ISender sender) =>
{
var command = new ConfirmOrderCompletionCommand(id, request.CustomerId);
await sender.Send(command);
return Results.Ok();
})
.WithName("ConfirmOrderCompletion")
.WithOpenApi(op => new(op)
{
Summary = "Подтвердить выполнение (заказчик)",
Description = "Заказчик подтверждает, что работа выполнена успешно. Статус заказа становится «Завершён»."
});
// ─── Открыть спор (заказчик) ──────────────────────────────────────────────────
group.MapPost("/{id:guid}/dispute", async (Guid id, OpenDisputeRequest request, ISender sender) =>
{
var command = new OpenOrderDisputeCommand(
id,
request.CustomerId,
request.Reason,
request.Description,
request.ProposedSolution,
request.Evidence);
await sender.Send(command);
return Results.Ok();
})
.WithName("OpenOrderDispute")
.WithOpenApi(op => new(op)
{
Summary = "Открыть спор",
Description = "Заказчик открывает спор, если работа выполнена некачественно или не в полном объеме."
});
// ─── Ответить на спор (мастер) ───────────────────────────────────────────────
group.MapPost("/{id:guid}/dispute/respond", async (Guid id, RespondToDisputeRequest request, ISender sender) =>
{
var command = new RespondToOrderDisputeCommand(
id,
request.PerformerId,
request.Position,
request.Evidence,
request.CounterSolution);
await sender.Send(command);
return Results.Ok();
})
.WithName("RespondToDispute")
.WithOpenApi(op => new(op)
{
Summary = "Ответить на спор",
Description = "Исполнитель предоставляет свою позицию и доказательства в рамках открытого спора."
});
// ─── Возразить на ответ исполнителя (заказчик) ──────────────────────────────
group.MapPost("/{id:guid}/dispute/rebut", async (Guid id, RebutDisputeRequest request, ISender sender) =>
{
var command = new RebutDisputeCommand(
id,
request.CustomerId,
request.Comment,
request.Evidence,
request.CounterSolution);
await sender.Send(command);
return Results.Ok();
})
.WithName("RebutDispute")
.WithOpenApi(op => new(op)
{
Summary = "Возразить на ответ исполнителя",
Description = "Заказчик добавляет новые доказательства или требования в ответ на позицию исполнителя."
});
// ─── Принять условия спора (заказчик) ──────────────────────────────────────
group.MapPost("/{id:guid}/dispute/accept", async (Guid id, AcceptDisputeTermsRequest request, ISender sender) =>
{
var command = new AcceptDisputeTermsCommand(id, request.CustomerId);
await sender.Send(command);
return Results.Ok();
})
.WithName("AcceptDisputeTerms")
.WithOpenApi(op => new(op)
{
Summary = "Принять условия спора",
Description = "Заказчик принимает предложенное исполнителем решение. Заказ завершается."
});
// ─── Отменить заказ ──────────────────────────────────────────────────────────
@@ -199,15 +286,9 @@ public record CreateOrderRequest
}
/// <summary>
/// Запрос принятия заказа исполнителем.
/// Запрос принятия заказа исполнителем (Пустой, так как ID берется из токена).
/// </summary>
public record AcceptOrderRequest
{
/// <summary>Идентификатор исполнителя, принимающего заказ.</summary>
public Guid PerformerId { get; init; }
public AcceptOrderRequest() { }
}
public record AcceptOrderRequest();
/// <summary>
/// Запрос завершения заказа.
@@ -264,3 +345,45 @@ public record SelectPerformerRequest
public SelectPerformerRequest() { }
}
/// <summary>
/// Запрос пометки выполнения заказа.
/// </summary>
public record FinishOrderRequest(Guid PerformerId);
/// <summary>
/// Запрос подтверждения выполнения.
/// </summary>
public record ConfirmOrderCompletionRequest(Guid CustomerId);
/// <summary>
/// Запрос открытия спора.
/// </summary>
public record OpenDisputeRequest(
Guid CustomerId,
DisputeReason Reason,
string Description,
string ProposedSolution,
List<string> Evidence);
/// <summary>
/// Запрос ответа на спор.
/// </summary>
public record RespondToDisputeRequest(
Guid PerformerId,
string Position,
List<string> Evidence,
string? CounterSolution);
/// <summary>
/// Запрос на возражение по спору.
/// </summary>
public record RebutDisputeRequest(
Guid CustomerId,
string Comment,
List<string> Evidence,
string? CounterSolution);
/// <summary>
/// Запрос на принятие условий спора.
/// </summary>
public record AcceptDisputeTermsRequest(Guid CustomerId);

View File

@@ -0,0 +1,77 @@
using FluentAssertions;
using NSubstitute;
using Nashel.BuildingBlocks.Application.Abstractions;
using Nashel.BuildingBlocks.Application.Exceptions;
using Nashel.Modules.Order.Application.Commands;
using Nashel.Modules.Order.Domain.Aggregates;
using Nashel.Modules.Order.Domain.Enums;
using Nashel.Modules.Order.Domain.Repositories;
using Nashel.Modules.Order.Domain.ValueObjects;
using NetTopologySuite.Geometries;
using Xunit;
namespace Nashel.Modules.Order.Tests.Application;
public class AcceptOrderCommandTests
{
private readonly IOrderRepository _repository;
private readonly ICurrentUserService _currentUserService;
private readonly AcceptOrderHandler _handler;
public AcceptOrderCommandTests()
{
_repository = Substitute.For<IOrderRepository>();
_currentUserService = Substitute.For<ICurrentUserService>();
_handler = new AcceptOrderHandler(_repository, _currentUserService);
}
[Fact]
public async Task Handle_Should_AcceptOrder_WhenCurrentUserIsSelectedPerformer()
{
// Arrange (Подготовка)
var customerId = Guid.NewGuid();
var performerId = Guid.NewGuid();
var location = new OrderLocation("Test St", new Point(10, 20));
// Создаем прямой заказ (он сразу выбран для этого исполнителя)
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
customerId, "Customer", Guid.NewGuid(), "Service", 100, 0, performerId, "Performer", location, null);
_repository.GetByIdAsync(order.Id, Arg.Any<CancellationToken>()).Returns(order);
_currentUserService.UserId.Returns(performerId);
var command = new AcceptOrderCommand(order.Id);
// Act (Действие)
await _handler.Handle(command, CancellationToken.None);
// Assert (Проверка)
order.Status.Should().Be(OrderStatus.InProgress);
await _repository.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task Handle_Should_ThrowForbidden_WhenCurrentUserIsNOTSelectedPerformer()
{
// Arrange (Подготовка)
var customerId = Guid.NewGuid();
var performerId = Guid.NewGuid();
var strangerId = Guid.NewGuid();
var location = new OrderLocation("Test St", new Point(10, 20));
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
customerId, "Customer", Guid.NewGuid(), "Service", 100, 0, performerId, "Performer", location, null);
_repository.GetByIdAsync(order.Id, Arg.Any<CancellationToken>()).Returns(order);
_currentUserService.UserId.Returns(strangerId);
var command = new AcceptOrderCommand(order.Id);
// Act (Действие)
var act = async () => await _handler.Handle(command, CancellationToken.None);
// Assert (Проверка)
await act.Should().ThrowAsync<ForbiddenAccessException>();
order.Status.Should().Be(OrderStatus.PendingAcceptance);
}
}

View File

@@ -27,7 +27,7 @@ public class SelectPerformerHandlerTests
[Fact]
public async Task Handle_Should_Call_SlaTimer()
{
// Arrange
// Arrange (Подготовка)
var customerId = Guid.NewGuid();
var serviceId = Guid.NewGuid();
var performerId = Guid.NewGuid();
@@ -45,10 +45,10 @@ public class SelectPerformerHandlerTests
var command = new SelectPerformerCommand(order.Id, performerId);
// Act
// Act (Действие)
await _handler.Handle(command, CancellationToken.None);
// Assert
// Assert (Проверка)
// Проверяем изменение статуса
order.Status.Should().Be(OrderStatus.PendingAcceptance);

View File

@@ -1,6 +1,7 @@
using FluentAssertions;
using Nashel.Modules.Order.Domain.Enums;
using Nashel.Modules.Order.Domain.ValueObjects;
using Nashel.Modules.Order.Domain.Exceptions;
using NetTopologySuite.Geometries;
using Xunit;
@@ -11,17 +12,17 @@ public class OrderAggregateTests
[Fact]
public void CreateDirect_Should_SetStatus_PendingAcceptance()
{
// Arrange
// Arrange (Подготовка)
var customerId = Guid.NewGuid();
var serviceId = Guid.NewGuid();
var performerId = Guid.NewGuid();
var location = new OrderLocation("Test St", new Point(10, 20));
// Act
// Act (Действие)
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
customerId, "Customer", serviceId, "Service", 100, 0, performerId, "Performer", location, DateTime.UtcNow.AddDays(1));
// Assert
// Assert (Проверка)
order.Status.Should().Be(OrderStatus.PendingAcceptance);
order.SelectedPerformerId.Should().Be(performerId);
order.Type.Should().Be(OrderType.Direct);
@@ -30,27 +31,28 @@ public class OrderAggregateTests
[Fact]
public void AddApplication_Should_Throw_If_OrderNotPublished()
{
// Arrange
// Arrange (Подготовка)
var customerId = Guid.NewGuid();
var serviceId = Guid.NewGuid();
var performerId = Guid.NewGuid();
var location = new OrderLocation("Test St", new Point(10, 20));
// Direct order created in PendingAcceptance
// Прямой заказ создается со статусом PendingAcceptance
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
customerId, "Customer", serviceId, "Service", 100, 0, performerId, "Performer", location, null);
// Act
// Act (Действие)
var action = () => order.AddApplication(Guid.NewGuid(), new Money(100, "RUB"), "Comment");
// Assert
action.Should().Throw<InvalidOperationException>()
.WithMessage("Нельзя оставить отклик на заказ, который не опубликован.*"); // Regex match
// Assert (Проверка)
// Теперь должно выбрасываться доменное исключение OrderDomainException
action.Should().Throw<OrderDomainException>()
.WithMessage("Нельзя оставить отклик на заказ, который не опубликован.");
}
[Fact]
public void SelectPerformer_Should_ChangeStatus_To_Pending()
{
// Arrange
// Arrange (Подготовка)
var customerId = Guid.NewGuid();
var serviceId = Guid.NewGuid();
var location = new OrderLocation("Test St", new Point(10, 20));
@@ -60,10 +62,10 @@ public class OrderAggregateTests
var performerId = Guid.NewGuid();
order.AddApplication(performerId, new Money(100, "RUB"), "Comment");
// Act
// Act (Действие)
order.SelectPerformer(performerId);
// Assert
// Assert (Проверка)
order.Status.Should().Be(OrderStatus.PendingAcceptance);
order.SelectedPerformerId.Should().Be(performerId);
}

View File

@@ -0,0 +1,36 @@
using MediatR;
using Nashel.Modules.Reputation.Domain.Repositories;
namespace Nashel.Modules.Reputation.Application.Commands;
public record AddReviewAdditionCommand(
Guid ReviewId,
Guid AuthorId,
string Comment,
List<string>? MediaUrls = null) : IRequest<Unit>;
public class AddReviewAdditionHandler : IRequestHandler<AddReviewAdditionCommand, Unit>
{
private readonly IReviewRepository _repository;
public AddReviewAdditionHandler(IReviewRepository repository)
{
_repository = repository;
}
public async Task<Unit> Handle(AddReviewAdditionCommand request, CancellationToken cancellationToken)
{
var review = await _repository.GetByIdAsync(request.ReviewId, cancellationToken);
if (review == null) throw new KeyNotFoundException("Отзыв не найден.");
if (review.AuthorId != request.AuthorId)
{
throw new UnauthorizedAccessException("Только автор может дополнять отзыв.");
}
review.AddAddition(request.Comment, request.MediaUrls);
await _repository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -1,46 +0,0 @@
using MediatR;
using Nashel.Modules.Reputation.Domain.Entities;
using Nashel.Modules.Reputation.Domain.Repositories;
namespace Nashel.Modules.Reputation.Application.Commands;
/// <summary>
/// Команда создания отзыва.
/// </summary>
public record CreateReviewCommand(Guid OrderId, Guid AuthorId, Guid TargetId, int Rating, string Text) : IRequest<Guid>;
public class CreateReviewHandler : IRequestHandler<CreateReviewCommand, Guid>
{
private readonly IReviewRepository _repository;
private readonly IMediator _mediator;
public CreateReviewHandler(IReviewRepository repository, IMediator mediator)
{
_repository = repository;
_mediator = mediator;
}
public async Task<Guid> Handle(CreateReviewCommand request, CancellationToken cancellationToken)
{
// В реальном приложении здесь должна быть проверка существования заказа и ролей.
var review = Review.Create(
request.OrderId,
request.AuthorId,
request.TargetId,
request.Rating,
request.Text
);
await _repository.AddAsync(review, cancellationToken);
// Публикация доменных событий
foreach (var domainEvent in review.DomainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
review.ClearDomainEvents();
return review.Id;
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using Nashel.Modules.Reputation.Domain.Entities;
using Nashel.Modules.Reputation.Domain.Repositories;
namespace Nashel.Modules.Reputation.Application.Commands;
/// <summary>
/// Команда создания отзыва.
/// </summary>
public record LeaveReviewCommand(Guid OrderId, Guid OfferId, Guid AuthorId, Guid TargetId, int Rating, string? Comment, List<string> MediaUrls) : IRequest<Guid>;
public class LeaveReviewHandler : IRequestHandler<LeaveReviewCommand, Guid>
{
private readonly IReviewRepository _repository;
private readonly IMediator _mediator;
public LeaveReviewHandler(IReviewRepository repository, IMediator mediator)
{
_repository = repository;
_mediator = mediator;
}
public async Task<Guid> Handle(LeaveReviewCommand request, CancellationToken cancellationToken)
{
// 1. Проверка на дубликат через репозиторий
var exists = await _repository.ExistsAsync(request.OrderId, request.AuthorId, cancellationToken);
if (exists)
{
throw new InvalidOperationException("Вы уже оставили отзыв на этот заказ.");
}
// 2. Создание отзыва
var review = Review.Create(
request.OrderId,
request.OfferId,
request.AuthorId,
request.TargetId,
request.Rating,
request.Comment,
isAutoGenerated: false
);
if (request.MediaUrls.Any())
{
review.AddMedia(request.MediaUrls);
}
await _repository.AddAsync(review, cancellationToken);
// 3. Публикация событий
foreach (var domainEvent in review.DomainEvents)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
review.ClearDomainEvents();
return review.Id;
}
}

View File

@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\BuildingBlocks\Nashel.BuildingBlocks.csproj" />
<ProjectReference Include="..\Domain\Nashel.Modules.Reputation.Domain.csproj" />
<ProjectReference Include="..\..\Identity\Application\Nashel.Modules.Identity.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="14.0.0" />

View File

@@ -0,0 +1,25 @@
using MediatR;
using Nashel.Modules.Reputation.Domain.Repositories;
namespace Nashel.Modules.Reputation.Application.Queries;
/// <summary>
/// Запрос получения агрегированного рейтинга для конкретной услуги.
/// </summary>
public record GetOfferRatingQuery(Guid OfferId) : IRequest<ProfileRatingDto>;
public class GetOfferRatingHandler : IRequestHandler<GetOfferRatingQuery, ProfileRatingDto>
{
private readonly IReviewRepository _repository;
public GetOfferRatingHandler(IReviewRepository repository)
{
_repository = repository;
}
public async Task<ProfileRatingDto> Handle(GetOfferRatingQuery request, CancellationToken cancellationToken)
{
var (avg, total) = await _repository.GetOfferStatsAsync(request.OfferId, cancellationToken);
return new ProfileRatingDto(avg, total);
}
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using Nashel.Modules.Reputation.Domain.Repositories;
namespace Nashel.Modules.Reputation.Application.Queries;
/// <summary>
/// Запрос получения агрегированного рейтинга.
/// </summary>
public record GetProfileRatingQuery(Guid UserId) : IRequest<ProfileRatingDto>;
public class GetProfileRatingHandler : IRequestHandler<GetProfileRatingQuery, ProfileRatingDto>
{
private readonly IReviewRepository _repository;
public GetProfileRatingHandler(IReviewRepository repository)
{
_repository = repository;
}
public async Task<ProfileRatingDto> Handle(GetProfileRatingQuery request, CancellationToken cancellationToken)
{
var (avg, total) = await _repository.GetStatsAsync(request.UserId, cancellationToken);
return new ProfileRatingDto(avg, total);
}
}

View File

@@ -0,0 +1,76 @@
using MediatR;
using Nashel.Modules.Reputation.Domain.Repositories;
namespace Nashel.Modules.Reputation.Application.Queries;
public record AuthorDto(Guid Id, string FirstName, string LastName);
public record ReviewAdditionDto(string Comment, List<string> MediaUrls, DateTime CreatedAt);
/// <summary>
/// DTO конкретного отзыва.
/// </summary>
public record ProfileReviewDto(
Guid Id,
Guid OrderId,
Guid AuthorId,
Guid TargetId,
int Rating,
string? Comment,
DateTime CreatedAt,
bool IsAutoGenerated,
List<string> MediaUrls,
List<ReviewAdditionDto> Additions,
AuthorDto? Author = null);
/// <summary>
/// Запрос списка отзывов с пагинацией.
/// </summary>
public record GetProfileReviewsQuery(Guid TargetId, int Page, int PageSize) : IRequest<ProfileReviewsResult>;
public record ProfileReviewsResult(List<ProfileReviewDto> Reviews, int TotalCount);
public class GetProfileReviewsHandler : IRequestHandler<GetProfileReviewsQuery, ProfileReviewsResult>
{
private readonly IReviewRepository _repository;
private readonly IMediator _mediator;
public GetProfileReviewsHandler(IReviewRepository repository, IMediator mediator)
{
_repository = repository;
_mediator = mediator;
}
public async Task<ProfileReviewsResult> Handle(GetProfileReviewsQuery request, CancellationToken cancellationToken)
{
var (items, total) = await _repository.GetPaginatedAsync(request.TargetId, request.Page, request.PageSize, cancellationToken);
var authorIds = items.Select(r => r.AuthorId).Distinct().ToList();
var authorsDetailsResult = await _mediator.Send(new Nashel.Modules.Identity.Application.Queries.GetUsersDetailsQuery(authorIds), cancellationToken);
var authorsMap = (authorsDetailsResult.IsSuccess && authorsDetailsResult.Value != null)
? authorsDetailsResult.Value.ToDictionary(a => a.Id)
: new Dictionary<Guid, Nashel.Modules.Identity.Application.Queries.UserDetailsDto>();
var reviews = items.Select(r =>
{
var authorDetails = authorsMap.GetValueOrDefault(r.AuthorId);
return new ProfileReviewDto(
r.Id,
r.OrderId,
r.AuthorId,
r.TargetId,
r.Rating,
r.Comment,
r.CreatedAt,
r.IsAutoGenerated,
r.MediaUrls,
r.Additions.Select(a => new ReviewAdditionDto(a.Comment, a.MediaUrls, a.CreatedAt)).ToList(),
Author: authorDetails != null
? new AuthorDto(authorDetails.Id, authorDetails.FullName ?? "Пользователь", string.Empty)
: null
);
}).ToList();
return new ProfileReviewsResult(reviews, total);
}
}

View File

@@ -10,7 +10,7 @@ namespace Nashel.Modules.Reputation.Application.Queries;
/// <param name="TargetId">ID пользователя.</param>
public record GetReviewsByTargetQuery(Guid TargetId) : IRequest<List<ReviewDto>>;
public record ReviewDto(Guid ReviewId, int Rating, string Text, string[] MediaUrls, DateTime CreatedAt);
public record ReviewDto(Guid ReviewId, int Rating, string? Text, string[] MediaUrls, DateTime CreatedAt);
public class GetReviewsByTargetHandler : IRequestHandler<GetReviewsByTargetQuery, List<ReviewDto>>
{
@@ -24,11 +24,11 @@ public class GetReviewsByTargetHandler : IRequestHandler<GetReviewsByTargetQuery
public async Task<List<ReviewDto>> Handle(GetReviewsByTargetQuery request, CancellationToken cancellationToken)
{
var reviews = await _repository.GetByTargetIdAsync(request.TargetId, cancellationToken);
return reviews.Select(r => new ReviewDto(
r.Id,
r.Rating,
r.Text,
r.Comment,
r.MediaUrls.ToArray(),
r.CreatedAt
)).ToList();

View File

@@ -0,0 +1,25 @@
using MediatR;
using Nashel.Modules.Reputation.Domain.Repositories;
namespace Nashel.Modules.Reputation.Application.Queries;
public record GetUsersRatingQuery(List<Guid> UserIds) : IRequest<Dictionary<Guid, ProfileRatingDto>>;
public class GetUsersRatingHandler : IRequestHandler<GetUsersRatingQuery, Dictionary<Guid, ProfileRatingDto>>
{
private readonly IReviewRepository _repository;
public GetUsersRatingHandler(IReviewRepository repository)
{
_repository = repository;
}
public async Task<Dictionary<Guid, ProfileRatingDto>> Handle(GetUsersRatingQuery request, CancellationToken cancellationToken)
{
var stats = await _repository.GetStatsForUsersAsync(request.UserIds, cancellationToken);
return stats.ToDictionary(
s => s.Key,
s => new ProfileRatingDto(s.Value.avg, s.Value.total)
);
}
}

View File

@@ -0,0 +1,6 @@
namespace Nashel.Modules.Reputation.Application.Queries;
/// <summary>
/// DTO рейтинга пользователя или услуги.
/// </summary>
public record ProfileRatingDto(double AverageRating, int TotalReviews);

View File

@@ -8,13 +8,16 @@ namespace Nashel.Modules.Reputation.Domain.Entities;
/// </summary>
public class Review : AggregateRoot<Guid>
{
private DateTime _createdAt;
/// <summary>
/// ID заказа, к которому относится отзыв.
/// </summary>
public Guid OrderId { get; private set; }
/// <summary>
/// ID оффера/услуги, к которой относится отзыв.
/// </summary>
public Guid OfferId { get; private set; }
/// <summary>
/// ID автора отзыва.
/// </summary>
@@ -31,9 +34,9 @@ public class Review : AggregateRoot<Guid>
public int Rating { get; private set; }
/// <summary>
/// Текст отзыва.
/// Текст комментария.
/// </summary>
public string Text { get; private set; }
public string? Comment { get; private set; }
/// <summary>
/// Список ссылок на медиа-файлы (фото).
@@ -48,35 +51,44 @@ public class Review : AggregateRoot<Guid>
/// <summary>
/// Дата создания отзыва.
/// </summary>
public DateTime CreatedAt => _createdAt;
public DateTime CreatedAt { get; private set; }
/// <summary>
/// Дополнения к отзыву.
/// </summary>
public List<ReviewAddition> Additions { get; private set; } = new();
// Для EF Core
private Review()
{
Text = null!;
MediaUrls = null!;
}
private Review(Guid id, Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated)
private Review(Guid id, Guid orderId, Guid offerId, Guid authorId, Guid targetId, int rating, string? comment, bool isAutoGenerated)
{
Id = id;
OrderId = orderId;
OfferId = offerId;
AuthorId = authorId;
TargetId = targetId;
Rating = rating;
Text = text;
Comment = comment;
IsAutoGenerated = isAutoGenerated;
MediaUrls = new List<string>();
_createdAt = DateTime.UtcNow;
CreatedAt = DateTime.UtcNow;
if (rating < 1 || rating > 5)
{
throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5.");
}
}
public static Review Create(Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated = false)
/// <summary>
/// Создает новый отзыв.
/// </summary>
public static Review Create(Guid orderId, Guid offerId, Guid authorId, Guid targetId, int rating, string? comment, bool isAutoGenerated = false)
{
var review = new Review(Guid.NewGuid(), orderId, authorId, targetId, rating, text, isAutoGenerated);
var review = new Review(Guid.NewGuid(), orderId, offerId, authorId, targetId, rating, comment, isAutoGenerated);
review.AddDomainEvent(new ReviewCreatedEvent(review.Id, targetId, rating));
return review;
}
@@ -86,7 +98,6 @@ public class Review : AggregateRoot<Guid>
/// </summary>
public void AddMedia(List<string> urls)
{
// Можно проверить время редактирования
CheckIfEditable();
MediaUrls.AddRange(urls);
}
@@ -94,25 +105,53 @@ public class Review : AggregateRoot<Guid>
/// <summary>
/// Обновляет текст и рейтинг.
/// </summary>
public void Update(string text, int rating)
public void Update(string? comment, int rating)
{
CheckIfEditable();
if (rating < 1 || rating > 5)
throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5.");
Text = text;
Comment = comment;
Rating = rating;
}
/// <summary>
/// Добавляет дополнение к отзыву.
/// </summary>
public void AddAddition(string comment, List<string>? mediaUrls = null)
{
Additions.Add(new ReviewAddition(comment, mediaUrls ?? new List<string>()));
}
/// <summary>
/// Проверяет, можно ли редактировать отзыв (доступно только в течение 3 дней).
/// </summary>
private void CheckIfEditable()
{
if (DateTime.UtcNow > _createdAt.AddDays(3))
if (DateTime.UtcNow > CreatedAt.AddDays(3))
{
throw new InvalidOperationException("Редактирование отзыва доступно только в течение 3 дней после создания.");
throw new InvalidOperationException("Редактирование первоначального отзыва доступно только в течение 3 дней после создания. Используйте дополнения.");
}
}
}
public class ReviewAddition
{
public string Comment { get; private set; }
public List<string> MediaUrls { get; private set; }
public DateTime CreatedAt { get; private set; }
private ReviewAddition()
{
Comment = null!;
MediaUrls = null!;
}
public ReviewAddition(string comment, List<string> mediaUrls)
{
Comment = comment;
MediaUrls = mediaUrls;
CreatedAt = DateTime.UtcNow;
}
}

View File

@@ -11,7 +11,14 @@ namespace Nashel.Modules.Reputation.Domain.Repositories
Task AddAsync(Review review, CancellationToken cancellationToken = default);
Task<List<Review>> GetByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default);
Task<Review?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
// Для пересчета среднего рейтинга можно использовать SQL-запрос, но здесь пока просто GetByTargetId
// Новые методы
Task<bool> ExistsAsync(Guid orderId, Guid authorId, CancellationToken cancellationToken = default);
Task<(double avg, int total)> GetStatsAsync(Guid userId, CancellationToken cancellationToken = default);
Task<Dictionary<Guid, (double avg, int total)>> GetStatsForUsersAsync(IEnumerable<Guid> userIds, CancellationToken cancellationToken = default);
Task<(double avg, int total)> GetOfferStatsAsync(Guid offerId, CancellationToken cancellationToken = default);
Task<(List<Review> items, int total)> GetPaginatedAsync(Guid targetId, int page, int pageSize, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
public interface IDisputeRepository

View File

@@ -0,0 +1,85 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Nashel.Modules.Order.Domain.Enums;
using Nashel.Modules.Order.Infrastructure.Persistence;
using Nashel.Modules.Reputation.Domain.Entities;
using Nashel.Modules.Reputation.Infrastructure.Persistence;
namespace Nashel.Modules.Reputation.Infrastructure.BackgroundJobs;
/// <summary>
/// Фоновая служба для автоматического завершения отзывов через 7 дней.
/// </summary>
public class AutoReviewBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<AutoReviewBackgroundService> _logger;
public AutoReviewBackgroundService(IServiceProvider serviceProvider, ILogger<AutoReviewBackgroundService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Запуск AutoReviewBackgroundService...");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessAutoReviewsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка при выполнении AutoReviewBackgroundService.");
}
// Ждем 24 часа. Можно использовать Timer или Cron.
await Task.Delay(TimeSpan.FromDays(1), stoppingToken);
}
}
private async Task ProcessAutoReviewsAsync(CancellationToken stoppingToken)
{
using var scope = _serviceProvider.CreateScope();
var orderContext = scope.ServiceProvider.GetRequiredService<OrderDbContext>();
var reputationContext = scope.ServiceProvider.GetRequiredService<ReputationDbContext>();
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
// 1. Ищем завершенные заказы старше 7 дней
var completedOrders = await orderContext.Orders
.Where(o => o.Status == OrderStatus.Completed
&& o.CompletedAt < sevenDaysAgo)
.ToListAsync(stoppingToken);
foreach (var order in completedOrders)
{
// 2. Проверяем, есть ли уже отзыв
var existingReview = await reputationContext.Reviews
.AnyAsync(r => r.OrderId == order.Id && r.AuthorId == order.CustomerId, stoppingToken);
if (!existingReview)
{
_logger.LogInformation("Генерация авто-отзыва для заказа {OrderId}", order.Id);
var review = Review.Create(
order.Id,
order.ServiceId,
order.CustomerId,
order.SelectedPerformerId ?? Guid.Empty, // Кого оцениваем
5,
"Заказ завершен успешно",
true
);
reputationContext.Reviews.Add(review);
}
}
await reputationContext.SaveChangesAsync(stoppingToken);
}
}

Some files were not shown because too many files have changed in this diff Show More