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); 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) { var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); // MoySklad сам НЕ имеет встроенного "Supplier/Customer" поля у контрагентов. // Классификация обычно через теги ("Поставщик"/"Покупатель") или группы. Если их нет — // оставляем Unspecified, не выдумываем за пользователя. static foodmarket.Domain.Catalog.CounterpartyKind ResolveKind(IReadOnlyList? tags) { if (tags is null || tags.Count == 0) return foodmarket.Domain.Catalog.CounterpartyKind.Unspecified; var lower = tags.Select(t => t.ToLowerInvariant()).ToList(); var hasSupplier = lower.Any(t => t.Contains("постав")); var hasCustomer = lower.Any(t => t.Contains("покуп") || t.Contains("клиент")); if (hasSupplier && hasCustomer) return foodmarket.Domain.Catalog.CounterpartyKind.Both; if (hasSupplier) return foodmarket.Domain.Catalog.CounterpartyKind.Supplier; if (hasCustomer) return foodmarket.Domain.Catalog.CounterpartyKind.Customer; return foodmarket.Domain.Catalog.CounterpartyKind.Unspecified; } 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 .Select(c => new { c.Id, c.Name }) .ToDictionaryAsync(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase, ct); var created = 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 (c.Archived) { skipped++; continue; } if (existingByName.ContainsKey(c.Name) && !overwriteExisting) { skipped++; continue; } try { var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId, Name = Trim(c.Name, 255) ?? c.Name, LegalName = Trim(c.LegalTitle, 500), Kind = ResolveKind(c.Tags), Type = ResolveType(c.CompanyType), Bin = Trim(c.Inn, 20), TaxNumber = Trim(c.Kpp, 20), Phone = Trim(c.Phone, 50), Email = Trim(c.Email, 255), Address = Trim(c.ActualAddress ?? c.LegalAddress, 500), Notes = Trim(c.Description, 1000), IsActive = !c.Archived, }; _db.Counterparties.Add(entity); existingByName[c.Name] = entity.Id; created++; batch++; if (batch >= 500) { 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 (batch > 0) await _db.SaveChangesAsync(ct); return new MoySkladImportResult(total, created, skipped, 0, errors); } public async Task ImportProductsAsync( string token, bool overwriteExisting, CancellationToken ct) { var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); // Pre-load tenant defaults. var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct) ?? await _db.VatRates.FirstAsync(ct); var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct); var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, ct) ?? await _db.UnitsOfMeasure.FirstAsync(ct); 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); // Import folders first — build flat then link parents. 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)) { 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) { localGroupByMsId[f.Id] = existing.Id; continue; } var g = new ProductGroup { OrganizationId = orgId, Name = f.Name, Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}", IsActive = true, }; _db.ProductGroups.Add(g); localGroupByMsId[f.Id] = g.Id; groupsCreated++; } if (groupsCreated > 0) await _db.SaveChangesAsync(ct); // Import products var errors = new List(); var created = 0; var skipped = 0; var total = 0; var existingArticles = await _db.Products .Where(p => p.Article != null) .Select(p => p.Article!) .ToListAsync(ct); var existingArticleSet = new HashSet(existingArticles, StringComparer.OrdinalIgnoreCase); var existingBarcodeSet = new HashSet( await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct)); await foreach (var p in _client.StreamProductsAsync(token, ct)) { total++; if (p.Archived) { skipped++; continue; } var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article; var primaryBarcode = ExtractBarcodes(p).FirstOrDefault(); var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article); var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code); if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting) { skipped++; continue; } try { var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id; 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 retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true) ?? p.SalePrices?.FirstOrDefault(); var product = new Product { OrganizationId = orgId, Name = Trim(p.Name, 500), Article = Trim(article, 500), Description = p.Description, UnitOfMeasureId = baseUnit.Id, VatRateId = vatId, ProductGroupId = groupId, CountryOfOriginId = countryId, IsWeighed = p.Weighed, IsAlcohol = p.Alcoholic is not null, IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED", IsActive = !p.Archived, PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, PurchaseCurrencyId = kzt.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 ExtractBarcodes(p)) { if (existingBarcodeSet.Contains(b.Code)) continue; product.Barcodes.Add(b); existingBarcodeSet.Add(b.Code); } _db.Products.Add(product); if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article); created++; // Flush every 500 products to keep change tracker light. if (created % 500 == 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}"); } } await _db.SaveChangesAsync(ct); return new MoySkladImportResult(total, created, skipped, groupsCreated, errors); } private static List ExtractBarcodes(MsProduct p) { if (p.Barcodes is null) return []; var list = new List(); var primarySet = false; foreach (var entry in p.Barcodes) { foreach (var (kind, code) in entry) { if (string.IsNullOrWhiteSpace(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; } }