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>
This commit is contained in:
nns 2026-10-04 23:22:14 +05:00
parent 17374c3943
commit 864d492c93
8 changed files with 490 additions and 87 deletions

View file

@ -0,0 +1,36 @@
# Catalog import МойСклад → «Демо-магазин» (stage)
**Решение владельца.** Стенд закрыт basic-auth'ом на gateway от
интернета (внутренняя сеть 192.168.1.x — без пароля), поэтому
можно залить полный каталог включая закупочные цены.
**Токен МойСклада.** `/var/www/sklad/appsettings.Production.json`,
ключ `MoySklad:Token`, забирается через ssh + sudo на prod-vm. В
логи / коммиты / docs / отчёты токен **никогда** не попадает.
Передаётся только через env-переменную или stdin.
**Границы.** Не трогаем проект `sklad`, прод `admin.food-market.kz`,
`global.json`, `deploy/*/nginx`.
## Чек-лист
- [ ] 1. Аудит текущего MoySklad-импорта + добор недостающих полей
для «полной карточки» (товары, группы, ед. изм., штрихкоды,
цены продажи/закупки, весовой признак, мин. остаток, артикул,
фото) — сверка полей против API МойСклад 1.2.
- [ ] 2. Импорт в «Демо-магазин» на стейдже без дублей:
upsert по внешнему id МойСклада; merge по штрихкоду; дубли
штрихкодов в самом МойСкладе — первый пропускаем в отчёт;
группы — тоже без дублей; архивные товары МойСклада не льём.
- [ ] 3. Токен у организации стенда после импорта удалить (или
импорт без сохранения токена — тоже ок).
- [ ] 4. Отчёт: группы/товары created/updated/merged/skipped +
выборочная сверка 10 карточек с МойСкладом.
- [ ] 5. Playwright-проверка каталога Демо-магазина изнутри сети:
открывается, поиск по названию и штрихкоду работает, карточка
показывает поля простого режима; повторный запуск импорта → 0
новых.
## Протокол выполнения
(заполняется построчно)

View file

@ -34,7 +34,11 @@ public class MoySkladImportController : ControllerBase
} }
public record TestRequest(string? Token = null); public record TestRequest(string? Token = null);
public record ImportRequest(string? Token = null, bool OverwriteExisting = false); public record ImportRequest(
string? Token = null,
bool OverwriteExisting = false,
bool SkipArchived = true,
bool MergeByBarcode = true);
public record SettingsDto(bool HasToken, string? Masked); public record SettingsDto(bool HasToken, string? Masked);
public record SettingsInput(string Token); public record SettingsInput(string Token);
@ -89,11 +93,25 @@ public async Task<ActionResult<object>> ImportProducts([FromBody] ImportRequest
var job = _jobs.Create("products"); var job = _jobs.Create("products");
job.Stage = "Подключение к MoySklad…"; job.Stage = "Подключение к MoySklad…";
var options = new MoySkladImportOptions
{
OverwriteExisting = req.OverwriteExisting,
SkipArchived = req.SkipArchived,
MergeByBarcode = req.MergeByBarcode,
};
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) => _ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
{ {
progress.Stage = "Импорт товаров…"; progress.Stage = "Импорт товаров…";
var result = await svc.ImportProductsAsync(token, req.OverwriteExisting, ctInner, progress); var result = await svc.ImportProductsAsync(token, options, ctInner, progress);
progress.Message = $"Готово: {result.Created} записей (создано/обновлено), {result.Skipped} пропущено, {result.GroupsCreated} групп."; // Sprint 32: расширенный summary — отдельно merge/skip-archived/skip-dupe.
progress.Message =
$"Готово: создано {result.Created - result.MergedByBarcode}," +
$" merge-barcode {result.MergedByBarcode}," +
$" пропущено {result.Skipped}," +
$" архивных пропущено {result.SkippedArchivedInSource}," +
$" дубль-штрихкод в MS {result.SkippedDuplicateBarcodeInSource}," +
$" групп создано {result.GroupsCreated}," +
$" групп обновлено {result.GroupsUpdated}.";
}, orgId); }, orgId);
return Ok(new { jobId = job.Id }); return Ok(new { jobId = job.Id });
} }

View file

@ -55,6 +55,12 @@ public class Product : TenantEntity
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage) public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
/// <summary>Внешний id сущности в MoySklad (UUID). Используется для
/// идемпотентного upsert'а при повторных импортах — повторный прогон
/// находит ту же запись и обновляет её, а не создаёт дубликат.
/// NULL для товаров, созданных пользователем вручную.</summary>
public string? MoySkladExternalId { get; set; }
/// <summary>Архивный товар. Скрывается из обычных списков (List default /// <summary>Архивный товар. Скрывается из обычных списков (List default
/// filter); попадает в отчёты по историческим данным, но не предлагается /// filter); попадает в отчёты по историческим данным, но не предлагается
/// в новых документах. Sprint 19: bulk-операция «Архивировать».</summary> /// в новых документах. Sprint 19: bulk-операция «Архивировать».</summary>

View file

@ -24,4 +24,10 @@ public class ProductGroup : Entity, IOptionalTenantEntity
/// предварительной настройки. Удалить или переименовать нельзя — иначе /// предварительной настройки. Удалить или переименовать нельзя — иначе
/// продукты осиротеют.</summary> /// продукты осиротеют.</summary>
public bool IsSystem { get; set; } public bool IsSystem { get; set; }
/// <summary>Внешний id группы в MoySklad (UUID productfolder). Нужен,
/// чтобы повторный импорт находил ту же группу (а не создавал
/// одноимённую «Молочные продукты №2»). NULL для групп, созданных
/// SuperAdmin'ом или пользователем вручную.</summary>
public string? MoySkladExternalId { get; set; }
} }

View file

@ -32,7 +32,15 @@ public MoySkladClient(HttpClient http)
private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token) private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token)
{ {
var req = new HttpRequestMessage(method, pathAndQuery); var req = new HttpRequestMessage(method, pathAndQuery);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); // МойСклад принимает оба варианта аутентификации:
// - Basic <base64(login:password)> — пароль пользователя кабинета
// - Bearer <access_token> — токен из «Сервис → Настройки → API»
// Авто-детект: если токен выглядит как base64 (один раз распарсился
// в login:password) — используем Basic, иначе Bearer. У сторонних
// проектов (sklad на prod-vm) токен хранится в Basic-формате, у
// новых users чаще bearer-токен из UI.
var scheme = LooksLikeBasicB64(token) ? "Basic" : "Bearer";
req.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
// MoySklad requires the exact literal "application/json;charset=utf-8" (no space // MoySklad requires the exact literal "application/json;charset=utf-8" (no space
// after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to // after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to
// "application/json; charset=utf-8" which MoySklad rejects with code 1062. // "application/json; charset=utf-8" which MoySklad rejects with code 1062.
@ -69,12 +77,21 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
public async IAsyncEnumerable<MsProduct> StreamProductsAsync( public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
string token, string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct,
bool includeArchived = true)
{ {
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p; await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: false, ct)) yield return p;
if (!includeArchived) yield break;
await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p; await foreach (var p in StreamPagedAsync<MsProduct>(token, "entity/product", archivedOnly: true, ct)) yield return p;
} }
public async Task<List<MsUom>> GetAllUomsAsync(string token, CancellationToken ct)
{
var all = new List<MsUom>();
await foreach (var u in StreamPagedAsync<MsUom>(token, "entity/uom", archivedOnly: false, ct)) all.Add(u);
return all;
}
public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync( public async IAsyncEnumerable<MsCounterparty> StreamCounterpartiesAsync(
string token, string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
@ -83,6 +100,33 @@ public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, C
await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c; await foreach (var c in StreamPagedAsync<MsCounterparty>(token, "entity/counterparty", archivedOnly: true, ct)) yield return c;
} }
/// <summary>Base64(login:password) выглядит как base64url-ish строка
/// без пробелов, при декодировании содержит ':' между логином и
/// паролем. У bearer'ов этот паттерн тоже встречается, но в них нет
/// base64 padding и декод в login:password обычно даёт мусор.</summary>
private static bool LooksLikeBasicB64(string token)
{
if (string.IsNullOrEmpty(token)) return false;
// Длина 36 чаще всего — base64(login:password) ~ 24..40 символов.
// Проверяем валидный base64 + наличие ':' после декода.
try
{
var padded = token;
// Padding, если потеряли.
var remainder = padded.Length % 4;
if (remainder == 2) padded += "==";
else if (remainder == 3) padded += "=";
else if (remainder == 1) return false;
var raw = Convert.FromBase64String(padded);
var text = System.Text.Encoding.UTF8.GetString(raw);
return text.Contains(':') && !text.Any(c => c is < (char)0x20 and not '\t');
}
catch
{
return false;
}
}
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct) public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
{ {
var all = new List<MsProductFolder>(); var all = new List<MsProductFolder>();

View file

@ -11,7 +11,37 @@ public record MoySkladImportResult(
int Created, int Created,
int Skipped, int Skipped,
int GroupsCreated, int GroupsCreated,
IReadOnlyList<string> Errors); 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 public class MoySkladImportService
{ {
@ -45,12 +75,6 @@ public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token,
var orgId = organizationIdOverride ?? _tenant.OrganizationId var orgId = organizationIdOverride ?? _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant organization in context."); ?? throw new InvalidOperationException("No tenant organization in context.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
// counterparty entity содержит только group (группа доступа), tags
// (произвольные), state (пользовательская цепочка статусов), companyType
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
// этого поля нет — пусть пользователь сам решит.
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType) static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch => companyType switch
{ {
@ -58,10 +82,6 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity, _ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
}; };
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
// есть — обновляем ту же запись, иначе создаём.
var existingByName = await _db.Counterparties var existingByName = await _db.Counterparties
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct); .ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
@ -76,7 +96,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{ {
total++; total++;
if (progress is not null) progress.Total = total; if (progress is not null) progress.Total = total;
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
try try
{ {
@ -113,7 +132,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
} }
if (batch > 0) await _db.SaveChangesAsync(ct); if (batch > 0) await _db.SaveChangesAsync(ct);
// `created` в отчёте = вставки + апдейты (чтобы не ломать UI, который знает только Created).
return new MoySkladImportResult(total, created + updated, skipped, 0, errors); return new MoySkladImportResult(total, created + updated, skipped, 0, errors);
} }
@ -133,33 +151,59 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
entity.Notes = Trim(c.Description, 1000); entity.Notes = Trim(c.Description, 1000);
} }
public async Task<MoySkladImportResult> ImportProductsAsync( /// <summary>Backward-compat обёртка для старого API /admin/moysklad/import-products.
/// Делегирует к расширенной версии с дефолтными опциями.</summary>
public Task<MoySkladImportResult> ImportProductsAsync(
string token, string token,
bool overwriteExisting, bool overwriteExisting,
CancellationToken ct, CancellationToken ct,
ImportJobProgress? progress = null, ImportJobProgress? progress = null,
Guid? organizationIdOverride = 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 var orgId = organizationIdOverride ?? _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant organization in context."); ?? throw new InvalidOperationException("No tenant organization in context.");
// Дефолт VAT — из страны организации (Country.VatRate), decimal(5,2). // ------------------------------------------------------------------
// Справочники — грузим один раз.
// ------------------------------------------------------------------
var defaultVat = await _db.Countries var defaultVat = await _db.Countries
.Where(c => c.Code == (_db.Organizations .Where(c => c.Code == (_db.Organizations
.Where(o => o.Id == orgId).Select(o => o.CountryCode).First())) .Where(o => o.Id == orgId).Select(o => o.CountryCode).First()))
.Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m; .Select(c => (decimal?)c.VatRate).FirstOrDefaultAsync(ct) ?? 16m;
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", ct)
?? await _db.UnitsOfMeasure.FirstAsync(ct); 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) var retailType = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsRetail, ct)
?? await _db.PriceTypes.FirstAsync(ct); ?? await _db.PriceTypes.FirstAsync(ct);
var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct); var kzt = await _db.Currencies.FirstAsync(c => c.Code == "KZT", ct);
var countriesByName = await _db.Countries var countriesByName = await _db.Countries
.IgnoreQueryFilters() .IgnoreQueryFilters()
.ToDictionaryAsync(c => c.Name, c => c.Id, ct); .ToDictionaryAsync(c => c.Name, c => c.Id, ct);
// Дефолтная группа на случай, когда у товара в MoySklad нет productFolder. // MS UOM id → MS UOM code (чтобы потом смапить в наш UOM по code).
var defaultGroup = await _db.ProductGroups.FirstOrDefaultAsync(g => g.Name == "Продукты питания", ct); 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) if (defaultGroup is null)
{ {
defaultGroup = new ProductGroup defaultGroup = new ProductGroup
@ -173,113 +217,254 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
} }
var defaultGroupId = defaultGroup.Id; var defaultGroupId = defaultGroup.Id;
// Import folders first — build flat then link parents. Архивные тоже берём, // ------------------------------------------------------------------
// помечаем IsActive=false — у MoySklad у productfolder есть archived. // Группы — импорт/upsert по MoySkladExternalId.
// ------------------------------------------------------------------
var folders = await _client.GetAllFoldersAsync(token, ct); var folders = await _client.GetAllFoldersAsync(token, ct);
var localGroupByMsId = new Dictionary<string, Guid>(); var localGroupByMsId = new Dictionary<string, Guid>();
var groupsCreated = 0; 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)) foreach (var f in folders.OrderBy(f => f.PathName?.Length ?? 0))
{ {
if (f.Id is null) continue; if (f.Id is null) continue;
var existing = await _db.ProductGroups.FirstOrDefaultAsync( if (options.SkipArchived && f.Archived) continue;
g => g.Name == f.Name && g.Path == (f.PathName ?? f.Name), ct);
if (existing is not null) 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; localGroupByMsId[f.Id] = existing.Id;
groupsUpdated++;
continue; continue;
} }
var g = new ProductGroup
// 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, OrganizationId = orgId,
Name = f.Name, Name = f.Name,
ParentId = parentLocalId,
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}", Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
MoySkladExternalId = f.Id,
}; };
_db.ProductGroups.Add(g); _db.ProductGroups.Add(newGroup);
localGroupByMsId[f.Id] = g.Id; existingGroupsByMsId[f.Id] = newGroup;
existingGroupsByParentAndName[(parentLocalId, f.Name.ToLower())] = newGroup;
localGroupByMsId[f.Id] = newGroup.Id;
groupsCreated++; groupsCreated++;
} }
if (groupsCreated > 0) await _db.SaveChangesAsync(ct); if (groupsCreated > 0 || groupsUpdated > 0) await _db.SaveChangesAsync(ct);
if (progress is not null) progress.GroupsCreated = groupsCreated; if (progress is not null) progress.GroupsCreated = groupsCreated;
// Import products // ------------------------------------------------------------------
// Товары — upsert по приоритету: MS id → barcode → article.
// ------------------------------------------------------------------
var errors = new List<string>(); var errors = new List<string>();
var created = 0; var created = 0;
var updated = 0; var updated = 0;
var mergedByBarcode = 0;
var skipped = 0; var skipped = 0;
var skippedArchived = 0;
var skippedDupeBarcodeInMs = 0;
var total = 0; var total = 0;
// При overwriteExisting=true загружаем товары целиком, чтобы обновлять существующие
// вместо создания дубликатов. Ключ = артикул (нормализованный).
var existingByArticle = await _db.Products
.Where(p => p.Article != null)
.ToDictionaryAsync(p => p.Article!, p => p, StringComparer.OrdinalIgnoreCase, ct);
var existingBarcodeSet = new HashSet<string>(
await _db.ProductBarcodes.Select(b => b.Code).ToListAsync(ct));
await foreach (var p in _client.StreamProductsAsync(token, ct)) 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++; total++;
if (progress is not null) progress.Total = total; if (progress is not null) progress.Total = total;
// Архивных не пропускаем — импортируем как IsActive=false.
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article; if (options.SkipArchived && p.Archived)
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);
if (alreadyByArticle && !overwriteExisting)
{ {
skipped++; skippedArchived++;
if (progress is not null) progress.Skipped = skipped;
continue; continue;
} }
try try
{ {
// Vat товара: из MoySklad.vat если задано, иначе дефолт страны. var barcodes = ExtractBarcodes(p);
// VatEnabled: приоритет p.VatEnabled, fallback — «без НДС» если p.Vat=0.
var vat = p.Vat.HasValue ? (decimal)p.Vat.Value : defaultVat;
var vatEnabled = p.VatEnabled ?? (p.Vat is null || p.Vat.Value != 0);
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;
// 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) var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
?? p.SalePrices?.FirstOrDefault(); ?? p.SalePrices?.FirstOrDefault();
Product product; if (existing is not null)
if (alreadyByArticle && overwriteExisting)
{ {
product = existingByArticle[article!]; if (!options.OverwriteExisting && mergeTag == "")
// Обновляем только скалярные поля — коллекции (prices, barcodes) оставляем: {
// там могут быть данные, которые редактировал пользователь после импорта. skipped++;
product.Name = Trim(p.Name, 500); if (progress is not null) progress.Skipped = skipped;
product.Article = Trim(article, 500); continue;
product.Description = p.Description; }
product.Vat = vat; existing.Name = Trim(p.Name, 500) ?? existing.Name;
product.VatEnabled = vatEnabled; existing.Article = Trim(article, 500) ?? existing.Article;
product.ProductGroupId = groupId ?? product.ProductGroupId; existing.Description = p.Description;
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId; existing.Vat = vat;
product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece; existing.VatEnabled = vatEnabled;
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED"; existing.ProductGroupId = groupId;
product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m; existing.UnitOfMeasureId = uomId;
updated++; existing.CountryOfOriginId = countryId ?? existing.CountryOfOriginId;
if (progress is not null) progress.Updated = updated; 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 else
{ {
product = new Product 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, OrganizationId = orgId,
Name = Trim(p.Name, 500), Name = Trim(p.Name, 500) ?? "(без имени)",
Article = Trim(article, 500), Article = Trim(article, 500),
Description = p.Description, Description = p.Description,
UnitOfMeasureId = baseUnit.Id, UnitOfMeasureId = uomId,
Vat = vat, Vat = vat,
VatEnabled = vatEnabled, VatEnabled = vatEnabled,
ProductGroupId = groupId ?? defaultGroupId, ProductGroupId = groupId,
CountryOfOriginId = countryId, CountryOfOriginId = countryId,
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece, Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED", IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
PurchaseCurrencyId = kzt.Id, PurchaseCurrencyId = kzt.Id,
MoySkladExternalId = p.Id,
}; };
if (retailPrice is not null) if (retailPrice is not null)
@ -293,7 +478,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
}); });
} }
foreach (var b in ExtractBarcodes(p)) foreach (var b in barcodes)
{ {
if (existingBarcodeSet.Contains(b.Code)) if (existingBarcodeSet.Contains(b.Code))
{ {
@ -301,19 +486,21 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
if (progress is not null) progress.Errors = errors; if (progress is not null) progress.Errors = errors;
continue; continue;
} }
b.OrganizationId = orgId;
product.Barcodes.Add(b); product.Barcodes.Add(b);
existingBarcodeSet.Add(b.Code); existingBarcodeSet.Add(b.Code);
} }
_db.Products.Add(product); _db.Products.Add(product);
if (p.Id is not null) existingByMsId[p.Id] = product;
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product; if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
foreach (var b in product.Barcodes) existingProductIdByBarcode[b.Code] = product.Id;
created++; created++;
if (progress is not null) progress.Created = created; if (progress is not null) progress.Created = created;
} }
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице // Flush чаще — чтобы при обрыве сохранялось максимум.
// мы сохранили как можно больше и смогли безопасно продолжить с overwrite. if ((created + updated + mergedByBarcode) % 200 == 0) await _db.SaveChangesAsync(ct);
if ((created + updated) % 100 == 0) await _db.SaveChangesAsync(ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -325,18 +512,52 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
// Финальная проверка дубликатов штрихкодов (исторические записи или // Финальная проверка дубликатов штрихкодов (на всякий, если
// расхождения c уникальным индексом). Только warning в errors[]. // уникальный индекс был нарушен исторически).
var duplicates = await _db.ProductBarcodes var duplicates = await _db.ProductBarcodes
.Where(b => b.OrganizationId == orgId)
.GroupBy(b => b.Code) .GroupBy(b => b.Code)
.Where(g => g.Count() > 1) .Where(g => g.Count() > 1)
.Select(g => g.Key) .Select(g => g.Key)
.ToListAsync(ct); .ToListAsync(ct);
foreach (var dup in duplicates) foreach (var dup in duplicates)
errors.Add($"Внимание: штрихкод {dup} привязан к нескольким товарам — почисти вручную."); errors.Add($"Внимание: штрихкод {dup} привязан к нескольким товарам в БД.");
if (progress is not null && duplicates.Count > 0) progress.Errors = errors; if (progress is not null && duplicates.Count > 0) progress.Errors = errors;
return new MoySkladImportResult(total, created + updated, skipped, groupsCreated, 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) private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
@ -344,11 +565,15 @@ private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
if (p.Barcodes is null) return []; if (p.Barcodes is null) return [];
var list = new List<ProductBarcode>(); var list = new List<ProductBarcode>();
var primarySet = false; var primarySet = false;
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in p.Barcodes) foreach (var entry in p.Barcodes)
{ {
foreach (var (kind, code) in entry) foreach (var (kind, code) in entry)
{ {
if (string.IsNullOrWhiteSpace(code)) continue; if (string.IsNullOrWhiteSpace(code)) continue;
// Дедуп внутри одного товара тоже — иногда MS возвращает один и
// тот же код дважды (ean13+code128 на одно значение).
if (!seen.Add(code)) continue;
var type = kind switch var type = kind switch
{ {
"ean13" => BarcodeType.Ean13, "ean13" => BarcodeType.Ean13,
@ -359,7 +584,12 @@ private static List<ProductBarcode> ExtractBarcodes(MsProduct p)
"upce" => BarcodeType.Upce, "upce" => BarcodeType.Upce,
_ => BarcodeType.Other, _ => BarcodeType.Other,
}; };
list.Add(new ProductBarcode { Code = code.Length > 500 ? code[..500] : code, Type = type, IsPrimary = !primarySet }); list.Add(new ProductBarcode
{
Code = code.Length > 500 ? code[..500] : code,
Type = type,
IsPrimary = !primarySet,
});
primarySet = true; primarySet = true;
} }
} }

View file

@ -115,6 +115,12 @@ private static void ConfigureProductGroup(EntityTypeBuilder<ProductGroup> b)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => new { x.OrganizationId, x.ParentId }); b.HasIndex(x => new { x.OrganizationId, x.ParentId });
b.HasIndex(x => new { x.OrganizationId, x.Path }); b.HasIndex(x => new { x.OrganizationId, x.Path });
// Sprint 32: upsert по внешнему id МойСклада — повторный импорт
// находит ту же группу, а не создаёт одноимённую вторую.
b.Property(x => x.MoySkladExternalId).HasMaxLength(64);
b.HasIndex(x => new { x.OrganizationId, x.MoySkladExternalId })
.IsUnique()
.HasFilter("\"MoySkladExternalId\" IS NOT NULL");
} }
private static void ConfigurePriceType(EntityTypeBuilder<PriceType> b) private static void ConfigurePriceType(EntityTypeBuilder<PriceType> b)
@ -158,6 +164,13 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
// Sprint 19: partial index по IsArchived — listings default фильтруют // Sprint 19: partial index по IsArchived — listings default фильтруют
// archived=false, селективность хорошая (архивных меньшинство). // archived=false, селективность хорошая (архивных меньшинство).
b.HasIndex(x => new { x.OrganizationId, x.IsArchived }); b.HasIndex(x => new { x.OrganizationId, x.IsArchived });
// Sprint 32: внешний id МойСклада для идемпотентного upsert'а.
// Partial-уникальный индекс: NULL разрешён (товар создан вручную),
// но дважды тот же MS id в одной орге — нельзя.
b.Property(x => x.MoySkladExternalId).HasMaxLength(64);
b.HasIndex(x => new { x.OrganizationId, x.MoySkladExternalId })
.IsUnique()
.HasFilter("\"MoySkladExternalId\" IS NOT NULL");
} }
private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b) private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b)

View file

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Sprint 32 — MoySkladExternalId на Product и ProductGroup.
///
/// Хранит UUID сущности в МойСклад. Нужен, чтобы повторный импорт был
/// идемпотентным: находим существующую запись по MS id и обновляем, а
/// не создаём одноимённую вторую. Partial-уникальный индекс — NULL
/// разрешён (ручные записи), а задан — уникален в рамках org.
///
/// Применяется идемпотентно через IF NOT EXISTS.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20261004210000_Phase32a_MoySkladExternalId")]
public partial class Phase32a_MoySkladExternalId : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql(@"
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS ""MoySkladExternalId"" varchar(64) NULL;
ALTER TABLE public.product_groups
ADD COLUMN IF NOT EXISTS ""MoySkladExternalId"" varchar(64) NULL;
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_products_OrganizationId_MoySkladExternalId""
ON public.products (""OrganizationId"", ""MoySkladExternalId"")
WHERE ""MoySkladExternalId"" IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_product_groups_OrganizationId_MoySkladExternalId""
ON public.product_groups (""OrganizationId"", ""MoySkladExternalId"")
WHERE ""MoySkladExternalId"" IS NOT NULL;
");
}
protected override void Down(MigrationBuilder b)
{
b.Sql(@"
DROP INDEX IF EXISTS public.""IX_products_OrganizationId_MoySkladExternalId"";
DROP INDEX IF EXISTS public.""IX_product_groups_OrganizationId_MoySkladExternalId"";
ALTER TABLE public.products DROP COLUMN IF EXISTS ""MoySkladExternalId"";
ALTER TABLE public.product_groups DROP COLUMN IF EXISTS ""MoySkladExternalId"";
");
}
}
}