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 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; } }