diff --git a/AsbCloudApp/Repositories/IWellOperationRepository.cs b/AsbCloudApp/Repositories/IWellOperationRepository.cs index d1db155e..8cb313ac 100644 --- a/AsbCloudApp/Repositories/IWellOperationRepository.cs +++ b/AsbCloudApp/Repositories/IWellOperationRepository.cs @@ -2,6 +2,7 @@ using AsbCloudApp.Requests; using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; @@ -114,5 +115,20 @@ namespace AsbCloudApp.Repositories /// /// Task GetDatesRangeAsync(int idWell, int idType, CancellationToken cancellationToken); - } + + /// + /// Валидация данных + /// + /// + /// + IEnumerable Validate(IEnumerable wellOperations); + + /// + /// Валидация данных (проверка с базой) + /// + /// + /// + /// + Task> ValidateWithDbAsync(IEnumerable wellOperations, CancellationToken cancellationToken); + } } \ No newline at end of file diff --git a/AsbCloudInfrastructure/Repository/WellOperationRepository.cs b/AsbCloudInfrastructure/Repository/WellOperationRepository.cs index fb0a8c12..208e94ac 100644 --- a/AsbCloudInfrastructure/Repository/WellOperationRepository.cs +++ b/AsbCloudInfrastructure/Repository/WellOperationRepository.cs @@ -9,6 +9,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -22,6 +23,8 @@ namespace AsbCloudInfrastructure.Repository; public class WellOperationRepository : IWellOperationRepository { private const string KeyCacheSections = "OperationsBySectionSummarties"; + private const int Gap = 90; + private readonly IAsbCloudDbContext db; private readonly IMemoryCache memoryCache; private readonly IWellService wellService; @@ -50,7 +53,7 @@ public class WellOperationRepository : IWellOperationRepository } var result = categories - .OrderBy(o => o.Name) + .OrderBy(o => o.Name) .Adapt>(); return result; @@ -89,14 +92,14 @@ public class WellOperationRepository : IWellOperationRepository } private async Task GetDateLastAssosiatedPlanOperationAsync( - int idWell, - DateTime? lessThenDate, - double timeZoneHours, + int idWell, + DateTime? lessThenDate, + double timeZoneHours, CancellationToken token) { - if (lessThenDate is null) + if (lessThenDate is null) return null; - + var currentDateOffset = lessThenDate.Value.ToUtcDateTimeOffset(timeZoneHours); var timeZoneOffset = TimeSpan.FromHours(timeZoneHours); @@ -187,7 +190,7 @@ public class WellOperationRepository : IWellOperationRepository public async Task GetDatesRangeAsync(int idWell, int idType, CancellationToken cancellationToken) { var timezone = wellService.GetTimezone(idWell); - + var query = db.WellOperations.Where(o => o.IdWell == idWell && o.IdType == idType); if (!await query.AnyAsync(cancellationToken)) @@ -195,7 +198,7 @@ public class WellOperationRepository : IWellOperationRepository var minDate = await query.MinAsync(o => o.DateStart, cancellationToken); var maxDate = await query.MaxAsync(o => o.DateStart, cancellationToken); - + return new DatesRangeDto { From = minDate.ToRemoteDateTime(timezone.Hours), @@ -306,12 +309,13 @@ public class WellOperationRepository : IWellOperationRepository DeltaDepth = g.Sum(o => o.DurationDepth), IdParent = parentRelationDictionary[g.Key].IdParent }); - + while (dtos.All(x => x.IdParent != null)) { dtos = dtos .GroupBy(o => o.IdParent!) - .Select(g => { + .Select(g => + { var idCategory = g.Key ?? int.MinValue; var category = parentRelationDictionary.GetValueOrDefault(idCategory); var newDto = new WellGroupOpertionDto @@ -330,6 +334,66 @@ public class WellOperationRepository : IWellOperationRepository return dtos; } + public async Task> ValidateWithDbAsync(IEnumerable wellOperationDtos, CancellationToken token) + { + var firstOperation = wellOperationDtos + .FirstOrDefault(); + + if (firstOperation is null) + return Enumerable.Empty(); + + var request = new WellOperationRequest() + { + IdWell = firstOperation.IdWell, + OperationType = firstOperation.IdType, + }; + + var entities = await BuildQuery(request) + .AsNoTracking() + .ToArrayAsync(token); + + var wellOperationsUnion = entities.Union(wellOperationDtos).OrderBy(o => o.DateStart); + + var results = Validate(wellOperationsUnion); + return results; + } + + public IEnumerable Validate(IEnumerable wellOperationDtos) + { + var enumerator = wellOperationDtos.OrderBy(o => o.DateStart) + .GetEnumerator(); + + if (!enumerator.MoveNext()) + yield break; + + var previous = enumerator.Current; + + while(enumerator.MoveNext()) + { + var current = enumerator.Current; + var previousDateStart = previous.DateStart.ToUniversalTime(); + var currentDateStart = current.DateStart.ToUniversalTime(); + + var previousDateEnd = previous.DateStart.AddHours(previous.DurationHours).ToUniversalTime(); + + if (previousDateStart.AddDays(Gap) < currentDateStart) + { + yield return new ValidationResult( + "Разница дат между операциями не должна превышать 90 дней", + new[] { nameof(wellOperationDtos) }); + } + + if (previousDateEnd > currentDateStart) + { + yield return new ValidationResult( + "Предыдущая операция не завершена", + new[] { nameof(wellOperationDtos) }); + } + + previous = current; + } + } + /// public async Task InsertRangeAsync( IEnumerable wellOperationDtos, diff --git a/AsbCloudWebApi.IntegrationTests/BaseIntegrationTest.cs b/AsbCloudWebApi.IntegrationTests/BaseIntegrationTest.cs index d18c54bf..add2e07b 100644 --- a/AsbCloudWebApi.IntegrationTests/BaseIntegrationTest.cs +++ b/AsbCloudWebApi.IntegrationTests/BaseIntegrationTest.cs @@ -6,7 +6,7 @@ namespace AsbCloudWebApi.IntegrationTests; public abstract class BaseIntegrationTest : IClassFixture { - private readonly IServiceScope scope; + protected readonly IServiceScope scope; protected readonly IAsbCloudDbContext dbContext; diff --git a/AsbCloudWebApi.IntegrationTests/Clients/IWellOperationClient.cs b/AsbCloudWebApi.IntegrationTests/Clients/IWellOperationClient.cs new file mode 100644 index 00000000..2db55d36 --- /dev/null +++ b/AsbCloudWebApi.IntegrationTests/Clients/IWellOperationClient.cs @@ -0,0 +1,17 @@ +using AsbCloudApp.Data; +using Microsoft.AspNetCore.Mvc; +using Refit; + +namespace AsbCloudWebApi.IntegrationTests.Clients; + +public interface IWellOperationClient +{ + private const string BaseRoute = "/api/well/{idWell}/wellOperations"; + + [Post(BaseRoute + "/{idType}/{deleteBeforeInsert}")] + Task> InsertRangeAsync(int idWell, int idType, bool deleteBeforeInsert, [Body] IEnumerable dtos); + + [Put(BaseRoute + "/{idOperation}")] + Task> UpdateAsync(int idWell, int idOperation, [FromBody] WellOperationDto value, CancellationToken token); + +} \ No newline at end of file diff --git a/AsbCloudWebApi.IntegrationTests/Controllers/WellOperationControllerTest.cs b/AsbCloudWebApi.IntegrationTests/Controllers/WellOperationControllerTest.cs new file mode 100644 index 00000000..3ac32e40 --- /dev/null +++ b/AsbCloudWebApi.IntegrationTests/Controllers/WellOperationControllerTest.cs @@ -0,0 +1,186 @@ +using AsbCloudApp.Data; +using AsbCloudDb.Model; +using AsbCloudWebApi.IntegrationTests.Clients; +using System.Net; +using Xunit; + +namespace AsbCloudWebApi.IntegrationTests.Controllers; + + +public class WellOperationControllerTest : BaseIntegrationTest +{ + private static int idWell = 1; + + private readonly WellOperationDto[] dtos = new WellOperationDto[] + { + new WellOperationDto() + { + Id = 2, + IdWell = idWell, + IdType = 1, + DateStart = DateTimeOffset.Now, + CategoryInfo = "1", + CategoryName = "1", + Comment = "1", + Day = 1, + DepthEnd = 20, + DepthStart = 10, + DurationHours = 1, + IdCategory = 5000, + IdParentCategory = null, + IdPlan = null, + IdUser = 1, + IdWellSectionType = 1, + LastUpdateDate = DateTimeOffset.Now, + NptHours = 1, + WellSectionTypeName = null, + UserName = null + } + }; + + private readonly WellOperationDto[] dtosWithError = new WellOperationDto[] + { + new WellOperationDto() + { + Id = 3, + IdWell = idWell, + IdType = 1, + DateStart = DateTimeOffset.Now, + CategoryInfo = "1", + CategoryName = "1", + Comment = "1", + Day = 1, + DepthEnd = 20, + DepthStart = 10, + DurationHours = 1, + IdCategory = 5000, + IdParentCategory = null, + IdPlan = null, + IdUser = 1, + IdWellSectionType = 1, + LastUpdateDate = DateTimeOffset.Now, + NptHours = 1, + WellSectionTypeName = null, + UserName = null + }, + new WellOperationDto() + { + Id = 4, + IdWell = idWell, + IdType = 1, + DateStart = DateTimeOffset.Now.AddDays(1000), + CategoryInfo = "1", + CategoryName = "1", + Comment = "1", + Day = 1, + DepthEnd = 20, + DepthStart = 10, + DurationHours = 1, + IdCategory = 5000, + IdParentCategory = null, + IdPlan = null, + IdUser = 1, + IdWellSectionType = 1, + LastUpdateDate = DateTimeOffset.Now, + NptHours = 1, + WellSectionTypeName = null, + UserName = null + } + }; + + private IWellOperationClient wellOperationClient; + + public WellOperationControllerTest(WebAppFactoryFixture factory) + : base(factory) + { + wellOperationClient = factory.GetAuthorizedHttpClient(); + } + + /// + /// Успешное добавление операций (без предварительной очистки данных) + /// + /// + [Fact] + public async Task InsertRange_returns_success() + { + dbContext.CleanupDbSet(); + //act + var response = await wellOperationClient.InsertRangeAsync(idWell, 1, false, dtos); + + //assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + + /// + /// Неуспешное добавление операций (без предварительной очистки данных) + /// + /// + [Fact] + public async Task InsertRange_returns_error() + { + //act + var response = await wellOperationClient.InsertRangeAsync(idWell, 1, false, dtosWithError); + + //assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + /// + /// Успешное добавление операций (с предварительной очисткой данных) + /// + /// + [Fact] + public async Task InsertRangeWithDeleteBefore_returns_success() + { + //act + var response = await wellOperationClient.InsertRangeAsync(idWell, 1, true, dtos); + + //assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Неуспешное добавление операций (с предварительной очисткой данных) + /// + /// + [Fact] + public async Task InsertRangeWithDeleteBefore_returns_error() + { + //act + var response = await wellOperationClient.InsertRangeAsync(idWell, 1, true, dtosWithError); + + //assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + /// + /// Успешное обновление операции + /// + /// + [Fact] + public async Task UpdateAsync_returns_success() + { + //act + var dto = dtos.FirstOrDefault()!; + var response = await wellOperationClient.UpdateAsync(idWell, 1, dto, CancellationToken.None); + + //assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Неуспешное обновление операции + /// + /// + [Fact] + public async Task UpdateAsync_returns_error() + { + //act + var dto = dtosWithError.LastOrDefault()!; + var response = await wellOperationClient.UpdateAsync(idWell, 1, dto, CancellationToken.None); + + //assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} \ No newline at end of file diff --git a/AsbCloudWebApi.IntegrationTests/Data/Defaults.cs b/AsbCloudWebApi.IntegrationTests/Data/Defaults.cs index ddb7dbe7..45219449 100644 --- a/AsbCloudWebApi.IntegrationTests/Data/Defaults.cs +++ b/AsbCloudWebApi.IntegrationTests/Data/Defaults.cs @@ -14,7 +14,28 @@ namespace AsbCloudWebApi.IntegrationTests.Data Surname = "test" } }; - + + public static WellOperation[] WellOperations = new WellOperation[] + { + new() + { + Id = 1, + IdWell = 1, + IdType = 1, + DateStart = DateTimeOffset.UtcNow.AddDays(-1), + CategoryInfo = "1", + Comment = "1", + DepthEnd = 20, + DepthStart = 10, + DurationHours = 1, + IdCategory = 5000, + IdPlan = null, + IdUser = 1, + IdWellSectionType = 1, + LastUpdateDate = DateTimeOffset.UtcNow + } + }; + public static Deposit[] Deposits = new Deposit[] { new() { diff --git a/AsbCloudWebApi.IntegrationTests/WebAppFactoryFixture.cs b/AsbCloudWebApi.IntegrationTests/WebAppFactoryFixture.cs index a0111303..af7303b6 100644 --- a/AsbCloudWebApi.IntegrationTests/WebAppFactoryFixture.cs +++ b/AsbCloudWebApi.IntegrationTests/WebAppFactoryFixture.cs @@ -60,7 +60,8 @@ public class WebAppFactoryFixture : WebApplicationFactory, dbContext.AddRange(Data.Defaults.RelationsCompanyWell); dbContext.AddRange(Data.Defaults.Telemetries); dbContext.AddRange(Data.Defaults.Drillers); - await dbContext.SaveChangesAsync(); + dbContext.AddRange(Data.Defaults.WellOperations); + await dbContext.SaveChangesAsync(); } public new async Task DisposeAsync() diff --git a/AsbCloudWebApi/Controllers/WellOperationController.cs b/AsbCloudWebApi/Controllers/WellOperationController.cs index ae0c8b92..74d4e16f 100644 --- a/AsbCloudWebApi/Controllers/WellOperationController.cs +++ b/AsbCloudWebApi/Controllers/WellOperationController.cs @@ -1,7 +1,13 @@ using AsbCloudApp.Data; +using AsbCloudApp.Data.WellOperationImport; +using AsbCloudApp.Data.WellOperationImport.Options; +using AsbCloudApp.Exceptions; using AsbCloudApp.Repositories; using AsbCloudApp.Requests; using AsbCloudApp.Services; +using AsbCloudApp.Services.WellOperationImport; +using AsbCloudDb.Model; +using AsbCloudInfrastructure; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -12,12 +18,6 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using AsbCloudApp.Data.WellOperationImport; -using AsbCloudApp.Services.WellOperationImport; -using AsbCloudApp.Data.WellOperationImport.Options; -using AsbCloudApp.Exceptions; -using AsbCloudDb.Model; -using AsbCloudInfrastructure; namespace AsbCloudWebApi.Controllers { @@ -39,8 +39,8 @@ namespace AsbCloudWebApi.Controllers private readonly IWellOperationExcelParser wellOperationGazpromKhantosExcelParser; private readonly IUserRepository userRepository; - public WellOperationController(IWellOperationRepository operationRepository, - IWellService wellService, + public WellOperationController(IWellOperationRepository operationRepository, + IWellService wellService, IWellOperationImportTemplateService wellOperationImportTemplateService, IWellOperationExportService wellOperationExportService, IWellOperationImportService wellOperationImportService, @@ -231,12 +231,16 @@ namespace AsbCloudWebApi.Controllers if (!await CanUserEditWellOperationsAsync(idWell, cancellationToken)) return Forbid(); - + wellOperation.IdWell = idWell; wellOperation.LastUpdateDate = DateTimeOffset.UtcNow; wellOperation.IdUser = User.GetUserId(); wellOperation.IdType = idType; + var validationResult = await operationRepository.ValidateWithDbAsync(new[] { wellOperation }, cancellationToken); + if (validationResult.Any()) + return this.ValidationBadRequest(validationResult); + var result = await operationRepository.InsertRangeAsync(new[] { wellOperation }, cancellationToken); return Ok(result); @@ -278,7 +282,7 @@ namespace AsbCloudWebApi.Controllers await operationRepository.DeleteAsync(existingOperations.Select(o => o.Id), cancellationToken); } - + foreach (var wellOperation in wellOperations) { wellOperation.IdWell = idWell; @@ -287,11 +291,32 @@ namespace AsbCloudWebApi.Controllers wellOperation.IdType = idType; } + + var validationResult = await Validate(wellOperations, deleteBeforeInsert, cancellationToken); + if (validationResult.Any()) + return this.ValidationBadRequest(validationResult); + var result = await operationRepository.InsertRangeAsync(wellOperations, cancellationToken); return Ok(result); } + + /// + /// Валидация данных перед вставкой / обновлением / импортом + /// + /// + /// + /// + /// + private async Task> Validate(IEnumerable wellOperations, bool deleteBeforeInsert, CancellationToken cancellationToken) + { + if (deleteBeforeInsert) + return operationRepository.Validate(wellOperations); + else + return await operationRepository.ValidateWithDbAsync(wellOperations, cancellationToken); + } + /// /// Обновляет выбранную операцию на скважине /// @@ -308,7 +333,7 @@ namespace AsbCloudWebApi.Controllers { if (!await CanUserAccessToWellAsync(idWell, token)) return Forbid(); - + if (!await CanUserEditWellOperationsAsync(idWell, token)) return Forbid(); @@ -317,6 +342,10 @@ namespace AsbCloudWebApi.Controllers value.LastUpdateDate = DateTimeOffset.UtcNow; value.IdUser = User.GetUserId(); + var validationResult = await operationRepository.ValidateWithDbAsync(new[] { value }, token); + if (validationResult.Any()) + return this.ValidationBadRequest(validationResult); + var result = await operationRepository.UpdateAsync(value, token) .ConfigureAwait(false); return Ok(result); @@ -336,7 +365,7 @@ namespace AsbCloudWebApi.Controllers { if (!await CanUserAccessToWellAsync(idWell, token)) return Forbid(); - + if (!await CanUserEditWellOperationsAsync(idWell, token)) return Forbid(); @@ -373,7 +402,7 @@ namespace AsbCloudWebApi.Controllers deleteBeforeInsert, cancellationToken); } - + /// /// Импорт плановых операций из excel (xlsx) файла. Стандартный заполненный шаблон /// @@ -393,7 +422,7 @@ namespace AsbCloudWebApi.Controllers { IdType = WellOperation.IdOperationTypePlan }; - + return ImportExcelFileAsync(idWell, files, options, (stream, _) => wellOperationDefaultExcelParser.Parse(stream, options), null, @@ -522,7 +551,7 @@ namespace AsbCloudWebApi.Controllers return this.ValidationBadRequest(nameof(files), "Требуется xlsx файл."); using Stream stream = file.OpenReadStream(); - + try { var sheet = parseMethod(stream, options); @@ -541,8 +570,8 @@ namespace AsbCloudWebApi.Controllers //TODO: очень быстрый костыль if (deleteBeforeInsert is not null && options.IdType == WellOperation.IdOperationTypeFact) { - return await InsertRangeAsync(idWell, options.IdType, - deleteBeforeInsert.Value, + return await InsertRangeAsync(idWell, options.IdType, + deleteBeforeInsert.Value, wellOperations, cancellationToken); } @@ -554,21 +583,21 @@ namespace AsbCloudWebApi.Controllers return this.ValidationBadRequest(nameof(files), ex.Message); } } - + private async Task CanUserAccessToWellAsync(int idWell, CancellationToken token) { int? idCompany = User.GetCompanyId(); return idCompany is not null && await wellService.IsCompanyInvolvedInWellAsync((int)idCompany, idWell, token).ConfigureAwait(false); } - + private async Task CanUserEditWellOperationsAsync(int idWell, CancellationToken token) { var idUser = User.GetUserId(); if (!idUser.HasValue) return false; - + var well = await wellService.GetOrDefaultAsync(idWell, token); if (well is null) diff --git a/AsbCloudWebApi/Extentions.cs b/AsbCloudWebApi/Extentions.cs index ba87612f..5659292c 100644 --- a/AsbCloudWebApi/Extentions.cs +++ b/AsbCloudWebApi/Extentions.cs @@ -3,6 +3,8 @@ using AsbCloudWebApi.Converters; using System; using System.Collections.Generic; using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Security.Claims; namespace Microsoft.AspNetCore.Mvc @@ -53,6 +55,27 @@ namespace Microsoft.AspNetCore.Mvc return controller.BadRequest(problem); } + /// + /// + /// Returns BadRequest with ValidationProblemDetails as body + /// + /// + /// Используйте этот метод только если валидацию нельзя сделать через + /// атрибуты валидации или IValidatableObject модели. + /// + /// + /// + /// + /// + public static BadRequestObjectResult ValidationBadRequest(this ControllerBase controller, IEnumerable validationResults) + { + var errors = validationResults + .SelectMany(e => e.MemberNames.Select(name=> new { name, e.ErrorMessage })) + .ToDictionary(e => e.name, e => new[] { e.ErrorMessage ?? string.Empty }); + var problem = new ValidationProblemDetails(errors); + return controller.BadRequest(problem); + } + public static MvcOptions UseDateOnlyTimeOnlyStringConverters(this MvcOptions options) { TypeDescriptor.AddAttributes(typeof(DateOnly), new TypeConverterAttribute(typeof(DateOnlyTypeConverter)));