| | | 1 | | using System.Security.Claims; |
| | | 2 | | using Microsoft.AspNetCore.Authorization; |
| | | 3 | | using Microsoft.AspNetCore.Mvc; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | using OzonDomains.Models; |
| | | 6 | | using OzonDomains.Models.Authorization.Enums; |
| | | 7 | | using OzonOrdersWeb.Authorization; |
| | | 8 | | using OzonRepositories.Context; |
| | | 9 | | using OzonRepositories.Context.Identity; |
| | | 10 | | using Microsoft.AspNetCore.Identity; |
| | | 11 | | using Hangfire; |
| | | 12 | | using Services.BackgroundJobsServices; |
| | | 13 | | using Services.OrderControlServices; |
| | | 14 | | using Services.OrderDailySummaryServices; |
| | | 15 | | |
| | | 16 | | namespace OzonOrdersWeb.Areas.Studio2.Controllers; |
| | | 17 | | |
| | | 18 | | [Area("Studio2")] |
| | | 19 | | [Authorize(Roles = "User,Admin,SuperAdmin")] |
| | | 20 | | public sealed class OrderControlController : Controller |
| | | 21 | | { |
| | | 22 | | private readonly IOrderControlService _service; |
| | | 23 | | private readonly ApplicationDbContext _db; |
| | | 24 | | private readonly UserManager<CustomIdentityUser> _users; |
| | | 25 | | private readonly IRecurringJobManager _recurringJobManager; |
| | | 26 | | private readonly IBackgroundJobClient _backgroundJobs; |
| | | 27 | | private readonly IOrderDailySummaryService _dailySummaryService; |
| | | 28 | | |
| | 0 | 29 | | public OrderControlController(IOrderControlService service, ApplicationDbContext db, UserManager<CustomIdentityUser> |
| | | 30 | | { |
| | 0 | 31 | | _service = service; |
| | 0 | 32 | | _db = db; |
| | 0 | 33 | | _users = users; |
| | 0 | 34 | | _recurringJobManager = recurringJobManager; |
| | 0 | 35 | | _backgroundJobs = backgroundJobs; |
| | 0 | 36 | | _dailySummaryService = dailySummaryService; |
| | 0 | 37 | | } |
| | | 38 | | |
| | | 39 | | [PageAuthorize("Studio2.OrderControl.Index")] |
| | | 40 | | public async Task<IActionResult> Index(int page = 1, bool missingStatus = false, bool onlyOverdue = false, Cancellat |
| | | 41 | | { |
| | 0 | 42 | | var tasks = await _service.GetAvailableTasksAsync(CurrentUserId(), IsAdmin(), cancellationToken); |
| | 0 | 43 | | if (onlyOverdue) |
| | 0 | 44 | | tasks = tasks.Where(x => x.Status == OrderControlTaskStatus.Overdue).ToList(); |
| | | 45 | | const int pageSize = 25; |
| | 0 | 46 | | var total = tasks.Count; |
| | 0 | 47 | | var totalPages = Math.Max(1, (int)Math.Ceiling(total / (double)pageSize)); |
| | 0 | 48 | | page = Math.Clamp(page, 1, totalPages); |
| | 0 | 49 | | ViewBag.PageNumber = page; |
| | 0 | 50 | | ViewBag.TotalPages = totalPages; |
| | 0 | 51 | | ViewBag.TotalItems = total; |
| | 0 | 52 | | ViewBag.OnlyOverdue = onlyOverdue; |
| | 0 | 53 | | ViewBag.IsAdmin = IsAdmin(); |
| | 0 | 54 | | ViewBag.ShowMissingStatus = missingStatus && IsAdmin(); |
| | 0 | 55 | | if (missingStatus && IsAdmin()) |
| | 0 | 56 | | ViewBag.MissingSystemStatusOrders = await _dailySummaryService.GetMissingSystemStatusOrdersAsync(cancellatio |
| | 0 | 57 | | return View(tasks.Skip((page - 1) * pageSize).Take(pageSize).ToList()); |
| | 0 | 58 | | } |
| | | 59 | | |
| | | 60 | | [HttpGet] |
| | | 61 | | [PageAuthorize("Studio2.OrderControl.Index")] |
| | | 62 | | public async Task<IActionResult> HeaderTasks(int skip = 0, int take = 5, CancellationToken cancellationToken = defau |
| | | 63 | | { |
| | 0 | 64 | | var tasks = await _service.GetAvailableTasksAsync(CurrentUserId(), IsAdmin(), cancellationToken); |
| | 0 | 65 | | var total = await _service.GetAvailableTaskCountAsync(CurrentUserId(), IsAdmin(), cancellationToken); |
| | 0 | 66 | | skip = Math.Max(0, skip); |
| | 0 | 67 | | take = Math.Clamp(take, 1, 10); |
| | 0 | 68 | | return Json(new |
| | 0 | 69 | | { |
| | 0 | 70 | | total, |
| | 0 | 71 | | hasMore = skip + take < total, |
| | 0 | 72 | | items = tasks.Skip(skip).Take(take).Select(x => new |
| | 0 | 73 | | { |
| | 0 | 74 | | x.Id, |
| | 0 | 75 | | shipmentNumber = x.Order?.ShipmentNumber ?? x.OrderId.ToString(), |
| | 0 | 76 | | ruleName = x.Rule?.Name ?? "Контроль заказа", |
| | 0 | 77 | | status = TaskStatusName(x.Status), |
| | 0 | 78 | | statusClass = TaskStatusClass(x.Status), |
| | 0 | 79 | | dueAtUtc = x.DueAtUtc, |
| | 0 | 80 | | isOverdue = x.Status == OrderControlTaskStatus.Overdue, |
| | 0 | 81 | | processingDate = x.Order?.ProcessingDate, |
| | 0 | 82 | | article = x.Order?.Article, |
| | 0 | 83 | | productName = x.Order?.ProductName, |
| | 0 | 84 | | producer = x.Order?.EtProducer?.Name ?? x.Order?.Manufacturer?.Name, |
| | 0 | 85 | | supplier = x.Order?.Supplier?.Name, |
| | 0 | 86 | | ozonClient = x.Order?.OzonClient?.Name, |
| | 0 | 87 | | imageUrl = x.Order?.ProductInfo?.ImageUrl, |
| | 0 | 88 | | clientStatus = x.Order?.ClientStatus?.Name ?? "Ozon: не указан", |
| | 0 | 89 | | clientStatusColor = x.Order?.ClientStatus?.StatusColor?.ColorCode, |
| | 0 | 90 | | appStatus = x.Order?.AppStatus?.Name ?? "Не указан", |
| | 0 | 91 | | appStatusColor = x.Order?.AppStatus?.StatusColor?.ColorCode |
| | 0 | 92 | | }) |
| | 0 | 93 | | }); |
| | 0 | 94 | | } |
| | | 95 | | |
| | | 96 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 97 | | [PageAuthorize("Studio2.OrderControl.Claim", AccessType.Edit)] |
| | | 98 | | public async Task<IActionResult> Claim(long id, CancellationToken cancellationToken) |
| | | 99 | | { |
| | 0 | 100 | | await _service.ClaimAsync(id, CurrentUserId(), IsAdmin(), cancellationToken); |
| | 0 | 101 | | return RedirectToAction(nameof(Index)); |
| | 0 | 102 | | } |
| | | 103 | | |
| | | 104 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 105 | | [PageAuthorize("Studio2.OrderControl.Close", AccessType.Edit)] |
| | | 106 | | public async Task<IActionResult> Close(long id, string reason, CancellationToken cancellationToken) |
| | | 107 | | { |
| | 0 | 108 | | if (!await _service.CloseAsync(id, CurrentUserId(), reason, IsAdmin(), cancellationToken)) |
| | 0 | 109 | | TempData["ErrorMessage"] = "Укажите причину закрытия или убедитесь, что задача ещё активна."; |
| | 0 | 110 | | return RedirectToAction(nameof(Index)); |
| | 0 | 111 | | } |
| | | 112 | | |
| | | 113 | | [HttpPost, ValidateAntiForgeryToken] |
| | | 114 | | [PageAuthorize("Studio2.OrderControl.Close", AccessType.Edit)] |
| | | 115 | | public async Task<IActionResult> RemoveFromQueue(long id, CancellationToken cancellationToken) |
| | | 116 | | { |
| | 0 | 117 | | var removed = await _service.RemoveFromQueueAsync(id, CurrentUserId(), IsAdmin(), cancellationToken); |
| | 0 | 118 | | var total = removed |
| | 0 | 119 | | ? await _service.GetAvailableTaskCountAsync(CurrentUserId(), IsAdmin(), cancellationToken) |
| | 0 | 120 | | : 0; |
| | 0 | 121 | | return Json(new { success = removed, total }); |
| | 0 | 122 | | } |
| | | 123 | | |
| | | 124 | | [Authorize(Roles = "Admin")] |
| | | 125 | | [PageAuthorize("Studio2.OrderControl.Rules")] |
| | | 126 | | public async Task<IActionResult> Rules(string? type, bool? active, CancellationToken cancellationToken) |
| | | 127 | | { |
| | 0 | 128 | | var query = _db.OrderControlRules.Include(x => x.TargetAppRole).AsNoTracking(); |
| | 0 | 129 | | if (!string.IsNullOrWhiteSpace(type)) query = query.Where(x => x.RuleType == type); |
| | 0 | 130 | | if (active.HasValue) query = query.Where(x => x.IsActive == active.Value); |
| | 0 | 131 | | ViewBag.SelectedType = type; |
| | 0 | 132 | | ViewBag.SelectedActive = active; |
| | 0 | 133 | | ViewBag.RuleTypes = RuleTypes; |
| | 0 | 134 | | var rules = await query.OrderBy(x => x.RuleType).ThenBy(x => x.Name).ToListAsync(cancellationToken); |
| | 0 | 135 | | var roleLinks = await _db.UserAppRoles.Where(x => rules.Select(r => r.TargetAppRoleId).Contains(x.AppRoleId)).To |
| | 0 | 136 | | var ids = roleLinks.Select(x => x.UserId).Distinct().ToList(); |
| | 0 | 137 | | var names = await _users.Users.Where(x => ids.Contains(x.Id)).Select(x => new { x.Id, Name = x.GoogleDisplayName |
| | 0 | 138 | | ViewBag.RoleUsers = roleLinks.GroupBy(x => x.AppRoleId).ToDictionary(x => x.Key, x => string.Join(", ", x.Select |
| | 0 | 139 | | return View(rules); |
| | 0 | 140 | | } |
| | | 141 | | |
| | | 142 | | [Authorize(Roles = "Admin")] |
| | | 143 | | public async Task<IActionResult> RoleUsers(int roleId, CancellationToken cancellationToken) |
| | | 144 | | { |
| | 0 | 145 | | var ids = await _db.UserAppRoles.Where(x => x.AppRoleId == roleId).Select(x => x.UserId).ToListAsync(cancellatio |
| | 0 | 146 | | var users = await _users.Users.Where(x => ids.Contains(x.Id)).OrderBy(x => x.UserName).Select(x => x.GoogleDispl |
| | 0 | 147 | | return Json(users); |
| | 0 | 148 | | } |
| | | 149 | | |
| | | 150 | | [Authorize(Roles = "Admin")] |
| | | 151 | | [PageAuthorize("Studio2.OrderControl.Rules")] |
| | | 152 | | public async Task<IActionResult> Scheduler(CancellationToken cancellationToken) |
| | | 153 | | { |
| | 0 | 154 | | ViewBag.Executions = await _db.OrderControlExecutions.AsNoTracking().OrderByDescending(x => x.StartedAtUtc).Take |
| | 0 | 155 | | return View(await _db.OrderControlSettings.FindAsync([1], cancellationToken) ?? new OrderControlSettings { Id = |
| | 0 | 156 | | } |
| | | 157 | | |
| | | 158 | | [Authorize(Roles = "Admin")] |
| | | 159 | | public async Task<IActionResult> SchedulerStatus(CancellationToken cancellationToken) |
| | | 160 | | { |
| | 0 | 161 | | var last = await _db.OrderControlExecutions.AsNoTracking().OrderByDescending(x => x.StartedAtUtc).FirstOrDefault |
| | 0 | 162 | | var today = DateTime.UtcNow.Date; |
| | 0 | 163 | | return Json(new |
| | 0 | 164 | | { |
| | 0 | 165 | | isRunning = last?.Status == "Running", |
| | 0 | 166 | | lastStatus = last?.Status ?? "Не запускалась", |
| | 0 | 167 | | lastStartedAtUtc = last?.StartedAtUtc, |
| | 0 | 168 | | lastCompletedAtUtc = last?.CompletedAtUtc, |
| | 0 | 169 | | lastCreatedTasks = last?.CreatedTasksCount ?? 0, |
| | 0 | 170 | | activeTasks = await _db.OrderControlTasks.CountAsync(x => OrderControlTaskStatus.Active.Contains(x.Status), |
| | 0 | 171 | | newToday = await _db.OrderControlTasks.CountAsync(x => x.CreatedAtUtc >= today, cancellationToken) |
| | 0 | 172 | | }); |
| | 0 | 173 | | } |
| | | 174 | | |
| | | 175 | | [HttpPost, ValidateAntiForgeryToken, Authorize(Roles = "Admin")] |
| | | 176 | | [PageAuthorize("Studio2.OrderControl.Rules", AccessType.Edit)] |
| | | 177 | | public async Task<IActionResult> Scheduler(int intervalMinutes, CancellationToken cancellationToken) |
| | | 178 | | { |
| | 0 | 179 | | var settings = await _db.OrderControlSettings.FindAsync([1], cancellationToken) ?? new OrderControlSettings { Id |
| | 0 | 180 | | settings.IntervalMinutes = Math.Clamp(intervalMinutes, 5, 59); settings.UpdatedAtUtc = DateTime.UtcNow; |
| | 0 | 181 | | _db.Update(settings); await _db.SaveChangesAsync(cancellationToken); |
| | 0 | 182 | | _recurringJobManager.RemoveIfExists(OrderControlEvaluationJob.JobId); |
| | 0 | 183 | | _recurringJobManager.AddOrUpdate<OrderControlEvaluationJob>(OrderControlEvaluationJob.JobId, |
| | 0 | 184 | | x => x.ExecuteAsync(), OrderControlEvaluationJob.GetCron(settings.IntervalMinutes), |
| | 0 | 185 | | new RecurringJobOptions { TimeZone = RussianTimeZoneHelper.Zone }); |
| | 0 | 186 | | TempData["SuccessMessage"] = "Периодичность сохранена, задача Hangfire перерегистрирована."; |
| | 0 | 187 | | return RedirectToAction(nameof(Scheduler)); |
| | 0 | 188 | | } |
| | | 189 | | |
| | | 190 | | [HttpPost, ValidateAntiForgeryToken, Authorize(Roles = "Admin")] |
| | | 191 | | [PageAuthorize("Studio2.OrderControl.Rules", AccessType.Edit)] |
| | | 192 | | public IActionResult RunNow() |
| | | 193 | | { |
| | 0 | 194 | | _backgroundJobs.Enqueue<OrderControlEvaluationJob>(x => x.ExecuteAsync()); |
| | 0 | 195 | | TempData["SuccessMessage"] = "Проверка поставлена в очередь Hangfire. Статус обновится автоматически."; |
| | 0 | 196 | | return RedirectToAction(nameof(Scheduler)); |
| | | 197 | | } |
| | | 198 | | |
| | | 199 | | [Authorize(Roles = "Admin")] |
| | | 200 | | [PageAuthorize("Studio2.OrderControl.Rules", AccessType.Edit)] |
| | | 201 | | public async Task<IActionResult> EditRule(int? id, CancellationToken cancellationToken) |
| | | 202 | | { |
| | 0 | 203 | | ViewBag.Roles = await _db.AppRoles.Where(x => x.IsActive).OrderBy(x => x.DisplayName).ToListAsync(cancellationTo |
| | 0 | 204 | | ViewBag.RuleTypes = RuleTypes; |
| | 0 | 205 | | if (id is null) |
| | 0 | 206 | | return View(new OrderControlRule { IsActive = true, Priority = 2 }); |
| | 0 | 207 | | var rule = await _db.OrderControlRules.FindAsync([id.Value], cancellationToken); |
| | 0 | 208 | | return rule is null ? NotFound() : View(rule); |
| | 0 | 209 | | } |
| | | 210 | | |
| | | 211 | | [HttpPost, ValidateAntiForgeryToken, Authorize(Roles = "Admin")] |
| | | 212 | | [PageAuthorize("Studio2.OrderControl.Rules", AccessType.Edit)] |
| | | 213 | | public async Task<IActionResult> EditRule(OrderControlRule model, CancellationToken cancellationToken) |
| | | 214 | | { |
| | 0 | 215 | | if (!ModelState.IsValid) return await EditRule(model.Id == 0 ? null : model.Id, cancellationToken); |
| | 0 | 216 | | var now = DateTime.UtcNow; |
| | 0 | 217 | | if (model.Id == 0) |
| | | 218 | | { |
| | 0 | 219 | | model.Code = $"{model.RuleType.ToLowerInvariant()}-{Guid.NewGuid():N}"; |
| | 0 | 220 | | model.CreatedAtUtc = now; |
| | 0 | 221 | | model.UpdatedAtUtc = now; |
| | 0 | 222 | | _db.OrderControlRules.Add(model); |
| | | 223 | | } |
| | | 224 | | else |
| | | 225 | | { |
| | 0 | 226 | | var existing = await _db.OrderControlRules.FindAsync([model.Id], cancellationToken); |
| | 0 | 227 | | if (existing is null) return NotFound(); |
| | 0 | 228 | | existing.Name = model.Name; |
| | 0 | 229 | | existing.IsActive = model.IsActive; |
| | 0 | 230 | | existing.Priority = model.Priority; |
| | 0 | 231 | | existing.TargetAppRoleId = model.TargetAppRoleId; |
| | 0 | 232 | | existing.ParametersJson = model.ParametersJson; |
| | 0 | 233 | | existing.UpdatedAtUtc = now; |
| | | 234 | | } |
| | 0 | 235 | | await _db.SaveChangesAsync(cancellationToken); |
| | 0 | 236 | | return RedirectToAction(nameof(Rules)); |
| | 0 | 237 | | } |
| | | 238 | | |
| | 0 | 239 | | private string CurrentUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; |
| | 0 | 240 | | private bool IsAdmin() => User.IsInRole("Admin") || User.IsInRole("SuperAdmin"); |
| | 0 | 241 | | private static string TaskStatusName(string status) => status switch |
| | 0 | 242 | | { |
| | 0 | 243 | | OrderControlTaskStatus.Open => "Новая", |
| | 0 | 244 | | OrderControlTaskStatus.InProgress => "В работе", |
| | 0 | 245 | | OrderControlTaskStatus.Overdue => "Просрочена", |
| | 0 | 246 | | _ => status |
| | 0 | 247 | | }; |
| | 0 | 248 | | private static string TaskStatusClass(string status) => status switch |
| | 0 | 249 | | { |
| | 0 | 250 | | OrderControlTaskStatus.Overdue => "overdue", |
| | 0 | 251 | | OrderControlTaskStatus.InProgress => "progress", |
| | 0 | 252 | | _ => "open" |
| | 0 | 253 | | }; |
| | 0 | 254 | | public static readonly IReadOnlyDictionary<string, (string Name, string Description, string BadgeClass, string Examp |
| | 0 | 255 | | new Dictionary<string, (string, string, string, string)> |
| | 0 | 256 | | { |
| | 0 | 257 | | ["NewUnprocessedToday"] = ("Новый заказ без обработки", "Контролирует заказы, поступившие сегодня без статус |
| | 0 | 258 | | ["UnprocessedOver12Hours"] = ("Без обработки более 12 часов", "Контролирует заказы без системного статуса бо |
| | 0 | 259 | | ["SupplierArrivalOverdue"] = ("Просрочен приход от поставщика", "Контролирует заказы со статусом «Заказан по |
| | 0 | 260 | | ["NoStatusMovement"] = ("Нет движения по статусу", "Контролирует заказы без изменения статуса заданное число |
| | 0 | 261 | | }; |
| | | 262 | | } |