| | | 1 | | using System.Data.Common; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | using OzonDomains; |
| | | 4 | | using OzonDomains.Models; |
| | | 5 | | using OzonDomains.Models.SupplierStatuses; |
| | | 6 | | using OzonRepositories.Context; |
| | | 7 | | using Services.ApiServices.TelegramBotApi; |
| | | 8 | | using Services.BackgroundJobsServices; |
| | | 9 | | using Services.BitrixStockServices; |
| | | 10 | | using Services.OrderDailySummaryServices; |
| | | 11 | | |
| | | 12 | | namespace Services.DailyWorkSummaryServices; |
| | | 13 | | |
| | | 14 | | public sealed record DailyWorkCount(string Name, int Count); |
| | | 15 | | |
| | | 16 | | public sealed record DailyTransactionRow(string Type, int Transactions, int Orders); |
| | | 17 | | |
| | | 18 | | public sealed record DailyEmployeeRow(string User, int Transactions, int Orders); |
| | | 19 | | |
| | | 20 | | public sealed record DailyStockRolloutStats(int Reports, int Applied, int Failed); |
| | | 21 | | |
| | 7 | 22 | | public sealed record DailyDisk(double FreeGb, double TotalGb); |
| | | 23 | | |
| | | 24 | | /// <summary>Итог рабочего дня по данным БД: что сделано за сутки и состояние на момент отправки.</summary> |
| | | 25 | | public sealed record DailyWorkSummary( |
| | | 26 | | DateTime FromLocal, |
| | | 27 | | DateTime ToLocal, |
| | | 28 | | int NewOrders, |
| | | 29 | | IReadOnlyList<DailyWorkCount> NewOrdersByClient, |
| | | 30 | | IReadOnlyList<DailyTransactionRow> Transactions, |
| | | 31 | | IReadOnlyList<DailyEmployeeRow> Employees, |
| | | 32 | | int SupplierOrdersPlaced, |
| | | 33 | | IReadOnlyList<DailyWorkCount> SupplierOrdersByProvider, |
| | | 34 | | int OneCPostedOk, |
| | | 35 | | int OneCPostedFailed, |
| | | 36 | | int ControlTasksClosed, |
| | | 37 | | int ControlTasksOverdue, |
| | | 38 | | DailyStockRolloutStats Rollout, |
| | | 39 | | int AlertsCritical, |
| | | 40 | | int AlertsWarning, |
| | | 41 | | int MissingSystemStatus, |
| | | 42 | | DailyDisk? Disk, |
| | | 43 | | IReadOnlyList<TelegramOzonStockRow>? OzonStock = null); |
| | | 44 | | |
| | | 45 | | public interface IDailyWorkSummaryService |
| | | 46 | | { |
| | | 47 | | Task<DailyWorkSummary> BuildAsync(CancellationToken cancellationToken = default); |
| | | 48 | | } |
| | | 49 | | |
| | | 50 | | /// <summary> |
| | | 51 | | /// Собирает сводку «что сделано сегодня» (с 00:00 по московскому времени до момента запуска). |
| | | 52 | | /// Время в таблицах хранится по-разному: транзакции, история заказов и дата обработки — по Москве, |
| | | 53 | | /// проводки 1С, история поставщика, задачи контроля и отчёты Bitrix — в UTC; окно переводится для каждого источника. |
| | | 54 | | /// </summary> |
| | | 55 | | public sealed class DailyWorkSummaryService : IDailyWorkSummaryService |
| | | 56 | | { |
| | | 57 | | private const int TopClients = 6; |
| | | 58 | | private const int TopEmployees = 8; |
| | | 59 | | |
| | | 60 | | private readonly ApplicationDbContext _db; |
| | | 61 | | private readonly IOrderDailySummaryService _orderSummary; |
| | | 62 | | private readonly TimeProvider _timeProvider; |
| | | 63 | | |
| | | 64 | | public DailyWorkSummaryService(ApplicationDbContext db, IOrderDailySummaryService orderSummary, TimeProvider? timePr |
| | | 65 | | { |
| | | 66 | | _db = db; |
| | | 67 | | _orderSummary = orderSummary; |
| | | 68 | | _timeProvider = timeProvider ?? TimeProvider.System; |
| | | 69 | | } |
| | | 70 | | |
| | | 71 | | public async Task<DailyWorkSummary> BuildAsync(CancellationToken ct = default) |
| | | 72 | | { |
| | | 73 | | var nowUtc = _timeProvider.GetUtcNow().UtcDateTime; |
| | | 74 | | var toLocal = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, RussianTimeZoneHelper.Zone); |
| | | 75 | | var fromLocal = toLocal.Date; |
| | | 76 | | var fromUtc = TimeZoneInfo.ConvertTimeToUtc(fromLocal, RussianTimeZoneHelper.Zone); |
| | | 77 | | |
| | | 78 | | var newOrders = await _db.Orders.AsNoTracking() |
| | | 79 | | .Where(o => o.ProcessingDate >= fromLocal && o.ProcessingDate <= toLocal) |
| | | 80 | | .Select(o => o.OzonClient != null ? o.OzonClient.Name : null) |
| | | 81 | | .ToListAsync(ct); |
| | | 82 | | var byClient = newOrders |
| | | 83 | | .GroupBy(n => string.IsNullOrWhiteSpace(n) ? "Без клиента" : n!.Trim()) |
| | | 84 | | .Select(g => new DailyWorkCount(g.Key, g.Count())) |
| | | 85 | | .OrderByDescending(x => x.Count).ThenBy(x => x.Name).Take(TopClients).ToList(); |
| | | 86 | | |
| | | 87 | | var transactions = await _db.Transactions.AsNoTracking() |
| | | 88 | | .Where(t => t.CreatedDateTime >= fromLocal && t.CreatedDateTime <= toLocal) |
| | | 89 | | .Select(t => new { t.Type, t.CreateBy, Orders = t.Orders.Count }) |
| | | 90 | | .ToListAsync(ct); |
| | | 91 | | var byType = transactions |
| | | 92 | | .GroupBy(t => t.Type is { } type ? type.GetDisplayName() : "Без типа") |
| | | 93 | | .Select(g => new DailyTransactionRow(g.Key, g.Count(), g.Sum(x => x.Orders))) |
| | | 94 | | .OrderByDescending(x => x.Orders).ThenBy(x => x.Type).ToList(); |
| | | 95 | | var employees = transactions |
| | | 96 | | .GroupBy(t => string.IsNullOrWhiteSpace(t.CreateBy) ? "—" : t.CreateBy!.Trim()) |
| | | 97 | | .Select(g => new DailyEmployeeRow(g.Key, g.Count(), g.Sum(x => x.Orders))) |
| | | 98 | | .OrderByDescending(x => x.Orders).ThenBy(x => x.User).Take(TopEmployees).ToList(); |
| | | 99 | | |
| | | 100 | | var placed = await _db.OrderSupplierStateHistory.AsNoTracking() |
| | | 101 | | .Where(h => h.ToStage == OrderSupplierStage.Ordered && h.FromStage != OrderSupplierStage.Ordered && h.Perfor |
| | | 102 | | .Select(h => new { h.OrderId, Provider = h.OrderSupplierState != null ? h.OrderSupplierState.ProviderKey : " |
| | | 103 | | .ToListAsync(ct); |
| | | 104 | | var distinctPlaced = placed.DistinctBy(x => x.OrderId).ToList(); |
| | | 105 | | var byProvider = distinctPlaced |
| | | 106 | | .GroupBy(x => x.Provider) |
| | | 107 | | .Select(g => new DailyWorkCount(g.Key, g.Count())) |
| | | 108 | | .OrderByDescending(x => x.Count).ThenBy(x => x.Name).ToList(); |
| | | 109 | | |
| | | 110 | | var postings = await _db.OrderHistoryOneCPostings.AsNoTracking() |
| | | 111 | | .Where(p => p.PostedAt >= fromUtc && p.PostedAt <= nowUtc) |
| | | 112 | | .GroupBy(p => p.Success) |
| | | 113 | | .Select(g => new { Success = g.Key, Count = g.Count() }) |
| | | 114 | | .ToListAsync(ct); |
| | | 115 | | |
| | | 116 | | var controlClosed = await _db.OrderControlTasks.AsNoTracking() |
| | | 117 | | .CountAsync(x => x.ClosedAtUtc >= fromUtc && x.ClosedAtUtc <= nowUtc, ct); |
| | | 118 | | var controlOverdue = await _db.OrderControlTasks.AsNoTracking() |
| | | 119 | | .CountAsync(x => x.Status == OrderControlTaskStatus.Overdue, ct); |
| | | 120 | | |
| | | 121 | | var alerts = await _db.SystemAlertIncidents.AsNoTracking() |
| | | 122 | | .Where(x => x.IsActive && x.ConfirmedAtUtc != null && x.AcknowledgedAtUtc == null |
| | | 123 | | && (x.MutedUntilUtc == null || x.MutedUntilUtc <= nowUtc)) |
| | | 124 | | .GroupBy(x => x.Severity) |
| | | 125 | | .Select(g => new { Severity = g.Key, Count = g.Count() }) |
| | | 126 | | .ToListAsync(ct); |
| | | 127 | | |
| | | 128 | | var rollout = await LoadRolloutAsync(fromUtc, nowUtc, ct); |
| | | 129 | | var ozonStock = await LoadOzonStockAsync(nowUtc, ct); |
| | | 130 | | var missing = (await _orderSummary.GetYesterdayAsync(ct)).MissingSystemStatusCount; |
| | | 131 | | |
| | | 132 | | return new DailyWorkSummary( |
| | | 133 | | fromLocal, toLocal, newOrders.Count, byClient, byType, employees, |
| | | 134 | | distinctPlaced.Count(), byProvider, |
| | | 135 | | postings.Where(x => x.Success).Sum(x => x.Count), postings.Where(x => !x.Success).Sum(x => x.Count), |
| | | 136 | | controlClosed, controlOverdue, rollout, |
| | | 137 | | alerts.Where(x => x.Severity == "danger").Sum(x => x.Count), |
| | | 138 | | alerts.Where(x => x.Severity == "warning").Sum(x => x.Count), |
| | | 139 | | missing, ReadDisk(), ozonStock); |
| | | 140 | | } |
| | | 141 | | |
| | | 142 | | /// <summary>Отчёты наката остатков Bitrix за окно (таблица без DbSet — сырой запрос). Для не-реляционных провайдеро |
| | | 143 | | private async Task<DailyStockRolloutStats> LoadRolloutAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct) |
| | | 144 | | { |
| | | 145 | | if (!_db.Database.IsRelational()) return new DailyStockRolloutStats(0, 0, 0); |
| | | 146 | | |
| | | 147 | | await _db.Database.OpenConnectionAsync(ct); |
| | | 148 | | try |
| | | 149 | | { |
| | | 150 | | await using var cmd = _db.Database.GetDbConnection().CreateCommand(); |
| | | 151 | | cmd.CommandText = @"SELECT COUNT(*), COALESCE(SUM(OzonAppliedSuccessCount), 0), COALESCE(SUM(OzonApplyFailed |
| | | 152 | | FROM BitrixStockSyncReports |
| | | 153 | | WHERE Status = 'Completed' AND GeneratedAtUtc >= @from AND GeneratedAtUtc <= @to"; |
| | | 154 | | AddParam(cmd, "@from", fromUtc); |
| | | 155 | | AddParam(cmd, "@to", toUtc); |
| | | 156 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | | 157 | | return await reader.ReadAsync(ct) |
| | | 158 | | ? new DailyStockRolloutStats(Convert.ToInt32(reader.GetValue(0)), Convert.ToInt32(reader.GetValue(1)), C |
| | | 159 | | : new DailyStockRolloutStats(0, 0, 0); |
| | | 160 | | } |
| | | 161 | | finally |
| | | 162 | | { |
| | | 163 | | await _db.Database.CloseConnectionAsync(); |
| | | 164 | | } |
| | | 165 | | } |
| | | 166 | | |
| | | 167 | | /// <summary>Актуальные остатки по складам Ozon: из последнего завершённого отчёта наката каждого клиента.</summary> |
| | | 168 | | private async Task<IReadOnlyList<TelegramOzonStockRow>> LoadOzonStockAsync(DateTime toUtc, CancellationToken ct) |
| | | 169 | | { |
| | | 170 | | if (!_db.Database.IsRelational()) return []; |
| | | 171 | | |
| | | 172 | | await _db.Database.OpenConnectionAsync(ct); |
| | | 173 | | try |
| | | 174 | | { |
| | | 175 | | var conn = _db.Database.GetDbConnection(); |
| | | 176 | | var reportIds = new List<string>(); |
| | | 177 | | await using (var cmd = conn.CreateCommand()) |
| | | 178 | | { |
| | | 179 | | cmd.CommandText = @"SELECT r.ReportId |
| | | 180 | | FROM BitrixStockSyncReports r |
| | | 181 | | JOIN (SELECT OzonClientId, MAX(GeneratedAtUtc) AS g FROM BitrixStockSyncReports |
| | | 182 | | WHERE Status = 'Completed' AND GeneratedAtUtc <= @to AND ProductsInReport > 0 GROUP BY OzonClientId) m |
| | | 183 | | ON m.OzonClientId = r.OzonClientId AND m.g = r.GeneratedAtUtc"; |
| | | 184 | | AddParam(cmd, "@to", toUtc); |
| | | 185 | | await using var reader = await cmd.ExecuteReaderAsync(ct); |
| | | 186 | | while (await reader.ReadAsync(ct)) reportIds.Add(reader.GetString(0)); |
| | | 187 | | } |
| | | 188 | | |
| | | 189 | | var rows = new List<TelegramOzonStockRow>(); |
| | | 190 | | foreach (var id in reportIds) |
| | | 191 | | rows.AddRange(await OzonWarehouseStockQuery.LoadAsync(conn, id, ct)); |
| | | 192 | | return rows; |
| | | 193 | | } |
| | | 194 | | finally |
| | | 195 | | { |
| | | 196 | | await _db.Database.CloseConnectionAsync(); |
| | | 197 | | } |
| | | 198 | | } |
| | | 199 | | |
| | | 200 | | private static void AddParam(DbCommand command, string name, object value) |
| | | 201 | | { |
| | | 202 | | var parameter = command.CreateParameter(); |
| | | 203 | | parameter.ParameterName = name; |
| | | 204 | | parameter.Value = value; |
| | | 205 | | command.Parameters.Add(parameter); |
| | | 206 | | } |
| | | 207 | | |
| | | 208 | | private static DailyDisk? ReadDisk() |
| | | 209 | | { |
| | | 210 | | try |
| | | 211 | | { |
| | | 212 | | var root = Path.GetPathRoot(AppContext.BaseDirectory); |
| | | 213 | | if (string.IsNullOrEmpty(root)) return null; |
| | | 214 | | var drive = new DriveInfo(root); |
| | | 215 | | const double gb = 1024d * 1024 * 1024; |
| | | 216 | | return new DailyDisk(Math.Round(drive.AvailableFreeSpace / gb, 1), Math.Round(drive.TotalSize / gb, 1)); |
| | | 217 | | } |
| | | 218 | | catch |
| | | 219 | | { |
| | | 220 | | return null; |
| | | 221 | | } |
| | | 222 | | } |
| | | 223 | | } |