| | | 1 | | using Services.SystemMonitoringServices; |
| | | 2 | | using System.Collections.Concurrent; |
| | | 3 | | using System.Text; |
| | | 4 | | using Microsoft.Extensions.Logging; |
| | | 5 | | using Newtonsoft.Json.Linq; |
| | | 6 | | using OzonDomains; |
| | | 7 | | using OzonDomains.Models; |
| | | 8 | | using Services.ApiServices._1CApi.Nomenclature; |
| | | 9 | | using Services.ApiServices._1CApi.OData.Models; |
| | | 10 | | using Services.BackgroundJobsServices; |
| | | 11 | | |
| | | 12 | | namespace Services.ApiServices._1CApi.OData; |
| | | 13 | | |
| | | 14 | | /// <summary>Откуда пришло создание карточки — попадает в «Комментарий» номенклатуры в 1С.</summary> |
| | | 15 | | public sealed record OneCNomenclatureOrigin(TransactionType? TransactionType); |
| | | 16 | | |
| | | 17 | | public interface IOneCNomenclatureService |
| | | 18 | | { |
| | | 19 | | Task<OneCNomenclatureResult> GetOrCreateAsync(Order order, OneCNomenclatureOrigin? origin = null); |
| | | 20 | | |
| | | 21 | | /// <summary> |
| | | 22 | | /// Разрешает номенклатуру для нескольких заказов параллельно (с ограничением конкурентности) — |
| | | 23 | | /// на порядки быстрее, чем последовательный вызов GetOrCreateAsync в цикле, когда несколько |
| | | 24 | | /// позиций документа отсутствуют в 1С. Результат в том же порядке, что и входные orders. |
| | | 25 | | /// </summary> |
| | | 26 | | Task<IReadOnlyList<OneCNomenclatureResult>> GetOrCreateManyAsync( |
| | | 27 | | IReadOnlyList<Order> orders, |
| | | 28 | | int maxConcurrency = 3, |
| | | 29 | | OneCNomenclatureOrigin? origin = null); |
| | | 30 | | |
| | | 31 | | /// <summary> |
| | | 32 | | /// Ищет карточку номенклатуры теми же правилами, что GetOrCreateAsync (карта соответствий, |
| | | 33 | | /// алиасы производителей, проба по префиксу артикула), но НИКОГДА ничего не создаёт в 1С. |
| | | 34 | | /// null — карточки нет. Нужен для проверок «до записи», где создание карточки недопустимо. |
| | | 35 | | /// </summary> |
| | | 36 | | Task<Guid?> FindAsync(string? article, string? producerName); |
| | | 37 | | |
| | | 38 | | /// <summary>Пакетный FindAsync. Результат в том же порядке, что и входные items.</summary> |
| | | 39 | | Task<IReadOnlyList<Guid?>> FindManyAsync( |
| | | 40 | | IReadOnlyList<(string? Article, string? ProducerName)> items, |
| | | 41 | | int maxConcurrency = 3); |
| | | 42 | | |
| | | 43 | | /// <summary> |
| | | 44 | | /// Разрешает номенклатуру позиций с возможностью «карточки нет»: <see cref="NomenclatureResolution.RefKey"/> = null |
| | | 45 | | /// и причина в <see cref="NomenclatureResolution.Reason"/>. Менеджеры документов кладут такие позиции в черновик. |
| | | 46 | | /// Реализация по умолчанию — прежнее поведение: «только найти» через <see cref="FindManyAsync"/>, |
| | | 47 | | /// «найти или создать» через <see cref="GetOrCreateManyAsync"/> (там карточка есть всегда или бросается исключение) |
| | | 48 | | /// </summary> |
| | | 49 | | async Task<IReadOnlyList<NomenclatureResolution>> ResolveManyAsync( |
| | | 50 | | IReadOnlyList<Order> orders, |
| | | 51 | | NomenclatureMode mode, |
| | | 52 | | int maxConcurrency = 3, |
| | | 53 | | OneCNomenclatureOrigin? origin = null) |
| | | 54 | | { |
| | 2 | 55 | | if (mode == NomenclatureMode.FindOnly) |
| | | 56 | | { |
| | 1 | 57 | | var found = await FindManyAsync( |
| | 2 | 58 | | orders.Select(o => (Article: (string?)NomenclatureResolution.LegacySearchArticle(o), ProducerName: o.EtP |
| | 1 | 59 | | maxConcurrency); |
| | 3 | 60 | | return found.Select(g => new NomenclatureResolution(g, false, string.Empty, null, null, false)).ToList(); |
| | | 61 | | } |
| | | 62 | | |
| | 1 | 63 | | var created = await GetOrCreateManyAsync(orders, maxConcurrency, origin); |
| | 2 | 64 | | return created.Select(c => new NomenclatureResolution(c.RefKey, c.Created, c.DisplayName, null, null, false)).To |
| | 2 | 65 | | } |
| | | 66 | | } |
| | | 67 | | |
| | | 68 | | public sealed record OneCNomenclatureResult(Guid RefKey, bool Created, string DisplayName); |
| | | 69 | | |
| | | 70 | | /// <summary>Гарантирует, что товар из заказа существует и пригоден для проведения документов 1С.</summary> |
| | | 71 | | public sealed partial class OneCNomenclatureService : IOneCNomenclatureService, INomenclatureLiveLookup |
| | | 72 | | { |
| | | 73 | | private const string NomenclatureEndpoint = "Catalog_Номенклатура"; |
| | | 74 | | internal const string PropertyValuesEndpoint = "Catalog_ЗначенияСвойствОбъектов"; |
| | | 75 | | internal const string ProducerPropertyKey = "abba39bc-6cc0-11e8-418f-005056890939"; |
| | | 76 | | private const string StockAccountKey = "fa5a6448-4871-11e8-4680-fa163e49a0ee"; |
| | | 77 | | private const string ExpenseAccountKey = "fa5a70f0-4871-11e8-4680-fa163e49a0ee"; |
| | | 78 | | private const string ActivityDirectionKey = "faf5480a-4871-11e8-4680-fa163e49a0ee"; |
| | | 79 | | // «Без категории» в Catalog_КатегорииНоменклатуры. |
| | | 80 | | private const string CategoryKey = "fdd99328-4871-11e8-4680-fa163e49a0ee"; |
| | | 81 | | private const string VatSalesAccountKey = "3206a2f4-7ed1-49da-92a8-5f2bf0384c0e"; |
| | | 82 | | private const string VatPurchaseAccountKey = "9e4ace75-ec94-49b2-842c-3642f4a35a32"; |
| | | 83 | | |
| | | 84 | | /// <summary>Длина префикса артикула для substringof-пробы. См. FetchCandidatesAsync.</summary> |
| | | 85 | | private const int ProbePrefixLength = 5; |
| | | 86 | | |
| | | 87 | | /// <summary>Короче этого имя производителя не участвует в эвристике вхождения — слишком много ложных срабатываний.< |
| | | 88 | | private const int MinProducerContainsLength = 4; |
| | | 89 | | |
| | | 90 | | private static readonly ConcurrentDictionary<string, SemaphoreSlim> Locks = new(StringComparer.Ordinal); |
| | | 91 | | // Отдельный лок на имя производителя: при параллельном создании (GetOrCreateManyAsync) два разных |
| | | 92 | | // артикула с одним новым производителем не заблокируют друг друга через Locks (ключ там включает |
| | | 93 | | // артикул) и могут создать в 1С два дублирующих Catalog_ЗначенияСвойствОбъектов на один Description. |
| | | 94 | | // Ключ — нормализованное имя (см. NormalizeProducerName), иначе "PARTS-MALL" и "PARTS MALL" лочатся |
| | | 95 | | // раздельно и всё равно создают два разных значения реквизита. |
| | | 96 | | private static readonly ConcurrentDictionary<string, SemaphoreSlim> ProducerLocks = new(StringComparer.Ordinal); |
| | | 97 | | |
| | | 98 | | private readonly IOneCODataClient _client; |
| | | 99 | | private readonly ILogger<OneCNomenclatureService> _logger; |
| | | 100 | | private readonly IOneCNomenclatureCreationLock? _creationLock; |
| | | 101 | | private readonly IOneCNomenclatureMapStore? _map; |
| | | 102 | | private readonly bool _backfillProducerOnReuse; |
| | | 103 | | private readonly ISystemMonitoringSignalWriter? _signals; |
| | | 104 | | private readonly NomenclatureMatcher? _matcher; |
| | | 105 | | private readonly NomenclatureResolverMode _resolverMode; |
| | | 106 | | private readonly ProducerValueResolver? _producerValues; |
| | | 107 | | private readonly INomenclatureIndexProvider? _indexProvider; |
| | | 108 | | private readonly INomenclatureNotificationSink? _notificationSink; |
| | | 109 | | |
| | | 110 | | public OneCNomenclatureService( |
| | | 111 | | IOneCODataClient client, |
| | | 112 | | ILogger<OneCNomenclatureService> logger, |
| | | 113 | | IOneCNomenclatureCreationLock? creationLock = null, |
| | | 114 | | IOneCNomenclatureMapStore? map = null, |
| | | 115 | | OneCODataConfig? config = null, |
| | | 116 | | ISystemMonitoringSignalWriter? signals = null, |
| | | 117 | | NomenclatureMatcher? matcher = null, |
| | | 118 | | ProducerValueResolver? producerValues = null, |
| | | 119 | | INomenclatureIndexProvider? indexProvider = null, |
| | | 120 | | INomenclatureNotificationSink? notificationSink = null) |
| | | 121 | | { |
| | | 122 | | _producerValues = producerValues; |
| | | 123 | | _indexProvider = indexProvider; |
| | | 124 | | _notificationSink = notificationSink; |
| | | 125 | | _signals = signals; |
| | | 126 | | _matcher = matcher; |
| | | 127 | | _resolverMode = config?.ResolverMode ?? NomenclatureResolverMode.Off; |
| | | 128 | | _client = client; |
| | | 129 | | _logger = logger; |
| | | 130 | | _creationLock = creationLock; |
| | | 131 | | _map = map; |
| | | 132 | | _backfillProducerOnReuse = config?.BackfillProducerOnReuse ?? true; |
| | | 133 | | } |
| | | 134 | | |
| | | 135 | | public async Task<OneCNomenclatureResult> GetOrCreateAsync(Order order, OneCNomenclatureOrigin? origin = null) |
| | | 136 | | { |
| | | 137 | | var shadow = await ShadowMatchAsync(new ProductQuery(order.Article, order.ProductKey, order.ProductName, order.E |
| | | 138 | | try |
| | | 139 | | { |
| | | 140 | | var result = await GetOrCreateCoreAsync(order, origin); |
| | | 141 | | LogShadow(shadow, result.RefKey, result.Created, NomenclatureMode.FindOrCreate, order.Article, order.EtProdu |
| | | 142 | | return result; |
| | | 143 | | } |
| | | 144 | | catch (Exception ex) |
| | | 145 | | { |
| | | 146 | | await SignalAsync("create-failed", $"{order.Article}: {ex.Message}"); |
| | | 147 | | throw; |
| | | 148 | | } |
| | | 149 | | } |
| | | 150 | | |
| | | 151 | | /// <summary> |
| | | 152 | | /// Проблемы с номенклатурой/производителями 1С попадают в «Системные алерты» (Studio2/JobLogs?tab=alerts) |
| | | 153 | | /// по общим правилам пассивных сигналов. Запись сигнала не должна влиять на основную операцию. |
| | | 154 | | /// </summary> |
| | | 155 | | private async Task SignalAsync(string scope, string message) |
| | | 156 | | { |
| | | 157 | | if (_signals == null) return; |
| | | 158 | | await _signals.RecordAsync(NomenclatureSignalSource, "failure", scope, message); |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | private async Task AuditAsync(string scope, string message) |
| | | 162 | | { |
| | | 163 | | if (_signals == null) return; |
| | | 164 | | await _signals.RecordAsync(NomenclatureSignalSource, "success", scope, message); |
| | | 165 | | } |
| | | 166 | | |
| | | 167 | | public const string NomenclatureSignalSource = "onec-nomenclature"; |
| | | 168 | | |
| | | 169 | | /// <summary>Новый поиск в режиме Shadow: только читает, любые сбои гасятся — прежний поиск от него не зависит.</sum |
| | | 170 | | private async Task<NomenclatureMatch?> ShadowMatchAsync(ProductQuery query) |
| | | 171 | | { |
| | | 172 | | if (_resolverMode != NomenclatureResolverMode.Shadow || _matcher == null) |
| | | 173 | | return null; |
| | | 174 | | |
| | | 175 | | try |
| | | 176 | | { |
| | | 177 | | return await _matcher.MatchAsync(query, this); |
| | | 178 | | } |
| | | 179 | | catch (Exception ex) |
| | | 180 | | { |
| | | 181 | | _logger.LogWarning(ex, "[NomenclatureShadow] Новый поиск не отработал для {Article}", query.Article); |
| | | 182 | | return null; |
| | | 183 | | } |
| | | 184 | | } |
| | | 185 | | |
| | | 186 | | private void LogShadow(NomenclatureMatch? shadow, Guid? oldRefKey, bool oldCreated, NomenclatureMode mode, string? a |
| | | 187 | | { |
| | | 188 | | if (shadow == null) |
| | | 189 | | return; |
| | | 190 | | |
| | | 191 | | var outcome = NomenclatureShadowComparer.Compare(oldRefKey, oldCreated, shadow, mode); |
| | | 192 | | if (outcome.Kind == ShadowDiffKind.Agree) |
| | | 193 | | { |
| | | 194 | | _logger.LogDebug("[NomenclatureShadow] совпало {Article}/{Producer}: {Text}", article, producer, outcome.Des |
| | | 195 | | return; |
| | | 196 | | } |
| | | 197 | | |
| | | 198 | | _logger.LogInformation("[NomenclatureShadow] {Kind} {Article}/{Producer}: {Text}", outcome.Kind, article, produc |
| | | 199 | | } |
| | | 200 | | |
| | | 201 | | /// <summary>Живой поиск для нового матчера: те же OData-запросы, что у прежнего поиска, плюс имя производителя карт |
| | | 202 | | async Task<IReadOnlyList<LiveCard>> INomenclatureLiveLookup.FindByArticleAsync( |
| | | 203 | | string articleClean, string articleKey, CancellationToken cancellationToken) |
| | | 204 | | { |
| | | 205 | | var candidates = await FetchCandidatesAsync(NormalizeArticle(articleClean), articleClean); |
| | | 206 | | var cards = new List<LiveCard>(candidates.Count); |
| | | 207 | | foreach (var candidate in candidates) |
| | | 208 | | { |
| | | 209 | | var producerGuid = await ReadProducerAsync(candidate.RefKey); |
| | | 210 | | string? producerName = null; |
| | | 211 | | if (producerGuid.HasValue) |
| | | 212 | | { |
| | | 213 | | var value = await _client.GetByGuidAsync<ODataPropertyValue>( |
| | | 214 | | PropertyValuesEndpoint, producerGuid.Value, "Ref_Key,Description"); |
| | | 215 | | producerName = value?.Description; |
| | | 216 | | } |
| | | 217 | | |
| | | 218 | | cards.Add(new LiveCard(candidate.RefKey, candidate.Article, producerName)); |
| | | 219 | | } |
| | | 220 | | |
| | | 221 | | return cards; |
| | | 222 | | } |
| | | 223 | | |
| | | 224 | | /// <summary> |
| | | 225 | | /// Создаёт карточку в 1С: тело, единица, производитель, обязательные поля, запись в карту. Вызывать только под локо |
| | | 226 | | /// ключа (артикул + производитель), чтобы параллельные вызовы не завели две карточки. |
| | | 227 | | /// </summary> |
| | | 228 | | private async Task<OneCNomenclatureResult> CreateNewCardAsync( |
| | | 229 | | Order order, |
| | | 230 | | string article, |
| | | 231 | | string creationArticle, |
| | | 232 | | Guid? producerGuid, |
| | | 233 | | string? producerName, |
| | | 234 | | string producerNorm, |
| | | 235 | | OneCNomenclatureOrigin? origin) |
| | | 236 | | { |
| | | 237 | | var name = string.IsNullOrWhiteSpace(order.ProductName) ? creationArticle : order.ProductName.Trim(); |
| | | 238 | | var body = BuildNomenclatureBody(creationArticle, name); |
| | | 239 | | ODataNomenclature created; |
| | | 240 | | try |
| | | 241 | | { |
| | | 242 | | created = await _client.PostAsync<object, ODataNomenclature>(NomenclatureEndpoint, body); |
| | | 243 | | } |
| | | 244 | | catch (InvalidOperationException ex) when (ex is OneCODataException { Error.StatusCode: 500 } |
| | | 245 | | || ex.Message.Contains("HTTP 500", StringComparison.Ordinal)) |
| | | 246 | | { |
| | | 247 | | _logger.LogWarning("[OData] Создание номенклатуры {Article} с единицей вернуло 500; повторяем без единицы.", |
| | | 248 | | body.Remove("ЕдиницаИзмерения_Key"); |
| | | 249 | | created = await _client.PostAsync<object, ODataNomenclature>(NomenclatureEndpoint, body); |
| | | 250 | | } |
| | | 251 | | |
| | | 252 | | if (created.RefKey == Guid.Empty) |
| | | 253 | | throw new InvalidOperationException($"1С создала номенклатуру '{article}' без Ref_Key."); |
| | | 254 | | |
| | | 255 | | if (producerGuid.HasValue) |
| | | 256 | | await SetProducerAsync(created.RefKey, producerGuid.Value); |
| | | 257 | | |
| | | 258 | | // EnsureRequiredFieldsAsync должен быть последней записью в карточку: SetProducerAsync делает |
| | | 259 | | // full-PUT (PutAsync), который может откатить единицу/категорию, если вызвать проверку раньше него. |
| | | 260 | | // Отметка о происхождении идёт этим же PATCH — в теле POST её слать нельзя: неизвестное поле |
| | | 261 | | // может уронить создание карточки, а PATCH по «Комментарий» проверен на проде. |
| | | 262 | | await EnsureRequiredFieldsAsync(created.RefKey, BuildOriginComment(origin)); |
| | | 263 | | |
| | | 264 | | await RememberAsync(article, producerNorm, created.RefKey, creationArticle, producerName, "created"); |
| | | 265 | | |
| | | 266 | | _logger.LogInformation("[OData] Создана номенклатура {Article} (производитель {Producer}): {RefKey}", |
| | | 267 | | creationArticle, producerName ?? "—", created.RefKey); |
| | | 268 | | return new OneCNomenclatureResult(created.RefKey, true, $"{name} [{article}]"); |
| | | 269 | | } |
| | | 270 | | |
| | | 271 | | private async Task<OneCNomenclatureResult> GetOrCreateCoreAsync(Order order, OneCNomenclatureOrigin? origin) |
| | | 272 | | { |
| | | 273 | | var (article, searchArticle, creationArticle) = ResolveArticle(order); |
| | | 274 | | if (string.IsNullOrWhiteSpace(article)) |
| | | 275 | | throw new InvalidOperationException($"Заказ {order.ShipmentNumber}: артикул пуст после нормализации, создать |
| | | 276 | | |
| | | 277 | | var producerName = order.EtProducer?.Name?.Trim(); |
| | | 278 | | var producerNorm = NormalizeProducerName(producerName); |
| | | 279 | | |
| | | 280 | | // Алиас разрешается ДО ключа карты и локов. Иначе «BLUE» ищется по собственному ключу, |
| | | 281 | | // находит старую дублирующую карточку и до канонического «BLUE PRINT» дело не доходит. |
| | | 282 | | var (aliasProducerGuid, aliasedNorm, aliasedName) = await ResolveProducerAliasAsync(producerNorm, producerName); |
| | | 283 | | producerNorm = aliasedNorm; |
| | | 284 | | producerName = aliasedName; |
| | | 285 | | |
| | | 286 | | var lockKey = $"{article}|{producerNorm}"; |
| | | 287 | | var gate = Locks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1)); |
| | | 288 | | await gate.WaitAsync(); |
| | | 289 | | try |
| | | 290 | | { |
| | | 291 | | await using var distributedLock = _creationLock == null |
| | | 292 | | ? null |
| | | 293 | | : await _creationLock.AcquireAsync(article, producerNorm); |
| | | 294 | | |
| | | 295 | | // Быстрый путь: карта соответствий знает карточку по нормализованному ключу независимо |
| | | 296 | | // от того, как артикул отформатирован в самой 1С. Проверяем уже под локом, чтобы второй |
| | | 297 | | // инстанс увидел запись, сделанную первым. |
| | | 298 | | if (_map != null) |
| | | 299 | | { |
| | | 300 | | var mapped = await _map.FindAsync(article, producerNorm); |
| | | 301 | | if (mapped != null) |
| | | 302 | | return new OneCNomenclatureResult(mapped.RefKey, false, string.Empty); |
| | | 303 | | } |
| | | 304 | | |
| | | 305 | | var producerGuid = aliasProducerGuid |
| | | 306 | | ?? (string.IsNullOrWhiteSpace(producerName) |
| | | 307 | | ? (Guid?)null |
| | | 308 | | : await GetOrCreateProducerAsync(producerName)); |
| | | 309 | | |
| | | 310 | | var existing = await FindExistingAsync(article, searchArticle, producerGuid, producerNorm); |
| | | 311 | | if (existing != null) |
| | | 312 | | { |
| | | 313 | | if (existing.NeedsProducerBackfill && producerGuid.HasValue && _backfillProducerOnReuse) |
| | | 314 | | { |
| | | 315 | | _logger.LogInformation( |
| | | 316 | | "[OData] Артикул {Article}: переиспользуем карточку {RefKey} без производителя и дописываем «{Pr |
| | | 317 | | article, existing.RefKey, producerName); |
| | | 318 | | await SetProducerAsync(existing.RefKey, producerGuid.Value); |
| | | 319 | | |
| | | 320 | | // SetProducerAsync — полная перезапись карточки (PUT), а 1С Fresh при ней не |
| | | 321 | | // сохраняет навигационные реквизиты: единица, счета учёта и категория обнуляются. |
| | | 322 | | // В ветке создания это лечит EnsureRequiredFieldsAsync ниже, здесь нужен такой же |
| | | 323 | | // вызов, иначе строка документа уходит в 1С с пустой «Ед.». Комментарий |
| | | 324 | | // происхождения не пишем: карточку завёл не Studio2. |
| | | 325 | | await EnsureRequiredFieldsAsync(existing.RefKey); |
| | | 326 | | } |
| | | 327 | | |
| | | 328 | | await RememberAsync(article, producerNorm, existing.RefKey, existing.RawArticle, producerName, "probe"); |
| | | 329 | | return new OneCNomenclatureResult(existing.RefKey, false, string.Empty); |
| | | 330 | | } |
| | | 331 | | |
| | | 332 | | return await CreateNewCardAsync(order, article, creationArticle, producerGuid, producerName, producerNorm, o |
| | | 333 | | } |
| | | 334 | | finally |
| | | 335 | | { |
| | | 336 | | gate.Release(); |
| | | 337 | | } |
| | | 338 | | } |
| | | 339 | | |
| | | 340 | | public async Task<IReadOnlyList<OneCNomenclatureResult>> GetOrCreateManyAsync( |
| | | 341 | | IReadOnlyList<Order> orders, |
| | | 342 | | int maxConcurrency = 3, |
| | | 343 | | OneCNomenclatureOrigin? origin = null) |
| | | 344 | | { |
| | | 345 | | using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency)); |
| | | 346 | | var tasks = orders.Select(async order => |
| | | 347 | | { |
| | | 348 | | await throttle.WaitAsync(); |
| | | 349 | | try |
| | | 350 | | { |
| | | 351 | | return await GetOrCreateAsync(order, origin); |
| | | 352 | | } |
| | | 353 | | finally |
| | | 354 | | { |
| | | 355 | | throttle.Release(); |
| | | 356 | | } |
| | | 357 | | }); |
| | | 358 | | return await Task.WhenAll(tasks); |
| | | 359 | | } |
| | | 360 | | |
| | | 361 | | public async Task<Guid?> FindAsync(string? article, string? producerName) |
| | | 362 | | { |
| | | 363 | | var shadow = await ShadowMatchAsync(new ProductQuery(article, null, null, producerName)); |
| | | 364 | | var found = await FindCoreAsync(article, producerName); |
| | | 365 | | LogShadow(shadow, found, false, NomenclatureMode.FindOnly, article, producerName); |
| | | 366 | | return found; |
| | | 367 | | } |
| | | 368 | | |
| | | 369 | | private async Task<Guid?> FindCoreAsync(string? article, string? producerName) |
| | | 370 | | { |
| | | 371 | | var normalizedArticle = NormalizeArticle(article); |
| | | 372 | | if (normalizedArticle.Length == 0) |
| | | 373 | | return null; |
| | | 374 | | |
| | | 375 | | var trimmedProducer = producerName?.Trim(); |
| | | 376 | | var producerNorm = NormalizeProducerName(trimmedProducer); |
| | | 377 | | var originalProducerNorm = producerNorm; |
| | | 378 | | |
| | | 379 | | // Порядок тот же, что в GetOrCreateAsync: алиас → карта соответствий → поиск по 1С. |
| | | 380 | | // Локи не берём — поиск ничего не мутирует, а лок сериализовал бы его с параллельным |
| | | 381 | | // созданием карточек того же артикула. |
| | | 382 | | var (aliasProducerGuid, aliasedNorm, aliasedName) = await ResolveProducerAliasAsync(producerNorm, trimmedProduce |
| | | 383 | | producerNorm = aliasedNorm; |
| | | 384 | | trimmedProducer = aliasedName; |
| | | 385 | | |
| | | 386 | | if (_map != null) |
| | | 387 | | { |
| | | 388 | | var mapped = await _map.FindAsync(normalizedArticle, producerNorm); |
| | | 389 | | |
| | | 390 | | // GetOrCreateAsync намеренно ищет только по каноническому производителю, чтобы не взять старую |
| | | 391 | | // карточку-дубль. Поиск без создания (сверка остатков, проверки) должен находить любую карточку, |
| | | 392 | | // а карта хранит их под тем написанием, под которым их завели: «FEBI», а не «FEBI BILSTEIN». |
| | | 393 | | // 14.10.2026 алиас FEBI → FEBI BILSTEIN сделал такие карточки невидимыми, защита «1С онлайн» |
| | | 394 | | // перестала их проверять и на Ozon встал товар, которого на M1 нет. |
| | | 395 | | if (mapped == null && !string.Equals(originalProducerNorm, producerNorm, StringComparison.Ordinal)) |
| | | 396 | | mapped = await _map.FindAsync(normalizedArticle, originalProducerNorm); |
| | | 397 | | |
| | | 398 | | if (mapped != null) |
| | | 399 | | return mapped.RefKey; |
| | | 400 | | } |
| | | 401 | | |
| | | 402 | | var producerGuid = aliasProducerGuid; |
| | | 403 | | if (producerGuid == null && !string.IsNullOrWhiteSpace(trimmedProducer)) |
| | | 404 | | { |
| | | 405 | | // Значения реквизита нет в 1С — карточки с точным производителем тоже быть не может, |
| | | 406 | | // поэтому ищем дальше как без производителя (та же ветка, куда попал бы GetOrCreateAsync). |
| | | 407 | | var (match, _) = await TryFindProducerAsync(NormalizeProducerName(trimmedProducer)); |
| | | 408 | | producerGuid = match?.RefKey; |
| | | 409 | | } |
| | | 410 | | |
| | | 411 | | var existing = await FindExistingAsync(normalizedArticle, article?.Trim(), producerGuid, producerNorm); |
| | | 412 | | return existing?.RefKey; |
| | | 413 | | } |
| | | 414 | | |
| | | 415 | | public async Task<IReadOnlyList<Guid?>> FindManyAsync( |
| | | 416 | | IReadOnlyList<(string? Article, string? ProducerName)> items, |
| | | 417 | | int maxConcurrency = 3) |
| | | 418 | | { |
| | | 419 | | using var throttle = new SemaphoreSlim(Math.Max(1, maxConcurrency)); |
| | | 420 | | var tasks = items.Select(async item => |
| | | 421 | | { |
| | | 422 | | await throttle.WaitAsync(); |
| | | 423 | | try |
| | | 424 | | { |
| | | 425 | | return await FindAsync(item.Article, item.ProducerName); |
| | | 426 | | } |
| | | 427 | | finally |
| | | 428 | | { |
| | | 429 | | throttle.Release(); |
| | | 430 | | } |
| | | 431 | | }); |
| | | 432 | | return await Task.WhenAll(tasks); |
| | | 433 | | } |
| | | 434 | | |
| | | 435 | | internal static string NormalizeArticle(string? value) |
| | | 436 | | { |
| | | 437 | | var normalized = (value ?? string.Empty).Normalize(NormalizationForm.FormKC).ToUpperInvariant(); |
| | | 438 | | return new string(normalized.Where(char.IsLetterOrDigit).ToArray()); |
| | | 439 | | } |
| | | 440 | | |
| | | 441 | | /// <summary> |
| | | 442 | | /// Возвращает нормализованный ключ для карты, исходное написание для поиска и артикул для создания в 1С. |
| | | 443 | | /// В заказе <see cref="Order.Article"/> может быть очищен импортом, но ProductKey сохраняет |
| | | 444 | | /// артикул до знака «=» вместе с дефисами, точками и другими разделителями. |
| | | 445 | | /// </summary> |
| | | 446 | | private static (string Normalized, string SearchArticle, string CreationArticle) ResolveArticle(Order order) |
| | | 447 | | { |
| | | 448 | | var article = order.Article?.Trim(); |
| | | 449 | | var normalized = NormalizeArticle(article); |
| | | 450 | | if (normalized.Length == 0) |
| | | 451 | | return (string.Empty, string.Empty, string.Empty); |
| | | 452 | | |
| | | 453 | | var productKeyArticle = order.ProductKey?.Split('=', 2)[0].Trim(); |
| | | 454 | | if (!string.IsNullOrWhiteSpace(productKeyArticle) |
| | | 455 | | && NormalizeArticle(productKeyArticle) == normalized |
| | | 456 | | && productKeyArticle.Any(c => !char.IsLetterOrDigit(c))) |
| | | 457 | | { |
| | | 458 | | return (normalized, productKeyArticle, productKeyArticle); |
| | | 459 | | } |
| | | 460 | | |
| | | 461 | | return (normalized, article ?? normalized, normalized); |
| | | 462 | | } |
| | | 463 | | |
| | | 464 | | /// <summary> |
| | | 465 | | /// Убирает дефисы/пробелы/регистр из имени производителя перед поиском и сравнением, иначе |
| | | 466 | | /// "PARTS-MALL" и "PARTS MALL" считаются разными производителями — оба создают своё значение |
| | | 467 | | /// реквизита, и карточка номенклатуры с тем же артикулом не находится по составному ключу |
| | | 468 | | /// (артикул + производитель) в FindExistingAsync, что порождает дубль карточки. |
| | | 469 | | /// </summary> |
| | | 470 | | public static string NormalizeProducerName(string? value) |
| | | 471 | | { |
| | | 472 | | var normalized = (value ?? string.Empty).Normalize(NormalizationForm.FormKC).ToUpperInvariant(); |
| | | 473 | | return new string(normalized.Where(char.IsLetterOrDigit).ToArray()); |
| | | 474 | | } |
| | | 475 | | |
| | | 476 | | private sealed record ExistingMatch(Guid RefKey, string? RawArticle, bool NeedsProducerBackfill); |
| | | 477 | | |
| | | 478 | | /// <summary> |
| | | 479 | | /// Сводит написание производителя к каноническому: «BLUE» → «BLUE PRINT», «Kayaba» → «KYB». |
| | | 480 | | /// Возвращает GUID канонического значения и канонические имя/ключ для карты соответствий. |
| | | 481 | | /// </summary> |
| | | 482 | | private async Task<(Guid? Guid, string Norm, string? Name)> ResolveProducerAliasAsync(string producerNorm, string? p |
| | | 483 | | { |
| | | 484 | | if (_map == null || producerNorm.Length == 0) |
| | | 485 | | return (null, producerNorm, producerName); |
| | | 486 | | |
| | | 487 | | OneCProducerAliasEntry? alias; |
| | | 488 | | try |
| | | 489 | | { |
| | | 490 | | alias = await _map.FindProducerAliasAsync(producerNorm); |
| | | 491 | | } |
| | | 492 | | catch (Exception ex) |
| | | 493 | | { |
| | | 494 | | _logger.LogWarning(ex, "[OData] Не удалось прочитать алиасы производителя для «{Producer}».", producerName); |
| | | 495 | | return (null, producerNorm, producerName); |
| | | 496 | | } |
| | | 497 | | |
| | | 498 | | if (alias == null) |
| | | 499 | | return (null, producerNorm, producerName); |
| | | 500 | | |
| | | 501 | | var canonicalNorm = NormalizeProducerName(alias.CanonicalName); |
| | | 502 | | return canonicalNorm.Length == 0 |
| | | 503 | | ? (alias.RefKey, producerNorm, producerName) |
| | | 504 | | : (alias.RefKey, canonicalNorm, alias.CanonicalName); |
| | | 505 | | } |
| | | 506 | | |
| | | 507 | | private async Task RememberAsync(string article, string producerNorm, Guid refKey, string? rawArticle, string? produ |
| | | 508 | | { |
| | | 509 | | if (_map == null) |
| | | 510 | | return; |
| | | 511 | | |
| | | 512 | | try |
| | | 513 | | { |
| | | 514 | | await _map.UpsertAsync(new OneCMapEntry(refKey, article, producerNorm, producerName), rawArticle, source); |
| | | 515 | | } |
| | | 516 | | catch (Exception ex) |
| | | 517 | | { |
| | | 518 | | // Карта — ускоритель и защита от дублей, но не источник истины: её недоступность |
| | | 519 | | // не должна ронять проведение документа. |
| | | 520 | | _logger.LogWarning(ex, "[OData] Не удалось записать соответствие {Article}|{Producer} → {RefKey} в карту ном |
| | | 521 | | article, producerNorm, refKey); |
| | | 522 | | } |
| | | 523 | | } |
| | | 524 | | |
| | | 525 | | private async Task<ExistingMatch?> FindExistingAsync(string article, string? rawArticle, Guid? producerGuid, string? |
| | | 526 | | { |
| | | 527 | | var candidates = await FetchCandidatesAsync(article, rawArticle); |
| | | 528 | | if (candidates.Count == 0) |
| | | 529 | | return null; |
| | | 530 | | |
| | | 531 | | if (!producerGuid.HasValue) |
| | | 532 | | { |
| | | 533 | | // Производитель у входящей позиции не указан — сопоставляем только по артикулу. |
| | | 534 | | // Единственный кандидат берётся без лишнего чтения карточки. |
| | | 535 | | if (candidates.Count == 1) |
| | | 536 | | return new ExistingMatch(candidates[0].RefKey, candidates[0].Article, false); |
| | | 537 | | |
| | | 538 | | var withProducers = await ReadProducersAsync(candidates); |
| | | 539 | | var producerless = withProducers.FirstOrDefault(x => !x.Producer.HasValue); |
| | | 540 | | var chosen = producerless.Card ?? candidates.OrderBy(x => x.RefKey).First(); |
| | | 541 | | _logger.LogWarning( |
| | | 542 | | "[OData] Артикул {Article}: {Count} карточек в 1С, производитель во входящей позиции не задан — выбрана |
| | | 543 | | article, candidates.Count, chosen.RefKey); |
| | | 544 | | await SignalAsync("duplicate", $"Артикул {article}: {candidates.Count} карточек в 1С, производитель не задан |
| | | 545 | | return new ExistingMatch(chosen.RefKey, chosen.Article, false); |
| | | 546 | | } |
| | | 547 | | |
| | | 548 | | var resolved = await ReadProducersAsync(candidates); |
| | | 549 | | |
| | | 550 | | var exact = resolved.FirstOrDefault(x => x.Producer == producerGuid); |
| | | 551 | | if (exact.Card != null) |
| | | 552 | | return new ExistingMatch(exact.Card.RefKey, exact.Card.Article, false); |
| | | 553 | | |
| | | 554 | | // В 1С одно и то же имя бренда бывает записано разными значениями (в т.ч. у другого владельца |
| | | 555 | | // реквизита), и GUID у карточки не совпадает с найденным по Owner_Key. Сверяем по имени. |
| | | 556 | | if (!string.IsNullOrEmpty(producerNorm)) |
| | | 557 | | { |
| | | 558 | | foreach (var (card, producer) in resolved.Where(x => x.Producer.HasValue)) |
| | | 559 | | { |
| | | 560 | | var value = await _client.GetByGuidAsync<ODataPropertyValue>( |
| | | 561 | | PropertyValuesEndpoint, producer!.Value, "Ref_Key,Description"); |
| | | 562 | | if (NormalizeProducerName(value?.Description) == producerNorm) |
| | | 563 | | return new ExistingMatch(card!.RefKey, card.Article, false); |
| | | 564 | | } |
| | | 565 | | } |
| | | 566 | | |
| | | 567 | | // Карточка с тем же артикулом, но вообще без производителя — самый частый источник дублей: |
| | | 568 | | // раньше на неё не попадали и заводили вторую. Переиспользуем, если она единственная. |
| | | 569 | | var producerlessCards = resolved.Where(x => !x.Producer.HasValue).ToList(); |
| | | 570 | | if (producerlessCards.Count == 1) |
| | | 571 | | return new ExistingMatch(producerlessCards[0].Card!.RefKey, producerlessCards[0].Card!.Article, true); |
| | | 572 | | |
| | | 573 | | // Остальное — тот же артикул у другого бренда (один OEM-номер у разных производителей). |
| | | 574 | | return null; |
| | | 575 | | } |
| | | 576 | | |
| | | 577 | | /// <summary> |
| | | 578 | | /// Кандидаты по артикулу. В OData 1С нет replace, а <c>Артикул</c> сравнивается точно, поэтому |
| | | 579 | | /// «PR-66622» не находится по фильтру <c>Артикул eq 'PR66622'</c>. Сначала пробуем исходное |
| | | 580 | | /// написание из ProductKey (оно не теряет разделители при импорте), затем нормализованное и |
| | | 581 | | /// только после этого выполняем substringof-пробу. Полное покрытие даёт карта соответствий |
| | | 582 | | /// (IOneCNomenclatureMapStore). |
| | | 583 | | /// </summary> |
| | | 584 | | private async Task<List<ODataNomenclature>> FetchCandidatesAsync(string article, string? rawArticle) |
| | | 585 | | { |
| | | 586 | | var result = new List<ODataNomenclature>(); |
| | | 587 | | |
| | | 588 | | var exactArticles = new[] { rawArticle?.Trim(), article } |
| | | 589 | | .Where(x => !string.IsNullOrWhiteSpace(x)) |
| | | 590 | | .Distinct(StringComparer.OrdinalIgnoreCase); |
| | | 591 | | foreach (var exactArticle in exactArticles) |
| | | 592 | | { |
| | | 593 | | var exact = await _client.GetSinglePageAsync<ODataNomenclature>(NomenclatureEndpoint, 50, |
| | | 594 | | new Dictionary<string, string> |
| | | 595 | | { |
| | | 596 | | ["$select"] = "Ref_Key,Артикул,Description,IsFolder", |
| | | 597 | | ["$filter"] = $"Артикул eq '{EscapeOData(exactArticle!)}' and IsFolder eq false and DeletionMark eq |
| | | 598 | | }); |
| | | 599 | | result.AddRange(exact.Where(x => result.All(known => known.RefKey != x.RefKey))); |
| | | 600 | | } |
| | | 601 | | |
| | | 602 | | if (article.Length >= ProbePrefixLength) |
| | | 603 | | { |
| | | 604 | | var probe = article[..ProbePrefixLength]; |
| | | 605 | | var contains = await _client.GetSinglePageAsync<ODataNomenclature>(NomenclatureEndpoint, 50, |
| | | 606 | | new Dictionary<string, string> |
| | | 607 | | { |
| | | 608 | | ["$select"] = "Ref_Key,Артикул,Description,IsFolder", |
| | | 609 | | ["$filter"] = $"substringof('{EscapeOData(probe)}', Артикул) and IsFolder eq false and DeletionMark |
| | | 610 | | }); |
| | | 611 | | result.AddRange(contains.Where(x => result.All(known => known.RefKey != x.RefKey))); |
| | | 612 | | } |
| | | 613 | | |
| | | 614 | | return result.Where(x => NormalizeArticle(x.Article) == article).ToList(); |
| | | 615 | | } |
| | | 616 | | |
| | | 617 | | private async Task<List<(ODataNomenclature? Card, Guid? Producer)>> ReadProducersAsync(List<ODataNomenclature> candi |
| | | 618 | | { |
| | | 619 | | var result = new List<(ODataNomenclature?, Guid?)>(candidates.Count); |
| | | 620 | | foreach (var candidate in candidates) |
| | | 621 | | result.Add((candidate, await ReadProducerAsync(candidate.RefKey))); |
| | | 622 | | return result; |
| | | 623 | | } |
| | | 624 | | |
| | | 625 | | private async Task<Guid?> ReadProducerAsync(Guid refKey) |
| | | 626 | | { |
| | | 627 | | var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, refKey); |
| | | 628 | | if (raw == "{}") |
| | | 629 | | return null; |
| | | 630 | | |
| | | 631 | | var properties = JObject.Parse(raw)["ДополнительныеРеквизиты"] as JArray; |
| | | 632 | | var node = properties?.FirstOrDefault(p => |
| | | 633 | | string.Equals(p?["Свойство_Key"]?.Value<string>(), ProducerPropertyKey, StringComparison.OrdinalIgnoreCase)) |
| | | 634 | | var value = node?["Значение"]?.Value<string>(); |
| | | 635 | | return Guid.TryParse(value, out var guid) && guid != Guid.Empty ? guid : null; |
| | | 636 | | } |
| | | 637 | | |
| | | 638 | | /// <summary> |
| | | 639 | | /// Ищет значение реквизита «Производитель» без создания. ByContainment=true — совпадение найдено |
| | | 640 | | /// эвристикой вхождения, а не точным сравнением имён. |
| | | 641 | | /// </summary> |
| | | 642 | | private async Task<(ODataPropertyValue? Match, bool ByContainment)> TryFindProducerAsync(string normalizedTarget) |
| | | 643 | | { |
| | | 644 | | // Тянем все значения этого реквизита (не фильтруем по Description на сервере — иначе |
| | | 645 | | // разные написания одного бренда, напр. "PARTS-MALL" и "PARTS MALL", не совпадут по |
| | | 646 | | // точному OData-сравнению строк) и матчим локально по нормализованному имени. |
| | | 647 | | var values = await _client.GetListAsync<ODataPropertyValue>(PropertyValuesEndpoint, |
| | | 648 | | new Dictionary<string, string> |
| | | 649 | | { |
| | | 650 | | ["$select"] = "Ref_Key,Description,Owner_Key,DeletionMark", |
| | | 651 | | ["$filter"] = $"Owner_Key eq guid'{ProducerPropertyKey}' and DeletionMark eq false" |
| | | 652 | | }); |
| | | 653 | | |
| | | 654 | | var exact = values.FirstOrDefault(x => NormalizeProducerName(x.Description) == normalizedTarget); |
| | | 655 | | return exact != null |
| | | 656 | | ? (exact, false) |
| | | 657 | | : (InferProducerByContainment(values, normalizedTarget), true); |
| | | 658 | | } |
| | | 659 | | |
| | | 660 | | private async Task<Guid> GetOrCreateProducerAsync(string name) |
| | | 661 | | { |
| | | 662 | | var normalizedTarget = NormalizeProducerName(name); |
| | | 663 | | var gate = ProducerLocks.GetOrAdd(normalizedTarget, _ => new SemaphoreSlim(1, 1)); |
| | | 664 | | await gate.WaitAsync(); |
| | | 665 | | try |
| | | 666 | | { |
| | | 667 | | // Явные алиасы уже разрешены в ResolveProducerAliasAsync — сюда попадают только имена, |
| | | 668 | | // для которых соответствия не задано. |
| | | 669 | | var (match, byContainment) = await TryFindProducerAsync(normalizedTarget); |
| | | 670 | | if (match != null && !byContainment) |
| | | 671 | | return match.RefKey; |
| | | 672 | | |
| | | 673 | | if (match != null) |
| | | 674 | | { |
| | | 675 | | _logger.LogInformation( |
| | | 676 | | "[OData] Производитель «{Name}» сопоставлен с существующим «{Existing}» по вхождению — новое значени |
| | | 677 | | name, match.Description); |
| | | 678 | | if (_map != null) |
| | | 679 | | await _map.UpsertProducerAliasAsync(normalizedTarget, match.RefKey, match.Description, "auto-contain |
| | | 680 | | return match.RefKey; |
| | | 681 | | } |
| | | 682 | | |
| | | 683 | | var created = await _client.PostAsync<object, ODataPropertyValue>(PropertyValuesEndpoint, new |
| | | 684 | | { |
| | | 685 | | Description = name, |
| | | 686 | | DeletionMark = false, |
| | | 687 | | Owner_Key = ProducerPropertyKey |
| | | 688 | | }); |
| | | 689 | | if (created.RefKey == Guid.Empty) |
| | | 690 | | throw new InvalidOperationException($"1С создала производителя '{name}' без Ref_Key."); |
| | | 691 | | |
| | | 692 | | _logger.LogInformation("[OData] Создано значение реквизита «Производитель»: {Name} → {RefKey}", name, create |
| | | 693 | | return created.RefKey; |
| | | 694 | | } |
| | | 695 | | finally |
| | | 696 | | { |
| | | 697 | | gate.Release(); |
| | | 698 | | } |
| | | 699 | | } |
| | | 700 | | |
| | | 701 | | /// <summary> |
| | | 702 | | /// Сводит усечённые написания бренда к существующему значению: «BLUE» → «BLUE PRINT». |
| | | 703 | | /// Срабатывает только при единственном кандидате — иначе можно склеить разные бренды. |
| | | 704 | | /// </summary> |
| | | 705 | | internal static ODataPropertyValue? InferProducerByContainment( |
| | | 706 | | IReadOnlyCollection<ODataPropertyValue> values, |
| | | 707 | | string normalizedTarget) |
| | | 708 | | { |
| | | 709 | | if (normalizedTarget.Length < MinProducerContainsLength) |
| | | 710 | | return null; |
| | | 711 | | |
| | | 712 | | var matches = values |
| | | 713 | | .Where(x => |
| | | 714 | | { |
| | | 715 | | var candidate = NormalizeProducerName(x.Description); |
| | | 716 | | if (candidate.Length < MinProducerContainsLength) |
| | | 717 | | return false; |
| | | 718 | | return candidate.StartsWith(normalizedTarget, StringComparison.Ordinal) |
| | | 719 | | || normalizedTarget.StartsWith(candidate, StringComparison.Ordinal); |
| | | 720 | | }) |
| | | 721 | | .ToList(); |
| | | 722 | | |
| | | 723 | | return matches.Count == 1 ? matches[0] : null; |
| | | 724 | | } |
| | | 725 | | |
| | | 726 | | private async Task SetProducerAsync(Guid nomenclatureGuid, Guid producerGuid) |
| | | 727 | | { |
| | | 728 | | // 412 означает, что 1С изменила карточку между GET и full-PUT. Повторяем ровно один раз |
| | | 729 | | // с заново прочитанной карточкой, чтобы не перезаписать параллельные изменения устаревшим телом. |
| | | 730 | | for (var attempt = 1; attempt <= 2; attempt++) |
| | | 731 | | { |
| | | 732 | | var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, nomenclatureGuid); |
| | | 733 | | if (raw == "{}") |
| | | 734 | | throw new InvalidOperationException($"Не удалось прочитать созданную номенклатуру {nomenclatureGuid} для |
| | | 735 | | var item = JObject.Parse(raw); |
| | | 736 | | item.Remove("odata.metadata"); |
| | | 737 | | item.Remove("odata.type"); |
| | | 738 | | item.Remove("odata.etag"); |
| | | 739 | | |
| | | 740 | | var properties = item["ДополнительныеРеквизиты"] as JArray ?? new JArray(); |
| | | 741 | | var producer = properties.FirstOrDefault(p => string.Equals(p?["Свойство_Key"]?.Value<string>(), ProducerPro |
| | | 742 | | if (producer == null) |
| | | 743 | | { |
| | | 744 | | producer = new JObject { ["LineNumber"] = properties.Count + 1, ["Свойство_Key"] = ProducerPropertyKey } |
| | | 745 | | properties.Add(producer); |
| | | 746 | | } |
| | | 747 | | producer["Значение"] = producerGuid.ToString(); |
| | | 748 | | producer["Значение_Type"] = "StandardODATA.Catalog_ЗначенияСвойствОбъектов"; |
| | | 749 | | item["ДополнительныеРеквизиты"] = properties; |
| | | 750 | | |
| | | 751 | | try |
| | | 752 | | { |
| | | 753 | | await _client.PutAsync(NomenclatureEndpoint, nomenclatureGuid, item); |
| | | 754 | | return; |
| | | 755 | | } |
| | | 756 | | catch (OneCODataException ex) when (ex.Error?.StatusCode == 412 && attempt == 1) |
| | | 757 | | { |
| | | 758 | | _logger.LogInformation("[OData] Карточка номенклатуры {RefKey} изменилась перед установкой производителя |
| | | 759 | | } |
| | | 760 | | } |
| | | 761 | | } |
| | | 762 | | |
| | | 763 | | /// <summary> |
| | | 764 | | /// 1С Fresh может вернуть успешный POST, но не записать навигационные реквизиты карточки. |
| | | 765 | | /// Не продолжаем проведение, пока повторное чтение не подтвердит единицу «шт» и «Без категории». |
| | | 766 | | /// </summary> |
| | | 767 | | private async Task EnsureRequiredFieldsAsync(Guid nomenclatureGuid, string? originComment = null) |
| | | 768 | | { |
| | | 769 | | var patch = new Dictionary<string, object> |
| | | 770 | | { |
| | | 771 | | ["ЕдиницаИзмерения_Key"] = OneCODataConstants.UnitShtukaGuid, |
| | | 772 | | ["КатегорияНоменклатуры_Key"] = CategoryKey, |
| | | 773 | | ["СчетУчетаЗапасов_Key"] = StockAccountKey, |
| | | 774 | | ["СчетУчетаЗатрат_Key"] = ExpenseAccountKey, |
| | | 775 | | ["НаправлениеДеятельности_Key"] = ActivityDirectionKey, |
| | | 776 | | ["СчетУчетаНДСПоРеализации_Key"] = VatSalesAccountKey, |
| | | 777 | | ["СчетУчетаНДСПоПриобретеннымЦенностям_Key"] = VatPurchaseAccountKey |
| | | 778 | | }; |
| | | 779 | | |
| | | 780 | | // «Комментарий» — свободное поле карточки. Наименование (Description) не трогаем: |
| | | 781 | | // оно видно в списках номенклатуры и в печатных формах. |
| | | 782 | | if (!string.IsNullOrWhiteSpace(originComment)) |
| | | 783 | | patch["Комментарий"] = originComment; |
| | | 784 | | |
| | | 785 | | for (var attempt = 1; attempt <= 2; attempt++) |
| | | 786 | | { |
| | | 787 | | await _client.PatchAsync<object>(NomenclatureEndpoint, nomenclatureGuid, patch); |
| | | 788 | | |
| | | 789 | | var raw = await _client.GetRawJsonAsync(NomenclatureEndpoint, nomenclatureGuid); |
| | | 790 | | if (raw != "{}") |
| | | 791 | | { |
| | | 792 | | var item = JObject.Parse(raw); |
| | | 793 | | var unit = item["ЕдиницаИзмерения_Key"]?.Value<string>(); |
| | | 794 | | var category = item["КатегорияНоменклатуры_Key"]?.Value<string>(); |
| | | 795 | | var stockAccount = item["СчетУчетаЗапасов_Key"]?.Value<string>(); |
| | | 796 | | var expenseAccount = item["СчетУчетаЗатрат_Key"]?.Value<string>(); |
| | | 797 | | var activityDirection = item["НаправлениеДеятельности_Key"]?.Value<string>(); |
| | | 798 | | var vatSalesAccount = item["СчетУчетаНДСПоРеализации_Key"]?.Value<string>(); |
| | | 799 | | var vatPurchaseAccount = item["СчетУчетаНДСПоПриобретеннымЦенностям_Key"]?.Value<string>(); |
| | | 800 | | if (string.Equals(unit, OneCODataConstants.UnitShtukaGuid, StringComparison.OrdinalIgnoreCase) |
| | | 801 | | && string.Equals(category, CategoryKey, StringComparison.OrdinalIgnoreCase) |
| | | 802 | | && string.Equals(stockAccount, StockAccountKey, StringComparison.OrdinalIgnoreCase) |
| | | 803 | | && string.Equals(expenseAccount, ExpenseAccountKey, StringComparison.OrdinalIgnoreCase) |
| | | 804 | | && string.Equals(activityDirection, ActivityDirectionKey, StringComparison.OrdinalIgnoreCase) |
| | | 805 | | && string.Equals(vatSalesAccount, VatSalesAccountKey, StringComparison.OrdinalIgnoreCase) |
| | | 806 | | && string.Equals(vatPurchaseAccount, VatPurchaseAccountKey, StringComparison.OrdinalIgnoreCase)) |
| | | 807 | | return; |
| | | 808 | | } |
| | | 809 | | |
| | | 810 | | _logger.LogWarning("[OData] Номенклатура {RefKey}: попытка {Attempt}/2 не подтвердила обязательные реквизиты |
| | | 811 | | } |
| | | 812 | | |
| | | 813 | | throw new InvalidOperationException( |
| | | 814 | | $"Карточка номенклатуры {nomenclatureGuid} создана, но 1С не сохранила обязательные реквизиты учёта. Докумен |
| | | 815 | | } |
| | | 816 | | |
| | | 817 | | /// <summary> |
| | | 818 | | /// Отметка в «Комментарий» карточки: кто завёл позицию, когда и в рамках какой транзакции. |
| | | 819 | | /// Нужна, чтобы в 1С было видно, что карточка не заведена вручную. |
| | | 820 | | /// </summary> |
| | | 821 | | internal static string BuildOriginComment(OneCNomenclatureOrigin? origin, DateTime? nowUtc = null) |
| | | 822 | | { |
| | | 823 | | var moscow = TimeZoneInfo.ConvertTimeFromUtc(nowUtc ?? DateTime.UtcNow, RussianTimeZoneHelper.Zone); |
| | | 824 | | var stamp = moscow.ToString("dd.MM.yyyy HH:mm:ss"); |
| | | 825 | | |
| | | 826 | | var transaction = origin?.TransactionType?.GetDisplayName(); |
| | | 827 | | return string.IsNullOrWhiteSpace(transaction) |
| | | 828 | | ? $"Создано автоматически: Studio2, {stamp} (МСК)." |
| | | 829 | | : $"Создано автоматически: Studio2, {stamp} (МСК). Транзакция: {transaction}."; |
| | | 830 | | } |
| | | 831 | | |
| | | 832 | | private static Dictionary<string, object> BuildNomenclatureBody(string article, string name) => new() |
| | | 833 | | { |
| | | 834 | | ["Description"] = name, |
| | | 835 | | ["НаименованиеПолное"] = name, |
| | | 836 | | ["Артикул"] = article, |
| | | 837 | | ["IsFolder"] = false, |
| | | 838 | | ["DeletionMark"] = false, |
| | | 839 | | ["ЕдиницаИзмерения_Key"] = OneCODataConstants.UnitShtukaGuid, |
| | | 840 | | ["СчетУчетаЗапасов_Key"] = StockAccountKey, |
| | | 841 | | ["СчетУчетаЗатрат_Key"] = ExpenseAccountKey, |
| | | 842 | | ["ВидСтавкиНДС"] = "Общая", |
| | | 843 | | ["НаправлениеДеятельности_Key"] = ActivityDirectionKey, |
| | | 844 | | ["КатегорияНоменклатуры_Key"] = CategoryKey, |
| | | 845 | | ["СчетУчетаНДСПоРеализации_Key"] = VatSalesAccountKey, |
| | | 846 | | ["СчетУчетаНДСПоПриобретеннымЦенностям_Key"] = VatPurchaseAccountKey |
| | | 847 | | }; |
| | | 848 | | |
| | | 849 | | private static string EscapeOData(string value) => value.Replace("'", "''"); |
| | | 850 | | } |