| | | 1 | | using OzonDomains.Models; |
| | | 2 | | |
| | | 3 | | namespace Services.EmailRoutingServices; |
| | | 4 | | |
| | | 5 | | public class ImapSettings |
| | | 6 | | { |
| | | 7 | | public string Host { get; set; } = string.Empty; |
| | | 8 | | public int Port { get; set; } = 993; |
| | | 9 | | public string UserName { get; set; } = string.Empty; |
| | | 10 | | public string Password { get; set; } = string.Empty; |
| | | 11 | | public bool UseSsl { get; set; } = true; |
| | | 12 | | public string Folder { get; set; } = "INBOX"; |
| | | 13 | | public int FetchLimit { get; set; } = 20; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Сколько последних писем в папке (по UID) просматривать за один запуск. |
| | | 17 | | /// </summary> |
| | | 18 | | public int FetchScanLimit { get; set; } = ImapFetchWindow.DefaultFetchScanLimit; |
| | | 19 | | |
| | | 20 | | /// <summary> |
| | | 21 | | /// Скользящее окно в сутках — всегда действует при автополлинге (ImapEmailInboxClient.FetchMessagesAsync), |
| | | 22 | | /// если не задан <see cref="FetchAfterUtc"/>. Фактическое окно — min(N суток назад, начало текущих суток MSK), |
| | | 23 | | /// то есть всегда покрывает минимум "сегодня". Письма старше окна auto-polling не обрабатывает вовсе — |
| | | 24 | | /// только вручную (Reprocess/.cursor/skills/load-exist-orders/SKILL.md). |
| | | 25 | | /// </summary> |
| | | 26 | | public int FetchWindowDays { get; set; } = ImapFetchWindow.DefaultFetchWindowDays; |
| | | 27 | | |
| | | 28 | | // Если задано, ПОЛНОСТЬЮ заменяет скользящее окно FetchWindowDays: IMAP будет выбирать только письма, |
| | | 29 | | // полученные после этой фиксированной даты (UTC), пока её не изменят. |
| | | 30 | | public DateTime? FetchAfterUtc { get; set; } |
| | | 31 | | } |
| | | 32 | | |
| | | 33 | | public class ImapConnectionStatus |
| | | 34 | | { |
| | | 35 | | public bool IsConfigured { get; set; } |
| | | 36 | | public bool IsConnected { get; set; } |
| | | 37 | | public string? UserName { get; set; } |
| | | 38 | | public string? Host { get; set; } |
| | | 39 | | public int Port { get; set; } |
| | | 40 | | public string? Folder { get; set; } |
| | | 41 | | public int FetchLimit { get; set; } |
| | | 42 | | public int? UnseenCount { get; set; } |
| | | 43 | | |
| | | 44 | | /// <summary> |
| | | 45 | | /// Все письма в папке за окно FetchAfterUtc (обычно 24ч), включая прочитанные. |
| | | 46 | | /// </summary> |
| | | 47 | | public int? UnseenReadyCount { get; set; } |
| | | 48 | | public string? ErrorMessage { get; set; } |
| | | 49 | | public DateTime CheckedAtUtc { get; set; } = DateTime.UtcNow; |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | public class EmailRoutingExecutionOptions |
| | | 53 | | { |
| | | 54 | | public bool AllowMultiMatch { get; set; } |
| | | 55 | | |
| | | 56 | | public bool RecurringJobEnabled { get; set; } = true; |
| | | 57 | | |
| | | 58 | | public int IntervalMinutes { get; set; } = 5; |
| | | 59 | | |
| | | 60 | | /// <summary> |
| | | 61 | | /// Сколько раз подряд вызывать IMAP-polling за один Hangfire-запуск (каждый раз до FetchLimit писем). |
| | | 62 | | /// </summary> |
| | | 63 | | public int MaxBatchesPerRun { get; set; } = 20; |
| | | 64 | | |
| | | 65 | | /// <summary> |
| | | 66 | | /// Максимальный возраст письма (по ReceivedAtUtc) для прямого автоимпорта заказов Exist/Эмекс |
| | | 67 | | /// (OrderEmailImportService) без подтверждения человеком — применяется только к обычному IMAP polling. |
| | | 68 | | /// Письма старше — не импортируются автоматически (Status=Failed с понятной причиной), обрабатываются |
| | | 69 | | /// только вручную: либо через Reprocess одного письма из EmailRoutingResults (человек явно подтвердил), |
| | | 70 | | /// либо по .cursor/skills/load-exist-orders/SKILL.md. Защита от повторного автоматического импорта беклога |
| | | 71 | | /// при рассинхроне состояния "уже обработано" (например после восстановления БД из бэкапа) — см. инцидент |
| | | 72 | | /// 17.07.2026, когда 1205 старых заказов получили ProcessingDate=Now и часть данных перезаписалась чужим |
| | | 73 | | /// Key-совпадением. Ручной Reprocess этот cutoff обходит — см. IOrderEmailImportService.TryImportAsync. |
| | | 74 | | /// Должен быть с запасом больше окна IMAP-polling (<see cref="ImapSettings.FetchWindowDays"/>, по умолчанию |
| | | 75 | | /// 3 суток = 72ч) — иначе письмо, впервые попавшее в выборку на границе окна, тут же упирается в cutoff |
| | | 76 | | /// (см. инцидент 19.07.2026: письма 17.07 не проходили при cutoff=48ч). Значение переопределяется в |
| | | 77 | | /// appsettings.json (EmailRouting:OrderImportMaxBacklogHours) — сейчас 96ч. |
| | | 78 | | /// </summary> |
| | | 79 | | public double OrderImportMaxBacklogHours { get; set; } = 48; |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | public class InboxMessageDto |
| | | 83 | | { |
| | 147 | 84 | | public string MessageExternalId { get; set; } = string.Empty; |
| | 15 | 85 | | public string? Uid { get; set; } |
| | 90 | 86 | | public string? SenderEmail { get; set; } |
| | 123 | 87 | | public string? Subject { get; set; } |
| | 118 | 88 | | public string? BodyText { get; set; } |
| | 75 | 89 | | public DateTime? ReceivedAtUtc { get; set; } |
| | 78 | 90 | | public List<InboxAttachmentDto> Attachments { get; set; } = new(); |
| | | 91 | | } |
| | | 92 | | |
| | | 93 | | public class InboxAttachmentDto |
| | | 94 | | { |
| | | 95 | | public string FileName { get; set; } = string.Empty; |
| | | 96 | | public string? Extension { get; set; } |
| | | 97 | | public long FileSizeBytes { get; set; } |
| | | 98 | | public byte[] FileBytes { get; set; } = Array.Empty<byte>(); |
| | | 99 | | } |
| | | 100 | | |
| | | 101 | | public class RuleEvaluationResult |
| | | 102 | | { |
| | | 103 | | public bool IsMatch { get; set; } |
| | | 104 | | public string? RejectReason { get; set; } |
| | | 105 | | public EmailRoutingRule? Rule { get; set; } |
| | | 106 | | } |
| | | 107 | | |
| | | 108 | | public class DropboxRouteResult |
| | | 109 | | { |
| | | 110 | | public bool IsSuccess { get; set; } |
| | | 111 | | public string? FolderPath { get; set; } |
| | | 112 | | public string? FileName { get; set; } |
| | | 113 | | public string? FilePath { get; set; } |
| | | 114 | | public string? DownloadLink { get; set; } |
| | | 115 | | public string? Error { get; set; } |
| | | 116 | | } |
| | | 117 | | |
| | | 118 | | public class OrderImportResult |
| | | 119 | | { |
| | | 120 | | /// <summary> |
| | | 121 | | /// true, если правило распознано как заказ-импортирующее (Exist/Эмекс) — Dropbox для него не используется, |
| | | 122 | | /// независимо от IsSuccess. false — правило не про импорт заказов, обработку нужно продолжить как обычно (Dropbox). |
| | | 123 | | /// </summary> |
| | | 124 | | public bool Handled { get; set; } |
| | | 125 | | public bool IsSuccess { get; set; } |
| | | 126 | | public string? MappingName { get; set; } |
| | | 127 | | public string? Delivery { get; set; } |
| | | 128 | | public int AddedCount { get; set; } |
| | | 129 | | public int UpdatedCount { get; set; } |
| | | 130 | | |
| | | 131 | | /// <summary> |
| | | 132 | | /// Сколько строк файла пропущено, потому что заказ с таким (Article, ShipmentNumber) уже есть в базе — |
| | | 133 | | /// файл поставщика ничего обновить не может, такие строки не трогаем. |
| | | 134 | | /// </summary> |
| | | 135 | | public int SkippedExistingCount { get; set; } |
| | | 136 | | |
| | | 137 | | /// <summary> |
| | | 138 | | /// Номера отгрузок (ShipmentNumber) реально добавленных заказов — для отображения в EmailRoutingResults. |
| | | 139 | | /// </summary> |
| | | 140 | | public List<string> AddedOrderNumbers { get; set; } = new(); |
| | | 141 | | |
| | | 142 | | public string? Error { get; set; } |
| | | 143 | | } |
| | | 144 | | |
| | | 145 | | public interface IEmailInboxClient |
| | | 146 | | { |
| | | 147 | | Task<List<InboxMessageDto>> FetchMessagesAsync( |
| | | 148 | | InboxKnownMessages? knownMessages = null, |
| | | 149 | | CancellationToken cancellationToken = default); |
| | | 150 | | |
| | | 151 | | Task<ImapConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default); |
| | | 152 | | } |
| | | 153 | | |
| | | 154 | | public interface IRoutingRuleEvaluator |
| | | 155 | | { |
| | | 156 | | RuleEvaluationResult Evaluate(InboxMessageDto message, InboxAttachmentDto attachment, List<EmailRoutingRule> rules); |
| | | 157 | | } |
| | | 158 | | |
| | | 159 | | public interface IFileDeduplicationService |
| | | 160 | | { |
| | | 161 | | string ComputeSha256(byte[] bytes); |
| | | 162 | | Task<bool> IsDuplicateAsync(string messageExternalId, string hash, string fileName, long fileSizeBytes); |
| | | 163 | | } |
| | | 164 | | |
| | | 165 | | public interface IDropboxFileRouter |
| | | 166 | | { |
| | | 167 | | Task<DropboxRouteResult> RouteAsync(EmailRoutingRule rule, InboxMessageDto message, InboxAttachmentDto attachment, s |
| | | 168 | | } |
| | | 169 | | |
| | | 170 | | /// <summary> |
| | | 171 | | /// Прямой импорт заказов из вложения письма (Exist/Эмекс) в Orders, минуя Dropbox — |
| | | 172 | | /// переиспользует штатный парсинг OrderCaster/ColumnMapping, которым раньше пользовался |
| | | 173 | | /// отключённый Dropbox-джоб FileUpload (см. .cursor/skills/load-exist-orders/SKILL.md). |
| | | 174 | | /// </summary> |
| | | 175 | | public interface IOrderEmailImportService |
| | | 176 | | { |
| | | 177 | | /// <summary> |
| | | 178 | | /// <paramref name="forceReprocess"/> = true (ручной Reprocess одного письма через UI) обходит только |
| | | 179 | | /// возрастной cutoff (<see cref="EmailRoutingExecutionOptions.OrderImportMaxBacklogHours"/>) — человек |
| | | 180 | | /// явно запросил переобработку этого письма. Защиту от порчи данных при этом даёт не cutoff, а insert-only |
| | | 181 | | /// вставка (<see cref="OrderImportResult.SkippedExistingCount"/>) — совпавшие по (Article, ShipmentNumber) |
| | | 182 | | /// строки по-прежнему никогда не апдейтятся. Письма без определённой даты (ReceivedAtUtc == null) |
| | | 183 | | /// cutoff не обходит — без даты не на что ссылаться при смещении даты отгрузки. |
| | | 184 | | /// </summary> |
| | | 185 | | Task<OrderImportResult> TryImportAsync( |
| | | 186 | | EmailRoutingRule rule, |
| | | 187 | | InboxMessageDto message, |
| | | 188 | | InboxAttachmentDto attachment, |
| | | 189 | | bool forceReprocess = false, |
| | | 190 | | CancellationToken cancellationToken = default); |
| | | 191 | | } |
| | | 192 | | |
| | | 193 | | public interface IEmailAttachmentRoutingService |
| | | 194 | | { |
| | | 195 | | Task<EmailInboxProcessResult> ProcessInboxAsync(bool dryRun = false, CancellationToken cancellationToken = default); |
| | | 196 | | Task<int> ReprocessMessageAsync(long messageId, CancellationToken cancellationToken = default); |
| | | 197 | | } |