using System.Globalization; using System.Text.RegularExpressions; namespace Deal.Modules.Kanban.Application.ColumnRules; /// /// Парсер сумм из текста сообщения (rules.py extract_amounts L93–144, _norm_amount L62–73, _cur_from_tail L76–90). /// /// /// Сохраняет смысл суммы: «от A до B»/«A–B» → from/to, «до B» → только верхняя граница, одна сумма/«от A» /// → from=to. Валюту ищем сразу после суммы (символ «$ € ₽ ₮ £ ¥» или слово «usd eur rub … руб долл бакс») /// либо перед ней для «$1 200»; суффикс «к/К» в числе — тысячи («2к» → 2000, «1.5к$» → 1500 USD). /// Суммы БЕЗ валюты игнорируются (прототип L98); повторное срабатывание конструкций на одном тексте /// отсекается занятыми диапазонами (_free/_add L104–112) — порядок проходов 1:1 с прототипом. /// Семантика 1:1, включая особенности: «usdt» словом после числа распознаётся как USD (первым срабатывает /// startswith «usd», rules.py L82), «евро»/«рубли» словами не распознаются (в _CUR_WORDS их нет). /// public static class AmountParser { // Цифры суммы: число с разделителями тысяч и опциональным суффиксом «к/К» (rules.py _AMT L58). private const string Amount = @"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?"; // Символы валют сразу после суммы (rules.py _CUR_SYMBOLS L15, порядок 1:1). private static readonly (string Symbol, string Code)[] CurrencySymbols = { ("$", "USD"), ("€", "EUR"), ("₽", "RUB"), ("₮", "USDT"), ("£", "GBP"), ("¥", "CNY"), }; // Слова валют сразу после суммы (rules.py _CUR_WORDS L16, порядок 1:1 — важен для «usdt»). private static readonly (string Word, string Code)[] CurrencyWords = { ("usd", "USD"), ("eur", "EUR"), ("rub", "RUB"), ("usdt", "USDT"), ("gbp", "GBP"), ("cny", "CNY"), ("руб", "RUB"), ("долл", "USD"), ("бакс", "USD"), }; // Проход 1: словесный диапазон «от A до B» (rules.py L115). private static readonly Regex WordRangeRe = new(@"\bот\s+(" + Amount + @")\s+до\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); // Проход 2: «до B» без пары «от…» — только верхняя граница (rules.py L121). private static readonly Regex ToOnlyRe = new(@"\bдо\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); // Проход 3: «от A» без верхней границы — считаем одной суммой (rules.py L127). private static readonly Regex FromOnlyRe = new(@"\bот\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); // Проход 4: числовой диапазон «A–B» / «A - B» / «A до B» (rules.py L133). private static readonly Regex NumericRangeRe = new("(" + Amount + @")\s*(?:[-–—]|\s+до\s+)\s*(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); // Проход 5: одиночные суммы с валютой (rules.py L139). private static readonly Regex AnyAmountRe = new(Amount, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); /// /// Разбирает суммы в тексте (rules.py extract_amounts L93–144). /// /// Текст сообщения (сырой, как сохранил источник). /// /// Список распознанных сумм/диапазонов с кодом валюты; пустой список — сумм с валютой нет. /// Порядок — по проходам парсера (1:1 с прототипом), а не по позиции в тексте. /// public static IReadOnlyList Parse(string? text) { string t = text ?? string.Empty; if (string.IsNullOrWhiteSpace(t)) { return Array.Empty(); } var result = new List(); var occupied = new List<(int Start, int End)>(); // Свободен ли диапазон (не пересекается с уже занятым конструкцией выше) — прототип _free L106–107. static bool IsFree( List<(int Start, int End)> spans, int start, int end) { foreach ((int os, int oe) in spans) { if (start < oe && end > os) { return false; } } return true; } void Add( double? from, double? to, string? cur, int start, int end) { if (cur is not null && (from is not null || to is not null) && IsFree(occupied, start, end)) { result.Add(new AmountRange(from, to, cur)); occupied.Add((start, end)); } } // 1) «от A до B» (+ валюта после B). foreach (Match m in WordRangeRe.Matches(t)) { double? a = NormalizeAmount(m.Groups[1].Value); double? b = NormalizeAmount(m.Groups[2].Value); string? cur = CurFromTail(t, m.Groups[2].Index + m.Groups[2].Length); if (a is not null && b is not null && cur is not null) { Add(a, b, cur, m.Groups[1].Index, m.Groups[2].Index + m.Groups[2].Length); } } // 2) «до B» (без пары «от … до») — верхняя граница. foreach (Match m in ToOnlyRe.Matches(t)) { double? b = NormalizeAmount(m.Groups[1].Value); string? cur = CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length); if (b is not null && cur is not null) { Add(null, b, cur, m.Groups[1].Index, m.Groups[1].Index + m.Groups[1].Length); } } // 3) «от A» без верхней границы — считаем одной суммой. foreach (Match m in FromOnlyRe.Matches(t)) { double? a = NormalizeAmount(m.Groups[1].Value); string? cur = CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length); if (a is not null && cur is not null) { Add(a, a, cur, m.Groups[1].Index, m.Groups[1].Index + m.Groups[1].Length); } } // 4) Числовые диапазоны «A–B» / «A - B» / «A до B». foreach (Match m in NumericRangeRe.Matches(t)) { double? a = NormalizeAmount(m.Groups[1].Value); double? b = NormalizeAmount(m.Groups[2].Value); string? cur = CurFromTail(t, m.Groups[2].Index + m.Groups[2].Length) ?? CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length); if (a is not null && b is not null && cur is not null) { Add(a, b, cur, m.Groups[1].Index, m.Groups[2].Index + m.Groups[2].Length); } } // 5) Одиночные суммы с валютой (не вошедшие в конструкции выше). foreach (Match m in AnyAmountRe.Matches(t)) { double? a = NormalizeAmount(m.Value); string? cur = CurFromTail(t, m.Index + m.Length); if (a is not null && cur is not null) { Add(a, a, cur, m.Index, m.Index + m.Length); } } return result; } // Число из строки суммы: пробелы и неразрывные пробелы убираем, «,» — десятичный разделитель, // суффикс «к/К» — множитель 1000 (прототип _norm_amount L62–73). // raw: Сырая цифровая часть из regex (может содержать «к»/«К» на конце). // Возвращает: Значение суммы или null при ошибке парсинга. private static double? NormalizeAmount(string raw) { string s = raw.Replace("\u00a0", " ").Replace(" ", string.Empty).Replace(",", "."); double multiplier = 1.0; if (s.Length > 0) { char last = s[^1]; if (char.ToLowerInvariant(last) == 'k' || char.ToLowerInvariant(last) == 'к') { multiplier = 1000.0; s = s[..^1]; } } return double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) ? value * multiplier : null; } // Код валюты рядом с суммой: символ/слово сразу после позиции либо символ за ≤3 символа до неё // («$1 200», прототип _cur_from_tail L76–90). // text: Весь текст сообщения. // position: Позиция сразу после конца суммы (m.end). // Возвращает: Код валюты или null, если валюты рядом нет. private static string? CurFromTail(string text, int position) { // Окно до 12 символов после суммы (прототип: text[m_start:m_start+12].lower().strip()). int tailLength = Math.Min(12, text.Length - position); string tail = text.Substring(Math.Max(0, position), Math.Max(0, tailLength)).ToLowerInvariant().Trim(); foreach ((string symbol, string code) in CurrencySymbols) { if (tail.StartsWith(symbol, StringComparison.Ordinal)) { return code; } } foreach ((string word, string code) in CurrencyWords) { if (tail.StartsWith(word, StringComparison.Ordinal)) { return code; } } // Валюта перед числом («$1 200»): символ за ≤3 символа до конца суммы (прототип L86–89). int headStart = Math.Max(0, position - 3); string head = text.Substring(headStart, position - headStart).Trim(); foreach ((string symbol, string code) in CurrencySymbols) { if (head.EndsWith(symbol, StringComparison.Ordinal)) { return code; } } return null; } }