food-market/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs
nurdotnet c47826e015 fix(catalog): widen Article + Barcode.Code to 500 chars for real-world catalogs
Import against a live MoySklad account crashed with PostgreSQL 22001 after
loading 21500/~N products: Article column was varchar(100), but some MoySklad
items have longer internal codes, and Barcode.Code needed to grow for future
GS1 DataMatrix / Честный ЗНАК tracking codes (up to ~300 chars).

- EF config: Product.Article 100 → 500, ProductBarcode.Code 100 → 500.
- Migration Phase1e_WidenArticleBarcode (applied to dev DB).
- Defensive Trim() in the MoySklad importer for Name/Article/Barcode so even
  future schema drift won't take the whole import down.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:15:00 +05:00

217 lines
8.6 KiB
C#

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<string> Errors);
public class MoySkladImportService
{
private readonly MoySkladClient _client;
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly ILogger<MoySkladImportService> _log;
public MoySkladImportService(
MoySkladClient client,
AppDbContext db,
ITenantContext tenant,
ILogger<MoySkladImportService> log)
{
_client = client;
_db = db;
_tenant = tenant;
_log = log;
}
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
=> _client.WhoAmIAsync(token, ct);
public async Task<MoySkladImportResult> 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<string, Guid>();
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<string>();
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<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
var existingBarcodeSet = new HashSet<string>(
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<ProductBarcode> ExtractBarcodes(MsProduct p)
{
if (p.Barcodes is null) return [];
var list = new List<ProductBarcode>();
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/<guid>"
var lastSlash = href.LastIndexOf('/');
return lastSlash >= 0 ? href[(lastSlash + 1)..] : null;
}
}