food-market/src/food-market.infrastructure/Integrations/MoySklad/ImportJobRegistry.cs
nurdotnet ce0c3acdd6 feat(other-system-import): async jobs с прогрессом + токен в настройках
Pain points:
1. Импорт на ~30k товарах проходит 15-30 мин, nginx рвал на 60s → 504.
2. При импорте/очистке ничего не видно — ни счётчика, ни прогресса.
3. Токен приходилось вводить каждый раз вручную.

Фиксы:
- Async-job pattern: POST /api/admin/other-system/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.
- OtherSystemImportService обновляет progress по мере пейджинга
  (в т.ч. счётчик Created/Updated/Skipped).
- Cleanup разбит на именованные шаги, Stage меняется по мере
  "Товары…" → "Группы…" → "Контрагенты…".
- Токен per-organization: Organization.OtherSystemToken + миграция
  Phase3_OrganizationOtherSystemToken. Endpoints:
  GET/PUT /api/admin/other-system/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:
- OtherSystemImportPage переработан: блок "Токен API" (save/test
  mask), блок импорта с кнопками без поля токена.
- JobCard с polling каждые 1.5s отображает живые счётчики и stage.
- DangerZone тоже теперь async с live-прогрессом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:49:11 +05:00

48 lines
2.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using 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();
}