Создание заказов
This commit is contained in:
10
Dockerfile
10
Dockerfile
@@ -9,9 +9,6 @@ COPY ["src/", "src/"]
|
||||
# Restore dependencies
|
||||
RUN dotnet restore "Nashel.sln"
|
||||
|
||||
# Copy the rest of the source code (already copied in previous step fundamentally, but ensuring context)
|
||||
# Actually, copying src/ covers it.
|
||||
|
||||
# Publish the application
|
||||
WORKDIR "/src/src/Host"
|
||||
RUN dotnet publish "Nashel.Host.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
@@ -19,6 +16,13 @@ RUN dotnet publish "Nashel.Host.csproj" -c Release -o /app/publish /p:UseAppHost
|
||||
# Final stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем зависимости Npgsql / Kerberos (нужны для PostgreSQL через Npgsql в .NET 10 preview)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgssapi-krb5-2 \
|
||||
krb5-user \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
# Copy wwwroot folder for static files (uploads)
|
||||
COPY src/Host/wwwroot /app/wwwroot
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
40
src/Modules/Order/Application/Commands/AcceptOrderCommand.cs
Normal file
40
src/Modules/Order/Application/Commands/AcceptOrderCommand.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
}
|
||||
|
||||
113
src/Modules/Order/Application/Queries/GetMyOrdersQuery.cs
Normal file
113
src/Modules/Order/Application/Queries/GetMyOrdersQuery.cs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
11
src/Modules/Order/Domain/Enums/PriceType.cs
Normal file
11
src/Modules/Order/Domain/Enums/PriceType.cs
Normal file
@@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
182
src/Modules/Order/Infrastructure/Migrations/20260307182933_AddOrderSlaFields.Designer.cs
generated
Normal file
182
src/Modules/Order/Infrastructure/Migrations/20260307182933_AddOrderSlaFields.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/Modules/Order/Infrastructure/Migrations/20260307192905_ExpandOrderDetails.Designer.cs
generated
Normal file
199
src/Modules/Order/Infrastructure/Migrations/20260307192905_ExpandOrderDetails.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/Modules/Order/Infrastructure/Migrations/20260307214623_UpdatePriceTypeToEnum.Designer.cs
generated
Normal file
199
src/Modules/Order/Infrastructure/Migrations/20260307214623_UpdatePriceTypeToEnum.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
.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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user