Модуль коллаборации
This commit is contained in:
@@ -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/
|
||||
|
||||
+18
@@ -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
|
||||
|
||||
+5
-1
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+104
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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; }
|
||||
}
|
||||
}
|
||||
+9
@@ -7,5 +7,14 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user