food-market/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs
nns 864d492c93 feat(s32): MoySklad-импорт — upsert по external id, merge по barcode, skip archived, UOM-mapping
Для полной заливки каталога МойСклада в «Демо-магазин» на стейдже
переделал импорт по требованию «идемпотентный + без дублей».

Схема:
- 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>
2026-10-04 23:22:14 +05:00

609 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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