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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+