feat(moysklad-import): async jobs с прогрессом + токен в настройках
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 36s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 42s
Docker Images / Web image (push) Successful in 25s
Docker Images / Deploy stage (push) Successful in 18s

Pain points:
1. Импорт на ~30k товарах проходит 15-30 мин, nginx рвал на 60s → 504.
2. При импорте/очистке ничего не видно — ни счётчика, ни прогресса.
3. Токен приходилось вводить каждый раз вручную.

Фиксы:
- Async-job pattern: POST /api/admin/moysklad/import-products и
  /api/admin/cleanup/all/async возвращают jobId, реальная работа
  в Task.Run. GET /api/admin/jobs/{id} — статус +
  Total/Created/Updated/Skipped/Deleted/Stage/Message.
- ImportJobRegistry (singleton, in-memory) — хранит job-progress.
- MoySkladImportService обновляет progress по мере пейджинга
  (в т.ч. счётчик Created/Updated/Skipped).
- Cleanup разбит на именованные шаги, Stage меняется по мере
  "Товары…" → "Группы…" → "Контрагенты…".
- Токен per-organization: Organization.MoySkladToken + миграция
  Phase3_OrganizationMoySkladToken. Endpoints:
  GET/PUT /api/admin/moysklad/settings.
- Импорт-endpoints больше не требуют token в теле — берут из org.
- HttpContextTenantContext.UseOverride(orgId) — AsyncLocal-scope
  для background tasks (HttpContext там нет, а query-filter'у нужен
  orgId — ставим через override).

Nginx (host + web-container) получил 60-минутный timeout на
/api/admin/import/ чтобы старый sync-путь тоже не ронять (на
случай если кто-то вернёт sync call).

Web:
- MoySkladImportPage переработан: блок "Токен API" (save/test
  mask), блок импорта с кнопками без поля токена.
- JobCard с polling каждые 1.5s отображает живые счётчики и stage.
- DangerZone тоже теперь async с live-прогрессом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-23 23:49:11 +05:00
parent bd15854b42
commit 2fc6d207f3
14 changed files with 2449 additions and 206 deletions

View file

@ -3,6 +3,21 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Long-running admin imports (MoySklad etc.) read from upstream for tens of
# minutes. Bump timeouts only on that path so normal API stays snappy.
location /api/admin/import/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60m;
proxy_send_timeout 60m;
proxy_request_buffering off;
proxy_buffering off;
}
# API reverse-proxy upstream name "api" resolves in the compose network. # API reverse-proxy upstream name "api" resolves in the compose network.
location /api/ { location /api/ {
proxy_pass http://api:8080; proxy_pass http://api:8080;

View file

@ -1,3 +1,6 @@
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Integrations.MoySklad;
using foodmarket.Infrastructure.Persistence; using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -13,8 +16,21 @@ namespace foodmarket.Api.Controllers.Admin;
public class AdminCleanupController : ControllerBase public class AdminCleanupController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IServiceScopeFactory _scopes;
private readonly ImportJobRegistry _jobs;
private readonly ITenantContext _tenant;
public AdminCleanupController(AppDbContext db) => _db = db; public AdminCleanupController(
AppDbContext db,
IServiceScopeFactory scopes,
ImportJobRegistry jobs,
ITenantContext tenant)
{
_db = db;
_scopes = scopes;
_jobs = jobs;
_tenant = tenant;
}
public record CleanupStats( public record CleanupStats(
int Counterparties, int Counterparties,
@ -72,6 +88,61 @@ await _db.StockMovements
return new CleanupResult("counterparties", Diff(before, after)); return new CleanupResult("counterparties", Diff(before, after));
} }
// Async-версия полной очистки: возвращает jobId, реальные DELETE в фоне, прогресс в Stage+Deleted.
[HttpPost("all/async")]
public ActionResult<object> WipeAllAsync()
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var job = _jobs.Create("cleanup-all");
job.Stage = "Подготовка…";
_ = Task.Run(async () =>
{
try
{
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
using var scope = _scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var steps = new (string Stage, Func<Task<int>> Run)[]
{
("Движения склада", () => db.StockMovements.ExecuteDeleteAsync()),
("Остатки", () => db.Stocks.ExecuteDeleteAsync()),
("Строки поставок", () => db.SupplyLines.ExecuteDeleteAsync()),
("Поставки", () => db.Supplies.ExecuteDeleteAsync()),
("Строки продаж", () => db.RetailSaleLines.ExecuteDeleteAsync()),
("Продажи", () => db.RetailSales.ExecuteDeleteAsync()),
("Изображения товаров", () => db.ProductImages.ExecuteDeleteAsync()),
("Цены товаров", () => db.ProductPrices.ExecuteDeleteAsync()),
("Штрихкоды", () => db.ProductBarcodes.ExecuteDeleteAsync()),
("Товары", () => db.Products.ExecuteDeleteAsync()),
("Группы товаров", () => db.ProductGroups.ExecuteDeleteAsync()),
("Контрагенты", () => db.Counterparties.ExecuteDeleteAsync()),
};
foreach (var (stage, run) in steps)
{
job.Stage = $"Удаление: {stage}…";
job.Deleted += await run();
}
job.Stage = "Готово";
job.Message = $"Удалено записей: {job.Deleted}.";
job.Status = ImportJobStatus.Succeeded;
}
catch (Exception ex)
{
job.Status = ImportJobStatus.Failed;
job.Message = ex.Message;
job.Errors.Add(ex.ToString());
}
finally
{
job.FinishedAt = DateTime.UtcNow;
}
});
return Ok(new { jobId = job.Id });
}
/// <summary>Полная очистка данных текущей организации — всё кроме настроек: /// <summary>Полная очистка данных текущей организации — всё кроме настроек:
/// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure, /// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure,
/// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty, /// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty,

View file

@ -0,0 +1,41 @@
using foodmarket.Infrastructure.Integrations.MoySklad;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace foodmarket.Api.Controllers.Admin;
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/jobs")]
public class AdminJobsController : ControllerBase
{
private readonly ImportJobRegistry _jobs;
public AdminJobsController(ImportJobRegistry jobs) => _jobs = jobs;
public record JobView(
Guid Id,
string Kind,
string Status,
string? Stage,
DateTime StartedAt,
DateTime? FinishedAt,
int Total, int Created, int Updated, int Skipped, int Deleted, int GroupsCreated,
string? Message,
IReadOnlyList<string> Errors);
private static JobView Project(ImportJobProgress j) => new(
j.Id, j.Kind, j.Status.ToString(), j.Stage, j.StartedAt, j.FinishedAt,
j.Total, j.Created, j.Updated, j.Skipped, j.Deleted, j.GroupsCreated,
j.Message, j.Errors.TakeLast(20).ToList());
[HttpGet("{id:guid}")]
public ActionResult<JobView> Get(Guid id)
{
var j = _jobs.Get(id);
return j is null ? NotFound() : Project(j);
}
[HttpGet("recent")]
public IReadOnlyList<JobView> Recent([FromQuery] int take = 10)
=> _jobs.RecentlyFinished(take).Select(Project).ToList();
}

View file

@ -1,6 +1,10 @@
using foodmarket.Api.Infrastructure.Tenancy;
using foodmarket.Application.Common.Tenancy;
using foodmarket.Infrastructure.Integrations.MoySklad; using foodmarket.Infrastructure.Integrations.MoySklad;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Admin; namespace foodmarket.Api.Controllers.Admin;
@ -9,20 +13,57 @@ namespace foodmarket.Api.Controllers.Admin;
[Route("api/admin/moysklad")] [Route("api/admin/moysklad")]
public class MoySkladImportController : ControllerBase public class MoySkladImportController : ControllerBase
{ {
private readonly IServiceScopeFactory _scopes;
private readonly MoySkladImportService _svc; private readonly MoySkladImportService _svc;
private readonly ImportJobRegistry _jobs;
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
public MoySkladImportController(MoySkladImportService svc) => _svc = svc; public MoySkladImportController(
IServiceScopeFactory scopes,
MoySkladImportService svc,
ImportJobRegistry jobs,
AppDbContext db,
ITenantContext tenant)
{
_scopes = scopes;
_svc = svc;
_jobs = jobs;
_db = db;
_tenant = tenant;
}
public record TestRequest(string Token); public record TestRequest(string Token);
public record ImportRequest(string Token, bool OverwriteExisting = false); public record ImportRequest(string? Token = null, bool OverwriteExisting = false);
public record SettingsDto(bool HasToken, string? Masked);
public record SettingsInput(string Token);
[HttpGet("settings")]
public async Task<ActionResult<SettingsDto>> GetSettings(CancellationToken ct)
{
var token = await ReadTokenFromOrgAsync(ct);
return new SettingsDto(!string.IsNullOrEmpty(token), Mask(token));
}
[HttpPut("settings")]
public async Task<ActionResult<SettingsDto>> SetSettings([FromBody] SettingsInput input, CancellationToken ct)
{
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var org = await _db.Organizations.FirstOrDefaultAsync(o => o.Id == orgId, ct);
if (org is null) return NotFound();
org.MoySkladToken = string.IsNullOrWhiteSpace(input.Token) ? null : input.Token.Trim();
await _db.SaveChangesAsync(ct);
return new SettingsDto(!string.IsNullOrEmpty(org.MoySkladToken), Mask(org.MoySkladToken));
}
[HttpPost("test")] [HttpPost("test")]
public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct) public async Task<IActionResult> TestConnection([FromBody] TestRequest req, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(req.Token)) var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Token is required." }); return BadRequest(new { error = "Token is required." });
var result = await _svc.TestConnectionAsync(req.Token, ct); var result = await _svc.TestConnectionAsync(token, ct);
if (!result.Success) if (!result.Success)
{ {
var msg = result.StatusCode switch var msg = result.StatusCode switch
@ -36,26 +77,91 @@ public async Task<IActionResult> TestConnection([FromBody] TestRequest req, Canc
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn }); return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
} }
private static string? Truncate(string? s, int max) // Async launch — возвращает jobId, реальная работа в фоне. Клиент полит /jobs/{id}.
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
[HttpPost("import-products")] [HttpPost("import-products")]
public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct) public async Task<ActionResult<object>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(req.Token)) var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
return BadRequest(new { error = "Token is required." }); if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var result = await _svc.ImportProductsAsync(req.Token, req.OverwriteExisting, ct); var job = _jobs.Create("products");
return result; job.Stage = "Подключение к MoySklad…";
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
{
progress.Stage = "Импорт товаров…";
var result = await svc.ImportProductsAsync(token, req.OverwriteExisting, ctInner, progress);
progress.Message = $"Готово: {result.Created} записей (создано/обновлено), {result.Skipped} пропущено, {result.GroupsCreated} групп.";
}, orgId);
return Ok(new { jobId = job.Id });
} }
[HttpPost("import-counterparties")] [HttpPost("import-counterparties")]
public async Task<ActionResult<MoySkladImportResult>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct) public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(req.Token)) var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
return BadRequest(new { error = "Token is required." }); if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Токен не настроен. Сохраните его в /admin/moysklad/settings." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var result = await _svc.ImportCounterpartiesAsync(req.Token, req.OverwriteExisting, ct); var job = _jobs.Create("counterparties");
return result; job.Stage = "Подключение к MoySklad…";
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
{
progress.Stage = "Импорт контрагентов…";
var result = await svc.ImportCounterpartiesAsync(token, req.OverwriteExisting, ctInner, progress);
progress.Message = $"Готово: {result.Created} записей, {result.Skipped} пропущено.";
}, orgId);
return Ok(new { jobId = job.Id });
} }
private Task RunInBackgroundAsync(
ImportJobProgress job,
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,
Guid orgId)
{
return Task.Run(async () =>
{
try
{
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
using var scope = _scopes.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<MoySkladImportService>();
await work(svc, job, CancellationToken.None);
job.Status = ImportJobStatus.Succeeded;
}
catch (Exception ex)
{
job.Status = ImportJobStatus.Failed;
job.Message = ex.Message;
job.Errors.Add(ex.ToString());
}
finally
{
job.FinishedAt = DateTime.UtcNow;
}
});
}
private async Task<string?> ReadTokenFromOrgAsync(CancellationToken ct)
{
var orgId = _tenant.OrganizationId;
if (orgId is null) return null;
return await _db.Organizations
.Where(o => o.Id == orgId)
.Select(o => o.MoySkladToken)
.FirstOrDefaultAsync(ct);
}
private static string? Mask(string? token)
{
if (string.IsNullOrEmpty(token)) return null;
if (token.Length <= 8) return new string('•', token.Length);
return token[..4] + new string('•', 8) + token[^4..];
}
private static string? Truncate(string? s, int max)
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
} }

View file

@ -8,6 +8,22 @@ public class HttpContextTenantContext : ITenantContext
public const string OrganizationClaim = "org_id"; public const string OrganizationClaim = "org_id";
public const string SuperAdminRole = "SuperAdmin"; public const string SuperAdminRole = "SuperAdmin";
// Override для background задач (например, импорт из MoySklad): сохраняем tenant
// в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует,
// но query-filter'у по-прежнему нужен orgId — вот его и берём из override.
private static readonly AsyncLocal<(Guid? OrgId, bool IsSuper)?> _override = new();
public static IDisposable UseOverride(Guid orgId, bool isSuperAdmin = false)
{
_override.Value = (orgId, isSuperAdmin);
return new OverrideScope();
}
private sealed class OverrideScope : IDisposable
{
public void Dispose() => _override.Value = null;
}
private readonly IHttpContextAccessor _accessor; private readonly IHttpContextAccessor _accessor;
public HttpContextTenantContext(IHttpContextAccessor accessor) public HttpContextTenantContext(IHttpContextAccessor accessor)
@ -15,14 +31,29 @@ public HttpContextTenantContext(IHttpContextAccessor accessor)
_accessor = accessor; _accessor = accessor;
} }
public bool IsAuthenticated => _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; public bool IsAuthenticated
{
get
{
if (_override.Value is not null) return true;
return _accessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false;
}
}
public bool IsSuperAdmin => _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false; public bool IsSuperAdmin
{
get
{
if (_override.Value is { IsSuper: var s }) return s;
return _accessor.HttpContext?.User?.IsInRole(SuperAdminRole) ?? false;
}
}
public Guid? OrganizationId public Guid? OrganizationId
{ {
get get
{ {
if (_override.Value is { OrgId: var o }) return o;
var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value; var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value;
return Guid.TryParse(claim, out var id) ? id : null; return Guid.TryParse(claim, out var id) ? id : null;
} }

View file

@ -130,6 +130,7 @@
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate, AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
}); });
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>(); builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
// Inventory // Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>(); builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();

View file

@ -11,4 +11,8 @@ public class Organization : Entity
public string? Phone { get; set; } public string? Phone { get; set; }
public string? Email { get; set; } public string? Email { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
/// <summary>Персональный API-токен MoySklad. Храним per-organization чтобы
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
public string? MoySkladToken { get; set; }
} }

View file

@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace foodmarket.Infrastructure.Integrations.MoySklad;
public enum ImportJobStatus { Running, Succeeded, Failed, Cancelled }
public class ImportJobProgress
{
public Guid Id { get; init; } = Guid.NewGuid();
public string Kind { get; init; } = ""; // "products" | "counterparties"
public DateTime StartedAt { get; init; } = DateTime.UtcNow;
public DateTime? FinishedAt { get; set; }
public ImportJobStatus Status { get; set; } = ImportJobStatus.Running;
public string? Stage { get; set; } // человекочитаемое описание текущего шага
public int Total { get; set; } // входящих записей от MS (растёт по мере пейджинга)
public int Created { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public int Deleted { get; set; } // для cleanup
public int GroupsCreated { get; set; }
public string? Message { get; set; } // последняя ошибка / финальное сообщение
public List<string> Errors { get; set; } = [];
}
// Process-memory реестр прогресса фоновых импортов. Один процесс API — одно DI singleton.
// При рестарте контейнера история импортов теряется — для просмотра «вчерашнего» надо
// смотреть логи. На MVP достаточно.
public class ImportJobRegistry
{
private readonly ConcurrentDictionary<Guid, ImportJobProgress> _jobs = new();
public ImportJobProgress Create(string kind)
{
var job = new ImportJobProgress { Kind = kind };
_jobs[job.Id] = job;
return job;
}
public ImportJobProgress? Get(Guid id) => _jobs.TryGetValue(id, out var j) ? j : null;
public IReadOnlyList<ImportJobProgress> RecentlyFinished(int take = 10) =>
_jobs.Values
.Where(j => j.FinishedAt is not null)
.OrderByDescending(j => j.FinishedAt)
.Take(take)
.ToList();
}

View file

@ -35,9 +35,15 @@ public class MoySkladImportService
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct) public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
=> _client.WhoAmIAsync(token, ct); => _client.WhoAmIAsync(token, ct);
public async Task<MoySkladImportResult> ImportCounterpartiesAsync(string token, bool overwriteExisting, CancellationToken ct) public async Task<MoySkladImportResult> ImportCounterpartiesAsync(
string token,
bool overwriteExisting,
CancellationToken ct,
ImportJobProgress? progress = null,
Guid? organizationIdOverride = null)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); var orgId = organizationIdOverride ?? _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant organization in context.");
// MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще — // MoySklad НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
// counterparty entity содержит только group (группа доступа), tags // counterparty entity содержит только group (группа доступа), tags
@ -69,15 +75,17 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{ {
total++; total++;
if (progress is not null) progress.Total = total;
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty). // Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
try try
{ {
if (existingByName.TryGetValue(c.Name, out var existing)) if (existingByName.TryGetValue(c.Name, out var existing))
{ {
if (!overwriteExisting) { skipped++; continue; } if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; }
ApplyCounterparty(existing, c, ResolveType); ApplyCounterparty(existing, c, ResolveType);
updated++; updated++;
if (progress is not null) progress.Updated = updated;
} }
else else
{ {
@ -86,10 +94,11 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
_db.Counterparties.Add(entity); _db.Counterparties.Add(entity);
existingByName[c.Name] = entity; existingByName[c.Name] = entity;
created++; created++;
if (progress is not null) progress.Created = created;
} }
batch++; batch++;
if (batch >= 500) if (batch >= 100)
{ {
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
batch = 0; batch = 0;
@ -99,6 +108,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
{ {
_log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name); _log.LogWarning(ex, "Failed to import counterparty {Name}", c.Name);
errors.Add($"{c.Name}: {ex.Message}"); errors.Add($"{c.Name}: {ex.Message}");
if (progress is not null) progress.Errors = errors;
} }
} }
@ -127,9 +137,12 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
public async Task<MoySkladImportResult> ImportProductsAsync( public async Task<MoySkladImportResult> ImportProductsAsync(
string token, string token,
bool overwriteExisting, bool overwriteExisting,
CancellationToken ct) CancellationToken ct,
ImportJobProgress? progress = null,
Guid? organizationIdOverride = null)
{ {
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant organization in context."); var orgId = organizationIdOverride ?? _tenant.OrganizationId
?? throw new InvalidOperationException("No tenant organization in context.");
// Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't // Pre-load tenant defaults. KZ default VAT is 16% — applied when product didn't
// carry its own vat from MoySklad. // carry its own vat from MoySklad.
@ -171,6 +184,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
groupsCreated++; groupsCreated++;
} }
if (groupsCreated > 0) await _db.SaveChangesAsync(ct); if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
if (progress is not null) progress.GroupsCreated = groupsCreated;
// Import products // Import products
var errors = new List<string>(); var errors = new List<string>();
@ -189,6 +203,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
await foreach (var p in _client.StreamProductsAsync(token, ct)) await foreach (var p in _client.StreamProductsAsync(token, ct))
{ {
total++; total++;
if (progress is not null) progress.Total = total;
// Архивных не пропускаем — импортируем как IsActive=false. // Архивных не пропускаем — импортируем как IsActive=false.
var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article; var article = string.IsNullOrWhiteSpace(p.Article) ? p.Code : p.Article;
@ -197,6 +212,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
if (alreadyByArticle && !overwriteExisting) if (alreadyByArticle && !overwriteExisting)
{ {
skipped++; skipped++;
if (progress is not null) progress.Skipped = skipped;
continue; continue;
} }
@ -229,6 +245,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
product.IsActive = !p.Archived; product.IsActive = !p.Archived;
product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m; product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m;
updated++; updated++;
if (progress is not null) progress.Updated = updated;
} }
else else
{ {
@ -271,6 +288,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
_db.Products.Add(product); _db.Products.Add(product);
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product; if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
created++; created++;
if (progress is not null) progress.Created = created;
} }
// Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице // Flush чаще (каждые 100) чтобы при сетевом обрыве на следующей странице
@ -281,6 +299,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
{ {
_log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name); _log.LogWarning(ex, "Failed to import MoySklad product {Name}", p.Name);
errors.Add($"{p.Name}: {ex.Message}"); errors.Add($"{p.Name}: {ex.Message}");
if (progress is not null) progress.Errors = errors;
} }
} }

View file

@ -69,6 +69,7 @@ protected override void OnModelCreating(ModelBuilder builder)
b.Property(o => o.Name).HasMaxLength(200).IsRequired(); b.Property(o => o.Name).HasMaxLength(200).IsRequired();
b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired(); b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired();
b.Property(o => o.Bin).HasMaxLength(20); b.Property(o => o.Bin).HasMaxLength(20);
b.Property(o => o.MoySkladToken).HasMaxLength(200);
b.HasIndex(o => o.Name); b.HasIndex(o => o.Name);
}); });

View file

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Добавляем колонку organizations.MoySkladToken — per-tenant API-токен
/// MoySklad. Хранится, чтобы не вводить вручную при каждом импорте.</summary>
public partial class Phase3_OrganizationMoySkladToken : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "MoySkladToken",
schema: "public",
table: "organizations",
type: "character varying(200)",
maxLength: 200,
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MoySkladToken",
schema: "public",
table: "organizations");
}
}
}

View file

@ -1074,6 +1074,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("MoySkladToken")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)

View file

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQuery, useQueryClient, type UseMutationResult } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package, Trash2, AlertTriangle } from 'lucide-react' import { AlertCircle, CheckCircle, KeyRound, Users, Package, Trash2, AlertTriangle, Save } from 'lucide-react'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
@ -12,116 +12,165 @@ function formatError(err: unknown): string {
const status = err.response?.status const status = err.response?.status
const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined
const detail = body?.error ?? body?.error_description ?? body?.title const detail = body?.error ?? body?.error_description ?? body?.title
if (status === 404) { if (status === 404) return '404 — эндпоинт не существует. API обновлён?'
return '404 Not Found — эндпоинт не существует. Вероятно, API не перезапущен после git pull.' if (status === 401) return '401 — сессия истекла, перелогинься.'
} if (status === 403) return '403 — нужна роль Admin.'
if (status === 401) return '401 Unauthorized — сессия истекла, перелогинься.' if (status === 502 || status === 503) return `${status} — МойСклад недоступен.`
if (status === 403) return '403 Forbidden — нужна роль Admin или SuperAdmin.'
if (status === 502 || status === 503) return `${status} — МойСклад недоступен, попробуй позже.`
return detail ? `${status ?? ''} ${detail}` : err.message return detail ? `${status ?? ''} ${detail}` : err.message
} }
if (err instanceof Error) return err.message if (err instanceof Error) return err.message
return String(err) return String(err)
} }
interface TestResponse { organization: string; inn?: string | null } interface SettingsDto { hasToken: boolean; masked: string | null }
interface ImportResponse { interface JobView {
total: number; created: number; skipped: number; groupsCreated: number; errors: string[] id: string
kind: string
status: 'Running' | 'Succeeded' | 'Failed' | 'Cancelled'
stage: string | null
startedAt: string
finishedAt: string | null
total: number; created: number; updated: number; skipped: number; deleted: number; groupsCreated: number
message: string | null
errors: string[]
}
function useJob(jobId: string | null) {
return useQuery({
queryKey: ['admin-job', jobId],
enabled: !!jobId,
queryFn: async () => (await api.get<JobView>(`/api/admin/jobs/${jobId}`)).data,
refetchInterval: (q) => {
const status = q.state.data?.status
return status === 'Succeeded' || status === 'Failed' ? false : 1500
},
})
} }
export function MoySkladImportPage() { export function MoySkladImportPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [token, setToken] = useState('')
const [overwrite, setOverwrite] = useState(false) const settings = useQuery({
queryKey: ['/api/admin/moysklad/settings'],
queryFn: async () => (await api.get<SettingsDto>('/api/admin/moysklad/settings')).data,
})
const [tokenInput, setTokenInput] = useState('')
const [overwrite, setOverwrite] = useState(true)
const saveToken = useMutation({
mutationFn: async () =>
(await api.put<SettingsDto>('/api/admin/moysklad/settings', { token: tokenInput })).data,
onSuccess: () => {
setTokenInput('')
qc.invalidateQueries({ queryKey: ['/api/admin/moysklad/settings'] })
},
})
const test = useMutation({ const test = useMutation({
mutationFn: async () => (await api.post<TestResponse>('/api/admin/moysklad/test', { token })).data, mutationFn: async () =>
(await api.post<{ organization: string; inn?: string }>('/api/admin/moysklad/test', {})).data,
}) })
const products = useMutation({ const [productsJobId, setProductsJobId] = useState<string | null>(null)
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data, const [counterpartiesJobId, setCounterpartiesJobId] = useState<string | null>(null)
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
const startProducts = useMutation({
mutationFn: async () =>
(await api.post<{ jobId: string }>('/api/admin/moysklad/import-products', { overwriteExisting: overwrite })).data,
onSuccess: (d) => setProductsJobId(d.jobId),
})
const startCounterparties = useMutation({
mutationFn: async () =>
(await api.post<{ jobId: string }>('/api/admin/moysklad/import-counterparties', { overwriteExisting: overwrite })).data,
onSuccess: (d) => setCounterpartiesJobId(d.jobId),
}) })
const counterparties = useMutation({ const productsJob = useJob(productsJobId)
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-counterparties', { token, overwriteExisting: overwrite })).data, const counterpartiesJob = useJob(counterpartiesJobId)
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/counterparties'] }),
}) const hasToken = settings.data?.hasToken ?? false
return ( return (
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">
<div className="p-6 max-w-3xl"> <div className="p-6 max-w-3xl">
<PageHeader <PageHeader
title="Импорт из МойСклад" title="Импорт из МойСклад"
description="Перенос товаров, групп, контрагентов из учётной записи МойСклад в food-market." description="Перенос товаров, групп и контрагентов из учётной записи МойСклад."
/> />
<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> Настройки аккаунта Доступ к API создать токен.</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"> <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)"> <h2 className="text-sm font-semibold">Токен API</h2>
{settings.data && (
<div className="text-sm">
{hasToken ? (
<div className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<CheckCircle className="w-4 h-4" /> сохранён:{' '}
<code className="font-mono">{settings.data.masked}</code>
</div>
) : (
<div className="text-amber-700 dark:text-amber-400">Ещё не задан импорт не сработает.</div>
)}
</div>
)}
<Field label={hasToken ? 'Заменить токен' : 'Bearer-токен MoySklad'}>
<TextInput <TextInput
type="password" type="password"
value={token} value={tokenInput}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setTokenInput(e.target.value)}
placeholder="персональный токен или токен сервисного аккаунта" placeholder="персональный или сервисный токен"
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
/> />
</Field> </Field>
<div className="flex gap-2 items-center flex-wrap">
<div className="flex gap-3 items-center flex-wrap"> <Button
onClick={() => saveToken.mutate()}
disabled={!tokenInput || saveToken.isPending}
>
<Save className="w-4 h-4" />
{saveToken.isPending ? 'Сохраняю…' : 'Сохранить токен'}
</Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => test.mutate()} onClick={() => test.mutate()}
disabled={!token || test.isPending} disabled={!hasToken || test.isPending}
> >
<KeyRound className="w-4 h-4" /> <KeyRound className="w-4 h-4" />
{test.isPending ? 'Проверяю…' : 'Проверить соединение'} {test.isPending ? 'Проверяю…' : 'Проверить соединение'}
</Button> </Button>
{test.data && ( {test.data && (
<div className="text-sm text-green-700 dark:text-green-400 flex items-center gap-1.5"> <div className="text-sm text-emerald-700 dark:text-emerald-400 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" /> Подключено: <strong>{test.data.organization}</strong>
Подключено: <strong>{test.data.organization}</strong>
{test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>} {test.data.inn && <span className="text-slate-500">(ИНН {test.data.inn})</span>}
</div> </div>
)} )}
{test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>} {test.error && <div className="text-sm text-red-600">{formatError(test.error)}</div>}
{saveToken.error && <div className="text-sm text-red-600">{formatError(saveToken.error)}</div>}
</div> </div>
</section> </section>
<section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4"> <section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Операции импорта</h2> <h2 className="text-sm font-semibold">Операции импорта</h2>
<Checkbox <Checkbox
label="Перезаписать существующие записи (по артикулу/имени)" label="Обновлять уже импортированные записи (если найдены по артикулу/имени)"
checked={overwrite} checked={overwrite}
onChange={setOverwrite} onChange={setOverwrite}
/> />
<div className="flex gap-3 flex-wrap"> <div className="flex gap-3 flex-wrap">
<Button onClick={() => products.mutate()} disabled={!token || products.isPending}> <Button onClick={() => startProducts.mutate()} disabled={!hasToken || startProducts.isPending || productsJob.data?.status === 'Running'}>
<Package className="w-4 h-4" /> <Package className="w-4 h-4" />
{products.isPending ? 'Импортирую товары…' : 'Товары + группы + цены'} {productsJob.data?.status === 'Running' ? 'Товары импортируются…' : 'Товары + группы + цены'}
</Button> </Button>
<Button onClick={() => counterparties.mutate()} disabled={!token || counterparties.isPending}> <Button onClick={() => startCounterparties.mutate()} disabled={!hasToken || startCounterparties.isPending || counterpartiesJob.data?.status === 'Running'}>
<Download className="w-4 h-4" />
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
{counterparties.isPending ? 'Импортирую контрагентов…' : 'Контрагенты'} {counterpartiesJob.data?.status === 'Running' ? 'Контрагенты импортируются…' : 'Контрагенты'}
</Button> </Button>
</div> </div>
</section> </section>
<ImportResult title="Товары" result={products} /> {productsJob.data && <JobCard title="Импорт товаров" job={productsJob.data} />}
<ImportResult title="Контрагенты" result={counterparties} /> {counterpartiesJob.data && <JobCard title="Импорт контрагентов" job={counterpartiesJob.data} />}
<DangerZone /> <DangerZone />
</div> </div>
@ -129,19 +178,61 @@ export function MoySkladImportPage() {
) )
} }
interface CleanupStats { function JobCard({ title, job }: { title: string; job: JobView }) {
counterparties: number const done = job.status === 'Succeeded' || job.status === 'Failed'
products: number const color = job.status === 'Succeeded' ? 'text-emerald-600'
productGroups: number : job.status === 'Failed' ? 'text-red-600' : 'text-slate-500'
productBarcodes: number return (
productPrices: number <section className="mt-5 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
supplies: number <h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
retailSales: number {job.status === 'Succeeded'
stocks: number ? <CheckCircle className="w-4 h-4 text-emerald-600" />
stockMovements: number : job.status === 'Failed'
? <AlertCircle className="w-4 h-4 text-red-600" />
: <span className="inline-block w-3 h-3 rounded-full bg-emerald-500 animate-pulse" />}
{title}
<span className={`text-xs font-normal ${color}`}>
{job.status === 'Running' ? (job.stage ?? 'идёт…') : job.status}
</span>
</h3>
<dl className="grid grid-cols-5 gap-3 text-sm">
<Stat label="Всего" value={job.total} />
<Stat label="Создано" value={job.created} accent="green" />
<Stat label="Обновлено" value={job.updated} />
<Stat label="Пропущено" value={job.skipped} />
<Stat label="Групп" value={job.groupsCreated} />
</dl>
{done && job.message && (
<div className={`mt-3 text-sm ${color}`}>{job.message}</div>
)}
{job.errors.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-red-600 cursor-pointer">Ошибок: {job.errors.length}</summary>
<ul className="mt-2 text-[11px] font-mono bg-red-50 dark:bg-red-950/30 p-3 rounded space-y-0.5 max-h-60 overflow-auto">
{job.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</details>
)}
</section>
)
} }
interface CleanupResult { scope: string; deleted: CleanupStats } function Stat({ label, value, accent }: { label: string; value: number; accent?: 'green' }) {
const bg = accent === 'green' ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-slate-50 dark:bg-slate-800/50'
const fg = accent === 'green' ? 'text-emerald-700 dark:text-emerald-400' : ''
return (
<div className={`rounded-lg ${bg} p-3`}>
<dt className={`text-xs uppercase ${accent === 'green' ? fg : 'text-slate-500'}`}>{label}</dt>
<dd className={`text-lg font-semibold mt-1 ${fg}`}>{value.toLocaleString('ru')}</dd>
</div>
)
}
interface CleanupStats {
counterparties: number; products: number; productGroups: number
productBarcodes: number; productPrices: number; supplies: number
retailSales: number; stocks: number; stockMovements: number
}
function DangerZone() { function DangerZone() {
const qc = useQueryClient() const qc = useQueryClient()
@ -152,17 +243,14 @@ function DangerZone() {
refetchOnMount: 'always', refetchOnMount: 'always',
}) })
const wipeCounterparties = useMutation({ const [wipeJobId, setWipeJobId] = useState<string | null>(null)
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/counterparties')).data, const wipeJob = useJob(wipeJobId)
onSuccess: () => {
qc.invalidateQueries()
},
})
const wipeAll = useMutation({ const startWipe = useMutation({
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/all')).data, mutationFn: async () => (await api.post<{ jobId: string }>('/api/admin/cleanup/all/async')).data,
onSuccess: () => { onSuccess: (d) => {
qc.invalidateQueries() setWipeJobId(d.jobId)
qc.invalidateQueries({ queryKey: ['/api/admin/cleanup/stats'] })
}, },
}) })
@ -176,79 +264,52 @@ function DangerZone() {
return ( return (
<section className="mt-8 rounded-xl border-2 border-red-200 dark:border-red-900/50 bg-red-50/40 dark:bg-red-950/20 p-5"> <section className="mt-8 rounded-xl border-2 border-red-200 dark:border-red-900/50 bg-red-50/40 dark:bg-red-950/20 p-5">
<h2 className="text-sm font-semibold text-red-900 dark:text-red-300 flex items-center gap-2 mb-1.5"> <h2 className="text-sm font-semibold text-red-900 dark:text-red-300 flex items-center gap-2 mb-1.5">
<AlertTriangle className="w-4 h-4" /> Опасная зона временные инструменты очистки <AlertTriangle className="w-4 h-4" /> Опасная зона полная очистка данных
</h2> </h2>
<p className="text-xs text-red-900/80 dark:text-red-300/80 mb-4"> <p className="text-xs text-red-900/80 dark:text-red-300/80 mb-4">
Удаляет данные текущей организации. Справочники (ед. измерения, страны, валюты, типы цен, склады, точки продаж), пользователи и сама организация сохраняются. Удаляет товары, группы, контрагентов, документы и остатки. Справочники, пользователи, склады и организация сохраняются.
</p> </p>
{s && ( {s && (
<dl className="grid grid-cols-3 md:grid-cols-5 gap-2 text-xs mb-4"> <dl className="grid grid-cols-3 md:grid-cols-5 gap-2 text-xs mb-4">
<Stat label="Контрагенты" value={s.counterparties} /> <Tile label="Контрагенты" value={s.counterparties} />
<Stat label="Товары" value={s.products} /> <Tile label="Товары" value={s.products} />
<Stat label="Группы" value={s.productGroups} /> <Tile label="Группы" value={s.productGroups} />
<Stat label="Штрихкоды" value={s.productBarcodes} /> <Tile label="Штрихкоды" value={s.productBarcodes} />
<Stat label="Цены" value={s.productPrices} /> <Tile label="Цены" value={s.productPrices} />
<Stat label="Поставки" value={s.supplies} /> <Tile label="Поставки" value={s.supplies} />
<Stat label="Чеки" value={s.retailSales} /> <Tile label="Чеки" value={s.retailSales} />
<Stat label="Остатки" value={s.stocks} /> <Tile label="Остатки" value={s.stocks} />
<Stat label="Движения" value={s.stockMovements} /> <Tile label="Движения" value={s.stockMovements} />
</dl> </dl>
)} )}
<Button
<div className="flex gap-3 flex-wrap"> variant="danger"
<Button onClick={() => confirmAndRun(
variant="danger" 'ВСЕ данные организации',
onClick={() => confirmAndRun( () => startWipe.mutate(),
`${s?.counterparties ?? '?'} контрагентов (+ связанные поставки/движения)`, )}
() => wipeCounterparties.mutate(), disabled={startWipe.isPending || wipeJob.data?.status === 'Running'}
)} >
disabled={wipeCounterparties.isPending || !s || s.counterparties === 0} <Trash2 className="w-4 h-4" />
> {wipeJob.data?.status === 'Running' ? 'Очищаю…' : 'Очистить все данные'}
<Trash2 className="w-4 h-4" /> </Button>
{wipeCounterparties.isPending ? 'Удаляю…' : 'Удалить контрагентов'} {wipeJob.data && (
</Button> <div className="mt-4 rounded-lg bg-white dark:bg-slate-900 border border-red-200 dark:border-red-900/50 p-3 text-sm">
<Button <div className="flex items-center gap-2 mb-2">
variant="danger" {wipeJob.data.status === 'Succeeded' && <CheckCircle className="w-4 h-4 text-emerald-600" />}
onClick={() => confirmAndRun( {wipeJob.data.status === 'Failed' && <AlertCircle className="w-4 h-4 text-red-600" />}
'ВСЕ данные организации (товары, группы, контрагенты, документы, остатки)', {wipeJob.data.status === 'Running' && <span className="inline-block w-3 h-3 rounded-full bg-red-500 animate-pulse" />}
() => wipeAll.mutate(), <strong>{wipeJob.data.stage ?? wipeJob.data.status}</strong>
)} <span className="text-xs text-slate-500">удалено записей: {wipeJob.data.deleted.toLocaleString('ru')}</span>
disabled={wipeAll.isPending} </div>
> {wipeJob.data.message && <div className="text-xs text-slate-600">{wipeJob.data.message}</div>}
<Trash2 className="w-4 h-4" />
{wipeAll.isPending ? 'Удаляю всё…' : 'Очистить все данные'}
</Button>
</div>
{wipeCounterparties.data && (
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
Удалено: {wipeCounterparties.data.deleted.counterparties} контрагентов,
{' '}{wipeCounterparties.data.deleted.supplies} поставок,
{' '}{wipeCounterparties.data.deleted.stockMovements} движений.
</div> </div>
)} )}
{wipeAll.data && (
<div className="mt-3 text-xs text-red-900 dark:text-red-300">
Удалено: {wipeAll.data.deleted.counterparties} контрагентов,
{' '}{wipeAll.data.deleted.products} товаров,
{' '}{wipeAll.data.deleted.productGroups} групп,
{' '}{wipeAll.data.deleted.supplies} поставок,
{' '}{wipeAll.data.deleted.retailSales} чеков,
{' '}{wipeAll.data.deleted.stockMovements} движений.
</div>
)}
{wipeCounterparties.error && (
<div className="mt-3 text-xs text-red-700">{formatError(wipeCounterparties.error)}</div>
)}
{wipeAll.error && (
<div className="mt-3 text-xs text-red-700">{formatError(wipeAll.error)}</div>
)}
</section> </section>
) )
} }
function Stat({ label, value }: { label: string; value: number }) { function Tile({ label, value }: { label: string; value: number }) {
return ( return (
<div className="rounded bg-white dark:bg-slate-900 border border-red-200 dark:border-red-900/50 px-2 py-1.5"> <div className="rounded bg-white dark:bg-slate-900 border border-red-200 dark:border-red-900/50 px-2 py-1.5">
<dt className="text-[10px] uppercase text-slate-500">{label}</dt> <dt className="text-[10px] uppercase text-slate-500">{label}</dt>
@ -256,50 +317,3 @@ function Stat({ label, value }: { label: string; value: number }) {
</div> </div>
) )
} }
function ImportResult({ title, result }: { title: string; result: UseMutationResult<ImportResponse, Error, void, unknown> }) {
if (!result.data && !result.error) return null
return (
<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">
{result.data
? <><CheckCircle className="w-4 h-4 text-green-600" /> {title} импорт завершён</>
: <><AlertCircle className="w-4 h-4 text-red-600" /> {title} ошибка</>}
</h3>
{result.data && (
<>
<dl className="grid grid-cols-4 gap-3 text-sm">
<StatBox label="Всего получено" value={result.data.total} />
<StatBox label="Создано" value={result.data.created} accent="green" />
<StatBox label="Пропущено" value={result.data.skipped} />
<StatBox label="Групп создано" value={result.data.groupsCreated} />
</dl>
{result.data.errors.length > 0 && (
<details className="mt-4">
<summary className="text-sm text-red-600 cursor-pointer">
Ошибок: {result.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">
{result.data.errors.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</details>
)}
</>
)}
{result.error && (
<div className="text-sm text-red-700 dark:text-red-300">{formatError(result.error)}</div>
)}
</section>
)
}
function StatBox({ label, value, accent }: { label: string; value: number; accent?: 'green' }) {
const bg = accent === 'green' ? 'bg-green-50 dark:bg-green-950/30' : 'bg-slate-50 dark:bg-slate-800/50'
const fg = accent === 'green' ? 'text-green-700 dark:text-green-400' : ''
return (
<div className={`rounded-lg ${bg} p-3`}>
<dt className={`text-xs uppercase ${accent === 'green' ? fg : 'text-slate-500'}`}>{label}</dt>
<dd className={`text-xl font-semibold mt-1 ${fg}`}>{value.toLocaleString('ru')}</dd>
</div>
)
}