Модуль коллаборации

This commit is contained in:
Халимов Рустам
2026-02-11 14:25:13 +03:00
parent b7ba835689
commit d6c1f63842
24 changed files with 1005 additions and 1 deletions
+4
View File
@@ -150,3 +150,7 @@ src/Modules/Geo/Presentation/bin/
src/Modules/Order/Tests/bin/ src/Modules/Order/Tests/bin/
src/Modules/Order/Tests/obj/ src/Modules/Order/Tests/obj/
src/Modules/Collaboration/Tests/bin/
src/Modules/Collaboration/Tests/obj/
+18
View File
@@ -95,6 +95,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{FB27511E
EndProject 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}" 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 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{DC1E99E0-6B92-4FBD-B74B-68D202961FB7}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -487,5 +503,7 @@ Global
{D0003F1F-EE71-3913-8EC0-5F1638807A8D} = {2C821288-5B9D-778E-74EE-053FE87F223E} {D0003F1F-EE71-3913-8EC0-5F1638807A8D} = {2C821288-5B9D-778E-74EE-053FE87F223E}
{FB27511E-CBA6-E343-8CE0-54AAD98AFC9F} = {2C821288-5B9D-778E-74EE-053FE87F223E} {FB27511E-CBA6-E343-8CE0-54AAD98AFC9F} = {2C821288-5B9D-778E-74EE-053FE87F223E}
{DC1E99E0-6B92-4FBD-B74B-68D202961FB7} = {FB27511E-CBA6-E343-8CE0-54AAD98AFC9F} {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 EndGlobalSection
EndGlobal EndGlobal
+5 -1
View File
@@ -10,6 +10,8 @@ using Nashel.Modules.Identity.Infrastructure;
using Nashel.Modules.Identity.Presentation.Endpoints; using Nashel.Modules.Identity.Presentation.Endpoints;
using Nashel.Modules.Order.Infrastructure; using Nashel.Modules.Order.Infrastructure;
using Nashel.Modules.Order.Presentation; using Nashel.Modules.Order.Presentation;
using Nashel.Modules.Collaboration.Infrastructure;
using Nashel.Modules.Collaboration.Presentation;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -61,7 +63,8 @@ builder.Services.AddCatalogModule(builder.Configuration);
builder.Services.AddGeoModule(builder.Configuration); builder.Services.AddGeoModule(builder.Configuration);
// Регистрация модуля Order // Регистрация модуля Order
builder.Services.AddOrderModule(builder.Configuration); // Регистрация модуля Collaboartion
builder.Services.AddCollaborationModule(builder.Configuration);
// Настройка аутентификации // Настройка аутентификации
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
@@ -102,5 +105,6 @@ app.MapIdentityEndpoints();
app.MapCatalogEndpoints(); app.MapCatalogEndpoints();
app.MapGeoEndpoints(); app.MapGeoEndpoints();
app.MapOrderEndpoints(); app.MapOrderEndpoints();
app.MapCollaborationEndpoints();
app.Run(); app.Run();
@@ -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
{
/// <summary>
/// Команда найма сотрудника (создание контракта).
/// </summary>
public record HireEmployeeCommand(Guid EmployerId, Guid EmployeeId, string Role) : IRequest<Guid>;
public class HireEmployeeHandler : IRequestHandler<HireEmployeeCommand, Guid>
{
private readonly IContractRepository _repository;
public HireEmployeeHandler(IContractRepository repository)
{
_repository = repository;
}
public async Task<Guid> Handle(HireEmployeeCommand request, CancellationToken cancellationToken)
{
// Здесь можно добавить проверку, не существует ли уже активный контракт между этими сторонами
// Но пока просто создаем новый.
var contract = Contract.Create(request.EmployerId, request.EmployeeId, request.Role);
await _repository.AddAsync(contract, cancellationToken);
return contract.Id;
}
}
}
@@ -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
{
/// <summary>
/// Команда установки вето (блокировки) на контракт.
/// </summary>
public record SetVetoCommand(Guid ContractId, DateTime Start, DateTime End) : IRequest;
public class SetVetoHandler : IRequestHandler<SetVetoCommand>
{
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);
}
}
}
@@ -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
{
/// <summary>
/// Проверка доступности сотрудника (Query).
/// </summary>
public record CheckAvailabilityQuery(Guid EmployeeId, DateTime Start, DateTime End) : IRequest<bool>;
public class CheckAvailabilityHandler : IRequestHandler<CheckAvailabilityQuery, bool>
{
private readonly ScheduleConflictService _conflictService;
public CheckAvailabilityHandler(ScheduleConflictService conflictService)
{
_conflictService = conflictService;
}
public async Task<bool> Handle(CheckAvailabilityQuery request, CancellationToken cancellationToken)
{
var range = new DateRange(request.Start, request.End);
return await _conflictService.CheckAvailability(request.EmployeeId, range, cancellationToken);
}
}
}
@@ -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;
/// <summary>
/// Контракт найма между Компанией и Сотрудником (Мастером).
/// </summary>
public class Contract : AggregateRoot<Guid>
{
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<VetoLock> _vetoLocks = new();
public IReadOnlyCollection<VetoLock> 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);
}
/// <summary>
/// Накладывает вето (блокировку) на указанный период.
/// </summary>
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));
}
/// <summary>
/// Снимает вето (блокировку) на указанный период.
/// Логика удаления по полному совпадению.
/// </summary>
public void RemoveVeto(DateRange period)
{
var existingLock = _vetoLocks.FirstOrDefault(v => v.Period == period);
if (existingLock != null)
{
_vetoLocks.Remove(existingLock);
}
}
/// <summary>
/// Завершает контракт.
/// </summary>
public void Terminate()
{
Status = ContractStatus.Terminated;
_vetoLocks.Clear(); // При расторжении контракта все блокировки снимаются?
// Допустим, да, так как работодатель больше не имеет прав.
}
}
@@ -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;
/// <summary>
/// Сущестность блокировки (Вето) на доступность сотрудника.
/// </summary>
public class VetoLock : Entity<Guid>
{
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;
}
}
@@ -0,0 +1,17 @@
namespace Nashel.Modules.Collaboration.Domain.Enums;
/// <summary>
/// Статус контракта найма.
/// </summary>
public enum ContractStatus
{
/// <summary>
/// Контракт активен.
/// </summary>
Active,
/// <summary>
/// Контракт расторгнут.
/// </summary>
Terminated
}
@@ -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<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<List<Contract>> GetAllByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default);
Task AddAsync(Contract contract, CancellationToken cancellationToken = default);
Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default);
}
}
@@ -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;
}
/// <summary>
/// Проверяет доступность сотрудника, анализируя все активные контракты на наличие блокировок.
/// </summary>
/// <returns>True - если сотрудник свободен, False - если заблокирован хотя бы одним Вето.</returns>
public async Task<bool> 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;
}
}
}
@@ -0,0 +1,21 @@
using Nashel.BuildingBlocks.Domain;
namespace Nashel.Modules.Collaboration.Domain.ValueObjects;
/// <summary>
/// Диапазон дат.
/// </summary>
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;
}
}
@@ -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<CollaborationDbContext>(options =>
options.UseNpgsql(connectionString));
services.AddScoped<IContractRepository, ContractRepository>();
services.AddScoped<ScheduleConflictService>();
// Register MediatR
services.AddMediatR(cfg =>
{
// Register handlers from Application layer
cfg.RegisterServicesFromAssembly(Assembly.Load("Nashel.Modules.Collaboration.Application"));
});
return services;
}
}
}
@@ -0,0 +1,104 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("EmployeeId")
.HasColumnType("uuid");
b.Property<Guid>("EmployerId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("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<Guid>("VetoLockId")
.HasColumnType("uuid");
b2.Property<DateTime>("End")
.HasColumnType("timestamp with time zone")
.HasColumnName("VetoEnd");
b2.Property<DateTime>("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
}
}
}
@@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Collaboration.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "collaboration");
migrationBuilder.CreateTable(
name: "Contracts",
schema: "collaboration",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
EmployerId = table.Column<Guid>(type: "uuid", nullable: false),
EmployeeId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
VetoStart = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
VetoEnd = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ContractId = table.Column<Guid>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VetoLocks",
schema: "collaboration");
migrationBuilder.DropTable(
name: "Contracts",
schema: "collaboration");
}
}
}
@@ -0,0 +1,101 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("EmployeeId")
.HasColumnType("uuid");
b.Property<Guid>("EmployerId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("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<Guid>("VetoLockId")
.HasColumnType("uuid");
b2.Property<DateTime>("End")
.HasColumnType("timestamp with time zone")
.HasColumnName("VetoEnd");
b2.Property<DateTime>("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
}
}
}
@@ -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<Contract> Contracts { get; set; }
public CollaborationDbContext(DbContextOptions<CollaborationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("collaboration");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(CollaborationDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}
}
@@ -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<Contract>
{
public void Configure(EntityTypeBuilder<Contract> builder)
{
builder.HasKey(c => c.Id);
builder.Property(c => c.Role).IsRequired();
builder.Property(c => c.Status).HasConversion<string>();
// Настройка 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");
});
});
}
}
}
@@ -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<List<Contract>> GetAllByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default)
{
return await _context.Contracts
.Include(c => c.VetoLocks)
.Where(c => c.EmployeeId == employeeId)
.ToListAsync(cancellationToken);
}
public async Task<Contract?> 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);
}
}
}
@@ -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 = "Проверяет, свободен ли сотрудник в указанный период (нет ли вето)." });
}
}
/// <summary>
/// Запрос на найм сотрудника.
/// </summary>
public record HireEmployeeRequest
{
/// <summary>
/// ID работодателя.
/// </summary>
public Guid EmployerId { get; init; }
/// <summary>
/// ID сотрудника.
/// </summary>
public Guid EmployeeId { get; init; }
/// <summary>
/// Роль сотрудника (прораб, мастер и т.д.).
/// </summary>
public string Role { get; init; } = default!;
}
/// <summary>
/// Запрос на установку вето.
/// </summary>
public record SetVetoRequest
{
/// <summary>
/// ID контракта.
/// </summary>
public Guid ContractId { get; init; }
/// <summary>
/// Начало периода блокировки.
/// </summary>
public DateTime Start { get; init; }
/// <summary>
/// Конец периода блокировки.
/// </summary>
public DateTime End { get; init; }
}
}
@@ -7,5 +7,14 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="14.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4" />
</ItemGroup>
</Project> </Project>
@@ -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<InvalidOperationException>()
.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);
}
}
@@ -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<IContractRepository> _repositoryMock;
private readonly ScheduleConflictService _service;
public ScheduleConflictServiceTests()
{
_repositoryMock = new Mock<IContractRepository>();
_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<CancellationToken>()))
.ReturnsAsync(new List<Contract> { 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<CancellationToken>()))
.ReturnsAsync(new List<Contract> { 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("Сотрудник должен быть доступен за пределами Вето");
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Nashel.Modules.Collaboration.Domain.csproj" />
<ProjectReference Include="..\Application\Nashel.Modules.Collaboration.Application.csproj" />
</ItemGroup>
</Project>