Files
Deal/src/core/Deal.Modules.Kanban/Application/ColumnRules/AmountParser.cs
T
Rustam Khalimov 492950bdd0 Отформатировать списки параметров по код-стайлу
Больше двух параметров — каждый на отдельной строке (закрывающая
скобка в конце последнего); два и меньше — в одну строку. Правило
добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам
в 253 файлах.
2026-09-11 13:22:56 +03:00

226 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Text.RegularExpressions;
namespace Deal.Modules.Kanban.Application.ColumnRules;
/// <summary>
/// Парсер сумм из текста сообщения (rules.py extract_amounts L93144, _norm_amount L6273, _cur_from_tail L7690).
/// </summary>
/// <remarks>
/// Сохраняет смысл суммы: «от A до B»/«A–B» → from/to, «до B» → только верхняя граница, одна сумма/«от A»
/// → from=to. Валюту ищем сразу после суммы (символ «$ € ₽ ₮ £ ¥» или слово «usd eur rub … руб долл бакс»)
/// либо перед ней для «$1 200»; суффикс «к/К» в числе — тысячи («2к» → 2000, «1.5к$» → 1500 USD).
/// Суммы БЕЗ валюты игнорируются (прототип L98); повторное срабатывание конструкций на одном тексте
/// отсекается занятыми диапазонами (_free/_add L104112) — порядок проходов 1:1 с прототипом.
/// Семантика 1:1, включая особенности: «usdt» словом после числа распознаётся как USD (первым срабатывает
/// startswith «usd», rules.py L82), «евро»/«рубли» словами не распознаются (в _CUR_WORDS их нет).
/// </remarks>
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);
/// <summary>
/// Разбирает суммы в тексте (rules.py extract_amounts L93144).
/// </summary>
/// <param name="text">Текст сообщения (сырой, как сохранил источник).</param>
/// <returns>
/// Список распознанных сумм/диапазонов с кодом валюты; пустой список — сумм с валютой нет.
/// Порядок — по проходам парсера (1:1 с прототипом), а не по позиции в тексте.
/// </returns>
public static IReadOnlyList<AmountRange> Parse(string? text)
{
string t = text ?? string.Empty;
if (string.IsNullOrWhiteSpace(t))
{
return Array.Empty<AmountRange>();
}
var result = new List<AmountRange>();
var occupied = new List<(int Start, int End)>();
// Свободен ли диапазон (не пересекается с уже занятым конструкцией выше) — прототип _free L106107.
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 L6273).
// 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 L7690).
// 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;
}
}