< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.OneCNomenclatureController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/OneCNomenclatureController.cs
Line coverage
100%
Covered lines: 138
Uncovered lines: 0
Coverable lines: 138
Total lines: 220
Line coverage: 100%
Branch coverage
90%
Covered branches: 49
Total branches: 54
Branch coverage: 90.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_AliasName()100%11100%
Explain()75%88100%
LinkProducers()95%2020100%
Duplicates()100%88100%
BrandPairs()87.5%88100%
HasDuplicateGroup(...)100%1010100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/OneCNomenclatureController.cs

#LineLine coverage
 1using Microsoft.AspNetCore.Authorization;
 2using Microsoft.AspNetCore.Mvc;
 3using Microsoft.EntityFrameworkCore;
 4using OzonDomains.Models;
 5using OzonDomains.Nomenclature;
 6using OzonRepositories.Context;
 7using Services.ApiServices._1CApi.Nomenclature;
 8using Services.ApiServices._1CApi.OData;
 9
 10namespace OzonOrdersWeb.Areas.Studio2.Controllers;
 11
 12/// <summary>
 13/// Диагностика поиска номенклатуры 1С: «почему так нашлось», ручная связь написаний бренда, отчёты по дублям и парам бр
 14/// Только для администраторов. Explain и отчёты ничего не пишут и не создают карточек.
 15/// </summary>
 16[Authorize(Roles = "Admin,SuperAdmin")]
 17[Area("Studio2")]
 18[Route("Studio2/OneCNomenclature")]
 1219public sealed class OneCNomenclatureController(
 1220    ApplicationDbContext db,
 1221    NomenclatureMatcher matcher,
 1222    IProducerDirectoryProvider directoryProvider,
 1223    INomenclatureIndexProvider indexProvider,
 1224    ProducerValueResolver producerValues,
 1225    IOneCNomenclatureMapStore mapStore,
 1226    IOneCNomenclatureService nomenclatureService,
 1227    ILogger<OneCNomenclatureController> logger) : Controller
 28{
 29    private const int MaxReportRows = 200;
 30
 2131    public sealed record LinkProducersRequest(string? AliasName, string? CanonicalName);
 32
 33    /// <summary>
 34    /// Разбор поиска для заказа (<c>orderId</c>) или для введённых значений. Показывает разбор ключа, бренд, кандидатов
 35    /// индекса, статус, причину и решение политики в обоих режимах.
 36    /// </summary>
 37    [HttpGet("Explain")]
 38    public async Task<IActionResult> Explain(int? orderId, string? article, string? key, string? name, string? producer,
 39    {
 440        if (orderId.HasValue)
 41        {
 242            var order = await db.Orders.AsNoTracking().Include(o => o.EtProducer)
 243                .FirstOrDefaultAsync(o => o.Id == orderId.Value, cancellationToken);
 244            if (order == null)
 145                return NotFound(new { success = false, message = "Заказ не найден." });
 46
 147            article = order.Article;
 148            key = order.ProductKey;
 149            name = order.ProductName;
 150            producer = order.EtProducer?.Name;
 51        }
 52
 353        var query = new ProductQuery(article, key, name, producer);
 354        var match = await matcher.MatchAsync(query, nomenclatureService as INomenclatureLiveLookup, cancellationToken);
 355        var directory = await directoryProvider.GetAsync(cancellationToken);
 56
 357        return Json(new
 358        {
 359            success = true,
 360            query = new { article, key, name, producer },
 361            parsed = new
 362            {
 363                match.Parsed.ArticleRaw,
 364                match.Parsed.ArticleClean,
 365                match.Parsed.ArticleKey,
 366                match.Parsed.ProductKeyArticleRaw,
 367                match.Parsed.ProductKeyArticleClean,
 368                match.Parsed.ProductKeyArticleKey,
 369                match.Parsed.HasArticleConflict,
 370                match.Parsed.BrandPrefix,
 371                match.Parsed.BatchSize,
 372                match.Parsed.BatchSizeFromArticle,
 373                match.Parsed.VariantTag,
 374                Flags = match.Parsed.Flags.ToString()
 375            },
 376            brand = new
 377            {
 378                kind = match.Producer.Kind.ToString(),
 379                source = match.Producer.Source,
 380                groups = match.Producer.Groups.Select(g => new { group = g, names = directory.NamesOfGroup(g).Take(12) }
 381            },
 382            status = match.Status.ToString(),
 383            confidence = match.Confidence.ToString(),
 384            reason = match.Reason,
 385            refKey = match.RefKey,
 386            variantKind = match.VariantKind?.ToString(),
 387            duplicate = match.Duplicate,
 388            producerlessCard = match.ProducerlessCard,
 389            fromLiveLookup = match.FromLiveLookup,
 390            candidates = match.Candidates.Select(c => new
 391            {
 392                c.RefKey,
 393                producerName = c.ProducerName,
 394                producerNorm = c.ProducerNorm,
 395                c.RawArticle,
 396                c.Source,
 397                groups = c.Groups
 398            }),
 399            decisions = new
 3100            {
 3101                findOnly = NomenclatureResolvePolicy.Decide(match, NomenclatureMode.FindOnly).Action.ToString(),
 3102                findOrCreate = NomenclatureResolvePolicy.Decide(match, NomenclatureMode.FindOrCreate).Action.ToString()
 3103            }
 3104        });
 4105    }
 106
 107    /// <summary>
 108    /// Ручная связь написаний бренда: запись в <c>OneCProducerAliases</c> (источник <c>curated</c>). Каноническое имя д
 109    /// существовать среди значений реквизита «Производитель» в 1С, алиас — в справочнике производителей или в 1С.
 110    /// Работает через JSON-тело: кросс-сайтовая форма такой запрос отправить не может.
 111    /// </summary>
 112    [HttpPost("LinkProducers")]
 113    public async Task<IActionResult> LinkProducers([FromBody] LinkProducersRequest request, CancellationToken cancellati
 114    {
 7115        var alias = request.AliasName?.Trim();
 7116        var canonical = request.CanonicalName?.Trim();
 7117        if (string.IsNullOrEmpty(alias) || string.IsNullOrEmpty(canonical))
 3118            return BadRequest(new { success = false, message = "Нужны оба имени бренда." });
 119
 4120        var aliasNorm = OneCNomenclatureService.NormalizeProducerName(alias);
 4121        if (aliasNorm.Length == 0 || ArticleKey.Key(alias) == ArticleKey.Key(canonical))
 1122            return BadRequest(new { success = false, message = "Имена совпадают — связывать нечего." });
 123
 3124        var target = await producerValues.FindByNameAsync(canonical, cancellationToken);
 3125        if (target == null)
 1126            return BadRequest(new { success = false, message = $"В 1С нет значения «Производитель» «{canonical}»." });
 127
 2128        var directory = await directoryProvider.GetAsync(cancellationToken);
 2129        var aliasKnown = directory.GroupsOfName(alias).Count > 0 || await producerValues.FindByNameAsync(alias, cancella
 2130        if (!aliasKnown)
 1131            return BadRequest(new { success = false, message = $"Бренд «{alias}» не найден ни в справочнике производител
 132
 1133        await mapStore.UpsertProducerAliasAsync(aliasNorm, target.RefKey, target.Name, "curated", cancellationToken);
 1134        directoryProvider.Invalidate();
 1135        indexProvider.Invalidate();
 1136        matcher.ForgetMisses();
 137
 1138        logger.LogInformation("[NomenclatureLink] {User} связал бренд «{Alias}» с «{Canonical}» ({RefKey})",
 1139            User.Identity?.Name, alias, target.Name, target.RefKey);
 1140        return Json(new { success = true, alias = aliasNorm, canonical = target.Name, refKey = target.RefKey });
 7141    }
 142
 143    /// <summary>Артикулы, у которых в одной группе бренда больше одной карточки, — кандидаты на слияние.</summary>
 144    [HttpGet("Report/Duplicates")]
 145    public async Task<IActionResult> Duplicates(CancellationToken cancellationToken)
 146    {
 1147        var index = await indexProvider.GetAsync(cancellationToken);
 1148        var rows = new List<object>();
 1149        var total = 0;
 11150        foreach (var (articleKey, cards) in index.Articles().OrderBy(a => a.Key, StringComparer.Ordinal))
 151        {
 3152            if (cards.Count < 2 || !HasDuplicateGroup(cards))
 153                continue;
 154
 1155            total++;
 1156            if (rows.Count < MaxReportRows)
 1157                rows.Add(new
 1158                {
 1159                    articleKey,
 2160                    cards = cards.Select(c => new { c.RefKey, c.ProducerName, c.Source, c.RawArticle })
 1161                });
 162        }
 163
 1164        return Json(new { success = true, total, shown = rows.Count, rows });
 1165    }
 166
 167    /// <summary>
 168    /// Самые частые пары «бренд заказа ↔ бренд карточки», по которым поиск вернул «другой бренд», среди последних заказ
 169    /// Кандидаты на ручную связь. Карточки 1С по живому запросу не читаются — только индекс.
 170    /// </summary>
 171    [HttpGet("Report/BrandPairs")]
 172    public async Task<IActionResult> BrandPairs(int take = 20000, CancellationToken cancellationToken = default)
 173    {
 1174        take = Math.Clamp(take, 100, 100000);
 1175        var orders = await db.Orders.AsNoTracking()
 1176            .OrderByDescending(o => o.Id)
 1177            .Take(take)
 1178            .Select(o => new { o.Id, o.Article, o.ProductKey, o.ProductName, Producer = o.EtProducer != null ? o.EtProdu
 1179            .ToListAsync(cancellationToken);
 180
 1181        var pairs = new Dictionary<(string Order, string Cards), (int Count, string Article, int OrderId)>();
 8182        foreach (var order in orders)
 183        {
 3184            var match = await matcher.MatchAsync(new ProductQuery(order.Article, order.ProductKey, order.ProductName, or
 3185            if (match.Status != NomenclatureMatchStatus.BrandMismatch)
 186                continue;
 187
 2188            var orderBrand = string.IsNullOrWhiteSpace(order.Producer) ? match.Parsed.BrandPrefix : order.Producer!.Trim
 2189            var cardBrands = string.Join(" / ", match.Candidates
 2190                .Select(c => string.IsNullOrWhiteSpace(c.ProducerName) ? c.ProducerNorm : c.ProducerName!.Trim())
 2191                .Distinct(StringComparer.OrdinalIgnoreCase));
 2192            var pairKey = (orderBrand, cardBrands);
 2193            pairs[pairKey] = pairs.TryGetValue(pairKey, out var known)
 2194                ? (known.Count + 1, known.Article, known.OrderId)
 2195                : (1, match.Parsed.ArticleClean, order.Id);
 2196        }
 197
 2198        var rows = pairs.OrderByDescending(p => p.Value.Count).Take(MaxReportRows)
 2199            .Select(p => new { orderBrand = p.Key.Order, cardBrands = p.Key.Cards, count = p.Value.Count, exampleArticle
 2200        return Json(new { success = true, scannedOrders = orders.Count, totalPairs = pairs.Count, totalMismatches = pair
 1201    }
 202
 203    private static bool HasDuplicateGroup(IReadOnlyList<IndexedCard> cards)
 204    {
 8205        for (var i = 0; i < cards.Count; i++)
 206        {
 8207            for (var j = i + 1; j < cards.Count; j++)
 208            {
 2209                var a = cards[i];
 2210                var b = cards[j];
 2211                if (a.RefKey == b.RefKey)
 212                    continue;
 2213                if (a.Groups.Count > 0 && a.Groups.Overlaps(b.Groups))
 1214                    return true;
 215            }
 216        }
 217
 1218        return false;
 219    }
 220}