using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; using Deal.Modules.Kanban.Application; using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Settings.Application; namespace Deal.Modules.Pipeline.Application; /// /// Сборка снимка карточки из разобранного сообщения для записи через Kanban (план Task 7 L393–399, /// Ruling 4; python pipeline.py _store_lead L433–514). /// /// /// Чистый класс модуля Pipeline (без EF/HTTP): из разбора (ИИ/локальный путь) /// и строки-сообщения собирает полный 1:1 с _store_lead: /// title — (140, fallback — начало исходника); «О заявке» — /// (блоки Компания → … → Условия, 1:1 с cardPrompt/compose_summary /// L225–284) через (2000, fallback — clean_short исходника); /// stack — (≤12); бюджет — /// из разбора (форма хранения) + fallback первой суммы по /// исходнику/«О заявке» (L459–468); конверсия один раз при поступлении — /// (conversionOn/targetCurrency/ratesCache из типизированного снимка TenantSettingsSnapshot, C30, с мок-фолбэком); /// контакты — из значений разбора или текста (L389–421, ≤6, дедуп), /// contact = (L424–430, ≤200); ch-поля канала, sourceMsg = text[:4000], /// prevCol=inbox, isVacancy/isVacancyKnown из разбора. /// /// Колонка: разбор может назначить доску (parsed.Board) — читает её через /// и применяет страховку (python /// L449–450): доска отсутствует или текст не прошёл правила → col=inbox (ИИ/ML не кладут в отфильтрованную /// колонку); прошла → col=доска, matchHits = для прошедшей доски (иначе /// пусто). Id карточки (c_) генерирует вызывающий (PipelineCardWriter) и передаёт готовым (Ruling 12). /// /// public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) { // Лимит заголовка карточки (python _store_lead L455: clean_short(title, 140)). private const int MaxTitleCodePoints = 140; // Лимит блока «О заявке» (python L458: clean_block(summary, 2000)). private const int MaxSummaryCodePoints = 2000; // Лимит исходного сообщения на карточке (python L503: text[:4000]). private const int MaxSourceMsgCodePoints = 4000; // Лимит «быстрого» контакта карточки (python L471: primary_contact(contacts)[:200]). private const int MaxPrimaryContactCodePoints = 200; /// /// Собирает полный снимок новой карточки из разбора и строки сообщения (1:1 с _store_lead L433–514). /// /// /// Использует только поля-сообщения QueueItemDto (DialogId/Channel/Text/MsgId/MsgAtMs) — статус/время /// постановки строки на карточку не влияют. «Повтор по дедупу» здесь не проверяется (этап воркера, /// Ruling 8): вызывающий (PipelineCardWriter/воркер) уже заявил хэш и связал карточку после записи. /// /// Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы). /// Строка очереди с сообщением-источником (метаданные канала, текст, время). /// Готовый id карточки (c_...; генерирует PipelineCardWriter через PrefixId). /// Токен отмены. /// Полный снимок карточки для (CreatedAt проставит хранилище). public async Task BuildAsync( AiParsedCardDto parsed, QueueItemDto message, string cardId, CancellationToken ct) { // «О заявке» всегда собирается из одинаковых блоков (Компания → … → Условия); структуры нет — суть // как есть либо «О задаче: …» из исходника (SummaryComposer.Compose), сверху clean_block 2000. ParsedCardContent content = ToParsedContent(parsed); string summary = MessageTextCleaner.CleanBlock(SummaryComposer.Compose(content, message.Text), MaxSummaryCodePoints); if (summary.Length == 0) { summary = MessageTextCleaner.CleanShort(message.Text, MaxSummaryCodePoints); // python L458 fallback } string title = MessageTextCleaner.CleanShort(parsed.Title, MaxTitleCodePoints); if (title.Length == 0) { title = MessageTextCleaner.CleanShort(message.Text, MaxTitleCodePoints); // python L455 fallback } IReadOnlyList stack = MessageListNormalizer.NormalizeStack(parsed.Stack); CardBudgetDto? budget = ComposeBudget(parsed.Budget, message.Text, summary); // Доска разбора (страховка ContainerAccepts) и конверсия бюджета требуют курсы: типизированный снимок // настроек читается ОДИН раз на карточку (C30) — мок-фолбэк при отсутствии кэша, как раньше LoadRatesAsync. string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim(); TenantSettingsSnapshot? settingsSnapshot = budget is not null || boardCandidate is not null ? await TenantSettingsSnapshot.LoadAsync(settings, ct) : null; IReadOnlyDictionary? rates = null; if (settingsSnapshot is not null) { rates = settingsSnapshot.TryGetRatesCache()?.Rates ?? MockRates.Values; } CardBudgetDto? converted = null; if (budget is not null) { bool conversionOn = settingsSnapshot!.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn); string targetCurrency = NormalizeTargetCurrency( settingsSnapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency)); converted = BudgetNormalizer.ToTarget(budget, conversionOn, targetCurrency, rates); } // Колонка и «почему карточка здесь»: страховка L449–450 (доски нет/не прошла правила → inbox). string col = CardIds.Inbox; IReadOnlyList matchHits = Array.Empty(); if (boardCandidate is not null) { ContainerDto? board = await kanjStore.GetContainerAsync(boardCandidate, ct); if (board is not null && ColumnRules.ContainerAccepts(board.Rules, message.Text, rates)) { col = board.Id; matchHits = ColumnRules.ComputeHits(board.Rules, message.Text, rates); } } IReadOnlyList contacts = ContactsQualifier.Build( parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text); string contact = MessageTextCleaner.SliceCodePoints(ContactsQualifier.Primary(contacts), MaxPrimaryContactCodePoints); string hue = string.IsNullOrWhiteSpace(message.Channel.Hue) ? SourceDefaults.DefaultHue : message.Channel.Hue; DateTimeOffset receivedAt = message.MsgAtMs != 0 ? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs) : DateTimeOffset.UtcNow; // python row.get("msg_at") or now (L501) return new CardSnapshot { Id = cardId, Col = col, IsNew = true, // создание — точка «новое» (Ruling 2) IsVacancy = parsed.IsVacancy, IsVacancyKnown = parsed.IsVacancyKnown, Title = title, Summary = summary, Stack = stack, BudgetFrom = budget?.From, BudgetTo = budget?.To, BudgetCur = budget?.Cur ?? string.Empty, ConvFrom = converted?.From, ConvTo = converted?.To, ConvCur = converted?.Cur ?? string.Empty, Contact = contact, Contacts = contacts, ChannelName = message.Channel.Name, ChannelHandle = message.Channel.Handle, ChannelHue = hue, ReceivedAt = receivedAt, SourceMsg = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints), SourceDialogId = message.DialogId, SourceMsgId = message.MsgId, PrevCol = CardIds.Inbox, ArchivedAt = null, MatchHits = matchHits, }; } // Бюджет карточки: нормализация из разбора, иначе fallback первой суммы по исходнику/«О заявке» // (python L453/L459–468; формы хранения — CardBudgetDto). // parsedBudget: Бюджет разбора (форма контракта; null — разбор не выделил сумму). // text: Текст исходного сообщения (первый источник fallback). // summary: Блок «О заявке» (второй источник fallback — сумма часто уходит в «Условия»). // Возвращает: Нормализованный бюджет либо null — суммы с валютой нет ни в разборе, ни в тексте. private static CardBudgetDto? ComposeBudget(AiBudgetDto? parsedBudget, string? text, string summary) { if (parsedBudget is not null) { return BudgetNormalizer.Normalize(new BudgetRangeDto(parsedBudget.From, parsedBudget.To, parsedBudget.Cur)); } BudgetRangeDto? fallback = AmountRangeBudgetFallback.Extract(text, summary); return fallback is null ? null : BudgetNormalizer.Normalize(fallback); } // Маппинг разбора классификатора в структуру блока «О заявке» (поля 1:1 с cardPrompt L116–123). // parsed: Разбор (пустые/отсутствующие поля блок не дают — SummaryComposer). // Возвращает: Структура для SummaryComposer.Compose. private static ParsedCardContent ToParsedContent(AiParsedCardDto parsed) => new( Company: parsed.Company, Format: parsed.Format, Task: parsed.Task, Requirements: parsed.Requirements, Plus: parsed.Plus, Conditions: parsed.Conditions, Summary: parsed.Summary); // Код целевой валюты для конверсии: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH). // value: Значение настройки targetCurrency (JSON-строка). // Возвращает: Код валюты (RUB/USD/…) либо дефолт. private static string NormalizeTargetCurrency(string value) { string currency = value.Trim().ToUpperInvariant(); return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency; } }