< Summary

Information
Class: Services.ApiServices._1CApi.OData.OneCNomenclatureMapSyncJob
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureMapSyncJob.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 80
Coverable lines: 80
Total lines: 152
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 30
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
ExecuteAsync()0%930300%
PickWinner(...)100%210%
IsPostable(...)0%620%
get_RefKey()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCNomenclatureMapSyncJob.cs

#LineLine coverage
 1using Hangfire;
 2using Microsoft.Extensions.Logging;
 3using Services.ApiServices._1CApi.OData.Models;
 4using Services.HangfireService;
 5
 6namespace Services.ApiServices._1CApi.OData;
 7
 8/// <summary>
 9/// Пересобирает карту «нормализованный артикул + производитель» → карточка 1С и отчитывается
 10/// о дублях. Первый запуск наполняет пустую карту (полный бэкфилл), последующие чинят дрейф:
 11/// карточки, заведённые в 1С руками между запусками, и переименования.
 12/// </summary>
 13public sealed class OneCNomenclatureMapSyncJob
 14{
 15    public const string JobId = "OneCNomenclatureMapSyncJob";
 16    /// <summary>Каждый день в 03:30 — выгрузка каталога тяжёлая, гоняем вне рабочих часов.</summary>
 17    public const string DefaultCron = "30 3 * * *";
 18    public const string QueueName = HangfireQueueNames.Main;
 19
 20    private readonly IOneCODataClient _client;
 21    private readonly IOneCNomenclatureMapStore _map;
 22    private readonly ILogger<OneCNomenclatureMapSyncJob> _logger;
 23
 024    public OneCNomenclatureMapSyncJob(
 025        IOneCODataClient client,
 026        IOneCNomenclatureMapStore map,
 027        ILogger<OneCNomenclatureMapSyncJob> logger)
 28    {
 029        _client = client;
 030        _map = map;
 031        _logger = logger;
 032    }
 33
 34    [Queue(QueueName)]
 35    [DisableConcurrentExecution(timeoutInSeconds: 0)]
 36    public async Task<string> ExecuteAsync()
 37    {
 038        var cards = await _client.GetListAsync<ODataNomenclature>(
 039            OneCODataConstants.NomenclatureEndpoint,
 040            new Dictionary<string, string>
 041            {
 042                ["$select"] = "Ref_Key,Артикул,IsFolder,СчетУчетаЗапасов_Key",
 043                ["$filter"] = "IsFolder eq false and DeletionMark eq false"
 044            });
 45
 46        // Производители всех карточек одним запросом к табличной части — на порядки дешевле,
 47        // чем читать каждую карточку по Ref_Key.
 048        var producerLinks = await _client.GetListAsync<ODataNomenclatureProperty>(
 049            OneCODataConstants.NomenclaturePropertiesEndpoint,
 050            new Dictionary<string, string>
 051            {
 052                ["$select"] = "Ref_Key,Свойство_Key,Значение",
 053                ["$filter"] = $"Свойство_Key eq guid'{OneCODataConstants.ProducerPropertyKey}'",
 054                // У табличной части составной ключ (Ref_Key, LineNumber). Без явного второго поля
 055                // порядок строк внутри одного Ref_Key не определён, и при выборке в несколько страниц
 056                // часть строк может выпасть между $skip-запросами.
 057                ["$orderby"] = "Ref_Key,LineNumber"
 058            });
 59
 060        var producerValues = await _client.GetListAsync<ODataPropertyValue>(
 061            OneCODataConstants.PropertyValuesEndpoint,
 062            new Dictionary<string, string>
 063            {
 064                ["$select"] = "Ref_Key,Description,Owner_Key,DeletionMark",
 065                ["$filter"] = $"Owner_Key eq guid'{OneCODataConstants.ProducerPropertyKey}'"
 066            });
 67
 068        var producerNameByGuid = producerValues
 069            .GroupBy(x => x.RefKey)
 070            .ToDictionary(g => g.Key, g => g.First().Description);
 71
 072        var producerGuidByCard = new Dictionary<Guid, Guid>();
 073        foreach (var link in producerLinks)
 74        {
 075            if (Guid.TryParse(link.Value, out var value) && value != Guid.Empty)
 076                producerGuidByCard[link.RefKey] = value;
 77        }
 78
 79        // Группируем живые карточки по нормализованному ключу.
 080        var grouped = new Dictionary<(string Article, string Producer), List<CardInfo>>();
 081        foreach (var card in cards)
 82        {
 083            var articleNorm = OneCNomenclatureService.NormalizeArticle(card.Article);
 084            if (string.IsNullOrEmpty(articleNorm))
 85                continue;
 86
 087            producerGuidByCard.TryGetValue(card.RefKey, out var producerGuid);
 088            var producerName = producerGuid != Guid.Empty && producerNameByGuid.TryGetValue(producerGuid, out var n) ? n
 089            var producerNorm = OneCNomenclatureService.NormalizeProducerName(producerName);
 90
 091            var key = (articleNorm, producerNorm);
 092            if (!grouped.TryGetValue(key, out var list))
 093                grouped[key] = list = [];
 094            list.Add(new CardInfo(card.RefKey, card.Article, producerName, IsPostable(card)));
 95        }
 96
 097        var existing = await _map.GetAllKeysAsync();
 098        var liveRefKeys = cards.Select(c => c.RefKey).ToHashSet();
 99
 0100        var payload = new List<(OneCMapEntry Entry, string? RawArticle)>();
 0101        var conflicts = 0;
 0102        var conflictExamples = new List<string>();
 103
 0104        foreach (var ((articleNorm, producerNorm), candidates) in grouped)
 105        {
 0106            if (candidates.Count > 1)
 107            {
 0108                conflicts++;
 0109                if (conflictExamples.Count < 20)
 0110                    conflictExamples.Add($"{articleNorm}|{(producerNorm.Length == 0 ? "—" : producerNorm)}: {string.Join
 111            }
 112
 113            // Стабильность важнее «лучшего» выбора: если ключ уже сопоставлен и та карточка жива,
 114            // не переводим будущие проведения на другую — иначе история разъедется по двум карточкам.
 0115            if (existing.TryGetValue((articleNorm, producerNorm), out var mapped) && liveRefKeys.Contains(mapped))
 116                continue;
 117
 0118            var winner = PickWinner(candidates);
 0119            payload.Add((new OneCMapEntry(winner.RefKey, articleNorm, producerNorm, winner.ProducerName), winner.RawArti
 120        }
 121
 0122        var written = await _map.BulkUpsertAsync(payload, "sync");
 123
 0124        if (conflicts > 0)
 125        {
 0126            _logger.LogInformation(
 0127                "[OData] Сверка номенклатуры: {Conflicts} ключей с несколькими карточками в 1С. Примеры: {Examples}",
 0128                conflicts, string.Join(" | ", conflictExamples));
 129        }
 130
 0131        var summary =
 0132            $"Карточек 1С: {cards.Count}; ключей: {grouped.Count}; записано/обновлено: {written}; дублей по ключу: {conf
 0133        _logger.LogInformation("[OData] Сверка карты номенклатуры завершена. {Summary}", summary);
 0134        return summary;
 0135    }
 136
 137    /// <summary>
 138    /// Карточка без счёта учёта запасов не проводится в документах, поэтому при дубле
 139    /// предпочитаем «рабочую». При равенстве — детерминированно наименьший Ref_Key,
 140    /// чтобы оба прод-инстанса выбрали одно и то же.
 141    /// </summary>
 142    private static CardInfo PickWinner(List<CardInfo> candidates)
 0143        => candidates
 0144            .OrderByDescending(c => c.Postable)
 0145            .ThenBy(c => c.RefKey)
 0146            .First();
 147
 148    private static bool IsPostable(ODataNomenclature card)
 0149        => card.StockAccountKey.HasValue && card.StockAccountKey.Value != Guid.Empty;
 150
 0151    private sealed record CardInfo(Guid RefKey, string? RawArticle, string? ProducerName, bool Postable);
 152}