Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user