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

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

View File

@@ -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

View File

@@ -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 =>

View File

@@ -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;
}
}

View File

@@ -6,9 +6,9 @@ namespace Nashel.Modules.Order.Application.Commands;
/// <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>
@@ -25,10 +25,19 @@ public class CancelOrderHandler : IRequestHandler<CancelOrderCommand, Unit>
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);

View File

@@ -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;
}
}

View File

@@ -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;
/// </summary>
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<Guid>;
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly IOrderRepository _repository;
public CreateOrderHandler(IOrderRepository repository)
private readonly ILogger<CreateOrderHandler> _logger;
public CreateOrderHandler(IOrderRepository repository, ILogger<CreateOrderHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<Guid> 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;
}
}

View File

@@ -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
);
}
}

View File

@@ -11,13 +11,30 @@ namespace Nashel.Modules.Order.Domain.Aggregates;
public class Order : AggregateRoot<Guid>
{
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<Guid>
private Order() { }
/// <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
{
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
};
// Для прямого заказа отклик можно создать косвенно или считать цену договорной.
}
/// <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
{
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
};
}
/// <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)
{
if (Status != OrderStatus.Published)
@@ -77,6 +193,9 @@ public class Order : AggregateRoot<Guid>
_applications.Add(new OrderApplication(performerId, price, comment));
}
/// <summary>
/// Заказчик выбирает исполнителя из откликнувшихся.
/// </summary>
public void SelectPerformer(Guid performerId)
{
if (Status != OrderStatus.Published)
@@ -93,9 +212,4 @@ public class Order : AggregateRoot<Guid>
SelectedPerformerId = performerId;
Status = OrderStatus.PendingAcceptance;
}
public void Cancel()
{
Status = OrderStatus.Cancelled;
}
}

View File

@@ -6,27 +6,37 @@ namespace Nashel.Modules.Order.Domain.Enums;
public enum OrderStatus
{
/// <summary>
/// Создан.
/// Создан (внутренний, промежуточный).
/// </summary>
Created,
/// <summary>
/// Опубликован (поиск исполнителей).
/// Опубликован (идёт поиск исполнителей для публичного заказа).
/// </summary>
Published,
/// <summary>
/// Ожидает подтверждения (выбран исполнитель).
/// Ожидает подтверждения мастером (SLA-таймер 60 минут запущен).
/// </summary>
PendingAcceptance,
/// <summary>
/// В работе.
/// В работе (мастер принял заказ).
/// </summary>
InProgress,
/// <summary>
/// Завершен.
/// Завершён успешно.
/// </summary>
Completed,
/// <summary>
/// Отменен.
/// Отменён любой из сторон.
/// </summary>
Cancelled
Cancelled,
/// <summary>
/// Истекло время ожидания (мастер не принял заказ в течение 60 минут).
/// </summary>
Expired
}

View File

@@ -0,0 +1,11 @@
namespace Nashel.Modules.Order.Domain.Enums;
/// <summary>
/// Тип оплаты в заказе.
/// </summary>
public enum PriceType
{
Fixed,
Hourly,
Negotiable
}

View File

@@ -2,9 +2,29 @@ using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order;
namespace Nashel.Modules.Order.Domain.Repositories;
/// <summary>
/// Репозиторий заказов.
/// </summary>
public interface IOrderRepository
{
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 SaveChangesAsync(CancellationToken cancellationToken = default);
Task<OrderAggregate?> GetActiveOrderAsync(Guid customerId, Guid performerId, Guid serviceId, CancellationToken cancellationToken = default);
}

View File

@@ -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<IOrderRepository, OrderRepository>();
// Регистрируем DummySlaTimerService для обратной совместимости (используется в SelectPerformerCommand)
services.AddScoped<ISlaTimerService, DummySlaTimerService>();
// Регистрируем реальный SLA BackgroundService
services.AddHostedService<SlaBackgroundService>();
services.AddMediatR(cfg =>
{
// Application: Commands
cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
// Application: Queries
cfg.RegisterServicesFromAssembly(typeof(GetMyOrdersAsCustomerQuery).Assembly);
});
return services;

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
{
}
}
}

View File

@@ -31,12 +31,38 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
.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()
@@ -49,6 +75,10 @@ namespace Nashel.Modules.Order.Infrastructure.Migrations
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("ServiceTitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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");
});

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0" />

View File

@@ -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<Nashel.Modules.Order.
builder.HasKey(o => 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<string>();
@@ -23,6 +25,20 @@ public class OrderConfiguration : IEntityTypeConfiguration<Nashel.Modules.Order.
builder.Property(o => o.Status)
.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 =>
{
nav.Property(l => l.Address).HasColumnName("Address");
@@ -34,6 +50,9 @@ public class OrderConfiguration : IEntityTypeConfiguration<Nashel.Modules.Order.
.WithOne()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
// Индекс для SLA-таймера (быстрый поиск просроченных заказов)
builder.HasIndex(o => new { o.Status, o.CreatedAt });
}
}

View File

@@ -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<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)
{
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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 ────────────────────────────────────────────────────────────
/// <summary>
/// Запрос на создание заказа.
/// </summary>
public record CreateOrderRequest
{
/// <summary>
/// Идентификатор заказчика.
/// </summary>
/// <summary>Идентификатор заказчика.</summary>
public Guid CustomerId { get; init; }
/// <summary>
/// Идентификатор услуги.
/// </summary>
/// <summary>ФИО заказчика.</summary>
public string CustomerName { get; init; } = default!;
/// <summary>Идентификатор услуги.</summary>
public Guid ServiceId { get; init; }
/// <summary>
/// Тип заказа (прямой, публичный).
/// </summary>
/// <summary>Название услуги.</summary>
public string ServiceTitle { get; init; } = default!;
/// <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; }
/// <summary>
/// Текстовый адрес выполнения заказа.
/// </summary>
/// <summary>Адрес выполнения заказа (текстовый).</summary>
public string Address { get; init; } = default!;
/// <summary>
/// Географическая широта места выполнения.
/// </summary>
/// <summary>Широта места выполнения.</summary>
public double Latitude { get; init; }
/// <summary>
/// Географическая долгота места выполнения.
/// </summary>
/// <summary>Долгота места выполнения.</summary>
public double Longitude { get; init; }
/// <summary>
/// Крайний срок выполнения заказа (опционально).
/// </summary>
/// <summary>Крайний срок выполнения (необязательно).</summary>
public DateTime? Deadline { get; init; }
/// <summary>
/// Идентификатор конкретного исполнителя (если прямой заказ).
/// </summary>
/// <summary>Идентификатор исполнителя (обязателен для прямого заказа).</summary>
public Guid? PerformerId { get; init; }
/// <summary>Имя исполнителя (обязателен для прямого заказа).</summary>
public string? PerformerName { get; init; }
public CreateOrderRequest() { }
}
/// <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>
public record AddApplicationRequest
{
/// <summary>
/// Идентификатор исполнителя.
/// </summary>
/// <summary>Идентификатор исполнителя.</summary>
public Guid PerformerId { get; init; }
/// <summary>
/// Сумма предложения.
/// </summary>
/// <summary>Предлагаемая сумма.</summary>
public decimal Amount { get; init; }
/// <summary>
/// Валюта предложения (например, RUB).
/// </summary>
/// <summary>Валюта (например, RUB).</summary>
public string Currency { get; init; } = "RUB";
/// <summary>
/// Комментарий к отклику.
/// </summary>
/// <summary>Комментарий к отклику.</summary>
public string Comment { get; init; } = default!;
public AddApplicationRequest() { }
}
/// <summary>
/// Запрос на выбор исполнителя.
/// Запрос выбора исполнителя.
/// </summary>
public record SelectPerformerRequest
{
/// <summary>
/// Идентификатор выбранного исполнителя.
/// </summary>
/// <summary>Идентификатор выбранного исполнителя.</summary>
public Guid PerformerId { get; init; }
public SelectPerformerRequest() { }

View File

@@ -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");

View File

@@ -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");