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>
379 lines
18 KiB
C#
379 lines
18 KiB
C#
using foodmarket.Application.Common.Tenancy;
|
||
using foodmarket.Domain.Catalog;
|
||
using foodmarket.Infrastructure.Persistence;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace foodmarket.Infrastructure.Integrations.MoySklad;
|
||
|
||
public record MoySkladImportResult(
|
||
int Total,
|
||
int Created,
|
||
int Skipped,
|
||
int GroupsCreated,
|
||
IReadOnlyList<string> Errors);
|
||
|
||
public class MoySkladImportService
|
||
{
|
||
private readonly MoySkladClient _client;
|
||
private readonly AppDbContext _db;
|
||
private readonly ITenantContext _tenant;
|
||
private readonly ILogger<MoySkladImportService> _log;
|
||
|
||
public MoySkladImportService(
|
||
MoySkladClient client,
|
||
AppDbContext db,
|
||
ITenantContext tenant,
|
||
ILogger<MoySkladImportService> log)
|
||
{
|
||
_client = client;
|
||
_db = db;
|
||
_tenant = tenant;
|
||
_log = log;
|
||
}
|
||
|
||
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
|
||
=> _client.WhoAmIAsync(token, ct);
|
||
|
||
public async Task<MoySkladImportResult> 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;
|
||
}
|
||
}
|