Для полной заливки каталога МойСклада в «Демо-магазин» на стейдже переделал импорт по требованию «идемпотентный + без дублей». Схема: - Phase32a_MoySkladExternalId: VARCHAR(64) на products и product_groups + partial-unique index (OrgId, MoySkladExternalId) WHERE IS NOT NULL. NULL разрешён — товары, созданные вручную, без MS id. Клиент (MoySkladClient): - Auto-detect Basic vs Bearer: МойСклад принимает оба, но сторонние проекты (sklad на prod-vm) используют Base64(login:password). Строку распознаём как base64 с ':' в декоде → ставим Basic, иначе Bearer. - StreamProductsAsync принимает includeArchived (default true для back-compat; импортный путь передаёт false). - GetAllUomsAsync — грузит справочник единиц измерения один раз. Сервис (MoySkladImportService): - MoySkladImportOptions: OverwriteExisting / SkipArchived / MergeByBarcode. - Resolution order: MS id → barcode → article. Первый найденный апдейтится, не плодит дубль. - Группы — upsert по MS id; pre-существующие группы без MS id с тем же именем в том же родителе — подхватываются (MS id проставляется). - Товары из архива MoySklad (archived=true) полностью пропускаются (счётчик SkippedArchivedInSource в результате). - Barcode-дубли внутри MoySklad: первый встреченный выигрывает, остальные пропускаются с записью в Errors (счётчик SkippedDuplicateBarcodeInSource). - UOM: MS uom.id → MS uom.code → наш локальный Code. Весовые товары (weighed=true) всегда ставятся в «кг». - Расширенный MoySkladImportResult: Created / MergedByBarcode / SkippedArchivedInSource / SkippedDuplicateBarcodeInSource / GroupsUpdated. Контроллер (MoySkladImportController): - ImportRequest получает SkipArchived (default true), MergeByBarcode (default true). Токен по-прежнему принимается в body и НЕ сохраняется в Organizations.MoySkladToken, если передан в теле запроса. - Финальный Message прогресса собирает все счётчики. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
609 lines
28 KiB
C#
609 lines
28 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)
|
||
{
|
||
/// <summary>Sprint 32: товары, которые уже существовали локально с
|
||
/// тем же штрихкодом — merge-ились в них, а не создавали дубль.
|
||
/// Входит в <see cref="Created"/> как часть «записей (создано/обновлено)».</summary>
|
||
public int MergedByBarcode { get; init; }
|
||
|
||
/// <summary>Sprint 32: товары МойСклада, которые пропущены как
|
||
/// archived=true. С флагом <c>skipArchived=false</c> остаётся 0.</summary>
|
||
public int SkippedArchivedInSource { get; init; }
|
||
|
||
/// <summary>Sprint 32: штрихкоды, встреченные в самом МойСкладе
|
||
/// у нескольких товаров — импортируем первый, остальные пропускаем
|
||
/// (их имена и штрихкоды — в <see cref="Errors"/>).</summary>
|
||
public int SkippedDuplicateBarcodeInSource { get; init; }
|
||
|
||
/// <summary>Sprint 32: групп обновлено (MS id совпал с существующей).</summary>
|
||
public int GroupsUpdated { get; init; }
|
||
}
|
||
|
||
public record MoySkladImportOptions
|
||
{
|
||
public bool OverwriteExisting { get; init; } = true;
|
||
/// <summary>Товары из архива MoySklad не импортировать совсем.
|
||
/// Соответствует требованию «архивные не льём».</summary>
|
||
public bool SkipArchived { get; init; } = true;
|
||
/// <summary>Если локально уже есть товар с таким же штрихкодом (а в
|
||
/// MoySklad он с другим external id) — обновлять его как merge, а
|
||
/// не создавать второй.</summary>
|
||
public bool MergeByBarcode { get; init; } = true;
|
||
}
|
||
|
||
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> 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<string>();
|
||
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<string?, foodmarket.Domain.Catalog.CounterpartyType> 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);
|
||
}
|
||
|
||
/// <summary>Backward-compat обёртка для старого API /admin/moysklad/import-products.
|
||
/// Делегирует к расширенной версии с дефолтными опциями.</summary>
|
||
public Task<MoySkladImportResult> 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<MoySkladImportResult> 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<string, Guid>();
|
||
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<string>();
|
||
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<string>(existingProductIdByBarcode.Keys);
|
||
|
||
// Дедуп внутри входа: один и тот же barcode встречается у нескольких
|
||
// товаров MoySklad → импортируем первый, остальные пропускаем в отчёт.
|
||
var seenBarcodesInInput = new Dictionary<string, string>(); // 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<string, string> msUomCodeById,
|
||
IReadOnlyDictionary<string, Guid> 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<ProductBarcode> ExtractBarcodes(MsProduct p)
|
||
{
|
||
if (p.Barcodes is null) return [];
|
||
var list = new List<ProductBarcode>();
|
||
var primarySet = false;
|
||
var seen = new HashSet<string>(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/<guid>"
|
||
var lastSlash = href.LastIndexOf('/');
|
||
return lastSlash >= 0 ? href[(lastSlash + 1)..] : null;
|
||
}
|
||
}
|