From beae0ad604130a65eb41bfc5bc74e640acdd87f1 Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:35:44 +0500 Subject: [PATCH] feat(moysklad): import archived entities too (as IsActive=false) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше архивных контрагентов/товаров MoySklad API по умолчанию не возвращает (default filter = active only). Для полной синхронизации MoySklad → food-market теперь делаем 2 прохода: сначала активных (default), затем filter=archived=true — отдаём всё одним потоком. В service убран skip-if-archived; archived записи импортируются c IsActive=false (уже было в ApplyCounterparty / ApplyProduct; продублировал для product folders: IsActive = !f.Archived). Клиент: рефакторинг — один generic StreamPagedAsync(path, archivedOnly) вместо трёх копий постраничного цикла. Теперь пользовательский MoySklad-каталог мапится в food-market 1:1 включая архив. Счётчик "Пропущено" отныне значит только "уже существует и галка Перезаписать не стоит". --- .../Integrations/MoySklad/MoySkladClient.cs | 71 +++++++++---------- .../MoySklad/MoySkladImportService.cs | 11 +-- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs index 618a589..bed9460 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -63,60 +63,53 @@ public async Task> WhoAmIAsync(string token, C : MoySkladApiResult.Ok(org); } + // MoySklad list endpoints по умолчанию возвращают только активных (archived=false). + // Чтобы получить архивных, нужен явный filter=archived=true. Делаем два прохода: + // сначала активные (default), затем архивные — и отдаём всё одним потоком. + public async IAsyncEnumerable StreamProductsAsync( string token, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) { - const int pageSize = 1000; - var offset = 0; - while (true) - { - using var req = Build(HttpMethod.Get, $"entity/product?limit={pageSize}&offset={offset}", token); - using var res = await _http.SendAsync(req, ct); - res.EnsureSuccessStatusCode(); - var page = await res.Content.ReadFromJsonAsync>(Json, ct); - if (page is null || page.Rows.Count == 0) yield break; - foreach (var p in page.Rows) yield return p; - if (page.Rows.Count < pageSize) yield break; - offset += pageSize; - } + await foreach (var p in StreamPagedAsync(token, "entity/product", archivedOnly: false, ct)) yield return p; + await foreach (var p in StreamPagedAsync(token, "entity/product", archivedOnly: true, ct)) yield return p; } public async IAsyncEnumerable StreamCounterpartiesAsync( string token, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) { - const int pageSize = 1000; - var offset = 0; - while (true) - { - using var req = Build(HttpMethod.Get, $"entity/counterparty?limit={pageSize}&offset={offset}", token); - using var res = await _http.SendAsync(req, ct); - res.EnsureSuccessStatusCode(); - var page = await res.Content.ReadFromJsonAsync>(Json, ct); - if (page is null || page.Rows.Count == 0) yield break; - foreach (var c in page.Rows) yield return c; - if (page.Rows.Count < pageSize) yield break; - offset += pageSize; - } + await foreach (var c in StreamPagedAsync(token, "entity/counterparty", archivedOnly: false, ct)) yield return c; + await foreach (var c in StreamPagedAsync(token, "entity/counterparty", archivedOnly: true, ct)) yield return c; } public async Task> GetAllFoldersAsync(string token, CancellationToken ct) { var all = new List(); - var offset = 0; - const int pageSize = 1000; - while (true) - { - using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", token); - using var res = await _http.SendAsync(req, ct); - res.EnsureSuccessStatusCode(); - var page = await res.Content.ReadFromJsonAsync>(Json, ct); - if (page is null || page.Rows.Count == 0) break; - all.AddRange(page.Rows); - if (page.Rows.Count < pageSize) break; - offset += pageSize; - } + await foreach (var f in StreamPagedAsync(token, "entity/productfolder", archivedOnly: false, ct)) all.Add(f); + await foreach (var f in StreamPagedAsync(token, "entity/productfolder", archivedOnly: true, ct)) all.Add(f); return all; } + + private async IAsyncEnumerable StreamPagedAsync( + string token, + string path, + bool archivedOnly, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + const int pageSize = 1000; + var offset = 0; + var filterSuffix = archivedOnly ? "&filter=archived=true" : ""; + while (true) + { + using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}{filterSuffix}", token); + using var res = await _http.SendAsync(req, ct); + res.EnsureSuccessStatusCode(); + var page = await res.Content.ReadFromJsonAsync>(Json, ct); + if (page is null || page.Rows.Count == 0) yield break; + foreach (var row in page.Rows) yield return row; + if (page.Rows.Count < pageSize) yield break; + offset += pageSize; + } + } } diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 20ff3a9..89ec7bb 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -69,7 +69,7 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) { total++; - if (c.Archived) { skipped++; continue; } + // Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty). try { @@ -144,11 +144,12 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) .IgnoreQueryFilters() .ToDictionaryAsync(c => c.Name, c => c.Id, ct); - // Import folders first — build flat then link parents. + // Import folders first — build flat then link parents. Архивные тоже берём, + // помечаем IsActive=false — у MoySklad у productfolder есть archived. var folders = await _client.GetAllFoldersAsync(token, ct); var localGroupByMsId = new Dictionary(); var groupsCreated = 0; - foreach (var f in folders.Where(f => !f.Archived).OrderBy(f => f.PathName?.Length ?? 0)) + foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0)) { if (f.Id is null) continue; var existing = await _db.ProductGroups.FirstOrDefaultAsync( @@ -163,7 +164,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) OrganizationId = orgId, Name = f.Name, Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}", - IsActive = true, + IsActive = !f.Archived, }; _db.ProductGroups.Add(g); localGroupByMsId[f.Id] = g.Id; @@ -188,7 +189,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) await foreach (var p in _client.StreamProductsAsync(token, ct)) { total++; - if (p.Archived) { skipped++; continue; } + // Архивных не пропускаем — импортируем как IsActive=false. var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article; var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);