Создание заказов

This commit is contained in:
Халимов Рустам
2026-03-08 00:55:47 +03:00
parent 92f073e827
commit 5c9c6ab975
26 changed files with 1598 additions and 122 deletions
+7 -3
View File
@@ -9,9 +9,6 @@ COPY ["src/", "src/"]
# Restore dependencies # Restore dependencies
RUN dotnet restore "Nashel.sln" 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 # Publish the application
WORKDIR "/src/src/Host" WORKDIR "/src/src/Host"
RUN dotnet publish "Nashel.Host.csproj" -c Release -o /app/publish /p:UseAppHost=false 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 # Final stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS final FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS final
WORKDIR /app 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 --from=build /app/publish .
# Copy wwwroot folder for static files (uploads) # Copy wwwroot folder for static files (uploads)
COPY src/Host/wwwroot /app/wwwroot COPY src/Host/wwwroot /app/wwwroot
+6
View File
@@ -18,6 +18,12 @@ using Nashel.Modules.Reputation.Presentation;
var builder = WebApplication.CreateBuilder(args); 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.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options => builder.Services.AddSwaggerGen(options =>
@@ -0,0 +1,40 @@
using MediatR;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
/// <summary>
/// Команда принятия заказа исполнителем.
/// </summary>
public record AcceptOrderCommand(Guid OrderId, Guid PerformerId) : IRequest<Unit>;
public class AcceptOrderHandler : IRequestHandler<AcceptOrderCommand, Unit>
{
private readonly IOrderRepository _repository;
public AcceptOrderHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> 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;
}
}
@@ -6,9 +6,9 @@ namespace Nashel.Modules.Order.Application.Commands;
/// <summary> /// <summary>
/// Команда отмены заказа. /// Команда отмены заказа.
/// </summary> /// </summary>
public record CancelOrderCommand(Guid OrderId) : IRequest<Unit> public record CancelOrderCommand(Guid OrderId, Guid RequesterId, string? Reason = null) : IRequest<Unit>
{ {
public CancelOrderCommand() : this(Guid.Empty) { } public CancelOrderCommand() : this(Guid.Empty, Guid.Empty, null) { }
} }
public class CancelOrderHandler : IRequestHandler<CancelOrderCommand, Unit> public class CancelOrderHandler : IRequestHandler<CancelOrderCommand, Unit>
@@ -25,10 +25,19 @@ public class CancelOrderHandler : IRequestHandler<CancelOrderCommand, Unit>
var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken);
if (order == null) 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); await _repository.SaveChangesAsync(cancellationToken);
@@ -0,0 +1,43 @@
using MediatR;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
/// <summary>
/// Команда завершения заказа.
/// </summary>
public record CompleteOrderCommand(Guid OrderId, Guid RequesterId) : IRequest<Unit>;
public class CompleteOrderHandler : IRequestHandler<CompleteOrderCommand, Unit>
{
private readonly IOrderRepository _repository;
public CompleteOrderHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> 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;
}
}
@@ -1,4 +1,5 @@
using MediatR; using MediatR;
using Microsoft.Extensions.Logging;
using Nashel.Modules.Order.Domain.Enums; using Nashel.Modules.Order.Domain.Enums;
using Nashel.Modules.Order.Domain.Repositories; using Nashel.Modules.Order.Domain.Repositories;
using Nashel.Modules.Order.Domain.ValueObjects; using Nashel.Modules.Order.Domain.ValueObjects;
@@ -11,26 +12,38 @@ namespace Nashel.Modules.Order.Application.Commands;
/// </summary> /// </summary>
public record CreateOrderCommand( public record CreateOrderCommand(
Guid CustomerId, Guid CustomerId,
string CustomerName,
Guid ServiceId, Guid ServiceId,
string ServiceTitle,
decimal PriceAmount,
PriceType PriceType,
OrderType Type, OrderType Type,
string Address, string Address,
double Latitude, double Latitude,
double Longitude, double Longitude,
DateTime? Deadline, DateTime? Deadline,
Guid? PerformerId Guid? PerformerId,
string? PerformerName
) : IRequest<Guid>; ) : IRequest<Guid>;
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid> public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{ {
private readonly IOrderRepository _repository; private readonly IOrderRepository _repository;
private readonly ILogger<CreateOrderHandler> _logger;
public CreateOrderHandler(IOrderRepository repository) public CreateOrderHandler(IOrderRepository repository, ILogger<CreateOrderHandler> logger)
{ {
_repository = repository; _repository = repository;
_logger = logger;
} }
public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken cancellationToken) public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{ {
try
{
_logger.LogInformation("Creating order for customer {CustomerId}, service {ServiceId}, type {Type}",
request.CustomerId, request.ServiceId, request.Type);
// Создаем точку (WGS84) // Создаем точку (WGS84)
var point = new Point(request.Longitude, request.Latitude) { SRID = 4326 }; var point = new Point(request.Longitude, request.Latitude) { SRID = 4326 };
var location = new OrderLocation(request.Address, point); var location = new OrderLocation(request.Address, point);
@@ -41,13 +54,29 @@ public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{ {
if (request.PerformerId == null) if (request.PerformerId == null)
{ {
_logger.LogError("PerformerId is null for direct order");
throw new ArgumentException("PerformerId обязателен для прямого заказа."); 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( order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect(
request.CustomerId, request.CustomerId,
request.CustomerName,
request.ServiceId, request.ServiceId,
request.ServiceTitle,
request.PriceAmount,
request.PriceType,
request.PerformerId.Value, request.PerformerId.Value,
request.PerformerName ?? "Мастер",
location, location,
request.Deadline request.Deadline
); );
@@ -56,7 +85,11 @@ public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{ {
order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic( order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic(
request.CustomerId, request.CustomerId,
request.CustomerName,
request.ServiceId, request.ServiceId,
request.ServiceTitle,
request.PriceAmount,
request.PriceType,
location, location,
request.Deadline request.Deadline
); );
@@ -65,6 +98,13 @@ public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
await _repository.AddAsync(order, cancellationToken); await _repository.AddAsync(order, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken); await _repository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Order created with ID: {OrderId}", order.Id);
return order.Id; return order.Id;
} }
catch (Exception ex)
{
_logger.LogError(ex, "Error in CreateOrderHandler for customer {CustomerId}", request.CustomerId);
throw;
}
}
} }
@@ -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;
/// <summary>
/// DTO для передачи данных заказа на фронтенд.
/// </summary>
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,
/// <summary>Секунды до истечения SLA. Null если не в статусе PendingAcceptance.</summary>
int? SlaSecondsLeft
);
/// <summary>
/// Запрос списка заказов, где пользователь является заказчиком.
/// </summary>
public record GetMyOrdersAsCustomerQuery(Guid UserId) : IRequest<IReadOnlyList<OrderDto>>;
/// <summary>
/// Запрос списка заказов, где пользователь является исполнителем/мастером.
/// </summary>
public record GetMyOrdersAsPerformerQuery(Guid UserId) : IRequest<IReadOnlyList<OrderDto>>;
// ─── Handlers ────────────────────────────────────────────────────────────────
public class GetMyOrdersAsCustomerHandler : IRequestHandler<GetMyOrdersAsCustomerQuery, IReadOnlyList<OrderDto>>
{
private readonly IOrderRepository _repository;
public GetMyOrdersAsCustomerHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<IReadOnlyList<OrderDto>> Handle(GetMyOrdersAsCustomerQuery request, CancellationToken cancellationToken)
{
var orders = await _repository.GetByCustomerIdAsync(request.UserId, cancellationToken);
return orders.Select(OrderMapper.ToDto).ToList();
}
}
public class GetMyOrdersAsPerformerHandler : IRequestHandler<GetMyOrdersAsPerformerQuery, IReadOnlyList<OrderDto>>
{
private readonly IOrderRepository _repository;
public GetMyOrdersAsPerformerHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<IReadOnlyList<OrderDto>> 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
);
}
}
+128 -14
View File
@@ -11,13 +11,30 @@ namespace Nashel.Modules.Order.Domain.Aggregates;
public class Order : AggregateRoot<Guid> public class Order : AggregateRoot<Guid>
{ {
public Guid CustomerId { get; private set; } public Guid CustomerId { get; private set; }
public string CustomerName { get; private set; } = default!;
public Guid ServiceId { get; private set; } 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 OrderType Type { get; private set; }
public OrderStatus Status { get; private set; } public OrderStatus Status { get; private set; }
public Guid? SelectedPerformerId { get; private set; } public Guid? SelectedPerformerId { get; private set; }
public string? PerformerName { get; private set; }
public DateTime? Deadline { get; private set; } public DateTime? Deadline { get; private set; }
public OrderLocation Location { get; private set; } = null!; 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 // Optimistic Concurrency
public uint RowVersion { get; private set; } public uint RowVersion { get; private set; }
@@ -27,41 +44,140 @@ public class Order : AggregateRoot<Guid>
private Order() { } private Order() { }
/// <summary> /// <summary>
/// Создание прямого заказа. /// Создание прямого заказа (заказчик выбирает конкретного мастера).
/// </summary> /// </summary>
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 return new Order
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
CustomerId = customerId, CustomerId = customerId,
CustomerName = customerName,
ServiceId = serviceId, ServiceId = serviceId,
ServiceTitle = serviceTitle,
PriceAmount = priceAmount,
PriceType = priceType,
Type = OrderType.Direct, Type = OrderType.Direct,
Status = OrderStatus.PendingAcceptance, // Сразу ждет подтверждения Status = OrderStatus.PendingAcceptance,
SelectedPerformerId = performerId, SelectedPerformerId = performerId,
PerformerName = performerName,
Location = location, Location = location,
Deadline = deadline Deadline = deadline,
CreatedAt = DateTime.UtcNow
}; };
// Для прямого заказа отклик можно создать косвенно или считать цену договорной.
} }
/// <summary> /// <summary>
/// Создание публичной заявки. /// Создание публичной заявки (любой мастер может откликнуться).
/// </summary> /// </summary>
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 return new Order
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
CustomerId = customerId, CustomerId = customerId,
CustomerName = customerName,
ServiceId = serviceId, ServiceId = serviceId,
ServiceTitle = serviceTitle,
PriceAmount = priceAmount,
PriceType = priceType,
Type = OrderType.PublicJob, Type = OrderType.PublicJob,
Status = OrderStatus.Published, // Публикуется для поиска Status = OrderStatus.Published,
Location = location, Location = location,
Deadline = deadline Deadline = deadline,
CreatedAt = DateTime.UtcNow
}; };
} }
/// <summary>
/// Исполнитель принимает заказ. Запускает рабочий процесс.
/// </summary>
public void Accept()
{
if (Status != OrderStatus.PendingAcceptance)
{
throw new InvalidOperationException(
$"Принять заказ можно только если он ожидает подтверждения. Текущий статус: {Status}.");
}
Status = OrderStatus.InProgress;
AcceptedAt = DateTime.UtcNow;
}
/// <summary>
/// Заказ завершён (инициируется любой из сторон по договорённости).
/// </summary>
public void Complete()
{
if (Status != OrderStatus.InProgress)
{
throw new InvalidOperationException(
$"Завершить заказ можно только если он в работе. Текущий статус: {Status}.");
}
Status = OrderStatus.Completed;
CompletedAt = DateTime.UtcNow;
}
/// <summary>
/// Отмена заказа с указанием причины.
/// </summary>
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;
}
/// <summary>
/// SLA-таймер истёк: мастер не принял заказ в течение 60 минут.
/// </summary>
public void Expire()
{
if (Status != OrderStatus.PendingAcceptance)
{
// Уже обработан (принят, отменён и т.д.) — ничего не делаем
return;
}
Status = OrderStatus.Expired;
}
/// <summary>
/// Добавление отклика исполнителя на публичный заказ.
/// </summary>
public void AddApplication(Guid performerId, Money price, string comment) public void AddApplication(Guid performerId, Money price, string comment)
{ {
if (Status != OrderStatus.Published) if (Status != OrderStatus.Published)
@@ -77,6 +193,9 @@ public class Order : AggregateRoot<Guid>
_applications.Add(new OrderApplication(performerId, price, comment)); _applications.Add(new OrderApplication(performerId, price, comment));
} }
/// <summary>
/// Заказчик выбирает исполнителя из откликнувшихся.
/// </summary>
public void SelectPerformer(Guid performerId) public void SelectPerformer(Guid performerId)
{ {
if (Status != OrderStatus.Published) if (Status != OrderStatus.Published)
@@ -93,9 +212,4 @@ public class Order : AggregateRoot<Guid>
SelectedPerformerId = performerId; SelectedPerformerId = performerId;
Status = OrderStatus.PendingAcceptance; Status = OrderStatus.PendingAcceptance;
} }
public void Cancel()
{
Status = OrderStatus.Cancelled;
}
} }
+17 -7
View File
@@ -6,27 +6,37 @@ namespace Nashel.Modules.Order.Domain.Enums;
public enum OrderStatus public enum OrderStatus
{ {
/// <summary> /// <summary>
/// Создан. /// Создан (внутренний, промежуточный).
/// </summary> /// </summary>
Created, Created,
/// <summary> /// <summary>
/// Опубликован (поиск исполнителей). /// Опубликован (идёт поиск исполнителей для публичного заказа).
/// </summary> /// </summary>
Published, Published,
/// <summary> /// <summary>
/// Ожидает подтверждения (выбран исполнитель). /// Ожидает подтверждения мастером (SLA-таймер 60 минут запущен).
/// </summary> /// </summary>
PendingAcceptance, PendingAcceptance,
/// <summary> /// <summary>
/// В работе. /// В работе (мастер принял заказ).
/// </summary> /// </summary>
InProgress, InProgress,
/// <summary> /// <summary>
/// Завершен. /// Завершён успешно.
/// </summary> /// </summary>
Completed, Completed,
/// <summary> /// <summary>
/// Отменен. /// Отменён любой из сторон.
/// </summary> /// </summary>
Cancelled Cancelled,
/// <summary>
/// Истекло время ожидания (мастер не принял заказ в течение 60 минут).
/// </summary>
Expired
} }
@@ -0,0 +1,11 @@
namespace Nashel.Modules.Order.Domain.Enums;
/// <summary>
/// Тип оплаты в заказе.
/// </summary>
public enum PriceType
{
Fixed,
Hourly,
Negotiable
}
@@ -2,9 +2,29 @@ using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order;
namespace Nashel.Modules.Order.Domain.Repositories; namespace Nashel.Modules.Order.Domain.Repositories;
/// <summary>
/// Репозиторий заказов.
/// </summary>
public interface IOrderRepository public interface IOrderRepository
{ {
Task<OrderAggregate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<OrderAggregate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Заказы где пользователь является заказчиком.
/// </summary>
Task<IReadOnlyList<OrderAggregate>> GetByCustomerIdAsync(Guid customerId, CancellationToken cancellationToken = default);
/// <summary>
/// Заказы где пользователь является исполнителем.
/// </summary>
Task<IReadOnlyList<OrderAggregate>> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default);
/// <summary>
/// Заказы в статусе PendingAcceptance, у которых CreatedAt < cutoff (для SLA-таймера).
/// </summary>
Task<IReadOnlyList<OrderAggregate>> GetExpiredPendingOrdersAsync(DateTime cutoff, CancellationToken cancellationToken = default);
Task AddAsync(OrderAggregate order, CancellationToken cancellationToken = default); Task AddAsync(OrderAggregate order, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default); Task SaveChangesAsync(CancellationToken cancellationToken = default);
Task<OrderAggregate?> GetActiveOrderAsync(Guid customerId, Guid performerId, Guid serviceId, CancellationToken cancellationToken = default);
} }
@@ -1,13 +1,14 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Nashel.Modules.Order.Application.Commands; using Nashel.Modules.Order.Application.Commands;
using Nashel.Modules.Order.Application.Queries;
using Nashel.Modules.Order.Application.Services; using Nashel.Modules.Order.Application.Services;
using Nashel.Modules.Order.Domain.Repositories; using Nashel.Modules.Order.Domain.Repositories;
using Nashel.Modules.Order.Infrastructure.Persistence; using Nashel.Modules.Order.Infrastructure.Persistence;
using Nashel.Modules.Order.Infrastructure.Persistence.Repositories; using Nashel.Modules.Order.Infrastructure.Persistence.Repositories;
using Nashel.Modules.Order.Infrastructure.Services; using Nashel.Modules.Order.Infrastructure.Services;
using Npgsql;
namespace Nashel.Modules.Order.Infrastructure; namespace Nashel.Modules.Order.Infrastructure;
@@ -24,11 +25,19 @@ public static class DependencyInjection
)); ));
services.AddScoped<IOrderRepository, OrderRepository>(); services.AddScoped<IOrderRepository, OrderRepository>();
// Регистрируем DummySlaTimerService для обратной совместимости (используется в SelectPerformerCommand)
services.AddScoped<ISlaTimerService, DummySlaTimerService>(); services.AddScoped<ISlaTimerService, DummySlaTimerService>();
// Регистрируем реальный SLA BackgroundService
services.AddHostedService<SlaBackgroundService>();
services.AddMediatR(cfg => services.AddMediatR(cfg =>
{ {
// Application: Commands
cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly); cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
// Application: Queries
cfg.RegisterServicesFromAssembly(typeof(GetMyOrdersAsCustomerQuery).Assembly);
}); });
return services; return services;
@@ -0,0 +1,182 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,80 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddOrderSlaFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "AcceptedAt",
schema: "ordering",
table: "Orders",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CancellationReason",
schema: "ordering",
table: "Orders",
type: "character varying(512)",
maxLength: 512,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CompletedAt",
schema: "ordering",
table: "Orders",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
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" });
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,199 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<string>("PerformerName")
.HasColumnType("text");
b.Property<decimal>("PriceAmount")
.HasColumnType("numeric");
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ExpandOrderDetails : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CustomerName",
schema: "ordering",
table: "Orders",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "PerformerName",
schema: "ordering",
table: "Orders",
type: "text",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PriceAmount",
schema: "ordering",
table: "Orders",
type: "numeric",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<int>(
name: "PriceType",
schema: "ordering",
table: "Orders",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "ServiceTitle",
schema: "ordering",
table: "Orders",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,199 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<string>("PerformerName")
.HasColumnType("text");
b.Property<decimal>("PriceAmount")
.HasColumnType("numeric");
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class UpdatePriceTypeToEnum : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -31,12 +31,38 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CancellationReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId") b.Property<Guid>("CustomerId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("CustomerName")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Deadline") b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<string>("PerformerName")
.HasColumnType("text");
b.Property<decimal>("PriceAmount")
.HasColumnType("numeric");
b.Property<int>("PriceType")
.HasColumnType("integer");
b.Property<uint>("RowVersion") b.Property<uint>("RowVersion")
.IsConcurrencyToken() .IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
@@ -49,6 +75,10 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
b.Property<Guid>("ServiceId") b.Property<Guid>("ServiceId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -59,6 +89,8 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("Orders", "ordering"); b.ToTable("Orders", "ordering");
}); });
@@ -9,6 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-preview.2.25163.2" />
<PackageReference Include="NetTopologySuite.IO.PostGis" Version="2.1.0" /> <PackageReference Include="NetTopologySuite.IO.PostGis" Version="2.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0" />
@@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Nashel.Modules.Order.Domain.Aggregates;
using Nashel.Modules.Order.Domain.Entities; using Nashel.Modules.Order.Domain.Entities;
namespace Nashel.Modules.Order.Infrastructure.Persistence.Configurations; namespace Nashel.Modules.Order.Infrastructure.Persistence.Configurations;
@@ -13,9 +12,12 @@ public class OrderConfiguration : IEntityTypeConfiguration<Nashel.Modules.Order.
builder.HasKey(o => o.Id); builder.HasKey(o => o.Id);
// Optimistic Concurrency (xmin mapping for uint) // Optimistic Concurrency через PostgreSQL системный столбец xmin
builder.Property(o => o.RowVersion) builder.Property(o => o.RowVersion)
.IsRowVersion(); .HasColumnName("xmin")
.HasColumnType("xid")
.ValueGeneratedOnAddOrUpdate()
.IsConcurrencyToken();
builder.Property(o => o.Type) builder.Property(o => o.Type)
.HasConversion<string>(); .HasConversion<string>();
@@ -23,6 +25,20 @@ public class OrderConfiguration : IEntityTypeConfiguration<Nashel.Modules.Order.
builder.Property(o => o.Status) builder.Property(o => o.Status)
.HasConversion<string>(); .HasConversion<string>();
// 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 => builder.OwnsOne(o => o.Location, nav =>
{ {
nav.Property(l => l.Address).HasColumnName("Address"); nav.Property(l => l.Address).HasColumnName("Address");
@@ -34,6 +50,9 @@ public class OrderConfiguration : IEntityTypeConfiguration<Nashel.Modules.Order.
.WithOne() .WithOne()
.HasForeignKey("OrderId") .HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
// Индекс для SLA-таймера (быстрый поиск просроченных заказов)
builder.HasIndex(o => new { o.Status, o.CreatedAt });
} }
} }
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Nashel.Modules.Order.Domain.Enums;
using Nashel.Modules.Order.Domain.Repositories; using Nashel.Modules.Order.Domain.Repositories;
using Nashel.Modules.Order.Infrastructure.Persistence; using Nashel.Modules.Order.Infrastructure.Persistence;
using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order; using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order;
@@ -26,8 +27,45 @@ public class OrderRepository : IOrderRepository
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken); .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
} }
public async Task<IReadOnlyList<OrderAggregate>> 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<IReadOnlyList<OrderAggregate>> 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<IReadOnlyList<OrderAggregate>> 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) public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{ {
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
public async Task<OrderAggregate?> 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);
}
} }
@@ -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;
/// <summary>
/// Фоновый сервис SLA-таймера.
/// Раз в 1 минуту проверяет заказы в статусе PendingAcceptance,
/// которые ожидают более 60 минут, и переводит их в статус Expired.
/// </summary>
public class SlaBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SlaBackgroundService> _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<SlaBackgroundService> 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<IOrderRepository>();
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);
}
}
+174 -55
View File
@@ -1,8 +1,12 @@
using System.Security.Claims;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Nashel.Modules.Order.Application.Commands; using Nashel.Modules.Order.Application.Commands;
using Nashel.Modules.Order.Application.Queries;
using Nashel.Modules.Order.Domain.Enums; using Nashel.Modules.Order.Domain.Enums;
namespace Nashel.Modules.Order.Presentation; namespace Nashel.Modules.Order.Presentation;
@@ -11,41 +15,125 @@ public static class OrderEndpoints
{ {
public static void MapOrderEndpoints(this IEndpointRouteBuilder app) public static void MapOrderEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup("/api/orders").WithTags("Orders"); // ─── Создание заказа (временно без авторизации) ─────────────────────────────
// AllowAnonymous не перекрывает RequireAuthorization на группе в Minimal API,
group.MapPost("/", async (CreateOrderRequest request, ISender sender) => // поэтому создание вынесено в отдельную группу.
app.MapPost("/api/orders", async ([FromBody] CreateOrderRequest request, ISender sender) =>
{ {
var command = new CreateOrderCommand( var command = new CreateOrderCommand(
request.CustomerId, request.CustomerId,
request.CustomerName,
request.ServiceId, request.ServiceId,
request.ServiceTitle,
request.PriceAmount,
request.PriceType,
request.Type, request.Type,
request.Address, request.Address,
request.Latitude, request.Latitude,
request.Longitude, request.Longitude,
request.Deadline, request.Deadline,
request.PerformerId request.PerformerId,
request.PerformerName
); );
var orderId = await sender.Send(command); var orderId = await sender.Send(command);
return Results.Ok(orderId); return Results.Ok(orderId);
}) })
.WithTags("Заказы")
.WithName("CreateOrder") .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) => group.MapPost("/{id:guid}/applications", async (Guid id, AddApplicationRequest request, ISender sender) =>
{ {
var command = new AddApplicationCommand( var command = new AddApplicationCommand(id, request.PerformerId, request.Amount, request.Currency, request.Comment);
id,
request.PerformerId,
request.Amount,
request.Currency,
request.Comment
);
await sender.Send(command); await sender.Send(command);
return Results.Ok(); return Results.Ok();
}) })
.WithName("AddOrderApplication") .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) => group.MapPost("/{id:guid}/select-performer", async (Guid id, SelectPerformerRequest request, ISender sender) =>
{ {
var command = new SelectPerformerCommand(id, request.PerformerId); var command = new SelectPerformerCommand(id, request.PerformerId);
@@ -53,94 +141,125 @@ public static class OrderEndpoints
return Results.Ok(); return Results.Ok();
}) })
.WithName("SelectPerformer") .WithName("SelectPerformer")
.WithOpenApi(operation => new(operation) { Summary = "Выбор исполнителя для заказа.", Description = "Позволяет заказчику выбрать конкретного исполнителя." }); .WithOpenApi(op => new(op)
{
Summary = "Выбрать исполнителя",
Description = "Заказчик выбирает конкретного исполнителя из откликнувшихся. Запускает SLA-таймер 60 минут."
});
} }
} }
// ─── Request DTOs ────────────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Запрос на создание заказа. /// Запрос на создание заказа.
/// </summary> /// </summary>
public record CreateOrderRequest public record CreateOrderRequest
{ {
/// <summary> /// <summary>Идентификатор заказчика.</summary>
/// Идентификатор заказчика.
/// </summary>
public Guid CustomerId { get; init; } public Guid CustomerId { get; init; }
/// <summary> /// <summary>ФИО заказчика.</summary>
/// Идентификатор услуги. public string CustomerName { get; init; } = default!;
/// </summary>
/// <summary>Идентификатор услуги.</summary>
public Guid ServiceId { get; init; } public Guid ServiceId { get; init; }
/// <summary> /// <summary>Название услуги.</summary>
/// Тип заказа (прямой, публичный). public string ServiceTitle { get; init; } = default!;
/// </summary>
/// <summary>Сумма услуги.</summary>
public decimal PriceAmount { get; init; }
/// <summary>Тип цены (0=Фикс, 1=Почасовая, 2=Договорная).</summary>
public PriceType PriceType { get; init; }
/// <summary>Тип заказа: Direct (прямой) или PublicJob (публичный).</summary>
public OrderType Type { get; init; } public OrderType Type { get; init; }
/// <summary> /// <summary>Адрес выполнения заказа (текстовый).</summary>
/// Текстовый адрес выполнения заказа.
/// </summary>
public string Address { get; init; } = default!; public string Address { get; init; } = default!;
/// <summary> /// <summary>Широта места выполнения.</summary>
/// Географическая широта места выполнения.
/// </summary>
public double Latitude { get; init; } public double Latitude { get; init; }
/// <summary> /// <summary>Долгота места выполнения.</summary>
/// Географическая долгота места выполнения.
/// </summary>
public double Longitude { get; init; } public double Longitude { get; init; }
/// <summary> /// <summary>Крайний срок выполнения (необязательно).</summary>
/// Крайний срок выполнения заказа (опционально).
/// </summary>
public DateTime? Deadline { get; init; } public DateTime? Deadline { get; init; }
/// <summary> /// <summary>Идентификатор исполнителя (обязателен для прямого заказа).</summary>
/// Идентификатор конкретного исполнителя (если прямой заказ).
/// </summary>
public Guid? PerformerId { get; init; } public Guid? PerformerId { get; init; }
/// <summary>Имя исполнителя (обязателен для прямого заказа).</summary>
public string? PerformerName { get; init; }
public CreateOrderRequest() { } public CreateOrderRequest() { }
} }
/// <summary> /// <summary>
/// Запрос на добавление отклика. /// Запрос принятия заказа исполнителем.
/// </summary>
public record AcceptOrderRequest
{
/// <summary>Идентификатор исполнителя, принимающего заказ.</summary>
public Guid PerformerId { get; init; }
public AcceptOrderRequest() { }
}
/// <summary>
/// Запрос завершения заказа.
/// </summary>
public record CompleteOrderRequest
{
/// <summary>Идентификатор пользователя (заказчик или исполнитель).</summary>
public Guid RequesterId { get; init; }
public CompleteOrderRequest() { }
}
/// <summary>
/// Запрос отмены заказа.
/// </summary>
public record CancelOrderRequest
{
/// <summary>Идентификатор пользователя, инициирующего отмену.</summary>
public Guid RequesterId { get; init; }
/// <summary>Причина отмены (необязательно).</summary>
public string? Reason { get; init; }
public CancelOrderRequest() { }
}
/// <summary>
/// Запрос добавления отклика на заказ.
/// </summary> /// </summary>
public record AddApplicationRequest public record AddApplicationRequest
{ {
/// <summary> /// <summary>Идентификатор исполнителя.</summary>
/// Идентификатор исполнителя.
/// </summary>
public Guid PerformerId { get; init; } public Guid PerformerId { get; init; }
/// <summary> /// <summary>Предлагаемая сумма.</summary>
/// Сумма предложения.
/// </summary>
public decimal Amount { get; init; } public decimal Amount { get; init; }
/// <summary> /// <summary>Валюта (например, RUB).</summary>
/// Валюта предложения (например, RUB).
/// </summary>
public string Currency { get; init; } = "RUB"; public string Currency { get; init; } = "RUB";
/// <summary> /// <summary>Комментарий к отклику.</summary>
/// Комментарий к отклику.
/// </summary>
public string Comment { get; init; } = default!; public string Comment { get; init; } = default!;
public AddApplicationRequest() { } public AddApplicationRequest() { }
} }
/// <summary> /// <summary>
/// Запрос на выбор исполнителя. /// Запрос выбора исполнителя.
/// </summary> /// </summary>
public record SelectPerformerRequest public record SelectPerformerRequest
{ {
/// <summary> /// <summary>Идентификатор выбранного исполнителя.</summary>
/// Идентификатор выбранного исполнителя.
/// </summary>
public Guid PerformerId { get; init; } public Guid PerformerId { get; init; }
public SelectPerformerRequest() { } public SelectPerformerRequest() { }
@@ -35,7 +35,7 @@ public class SelectPerformerHandlerTests
// Создаем публичный заказ (Status = Published) // Создаем публичный заказ (Status = Published)
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic( 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"); order.AddApplication(performerId, new Money(100, "RUB"), "Comment");
@@ -19,7 +19,7 @@ public class OrderAggregateTests
// Act // Act
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect( 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 // Assert
order.Status.Should().Be(OrderStatus.PendingAcceptance); order.Status.Should().Be(OrderStatus.PendingAcceptance);
@@ -37,7 +37,7 @@ public class OrderAggregateTests
var location = new OrderLocation("Test St", new Point(10, 20)); var location = new OrderLocation("Test St", new Point(10, 20));
// Direct order created in PendingAcceptance // Direct order created in PendingAcceptance
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect( 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 // Act
var action = () => order.AddApplication(Guid.NewGuid(), new Money(100, "RUB"), "Comment"); var action = () => order.AddApplication(Guid.NewGuid(), new Money(100, "RUB"), "Comment");
@@ -55,7 +55,7 @@ public class OrderAggregateTests
var serviceId = Guid.NewGuid(); var serviceId = Guid.NewGuid();
var location = new OrderLocation("Test St", new Point(10, 20)); var location = new OrderLocation("Test St", new Point(10, 20));
var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic( 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(); var performerId = Guid.NewGuid();
order.AddApplication(performerId, new Money(100, "RUB"), "Comment"); order.AddApplication(performerId, new Money(100, "RUB"), "Comment");