diff --git a/.gitignore b/.gitignore index c5af949..f69adab 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,7 @@ src/Modules/Geo/Presentation/bin/ src/Modules/Order/Tests/bin/ src/Modules/Order/Tests/obj/ + +src/Modules/Collaboration/Tests/bin/ + +src/Modules/Collaboration/Tests/obj/ diff --git a/Nashel.sln b/Nashel.sln index 77e6b5f..a07adc9 100644 --- a/Nashel.sln +++ b/Nashel.sln @@ -95,6 +95,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{FB27511E 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 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Collaboration", "Collaboration", "{0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "src\Modules\Collaboration\Tests\Tests.csproj", "{5E1F169E-6639-416B-8C9F-DE3C5785430F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -441,6 +445,18 @@ Global {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 + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x64.ActiveCfg = Debug|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x64.Build.0 = Debug|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x86.ActiveCfg = Debug|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Debug|x86.Build.0 = Debug|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|Any CPU.Build.0 = Release|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x64.ActiveCfg = Release|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x64.Build.0 = Release|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x86.ActiveCfg = Release|Any CPU + {5E1F169E-6639-416B-8C9F-DE3C5785430F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -487,5 +503,7 @@ Global {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} + {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {5E1F169E-6639-416B-8C9F-DE3C5785430F} = {0D6C7A58-55D3-AEBB-0CCB-984ED0F92F71} EndGlobalSection EndGlobal diff --git a/src/Host/Program.cs b/src/Host/Program.cs index 194d652..d325846 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -10,6 +10,8 @@ using Nashel.Modules.Identity.Infrastructure; using Nashel.Modules.Identity.Presentation.Endpoints; using Nashel.Modules.Order.Infrastructure; using Nashel.Modules.Order.Presentation; +using Nashel.Modules.Collaboration.Infrastructure; +using Nashel.Modules.Collaboration.Presentation; var builder = WebApplication.CreateBuilder(args); @@ -61,7 +63,8 @@ builder.Services.AddCatalogModule(builder.Configuration); builder.Services.AddGeoModule(builder.Configuration); // Регистрация модуля Order -builder.Services.AddOrderModule(builder.Configuration); +// Регистрация модуля Collaboartion +builder.Services.AddCollaborationModule(builder.Configuration); // Настройка аутентификации builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) @@ -102,5 +105,6 @@ app.MapIdentityEndpoints(); app.MapCatalogEndpoints(); app.MapGeoEndpoints(); app.MapOrderEndpoints(); +app.MapCollaborationEndpoints(); app.Run(); diff --git a/src/Modules/Collaboration/Application/Commands/HireEmployeeCommand.cs b/src/Modules/Collaboration/Application/Commands/HireEmployeeCommand.cs new file mode 100644 index 0000000..1102842 --- /dev/null +++ b/src/Modules/Collaboration/Application/Commands/HireEmployeeCommand.cs @@ -0,0 +1,34 @@ +using MediatR; +using Nashel.Modules.Collaboration.Domain.Entities; +using Nashel.Modules.Collaboration.Domain.Repositories; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Nashel.Modules.Collaboration.Application.Commands +{ + /// + /// Команда найма сотрудника (создание контракта). + /// + public record HireEmployeeCommand(Guid EmployerId, Guid EmployeeId, string Role) : IRequest; + + public class HireEmployeeHandler : IRequestHandler + { + private readonly IContractRepository _repository; + + public HireEmployeeHandler(IContractRepository repository) + { + _repository = repository; + } + + public async Task Handle(HireEmployeeCommand request, CancellationToken cancellationToken) + { + // Здесь можно добавить проверку, не существует ли уже активный контракт между этими сторонами + // Но пока просто создаем новый. + + var contract = Contract.Create(request.EmployerId, request.EmployeeId, request.Role); + await _repository.AddAsync(contract, cancellationToken); + return contract.Id; + } + } +} diff --git a/src/Modules/Collaboration/Application/Commands/SetVetoCommand.cs b/src/Modules/Collaboration/Application/Commands/SetVetoCommand.cs new file mode 100644 index 0000000..2ecb443 --- /dev/null +++ b/src/Modules/Collaboration/Application/Commands/SetVetoCommand.cs @@ -0,0 +1,38 @@ +using MediatR; +using Nashel.Modules.Collaboration.Domain.Repositories; +using Nashel.Modules.Collaboration.Domain.ValueObjects; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Nashel.Modules.Collaboration.Application.Commands +{ + /// + /// Команда установки вето (блокировки) на контракт. + /// + public record SetVetoCommand(Guid ContractId, DateTime Start, DateTime End) : IRequest; + + public class SetVetoHandler : IRequestHandler + { + private readonly IContractRepository _repository; + + public SetVetoHandler(IContractRepository repository) + { + _repository = repository; + } + + public async Task Handle(SetVetoCommand request, CancellationToken cancellationToken) + { + var contract = await _repository.GetByIdAsync(request.ContractId, cancellationToken); + if (contract == null) + { + throw new ApplicationException($"Contract with ID {request.ContractId} not found."); + } + + var range = new DateRange(request.Start, request.End); + contract.ImposeVeto(range); + + await _repository.UpdateAsync(contract, cancellationToken); + } + } +} diff --git a/src/Modules/Collaboration/Application/Queries/CheckAvailabilityQuery.cs b/src/Modules/Collaboration/Application/Queries/CheckAvailabilityQuery.cs new file mode 100644 index 0000000..adf49c4 --- /dev/null +++ b/src/Modules/Collaboration/Application/Queries/CheckAvailabilityQuery.cs @@ -0,0 +1,30 @@ +using MediatR; +using Nashel.Modules.Collaboration.Domain.Services; +using Nashel.Modules.Collaboration.Domain.ValueObjects; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Nashel.Modules.Collaboration.Application.Queries +{ + /// + /// Проверка доступности сотрудника (Query). + /// + public record CheckAvailabilityQuery(Guid EmployeeId, DateTime Start, DateTime End) : IRequest; + + public class CheckAvailabilityHandler : IRequestHandler + { + private readonly ScheduleConflictService _conflictService; + + public CheckAvailabilityHandler(ScheduleConflictService conflictService) + { + _conflictService = conflictService; + } + + public async Task Handle(CheckAvailabilityQuery request, CancellationToken cancellationToken) + { + var range = new DateRange(request.Start, request.End); + return await _conflictService.CheckAvailability(request.EmployeeId, range, cancellationToken); + } + } +} diff --git a/src/Modules/Collaboration/Domain/Entities/Contract.cs b/src/Modules/Collaboration/Domain/Entities/Contract.cs new file mode 100644 index 0000000..3eafe29 --- /dev/null +++ b/src/Modules/Collaboration/Domain/Entities/Contract.cs @@ -0,0 +1,80 @@ +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Collaboration.Domain.Enums; +using Nashel.Modules.Collaboration.Domain.ValueObjects; + +namespace Nashel.Modules.Collaboration.Domain.Entities; + +/// +/// Контракт найма между Компанией и Сотрудником (Мастером). +/// +public class Contract : AggregateRoot +{ + public Guid EmployerId { get; private set; } + public Guid EmployeeId { get; private set; } + public string Role { get; private set; } + public ContractStatus Status { get; private set; } + + private readonly List _vetoLocks = new(); + public IReadOnlyCollection VetoLocks => _vetoLocks.AsReadOnly(); + + private Contract() { } + + private Contract(Guid id, Guid employerId, Guid employeeId, string role) + { + Id = id; + EmployerId = employerId; + EmployeeId = employeeId; + Role = role; + Status = ContractStatus.Active; + } + + public static Contract Create(Guid employerId, Guid employeeId, string role) + { + return new Contract(Guid.NewGuid(), employerId, employeeId, role); + } + + /// + /// Накладывает вето (блокировку) на указанный период. + /// + public void ImposeVeto(DateRange period) + { + if (Status == ContractStatus.Terminated) + { + throw new InvalidOperationException("Нельзя наложить вето на расторгнутый контракт."); + } + + // Проверяем пересечение с существующими блокировками в этом же контракте (опционально, но логично) + var hasOverlap = _vetoLocks.Any(v => v.Period.Overlaps(period)); + if (hasOverlap) + { + // Здесь можно бросать ошибку или просто игнорировать/мерджить. + // Для упрощения: бросаем ошибку, чтобы фронт знал о дублировании. + throw new InvalidOperationException("Период вето пересекается с уже существующей блокировкой в этом контракте."); + } + + _vetoLocks.Add(new VetoLock(Id, period)); + } + + /// + /// Снимает вето (блокировку) на указанный период. + /// Логика удаления по полному совпадению. + /// + public void RemoveVeto(DateRange period) + { + var existingLock = _vetoLocks.FirstOrDefault(v => v.Period == period); + if (existingLock != null) + { + _vetoLocks.Remove(existingLock); + } + } + + /// + /// Завершает контракт. + /// + public void Terminate() + { + Status = ContractStatus.Terminated; + _vetoLocks.Clear(); // При расторжении контракта все блокировки снимаются? + // Допустим, да, так как работодатель больше не имеет прав. + } +} diff --git a/src/Modules/Collaboration/Domain/Entities/VetoLock.cs b/src/Modules/Collaboration/Domain/Entities/VetoLock.cs new file mode 100644 index 0000000..24ff84d --- /dev/null +++ b/src/Modules/Collaboration/Domain/Entities/VetoLock.cs @@ -0,0 +1,23 @@ +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Collaboration.Domain.Enums; +using Nashel.Modules.Collaboration.Domain.ValueObjects; + +namespace Nashel.Modules.Collaboration.Domain.Entities; + +/// +/// Сущестность блокировки (Вето) на доступность сотрудника. +/// +public class VetoLock : Entity +{ + public DateRange Period { get; private set; } + public Guid ContractId { get; private set; } + + private VetoLock() { } + + public VetoLock(Guid contractId, DateRange period) + { + Id = Guid.NewGuid(); + ContractId = contractId; + Period = period; + } +} diff --git a/src/Modules/Collaboration/Domain/Enums/ContractStatus.cs b/src/Modules/Collaboration/Domain/Enums/ContractStatus.cs new file mode 100644 index 0000000..59d2504 --- /dev/null +++ b/src/Modules/Collaboration/Domain/Enums/ContractStatus.cs @@ -0,0 +1,17 @@ +namespace Nashel.Modules.Collaboration.Domain.Enums; + +/// +/// Статус контракта найма. +/// +public enum ContractStatus +{ + /// + /// Контракт активен. + /// + Active, + + /// + /// Контракт расторгнут. + /// + Terminated +} diff --git a/src/Modules/Collaboration/Domain/Repositories/IContractRepository.cs b/src/Modules/Collaboration/Domain/Repositories/IContractRepository.cs new file mode 100644 index 0000000..bd1c2f5 --- /dev/null +++ b/src/Modules/Collaboration/Domain/Repositories/IContractRepository.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Nashel.Modules.Collaboration.Domain.Entities; + +namespace Nashel.Modules.Collaboration.Domain.Repositories +{ + public interface IContractRepository + { + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetAllByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default); + Task AddAsync(Contract contract, CancellationToken cancellationToken = default); + Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default); + } +} diff --git a/src/Modules/Collaboration/Domain/Services/ScheduleConflictService.cs b/src/Modules/Collaboration/Domain/Services/ScheduleConflictService.cs new file mode 100644 index 0000000..d5a0acb --- /dev/null +++ b/src/Modules/Collaboration/Domain/Services/ScheduleConflictService.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Nashel.Modules.Collaboration.Domain.Enums; +using Nashel.Modules.Collaboration.Domain.Repositories; +using Nashel.Modules.Collaboration.Domain.ValueObjects; + +namespace Nashel.Modules.Collaboration.Domain.Services +{ + public class ScheduleConflictService + { + private readonly IContractRepository _repository; + + public ScheduleConflictService(IContractRepository repository) + { + _repository = repository; + } + + /// + /// Проверяет доступность сотрудника, анализируя все активные контракты на наличие блокировок. + /// + /// True - если сотрудник свободен, False - если заблокирован хотя бы одним Вето. + public async Task CheckAvailability(Guid employeeId, DateRange requestedPeriod, CancellationToken ct = default) + { + var contracts = await _repository.GetAllByEmployeeIdAsync(employeeId, ct); + + // Фильтруем только активные контракты + var activeContracts = contracts.Where(c => c.Status == ContractStatus.Active); + + foreach (var contract in activeContracts) + { + // Если есть хоть одно пересекающееся вето - сотрудник занят + if (contract.VetoLocks.Any(veto => veto.Period.Overlaps(requestedPeriod))) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/Modules/Collaboration/Domain/ValueObjects/DateRange.cs b/src/Modules/Collaboration/Domain/ValueObjects/DateRange.cs new file mode 100644 index 0000000..47a11c8 --- /dev/null +++ b/src/Modules/Collaboration/Domain/ValueObjects/DateRange.cs @@ -0,0 +1,21 @@ +using Nashel.BuildingBlocks.Domain; + +namespace Nashel.Modules.Collaboration.Domain.ValueObjects; + +/// +/// Диапазон дат. +/// +public record DateRange(DateTime Start, DateTime End) +{ + private DateRange() : this(default, default) { } // For EF Core + + public bool Overlaps(DateRange other) + { + return Start < other.End && End > other.Start; + } + + public bool Includes(DateTime date) + { + return date >= Start && date <= End; + } +} diff --git a/src/Modules/Collaboration/Infrastructure/DependencyInjection.cs b/src/Modules/Collaboration/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..dcbe12d --- /dev/null +++ b/src/Modules/Collaboration/Infrastructure/DependencyInjection.cs @@ -0,0 +1,35 @@ +using System; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Nashel.Modules.Collaboration.Domain.Repositories; +using Nashel.Modules.Collaboration.Domain.Services; +using Nashel.Modules.Collaboration.Infrastructure.Persistence; +using Nashel.Modules.Collaboration.Infrastructure.Persistence.Repositories; + +namespace Nashel.Modules.Collaboration.Infrastructure +{ + public static class DependencyInjection + { + public static IServiceCollection AddCollaborationModule(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("DefaultConnection"); + + services.AddDbContext(options => + options.UseNpgsql(connectionString)); + + services.AddScoped(); + services.AddScoped(); + + // Register MediatR + services.AddMediatR(cfg => + { + // Register handlers from Application layer + cfg.RegisterServicesFromAssembly(Assembly.Load("Nashel.Modules.Collaboration.Application")); + }); + + return services; + } + } +} diff --git a/src/Modules/Collaboration/Infrastructure/Migrations/20260211111004_InitialCreate.Designer.cs b/src/Modules/Collaboration/Infrastructure/Migrations/20260211111004_InitialCreate.Designer.cs new file mode 100644 index 0000000..7da51d4 --- /dev/null +++ b/src/Modules/Collaboration/Infrastructure/Migrations/20260211111004_InitialCreate.Designer.cs @@ -0,0 +1,104 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Collaboration.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Collaboration.Infrastructure.Migrations +{ + [DbContext(typeof(CollaborationDbContext))] + [Migration("20260211111004_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("collaboration") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Collaboration.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EmployerId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contracts", "collaboration"); + }); + + modelBuilder.Entity("Nashel.Modules.Collaboration.Domain.Entities.Contract", b => + { + b.OwnsMany("Nashel.Modules.Collaboration.Domain.Entities.VetoLock", "VetoLocks", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("ContractId") + .HasColumnType("uuid"); + + b1.HasKey("Id"); + + b1.HasIndex("ContractId"); + + b1.ToTable("VetoLocks", "collaboration"); + + b1.WithOwner() + .HasForeignKey("ContractId"); + + b1.OwnsOne("Nashel.Modules.Collaboration.Domain.ValueObjects.DateRange", "Period", b2 => + { + b2.Property("VetoLockId") + .HasColumnType("uuid"); + + b2.Property("End") + .HasColumnType("timestamp with time zone") + .HasColumnName("VetoEnd"); + + b2.Property("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("VetoStart"); + + b2.HasKey("VetoLockId"); + + b2.ToTable("VetoLocks", "collaboration"); + + b2.WithOwner() + .HasForeignKey("VetoLockId"); + }); + + b1.Navigation("Period") + .IsRequired(); + }); + + b.Navigation("VetoLocks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Collaboration/Infrastructure/Migrations/20260211111004_InitialCreate.cs b/src/Modules/Collaboration/Infrastructure/Migrations/20260211111004_InitialCreate.cs new file mode 100644 index 0000000..7dbc97b --- /dev/null +++ b/src/Modules/Collaboration/Infrastructure/Migrations/20260211111004_InitialCreate.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Collaboration.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "collaboration"); + + migrationBuilder.CreateTable( + name: "Contracts", + schema: "collaboration", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + EmployerId = table.Column(type: "uuid", nullable: false), + EmployeeId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Contracts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "VetoLocks", + schema: "collaboration", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + VetoStart = table.Column(type: "timestamp with time zone", nullable: false), + VetoEnd = table.Column(type: "timestamp with time zone", nullable: false), + ContractId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VetoLocks", x => x.Id); + table.ForeignKey( + name: "FK_VetoLocks_Contracts_ContractId", + column: x => x.ContractId, + principalSchema: "collaboration", + principalTable: "Contracts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_VetoLocks_ContractId", + schema: "collaboration", + table: "VetoLocks", + column: "ContractId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "VetoLocks", + schema: "collaboration"); + + migrationBuilder.DropTable( + name: "Contracts", + schema: "collaboration"); + } + } +} diff --git a/src/Modules/Collaboration/Infrastructure/Migrations/CollaborationDbContextModelSnapshot.cs b/src/Modules/Collaboration/Infrastructure/Migrations/CollaborationDbContextModelSnapshot.cs new file mode 100644 index 0000000..06d93d1 --- /dev/null +++ b/src/Modules/Collaboration/Infrastructure/Migrations/CollaborationDbContextModelSnapshot.cs @@ -0,0 +1,101 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Collaboration.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Collaboration.Infrastructure.Migrations +{ + [DbContext(typeof(CollaborationDbContext))] + partial class CollaborationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("collaboration") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Collaboration.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EmployerId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contracts", "collaboration"); + }); + + modelBuilder.Entity("Nashel.Modules.Collaboration.Domain.Entities.Contract", b => + { + b.OwnsMany("Nashel.Modules.Collaboration.Domain.Entities.VetoLock", "VetoLocks", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("ContractId") + .HasColumnType("uuid"); + + b1.HasKey("Id"); + + b1.HasIndex("ContractId"); + + b1.ToTable("VetoLocks", "collaboration"); + + b1.WithOwner() + .HasForeignKey("ContractId"); + + b1.OwnsOne("Nashel.Modules.Collaboration.Domain.ValueObjects.DateRange", "Period", b2 => + { + b2.Property("VetoLockId") + .HasColumnType("uuid"); + + b2.Property("End") + .HasColumnType("timestamp with time zone") + .HasColumnName("VetoEnd"); + + b2.Property("Start") + .HasColumnType("timestamp with time zone") + .HasColumnName("VetoStart"); + + b2.HasKey("VetoLockId"); + + b2.ToTable("VetoLocks", "collaboration"); + + b2.WithOwner() + .HasForeignKey("VetoLockId"); + }); + + b1.Navigation("Period") + .IsRequired(); + }); + + b.Navigation("VetoLocks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Collaboration/Infrastructure/Persistence/CollaborationDbContext.cs b/src/Modules/Collaboration/Infrastructure/Persistence/CollaborationDbContext.cs new file mode 100644 index 0000000..000ae0f --- /dev/null +++ b/src/Modules/Collaboration/Infrastructure/Persistence/CollaborationDbContext.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Collaboration.Domain.Entities; + +namespace Nashel.Modules.Collaboration.Infrastructure.Persistence +{ + public class CollaborationDbContext : DbContext + { + public DbSet Contracts { get; set; } + + public CollaborationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("collaboration"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(CollaborationDbContext).Assembly); + base.OnModelCreating(modelBuilder); + } + } +} diff --git a/src/Modules/Collaboration/Infrastructure/Persistence/Configurations/ContractConfiguration.cs b/src/Modules/Collaboration/Infrastructure/Persistence/Configurations/ContractConfiguration.cs new file mode 100644 index 0000000..71a6954 --- /dev/null +++ b/src/Modules/Collaboration/Infrastructure/Persistence/Configurations/ContractConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Nashel.Modules.Collaboration.Domain.Entities; + +namespace Nashel.Modules.Collaboration.Infrastructure.Persistence.Configurations +{ + public class ContractConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.Property(c => c.Role).IsRequired(); + builder.Property(c => c.Status).HasConversion(); + + // Настройка VetoLocks как Owned Collection (или таблицы связи) + // Вариант с отдельной таблицей "VetoLocks" + builder.OwnsMany(c => c.VetoLocks, a => + { + a.ToTable("VetoLocks"); + a.HasKey("Id"); // Теневое свойство или явное + a.WithOwner().HasForeignKey("ContractId"); + + a.OwnsOne(v => v.Period, p => + { + p.Property(d => d.Start).HasColumnName("VetoStart"); + p.Property(d => d.End).HasColumnName("VetoEnd"); + }); + }); + } + } +} diff --git a/src/Modules/Collaboration/Infrastructure/Persistence/Repositories/ContractRepository.cs b/src/Modules/Collaboration/Infrastructure/Persistence/Repositories/ContractRepository.cs new file mode 100644 index 0000000..12e892e --- /dev/null +++ b/src/Modules/Collaboration/Infrastructure/Persistence/Repositories/ContractRepository.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Collaboration.Domain.Entities; +using Nashel.Modules.Collaboration.Domain.Repositories; + +namespace Nashel.Modules.Collaboration.Infrastructure.Persistence.Repositories +{ + public class ContractRepository : IContractRepository + { + private readonly CollaborationDbContext _context; + + public ContractRepository(CollaborationDbContext context) + { + _context = context; + } + + public async Task AddAsync(Contract contract, CancellationToken cancellationToken = default) + { + await _context.Contracts.AddAsync(contract, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task> GetAllByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default) + { + return await _context.Contracts + .Include(c => c.VetoLocks) + .Where(c => c.EmployeeId == employeeId) + .ToListAsync(cancellationToken); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Contracts + .Include(c => c.VetoLocks) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } + + public async Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default) + { + _context.Contracts.Update(contract); + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/src/Modules/Collaboration/Presentation/CollaborationEndpoints.cs b/src/Modules/Collaboration/Presentation/CollaborationEndpoints.cs new file mode 100644 index 0000000..db550a5 --- /dev/null +++ b/src/Modules/Collaboration/Presentation/CollaborationEndpoints.cs @@ -0,0 +1,94 @@ +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Nashel.Modules.Collaboration.Application.Commands; +using Nashel.Modules.Collaboration.Application.Queries; + +namespace Nashel.Modules.Collaboration.Presentation +{ + public static class CollaborationEndpoints + { + public static void MapCollaborationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/hr").WithTags("Collaboration (HR)"); + + // POST /api/hr/hire + group.MapPost("/hire", async ([FromBody] HireEmployeeRequest request, ISender sender) => + { + var command = new HireEmployeeCommand(request.EmployerId, request.EmployeeId, request.Role); + var contractId = await sender.Send(command); + return Results.Ok(contractId); + }) + .WithName("HireEmployee") + .WithOpenApi(operation => new(operation) { Summary = "Нанять сотрудника", Description = "Создает контракт между работодателем и сотрудником." }); + + // POST /api/hr/veto + group.MapPost("/veto", async ([FromBody] SetVetoRequest request, ISender sender) => + { + var command = new SetVetoCommand(request.ContractId, request.Start, request.End); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("SetVeto") + .WithOpenApi(operation => new(operation) { Summary = "Наложить вето", Description = "Блокирует расписание сотрудника на указанный период." }); + + // GET /api/hr/check-availability + group.MapGet("/check-availability", async ( + [FromQuery] Guid employeeId, + [FromQuery] DateTime start, + [FromQuery] DateTime end, + ISender sender) => + { + var query = new CheckAvailabilityQuery(employeeId, start, end); + var isAvailable = await sender.Send(query); + return Results.Ok(new { IsAvailable = isAvailable }); + }) + .WithName("CheckAvailability") + .WithOpenApi(operation => new(operation) { Summary = "Проверить доступность", Description = "Проверяет, свободен ли сотрудник в указанный период (нет ли вето)." }); + } + } + + /// + /// Запрос на найм сотрудника. + /// + public record HireEmployeeRequest + { + /// + /// ID работодателя. + /// + public Guid EmployerId { get; init; } + + /// + /// ID сотрудника. + /// + public Guid EmployeeId { get; init; } + + /// + /// Роль сотрудника (прораб, мастер и т.д.). + /// + public string Role { get; init; } = default!; + } + + /// + /// Запрос на установку вето. + /// + public record SetVetoRequest + { + /// + /// ID контракта. + /// + public Guid ContractId { get; init; } + + /// + /// Начало периода блокировки. + /// + public DateTime Start { get; init; } + + /// + /// Конец периода блокировки. + /// + public DateTime End { get; init; } + } +} diff --git a/src/Modules/Collaboration/Presentation/Nashel.Modules.Collaboration.Presentation.csproj b/src/Modules/Collaboration/Presentation/Nashel.Modules.Collaboration.Presentation.csproj index 44f45b7..9a66338 100644 --- a/src/Modules/Collaboration/Presentation/Nashel.Modules.Collaboration.Presentation.csproj +++ b/src/Modules/Collaboration/Presentation/Nashel.Modules.Collaboration.Presentation.csproj @@ -7,5 +7,14 @@ net10.0 enable enable + true + $(NoWarn);1591 + + + + + + + \ No newline at end of file diff --git a/src/Modules/Collaboration/Tests/Domain/ContractTests.cs b/src/Modules/Collaboration/Tests/Domain/ContractTests.cs new file mode 100644 index 0000000..64d6187 --- /dev/null +++ b/src/Modules/Collaboration/Tests/Domain/ContractTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using Nashel.Modules.Collaboration.Domain.Entities; +using Nashel.Modules.Collaboration.Domain.ValueObjects; +using Xunit; + +namespace Nashel.Modules.Collaboration.Tests.Domain; + +public class ContractTests +{ + [Fact] + public void ImposeVeto_Should_AddLock_When_ContractIsActive() + { + // Arrange + var contract = Contract.Create(Guid.NewGuid(), Guid.NewGuid(), "Прораб"); + var period = new DateRange(DateTime.UtcNow, DateTime.UtcNow.AddDays(1)); + + // Act + contract.ImposeVeto(period); + + // Assert + contract.VetoLocks.Should().ContainSingle() + .Which.Period.Should().Be(period); + } + + [Fact] + public void ImposeVeto_Should_Fail_When_ContractIsTerminated() + { + // Arrange + var contract = Contract.Create(Guid.NewGuid(), Guid.NewGuid(), "Прораб"); + contract.Terminate(); + var period = new DateRange(DateTime.UtcNow, DateTime.UtcNow.AddDays(1)); + + // Act + var action = () => contract.ImposeVeto(period); + + // Assert + action.Should().Throw() + .WithMessage("Нельзя наложить вето на расторгнутый контракт."); + } + + [Fact] + public void Contract_Should_Be_Active_Upon_Creation() + { + // Arrange & Act + var contract = Contract.Create(Guid.NewGuid(), Guid.NewGuid(), "Мастер"); + + // Assert + contract.Status.Should().Be(Nashel.Modules.Collaboration.Domain.Enums.ContractStatus.Active); + } +} diff --git a/src/Modules/Collaboration/Tests/Services/ScheduleConflictServiceTests.cs b/src/Modules/Collaboration/Tests/Services/ScheduleConflictServiceTests.cs new file mode 100644 index 0000000..8227942 --- /dev/null +++ b/src/Modules/Collaboration/Tests/Services/ScheduleConflictServiceTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using Moq; +using Nashel.Modules.Collaboration.Domain.Entities; +using Nashel.Modules.Collaboration.Domain.Repositories; +using Nashel.Modules.Collaboration.Domain.Services; +using Nashel.Modules.Collaboration.Domain.ValueObjects; +using Xunit; + +namespace Nashel.Modules.Collaboration.Tests.Services; + +public class ScheduleConflictServiceTests +{ + private readonly Mock _repositoryMock; + private readonly ScheduleConflictService _service; + + public ScheduleConflictServiceTests() + { + _repositoryMock = new Mock(); + _service = new ScheduleConflictService(_repositoryMock.Object); + } + + [Fact] + public async Task CheckAvailability_Should_ReturnFailure_When_VetoOverlaps() + { + // 1. Arrange + var employeeId = Guid.NewGuid(); + var employerId = Guid.NewGuid(); + + // Создаем контракты + var contractA = Contract.Create(employerId, employeeId, "Плиточник"); + // Блокировка 01.05 - 05.05 + var vetoStart = new DateTime(2025, 5, 1); + var vetoEnd = new DateTime(2025, 5, 5); + contractA.ImposeVeto(new DateRange(vetoStart, vetoEnd)); + + var contractB = Contract.Create(Guid.NewGuid(), employeeId, "Электрик"); + + // Мокаем репозиторий + _repositoryMock.Setup(r => r.GetAllByEmployeeIdAsync(employeeId, It.IsAny())) + .ReturnsAsync(new List { contractA, contractB }); + + // Период проверки: 03.05 (попадает в диапазон) + var requestedDate = new DateTime(2025, 5, 3); + var requestedPeriod = new DateRange(requestedDate, requestedDate.AddHours(4)); + + // 2. Act + var isAvailable = await _service.CheckAvailability(employeeId, requestedPeriod); + + // 3. Assert + isAvailable.Should().BeFalse("Сотрудник должен быть занят из-за Вето"); + } + + [Fact] + public async Task CheckAvailability_Should_ReturnSuccess_When_NoVetoOverlap() + { + // 1. Arrange + var employeeId = Guid.NewGuid(); + var employerId = Guid.NewGuid(); + + var contractA = Contract.Create(employerId, employeeId, "Плиточник"); + // Блокировка 01.05 - 05.05 + var vetoStart = new DateTime(2025, 5, 1); + var vetoEnd = new DateTime(2025, 5, 5); + contractA.ImposeVeto(new DateRange(vetoStart, vetoEnd)); + + _repositoryMock.Setup(r => r.GetAllByEmployeeIdAsync(employeeId, It.IsAny())) + .ReturnsAsync(new List { contractA }); + + // Период проверки: 06.05 (Свободен) + var requestedDate = new DateTime(2025, 5, 6); + var requestedPeriod = new DateRange(requestedDate, requestedDate.AddHours(4)); + + // 2. Act + var isAvailable = await _service.CheckAvailability(employeeId, requestedPeriod); + + // 3. Assert + isAvailable.Should().BeTrue("Сотрудник должен быть доступен за пределами Вето"); + } +} diff --git a/src/Modules/Collaboration/Tests/Tests.csproj b/src/Modules/Collaboration/Tests/Tests.csproj new file mode 100644 index 0000000..48e48fb --- /dev/null +++ b/src/Modules/Collaboration/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + +