diff --git a/.gitignore b/.gitignore index e369b34..c5af949 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,7 @@ src/Modules/Geo/Tests/bin/ src/Modules/Geo/Tests/obj/ src/Modules/Geo/Presentation/bin/ + +src/Modules/Order/Tests/bin/ + +src/Modules/Order/Tests/obj/ diff --git a/Nashel.sln b/Nashel.sln index 16d21f6..77e6b5f 100644 --- a/Nashel.sln +++ b/Nashel.sln @@ -77,6 +77,24 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Identity.Tests", "src\Modules\Identity\Tests\Nashel.Modules.Identity.Tests.csproj", "{401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Order", "Order", "{2C821288-5B9D-778E-74EE-053FE87F223E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{7D435AA2-42F6-BE60-ABDD-B2A72FA358F7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{06FD8EF1-B9DE-B088-FC77-09DC201E1B21}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{159DA5B4-03CA-95B4-A9E1-B46977641578}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Presentation", "Presentation", "{D0003F1F-EE71-3913-8EC0-5F1638807A8D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{FB27511E-CBA6-E343-8CE0-54AAD98AFC9F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nashel.Modules.Order.Tests", "src\Modules\Order\Tests\Nashel.Modules.Order.Tests.csproj", "{DC1E99E0-6B92-4FBD-B74B-68D202961FB7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -411,6 +429,18 @@ Global {401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}.Release|x64.Build.0 = Release|Any CPU {401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}.Release|x86.ActiveCfg = Release|Any CPU {401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}.Release|x86.Build.0 = Release|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|x64.ActiveCfg = Debug|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|x64.Build.0 = Debug|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|x86.ActiveCfg = Debug|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Debug|x86.Build.0 = Debug|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|Any CPU.Build.0 = Release|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x64.ActiveCfg = Release|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x64.Build.0 = Release|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x86.ActiveCfg = Release|Any CPU + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -449,5 +479,13 @@ Global {3266C089-BF45-8FB5-CBFB-520E7D591FF0} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} {6153DBFE-E924-7D0F-FF5C-84EC91E8B397} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} {401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2C821288-5B9D-778E-74EE-053FE87F223E} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {7D435AA2-42F6-BE60-ABDD-B2A72FA358F7} = {2C821288-5B9D-778E-74EE-053FE87F223E} + {06FD8EF1-B9DE-B088-FC77-09DC201E1B21} = {2C821288-5B9D-778E-74EE-053FE87F223E} + {159DA5B4-03CA-95B4-A9E1-B46977641578} = {2C821288-5B9D-778E-74EE-053FE87F223E} + {D0003F1F-EE71-3913-8EC0-5F1638807A8D} = {2C821288-5B9D-778E-74EE-053FE87F223E} + {FB27511E-CBA6-E343-8CE0-54AAD98AFC9F} = {2C821288-5B9D-778E-74EE-053FE87F223E} + {DC1E99E0-6B92-4FBD-B74B-68D202961FB7} = {FB27511E-CBA6-E343-8CE0-54AAD98AFC9F} EndGlobalSection EndGlobal diff --git a/src/Host/Nashel.Host.csproj b/src/Host/Nashel.Host.csproj index a4bac45..80636fb 100644 --- a/src/Host/Nashel.Host.csproj +++ b/src/Host/Nashel.Host.csproj @@ -15,6 +15,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/src/Host/Program.cs b/src/Host/Program.cs index 6ef9258..194d652 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -8,6 +8,8 @@ using Nashel.Modules.Geo.Infrastructure; using Nashel.Modules.Geo.Presentation; using Nashel.Modules.Identity.Infrastructure; using Nashel.Modules.Identity.Presentation.Endpoints; +using Nashel.Modules.Order.Infrastructure; +using Nashel.Modules.Order.Presentation; var builder = WebApplication.CreateBuilder(args); @@ -58,6 +60,9 @@ builder.Services.AddCatalogModule(builder.Configuration); // Регистрация модуля Geo builder.Services.AddGeoModule(builder.Configuration); +// Регистрация модуля Order +builder.Services.AddOrderModule(builder.Configuration); + // Настройка аутентификации builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => @@ -96,5 +101,6 @@ app.UseAuthorization(); app.MapIdentityEndpoints(); app.MapCatalogEndpoints(); app.MapGeoEndpoints(); +app.MapOrderEndpoints(); app.Run(); diff --git a/src/Modules/Geo/Tests/UnitTest1.cs b/src/Modules/Geo/Tests/UnitTest1.cs deleted file mode 100644 index cae23f6..0000000 --- a/src/Modules/Geo/Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Nashel.Modules.Geo.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/src/Modules/Identity/Tests/UnitTest1.cs b/src/Modules/Identity/Tests/UnitTest1.cs deleted file mode 100644 index 8d72746..0000000 --- a/src/Modules/Identity/Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Nashel.Modules.Identity.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/src/Modules/Order/Application/Commands/AddApplicationCommand.cs b/src/Modules/Order/Application/Commands/AddApplicationCommand.cs new file mode 100644 index 0000000..0ccead7 --- /dev/null +++ b/src/Modules/Order/Application/Commands/AddApplicationCommand.cs @@ -0,0 +1,53 @@ +using MediatR; +using Nashel.Modules.Order.Domain.Repositories; +using Nashel.Modules.Order.Domain.ValueObjects; + +namespace Nashel.Modules.Order.Application.Commands; + +/// +/// Команда добавления отклика на заказ. +/// +public record AddApplicationCommand : IRequest +{ + public Guid OrderId { get; init; } + public Guid PerformerId { get; init; } + public decimal Amount { get; init; } + public string Currency { get; init; } = "RUB"; + public string Comment { get; init; } = string.Empty; + + public AddApplicationCommand(Guid orderId, Guid performerId, decimal amount, string currency, string comment) + { + OrderId = orderId; + PerformerId = performerId; + Amount = amount; + Currency = currency; + Comment = comment; + } + public AddApplicationCommand() { } +} + +public class AddApplicationHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + + public AddApplicationHandler(IOrderRepository repository) + { + _repository = repository; + } + + public async Task Handle(AddApplicationCommand request, CancellationToken cancellationToken) + { + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) + { + throw new KeyNotFoundException($"Order {request.OrderId} not found"); + } + + var price = new Money(request.Amount, request.Currency); + order.AddApplication(request.PerformerId, price, request.Comment); + + 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 new file mode 100644 index 0000000..9ed2471 --- /dev/null +++ b/src/Modules/Order/Application/Commands/CancelOrderCommand.cs @@ -0,0 +1,37 @@ +using MediatR; +using Nashel.Modules.Order.Domain.Repositories; + +namespace Nashel.Modules.Order.Application.Commands; + +/// +/// Команда отмены заказа. +/// +public record CancelOrderCommand(Guid OrderId) : IRequest +{ + public CancelOrderCommand() : this(Guid.Empty) { } +} + +public class CancelOrderHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + + public CancelOrderHandler(IOrderRepository repository) + { + _repository = repository; + } + + public async Task Handle(CancelOrderCommand request, CancellationToken cancellationToken) + { + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) + { + throw new KeyNotFoundException($"Order {request.OrderId} not found"); + } + + order.Cancel(); + + 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 new file mode 100644 index 0000000..d569a09 --- /dev/null +++ b/src/Modules/Order/Application/Commands/CreateOrderCommand.cs @@ -0,0 +1,70 @@ +using MediatR; +using Nashel.Modules.Order.Domain.Enums; +using Nashel.Modules.Order.Domain.Repositories; +using Nashel.Modules.Order.Domain.ValueObjects; +using NetTopologySuite.Geometries; + +namespace Nashel.Modules.Order.Application.Commands; + +/// +/// Команда на создание заказа. +/// +public record CreateOrderCommand( + Guid CustomerId, + Guid ServiceId, + OrderType Type, + string Address, + double Latitude, + double Longitude, + DateTime? Deadline, + Guid? PerformerId +) : IRequest; + +public class CreateOrderHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + + public CreateOrderHandler(IOrderRepository repository) + { + _repository = repository; + } + + 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) + { + if (request.PerformerId == null) + { + throw new ArgumentException("PerformerId обязателен для прямого заказа."); + } + + order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect( + request.CustomerId, + request.ServiceId, + request.PerformerId.Value, + location, + request.Deadline + ); + } + else + { + order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic( + request.CustomerId, + request.ServiceId, + location, + request.Deadline + ); + } + + await _repository.AddAsync(order, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + + return order.Id; + } +} diff --git a/src/Modules/Order/Application/Commands/SelectPerformerCommand.cs b/src/Modules/Order/Application/Commands/SelectPerformerCommand.cs new file mode 100644 index 0000000..fdd098a --- /dev/null +++ b/src/Modules/Order/Application/Commands/SelectPerformerCommand.cs @@ -0,0 +1,60 @@ +using MediatR; +using Nashel.Modules.Order.Application.Services; +using Nashel.Modules.Order.Domain.Repositories; + +namespace Nashel.Modules.Order.Application.Commands; + +/// +/// Команда выбора исполнителя. +/// +public record SelectPerformerCommand : IRequest +{ + /// + /// Идентификатор заказа. + /// + public Guid OrderId { get; init; } + + /// + /// Идентификатор исполнителя. + /// + public Guid PerformerId { get; init; } + + public SelectPerformerCommand(Guid orderId, Guid performerId) + { + OrderId = orderId; + PerformerId = performerId; + } + + public SelectPerformerCommand() { } +} + + +public class SelectPerformerHandler : IRequestHandler +{ + private readonly IOrderRepository _repository; + private readonly ISlaTimerService _slaTimerService; + + public SelectPerformerHandler(IOrderRepository repository, ISlaTimerService slaTimerService) + { + _repository = repository; + _slaTimerService = slaTimerService; + } + + public async Task Handle(SelectPerformerCommand request, CancellationToken cancellationToken) + { + var order = await _repository.GetByIdAsync(request.OrderId, cancellationToken); + if (order == null) + { + throw new KeyNotFoundException($"Order {request.OrderId} not found"); + } + + order.SelectPerformer(request.PerformerId); + + // Запускаем SLA таймер на 60 минут + await _slaTimerService.ScheduleAutoCancel(order.Id, TimeSpan.FromMinutes(60)); + + await _repository.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Modules/Order/Application/Services/ISlaTimerService.cs b/src/Modules/Order/Application/Services/ISlaTimerService.cs new file mode 100644 index 0000000..3133740 --- /dev/null +++ b/src/Modules/Order/Application/Services/ISlaTimerService.cs @@ -0,0 +1,12 @@ +namespace Nashel.Modules.Order.Application.Services; + +/// +/// Сервис управления SLA-таймерами. +/// +public interface ISlaTimerService +{ + /// + /// Запланировать автоматическую отмену заказа через заданное время. + /// + Task ScheduleAutoCancel(Guid orderId, TimeSpan delay); +} diff --git a/src/Modules/Order/Domain/Aggregates/Order.cs b/src/Modules/Order/Domain/Aggregates/Order.cs new file mode 100644 index 0000000..f8692b9 --- /dev/null +++ b/src/Modules/Order/Domain/Aggregates/Order.cs @@ -0,0 +1,101 @@ +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Order.Domain.Entities; +using Nashel.Modules.Order.Domain.Enums; +using Nashel.Modules.Order.Domain.ValueObjects; + +namespace Nashel.Modules.Order.Domain.Aggregates; + +/// +/// Агрегат Заказ. +/// +public class Order : AggregateRoot +{ + public Guid CustomerId { get; private set; } + public Guid ServiceId { get; private set; } + public OrderType Type { get; private set; } + public OrderStatus Status { get; private set; } + public Guid? SelectedPerformerId { get; private set; } + public DateTime? Deadline { get; private set; } + public OrderLocation Location { get; private set; } = null!; + + // Optimistic Concurrency + public uint RowVersion { get; private set; } + + private readonly List _applications = new(); + public IReadOnlyCollection Applications => _applications.AsReadOnly(); + + private Order() { } + + /// + /// Создание прямого заказа. + /// + public static Order CreateDirect(Guid customerId, Guid serviceId, Guid performerId, OrderLocation location, DateTime? deadline) + { + return new Order + { + Id = Guid.NewGuid(), + CustomerId = customerId, + ServiceId = serviceId, + Type = OrderType.Direct, + Status = OrderStatus.PendingAcceptance, // Сразу ждет подтверждения + SelectedPerformerId = performerId, + Location = location, + Deadline = deadline + }; + // Для прямого заказа отклик можно создать косвенно или считать цену договорной. + } + + /// + /// Создание публичной заявки. + /// + public static Order CreatePublic(Guid customerId, Guid serviceId, OrderLocation location, DateTime? deadline) + { + return new Order + { + Id = Guid.NewGuid(), + CustomerId = customerId, + ServiceId = serviceId, + Type = OrderType.PublicJob, + Status = OrderStatus.Published, // Публикуется для поиска + Location = location, + Deadline = deadline + }; + } + + public void AddApplication(Guid performerId, Money price, string comment) + { + if (Status != OrderStatus.Published) + { + throw new InvalidOperationException("Нельзя оставить отклик на заказ, который не опубликован."); + } + + if (_applications.Any(a => a.PerformerId == performerId)) + { + throw new InvalidOperationException("Исполнитель уже оставил отклик на этот заказ."); + } + + _applications.Add(new OrderApplication(performerId, price, comment)); + } + + public void SelectPerformer(Guid performerId) + { + if (Status != OrderStatus.Published) + { + throw new InvalidOperationException("Выбрать исполнителя можно только для опубликованного заказа."); + } + + var app = _applications.FirstOrDefault(a => a.PerformerId == performerId); + if (app == null) + { + throw new InvalidOperationException("Выбранный исполнитель не оставлял отклик."); + } + + SelectedPerformerId = performerId; + Status = OrderStatus.PendingAcceptance; + } + + public void Cancel() + { + Status = OrderStatus.Cancelled; + } +} diff --git a/src/Modules/Order/Domain/Entities/OrderApplication.cs b/src/Modules/Order/Domain/Entities/OrderApplication.cs new file mode 100644 index 0000000..d6c016a --- /dev/null +++ b/src/Modules/Order/Domain/Entities/OrderApplication.cs @@ -0,0 +1,26 @@ +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Order.Domain.ValueObjects; + +namespace Nashel.Modules.Order.Domain.Entities; + +/// +/// Отклик на заказ. +/// +public class OrderApplication : Entity +{ + public Guid PerformerId { get; private set; } + public Money Price { get; private set; } = null!; + public string Comment { get; private set; } = string.Empty; + public DateTime CreatedAt { get; private set; } + + private OrderApplication() { } + + public OrderApplication(Guid performerId, Money price, string comment) + { + Id = Guid.NewGuid(); + PerformerId = performerId; + Price = price; + Comment = comment; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/src/Modules/Order/Domain/Enums/OrderStatus.cs b/src/Modules/Order/Domain/Enums/OrderStatus.cs new file mode 100644 index 0000000..c933ad2 --- /dev/null +++ b/src/Modules/Order/Domain/Enums/OrderStatus.cs @@ -0,0 +1,32 @@ +namespace Nashel.Modules.Order.Domain.Enums; + +/// +/// Статус заказа. +/// +public enum OrderStatus +{ + /// + /// Создан. + /// + Created, + /// + /// Опубликован (поиск исполнителей). + /// + Published, + /// + /// Ожидает подтверждения (выбран исполнитель). + /// + PendingAcceptance, + /// + /// В работе. + /// + InProgress, + /// + /// Завершен. + /// + Completed, + /// + /// Отменен. + /// + Cancelled +} diff --git a/src/Modules/Order/Domain/Enums/OrderType.cs b/src/Modules/Order/Domain/Enums/OrderType.cs new file mode 100644 index 0000000..26d8f31 --- /dev/null +++ b/src/Modules/Order/Domain/Enums/OrderType.cs @@ -0,0 +1,16 @@ +namespace Nashel.Modules.Order.Domain.Enums; + +/// +/// Тип заказа. +/// +public enum OrderType +{ + /// + /// Прямой заказ. + /// + Direct, + /// + /// Публичная заявка. + /// + PublicJob +} diff --git a/src/Modules/Order/Domain/Nashel.Modules.Order.Domain.csproj b/src/Modules/Order/Domain/Nashel.Modules.Order.Domain.csproj index 03cb9ea..795cb60 100644 --- a/src/Modules/Order/Domain/Nashel.Modules.Order.Domain.csproj +++ b/src/Modules/Order/Domain/Nashel.Modules.Order.Domain.csproj @@ -4,6 +4,10 @@ + + + + Nashel.Modules.Order.Domain net10.0 diff --git a/src/Modules/Order/Domain/Repositories/IOrderRepository.cs b/src/Modules/Order/Domain/Repositories/IOrderRepository.cs new file mode 100644 index 0000000..626ad93 --- /dev/null +++ b/src/Modules/Order/Domain/Repositories/IOrderRepository.cs @@ -0,0 +1,10 @@ +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 AddAsync(OrderAggregate order, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Order/Domain/ValueObjects/Money.cs b/src/Modules/Order/Domain/ValueObjects/Money.cs new file mode 100644 index 0000000..76c0473 --- /dev/null +++ b/src/Modules/Order/Domain/ValueObjects/Money.cs @@ -0,0 +1,9 @@ +namespace Nashel.Modules.Order.Domain.ValueObjects; + +/// +/// Денежная сумма. +/// +public record Money(decimal Amount, string Currency) +{ + public static Money Zero(string currency = "RUB") => new(0m, currency); +} diff --git a/src/Modules/Order/Domain/ValueObjects/OrderLocation.cs b/src/Modules/Order/Domain/ValueObjects/OrderLocation.cs new file mode 100644 index 0000000..3243291 --- /dev/null +++ b/src/Modules/Order/Domain/ValueObjects/OrderLocation.cs @@ -0,0 +1,8 @@ +using NetTopologySuite.Geometries; + +namespace Nashel.Modules.Order.Domain.ValueObjects; + +/// +/// Локация заказа. +/// +public record OrderLocation(string Address, Point Point); diff --git a/src/Modules/Order/Infrastructure/DependencyInjection.cs b/src/Modules/Order/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..b0d90be --- /dev/null +++ b/src/Modules/Order/Infrastructure/DependencyInjection.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Nashel.Modules.Order.Application.Commands; +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; + +public static class DependencyInjection +{ + public static IServiceCollection AddOrderModule(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("DefaultConnection"); + + services.AddDbContext(options => + options.UseNpgsql( + connectionString, + o => o.UseNetTopologySuite() + )); + + services.AddScoped(); + services.AddScoped(); + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly); + }); + + return services; + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.Designer.cs new file mode 100644 index 0000000..6cfe7ea --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.Designer.cs @@ -0,0 +1,167 @@ +// +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("20260210192734_InitialCreate")] + partial class InitialCreate + { + /// + 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("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.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/20260210192734_InitialCreate.cs b/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.cs new file mode 100644 index 0000000..3e5c7bb --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260210192734_InitialCreate.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NetTopologySuite.Geometries; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "ordering"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:postgis", ",,"); + + migrationBuilder.CreateTable( + name: "Orders", + schema: "ordering", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CustomerId = table.Column(type: "uuid", nullable: false), + ServiceId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false), + SelectedPerformerId = table.Column(type: "uuid", nullable: true), + Deadline = table.Column(type: "timestamp with time zone", nullable: true), + Address = table.Column(type: "text", nullable: false), + Location_Point = table.Column(type: "geography (point)", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OrderApplications", + schema: "ordering", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PerformerId = table.Column(type: "uuid", nullable: false), + PriceAmount = table.Column(type: "numeric", nullable: false), + PriceCurrency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + Comment = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + OrderId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OrderApplications", x => x.Id); + table.ForeignKey( + name: "FK_OrderApplications_Orders_OrderId", + column: x => x.OrderId, + principalSchema: "ordering", + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OrderApplications_OrderId", + schema: "ordering", + table: "OrderApplications", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_Orders_Location_Point", + schema: "ordering", + table: "Orders", + column: "Location_Point") + .Annotation("Npgsql:IndexMethod", "gist"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OrderApplications", + schema: "ordering"); + + migrationBuilder.DropTable( + name: "Orders", + schema: "ordering"); + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs b/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs new file mode 100644 index 0000000..f5cc941 --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/OrderDbContextModelSnapshot.cs @@ -0,0 +1,164 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +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))] + partial class OrderDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("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.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/Nashel.Modules.Order.Infrastructure.csproj b/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj index e026d86..b266df7 100644 --- a/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj +++ b/src/Modules/Order/Infrastructure/Nashel.Modules.Order.Infrastructure.csproj @@ -1,16 +1,23 @@ + + + net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + - - - - - Nashel.Modules.Order.Infrastructure - net10.0 - enable - enable - + \ No newline at end of file diff --git a/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs b/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs new file mode 100644 index 0000000..b9118b3 --- /dev/null +++ b/src/Modules/Order/Infrastructure/Persistence/Configurations/OrderConfiguration.cs @@ -0,0 +1,54 @@ +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; + +public class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Orders", "ordering"); + + builder.HasKey(o => o.Id); + + // Optimistic Concurrency (xmin mapping for uint) + builder.Property(o => o.RowVersion) + .IsRowVersion(); + + builder.Property(o => o.Type) + .HasConversion(); + + builder.Property(o => o.Status) + .HasConversion(); + + builder.OwnsOne(o => o.Location, nav => + { + nav.Property(l => l.Address).HasColumnName("Address"); + nav.Property(l => l.Point).HasColumnType("geography (point)"); + nav.HasIndex(l => l.Point).HasMethod("gist"); + }); + + builder.HasMany(o => o.Applications) + .WithOne() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + } +} + +public class OrderApplicationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("OrderApplications", "ordering"); + + builder.HasKey(x => x.Id); + + builder.OwnsOne(x => x.Price, money => + { + money.Property(m => m.Amount).HasColumnName("PriceAmount"); + money.Property(m => m.Currency).HasColumnName("PriceCurrency").HasMaxLength(3); + }); + } +} diff --git a/src/Modules/Order/Infrastructure/Persistence/OrderDbContext.cs b/src/Modules/Order/Infrastructure/Persistence/OrderDbContext.cs new file mode 100644 index 0000000..9c446c5 --- /dev/null +++ b/src/Modules/Order/Infrastructure/Persistence/OrderDbContext.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Order.Domain.Aggregates; +using Nashel.Modules.Order.Domain.Entities; +using Nashel.Modules.Order.Infrastructure.Persistence.Configurations; + +namespace Nashel.Modules.Order.Infrastructure.Persistence; + +public class OrderDbContext : DbContext +{ + public OrderDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Orders { get; set; } = null!; + public DbSet OrderApplications { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("ordering"); + modelBuilder.HasPostgresExtension("postgis"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderConfiguration).Assembly); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs b/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs new file mode 100644 index 0000000..a825ed6 --- /dev/null +++ b/src/Modules/Order/Infrastructure/Persistence/Repositories/OrderRepository.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Order.Domain.Repositories; +using Nashel.Modules.Order.Infrastructure.Persistence; +using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order; + +namespace Nashel.Modules.Order.Infrastructure.Persistence.Repositories; + +public class OrderRepository : IOrderRepository +{ + private readonly OrderDbContext _context; + + public OrderRepository(OrderDbContext context) + { + _context = context; + } + + public async Task AddAsync(OrderAggregate order, CancellationToken cancellationToken = default) + { + await _context.Orders.AddAsync(order, cancellationToken); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Orders + .Include(o => o.Applications) + .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Modules/Order/Infrastructure/Services/DummySlaTimerService.cs b/src/Modules/Order/Infrastructure/Services/DummySlaTimerService.cs new file mode 100644 index 0000000..091d92d --- /dev/null +++ b/src/Modules/Order/Infrastructure/Services/DummySlaTimerService.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Logging; +using Nashel.Modules.Order.Application.Services; + +namespace Nashel.Modules.Order.Infrastructure.Services; + +public class DummySlaTimerService : ISlaTimerService +{ + private readonly ILogger _logger; + + public DummySlaTimerService(ILogger logger) + { + _logger = logger; + } + + public Task ScheduleAutoCancel(Guid orderId, TimeSpan delay) + { + _logger.LogInformation("SLA Timer scheduled for Order {OrderId} in {Delay}.", orderId, delay); + return Task.CompletedTask; + } +} diff --git a/src/Modules/Order/Presentation/Nashel.Modules.Order.Presentation.csproj b/src/Modules/Order/Presentation/Nashel.Modules.Order.Presentation.csproj index db8419a..164294b 100644 --- a/src/Modules/Order/Presentation/Nashel.Modules.Order.Presentation.csproj +++ b/src/Modules/Order/Presentation/Nashel.Modules.Order.Presentation.csproj @@ -1,11 +1,24 @@ - - - + - Nashel.Modules.Order.Presentation net10.0 enable enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Modules/Order/Presentation/OrderEndpoints.cs b/src/Modules/Order/Presentation/OrderEndpoints.cs new file mode 100644 index 0000000..76b5a86 --- /dev/null +++ b/src/Modules/Order/Presentation/OrderEndpoints.cs @@ -0,0 +1,87 @@ +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Nashel.Modules.Order.Application.Commands; +using Nashel.Modules.Order.Domain.Enums; + +namespace Nashel.Modules.Order.Presentation; + +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) => + { + var command = new CreateOrderCommand( + request.CustomerId, + request.ServiceId, + request.Type, + request.Address, + request.Latitude, + request.Longitude, + request.Deadline, + request.PerformerId + ); + var orderId = await sender.Send(command); + return Results.Ok(orderId); + }) + .WithName("CreateOrder") + .WithOpenApi(operation => new(operation) { 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 + ); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("AddOrderApplication") + .WithOpenApi(operation => new(operation) { Summary = "Добавление отклика на заказ." }); + + group.MapPost("/{id:guid}/select-performer", async (Guid id, SelectPerformerRequest request, ISender sender) => + { + var command = new SelectPerformerCommand(id, request.PerformerId); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("SelectPerformer") + .WithOpenApi(operation => new(operation) { Summary = "Выбор исполнителя для заказа." }); + } +} + +/// +/// Запрос на создание заказа. +/// +public record CreateOrderRequest( + Guid CustomerId, + Guid ServiceId, + OrderType Type, + string Address, + double Latitude, + double Longitude, + DateTime? Deadline, + Guid? PerformerId +); + +/// +/// Запрос на добавление отклика. +/// +public record AddApplicationRequest( + Guid PerformerId, + decimal Amount, + string Currency, + string Comment +); + +/// +/// Запрос на выбор исполнителя. +/// +public record SelectPerformerRequest(Guid PerformerId); diff --git a/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs b/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs new file mode 100644 index 0000000..7d2aee5 --- /dev/null +++ b/src/Modules/Order/Tests/Application/SelectPerformerHandlerTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using MediatR; +using NSubstitute; +using Nashel.Modules.Order.Application.Commands; +using Nashel.Modules.Order.Application.Services; +using Nashel.Modules.Order.Domain.Enums; +using Nashel.Modules.Order.Domain.Repositories; +using Nashel.Modules.Order.Domain.ValueObjects; +using NetTopologySuite.Geometries; +using Xunit; + +namespace Nashel.Modules.Order.Tests.Application; + +public class SelectPerformerHandlerTests +{ + private readonly IOrderRepository _repository; + private readonly ISlaTimerService _slaTimerService; + private readonly SelectPerformerHandler _handler; + + public SelectPerformerHandlerTests() + { + _repository = Substitute.For(); + _slaTimerService = Substitute.For(); + _handler = new SelectPerformerHandler(_repository, _slaTimerService); + } + + [Fact] + public async Task Handle_Should_Call_SlaTimer() + { + // Arrange + var customerId = Guid.NewGuid(); + var serviceId = Guid.NewGuid(); + var performerId = Guid.NewGuid(); + var location = new OrderLocation("Test St", new Point(10, 20)); + + // Создаем публичный заказ (Status = Published) + var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreatePublic( + customerId, serviceId, location, null); + + // Добавляем отклик исполнителя + order.AddApplication(performerId, new Money(100, "RUB"), "Comment"); + + // Mock Repository + _repository.GetByIdAsync(order.Id, Arg.Any()).Returns(order); + + var command = new SelectPerformerCommand(order.Id, performerId); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + // Проверяем изменение статуса + order.Status.Should().Be(OrderStatus.PendingAcceptance); + + // Проверяем вызов SLA таймера (60 минут) + await _slaTimerService.Received(1).ScheduleAutoCancel(order.Id, Arg.Is(t => t.TotalMinutes == 60)); + + // Проверяем сохранение в БД + await _repository.Received(1).SaveChangesAsync(Arg.Any()); + } +} diff --git a/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs b/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs new file mode 100644 index 0000000..fba595b --- /dev/null +++ b/src/Modules/Order/Tests/Domain/OrderAggregateTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using Nashel.Modules.Order.Domain.Enums; +using Nashel.Modules.Order.Domain.ValueObjects; +using NetTopologySuite.Geometries; +using Xunit; + +namespace Nashel.Modules.Order.Tests.Domain; + +public class OrderAggregateTests +{ + [Fact] + public void CreateDirect_Should_SetStatus_PendingAcceptance() + { + // Arrange + var customerId = Guid.NewGuid(); + var serviceId = Guid.NewGuid(); + var performerId = Guid.NewGuid(); + var location = new OrderLocation("Test St", new Point(10, 20)); + + // Act + var order = Nashel.Modules.Order.Domain.Aggregates.Order.CreateDirect( + customerId, serviceId, performerId, location, DateTime.UtcNow.AddDays(1)); + + // Assert + order.Status.Should().Be(OrderStatus.PendingAcceptance); + order.SelectedPerformerId.Should().Be(performerId); + order.Type.Should().Be(OrderType.Direct); + } + + [Fact] + public void AddApplication_Should_Throw_If_OrderNotPublished() + { + // Arrange + var customerId = Guid.NewGuid(); + var serviceId = Guid.NewGuid(); + var performerId = Guid.NewGuid(); + 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); + + // Act + var action = () => order.AddApplication(Guid.NewGuid(), new Money(100, "RUB"), "Comment"); + + // Assert + action.Should().Throw() + .WithMessage("Нельзя оставить отклик на заказ, который не опубликован.*"); // Regex match + } + + [Fact] + public void SelectPerformer_Should_ChangeStatus_To_Pending() + { + // Arrange + var customerId = Guid.NewGuid(); + 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); + + var performerId = Guid.NewGuid(); + order.AddApplication(performerId, new Money(100, "RUB"), "Comment"); + + // Act + order.SelectPerformer(performerId); + + // Assert + order.Status.Should().Be(OrderStatus.PendingAcceptance); + order.SelectedPerformerId.Should().Be(performerId); + } +} diff --git a/src/Modules/Order/Tests/Nashel.Modules.Order.Tests.csproj b/src/Modules/Order/Tests/Nashel.Modules.Order.Tests.csproj new file mode 100644 index 0000000..c45aea3 --- /dev/null +++ b/src/Modules/Order/Tests/Nashel.Modules.Order.Tests.csproj @@ -0,0 +1,40 @@ + + + + net10.0 + enable + enable + false + + + + + 8.8.0 + + + 18.0.1 + + + 2.6.0 + + + 5.3.0 + + + 2.9.3 + + + 3.1.5 + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + +