food-market/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs
nns b79c71591d feat(phase3b): drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired
Domain + миграция Phase3b_PricingCleanup:
- DROP IsActive у products / product_groups / units_of_measure /
  counterparties / price_types (включая индекс
  IX_products_OrganizationId_IsActive). В этих сущностях концепт
  деактивации не оправдан — если товар/группа/единица/контрагент
  не нужны, их физически удаляют.
- DROP organizations.MultiplePriceTypesEnabled — раздел «Типы цен»
  всегда виден, отдельной настройки больше не нужно.
- ADD price_types.IsRequired bool default false — обязательность
  заполнения для каждого товара.
- ADD price_types.IsSystem bool default false — защищённая запись,
  не удаляется и IsRequired всегда true; имя редактируется.
  В каждой организации гарантируется одна системная запись
  «Розничная цена» (создаётся миграцией если её нет).
- ADD products.ShelfLifeDays integer NULL — срок годности.

API:
- ProductsController/UnitsOfMeasureController/ProductGroupsController/
  CounterpartiesController/PriceTypesController: убраны параметры
  isActive в фильтрах, sort-keys, DTO, Apply, Создании.
- Products проекция: вместо IsActive теперь ShelfLifeDays.
- PriceTypesController: 400 при попытке удалить системную запись;
  IsRequired у системной — всегда true, не меняется через PUT.
- recalc-retail / supply posting: дефолтный PriceType ищется по
  IsSystem → IsDefault → IsRetail → SortOrder → Name (без IsActive).
- OrgSettingsDto/Input — без MultiplePriceTypesEnabled.

Web:
- types.ts: убраны isActive у Product/ProductGroup/UnitOfMeasure/
  Counterparty/PriceType. PriceType пополнен isRequired/isSystem.
  Product получил shelfLifeDays.
- useOrgSettings: убрано multiplePriceTypesEnabled.
- AppLayout: меню «Типы цен» всегда видно.
- Pages (Counterparties/Units/ProductGroups/PriceTypes/ProductEdit/
  OrganizationSettings): сняты колонки/чекбоксы/поля «Активен»;
  удалён GroupMarkupsPage; в PriceTypesPage добавлен Lock-индикатор
  системной записи и блок-подсказка, кнопка удаления скрыта.
- DemoCatalogSeeder и OtherSystem-импортёр: больше не пишут IsActive.

UI-перекомпоновка карточки товара (Phase3b пп.6/9), Supply Posted-toggle,
PercentInput, ShelfLifeDays-фильтр и редизайн прайс-секции — отдельными
коммитами далее по плану.

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

379 lines
18 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);
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.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
// counterparty entity содержит только group (группа доступа), tags
// (произвольные), state (пользовательская цепочка статусов), companyType
// (legal/individual/entrepreneur). Никакого role/kind. Поэтому у нас тоже
// этого поля нет — пусть пользователь сам решит.
static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyType)
=> companyType switch
{
"individual" or "entrepreneur" => foodmarket.Domain.Catalog.CounterpartyType.Individual,
_ => foodmarket.Domain.Catalog.CounterpartyType.LegalEntity,
};
// Загружаем существующих в память — обновлять будем по имени (case-insensitive).
// Раньше при overwriteExisting=true бага: пропускалась проверка "skip" и ВСЕ
// поступающие записи добавлялись как новые, порождая дубли. Теперь если имя уже
// есть — обновляем ту же запись, иначе создаём.
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;
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
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);
// `created` в отчёте = вставки + апдейты (чтобы не ломать UI, который знает только Created).
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);
}
public async Task<MoySkladImportResult> ImportProductsAsync(
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.");
// Дефолт VAT — из страны организации (Country.VatRate), decimal(5,2).
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 baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.Code == "796", 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);
// Дефолтная группа на случай, когда у товара в MoySklad нет productFolder.
var defaultGroup = await _db.ProductGroups.FirstOrDefaultAsync(g => 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;
// Import folders first — build flat then link parents. Архивные тоже берём,
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
var folders = await _client.GetAllFoldersAsync(token, ct);
var localGroupByMsId = new Dictionary<string, Guid>();
var groupsCreated = 0;
foreach (var f in folders.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}",
};
_db.ProductGroups.Add(g);
localGroupByMsId[f.Id] = g.Id;
groupsCreated++;
}
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
if (progress is not null) progress.GroupsCreated = groupsCreated;
// Import products
var errors = new List<string>();
var created = 0;
var updated = 0;
var skipped = 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))
{
total++;
if (progress is not null) progress.Total = total;
// Архивных не пропускаем — импортируем как IsActive=false.
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingByArticle.ContainsKey(article);
if (alreadyByArticle && !overwriteExisting)
{
skipped++;
if (progress is not null) progress.Skipped = skipped;
continue;
}
try
{
// Vat товара: из MoySklad.vat если задано, иначе дефолт страны.
// 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;
var retailPrice = p.SalePrices?.FirstOrDefault(sp => sp.PriceType?.Name?.Contains("Розничная", StringComparison.OrdinalIgnoreCase) == true)
?? p.SalePrices?.FirstOrDefault();
Product product;
if (alreadyByArticle && overwriteExisting)
{
product = existingByArticle[article!];
// Обновляем только скалярные поля — коллекции (prices, barcodes) оставляем:
// там могут быть данные, которые редактировал пользователь после импорта.
product.Name = Trim(p.Name, 500);
product.Article = Trim(article, 500);
product.Description = p.Description;
product.Vat = vat;
product.VatEnabled = vatEnabled;
product.ProductGroupId = groupId ?? product.ProductGroupId;
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m;
updated++;
if (progress is not null) progress.Updated = updated;
}
else
{
product = new Product
{
OrganizationId = orgId,
Name = Trim(p.Name, 500),
Article = Trim(article, 500),
Description = p.Description,
UnitOfMeasureId = baseUnit.Id,
Vat = vat,
VatEnabled = vatEnabled,
ProductGroupId = groupId ?? defaultGroupId,
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,
};
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))
{
errors.Add($"{p.Name}: штрихкод {b.Code} уже занят, пропущен.");
if (progress is not null) progress.Errors = errors;
continue;
}
product.Barcodes.Add(b);
existingBarcodeSet.Add(b.Code);
}
_db.Products.Add(product);
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
created++;
if (progress is not null) progress.Created = created;
}
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
// мы сохранили как можно больше и смогли безопасно продолжить с overwrite.
if ((created + updated) % 100 == 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);
// Финальная проверка дубликатов штрихкодов (исторические записи или
// расхождения c уникальным индексом). Только warning в errors[].
var duplicates = await _db.ProductBarcodes
.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, 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;
}
}