< Summary

Information
Class: Services.AiAssistant.BitrixStockArticleHistoryAiFormatter
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/BitrixStockArticleHistoryAiFormatter.cs
Line coverage
99%
Covered lines: 184
Uncovered lines: 1
Coverable lines: 185
Total lines: 341
Line coverage: 99.4%
Branch coverage
98%
Covered branches: 151
Total branches: 154
Branch coverage: 98%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Format(...)100%11100%
FormatForAiAnalysis(...)100%66100%
FormatBrief(...)100%11100%
FormatNarrative(...)100%11100%
FormatNarrative(...)95%2020100%
AppendBatchSummary(...)100%66100%
AppendPriceChronology(...)100%2424100%
ExplainActionRule(...)100%3636100%
FormatNarrativeRow(...)98.21%565697.82%
ResolveBatchInfo(...)100%22100%
ShortReportId(...)75%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/AiAssistant/BitrixStockArticleHistoryAiFormatter.cs

#LineLine coverage
 1using System.Text;
 2using Services.BitrixStockServices;
 3
 4namespace Services.AiAssistant;
 5
 6/// <summary>
 7/// Текстовое резюме истории BitrixStock для промпта AI (человекочитаемые предложения + правила этапов).
 8/// </summary>
 9public static class BitrixStockArticleHistoryAiFormatter
 10{
 11    private const int DefaultMaxReports  = 5;
 12    private const int MaxRowsPerReport   = 12;
 13    private const int MaxTotalRows       = 40;
 14
 15    private const int AiMaxReports       = 20;
 16    private const int AiMaxRowsPerReport   = 15;
 17    private const int AiMaxTotalRows       = 150;
 18
 19    public static string Format(
 20        BitrixStockArticleHistoryPageDto page,
 21        string? manufacturerPrefix = null,
 22        int maxReports = DefaultMaxReports) =>
 1523        FormatNarrative(page, manufacturerPrefix, maxReports);
 24
 25    /// <summary>Полный разбор для AI: правила этапов, все отчёты, партии, хронология цен.</summary>
 26    public static string FormatForAiAnalysis(
 27        BitrixStockArticleHistoryPageDto page,
 28        string? manufacturerPrefix = null)
 29    {
 1030        if (string.IsNullOrWhiteSpace(page.Article))
 331            return "Артикул не указан.";
 32
 733        var sb = new StringBuilder();
 734        sb.AppendLine(BitrixStockSyncAiKnowledge.GetPromptSection());
 735        sb.AppendLine();
 36
 737        if (page.TotalReportCount == 0)
 38        {
 139            sb.AppendLine($"История синхронизаций Ozon по артикулу «{page.Article}»: отчётов не найдено.");
 140            return sb.ToString().TrimEnd();
 41        }
 42
 643        AppendBatchSummary(sb, page);
 644        AppendPriceChronology(sb, page);
 645        sb.AppendLine(FormatNarrative(page, manufacturerPrefix, AiMaxReports, AiMaxRowsPerReport, AiMaxTotalRows));
 46
 647        if (page.TotalReportCount > page.Reports.Count)
 48        {
 149            sb.AppendLine();
 150            sb.AppendLine(
 151                $"⚠ В контексте {page.Reports.Count} из {page.TotalReportCount} отчётов. " +
 152                "UI: /Studio2/BitrixStock?tab=sync → история по артикулу.");
 53        }
 54
 655        return sb.ToString().TrimEnd();
 56    }
 57
 58    public static string FormatBrief(
 59        BitrixStockArticleHistoryPageDto page,
 60        string? manufacturerPrefix = null,
 61        int maxReports = DefaultMaxReports) =>
 362        FormatNarrative(page, manufacturerPrefix, maxReports);
 63
 64    public static string FormatNarrative(
 65        BitrixStockArticleHistoryPageDto page,
 66        string? manufacturerPrefix = null,
 67        int maxReports = DefaultMaxReports) =>
 1868        FormatNarrative(page, manufacturerPrefix, maxReports, MaxRowsPerReport, MaxTotalRows);
 69
 70    private static string FormatNarrative(
 71        BitrixStockArticleHistoryPageDto page,
 72        string? manufacturerPrefix,
 73        int maxReports,
 74        int maxRowsPerReport,
 75        int maxTotalRows)
 76    {
 2477        if (string.IsNullOrWhiteSpace(page.Article))
 678            return "Артикул не указан.";
 79
 1880        if (page.TotalReportCount == 0)
 181            return $"История синхронизаций Ozon по артикулу «{page.Article}»: отчётов не найдено.";
 82
 1783        var sb = new StringBuilder();
 1784        sb.AppendLine($"=== История синхронизаций по артикулу {page.Article} ===");
 1785        if (!string.IsNullOrWhiteSpace(manufacturerPrefix))
 186            sb.AppendLine($"Product key: {page.Article}={manufacturerPrefix}");
 1787        sb.AppendLine(
 1788            $"Всего отчётов: {page.TotalReportCount}. " +
 1789            $"В контексте: {Math.Min(maxReports, page.Reports.Count)} (от старых к новым в выборке).");
 1790        sb.AppendLine();
 91
 1792        var rowBudget = maxTotalRows;
 11093        foreach (var report in page.Reports.Take(maxReports))
 94        {
 3895            if (rowBudget <= 0) break;
 96
 3897            var msk = BitrixStockMskDateTime.Format(report.GeneratedAtUtc);
 3898            var client = report.IsMultiClient
 3899                ? string.Join(", ", report.MultiClientNames.Values.DefaultIfEmpty(report.OzonClientName).Distinct())
 38100                : report.OzonClientName ?? "—";
 101
 38102            sb.AppendLine($"— Отчёт {msk} ({client}), id …{ShortReportId(report.ReportId)}:");
 103
 38104            var rows = report.Rows.Take(Math.Min(maxRowsPerReport, rowBudget)).ToList();
 38105            rowBudget -= rows.Count;
 106
 176107            foreach (var row in rows)
 108            {
 50109                sb.AppendLine(FormatNarrativeRow(row, page.HasPrices, page.HasPushes));
 50110                var rule = ExplainActionRule(row.Line);
 50111                if (!string.IsNullOrWhiteSpace(rule))
 49112                    sb.AppendLine($"      Правило: {rule}");
 113            }
 114
 38115            if (report.Rows.Count > rows.Count)
 1116                sb.AppendLine($"  …ещё {report.Rows.Count - rows.Count} строк в этом отчёте");
 117
 38118            sb.AppendLine();
 119        }
 120
 17121        return sb.ToString().TrimEnd();
 122    }
 123
 124    private static void AppendBatchSummary(StringBuilder sb, BitrixStockArticleHistoryPageDto page)
 125    {
 6126        var batches = page.Reports
 75127            .SelectMany(r => r.Rows.Select(x => x.Line))
 6128            .Select(ResolveBatchInfo)
 38129            .Where(b => b.Multiplier > 1)
 3130            .GroupBy(b => b.Multiplier)
 3131            .OrderBy(g => g.Key)
 6132            .ToList();
 133
 10134        if (batches.Count == 0) return;
 135
 2136        sb.AppendLine("--- Партии [N] по артикулу ---");
 10137        foreach (var g in batches)
 138        {
 3139            var sample = g.First();
 3140            sb.AppendLine(
 3141                $"• Партия ×{g.Key}: OfferId «{sample.OfferId}» → на Ozon floor(1С/{g.Key}) упаковок; " +
 3142                $"цена = PriceOptimizationManager × {g.Key} (этап 5a или этап 2 с BatchMultiplier).");
 3143            if (!string.IsNullOrWhiteSpace(sample.BaseArticle))
 2144                sb.AppendLine($"  Базовый артикул в 1С: «{sample.BaseArticle}».");
 145        }
 146
 2147        sb.AppendLine();
 2148    }
 149
 150    private static void AppendPriceChronology(StringBuilder sb, BitrixStockArticleHistoryPageDto page)
 151    {
 6152        var events = new List<(DateTime When, string Text)>();
 153
 123154        foreach (var report in page.Reports.OrderBy(r => r.GeneratedAtUtc))
 155        {
 37156            var msk = BitrixStockMskDateTime.Format(report.GeneratedAtUtc);
 150157            foreach (var row in report.Rows)
 158            {
 38159                var w  = row.Line;
 38160                var pi = row.Price;
 38161                if (pi == null) continue;
 162
 34163                if (pi.PricePushedNew.HasValue || !string.IsNullOrWhiteSpace(pi.PricePushStatus))
 164                {
 31165                    var status = string.IsNullOrWhiteSpace(pi.PricePushStatus) ? "изменена" : pi.PricePushStatus;
 31166                    events.Add((
 31167                        report.GeneratedAtUtc,
 31168                        $"{msk}: этап 4 — пуш цены {pi.PricePushedOld:N0} → {pi.PricePushedNew:N0} ₽ ({status}), " +
 31169                        $"склад Ozon «{w.OzonWarehouseName}»"));
 170                }
 3171                else if (w.BatchPushedPrice.HasValue)
 172                {
 1173                    events.Add((
 1174                        report.GeneratedAtUtc,
 1175                        $"{msk}: этап 5a — цена партии ×{w.BatchMultiplier} = {w.BatchPushedPrice:N0} ₽, " +
 1176                        $"OfferId «{w.OfferId}»"));
 177                }
 3178                else if (pi.TruePrice.HasValue && events.All(e => !e.Text.Contains("индекс")))
 179                {
 1180                    var idx = BitrixStockSyncReportRowPresenter.FormatPriceIndex(pi.ColorIndex);
 1181                    if (!string.IsNullOrWhiteSpace(idx))
 182                    {
 1183                        events.Add((
 1184                            report.GeneratedAtUtc,
 1185                            $"{msk}: снимок цены Ozon {pi.TruePrice:N0} ₽, индекс «{idx}», C0 {w.OneCPrice:N0} ₽"));
 186                    }
 187                }
 188            }
 189        }
 190
 9191        if (events.Count == 0) return;
 192
 3193        sb.AppendLine("--- Хронология цен (все отчёты в контексте) ---");
 95194        foreach (var (_, text) in events.OrderBy(e => e.When).Take(25))
 28195            sb.AppendLine($"  • {text}");
 3196        sb.AppendLine();
 3197    }
 198
 199    public static string ExplainActionRule(BitrixStockSyncReportWarehouseLineDto w)
 200    {
 66201        var action = w.Action ?? "";
 66202        var batchN = w.BatchMultiplier > 1
 66203            ? w.BatchMultiplier
 66204            : BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(w.OfferId);
 205
 66206        if (w.IsOzonOrphan)
 1207            return "этап 5b: сирота — остаток на Ozon без покрытия в снэпшоте 1С → обнуление (OzonZero)";
 208
 65209        if (string.Equals(action, BitrixStockSyncReportActions.BatchVariant, StringComparison.OrdinalIgnoreCase)
 65210            || batchN > 1)
 211        {
 6212            var packs = batchN > 1 ? (int)Math.Floor(w.OneCQty / (double)batchN) : (int?)null;
 6213            return batchN > 1
 6214                ? $"этап 5a/2 партия ×{batchN}: floor({w.OneCQty}/{batchN})={packs} упак. на Ozon «{w.OzonWarehouseName}
 6215                : "партийный товар: пересчёт штук в упаковки";
 216        }
 217
 59218        if (string.Equals(action, BitrixStockSyncReportActions.BatchVariantDisabledLink, StringComparison.OrdinalIgnoreC
 1219            return "этап 5a: склад в ApplyDisabledWarehouseMappingIds → Stock=0 (⓪ связь снята)";
 220
 58221        if (string.Equals(action, BitrixStockSyncReportActions.CleanupExtra, StringComparison.OrdinalIgnoreCase))
 1222            return "этап 3: лишний FBS-склад Ozon (не в защищённых) → обнуление CleanupExtra";
 223
 57224        if (string.Equals(action, BitrixStockSyncReportActions.ClearDisabledLink, StringComparison.OrdinalIgnoreCase))
 1225            return "этап 2: связь склада отключена в пресете → принудительное обнуление";
 226
 56227        if (string.Equals(action, BitrixStockSyncReportActions.Push, StringComparison.OrdinalIgnoreCase))
 228        {
 48229            var reserve = w.ReservedQty > 0 ? $", резерв заказов {w.ReservedQty} шт. вычтен из 1С" : "";
 48230            return $"этап 1→2: Ozon {w.OzonQty} < 1С {w.OneCQty} шт.{reserve} → накат цели {w.OneCQty} шт.";
 231        }
 232
 8233        if (string.Equals(action, BitrixStockSyncReportActions.Decrease, StringComparison.OrdinalIgnoreCase))
 1234            return $"этап 1→2: Ozon {w.OzonQty} > 1С {w.OneCQty} шт. → уменьшение до {w.OneCQty} шт.";
 235
 7236        if (string.Equals(action, BitrixStockSyncReportActions.Clear, StringComparison.OrdinalIgnoreCase))
 1237            return "этап 1→2: остаток 1С=0 → обнуление на Ozon (Clear)";
 238
 6239        if (string.Equals(action, BitrixStockSyncReportActions.Match, StringComparison.OrdinalIgnoreCase))
 1240            return "этап 1: остатки совпадают (Match), накат не требуется";
 241
 5242        if (string.Equals(action, BitrixStockSyncReportActions.NotFound, StringComparison.OrdinalIgnoreCase))
 1243            return "этап 1: артикул не найден в каталоге Ozon (NotFound)";
 244
 4245        if (string.Equals(action, BitrixStockSyncReportActions.FbsNoWarehouse, StringComparison.OrdinalIgnoreCase))
 1246            return "этап 1: FBS без привязки склада — нужен WarehouseMapping (FbsNoWarehouse)";
 247
 3248        if (!string.IsNullOrWhiteSpace(w.MappingDescription))
 1249            return $"маппинг: {w.MappingDescription}";
 250
 2251        return "";
 252    }
 253
 254    private static string FormatNarrativeRow(
 255        BitrixStockArticleHistoryRowDto row,
 256        bool hasPrices,
 257        bool hasPushes)
 258    {
 50259        var w = row.Line;
 50260        var pi = row.Price;
 50261        var oneCStore = w.OneCStore ?? "—";
 50262        var ozonWh = w.OzonWarehouseName ?? "—";
 50263        var apply = BitrixStockSyncReportRowPresenter.FormatOzonApplyStatus(w);
 50264        var action = w.ActionLabel ?? w.Action ?? "—";
 265
 50266        var sb = new StringBuilder();
 50267        if (!string.IsNullOrWhiteSpace(w.OfferId))
 49268            sb.Append($"  OfferId «{w.OfferId}»: ");
 269
 50270        sb.Append($"склад 1С «{oneCStore}» → Ozon «{ozonWh}»: ");
 50271        sb.Append($"1С {w.OneCQty} шт., Ozon {w.OzonQty} шт.");
 272
 50273        if (w.ReservedQty > 0)
 1274            sb.Append($" (резерв {w.ReservedQty} шт.)");
 275
 50276        if (w.OzonQtyAfter.HasValue)
 1277            sb.Append($" → после этапа 2: {w.OzonQtyAfter} шт. на Ozon");
 278
 50279        sb.Append($" | действие «{action}»");
 280
 50281        if (!string.IsNullOrWhiteSpace(apply))
 0282            sb.Append($", накат: {apply}");
 283
 50284        if (w.OneCPrice.HasValue)
 285        {
 3286            var deltaC0 = BitrixStockSyncReportRowPresenter.FormatDeltaC0(w.OneCPrice, w.PrevOneCPrice);
 3287            sb.Append($". C0 {w.OneCPrice.Value:N0} ₽");
 3288            if (!string.IsNullOrWhiteSpace(deltaC0))
 1289                sb.Append($" ({deltaC0})");
 290        }
 291
 50292        if (hasPrices && pi != null)
 293        {
 2294            if (pi.TruePrice.HasValue)
 2295                sb.Append($". Цена Ozon {pi.TruePrice.Value:N0} ₽");
 2296            if (pi.SellingPrice.HasValue && pi.SellingPrice != pi.TruePrice)
 1297                sb.Append($", витрина {pi.SellingPrice.Value:N0} ₽");
 2298            if (!string.IsNullOrWhiteSpace(pi.ColorIndex))
 299            {
 1300                var indexLabel = BitrixStockSyncReportRowPresenter.FormatPriceIndex(pi.ColorIndex);
 1301                if (!string.IsNullOrWhiteSpace(indexLabel))
 1302                    sb.Append($", индекс «{indexLabel}»");
 303            }
 304        }
 305
 50306        if (hasPushes && pi != null &&
 50307            (pi.PricePushedOld.HasValue || pi.PricePushedNew.HasValue || !string.IsNullOrWhiteSpace(pi.PricePushStatus))
 308        {
 2309            if (pi.PricePushedOld.HasValue || pi.PricePushedNew.HasValue)
 1310                sb.Append($". Этап 4: {pi.PricePushedOld:N0} → {pi.PricePushedNew:N0} ₽");
 2311            if (!string.IsNullOrWhiteSpace(pi.PricePushStatus))
 2312                sb.Append($" ({pi.PricePushStatus})");
 2313            if (pi.PricePushedAtUtc.HasValue)
 2314                sb.Append($", {BitrixStockMskDateTime.Format(pi.PricePushedAtUtc.Value)}");
 315        }
 316
 50317        if (w.BatchPushedPrice.HasValue)
 2318            sb.Append($". Этап 5a цена партии: {w.BatchPushedPrice:N0} ₽");
 319
 50320        if (!string.IsNullOrWhiteSpace(w.Note))
 1321            sb.Append($". {w.Note}");
 322
 50323        sb.Append('.');
 50324        return sb.ToString();
 325    }
 326
 327    private static (int Multiplier, string? OfferId, string? BaseArticle) ResolveBatchInfo(
 328        BitrixStockSyncReportWarehouseLineDto w)
 329    {
 38330        var mult = w.BatchMultiplier > 1
 38331            ? w.BatchMultiplier
 38332            : BitrixStockOrphanOfferMatching.ExtractBatchMultiplier(w.OfferId);
 38333        return (mult, w.OfferId, w.BatchBaseArticle);
 334    }
 335
 336    private static string ShortReportId(string? reportId)
 337    {
 38338        if (string.IsNullOrWhiteSpace(reportId)) return "—";
 38339        return reportId.Length <= 8 ? reportId : reportId[^8..];
 340    }
 341}