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