Больше двух параметров — каждый на отдельной строке (закрывающая скобка в конце последнего); два и меньше — в одну строку. Правило добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам в 253 файлах.
226 lines
11 KiB
C#
226 lines
11 KiB
C#
using System.Globalization;
|
||
using System.Text.RegularExpressions;
|
||
|
||
namespace Deal.Modules.Kanban.Application.ColumnRules;
|
||
|
||
/// <summary>
|
||
/// Парсер сумм из текста сообщения (rules.py extract_amounts L93–144, _norm_amount L62–73, _cur_from_tail L76–90).
|
||
/// </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 L104–112) — порядок проходов 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 L93–144).
|
||
/// </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 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;
|
||
}
|
||
}
|