Переписаны модули, тесты, обработка ошибок
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -8,6 +8,7 @@ ENV PATH="${PATH}:/root/.dotnet/tools"
|
||||
# Копируем решение и проекты
|
||||
COPY ["Nashel.sln", "./"]
|
||||
COPY ["src/", "src/"]
|
||||
COPY ["tests/", "tests/"]
|
||||
|
||||
# Восстанавливаем зависимости
|
||||
RUN dotnet restore "Nashel.sln"
|
||||
|
||||
136
Nashel.sln
136
Nashel.sln
@@ -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
170
README.md
@@ -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 уведомления о статусах заказов.
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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("У вас недостаточно прав для выполнения этой операции.") { }
|
||||
}
|
||||
9
src/BuildingBlocks/Domain/DomainException.cs
Normal file
9
src/BuildingBlocks/Domain/DomainException.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Nashel.BuildingBlocks.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Базовый класс для исключений доменного слоя.
|
||||
/// </summary>
|
||||
public abstract class DomainException : Exception
|
||||
{
|
||||
protected DomainException(string message) : base(message) { }
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
102
src/Host/Infrastructure/Exceptions/GlobalExceptionHandler.cs
Normal file
102
src/Host/Infrastructure/Exceptions/GlobalExceptionHandler.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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() { }
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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("Сотрудник должен быть доступен за пределами Вето");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
59
src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.Designer.cs
generated
Normal file
59
src/Modules/Geo/Infrastructure/Migrations/20260310144949_BackendUpdate_Geo.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "Получить публичную информацию о пользователе" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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("Неверные учетные данные");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
35
src/Modules/Order/Application/Commands/FinishOrderCommand.cs
Normal file
35
src/Modules/Order/Application/Commands/FinishOrderCommand.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
76
src/Modules/Order/Domain/Entities/Dispute.cs
Normal file
76
src/Modules/Order/Domain/Entities/Dispute.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/Modules/Order/Domain/Enums/DisputeReason.cs
Normal file
18
src/Modules/Order/Domain/Enums/DisputeReason.cs
Normal 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
|
||||
}
|
||||
16
src/Modules/Order/Domain/Enums/DisputeStatus.cs
Normal file
16
src/Modules/Order/Domain/Enums/DisputeStatus.cs
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
11
src/Modules/Order/Domain/Exceptions/OrderDomainException.cs
Normal file
11
src/Modules/Order/Domain/Exceptions/OrderDomainException.cs
Normal 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) { }
|
||||
}
|
||||
253
src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.Designer.cs
generated
Normal file
253
src/Modules/Order/Infrastructure/Migrations/20260309200524_AddOrderDisputes.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
290
src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.Designer.cs
generated
Normal file
290
src/Modules/Order/Infrastructure/Migrations/20260309204912_UpdateDisputeHistory.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
290
src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.Designer.cs
generated
Normal file
290
src/Modules/Order/Infrastructure/Migrations/20260309210444_FixDisputeMappingAndHistory.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
290
src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.Designer.cs
generated
Normal file
290
src/Modules/Order/Infrastructure/Migrations/20260309224622_UseIsRowVersion.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
290
src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.Designer.cs
generated
Normal file
290
src/Modules/Order/Infrastructure/Migrations/20260310000000_FixDisputeData.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
284
src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.Designer.cs
generated
Normal file
284
src/Modules/Order/Infrastructure/Migrations/20260310145112_BackendUpdate_Order.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Nashel.Modules.Reputation.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// DTO рейтинга пользователя или услуги.
|
||||
/// </summary>
|
||||
public record ProfileRatingDto(double AverageRating, int TotalReviews);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user