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>
217 lines
8.6 KiB
C#
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;
|
|
}
|
|
}
|