using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace foodmarket.Infrastructure.Integrations.MoySklad; public record MoySkladImportResult( int Total, int Created, int Skipped, int GroupsCreated, 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 { private readonly MoySkladClient _client; private readonly AppDbContext _db; private readonly ITenantContext _tenant; private readonly ILogger _log; public MoySkladImportService( MoySkladClient client, AppDbContext db, ITenantContext tenant, ILogger log) { _client = client; _db = db; _tenant = tenant; _log = log; } public Task> TestConnectionAsync(string token, CancellationToken ct) => _client.WhoAmIAsync(token, ct); public async Task ImportCounterpartiesAsync( string token, bool overwriteExisting, CancellationToken ct, ImportJobProgress? progress = null, Guid? organizationIdOverride = null) { var orgId = organizationIdOverride ?? _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType) => companyType switch { "individual" or "entrepreneur" => foodmarket.Domain.Catalog.CounterpartyType.Individual, _ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity, }; var existingByName = await _db.Counterparties .ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct); var created = 0; var updated = 0; var skipped = 0; var total = 0; var errors = new List(); var batch = 0; await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) { total++; if (progress is not null) progress.Total = total; try { if (existingByName.TryGetValue(c.Name, out var existing)) { if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; } ApplyCounterparty(existing, c, ResolveType); updated++; if (progress is not null) progress.Updated = updated; } else { var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId }; ApplyCounterparty(entity, c, ResolveType); _db.Counterparties.Add(entity); existingByName[c.Name] = entity; created++; if (progress is not null) progress.Created = created; } batch++; if (batch >= 100) { await _db.SaveChangesAsync(ct); batch = 0; } } catch (Exception ex) { _log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name); errors.Add($"{c.Name}: {ex.Message}"); if (progress is not null) progress.Errors = errors; } } if (batch > 0) await _db.SaveChangesAsync(ct); return new MoySkladImportResult(total, created + updated, skipped, 0, errors); } private static void ApplyCounterparty( foodmarket.Domain.Catalog.Counterparty entity, MsCounterparty c, Func resolveType) { entity.Name = Trim(c.Name, 255) ?? c.Name; entity.LegalName = Trim(c.LegalTitle, 500); entity.Type = resolveType(c.CompanyType); entity.Bin = Trim(c.Inn, 20); entity.TaxNumber = Trim(c.Kpp, 20); entity.Phone = Trim(c.Phone, 50); entity.Email = Trim(c.Email, 255); entity.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500); entity.Notes = Trim(c.Description, 1000); } /// 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."); // ------------------------------------------------------------------ // Справочники — грузим один раз. // ------------------------------------------------------------------ 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 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); // 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 { OrganizationId = orgId, Name = "Продукты питания", Path = "Продукты питания", }; _db.ProductGroups.Add(defaultGroup); await _db.SaveChangesAsync(ct); } var defaultGroupId = defaultGroup.Id; // ------------------------------------------------------------------ // Группы — импорт/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; 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; } // 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(newGroup); existingGroupsByMsId[f.Id] = newGroup; existingGroupsByParentAndName[(parentLocalId, f.Name.ToLower())] = newGroup; localGroupByMsId[f.Id] = newGroup.Id; groupsCreated++; } if (groupsCreated > 0 || groupsUpdated > 0) await _db.SaveChangesAsync(ct); if (progress is not null) progress.GroupsCreated = groupsCreated; // ------------------------------------------------------------------ // Товары — 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; 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; if (options.SkipArchived && p.Archived) { skippedArchived++; continue; } try { 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(); if (existing is not null) { 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 { var product = new Product { OrganizationId = orgId, Name = Trim(p.Name, 500) ?? "(без имени)", Article = Trim(article, 500), Description = p.Description, UnitOfMeasureId = uomId, Vat = vat, VatEnabled = vatEnabled, 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) { product.Prices.Add(new ProductPrice { OrganizationId = orgId, PriceTypeId = retailType.Id, Amount = retailPrice.Value / 100m, CurrencyId = kzt.Id, }); } foreach (var b in barcodes) { if (existingBarcodeSet.Contains(b.Code)) { errors.Add($"{p.Name}: штрихкод {b.Code} уже занят, пропущен."); 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 чаще — чтобы при обрыве сохранялось максимум. if ((created + updated + mergedByBarcode) % 200 == 0) await _db.SaveChangesAsync(ct); } catch (Exception ex) { _log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name); errors.Add($"{p.Name}: {ex.Message}"); if (progress is not null) progress.Errors = errors; } } await _db.SaveChangesAsync(ct); // Финальная проверка дубликатов штрихкодов (на всякий, если // уникальный индекс был нарушен исторически). 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} привязан к нескольким товарам в БД."); if (progress is not null && duplicates.Count > 0) progress.Errors = 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) { 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, "ean8" => BarcodeType.Ean8, "code128" => BarcodeType.Code128, "gtin" => BarcodeType.Ean13, "upca" => BarcodeType.Upca, "upce" => BarcodeType.Upce, _ => BarcodeType.Other, }; list.Add(new ProductBarcode { Code = code.Length > 500 ? code[..500] : code, Type = type, IsPrimary = !primarySet, }); primarySet = true; } } return list; } private static string? Trim(string? s, int max) => string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max]); private static string? TryExtractId(string href) { // href like "https://api.moysklad.ru/api/remap/1.2/entity/productfolder/" var lastSlash = href.LastIndexOf('/'); return lastSlash >= 0 ? href[(lastSlash + 1)..] : null; } }