diff --git a/.github/workflows/dotnet_tests.yml b/.github/workflows/dotnet_tests.yml new file mode 100644 index 000000000..1f55f21a3 --- /dev/null +++ b/.github/workflows/dotnet_tests.yml @@ -0,0 +1,28 @@ +name: .NET Tests + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore dependencies + run: dotnet restore Polyclinic/Polyclinic.sln + + - name: Build + run: dotnet build Polyclinic/Polyclinic.sln --no-restore --configuration Release + + - name: Test + run: dotnet test Polyclinic/Polyclinic.sln --no-build --configuration Release --verbosity normal \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Api.Host/Controllers/AnalyticsController.cs b/Polyclinic/Polyclinic.Api.Host/Controllers/AnalyticsController.cs new file mode 100644 index 000000000..ee88fceb5 --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/Controllers/AnalyticsController.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Mvc; +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Analytics; +using Polyclinic.Application.Contracts.Appointments; +using Polyclinic.Application.Contracts.Doctors; +using Polyclinic.Application.Contracts.Patients; + +namespace Polyclinic.Api.Host.Controllers; + +/// +/// Контроллер для получения аналитических отчетов и выборок +/// +[ApiController] +[Route("api/[controller]")] +public class AnalyticsController(IAnalyticsService analyticsService) : ControllerBase +{ + /// + /// Получить врачей со стажем работы более указанного (по умолчанию 10 лет) + /// + /// Минимальный стаж в годах + [HttpGet("doctors/experienced")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetExperiencedDoctors([FromQuery] int minExperience = 10) + { + try + { + var result = await analyticsService.GetDoctorsWithExperienceAsync(minExperience); + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } + + /// + /// Получить список пациентов, записанных к конкретному врачу, отсортированный по ФИО + /// + /// Идентификатор врача + [HttpGet("doctors/{doctorId}/patients")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetPatientsByDoctor(int doctorId) + { + try + { + var result = await analyticsService.GetPatientsByDoctorAsync(doctorId); + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } + + /// + /// Получить статистику повторных приемов за указанный месяц + /// + /// Дата для определения месяца и года + [HttpGet("appointments/stats/monthly")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetMonthlyStats([FromQuery] DateTime date) + { + try + { + var result = await analyticsService.GetMonthlyRepeatStatsAsync(date); + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } + + /// + /// Получить пациентов старше указанного возраста (по умолчанию 30), посетивших более одного врача + /// + /// Минимальный возраст + [HttpGet("patients/active-visitors")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetPatientsWithMultipleDoctors([FromQuery] int minAge = 30) + { + try + { + var result = await analyticsService.GetPatientsWithMultipleDoctorsAsync(minAge); + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } + + /// + /// Получить список приемов в конкретном кабинете за месяц + /// + /// Номер кабинета + /// Дата для определения месяца выборки + [HttpGet("rooms/{roomNumber}/appointments")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetAppointmentsByRoom(string roomNumber, [FromQuery] DateTime date) + { + try + { + var result = await analyticsService.GetAppointmentsByRoomAsync(roomNumber, date); + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Api.Host/Controllers/AppointmentController.cs b/Polyclinic/Polyclinic.Api.Host/Controllers/AppointmentController.cs new file mode 100644 index 000000000..ab46376ee --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/Controllers/AppointmentController.cs @@ -0,0 +1,11 @@ +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Appointments; + +namespace Polyclinic.Api.Host.Controllers; + +/// +/// Контроллер для управления записями на прием +/// +public class AppointmentsController( + IApplicationService service) + : CrudControllerBase(service); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Api.Host/Controllers/CrudControllerBase.cs b/Polyclinic/Polyclinic.Api.Host/Controllers/CrudControllerBase.cs new file mode 100644 index 000000000..10f43ada2 --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/Controllers/CrudControllerBase.cs @@ -0,0 +1,137 @@ +using Microsoft.AspNetCore.Mvc; +using Polyclinic.Application.Contracts; + +namespace Polyclinic.Api.Host.Controllers; + +/// +/// Базовый абстрактный контроллер, реализующий стандартные CRUD-операции +/// +/// Тип DTO для чтения +/// Тип DTO для создания и обновления +/// Тип идентификатора сущности +[ApiController] +[Route("api/[controller]")] +public abstract class CrudControllerBase( + IApplicationService service) + : ControllerBase + where TDto : class + where TCreateUpdateDto : class + where TKey : struct +{ + /// + /// Получает список всех сущностей + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task>> GetAll() + { + try + { + var result = await service.GetAll(); + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } + + /// + /// Получает сущность по идентификатору + /// + /// Идентификатор сущности + [HttpGet("{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task> Get(TKey id) + { + try + { + var result = await service.Get(id); + if (result == null) return NotFound(); + + return Ok(result); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { error = ex.Message }); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } + + /// + /// Создает новую сущность + /// + /// DTO с данными для создания + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task> Create([FromBody] TCreateUpdateDto dto) + { + try + { + var result = await service.Create(dto); + return CreatedAtAction(nameof(this.Create), result); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { error = ex.Message }); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } + + /// + /// Обновляет существующую сущность + /// + /// Идентификатор обновляемой сущности + /// DTO с новыми данными + [HttpPut("{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task> Update(TKey id, [FromBody] TCreateUpdateDto dto) + { + try + { + var result = await service.Update(dto, id); + return Ok(result); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { error = ex.Message }); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } + + /// + /// Удаляет сущность по идентификатору + /// + /// Идентификатор удаляемой сущности + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task Delete(TKey id) + { + try + { + await service.Delete(id); + return NoContent(); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Api.Host/Controllers/DoctorController.cs b/Polyclinic/Polyclinic.Api.Host/Controllers/DoctorController.cs new file mode 100644 index 000000000..9def16d00 --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/Controllers/DoctorController.cs @@ -0,0 +1,11 @@ +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Doctors; + +namespace Polyclinic.Api.Host.Controllers; + +/// +/// Контроллер для управления данными врачей +/// +public class DoctorController( + IApplicationService service) + : CrudControllerBase(service); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Api.Host/Controllers/PatientController.cs b/Polyclinic/Polyclinic.Api.Host/Controllers/PatientController.cs new file mode 100644 index 000000000..0d01d2a3d --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/Controllers/PatientController.cs @@ -0,0 +1,11 @@ +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Patients; + +namespace Polyclinic.Api.Host.Controllers; + +/// +/// Контроллер для управления данными пациентов +/// +public class PatientsController( + IApplicationService service) + : CrudControllerBase(service); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Api.Host/Controllers/SpecializationController.cs b/Polyclinic/Polyclinic.Api.Host/Controllers/SpecializationController.cs new file mode 100644 index 000000000..3c3d95eb6 --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/Controllers/SpecializationController.cs @@ -0,0 +1,11 @@ +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Specializations; + +namespace Polyclinic.Api.Host.Controllers; + +/// +/// Контроллер для управления справочником специализаций +/// +public class SpecializationController( + IApplicationService service) + : CrudControllerBase(service); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Api.Host/Polyclinic.Api.Host.csproj b/Polyclinic/Polyclinic.Api.Host/Polyclinic.Api.Host.csproj new file mode 100644 index 000000000..e8741d67d --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/Polyclinic.Api.Host.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/Polyclinic/Polyclinic.Api.Host/Program.cs b/Polyclinic/Polyclinic.Api.Host/Program.cs new file mode 100644 index 000000000..c5058d856 --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/Program.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore; +using Polyclinic.Application; +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Appointments; +using Polyclinic.Application.Contracts.Doctors; +using Polyclinic.Application.Contracts.Patients; +using Polyclinic.Application.Contracts.Specializations; +using Polyclinic.Application.Services; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; +using Polyclinic.Infrastructure.EfCore; +using Polyclinic.Infrastructure.EfCore.Repositories; +using Polyclinic.Infrastructure.RabbitMq; +using Polyclinic.ServiceDefaults; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAutoMapper(config => +{ + config.AddProfile(new PolyclinicProfile()); +}); + +builder.Services.AddSingleton(); + +builder.AddServiceDefaults(); + +builder.Services.AddTransient, SpecializationRepository>(); +builder.Services.AddTransient, PatientRepository>(); +builder.Services.AddTransient, DoctorRepository>(); +builder.Services.AddTransient, AppointmentRepository>(); + +builder.Services.AddScoped(); +builder.Services.AddScoped, SpecializationService>(); +builder.Services.AddScoped, PatientService>(); +builder.Services.AddScoped, DoctorService>(); +builder.Services.AddScoped, AppointmentService>(); + +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + var assemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.GetName().Name!.StartsWith("Polyclinic")) + .Distinct(); + + foreach (var assembly in assemblies) + { + var xmlFile = $"{assembly.GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + c.IncludeXmlComments(xmlPath); + } + + c.UseInlineDefinitionsForEnums(); +}); + +builder.AddSqlServerDbContext("DatabaseConnectionString"); + +builder.AddRabbitMQClient("rabbitMqConnection"); + +builder.Services.Configure(builder.Configuration.GetSection("RabbitMq")); + +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/Polyclinic/Polyclinic.Api.Host/Properties/launchSettings.json b/Polyclinic/Polyclinic.Api.Host/Properties/launchSettings.json new file mode 100644 index 000000000..6c75e264a --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:20801", + "sslPort": 44366 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5087", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7184;http://localhost:5087", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Polyclinic/Polyclinic.Api.Host/appsettings.Development.json b/Polyclinic/Polyclinic.Api.Host/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Polyclinic/Polyclinic.Api.Host/appsettings.json b/Polyclinic/Polyclinic.Api.Host/appsettings.json new file mode 100644 index 000000000..222e0e158 --- /dev/null +++ b/Polyclinic/Polyclinic.Api.Host/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "RabbitMq": { + "QueueName": "appointment-contracts", + "RetryCount": 5, + "RetryDelayMs": 1000 + } +} diff --git a/Polyclinic/Polyclinic.AppHost/AppHost.cs b/Polyclinic/Polyclinic.AppHost/AppHost.cs new file mode 100644 index 000000000..15693d94b --- /dev/null +++ b/Polyclinic/Polyclinic.AppHost/AppHost.cs @@ -0,0 +1,19 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var sqlDb = builder.AddSqlServer("polyclinic-sql-server") + .AddDatabase("PolyclinicDb"); + +var rabbitmq = builder.AddRabbitMQ("rabbitMqConnection") + .WithManagementPlugin(); + +builder.AddProject("polyclinic-api-host") + .WithReference(sqlDb, "DatabaseConnectionString") + .WithReference(rabbitmq) + .WaitFor(sqlDb) + .WaitFor(rabbitmq); + +builder.AddProject("polyclinic-generator") + .WithReference(rabbitmq) + .WaitFor(rabbitmq); + +builder.Build().Run(); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.AppHost/Polyclinic.AppHost.csproj b/Polyclinic/Polyclinic.AppHost/Polyclinic.AppHost.csproj new file mode 100644 index 000000000..d2527de5c --- /dev/null +++ b/Polyclinic/Polyclinic.AppHost/Polyclinic.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + + Exe + net8.0 + enable + enable + 8caaa9c7-a496-41cb-a0fe-a05874e7d079 + + + + + + + + + + + + + + diff --git a/Polyclinic/Polyclinic.AppHost/Properties/launchSettings.json b/Polyclinic/Polyclinic.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..e321feca2 --- /dev/null +++ b/Polyclinic/Polyclinic.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17108;http://localhost:15280", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21020", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22277" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15280", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19195", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20298" + } + } + } +} diff --git a/Polyclinic/Polyclinic.AppHost/appsettings.Development.json b/Polyclinic/Polyclinic.AppHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/Polyclinic/Polyclinic.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Polyclinic/Polyclinic.AppHost/appsettings.json b/Polyclinic/Polyclinic.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/Polyclinic/Polyclinic.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/Polyclinic/Polyclinic.Application.Contracts/Analytics/MonthlyAppointmentStatsDto.cs b/Polyclinic/Polyclinic.Application.Contracts/Analytics/MonthlyAppointmentStatsDto.cs new file mode 100644 index 000000000..0d0719394 --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Analytics/MonthlyAppointmentStatsDto.cs @@ -0,0 +1,15 @@ +namespace Polyclinic.Application.Contracts.Analytics; + +/// +/// DTO со статистикой приемов за месяц +/// +/// Год статистики +/// Месяц статистики +/// Количество повторных приемов +/// Общее количество приемов +public record MonthlyAppointmentStatsDto( + int Year, + int Month, + int RepeatAppointmentCount, + int TotalAppointmentCount +); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/Appointments/AppointmentCreateUpdateDto.cs b/Polyclinic/Polyclinic.Application.Contracts/Appointments/AppointmentCreateUpdateDto.cs new file mode 100644 index 000000000..cca75b46a --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Appointments/AppointmentCreateUpdateDto.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Polyclinic.Application.Contracts.Appointments; + +/// +/// DTO для создания и обновления записи на прием +/// +/// Дата и время приема +/// Номер кабинета +/// Признак повторного приема +/// Идентификатор пациента +/// Идентификатор врача +public record AppointmentCreateUpdateDto( + [Required(ErrorMessage = "Дата и время приема обязательны")] + DateTime AppointmentDateTime, + + [Required(ErrorMessage = "Номер кабинета обязателен")] + [MaxLength(10, ErrorMessage = "Номер кабинета не должен превышать 10 символов")] + string RoomNumber, + + bool IsRepeat, + + [Required(ErrorMessage = "Необходимо указать пациента")] + [Range(1, int.MaxValue, ErrorMessage = "Некорректный идентификатор пациента")] + int PatientId, + + [Required(ErrorMessage = "Необходимо указать врача")] + [Range(1, int.MaxValue, ErrorMessage = "Некорректный идентификатор врача")] + int DoctorId +); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/Appointments/AppointmentDto.cs b/Polyclinic/Polyclinic.Application.Contracts/Appointments/AppointmentDto.cs new file mode 100644 index 000000000..816c8ed39 --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Appointments/AppointmentDto.cs @@ -0,0 +1,19 @@ +namespace Polyclinic.Application.Contracts.Appointments; + +/// +/// DTO записи на прием для чтения +/// +/// Уникальный идентификатор записи +/// Дата и время приема +/// Номер кабинета +/// Признак повторного приема +/// Идентификатор пациента +/// Идентификатор врача +public record AppointmentDto( + int Id, + DateTime AppointmentDateTime, + string RoomNumber, + bool IsRepeat, + int PatientId, + int DoctorId +); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/Doctors/DoctorCreateUpdateDto.cs b/Polyclinic/Polyclinic.Application.Contracts/Doctors/DoctorCreateUpdateDto.cs new file mode 100644 index 000000000..d885fcada --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Doctors/DoctorCreateUpdateDto.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Polyclinic.Application.Contracts.Doctors; + +/// +/// DTO для создания и обновления врача +/// +/// Номер паспорта +/// ФИО врача +/// Дата рождения +/// Идентификатор специализации +/// Стаж работы (лет) +public record DoctorCreateUpdateDto( + [Required(ErrorMessage = "Номер паспорта обязателен")] + [MaxLength(20, ErrorMessage = "Номер паспорта не должен превышать 20 символов")] + string PassportNumber, + + [Required(ErrorMessage = "ФИО обязательно")] + [MaxLength(150, ErrorMessage = "ФИО не должно превышать 150 символов")] + string FullName, + + [Required(ErrorMessage = "Дата рождения обязательна")] + [DataType(DataType.Date)] + DateTime BirthDate, + + [Required(ErrorMessage = "Необходимо указать специализацию")] + [Range(1, int.MaxValue, ErrorMessage = "Некорректный идентификатор специализации")] + int SpecializationId, + + [Range(0, 100, ErrorMessage = "Стаж работы должен быть от 0 до 100 лет")] + int ExperienceYears +); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/Doctors/DoctorDto.cs b/Polyclinic/Polyclinic.Application.Contracts/Doctors/DoctorDto.cs new file mode 100644 index 000000000..54a669b76 --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Doctors/DoctorDto.cs @@ -0,0 +1,19 @@ +namespace Polyclinic.Application.Contracts.Doctors; + +/// +/// DTO врача для чтения +/// +/// Уникальный идентификатор врача +/// Номер паспорта +/// ФИО врача +/// Дата рождения +/// Идентификатор специализации +/// Стаж работы (лет) +public record DoctorDto( + int Id, + string PassportNumber, + string FullName, + DateTime BirthDate, + int SpecializationId, + int ExperienceYears +); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/IAnalyticsService.cs b/Polyclinic/Polyclinic.Application.Contracts/IAnalyticsService.cs new file mode 100644 index 000000000..b00122bb1 --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/IAnalyticsService.cs @@ -0,0 +1,58 @@ +using Polyclinic.Application.Contracts.Analytics; +using Polyclinic.Application.Contracts.Appointments; +using Polyclinic.Application.Contracts.Doctors; +using Polyclinic.Application.Contracts.Patients; + +namespace Polyclinic.Application.Contracts; + +/// +/// Сервис для получения аналитических выборок и отчетов +/// +public interface IAnalyticsService +{ + /// + /// Возвращает список врачей со стажем работы более указанного количества лет + /// + /// Минимальный стаж в годах (по умолчанию 10) + /// + /// Список врачей, удовлетворяющих условию + /// + public Task> GetDoctorsWithExperienceAsync(int minExperienceYears = 10); + + /// + /// Возвращает список пациентов, записанных к конкретному врачу, отсортированный по ФИО + /// + /// Идентификатор врача + /// + /// Отсортированный список пациентов + /// + public Task> GetPatientsByDoctorAsync(int doctorId); + + /// + /// Возвращает статистику по повторным приемам за указанный месяц + /// + /// Дата, определяющая месяц и год выборки + /// + /// Статистика по приемам + /// + public Task GetMonthlyRepeatStatsAsync(DateTime targetDate); + + /// + /// Возвращает пациентов старше определенного возраста, посетивших более одного врача, отсортированных по дате рождения + /// + /// Минимальный возраст пациента (по умолчанию 30) + /// + /// Список пациентов + /// + public Task> GetPatientsWithMultipleDoctorsAsync(int minAge = 30); + + /// + /// Возвращает список приемов в указанном кабинете за текущий месяц (относительно переданной даты) + /// + /// Номер кабинета + /// Дата для определения месяца выборки + /// + /// Список приемов + /// + public Task> GetAppointmentsByRoomAsync(string roomNumber, DateTime targetDate); +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/IApplicationService.cs b/Polyclinic/Polyclinic.Application.Contracts/IApplicationService.cs new file mode 100644 index 000000000..8eaa7c238 --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/IApplicationService.cs @@ -0,0 +1,58 @@ +namespace Polyclinic.Application.Contracts; + +/// +/// Общий интерфейс сервиса приложения для выполнения CRUD-операций над DTO +/// +/// Тип DTO для чтения данных +/// Тип DTO для создания и обновления данных +/// Тип первичного ключа сущности +public interface IApplicationService + where TDto : class + where TCreateUpdateDto : class + where TKey : struct +{ + /// + /// Создаёт новую запись на основе DTO + /// + /// DTO с данными для создания + /// + /// Созданный объект DTO с заполненным идентификатором + /// + public Task Create(TCreateUpdateDto dto); + + /// + /// Получает запись по идентификатору + /// + /// Идентификатор записи + /// + /// DTO записи, если она найдена; иначе null + /// + public Task Get(TKey dtoId); + + /// + /// Получает список всех записей + /// + /// + /// Коллекция всех записей DTO + /// + public Task> GetAll(); + + /// + /// Обновляет существующую запись + /// + /// DTO с обновлёнными данными + /// Идентификатор обновляемой записи + /// + /// Обновлённый объект DTO + /// + public Task Update(TCreateUpdateDto dto, TKey dtoId); + + /// + /// Удаляет запись по идентификатору + /// + /// Идентификатор удаляемой записи + /// + /// true, если запись была найдена и удалена; иначе false + /// + public Task Delete(TKey dtoId); +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/Patients/PatientCreateUpdateDto.cs b/Polyclinic/Polyclinic.Application.Contracts/Patients/PatientCreateUpdateDto.cs new file mode 100644 index 000000000..9e9f6b5be --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Patients/PatientCreateUpdateDto.cs @@ -0,0 +1,47 @@ +using Polyclinic.Domain.Enums; +using System.ComponentModel.DataAnnotations; + +namespace Polyclinic.Application.Contracts.Patients; + +/// +/// DTO для создания и обновления пациента +/// +/// Номер паспорта +/// ФИО пациента +/// Пол пациента +/// Дата рождения +/// Адрес проживания +/// Группа крови +/// Резус-фактор +/// Контактный телефон +public record PatientCreateUpdateDto( + [Required(ErrorMessage = "Номер паспорта обязателен")] + [MaxLength(20, ErrorMessage = "Номер паспорта не должен превышать 20 символов")] + string PassportNumber, + + [Required(ErrorMessage = "ФИО обязательно")] + [MaxLength(150, ErrorMessage = "ФИО не должно превышать 150 символов")] + string FullName, + + [Required(ErrorMessage = "Пол обязателен")] + Gender Gender, + + [Required(ErrorMessage = "Дата рождения обязательна")] + [DataType(DataType.Date)] + DateTime BirthDate, + + [Required(ErrorMessage = "Адрес обязателен")] + [MaxLength(250, ErrorMessage = "Адрес не должен превышать 250 символов")] + string Address, + + [Required(ErrorMessage = "Группа крови обязательна")] + BloodGroup BloodGroup, + + [Required(ErrorMessage = "Резус-фактор обязателен")] + RhFactor RhFactor, + + [Required(ErrorMessage = "Телефон обязателен")] + [Phone(ErrorMessage = "Некорректный формат телефона")] + [MaxLength(20, ErrorMessage = "Телефон не должен превышать 20 символов")] + string PhoneNumber +); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/Patients/PatientDto.cs b/Polyclinic/Polyclinic.Application.Contracts/Patients/PatientDto.cs new file mode 100644 index 000000000..8332c45d6 --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Patients/PatientDto.cs @@ -0,0 +1,27 @@ +using Polyclinic.Domain.Enums; + +namespace Polyclinic.Application.Contracts.Patients; + +/// +/// DTO пациента для чтения +/// +/// Уникальный идентификатор пациента +/// Номер паспорта +/// ФИО пациента +/// Пол пациента +/// Дата рождения +/// Адрес проживания +/// Группа крови +/// Резус-фактор +/// Контактный телефон +public record PatientDto( + int Id, + string PassportNumber, + string FullName, + Gender Gender, + DateTime BirthDate, + string Address, + BloodGroup BloodGroup, + RhFactor RhFactor, + string PhoneNumber +); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/Polyclinic.Application.Contracts.csproj b/Polyclinic/Polyclinic.Application.Contracts/Polyclinic.Application.Contracts.csproj new file mode 100644 index 000000000..14a830ef0 --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Polyclinic.Application.Contracts.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + true + + + + + + + diff --git a/Polyclinic/Polyclinic.Application.Contracts/Specializations/SpecializationCreateUpdateDto.cs b/Polyclinic/Polyclinic.Application.Contracts/Specializations/SpecializationCreateUpdateDto.cs new file mode 100644 index 000000000..251da3201 --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Specializations/SpecializationCreateUpdateDto.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Polyclinic.Application.Contracts.Specializations; + +/// +/// DTO для создания и обновления специализации +/// +/// Название специализации +/// Описание специализации +/// Код специализации +public record SpecializationCreateUpdateDto( + [Required(ErrorMessage = "Название специализации обязательно")] + [MaxLength(100, ErrorMessage = "Название не должно превышать 100 символов")] + string Name, + + [MaxLength(500, ErrorMessage = "Описание не должно превышать 500 символов")] + string Description, + + [Required(ErrorMessage = "Код специализации обязателен")] + [MaxLength(20, ErrorMessage = "Код не должен превышать 20 символов")] + string Code +); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application.Contracts/Specializations/SpecializationDto.cs b/Polyclinic/Polyclinic.Application.Contracts/Specializations/SpecializationDto.cs new file mode 100644 index 000000000..0c25f8223 --- /dev/null +++ b/Polyclinic/Polyclinic.Application.Contracts/Specializations/SpecializationDto.cs @@ -0,0 +1,15 @@ +namespace Polyclinic.Application.Contracts.Specializations; + +/// +/// DTO специализации для чтения +/// +/// Уникальный идентификатор специализации +/// Название специализации +/// Описание специализации +/// Код специализации +public record SpecializationDto( + int Id, + string Name, + string Description, + string Code +); \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application/Polyclinic.Application.csproj b/Polyclinic/Polyclinic.Application/Polyclinic.Application.csproj new file mode 100644 index 000000000..14b3bad96 --- /dev/null +++ b/Polyclinic/Polyclinic.Application/Polyclinic.Application.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Polyclinic/Polyclinic.Application/PolyclinicProfile.cs b/Polyclinic/Polyclinic.Application/PolyclinicProfile.cs new file mode 100644 index 000000000..cc66113ad --- /dev/null +++ b/Polyclinic/Polyclinic.Application/PolyclinicProfile.cs @@ -0,0 +1,29 @@ +using AutoMapper; +using Polyclinic.Application.Contracts.Appointments; +using Polyclinic.Application.Contracts.Doctors; +using Polyclinic.Application.Contracts.Patients; +using Polyclinic.Application.Contracts.Specializations; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Application; + +/// +/// Профиль маппинга для преобразования данных между доменными сущностями и DTO +/// +public class PolyclinicProfile : Profile +{ + public PolyclinicProfile() + { + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application/Services/AnalyticsSerivce.cs b/Polyclinic/Polyclinic.Application/Services/AnalyticsSerivce.cs new file mode 100644 index 000000000..208a5ce9b --- /dev/null +++ b/Polyclinic/Polyclinic.Application/Services/AnalyticsSerivce.cs @@ -0,0 +1,106 @@ +using AutoMapper; +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Analytics; +using Polyclinic.Application.Contracts.Appointments; +using Polyclinic.Application.Contracts.Doctors; +using Polyclinic.Application.Contracts.Patients; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Application.Services; + +/// +/// Сервис для получения аналитических данных и отчетов +/// +public class AnalyticsService( + IRepository doctorRepository, + IRepository appointmentRepository, + IMapper mapper) : IAnalyticsService +{ + /// + public async Task> GetDoctorsWithExperienceAsync(int minExperienceYears = 10) + { + var doctors = await doctorRepository.ReadAll(); + + var filteredDoctors = doctors + .Where(d => d.ExperienceYears >= minExperienceYears) + .ToList(); + + return mapper.Map>(filteredDoctors); + } + + /// + public async Task> GetPatientsByDoctorAsync(int doctorId) + { + var appointments = await appointmentRepository.ReadAll(); + + var patients = appointments + .Where(a => a.DoctorId == doctorId && a.Patient != null) + .Select(a => a.Patient!) + .DistinctBy(p => p.Id) + .OrderBy(p => p.FullName) + .ToList(); + + return mapper.Map>(patients); + } + + /// + public async Task GetMonthlyRepeatStatsAsync(DateTime targetDate) + { + var appointments = await appointmentRepository.ReadAll(); + + var monthlyAppointments = appointments + .Where(a => a.AppointmentDateTime.Year == targetDate.Year + && a.AppointmentDateTime.Month == targetDate.Month) + .ToList(); + + var totalCount = monthlyAppointments.Count; + var repeatCount = monthlyAppointments.Count(a => a.IsRepeat); + + return new MonthlyAppointmentStatsDto( + Year: targetDate.Year, + Month: targetDate.Month, + RepeatAppointmentCount: repeatCount, + TotalAppointmentCount: totalCount + ); + } + + /// + public async Task> GetPatientsWithMultipleDoctorsAsync(int minAge = 30) + { + var appointments = await appointmentRepository.ReadAll(); + var today = DateTime.Today; + + var resultPatients = appointments + .Where(a => a.Patient != null) + .GroupBy(a => a.Patient) + .Where(g => + { + var patient = g.Key!; + var isOldEnough = patient.GetAge(today) > minAge; + + var hasMultipleDoctors = g.Select(a => a.DoctorId).Distinct().Count() > 1; + + return isOldEnough && hasMultipleDoctors; + }) + .Select(g => g.Key!) + .OrderBy(p => p.BirthDate) + .ToList(); + + return mapper.Map>(resultPatients); + } + + /// + public async Task> GetAppointmentsByRoomAsync(string roomNumber, DateTime targetDate) + { + var appointments = await appointmentRepository.ReadAll(); + + var filtered = appointments + .Where(a => a.RoomNumber == roomNumber + && a.AppointmentDateTime.Year == targetDate.Year + && a.AppointmentDateTime.Month == targetDate.Month) + .ToList(); + + return mapper.Map>(filtered); + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application/Services/AppointmentService.cs b/Polyclinic/Polyclinic.Application/Services/AppointmentService.cs new file mode 100644 index 000000000..58e151b61 --- /dev/null +++ b/Polyclinic/Polyclinic.Application/Services/AppointmentService.cs @@ -0,0 +1,69 @@ +using AutoMapper; +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Appointments; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Application.Services; + +/// +/// Сервис для управления записями на прием +/// +public class AppointmentService( + IRepository appointmentRepository, + IRepository doctorRepository, + IRepository patientRepository, + IMapper mapper) + : IApplicationService +{ + /// + public async Task Create(AppointmentCreateUpdateDto dto) + { + _ = await patientRepository.Read(dto.PatientId) + ?? throw new KeyNotFoundException($"Пациент с id {dto.PatientId} не найден."); + + _ = await doctorRepository.Read(dto.DoctorId) + ?? throw new KeyNotFoundException($"Врач с id {dto.DoctorId} не найден."); + + var entity = mapper.Map(dto); + var result = await appointmentRepository.Create(entity); + return mapper.Map(result); + } + + /// + public async Task Get(int dtoId) + { + var entity = await appointmentRepository.Read(dtoId) + ?? throw new KeyNotFoundException($"Запись на прием с id {dtoId} не найдена."); + + return mapper.Map(entity); + } + + /// + public async Task> GetAll() + { + var entities = await appointmentRepository.ReadAll(); + return mapper.Map>(entities); + } + + /// + public async Task Update(AppointmentCreateUpdateDto dto, int dtoId) + { + _ = await patientRepository.Read(dto.PatientId) + ?? throw new KeyNotFoundException($"Пациент с id {dto.PatientId} не найден."); + + _ = await doctorRepository.Read(dto.DoctorId) + ?? throw new KeyNotFoundException($"Врач с id {dto.DoctorId} не найден."); + + var entity = await appointmentRepository.Read(dtoId) + ?? throw new KeyNotFoundException($"Запись на прием с id {dtoId} не найдена."); + + mapper.Map(dto, entity); + + var result = await appointmentRepository.Update(entity); + return mapper.Map(result); + } + + /// + public async Task Delete(int dtoId) => await appointmentRepository.Delete(dtoId); +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application/Services/DoctorService.cs b/Polyclinic/Polyclinic.Application/Services/DoctorService.cs new file mode 100644 index 000000000..cc6f59cf9 --- /dev/null +++ b/Polyclinic/Polyclinic.Application/Services/DoctorService.cs @@ -0,0 +1,62 @@ +using AutoMapper; +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Doctors; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Application.Services; + +/// +/// Сервис для управления врачами +/// +public class DoctorService( + IRepository doctorRepository, + IRepository specializationRepository, + IMapper mapper) + : IApplicationService +{ + /// + public async Task Create(DoctorCreateUpdateDto dto) + { + _ = await specializationRepository.Read(dto.SpecializationId) + ?? throw new KeyNotFoundException($"Специализация с id {dto.SpecializationId} не найдена."); + + var entity = mapper.Map(dto); + var result = await doctorRepository.Create(entity); + return mapper.Map(result); + } + + /// + public async Task Get(int dtoId) + { + var entity = await doctorRepository.Read(dtoId) + ?? throw new KeyNotFoundException($"Врач с id {dtoId} не найден."); + + return mapper.Map(entity); + } + + /// + public async Task> GetAll() + { + var entities = await doctorRepository.ReadAll(); + return mapper.Map>(entities); + } + + /// + public async Task Update(DoctorCreateUpdateDto dto, int dtoId) + { + _ = await specializationRepository.Read(dto.SpecializationId) + ?? throw new KeyNotFoundException($"Специализация с id {dto.SpecializationId} не найдена."); + + var entity = await doctorRepository.Read(dtoId) + ?? throw new KeyNotFoundException($"Врач с id {dtoId} не найден."); + + mapper.Map(dto, entity); + + var result = await doctorRepository.Update(entity); + return mapper.Map(result); + } + + /// + public async Task Delete(int dtoId) => await doctorRepository.Delete(dtoId); +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application/Services/PatientService.cs b/Polyclinic/Polyclinic.Application/Services/PatientService.cs new file mode 100644 index 000000000..4b04075bd --- /dev/null +++ b/Polyclinic/Polyclinic.Application/Services/PatientService.cs @@ -0,0 +1,55 @@ +using AutoMapper; +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Patients; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Application.Services; + +/// +/// Сервис для управления пациентами +/// +public class PatientService( + IRepository repository, + IMapper mapper) + : IApplicationService +{ + /// + public async Task Create(PatientCreateUpdateDto dto) + { + var entity = mapper.Map(dto); + var result = await repository.Create(entity); + return mapper.Map(result); + } + + /// + public async Task Get(int dtoId) + { + var entity = await repository.Read(dtoId) + ?? throw new KeyNotFoundException($"Пациент с id {dtoId} не найден."); + + return mapper.Map(entity); + } + + /// + public async Task> GetAll() + { + var entities = await repository.ReadAll(); + return mapper.Map>(entities); + } + + /// + public async Task Update(PatientCreateUpdateDto dto, int dtoId) + { + var entity = await repository.Read(dtoId) + ?? throw new KeyNotFoundException($"Пациент с id {dtoId} не найден."); + + mapper.Map(dto, entity); + + var result = await repository.Update(entity); + return mapper.Map(result); + } + + /// + public async Task Delete(int dtoId) => await repository.Delete(dtoId); +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Application/Services/SpecializationService.cs b/Polyclinic/Polyclinic.Application/Services/SpecializationService.cs new file mode 100644 index 000000000..e698a3d6c --- /dev/null +++ b/Polyclinic/Polyclinic.Application/Services/SpecializationService.cs @@ -0,0 +1,55 @@ +using AutoMapper; +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Specializations; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Application.Services; + +/// +/// Сервис для управления специализациями +/// +public class SpecializationService( + IRepository repository, + IMapper mapper) + : IApplicationService +{ + /// + public async Task Create(SpecializationCreateUpdateDto dto) + { + var entity = mapper.Map(dto); + var result = await repository.Create(entity); + return mapper.Map(result); + } + + /// + public async Task Get(int dtoId) + { + var entity = await repository.Read(dtoId) + ?? throw new KeyNotFoundException($"Специализация с id {dtoId} не найдена."); + + return mapper.Map(entity); + } + + /// + public async Task> GetAll() + { + var entities = await repository.ReadAll(); + return mapper.Map>(entities); + } + + /// + public async Task Update(SpecializationCreateUpdateDto dto, int dtoId) + { + var entity = await repository.Read(dtoId) + ?? throw new KeyNotFoundException($"Специализация с id {dtoId} не найдена."); + + mapper.Map(dto, entity); + + var result = await repository.Update(entity); + return mapper.Map(result); + } + + /// + public async Task Delete(int dtoId) => await repository.Delete(dtoId); +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Domain/Entities/Appointment.cs b/Polyclinic/Polyclinic.Domain/Entities/Appointment.cs new file mode 100644 index 000000000..627d57291 --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/Entities/Appointment.cs @@ -0,0 +1,47 @@ +namespace Polyclinic.Domain.Entities; + +/// +/// Запись пациента на прием к врачу +/// +public class Appointment +{ + /// + /// Уникальный идентификатор записи + /// + public int Id { get; set; } + + /// + /// Дата и время приема + /// + public DateTime AppointmentDateTime { get; set; } + + /// + /// Номер кабинета + /// + public required string RoomNumber { get; set; } + + /// + /// Флаг повторного приема + /// + public bool IsRepeat { get; set; } + + /// + /// Идентификатор пациента + /// + public int PatientId { get; set; } + + /// + /// Идентификатор врача + /// + public int DoctorId { get; set; } + + /// + /// Навигационное свойство: пациент + /// + public Patient? Patient { get; set; } + + /// + /// Навигационное свойство: врач + /// + public Doctor? Doctor { get; set; } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Domain/Entities/Doctor.cs b/Polyclinic/Polyclinic.Domain/Entities/Doctor.cs new file mode 100644 index 000000000..94c610777 --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/Entities/Doctor.cs @@ -0,0 +1,57 @@ +namespace Polyclinic.Domain.Entities; + +/// +/// Врач поликлиники +/// +public class Doctor +{ + /// + /// Уникальный идентификатор врача + /// + public int Id { get; set; } + + /// + /// Номер паспорта (уникальный) + /// + public required string PassportNumber { get; set; } + + /// + /// ФИО врача + /// + public required string FullName { get; set; } + + /// + /// Дата рождения + /// + public DateTime BirthDate { get; set; } + + /// + /// Идентификатор специализации + /// + public int SpecializationId { get; set; } + + /// + /// Стаж работы (в годах) + /// + public int ExperienceYears { get; set; } + + /// + /// Навигационное свойство: специализация + /// + public Specialization? Specialization { get; set; } + + /// + /// Список приемов у этого врача + /// + public List Appointments { get; set; } = []; + + /// + /// Вычисление возраста врача на указанную дату + /// + public int GetAge(DateTime onDate) + { + var age = onDate.Year - BirthDate.Year; + if (BirthDate.Date > onDate.AddYears(-age)) age--; + return age; + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Domain/Entities/Patient.cs b/Polyclinic/Polyclinic.Domain/Entities/Patient.cs new file mode 100644 index 000000000..aab8ee069 --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/Entities/Patient.cs @@ -0,0 +1,69 @@ +using Polyclinic.Domain.Enums; + +namespace Polyclinic.Domain.Entities; + +/// +/// Пациент поликлиники +/// +public class Patient +{ + /// + /// Уникальный идентификатор пациента + /// + public int Id { get; set; } + + /// + /// Номер паспорта (уникальный) + /// + public required string PassportNumber { get; set; } + + /// + /// ФИО пациента + /// + public required string FullName { get; set; } + + /// + /// Пол пациента + /// + public Gender Gender { get; set; } + + /// + /// Дата рождения + /// + public DateTime BirthDate { get; set; } + + /// + /// Адрес проживания + /// + public required string Address { get; set; } + + /// + /// Группа крови + /// + public BloodGroup BloodGroup { get; set; } + + /// + /// Резус-фактор + /// + public RhFactor RhFactor { get; set; } + + /// + /// Контактный телефон + /// + public required string PhoneNumber { get; set; } + + /// + /// Список записей на прием этого пациента + /// + public List Appointments { get; set; } = []; + + /// + /// Вычисление возраста пациента на указанную дату + /// + public int GetAge(DateTime onDate) + { + var age = onDate.Year - BirthDate.Year; + if (BirthDate.Date > onDate.AddYears(-age)) age--; + return age; + } +} diff --git a/Polyclinic/Polyclinic.Domain/Entities/Specialization.cs b/Polyclinic/Polyclinic.Domain/Entities/Specialization.cs new file mode 100644 index 000000000..6f728be3f --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/Entities/Specialization.cs @@ -0,0 +1,32 @@ +namespace Polyclinic.Domain.Entities; + +/// +/// Специализация врача (справочник) +/// +public class Specialization +{ + /// + /// Уникальный идентификатор специализации + /// + public int Id { get; set; } + + /// + /// Название специализации + /// + public required string Name { get; set; } + + /// + /// Описание специализации + /// + public required string Description { get; set; } + + /// + /// Код специализации + /// + public required string Code { get; set; } + + /// + /// Список врачей с этой специализацией + /// + public List Doctors { get; set; } = []; +} diff --git a/Polyclinic/Polyclinic.Domain/Enums/BloodGroup.cs b/Polyclinic/Polyclinic.Domain/Enums/BloodGroup.cs new file mode 100644 index 000000000..f9a6f0722 --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/Enums/BloodGroup.cs @@ -0,0 +1,27 @@ +namespace Polyclinic.Domain.Enums; + +/// +/// Группа крови пациента +/// +public enum BloodGroup +{ + /// + /// Первая (0) + /// + O, + + /// + /// Вторая (A) + /// + A, + + /// + /// Третья (B) + /// + B, + + /// + /// Четвертая (AB) + /// + Ab +} diff --git a/Polyclinic/Polyclinic.Domain/Enums/Gender.cs b/Polyclinic/Polyclinic.Domain/Enums/Gender.cs new file mode 100644 index 000000000..2f909a5c4 --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/Enums/Gender.cs @@ -0,0 +1,22 @@ +namespace Polyclinic.Domain.Enums; + +/// +/// Пол пациента +/// +public enum Gender +{ + /// + /// Не указано + /// + NotSet, + + /// + /// Мужской + /// + Male, + + /// + /// Женский + /// + Female +} diff --git a/Polyclinic/Polyclinic.Domain/Enums/RhFactor.cs b/Polyclinic/Polyclinic.Domain/Enums/RhFactor.cs new file mode 100644 index 000000000..0766c45e3 --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/Enums/RhFactor.cs @@ -0,0 +1,17 @@ +namespace Polyclinic.Domain.Enums; + +/// +/// Резус-фактор пациента +/// +public enum RhFactor +{ + /// + /// Положительный + /// + Positive, + + /// + /// Отрицательный + /// + Negative +} diff --git a/Polyclinic/Polyclinic.Domain/IRepository.cs b/Polyclinic/Polyclinic.Domain/IRepository.cs new file mode 100644 index 000000000..4f4dc5f08 --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/IRepository.cs @@ -0,0 +1,55 @@ +namespace Polyclinic.Domain; + +/// +/// Интерфейс репозитория, инкапсулирующий CRUD-операции над сущностью доменной модели +/// +/// Тип доменной сущности, над которой выполняются операции репозитория +/// Тип первичного ключа (идентификатора) сущности +public interface IRepository + where TEntity : class + where TKey : struct +{ + /// + /// Создаёт новую сущность и сохраняет её в источнике данных + /// + /// Экземпляр новой сущности для сохранения + /// + /// Созданная сущность + /// + public Task Create(TEntity entity); + + /// + /// Возвращает сущность по её идентификатору + /// + /// Идентификатор искомой сущности + /// + /// Сущность, если она найдена; иначе null + /// + public Task Read(TKey entityId); + + /// + /// Возвращает полный список сущностей данного типа + /// + /// + /// Список сущностей + /// + public Task> ReadAll(); + + /// + /// Обновляет существующую сущность в источнике данных + /// + /// Сущность с актуальными значениями полей + /// + /// Обновлённая сущность + /// + public Task Update(TEntity entity); + + /// + /// Удаляет сущность по её идентификатору + /// + /// Идентификатор удаляемой сущности + /// + /// true, если сущность была найдена и удалена; иначе false + /// + public Task Delete(TKey entityId); +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Domain/Polyclinic.Domain.csproj b/Polyclinic/Polyclinic.Domain/Polyclinic.Domain.csproj new file mode 100644 index 000000000..fa71b7ae6 --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/Polyclinic.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Polyclinic/Polyclinic.Domain/PolyclinicFixture.cs b/Polyclinic/Polyclinic.Domain/PolyclinicFixture.cs new file mode 100644 index 000000000..e7b6ea4b1 --- /dev/null +++ b/Polyclinic/Polyclinic.Domain/PolyclinicFixture.cs @@ -0,0 +1,419 @@ +using Polyclinic.Domain.Entities; +using Polyclinic.Domain.Enums; + +namespace Polyclinic.Domain; + +/// +/// Фикстура с тестовыми данными для поликлиники +/// +public class PolyclinicFixture +{ + public List Specializations { get; } + public List Doctors { get; } + public List Patients { get; } + public List Appointments { get; } + + public PolyclinicFixture() + { + Specializations = GetSpecializations(); + Doctors = GetDoctors(); + Patients = GetPatients(); + Appointments = GetAppointments(); + } + + private static List GetSpecializations() => + [ + new() { Id = 1, Name = "Терапевт", Code = "THERAPIST", Description = "Врач общей практики" }, + new() { Id = 2, Name = "Хирург", Code = "SURGEON", Description = "Проведение операций" }, + new() { Id = 3, Name = "Кардиолог", Code = "CARDIOLOGIST", Description = "Заболевания сердца" }, + new() { Id = 4, Name = "Невролог", Code = "NEUROLOGIST", Description = "Заболевания нервной системы" }, + new() { Id = 5, Name = "Педиатр", Code = "PEDIATRICIAN", Description = "Детские болезни" }, + new() { Id = 6, Name = "Гинеколог", Code = "GYNECOLOGIST", Description = "Женское здоровье" }, + new() { Id = 7, Name = "Офтальмолог", Code = "OPHTHALMOLOGIST", Description = "Заболевания глаз" }, + new() { Id = 8, Name = "Отоларинголог", Code = "ENT", Description = "Ухо, горло, нос" }, + new() { Id = 9, Name = "Дерматолог", Code = "DERMATOLOGIST", Description = "Кожные заболевания" }, + new() { Id = 10, Name = "Эндокринолог", Code = "ENDOCRINOLOGIST", Description = "Гормональные нарушения" } + ]; + + private static List GetDoctors() => + [ + new() + { + Id = 1, + PassportNumber = "4501 123456", + FullName = "Иванов Иван Иванович", + BirthDate = new DateTime(1980, 5, 15), + SpecializationId = 1, + ExperienceYears = 15 + }, + new() + { + Id = 2, + PassportNumber = "4502 234567", + FullName = "Петров Петр Петрович", + BirthDate = new DateTime(1975, 8, 22), + SpecializationId = 2, + ExperienceYears = 20 + }, + new() + { + Id = 3, + PassportNumber = "4503 345678", + FullName = "Сидорова Анна Сергеевна", + BirthDate = new DateTime(1985, 3, 10), + SpecializationId = 3, + ExperienceYears = 12 + }, + new() + { + Id = 4, + PassportNumber = "4504 456789", + FullName = "Козлов Дмитрий Николаевич", + BirthDate = new DateTime(1990, 11, 30), + SpecializationId = 4, + ExperienceYears = 8 + }, + new() + { + Id = 5, + PassportNumber = "4505 567890", + FullName = "Морозова Елена Владимировна", + BirthDate = new DateTime(1982, 7, 18), + SpecializationId = 5, + ExperienceYears = 14 + }, + new() + { + Id = 6, + PassportNumber = "4506 678901", + FullName = "Волков Андрей Игоревич", + BirthDate = new DateTime(1978, 9, 25), + SpecializationId = 6, + ExperienceYears = 18 + }, + new() + { + Id = 7, + PassportNumber = "4507 789012", + FullName = "Соколова Татьяна Александровна", + BirthDate = new DateTime(1988, 2, 14), + SpecializationId = 7, + ExperienceYears = 10 + }, + new() + { + Id = 8, + PassportNumber = "4508 890123", + FullName = "Лебедев Михаил Сергеевич", + BirthDate = new DateTime(1992, 6, 5), + SpecializationId = 8, + ExperienceYears = 6 + }, + new() + { + Id = 9, + PassportNumber = "4509 901234", + FullName = "Николаева Ольга Викторовна", + BirthDate = new DateTime(1983, 12, 3), + SpecializationId = 9, + ExperienceYears = 13 + }, + new() + { + Id = 10, + PassportNumber = "4510 012345", + FullName = "Федоров Алексей Павлович", + BirthDate = new DateTime(1970, 4, 20), + SpecializationId = 10, + ExperienceYears = 25 + } + ]; + + private static List GetPatients() => + [ + new() + { + Id = 1, + PassportNumber = "6001 123456", + FullName = "Смирнов Алексей Викторович", + Gender = Gender.Male, + BirthDate = new DateTime(1990, 5, 15), + Address = "ул. Ленина, д. 10, кв. 25", + BloodGroup = BloodGroup.A, + RhFactor = RhFactor.Positive, + PhoneNumber = "+7 (999) 123-45-67" + }, + new() + { + Id = 2, + PassportNumber = "6002 234567", + FullName = "Кузнецова Елена Дмитриевна", + Gender = Gender.Female, + BirthDate = new DateTime(1985, 8, 22), + Address = "ул. Гагарина, д. 5, кв. 12", + BloodGroup = BloodGroup.O, + RhFactor = RhFactor.Positive, + PhoneNumber = "+7 (999) 234-56-78" + }, + new() + { + Id = 3, + PassportNumber = "6003 345678", + FullName = "Попов Сергей Иванович", + Gender = Gender.Male, + BirthDate = new DateTime(1978, 3, 10), + Address = "пр. Мира, д. 15, кв. 7", + BloodGroup = BloodGroup.B, + RhFactor = RhFactor.Negative, + PhoneNumber = "+7 (999) 345-67-89" + }, + new() + { + Id = 4, + PassportNumber = "6004 456789", + FullName = "Васильева Мария Петровна", + Gender = Gender.Female, + BirthDate = new DateTime(1995, 11, 30), + Address = "ул. Советская, д. 8, кв. 42", + BloodGroup = BloodGroup.Ab, + RhFactor = RhFactor.Positive, + PhoneNumber = "+7 (999) 456-78-90" + }, + new() + { + Id = 5, + PassportNumber = "6005 567890", + FullName = "Соколов Андрей Николаевич", + Gender = Gender.Male, + BirthDate = new DateTime(1982, 7, 18), + Address = "ул. Пушкина, д. 3, кв. 56", + BloodGroup = BloodGroup.A, + RhFactor = RhFactor.Negative, + PhoneNumber = "+7 (999) 567-89-01" + }, + new() + { + Id = 6, + PassportNumber = "6006 678901", + FullName = "Михайлова Анна Сергеевна", + Gender = Gender.Female, + BirthDate = new DateTime(1975, 9, 25), + Address = "пр. Ленинградский, д. 22, кв. 15", + BloodGroup = BloodGroup.O, + RhFactor = RhFactor.Positive, + PhoneNumber = "+7 (999) 678-90-12" + }, + new() + { + Id = 7, + PassportNumber = "6007 789012", + FullName = "Новиков Денис Александрович", + Gender = Gender.Male, + BirthDate = new DateTime(1988, 2, 14), + Address = "ул. Кирова, д. 12, кв. 8", + BloodGroup = BloodGroup.B, + RhFactor = RhFactor.Positive, + PhoneNumber = "+7 (999) 789-01-23" + }, + new() + { + Id = 8, + PassportNumber = "6008 890123", + FullName = "Морозова Татьяна Владимировна", + Gender = Gender.Female, + BirthDate = new DateTime(1992, 6, 5), + Address = "ул. Садовая, д. 7, кв. 31", + BloodGroup = BloodGroup.Ab, + RhFactor = RhFactor.Negative, + PhoneNumber = "+7 (999) 890-12-34" + }, + new() + { + Id = 9, + PassportNumber = "6009 901234", + FullName = "Зайцев Игорь Павлович", + Gender = Gender.Male, + BirthDate = new DateTime(1970, 12, 3), + Address = "пр. Невский, д. 45, кв. 19", + BloodGroup = BloodGroup.A, + RhFactor = RhFactor.Positive, + PhoneNumber = "+7 (999) 901-23-45" + }, + new() + { + Id = 10, + PassportNumber = "6010 012345", + FullName = "Волкова Ольга Игоревна", + Gender = Gender.Female, + BirthDate = new DateTime(1965, 4, 20), + Address = "ул. Комсомольская, д. 6, кв. 23", + BloodGroup = BloodGroup.O, + RhFactor = RhFactor.Positive, + PhoneNumber = "+7 (999) 012-34-56" + }, + new() + { + Id = 11, + PassportNumber = "6011 123456", + FullName = "Белова Наталья Сергеевна", + Gender = Gender.Female, + BirthDate = new DateTime(1998, 1, 8), + Address = "ул. Мичурина, д. 18, кв. 67", + BloodGroup = BloodGroup.B, + RhFactor = RhFactor.Positive, + PhoneNumber = "+7 (999) 123-56-78" + }, + new() + { + Id = 12, + PassportNumber = "6012 234567", + FullName = "Карпов Евгений Владимирович", + Gender = Gender.Male, + BirthDate = new DateTime(1983, 9, 12), + Address = "ул. Лермонтова, д. 9, кв. 14", + BloodGroup = BloodGroup.Ab, + RhFactor = RhFactor.Negative, + PhoneNumber = "+7 (999) 234-67-89" + } + ]; + + private static List GetAppointments() + { + var appointments = new List(); + var appointmentId = 1; + + // Записи на текущий месяц (февраль 2026) + appointments.AddRange([ + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 2, 5, 10, 0, 0), + RoomNumber = "101", + IsRepeat = false, + PatientId = 1, + DoctorId = 1 + }, + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 2, 5, 11, 0, 0), + RoomNumber = "101", + IsRepeat = true, + PatientId = 2, + DoctorId = 1 + }, + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 2, 10, 14, 30, 0), + RoomNumber = "202", + IsRepeat = false, + PatientId = 3, + DoctorId = 2 + }, + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 2, 15, 9, 15, 0), + RoomNumber = "303", + IsRepeat = false, + PatientId = 4, + DoctorId = 3 + }, + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 2, 12, 13, 30, 0), + RoomNumber = "101", + IsRepeat = false, + PatientId = 8, + DoctorId = 1 + }, + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 2, 16, 9, 30, 0), + RoomNumber = "101", + IsRepeat = false, + PatientId = 11, + DoctorId = 1 + } + ]); + + // Записи на прошлый месяц (январь 2026) + appointments.AddRange([ + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 1, 15, 10, 0, 0), + RoomNumber = "101", + IsRepeat = true, + PatientId = 6, + DoctorId = 2 + }, + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 1, 20, 11, 0, 0), + RoomNumber = "202", + IsRepeat = true, + PatientId = 7, + DoctorId = 2 + }, + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 1, 5, 9, 0, 0), + RoomNumber = "303", + IsRepeat = false, + PatientId = 8, + DoctorId = 3 + } + ]); + + // Пациент Соколов (Id=5) записан к нескольким врачам + appointments.AddRange([ + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 2, 8, 9, 0, 0), + RoomNumber = "102", + IsRepeat = false, + PatientId = 5, + DoctorId = 2 + }, + new() + { + Id = appointmentId++, + AppointmentDateTime = new DateTime(2026, 2, 22, 11, 30, 0), + RoomNumber = "606", + IsRepeat = false, + PatientId = 5, + DoctorId = 6 + } + ]); + + return appointments; + } + + private void LinkDoctorsWithSpecializations() + { + foreach (var doctor in Doctors) + { + doctor.Specialization = Specializations.First(s => s.Id == doctor.SpecializationId); + doctor.Specialization.Doctors.Add(doctor); + } + } + + private void LinkAppointmentsWithDoctorsAndPatients() + { + foreach (var appointment in Appointments) + { + appointment.Doctor = Doctors.First(d => d.Id == appointment.DoctorId); + appointment.Patient = Patients.First(p => p.Id == appointment.PatientId); + + appointment.Doctor.Appointments.Add(appointment); + appointment.Patient.Appointments.Add(appointment); + } + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Controllers/GeneratorController.cs b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Controllers/GeneratorController.cs new file mode 100644 index 000000000..02865cfd7 --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Controllers/GeneratorController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; +using Polyclinic.Application.Contracts.Appointments; +using Polyclinic.Generator.RabbitMq.Host.Services; + +namespace Polyclinic.Generator.RabbitMq.Host.Controllers; + +/// +/// Контроллер для управления генерацией тестовых данных +/// +/// Сервис генерации фейковых данных +/// Сервис публикации сообщений в RabbitMQ +/// Логгер +[Route("api/[controller]")] +[ApiController] +public class GeneratorController( + AppointmentGenerator generator, + AppointmentProducer producer, + ILogger logger) : ControllerBase +{ + /// + /// Инициирует генерацию указанного количества записей на прием и отправляет их в очередь RabbitMQ + /// + /// Количество записей для генерации (по умолчанию 10) + /// Токен отмены операции + /// Список сгенерированных и отправленных записей + [HttpPost("generate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> Generate( + [FromQuery] int count = 10, + CancellationToken cancellationToken = default) + { + logger.LogInformation("Запуск генерации {Count} записей на прием...", count); + + try + { + var appointments = generator.Generate(count).ToList(); + + foreach (var appointment in appointments) + { + await producer.PublishAsync(appointment, cancellationToken); + } + + logger.LogInformation("Успешно сгенерировано и отправлено {Count} записей.", appointments.Count); + + return Ok(appointments); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при генерации или отправке данных в RabbitMQ."); + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Options/GeneratorOptions.cs b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Options/GeneratorOptions.cs new file mode 100644 index 000000000..0be9d2db1 --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Options/GeneratorOptions.cs @@ -0,0 +1,53 @@ +namespace Polyclinic.Generator.RabbitMq.Host.Options; + +/// +/// Настройки генератора данных +/// Определяют границы значений для создаваемых сущностей +/// +public class GeneratorOptions +{ + /// + /// Минимальный идентификатор пациента + /// + public int MinPatientId { get; set; } = 1; + + /// + /// Максимальный идентификатор пациента + /// + public int MaxPatientId { get; set; } = 12; + + /// + /// Минимальный идентификатор врача + /// + public int MinDoctorId { get; set; } = 1; + + /// + /// Максимальный идентификатор врача + /// + public int MaxDoctorId { get; set; } = 10; + + /// + /// Дата начала периода генерации записей + /// + public DateTime StartDate { get; set; } = new DateTime(2025, 2, 18, 12, 0, 0); + + /// + /// Дата окончания периода генерации записей + /// + public DateTime EndDate { get; set; } = new DateTime(2025, 3, 18, 12, 0, 0); + + /// + /// Минимальный номер кабинета + /// + public int MinRoomNumber { get; set; } = 100; + + /// + /// Максимальный номер кабинета + /// + public int MaxRoomNumber { get; set; } = 599; + + /// + /// Вероятность того, что прием является повторным (от 0.0 до 1.0) + /// + public float RepeatProbability { get; set; } = 0.3f; +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Options/RabbitMqOptions.cs b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Options/RabbitMqOptions.cs new file mode 100644 index 000000000..ccb83bbc8 --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Options/RabbitMqOptions.cs @@ -0,0 +1,22 @@ +namespace Polyclinic.Generator.RabbitMq.Host.Options; + +/// +/// Настройки конфигурации для подключения и работы с RabbitMQ +/// +public class RabbitMqOptions +{ + /// + /// Имя очереди для отправки сообщений + /// + public required string QueueName { get; set; } = "appointment-contracts"; + + /// + /// Количество повторных попыток обработки сообщения при возникновении ошибок + /// + public int RetryCount { get; set; } = 5; + + /// + /// Задержка между повторными попытками в миллисекундах + /// + public int RetryDelayMs { get; set; } = 1000; +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Polyclinic.Generator.RabbitMq.Host.csproj b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Polyclinic.Generator.RabbitMq.Host.csproj new file mode 100644 index 000000000..8801d7e0b --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Polyclinic.Generator.RabbitMq.Host.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Program.cs b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Program.cs new file mode 100644 index 000000000..4f20d24ee --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Program.cs @@ -0,0 +1,50 @@ +using Polyclinic.Generator.RabbitMq.Host.Options; +using Polyclinic.Generator.RabbitMq.Host.Services; +using Polyclinic.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddRabbitMQClient("rabbitMqConnection"); + +builder.Services.Configure(builder.Configuration.GetSection("RabbitMq")); +builder.Services.Configure(builder.Configuration.GetSection("Generator")); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + var assemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.GetName().Name!.StartsWith("Polyclinic")) + .Distinct(); + + foreach (var assembly in assemblies) + { + var xmlFile = $"{assembly.GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + c.IncludeXmlComments(xmlPath); + } +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Properties/launchSettings.json b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Properties/launchSettings.json new file mode 100644 index 000000000..146c903da --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:51800", + "sslPort": 44382 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5212", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7255;http://localhost:5212", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Services/AppointmentGenerator.cs b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Services/AppointmentGenerator.cs new file mode 100644 index 000000000..b429d52ad --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Services/AppointmentGenerator.cs @@ -0,0 +1,42 @@ +using Bogus; +using Microsoft.Extensions.Options; +using Polyclinic.Application.Contracts.Appointments; +using Polyclinic.Generator.RabbitMq.Host.Options; + +namespace Polyclinic.Generator.RabbitMq.Host.Services; + +/// +/// Сервис для генерации фейковых данных о записях на прием +/// +public class AppointmentGenerator +{ + private readonly Faker _faker; + + /// + /// Инициализирует новый экземпляр генератора с заданными настройками + /// + /// Настройки генерации (диапазоны ID, дат и т.д.) + public AppointmentGenerator(IOptions options) + { + var configuration = options.Value; + + _faker = new Faker() + .CustomInstantiator(f => new AppointmentCreateUpdateDto( + AppointmentDateTime: f.Date.Between(configuration.StartDate, configuration.EndDate), + RoomNumber: f.Random.Int(configuration.MinRoomNumber, configuration.MaxRoomNumber).ToString(), + IsRepeat: f.Random.Bool(configuration.RepeatProbability), + PatientId: f.Random.Int(configuration.MinPatientId, configuration.MaxPatientId), + DoctorId: f.Random.Int(configuration.MinDoctorId, configuration.MaxDoctorId) + )); + } + + /// + /// Генерирует указанное количество записей на прием + /// + /// Количество записей для генерации + /// Коллекция DTO для создания записей + public IEnumerable Generate(int count) + { + return _faker.Generate(count); + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Services/AppointmentProducer.cs b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Services/AppointmentProducer.cs new file mode 100644 index 000000000..2e93b9cb6 --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/Services/AppointmentProducer.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.Options; +using Polyclinic.Application.Contracts.Appointments; +using Polyclinic.Generator.RabbitMq.Host.Options; +using RabbitMQ.Client; +using System.Text; +using System.Text.Json; + +namespace Polyclinic.Generator.RabbitMq.Host.Services; + +/// +/// Сервис для публикации сообщений о создании записей на прием в RabbitMQ +/// +/// Активное соединение с RabbitMQ +/// Настройки очереди и повторных попыток +/// Логгер для записи событий и ошибок +public class AppointmentProducer( + IConnection connection, + IOptions options, + ILogger logger) : IDisposable +{ + private readonly RabbitMqOptions _options = options.Value; + private IChannel? _channel; + + /// + /// Публикует сообщение с данными о записи на прием в очередь + /// + /// DTO с данными записи + /// Токен отмены + public async Task PublishAsync(AppointmentCreateUpdateDto appointment, CancellationToken token = default) + { + await EnsureChannelAsync(token); + + var json = JsonSerializer.Serialize(appointment); + var body = Encoding.UTF8.GetBytes(json); + + var properties = new BasicProperties + { + DeliveryMode = DeliveryModes.Persistent, + ContentType = "application/json" + }; + + await _channel!.BasicPublishAsync( + exchange: string.Empty, + routingKey: _options.QueueName, + mandatory: false, + basicProperties: properties, + body: body, + cancellationToken: token); + + logger.LogInformation("Отправлено сообщение в очередь {Queue}: {Json}.", _options.QueueName, json); + } + + /// + /// Инициализирует канал и объявляет очередь, если это еще не было сделано + /// + private async Task EnsureChannelAsync(CancellationToken token) + { + if (_channel is not null) return; + + logger.LogInformation("Создание RabbitMQ канала для очереди {QueueName}.", _options.QueueName); + + _channel = await connection.CreateChannelAsync(cancellationToken: token); + + await _channel.QueueDeclareAsync( + queue: _options.QueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + cancellationToken: token); + + logger.LogInformation("Канал RabbitMQ успешно создан."); + } + + /// + /// Освобождает ресурсы канала + /// + public void Dispose() + { + _channel?.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/appsettings.Development.json b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Polyclinic/Polyclinic.Generator.RabbitMq.Host/appsettings.json b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/appsettings.json new file mode 100644 index 000000000..00233a8b2 --- /dev/null +++ b/Polyclinic/Polyclinic.Generator.RabbitMq.Host/appsettings.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "RabbitMq": { + "QueueName": "appointment-contracts", + "RetryCount": 5, + "RetryDelayMs": 1000 + }, + "Generator": { + "MinPatientId": 1, + "MaxPatientId": 12, + "MinDoctorId": 1, + "MaxDoctorId": 10, + "StartDate": "2025-02-18T12:00:00", + "EndDate": "2025-03-18T12:00:00", + "MinRoomNumber": 100, + "MaxRoomNumber": 599, + "RepeatProbability": 0.3 + } +} diff --git a/Polyclinic/Polyclinic.Infrastructure.EfCore/Migrations/20260216193102_InitialCreate.Designer.cs b/Polyclinic/Polyclinic.Infrastructure.EfCore/Migrations/20260216193102_InitialCreate.Designer.cs new file mode 100644 index 000000000..f8d283b86 --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.EfCore/Migrations/20260216193102_InitialCreate.Designer.cs @@ -0,0 +1,627 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Polyclinic.Infrastructure.EfCore; + +#nullable disable + +namespace Polyclinic.Infrastructure.EfCore.Migrations +{ + [DbContext(typeof(PolyclinicDbContext))] + [Migration("20260216193102_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Appointment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppointmentDateTime") + .HasColumnType("datetime2"); + + b.Property("DoctorId") + .HasColumnType("int"); + + b.Property("IsRepeat") + .HasColumnType("bit"); + + b.Property("PatientId") + .HasColumnType("int"); + + b.Property("RoomNumber") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("PatientId"); + + b.ToTable("Appointments"); + + b.HasData( + new + { + Id = 1, + AppointmentDateTime = new DateTime(2026, 2, 5, 10, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 1, + IsRepeat = false, + PatientId = 1, + RoomNumber = "101" + }, + new + { + Id = 2, + AppointmentDateTime = new DateTime(2026, 2, 5, 11, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 1, + IsRepeat = true, + PatientId = 2, + RoomNumber = "101" + }, + new + { + Id = 3, + AppointmentDateTime = new DateTime(2026, 2, 10, 14, 30, 0, 0, DateTimeKind.Unspecified), + DoctorId = 2, + IsRepeat = false, + PatientId = 3, + RoomNumber = "202" + }, + new + { + Id = 4, + AppointmentDateTime = new DateTime(2026, 2, 15, 9, 15, 0, 0, DateTimeKind.Unspecified), + DoctorId = 3, + IsRepeat = false, + PatientId = 4, + RoomNumber = "303" + }, + new + { + Id = 5, + AppointmentDateTime = new DateTime(2026, 2, 12, 13, 30, 0, 0, DateTimeKind.Unspecified), + DoctorId = 1, + IsRepeat = false, + PatientId = 8, + RoomNumber = "101" + }, + new + { + Id = 6, + AppointmentDateTime = new DateTime(2026, 2, 16, 9, 30, 0, 0, DateTimeKind.Unspecified), + DoctorId = 1, + IsRepeat = false, + PatientId = 11, + RoomNumber = "101" + }, + new + { + Id = 7, + AppointmentDateTime = new DateTime(2026, 1, 15, 10, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 2, + IsRepeat = true, + PatientId = 6, + RoomNumber = "101" + }, + new + { + Id = 8, + AppointmentDateTime = new DateTime(2026, 1, 20, 11, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 2, + IsRepeat = true, + PatientId = 7, + RoomNumber = "202" + }, + new + { + Id = 9, + AppointmentDateTime = new DateTime(2026, 1, 5, 9, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 3, + IsRepeat = false, + PatientId = 8, + RoomNumber = "303" + }, + new + { + Id = 10, + AppointmentDateTime = new DateTime(2026, 2, 8, 9, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 2, + IsRepeat = false, + PatientId = 5, + RoomNumber = "102" + }, + new + { + Id = 11, + AppointmentDateTime = new DateTime(2026, 2, 22, 11, 30, 0, 0, DateTimeKind.Unspecified), + DoctorId = 6, + IsRepeat = false, + PatientId = 5, + RoomNumber = "606" + }); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Doctor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("datetime2"); + + b.Property("ExperienceYears") + .HasColumnType("int"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("PassportNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SpecializationId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SpecializationId"); + + b.ToTable("Doctors"); + + b.HasData( + new + { + Id = 1, + BirthDate = new DateTime(1980, 5, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 15, + FullName = "Иванов Иван Иванович", + PassportNumber = "4501 123456", + SpecializationId = 1 + }, + new + { + Id = 2, + BirthDate = new DateTime(1975, 8, 22, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 20, + FullName = "Петров Петр Петрович", + PassportNumber = "4502 234567", + SpecializationId = 2 + }, + new + { + Id = 3, + BirthDate = new DateTime(1985, 3, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 12, + FullName = "Сидорова Анна Сергеевна", + PassportNumber = "4503 345678", + SpecializationId = 3 + }, + new + { + Id = 4, + BirthDate = new DateTime(1990, 11, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 8, + FullName = "Козлов Дмитрий Николаевич", + PassportNumber = "4504 456789", + SpecializationId = 4 + }, + new + { + Id = 5, + BirthDate = new DateTime(1982, 7, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 14, + FullName = "Морозова Елена Владимировна", + PassportNumber = "4505 567890", + SpecializationId = 5 + }, + new + { + Id = 6, + BirthDate = new DateTime(1978, 9, 25, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 18, + FullName = "Волков Андрей Игоревич", + PassportNumber = "4506 678901", + SpecializationId = 6 + }, + new + { + Id = 7, + BirthDate = new DateTime(1988, 2, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 10, + FullName = "Соколова Татьяна Александровна", + PassportNumber = "4507 789012", + SpecializationId = 7 + }, + new + { + Id = 8, + BirthDate = new DateTime(1992, 6, 5, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 6, + FullName = "Лебедев Михаил Сергеевич", + PassportNumber = "4508 890123", + SpecializationId = 8 + }, + new + { + Id = 9, + BirthDate = new DateTime(1983, 12, 3, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 13, + FullName = "Николаева Ольга Викторовна", + PassportNumber = "4509 901234", + SpecializationId = 9 + }, + new + { + Id = 10, + BirthDate = new DateTime(1970, 4, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 25, + FullName = "Федоров Алексей Павлович", + PassportNumber = "4510 012345", + SpecializationId = 10 + }); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("BirthDate") + .HasColumnType("datetime2"); + + b.Property("BloodGroup") + .HasColumnType("int"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("PassportNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RhFactor") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Patients"); + + b.HasData( + new + { + Id = 1, + Address = "ул. Ленина, д. 10, кв. 25", + BirthDate = new DateTime(1990, 5, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 1, + FullName = "Смирнов Алексей Викторович", + Gender = 1, + PassportNumber = "6001 123456", + PhoneNumber = "+7 (999) 123-45-67", + RhFactor = 0 + }, + new + { + Id = 2, + Address = "ул. Гагарина, д. 5, кв. 12", + BirthDate = new DateTime(1985, 8, 22, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 0, + FullName = "Кузнецова Елена Дмитриевна", + Gender = 2, + PassportNumber = "6002 234567", + PhoneNumber = "+7 (999) 234-56-78", + RhFactor = 0 + }, + new + { + Id = 3, + Address = "пр. Мира, д. 15, кв. 7", + BirthDate = new DateTime(1978, 3, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 2, + FullName = "Попов Сергей Иванович", + Gender = 1, + PassportNumber = "6003 345678", + PhoneNumber = "+7 (999) 345-67-89", + RhFactor = 1 + }, + new + { + Id = 4, + Address = "ул. Советская, д. 8, кв. 42", + BirthDate = new DateTime(1995, 11, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 3, + FullName = "Васильева Мария Петровна", + Gender = 2, + PassportNumber = "6004 456789", + PhoneNumber = "+7 (999) 456-78-90", + RhFactor = 0 + }, + new + { + Id = 5, + Address = "ул. Пушкина, д. 3, кв. 56", + BirthDate = new DateTime(1982, 7, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 1, + FullName = "Соколов Андрей Николаевич", + Gender = 1, + PassportNumber = "6005 567890", + PhoneNumber = "+7 (999) 567-89-01", + RhFactor = 1 + }, + new + { + Id = 6, + Address = "пр. Ленинградский, д. 22, кв. 15", + BirthDate = new DateTime(1975, 9, 25, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 0, + FullName = "Михайлова Анна Сергеевна", + Gender = 2, + PassportNumber = "6006 678901", + PhoneNumber = "+7 (999) 678-90-12", + RhFactor = 0 + }, + new + { + Id = 7, + Address = "ул. Кирова, д. 12, кв. 8", + BirthDate = new DateTime(1988, 2, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 2, + FullName = "Новиков Денис Александрович", + Gender = 1, + PassportNumber = "6007 789012", + PhoneNumber = "+7 (999) 789-01-23", + RhFactor = 0 + }, + new + { + Id = 8, + Address = "ул. Садовая, д. 7, кв. 31", + BirthDate = new DateTime(1992, 6, 5, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 3, + FullName = "Морозова Татьяна Владимировна", + Gender = 2, + PassportNumber = "6008 890123", + PhoneNumber = "+7 (999) 890-12-34", + RhFactor = 1 + }, + new + { + Id = 9, + Address = "пр. Невский, д. 45, кв. 19", + BirthDate = new DateTime(1970, 12, 3, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 1, + FullName = "Зайцев Игорь Павлович", + Gender = 1, + PassportNumber = "6009 901234", + PhoneNumber = "+7 (999) 901-23-45", + RhFactor = 0 + }, + new + { + Id = 10, + Address = "ул. Комсомольская, д. 6, кв. 23", + BirthDate = new DateTime(1965, 4, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 0, + FullName = "Волкова Ольга Игоревна", + Gender = 2, + PassportNumber = "6010 012345", + PhoneNumber = "+7 (999) 012-34-56", + RhFactor = 0 + }, + new + { + Id = 11, + Address = "ул. Мичурина, д. 18, кв. 67", + BirthDate = new DateTime(1998, 1, 8, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 2, + FullName = "Белова Наталья Сергеевна", + Gender = 2, + PassportNumber = "6011 123456", + PhoneNumber = "+7 (999) 123-56-78", + RhFactor = 0 + }, + new + { + Id = 12, + Address = "ул. Лермонтова, д. 9, кв. 14", + BirthDate = new DateTime(1983, 9, 12, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 3, + FullName = "Карпов Евгений Владимирович", + Gender = 1, + PassportNumber = "6012 234567", + PhoneNumber = "+7 (999) 234-67-89", + RhFactor = 1 + }); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Specialization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Specializations"); + + b.HasData( + new + { + Id = 1, + Code = "THERAPIST", + Description = "Врач общей практики", + Name = "Терапевт" + }, + new + { + Id = 2, + Code = "SURGEON", + Description = "Проведение операций", + Name = "Хирург" + }, + new + { + Id = 3, + Code = "CARDIOLOGIST", + Description = "Заболевания сердца", + Name = "Кардиолог" + }, + new + { + Id = 4, + Code = "NEUROLOGIST", + Description = "Заболевания нервной системы", + Name = "Невролог" + }, + new + { + Id = 5, + Code = "PEDIATRICIAN", + Description = "Детские болезни", + Name = "Педиатр" + }, + new + { + Id = 6, + Code = "GYNECOLOGIST", + Description = "Женское здоровье", + Name = "Гинеколог" + }, + new + { + Id = 7, + Code = "OPHTHALMOLOGIST", + Description = "Заболевания глаз", + Name = "Офтальмолог" + }, + new + { + Id = 8, + Code = "ENT", + Description = "Ухо, горло, нос", + Name = "Отоларинголог" + }, + new + { + Id = 9, + Code = "DERMATOLOGIST", + Description = "Кожные заболевания", + Name = "Дерматолог" + }, + new + { + Id = 10, + Code = "ENDOCRINOLOGIST", + Description = "Гормональные нарушения", + Name = "Эндокринолог" + }); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Appointment", b => + { + b.HasOne("Polyclinic.Domain.Entities.Doctor", "Doctor") + .WithMany("Appointments") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Polyclinic.Domain.Entities.Patient", "Patient") + .WithMany("Appointments") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Doctor", b => + { + b.HasOne("Polyclinic.Domain.Entities.Specialization", "Specialization") + .WithMany("Doctors") + .HasForeignKey("SpecializationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specialization"); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Doctor", b => + { + b.Navigation("Appointments"); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Patient", b => + { + b.Navigation("Appointments"); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Specialization", b => + { + b.Navigation("Doctors"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Polyclinic/Polyclinic.Infrastructure.EfCore/Migrations/20260216193102_InitialCreate.cs b/Polyclinic/Polyclinic.Infrastructure.EfCore/Migrations/20260216193102_InitialCreate.cs new file mode 100644 index 000000000..e36d11bca --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.EfCore/Migrations/20260216193102_InitialCreate.cs @@ -0,0 +1,206 @@ +// +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Polyclinic.Infrastructure.EfCore.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Patients", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PassportNumber = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + FullName = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + Gender = table.Column(type: "int", nullable: false), + BirthDate = table.Column(type: "datetime2", nullable: false), + Address = table.Column(type: "nvarchar(250)", maxLength: 250, nullable: false), + BloodGroup = table.Column(type: "int", nullable: false), + RhFactor = table.Column(type: "int", nullable: false), + PhoneNumber = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Patients", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Specializations", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Code = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Specializations", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Doctors", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PassportNumber = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + FullName = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + BirthDate = table.Column(type: "datetime2", nullable: false), + SpecializationId = table.Column(type: "int", nullable: false), + ExperienceYears = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Doctors", x => x.Id); + table.ForeignKey( + name: "FK_Doctors_Specializations_SpecializationId", + column: x => x.SpecializationId, + principalTable: "Specializations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Appointments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + AppointmentDateTime = table.Column(type: "datetime2", nullable: false), + RoomNumber = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: false), + IsRepeat = table.Column(type: "bit", nullable: false), + PatientId = table.Column(type: "int", nullable: false), + DoctorId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Appointments", x => x.Id); + table.ForeignKey( + name: "FK_Appointments_Doctors_DoctorId", + column: x => x.DoctorId, + principalTable: "Doctors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Appointments_Patients_PatientId", + column: x => x.PatientId, + principalTable: "Patients", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "Patients", + columns: new[] { "Id", "Address", "BirthDate", "BloodGroup", "FullName", "Gender", "PassportNumber", "PhoneNumber", "RhFactor" }, + values: new object[,] + { + { 1, "ул. Ленина, д. 10, кв. 25", new DateTime(1990, 5, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), 1, "Смирнов Алексей Викторович", 1, "6001 123456", "+7 (999) 123-45-67", 0 }, + { 2, "ул. Гагарина, д. 5, кв. 12", new DateTime(1985, 8, 22, 0, 0, 0, 0, DateTimeKind.Unspecified), 0, "Кузнецова Елена Дмитриевна", 2, "6002 234567", "+7 (999) 234-56-78", 0 }, + { 3, "пр. Мира, д. 15, кв. 7", new DateTime(1978, 3, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), 2, "Попов Сергей Иванович", 1, "6003 345678", "+7 (999) 345-67-89", 1 }, + { 4, "ул. Советская, д. 8, кв. 42", new DateTime(1995, 11, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), 3, "Васильева Мария Петровна", 2, "6004 456789", "+7 (999) 456-78-90", 0 }, + { 5, "ул. Пушкина, д. 3, кв. 56", new DateTime(1982, 7, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), 1, "Соколов Андрей Николаевич", 1, "6005 567890", "+7 (999) 567-89-01", 1 }, + { 6, "пр. Ленинградский, д. 22, кв. 15", new DateTime(1975, 9, 25, 0, 0, 0, 0, DateTimeKind.Unspecified), 0, "Михайлова Анна Сергеевна", 2, "6006 678901", "+7 (999) 678-90-12", 0 }, + { 7, "ул. Кирова, д. 12, кв. 8", new DateTime(1988, 2, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), 2, "Новиков Денис Александрович", 1, "6007 789012", "+7 (999) 789-01-23", 0 }, + { 8, "ул. Садовая, д. 7, кв. 31", new DateTime(1992, 6, 5, 0, 0, 0, 0, DateTimeKind.Unspecified), 3, "Морозова Татьяна Владимировна", 2, "6008 890123", "+7 (999) 890-12-34", 1 }, + { 9, "пр. Невский, д. 45, кв. 19", new DateTime(1970, 12, 3, 0, 0, 0, 0, DateTimeKind.Unspecified), 1, "Зайцев Игорь Павлович", 1, "6009 901234", "+7 (999) 901-23-45", 0 }, + { 10, "ул. Комсомольская, д. 6, кв. 23", new DateTime(1965, 4, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 0, "Волкова Ольга Игоревна", 2, "6010 012345", "+7 (999) 012-34-56", 0 }, + { 11, "ул. Мичурина, д. 18, кв. 67", new DateTime(1998, 1, 8, 0, 0, 0, 0, DateTimeKind.Unspecified), 2, "Белова Наталья Сергеевна", 2, "6011 123456", "+7 (999) 123-56-78", 0 }, + { 12, "ул. Лермонтова, д. 9, кв. 14", new DateTime(1983, 9, 12, 0, 0, 0, 0, DateTimeKind.Unspecified), 3, "Карпов Евгений Владимирович", 1, "6012 234567", "+7 (999) 234-67-89", 1 } + }); + + migrationBuilder.InsertData( + table: "Specializations", + columns: new[] { "Id", "Code", "Description", "Name" }, + values: new object[,] + { + { 1, "THERAPIST", "Врач общей практики", "Терапевт" }, + { 2, "SURGEON", "Проведение операций", "Хирург" }, + { 3, "CARDIOLOGIST", "Заболевания сердца", "Кардиолог" }, + { 4, "NEUROLOGIST", "Заболевания нервной системы", "Невролог" }, + { 5, "PEDIATRICIAN", "Детские болезни", "Педиатр" }, + { 6, "GYNECOLOGIST", "Женское здоровье", "Гинеколог" }, + { 7, "OPHTHALMOLOGIST", "Заболевания глаз", "Офтальмолог" }, + { 8, "ENT", "Ухо, горло, нос", "Отоларинголог" }, + { 9, "DERMATOLOGIST", "Кожные заболевания", "Дерматолог" }, + { 10, "ENDOCRINOLOGIST", "Гормональные нарушения", "Эндокринолог" } + }); + + migrationBuilder.InsertData( + table: "Doctors", + columns: new[] { "Id", "BirthDate", "ExperienceYears", "FullName", "PassportNumber", "SpecializationId" }, + values: new object[,] + { + { 1, new DateTime(1980, 5, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), 15, "Иванов Иван Иванович", "4501 123456", 1 }, + { 2, new DateTime(1975, 8, 22, 0, 0, 0, 0, DateTimeKind.Unspecified), 20, "Петров Петр Петрович", "4502 234567", 2 }, + { 3, new DateTime(1985, 3, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), 12, "Сидорова Анна Сергеевна", "4503 345678", 3 }, + { 4, new DateTime(1990, 11, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), 8, "Козлов Дмитрий Николаевич", "4504 456789", 4 }, + { 5, new DateTime(1982, 7, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), 14, "Морозова Елена Владимировна", "4505 567890", 5 }, + { 6, new DateTime(1978, 9, 25, 0, 0, 0, 0, DateTimeKind.Unspecified), 18, "Волков Андрей Игоревич", "4506 678901", 6 }, + { 7, new DateTime(1988, 2, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), 10, "Соколова Татьяна Александровна", "4507 789012", 7 }, + { 8, new DateTime(1992, 6, 5, 0, 0, 0, 0, DateTimeKind.Unspecified), 6, "Лебедев Михаил Сергеевич", "4508 890123", 8 }, + { 9, new DateTime(1983, 12, 3, 0, 0, 0, 0, DateTimeKind.Unspecified), 13, "Николаева Ольга Викторовна", "4509 901234", 9 }, + { 10, new DateTime(1970, 4, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), 25, "Федоров Алексей Павлович", "4510 012345", 10 } + }); + + migrationBuilder.InsertData( + table: "Appointments", + columns: new[] { "Id", "AppointmentDateTime", "DoctorId", "IsRepeat", "PatientId", "RoomNumber" }, + values: new object[,] + { + { 1, new DateTime(2026, 2, 5, 10, 0, 0, 0, DateTimeKind.Unspecified), 1, false, 1, "101" }, + { 2, new DateTime(2026, 2, 5, 11, 0, 0, 0, DateTimeKind.Unspecified), 1, true, 2, "101" }, + { 3, new DateTime(2026, 2, 10, 14, 30, 0, 0, DateTimeKind.Unspecified), 2, false, 3, "202" }, + { 4, new DateTime(2026, 2, 15, 9, 15, 0, 0, DateTimeKind.Unspecified), 3, false, 4, "303" }, + { 5, new DateTime(2026, 2, 12, 13, 30, 0, 0, DateTimeKind.Unspecified), 1, false, 8, "101" }, + { 6, new DateTime(2026, 2, 16, 9, 30, 0, 0, DateTimeKind.Unspecified), 1, false, 11, "101" }, + { 7, new DateTime(2026, 1, 15, 10, 0, 0, 0, DateTimeKind.Unspecified), 2, true, 6, "101" }, + { 8, new DateTime(2026, 1, 20, 11, 0, 0, 0, DateTimeKind.Unspecified), 2, true, 7, "202" }, + { 9, new DateTime(2026, 1, 5, 9, 0, 0, 0, DateTimeKind.Unspecified), 3, false, 8, "303" }, + { 10, new DateTime(2026, 2, 8, 9, 0, 0, 0, DateTimeKind.Unspecified), 2, false, 5, "102" }, + { 11, new DateTime(2026, 2, 22, 11, 30, 0, 0, DateTimeKind.Unspecified), 6, false, 5, "606" } + }); + + migrationBuilder.CreateIndex( + name: "IX_Appointments_DoctorId", + table: "Appointments", + column: "DoctorId"); + + migrationBuilder.CreateIndex( + name: "IX_Appointments_PatientId", + table: "Appointments", + column: "PatientId"); + + migrationBuilder.CreateIndex( + name: "IX_Doctors_SpecializationId", + table: "Doctors", + column: "SpecializationId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Appointments"); + + migrationBuilder.DropTable( + name: "Doctors"); + + migrationBuilder.DropTable( + name: "Patients"); + + migrationBuilder.DropTable( + name: "Specializations"); + } + } +} diff --git a/Polyclinic/Polyclinic.Infrastructure.EfCore/Migrations/PolyclinicDbContextModelSnapshot.cs b/Polyclinic/Polyclinic.Infrastructure.EfCore/Migrations/PolyclinicDbContextModelSnapshot.cs new file mode 100644 index 000000000..3b052e867 --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.EfCore/Migrations/PolyclinicDbContextModelSnapshot.cs @@ -0,0 +1,624 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Polyclinic.Infrastructure.EfCore; + +#nullable disable + +namespace Polyclinic.Infrastructure.EfCore.Migrations +{ + [DbContext(typeof(PolyclinicDbContext))] + partial class PolyclinicDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Appointment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppointmentDateTime") + .HasColumnType("datetime2"); + + b.Property("DoctorId") + .HasColumnType("int"); + + b.Property("IsRepeat") + .HasColumnType("bit"); + + b.Property("PatientId") + .HasColumnType("int"); + + b.Property("RoomNumber") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("PatientId"); + + b.ToTable("Appointments"); + + b.HasData( + new + { + Id = 1, + AppointmentDateTime = new DateTime(2026, 2, 5, 10, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 1, + IsRepeat = false, + PatientId = 1, + RoomNumber = "101" + }, + new + { + Id = 2, + AppointmentDateTime = new DateTime(2026, 2, 5, 11, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 1, + IsRepeat = true, + PatientId = 2, + RoomNumber = "101" + }, + new + { + Id = 3, + AppointmentDateTime = new DateTime(2026, 2, 10, 14, 30, 0, 0, DateTimeKind.Unspecified), + DoctorId = 2, + IsRepeat = false, + PatientId = 3, + RoomNumber = "202" + }, + new + { + Id = 4, + AppointmentDateTime = new DateTime(2026, 2, 15, 9, 15, 0, 0, DateTimeKind.Unspecified), + DoctorId = 3, + IsRepeat = false, + PatientId = 4, + RoomNumber = "303" + }, + new + { + Id = 5, + AppointmentDateTime = new DateTime(2026, 2, 12, 13, 30, 0, 0, DateTimeKind.Unspecified), + DoctorId = 1, + IsRepeat = false, + PatientId = 8, + RoomNumber = "101" + }, + new + { + Id = 6, + AppointmentDateTime = new DateTime(2026, 2, 16, 9, 30, 0, 0, DateTimeKind.Unspecified), + DoctorId = 1, + IsRepeat = false, + PatientId = 11, + RoomNumber = "101" + }, + new + { + Id = 7, + AppointmentDateTime = new DateTime(2026, 1, 15, 10, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 2, + IsRepeat = true, + PatientId = 6, + RoomNumber = "101" + }, + new + { + Id = 8, + AppointmentDateTime = new DateTime(2026, 1, 20, 11, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 2, + IsRepeat = true, + PatientId = 7, + RoomNumber = "202" + }, + new + { + Id = 9, + AppointmentDateTime = new DateTime(2026, 1, 5, 9, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 3, + IsRepeat = false, + PatientId = 8, + RoomNumber = "303" + }, + new + { + Id = 10, + AppointmentDateTime = new DateTime(2026, 2, 8, 9, 0, 0, 0, DateTimeKind.Unspecified), + DoctorId = 2, + IsRepeat = false, + PatientId = 5, + RoomNumber = "102" + }, + new + { + Id = 11, + AppointmentDateTime = new DateTime(2026, 2, 22, 11, 30, 0, 0, DateTimeKind.Unspecified), + DoctorId = 6, + IsRepeat = false, + PatientId = 5, + RoomNumber = "606" + }); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Doctor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("datetime2"); + + b.Property("ExperienceYears") + .HasColumnType("int"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("PassportNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SpecializationId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SpecializationId"); + + b.ToTable("Doctors"); + + b.HasData( + new + { + Id = 1, + BirthDate = new DateTime(1980, 5, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 15, + FullName = "Иванов Иван Иванович", + PassportNumber = "4501 123456", + SpecializationId = 1 + }, + new + { + Id = 2, + BirthDate = new DateTime(1975, 8, 22, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 20, + FullName = "Петров Петр Петрович", + PassportNumber = "4502 234567", + SpecializationId = 2 + }, + new + { + Id = 3, + BirthDate = new DateTime(1985, 3, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 12, + FullName = "Сидорова Анна Сергеевна", + PassportNumber = "4503 345678", + SpecializationId = 3 + }, + new + { + Id = 4, + BirthDate = new DateTime(1990, 11, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 8, + FullName = "Козлов Дмитрий Николаевич", + PassportNumber = "4504 456789", + SpecializationId = 4 + }, + new + { + Id = 5, + BirthDate = new DateTime(1982, 7, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 14, + FullName = "Морозова Елена Владимировна", + PassportNumber = "4505 567890", + SpecializationId = 5 + }, + new + { + Id = 6, + BirthDate = new DateTime(1978, 9, 25, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 18, + FullName = "Волков Андрей Игоревич", + PassportNumber = "4506 678901", + SpecializationId = 6 + }, + new + { + Id = 7, + BirthDate = new DateTime(1988, 2, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 10, + FullName = "Соколова Татьяна Александровна", + PassportNumber = "4507 789012", + SpecializationId = 7 + }, + new + { + Id = 8, + BirthDate = new DateTime(1992, 6, 5, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 6, + FullName = "Лебедев Михаил Сергеевич", + PassportNumber = "4508 890123", + SpecializationId = 8 + }, + new + { + Id = 9, + BirthDate = new DateTime(1983, 12, 3, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 13, + FullName = "Николаева Ольга Викторовна", + PassportNumber = "4509 901234", + SpecializationId = 9 + }, + new + { + Id = 10, + BirthDate = new DateTime(1970, 4, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), + ExperienceYears = 25, + FullName = "Федоров Алексей Павлович", + PassportNumber = "4510 012345", + SpecializationId = 10 + }); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("BirthDate") + .HasColumnType("datetime2"); + + b.Property("BloodGroup") + .HasColumnType("int"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("PassportNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RhFactor") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Patients"); + + b.HasData( + new + { + Id = 1, + Address = "ул. Ленина, д. 10, кв. 25", + BirthDate = new DateTime(1990, 5, 15, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 1, + FullName = "Смирнов Алексей Викторович", + Gender = 1, + PassportNumber = "6001 123456", + PhoneNumber = "+7 (999) 123-45-67", + RhFactor = 0 + }, + new + { + Id = 2, + Address = "ул. Гагарина, д. 5, кв. 12", + BirthDate = new DateTime(1985, 8, 22, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 0, + FullName = "Кузнецова Елена Дмитриевна", + Gender = 2, + PassportNumber = "6002 234567", + PhoneNumber = "+7 (999) 234-56-78", + RhFactor = 0 + }, + new + { + Id = 3, + Address = "пр. Мира, д. 15, кв. 7", + BirthDate = new DateTime(1978, 3, 10, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 2, + FullName = "Попов Сергей Иванович", + Gender = 1, + PassportNumber = "6003 345678", + PhoneNumber = "+7 (999) 345-67-89", + RhFactor = 1 + }, + new + { + Id = 4, + Address = "ул. Советская, д. 8, кв. 42", + BirthDate = new DateTime(1995, 11, 30, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 3, + FullName = "Васильева Мария Петровна", + Gender = 2, + PassportNumber = "6004 456789", + PhoneNumber = "+7 (999) 456-78-90", + RhFactor = 0 + }, + new + { + Id = 5, + Address = "ул. Пушкина, д. 3, кв. 56", + BirthDate = new DateTime(1982, 7, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 1, + FullName = "Соколов Андрей Николаевич", + Gender = 1, + PassportNumber = "6005 567890", + PhoneNumber = "+7 (999) 567-89-01", + RhFactor = 1 + }, + new + { + Id = 6, + Address = "пр. Ленинградский, д. 22, кв. 15", + BirthDate = new DateTime(1975, 9, 25, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 0, + FullName = "Михайлова Анна Сергеевна", + Gender = 2, + PassportNumber = "6006 678901", + PhoneNumber = "+7 (999) 678-90-12", + RhFactor = 0 + }, + new + { + Id = 7, + Address = "ул. Кирова, д. 12, кв. 8", + BirthDate = new DateTime(1988, 2, 14, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 2, + FullName = "Новиков Денис Александрович", + Gender = 1, + PassportNumber = "6007 789012", + PhoneNumber = "+7 (999) 789-01-23", + RhFactor = 0 + }, + new + { + Id = 8, + Address = "ул. Садовая, д. 7, кв. 31", + BirthDate = new DateTime(1992, 6, 5, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 3, + FullName = "Морозова Татьяна Владимировна", + Gender = 2, + PassportNumber = "6008 890123", + PhoneNumber = "+7 (999) 890-12-34", + RhFactor = 1 + }, + new + { + Id = 9, + Address = "пр. Невский, д. 45, кв. 19", + BirthDate = new DateTime(1970, 12, 3, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 1, + FullName = "Зайцев Игорь Павлович", + Gender = 1, + PassportNumber = "6009 901234", + PhoneNumber = "+7 (999) 901-23-45", + RhFactor = 0 + }, + new + { + Id = 10, + Address = "ул. Комсомольская, д. 6, кв. 23", + BirthDate = new DateTime(1965, 4, 20, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 0, + FullName = "Волкова Ольга Игоревна", + Gender = 2, + PassportNumber = "6010 012345", + PhoneNumber = "+7 (999) 012-34-56", + RhFactor = 0 + }, + new + { + Id = 11, + Address = "ул. Мичурина, д. 18, кв. 67", + BirthDate = new DateTime(1998, 1, 8, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 2, + FullName = "Белова Наталья Сергеевна", + Gender = 2, + PassportNumber = "6011 123456", + PhoneNumber = "+7 (999) 123-56-78", + RhFactor = 0 + }, + new + { + Id = 12, + Address = "ул. Лермонтова, д. 9, кв. 14", + BirthDate = new DateTime(1983, 9, 12, 0, 0, 0, 0, DateTimeKind.Unspecified), + BloodGroup = 3, + FullName = "Карпов Евгений Владимирович", + Gender = 1, + PassportNumber = "6012 234567", + PhoneNumber = "+7 (999) 234-67-89", + RhFactor = 1 + }); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Specialization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Specializations"); + + b.HasData( + new + { + Id = 1, + Code = "THERAPIST", + Description = "Врач общей практики", + Name = "Терапевт" + }, + new + { + Id = 2, + Code = "SURGEON", + Description = "Проведение операций", + Name = "Хирург" + }, + new + { + Id = 3, + Code = "CARDIOLOGIST", + Description = "Заболевания сердца", + Name = "Кардиолог" + }, + new + { + Id = 4, + Code = "NEUROLOGIST", + Description = "Заболевания нервной системы", + Name = "Невролог" + }, + new + { + Id = 5, + Code = "PEDIATRICIAN", + Description = "Детские болезни", + Name = "Педиатр" + }, + new + { + Id = 6, + Code = "GYNECOLOGIST", + Description = "Женское здоровье", + Name = "Гинеколог" + }, + new + { + Id = 7, + Code = "OPHTHALMOLOGIST", + Description = "Заболевания глаз", + Name = "Офтальмолог" + }, + new + { + Id = 8, + Code = "ENT", + Description = "Ухо, горло, нос", + Name = "Отоларинголог" + }, + new + { + Id = 9, + Code = "DERMATOLOGIST", + Description = "Кожные заболевания", + Name = "Дерматолог" + }, + new + { + Id = 10, + Code = "ENDOCRINOLOGIST", + Description = "Гормональные нарушения", + Name = "Эндокринолог" + }); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Appointment", b => + { + b.HasOne("Polyclinic.Domain.Entities.Doctor", "Doctor") + .WithMany("Appointments") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Polyclinic.Domain.Entities.Patient", "Patient") + .WithMany("Appointments") + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("Patient"); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Doctor", b => + { + b.HasOne("Polyclinic.Domain.Entities.Specialization", "Specialization") + .WithMany("Doctors") + .HasForeignKey("SpecializationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specialization"); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Doctor", b => + { + b.Navigation("Appointments"); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Patient", b => + { + b.Navigation("Appointments"); + }); + + modelBuilder.Entity("Polyclinic.Domain.Entities.Specialization", b => + { + b.Navigation("Doctors"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Polyclinic/Polyclinic.Infrastructure.EfCore/Polyclinic.Infrastructure.EfCore.csproj b/Polyclinic/Polyclinic.Infrastructure.EfCore/Polyclinic.Infrastructure.EfCore.csproj new file mode 100644 index 000000000..edff78024 --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.EfCore/Polyclinic.Infrastructure.EfCore.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Polyclinic/Polyclinic.Infrastructure.EfCore/PolyclinicDbContext.cs b/Polyclinic/Polyclinic.Infrastructure.EfCore/PolyclinicDbContext.cs new file mode 100644 index 000000000..6965e1b19 --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.EfCore/PolyclinicDbContext.cs @@ -0,0 +1,165 @@ +using Microsoft.EntityFrameworkCore; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Infrastructure.EfCore; + +/// +/// Контекст базы данных поликлиники +/// +public class PolyclinicDbContext( + DbContextOptions options, + PolyclinicFixture fixture) : DbContext(options) +{ + /// + /// Таблица специализаций врачей (справочник) + /// + public DbSet Specializations { get; set; } + + /// + /// Таблица врачей поликлиники + /// + public DbSet Doctors { get; set; } + + /// + /// Таблица пациентов + /// + public DbSet Patients { get; set; } + + /// + /// Таблица записей на прием (журнал посещений) + /// + public DbSet Appointments { get; set; } + + /// + /// Настройка модели базы данных при её создании + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + ConfigureSpecialization(modelBuilder); + ConfigureDoctor(modelBuilder); + ConfigurePatient(modelBuilder); + ConfigureAppointment(modelBuilder); + } + + /// + /// Конфигурация сущности + /// + private void ConfigureSpecialization(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.Name) + .IsRequired() + .HasMaxLength(100); + + entity.Property(e => e.Code) + .IsRequired() + .HasMaxLength(20); + + entity.Property(e => e.Description) + .HasMaxLength(500); + + if (fixture.Specializations.Count > 0) + { + entity.HasData(fixture.Specializations); + } + }); + } + + /// + /// Конфигурация сущности + /// + private void ConfigureDoctor(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.FullName) + .IsRequired() + .HasMaxLength(150); + + entity.Property(e => e.PassportNumber) + .IsRequired() + .HasMaxLength(20); + + entity.HasOne(d => d.Specialization) + .WithMany(s => s.Doctors) + .HasForeignKey(d => d.SpecializationId) + .OnDelete(DeleteBehavior.Restrict); + + if (fixture.Doctors.Count > 0) + { + entity.HasData(fixture.Doctors); + } + }); + } + + /// + /// Конфигурация сущности + /// + private void ConfigurePatient(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.FullName) + .IsRequired() + .HasMaxLength(150); + + entity.Property(e => e.PassportNumber) + .IsRequired() + .HasMaxLength(20); + + entity.Property(e => e.Address) + .HasMaxLength(250); + + entity.Property(e => e.PhoneNumber) + .HasMaxLength(20); + + if (fixture.Patients.Count > 0) + { + entity.HasData(fixture.Patients); + } + }); + } + + /// + /// Конфигурация сущности + /// + private void ConfigureAppointment(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.Property(e => e.RoomNumber) + .HasMaxLength(10) + .IsRequired(); + + entity.Property(e => e.AppointmentDateTime) + .HasColumnType("datetime2"); + + entity.HasOne(a => a.Patient) + .WithMany(p => p.Appointments) + .HasForeignKey(a => a.PatientId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(a => a.Doctor) + .WithMany(d => d.Appointments) + .HasForeignKey(a => a.DoctorId) + .OnDelete(DeleteBehavior.Restrict); + + if (fixture.Appointments.Count > 0) + { + entity.HasData(fixture.Appointments); + } + }); + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/AppointmentRepository.cs b/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/AppointmentRepository.cs new file mode 100644 index 000000000..2396d127b --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/AppointmentRepository.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Infrastructure.EfCore.Repositories; + +/// +/// Репозиторий для управления записями на прием +/// +public class AppointmentRepository(PolyclinicDbContext context) : IRepository +{ + /// + /// Создаёт новую запись на прием в базе данных + /// + public async Task Create(Appointment entity) + { + await context.Appointments.AddAsync(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Получает запись по идентификатору с данными о враче и пациенте + /// + public async Task Read(int entityId) + { + return await context.Appointments + .Include(a => a.Patient) + .Include(a => a.Doctor) + .ThenInclude(d => d!.Specialization) + .FirstOrDefaultAsync(a => a.Id == entityId); + } + + /// + /// Получает список всех записей с данными о врачах и пациентах + /// + public async Task> ReadAll() + { + return await context.Appointments + .Include(a => a.Patient) + .Include(a => a.Doctor) + .ThenInclude(d => d!.Specialization) + .ToListAsync(); + } + + /// + /// Обновляет данные записи на прием + /// + public async Task Update(Appointment entity) + { + context.Appointments.Update(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Удаляет запись на прием по идентификатору + /// + public async Task Delete(int entityId) + { + var entity = await context.Appointments.FirstOrDefaultAsync(e => e.Id == entityId); + if (entity == null) + { + return false; + } + + context.Appointments.Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/DoctorRepository.cs b/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/DoctorRepository.cs new file mode 100644 index 000000000..b51313ca6 --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/DoctorRepository.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Infrastructure.EfCore.Repositories; + +/// +/// Репозиторий для управления врачами +/// +public class DoctorRepository(PolyclinicDbContext context) : IRepository +{ + /// + /// Создаёт нового врача в базе данных + /// + public async Task Create(Doctor entity) + { + await context.Doctors.AddAsync(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Получает врача по идентификатору вместе со специализацией + /// + public async Task Read(int entityId) + { + return await context.Doctors + .Include(d => d.Specialization) + .FirstOrDefaultAsync(d => d.Id == entityId); + } + + /// + /// Получает список всех врачей вместе с их специализациями + /// + public async Task> ReadAll() + { + return await context.Doctors + .Include(d => d.Specialization) + .ToListAsync(); + } + + /// + /// Обновляет данные врача + /// + public async Task Update(Doctor entity) + { + context.Doctors.Update(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Удаляет врача по идентификатору + /// + public async Task Delete(int entityId) + { + var entity = await context.Doctors.FirstOrDefaultAsync(e => e.Id == entityId); + if (entity == null) + { + return false; + } + + context.Doctors.Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/PatientRepository.cs b/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/PatientRepository.cs new file mode 100644 index 000000000..e68717bac --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/PatientRepository.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Infrastructure.EfCore.Repositories; + +/// +/// Репозиторий для управления пациентами +/// +public class PatientRepository(PolyclinicDbContext context) : IRepository +{ + /// + /// Создаёт нового пациента в базе данных + /// + public async Task Create(Patient entity) + { + await context.Patients.AddAsync(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Получает пациента по идентификатору + /// + public async Task Read(int entityId) + { + return await context.Patients.FirstOrDefaultAsync(e => e.Id == entityId); + } + + /// + /// Получает список всех пациентов + /// + public async Task> ReadAll() + { + return await context.Patients.ToListAsync(); + } + + /// + /// Обновляет данные пациента + /// + public async Task Update(Patient entity) + { + context.Patients.Update(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Удаляет пациента по идентификатору + /// + public async Task Delete(int entityId) + { + var entity = await context.Patients.FirstOrDefaultAsync(e => e.Id == entityId); + if (entity == null) + { + return false; + } + + context.Patients.Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/SpecializationRepository.cs b/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/SpecializationRepository.cs new file mode 100644 index 000000000..a2559e2d5 --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.EfCore/Repositories/SpecializationRepository.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore; +using Polyclinic.Domain; +using Polyclinic.Domain.Entities; + +namespace Polyclinic.Infrastructure.EfCore.Repositories; + +/// +/// Репозиторий для управления специализациями +/// +public class SpecializationRepository(PolyclinicDbContext context) : IRepository +{ + /// + /// Создаёт новую специализацию в базе данных + /// + public async Task Create(Specialization entity) + { + await context.Specializations.AddAsync(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Получает специализацию по идентификатору + /// + public async Task Read(int entityId) + { + return await context.Specializations.FirstOrDefaultAsync(e => e.Id == entityId); + } + + /// + /// Получает список всех специализаций + /// + public async Task> ReadAll() + { + return await context.Specializations.ToListAsync(); + } + + /// + /// Обновляет данные специализации + /// + public async Task Update(Specialization entity) + { + context.Specializations.Update(entity); + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Удаляет специализацию по идентификатору + /// + public async Task Delete(int entityId) + { + var entity = await context.Specializations.FirstOrDefaultAsync(e => e.Id == entityId); + if (entity == null) + { + return false; + } + + context.Specializations.Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Infrastructure.RabbitMq/AppointmentConsumer.cs b/Polyclinic/Polyclinic.Infrastructure.RabbitMq/AppointmentConsumer.cs new file mode 100644 index 000000000..c67f05adb --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.RabbitMq/AppointmentConsumer.cs @@ -0,0 +1,159 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polyclinic.Application.Contracts; +using Polyclinic.Application.Contracts.Appointments; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using System.Text; +using System.Text.Json; + +namespace Polyclinic.Infrastructure.RabbitMq; + +/// +/// Фоновый сервис для чтения сообщений из RabbitMQ и создания записей на прием +/// +/// Активное соединение с RabbitMQ +/// Настройки очереди и повторных попыток +/// Логгер для записи событий и ошибок +/// Фабрика областей видимости для получения Scoped-сервисов +public class AppointmentConsumer( + IConnection connection, + IOptions options, + ILogger logger, + IServiceScopeFactory serviceScopeFactory) : BackgroundService +{ + private readonly RabbitMqOptions _options = options.Value; + private IChannel? _channel; + + /// + /// Основной цикл выполнения фоновой задачи + /// Инициализирует канал, объявляет очередь и подписывается на события получения сообщений + /// + /// Токен отмены для остановки сервиса + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + logger.LogInformation("Инициализация подключения к RabbitMQ."); + + _channel = await connection.CreateChannelAsync(options: null, cancellationToken: stoppingToken); + + await _channel.QueueDeclareAsync( + queue: _options.QueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + cancellationToken: stoppingToken); + + await _channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 10, global: false, cancellationToken: stoppingToken); + + logger.LogInformation("Подключение успешно. Очередь: {QueueName}", _options.QueueName); + + var consumer = new AsyncEventingBasicConsumer(_channel); + + consumer.ReceivedAsync += async (sender, @event) => + { + var content = Encoding.UTF8.GetString(@event.Body.Span); + logger.LogInformation("Получено сообщение: {Content}", content); + + try + { + var dto = JsonSerializer.Deserialize(content); + + if (dto is null) + { + logger.LogWarning("Сообщение пустое или некорректный JSON. Удаляем из очереди."); + await _channel.BasicAckAsync(@event.DeliveryTag, multiple: false); + return; + } + + var processed = await ProcessMessageAsync(dto, stoppingToken); + + if (processed) + { + await _channel.BasicAckAsync(@event.DeliveryTag, multiple: false); + logger.LogInformation("Сообщение успешно обработано."); + } + else + { + logger.LogError("Сообщение не обработано после {RetryCount} попыток. Удаляем.", _options.RetryCount); + await _channel.BasicNackAsync(@event.DeliveryTag, multiple: false, requeue: false); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Критическая ошибка в обработчике событий RabbitMQ."); + await _channel.BasicNackAsync(@event.DeliveryTag, multiple: false, requeue: false); + } + }; + + await _channel.BasicConsumeAsync(queue: _options.QueueName, autoAck: false, consumer: consumer, cancellationToken: stoppingToken); + } + catch (OperationCanceledException) + { + logger.LogInformation("Операция получения сообщений была отменена, RabbitMQ Consumer останавливается."); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Ошибка запуска RabbitMQ Consumer."); + } + } + + /// + /// Обрабатывает полученное DTO сообщения, пытаясь сохранить запись в БД + /// + /// Данные для создания записи на прием + /// Токен отмены + /// Возвращает true, если сообщение обработано успешно или отброшено из-за логической ошибки; false, если исчерпаны лимиты попыток + private async Task ProcessMessageAsync(AppointmentCreateUpdateDto dto, CancellationToken token) + { + using var scope = serviceScopeFactory.CreateScope(); + var appointmentService = scope.ServiceProvider.GetRequiredService>(); + + var currentRetry = 0; + + while (currentRetry <= _options.RetryCount) + { + if (token.IsCancellationRequested) return false; + + try + { + await appointmentService.Create(dto); + return true; + } + catch (KeyNotFoundException ex) + { + logger.LogError(ex, "Ошибка целостности данных: {Message}. Сообщение будет пропущено.", ex.Message); + return true; + } + catch (Exception ex) + { + currentRetry++; + logger.LogWarning(ex, "Ошибка при создании записи (Попытка {Retry}/{Max}). Ждем {Delay}мс...", + currentRetry, _options.RetryCount, _options.RetryDelayMs); + + if (currentRetry > _options.RetryCount) + { + logger.LogError("Превышен лимит попыток для сообщения."); + return false; + } + + await Task.Delay(_options.RetryDelayMs, token); + } + } + + return false; + } + + /// + /// Освобождает ресурсы канала + /// + public override void Dispose() + { + _channel?.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Infrastructure.RabbitMq/Polyclinic.Infrastructure.RabbitMq.csproj b/Polyclinic/Polyclinic.Infrastructure.RabbitMq/Polyclinic.Infrastructure.RabbitMq.csproj new file mode 100644 index 000000000..32e5029c2 --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.RabbitMq/Polyclinic.Infrastructure.RabbitMq.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Polyclinic/Polyclinic.Infrastructure.RabbitMq/RabbitMqOptions.cs b/Polyclinic/Polyclinic.Infrastructure.RabbitMq/RabbitMqOptions.cs new file mode 100644 index 000000000..d9a36a570 --- /dev/null +++ b/Polyclinic/Polyclinic.Infrastructure.RabbitMq/RabbitMqOptions.cs @@ -0,0 +1,22 @@ +namespace Polyclinic.Infrastructure.RabbitMq; + +/// +/// Настройки конфигурации для подключения и работы с RabbitMQ +/// +public class RabbitMqOptions +{ + /// + /// Имя очереди для прослушивания сообщений + /// + public required string QueueName { get; set; } = "appointment-contracts"; + + /// + /// Количество повторных попыток обработки сообщения при возникновении ошибок + /// + public int RetryCount { get; set; } = 5; + + /// + /// Задержка между повторными попытками в миллисекундах + /// + public int RetryDelayMs { get; set; } = 1000; +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.ServiceDefaults/Extensions.cs b/Polyclinic/Polyclinic.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..16471f423 --- /dev/null +++ b/Polyclinic/Polyclinic.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Polyclinic.ServiceDefaults; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/Polyclinic/Polyclinic.ServiceDefaults/Polyclinic.ServiceDefaults.csproj b/Polyclinic/Polyclinic.ServiceDefaults/Polyclinic.ServiceDefaults.csproj new file mode 100644 index 000000000..1b6e209a7 --- /dev/null +++ b/Polyclinic/Polyclinic.ServiceDefaults/Polyclinic.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/Polyclinic/Polyclinic.Tests/Polyclinic.Tests.csproj b/Polyclinic/Polyclinic.Tests/Polyclinic.Tests.csproj new file mode 100644 index 000000000..bb1e0508d --- /dev/null +++ b/Polyclinic/Polyclinic.Tests/Polyclinic.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/Polyclinic/Polyclinic.Tests/PolyclinicTests.cs b/Polyclinic/Polyclinic.Tests/PolyclinicTests.cs new file mode 100644 index 000000000..a80553106 --- /dev/null +++ b/Polyclinic/Polyclinic.Tests/PolyclinicTests.cs @@ -0,0 +1,175 @@ +using Polyclinic.Domain; + +namespace Polyclinic.Tests; + +/// +/// Тесты для поликлиники с использованием фикстуры +/// +public class PolyclinicTests(PolyclinicFixture fixture) : IClassFixture +{ + /// + /// ТЕСТ 1: Вывести информацию о всех врачах, стаж работы которых не менее 10 лет + /// + [Fact] + public void GetDoctorsWithExperienceMoreThan10Years() + { + var actual = ( + from doctor in fixture.Doctors + where doctor.ExperienceYears >= TestConstants.MinExperienceYears + orderby doctor.FullName + select doctor + ).ToList(); + + Assert.NotEmpty(actual); + Assert.All(actual, doctor => + Assert.True(doctor.ExperienceYears >= TestConstants.MinExperienceYears)); + + var excludedDoctors = fixture.Doctors.Except(actual); + Assert.All(excludedDoctors, doctor => + Assert.True(doctor.ExperienceYears < TestConstants.MinExperienceYears)); + + Assert.Equal([.. actual.OrderBy(d => d.FullName)], actual); + } + + /// + /// ТЕСТ 2: Вывести информацию о всех пациентах, записанных к указанному врачу, упорядочить по ФИО + /// + [Fact] + public void GetPatientsByDoctorOrderedByFullName() + { + var testDoctor = fixture.Doctors.First(d => fixture.Appointments.Any(a => a.DoctorId == d.Id)); + + var actual = ( + from appointment in fixture.Appointments + where appointment.DoctorId == testDoctor.Id + join patient in fixture.Patients on appointment.PatientId equals patient.Id + orderby patient.FullName + select patient + ).Distinct().ToList(); + + Assert.NotEmpty(actual); + Assert.All(actual, patient => + { + var hasAppointmentWithDoctor = fixture.Appointments + .Any(a => a.PatientId == patient.Id && a.DoctorId == testDoctor.Id); + Assert.True(hasAppointmentWithDoctor); + }); + + Assert.Equal([.. actual.OrderBy(p => p.FullName)], actual); + } + + /// + /// ТЕСТ 3: Вывести информацию о количестве повторных приемов пациентов за последний месяц + /// + [Fact] + public void CountRepeatAppointmentsLastMonth() + { + var actual = ( + from appointment in fixture.Appointments + where appointment.AppointmentDateTime >= TestConstants.StartOfLastMonth + where appointment.AppointmentDateTime < TestConstants.StartOfCurrentMonth + where appointment.IsRepeat + select appointment + ).ToList(); + + Assert.All(actual, appointment => + { + Assert.True(appointment.IsRepeat); + Assert.True(appointment.AppointmentDateTime >= TestConstants.StartOfLastMonth); + Assert.True(appointment.AppointmentDateTime < TestConstants.StartOfCurrentMonth); + }); + + var nonRepeatInLastMonth = fixture.Appointments + .Where(a => a.AppointmentDateTime >= TestConstants.StartOfLastMonth) + .Where(a => a.AppointmentDateTime < TestConstants.StartOfCurrentMonth) + .Where(a => !a.IsRepeat); + + Assert.All(nonRepeatInLastMonth, a => Assert.False(a.IsRepeat)); + } + + /// + /// ТЕСТ 4: Вывести информацию о пациентах старше 30 лет, + /// которые записаны на прием к нескольким врачам, упорядочить по дате рождения + /// + [Fact] + public void GetPatientsOver30WithMultipleDoctorsOrderedByBirthDate() + { + var actual = ( + from patient in fixture.Patients + where patient.GetAge(TestConstants.Today) >= TestConstants.MinPatientAge + let doctorsCount = ( + from appointment in fixture.Appointments + where appointment.PatientId == patient.Id + select appointment.DoctorId + ).Distinct().Count() + where doctorsCount >= 2 + orderby patient.BirthDate + select patient + ).ToList(); + + Assert.All(actual, patient => + { + Assert.True(patient.GetAge(TestConstants.Today) >= TestConstants.MinPatientAge); + + var doctorsCount = fixture.Appointments + .Where(a => a.PatientId == patient.Id) + .Select(a => a.DoctorId) + .Distinct() + .Count(); + Assert.True(doctorsCount >= 2); + }); + + var excludedPatients = fixture.Patients.Except(actual); + Assert.All(excludedPatients, patient => + { + if (patient.GetAge(TestConstants.Today) >= TestConstants.MinPatientAge) + { + var doctorsCount = fixture.Appointments + .Where(a => a.PatientId == patient.Id) + .Select(a => a.DoctorId) + .Distinct() + .Count(); + Assert.True(doctorsCount < 2); + } + }); + + Assert.Equal([.. actual.OrderBy(p => p.BirthDate)], actual); + } + + /// + /// ТЕСТ 5: Вывести информацию о приемах за текущий месяц, проходящих в выбранном кабинете + /// + [Fact] + public void GetAppointmentsCurrentMonthInRoom() + { + var testRoom = fixture.Appointments + .First(a => a.AppointmentDateTime >= TestConstants.StartOfCurrentMonth && + a.AppointmentDateTime < TestConstants.StartOfCurrentMonth.AddMonths(1)) + .RoomNumber; + + var actual = ( + from appointment in fixture.Appointments + where appointment.RoomNumber == testRoom + where appointment.AppointmentDateTime >= TestConstants.StartOfCurrentMonth + where appointment.AppointmentDateTime < TestConstants.StartOfCurrentMonth.AddMonths(1) + orderby appointment.AppointmentDateTime + select appointment + ).ToList(); + + Assert.NotEmpty(actual); + Assert.All(actual, appointment => + { + Assert.Equal(testRoom, appointment.RoomNumber); + Assert.True(appointment.AppointmentDateTime >= TestConstants.StartOfCurrentMonth); + Assert.True(appointment.AppointmentDateTime < TestConstants.StartOfCurrentMonth.AddMonths(1)); + }); + + var otherRoomAppointments = fixture.Appointments + .Where(a => a.AppointmentDateTime >= TestConstants.StartOfCurrentMonth) + .Where(a => a.AppointmentDateTime < TestConstants.StartOfCurrentMonth.AddMonths(1)) + .Where(a => a.RoomNumber != testRoom); + + Assert.All(otherRoomAppointments, a => Assert.NotEqual(testRoom, a.RoomNumber)); + Assert.Equal([.. actual.OrderBy(a => a.AppointmentDateTime)], actual); + } +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.Tests/TestConstants.cs b/Polyclinic/Polyclinic.Tests/TestConstants.cs new file mode 100644 index 000000000..53541a3f7 --- /dev/null +++ b/Polyclinic/Polyclinic.Tests/TestConstants.cs @@ -0,0 +1,47 @@ +namespace Polyclinic.Tests; + +/// +/// Константы для тестов +/// +public static class TestConstants +{ + /// + /// Фиксированная дата для тестов (15 февраля 2026) + /// + public static readonly DateTime Today = new(2026, 2, 15); + + /// + /// Начало текущего месяца + /// + public static readonly DateTime StartOfCurrentMonth = new(Today.Year, Today.Month, 1); + + /// + /// Начало прошлого месяца + /// + public static readonly DateTime StartOfLastMonth = StartOfCurrentMonth.AddMonths(-1); + + /// + /// Минимальный стаж для опытных врачей + /// + public const int MinExperienceYears = 10; + + /// + /// Минимальный возраст для "пациенты старше 30 лет" + /// + public const int MinPatientAge = 30; + + /// + /// Номер кабинета терапевта + /// + public const string TherapyRoom = "101"; + + /// + /// Номер кабинета хирурга + /// + public const string SurgeryRoom = "202"; + + /// + /// Номер кабинета кардиолога + /// + public const string CardiologyRoom = "303"; +} \ No newline at end of file diff --git a/Polyclinic/Polyclinic.sln b/Polyclinic/Polyclinic.sln new file mode 100644 index 000000000..26dbbb77a --- /dev/null +++ b/Polyclinic/Polyclinic.sln @@ -0,0 +1,79 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36915.13 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.Domain", "Polyclinic.Domain\Polyclinic.Domain.csproj", "{6C9C08E8-CE8E-422F-8BDC-E7B751E8E84A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.Tests", "Polyclinic.Tests\Polyclinic.Tests.csproj", "{0C24BBBB-C432-4776-9968-5DAD9432AB5A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.Infrastructure.EfCore", "Polyclinic.Infrastructure.EfCore\Polyclinic.Infrastructure.EfCore.csproj", "{33F86AF2-9AF7-425B-BA91-9CCF0FB7BBA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.Application.Contracts", "Polyclinic.Application.Contracts\Polyclinic.Application.Contracts.csproj", "{BA50FC27-A077-4902-B5D4-EE6E5485F0A3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.Application", "Polyclinic.Application\Polyclinic.Application.csproj", "{7DA00279-0DD6-4DE2-8790-E24B87AFA619}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.Api.Host", "Polyclinic.Api.Host\Polyclinic.Api.Host.csproj", "{D09E3941-0AB9-C8DB-7E7A-424732E05A20}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.AppHost", "Polyclinic.AppHost\Polyclinic.AppHost.csproj", "{472D09AB-BC31-4355-967F-DE9994323957}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.ServiceDefaults", "Polyclinic.ServiceDefaults\Polyclinic.ServiceDefaults.csproj", "{B3604DE1-E61C-254E-A2A2-7B3A153D5ED4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.Infrastructure.RabbitMq", "Polyclinic.Infrastructure.RabbitMq\Polyclinic.Infrastructure.RabbitMq.csproj", "{1DEF4446-AF07-4895-AEDE-4F5882D973FB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyclinic.Generator.RabbitMq.Host", "Polyclinic.Generator.RabbitMq.Host\Polyclinic.Generator.RabbitMq.Host.csproj", "{94FCD14B-F3F3-1B4A-2038-FCA0812FAE18}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6C9C08E8-CE8E-422F-8BDC-E7B751E8E84A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6C9C08E8-CE8E-422F-8BDC-E7B751E8E84A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C9C08E8-CE8E-422F-8BDC-E7B751E8E84A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6C9C08E8-CE8E-422F-8BDC-E7B751E8E84A}.Release|Any CPU.Build.0 = Release|Any CPU + {0C24BBBB-C432-4776-9968-5DAD9432AB5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C24BBBB-C432-4776-9968-5DAD9432AB5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C24BBBB-C432-4776-9968-5DAD9432AB5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C24BBBB-C432-4776-9968-5DAD9432AB5A}.Release|Any CPU.Build.0 = Release|Any CPU + {33F86AF2-9AF7-425B-BA91-9CCF0FB7BBA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33F86AF2-9AF7-425B-BA91-9CCF0FB7BBA5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33F86AF2-9AF7-425B-BA91-9CCF0FB7BBA5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33F86AF2-9AF7-425B-BA91-9CCF0FB7BBA5}.Release|Any CPU.Build.0 = Release|Any CPU + {BA50FC27-A077-4902-B5D4-EE6E5485F0A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA50FC27-A077-4902-B5D4-EE6E5485F0A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA50FC27-A077-4902-B5D4-EE6E5485F0A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA50FC27-A077-4902-B5D4-EE6E5485F0A3}.Release|Any CPU.Build.0 = Release|Any CPU + {7DA00279-0DD6-4DE2-8790-E24B87AFA619}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7DA00279-0DD6-4DE2-8790-E24B87AFA619}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7DA00279-0DD6-4DE2-8790-E24B87AFA619}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7DA00279-0DD6-4DE2-8790-E24B87AFA619}.Release|Any CPU.Build.0 = Release|Any CPU + {D09E3941-0AB9-C8DB-7E7A-424732E05A20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D09E3941-0AB9-C8DB-7E7A-424732E05A20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D09E3941-0AB9-C8DB-7E7A-424732E05A20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D09E3941-0AB9-C8DB-7E7A-424732E05A20}.Release|Any CPU.Build.0 = Release|Any CPU + {472D09AB-BC31-4355-967F-DE9994323957}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {472D09AB-BC31-4355-967F-DE9994323957}.Debug|Any CPU.Build.0 = Debug|Any CPU + {472D09AB-BC31-4355-967F-DE9994323957}.Release|Any CPU.ActiveCfg = Release|Any CPU + {472D09AB-BC31-4355-967F-DE9994323957}.Release|Any CPU.Build.0 = Release|Any CPU + {B3604DE1-E61C-254E-A2A2-7B3A153D5ED4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3604DE1-E61C-254E-A2A2-7B3A153D5ED4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3604DE1-E61C-254E-A2A2-7B3A153D5ED4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3604DE1-E61C-254E-A2A2-7B3A153D5ED4}.Release|Any CPU.Build.0 = Release|Any CPU + {1DEF4446-AF07-4895-AEDE-4F5882D973FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1DEF4446-AF07-4895-AEDE-4F5882D973FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1DEF4446-AF07-4895-AEDE-4F5882D973FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1DEF4446-AF07-4895-AEDE-4F5882D973FB}.Release|Any CPU.Build.0 = Release|Any CPU + {94FCD14B-F3F3-1B4A-2038-FCA0812FAE18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94FCD14B-F3F3-1B4A-2038-FCA0812FAE18}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94FCD14B-F3F3-1B4A-2038-FCA0812FAE18}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94FCD14B-F3F3-1B4A-2038-FCA0812FAE18}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1F0773E0-B943-4E7B-A2FB-1B30E6ADA139} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 76afcbfdd..bb2c6f182 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,183 @@ -# Разработка корпоративных приложений -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1JD6aiOG6r7GrA79oJncjgUHWtfeW4g_YZ9ayNgxb_w0/edit?usp=sharing) +# Разработка корпоративных приложений. Лабораторные работы №1-4 + +## Лабораторная работа №1 + +### Цель +Реализация объектной модели данных и unit-тестов. + +### Описание +Необходимо подготовить структуру классов, описывающих предметную область. В каждом из заданий присутствует часть, связанная с обработкой данных, представленная в разделе «Unit-тесты». Данную часть необходимо реализовать в виде unit-тестов: подготовить тестовые данные, выполнить запрос с использованием LINQ, проверить результаты. Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. + +### Предметная область - Поликлиника +В базе данных поликлиники содержится информация о записях пациентов на прием к врачам. + +Пациент характеризуется: номером паспорта, ФИО, полом, датой рождения, адресом, группой крови, резус фактором и контактным телефоном. +Пол пациента является перечислением. +Группа крови пациента является перечислением. +Резус фактор пациента является перечислением. + +Информация о враче включает номер паспорта, ФИО, год рождения, специализацию, стаж работы. Специализация врача является справочником. + +При записи на прием пациента в базе данных фиксируется дата и время приема, номер кабинета, а также индикатор того, является ли прием повторным. +Используется в качестве контракта. + +### Юнит-тесты + +- Вывести информацию о всех врачах, стаж работы которых не менее 10 лет. +- Вывести информацию о всех пациентах, записанных на прием к указанному врачу, упорядочить по ФИО. +- Вывести информацию о количестве повторных приемов пациентов за последний месяц. +- Вывести информацию о пациентах старше 30 лет, которые записаны на прием к нескольким врачам, упорядочить по дате рождения. +- Вывести информацию о приемах за текущий месяц, проходящих в выбранном кабинете. + +## Лабораторная работа №2 + +### Цель +Реализация серверного приложения с CRUD-операциями и аналитикой. + +### Реализация +Создано Web API на ASP.NET Core со следующими возможностями: + +**CRUD-контроллеры:** +- `PatientController` - управление пациентами +- `DoctorController` - управление врачами +- `SpecializationController` - управление специализациями +- `AppointmentController` - управление записями на прием + +**Аналитический контроллер (`AnalyticsController`):** +- `GET /api/analytics/doctors/experienced` - врачи со стажем ≥ 10 лет +- `GET /api/analytics/doctors/{doctorId}/patients` - пациенты врача (сортировка по ФИО) +- `GET /api/analytics/appointments/stats/monthly` - статистика повторных приемов за месяц +- `GET /api/analytics/patients/multiple-doctors` - пациенты старше 30 лет у нескольких врачей +- `GET /api/analytics/appointments/by-room` - приемы в кабинете за текущий месяц + +**Слой приложения:** +- DTO для передачи данных (CreateUpdateDto, Dto) +- Сервисы с бизнес-логикой +- AutoMapper для маппинга сущностей + +## Лабораторная работа №3 + +### Цель +Подключение базы данных и оркестрация запуска. + +### Реализация +**Entity Framework Core:** +- `PolyclinicDbContext` - контекст базы данных +- Репозитории для каждой сущности +- Миграция `InitialCreate` с созданием таблиц и начальными данными + +**Aspire оркестратор (`Polyclinic.AppHost`):** +- SQL Server контейнер (`polyclinic-sql-server`) +- База данных `PolyclinicDb` +- API Host с зависимостью от БД + +## Лабораторная работа №4 -## Задание ### Цель -Реализация проекта сервисно-ориентированного приложения. - -### Задачи -* Реализация объектно-ориентированной модели данных, -* Изучение реализации серверных приложений на базе WebAPI/OpenAPI, -* Изучение работы с брокерами сообщений, -* Изучение паттернов проектирования, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Unit-тестирование. - -### Лабораторные работы -
-1. «Классы» - Реализация объектной модели данных и unit-тестов -
-В рамках первой лабораторной работы необходимо подготовить структуру классов, описывающих предметную область, определяемую в задании. В каждом из заданий присутствует часть, связанная с обработкой данных, представленная в разделе «Unit-тесты». Данную часть необходимо реализовать в виде unit-тестов: подготовить тестовые данные, выполнить запрос с использованием LINQ, проверить результаты. - -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -Необходимо включить **как минимум 10** экземпляров каждого класса в датасид. - -
-
-2. «Сервер» - Реализация серверного приложения с использованием REST API -
-Во второй лабораторной работе необходимо реализовать серверное приложение, которое должно: -- Осуществлять базовые CRUD-операции с реализованными в первой лабораторной сущностями -- Предоставлять результаты аналитических запросов (раздел «Unit-тесты» задания) - -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -
-
-
-3. «ORM» - Реализация объектно-реляционной модели. Подключение к базе данных и настройка оркестрации -
-В третьей лабораторной работе хранение должно быть переделано c инмемори коллекций на базу данных. -Должны быть созданы миграции для создания таблиц в бд и их первоначального заполнения. -
-Также необходимо настроить оркестратор Aspire на запуск сервера и базы данных. -
-
-
-4. «Инфраструктура» - Реализация сервиса генерации данных и его интеграция с сервером -
-В четвертой лабораторной работе необходимо имплементировать сервис, который генерировал бы контракты. Контракты далее передаются в сервер и сохраняются в бд. -Сервис должен представлять из себя отдельное приложение без референсов к серверным проектам за исключением библиотеки с контрактами. -Отправка контрактов при помощи gRPC должна выполняться в потоковом виде. -При использовании брокеров сообщений, необходимо предусмотреть ретраи при подключении к брокеру. - -Также необходимо добавить в конфигурацию Aspire запуск генератора и (если того требует вариант) брокера сообщений. -
-
-
-5. «Клиент» - Интеграция клиентского приложения с оркестратором -
-В пятой лабораторной необходимо добавить в конфигурацию Aspire запуск клиентского приложения для написанного ранее сервера. Клиент создается в рамках курса "Веб разработка". -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Реализация серверной части на [ASP.NET](https://dotnet.microsoft.com/ru-ru/apps/aspnet). -* Реализация unit-тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Использование хранения данных в базе данных согласно варианту задания. -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview) -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus) и его взаимодейсвие с сервером согласно варианту задания. -* Автоматизация тестирования на уровне репозитория через [GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Реализация авторизации/аутентификации. -* Реализация atomic batch publishing/atomic batch consumption для брокеров, поддерживающих такой функционал. -* Реализация интеграционных тестов при помощи .NET Aspire. -* Реализация клиента на Blazor WASM. - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/enterprise-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма - -image1 - -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1Wc8AvsKS_1JptpsxHO-cwfAxz2ghxvQRQ0fy4el2ZOc/edit?usp=sharing) -[Список предметных областей](https://docs.google.com/document/d/15jWhXMwd2K8giFMKku_yrY_s2uQNEu4ugJXLYPvYJAE/edit?usp=sharing) -[Вопросы к экзамену](https://docs.google.com/document/d/1bjfvtzjyMljJbcu8YCvC8DzDegDUAmDeNtBz9M6FQes/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve -6. Прийти на занятие и защитить работу - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл -- **3 балла** за защиту: при сдаче лабораторной работы вам задается 3 вопроса, за каждый правильный ответ - 1 балл - -У вас 2 попытки пройти ревью (первичное ревью, ревью по результатам исправления). Если замечания по итогу не исправлены, то снимается один балл за код лабораторной работы. - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соотвествующим разделом дискуссий](https://github.com/itsecd/enterprise-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/enterprise-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/enterprise-development/discussions/categories/ideas). +Реализация сервиса генерации контрактов с использованием брокера сообщений. + +### Реализация +**Генератор записей (`Polyclinic.Generator.RabbitMq.Host`):** +- Отдельное приложение без зависимостей от серверных проектов +- `AppointmentGenerator` — генерация тестовых данных записей на прием +- `AppointmentProducer` — отправка сообщений в очередь RabbitMQ +- `GeneratorController` — API для управления генерацией + +**Инфраструктура RabbitMQ (`Polyclinic.Infrastructure.RabbitMq`):** +- `AppointmentConsumer` — фоновый сервис чтения сообщений из очереди +- `RabbitMqOptions` — конфигурация подключения к брокеру +- Автоматическое сохранение полученных контрактов в БД + +**Aspire оркестратор (обновлен):** +- RabbitMQ контейнер (`rabbitMqConnection`) с плагином управления +- Генератор (`polyclinic-generator`) с зависимостью от RabbitMQ +- API Host с зависимостями от SQL Server и RabbitMQ + +## Структура проекта + +``` +Polyclinic (Solution) +│ +├── Polyclinic.Domain (Class Library) - Доменные сущности +│ ├── Entities/ +│ │ ├── Appointment.cs +│ │ ├── Doctor.cs +│ │ ├── Patient.cs +│ │ └── Specialization.cs +│ ├── Enums/ +│ │ ├── BloodGroup.cs +│ │ ├── Gender.cs +│ │ └── RhFactor.cs +│ └── IRepository.cs +│ +├── Polyclinic.Application.Contracts (Class Library) - Контракты и DTO +│ ├── IApplicationService.cs +│ ├── IAnalyticsService.cs +│ ├── Appointments/ +│ │ ├── AppointmentDto.cs +│ │ └── AppointmentCreateUpdateDto.cs +│ ├── Doctors/ +│ │ ├── DoctorDto.cs +│ │ └── DoctorCreateUpdateDto.cs +│ ├── Patients/ +│ │ ├── PatientDto.cs +│ │ └── PatientCreateUpdateDto.cs +│ ├── Specializations/ +│ │ ├── SpecializationDto.cs +│ │ └── SpecializationCreateUpdateDto.cs +│ └── Analytics/ +│ └── MonthlyAppointmentStatsDto.cs +│ +├── Polyclinic.Application (Class Library) - Реализация сервисов +│ ├── PolyclinicProfile.cs (AutoMapper) +│ └── Services/ +│ ├── AnalyticsService.cs +│ ├── AppointmentService.cs +│ ├── DoctorService.cs +│ ├── PatientService.cs +│ └── SpecializationService.cs +│ +├── Polyclinic.Infrastructure.EfCore (Class Library) - Инфраструктура БД +│ ├── PolyclinicDbContext.cs +│ ├── Migrations/ +│ │ └── InitialCreate.cs +│ └── Repositories/ +│ ├── AppointmentRepository.cs +│ ├── DoctorRepository.cs +│ ├── PatientRepository.cs +│ └── SpecializationRepository.cs +│ +├── Polyclinic.Api.Host (ASP.NET Core Web API) - HTTP API +│ ├── Program.cs +│ └── Controllers/ +│ ├── CrudControllerBase.cs +│ ├── AnalyticsController.cs +│ ├── AppointmentController.cs +│ ├── DoctorController.cs +│ ├── PatientController.cs +│ └── SpecializationController.cs +│ +├── Polyclinic.Infrastructure.RabbitMq (Class Library) - Интеграция с RabbitMQ +│ ├── AppointmentConsumer.cs +│ └── RabbitMqOptions.cs +│ +├── Polyclinic.Generator.RabbitMq.Host (ASP.NET Core) - Генератор контрактов +│ ├── Program.cs +│ ├── Controllers/ +│ │ └── GeneratorController.cs +│ ├── Services/ +│ │ ├── AppointmentGenerator.cs +│ │ └── AppointmentProducer.cs +│ └── Options/ +│ +├── Polyclinic.AppHost (Aspire Host) - Оркестратор +│ └── AppHost.cs +│ +├── Polyclinic.ServiceDefaults (Class Library) - Общие настройки Aspire +│ └── Extensions.cs +│ +└── Polyclinic.Tests (xUnit) - Модульные тесты + ├── PolyclinicFixture.cs + ├── PolyclinicTests.cs + └── TestConstants.cs +``` \ No newline at end of file