diff --git a/.gitignore b/.gitignore
index f69adab..d6472e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -154,3 +154,7 @@ src/Modules/Order/Tests/obj/
src/Modules/Collaboration/Tests/bin/
src/Modules/Collaboration/Tests/obj/
+
+src/Modules/Reputation/Tests/obj/
+
+src/Modules/Reputation/Tests/bin/
diff --git a/src/Host/Program.cs b/src/Host/Program.cs
index d325846..56f68e0 100644
--- a/src/Host/Program.cs
+++ b/src/Host/Program.cs
@@ -12,6 +12,8 @@ using Nashel.Modules.Order.Infrastructure;
using Nashel.Modules.Order.Presentation;
using Nashel.Modules.Collaboration.Infrastructure;
using Nashel.Modules.Collaboration.Presentation;
+using Nashel.Modules.Reputation.Infrastructure;
+using Nashel.Modules.Reputation.Presentation;
var builder = WebApplication.CreateBuilder(args);
@@ -62,10 +64,15 @@ builder.Services.AddCatalogModule(builder.Configuration);
// Регистрация модуля Geo
builder.Services.AddGeoModule(builder.Configuration);
-// Регистрация модуля Order
// Регистрация модуля Collaboartion
builder.Services.AddCollaborationModule(builder.Configuration);
+// Регистрация модуля Order
+builder.Services.AddOrderModule(builder.Configuration);
+
+// Регистрация модуля Reputation
+builder.Services.AddReputationModule(builder.Configuration);
+
// Настройка аутентификации
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
@@ -106,5 +113,6 @@ app.MapCatalogEndpoints();
app.MapGeoEndpoints();
app.MapOrderEndpoints();
app.MapCollaborationEndpoints();
+app.MapReputationEndpoints();
app.Run();
diff --git a/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.Designer.cs b/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.Designer.cs
new file mode 100644
index 0000000..f58cb7b
--- /dev/null
+++ b/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.Designer.cs
@@ -0,0 +1,61 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Nashel.Modules.Geo.Infrastructure.Persistence;
+using NetTopologySuite.Geometries;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Nashel.Modules.Geo.Infrastructure.Migrations
+{
+ [DbContext(typeof(GeoDbContext))]
+ [Migration("20260212130229_GeoInitial")]
+ partial class GeoInitial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Nashel.Modules.Geo.Domain.Aggregates.LiveStatus", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BusyUntil")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastUpdated")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Location")
+ .IsRequired()
+ .HasColumnType("geography (point)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Location");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist");
+
+ b.ToTable("LiveStatuses", "geo");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.cs b/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.cs
new file mode 100644
index 0000000..a4fcfe3
--- /dev/null
+++ b/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Nashel.Modules.Geo.Infrastructure.Migrations
+{
+ ///
+ public partial class GeoInitial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+
+ }
+ }
+}
diff --git a/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.Designer.cs
new file mode 100644
index 0000000..54cc57d
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.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("20260212130220_OrderInitial")]
+ partial class OrderInitial
+ {
+ ///
+ 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/20260212130220_OrderInitial.cs b/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.cs
new file mode 100644
index 0000000..d0a1377
--- /dev/null
+++ b/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Nashel.Modules.Order.Infrastructure.Migrations
+{
+ ///
+ public partial class OrderInitial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+
+ }
+ }
+}
diff --git a/src/Modules/Reputation/Application/Commands/AddEvidenceCommand.cs b/src/Modules/Reputation/Application/Commands/AddEvidenceCommand.cs
new file mode 100644
index 0000000..3797b80
--- /dev/null
+++ b/src/Modules/Reputation/Application/Commands/AddEvidenceCommand.cs
@@ -0,0 +1,39 @@
+using MediatR;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Repositories;
+
+namespace Nashel.Modules.Reputation.Application.Commands;
+
+///
+/// Добавление улик к спору.
+///
+public record AddEvidenceCommand(Guid DisputeId, Guid UploaderId, string FileUrl) : IRequest;
+
+public class AddEvidenceHandler : IRequestHandler
+{
+ private readonly IDisputeRepository _repository;
+
+ public AddEvidenceHandler(IDisputeRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(AddEvidenceCommand request, CancellationToken cancellationToken)
+ {
+ var dispute = await _repository.GetByIdAsync(request.DisputeId, cancellationToken);
+ if (dispute == null)
+ {
+ throw new KeyNotFoundException($"Спор с ID {request.DisputeId} не найден.");
+ }
+
+ var evidence = DisputeEvidence.Create(
+ request.DisputeId,
+ request.UploaderId,
+ request.FileUrl
+ );
+
+ dispute.AddEvidence(evidence);
+
+ await _repository.UpdateAsync(dispute, cancellationToken);
+ }
+}
diff --git a/src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs b/src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs
new file mode 100644
index 0000000..58c4c1a
--- /dev/null
+++ b/src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs
@@ -0,0 +1,46 @@
+using MediatR;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Repositories;
+
+namespace Nashel.Modules.Reputation.Application.Commands;
+
+///
+/// Команда создания отзыва.
+///
+public record CreateReviewCommand(Guid OrderId, Guid AuthorId, Guid TargetId, int Rating, string Text) : IRequest;
+
+public class CreateReviewHandler : IRequestHandler
+{
+ private readonly IReviewRepository _repository;
+ private readonly IMediator _mediator;
+
+ public CreateReviewHandler(IReviewRepository repository, IMediator mediator)
+ {
+ _repository = repository;
+ _mediator = mediator;
+ }
+
+ public async Task Handle(CreateReviewCommand request, CancellationToken cancellationToken)
+ {
+ // В реальном приложении здесь должна быть проверка существования заказа и ролей.
+
+ var review = Review.Create(
+ request.OrderId,
+ request.AuthorId,
+ request.TargetId,
+ request.Rating,
+ request.Text
+ );
+
+ await _repository.AddAsync(review, cancellationToken);
+
+ // Публикация доменных событий
+ foreach (var domainEvent in review.DomainEvents)
+ {
+ await _mediator.Publish(domainEvent, cancellationToken);
+ }
+ review.ClearDomainEvents();
+
+ return review.Id;
+ }
+}
diff --git a/src/Modules/Reputation/Application/Commands/OpenDisputeCommand.cs b/src/Modules/Reputation/Application/Commands/OpenDisputeCommand.cs
new file mode 100644
index 0000000..fd9b547
--- /dev/null
+++ b/src/Modules/Reputation/Application/Commands/OpenDisputeCommand.cs
@@ -0,0 +1,40 @@
+using MediatR;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Repositories;
+
+namespace Nashel.Modules.Reputation.Application.Commands;
+
+///
+/// Команда на открытие спора (арбитраж).
+///
+public record OpenDisputeCommand(Guid OrderId, Guid InitiatorId, string Reason) : IRequest;
+
+public class OpenDisputeHandler : IRequestHandler
+{
+ private readonly IDisputeRepository _repository;
+ private readonly IPublisher _publisher;
+
+ public OpenDisputeHandler(IDisputeRepository repository, IPublisher publisher)
+ {
+ _repository = repository;
+ _publisher = publisher;
+ }
+
+ public async Task Handle(OpenDisputeCommand request, CancellationToken cancellationToken)
+ {
+ // В реальном приложении: проверка существования заказа и роли инициатора.
+
+ var dispute = Dispute.Open(
+ request.OrderId,
+ request.InitiatorId,
+ request.Reason
+ );
+
+ await _repository.AddAsync(dispute, cancellationToken);
+
+ // Публикуем интеграционное событие для блокировки выплат в модуле Order
+ await _publisher.Publish(new Events.DisputeOpenedIntegrationEvent(dispute.Id, dispute.OrderId), cancellationToken);
+
+ return dispute.Id;
+ }
+}
diff --git a/src/Modules/Reputation/Application/Commands/ResolveDisputeCommand.cs b/src/Modules/Reputation/Application/Commands/ResolveDisputeCommand.cs
new file mode 100644
index 0000000..e12a99b
--- /dev/null
+++ b/src/Modules/Reputation/Application/Commands/ResolveDisputeCommand.cs
@@ -0,0 +1,35 @@
+using MediatR;
+using Nashel.Modules.Reputation.Domain.Repositories;
+
+namespace Nashel.Modules.Reputation.Application.Commands;
+
+///
+/// Команда для разрешения спора модератором/администратором.
+///
+public record ResolveDisputeCommand(Guid DisputeId, string ResolutionDetails) : IRequest;
+
+public class ResolveDisputeHandler : IRequestHandler
+{
+ private readonly IDisputeRepository _repository;
+
+ public ResolveDisputeHandler(IDisputeRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task Handle(ResolveDisputeCommand request, CancellationToken cancellationToken)
+ {
+ var dispute = await _repository.GetByIdAsync(request.DisputeId, cancellationToken);
+ if (dispute == null)
+ {
+ throw new KeyNotFoundException($"Спор с ID {request.DisputeId} не найден.");
+ }
+
+ dispute.Resolve(request.ResolutionDetails);
+
+ await _repository.UpdateAsync(dispute, cancellationToken);
+
+ // Здесь можно было бы отправить еще одно интеграционное событие,
+ // например, чтобы разблокировать выплату стороне-победителю в модуле Order.
+ }
+}
diff --git a/src/Modules/Reputation/Application/Events/DisputeOpenedIntegrationEvent.cs b/src/Modules/Reputation/Application/Events/DisputeOpenedIntegrationEvent.cs
new file mode 100644
index 0000000..8617533
--- /dev/null
+++ b/src/Modules/Reputation/Application/Events/DisputeOpenedIntegrationEvent.cs
@@ -0,0 +1,9 @@
+using MediatR;
+
+namespace Nashel.Modules.Reputation.Application.Events;
+
+///
+/// Интеграционное событие: Спор открыт.
+/// Используется для уведомления других модулей (например, модуля Order для блокировки оплаты).
+///
+public record DisputeOpenedIntegrationEvent(Guid DisputeId, Guid OrderId) : INotification;
diff --git a/src/Modules/Reputation/Application/Events/ReviewCreatedEventHandler.cs b/src/Modules/Reputation/Application/Events/ReviewCreatedEventHandler.cs
new file mode 100644
index 0000000..1d07ae8
--- /dev/null
+++ b/src/Modules/Reputation/Application/Events/ReviewCreatedEventHandler.cs
@@ -0,0 +1,32 @@
+using MediatR;
+using Microsoft.Extensions.Logging;
+using Nashel.Modules.Reputation.Domain.Events;
+using Nashel.Modules.Reputation.Domain.Services;
+
+namespace Nashel.Modules.Reputation.Application.Events;
+
+///
+/// Обработчик события создания отзыва.
+///
+public class ReviewCreatedEventHandler : INotificationHandler
+{
+ private readonly RatingCalculator _ratingCalculator;
+ private readonly ILogger _logger;
+
+ public ReviewCreatedEventHandler(RatingCalculator ratingCalculator, ILogger logger)
+ {
+ _ratingCalculator = ratingCalculator;
+ _logger = logger;
+ }
+
+ public async Task Handle(ReviewCreatedEvent notification, CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Пересчет рейтинга для пользователя {UserId} после нового отзыва...", notification.TargetId);
+
+ var newAverageRating = await _ratingCalculator.CalculateAverage(notification.TargetId, cancellationToken);
+
+ _logger.LogInformation("Новый средний рейтинг пользователя {UserId}: {Rating}", notification.TargetId, newAverageRating);
+
+ // Здесь можно обновить кэш или таблицу профилей пользователей с их текущим рейтингом.
+ }
+}
diff --git a/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs b/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs
new file mode 100644
index 0000000..f8849a6
--- /dev/null
+++ b/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs
@@ -0,0 +1,36 @@
+using MediatR;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Repositories;
+
+namespace Nashel.Modules.Reputation.Application.Queries;
+
+///
+/// Запрос получения отзывов по пользователю.
+///
+/// ID пользователя.
+public record GetReviewsByTargetQuery(Guid TargetId) : IRequest>;
+
+public record ReviewDto(Guid ReviewId, int Rating, string Text, string[] MediaUrls, DateTime CreatedAt);
+
+public class GetReviewsByTargetHandler : IRequestHandler>
+{
+ private readonly IReviewRepository _repository;
+
+ public GetReviewsByTargetHandler(IReviewRepository repository)
+ {
+ _repository = repository;
+ }
+
+ public async Task> Handle(GetReviewsByTargetQuery request, CancellationToken cancellationToken)
+ {
+ var reviews = await _repository.GetByTargetIdAsync(request.TargetId, cancellationToken);
+
+ return reviews.Select(r => new ReviewDto(
+ r.Id,
+ r.Rating,
+ r.Text,
+ r.MediaUrls.ToArray(),
+ r.CreatedAt
+ )).ToList();
+ }
+}
diff --git a/src/Modules/Reputation/Domain/Entities/Dispute.cs b/src/Modules/Reputation/Domain/Entities/Dispute.cs
new file mode 100644
index 0000000..ab3ca4d
--- /dev/null
+++ b/src/Modules/Reputation/Domain/Entities/Dispute.cs
@@ -0,0 +1,127 @@
+using Nashel.BuildingBlocks.Domain;
+using Nashel.Modules.Reputation.Domain.Enums;
+
+namespace Nashel.Modules.Reputation.Domain.Entities;
+
+///
+/// Агрегат Спора (Арбитража) по заказу.
+///
+public class Dispute : AggregateRoot
+{
+ private DateTime _createdAt;
+
+ ///
+ /// ID заказа, по которому возник спор.
+ ///
+ public Guid OrderId { get; private set; }
+
+ ///
+ /// ID инициатора спора (кто открыл).
+ ///
+ public Guid InitiatorId { get; private set; }
+
+ ///
+ /// Причина спора / Жалоба.
+ ///
+ public string Reason { get; private set; }
+
+ ///
+ /// Текущий статус спора.
+ ///
+ public DisputeStatus Status { get; private set; }
+
+ ///
+ /// Список улик / доказательств (фото/видео).
+ ///
+ private readonly List _evidences = new();
+ public IReadOnlyCollection Evidences => _evidences.AsReadOnly();
+
+ ///
+ /// Дата открытия спора.
+ ///
+ public DateTime CreatedAt => _createdAt;
+
+ // Для EF Core
+ private Dispute()
+ {
+ Reason = null!;
+ }
+
+ private Dispute(Guid id, Guid orderId, Guid initiatorId, string reason)
+ {
+ Id = id;
+ OrderId = orderId;
+ InitiatorId = initiatorId;
+ Reason = reason;
+ Status = DisputeStatus.Created;
+ _createdAt = DateTime.UtcNow;
+ }
+
+ ///
+ /// Открывает новый спор.
+ ///
+ public static Dispute Open(Guid orderId, Guid initiatorId, string reason)
+ {
+ var dispute = new Dispute(Guid.NewGuid(), orderId, initiatorId, reason);
+ // При создании сразу переводим в статус сбора улик
+ dispute.StartEvidenceCollection();
+ return dispute;
+ }
+
+ ///
+ /// Начинает этап сбора улик (72 часа на предоставление доказательств).
+ ///
+ public void StartEvidenceCollection()
+ {
+ Status = DisputeStatus.EvidenceCollection;
+ // Здесь можно было бы добавить Domain Event: DisputeEvidenceCollectionStarted
+ }
+
+ ///
+ /// Добавляет улику (фото/видео) к спору.
+ ///
+ public void AddEvidence(DisputeEvidence evidence)
+ {
+ if (Status != DisputeStatus.EvidenceCollection)
+ {
+ throw new InvalidOperationException("Добавление улик возможно только на этапе сбора доказательств.");
+ }
+
+ // Проверка времени 72 часа (можно сделать через Background Job,
+ // но здесь тоже проверим для надежности).
+ if (DateTime.UtcNow > _createdAt.AddHours(72))
+ {
+ throw new InvalidOperationException("Время для сбора улик истекло (72 часа).");
+ }
+
+ _evidences.Add(evidence);
+ }
+
+ ///
+ /// Переводит спор в статус ожидания решения модератора (после истечения времени сбора улик).
+ ///
+ public void MoveToPendingDecision()
+ {
+ if (Status != DisputeStatus.EvidenceCollection)
+ {
+ // Логика: можно перевести из Created, если сразу все предоставили?
+ // Допустим, только из EvidenceCollection.
+ }
+
+ Status = DisputeStatus.PendingDecision;
+ }
+
+ ///
+ /// Разрешает спор (администратором/модератором).
+ ///
+ public void Resolve(string resolutionDetails)
+ {
+ if (Status == DisputeStatus.Resolved)
+ {
+ throw new InvalidOperationException("Спор уже разрешен.");
+ }
+
+ Status = DisputeStatus.Resolved;
+ // Здесь можно добавить Domain Event: DisputeResolvedEvent(Id, resolutionDetails)
+ }
+}
diff --git a/src/Modules/Reputation/Domain/Entities/DisputeEvidence.cs b/src/Modules/Reputation/Domain/Entities/DisputeEvidence.cs
new file mode 100644
index 0000000..3ccae8b
--- /dev/null
+++ b/src/Modules/Reputation/Domain/Entities/DisputeEvidence.cs
@@ -0,0 +1,58 @@
+using Nashel.BuildingBlocks.Domain;
+
+namespace Nashel.Modules.Reputation.Domain.Entities;
+
+///
+/// Улика (фото/видео/документ) в споре.
+///
+public class DisputeEvidence : Entity
+{
+ private DateTime _createdAt;
+
+ ///
+ /// ID спора.
+ ///
+ public Guid DisputeId { get; private set; }
+
+ ///
+ /// ID пользователя, загрузившего улику.
+ ///
+ public Guid UploaderId { get; private set; }
+
+ ///
+ /// Ссылка на файл (фото/видео).
+ ///
+ public string FileUrl { get; private set; }
+
+ ///
+ /// Тип файла (image, video, document).
+ ///
+ public string FileType { get; private set; }
+
+ ///
+ /// Дата загрузки.
+ ///
+ public DateTime CreatedAt => _createdAt;
+
+ // Для EF Core
+ private DisputeEvidence()
+ {
+ FileUrl = null!;
+ FileType = null!;
+ }
+
+ private DisputeEvidence(Guid id, Guid disputeId, Guid uploaderId, string fileUrl, string fileType)
+ {
+ Id = id;
+ DisputeId = disputeId;
+ UploaderId = uploaderId;
+ FileUrl = fileUrl;
+ FileType = fileType;
+ _createdAt = DateTime.UtcNow;
+ }
+
+ public static DisputeEvidence Create(Guid disputeId, Guid uploaderId, string fileUrl, string fileType = "image")
+ {
+ return new DisputeEvidence(Guid.NewGuid(), disputeId, uploaderId, fileUrl, fileType);
+ }
+}
diff --git a/src/Modules/Reputation/Domain/Entities/Review.cs b/src/Modules/Reputation/Domain/Entities/Review.cs
new file mode 100644
index 0000000..62738d2
--- /dev/null
+++ b/src/Modules/Reputation/Domain/Entities/Review.cs
@@ -0,0 +1,118 @@
+using Nashel.BuildingBlocks.Domain;
+using Nashel.Modules.Reputation.Domain.Events;
+
+namespace Nashel.Modules.Reputation.Domain.Entities;
+
+///
+/// Отзыв о пользователе (исполнителе или заказчике).
+///
+public class Review : AggregateRoot
+{
+ private DateTime _createdAt;
+
+ ///
+ /// ID заказа, к которому относится отзыв.
+ ///
+ public Guid OrderId { get; private set; }
+
+ ///
+ /// ID автора отзыва.
+ ///
+ public Guid AuthorId { get; private set; }
+
+ ///
+ /// ID получателя отзыва (на кого отзыв).
+ ///
+ public Guid TargetId { get; private set; }
+
+ ///
+ /// Оценка (рейтинг) от 1 до 5.
+ ///
+ public int Rating { get; private set; }
+
+ ///
+ /// Текст отзыва.
+ ///
+ public string Text { get; private set; }
+
+ ///
+ /// Список ссылок на медиа-файлы (фото).
+ ///
+ public List MediaUrls { get; private set; }
+
+ ///
+ /// Флаг авто-сгенерированного отзыва (если нет отзыва в течение 7 дней).
+ ///
+ public bool IsAutoGenerated { get; private set; }
+
+ ///
+ /// Дата создания отзыва.
+ ///
+ public DateTime CreatedAt => _createdAt;
+
+ // Для EF Core
+ private Review()
+ {
+ Text = null!;
+ MediaUrls = null!;
+ }
+
+ private Review(Guid id, Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated)
+ {
+ Id = id;
+ OrderId = orderId;
+ AuthorId = authorId;
+ TargetId = targetId;
+ Rating = rating;
+ Text = text;
+ IsAutoGenerated = isAutoGenerated;
+ MediaUrls = new List();
+ _createdAt = DateTime.UtcNow;
+
+ if (rating < 1 || rating > 5)
+ {
+ throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5.");
+ }
+ }
+ public static Review Create(Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated = false)
+ {
+ var review = new Review(Guid.NewGuid(), orderId, authorId, targetId, rating, text, isAutoGenerated);
+ review.AddDomainEvent(new ReviewCreatedEvent(review.Id, targetId, rating));
+ return review;
+ }
+
+ ///
+ /// Добавляет ссылки на медиа.
+ ///
+ public void AddMedia(List urls)
+ {
+ // Можно проверить время редактирования
+ CheckIfEditable();
+ MediaUrls.AddRange(urls);
+ }
+
+ ///
+ /// Обновляет текст и рейтинг.
+ ///
+ public void Update(string text, int rating)
+ {
+ CheckIfEditable();
+
+ if (rating < 1 || rating > 5)
+ throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5.");
+
+ Text = text;
+ Rating = rating;
+ }
+
+ ///
+ /// Проверяет, можно ли редактировать отзыв (доступно только в течение 3 дней).
+ ///
+ private void CheckIfEditable()
+ {
+ if (DateTime.UtcNow > _createdAt.AddDays(3))
+ {
+ throw new InvalidOperationException("Редактирование отзыва доступно только в течение 3 дней после создания.");
+ }
+ }
+}
diff --git a/src/Modules/Reputation/Domain/Enums/DisputeStatus.cs b/src/Modules/Reputation/Domain/Enums/DisputeStatus.cs
new file mode 100644
index 0000000..182df31
--- /dev/null
+++ b/src/Modules/Reputation/Domain/Enums/DisputeStatus.cs
@@ -0,0 +1,27 @@
+namespace Nashel.Modules.Reputation.Domain.Enums;
+
+///
+/// Статус спора (арбитража).
+///
+public enum DisputeStatus
+{
+ ///
+ /// Спор создан, ожидание принятия.
+ ///
+ Created,
+
+ ///
+ /// Сбор улик (72 часа).
+ ///
+ EvidenceCollection,
+
+ ///
+ /// Ожидание решения модератора.
+ ///
+ PendingDecision,
+
+ ///
+ /// Спор разрешен.
+ ///
+ Resolved
+}
diff --git a/src/Modules/Reputation/Domain/Events/ReviewCreatedEvent.cs b/src/Modules/Reputation/Domain/Events/ReviewCreatedEvent.cs
new file mode 100644
index 0000000..d85fb78
--- /dev/null
+++ b/src/Modules/Reputation/Domain/Events/ReviewCreatedEvent.cs
@@ -0,0 +1,20 @@
+using Nashel.BuildingBlocks.Domain;
+
+namespace Nashel.Modules.Reputation.Domain.Events;
+
+///
+/// Событие создания отзыва.
+///
+public class ReviewCreatedEvent : BaseDomainEvent
+{
+ public Guid ReviewId { get; }
+ public Guid TargetId { get; }
+ public int Rating { get; }
+
+ public ReviewCreatedEvent(Guid reviewId, Guid targetId, int rating)
+ {
+ ReviewId = reviewId;
+ TargetId = targetId;
+ Rating = rating;
+ }
+}
diff --git a/src/Modules/Reputation/Domain/Repositories/IRepositories.cs b/src/Modules/Reputation/Domain/Repositories/IRepositories.cs
new file mode 100644
index 0000000..e80697d
--- /dev/null
+++ b/src/Modules/Reputation/Domain/Repositories/IRepositories.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Nashel.Modules.Reputation.Domain.Entities;
+
+namespace Nashel.Modules.Reputation.Domain.Repositories
+{
+ public interface IReviewRepository
+ {
+ Task AddAsync(Review review, CancellationToken cancellationToken = default);
+ Task> GetByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ // Для пересчета среднего рейтинга можно использовать SQL-запрос, но здесь пока просто GetByTargetId
+ }
+
+ public interface IDisputeRepository
+ {
+ Task AddAsync(Dispute dispute, CancellationToken cancellationToken = default);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task UpdateAsync(Dispute dispute, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Modules/Reputation/Domain/Services/RatingCalculator.cs b/src/Modules/Reputation/Domain/Services/RatingCalculator.cs
new file mode 100644
index 0000000..90fe068
--- /dev/null
+++ b/src/Modules/Reputation/Domain/Services/RatingCalculator.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Nashel.Modules.Reputation.Domain.Repositories;
+
+namespace Nashel.Modules.Reputation.Domain.Services;
+
+///
+/// Сервис для расчета рейтинга пользователей.
+///
+public class RatingCalculator
+{
+ private readonly IReviewRepository _reviewRepository;
+
+ public RatingCalculator(IReviewRepository reviewRepository)
+ {
+ _reviewRepository = reviewRepository;
+ }
+
+ ///
+ /// Вычисляет средний рейтинг пользователя на основе всех его отзывов.
+ ///
+ /// Средний рейтинг (double) или 0, если отзывов нет.
+ public async Task CalculateAverage(Guid targetId, CancellationToken cancellationToken = default)
+ {
+ var reviews = await _reviewRepository.GetByTargetIdAsync(targetId, cancellationToken);
+
+ if (reviews == null || !reviews.Any())
+ {
+ return 0.0;
+ }
+
+ // Простое среднее арифметическое
+ double avg = reviews.Average(r => r.Rating);
+
+ // Округлим до 1 знака (опционально)
+ return Math.Round(avg, 1);
+ }
+}
diff --git a/src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewJob.cs b/src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewJob.cs
new file mode 100644
index 0000000..7728d9c
--- /dev/null
+++ b/src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewJob.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Nashel.Modules.Reputation.Infrastructure.BackgroundJobs
+{
+ public class AutoReviewJob : BackgroundService
+ {
+ private readonly ILogger _logger;
+ private readonly IServiceProvider _serviceProvider;
+
+ public AutoReviewJob(ILogger logger, IServiceProvider serviceProvider)
+ {
+ _logger = logger;
+ _serviceProvider = serviceProvider;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Запуск AutoReviewJob...");
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ _logger.LogInformation("AutoReviewJob: Проверка заказов без отзывов...");
+
+ // TODO: Реализовать логику авто-отзыва
+ // 1. Получить все завершенные заказы (из модуля Order или через Integration Events/View)
+ // 2. Отфильтровать те, которые завершены > 7 дней назад
+ // 3. Проверить, есть ли уже отзыв для этого заказа (в таблице Reviews)
+ // 4. Если отзыва нет -> создать Review с Rating=5 и IsAutoGenerated=true
+ // 5. Сохранить в БД (ReviewRepository.AddAsync)
+
+ using (var scope = _serviceProvider.CreateScope())
+ {
+ var logger = scope.ServiceProvider.GetRequiredService>();
+ var reviewRepo = scope.ServiceProvider.GetRequiredService();
+
+ // Логика авто-отзыва:
+ // 1. Ищем заказы, которые были завершены ровно 7 дней назад.
+ // Это можно сделать через интеграционное событие или запрос к Order Module.
+ // 2. Для каждого такого заказа проверяем, оставил ли уже клиент отзыв.
+ // 3. Если отзыва нет:
+ // Review autoReview = Review.Create(order.Id, Guid.Empty (System), order.EmployeeId, 5, "Авто-отзыв: Заказ успешно завершен.", isAutoGenerated: true);
+ // await reviewRepo.AddAsync(autoReview);
+
+ logger.LogInformation("AutoReviewJob: Проверка завершена.");
+ }
+
+ // Ждем 24 часа перед следующей проверкой
+ await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Ошибка в AutoReviewJob");
+ // Ждем немного перед повторной попыткой при ошибке
+ await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Modules/Reputation/Infrastructure/DependencyInjection.cs b/src/Modules/Reputation/Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..021afc3
--- /dev/null
+++ b/src/Modules/Reputation/Infrastructure/DependencyInjection.cs
@@ -0,0 +1,36 @@
+using System.Reflection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Nashel.Modules.Reputation.Domain.Repositories;
+using Nashel.Modules.Reputation.Domain.Services;
+using Nashel.Modules.Reputation.Infrastructure.Persistence;
+using Nashel.Modules.Reputation.Infrastructure.Persistence.Repositories;
+
+namespace Nashel.Modules.Reputation.Infrastructure;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddReputationModule(this IServiceCollection services, IConfiguration configuration)
+ {
+ var connectionString = configuration.GetConnectionString("DefaultConnection");
+
+ services.AddDbContext(options =>
+ options.UseNpgsql(connectionString));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddMediatR(cfg =>
+ {
+ cfg.RegisterServicesFromAssembly(Assembly.Load("Nashel.Modules.Reputation.Application"));
+ });
+
+ // Фоновые задачи
+ services.AddHostedService();
+
+ return services;
+ }
+}
diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.Designer.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.Designer.cs
new file mode 100644
index 0000000..105588a
--- /dev/null
+++ b/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.Designer.cs
@@ -0,0 +1,134 @@
+//
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Nashel.Modules.Reputation.Infrastructure.Persistence;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Nashel.Modules.Reputation.Infrastructure.Migrations
+{
+ [DbContext(typeof(ReputationDbContext))]
+ [Migration("20260212125841_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("reputation")
+ .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("InitiatorId")
+ .HasColumnType("uuid");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Disputes", "reputation");
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DisputeId")
+ .HasColumnType("uuid");
+
+ b.Property("FileType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FileUrl")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UploaderId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisputeId");
+
+ b.ToTable("DisputeEvidence", "reputation");
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid");
+
+ b.Property("IsAutoGenerated")
+ .HasColumnType("boolean");
+
+ b.PrimitiveCollection>("MediaUrls")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid");
+
+ b.Property("Rating")
+ .HasColumnType("integer");
+
+ b.Property("TargetId")
+ .HasColumnType("uuid");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TargetId");
+
+ b.ToTable("Reviews", "reputation");
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b =>
+ {
+ b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null)
+ .WithMany("Evidences")
+ .HasForeignKey("DisputeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b =>
+ {
+ b.Navigation("Evidences");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.cs
new file mode 100644
index 0000000..4f03fee
--- /dev/null
+++ b/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Nashel.Modules.Reputation.Infrastructure.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "reputation");
+
+ migrationBuilder.CreateTable(
+ name: "Disputes",
+ schema: "reputation",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ OrderId = table.Column(type: "uuid", nullable: false),
+ InitiatorId = table.Column(type: "uuid", nullable: false),
+ Reason = table.Column(type: "text", nullable: false),
+ Status = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Disputes", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Reviews",
+ schema: "reputation",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ OrderId = table.Column(type: "uuid", nullable: false),
+ AuthorId = table.Column(type: "uuid", nullable: false),
+ TargetId = table.Column(type: "uuid", nullable: false),
+ Rating = table.Column(type: "integer", nullable: false),
+ Text = table.Column(type: "text", nullable: false),
+ MediaUrls = table.Column>(type: "text[]", nullable: false),
+ IsAutoGenerated = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Reviews", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "DisputeEvidence",
+ schema: "reputation",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ DisputeId = table.Column(type: "uuid", nullable: false),
+ UploaderId = table.Column(type: "uuid", nullable: false),
+ FileUrl = table.Column(type: "text", nullable: false),
+ FileType = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DisputeEvidence", x => x.Id);
+ table.ForeignKey(
+ name: "FK_DisputeEvidence_Disputes_DisputeId",
+ column: x => x.DisputeId,
+ principalSchema: "reputation",
+ principalTable: "Disputes",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DisputeEvidence_DisputeId",
+ schema: "reputation",
+ table: "DisputeEvidence",
+ column: "DisputeId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Reviews_TargetId",
+ schema: "reputation",
+ table: "Reviews",
+ column: "TargetId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DisputeEvidence",
+ schema: "reputation");
+
+ migrationBuilder.DropTable(
+ name: "Reviews",
+ schema: "reputation");
+
+ migrationBuilder.DropTable(
+ name: "Disputes",
+ schema: "reputation");
+ }
+ }
+}
diff --git a/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs b/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs
new file mode 100644
index 0000000..f0c7dc2
--- /dev/null
+++ b/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs
@@ -0,0 +1,131 @@
+//
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Nashel.Modules.Reputation.Infrastructure.Persistence;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Nashel.Modules.Reputation.Infrastructure.Migrations
+{
+ [DbContext(typeof(ReputationDbContext))]
+ partial class ReputationDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("reputation")
+ .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("InitiatorId")
+ .HasColumnType("uuid");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Disputes", "reputation");
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DisputeId")
+ .HasColumnType("uuid");
+
+ b.Property("FileType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FileUrl")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UploaderId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisputeId");
+
+ b.ToTable("DisputeEvidence", "reputation");
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid");
+
+ b.Property("IsAutoGenerated")
+ .HasColumnType("boolean");
+
+ b.PrimitiveCollection>("MediaUrls")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid");
+
+ b.Property("Rating")
+ .HasColumnType("integer");
+
+ b.Property("TargetId")
+ .HasColumnType("uuid");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TargetId");
+
+ b.ToTable("Reviews", "reputation");
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b =>
+ {
+ b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null)
+ .WithMany("Evidences")
+ .HasForeignKey("DisputeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b =>
+ {
+ b.Navigation("Evidences");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj b/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj
index 71270f2..e1e7c63 100644
--- a/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj
+++ b/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj
@@ -6,6 +6,7 @@
+
Nashel.Modules.Reputation.Infrastructure
diff --git a/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs b/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs
new file mode 100644
index 0000000..468045f
--- /dev/null
+++ b/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Nashel.Modules.Reputation.Domain.Entities;
+
+namespace Nashel.Modules.Reputation.Infrastructure.Persistence.Configurations;
+
+public class ReviewConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(r => r.Id);
+
+ // Индекс для быстрого поиска по TargetId
+ builder.HasIndex(r => r.TargetId);
+
+ // MediaUrls хранится как простой JSON или Primitive Collection
+ // В EF Core 8+ есть поддержка Primitive Collections.
+ // Для Postgres Npgsql это text[] array по умолчанию.
+ // В 10-й версии это должно работать из коробки.
+ }
+}
+
+public class DisputeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasKey(d => d.Id);
+ builder.Property(d => d.Status).HasConversion();
+
+ // Отношение One-to-Many с Evidence
+ builder.HasMany(d => d.Evidences)
+ .WithOne()
+ .HasForeignKey(e => e.DisputeId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/Modules/Reputation/Infrastructure/Persistence/Repositories/DisputeRepository.cs b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/DisputeRepository.cs
new file mode 100644
index 0000000..8eb5d1a
--- /dev/null
+++ b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/DisputeRepository.cs
@@ -0,0 +1,34 @@
+using Microsoft.EntityFrameworkCore;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Repositories;
+
+namespace Nashel.Modules.Reputation.Infrastructure.Persistence.Repositories;
+
+public class DisputeRepository : IDisputeRepository
+{
+ private readonly ReputationDbContext _context;
+
+ public DisputeRepository(ReputationDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task AddAsync(Dispute dispute, CancellationToken cancellationToken = default)
+ {
+ await _context.Disputes.AddAsync(dispute, cancellationToken);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ return await _context.Disputes
+ .Include(d => d.Evidences)
+ .FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
+ }
+
+ public async Task UpdateAsync(Dispute dispute, CancellationToken cancellationToken = default)
+ {
+ _context.Disputes.Update(dispute);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs
new file mode 100644
index 0000000..c210ac3
--- /dev/null
+++ b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs
@@ -0,0 +1,34 @@
+using Microsoft.EntityFrameworkCore;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Repositories;
+
+namespace Nashel.Modules.Reputation.Infrastructure.Persistence.Repositories;
+
+public class ReviewRepository : IReviewRepository
+{
+ private readonly ReputationDbContext _context;
+
+ public ReviewRepository(ReputationDbContext context)
+ {
+ _context = context;
+ }
+
+ public async Task AddAsync(Review review, CancellationToken cancellationToken = default)
+ {
+ await _context.Reviews.AddAsync(review, cancellationToken);
+ await _context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ return await _context.Reviews.FindAsync(new object[] { id }, cancellationToken);
+ }
+
+ public async Task> GetByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default)
+ {
+ return await _context.Reviews
+ .Where(r => r.TargetId == targetId)
+ .OrderByDescending(r => r.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+}
diff --git a/src/Modules/Reputation/Infrastructure/Persistence/ReputationDbContext.cs b/src/Modules/Reputation/Infrastructure/Persistence/ReputationDbContext.cs
new file mode 100644
index 0000000..42c7c2f
--- /dev/null
+++ b/src/Modules/Reputation/Infrastructure/Persistence/ReputationDbContext.cs
@@ -0,0 +1,23 @@
+using Microsoft.EntityFrameworkCore;
+using Nashel.Modules.Reputation.Domain.Entities;
+
+namespace Nashel.Modules.Reputation.Infrastructure.Persistence;
+
+public class ReputationDbContext : DbContext
+{
+ public DbSet Reviews { get; set; }
+ public DbSet Disputes { get; set; }
+
+ public ReputationDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.HasDefaultSchema("reputation");
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(ReputationDbContext).Assembly);
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/src/Modules/Reputation/Presentation/Nashel.Modules.Reputation.Presentation.csproj b/src/Modules/Reputation/Presentation/Nashel.Modules.Reputation.Presentation.csproj
index 064a0ff..68331e4 100644
--- a/src/Modules/Reputation/Presentation/Nashel.Modules.Reputation.Presentation.csproj
+++ b/src/Modules/Reputation/Presentation/Nashel.Modules.Reputation.Presentation.csproj
@@ -1,11 +1,22 @@
-
-
-
Nashel.Modules.Reputation.Presentation
net10.0
enable
enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/Reputation/Presentation/ReputationEndpoints.cs b/src/Modules/Reputation/Presentation/ReputationEndpoints.cs
new file mode 100644
index 0000000..76fb0d4
--- /dev/null
+++ b/src/Modules/Reputation/Presentation/ReputationEndpoints.cs
@@ -0,0 +1,146 @@
+using MediatR;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Nashel.Modules.Reputation.Application.Commands;
+using Nashel.Modules.Reputation.Application.Queries;
+
+namespace Nashel.Modules.Reputation.Presentation;
+
+public static class ReputationEndpoints
+{
+ public static void MapReputationEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/reputation").WithTags("Reputation");
+
+ // POST /api/reputation/reviews
+ group.MapPost("/reviews", async ([FromBody] CreateReviewRequest request, ISender sender) =>
+ {
+ var command = new CreateReviewCommand(
+ request.OrderId,
+ request.AuthorId,
+ request.TargetId,
+ request.Rating,
+ request.Text
+ );
+ var reviewId = await sender.Send(command);
+ return Results.Ok(reviewId);
+ })
+ .WithName("CreateReview")
+ .WithOpenApi(operation => new(operation) { Summary = "Создать отзыв", Description = "Оставить отзыв о пользователе по завершенному заказу." });
+
+ // GET /api/reputation/reviews/{userId}
+ group.MapGet("/reviews/{userId:guid}", async (Guid userId, ISender sender) =>
+ {
+ var query = new GetReviewsByTargetQuery(userId);
+ var result = await sender.Send(query);
+ return Results.Ok(result);
+ })
+ .WithName("GetUserReviews")
+ .WithOpenApi(operation => new(operation) { Summary = "Получить отзывы пользователя", Description = "Возвращает список отзывов для конкретного пользователя." });
+
+ // POST /api/reputation/disputes
+ group.MapPost("/disputes", async ([FromBody] OpenDisputeRequest request, ISender sender) =>
+ {
+ var command = new OpenDisputeCommand(request.OrderId, request.InitiatorId, request.Reason);
+ var disputeId = await sender.Send(command);
+ return Results.Ok(disputeId);
+ })
+ .WithName("OpenDispute")
+ .WithOpenApi(operation => new(operation) { Summary = "Открыть спор (арбитраж)", Description = "Начинает процедуру спора по заказу. Блокирует выплаты." });
+
+ // POST /api/reputation/disputes/{id}/evidence
+ group.MapPost("/disputes/{id:guid}/evidence", async (Guid id, [FromBody] AddEvidenceRequest request, ISender sender) =>
+ {
+ var command = new AddEvidenceCommand(id, request.UploaderId, request.FileUrl);
+ await sender.Send(command);
+ return Results.Ok();
+ })
+ .WithName("AddDisputeEvidence")
+ .WithOpenApi(operation => new(operation) { Summary = "Добавить улики к спору", Description = "Добавляет фото/видео доказательства. Доступно только в статусе EvidenceCollection." });
+
+ // POST /api/reputation/disputes/{id}/resolve
+ group.MapPost("/disputes/{id:guid}/resolve", async (Guid id, [FromBody] ResolveDisputeRequest request, ISender sender) =>
+ {
+ var command = new ResolveDisputeCommand(id, request.ResolutionDetails);
+ await sender.Send(command);
+ return Results.Ok();
+ })
+ .WithName("ResolveDispute")
+ .WithOpenApi(operation => new(operation) { Summary = "Разрешить спор (Admin)", Description = "Принимает решение по спору. Доступно только администратору." });
+ }
+}
+
+///
+/// Запрос на разрешение спора.
+///
+public record ResolveDisputeRequest(string ResolutionDetails);
+
+///
+/// Запрос на создание отзыва.
+///
+public record CreateReviewRequest
+{
+ ///
+ /// ID заказа.
+ ///
+ public Guid OrderId { get; init; }
+
+ ///
+ /// ID автора отзыва.
+ ///
+ public Guid AuthorId { get; init; }
+
+ ///
+ /// ID получателя отзыва.
+ ///
+ public Guid TargetId { get; init; }
+
+ ///
+ /// Оценка (1-5).
+ ///
+ public int Rating { get; init; }
+
+ ///
+ /// Текст отзыва.
+ ///
+ public string Text { get; init; } = default!;
+}
+
+///
+/// Запрос на открытие спора.
+///
+public record OpenDisputeRequest
+{
+ ///
+ /// ID заказа.
+ ///
+ public Guid OrderId { get; init; }
+
+ ///
+ /// ID инициатора.
+ ///
+ public Guid InitiatorId { get; init; }
+
+ ///
+ /// Причина спора.
+ ///
+ public string Reason { get; init; } = default!;
+}
+
+///
+/// Запрос на добавление улики.
+///
+public record AddEvidenceRequest
+{
+ ///
+ /// ID загрузившего.
+ ///
+ public Guid UploaderId { get; init; }
+
+ ///
+ /// Ссылка на файл.
+ ///
+ public string FileUrl { get; init; } = default!;
+}
diff --git a/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs b/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs
new file mode 100644
index 0000000..04fbbad
--- /dev/null
+++ b/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs
@@ -0,0 +1,44 @@
+using FluentAssertions;
+using MediatR;
+using Moq;
+using Nashel.Modules.Reputation.Application.Commands;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Repositories;
+using Xunit;
+
+namespace Nashel.Modules.Reputation.Tests.Application;
+
+public class ReviewApplicationTests
+{
+ private readonly Mock _repositoryMock;
+ private readonly Mock _mediatorMock;
+ private readonly CreateReviewHandler _handler;
+
+ public ReviewApplicationTests()
+ {
+ _repositoryMock = new Mock();
+ _mediatorMock = new Mock();
+ _handler = new CreateReviewHandler(_repositoryMock.Object, _mediatorMock.Object);
+ }
+
+ [Fact]
+ public async Task СозданиеОтзыва_Должно_Сохранить_Отзыв_И_Опубликовать_События()
+ {
+ // 1. Arrange
+ var command = new CreateReviewCommand(
+ OrderId: Guid.NewGuid(),
+ AuthorId: Guid.NewGuid(),
+ TargetId: Guid.NewGuid(),
+ Rating: 5,
+ Text: "Отличный опыт!"
+ );
+
+ // 2. Act
+ var result = await _handler.Handle(command, CancellationToken.None);
+
+ // 3. Assert
+ result.Should().NotBeEmpty();
+ _repositoryMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _mediatorMock.Verify(m => m.Publish(It.IsAny(), It.IsAny()), Times.AtLeastOnce);
+ }
+}
diff --git a/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs b/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs
new file mode 100644
index 0000000..abec75a
--- /dev/null
+++ b/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs
@@ -0,0 +1,39 @@
+using FluentAssertions;
+using MediatR;
+using Moq;
+using Nashel.Modules.Reputation.Application.Commands;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Repositories;
+using Nashel.Modules.Reputation.Application.Events;
+using Xunit;
+
+namespace Nashel.Modules.Reputation.Tests.Application;
+
+public class OpenDisputeTests
+{
+ private readonly Mock _repositoryMock;
+ private readonly Mock _publisherMock;
+ private readonly OpenDisputeHandler _handler;
+
+ public OpenDisputeTests()
+ {
+ _repositoryMock = new Mock();
+ _publisherMock = new Mock();
+ _handler = new OpenDisputeHandler(_repositoryMock.Object, _publisherMock.Object);
+ }
+
+ [Fact]
+ public async Task Обработка_Должна_Создать_Спор_И_Опубликовать_Событие()
+ {
+ // Arrange
+ var request = new OpenDisputeCommand(Guid.NewGuid(), Guid.NewGuid(), "Причина");
+
+ // Act
+ var result = await _handler.Handle(request, CancellationToken.None);
+
+ // Assert
+ result.Should().NotBeEmpty();
+ _repositoryMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once);
+ _publisherMock.Verify(p => p.Publish(It.IsAny(), It.IsAny()), Times.Once);
+ }
+}
diff --git a/src/Modules/Reputation/Tests/Domain/DisputeTests.cs b/src/Modules/Reputation/Tests/Domain/DisputeTests.cs
new file mode 100644
index 0000000..45aa99a
--- /dev/null
+++ b/src/Modules/Reputation/Tests/Domain/DisputeTests.cs
@@ -0,0 +1,38 @@
+using FluentAssertions;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Enums;
+using Xunit;
+
+namespace Nashel.Modules.Reputation.Tests.Domain;
+
+public class DisputeTests
+{
+ [Fact]
+ public void Открытие_Должно_Установить_Статус_Сбор_Улик()
+ {
+ // Arrange
+ var dispute = Dispute.Open(Guid.NewGuid(), Guid.NewGuid(), "Проблема");
+
+ // Assert
+ dispute.Status.Should().Be(DisputeStatus.EvidenceCollection);
+ dispute.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
+ }
+
+ [Fact]
+ public void ДобавлениеУлик_Должно_Провалиться_Если_Статус_Не_СборУлик()
+ {
+ // Arrange
+ var dispute = Dispute.Open(Guid.NewGuid(), Guid.NewGuid(), "Проблема");
+
+ // Принудительно меняем статус через разрешение спора
+ dispute.Resolve("Решено");
+
+ // Act
+ var evidence = DisputeEvidence.Create(dispute.Id, Guid.NewGuid(), "url.com");
+ var action = () => dispute.AddEvidence(evidence);
+
+ // Assert
+ action.Should().Throw()
+ .WithMessage("Добавление улик возможно только на этапе сбора доказательств.");
+ }
+}
diff --git a/src/Modules/Reputation/Tests/Domain/ReviewTests.cs b/src/Modules/Reputation/Tests/Domain/ReviewTests.cs
new file mode 100644
index 0000000..dca9afe
--- /dev/null
+++ b/src/Modules/Reputation/Tests/Domain/ReviewTests.cs
@@ -0,0 +1,57 @@
+using FluentAssertions;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Events;
+using Xunit;
+
+namespace Nashel.Modules.Reputation.Tests.Domain;
+
+public class ReviewTests
+{
+ [Fact]
+ public void Создание_Должно_Установить_Правильные_Свойства()
+ {
+ // Arrange
+ var orderId = Guid.NewGuid();
+ var authorId = Guid.NewGuid();
+ var targetId = Guid.NewGuid();
+
+ // Act
+ var review = Review.Create(orderId, authorId, targetId, 5, "Отличная работа");
+
+ // Assert
+ review.Rating.Should().Be(5);
+ review.Text.Should().Be("Отличная работа");
+ review.IsAutoGenerated.Should().BeFalse();
+ review.DomainEvents.Should().ContainSingle(e => e is ReviewCreatedEvent);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(6)]
+ public void Создание_Должно_Выбросить_Исключение_Когда_Рейтинг_Недействителен(int invalidRating)
+ {
+ // Act
+ var action = () => Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), invalidRating, "текст");
+
+ // Assert
+ action.Should().Throw();
+ }
+
+ [Fact]
+ public void Обновление_Должно_Выбросить_Исключение_Через_3_Дня()
+ {
+ // Arrange
+ var review = Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 5, "Текст");
+
+ // Используем рефлексию для изменения даты создания
+ var createdAtField = typeof(Review).GetField("_createdAt", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
+ createdAtField?.SetValue(review, DateTime.UtcNow.AddDays(-4));
+
+ // Act
+ var action = () => review.Update("Новый текст", 4);
+
+ // Assert
+ action.Should().Throw()
+ .WithMessage("Редактирование отзыва доступно только в течение 3 дней после создания.");
+ }
+}
diff --git a/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs b/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs
new file mode 100644
index 0000000..44f9ea1
--- /dev/null
+++ b/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs
@@ -0,0 +1,58 @@
+using FluentAssertions;
+using Moq;
+using Nashel.Modules.Reputation.Domain.Entities;
+using Nashel.Modules.Reputation.Domain.Repositories;
+using Nashel.Modules.Reputation.Domain.Services;
+using Xunit;
+
+namespace Nashel.Modules.Reputation.Tests.Services;
+
+public class RatingCalculatorTests
+{
+ private readonly Mock _repositoryMock;
+ private readonly RatingCalculator _service;
+
+ public RatingCalculatorTests()
+ {
+ _repositoryMock = new Mock();
+ _service = new RatingCalculator(_repositoryMock.Object);
+ }
+
+ [Fact]
+ public async Task РасчетСреднего_Должен_Вернуть_Верное_Значение()
+ {
+ // 1. Arrange
+ var targetId = Guid.NewGuid();
+
+ var reviews = new List
+ {
+ Review.Create(Guid.NewGuid(), Guid.NewGuid(), targetId, 5, "Хорошо"),
+ Review.Create(Guid.NewGuid(), Guid.NewGuid(), targetId, 3, "Средне")
+ };
+ // 5 + 3 = 8 / 2 = 4.0
+
+ _repositoryMock.Setup(r => r.GetByTargetIdAsync(targetId, It.IsAny()))
+ .ReturnsAsync(reviews);
+
+ // 2. Act
+ var average = await _service.CalculateAverage(targetId);
+
+ // 3. Assert
+ average.Should().Be(4.0, "Средний рейтинг должен рассчитываться правильно.");
+ }
+
+ [Fact]
+ public async Task РасчетСреднего_Должен_Вернуть_Ноль_Если_Отзывов_Нет()
+ {
+ // 1. Arrange
+ var targetId = Guid.NewGuid();
+ _repositoryMock.Setup(r => r.GetByTargetIdAsync(targetId, It.IsAny()))
+ .ReturnsAsync(new List());
+
+ // 2. Act
+ var average = await _service.CalculateAverage(targetId);
+
+ // 3. Assert
+ average.Should().Be(0.0, "Рейтинг должен быть 0, если отзывов нет.");
+ }
+}
diff --git a/src/Modules/Reputation/Tests/Tests.csproj b/src/Modules/Reputation/Tests/Tests.csproj
new file mode 100644
index 0000000..6327b3f
--- /dev/null
+++ b/src/Modules/Reputation/Tests/Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+