diff --git a/.gitignore b/.gitignore
index e369b34..c5af949 100644
--- a/.gitignore
+++ b/.gitignore
@@ -146,3 +146,7 @@ src/Modules/Geo/Tests/bin/
src/Modules/Geo/Tests/obj/
src/Modules/Geo/Presentation/bin/
+
+src/Modules/Order/Tests/bin/
+
+src/Modules/Order/Tests/obj/
diff --git a/Nashel.sln b/Nashel.sln
index 16d21f6..77e6b5f 100644
--- a/Nashel.sln
+++ b/Nashel.sln
@@ -77,6 +77,24 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05
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}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
+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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -411,6 +429,18 @@ Global
{401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}.Release|x64.Build.0 = Release|Any CPU
{401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}.Release|x86.ActiveCfg = Release|Any CPU
{401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}.Release|x86.Build.0 = Release|Any CPU
+ {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|x64.Build.0 = Debug|Any CPU
+ {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|x86.Build.0 = Debug|Any CPU
+ {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x64.ActiveCfg = Release|Any CPU
+ {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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -449,5 +479,13 @@ Global
{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}
EndGlobalSection
EndGlobal
diff --git a/src/Host/Nashel.Host.csproj b/src/Host/Nashel.Host.csproj
index a4bac45..80636fb 100644
--- a/src/Host/Nashel.Host.csproj
+++ b/src/Host/Nashel.Host.csproj
@@ -15,6 +15,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
diff --git a/src/Host/Program.cs b/src/Host/Program.cs
index 6ef9258..194d652 100644
--- a/src/Host/Program.cs
+++ b/src/Host/Program.cs
@@ -8,6 +8,8 @@ using Nashel.Modules.Geo.Infrastructure;
using Nashel.Modules.Geo.Presentation;
using Nashel.Modules.Identity.Infrastructure;
using Nashel.Modules.Identity.Presentation.Endpoints;
+using Nashel.Modules.Order.Infrastructure;
+using Nashel.Modules.Order.Presentation;
var builder = WebApplication.CreateBuilder(args);
@@ -58,6 +60,9 @@ builder.Services.AddCatalogModule(builder.Configuration);
// Регистрация модуля Geo
builder.Services.AddGeoModule(builder.Configuration);
+// Регистрация модуля Order
+builder.Services.AddOrderModule(builder.Configuration);
+
// Настройка аутентификации
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
@@ -96,5 +101,6 @@ app.UseAuthorization();
app.MapIdentityEndpoints();
app.MapCatalogEndpoints();
app.MapGeoEndpoints();
+app.MapOrderEndpoints();
app.Run();
diff --git a/src/Modules/Geo/Tests/UnitTest1.cs b/src/Modules/Geo/Tests/UnitTest1.cs
deleted file mode 100644
index cae23f6..0000000
--- a/src/Modules/Geo/Tests/UnitTest1.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Nashel.Modules.Geo.Tests;
-
-public class UnitTest1
-{
- [Fact]
- public void Test1()
- {
-
- }
-}
diff --git a/src/Modules/Identity/Tests/UnitTest1.cs b/src/Modules/Identity/Tests/UnitTest1.cs
deleted file mode 100644
index 8d72746..0000000
--- a/src/Modules/Identity/Tests/UnitTest1.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace Nashel.Modules.Identity.Tests;
-
-public class UnitTest1
-{
- [Fact]
- public void Test1()
- {
-
- }
-}
diff --git a/src/Modules/Order/Application/Commands/AddApplicationCommand.cs b/src/Modules/Order/Application/Commands/AddApplicationCommand.cs
new file mode 100644
index 0000000..0ccead7
--- /dev/null
+++ b/src/Modules/Order/Application/Commands/AddApplicationCommand.cs
@@ -0,0 +1,53 @@
+using MediatR;
+using Nashel.Modules.Order.Domain.Repositories;
+using Nashel.Modules.Order.Domain.ValueObjects;
+
+namespace Nashel.Modules.Order.Application.Commands;
+
+///
+/// Команда добавления отклика на заказ.
+///
+public record AddApplicationCommand : IRequest
+{
+ public Guid OrderId { get; init; }
+ public Guid PerformerId { get; init; }
+ public decimal Amount { get; init; }
+ public string Currency { get; init; } = "RUB";
+ public string Comment { get; init; } = string.Empty;
+
+ public AddApplicationCommand(Guid orderId, Guid performerId, decimal amount, string currency, string comment)
+ {
+ OrderId = orderId;
+ PerformerId = performerId;
+ Amount = amount;
+ Currency = currency;
+ Comment = comment;
+ }
+ public AddApplicationCommand() { }
+}
+
+public class AddApplicationHandler : IRequestHandler
+{
+ private readonly IOrderRepository _repository;
+
+ public AddApplicationHandler(IOrderRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(AddApplicationCommand request, CancellationToken cancellationToken)
+ {
+ var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
+ if (order == null)
+ {
+ throw new KeyNotFoundException($"Order {request.OrderId} not found");
+ }
+
+ var price = new Money(request.Amount, request.Currency);
+ order.AddApplication(request.PerformerId, price, request.Comment);
+
+ await _repository.SaveChangesAsync(cancellationToken);
+
+ return Unit.Value;
+ }
+}
diff --git a/src/Modules/Order/Application/Commands/CancelOrderCommand.cs b/src/Modules/Order/Application/Commands/CancelOrderCommand.cs
new file mode 100644
index 0000000..9ed2471
--- /dev/null
+++ b/src/Modules/Order/Application/Commands/CancelOrderCommand.cs
@@ -0,0 +1,37 @@
+using MediatR;
+using Nashel.Modules.Order.Domain.Repositories;
+
+namespace Nashel.Modules.Order.Application.Commands;
+
+///
+/// Команда отмены заказа.
+///
+public record CancelOrderCommand(Guid OrderId) : IRequest
+{
+ public CancelOrderCommand() : this(Guid.Empty) { }
+}
+
+public class CancelOrderHandler : IRequestHandler
+{
+ private readonly IOrderRepository _repository;
+
+ public CancelOrderHandler(IOrderRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(CancelOrderCommand request, CancellationToken cancellationToken)
+ {
+ var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
+ if (order == null)
+ {
+ throw new KeyNotFoundException($"Order {request.OrderId} not found");
+ }
+
+ order.Cancel();
+
+ await _repository.SaveChangesAsync(cancellationToken);
+
+ return Unit.Value;
+ }
+}
diff --git a/src/Modules/Order/Application/Commands/CreateOrderCommand.cs b/src/Modules/Order/Application/Commands/CreateOrderCommand.cs
new file mode 100644
index 0000000..d569a09
--- /dev/null
+++ b/src/Modules/Order/Application/Commands/CreateOrderCommand.cs
@@ -0,0 +1,70 @@
+using MediatR;
+using Nashel.Modules.Order.Domain.Enums;
+using Nashel.Modules.Order.Domain.Repositories;
+using Nashel.Modules.Order.Domain.ValueObjects;
+using NetTopologySuite.Geometries;
+
+namespace Nashel.Modules.Order.Application.Commands;
+
+///
+/// Команда на создание заказа.
+///
+public record CreateOrderCommand(
+ Guid CustomerId,
+ Guid ServiceId,
+ OrderType Type,
+ string Address,
+ double Latitude,
+ double Longitude,
+ DateTime? Deadline,
+ Guid? PerformerId
+) : IRequest;
+
+public class CreateOrderHandler : IRequestHandler
+{
+ private readonly IOrderRepository _repository;
+
+ public CreateOrderHandler(IOrderRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken)
+ {
+ // Создаем точку (WGS84)
+ var point = new Point(request.Longitude, request.Latitude) { SRID = 4326 };
+ var location = new OrderLocation(request.Address, point);
+
+ Nashel.Modules.Order.Domain.Aggregates.Order order;
+
+ if (request.Type == OrderType.Direct)
+ {
+ if (request.PerformerId == null)
+ {
+ throw new ArgumentException("PerformerId обязателен для прямого заказа.");
+ }
+
+ order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
+ request.CustomerId,
+ request.ServiceId,
+ request.PerformerId.Value,
+ location,
+ request.Deadline
+ );
+ }
+ else
+ {
+ order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic(
+ request.CustomerId,
+ request.ServiceId,
+ location,
+ request.Deadline
+ );
+ }
+
+ await _repository.AddAsync(order, cancellationToken);
+ await _repository.SaveChangesAsync(cancellationToken);
+
+ return order.Id;
+ }
+}
diff --git a/src/Modules/Order/Application/Commands/SelectPerformerCommand.cs b/src/Modules/Order/Application/Commands/SelectPerformerCommand.cs
new file mode 100644
index 0000000..fdd098a
--- /dev/null
+++ b/src/Modules/Order/Application/Commands/SelectPerformerCommand.cs
@@ -0,0 +1,60 @@
+using MediatR;
+using Nashel.Modules.Order.Application.Services;
+using Nashel.Modules.Order.Domain.Repositories;
+
+namespace Nashel.Modules.Order.Application.Commands;
+
+///
+/// Команда выбора исполнителя.
+///
+public record SelectPerformerCommand : IRequest
+{
+ ///
+ /// Идентификатор заказа.
+ ///
+ public Guid OrderId { get; init; }
+
+ ///
+ /// Идентификатор исполнителя.
+ ///
+ public Guid PerformerId { get; init; }
+
+ public SelectPerformerCommand(Guid orderId, Guid performerId)
+ {
+ OrderId = orderId;
+ PerformerId = performerId;
+ }
+
+ public SelectPerformerCommand() { }
+}
+
+
+public class SelectPerformerHandler : IRequestHandler
+{
+ private readonly IOrderRepository _repository;
+ private readonly ISlaTimerService _slaTimerService;
+
+ public SelectPerformerHandler(IOrderRepository repository, ISlaTimerService slaTimerService)
+ {
+ _repository = repository;
+ _slaTimerService = slaTimerService;
+ }
+
+ public async Task Handle(SelectPerformerCommand request, CancellationToken cancellationToken)
+ {
+ var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
+ if (order == null)
+ {
+ throw new KeyNotFoundException($"Order {request.OrderId} not found");
+ }
+
+ order.SelectPerformer(request.PerformerId);
+
+ // Запускаем SLA таймер на 60 минут
+ await _slaTimerService.ScheduleAutoCancel(order.Id, TimeSpan.FromMinutes(60));
+
+ await _repository.SaveChangesAsync(cancellationToken);
+
+ return Unit.Value;
+ }
+}
diff --git a/src/Modules/Order/Application/Services/ISlaTimerService.cs b/src/Modules/Order/Application/Services/ISlaTimerService.cs
new file mode 100644
index 0000000..3133740
--- /dev/null
+++ b/src/Modules/Order/Application/Services/ISlaTimerService.cs
@@ -0,0 +1,12 @@
+namespace Nashel.Modules.Order.Application.Services;
+
+///
+/// Сервис управления SLA-таймерами.
+///
+public interface ISlaTimerService
+{
+ ///
+ /// Запланировать автоматическую отмену заказа через заданное время.
+ ///
+ Task ScheduleAutoCancel(Guid orderId, TimeSpan delay);
+}
diff --git a/src/Modules/Order/Domain/Aggregates/Order.cs b/src/Modules/Order/Domain/Aggregates/Order.cs
new file mode 100644
index 0000000..f8692b9
--- /dev/null
+++ b/src/Modules/Order/Domain/Aggregates/Order.cs
@@ -0,0 +1,101 @@
+using Nashel.BuildingBlocks.Domain;
+using Nashel.Modules.Order.Domain.Entities;
+using Nashel.Modules.Order.Domain.Enums;
+using Nashel.Modules.Order.Domain.ValueObjects;
+
+namespace Nashel.Modules.Order.Domain.Aggregates;
+
+///
+/// Агрегат Заказ.
+///
+public class Order : AggregateRoot
+{
+ public Guid CustomerId { get; private set; }
+ public Guid ServiceId { get; private set; }
+ public OrderType Type { get; private set; }
+ public OrderStatus Status { get; private set; }
+ public Guid? SelectedPerformerId { get; private set; }
+ public DateTime? Deadline { get; private set; }
+ public OrderLocation Location { get; private set; } = null!;
+
+ // Optimistic Concurrency
+ public uint RowVersion { get; private set; }
+
+ private readonly List _applications = new();
+ public IReadOnlyCollection Applications => _applications.AsReadOnly();
+
+ private Order() { }
+
+ ///
+ /// Создание прямого заказа.
+ ///
+ public static Order CreateDirect(Guid customerId, Guid serviceId, Guid performerId, OrderLocation location, DateTime? deadline)
+ {
+ return new Order
+ {
+ Id = Guid.NewGuid(),
+ CustomerId = customerId,
+ ServiceId = serviceId,
+ Type = OrderType.Direct,
+ Status = OrderStatus.PendingAcceptance, // Сразу ждет подтверждения
+ SelectedPerformerId = performerId,
+ Location = location,
+ Deadline = deadline
+ };
+ // Для прямого заказа отклик можно создать косвенно или считать цену договорной.
+ }
+
+ ///
+ /// Создание публичной заявки.
+ ///
+ public static Order CreatePublic(Guid customerId, Guid serviceId, OrderLocation location, DateTime? deadline)
+ {
+ return new Order
+ {
+ Id = Guid.NewGuid(),
+ CustomerId = customerId,
+ ServiceId = serviceId,
+ Type = OrderType.PublicJob,
+ Status = OrderStatus.Published, // Публикуется для поиска
+ Location = location,
+ Deadline = deadline
+ };
+ }
+
+ public void AddApplication(Guid performerId, Money price, string comment)
+ {
+ if (Status != OrderStatus.Published)
+ {
+ throw new InvalidOperationException("Нельзя оставить отклик на заказ, который не опубликован.");
+ }
+
+ if (_applications.Any(a => a.PerformerId == performerId))
+ {
+ throw new InvalidOperationException("Исполнитель уже оставил отклик на этот заказ.");
+ }
+
+ _applications.Add(new OrderApplication(performerId, price, comment));
+ }
+
+ public void SelectPerformer(Guid performerId)
+ {
+ if (Status != OrderStatus.Published)
+ {
+ throw new InvalidOperationException("Выбрать исполнителя можно только для опубликованного заказа.");
+ }
+
+ var app = _applications.FirstOrDefault(a => a.PerformerId == performerId);
+ if (app == null)
+ {
+ throw new InvalidOperationException("Выбранный исполнитель не оставлял отклик.");
+ }
+
+ SelectedPerformerId = performerId;
+ Status = OrderStatus.PendingAcceptance;
+ }
+
+ public void Cancel()
+ {
+ Status = OrderStatus.Cancelled;
+ }
+}
diff --git a/src/Modules/Order/Domain/Entities/OrderApplication.cs b/src/Modules/Order/Domain/Entities/OrderApplication.cs
new file mode 100644
index 0000000..d6c016a
--- /dev/null
+++ b/src/Modules/Order/Domain/Entities/OrderApplication.cs
@@ -0,0 +1,26 @@
+using Nashel.BuildingBlocks.Domain;
+using Nashel.Modules.Order.Domain.ValueObjects;
+
+namespace Nashel.Modules.Order.Domain.Entities;
+
+///
+/// Отклик на заказ.
+///
+public class OrderApplication : Entity
+{
+ public Guid PerformerId { get; private set; }
+ public Money Price { get; private set; } = null!;
+ public string Comment { get; private set; } = string.Empty;
+ public DateTime CreatedAt { get; private set; }
+
+ private OrderApplication() { }
+
+ public OrderApplication(Guid performerId, Money price, string comment)
+ {
+ Id = Guid.NewGuid();
+ PerformerId = performerId;
+ Price = price;
+ Comment = comment;
+ CreatedAt = DateTime.UtcNow;
+ }
+}
diff --git a/src/Modules/Order/Domain/Enums/OrderStatus.cs b/src/Modules/Order/Domain/Enums/OrderStatus.cs
new file mode 100644
index 0000000..c933ad2
--- /dev/null
+++ b/src/Modules/Order/Domain/Enums/OrderStatus.cs
@@ -0,0 +1,32 @@
+namespace Nashel.Modules.Order.Domain.Enums;
+
+///
+/// Статус заказа.
+///
+public enum OrderStatus
+{
+ ///
+ /// Создан.
+ ///
+ Created,
+ ///
+ /// Опубликован (поиск исполнителей).
+ ///
+ Published,
+ ///
+ /// Ожидает подтверждения (выбран исполнитель).
+ ///
+ PendingAcceptance,
+ ///
+ /// В работе.
+ ///
+ InProgress,
+ ///
+ /// Завершен.
+ ///
+ Completed,
+ ///
+ /// Отменен.
+ ///
+ Cancelled
+}
diff --git a/src/Modules/Order/Domain/Enums/OrderType.cs b/src/Modules/Order/Domain/Enums/OrderType.cs
new file mode 100644
index 0000000..26d8f31
--- /dev/null
+++ b/src/Modules/Order/Domain/Enums/OrderType.cs
@@ -0,0 +1,16 @@
+namespace Nashel.Modules.Order.Domain.Enums;
+
+///
+/// Тип заказа.
+///
+public enum OrderType
+{
+ ///
+ /// Прямой заказ.
+ ///
+ Direct,
+ ///
+ /// Публичная заявка.
+ ///
+ PublicJob
+}
diff --git a/src/Modules/Order/Domain/Nashel.Modules.Order.Domain.csproj b/src/Modules/Order/Domain/Nashel.Modules.Order.Domain.csproj
index 03cb9ea..795cb60 100644
--- a/src/Modules/Order/Domain/Nashel.Modules.Order.Domain.csproj
+++ b/src/Modules/Order/Domain/Nashel.Modules.Order.Domain.csproj
@@ -4,6 +4,10 @@
+
+
+
+
Nashel.Modules.Order.Domain
net10.0
diff --git a/src/Modules/Order/Domain/Repositories/IOrderRepository.cs b/src/Modules/Order/Domain/Repositories/IOrderRepository.cs
new file mode 100644
index 0000000..626ad93
--- /dev/null
+++ b/src/Modules/Order/Domain/Repositories/IOrderRepository.cs
@@ -0,0 +1,10 @@
+using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order;
+
+namespace Nashel.Modules.Order.Domain.Repositories;
+
+public interface IOrderRepository
+{
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task AddAsync(OrderAggregate order, CancellationToken cancellationToken = default);
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/Order/Domain/ValueObjects/Money.cs b/src/Modules/Order/Domain/ValueObjects/Money.cs
new file mode 100644
index 0000000..76c0473
--- /dev/null
+++ b/src/Modules/Order/Domain/ValueObjects/Money.cs
@@ -0,0 +1,9 @@
+namespace Nashel.Modules.Order.Domain.ValueObjects;
+
+///
+/// Денежная сумма.
+///
+public record Money(decimal Amount, string Currency)
+{
+ public static Money Zero(string currency = "RUB") => new(0m, currency);
+}
diff --git a/src/Modules/Order/Domain/ValueObjects/OrderLocation.cs b/src/Modules/Order/Domain/ValueObjects/OrderLocation.cs
new file mode 100644
index 0000000..3243291
--- /dev/null
+++ b/src/Modules/Order/Domain/ValueObjects/OrderLocation.cs
@@ -0,0 +1,8 @@
+using NetTopologySuite.Geometries;
+
+namespace Nashel.Modules.Order.Domain.ValueObjects;
+
+///
+/// Локация заказа.
+///
+public record OrderLocation(string Address, Point Point);
diff --git a/src/Modules/Order/Infrastructure/DependencyInjection.cs b/src/Modules/Order/Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..b0d90be
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/DependencyInjection.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Nashel.Modules.Order.Application.Commands;
+using Nashel.Modules.Order.Application.Services;
+using Nashel.Modules.Order.Domain.Repositories;
+using Nashel.Modules.Order.Infrastructure.Persistence;
+using Nashel.Modules.Order.Infrastructure.Persistence.Repositories;
+using Nashel.Modules.Order.Infrastructure.Services;
+using Npgsql;
+
+namespace Nashel.Modules.Order.Infrastructure;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddOrderModule(this IServiceCollection services, IConfiguration configuration)
+ {
+ var connectionString = configuration.GetConnectionString("DefaultConnection");
+
+ services.AddDbContext(options =>
+ options.UseNpgsql(
+ connectionString,
+ o => o.UseNetTopologySuite()
+ ));
+
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddMediatR(cfg =>
+ {
+ cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
+ });
+
+ return services;
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.Designer.cs
new file mode 100644
index 0000000..6cfe7ea
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.Designer.cs
@@ -0,0 +1,167 @@
+//
+using System;
+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("20260210192734_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("Deadline")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SelectedPerformerId")
+ .HasColumnType("uuid");
+
+ b.Property("ServiceId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Orders", "ordering");
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Comment")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid");
+
+ b.Property("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("OrderId")
+ .HasColumnType("uuid");
+
+ b1.Property("Address")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("Address");
+
+ b1.Property("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.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("OrderApplicationId")
+ .HasColumnType("uuid");
+
+ b1.Property("Amount")
+ .HasColumnType("numeric")
+ .HasColumnName("PriceAmount");
+
+ b1.Property("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
+ }
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.cs b/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.cs
new file mode 100644
index 0000000..3e5c7bb
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.cs
@@ -0,0 +1,93 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using NetTopologySuite.Geometries;
+
+#nullable disable
+
+namespace Nashel.Modules.Order.Infrastructure.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "ordering");
+
+ migrationBuilder.AlterDatabase()
+ .Annotation("Npgsql:PostgresExtension:postgis", ",,");
+
+ migrationBuilder.CreateTable(
+ name: "Orders",
+ schema: "ordering",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ CustomerId = table.Column(type: "uuid", nullable: false),
+ ServiceId = table.Column(type: "uuid", nullable: false),
+ Type = table.Column(type: "text", nullable: false),
+ Status = table.Column(type: "text", nullable: false),
+ SelectedPerformerId = table.Column(type: "uuid", nullable: true),
+ Deadline = table.Column(type: "timestamp with time zone", nullable: true),
+ Address = table.Column(type: "text", nullable: false),
+ Location_Point = table.Column(type: "geography (point)", nullable: false),
+ xmin = table.Column(type: "xid", rowVersion: true, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Orders", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "OrderApplications",
+ schema: "ordering",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ PerformerId = table.Column(type: "uuid", nullable: false),
+ PriceAmount = table.Column(type: "numeric", nullable: false),
+ PriceCurrency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false),
+ Comment = table.Column(type: "text", nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ OrderId = table.Column(type: "uuid", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_OrderApplications", x => x.Id);
+ table.ForeignKey(
+ name: "FK_OrderApplications_Orders_OrderId",
+ column: x => x.OrderId,
+ principalSchema: "ordering",
+ principalTable: "Orders",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_OrderApplications_OrderId",
+ schema: "ordering",
+ table: "OrderApplications",
+ column: "OrderId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Orders_Location_Point",
+ schema: "ordering",
+ table: "Orders",
+ column: "Location_Point")
+ .Annotation("Npgsql:IndexMethod", "gist");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "OrderApplications",
+ schema: "ordering");
+
+ migrationBuilder.DropTable(
+ name: "Orders",
+ schema: "ordering");
+ }
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs b/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs
new file mode 100644
index 0000000..f5cc941
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs
@@ -0,0 +1,164 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+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))]
+ partial class OrderDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("Deadline")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SelectedPerformerId")
+ .HasColumnType("uuid");
+
+ b.Property("ServiceId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Orders", "ordering");
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Comment")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid");
+
+ b.Property("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("OrderId")
+ .HasColumnType("uuid");
+
+ b1.Property("Address")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("Address");
+
+ b1.Property("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.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("OrderApplicationId")
+ .HasColumnType("uuid");
+
+ b1.Property("Amount")
+ .HasColumnType("numeric")
+ .HasColumnName("PriceAmount");
+
+ b1.Property("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
+ }
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj b/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj
index e026d86..b266df7 100644
--- a/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj
+++ b/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj
@@ -1,16 +1,23 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
-
-
-
-
- Nashel.Modules.Order.Infrastructure
- net10.0
- enable
- enable
-
+
\ No newline at end of file
diff --git a/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs b/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs
new file mode 100644
index 0000000..b9118b3
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs
@@ -0,0 +1,54 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Nashel.Modules.Order.Domain.Aggregates;
+using Nashel.Modules.Order.Domain.Entities;
+
+namespace Nashel.Modules.Order.Infrastructure.Persistence.Configurations;
+
+public class OrderConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Orders", "ordering");
+
+ builder.HasKey(o => o.Id);
+
+ // Optimistic Concurrency (xmin mapping for uint)
+ builder.Property(o => o.RowVersion)
+ .IsRowVersion();
+
+ builder.Property(o => o.Type)
+ .HasConversion();
+
+ builder.Property(o => o.Status)
+ .HasConversion();
+
+ builder.OwnsOne(o => o.Location, nav =>
+ {
+ nav.Property(l => l.Address).HasColumnName("Address");
+ nav.Property(l => l.Point).HasColumnType("geography (point)");
+ nav.HasIndex(l => l.Point).HasMethod("gist");
+ });
+
+ builder.HasMany(o => o.Applications)
+ .WithOne()
+ .HasForeignKey("OrderId")
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+public class OrderApplicationConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("OrderApplications", "ordering");
+
+ builder.HasKey(x => x.Id);
+
+ builder.OwnsOne(x => x.Price, money =>
+ {
+ money.Property(m => m.Amount).HasColumnName("PriceAmount");
+ money.Property(m => m.Currency).HasColumnName("PriceCurrency").HasMaxLength(3);
+ });
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Persistence/OrderDbContext.cs b/src/Modules/Order/Infrastructure/Persistence/OrderDbContext.cs
new file mode 100644
index 0000000..9c446c5
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Persistence/OrderDbContext.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore;
+using Nashel.Modules.Order.Domain.Aggregates;
+using Nashel.Modules.Order.Domain.Entities;
+using Nashel.Modules.Order.Infrastructure.Persistence.Configurations;
+
+namespace Nashel.Modules.Order.Infrastructure.Persistence;
+
+public class OrderDbContext : DbContext
+{
+ public OrderDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ public DbSet Orders { get; set; } = null!;
+ public DbSet OrderApplications { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("ordering");
+ modelBuilder.HasPostgresExtension("postgis");
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderConfiguration).Assembly);
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs b/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs
new file mode 100644
index 0000000..a825ed6
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs
@@ -0,0 +1,33 @@
+using Microsoft.EntityFrameworkCore;
+using Nashel.Modules.Order.Domain.Repositories;
+using Nashel.Modules.Order.Infrastructure.Persistence;
+using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order;
+
+namespace Nashel.Modules.Order.Infrastructure.Persistence.Repositories;
+
+public class OrderRepository : IOrderRepository
+{
+ private readonly OrderDbContext _context;
+
+ public OrderRepository(OrderDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task AddAsync(OrderAggregate order, CancellationToken cancellationToken = default)
+ {
+ await _context.Orders.AddAsync(order, cancellationToken);
+ }
+
+ public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ return await _context.Orders
+ .Include(o => o.Applications)
+ .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
+ }
+
+ public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Services/DummySlaTimerService.cs b/src/Modules/Order/Infrastructure/Services/DummySlaTimerService.cs
new file mode 100644
index 0000000..091d92d
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Services/DummySlaTimerService.cs
@@ -0,0 +1,20 @@
+using Microsoft.Extensions.Logging;
+using Nashel.Modules.Order.Application.Services;
+
+namespace Nashel.Modules.Order.Infrastructure.Services;
+
+public class DummySlaTimerService : ISlaTimerService
+{
+ private readonly ILogger _logger;
+
+ public DummySlaTimerService(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public Task ScheduleAutoCancel(Guid orderId, TimeSpan delay)
+ {
+ _logger.LogInformation("SLA Timer scheduled for Order {OrderId} in {Delay}.", orderId, delay);
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Modules/Order/Presentation/Nashel.Modules.Order.Presentation.csproj b/src/Modules/Order/Presentation/Nashel.Modules.Order.Presentation.csproj
index db8419a..164294b 100644
--- a/src/Modules/Order/Presentation/Nashel.Modules.Order.Presentation.csproj
+++ b/src/Modules/Order/Presentation/Nashel.Modules.Order.Presentation.csproj
@@ -1,11 +1,24 @@
-
-
-
+
- Nashel.Modules.Order.Presentation
net10.0
enable
enable
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/Order/Presentation/OrderEndpoints.cs b/src/Modules/Order/Presentation/OrderEndpoints.cs
new file mode 100644
index 0000000..76b5a86
--- /dev/null
+++ b/src/Modules/Order/Presentation/OrderEndpoints.cs
@@ -0,0 +1,87 @@
+using MediatR;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Nashel.Modules.Order.Application.Commands;
+using Nashel.Modules.Order.Domain.Enums;
+
+namespace Nashel.Modules.Order.Presentation;
+
+public static class OrderEndpoints
+{
+ public static void MapOrderEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/orders").WithTags("Orders");
+
+ group.MapPost("/", async (CreateOrderRequest request, ISender sender) =>
+ {
+ var command = new CreateOrderCommand(
+ request.CustomerId,
+ request.ServiceId,
+ request.Type,
+ request.Address,
+ request.Latitude,
+ request.Longitude,
+ request.Deadline,
+ request.PerformerId
+ );
+ var orderId = await sender.Send(command);
+ return Results.Ok(orderId);
+ })
+ .WithName("CreateOrder")
+ .WithOpenApi(operation => new(operation) { Summary = "Создание нового заказа.", Description = "Создает прямой или публичный заказ." });
+
+ group.MapPost("/{id:guid}/applications", async (Guid id, AddApplicationRequest request, ISender sender) =>
+ {
+ var command = new AddApplicationCommand(
+ id,
+ request.PerformerId,
+ request.Amount,
+ request.Currency,
+ request.Comment
+ );
+ await sender.Send(command);
+ return Results.Ok();
+ })
+ .WithName("AddOrderApplication")
+ .WithOpenApi(operation => new(operation) { Summary = "Добавление отклика на заказ." });
+
+ group.MapPost("/{id:guid}/select-performer", async (Guid id, SelectPerformerRequest request, ISender sender) =>
+ {
+ var command = new SelectPerformerCommand(id, request.PerformerId);
+ await sender.Send(command);
+ return Results.Ok();
+ })
+ .WithName("SelectPerformer")
+ .WithOpenApi(operation => new(operation) { Summary = "Выбор исполнителя для заказа." });
+ }
+}
+
+///
+/// Запрос на создание заказа.
+///
+public record CreateOrderRequest(
+ Guid CustomerId,
+ Guid ServiceId,
+ OrderType Type,
+ string Address,
+ double Latitude,
+ double Longitude,
+ DateTime? Deadline,
+ Guid? PerformerId
+);
+
+///
+/// Запрос на добавление отклика.
+///
+public record AddApplicationRequest(
+ Guid PerformerId,
+ decimal Amount,
+ string Currency,
+ string Comment
+);
+
+///
+/// Запрос на выбор исполнителя.
+///
+public record SelectPerformerRequest(Guid PerformerId);
diff --git a/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs b/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs
new file mode 100644
index 0000000..7d2aee5
--- /dev/null
+++ b/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs
@@ -0,0 +1,61 @@
+using FluentAssertions;
+using MediatR;
+using NSubstitute;
+using Nashel.Modules.Order.Application.Commands;
+using Nashel.Modules.Order.Application.Services;
+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 SelectPerformerHandlerTests
+{
+ private readonly IOrderRepository _repository;
+ private readonly ISlaTimerService _slaTimerService;
+ private readonly SelectPerformerHandler _handler;
+
+ public SelectPerformerHandlerTests()
+ {
+ _repository = Substitute.For();
+ _slaTimerService = Substitute.For();
+ _handler = new SelectPerformerHandler(_repository, _slaTimerService);
+ }
+
+ [Fact]
+ public async Task Handle_Should_Call_SlaTimer()
+ {
+ // Arrange
+ var customerId = Guid.NewGuid();
+ var serviceId = Guid.NewGuid();
+ var performerId = Guid.NewGuid();
+ var location = new OrderLocation("Test St", new Point(10, 20));
+
+ // Создаем публичный заказ (Status = Published)
+ var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic(
+ customerId, serviceId, location, null);
+
+ // Добавляем отклик исполнителя
+ order.AddApplication(performerId, new Money(100, "RUB"), "Comment");
+
+ // Mock Repository
+ _repository.GetByIdAsync(order.Id, Arg.Any()).Returns(order);
+
+ var command = new SelectPerformerCommand(order.Id, performerId);
+
+ // Act
+ await _handler.Handle(command, CancellationToken.None);
+
+ // Assert
+ // Проверяем изменение статуса
+ order.Status.Should().Be(OrderStatus.PendingAcceptance);
+
+ // Проверяем вызов SLA таймера (60 минут)
+ await _slaTimerService.Received(1).ScheduleAutoCancel(order.Id, Arg.Is(t => t.TotalMinutes == 60));
+
+ // Проверяем сохранение в БД
+ await _repository.Received(1).SaveChangesAsync(Arg.Any());
+ }
+}
diff --git a/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs b/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs
new file mode 100644
index 0000000..fba595b
--- /dev/null
+++ b/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs
@@ -0,0 +1,70 @@
+using FluentAssertions;
+using Nashel.Modules.Order.Domain.Enums;
+using Nashel.Modules.Order.Domain.ValueObjects;
+using NetTopologySuite.Geometries;
+using Xunit;
+
+namespace Nashel.Modules.Order.Tests.Domain;
+
+public class OrderAggregateTests
+{
+ [Fact]
+ public void CreateDirect_Should_SetStatus_PendingAcceptance()
+ {
+ // Arrange
+ var customerId = Guid.NewGuid();
+ var serviceId = Guid.NewGuid();
+ var performerId = Guid.NewGuid();
+ var location = new OrderLocation("Test St", new Point(10, 20));
+
+ // Act
+ var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
+ customerId, serviceId, performerId, location, DateTime.UtcNow.AddDays(1));
+
+ // Assert
+ order.Status.Should().Be(OrderStatus.PendingAcceptance);
+ order.SelectedPerformerId.Should().Be(performerId);
+ order.Type.Should().Be(OrderType.Direct);
+ }
+
+ [Fact]
+ public void AddApplication_Should_Throw_If_OrderNotPublished()
+ {
+ // 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
+ var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
+ customerId, serviceId, performerId, location, null);
+
+ // Act
+ var action = () => order.AddApplication(Guid.NewGuid(), new Money(100, "RUB"), "Comment");
+
+ // Assert
+ action.Should().Throw()
+ .WithMessage("Нельзя оставить отклик на заказ, который не опубликован.*"); // Regex match
+ }
+
+ [Fact]
+ public void SelectPerformer_Should_ChangeStatus_To_Pending()
+ {
+ // Arrange
+ var customerId = Guid.NewGuid();
+ var serviceId = Guid.NewGuid();
+ var location = new OrderLocation("Test St", new Point(10, 20));
+ var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic(
+ customerId, serviceId, location, null);
+
+ var performerId = Guid.NewGuid();
+ order.AddApplication(performerId, new Money(100, "RUB"), "Comment");
+
+ // Act
+ order.SelectPerformer(performerId);
+
+ // Assert
+ order.Status.Should().Be(OrderStatus.PendingAcceptance);
+ order.SelectedPerformerId.Should().Be(performerId);
+ }
+}
diff --git a/src/Modules/Order/Tests/Nashel.Modules.Order.Tests.csproj b/src/Modules/Order/Tests/Nashel.Modules.Order.Tests.csproj
new file mode 100644
index 0000000..c45aea3
--- /dev/null
+++ b/src/Modules/Order/Tests/Nashel.Modules.Order.Tests.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+ 8.8.0
+
+
+ 18.0.1
+
+
+ 2.6.0
+
+
+ 5.3.0
+
+
+ 2.9.3
+
+
+ 3.1.5
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+