phase1e: MoySklad import integration (admin-only, per-request token, no persistence)

Infrastructure (foodmarket.Infrastructure.Integrations.MoySklad):
- MoySkladDtos: minimal shapes of products, folders, uom, prices, barcodes from JSON-API 1.2
- MoySkladClient: HttpClient wrapper with Bearer auth per call
  - WhoAmIAsync (GET entity/organization) for connection test
  - StreamProductsAsync (paginated 1000/page, IAsyncEnumerable)
  - GetAllFoldersAsync (all product folders in one go)
- MoySkladImportService: orchestrates the full import
  - Creates missing product folders with Path preserved
  - Maps MoySklad VAT percent → local VatRate (fallback to default)
  - Maps barcodes: ean13/ean8/code128/gtin/upca/upce → our BarcodeType enum
  - Extracts retail price from salePrices (prefers "Розничная"), divides kopeck→major
  - Extracts buyPrice → PurchasePrice
  - Skips existing products by article OR primary barcode (unless overwrite flag set)
  - Batch SaveChanges every 500 items to keep EF tracker light
  - Returns counts + per-item error list

API: POST /api/admin/moysklad/test  — returns org name if token valid
API: POST /api/admin/moysklad/import-products { token, overwriteExisting }
  — Authorize(Roles = "Admin,SuperAdmin")

Web: /admin/import/moysklad page
- Amber notice: token is not persisted (request-scope only), how to create
  a service token in moysklad.ru with read-only rights
- Test connection button + result banner
- Import button with "overwrite existing" checkbox
- Result panel with 4 counters + collapsible error list

Sidebar adds "Импорт" section with MoySklad link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-21 21:07:58 +05:00
parent 5af8f74b5e
commit 25f25f9171
8 changed files with 616 additions and 1 deletions

View file

@ -0,0 +1,40 @@
using foodmarket.Infrastructure.Integrations.MoySklad;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace foodmarket.Api.Controllers.Admin;
[ApiController]
[Authorize(Roles = "Admin,SuperAdmin")]
[Route("api/admin/moysklad")]
public class MoySkladImportController : ControllerBase
{
private readonly MoySkladImportService _svc;
public MoySkladImportController(MoySkladImportService svc) => _svc = svc;
public record TestRequest(string Token);
public record ImportRequest(string Token, bool OverwriteExisting = false);
[HttpPost("test")]
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Token))
return BadRequest(new { error = "Token is required." });
var org = await _svc.TestConnectionAsync(req.Token, ct);
return org is null
? Unauthorized(new { error = "Токен недействителен или нет доступа к API." })
: Ok(new { organization = org.Name, inn = org.Inn });
}
[HttpPost("import-products")]
public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.Token))
return BadRequest(new { error = "Token is required." });
var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct);
return result;
}
}

View file

@ -94,6 +94,10 @@
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
// MoySklad import integration
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>();
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
builder.Services.AddHostedService<OpenIddictClientSeeder>(); builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>(); builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>(); builder.Services.AddHostedService<DevDataSeeder>();

View file

@ -0,0 +1,78 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
// Thin HTTP wrapper over MoySklad JSON-API 1.2. Caller supplies the personal token per request
// — we never persist it.
public class MoySkladClient
{
private const string BaseUrl = "https://api.moysklad.ru/api/remap/1.2";
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly HttpClient _http;
public MoySkladClient(HttpClient http)
{
_http = http;
_http.BaseAddress ??= new Uri(BaseUrl);
_http.Timeout = TimeSpan.FromSeconds(90);
}
private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token)
{
var req = new HttpRequestMessage(method, pathAndQuery);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip"));
return req;
}
public async Task<MsOrganization?> WhoAmIAsync(string token, CancellationToken ct)
{
using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token);
using var res = await _http.SendAsync(req, ct);
if (!res.IsSuccessStatusCode) return null;
var list = await res.Content.ReadFromJsonAsync<MsListResponse<MsOrganization>>(Json, ct);
return list?.Rows.FirstOrDefault();
}
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
string token,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 1000;
var offset = 0;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/product?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProduct>>(Json, ct);
if (page is null || page.Rows.Count == 0) yield break;
foreach (var p in page.Rows) yield return p;
if (page.Rows.Count < pageSize) yield break;
offset += pageSize;
}
}
public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, CancellationToken ct)
{
var all = new List<MsProductFolder>();
var offset = 0;
const int pageSize = 1000;
while (true)
{
using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", token);
using var res = await _http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
var page = await res.Content.ReadFromJsonAsync<MsListResponse<MsProductFolder>>(Json, ct);
if (page is null || page.Rows.Count == 0) break;
all.AddRange(page.Rows);
if (page.Rows.Count < pageSize) break;
offset += pageSize;
}
return all;
}
}

View file

@ -0,0 +1,127 @@
using System.Text.Json.Serialization;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
// Minimal MoySklad JSON-API response shapes we need for read-only product import.
// See https://dev.moysklad.ru/doc/api/remap/1.2/ — we intentionally ignore fields we don't consume.
public class MsListResponse<T>
{
[JsonPropertyName("meta")] public MsListMeta? Meta { get; set; }
[JsonPropertyName("rows")] public List<T> Rows { get; set; } = [];
}
public class MsListMeta
{
[JsonPropertyName("size")] public int Size { get; set; }
[JsonPropertyName("limit")] public int Limit { get; set; }
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("href")] public string? Href { get; set; }
}
public class MsMeta
{
[JsonPropertyName("href")] public string? Href { get; set; }
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("uuidHref")] public string? UuidHref { get; set; }
}
public class MsMetaWrapper
{
[JsonPropertyName("meta")] public MsMeta? Meta { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
}
public class MsOrganization
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("inn")] public string? Inn { get; set; }
[JsonPropertyName("kpp")] public string? Kpp { get; set; }
}
public class MsProduct
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("article")] public string? Article { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("weighed")] public bool Weighed { get; set; }
[JsonPropertyName("vat")] public int? Vat { get; set; }
[JsonPropertyName("vatEnabled")] public bool? VatEnabled { get; set; }
[JsonPropertyName("archived")] public bool Archived { get; set; }
[JsonPropertyName("barcodes")] public List<Dictionary<string, string>>? Barcodes { get; set; }
[JsonPropertyName("salePrices")] public List<MsSalePrice>? SalePrices { get; set; }
[JsonPropertyName("buyPrice")] public MsMoney? BuyPrice { get; set; }
[JsonPropertyName("minPrice")] public MsMoney? MinPrice { get; set; }
[JsonPropertyName("uom")] public MsMetaWrapper? Uom { get; set; }
[JsonPropertyName("productFolder")] public MsMetaWrapper? ProductFolder { get; set; }
[JsonPropertyName("country")] public MsMetaWrapper? Country { get; set; }
[JsonPropertyName("alcoholic")] public MsAlcoholic? Alcoholic { get; set; }
[JsonPropertyName("tnved")] public string? Tnved { get; set; }
[JsonPropertyName("trackingType")] public string? TrackingType { get; set; }
}
public class MsSalePrice
{
[JsonPropertyName("value")] public long Value { get; set; } // minor units (копейки/тиын)
[JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; }
[JsonPropertyName("priceType")] public MsPriceType? PriceType { get; set; }
}
public class MsPriceType
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("externalCode")] public string? ExternalCode { get; set; }
}
public class MsMoney
{
[JsonPropertyName("value")] public long Value { get; set; }
[JsonPropertyName("currency")] public MsMetaWrapper? Currency { get; set; }
}
public class MsAlcoholic
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("strength")] public double? Strength { get; set; }
}
public class MsCurrency
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("isoCode")] public string? IsoCode { get; set; }
[JsonPropertyName("rate")] public double? Rate { get; set; }
}
public class MsUom
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
}
public class MsProductFolder
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("pathName")] public string? PathName { get; set; }
[JsonPropertyName("productFolder")] public MsMetaWrapper? ParentFolder { get; set; }
[JsonPropertyName("archived")] public bool Archived { get; set; }
}
public class MsCountry
{
[JsonPropertyName("id")] public string? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("externalCode")] public string? ExternalCode { get; set; }
}

View file

@ -0,0 +1,215 @@
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 async Task<MsOrganization?> TestConnectionAsync(string token, CancellationToken ct)
{
return await _client.WhoAmIAsync(token, ct);
}
public async Task<MoySkladImportResult> ImportProductsAsync(
string token,
bool overwriteExisting,
CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context.");
// Pre-load tenant defaults.
var defaultVat = await _db.VatRates.FirstOrDefaultAsync(v => v.IsDefault, ct)
?? await _db.VatRates.FirstAsync(ct);
var vatByPercent = await _db.VatRates.ToDictionaryAsync(v => (int)v.Percent, v => v.Id, ct);
var baseUnit = await _db.UnitsOfMeasure.FirstOrDefaultAsync(u => u.IsBase, 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);
// Import folders first — build flat then link parents.
var folders = await _client.GetAllFoldersAsync(token, ct);
var localGroupByMsId = new Dictionary<string, Guid>();
var groupsCreated = 0;
foreach (var f in folders.Where(f => !f.Archived).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}",
IsActive = true,
};
_db.ProductGroups.Add(g);
localGroupByMsId[f.Id] = g.Id;
groupsCreated++;
}
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
// Import products
var errors = new List<string>();
var created = 0;
var skipped = 0;
var total = 0;
var existingArticles = await _db.Products
.Where(p => p.Article != null)
.Select(p => p.Article!)
.ToListAsync(ct);
var existingArticleSet = new HashSet<string>(existingArticles, StringComparer.OrdinalIgnoreCase);
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 (p.Archived) { skipped++; continue; }
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
var primaryBarcode = ExtractBarcodes(p).FirstOrDefault();
var alreadyByArticle = !string.IsNullOrWhiteSpace(article) && existingArticleSet.Contains(article);
var alreadyByBarcode = primaryBarcode is not null && existingBarcodeSet.Contains(primaryBarcode.Code);
if ((alreadyByArticle || alreadyByBarcode) && !overwriteExisting)
{
skipped++;
continue;
}
try
{
var vatId = p.Vat is int vp && vatByPercent.TryGetValue(vp, out var id) ? id : defaultVat.Id;
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();
var product = new Product
{
OrganizationId = orgId,
Name = p.Name,
Article = article,
Description = p.Description,
UnitOfMeasureId = baseUnit.Id,
VatRateId = vatId,
ProductGroupId = groupId,
CountryOfOriginId = countryId,
IsWeighed = p.Weighed,
IsAlcohol = p.Alcoholic is not null,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived,
PurchasePrice = 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)) continue;
product.Barcodes.Add(b);
existingBarcodeSet.Add(b.Code);
}
_db.Products.Add(product);
if (!string.IsNullOrWhiteSpace(article)) existingArticleSet.Add(article);
created++;
// Flush every 500 products to keep change tracker light.
if (created % 500 == 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}");
}
}
await _db.SaveChangesAsync(ct);
return new MoySkladImportResult(total, created, 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, Type = type, IsPrimary = !primarySet });
primarySet = true;
}
}
return list;
}
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;
}
}

View file

@ -13,6 +13,7 @@ import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
import { CounterpartiesPage } from '@/pages/CounterpartiesPage' import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
import { ProductsPage } from '@/pages/ProductsPage' import { ProductsPage } from '@/pages/ProductsPage'
import { ProductEditPage } from '@/pages/ProductEditPage' import { ProductEditPage } from '@/pages/ProductEditPage'
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
@ -46,6 +47,7 @@ export default function App() {
<Route path="/catalog/retail-points" element={<RetailPointsPage />} /> <Route path="/catalog/retail-points" element={<RetailPointsPage />} />
<Route path="/catalog/countries" element={<CountriesPage />} /> <Route path="/catalog/countries" element={<CountriesPage />} />
<Route path="/catalog/currencies" element={<CurrenciesPage />} /> <Route path="/catalog/currencies" element={<CurrenciesPage />} />
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -5,7 +5,7 @@ import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag, LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
@ -39,6 +39,9 @@ const nav = [
{ to: '/catalog/countries', icon: Globe, label: 'Страны' }, { to: '/catalog/countries', icon: Globe, label: 'Страны' },
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' }, { to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
]}, ]},
{ group: 'Импорт', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },
]},
] as const ] as const
export function AppLayout() { export function AppLayout() {

View file

@ -0,0 +1,146 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { AlertCircle, CheckCircle, Download, KeyRound } from 'lucide-react'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/Button'
import { Field, TextInput, Checkbox } from '@/components/Field'
interface TestResponse { organization: string; inn?: string | null }
interface ImportResponse {
total: number
created: number
skipped: number
groupsCreated: number
errors: string[]
}
export function MoySkladImportPage() {
const qc = useQueryClient()
const [token, setToken] = useState('')
const [overwrite, setOverwrite] = useState(false)
const test = useMutation({
mutationFn: async () => (await api.post<TestResponse>('/api/admin/moysklad/test', { token })).data,
})
const run = useMutation({
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
})
return (
<div className="p-6 max-w-3xl">
<PageHeader
title="Импорт из МойСклад"
description="Перенос товаров, групп и цен из учётной записи МойСклад в food-market."
/>
<section className="mb-5 rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-900/50 p-3.5 text-sm">
<div className="flex gap-2.5 items-start">
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-amber-900 dark:text-amber-200 space-y-1.5">
<p><strong>Токен не сохраняется</strong> передаётся только в текущий запрос и не пишется ни в БД, ни в логи.</p>
<p>Получить токен: <a className="underline" href="https://online.moysklad.ru/app/#admin" target="_blank" rel="noreferrer">online.moysklad.ru/app</a> Настройки аккаунта Сервисный аккаунт создать токен (read-only прав достаточно).</p>
<p>Рекомендуется отдельный сервисный аккаунт с правом только «Просмотр: Товары».</p>
</div>
</div>
</section>
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<Field label="Токен МойСклад (Bearer)">
<TextInput
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="персональный токен или токен сервисного аккаунта"
autoComplete="off"
spellCheck={false}
/>
</Field>
<div className="flex gap-2 items-center">
<Button
variant="secondary"
onClick={() => test.mutate()}
disabled={!token || test.isPending}
>
<KeyRound className="w-4 h-4" />
{test.isPending ? 'Проверяю…' : 'Проверить соединение'}
</Button>
{test.data && (
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" />
Подключено: <strong>{test.data.organization}</strong>
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
</div>
)}
{test.error && (
<div className="text-sm text-red-600">
{(test.error as Error).message}
</div>
)}
</div>
<div className="pt-2 border-t border-slate-200 dark:border-slate-800 space-y-3">
<Checkbox
label="Перезаписать существующие товары (по артикулу/штрихкоду)"
checked={overwrite}
onChange={setOverwrite}
/>
<Button
onClick={() => run.mutate()}
disabled={!token || run.isPending}
>
<Download className="w-4 h-4" />
{run.isPending ? 'Импортирую… (может занять минуты)' : 'Импортировать товары'}
</Button>
</div>
</section>
{run.data && (
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<h3 className="text-sm font-semibold mb-3 text-slate-900 dark:text-slate-100 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" /> Импорт завершён
</h3>
<dl className="grid grid-cols-4 gap-3 text-sm">
<div className="rounded-lg bg-slate-50 dark:bg-slate-800/50 p-3">
<dt className="text-xs text-slate-500 uppercase">Всего получено</dt>
<dd className="text-xl font-semibold mt-1">{run.data.total}</dd>
</div>
<div className="rounded-lg bg-green-50 dark:bg-green-950/30 p-3">
<dt className="text-xs text-green-700 dark:text-green-400 uppercase">Создано</dt>
<dd className="text-xl font-semibold mt-1 text-green-700 dark:text-green-400">{run.data.created}</dd>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800/50 p-3">
<dt className="text-xs text-slate-500 uppercase">Пропущено</dt>
<dd className="text-xl font-semibold mt-1">{run.data.skipped}</dd>
</div>
<div className="rounded-lg bg-slate-50 dark:bg-slate-800/50 p-3">
<dt className="text-xs text-slate-500 uppercase">Групп создано</dt>
<dd className="text-xl font-semibold mt-1">{run.data.groupsCreated}</dd>
</div>
</dl>
{run.data.errors.length > 0 && (
<details className="mt-4">
<summary className="text-sm text-red-600 cursor-pointer">
Ошибок: {run.data.errors.length} (развернуть)
</summary>
<ul className="mt-2 text-xs font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-80 overflow-auto">
{run.data.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</details>
)}
</section>
)}
{run.error && (
<div className="mt-5 p-3.5 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900 text-sm text-red-700 dark:text-red-300">
Ошибка: {(run.error as Error).message}
</div>
)}
</div>
)
}