diff --git a/Dockerfile b/Dockerfile
index f33fcc9..f94e94d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,9 +9,6 @@ COPY ["src/", "src/"]
# Restore dependencies
RUN dotnet restore "Nashel.sln"
-# Copy the rest of the source code (already copied in previous step fundamentally, but ensuring context)
-# Actually, copying src/ covers it.
-
# Publish the application
WORKDIR "/src/src/Host"
RUN dotnet publish "Nashel.Host.csproj" -c Release -o /app/publish /p:UseAppHost=false
@@ -19,6 +16,13 @@ RUN dotnet publish "Nashel.Host.csproj" -c Release -o /app/publish /p:UseAppHost
# Final stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS final
WORKDIR /app
+
+# Устанавливаем зависимости Npgsql / Kerberos (нужны для PostgreSQL через Npgsql в .NET 10 preview)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libgssapi-krb5-2 \
+ krb5-user \
+ && rm -rf /var/lib/apt/lists/*
+
COPY --from=build /app/publish .
# Copy wwwroot folder for static files (uploads)
COPY src/Host/wwwroot /app/wwwroot
diff --git a/src/Host/Program.cs b/src/Host/Program.cs
index 4f1e132..e56ab58 100644
--- a/src/Host/Program.cs
+++ b/src/Host/Program.cs
@@ -18,6 +18,12 @@ using Nashel.Modules.Reputation.Presentation;
var builder = WebApplication.CreateBuilder(args);
+// Настройка JSON: принимаем строковые значения enum ("Direct" вместо 0)
+builder.Services.ConfigureHttpJsonOptions(options =>
+{
+ options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
+});
+
// Добавление сервисов в контейнер.
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
diff --git a/src/Modules/Order/Application/Commands/AcceptOrderCommand.cs b/src/Modules/Order/Application/Commands/AcceptOrderCommand.cs
new file mode 100644
index 0000000..2ad2c61
--- /dev/null
+++ b/src/Modules/Order/Application/Commands/AcceptOrderCommand.cs
@@ -0,0 +1,40 @@
+using MediatR;
+using Nashel.Modules.Order.Domain.Repositories;
+
+namespace Nashel.Modules.Order.Application.Commands;
+
+///
+/// Команда принятия заказа исполнителем.
+///
+public record AcceptOrderCommand(Guid OrderId, Guid PerformerId) : IRequest;
+
+public class AcceptOrderHandler : IRequestHandler
+{
+ private readonly IOrderRepository _repository;
+
+ public AcceptOrderHandler(IOrderRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(AcceptOrderCommand request, CancellationToken cancellationToken)
+ {
+ var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
+ if (order == null)
+ {
+ throw new KeyNotFoundException($"Заказ {request.OrderId} не найден.");
+ }
+
+ // Проверяем, что именно этот исполнитель принимает заказ
+ if (order.SelectedPerformerId != request.PerformerId)
+ {
+ throw new UnauthorizedAccessException("Этот заказ предназначен другому исполнителю.");
+ }
+
+ order.Accept();
+
+ 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
index 9ed2471..4279bdd 100644
--- a/src/Modules/Order/Application/Commands/CancelOrderCommand.cs
+++ b/src/Modules/Order/Application/Commands/CancelOrderCommand.cs
@@ -6,9 +6,9 @@ namespace Nashel.Modules.Order.Application.Commands;
///
/// Команда отмены заказа.
///
-public record CancelOrderCommand(Guid OrderId) : IRequest
+public record CancelOrderCommand(Guid OrderId, Guid RequesterId, string? Reason = null) : IRequest
{
- public CancelOrderCommand() : this(Guid.Empty) { }
+ public CancelOrderCommand() : this(Guid.Empty, Guid.Empty, null) { }
}
public class CancelOrderHandler : IRequestHandler
@@ -25,10 +25,19 @@ public class CancelOrderHandler : IRequestHandler
var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null)
{
- throw new KeyNotFoundException($"Order {request.OrderId} not found");
+ throw new KeyNotFoundException($"Заказ {request.OrderId} не найден.");
}
- order.Cancel();
+ // Отменить может заказчик или исполнитель
+ var isCustomer = order.CustomerId == request.RequesterId;
+ var isPerformer = order.SelectedPerformerId == request.RequesterId;
+
+ if (!isCustomer && !isPerformer)
+ {
+ throw new UnauthorizedAccessException("Только заказчик или исполнитель может отменить заказ.");
+ }
+
+ order.Cancel(request.Reason);
await _repository.SaveChangesAsync(cancellationToken);
diff --git a/src/Modules/Order/Application/Commands/CompleteOrderCommand.cs b/src/Modules/Order/Application/Commands/CompleteOrderCommand.cs
new file mode 100644
index 0000000..3ab39da
--- /dev/null
+++ b/src/Modules/Order/Application/Commands/CompleteOrderCommand.cs
@@ -0,0 +1,43 @@
+using MediatR;
+using Nashel.Modules.Order.Domain.Repositories;
+
+namespace Nashel.Modules.Order.Application.Commands;
+
+///
+/// Команда завершения заказа.
+///
+public record CompleteOrderCommand(Guid OrderId, Guid RequesterId) : IRequest;
+
+public class CompleteOrderHandler : IRequestHandler
+{
+ private readonly IOrderRepository _repository;
+
+ public CompleteOrderHandler(IOrderRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(CompleteOrderCommand request, CancellationToken cancellationToken)
+ {
+ var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
+ if (order == null)
+ {
+ throw new KeyNotFoundException($"Заказ {request.OrderId} не найден.");
+ }
+
+ // Завершить может заказчик или исполнитель
+ var isCustomer = order.CustomerId == request.RequesterId;
+ var isPerformer = order.SelectedPerformerId == request.RequesterId;
+
+ if (!isCustomer && !isPerformer)
+ {
+ throw new UnauthorizedAccessException("Только заказчик или исполнитель может завершить заказ.");
+ }
+
+ order.Complete();
+
+ 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
index d569a09..b269573 100644
--- a/src/Modules/Order/Application/Commands/CreateOrderCommand.cs
+++ b/src/Modules/Order/Application/Commands/CreateOrderCommand.cs
@@ -1,4 +1,5 @@
using MediatR;
+using Microsoft.Extensions.Logging;
using Nashel.Modules.Order.Domain.Enums;
using Nashel.Modules.Order.Domain.Repositories;
using Nashel.Modules.Order.Domain.ValueObjects;
@@ -11,60 +12,99 @@ namespace Nashel.Modules.Order.Application.Commands;
///
public record CreateOrderCommand(
Guid CustomerId,
+ string CustomerName,
Guid ServiceId,
+ string ServiceTitle,
+ decimal PriceAmount,
+ PriceType PriceType,
OrderType Type,
string Address,
double Latitude,
double Longitude,
DateTime? Deadline,
- Guid? PerformerId
+ Guid? PerformerId,
+ string? PerformerName
) : IRequest;
public class CreateOrderHandler : IRequestHandler
{
private readonly IOrderRepository _repository;
-
- public CreateOrderHandler(IOrderRepository repository)
+ private readonly ILogger _logger;
+
+ public CreateOrderHandler(IOrderRepository repository, ILogger logger)
{
_repository = repository;
+ _logger = logger;
}
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)
+ try
{
- if (request.PerformerId == null)
+ _logger.LogInformation("Creating order for customer {CustomerId}, service {ServiceId}, type {Type}",
+ request.CustomerId, request.ServiceId, request.Type);
+
+ // Создаем точку (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)
{
- throw new ArgumentException("PerformerId обязателен для прямого заказа.");
+ if (request.PerformerId == null)
+ {
+ _logger.LogError("PerformerId is null for direct order");
+ throw new ArgumentException("PerformerId обязателен для прямого заказа.");
+ }
+
+ // ПРОВЕРКА НА ДУБЛИКАТ
+ var existing = await _repository.GetActiveOrderAsync(
+ request.CustomerId, request.PerformerId.Value, request.ServiceId, cancellationToken);
+ if (existing != null)
+ {
+ _logger.LogWarning("Duplicate active order found: {OrderId} for customer {CustomerId}, master {MasterId}",
+ existing.Id, request.CustomerId, request.PerformerId);
+ throw new InvalidOperationException("У вас уже есть активный заказ на эту услугу с данным мастером.");
+ }
+
+ order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
+ request.CustomerId,
+ request.CustomerName,
+ request.ServiceId,
+ request.ServiceTitle,
+ request.PriceAmount,
+ request.PriceType,
+ request.PerformerId.Value,
+ request.PerformerName ?? "Мастер",
+ location,
+ request.Deadline
+ );
+ }
+ else
+ {
+ order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic(
+ request.CustomerId,
+ request.CustomerName,
+ request.ServiceId,
+ request.ServiceTitle,
+ request.PriceAmount,
+ request.PriceType,
+ location,
+ request.Deadline
+ );
}
- order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
- request.CustomerId,
- request.ServiceId,
- request.PerformerId.Value,
- location,
- request.Deadline
- );
+ await _repository.AddAsync(order, cancellationToken);
+ await _repository.SaveChangesAsync(cancellationToken);
+
+ _logger.LogInformation("Order created with ID: {OrderId}", order.Id);
+ return order.Id;
}
- else
+ catch (Exception ex)
{
- order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic(
- request.CustomerId,
- request.ServiceId,
- location,
- request.Deadline
- );
+ _logger.LogError(ex, "Error in CreateOrderHandler for customer {CustomerId}", request.CustomerId);
+ throw;
}
-
- await _repository.AddAsync(order, cancellationToken);
- await _repository.SaveChangesAsync(cancellationToken);
-
- return order.Id;
}
}
diff --git a/src/Modules/Order/Application/Queries/GetMyOrdersQuery.cs b/src/Modules/Order/Application/Queries/GetMyOrdersQuery.cs
new file mode 100644
index 0000000..8afbb3b
--- /dev/null
+++ b/src/Modules/Order/Application/Queries/GetMyOrdersQuery.cs
@@ -0,0 +1,113 @@
+using MediatR;
+using Nashel.Modules.Order.Domain.Enums;
+using Nashel.Modules.Order.Domain.Repositories;
+using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order;
+
+namespace Nashel.Modules.Order.Application.Queries;
+
+///
+/// DTO для передачи данных заказа на фронтенд.
+///
+public record OrderDto(
+ Guid Id,
+ Guid CustomerId,
+ string CustomerName,
+ Guid? PerformerId,
+ string? PerformerName,
+ Guid ServiceId,
+ string ServiceTitle,
+ decimal PriceAmount,
+ string PriceType,
+ string Type,
+ string Status,
+ string Address,
+ DateTime CreatedAt,
+ DateTime? AcceptedAt,
+ DateTime? CompletedAt,
+ string? CancellationReason,
+ /// Секунды до истечения SLA. Null если не в статусе PendingAcceptance.
+ int? SlaSecondsLeft
+);
+
+///
+/// Запрос списка заказов, где пользователь является заказчиком.
+///
+public record GetMyOrdersAsCustomerQuery(Guid UserId) : IRequest>;
+
+///
+/// Запрос списка заказов, где пользователь является исполнителем/мастером.
+///
+public record GetMyOrdersAsPerformerQuery(Guid UserId) : IRequest>;
+
+// ─── Handlers ────────────────────────────────────────────────────────────────
+
+public class GetMyOrdersAsCustomerHandler : IRequestHandler>
+{
+ private readonly IOrderRepository _repository;
+
+ public GetMyOrdersAsCustomerHandler(IOrderRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task> Handle(GetMyOrdersAsCustomerQuery request, CancellationToken cancellationToken)
+ {
+ var orders = await _repository.GetByCustomerIdAsync(request.UserId, cancellationToken);
+ return orders.Select(OrderMapper.ToDto).ToList();
+ }
+}
+
+public class GetMyOrdersAsPerformerHandler : IRequestHandler>
+{
+ private readonly IOrderRepository _repository;
+
+ public GetMyOrdersAsPerformerHandler(IOrderRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task> Handle(GetMyOrdersAsPerformerQuery request, CancellationToken cancellationToken)
+ {
+ var orders = await _repository.GetByPerformerIdAsync(request.UserId, cancellationToken);
+ return orders.Select(OrderMapper.ToDto).ToList();
+ }
+}
+
+// ─── Mapper ──────────────────────────────────────────────────────────────────
+
+internal static class OrderMapper
+{
+ private static readonly TimeSpan SlaTimeout = TimeSpan.FromMinutes(60);
+
+ public static OrderDto ToDto(OrderAggregate order)
+ {
+ int? slaSecondsLeft = null;
+
+ if (order.Status == OrderStatus.PendingAcceptance)
+ {
+ var slaDeadline = order.CreatedAt.Add(SlaTimeout);
+ var secondsLeft = (int)(slaDeadline - DateTime.UtcNow).TotalSeconds;
+ slaSecondsLeft = Math.Max(0, secondsLeft);
+ }
+
+ return new OrderDto(
+ order.Id,
+ order.CustomerId,
+ order.CustomerName,
+ order.SelectedPerformerId,
+ order.PerformerName,
+ order.ServiceId,
+ order.ServiceTitle,
+ order.PriceAmount,
+ order.PriceType.ToString(),
+ order.Type.ToString(),
+ order.Status.ToString(),
+ order.Location?.Address ?? string.Empty,
+ order.CreatedAt,
+ order.AcceptedAt,
+ order.CompletedAt,
+ order.CancellationReason,
+ slaSecondsLeft
+ );
+ }
+}
diff --git a/src/Modules/Order/Domain/Aggregates/Order.cs b/src/Modules/Order/Domain/Aggregates/Order.cs
index f8692b9..09278f7 100644
--- a/src/Modules/Order/Domain/Aggregates/Order.cs
+++ b/src/Modules/Order/Domain/Aggregates/Order.cs
@@ -11,13 +11,30 @@ namespace Nashel.Modules.Order.Domain.Aggregates;
public class Order : AggregateRoot
{
public Guid CustomerId { get; private set; }
+ public string CustomerName { get; private set; } = default!;
public Guid ServiceId { get; private set; }
+ public string ServiceTitle { get; private set; } = default!;
+ public decimal PriceAmount { get; private set; }
+ public PriceType PriceType { get; private set; }
public OrderType Type { get; private set; }
public OrderStatus Status { get; private set; }
public Guid? SelectedPerformerId { get; private set; }
+ public string? PerformerName { get; private set; }
public DateTime? Deadline { get; private set; }
public OrderLocation Location { get; private set; } = null!;
+ // SLA-таймер: время создания заказа
+ public DateTime CreatedAt { get; private set; }
+
+ // Время принятия заказа исполнителем
+ public DateTime? AcceptedAt { get; private set; }
+
+ // Время завершения заказа
+ public DateTime? CompletedAt { get; private set; }
+
+ // Причина отмены
+ public string? CancellationReason { get; private set; }
+
// Optimistic Concurrency
public uint RowVersion { get; private set; }
@@ -27,41 +44,140 @@ public class Order : AggregateRoot
private Order() { }
///
- /// Создание прямого заказа.
+ /// Создание прямого заказа (заказчик выбирает конкретного мастера).
///
- public static Order CreateDirect(Guid customerId, Guid serviceId, Guid performerId, OrderLocation location, DateTime? deadline)
+ public static Order CreateDirect(
+ Guid customerId,
+ string customerName,
+ Guid serviceId,
+ string serviceTitle,
+ decimal priceAmount,
+ PriceType priceType,
+ Guid performerId,
+ string performerName,
+ OrderLocation location,
+ DateTime? deadline)
{
return new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
+ CustomerName = customerName,
ServiceId = serviceId,
+ ServiceTitle = serviceTitle,
+ PriceAmount = priceAmount,
+ PriceType = priceType,
Type = OrderType.Direct,
- Status = OrderStatus.PendingAcceptance, // Сразу ждет подтверждения
+ Status = OrderStatus.PendingAcceptance,
SelectedPerformerId = performerId,
+ PerformerName = performerName,
Location = location,
- Deadline = deadline
+ Deadline = deadline,
+ CreatedAt = DateTime.UtcNow
};
- // Для прямого заказа отклик можно создать косвенно или считать цену договорной.
}
///
- /// Создание публичной заявки.
+ /// Создание публичной заявки (любой мастер может откликнуться).
///
- public static Order CreatePublic(Guid customerId, Guid serviceId, OrderLocation location, DateTime? deadline)
+ public static Order CreatePublic(
+ Guid customerId,
+ string customerName,
+ Guid serviceId,
+ string serviceTitle,
+ decimal priceAmount,
+ PriceType priceType,
+ OrderLocation location,
+ DateTime? deadline)
{
return new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
+ CustomerName = customerName,
ServiceId = serviceId,
+ ServiceTitle = serviceTitle,
+ PriceAmount = priceAmount,
+ PriceType = priceType,
Type = OrderType.PublicJob,
- Status = OrderStatus.Published, // Публикуется для поиска
+ Status = OrderStatus.Published,
Location = location,
- Deadline = deadline
+ Deadline = deadline,
+ CreatedAt = DateTime.UtcNow
};
}
+ ///
+ /// Исполнитель принимает заказ. Запускает рабочий процесс.
+ ///
+ public void Accept()
+ {
+ if (Status != OrderStatus.PendingAcceptance)
+ {
+ throw new InvalidOperationException(
+ $"Принять заказ можно только если он ожидает подтверждения. Текущий статус: {Status}.");
+ }
+
+ Status = OrderStatus.InProgress;
+ AcceptedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Заказ завершён (инициируется любой из сторон по договорённости).
+ ///
+ public void Complete()
+ {
+ if (Status != OrderStatus.InProgress)
+ {
+ throw new InvalidOperationException(
+ $"Завершить заказ можно только если он в работе. Текущий статус: {Status}.");
+ }
+
+ Status = OrderStatus.Completed;
+ CompletedAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Отмена заказа с указанием причины.
+ ///
+ public void Cancel(string? reason = null)
+ {
+ if (Status == OrderStatus.Completed)
+ {
+ throw new InvalidOperationException("Нельзя отменить уже завершённый заказ.");
+ }
+
+ if (Status == OrderStatus.Cancelled)
+ {
+ throw new InvalidOperationException("Заказ уже отменён.");
+ }
+
+ if (Status == OrderStatus.Expired)
+ {
+ throw new InvalidOperationException("Нельзя отменить заказ с истёкшим SLA-временем.");
+ }
+
+ Status = OrderStatus.Cancelled;
+ CancellationReason = reason;
+ }
+
+ ///
+ /// SLA-таймер истёк: мастер не принял заказ в течение 60 минут.
+ ///
+ public void Expire()
+ {
+ if (Status != OrderStatus.PendingAcceptance)
+ {
+ // Уже обработан (принят, отменён и т.д.) — ничего не делаем
+ return;
+ }
+
+ Status = OrderStatus.Expired;
+ }
+
+ ///
+ /// Добавление отклика исполнителя на публичный заказ.
+ ///
public void AddApplication(Guid performerId, Money price, string comment)
{
if (Status != OrderStatus.Published)
@@ -77,6 +193,9 @@ public class Order : AggregateRoot
_applications.Add(new OrderApplication(performerId, price, comment));
}
+ ///
+ /// Заказчик выбирает исполнителя из откликнувшихся.
+ ///
public void SelectPerformer(Guid performerId)
{
if (Status != OrderStatus.Published)
@@ -93,9 +212,4 @@ public class Order : AggregateRoot
SelectedPerformerId = performerId;
Status = OrderStatus.PendingAcceptance;
}
-
- public void Cancel()
- {
- Status = OrderStatus.Cancelled;
- }
}
diff --git a/src/Modules/Order/Domain/Enums/OrderStatus.cs b/src/Modules/Order/Domain/Enums/OrderStatus.cs
index c933ad2..ec8a46a 100644
--- a/src/Modules/Order/Domain/Enums/OrderStatus.cs
+++ b/src/Modules/Order/Domain/Enums/OrderStatus.cs
@@ -6,27 +6,37 @@ namespace Nashel.Modules.Order.Domain.Enums;
public enum OrderStatus
{
///
- /// Создан.
+ /// Создан (внутренний, промежуточный).
///
Created,
+
///
- /// Опубликован (поиск исполнителей).
+ /// Опубликован (идёт поиск исполнителей для публичного заказа).
///
Published,
+
///
- /// Ожидает подтверждения (выбран исполнитель).
+ /// Ожидает подтверждения мастером (SLA-таймер 60 минут запущен).
///
PendingAcceptance,
+
///
- /// В работе.
+ /// В работе (мастер принял заказ).
///
InProgress,
+
///
- /// Завершен.
+ /// Завершён успешно.
///
Completed,
+
///
- /// Отменен.
+ /// Отменён любой из сторон.
///
- Cancelled
+ Cancelled,
+
+ ///
+ /// Истекло время ожидания (мастер не принял заказ в течение 60 минут).
+ ///
+ Expired
}
diff --git a/src/Modules/Order/Domain/Enums/PriceType.cs b/src/Modules/Order/Domain/Enums/PriceType.cs
new file mode 100644
index 0000000..3091567
--- /dev/null
+++ b/src/Modules/Order/Domain/Enums/PriceType.cs
@@ -0,0 +1,11 @@
+namespace Nashel.Modules.Order.Domain.Enums;
+
+///
+/// Тип оплаты в заказе.
+///
+public enum PriceType
+{
+ Fixed,
+ Hourly,
+ Negotiable
+}
diff --git a/src/Modules/Order/Domain/Repositories/IOrderRepository.cs b/src/Modules/Order/Domain/Repositories/IOrderRepository.cs
index 626ad93..d60662f 100644
--- a/src/Modules/Order/Domain/Repositories/IOrderRepository.cs
+++ b/src/Modules/Order/Domain/Repositories/IOrderRepository.cs
@@ -2,9 +2,29 @@ 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> GetByCustomerIdAsync(Guid customerId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Заказы где пользователь является исполнителем.
+ ///
+ Task> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Заказы в статусе PendingAcceptance, у которых CreatedAt < cutoff (для SLA-таймера).
+ ///
+ Task> GetExpiredPendingOrdersAsync(DateTime cutoff, CancellationToken cancellationToken = default);
+
Task AddAsync(OrderAggregate order, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
+ Task GetActiveOrderAsync(Guid customerId, Guid performerId, Guid serviceId, CancellationToken cancellationToken = default);
}
diff --git a/src/Modules/Order/Infrastructure/DependencyInjection.cs b/src/Modules/Order/Infrastructure/DependencyInjection.cs
index b0d90be..ddd505f 100644
--- a/src/Modules/Order/Infrastructure/DependencyInjection.cs
+++ b/src/Modules/Order/Infrastructure/DependencyInjection.cs
@@ -1,13 +1,14 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
using Nashel.Modules.Order.Application.Commands;
+using Nashel.Modules.Order.Application.Queries;
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;
@@ -24,11 +25,19 @@ public static class DependencyInjection
));
services.AddScoped();
+
+ // Регистрируем DummySlaTimerService для обратной совместимости (используется в SelectPerformerCommand)
services.AddScoped();
+ // Регистрируем реальный SLA BackgroundService
+ services.AddHostedService();
+
services.AddMediatR(cfg =>
{
+ // Application: Commands
cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
+ // Application: Queries
+ cfg.RegisterServicesFromAssembly(typeof(GetMyOrdersAsCustomerQuery).Assembly);
});
return services;
diff --git a/src/Modules/Order/Infrastructure/Migrations/20260307182933_AddOrderSlaFields.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260307182933_AddOrderSlaFields.Designer.cs
new file mode 100644
index 0000000..13ac933
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260307182933_AddOrderSlaFields.Designer.cs
@@ -0,0 +1,182 @@
+//
+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("20260307182933_AddOrderSlaFields")]
+ partial class AddOrderSlaFields
+ {
+ ///
+ 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("AcceptedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CancellationReason")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ 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.HasIndex("Status", "CreatedAt");
+
+ 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/20260307182933_AddOrderSlaFields.cs b/src/Modules/Order/Infrastructure/Migrations/20260307182933_AddOrderSlaFields.cs
new file mode 100644
index 0000000..7a61676
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260307182933_AddOrderSlaFields.cs
@@ -0,0 +1,80 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Nashel.Modules.Order.Infrastructure.Migrations
+{
+ ///
+ public partial class AddOrderSlaFields : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "AcceptedAt",
+ schema: "ordering",
+ table: "Orders",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "CancellationReason",
+ schema: "ordering",
+ table: "Orders",
+ type: "character varying(512)",
+ maxLength: 512,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "CompletedAt",
+ schema: "ordering",
+ table: "Orders",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "CreatedAt",
+ schema: "ordering",
+ table: "Orders",
+ type: "timestamp with time zone",
+ nullable: false,
+ defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Orders_Status_CreatedAt",
+ schema: "ordering",
+ table: "Orders",
+ columns: new[] { "Status", "CreatedAt" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_Orders_Status_CreatedAt",
+ schema: "ordering",
+ table: "Orders");
+
+ migrationBuilder.DropColumn(
+ name: "AcceptedAt",
+ schema: "ordering",
+ table: "Orders");
+
+ migrationBuilder.DropColumn(
+ name: "CancellationReason",
+ schema: "ordering",
+ table: "Orders");
+
+ migrationBuilder.DropColumn(
+ name: "CompletedAt",
+ schema: "ordering",
+ table: "Orders");
+
+ migrationBuilder.DropColumn(
+ name: "CreatedAt",
+ schema: "ordering",
+ table: "Orders");
+ }
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Migrations/20260307192905_ExpandOrderDetails.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260307192905_ExpandOrderDetails.Designer.cs
new file mode 100644
index 0000000..1cc413d
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260307192905_ExpandOrderDetails.Designer.cs
@@ -0,0 +1,199 @@
+//
+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("20260307192905_ExpandOrderDetails")]
+ partial class ExpandOrderDetails
+ {
+ ///
+ 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("AcceptedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CancellationReason")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("CustomerName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Deadline")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PerformerName")
+ .HasColumnType("text");
+
+ b.Property("PriceAmount")
+ .HasColumnType("numeric");
+
+ b.Property("PriceType")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SelectedPerformerId")
+ .HasColumnType("uuid");
+
+ b.Property("ServiceId")
+ .HasColumnType("uuid");
+
+ b.Property("ServiceTitle")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("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("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/20260307192905_ExpandOrderDetails.cs b/src/Modules/Order/Infrastructure/Migrations/20260307192905_ExpandOrderDetails.cs
new file mode 100644
index 0000000..d852c5f
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260307192905_ExpandOrderDetails.cs
@@ -0,0 +1,82 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Nashel.Modules.Order.Infrastructure.Migrations
+{
+ ///
+ public partial class ExpandOrderDetails : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "CustomerName",
+ schema: "ordering",
+ table: "Orders",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+
+ migrationBuilder.AddColumn(
+ name: "PerformerName",
+ schema: "ordering",
+ table: "Orders",
+ type: "text",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "PriceAmount",
+ schema: "ordering",
+ table: "Orders",
+ type: "numeric",
+ nullable: false,
+ defaultValue: 0m);
+
+ migrationBuilder.AddColumn(
+ name: "PriceType",
+ schema: "ordering",
+ table: "Orders",
+ type: "integer",
+ nullable: false,
+ defaultValue: 0);
+
+ migrationBuilder.AddColumn(
+ name: "ServiceTitle",
+ schema: "ordering",
+ table: "Orders",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "CustomerName",
+ schema: "ordering",
+ table: "Orders");
+
+ migrationBuilder.DropColumn(
+ name: "PerformerName",
+ schema: "ordering",
+ table: "Orders");
+
+ migrationBuilder.DropColumn(
+ name: "PriceAmount",
+ schema: "ordering",
+ table: "Orders");
+
+ migrationBuilder.DropColumn(
+ name: "PriceType",
+ schema: "ordering",
+ table: "Orders");
+
+ migrationBuilder.DropColumn(
+ name: "ServiceTitle",
+ schema: "ordering",
+ table: "Orders");
+ }
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Migrations/20260307214623_UpdatePriceTypeToEnum.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260307214623_UpdatePriceTypeToEnum.Designer.cs
new file mode 100644
index 0000000..7bdaedc
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260307214623_UpdatePriceTypeToEnum.Designer.cs
@@ -0,0 +1,199 @@
+//
+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("20260307214623_UpdatePriceTypeToEnum")]
+ partial class UpdatePriceTypeToEnum
+ {
+ ///
+ 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("AcceptedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CancellationReason")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("CustomerName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Deadline")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PerformerName")
+ .HasColumnType("text");
+
+ b.Property("PriceAmount")
+ .HasColumnType("numeric");
+
+ b.Property("PriceType")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SelectedPerformerId")
+ .HasColumnType("uuid");
+
+ b.Property("ServiceId")
+ .HasColumnType("uuid");
+
+ b.Property("ServiceTitle")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("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("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/20260307214623_UpdatePriceTypeToEnum.cs b/src/Modules/Order/Infrastructure/Migrations/20260307214623_UpdatePriceTypeToEnum.cs
new file mode 100644
index 0000000..acaf9a3
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260307214623_UpdatePriceTypeToEnum.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Nashel.Modules.Order.Infrastructure.Migrations
+{
+ ///
+ public partial class UpdatePriceTypeToEnum : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+
+ }
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs b/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs
index f5cc941..015ad85 100644
--- a/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs
+++ b/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs
@@ -31,12 +31,38 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
+ b.Property("AcceptedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CancellationReason")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
b.Property("CustomerId")
.HasColumnType("uuid");
+ b.Property("CustomerName")
+ .IsRequired()
+ .HasColumnType("text");
+
b.Property("Deadline")
.HasColumnType("timestamp with time zone");
+ b.Property("PerformerName")
+ .HasColumnType("text");
+
+ b.Property("PriceAmount")
+ .HasColumnType("numeric");
+
+ b.Property("PriceType")
+ .HasColumnType("integer");
+
b.Property("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
@@ -49,6 +75,10 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
b.Property("ServiceId")
.HasColumnType("uuid");
+ b.Property("ServiceTitle")
+ .IsRequired()
+ .HasColumnType("text");
+
b.Property("Status")
.IsRequired()
.HasColumnType("text");
@@ -59,6 +89,8 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
b.HasKey("Id");
+ b.HasIndex("Status", "CreatedAt");
+
b.ToTable("Orders", "ordering");
});
diff --git a/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj b/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj
index b266df7..cbb31e2 100644
--- a/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj
+++ b/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs b/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs
index b9118b3..6b24ef9 100644
--- a/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs
+++ b/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs
@@ -1,6 +1,5 @@
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;
@@ -13,9 +12,12 @@ public class OrderConfiguration : IEntityTypeConfiguration o.Id);
- // Optimistic Concurrency (xmin mapping for uint)
+ // Optimistic Concurrency через PostgreSQL системный столбец xmin
builder.Property(o => o.RowVersion)
- .IsRowVersion();
+ .HasColumnName("xmin")
+ .HasColumnType("xid")
+ .ValueGeneratedOnAddOrUpdate()
+ .IsConcurrencyToken();
builder.Property(o => o.Type)
.HasConversion();
@@ -23,6 +25,20 @@ public class OrderConfiguration : IEntityTypeConfiguration o.Status)
.HasConversion();
+ // SLA-поля
+ builder.Property(o => o.CreatedAt)
+ .IsRequired();
+
+ builder.Property(o => o.AcceptedAt)
+ .IsRequired(false);
+
+ builder.Property(o => o.CompletedAt)
+ .IsRequired(false);
+
+ builder.Property(o => o.CancellationReason)
+ .HasMaxLength(512)
+ .IsRequired(false);
+
builder.OwnsOne(o => o.Location, nav =>
{
nav.Property(l => l.Address).HasColumnName("Address");
@@ -34,6 +50,9 @@ public class OrderConfiguration : IEntityTypeConfiguration new { o.Status, o.CreatedAt });
}
}
diff --git a/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs b/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs
index a825ed6..819b6ca 100644
--- a/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs
+++ b/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
+using Nashel.Modules.Order.Domain.Enums;
using Nashel.Modules.Order.Domain.Repositories;
using Nashel.Modules.Order.Infrastructure.Persistence;
using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order;
@@ -26,8 +27,45 @@ public class OrderRepository : IOrderRepository
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
+ public async Task> GetByCustomerIdAsync(Guid customerId, CancellationToken cancellationToken = default)
+ {
+ return await _context.Orders
+ .Include(o => o.Applications)
+ .Where(o => o.CustomerId == customerId)
+ .OrderByDescending(o => o.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default)
+ {
+ return await _context.Orders
+ .Include(o => o.Applications)
+ .Where(o => o.SelectedPerformerId == performerId || o.Applications.Any(a => a.PerformerId == performerId))
+ .OrderByDescending(o => o.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetExpiredPendingOrdersAsync(DateTime cutoff, CancellationToken cancellationToken = default)
+ {
+ return await _context.Orders
+ .Where(o => o.Status == OrderStatus.PendingAcceptance && o.CreatedAt < cutoff)
+ .ToListAsync(cancellationToken);
+ }
+
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
await _context.SaveChangesAsync(cancellationToken);
}
+
+ public async Task GetActiveOrderAsync(Guid customerId, Guid performerId, Guid serviceId, CancellationToken cancellationToken = default)
+ {
+ var activeStatuses = new[] { OrderStatus.PendingAcceptance, OrderStatus.InProgress };
+
+ return await _context.Orders
+ .Where(o => o.CustomerId == customerId &&
+ o.SelectedPerformerId == performerId &&
+ o.ServiceId == serviceId &&
+ activeStatuses.Contains(o.Status))
+ .FirstOrDefaultAsync(cancellationToken);
+ }
}
diff --git a/src/Modules/Order/Infrastructure/Services/SlaBackgroundService.cs b/src/Modules/Order/Infrastructure/Services/SlaBackgroundService.cs
new file mode 100644
index 0000000..2645635
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Services/SlaBackgroundService.cs
@@ -0,0 +1,84 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Nashel.Modules.Order.Domain.Repositories;
+
+namespace Nashel.Modules.Order.Infrastructure.Services;
+
+///
+/// Фоновый сервис SLA-таймера.
+/// Раз в 1 минуту проверяет заказы в статусе PendingAcceptance,
+/// которые ожидают более 60 минут, и переводит их в статус Expired.
+///
+public class SlaBackgroundService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+
+ // Период проверки — 1 минута
+ private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(1);
+
+ // SLA — 60 минут
+ private static readonly TimeSpan SlaTimeout = TimeSpan.FromMinutes(60);
+
+ public SlaBackgroundService(IServiceScopeFactory scopeFactory, ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("SLA BackgroundService запущен. Период проверки: {Interval}.", CheckInterval);
+
+ // Стартовая задержка — даём приложению полностью запуститься
+ await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await ProcessExpiredOrdersAsync(stoppingToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Ошибка при обработке просроченных заказов.");
+ }
+
+ await Task.Delay(CheckInterval, stoppingToken);
+ }
+
+ _logger.LogInformation("SLA BackgroundService остановлен.");
+ }
+
+ private async Task ProcessExpiredOrdersAsync(CancellationToken cancellationToken)
+ {
+ // Используем scoped DI (репозиторий — scoped сервис)
+ using var scope = _scopeFactory.CreateScope();
+ var repository = scope.ServiceProvider.GetRequiredService();
+
+ var cutoff = DateTime.UtcNow.Subtract(SlaTimeout);
+ var expiredOrders = await repository.GetExpiredPendingOrdersAsync(cutoff, cancellationToken);
+
+ if (expiredOrders.Count == 0)
+ {
+ return;
+ }
+
+ _logger.LogInformation("SLA-таймер: найдено {Count} просроченных заказов.", expiredOrders.Count);
+
+ foreach (var order in expiredOrders)
+ {
+ order.Expire();
+ _logger.LogWarning(
+ "Заказ {OrderId} переведён в статус Expired (создан {CreatedAt:u}, просрочен на {OverdueMinutes} мин.).",
+ order.Id,
+ order.CreatedAt,
+ (int)(DateTime.UtcNow - order.CreatedAt - SlaTimeout).TotalMinutes
+ );
+ }
+
+ await repository.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation("SLA-таймер: {Count} заказов успешно обновлено.", expiredOrders.Count);
+ }
+}
diff --git a/src/Modules/Order/Presentation/OrderEndpoints.cs b/src/Modules/Order/Presentation/OrderEndpoints.cs
index bd15a5c..6f26e3e 100644
--- a/src/Modules/Order/Presentation/OrderEndpoints.cs
+++ b/src/Modules/Order/Presentation/OrderEndpoints.cs
@@ -1,8 +1,12 @@
+using System.Security.Claims;
using MediatR;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
+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.Enums;
namespace Nashel.Modules.Order.Presentation;
@@ -11,41 +15,125 @@ 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) =>
+ // ─── Создание заказа (временно без авторизации) ─────────────────────────────
+ // AllowAnonymous не перекрывает RequireAuthorization на группе в Minimal API,
+ // поэтому создание вынесено в отдельную группу.
+ app.MapPost("/api/orders", async ([FromBody] CreateOrderRequest request, ISender sender) =>
{
var command = new CreateOrderCommand(
request.CustomerId,
+ request.CustomerName,
request.ServiceId,
+ request.ServiceTitle,
+ request.PriceAmount,
+ request.PriceType,
request.Type,
request.Address,
request.Latitude,
request.Longitude,
request.Deadline,
- request.PerformerId
+ request.PerformerId,
+ request.PerformerName
);
var orderId = await sender.Send(command);
return Results.Ok(orderId);
})
+ .WithTags("Заказы")
.WithName("CreateOrder")
- .WithOpenApi(operation => new(operation) { Summary = "Создание нового заказа.", Description = "Создает прямой или публичный заказ." });
+ .WithOpenApi(op => new(op)
+ {
+ Summary = "Создать заказ",
+ Description = "Создаёт прямой заказ (конкретному мастеру) или публичную заявку."
+ })
+ .AllowAnonymous();
+ var group = app.MapGroup("/api/orders")
+ .WithTags("Заказы")
+ .RequireAuthorization();
+
+ // ─── Получить мои заказы (как заказчик) ─────────────────────────────────────
+ group.MapGet("/as-customer/{userId:guid}", async (Guid userId, ISender sender) =>
+ {
+ var result = await sender.Send(new GetMyOrdersAsCustomerQuery(userId));
+ return Results.Ok(result);
+ })
+ .WithName("GetMyOrdersAsCustomer")
+ .WithOpenApi(op => new(op)
+ {
+ Summary = "Мои заказы (роль: Заказчик)",
+ Description = "Возвращает список всех заказов, где текущий пользователь является заказчиком, отсортированных по дате создания."
+ });
+
+ // ─── Получить мои заказы (как исполнитель) ───────────────────────────────────
+ group.MapGet("/as-performer/{userId:guid}", async (Guid userId, ISender sender) =>
+ {
+ var result = await sender.Send(new GetMyOrdersAsPerformerQuery(userId));
+ return Results.Ok(result);
+ })
+ .WithName("GetMyOrdersAsPerformer")
+ .WithOpenApi(op => new(op)
+ {
+ Summary = "Мои заказы (роль: Исполнитель)",
+ Description = "Возвращает список всех заказов, где текущий пользователь является назначенным исполнителем."
+ });
+
+ // ─── Принять заказ (мастер) ──────────────────────────────────────────────────
+ group.MapPost("/{id:guid}/accept", async (Guid id, AcceptOrderRequest request, ISender sender) =>
+ {
+ var command = new AcceptOrderCommand(id, request.PerformerId);
+ await sender.Send(command);
+ return Results.Ok();
+ })
+ .WithName("AcceptOrder")
+ .WithOpenApi(op => new(op)
+ {
+ Summary = "Принять заказ",
+ Description = "Исполнитель принимает заказ. Переводит заказ в статус «В работе»."
+ });
+
+ // ─── Завершить заказ ─────────────────────────────────────────────────────────
+ group.MapPost("/{id:guid}/complete", async (Guid id, CompleteOrderRequest request, ISender sender) =>
+ {
+ var command = new CompleteOrderCommand(id, request.RequesterId);
+ await sender.Send(command);
+ return Results.Ok();
+ })
+ .WithName("CompleteOrder")
+ .WithOpenApi(op => new(op)
+ {
+ Summary = "Завершить заказ",
+ Description = "Заказчик или исполнитель завершает заказ. Переводит в статус «Завершён»."
+ });
+
+ // ─── Отменить заказ ──────────────────────────────────────────────────────────
+ group.MapPost("/{id:guid}/cancel", async (Guid id, CancelOrderRequest request, ISender sender) =>
+ {
+ var command = new CancelOrderCommand(id, request.RequesterId, request.Reason);
+ await sender.Send(command);
+ return Results.Ok();
+ })
+ .WithName("CancelOrder")
+ .WithOpenApi(op => new(op)
+ {
+ 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
- );
+ 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 = "Добавление отклика на заказ.", Description = "Позволяет исполнителю откликнуться на заказ." });
+ .WithOpenApi(op => new(op)
+ {
+ Summary = "Откликнуться на заказ",
+ Description = "Исполнитель отправляет отклик на публичный заказ с указанием цены и комментария."
+ });
+ // ─── Выбрать исполнителя из откликов (заказчик) ─────────────────────────────
group.MapPost("/{id:guid}/select-performer", async (Guid id, SelectPerformerRequest request, ISender sender) =>
{
var command = new SelectPerformerCommand(id, request.PerformerId);
@@ -53,94 +141,125 @@ public static class OrderEndpoints
return Results.Ok();
})
.WithName("SelectPerformer")
- .WithOpenApi(operation => new(operation) { Summary = "Выбор исполнителя для заказа.", Description = "Позволяет заказчику выбрать конкретного исполнителя." });
+ .WithOpenApi(op => new(op)
+ {
+ Summary = "Выбрать исполнителя",
+ Description = "Заказчик выбирает конкретного исполнителя из откликнувшихся. Запускает SLA-таймер 60 минут."
+ });
}
}
+// ─── Request DTOs ────────────────────────────────────────────────────────────
+
///
/// Запрос на создание заказа.
///
public record CreateOrderRequest
{
- ///
- /// Идентификатор заказчика.
- ///
+ /// Идентификатор заказчика.
public Guid CustomerId { get; init; }
- ///
- /// Идентификатор услуги.
- ///
+ /// ФИО заказчика.
+ public string CustomerName { get; init; } = default!;
+
+ /// Идентификатор услуги.
public Guid ServiceId { get; init; }
- ///
- /// Тип заказа (прямой, публичный).
- ///
+ /// Название услуги.
+ public string ServiceTitle { get; init; } = default!;
+
+ /// Сумма услуги.
+ public decimal PriceAmount { get; init; }
+
+ /// Тип цены (0=Фикс, 1=Почасовая, 2=Договорная).
+ public PriceType PriceType { get; init; }
+
+ /// Тип заказа: Direct (прямой) или PublicJob (публичный).
public OrderType Type { get; init; }
- ///
- /// Текстовый адрес выполнения заказа.
- ///
+ /// Адрес выполнения заказа (текстовый).
public string Address { get; init; } = default!;
- ///
- /// Географическая широта места выполнения.
- ///
+ /// Широта места выполнения.
public double Latitude { get; init; }
- ///
- /// Географическая долгота места выполнения.
- ///
+ /// Долгота места выполнения.
public double Longitude { get; init; }
- ///
- /// Крайний срок выполнения заказа (опционально).
- ///
+ /// Крайний срок выполнения (необязательно).
public DateTime? Deadline { get; init; }
- ///
- /// Идентификатор конкретного исполнителя (если прямой заказ).
- ///
+ /// Идентификатор исполнителя (обязателен для прямого заказа).
public Guid? PerformerId { get; init; }
+ /// Имя исполнителя (обязателен для прямого заказа).
+ public string? PerformerName { get; init; }
+
public CreateOrderRequest() { }
}
///
-/// Запрос на добавление отклика.
+/// Запрос принятия заказа исполнителем.
+///
+public record AcceptOrderRequest
+{
+ /// Идентификатор исполнителя, принимающего заказ.
+ public Guid PerformerId { get; init; }
+
+ public AcceptOrderRequest() { }
+}
+
+///
+/// Запрос завершения заказа.
+///
+public record CompleteOrderRequest
+{
+ /// Идентификатор пользователя (заказчик или исполнитель).
+ public Guid RequesterId { get; init; }
+
+ public CompleteOrderRequest() { }
+}
+
+///
+/// Запрос отмены заказа.
+///
+public record CancelOrderRequest
+{
+ /// Идентификатор пользователя, инициирующего отмену.
+ public Guid RequesterId { get; init; }
+
+ /// Причина отмены (необязательно).
+ public string? Reason { get; init; }
+
+ public CancelOrderRequest() { }
+}
+
+///
+/// Запрос добавления отклика на заказ.
///
public record AddApplicationRequest
{
- ///
- /// Идентификатор исполнителя.
- ///
+ /// Идентификатор исполнителя.
public Guid PerformerId { get; init; }
- ///
- /// Сумма предложения.
- ///
+ /// Предлагаемая сумма.
public decimal Amount { get; init; }
- ///
- /// Валюта предложения (например, RUB).
- ///
+ /// Валюта (например, RUB).
public string Currency { get; init; } = "RUB";
- ///
- /// Комментарий к отклику.
- ///
+ /// Комментарий к отклику.
public string Comment { get; init; } = default!;
public AddApplicationRequest() { }
}
///
-/// Запрос на выбор исполнителя.
+/// Запрос выбора исполнителя.
///
public record SelectPerformerRequest
{
- ///
- /// Идентификатор выбранного исполнителя.
- ///
+ /// Идентификатор выбранного исполнителя.
public Guid PerformerId { get; init; }
public SelectPerformerRequest() { }
diff --git a/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs b/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs
index 7d2aee5..e354df4 100644
--- a/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs
+++ b/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs
@@ -35,7 +35,7 @@ public class SelectPerformerHandlerTests
// Создаем публичный заказ (Status = Published)
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic(
- customerId, serviceId, location, null);
+ customerId, "Customer", serviceId, "Service", 100, 0, location, null);
// Добавляем отклик исполнителя
order.AddApplication(performerId, new Money(100, "RUB"), "Comment");
diff --git a/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs b/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs
index fba595b..e27f0bf 100644
--- a/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs
+++ b/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs
@@ -19,7 +19,7 @@ public class OrderAggregateTests
// Act
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
- customerId, serviceId, performerId, location, DateTime.UtcNow.AddDays(1));
+ customerId, "Customer", serviceId, "Service", 100, 0, performerId, "Performer", location, DateTime.UtcNow.AddDays(1));
// Assert
order.Status.Should().Be(OrderStatus.PendingAcceptance);
@@ -37,7 +37,7 @@ public class OrderAggregateTests
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);
+ customerId, "Customer", serviceId, "Service", 100, 0, performerId, "Performer", location, null);
// Act
var action = () => order.AddApplication(Guid.NewGuid(), new Money(100, "RUB"), "Comment");
@@ -55,7 +55,7 @@ public class OrderAggregateTests
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);
+ customerId, "Customer", serviceId, "Service", 100, 0, location, null);
var performerId = Guid.NewGuid();
order.AddApplication(performerId, new Money(100, "RUB"), "Comment");