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
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:
parent
bd15854b42
commit
2fc6d207f3
|
|
@ -3,6 +3,21 @@ server {
|
|||
root /usr/share/nginx/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.
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
using foodmarket.Api.Infrastructure.Tenancy;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
|
@ -13,8 +16,21 @@ namespace foodmarket.Api.Controllers.Admin;
|
|||
public class AdminCleanupController : ControllerBase
|
||||
{
|
||||
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(
|
||||
int Counterparties,
|
||||
|
|
@ -72,6 +88,61 @@ await _db.StockMovements
|
|||
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>Полная очистка данных текущей организации — всё кроме настроек:
|
||||
/// остаются Organization, пользователи, справочники (Country, Currency, UnitOfMeasure,
|
||||
/// PriceType), Store и RetailPoint. Удаляются: Product*, ProductGroup, Counterparty,
|
||||
|
|
|
|||
41
src/food-market.api/Controllers/Admin/AdminJobsController.cs
Normal file
41
src/food-market.api/Controllers/Admin/AdminJobsController.cs
Normal 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();
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
using foodmarket.Api.Infrastructure.Tenancy;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Infrastructure.Integrations.MoySklad;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Controllers.Admin;
|
||||
|
||||
|
|
@ -9,20 +13,57 @@ namespace foodmarket.Api.Controllers.Admin;
|
|||
[Route("api/admin/moysklad")]
|
||||
public class MoySkladImportController : ControllerBase
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
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 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")]
|
||||
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." });
|
||||
|
||||
var result = await _svc.TestConnectionAsync(req.Token, ct);
|
||||
var result = await _svc.TestConnectionAsync(token, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
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 });
|
||||
}
|
||||
|
||||
private static string? Truncate(string? s, int max)
|
||||
=> string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…");
|
||||
// Async launch — возвращает jobId, реальная работа в фоне. Клиент полит /jobs/{id}.
|
||||
|
||||
[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))
|
||||
return BadRequest(new { error = "Token is required." });
|
||||
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
||||
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);
|
||||
return result;
|
||||
var job = _jobs.Create("products");
|
||||
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")]
|
||||
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))
|
||||
return BadRequest(new { error = "Token is required." });
|
||||
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
|
||||
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);
|
||||
return result;
|
||||
var job = _jobs.Create("counterparties");
|
||||
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] + "…");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,22 @@ public class HttpContextTenantContext : ITenantContext
|
|||
public const string OrganizationClaim = "org_id";
|
||||
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;
|
||||
|
||||
public HttpContextTenantContext(IHttpContextAccessor accessor)
|
||||
|
|
@ -15,14 +31,29 @@ public HttpContextTenantContext(IHttpContextAccessor 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
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_override.Value is { OrgId: var o }) return o;
|
||||
var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value;
|
||||
return Guid.TryParse(claim, out var id) ? id : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@
|
|||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
||||
});
|
||||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
||||
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
||||
|
||||
// Inventory
|
||||
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
||||
|
|
|
|||
|
|
@ -11,4 +11,8 @@ public class Organization : Entity
|
|||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>Персональный API-токен MoySklad. Храним per-organization чтобы
|
||||
/// пользователю не нужно было вводить его каждый раз при импорте.</summary>
|
||||
public string? MoySkladToken { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -35,9 +35,15 @@ public class MoySkladImportService
|
|||
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken 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 НЕ имеет поля "Поставщик/Покупатель" у контрагентов вообще —
|
||||
// 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))
|
||||
{
|
||||
total++;
|
||||
if (progress is not null) progress.Total = total;
|
||||
// Архивных не пропускаем — импортируем как IsActive=false (см. ApplyCounterparty).
|
||||
|
||||
try
|
||||
{
|
||||
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);
|
||||
updated++;
|
||||
if (progress is not null) progress.Updated = updated;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -86,10 +94,11 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
_db.Counterparties.Add(entity);
|
||||
existingByName[c.Name] = entity;
|
||||
created++;
|
||||
if (progress is not null) progress.Created = created;
|
||||
}
|
||||
|
||||
batch++;
|
||||
if (batch >= 500)
|
||||
if (batch >= 100)
|
||||
{
|
||||
await _db.SaveChangesAsync(ct);
|
||||
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);
|
||||
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(
|
||||
string token,
|
||||
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
|
||||
// carry its own vat from MoySklad.
|
||||
|
|
@ -171,6 +184,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
groupsCreated++;
|
||||
}
|
||||
if (groupsCreated > 0) await _db.SaveChangesAsync(ct);
|
||||
if (progress is not null) progress.GroupsCreated = groupsCreated;
|
||||
|
||||
// Import products
|
||||
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))
|
||||
{
|
||||
total++;
|
||||
if (progress is not null) progress.Total = total;
|
||||
// Архивных не пропускаем — импортируем как IsActive=false.
|
||||
|
||||
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)
|
||||
{
|
||||
skipped++;
|
||||
if (progress is not null) progress.Skipped = skipped;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +245,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
|||
product.IsActive = !p.Archived;
|
||||
product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m;
|
||||
updated++;
|
||||
if (progress is not null) progress.Updated = updated;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -271,6 +288,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
|||
_db.Products.Add(product);
|
||||
if (!string.IsNullOrWhiteSpace(article)) existingByArticle[article] = product;
|
||||
created++;
|
||||
if (progress is not null) progress.Created = created;
|
||||
}
|
||||
|
||||
// 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);
|
||||
errors.Add($"{p.Name}: {ex.Message}");
|
||||
if (progress is not null) progress.Errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
b.Property(o => o.Name).HasMaxLength(200).IsRequired();
|
||||
b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired();
|
||||
b.Property(o => o.Bin).HasMaxLength(20);
|
||||
b.Property(o => o.MoySkladToken).HasMaxLength(200);
|
||||
b.HasIndex(o => o.Name);
|
||||
});
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1074,6 +1074,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MoySkladToken")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
||||
import { AlertCircle, CheckCircle, Download, KeyRound, Users, Package, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertCircle, CheckCircle, KeyRound, Users, Package, Trash2, AlertTriangle, Save } from 'lucide-react'
|
||||
import { AxiosError } from 'axios'
|
||||
import { api } from '@/lib/api'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
|
|
@ -12,116 +12,165 @@ function formatError(err: unknown): string {
|
|||
const status = err.response?.status
|
||||
const body = err.response?.data as { error?: string; error_description?: string; title?: string } | undefined
|
||||
const detail = body?.error ?? body?.error_description ?? body?.title
|
||||
if (status === 404) {
|
||||
return '404 Not Found — эндпоинт не существует. Вероятно, API не перезапущен после git pull.'
|
||||
}
|
||||
if (status === 401) return '401 Unauthorized — сессия истекла, перелогинься.'
|
||||
if (status === 403) return '403 Forbidden — нужна роль Admin или SuperAdmin.'
|
||||
if (status === 502 || status === 503) return `${status} — МойСклад недоступен, попробуй позже.`
|
||||
if (status === 404) return '404 — эндпоинт не существует. API обновлён?'
|
||||
if (status === 401) return '401 — сессия истекла, перелогинься.'
|
||||
if (status === 403) return '403 — нужна роль Admin.'
|
||||
if (status === 502 || status === 503) return `${status} — МойСклад недоступен.`
|
||||
return detail ? `${status ?? ''} ${detail}` : err.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return String(err)
|
||||
}
|
||||
|
||||
interface TestResponse { organization: string; inn?: string | null }
|
||||
interface ImportResponse {
|
||||
total: number; created: number; skipped: number; groupsCreated: number; errors: string[]
|
||||
interface SettingsDto { hasToken: boolean; masked: string | null }
|
||||
interface JobView {
|
||||
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() {
|
||||
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({
|
||||
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({
|
||||
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-products', { token, overwriteExisting: overwrite })).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/products'] }),
|
||||
const [productsJobId, setProductsJobId] = useState<string | null>(null)
|
||||
const [counterpartiesJobId, setCounterpartiesJobId] = useState<string | null>(null)
|
||||
|
||||
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({
|
||||
mutationFn: async () => (await api.post<ImportResponse>('/api/admin/moysklad/import-counterparties', { token, overwriteExisting: overwrite })).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['/api/catalog/counterparties'] }),
|
||||
})
|
||||
const productsJob = useJob(productsJobId)
|
||||
const counterpartiesJob = useJob(counterpartiesJobId)
|
||||
|
||||
const hasToken = settings.data?.hasToken ?? false
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="p-6 max-w-3xl">
|
||||
<PageHeader
|
||||
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">
|
||||
<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
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="персональный токен или токен сервисного аккаунта"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
placeholder="персональный или сервисный токен"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<Button
|
||||
onClick={() => saveToken.mutate()}
|
||||
disabled={!tokenInput || saveToken.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saveToken.isPending ? 'Сохраняю…' : 'Сохранить токен'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => test.mutate()}
|
||||
disabled={!token || test.isPending}
|
||||
disabled={!hasToken || 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>
|
||||
<div className="text-sm text-emerald-700 dark:text-emerald-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">{formatError(test.error)}</div>}
|
||||
{saveToken.error && <div className="text-sm text-red-600">{formatError(saveToken.error)}</div>}
|
||||
</div>
|
||||
</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">
|
||||
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Операции импорта</h2>
|
||||
<h2 className="text-sm font-semibold">Операции импорта</h2>
|
||||
<Checkbox
|
||||
label="Перезаписать существующие записи (по артикулу/имени)"
|
||||
label="Обновлять уже импортированные записи (если найдены по артикулу/имени)"
|
||||
checked={overwrite}
|
||||
onChange={setOverwrite}
|
||||
/>
|
||||
<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" />
|
||||
{products.isPending ? 'Импортирую товары…' : 'Товары + группы + цены'}
|
||||
{productsJob.data?.status === 'Running' ? 'Товары импортируются…' : 'Товары + группы + цены'}
|
||||
</Button>
|
||||
<Button onClick={() => counterparties.mutate()} disabled={!token || counterparties.isPending}>
|
||||
<Download className="w-4 h-4" />
|
||||
<Button onClick={() => startCounterparties.mutate()} disabled={!hasToken || startCounterparties.isPending || counterpartiesJob.data?.status === 'Running'}>
|
||||
<Users className="w-4 h-4" />
|
||||
{counterparties.isPending ? 'Импортирую контрагентов…' : 'Контрагенты'}
|
||||
{counterpartiesJob.data?.status === 'Running' ? 'Контрагенты импортируются…' : 'Контрагенты'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ImportResult title="Товары" result={products} />
|
||||
<ImportResult title="Контрагенты" result={counterparties} />
|
||||
{productsJob.data && <JobCard title="Импорт товаров" job={productsJob.data} />}
|
||||
{counterpartiesJob.data && <JobCard title="Импорт контрагентов" job={counterpartiesJob.data} />}
|
||||
|
||||
<DangerZone />
|
||||
</div>
|
||||
|
|
@ -129,19 +178,61 @@ export function MoySkladImportPage() {
|
|||
)
|
||||
}
|
||||
|
||||
interface CleanupStats {
|
||||
counterparties: number
|
||||
products: number
|
||||
productGroups: number
|
||||
productBarcodes: number
|
||||
productPrices: number
|
||||
supplies: number
|
||||
retailSales: number
|
||||
stocks: number
|
||||
stockMovements: number
|
||||
function JobCard({ title, job }: { title: string; job: JobView }) {
|
||||
const done = job.status === 'Succeeded' || job.status === 'Failed'
|
||||
const color = job.status === 'Succeeded' ? 'text-emerald-600'
|
||||
: job.status === 'Failed' ? 'text-red-600' : 'text-slate-500'
|
||||
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 flex items-center gap-2">
|
||||
{job.status === 'Succeeded'
|
||||
? <CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||
: 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() {
|
||||
const qc = useQueryClient()
|
||||
|
|
@ -152,17 +243,14 @@ function DangerZone() {
|
|||
refetchOnMount: 'always',
|
||||
})
|
||||
|
||||
const wipeCounterparties = useMutation({
|
||||
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/counterparties')).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries()
|
||||
},
|
||||
})
|
||||
const [wipeJobId, setWipeJobId] = useState<string | null>(null)
|
||||
const wipeJob = useJob(wipeJobId)
|
||||
|
||||
const wipeAll = useMutation({
|
||||
mutationFn: async () => (await api.delete<CleanupResult>('/api/admin/cleanup/all')).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries()
|
||||
const startWipe = useMutation({
|
||||
mutationFn: async () => (await api.post<{ jobId: string }>('/api/admin/cleanup/all/async')).data,
|
||||
onSuccess: (d) => {
|
||||
setWipeJobId(d.jobId)
|
||||
qc.invalidateQueries({ queryKey: ['/api/admin/cleanup/stats'] })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -176,79 +264,52 @@ function DangerZone() {
|
|||
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">
|
||||
<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>
|
||||
<p className="text-xs text-red-900/80 dark:text-red-300/80 mb-4">
|
||||
Удаляет данные текущей организации. Справочники (ед. измерения, страны, валюты, типы цен, склады, точки продаж), пользователи и сама организация сохраняются.
|
||||
Удаляет товары, группы, контрагентов, документы и остатки. Справочники, пользователи, склады и организация сохраняются.
|
||||
</p>
|
||||
|
||||
{s && (
|
||||
<dl className="grid grid-cols-3 md:grid-cols-5 gap-2 text-xs mb-4">
|
||||
<Stat label="Контрагенты" value={s.counterparties} />
|
||||
<Stat label="Товары" value={s.products} />
|
||||
<Stat label="Группы" value={s.productGroups} />
|
||||
<Stat label="Штрихкоды" value={s.productBarcodes} />
|
||||
<Stat label="Цены" value={s.productPrices} />
|
||||
<Stat label="Поставки" value={s.supplies} />
|
||||
<Stat label="Чеки" value={s.retailSales} />
|
||||
<Stat label="Остатки" value={s.stocks} />
|
||||
<Stat label="Движения" value={s.stockMovements} />
|
||||
<Tile label="Контрагенты" value={s.counterparties} />
|
||||
<Tile label="Товары" value={s.products} />
|
||||
<Tile label="Группы" value={s.productGroups} />
|
||||
<Tile label="Штрихкоды" value={s.productBarcodes} />
|
||||
<Tile label="Цены" value={s.productPrices} />
|
||||
<Tile label="Поставки" value={s.supplies} />
|
||||
<Tile label="Чеки" value={s.retailSales} />
|
||||
<Tile label="Остатки" value={s.stocks} />
|
||||
<Tile label="Движения" value={s.stockMovements} />
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => confirmAndRun(
|
||||
`${s?.counterparties ?? '?'} контрагентов (+ связанные поставки/движения)`,
|
||||
() => wipeCounterparties.mutate(),
|
||||
'ВСЕ данные организации',
|
||||
() => startWipe.mutate(),
|
||||
)}
|
||||
disabled={wipeCounterparties.isPending || !s || s.counterparties === 0}
|
||||
disabled={startWipe.isPending || wipeJob.data?.status === 'Running'}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{wipeCounterparties.isPending ? 'Удаляю…' : 'Удалить контрагентов'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => confirmAndRun(
|
||||
'ВСЕ данные организации (товары, группы, контрагенты, документы, остатки)',
|
||||
() => wipeAll.mutate(),
|
||||
)}
|
||||
disabled={wipeAll.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{wipeAll.isPending ? 'Удаляю всё…' : 'Очистить все данные'}
|
||||
{wipeJob.data?.status === 'Running' ? 'Очищаю…' : 'Очистить все данные'}
|
||||
</Button>
|
||||
{wipeJob.data && (
|
||||
<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">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{wipeJob.data.status === 'Succeeded' && <CheckCircle className="w-4 h-4 text-emerald-600" />}
|
||||
{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" />}
|
||||
<strong>{wipeJob.data.stage ?? wipeJob.data.status}</strong>
|
||||
<span className="text-xs text-slate-500">удалено записей: {wipeJob.data.deleted.toLocaleString('ru')}</span>
|
||||
</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} движений.
|
||||
{wipeJob.data.message && <div className="text-xs text-slate-600">{wipeJob.data.message}</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>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
function Tile({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<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>
|
||||
|
|
@ -256,50 +317,3 @@ function Stat({ label, value }: { label: string; value: number }) {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue