| | | 1 | | using System.Text.RegularExpressions; |
| | | 2 | | |
| | | 3 | | namespace Services.AiAssistant; |
| | | 4 | | |
| | | 5 | | public static partial class AiOrderFilterParser |
| | | 6 | | { |
| | | 7 | | // ── Количество ──────────────────────────────────────────────────────────── |
| | | 8 | | [GeneratedRegex( |
| | | 9 | | @"(?:количеств\w*|qty)\s*(?:>|больше|более|от)\s*(\d+)", |
| | | 10 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 11 | | private static partial Regex MinQuantityRegex(); |
| | | 12 | | |
| | | 13 | | [GeneratedRegex( |
| | | 14 | | @"(\d+)\s*шт\.?\s*(?:и\s*)?(?:больше|более|>|от)", |
| | | 15 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 16 | | private static partial Regex MinQuantityPiecesRegex(); |
| | | 17 | | |
| | | 18 | | // ── Участники ───────────────────────────────────────────────────────────── |
| | | 19 | | [GeneratedRegex( |
| | | 20 | | @"(?:провел\w*|провёл|провела|проводил\w*|оформил\w*|создал\w*)\s+(?:пользовател\w+\s+)?[«""]?([^»""\n,;.]+)[»"" |
| | | 21 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 22 | | private static partial Regex TransactionUserRegex(); |
| | | 23 | | |
| | | 24 | | [GeneratedRegex( |
| | | 25 | | @"(?:ozon[-\s]?клиент\w*|клиент\w*\s+ozon)\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 26 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 27 | | private static partial Regex OzonClientRegex(); |
| | | 28 | | |
| | | 29 | | [GeneratedRegex( |
| | | 30 | | @"поставщик\w*\s+[«""]?([^»""\n,;.]+?)(?=\s+со\s+статус|\s+(?:внутренн\w+\s+)?статус|\s+склад|\s+артикул|\s+това |
| | | 31 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 32 | | private static partial Regex SupplierRegex(); |
| | | 33 | | |
| | | 34 | | [GeneratedRegex( |
| | | 35 | | @"(?:изменил\w*|менял\w*|редактировал\w*|менял\w*)\s+(?:пользовател\w+\s+)?[«""]?([^»""\n,;.]{2,50})[»""]?", |
| | | 36 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 37 | | private static partial Regex ChangedByUserRegex(); |
| | | 38 | | |
| | | 39 | | // ── Статусы ─────────────────────────────────────────────────────────────── |
| | | 40 | | [GeneratedRegex( |
| | | 41 | | @"со\s+статусом\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 42 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 43 | | private static partial Regex CoStatusRegex(); |
| | | 44 | | |
| | | 45 | | [GeneratedRegex( |
| | | 46 | | @"(?:внутренн\w+\s+)?статус\w*\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 47 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 48 | | private static partial Regex AppStatusRegex(); |
| | | 49 | | |
| | | 50 | | [GeneratedRegex( |
| | | 51 | | @"(?:(?:клиентский|клиент)\s+)?статус\s+(?:ozon|озон)\s+[«""]?([^»""\n,;.]+)[»""]?|(?:статус\s+ozon|статус\s+озо |
| | | 52 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 53 | | private static partial Regex ClientStatusRegex(); |
| | | 54 | | |
| | | 55 | | // ── Товар ───────────────────────────────────────────────────────────────── |
| | | 56 | | [GeneratedRegex( |
| | | 57 | | @"(?:артикул\w*|артикл\w*)\s+[«""]?([^\s»""\n,;.]+)[»""]?", |
| | | 58 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 59 | | private static partial Regex ArticleRegex(); |
| | | 60 | | |
| | | 61 | | [GeneratedRegex( |
| | | 62 | | @"(?:товар\w*|наименован\w*|продукт\w*)\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 63 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 64 | | private static partial Regex ProductNameRegex(); |
| | | 65 | | |
| | | 66 | | [GeneratedRegex( |
| | | 67 | | @"(?:производител\w*|бренд\w*)\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 68 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 69 | | private static partial Regex ManufacturerRegex(); |
| | | 70 | | |
| | | 71 | | [GeneratedRegex( |
| | | 72 | | @"(?:категори\w*)\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 73 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 74 | | private static partial Regex CategoryRegex(); |
| | | 75 | | |
| | | 76 | | // ── Логистика ───────────────────────────────────────────────────────────── |
| | | 77 | | [GeneratedRegex( |
| | | 78 | | @"на\s+клиент\w*\s+[«""]?([^»""\n,;.]+?)(?=\s+за\s+|\s+со\s+статус|\s+котор|\s+минус|$|[»""])", |
| | | 79 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 80 | | private static partial Regex NaKlienteRegex(); |
| | | 81 | | |
| | | 82 | | [GeneratedRegex( |
| | | 83 | | @"по\s+склад\w*\s+[«""]?([^\s»""\n,;.]+)[»""]?", |
| | | 84 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 85 | | private static partial Regex PoSkladuRegex(); |
| | | 86 | | |
| | | 87 | | [GeneratedRegex( |
| | | 88 | | @"(?:склад\w*)\s+[«""]?([^»""\n,;.]+?)(?=\s+на\s+клиент|\s+у\s+клиент|\s+за\s+|\s+со\s+статус|\s+котор|\s+минус| |
| | | 89 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 90 | | private static partial Regex WarehouseRegex(); |
| | | 91 | | |
| | | 92 | | [GeneratedRegex( |
| | | 93 | | @"(?:провайдер\w*|служб\w+\s+доставки)\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 94 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 95 | | private static partial Regex DeliveryProviderRegex(); |
| | | 96 | | |
| | | 97 | | [GeneratedRegex( |
| | | 98 | | @"(?:город\w*|доставк\w+\s+в|в\s+город\w*)\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 99 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 100 | | private static partial Regex DeliveryCityRegex(); |
| | | 101 | | |
| | | 102 | | [GeneratedRegex( |
| | | 103 | | @"(?:тип\s+доставки|доставк\w+\s+типа?)\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 104 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 105 | | private static partial Regex DeliveryNameRegex(); |
| | | 106 | | |
| | | 107 | | // ── Финансы ─────────────────────────────────────────────────────────────── |
| | | 108 | | [GeneratedRegex( |
| | | 109 | | @"цен\w*\s+(?:от|>=|>|больше|более)\s*([\d\s]+)(?:\s*(?:₽|руб\w*|р\.))?", |
| | | 110 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 111 | | private static partial Regex PriceMinRegex(); |
| | | 112 | | |
| | | 113 | | [GeneratedRegex( |
| | | 114 | | @"цен\w*\s+(?:до|<=|<|меньше|менее)\s*([\d\s]+)(?:\s*(?:₽|руб\w*|р\.))?", |
| | | 115 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 116 | | private static partial Regex PriceMaxRegex(); |
| | | 117 | | |
| | | 118 | | [GeneratedRegex( |
| | | 119 | | @"прибыл\w*\s+(?:от|>=|>|больше|более)\s*([\d\s-]+)(?:\s*(?:₽|руб\w*|р\.))?", |
| | | 120 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 121 | | private static partial Regex ProfitMinRegex(); |
| | | 122 | | |
| | | 123 | | [GeneratedRegex( |
| | | 124 | | @"прибыл\w*\s+(?:до|<=|<|меньше|менее)\s*([\d\s-]+)(?:\s*(?:₽|руб\w*|р\.))?", |
| | | 125 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 126 | | private static partial Regex ProfitMaxRegex(); |
| | | 127 | | |
| | | 128 | | [GeneratedRegex( |
| | | 129 | | @"закупк\w*\s+(?:до|<=|<|меньше|менее|не\s+дороже)\s*([\d\s]+)(?:\s*(?:₽|руб\w*|р\.))?", |
| | | 130 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 131 | | private static partial Regex PurchasePriceMaxRegex(); |
| | | 132 | | |
| | | 133 | | // ── Даты ───────────────────────────────────────────────────────────────── |
| | | 134 | | [GeneratedRegex( |
| | | 135 | | @"за\s+последни\w+\s+(\d+)\s+месяц\w*", |
| | | 136 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 137 | | private static partial Regex LastMonthsRegex(); |
| | | 138 | | |
| | | 139 | | [GeneratedRegex( |
| | | 140 | | @"за\s+последний\s+месяц", |
| | | 141 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 142 | | private static partial Regex LastOneMonthRegex(); |
| | | 143 | | |
| | | 144 | | [GeneratedRegex( |
| | | 145 | | @"за\s+месяц(?:\b|$)", |
| | | 146 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 147 | | private static partial Regex ZaMonthRegex(); |
| | | 148 | | |
| | | 149 | | [GeneratedRegex( |
| | | 150 | | @"за\s+последни\w+\s+(\d+)\s+(?:нед\w*|week)", |
| | | 151 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 152 | | private static partial Regex LastWeeksRegex(); |
| | | 153 | | |
| | | 154 | | // ── История изменений ───────────────────────────────────────────────────── |
| | | 155 | | [GeneratedRegex( |
| | | 156 | | @"(?:поле|поменял\w*|изменил\w*|сменил\w*)\s+[«""]?([^»""\n,;.]+)[»""]?\s+(?:на|стал\w*|изменил\w*\s+на)", |
| | | 157 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 158 | | private static partial Regex FieldChangedNameRegex(); |
| | | 159 | | |
| | | 160 | | [GeneratedRegex( |
| | | 161 | | @"(?:стал\w*|новое\s+значение|изменил\w*\s+на|поменял\w*\s+на)\s+[«""]?([^»""\n,;.]+)[»""]?", |
| | | 162 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 163 | | private static partial Regex FieldChangedToValueRegex(); |
| | | 164 | | |
| | | 165 | | // ═══════════════════════════════════════════════════════════════════════════ |
| | | 166 | | |
| | | 167 | | /// <summary>Regex-fallback парсер (используется если LLM недоступен или не распознал запрос).</summary> |
| | | 168 | | public static bool TryParseRegex(string message, out AiOrderFilterCriteria criteria) |
| | | 169 | | { |
| | 4 | 170 | | criteria = null!; |
| | 4 | 171 | | var trimmed = message.Trim(); |
| | 4 | 172 | | var lower = trimmed.ToLowerInvariant(); |
| | | 173 | | |
| | 4 | 174 | | if (!IsOrderSearchMessage(lower)) |
| | 0 | 175 | | return false; |
| | | 176 | | |
| | 4 | 177 | | var actionable = ContainsAny(lower, |
| | 4 | 178 | | "актуальн", "обработать", "к обработке", "требуют обработки", |
| | 4 | 179 | | "нужно обработать", "нужно взять", "в работе", "требуют внимания"); |
| | | 180 | | |
| | 4 | 181 | | var overdue = ContainsAny(lower, "просроч"); |
| | 4 | 182 | | var emptyPrice = IsEmptyPriceIntent(lower); |
| | 4 | 183 | | var includeArchived = ContainsAny(lower, "в архиве", "включая архив", "из архива", "с архивными"); |
| | 4 | 184 | | var onlyNegProfit = ContainsAny(lower, "убыточн", "отрицательн", "прибыль < 0", "минус", "минусят", "минусуют |
| | 4 | 185 | | var onlyWithReturns = ContainsAny(lower, "с возврат", "только возврат", "есть возврат"); |
| | 4 | 186 | | var onlyWithTracking = ContainsAny(lower, "с трек", "с трекинг", "с трек-номером", "с номером отслеживания"); |
| | | 187 | | |
| | | 188 | | // ── числа ────────────────────────────────────────────────────────────── |
| | 4 | 189 | | var minQty = TryParseMinQuantity(lower); |
| | 4 | 190 | | var priceMin = TryParseDecimal(PriceMinRegex().Match(lower)); |
| | 4 | 191 | | var priceMax = TryParseDecimal(PriceMaxRegex().Match(lower)); |
| | 4 | 192 | | var profitMin = TryParseDecimal(ProfitMinRegex().Match(lower)); |
| | 4 | 193 | | var profitMax = TryParseDecimal(ProfitMaxRegex().Match(lower)); |
| | 4 | 194 | | var purchPriceMax = TryParseDecimal(PurchasePriceMaxRegex().Match(lower)); |
| | | 195 | | |
| | | 196 | | // ── даты ─────────────────────────────────────────────────────────────── |
| | 4 | 197 | | var lastMonths = TryParseLastMonths(lower); |
| | | 198 | | |
| | | 199 | | // ── строковые поля ──────────────────────────────────────────────────── |
| | 4 | 200 | | var txUser = TransactionUserRegex().Match(trimmed); |
| | 4 | 201 | | var changedBy = ChangedByUserRegex().Match(trimmed); |
| | 4 | 202 | | var ozonClient = OzonClientRegex().Match(trimmed); |
| | 4 | 203 | | var naKliente = NaKlienteRegex().Match(trimmed); |
| | 4 | 204 | | var supplier = SupplierRegex().Match(trimmed); |
| | 4 | 205 | | var coStatus = CoStatusRegex().Match(trimmed); |
| | 4 | 206 | | var appStatus = AppStatusRegex().Match(trimmed); |
| | 4 | 207 | | var clientStatus = ClientStatusRegex().Match(trimmed); |
| | 4 | 208 | | var article = ArticleRegex().Match(trimmed); |
| | 4 | 209 | | var productName = ProductNameRegex().Match(trimmed); |
| | 4 | 210 | | var manufacturer = ManufacturerRegex().Match(trimmed); |
| | 4 | 211 | | var category = CategoryRegex().Match(trimmed); |
| | 4 | 212 | | var warehouse = PoSkladuRegex().Match(trimmed); |
| | 4 | 213 | | if (!warehouse.Success) |
| | 1 | 214 | | warehouse = WarehouseRegex().Match(trimmed); |
| | 4 | 215 | | var provider = DeliveryProviderRegex().Match(trimmed); |
| | 4 | 216 | | var city = DeliveryCityRegex().Match(trimmed); |
| | 4 | 217 | | var deliveryType = DeliveryNameRegex().Match(trimmed); |
| | 4 | 218 | | var fieldName = FieldChangedNameRegex().Match(trimmed); |
| | 4 | 219 | | var fieldValue = FieldChangedToValueRegex().Match(trimmed); |
| | | 220 | | |
| | 4 | 221 | | var hasNamedFilter = txUser.Success || changedBy.Success || ozonClient.Success || naKliente.Success |
| | 4 | 222 | | || supplier.Success || coStatus.Success || appStatus.Success || clientStatus.Success || article.Success || p |
| | 4 | 223 | | || manufacturer.Success || category.Success || warehouse.Success || provider.Success |
| | 4 | 224 | | || city.Success || deliveryType.Success || fieldName.Success || fieldValue.Success |
| | 4 | 225 | | || minQty.HasValue || emptyPrice || overdue || onlyNegProfit || onlyWithReturns |
| | 4 | 226 | | || onlyWithTracking || priceMin.HasValue || priceMax.HasValue || profitMin.HasValue |
| | 4 | 227 | | || profitMax.HasValue || purchPriceMax.HasValue || lastMonths.HasValue || includeArchived; |
| | | 228 | | |
| | 4 | 229 | | if (!actionable && !hasNamedFilter && !IsGenericFindOrders(lower)) |
| | 0 | 230 | | return false; |
| | | 231 | | |
| | 4 | 232 | | if (!actionable && !hasNamedFilter && IsGenericFindOrders(lower)) |
| | 0 | 233 | | actionable = true; |
| | | 234 | | |
| | 4 | 235 | | var supplierName = supplier.Success ? TrimSupplierName(CleanCaptured(supplier.Groups[1].Value)) : null; |
| | 4 | 236 | | var warehouseName = warehouse.Success ? TrimWarehouseName(CleanCaptured(warehouse.Groups[1].Value)) : null; |
| | 4 | 237 | | var ozonClientName = naKliente.Success |
| | 4 | 238 | | ? CleanCaptured(naKliente.Groups[1].Value) |
| | 4 | 239 | | : ozonClient.Success ? CleanCaptured(ozonClient.Groups[1].Value) : null; |
| | 4 | 240 | | (warehouseName, ozonClientName) = SplitWarehouseAndOzonClient(warehouseName, ozonClientName); |
| | 4 | 241 | | var appStatusName = coStatus.Success |
| | 4 | 242 | | ? CleanCaptured(coStatus.Groups[1].Value) |
| | 4 | 243 | | : appStatus.Success ? CleanCaptured(appStatus.Groups[1].Value) : null; |
| | | 244 | | |
| | 4 | 245 | | if (IsCancelledAppStatus(appStatusName)) |
| | | 246 | | { |
| | 1 | 247 | | includeArchived = true; |
| | 1 | 248 | | actionable = false; |
| | | 249 | | } |
| | | 250 | | |
| | 4 | 251 | | criteria = new AiOrderFilterCriteria |
| | 4 | 252 | | { |
| | 4 | 253 | | ActionableOnly = actionable, |
| | 4 | 254 | | OverdueOnly = overdue, |
| | 4 | 255 | | EmptyPrice = emptyPrice, |
| | 4 | 256 | | IncludeArchived = includeArchived, |
| | 4 | 257 | | OnlyNegativeProfit = onlyNegProfit ? true : null, |
| | 4 | 258 | | OnlyWithReturns = onlyWithReturns ? true : null, |
| | 4 | 259 | | OnlyWithTrackingNumber = onlyWithTracking ? true : null, |
| | 4 | 260 | | |
| | 4 | 261 | | MinQuantity = minQty, |
| | 4 | 262 | | PriceMin = priceMin, |
| | 4 | 263 | | PriceMax = priceMax, |
| | 4 | 264 | | ProfitMin = profitMin, |
| | 4 | 265 | | ProfitMax = profitMax, |
| | 4 | 266 | | PurchasePriceMax = purchPriceMax, |
| | 4 | 267 | | LastMonths = lastMonths, |
| | 4 | 268 | | |
| | 4 | 269 | | TransactionConductedBy = txUser.Success ? CleanCaptured(txUser.Groups[1].Value) : null, |
| | 4 | 270 | | ChangedByUser = changedBy.Success ? CleanCaptured(changedBy.Groups[1].Value) : null, |
| | 4 | 271 | | OzonClientName = ozonClientName, |
| | 4 | 272 | | SupplierName = supplierName, |
| | 4 | 273 | | AppStatusName = appStatusName, |
| | 4 | 274 | | ClientStatusName = clientStatus.Success ? CleanCaptured(clientStatus.Groups[1].Success ? clientStatus |
| | 4 | 275 | | Article = article.Success ? CleanCaptured(article.Groups[1].Value) : null, |
| | 4 | 276 | | ProductName = productName.Success? CleanCaptured(productName.Groups[1].Value): null, |
| | 4 | 277 | | ManufacturerName = manufacturer.Success ? CleanCaptured(manufacturer.Groups[1].Value) : null, |
| | 4 | 278 | | NewCategory = category.Success ? CleanCaptured(category.Groups[1].Value) : null, |
| | 4 | 279 | | ShipmentWarehouseName = warehouseName, |
| | 4 | 280 | | DeliveryProviderName = provider.Success ? CleanCaptured(provider.Groups[1].Value) : null, |
| | 4 | 281 | | DeliveryCity = city.Success ? CleanCaptured(city.Groups[1].Value) : null, |
| | 4 | 282 | | DeliveryName = deliveryType.Success ? CleanCaptured(deliveryType.Groups[1].Value) : null, |
| | 4 | 283 | | FieldChangedName = fieldName.Success ? CleanCaptured(fieldName.Groups[1].Value) : null, |
| | 4 | 284 | | FieldChangedToValue = fieldValue.Success ? CleanCaptured(fieldValue.Groups[1].Value): null, |
| | 4 | 285 | | }; |
| | | 286 | | |
| | 4 | 287 | | return criteria.HasCriteria; |
| | | 288 | | } |
| | | 289 | | |
| | | 290 | | /// <summary>Запрос на просмотр/поиск заказов, не write-операция.</summary> |
| | | 291 | | public static bool IsReadOnlyOrderSearchIntent(string lower) |
| | | 292 | | { |
| | 27 | 293 | | if (!ContainsOrderSearchWord(lower)) |
| | 20 | 294 | | return false; |
| | | 295 | | |
| | 7 | 296 | | return ContainsAny(lower, "покажи", "показать", "найди", "найти", "список", "какие", |
| | 7 | 297 | | "выведи", "отбери", "подбери", "выбери", "ищи", "поиск", "отфильтр", "фильтр"); |
| | | 298 | | } |
| | | 299 | | |
| | | 300 | | /// <summary>Быстрая эвристика: запрос похож на orders_filter (без LLM).</summary> |
| | | 301 | | public static bool LooksLikeOrdersFilterQuery(string message) |
| | | 302 | | { |
| | 0 | 303 | | var lower = message.Trim().ToLowerInvariant(); |
| | 0 | 304 | | if (!IsOrderSearchMessage(lower)) |
| | 0 | 305 | | return false; |
| | | 306 | | |
| | 0 | 307 | | if (TryParseRegex(message, out var criteria) && criteria.HasCriteria) |
| | 0 | 308 | | return true; |
| | | 309 | | |
| | 0 | 310 | | if (!IsReadOnlyOrderSearchIntent(lower)) |
| | 0 | 311 | | return false; |
| | | 312 | | |
| | 0 | 313 | | return ContainsAny(lower, |
| | 0 | 314 | | "убыточ", "невыгод", "минус", "просроч", "поставщик", "supplier", |
| | 0 | 315 | | "склад", "клиент", "ozon", "статус", "артикул", "товар", |
| | 0 | 316 | | "за месяц", "за последн", "без цен", "возврат", "трек"); |
| | | 317 | | } |
| | | 318 | | |
| | | 319 | | /// <summary>«заказ» в поиске, но не «заказан поставщику» (тип транзакции).</summary> |
| | | 320 | | private static bool ContainsOrderSearchWord(string lower) |
| | | 321 | | { |
| | 27 | 322 | | if (lower.Contains("order")) |
| | 0 | 323 | | return true; |
| | 27 | 324 | | if (!lower.Contains("заказ")) |
| | 17 | 325 | | return false; |
| | 10 | 326 | | if (lower.Contains("заказан поставщик") || lower.Contains("заказан реализатор")) |
| | 3 | 327 | | return false; |
| | 7 | 328 | | return true; |
| | | 329 | | } |
| | | 330 | | |
| | | 331 | | /// <summary>Нормализует критерии после LLM/regex (отменённые статусы, поставщик).</summary> |
| | | 332 | | public static AiOrderFilterCriteria NormalizeCriteria(AiOrderFilterCriteria criteria) |
| | | 333 | | { |
| | 12 | 334 | | var supplier = TrimSupplierName(criteria.SupplierName); |
| | 12 | 335 | | var (warehouse, ozonClient) = SplitWarehouseAndOzonClient( |
| | 12 | 336 | | TrimWarehouseName(criteria.ShipmentWarehouseName), |
| | 12 | 337 | | criteria.OzonClientName); |
| | 12 | 338 | | var includeArchived = criteria.IncludeArchived; |
| | 12 | 339 | | var actionable = criteria.ActionableOnly; |
| | | 340 | | |
| | 12 | 341 | | if (IsCancelledAppStatus(criteria.AppStatusName)) |
| | | 342 | | { |
| | 1 | 343 | | includeArchived = true; |
| | 1 | 344 | | actionable = false; |
| | | 345 | | } |
| | | 346 | | |
| | 12 | 347 | | if (supplier == criteria.SupplierName |
| | 12 | 348 | | && warehouse == criteria.ShipmentWarehouseName |
| | 12 | 349 | | && ozonClient == criteria.OzonClientName |
| | 12 | 350 | | && includeArchived == criteria.IncludeArchived |
| | 12 | 351 | | && actionable == criteria.ActionableOnly) |
| | 9 | 352 | | return criteria; |
| | | 353 | | |
| | 3 | 354 | | return new AiOrderFilterCriteria |
| | 3 | 355 | | { |
| | 3 | 356 | | ActionableOnly = actionable, |
| | 3 | 357 | | OverdueOnly = criteria.OverdueOnly, |
| | 3 | 358 | | EmptyPrice = criteria.EmptyPrice, |
| | 3 | 359 | | IncludeArchived = includeArchived, |
| | 3 | 360 | | OzonClientName = ozonClient, |
| | 3 | 361 | | SupplierName = supplier, |
| | 3 | 362 | | TransactionConductedBy = criteria.TransactionConductedBy, |
| | 3 | 363 | | ChangedByUser = criteria.ChangedByUser, |
| | 3 | 364 | | AppStatusName = criteria.AppStatusName, |
| | 3 | 365 | | ClientStatusName = criteria.ClientStatusName, |
| | 3 | 366 | | OnlyWithReturns = criteria.OnlyWithReturns, |
| | 3 | 367 | | Article = criteria.Article, |
| | 3 | 368 | | ProductName = criteria.ProductName, |
| | 3 | 369 | | ManufacturerName = criteria.ManufacturerName, |
| | 3 | 370 | | NewCategory = criteria.NewCategory, |
| | 3 | 371 | | MinQuantity = criteria.MinQuantity, |
| | 3 | 372 | | MaxQuantity = criteria.MaxQuantity, |
| | 3 | 373 | | PriceMin = criteria.PriceMin, |
| | 3 | 374 | | PriceMax = criteria.PriceMax, |
| | 3 | 375 | | ProfitMin = criteria.ProfitMin, |
| | 3 | 376 | | ProfitMax = criteria.ProfitMax, |
| | 3 | 377 | | OnlyNegativeProfit = criteria.OnlyNegativeProfit, |
| | 3 | 378 | | PurchasePriceMax = criteria.PurchasePriceMax, |
| | 3 | 379 | | ShipmentWarehouseName = warehouse, |
| | 3 | 380 | | DeliveryProviderName = criteria.DeliveryProviderName, |
| | 3 | 381 | | DeliveryName = criteria.DeliveryName, |
| | 3 | 382 | | DeliveryCity = criteria.DeliveryCity, |
| | 3 | 383 | | OnlyWithTrackingNumber = criteria.OnlyWithTrackingNumber, |
| | 3 | 384 | | ShippingDateFrom = criteria.ShippingDateFrom, |
| | 3 | 385 | | ShippingDateTo = criteria.ShippingDateTo, |
| | 3 | 386 | | ProcessingDateFrom = criteria.ProcessingDateFrom, |
| | 3 | 387 | | ProcessingDateTo = criteria.ProcessingDateTo, |
| | 3 | 388 | | LastMonths = criteria.LastMonths, |
| | 3 | 389 | | ChangedFrom = criteria.ChangedFrom, |
| | 3 | 390 | | ChangedTo = criteria.ChangedTo, |
| | 3 | 391 | | FieldChangedName = criteria.FieldChangedName, |
| | 3 | 392 | | FieldChangedToValue = criteria.FieldChangedToValue, |
| | 3 | 393 | | }; |
| | | 394 | | } |
| | | 395 | | |
| | | 396 | | internal static bool IsCancelledAppStatus(string? statusName) => |
| | 16 | 397 | | !string.IsNullOrWhiteSpace(statusName) |
| | 16 | 398 | | && statusName.Contains("отмен", StringComparison.OrdinalIgnoreCase); |
| | | 399 | | |
| | | 400 | | private static string? TrimWarehouseName(string? value) |
| | | 401 | | { |
| | 15 | 402 | | if (string.IsNullOrWhiteSpace(value)) |
| | 9 | 403 | | return null; |
| | | 404 | | |
| | 6 | 405 | | var s = value.Trim(); |
| | 72 | 406 | | foreach (var stop in new[] { " на клиенте", " у клиента", " на клиент", " за месяц", " за последн" }) |
| | | 407 | | { |
| | 30 | 408 | | var idx = s.IndexOf(stop, StringComparison.OrdinalIgnoreCase); |
| | 30 | 409 | | if (idx > 0) |
| | 1 | 410 | | s = s[..idx].Trim(); |
| | | 411 | | } |
| | | 412 | | |
| | 6 | 413 | | return string.IsNullOrWhiteSpace(s) ? null : s; |
| | | 414 | | } |
| | | 415 | | |
| | | 416 | | /// <summary>LLM иногда склеивает «склад M2 на клиенте Interparts» в одно поле.</summary> |
| | | 417 | | internal static (string? Warehouse, string? OzonClient) SplitWarehouseAndOzonClient( |
| | | 418 | | string? warehouse, |
| | | 419 | | string? ozonClient) |
| | | 420 | | { |
| | 21 | 421 | | if (string.IsNullOrWhiteSpace(warehouse)) |
| | 10 | 422 | | return (warehouse, ozonClient); |
| | | 423 | | |
| | 11 | 424 | | var markers = new[] { " на клиенте ", " у клиента ", " на клиент " }; |
| | 78 | 425 | | foreach (var marker in markers) |
| | | 426 | | { |
| | 29 | 427 | | var idx = warehouse.IndexOf(marker, StringComparison.OrdinalIgnoreCase); |
| | 29 | 428 | | if (idx <= 0) |
| | | 429 | | continue; |
| | | 430 | | |
| | 2 | 431 | | var tail = warehouse[(idx + marker.Length)..].Trim(); |
| | 2 | 432 | | warehouse = warehouse[..idx].Trim(); |
| | 2 | 433 | | if (string.IsNullOrWhiteSpace(ozonClient) && !string.IsNullOrWhiteSpace(tail)) |
| | 2 | 434 | | ozonClient = tail; |
| | 2 | 435 | | break; |
| | | 436 | | } |
| | | 437 | | |
| | 11 | 438 | | return (warehouse, ozonClient); |
| | | 439 | | } |
| | | 440 | | |
| | | 441 | | private static string? TrimSupplierName(string? value) |
| | | 442 | | { |
| | 13 | 443 | | if (string.IsNullOrWhiteSpace(value)) |
| | 10 | 444 | | return null; |
| | | 445 | | |
| | 3 | 446 | | var s = value.Trim(); |
| | 30 | 447 | | foreach (var stop in new[] { " со статус", " статус", " склад", " артикул" }) |
| | | 448 | | { |
| | 12 | 449 | | var idx = s.IndexOf(stop, StringComparison.OrdinalIgnoreCase); |
| | 12 | 450 | | if (idx > 0) |
| | 1 | 451 | | s = s[..idx].Trim(); |
| | | 452 | | } |
| | | 453 | | |
| | 3 | 454 | | return string.IsNullOrWhiteSpace(s) ? null : s; |
| | | 455 | | } |
| | | 456 | | |
| | | 457 | | private static bool IsOrderSearchMessage(string lower) => |
| | 4 | 458 | | lower.Contains("заказ") || lower.Contains("order"); |
| | | 459 | | |
| | | 460 | | private static bool IsGenericFindOrders(string lower) => |
| | 0 | 461 | | ContainsAny(lower, "найди", "найти", "покажи", "показать", "список", "какие", |
| | 0 | 462 | | "выведи", "отбери", "подбери", "выбери", "ищи", "поиск"); |
| | | 463 | | |
| | | 464 | | private static bool IsEmptyPriceIntent(string lower) => |
| | 4 | 465 | | (ContainsAny(lower, "пуст", "без", "нет", "не заполнен", "не указан", "null") |
| | 4 | 466 | | && lower.Contains("цен")) |
| | 4 | 467 | | || lower.Contains("без цены") |
| | 4 | 468 | | || lower.Contains("без цен"); |
| | | 469 | | |
| | | 470 | | private static int? TryParseMinQuantity(string lower) |
| | | 471 | | { |
| | 24 | 472 | | foreach (var rx in new[] { MinQuantityRegex(), MinQuantityPiecesRegex() }) |
| | | 473 | | { |
| | 8 | 474 | | var m = rx.Match(lower); |
| | 8 | 475 | | if (m.Success && int.TryParse(m.Groups[1].Value, out var n) && n > 0) |
| | 0 | 476 | | return n; |
| | | 477 | | } |
| | 4 | 478 | | return null; |
| | | 479 | | } |
| | | 480 | | |
| | | 481 | | private static decimal? TryParseDecimal(Match m) |
| | | 482 | | { |
| | 40 | 483 | | if (!m.Success) return null; |
| | 0 | 484 | | var raw = m.Groups[1].Value.Replace(" ", "").Trim(); |
| | 0 | 485 | | return decimal.TryParse(raw, System.Globalization.NumberStyles.Number, |
| | 0 | 486 | | System.Globalization.CultureInfo.InvariantCulture, out var v) |
| | 0 | 487 | | ? v |
| | 0 | 488 | | : null; |
| | | 489 | | } |
| | | 490 | | |
| | | 491 | | private static int? TryParseLastMonths(string lower) |
| | | 492 | | { |
| | 4 | 493 | | var m = LastMonthsRegex().Match(lower); |
| | 4 | 494 | | if (m.Success && int.TryParse(m.Groups[1].Value, out var n)) |
| | 0 | 495 | | return n; |
| | 4 | 496 | | if (LastOneMonthRegex().IsMatch(lower) || ZaMonthRegex().IsMatch(lower)) |
| | 3 | 497 | | return 1; |
| | 1 | 498 | | var w = LastWeeksRegex().Match(lower); |
| | | 499 | | // конвертируем недели в месяцы (округляем вверх) |
| | 1 | 500 | | if (w.Success && int.TryParse(w.Groups[1].Value, out var weeks)) |
| | 0 | 501 | | return Math.Max(1, (int)Math.Ceiling(weeks / 4.0)); |
| | 1 | 502 | | return null; |
| | | 503 | | } |
| | | 504 | | |
| | | 505 | | // Temporal boundary words — stop capturing name before these |
| | | 506 | | [GeneratedRegex(@"\s+(?:за\s+последн\w*|за\s+прошл\w*|с\s+\d|по\s+\d|\d{2}\.\d{2}).+$", |
| | | 507 | | RegexOptions.IgnoreCase | RegexOptions.Compiled)] |
| | | 508 | | private static partial Regex TemporalSuffixRegex(); |
| | | 509 | | |
| | | 510 | | private static string? CleanCaptured(string value) |
| | | 511 | | { |
| | 8 | 512 | | var s = value.Trim(); |
| | | 513 | | // Strip trailing temporal phrases like "за последние 3 месяца", "за прошлый месяц" |
| | 8 | 514 | | s = TemporalSuffixRegex().Replace(s, "").Trim(); |
| | 8 | 515 | | s = s.TrimEnd('?', '!', '.', ',', ';').Trim(); |
| | 8 | 516 | | return string.IsNullOrWhiteSpace(s) ? null : s; |
| | | 517 | | } |
| | | 518 | | |
| | | 519 | | private static bool ContainsAny(string haystack, params string[] needles) |
| | | 520 | | { |
| | 413 | 521 | | foreach (var n in needles) |
| | | 522 | | { |
| | 175 | 523 | | if (haystack.Contains(n, StringComparison.Ordinal)) |
| | 7 | 524 | | return true; |
| | | 525 | | } |
| | 28 | 526 | | return false; |
| | | 527 | | } |
| | | 528 | | } |