From d67b6c61d867536facd6d34e009747557a16af79 Mon Sep 17 00:00:00 2001 From: Roman Efremov Date: Wed, 4 Dec 2024 14:13:25 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20WITS0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/WitsDataController.cs | 81 ++++++ Persistence.API/DependencyInjection.cs | 7 + Persistence.API/Startup.cs | 2 +- Persistence.Client/Clients/IWitsDataClient.cs | 20 ++ ...3120141_ParameterDataMigration.Designer.cs | 257 ++++++++++++++++++ .../20241203120141_ParameterDataMigration.cs | 36 +++ .../PersistenceDbContextModelSnapshot.cs | 24 ++ .../PersistenceDbContext.cs | 4 +- Persistence.Database/Entity/ParameterData.cs | 21 ++ .../Controllers/WitsDataControllerTest.cs | 223 +++++++++++++++ Persistence.Repository/DependencyInjection.cs | 1 + .../Repositories/ParameterRepository.cs | 66 +++++ Persistence/API/ISyncWithDiscriminatorApi.cs | 28 ++ Persistence/API/IWitsDataApi.cs | 26 ++ Persistence/Models/ParameterDto.cs | 32 +++ Persistence/Models/WitsDataDto.cs | 25 ++ Persistence/Models/WitsValueDto.cs | 26 ++ .../Repositories/IParameterRepository.cs | 38 +++ .../Services/Interfaces/IWitsDataService.cs | 10 + Persistence/Services/WitsDataService.cs | 89 ++++++ 20 files changed, 1013 insertions(+), 3 deletions(-) create mode 100644 Persistence.API/Controllers/WitsDataController.cs create mode 100644 Persistence.Client/Clients/IWitsDataClient.cs create mode 100644 Persistence.Database.Postgres/Migrations/20241203120141_ParameterDataMigration.Designer.cs create mode 100644 Persistence.Database.Postgres/Migrations/20241203120141_ParameterDataMigration.cs create mode 100644 Persistence.Database/Entity/ParameterData.cs create mode 100644 Persistence.IntegrationTests/Controllers/WitsDataControllerTest.cs create mode 100644 Persistence.Repository/Repositories/ParameterRepository.cs create mode 100644 Persistence/API/ISyncWithDiscriminatorApi.cs create mode 100644 Persistence/API/IWitsDataApi.cs create mode 100644 Persistence/Models/ParameterDto.cs create mode 100644 Persistence/Models/WitsDataDto.cs create mode 100644 Persistence/Models/WitsValueDto.cs create mode 100644 Persistence/Repositories/IParameterRepository.cs create mode 100644 Persistence/Services/Interfaces/IWitsDataService.cs create mode 100644 Persistence/Services/WitsDataService.cs diff --git a/Persistence.API/Controllers/WitsDataController.cs b/Persistence.API/Controllers/WitsDataController.cs new file mode 100644 index 0000000..6e74b2b --- /dev/null +++ b/Persistence.API/Controllers/WitsDataController.cs @@ -0,0 +1,81 @@ +using System.Net; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Persistence.Models; +using Persistence.Services.Interfaces; + +namespace Persistence.API.Controllers; + +[ApiController] +[Authorize] +[Route("api/[controller]")] +public class WitsDataController : ControllerBase, IWitsDataApi +{ + private readonly IWitsDataService witsDataService; + + public WitsDataController(IWitsDataService witsDataService) + { + this.witsDataService = witsDataService; + } + + /// + /// Получить диапазон дат, для которых есть данные в репозитории + /// + /// + /// + /// + [HttpGet("datesRange")] + public async Task> GetDatesRangeAsync([FromQuery] int discriminatorId, CancellationToken token) + { + var result = await witsDataService.GetDatesRangeAsync(discriminatorId, token); + + return Ok(result); + } + + /// + /// Получить порцию записей, начиная с заданной даты + /// + /// + /// + /// + /// + /// + [HttpGet("part")] + public async Task>> GetPart([FromQuery] int discriminatorId, [FromQuery] DateTimeOffset dateBegin, [FromQuery] int take, CancellationToken token) + { + var result = await witsDataService.GetPart(discriminatorId, dateBegin, take, token); + + return Ok(result); + } + + /// + /// Получить набор параметров (Wits) для построения графика + /// + /// + /// + /// + /// + /// + [HttpGet("graph")] + public async Task>> GetValuesForGraph([FromQuery] DateTimeOffset dateFrom, [FromQuery] DateTimeOffset dateTo, [FromQuery] int limit, CancellationToken token) + { + var result = await witsDataService.GetValuesForGraph(dateFrom, dateTo); + + return Ok(result); + } + + /// + /// Сохранить набор параметров (Wits) + /// + /// + /// + /// + [HttpPost] + [ProducesResponseType(typeof(int), (int)HttpStatusCode.Created)] + public async Task InsertRange([FromBody] IEnumerable dtos, CancellationToken token) + { + var result = await witsDataService.InsertRange(dtos, token); + + return CreatedAtAction(nameof(InsertRange), result); + } +} diff --git a/Persistence.API/DependencyInjection.cs b/Persistence.API/DependencyInjection.cs index 19cedc9..8506b4c 100644 --- a/Persistence.API/DependencyInjection.cs +++ b/Persistence.API/DependencyInjection.cs @@ -8,6 +8,8 @@ using Microsoft.OpenApi.Models; using Persistence.Database.Entity; using Persistence.Models; using Persistence.Models.Configurations; +using Persistence.Services; +using Persistence.Services.Interfaces; using Swashbuckle.AspNetCore.SwaggerGen; namespace Persistence.API; @@ -55,6 +57,11 @@ public static class DependencyInjection }); } + public static void AddServices(this IServiceCollection services) + { + services.AddTransient(); + } + #region Authentication public static void AddJWTAuthentication(this IServiceCollection services, IConfiguration configuration) { diff --git a/Persistence.API/Startup.cs b/Persistence.API/Startup.cs index 98ad4aa..9869754 100644 --- a/Persistence.API/Startup.cs +++ b/Persistence.API/Startup.cs @@ -24,13 +24,13 @@ public class Startup services.AddPersistenceDbContext(Configuration); services.AddJWTAuthentication(Configuration); services.AddMemoryCache(); + services.AddServices(); DependencyInjection.MapsterSetup(); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { - app.UseSwagger(); app.UseSwaggerUI(); diff --git a/Persistence.Client/Clients/IWitsDataClient.cs b/Persistence.Client/Clients/IWitsDataClient.cs new file mode 100644 index 0000000..d7b2035 --- /dev/null +++ b/Persistence.Client/Clients/IWitsDataClient.cs @@ -0,0 +1,20 @@ +using Persistence.Models; +using Refit; + +namespace Persistence.Client.Clients; +public interface IWitsDataClient +{ + private const string BaseRoute = "/api/witsData"; + + [Get($"{BaseRoute}/graph")] + Task>> GetValuesForGraph([Query] DateTimeOffset dateFrom, [Query] DateTimeOffset dateTo, [Query] int limit, CancellationToken token); + + [Post($"{BaseRoute}/")] + Task> InsertRange([Body] IEnumerable dtos, CancellationToken token); + + [Get($"{BaseRoute}/part")] + Task>> GetPart([Query] int discriminatorId, [Query] DateTimeOffset dateBegin, [Query] int take = 24 * 60 * 60, CancellationToken token = default); + + [Get($"{BaseRoute}/datesRange")] + Task> GetDatesRangeAsync([Query] int discriminatorId, CancellationToken token); +} diff --git a/Persistence.Database.Postgres/Migrations/20241203120141_ParameterDataMigration.Designer.cs b/Persistence.Database.Postgres/Migrations/20241203120141_ParameterDataMigration.Designer.cs new file mode 100644 index 0000000..c0f01fc --- /dev/null +++ b/Persistence.Database.Postgres/Migrations/20241203120141_ParameterDataMigration.Designer.cs @@ -0,0 +1,257 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Persistence.Database.Model; + +#nullable disable + +namespace Persistence.Database.Postgres.Migrations +{ + [DbContext(typeof(PersistenceDbContext))] + [Migration("20241203120141_ParameterDataMigration")] + partial class ParameterDataMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .UseCollation("Russian_Russia.1251") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "adminpack"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Persistence.Database.Entity.DrillingSystem", b => + { + b.Property("SystemId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasComment("Id системы автобурения"); + + b.Property("Description") + .HasColumnType("text") + .HasComment("Описание системы автобурения"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(256)") + .HasComment("Наименование системы автобурения"); + + b.HasKey("SystemId"); + + b.ToTable("DrillingSystem"); + }); + + modelBuilder.Entity("Persistence.Database.Entity.ParameterData", b => + { + b.Property("DiscriminatorId") + .HasColumnType("integer") + .HasComment("Дискриминатор системы"); + + b.Property("ParameterId") + .HasColumnType("integer") + .HasComment("Id параметра"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasComment("Временная отметка"); + + b.Property("Value") + .IsRequired() + .HasColumnType("varchar(256)") + .HasComment("Значение параметра в виде строки"); + + b.HasKey("DiscriminatorId", "ParameterId", "Timestamp"); + + b.ToTable("ParameterData"); + }); + + modelBuilder.Entity("Persistence.Database.Entity.TechMessage", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasComment("Id события"); + + b.Property("CategoryId") + .HasColumnType("integer") + .HasComment("Id Категории важности"); + + b.Property("Depth") + .HasColumnType("double precision") + .HasComment("Глубина забоя"); + + b.Property("MessageText") + .IsRequired() + .HasColumnType("varchar(512)") + .HasComment("Текст сообщения"); + + b.Property("SystemId") + .HasColumnType("uuid") + .HasComment("Id системы автобурения, к которой относится сообщение"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasComment("Дата возникновения"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasComment("Id пользователя за пультом бурильщика"); + + b.HasKey("EventId"); + + b.HasIndex("SystemId"); + + b.ToTable("TechMessage"); + }); + + modelBuilder.Entity("Persistence.Database.Entity.TimestampedSet", b => + { + b.Property("IdDiscriminator") + .HasColumnType("uuid") + .HasComment("Дискриминатор ссылка на тип сохраняемых данных"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasComment("Отметка времени, строго в UTC"); + + b.Property("Set") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("Набор сохраняемых данных"); + + b.HasKey("IdDiscriminator", "Timestamp"); + + b.ToTable("TimestampedSets", t => + { + t.HasComment("Общая таблица данных временных рядов"); + }); + }); + + modelBuilder.Entity("Persistence.Database.Model.DataSaub", b => + { + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasColumnName("date"); + + b.Property("AxialLoad") + .HasColumnType("double precision") + .HasColumnName("axialLoad"); + + b.Property("BitDepth") + .HasColumnType("double precision") + .HasColumnName("bitDepth"); + + b.Property("BlockPosition") + .HasColumnType("double precision") + .HasColumnName("blockPosition"); + + b.Property("BlockSpeed") + .HasColumnType("double precision") + .HasColumnName("blockSpeed"); + + b.Property("Flow") + .HasColumnType("double precision") + .HasColumnName("flow"); + + b.Property("HookWeight") + .HasColumnType("double precision") + .HasColumnName("hookWeight"); + + b.Property("IdFeedRegulator") + .HasColumnType("integer") + .HasColumnName("idFeedRegulator"); + + b.Property("Mode") + .HasColumnType("integer") + .HasColumnName("mode"); + + b.Property("Mse") + .HasColumnType("double precision") + .HasColumnName("mse"); + + b.Property("MseState") + .HasColumnType("smallint") + .HasColumnName("mseState"); + + b.Property("Pressure") + .HasColumnType("double precision") + .HasColumnName("pressure"); + + b.Property("Pump0Flow") + .HasColumnType("double precision") + .HasColumnName("pump0Flow"); + + b.Property("Pump1Flow") + .HasColumnType("double precision") + .HasColumnName("pump1Flow"); + + b.Property("Pump2Flow") + .HasColumnType("double precision") + .HasColumnName("pump2Flow"); + + b.Property("RotorSpeed") + .HasColumnType("double precision") + .HasColumnName("rotorSpeed"); + + b.Property("RotorTorque") + .HasColumnType("double precision") + .HasColumnName("rotorTorque"); + + b.Property("User") + .HasColumnType("text") + .HasColumnName("user"); + + b.Property("WellDepth") + .HasColumnType("double precision") + .HasColumnName("wellDepth"); + + b.HasKey("Date"); + + b.ToTable("DataSaub"); + }); + + modelBuilder.Entity("Persistence.Database.Model.Setpoint", b => + { + b.Property("Key") + .HasColumnType("uuid") + .HasComment("Ключ"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasComment("Дата создания уставки"); + + b.Property("IdUser") + .HasColumnType("uuid") + .HasComment("Id автора последнего изменения"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("Значение уставки"); + + b.HasKey("Key", "Created"); + + b.ToTable("Setpoint"); + }); + + modelBuilder.Entity("Persistence.Database.Entity.TechMessage", b => + { + b.HasOne("Persistence.Database.Entity.DrillingSystem", "System") + .WithMany() + .HasForeignKey("SystemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("System"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Persistence.Database.Postgres/Migrations/20241203120141_ParameterDataMigration.cs b/Persistence.Database.Postgres/Migrations/20241203120141_ParameterDataMigration.cs new file mode 100644 index 0000000..049e506 --- /dev/null +++ b/Persistence.Database.Postgres/Migrations/20241203120141_ParameterDataMigration.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Persistence.Database.Postgres.Migrations +{ + /// + public partial class ParameterDataMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ParameterData", + columns: table => new + { + DiscriminatorId = table.Column(type: "integer", nullable: false, comment: "Дискриминатор системы"), + ParameterId = table.Column(type: "integer", nullable: false, comment: "Id параметра"), + Timestamp = table.Column(type: "timestamp with time zone", nullable: false, comment: "Временная отметка"), + Value = table.Column(type: "varchar(256)", nullable: false, comment: "Значение параметра в виде строки") + }, + constraints: table => + { + table.PrimaryKey("PK_ParameterData", x => new { x.DiscriminatorId, x.ParameterId, x.Timestamp }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ParameterData"); + } + } +} diff --git a/Persistence.Database.Postgres/Migrations/PersistenceDbContextModelSnapshot.cs b/Persistence.Database.Postgres/Migrations/PersistenceDbContextModelSnapshot.cs index e53c81a..e1c9683 100644 --- a/Persistence.Database.Postgres/Migrations/PersistenceDbContextModelSnapshot.cs +++ b/Persistence.Database.Postgres/Migrations/PersistenceDbContextModelSnapshot.cs @@ -45,6 +45,30 @@ namespace Persistence.Database.Postgres.Migrations b.ToTable("DrillingSystem"); }); + modelBuilder.Entity("Persistence.Database.Entity.ParameterData", b => + { + b.Property("DiscriminatorId") + .HasColumnType("integer") + .HasComment("Дискриминатор системы"); + + b.Property("ParameterId") + .HasColumnType("integer") + .HasComment("Id параметра"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasComment("Временная отметка"); + + b.Property("Value") + .IsRequired() + .HasColumnType("varchar(256)") + .HasComment("Значение параметра в виде строки"); + + b.HasKey("DiscriminatorId", "ParameterId", "Timestamp"); + + b.ToTable("ParameterData"); + }); + modelBuilder.Entity("Persistence.Database.Entity.TechMessage", b => { b.Property("EventId") diff --git a/Persistence.Database.Postgres/PersistenceDbContext.cs b/Persistence.Database.Postgres/PersistenceDbContext.cs index 89b09db..abde7b7 100644 --- a/Persistence.Database.Postgres/PersistenceDbContext.cs +++ b/Persistence.Database.Postgres/PersistenceDbContext.cs @@ -1,7 +1,5 @@ using Microsoft.EntityFrameworkCore; -using Npgsql; using Persistence.Database.Entity; -using System.Data.Common; namespace Persistence.Database.Model; public partial class PersistenceDbContext : DbContext @@ -14,6 +12,8 @@ public partial class PersistenceDbContext : DbContext public DbSet TimestampedSets => Set(); + public DbSet ParameterData => Set(); + public PersistenceDbContext() : base() { diff --git a/Persistence.Database/Entity/ParameterData.cs b/Persistence.Database/Entity/ParameterData.cs new file mode 100644 index 0000000..627bf48 --- /dev/null +++ b/Persistence.Database/Entity/ParameterData.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace Persistence.Database.Entity; + +[PrimaryKey(nameof(DiscriminatorId), nameof(ParameterId), nameof(Timestamp))] +public class ParameterData +{ + [Required, Comment("Дискриминатор системы")] + public int DiscriminatorId { get; set; } + + [Comment("Id параметра")] + public int ParameterId { get; set; } + + [Column(TypeName = "varchar(256)"), Comment("Значение параметра в виде строки")] + public required string Value { get; set; } + + [Comment("Временная отметка")] + public DateTimeOffset Timestamp { get; set; } +} diff --git a/Persistence.IntegrationTests/Controllers/WitsDataControllerTest.cs b/Persistence.IntegrationTests/Controllers/WitsDataControllerTest.cs new file mode 100644 index 0000000..1364d5b --- /dev/null +++ b/Persistence.IntegrationTests/Controllers/WitsDataControllerTest.cs @@ -0,0 +1,223 @@ +using System.Net; +using Microsoft.Extensions.DependencyInjection; +using Persistence.Client; +using Persistence.Client.Clients; +using Persistence.Database.Entity; +using Persistence.Models; +using Xunit; + +namespace Persistence.IntegrationTests.Controllers; +public class WitsDataControllerTest : BaseIntegrationTest +{ + private IWitsDataClient witsDataClient; + + public WitsDataControllerTest(WebAppFactoryFixture factory) : base(factory) + { + var scope = factory.Services.CreateScope(); + var persistenceClientFactory = scope.ServiceProvider + .GetRequiredService(); + + witsDataClient = persistenceClientFactory.GetClient(); + } + + [Fact] + public async Task GetDatesRangeAsync_returns_success() + { + //arrange + dbContext.CleanupDbSet(); + + var discriminatorId = 1; + + //act + var response = await witsDataClient.GetDatesRangeAsync(discriminatorId, new CancellationToken()); + + //assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + } + + [Fact] + public async Task GetPart_returns_success() + { + //arrange + dbContext.CleanupDbSet(); + + var discriminatorId = 1; + var dateBegin = DateTimeOffset.UtcNow; + var take = 1; + + //act + var response = await witsDataClient.GetPart(discriminatorId, dateBegin, take, new CancellationToken()); + + //assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + Assert.Empty(response.Content); + } + + [Fact] + public async Task InsertRange_returns_success() + { + //arrange + dbContext.CleanupDbSet(); + + //act + await InsertRange(); + } + + [Fact] + public async Task GetValuesForGraph_returns_success() + { + //arrange + dbContext.CleanupDbSet(); + + //act + + //assert + } + + #region AfterSave + [Fact] + public async Task GetDatesRangeAsync_AfterSave_returns_success() + { + //arrange + dbContext.CleanupDbSet(); + + var dtos = await InsertRange(); + var discriminatorId = dtos.FirstOrDefault()!.DiscriminatorId; + + //act + var response = await witsDataClient.GetDatesRangeAsync(discriminatorId, new CancellationToken()); + + //assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + + var expectedDateFrom = dtos + .Select(e => e.Timestamped) + .Min() + .ToString("dd.MM.yyyy-HH:mm:ss"); + var actualDateFrom = response.Content.From.DateTime + .ToString("dd.MM.yyyy-HH:mm:ss"); + Assert.Equal(expectedDateFrom, actualDateFrom); + + var expectedDateTo = dtos + .Select(e => e.Timestamped) + .Min() + .ToString("dd.MM.yyyy-HH:mm:ss"); + var actualDateTo = response.Content.From.DateTime + .ToString("dd.MM.yyyy-HH:mm:ss"); + Assert.Equal(expectedDateTo, actualDateTo); + } + + [Fact] + public async Task GetPart_AfterSave_returns_success() + { + //arrange + dbContext.CleanupDbSet(); + + var dtos = await InsertRange(); + var discriminatorId = dtos.FirstOrDefault()!.DiscriminatorId; + var dateBegin = dtos.FirstOrDefault()!.Timestamped; + var take = 1; + + //act + var response = await witsDataClient.GetPart(discriminatorId, dateBegin, take, new CancellationToken()); + + //assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content); + Assert.NotEmpty(response.Content); + Assert.Equal(take, response.Content.Count()); + } + + [Fact] + public async Task GetValuesForGraph_AfterSave_returns_success() + { + + } + #endregion + + #region BadRequest + [Fact] + public async Task InsertRange_returns_BadRequest() + { + //arrange + var dtos = new List() + { + new WitsDataDto() + { + DiscriminatorId = -1, // < 0 + Timestamped = DateTimeOffset.UtcNow, + Values = new List() + { + new WitsValueDto() + { + RecordId = -1, // < 0 + ItemId = 101, // > 100 + Value = "string value" + } + } + } + }; + + //act + var response = await witsDataClient.InsertRange(dtos, new CancellationToken()); + + //assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + #endregion + + private async Task> InsertRange() + { + //arrange + var dtos = new List() + { + new WitsDataDto() + { + DiscriminatorId = 1, + Timestamped = DateTimeOffset.UtcNow, + Values = new List() + { + new WitsValueDto() + { + RecordId = 11, + ItemId = 22, + Value = "string value" + }, + new WitsValueDto() + { + RecordId = 11, + ItemId = 27, + Value = 2.22 + } + } + }, + new WitsDataDto() + { + DiscriminatorId = 2, + Timestamped = DateTimeOffset.UtcNow, + Values = new List() + { + new WitsValueDto() + { + RecordId = 13, + ItemId = 14, + Value = "string value" + } + } + } + }; + + //act + var response = await witsDataClient.InsertRange(dtos, new CancellationToken()); + + //assert + var count = dtos.SelectMany(e => e.Values).Count(); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal(count, response.Content); + + return dtos; + } +} diff --git a/Persistence.Repository/DependencyInjection.cs b/Persistence.Repository/DependencyInjection.cs index e353d30..ffcf3dc 100644 --- a/Persistence.Repository/DependencyInjection.cs +++ b/Persistence.Repository/DependencyInjection.cs @@ -20,6 +20,7 @@ public static class DependencyInjection services.AddTransient, TimeSeriesDataCachedRepository>(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services; } diff --git a/Persistence.Repository/Repositories/ParameterRepository.cs b/Persistence.Repository/Repositories/ParameterRepository.cs new file mode 100644 index 0000000..be97a74 --- /dev/null +++ b/Persistence.Repository/Repositories/ParameterRepository.cs @@ -0,0 +1,66 @@ +using Mapster; +using Microsoft.EntityFrameworkCore; +using Persistence.Database.Entity; +using Persistence.Models; +using Persistence.Repositories; + +namespace Persistence.Repository.Repositories; +public class ParameterRepository : IParameterRepository +{ + private DbContext db; + + public ParameterRepository(DbContext db) + { + this.db = db; + } + + protected virtual IQueryable GetQueryReadOnly() => db.Set(); + + public async Task GetDatesRangeAsync(int idDiscriminator, CancellationToken token) + { + var query = GetQueryReadOnly() + .Where(e => e.DiscriminatorId == idDiscriminator) + .GroupBy(e => 1) + .Select(group => new + { + Min = group.Min(e => e.Timestamp), + Max = group.Max(e => e.Timestamp), + }); + var values = await query.FirstOrDefaultAsync(token); + var result = new DatesRangeDto() + { + From = values?.Min ?? DateTimeOffset.MinValue, + To = values?.Max ?? DateTimeOffset.MaxValue + }; + + return result; + } + + public async Task> GetPart(int idDiscriminator, DateTimeOffset dateBegin, int take, CancellationToken token) + { + var query = GetQueryReadOnly(); + var universalDate = dateBegin.ToUniversalTime(); + var entities = await query + .Where(e => e.DiscriminatorId == idDiscriminator && e.Timestamp >= universalDate) + .Take(take) + .ToArrayAsync(token); + var dtos = entities.Select(e => e.Adapt()); + + return dtos; + } + + public Task GetValuesForGraph(DateTimeOffset dateFrom, DateTimeOffset dateTo) + { + throw new NotImplementedException(); + } + + public async Task InsertRange(IEnumerable dtos, CancellationToken token) + { + var entities = dtos.Select(e => e.Adapt()); + + await db.Set().AddRangeAsync(entities, token); + var result = await db.SaveChangesAsync(token); + + return result; + } +} diff --git a/Persistence/API/ISyncWithDiscriminatorApi.cs b/Persistence/API/ISyncWithDiscriminatorApi.cs new file mode 100644 index 0000000..1665b01 --- /dev/null +++ b/Persistence/API/ISyncWithDiscriminatorApi.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; +using Persistence.Models; + +namespace Persistence.API; + +/// +/// Интерфейс для API, предназначенного для синхронизации данных, у которых есть дискриминатор +/// +public interface ISyncWithDiscriminatorApi +{ + /// + /// Получить порцию записей, начиная с заданной даты + /// + /// + /// + /// количество записей + /// + /// + Task>> GetPart(int idDiscriminator, DateTimeOffset dateBegin, int take = 24 * 60 * 60, CancellationToken token = default); + + /// + /// Получить диапазон дат, для которых есть данные в репозитории + /// + /// + /// + /// + Task> GetDatesRangeAsync(int idDiscriminator, CancellationToken token); +} \ No newline at end of file diff --git a/Persistence/API/IWitsDataApi.cs b/Persistence/API/IWitsDataApi.cs new file mode 100644 index 0000000..8186445 --- /dev/null +++ b/Persistence/API/IWitsDataApi.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Mvc; +using Persistence.Models; + +namespace Persistence.API; + +/// +/// Интерфейс для работы с параметрами Wits +/// +public interface IWitsDataApi : ISyncWithDiscriminatorApi +{ + /// + /// Получить набор параметров (Wits) для построения графика + /// + /// + /// + /// + Task>> GetValuesForGraph(DateTimeOffset dateFrom, DateTimeOffset dateTo, int limit, CancellationToken token); + + /// + /// Сохранить набор параметров (Wits) + /// + /// + /// + /// + Task InsertRange(IEnumerable dtos, CancellationToken token); +} diff --git a/Persistence/Models/ParameterDto.cs b/Persistence/Models/ParameterDto.cs new file mode 100644 index 0000000..a1155ce --- /dev/null +++ b/Persistence/Models/ParameterDto.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace Persistence.Models; + +/// +/// Модель параметра +/// +public class ParameterDto +{ + /// + /// Дискриминатор системы + /// + [Range(0, int.MaxValue, ErrorMessage = "Дискриминатор системы не может быть меньше 0")] + public int DiscriminatorId { get; set; } + + /// + /// Id параметра + /// + [Range(0, int.MaxValue, ErrorMessage = "Id параметра не может быть меньше 0")] + public int ParameterId { get; set; } + + /// + /// Значение параметра в виде строки + /// + [StringLength(256, MinimumLength = 1, ErrorMessage = "Допустимая длина значения параметра от 1 до 256 символов")] + public required string Value { get; set; } + + /// + /// Временная отметка + /// + public DateTimeOffset Timestamp { get; set; } +} diff --git a/Persistence/Models/WitsDataDto.cs b/Persistence/Models/WitsDataDto.cs new file mode 100644 index 0000000..7293067 --- /dev/null +++ b/Persistence/Models/WitsDataDto.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace Persistence.Models; + +/// +/// Группа параметров Wits +/// +public class WitsDataDto +{ + /// + /// Временная отметка + /// + public required DateTimeOffset Timestamped { get; set; } + + /// + /// Дискриминатор системы + /// + [Range(0, int.MaxValue, ErrorMessage = "Дискриминатор системы не может быть меньше 0")] + public required int DiscriminatorId { get; set; } + + /// + /// Параметры + /// + public IEnumerable Values { get; set; } = []; +} diff --git a/Persistence/Models/WitsValueDto.cs b/Persistence/Models/WitsValueDto.cs new file mode 100644 index 0000000..0c68453 --- /dev/null +++ b/Persistence/Models/WitsValueDto.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Persistence.Models; + +/// +/// Параметр Wits +/// +public class WitsValueDto +{ + /// + /// Wits - Record Number + /// + [Range(1, 100, ErrorMessage = "Значение \"Record Number\" обязано находиться в диапозоне от 1 до 100")] + public int RecordId { get; set; } + + /// + /// Wits - Record Item + /// + [Range(1, 100, ErrorMessage = "Значение \"Wits Record Item\" обязано находиться в диапозоне от 1 до 100")] + public int ItemId { get; set; } + + /// + /// Значение параметра + /// + public required object Value { get; set; } +} diff --git a/Persistence/Repositories/IParameterRepository.cs b/Persistence/Repositories/IParameterRepository.cs new file mode 100644 index 0000000..fdf9bff --- /dev/null +++ b/Persistence/Repositories/IParameterRepository.cs @@ -0,0 +1,38 @@ +using Persistence.Models; + +namespace Persistence.Repositories; +public interface IParameterRepository +{ + /// + /// Получить порцию записей, начиная с заданной даты + /// + /// + /// + /// + /// + Task> GetPart(int idDiscriminator, DateTimeOffset dateBegin, int take, CancellationToken token); + + /// + /// Получить диапазон дат, для которых есть данные в репозитории + /// + /// + /// + Task GetDatesRangeAsync(int idDiscriminator, CancellationToken token); + + /// + /// Получить набор параметров (Wits) для построения графика + /// + /// + /// + /// + Task GetValuesForGraph(DateTimeOffset dateFrom, DateTimeOffset dateTo); + + /// + /// Сохранить набор параметров (Wits) + /// + /// + /// + /// + /// + Task InsertRange(IEnumerable dtos, CancellationToken token); +} diff --git a/Persistence/Services/Interfaces/IWitsDataService.cs b/Persistence/Services/Interfaces/IWitsDataService.cs new file mode 100644 index 0000000..4e431fe --- /dev/null +++ b/Persistence/Services/Interfaces/IWitsDataService.cs @@ -0,0 +1,10 @@ +using Persistence.Models; + +namespace Persistence.Services.Interfaces; +public interface IWitsDataService +{ + Task GetDatesRangeAsync(int idDiscriminator, CancellationToken token); + Task> GetPart(int idDiscriminator, DateTimeOffset dateBegin, int take, CancellationToken token); + Task GetValuesForGraph(DateTimeOffset dateFrom, DateTimeOffset dateTo); + Task InsertRange(IEnumerable dtos, CancellationToken token); +} \ No newline at end of file diff --git a/Persistence/Services/WitsDataService.cs b/Persistence/Services/WitsDataService.cs new file mode 100644 index 0000000..1cb52fb --- /dev/null +++ b/Persistence/Services/WitsDataService.cs @@ -0,0 +1,89 @@ +using Persistence.Models; +using Persistence.Repositories; +using Persistence.Services.Interfaces; + +namespace Persistence.Services; +public class WitsDataService : IWitsDataService +{ + private readonly IParameterRepository witsDataRepository; + private const int multiplier = 1000; + public WitsDataService(IParameterRepository witsDataRepository) + { + this.witsDataRepository = witsDataRepository; + } + + public Task GetDatesRangeAsync(int idDiscriminator, CancellationToken token) + { + var result = witsDataRepository.GetDatesRangeAsync(idDiscriminator, token); + + return result; + } + + public async Task> GetPart(int idDiscriminator, DateTimeOffset dateBegin, int take, CancellationToken token) + { + var dtos = await witsDataRepository.GetPart(idDiscriminator, dateBegin, take, token); + + var result = new List(); + foreach (var dto in dtos) + { + var witsDataDto = result.FirstOrDefault(e => e.DiscriminatorId == dto.DiscriminatorId && e.Timestamped == dto.Timestamp); + if (witsDataDto == null) + { + witsDataDto = new WitsDataDto() + { + DiscriminatorId = dto.DiscriminatorId, + Timestamped = dto.Timestamp + }; + result.Add(witsDataDto); + } + var witsValueDto = new WitsValueDto() + { + RecordId = DecodeRecordId(dto.ParameterId), + ItemId = DecodeItemId(dto.ParameterId), + Value = dto.Value + }; + witsDataDto.Values.Append(witsValueDto); + } + + return result; + } + + public Task GetValuesForGraph(DateTimeOffset dateFrom, DateTimeOffset dateTo) + { + throw new NotImplementedException(); + } + + public async Task InsertRange(IEnumerable dtos, CancellationToken token) + { + var parameterDtos = dtos.SelectMany(e => e.Values.Select(t => new ParameterDto() + { + DiscriminatorId = e.DiscriminatorId, + ParameterId = EncodeId(t.RecordId, t.ItemId), + Value = t.Value.ToString()!, + Timestamp = e.Timestamped + })); + var result = await witsDataRepository.InsertRange(parameterDtos, token); + + return result; + } + + private int EncodeId(int recordId, int itemId) + { + var resultId = multiplier * recordId + itemId; + return resultId; + } + + private int DecodeRecordId(int id) + { + var resultId = id / multiplier; + + return resultId; + } + + private int DecodeItemId(int id) + { + var resultId = id % multiplier; + + return resultId; + } +}