Модуль заказов

This commit is contained in:
Халимов Рустам
2026-02-10 22:32:28 +03:00
parent 12c86f9937
commit 3b2c0029a1
33 changed files with 1370 additions and 33 deletions
+4
View File
@@ -146,3 +146,7 @@ src/Modules/Geo/Tests/bin/
src/Modules/Geo/Tests/obj/ src/Modules/Geo/Tests/obj/
src/Modules/Geo/Presentation/bin/ src/Modules/Geo/Presentation/bin/
src/Modules/Order/Tests/bin/
src/Modules/Order/Tests/obj/
+38
View File
@@ -77,6 +77,24 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05
EndProject 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}" 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 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{401E3B50-A6B8-4AA1-9DD3-41F9EC78FABD}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -449,5 +479,13 @@ Global
{3266C089-BF45-8FB5-CBFB-520E7D591FF0} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17} {3266C089-BF45-8FB5-CBFB-520E7D591FF0} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}
{6153DBFE-E924-7D0F-FF5C-84EC91E8B397} = {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} {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 EndGlobalSection
EndGlobal EndGlobal
+1
View File
@@ -15,6 +15,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.OpenApi" Version="1.6.14" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+6
View File
@@ -8,6 +8,8 @@ using Nashel.Modules.Geo.Infrastructure;
using Nashel.Modules.Geo.Presentation; using Nashel.Modules.Geo.Presentation;
using Nashel.Modules.Identity.Infrastructure; using Nashel.Modules.Identity.Infrastructure;
using Nashel.Modules.Identity.Presentation.Endpoints; using Nashel.Modules.Identity.Presentation.Endpoints;
using Nashel.Modules.Order.Infrastructure;
using Nashel.Modules.Order.Presentation;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -58,6 +60,9 @@ builder.Services.AddCatalogModule(builder.Configuration);
// Регистрация модуля Geo // Регистрация модуля Geo
builder.Services.AddGeoModule(builder.Configuration); builder.Services.AddGeoModule(builder.Configuration);
// Регистрация модуля Order
builder.Services.AddOrderModule(builder.Configuration);
// Настройка аутентификации // Настройка аутентификации
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
@@ -96,5 +101,6 @@ app.UseAuthorization();
app.MapIdentityEndpoints(); app.MapIdentityEndpoints();
app.MapCatalogEndpoints(); app.MapCatalogEndpoints();
app.MapGeoEndpoints(); app.MapGeoEndpoints();
app.MapOrderEndpoints();
app.Run(); app.Run();
-10
View File
@@ -1,10 +0,0 @@
namespace Nashel.Modules.Geo.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace Nashel.Modules.Identity.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
@@ -0,0 +1,53 @@
using MediatR;
using Nashel.Modules.Order.Domain.Repositories;
using Nashel.Modules.Order.Domain.ValueObjects;
namespace Nashel.Modules.Order.Application.Commands;
/// <summary>
/// Команда добавления отклика на заказ.
/// </summary>
public record AddApplicationCommand : IRequest<Unit>
{
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<AddApplicationCommand, Unit>
{
private readonly IOrderRepository _repository;
public AddApplicationHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> 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;
}
}
@@ -0,0 +1,37 @@
using MediatR;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
/// <summary>
/// Команда отмены заказа.
/// </summary>
public record CancelOrderCommand(Guid OrderId) : IRequest<Unit>
{
public CancelOrderCommand() : this(Guid.Empty) { }
}
public class CancelOrderHandler : IRequestHandler<CancelOrderCommand, Unit>
{
private readonly IOrderRepository _repository;
public CancelOrderHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Unit> 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;
}
}
@@ -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;
/// <summary>
/// Команда на создание заказа.
/// </summary>
public record CreateOrderCommand(
Guid CustomerId,
Guid ServiceId,
OrderType Type,
string Address,
double Latitude,
double Longitude,
DateTime? Deadline,
Guid? PerformerId
) : IRequest<Guid>;
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly IOrderRepository _repository;
public CreateOrderHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
// Создаем точку (WGS84)
var point = new Point(request.Longitude, request.Latitude) { SRID = 4326 };
var location = new OrderLocation(request.Address, point);
Nashel.Modules.Order.Domain.Aggregates.Order order;
if (request.Type == OrderType.Direct)
{
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;
}
}
@@ -0,0 +1,60 @@
using MediatR;
using Nashel.Modules.Order.Application.Services;
using Nashel.Modules.Order.Domain.Repositories;
namespace Nashel.Modules.Order.Application.Commands;
/// <summary>
/// Команда выбора исполнителя.
/// </summary>
public record SelectPerformerCommand : IRequest<Unit>
{
/// <summary>
/// Идентификатор заказа.
/// </summary>
public Guid OrderId { get; init; }
/// <summary>
/// Идентификатор исполнителя.
/// </summary>
public Guid PerformerId { get; init; }
public SelectPerformerCommand(Guid orderId, Guid performerId)
{
OrderId = orderId;
PerformerId = performerId;
}
public SelectPerformerCommand() { }
}
public class SelectPerformerHandler : IRequestHandler<SelectPerformerCommand, Unit>
{
private readonly IOrderRepository _repository;
private readonly ISlaTimerService _slaTimerService;
public SelectPerformerHandler(IOrderRepository repository, ISlaTimerService slaTimerService)
{
_repository = repository;
_slaTimerService = slaTimerService;
}
public async Task<Unit> 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;
}
}
@@ -0,0 +1,12 @@
namespace Nashel.Modules.Order.Application.Services;
/// <summary>
/// Сервис управления SLA-таймерами.
/// </summary>
public interface ISlaTimerService
{
/// <summary>
/// Запланировать автоматическую отмену заказа через заданное время.
/// </summary>
Task ScheduleAutoCancel(Guid orderId, TimeSpan delay);
}
@@ -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;
/// <summary>
/// Агрегат Заказ.
/// </summary>
public class Order : AggregateRoot<Guid>
{
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<OrderApplication> _applications = new();
public IReadOnlyCollection<OrderApplication> Applications => _applications.AsReadOnly();
private Order() { }
/// <summary>
/// Создание прямого заказа.
/// </summary>
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
};
// Для прямого заказа отклик можно создать косвенно или считать цену договорной.
}
/// <summary>
/// Создание публичной заявки.
/// </summary>
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;
}
}
@@ -0,0 +1,26 @@
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Order.Domain.ValueObjects;
namespace Nashel.Modules.Order.Domain.Entities;
/// <summary>
/// Отклик на заказ.
/// </summary>
public class OrderApplication : Entity<Guid>
{
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;
}
}
@@ -0,0 +1,32 @@
namespace Nashel.Modules.Order.Domain.Enums;
/// <summary>
/// Статус заказа.
/// </summary>
public enum OrderStatus
{
/// <summary>
/// Создан.
/// </summary>
Created,
/// <summary>
/// Опубликован (поиск исполнителей).
/// </summary>
Published,
/// <summary>
/// Ожидает подтверждения (выбран исполнитель).
/// </summary>
PendingAcceptance,
/// <summary>
/// В работе.
/// </summary>
InProgress,
/// <summary>
/// Завершен.
/// </summary>
Completed,
/// <summary>
/// Отменен.
/// </summary>
Cancelled
}
@@ -0,0 +1,16 @@
namespace Nashel.Modules.Order.Domain.Enums;
/// <summary>
/// Тип заказа.
/// </summary>
public enum OrderType
{
/// <summary>
/// Прямой заказ.
/// </summary>
Direct,
/// <summary>
/// Публичная заявка.
/// </summary>
PublicJob
}
@@ -4,6 +4,10 @@
<ProjectReference Include="..\..\..\BuildingBlocks\Nashel.BuildingBlocks.csproj" /> <ProjectReference Include="..\..\..\BuildingBlocks\Nashel.BuildingBlocks.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<RootNamespace>Nashel.Modules.Order.Domain</RootNamespace> <RootNamespace>Nashel.Modules.Order.Domain</RootNamespace>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@@ -0,0 +1,10 @@
using OrderAggregate = Nashel.Modules.Order.Domain.Aggregates.Order;
namespace Nashel.Modules.Order.Domain.Repositories;
public interface IOrderRepository
{
Task<OrderAggregate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task AddAsync(OrderAggregate order, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,9 @@
namespace Nashel.Modules.Order.Domain.ValueObjects;
/// <summary>
/// Денежная сумма.
/// </summary>
public record Money(decimal Amount, string Currency)
{
public static Money Zero(string currency = "RUB") => new(0m, currency);
}
@@ -0,0 +1,8 @@
using NetTopologySuite.Geometries;
namespace Nashel.Modules.Order.Domain.ValueObjects;
/// <summary>
/// Локация заказа.
/// </summary>
public record OrderLocation(string Address, Point Point);
@@ -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<OrderDbContext>(options =>
options.UseNpgsql(
connectionString,
o => o.UseNetTopologySuite()
));
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<ISlaTimerService, DummySlaTimerService>();
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly);
});
return services;
}
}
@@ -0,0 +1,167 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nashel.Modules.Order.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
[DbContext(typeof(OrderDbContext))]
[Migration("20260210192734_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("ordering")
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NetTopologySuite.Geometries;
#nullable disable
namespace Nashel.Modules.Order.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
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<Guid>(type: "uuid", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
ServiceId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
SelectedPerformerId = table.Column<Guid>(type: "uuid", nullable: true),
Deadline = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Address = table.Column<string>(type: "text", nullable: false),
Location_Point = table.Column<Point>(type: "geography (point)", nullable: false),
xmin = table.Column<uint>(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<Guid>(type: "uuid", nullable: false),
PerformerId = table.Column<Guid>(type: "uuid", nullable: false),
PriceAmount = table.Column<decimal>(type: "numeric", nullable: false),
PriceCurrency = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false),
Comment = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
OrderId = table.Column<Guid>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OrderApplications",
schema: "ordering");
migrationBuilder.DropTable(
name: "Orders",
schema: "ordering");
}
}
}
@@ -0,0 +1,164 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<DateTime?>("Deadline")
.HasColumnType("timestamp with time zone");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.Property<Guid?>("SelectedPerformerId")
.HasColumnType("uuid");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Orders", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrderId")
.HasColumnType("uuid");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrderId");
b.ToTable("OrderApplications", "ordering");
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 =>
{
b1.Property<Guid>("OrderId")
.HasColumnType("uuid");
b1.Property<string>("Address")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Address");
b1.Property<Point>("Point")
.IsRequired()
.HasColumnType("geography (point)");
b1.HasKey("OrderId");
b1.HasIndex("Point");
NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist");
b1.ToTable("Orders", "ordering");
b1.WithOwner()
.HasForeignKey("OrderId");
});
b.Navigation("Location")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b =>
{
b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null)
.WithMany("Applications")
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);
b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 =>
{
b1.Property<Guid>("OrderApplicationId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.HasKey("OrderApplicationId");
b1.ToTable("OrderApplications", "ordering");
b1.WithOwner()
.HasForeignKey("OrderApplicationId");
});
b.Navigation("Price")
.IsRequired();
});
modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b =>
{
b.Navigation("Applications");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,16 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NetTopologySuite.IO.PostGis" Version="2.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\BuildingBlocks\Nashel.BuildingBlocks.csproj" /> <ProjectReference Include="..\..\..\BuildingBlocks\Nashel.BuildingBlocks.csproj" />
<ProjectReference Include="..\Application\Nashel.Modules.Order.Application.csproj" /> <ProjectReference Include="..\Application\Nashel.Modules.Order.Application.csproj" />
<ProjectReference Include="..\Domain\Nashel.Modules.Order.Domain.csproj" /> <ProjectReference Include="..\Domain\Nashel.Modules.Order.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
<PropertyGroup>
<RootNamespace>Nashel.Modules.Order.Infrastructure</RootNamespace>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project> </Project>
@@ -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<Nashel.Modules.Order.Domain.Aggregates.Order>
{
public void Configure(EntityTypeBuilder<Nashel.Modules.Order.Domain.Aggregates.Order> 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<string>();
builder.Property(o => o.Status)
.HasConversion<string>();
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<OrderApplication>
{
public void Configure(EntityTypeBuilder<OrderApplication> 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);
});
}
}
@@ -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<OrderDbContext> options) : base(options)
{
}
public DbSet<Nashel.Modules.Order.Domain.Aggregates.Order> Orders { get; set; } = null!;
public DbSet<OrderApplication> OrderApplications { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("ordering");
modelBuilder.HasPostgresExtension("postgis");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderConfiguration).Assembly);
base.OnModelCreating(modelBuilder);
}
}
@@ -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<OrderAggregate?> 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);
}
}
@@ -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<DummySlaTimerService> _logger;
public DummySlaTimerService(ILogger<DummySlaTimerService> 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;
}
}
@@ -1,11 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Application\Nashel.Modules.Order.Application.csproj" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<RootNamespace>Nashel.Modules.Order.Presentation</RootNamespace>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="14.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Application\Nashel.Modules.Order.Application.csproj" />
</ItemGroup>
</Project> </Project>
@@ -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 = "Выбор исполнителя для заказа." });
}
}
/// <summary>
/// Запрос на создание заказа.
/// </summary>
public record CreateOrderRequest(
Guid CustomerId,
Guid ServiceId,
OrderType Type,
string Address,
double Latitude,
double Longitude,
DateTime? Deadline,
Guid? PerformerId
);
/// <summary>
/// Запрос на добавление отклика.
/// </summary>
public record AddApplicationRequest(
Guid PerformerId,
decimal Amount,
string Currency,
string Comment
);
/// <summary>
/// Запрос на выбор исполнителя.
/// </summary>
public record SelectPerformerRequest(Guid PerformerId);
@@ -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<IOrderRepository>();
_slaTimerService = Substitute.For<ISlaTimerService>();
_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<CancellationToken>()).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<TimeSpan>(t => t.TotalMinutes == 60));
// Проверяем сохранение в БД
await _repository.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}
}
@@ -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<InvalidOperationException>()
.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);
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions">
<Version>8.8.0</Version>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk">
<Version>18.0.1</Version>
</PackageReference>
<PackageReference Include="NetTopologySuite">
<Version>2.6.0</Version>
</PackageReference>
<PackageReference Include="NSubstitute">
<Version>5.3.0</Version>
</PackageReference>
<PackageReference Include="xunit">
<Version>2.9.3</Version>
</PackageReference>
<PackageReference Include="xunit.runner.visualstudio">
<Version>3.1.5</Version>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Nashel.Modules.Order.Domain.csproj" />
<ProjectReference Include="..\Application\Nashel.Modules.Order.Application.csproj" />
<ProjectReference Include="..\Infrastructure\Nashel.Modules.Order.Infrastructure.csproj" />
</ItemGroup>
<!-- Implicit using of FluentAssertions might be useful but explicit is safer -->
</Project>