diff --git a/docs/catalog-import-progress.md b/docs/catalog-import-progress.md new file mode 100644 index 0000000..5580c4d --- /dev/null +++ b/docs/catalog-import-progress.md @@ -0,0 +1,36 @@ +# Catalog import МойСклад → «Демо-магазин» (stage) + +**Решение владельца.** Стенд закрыт basic-auth'ом на gateway от +интернета (внутренняя сеть 192.168.1.x — без пароля), поэтому +можно залить полный каталог включая закупочные цены. + +**Токен МойСклада.** `/var/www/sklad/appsettings.Production.json`, +ключ `MoySklad:Token`, забирается через ssh + sudo на prod-vm. В +логи / коммиты / docs / отчёты токен **никогда** не попадает. +Передаётся только через env-переменную или stdin. + +**Границы.** Не трогаем проект `sklad`, прод `admin.food-market.kz`, +`global.json`, `deploy/*/nginx`. + +## Чек-лист + +- [ ] 1. Аудит текущего MoySklad-импорта + добор недостающих полей + для «полной карточки» (товары, группы, ед. изм., штрихкоды, + цены продажи/закупки, весовой признак, мин. остаток, артикул, + фото) — сверка полей против API МойСклад 1.2. +- [ ] 2. Импорт в «Демо-магазин» на стейдже без дублей: + upsert по внешнему id МойСклада; merge по штрихкоду; дубли + штрихкодов в самом МойСкладе — первый пропускаем в отчёт; + группы — тоже без дублей; архивные товары МойСклада не льём. +- [ ] 3. Токен у организации стенда после импорта удалить (или + импорт без сохранения токена — тоже ок). +- [ ] 4. Отчёт: группы/товары created/updated/merged/skipped + + выборочная сверка 10 карточек с МойСкладом. +- [ ] 5. Playwright-проверка каталога Демо-магазина изнутри сети: + открывается, поиск по названию и штрихкоду работает, карточка + показывает поля простого режима; повторный запуск импорта → 0 + новых. + +## Протокол выполнения + +(заполняется построчно) diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs index 0c45ae4..e3aa290 100644 --- a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -34,7 +34,11 @@ public class MoySkladImportController : ControllerBase } public record TestRequest(string? Token = null); - public record ImportRequest(string? Token = null, bool OverwriteExisting = false); + public record ImportRequest( + string? Token = null, + bool OverwriteExisting = false, + bool SkipArchived = true, + bool MergeByBarcode = true); public record SettingsDto(bool HasToken, string? Masked); public record SettingsInput(string Token); @@ -89,11 +93,25 @@ public async Task> ImportProducts([FromBody] ImportRequest var job = _jobs.Create("products"); job.Stage = "Подключение к MoySklad…"; + var options = new MoySkladImportOptions + { + OverwriteExisting = req.OverwriteExisting, + SkipArchived = req.SkipArchived, + MergeByBarcode = req.MergeByBarcode, + }; _ = RunInBackgroundAsync(job, async (svc, progress, ctInner) => { progress.Stage = "Импорт товаров…"; - var result = await svc.ImportProductsAsync(token, req.OverwriteExisting, ctInner, progress); - progress.Message = $"Готово: {result.Created} записей (создано/обновлено), {result.Skipped} пропущено, {result.GroupsCreated} групп."; + var result = await svc.ImportProductsAsync(token, options, ctInner, progress); + // Sprint 32: расширенный summary — отдельно merge/skip-archived/skip-dupe. + progress.Message = + $"Готово: создано {result.Created - result.MergedByBarcode}," + + $" merge-barcode {result.MergedByBarcode}," + + $" пропущено {result.Skipped}," + + $" архивных пропущено {result.SkippedArchivedInSource}," + + $" дубль-штрихкод в MS {result.SkippedDuplicateBarcodeInSource}," + + $" групп создано {result.GroupsCreated}," + + $" групп обновлено {result.GroupsUpdated}."; }, orgId); return Ok(new { jobId = job.Id }); } diff --git a/src/food-market.domain/Catalog/Product.cs b/src/food-market.domain/Catalog/Product.cs index 908ebd5..0e937ea 100644 --- a/src/food-market.domain/Catalog/Product.cs +++ b/src/food-market.domain/Catalog/Product.cs @@ -55,6 +55,12 @@ public class Product : TenantEntity public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage) + /// Внешний id сущности в MoySklad (UUID). Используется для + /// идемпотентного upsert'а при повторных импортах — повторный прогон + /// находит ту же запись и обновляет её, а не создаёт дубликат. + /// NULL для товаров, созданных пользователем вручную. + public string? MoySkladExternalId { get; set; } + /// Архивный товар. Скрывается из обычных списков (List default /// filter); попадает в отчёты по историческим данным, но не предлагается /// в новых документах. Sprint 19: bulk-операция «Архивировать». diff --git a/src/food-market.domain/Catalog/ProductGroup.cs b/src/food-market.domain/Catalog/ProductGroup.cs index c3ba856..d3f5bb0 100644 --- a/src/food-market.domain/Catalog/ProductGroup.cs +++ b/src/food-market.domain/Catalog/ProductGroup.cs @@ -24,4 +24,10 @@ public class ProductGroup : Entity, IOptionalTenantEntity /// предварительной настройки. Удалить или переименовать нельзя — иначе /// продукты осиротеют. public bool IsSystem { get; set; } + + /// Внешний id группы в MoySklad (UUID productfolder). Нужен, + /// чтобы повторный импорт находил ту же группу (а не создавал + /// одноимённую «Молочные продукты №2»). NULL для групп, созданных + /// SuperAdmin'ом или пользователем вручную. + public string? MoySkladExternalId { get; set; } } diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs index d88f5f9..d7868e6 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -32,7 +32,15 @@ public MoySkladClient(HttpClient http) private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token) { var req = new HttpRequestMessage(method, pathAndQuery); - req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + // МойСклад принимает оба варианта аутентификации: + // - Basic — пароль пользователя кабинета + // - Bearer — токен из «Сервис → Настройки → API» + // Авто-детект: если токен выглядит как base64 (один раз распарсился + // в login:password) — используем Basic, иначе Bearer. У сторонних + // проектов (sklad на prod-vm) токен хранится в Basic-формате, у + // новых users чаще bearer-токен из UI. + var scheme = LooksLikeBasicB64(token) ? "Basic" : "Bearer"; + req.Headers.Authorization = new AuthenticationHeaderValue(scheme, token); // MoySklad requires the exact literal "application/json;charset=utf-8" (no space // after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to // "application/json; charset=utf-8" which MoySklad rejects with code 1062. @@ -69,12 +77,21 @@ public async Task> WhoAmIAsync(string token, C public async IAsyncEnumerable StreamProductsAsync( string token, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct, + bool includeArchived = true) { await foreach (var p in StreamPagedAsync(token, "entity/product", archivedOnly: false, ct)) yield return p; + if (!includeArchived) yield break; await foreach (var p in StreamPagedAsync(token, "entity/product", archivedOnly: true, ct)) yield return p; } + public async Task> GetAllUomsAsync(string token, CancellationToken ct) + { + var all = new List(); + await foreach (var u in StreamPagedAsync(token, "entity/uom", archivedOnly: false, ct)) all.Add(u); + return all; + } + public async IAsyncEnumerable StreamCounterpartiesAsync( string token, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) @@ -83,6 +100,33 @@ public async Task> WhoAmIAsync(string token, C await foreach (var c in StreamPagedAsync(token, "entity/counterparty", archivedOnly: true, ct)) yield return c; } + /// Base64(login:password) выглядит как base64url-ish строка + /// без пробелов, при декодировании содержит ':' между логином и + /// паролем. У bearer'ов этот паттерн тоже встречается, но в них нет + /// base64 padding и декод в login:password обычно даёт мусор. + private static bool LooksLikeBasicB64(string token) + { + if (string.IsNullOrEmpty(token)) return false; + // Длина 36 чаще всего — base64(login:password) ~ 24..40 символов. + // Проверяем валидный base64 + наличие ':' после декода. + try + { + var padded = token; + // Padding, если потеряли. + var remainder = padded.Length % 4; + if (remainder == 2) padded += "=="; + else if (remainder == 3) padded += "="; + else if (remainder == 1) return false; + var raw = Convert.FromBase64String(padded); + var text = System.Text.Encoding.UTF8.GetString(raw); + return text.Contains(':') && !text.Any(c => c is < (char)0x20 and not '\t'); + } + catch + { + return false; + } + } + public async Task> GetAllFoldersAsync(string token, CancellationToken ct) { var all = new List(); diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 4d27b04..7c35b37 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -11,7 +11,37 @@ public record MoySkladImportResult( int Created, int Skipped, int GroupsCreated, - IReadOnlyList Errors); + IReadOnlyList Errors) +{ + /// Sprint 32: товары, которые уже существовали локально с + /// тем же штрихкодом — merge-ились в них, а не создавали дубль. + /// Входит в как часть «записей (создано/обновлено)». + public int MergedByBarcode { get; init; } + + /// Sprint 32: товары МойСклада, которые пропущены как + /// archived=true. С флагом skipArchived=false остаётся 0. + public int SkippedArchivedInSource { get; init; } + + /// Sprint 32: штрихкоды, встреченные в самом МойСкладе + /// у нескольких товаров — импортируем первый, остальные пропускаем + /// (их имена и штрихкоды — в ). + public int SkippedDuplicateBarcodeInSource { get; init; } + + /// Sprint 32: групп обновлено (MS id совпал с существующей). + public int GroupsUpdated { get; init; } +} + +public record MoySkladImportOptions +{ + public bool OverwriteExisting { get; init; } = true; + /// Товары из архива MoySklad не импортировать совсем. + /// Соответствует требованию «архивные не льём». + public bool SkipArchived { get; init; } = true; + /// Если локально уже есть товар с таким же штрихкодом (а в + /// MoySklad он с другим external id) — обновлять его как merge, а + /// не создавать второй. + public bool MergeByBarcode { get; init; } = true; +} public class MoySkladImportService { @@ -45,12 +75,6 @@ public Task> TestConnectionAsync(string token, var orgId = organizationIdOverride ?? _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); - // MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — - // counterparty entity содержит только group (группа доступа), tags - // (произвольные), state (пользовательская цепочка статусов), companyType - // (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже - // этого поля нет — пусть пользователь сам решит. - static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType) => companyType switch { @@ -58,10 +82,6 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp _ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity, }; - // Загружаем существующих в память — обновлять будем по имени (case-insensitive). - // Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ - // поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже - // есть — обновляем ту же запись, иначе создаём. var existingByName = await _db.Counterparties .ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct); @@ -76,7 +96,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) { total++; if (progress is not null) progress.Total = total; - // Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty). try { @@ -113,7 +132,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) } if (batch > 0) await _db.SaveChangesAsync(ct); - // `created` в отчёте = вставки + апдейты (чтобы не ломать UI, который знает только Created). return new MoySkladImportResult(total, created + updated, skipped, 0, errors); } @@ -133,33 +151,59 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) entity.Notes = Trim(c.Description, 1000); } - public async Task ImportProductsAsync( + /// Backward-compat обёртка для старого API /admin/moysklad/import-products. + /// Делегирует к расширенной версии с дефолтными опциями. + public Task ImportProductsAsync( string token, bool overwriteExisting, CancellationToken ct, ImportJobProgress? progress = null, Guid? organizationIdOverride = null) + => ImportProductsAsync( + token, + new MoySkladImportOptions { OverwriteExisting = overwriteExisting }, + ct, progress, organizationIdOverride); + + public async Task ImportProductsAsync( + string token, + MoySkladImportOptions options, + CancellationToken ct, + ImportJobProgress? progress = null, + Guid? organizationIdOverride = null) { var orgId = organizationIdOverride ?? _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); - // Дефолт VAT — из страны организации (Country.VatRate), decimal(5,2). + // ------------------------------------------------------------------ + // Справочники — грузим один раз. + // ------------------------------------------------------------------ var defaultVat = await _db.Countries .Where(c => c.Code == (_db.Organizations .Where(o => o.Id == orgId).Select(o => o.CountryCode).First())) .Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m; - var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct) - ?? await _db.UnitsOfMeasure.FirstAsync(ct); + + var localUomsByCode = await _db.UnitsOfMeasure + .ToDictionaryAsync(u => u.Code, u => u.Id, StringComparer.OrdinalIgnoreCase, ct); + if (!localUomsByCode.TryGetValue("796", out var pieceUomId)) + pieceUomId = (await _db.UnitsOfMeasure.FirstAsync(ct)).Id; + if (!localUomsByCode.TryGetValue("166", out var kgUomId)) + kgUomId = pieceUomId; // fallback + var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct) ?? await _db.PriceTypes.FirstAsync(ct); var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct); - var countriesByName = await _db.Countries .IgnoreQueryFilters() .ToDictionaryAsync(c => c.Name, c => c.Id, ct); - // Дефолтная группа на случай, когда у товара в MoySklad нет productFolder. - var defaultGroup = await _db.ProductGroups.FirstOrDefaultAsync(g => g.Name == "Продукты питания", ct); + // MS UOM id → MS UOM code (чтобы потом смапить в наш UOM по code). + var msUomCodeById = (await _client.GetAllUomsAsync(token, ct)) + .Where(u => !string.IsNullOrEmpty(u.Id) && !string.IsNullOrEmpty(u.Code)) + .ToDictionary(u => u.Id!, u => u.Code!); + + // Дефолтная группа для товаров без folder'а в MoySklad. + var defaultGroup = await _db.ProductGroups.FirstOrDefaultAsync( + g => g.OrganizationId == orgId && g.Name == "Продукты питания", ct); if (defaultGroup is null) { defaultGroup = new ProductGroup @@ -173,113 +217,254 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) } var defaultGroupId = defaultGroup.Id; - // Import folders first — build flat then link parents. Архивные тоже берём, - // помечаем IsActive=false — у MoySklad у productfolder есть archived. + // ------------------------------------------------------------------ + // Группы — импорт/upsert по MoySkladExternalId. + // ------------------------------------------------------------------ var folders = await _client.GetAllFoldersAsync(token, ct); var localGroupByMsId = new Dictionary(); var groupsCreated = 0; + var groupsUpdated = 0; + + var existingGroupsByMsId = await _db.ProductGroups + .Where(g => g.OrganizationId == orgId && g.MoySkladExternalId != null) + .ToDictionaryAsync(g => g.MoySkladExternalId!, g => g, ct); + + // Для бэкона-ситуации (миграция с не-MS id'шных групп) — также + // индексируем по (ParentId, Name): если такая запись есть, подвязываем + // к ней MS id, не создавая вторую. + var existingGroupsByParentAndName = await _db.ProductGroups + .Where(g => g.OrganizationId == orgId) + .ToDictionaryAsync( + g => (g.ParentId, g.Name.ToLower()), + g => g, ct); + foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0)) { if (f.Id is null) continue; - var existing = await _db.ProductGroups.FirstOrDefaultAsync( - g => g.Name == f.Name && g.Path == (f.PathName ?? f.Name), ct); - if (existing is not null) + if (options.SkipArchived && f.Archived) continue; + + if (existingGroupsByMsId.TryGetValue(f.Id, out var existing)) { + existing.Name = f.Name; + existing.Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}"; localGroupByMsId[f.Id] = existing.Id; + groupsUpdated++; continue; } - var g = new ProductGroup + + // Pre-существующая группа без MS id, но с тем же именем в том же + // родителе — присваиваем MS id, не плодим дубль. + var parentMsId = f.ParentFolder?.Meta?.Href is { } ph && TryExtractId(ph) is { } pid ? pid : null; + var parentLocalId = parentMsId is not null && localGroupByMsId.TryGetValue(parentMsId, out var p) ? p : (Guid?)null; + if (existingGroupsByParentAndName.TryGetValue((parentLocalId, f.Name.ToLower()), out var sameName)) + { + sameName.MoySkladExternalId = f.Id; + sameName.Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}"; + existingGroupsByMsId[f.Id] = sameName; + localGroupByMsId[f.Id] = sameName.Id; + groupsUpdated++; + continue; + } + + var newGroup = new ProductGroup { OrganizationId = orgId, Name = f.Name, + ParentId = parentLocalId, Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}", + MoySkladExternalId = f.Id, }; - _db.ProductGroups.Add(g); - localGroupByMsId[f.Id] = g.Id; + _db.ProductGroups.Add(newGroup); + existingGroupsByMsId[f.Id] = newGroup; + existingGroupsByParentAndName[(parentLocalId, f.Name.ToLower())] = newGroup; + localGroupByMsId[f.Id] = newGroup.Id; groupsCreated++; } - if (groupsCreated > 0) await _db.SaveChangesAsync(ct); + if (groupsCreated > 0 || groupsUpdated > 0) await _db.SaveChangesAsync(ct); if (progress is not null) progress.GroupsCreated = groupsCreated; - // Import products + // ------------------------------------------------------------------ + // Товары — upsert по приоритету: MS id → barcode → article. + // ------------------------------------------------------------------ var errors = new List(); var created = 0; var updated = 0; + var mergedByBarcode = 0; var skipped = 0; + var skippedArchived = 0; + var skippedDupeBarcodeInMs = 0; var total = 0; - // При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие - // вместо создания дубликатов. Ключ = артикул (нормализованный). - var existingByArticle = await _db.Products - .Where(p => p.Article != null) - .ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct); - var existingBarcodeSet = new HashSet( - await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct)); - await foreach (var p in _client.StreamProductsAsync(token, ct)) + var existingByMsId = await _db.Products + .Where(p => p.OrganizationId == orgId && p.MoySkladExternalId != null) + .ToDictionaryAsync(p => p.MoySkladExternalId!, p => p, ct); + + var existingByArticle = await _db.Products + .Where(p => p.OrganizationId == orgId && p.Article != null) + .ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct); + + // Barcode → Product id (ограничиваем tenant'ом). Нужен для merge-логики. + var existingProductIdByBarcode = await _db.ProductBarcodes + .Where(b => b.OrganizationId == orgId) + .ToDictionaryAsync(b => b.Code, b => b.ProductId, ct); + + var existingBarcodeSet = new HashSet(existingProductIdByBarcode.Keys); + + // Дедуп внутри входа: один и тот же barcode встречается у нескольких + // товаров MoySklad → импортируем первый, остальные пропускаем в отчёт. + var seenBarcodesInInput = new Dictionary(); // barcode → первое имя товара + + await foreach (var p in _client.StreamProductsAsync(token, ct, includeArchived: !options.SkipArchived)) { total++; if (progress is not null) progress.Total = total; - // Архивных не пропускаем — импортируем как IsActive=false. - var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article; - var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article); - - if (alreadyByArticle && !overwriteExisting) + if (options.SkipArchived && p.Archived) { - skipped++; - if (progress is not null) progress.Skipped = skipped; + skippedArchived++; continue; } try { - // Vat товара: из MoySklad.vat если задано, иначе дефолт страны. - // VatEnabled: приоритет p.VatEnabled, fallback — «без НДС» если p.Vat=0. - var vat = p.Vat.HasValue ? (decimal)p.Vat.Value : defaultVat; - var vatEnabled = p.VatEnabled ?? (p.Vat is null || p.Vat.Value != 0); - Guid? groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId - && localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : null; - Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null; + var barcodes = ExtractBarcodes(p); + // 1) Если какой-то из barcodes этого товара уже встречался во + // входе под другим именем — пропускаем товар целиком. + var dupeInInput = barcodes + .Select(b => b.Code) + .FirstOrDefault(c => seenBarcodesInInput.ContainsKey(c) && seenBarcodesInInput[c] != p.Name); + if (dupeInInput is not null) + { + skippedDupeBarcodeInMs++; + errors.Add($"MS-дубль: товар «{p.Name}» имеет штрихкод {dupeInInput}, уже привязанный к «{seenBarcodesInInput[dupeInInput]}» — пропущен."); + if (progress is not null) progress.Errors = errors; + continue; + } + foreach (var b in barcodes) seenBarcodesInInput.TryAdd(b.Code, p.Name); + + // 2) Поиск существующей записи по приоритету. + Product? existing = null; + var mergeTag = ""; + if (p.Id is not null && existingByMsId.TryGetValue(p.Id, out var byMsId)) + { + existing = byMsId; + } + else if (options.MergeByBarcode) + { + foreach (var b in barcodes) + { + if (existingProductIdByBarcode.TryGetValue(b.Code, out var pid)) + { + existing = await _db.Products.FirstOrDefaultAsync(x => x.Id == pid, ct); + if (existing is not null) { mergeTag = "merge-barcode"; break; } + } + } + } + var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article; + if (existing is null && !string.IsNullOrWhiteSpace(article) && + existingByArticle.TryGetValue(article, out var byArt)) + { + existing = byArt; + mergeTag = "merge-article"; + } + + // 3) Common fields. + var vat = p.Vat.HasValue ? (decimal)p.Vat.Value : defaultVat; + // MoySklad useParentVat=true означает, что товар наследует НДС от + // группы — у нас такая иерархия не поддерживается, подставляем + // дефолт страны (как «без переопределения»). + var vatEnabled = p.VatEnabled ?? (p.Vat is null || p.Vat.Value != 0); + var groupId = p.ProductFolder?.Meta?.Href is { } href && TryExtractId(href) is { } msGroupId + && localGroupByMsId.TryGetValue(msGroupId, out var gId) ? gId : defaultGroupId; + Guid? countryId = p.Country?.Name is { } cn && countriesByName.TryGetValue(cn, out var cId) ? cId : null; + var uomId = ResolveUom(p, msUomCodeById, localUomsByCode, pieceUomId, kgUomId); var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true) ?? p.SalePrices?.FirstOrDefault(); - Product product; - if (alreadyByArticle && overwriteExisting) + if (existing is not null) { - product = existingByArticle[article!]; - // Обновляем только скалярные поля — коллекции (prices, barcodes) оставляем: - // там могут быть данные, которые редактировал пользователь после импорта. - product.Name = Trim(p.Name, 500); - product.Article = Trim(article, 500); - product.Description = p.Description; - product.Vat = vat; - product.VatEnabled = vatEnabled; - product.ProductGroupId = groupId ?? product.ProductGroupId; - product.CountryOfOriginId = countryId ?? product.CountryOfOriginId; - product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece; - product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED"; - product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m; - updated++; - if (progress is not null) progress.Updated = updated; + if (!options.OverwriteExisting && mergeTag == "") + { + skipped++; + if (progress is not null) progress.Skipped = skipped; + continue; + } + existing.Name = Trim(p.Name, 500) ?? existing.Name; + existing.Article = Trim(article, 500) ?? existing.Article; + existing.Description = p.Description; + existing.Vat = vat; + existing.VatEnabled = vatEnabled; + existing.ProductGroupId = groupId; + existing.UnitOfMeasureId = uomId; + existing.CountryOfOriginId = countryId ?? existing.CountryOfOriginId; + existing.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece; + existing.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED"; + existing.ReferencePrice = p.BuyPrice is null ? existing.ReferencePrice : p.BuyPrice.Value / 100m; + if (existing.PurchaseCurrencyId is null) existing.PurchaseCurrencyId = kzt.Id; + // Привязываем MS id в любом случае — это ключ будущих upsert'ов. + if (string.IsNullOrEmpty(existing.MoySkladExternalId)) + existing.MoySkladExternalId = p.Id; + + // Добавляем новые штрихкоды, оставляя уже существующие как есть. + foreach (var b in barcodes) + { + if (existingBarcodeSet.Contains(b.Code)) continue; + b.OrganizationId = orgId; + existing.Barcodes.Add(b); + existingBarcodeSet.Add(b.Code); + existingProductIdByBarcode[b.Code] = existing.Id; + } + + // Прайс retail: если у существующего товара нет цены этого типа — + // добавляем из MS. Если есть — оставляем (возможно, пользователь + // поправил). + if (retailPrice is not null) + { + var alreadyHas = await _db.ProductPrices.AnyAsync( + pp => pp.ProductId == existing.Id && pp.PriceTypeId == retailType.Id, ct); + if (!alreadyHas) + { + existing.Prices.Add(new ProductPrice + { + OrganizationId = orgId, + PriceTypeId = retailType.Id, + Amount = retailPrice.Value / 100m, + CurrencyId = kzt.Id, + }); + } + } + + if (mergeTag == "merge-barcode") + { + mergedByBarcode++; + } + else + { + updated++; + if (progress is not null) progress.Updated = updated; + } + if (p.Id is not null) existingByMsId[p.Id] = existing; + if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = existing; } else { - product = new Product + var product = new Product { OrganizationId = orgId, - Name = Trim(p.Name, 500), + Name = Trim(p.Name, 500) ?? "(без имени)", Article = Trim(article, 500), Description = p.Description, - UnitOfMeasureId = baseUnit.Id, + UnitOfMeasureId = uomId, Vat = vat, VatEnabled = vatEnabled, - ProductGroupId = groupId ?? defaultGroupId, + ProductGroupId = groupId, CountryOfOriginId = countryId, Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece, IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED", ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, PurchaseCurrencyId = kzt.Id, + MoySkladExternalId = p.Id, }; if (retailPrice is not null) @@ -293,7 +478,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) }); } - foreach (var b in ExtractBarcodes(p)) + foreach (var b in barcodes) { if (existingBarcodeSet.Contains(b.Code)) { @@ -301,19 +486,21 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) if (progress is not null) progress.Errors = errors; continue; } + b.OrganizationId = orgId; product.Barcodes.Add(b); existingBarcodeSet.Add(b.Code); } _db.Products.Add(product); + if (p.Id is not null) existingByMsId[p.Id] = product; if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product; + foreach (var b in product.Barcodes) existingProductIdByBarcode[b.Code] = product.Id; created++; if (progress is not null) progress.Created = created; } - // Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице - // мы сохранили как можно больше и смогли безопасно продолжить с overwrite. - if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct); + // Flush чаще — чтобы при обрыве сохранялось максимум. + if ((created + updated + mergedByBarcode) % 200 == 0) await _db.SaveChangesAsync(ct); } catch (Exception ex) { @@ -325,18 +512,52 @@ await foreach (var p in _client.StreamProductsAsync(token, ct)) await _db.SaveChangesAsync(ct); - // Финальная проверка дубликатов штрихкодов (исторические записи или - // расхождения c уникальным индексом). Только warning в errors[]. + // Финальная проверка дубликатов штрихкодов (на всякий, если + // уникальный индекс был нарушен исторически). var duplicates = await _db.ProductBarcodes + .Where(b => b.OrganizationId == orgId) .GroupBy(b => b.Code) .Where(g => g.Count() > 1) .Select(g => g.Key) .ToListAsync(ct); foreach (var dup in duplicates) - errors.Add($"Внимание: штрихкод {dup} привязан к нескольким товарам — почисти вручную."); + errors.Add($"Внимание: штрихкод {dup} привязан к нескольким товарам в БД."); if (progress is not null && duplicates.Count > 0) progress.Errors = errors; - return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, errors); + return new MoySkladImportResult( + total, + created + updated + mergedByBarcode, + skipped, + groupsCreated, + errors) + { + MergedByBarcode = mergedByBarcode, + SkippedArchivedInSource = skippedArchived, + SkippedDuplicateBarcodeInSource = skippedDupeBarcodeInMs, + GroupsUpdated = groupsUpdated, + }; + } + + private static Guid ResolveUom( + MsProduct p, + IReadOnlyDictionary msUomCodeById, + IReadOnlyDictionary localUomsByCode, + Guid pieceUomId, + Guid kgUomId) + { + // 1) Если weighed=true — всегда kg (у весовых в MS uom может быть что + // угодно, но у нас осмысленный default — кг). Нет кг? → штука. + if (p.Weighed) return kgUomId != Guid.Empty ? kgUomId : pieceUomId; + + // 2) Иначе по MS uom id → MS code → local code. + var msId = p.Uom?.Meta?.Href is { } href ? TryExtractId(href) : null; + if (msId is not null && msUomCodeById.TryGetValue(msId, out var code) + && localUomsByCode.TryGetValue(code, out var localId)) + { + return localId; + } + // 3) Fallback — штука. + return pieceUomId; } private static List ExtractBarcodes(MsProduct p) @@ -344,11 +565,15 @@ private static List ExtractBarcodes(MsProduct p) if (p.Barcodes is null) return []; var list = new List(); var primarySet = false; + var seen = new HashSet(StringComparer.Ordinal); foreach (var entry in p.Barcodes) { foreach (var (kind, code) in entry) { if (string.IsNullOrWhiteSpace(code)) continue; + // Дедуп внутри одного товара тоже — иногда MS возвращает один и + // тот же код дважды (ean13+code128 на одно значение). + if (!seen.Add(code)) continue; var type = kind switch { "ean13" => BarcodeType.Ean13, @@ -359,7 +584,12 @@ private static List ExtractBarcodes(MsProduct p) "upce" => BarcodeType.Upce, _ => BarcodeType.Other, }; - list.Add(new ProductBarcode { Code = code.Length > 500 ? code[..500] : code, Type = type, IsPrimary = !primarySet }); + list.Add(new ProductBarcode + { + Code = code.Length > 500 ? code[..500] : code, + Type = type, + IsPrimary = !primarySet, + }); primarySet = true; } } diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index 1e8c5d3..bf80441 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -115,6 +115,12 @@ private static void ConfigureProductGroup(EntityTypeBuilder b) .OnDelete(DeleteBehavior.Restrict); b.HasIndex(x => new { x.OrganizationId, x.ParentId }); b.HasIndex(x => new { x.OrganizationId, x.Path }); + // Sprint 32: upsert по внешнему id МойСклада — повторный импорт + // находит ту же группу, а не создаёт одноимённую вторую. + b.Property(x => x.MoySkladExternalId).HasMaxLength(64); + b.HasIndex(x => new { x.OrganizationId, x.MoySkladExternalId }) + .IsUnique() + .HasFilter("\"MoySkladExternalId\" IS NOT NULL"); } private static void ConfigurePriceType(EntityTypeBuilder b) @@ -158,6 +164,13 @@ private static void ConfigureProduct(EntityTypeBuilder b) // Sprint 19: partial index по IsArchived — listings default фильтруют // archived=false, селективность хорошая (архивных меньшинство). b.HasIndex(x => new { x.OrganizationId, x.IsArchived }); + // Sprint 32: внешний id МойСклада для идемпотентного upsert'а. + // Partial-уникальный индекс: NULL разрешён (товар создан вручную), + // но дважды тот же MS id в одной орге — нельзя. + b.Property(x => x.MoySkladExternalId).HasMaxLength(64); + b.HasIndex(x => new { x.OrganizationId, x.MoySkladExternalId }) + .IsUnique() + .HasFilter("\"MoySkladExternalId\" IS NOT NULL"); } private static void ConfigureProductPrice(EntityTypeBuilder b) diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261004210000_Phase32a_MoySkladExternalId.cs b/src/food-market.infrastructure/Persistence/Migrations/20261004210000_Phase32a_MoySkladExternalId.cs new file mode 100644 index 0000000..c128b01 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261004210000_Phase32a_MoySkladExternalId.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Sprint 32 — MoySkladExternalId на Product и ProductGroup. + /// + /// Хранит UUID сущности в МойСклад. Нужен, чтобы повторный импорт был + /// идемпотентным: находим существующую запись по MS id и обновляем, а + /// не создаём одноимённую вторую. Partial-уникальный индекс — NULL + /// разрешён (ручные записи), а задан — уникален в рамках org. + /// + /// Применяется идемпотентно через IF NOT EXISTS. + [DbContext(typeof(AppDbContext))] + [Migration("20261004210000_Phase32a_MoySkladExternalId")] + public partial class Phase32a_MoySkladExternalId : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.products + ADD COLUMN IF NOT EXISTS ""MoySkladExternalId"" varchar(64) NULL; + + ALTER TABLE public.product_groups + ADD COLUMN IF NOT EXISTS ""MoySkladExternalId"" varchar(64) NULL; + + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_products_OrganizationId_MoySkladExternalId"" + ON public.products (""OrganizationId"", ""MoySkladExternalId"") + WHERE ""MoySkladExternalId"" IS NOT NULL; + + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_product_groups_OrganizationId_MoySkladExternalId"" + ON public.product_groups (""OrganizationId"", ""MoySkladExternalId"") + WHERE ""MoySkladExternalId"" IS NOT NULL; + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + DROP INDEX IF EXISTS public.""IX_products_OrganizationId_MoySkladExternalId""; + DROP INDEX IF EXISTS public.""IX_product_groups_OrganizationId_MoySkladExternalId""; + ALTER TABLE public.products DROP COLUMN IF EXISTS ""MoySkladExternalId""; + ALTER TABLE public.product_groups DROP COLUMN IF EXISTS ""MoySkladExternalId""; + "); + } + } +}