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;
}
}