| | | 1 | | using Hangfire; |
| | | 2 | | using Microsoft.Extensions.Logging; |
| | | 3 | | using Services.ApiServices._1CApi.OData.Models; |
| | | 4 | | using Services.HangfireService; |
| | | 5 | | |
| | | 6 | | namespace Services.ApiServices._1CApi.OData; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Пересобирает карту «нормализованный артикул + производитель» → карточка 1С и отчитывается |
| | | 10 | | /// о дублях. Первый запуск наполняет пустую карту (полный бэкфилл), последующие чинят дрейф: |
| | | 11 | | /// карточки, заведённые в 1С руками между запусками, и переименования. |
| | | 12 | | /// </summary> |
| | | 13 | | public 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 | | |
| | 0 | 24 | | public OneCNomenclatureMapSyncJob( |
| | 0 | 25 | | IOneCODataClient client, |
| | 0 | 26 | | IOneCNomenclatureMapStore map, |
| | 0 | 27 | | ILogger<OneCNomenclatureMapSyncJob> logger) |
| | | 28 | | { |
| | 0 | 29 | | _client = client; |
| | 0 | 30 | | _map = map; |
| | 0 | 31 | | _logger = logger; |
| | 0 | 32 | | } |
| | | 33 | | |
| | | 34 | | [Queue(QueueName)] |
| | | 35 | | [DisableConcurrentExecution(timeoutInSeconds: 0)] |
| | | 36 | | public async Task<string> ExecuteAsync() |
| | | 37 | | { |
| | 0 | 38 | | var cards = await _client.GetListAsync<ODataNomenclature>( |
| | 0 | 39 | | OneCODataConstants.NomenclatureEndpoint, |
| | 0 | 40 | | new Dictionary<string, string> |
| | 0 | 41 | | { |
| | 0 | 42 | | ["$select"] = "Ref_Key,Артикул,IsFolder,СчетУчетаЗапасов_Key", |
| | 0 | 43 | | ["$filter"] = "IsFolder eq false and DeletionMark eq false" |
| | 0 | 44 | | }); |
| | | 45 | | |
| | | 46 | | // Производители всех карточек одним запросом к табличной части — на порядки дешевле, |
| | | 47 | | // чем читать каждую карточку по Ref_Key. |
| | 0 | 48 | | var producerLinks = await _client.GetListAsync<ODataNomenclatureProperty>( |
| | 0 | 49 | | OneCODataConstants.NomenclaturePropertiesEndpoint, |
| | 0 | 50 | | new Dictionary<string, string> |
| | 0 | 51 | | { |
| | 0 | 52 | | ["$select"] = "Ref_Key,Свойство_Key,Значение", |
| | 0 | 53 | | ["$filter"] = $"Свойство_Key eq guid'{OneCODataConstants.ProducerPropertyKey}'", |
| | 0 | 54 | | // У табличной части составной ключ (Ref_Key, LineNumber). Без явного второго поля |
| | 0 | 55 | | // порядок строк внутри одного Ref_Key не определён, и при выборке в несколько страниц |
| | 0 | 56 | | // часть строк может выпасть между $skip-запросами. |
| | 0 | 57 | | ["$orderby"] = "Ref_Key,LineNumber" |
| | 0 | 58 | | }); |
| | | 59 | | |
| | 0 | 60 | | var producerValues = await _client.GetListAsync<ODataPropertyValue>( |
| | 0 | 61 | | OneCODataConstants.PropertyValuesEndpoint, |
| | 0 | 62 | | new Dictionary<string, string> |
| | 0 | 63 | | { |
| | 0 | 64 | | ["$select"] = "Ref_Key,Description,Owner_Key,DeletionMark", |
| | 0 | 65 | | ["$filter"] = $"Owner_Key eq guid'{OneCODataConstants.ProducerPropertyKey}'" |
| | 0 | 66 | | }); |
| | | 67 | | |
| | 0 | 68 | | var producerNameByGuid = producerValues |
| | 0 | 69 | | .GroupBy(x => x.RefKey) |
| | 0 | 70 | | .ToDictionary(g => g.Key, g => g.First().Description); |
| | | 71 | | |
| | 0 | 72 | | var producerGuidByCard = new Dictionary<Guid, Guid>(); |
| | 0 | 73 | | foreach (var link in producerLinks) |
| | | 74 | | { |
| | 0 | 75 | | if (Guid.TryParse(link.Value, out var value) && value != Guid.Empty) |
| | 0 | 76 | | producerGuidByCard[link.RefKey] = value; |
| | | 77 | | } |
| | | 78 | | |
| | | 79 | | // Группируем живые карточки по нормализованному ключу. |
| | 0 | 80 | | var grouped = new Dictionary<(string Article, string Producer), List<CardInfo>>(); |
| | 0 | 81 | | foreach (var card in cards) |
| | | 82 | | { |
| | 0 | 83 | | var articleNorm = OneCNomenclatureService.NormalizeArticle(card.Article); |
| | 0 | 84 | | if (string.IsNullOrEmpty(articleNorm)) |
| | | 85 | | continue; |
| | | 86 | | |
| | 0 | 87 | | producerGuidByCard.TryGetValue(card.RefKey, out var producerGuid); |
| | 0 | 88 | | var producerName = producerGuid != Guid.Empty && producerNameByGuid.TryGetValue(producerGuid, out var n) ? n |
| | 0 | 89 | | var producerNorm = OneCNomenclatureService.NormalizeProducerName(producerName); |
| | | 90 | | |
| | 0 | 91 | | var key = (articleNorm, producerNorm); |
| | 0 | 92 | | if (!grouped.TryGetValue(key, out var list)) |
| | 0 | 93 | | grouped[key] = list = []; |
| | 0 | 94 | | list.Add(new CardInfo(card.RefKey, card.Article, producerName, IsPostable(card))); |
| | | 95 | | } |
| | | 96 | | |
| | 0 | 97 | | var existing = await _map.GetAllKeysAsync(); |
| | 0 | 98 | | var liveRefKeys = cards.Select(c => c.RefKey).ToHashSet(); |
| | | 99 | | |
| | 0 | 100 | | var payload = new List<(OneCMapEntry Entry, string? RawArticle)>(); |
| | 0 | 101 | | var conflicts = 0; |
| | 0 | 102 | | var conflictExamples = new List<string>(); |
| | | 103 | | |
| | 0 | 104 | | foreach (var ((articleNorm, producerNorm), candidates) in grouped) |
| | | 105 | | { |
| | 0 | 106 | | if (candidates.Count > 1) |
| | | 107 | | { |
| | 0 | 108 | | conflicts++; |
| | 0 | 109 | | if (conflictExamples.Count < 20) |
| | 0 | 110 | | conflictExamples.Add($"{articleNorm}|{(producerNorm.Length == 0 ? "—" : producerNorm)}: {string.Join |
| | | 111 | | } |
| | | 112 | | |
| | | 113 | | // Стабильность важнее «лучшего» выбора: если ключ уже сопоставлен и та карточка жива, |
| | | 114 | | // не переводим будущие проведения на другую — иначе история разъедется по двум карточкам. |
| | 0 | 115 | | if (existing.TryGetValue((articleNorm, producerNorm), out var mapped) && liveRefKeys.Contains(mapped)) |
| | | 116 | | continue; |
| | | 117 | | |
| | 0 | 118 | | var winner = PickWinner(candidates); |
| | 0 | 119 | | payload.Add((new OneCMapEntry(winner.RefKey, articleNorm, producerNorm, winner.ProducerName), winner.RawArti |
| | | 120 | | } |
| | | 121 | | |
| | 0 | 122 | | var written = await _map.BulkUpsertAsync(payload, "sync"); |
| | | 123 | | |
| | 0 | 124 | | if (conflicts > 0) |
| | | 125 | | { |
| | 0 | 126 | | _logger.LogInformation( |
| | 0 | 127 | | "[OData] Сверка номенклатуры: {Conflicts} ключей с несколькими карточками в 1С. Примеры: {Examples}", |
| | 0 | 128 | | conflicts, string.Join(" | ", conflictExamples)); |
| | | 129 | | } |
| | | 130 | | |
| | 0 | 131 | | var summary = |
| | 0 | 132 | | $"Карточек 1С: {cards.Count}; ключей: {grouped.Count}; записано/обновлено: {written}; дублей по ключу: {conf |
| | 0 | 133 | | _logger.LogInformation("[OData] Сверка карты номенклатуры завершена. {Summary}", summary); |
| | 0 | 134 | | return summary; |
| | 0 | 135 | | } |
| | | 136 | | |
| | | 137 | | /// <summary> |
| | | 138 | | /// Карточка без счёта учёта запасов не проводится в документах, поэтому при дубле |
| | | 139 | | /// предпочитаем «рабочую». При равенстве — детерминированно наименьший Ref_Key, |
| | | 140 | | /// чтобы оба прод-инстанса выбрали одно и то же. |
| | | 141 | | /// </summary> |
| | | 142 | | private static CardInfo PickWinner(List<CardInfo> candidates) |
| | 0 | 143 | | => candidates |
| | 0 | 144 | | .OrderByDescending(c => c.Postable) |
| | 0 | 145 | | .ThenBy(c => c.RefKey) |
| | 0 | 146 | | .First(); |
| | | 147 | | |
| | | 148 | | private static bool IsPostable(ODataNomenclature card) |
| | 0 | 149 | | => card.StockAccountKey.HasValue && card.StockAccountKey.Value != Guid.Empty; |
| | | 150 | | |
| | 0 | 151 | | private sealed record CardInfo(Guid RefKey, string? RawArticle, string? ProducerName, bool Postable); |
| | | 152 | | } |