using AsbCloudApp.Data; using AsbCloudApp.Data.User; using AsbCloudApp.Exceptions; using AsbCloudApp.Repositories; using AsbCloudApp.Services; using AsbCloudDb.Model; using AsbCloudInfrastructure.Background; using AsbCloudInfrastructure.Services.DrillingProgram.Convert; using Mapster; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using AsbCloudApp.Requests; using AsbCloudApp.Services.Notifications; using AsbCloudInfrastructure.Services.Email; namespace AsbCloudInfrastructure.Services.DrillingProgram; # nullable enable public class DrillingProgramService : IDrillingProgramService { private static readonly Dictionary drillingProgramCreateErrors = new Dictionary(); private readonly IAsbCloudDbContext context; private readonly FileService fileService; private readonly IUserRepository userRepository; private readonly IWellService wellService; private readonly IConfiguration configuration; private readonly BackgroundWorker backgroundWorker; private readonly NotificationService notificationService; private const int idTransportType = 1; private const int idFileCategoryDrillingProgram = 1000; private const int idFileCategoryDrillingProgramPartsStart = 1001; private const int idFileCategoryDrillingProgramPartsEnd = 1100; private const int idPartStateNoFile = 0; private const int idPartStateApproving = 1; private const int idPartStateApproved = 2; private const int idMarkTypeReject = 0; private const int idMarkTypeApprove = 1; private const int idUserRolePublisher = 1; private const int idUserRoleApprover = 2; private const int idStateNotInitialized = 0; private const int idStateApproving = 1; private const int idStateCreating = 2; private const int idStateReady = 3; private const int idStateError = 4; private static readonly string[] validFileExtensions = ConvertToPdf.filesExtensions; public DrillingProgramService( IAsbCloudDbContext context, FileService fileService, IUserRepository userRepository, IWellService wellService, IConfiguration configuration, BackgroundWorker backgroundWorker, NotificationService notificationService) { this.context = context; this.fileService = fileService; this.userRepository = userRepository; this.wellService = wellService; this.configuration = configuration; this.backgroundWorker = backgroundWorker; this.notificationService = notificationService; } public async Task> GetAvailableUsers(int idWell, CancellationToken token = default) { var users = await context.RelationCompaniesWells .Include(r => r.Company) .ThenInclude(c => c.Users) .Where(r => r.IdWell == idWell) .SelectMany(r => r.Company.Users) .Where(u => u != null && !string.IsNullOrEmpty(u.Email)) .ToListAsync(token); var usersDto = users.Adapt>(); return usersDto; } public async Task> GetCategoriesAsync(CancellationToken token = default) { var result = await context.FileCategories .Where(c => c.Id > idFileCategoryDrillingProgramPartsStart && c.Id < idFileCategoryDrillingProgramPartsEnd) .ToListAsync(token); return result.Select(c => c.Adapt()); } public async Task GetStateAsync(int idWell, int idUser, CancellationToken token = default) { var fileCategories = await context.FileCategories .Where(c => c.Id >= idFileCategoryDrillingProgramPartsStart && c.Id < idFileCategoryDrillingProgramPartsEnd) .ToListAsync(token); var files = await context.Files .Include(f => f.FileMarks) .ThenInclude(m => m.User) .Include(f => f.Author) .Include(f => f.FileCategory) .Where(f => f.IdWell == idWell && f.IdCategory >= idFileCategoryDrillingProgram && f.IdCategory < idFileCategoryDrillingProgramPartsEnd && f.IsDeleted == false) .OrderBy(f => f.UploadDate) .ToListAsync(token); var partEntities = await context.DrillingProgramParts .Include(p => p.RelatedUsers) .ThenInclude(r => r.User) .ThenInclude(u => u.Company) .Where(p => p.IdWell == idWell) .ToListAsync(token); var parts = new List(partEntities.Count); var timezoneOffset = wellService.GetTimezone(idWell)?.Hours ?? 5; foreach (var partEntity in partEntities) { var part = ConvertPart(idUser, fileCategories, files, partEntity, timezoneOffset); parts.Add(part); } var state = new DrillingProgramStateDto { Parts = parts, Program = files.FirstOrDefault(f => f.IdCategory == idFileCategoryDrillingProgram) ?.Adapt(), PermissionToEdit = userRepository.HasPermission(idUser, "DrillingProgram.edit"), }; if (parts.Any()) { if (parts.All(p => p.IdState == idPartStateApproved)) { if (state.Program is not null) { state.IdState = idStateReady; } else { var workId = MakeWorkId(idWell); if (drillingProgramCreateErrors.ContainsKey(workId)) { state.IdState = idStateError; state.Error = drillingProgramCreateErrors[workId]; } else state.IdState = idStateCreating; } } else state.IdState = idStateApproving; } else state.IdState = idStateNotInitialized; await EnqueueMakeProgramWorkAsync(idWell, state, token); return state; } private static bool IsFileExtensionValid(string file) { var fileExt = Path.GetExtension(file).ToLower(); return validFileExtensions.Contains(fileExt); } public async Task AddFile(int idWell, int idFileCategory, int idUser, string fileFullName, Stream fileStream, CancellationToken token = default) { if (!IsFileExtensionValid(fileFullName)) throw new FileFormatException($"Файл {fileFullName} - не поддерживаемого формата. Файл не может быть загружен."); var part = await context.DrillingProgramParts .Include(p => p.RelatedUsers) .ThenInclude(r => r.User) .FirstOrDefaultAsync(p => p.IdWell == idWell && p.IdFileCategory == idFileCategory, token) ?? throw new ArgumentInvalidException(nameof(idFileCategory), $"DrillingProgramPart id == {idFileCategory} does not exist"); if (!part.RelatedUsers.Any(r => r.IdUser == idUser && r.IdUserRole == idUserRolePublisher)) throw new ForbidException($"User {idUser} is not in the publisher list."); var result = await fileService.SaveAsync( part.IdWell, idUser, part.IdFileCategory, fileFullName, fileStream, token); await RemoveDrillingProgramAsync(part.IdWell, token); await NotifyApproversAsync(part, result.Id, fileFullName, token); return result.Id; } public async Task AddPartsAsync(int idWell, IEnumerable idFileCategories, CancellationToken token = default) { if (!idFileCategories.Any()) return 0; var existingCategories = await context.DrillingProgramParts .Where(p => p.IdWell == idWell) .Select(p => p.IdFileCategory) .ToListAsync(token); var newParts = idFileCategories .Where(c => !existingCategories.Any(e => e == c)) .Select(c => new DrillingProgramPart { IdWell = idWell, IdFileCategory = c, }); context.DrillingProgramParts.AddRange(newParts); var affected = await context.SaveChangesAsync(token); await RemoveDrillingProgramAsync(idWell, token); return affected; } public async Task RemovePartsAsync(int idWell, IEnumerable idFileCategories, CancellationToken token = default) { var whereQuery = context.DrillingProgramParts .Where(p => p.IdWell == idWell && idFileCategories.Contains(p.IdFileCategory)); context.DrillingProgramParts.RemoveRange(whereQuery); await RemoveDrillingProgramAsync(idWell, token); return await context.SaveChangesAsync(token); } public async Task AddUserAsync(int idWell, int idFileCategory, int idUser, int idUserRole, CancellationToken token = default) { var user = await userRepository.GetOrDefaultAsync(idUser, token) ?? throw new ArgumentInvalidException(nameof(idUser), $"User id: {idUser} does not exist"); var part = await context.DrillingProgramParts .Include(p => p.FileCategory) .FirstOrDefaultAsync(p => p.IdWell == idWell && p.IdFileCategory == idFileCategory, token) ?? throw new ArgumentInvalidException(nameof(idFileCategory), $"DrillingProgramPart idFileCategory: {idFileCategory} does not exist"); if (idUserRole != idUserRoleApprover && idUserRole != idUserRolePublisher) throw new ArgumentInvalidException(nameof(idUserRole), $"idUserRole ({idUserRole}), should be approver ({idUserRoleApprover}) or publisher ({idUserRolePublisher})"); var oldRelation = await context.RelationDrillingProgramPartUsers .FirstOrDefaultAsync(r => r.IdUser == idUser && r.IdDrillingProgramPart == part.Id, token); if (oldRelation is not null) context.RelationDrillingProgramPartUsers.Remove(oldRelation); var newRelation = new RelationUserDrillingProgramPart { IdUser = idUser, IdDrillingProgramPart = part.Id, IdUserRole = idUserRole, }; context.RelationDrillingProgramPartUsers.Add(newRelation); if (idUserRole == idUserRoleApprover) await RemoveDrillingProgramAsync(part.IdWell, token); if (idUserRole == idUserRolePublisher) await NotifyNewPublisherAsync(idWell, user, part.FileCategory.Name, token); return await context.SaveChangesAsync(token); } public async Task RemoveUserAsync(int idWell, int idFileCategory, int idUser, int idUserRole, CancellationToken token = default) { var whereQuery = context.RelationDrillingProgramPartUsers .Include(r => r.DrillingProgramPart) .Where(r => r.IdUser == idUser && r.IdUserRole == idUserRole && r.DrillingProgramPart.IdWell == idWell && r.DrillingProgramPart.IdFileCategory == idFileCategory); context.RelationDrillingProgramPartUsers.RemoveRange(whereQuery); return await context.SaveChangesAsync(token); } public async Task AddOrReplaceFileMarkAsync(FileMarkDto fileMarkDto, int idUser, CancellationToken token) { if (fileMarkDto.IdMarkType != idMarkTypeApprove && fileMarkDto.IdMarkType != idMarkTypeReject) throw new ArgumentInvalidException(nameof(fileMarkDto), $"В этом методе допустимы только отметки о принятии или отклонении."); var fileInfo = await fileService.GetOrDefaultAsync(fileMarkDto.IdFile, token) ?? throw new ArgumentInvalidException(nameof(fileMarkDto), $"Файла для такой отметки не существует."); if (fileInfo.IdCategory < idFileCategoryDrillingProgramPartsStart || fileInfo.IdCategory > idFileCategoryDrillingProgramPartsEnd) throw new ArgumentInvalidException(nameof(fileMarkDto), $"Этот метод допустим только для файлов-частей программы бурения."); var part = await context.DrillingProgramParts .Include(p => p.RelatedUsers) .ThenInclude(r => r.User) .AsNoTracking() .FirstOrDefaultAsync(p => p.IdWell == fileInfo.IdWell && p.IdFileCategory == fileInfo.IdCategory, token); var user = part?.RelatedUsers.FirstOrDefault(r => r.IdUser == idUser && r.IdUserRole == idUserRoleApprover)?.User ?? throw new ForbidException($"User {idUser} is not in the approvers list."); fileMarkDto.User = user.Adapt(); var oldMarksIds = fileInfo.FileMarks .Where(m => m.User?.Id == idUser) .Select(m => m.Id); if (oldMarksIds?.Any() == true) await fileService.MarkFileMarkAsDeletedAsync(oldMarksIds, token); var result = await fileService.CreateFileMarkAsync(fileMarkDto, idUser, token) .ConfigureAwait(false); if (fileMarkDto.IdMarkType == idMarkTypeReject) { await RemoveDrillingProgramAsync(fileInfo.IdWell, token); await NotifyPublisherOnRejectAsync(fileMarkDto, token); } else { // если все согласованты согласовали - оповещаем публикатора var approvers = part!.RelatedUsers .Where(u => u.IdUserRole == idUserRoleApprover); if (approvers .All(user => fileInfo.FileMarks ?.Any(mark => (mark.IdMarkType == idMarkTypeApprove && mark.User?.Id == user.IdUser && !mark.IsDeleted)) == true || (fileMarkDto.IdMarkType == idMarkTypeApprove && user.IdUser == idUser))) { await NotifyPublisherOnFullAccepAsync(fileMarkDto, token); } } return result; } public async Task MarkAsDeletedFileMarkAsync(int idMark, CancellationToken token) { var fileInfo = await fileService.GetByMarkId(idMark, token) .ConfigureAwait(false); if (fileInfo.IdCategory < idFileCategoryDrillingProgramPartsStart || fileInfo.IdCategory > idFileCategoryDrillingProgramPartsEnd) throw new ArgumentInvalidException(nameof(idMark), $"Этот метод допустим только для файлов-частей программы бурения."); var result = await fileService.MarkFileMarkAsDeletedAsync(idMark, token) .ConfigureAwait(false); await RemoveDrillingProgramAsync(fileInfo.IdWell, token); return result; } private async Task NotifyPublisherOnFullAccepAsync(FileMarkDto fileMark, CancellationToken token) { var file = await fileService.GetOrDefaultAsync(fileMark.IdFile, token); var well = await wellService.GetOrDefaultAsync(file!.IdWell, token) ?? throw new ArgumentInvalidException(nameof(file.IdWell), "idWell doesn`t exist"); var user = file.Author!; var factory = new DrillingMailBodyFactory(configuration); var subject = factory.MakeSubject(well, "Загруженный вами документ полностью согласован"); var body = factory.MakeMailBodyForPublisherOnFullAccept(well, user.Name ?? string.Empty, file.Id, file.Name); await notificationService.NotifyAsync(new NotifyRequest { IdUser = user.Id, IdNotificationCategory = NotificationCategory.IdSystemNotificationCategory, Title = subject, Message = body, IdTransportType = idTransportType }, token); } private async Task NotifyPublisherOnRejectAsync(FileMarkDto fileMark, CancellationToken token) { var file = await fileService.GetOrDefaultAsync(fileMark.IdFile, token); var well = await wellService.GetOrDefaultAsync(file!.IdWell, token) ?? throw new ArgumentInvalidException(nameof(file.IdWell), "idWell doesn`t exist"); var user = file.Author!; var factory = new DrillingMailBodyFactory(configuration); var subject = factory.MakeSubject(well, "Загруженный вами документ отклонен"); var body = factory.MakeMailBodyForPublisherOnReject(well, user.Name ?? string.Empty, file.Id, file.Name, fileMark); await notificationService.NotifyAsync(new NotifyRequest { IdUser = user.Id, IdNotificationCategory = NotificationCategory.IdSystemNotificationCategory, Title = subject, Message = body, IdTransportType = idTransportType }, token); } private async Task NotifyApproversAsync(DrillingProgramPart part, int idFile, string fileName, CancellationToken token) { var well = await wellService.GetOrDefaultAsync(part.IdWell, token) ?? throw new ArgumentInvalidException(nameof(part.IdWell), "idWell doesn`t exist"); var factory = new DrillingMailBodyFactory(configuration); var subject = factory.MakeSubject(well, "Загружен новый документ для согласования."); var users = part.RelatedUsers .Where(r => r.IdUserRole == idUserRoleApprover) .Select(r => r.User); foreach (var user in users) { var body = factory.MakeMailBodyForApproverNewFile(well, user.Name ?? string.Empty, idFile, fileName); await notificationService.NotifyAsync(new NotifyRequest { IdUser = user.Id, IdNotificationCategory = NotificationCategory.IdSystemNotificationCategory, Title = subject, Message = body, IdTransportType = idTransportType }, token); } } private async Task NotifyNewPublisherAsync(int idWell, UserDto user, string documentCategory, CancellationToken token) { var well = await wellService.GetOrDefaultAsync(idWell, token) ?? throw new ArgumentInvalidException(nameof(idWell), "idWell doesn`t exist"); var factory = new DrillingMailBodyFactory(configuration); var subject = factory.MakeSubject(well, $"От вас ожидается загрузка на портал документа «{documentCategory}»"); var body = factory.MakeMailBodyForNewPublisher(well, user.Name ?? string.Empty, documentCategory); await notificationService.NotifyAsync(new NotifyRequest { IdUser = user.Id, IdNotificationCategory = NotificationCategory.IdSystemNotificationCategory, Title = subject, Message = body, IdTransportType = idTransportType }, token); } private static DrillingProgramPartDto ConvertPart(int idUser, List fileCategories, List files, DrillingProgramPart partEntity, double timezoneOffset) { var part = new DrillingProgramPartDto { IdFileCategory = partEntity.IdFileCategory, Name = fileCategories.FirstOrDefault(c => c.Id == partEntity.IdFileCategory)!.Name, Approvers = partEntity.RelatedUsers .Where(r => r.IdUserRole == idUserRoleApprover) .Select(r => r.User.Adapt()), Publishers = partEntity.RelatedUsers .Where(r => r.IdUserRole == idUserRolePublisher) .Select(r => r.User.Adapt()), PermissionToApprove = partEntity.RelatedUsers .Any(r => r.IdUserRole == idUserRoleApprover && r.IdUser == idUser), PermissionToUpload = partEntity.RelatedUsers .Any(r => r.IdUserRole == idUserRolePublisher && r.IdUser == idUser), }; var fileEntity = files.LastOrDefault(f => f.IdCategory == partEntity.IdFileCategory); if (fileEntity is not null) { part.IdState = idPartStateApproving; part.File = new FileInfoDto { Id = fileEntity.Id, Author = fileEntity.Author?.Adapt(), IdAuthor = fileEntity.Author?.Id, IdCategory = fileEntity.IdCategory, IdWell = fileEntity.IdWell, Name = fileEntity.Name, Size = fileEntity.Size, UploadDate = fileEntity.UploadDate.ToOffset(TimeSpan.FromHours(timezoneOffset)), }; var marks = fileEntity.FileMarks?.Where(m => !m.IsDeleted); if (marks?.Any() == true) { part.File.FileMarks = marks.Select(m => { var mark = m.Adapt(); mark.DateCreated = m.DateCreated.ToOffset(TimeSpan.FromHours(timezoneOffset)); return mark; }); var hasReject = marks.Any(m => m.IdMarkType == idMarkTypeReject); if (!hasReject) { var allAproved = part.Approvers.All(a => marks.Any(m => m.IdUser == a.Id && m.IdMarkType == idMarkTypeApprove)); if (allAproved) part.IdState = idPartStateApproved; } } } else part.IdState = idPartStateNoFile; return part; } private async Task EnqueueMakeProgramWorkAsync(int idWell, DrillingProgramStateDto state, CancellationToken token) { if (state.IdState == idStateCreating) { var workId = MakeWorkId(idWell); if (!backgroundWorker.Works.Any(w => w.Id == workId)) { var well = (await wellService.GetOrDefaultAsync(idWell, token))!; var resultFileName = $"Программа бурения {well.Cluster} {well.Caption}.pdf"; var convertedFilesDir = Path.Combine(Path.GetTempPath(), "drillingProgram", $"{well.Cluster}_{well.Caption}"); var tempResultFilePath = Path.Combine(convertedFilesDir, resultFileName); var workAction = async (string workId, IServiceProvider serviceProvider, Action onProgress, CancellationToken token) => { var context = serviceProvider.GetRequiredService(); var fileService = serviceProvider.GetRequiredService(); var files = state.Parts.Select(p => fileService.GetUrl(p.File!)); onProgress($"Start converting {files.Count()} files to PDF.", null); await ConvertToPdf.GetConverteAndMergedFileAsync(files, tempResultFilePath, convertedFilesDir, token); await fileService.MoveAsync(idWell, null, idFileCategoryDrillingProgram, resultFileName, tempResultFilePath, token); }; var onErrorAction = (string workId, Exception exception, CancellationToken token) => { var message = $"Не удалось сформировать программу бурения по скважине {well?.Caption}"; drillingProgramCreateErrors[workId] = new() { Message = message, Exception = exception.Message, }; return Task.CompletedTask; }; var work = Work.CreateByDelegate(workId, workAction); work.OnErrorAsync = onErrorAction; backgroundWorker.Enqueue(work); } } } public void ClearError(int idWell) { var workId = MakeWorkId(idWell); drillingProgramCreateErrors.Remove(workId); } private async Task RemoveDrillingProgramAsync(int idWell, CancellationToken token) { var workId = MakeWorkId(idWell); backgroundWorker.TryRemoveFromQueue(workId); var filesIds = await context.Files .Where(f => f.IdWell == idWell && f.IdCategory == idFileCategoryDrillingProgram) .Select(f => f.Id) .ToListAsync(token); if (filesIds.Any()) return await fileService.DeleteAsync(filesIds, token); else return 0; } private static string MakeWorkId(int idWell) => $"Make drilling program for wellId {idWell}"; }