food-market/src/food-market.api/Controllers/Admin/AdminCleanupController.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

202 lines
8.6 KiB
C#

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;
// Временные эндпоинты для очистки данных после кривых импортов.
// Удалять только свой tenant — query-filter на DbSets это обеспечивает.
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/cleanup")]
public class AdminCleanupController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IServiceScopeFactory _scopes;
private readonly ImportJobRegistry _jobs;
private readonly ITenantContext _tenant;
public AdminCleanupController(
AppDbContext db,
IServiceScopeFactory scopes,
ImportJobRegistry jobs,
ITenantContext tenant)
{
_db = db;
_scopes = scopes;
_jobs = jobs;
_tenant = tenant;
}
public record CleanupStats(
int Counterparties,
int Products,
int ProductGroups,
int ProductBarcodes,
int ProductPrices,
int Supplies,
int RetailSales,
int Stocks,
int StockMovements);
public record CleanupResult(string Scope, CleanupStats Deleted);
[HttpGet("stats")]
public async Task<ActionResult<CleanupStats>> GetStats(CancellationToken ct)
=> new CleanupStats(
await _db.Counterparties.CountAsync(ct),
await _db.Products.CountAsync(ct),
await _db.ProductGroups.CountAsync(ct),
await _db.ProductBarcodes.CountAsync(ct),
await _db.ProductPrices.CountAsync(ct),
await _db.Supplies.CountAsync(ct),
await _db.RetailSales.CountAsync(ct),
await _db.Stocks.CountAsync(ct),
await _db.StockMovements.CountAsync(ct));
/// <summary>Удалить всех контрагентов текущей организации. Чтобы не нарваться на FK,
/// сначала обнуляем ссылки (Product.DefaultSupplier, RetailSale.Customer) и сносим
/// поставки (они жёстко ссылаются на supplier).</summary>
[HttpDelete("counterparties")]
public async Task<ActionResult<CleanupResult>> WipeCounterparties(CancellationToken ct)
{
var before = await SnapshotAsync(ct);
// 1. Обнуляем nullable-FK
await _db.Products
.Where(p => p.DefaultSupplierId != null)
.ExecuteUpdateAsync(u => u.SetProperty(p => p.DefaultSupplierId, (Guid?)null), ct);
await _db.RetailSales
.Where(s => s.CustomerId != null)
.ExecuteUpdateAsync(u => u.SetProperty(s => s.CustomerId, (Guid?)null), ct);
// 2. Сносим поставки (NOT NULL supplier) + их stock movements/stocks
await _db.StockMovements
.Where(m => m.DocumentType == "supply" || m.DocumentType == "supply-reversal")
.ExecuteDeleteAsync(ct);
await _db.SupplyLines.ExecuteDeleteAsync(ct);
await _db.Supplies.ExecuteDeleteAsync(ct);
// 3. Контрагенты
await _db.Counterparties.ExecuteDeleteAsync(ct);
var after = await SnapshotAsync(ct);
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,
/// Supply*, RetailSale*, Stock, StockMovement.</summary>
[HttpDelete("all")]
public async Task<ActionResult<CleanupResult>> WipeAll(CancellationToken ct)
{
var before = await SnapshotAsync(ct);
// Documents first — they reference products, counterparties, stores.
await _db.StockMovements.ExecuteDeleteAsync(ct);
await _db.Stocks.ExecuteDeleteAsync(ct);
await _db.SupplyLines.ExecuteDeleteAsync(ct);
await _db.Supplies.ExecuteDeleteAsync(ct);
await _db.RetailSaleLines.ExecuteDeleteAsync(ct);
await _db.RetailSales.ExecuteDeleteAsync(ct);
// Product composites.
await _db.ProductImages.ExecuteDeleteAsync(ct);
await _db.ProductPrices.ExecuteDeleteAsync(ct);
await _db.ProductBarcodes.ExecuteDeleteAsync(ct);
// Products reference counterparty.DefaultSupplier — FK Restrict, but we're about
// to delete products anyway, so order products → counterparties.
await _db.Products.ExecuteDeleteAsync(ct);
await _db.ProductGroups.ExecuteDeleteAsync(ct);
await _db.Counterparties.ExecuteDeleteAsync(ct);
var after = await SnapshotAsync(ct);
return new CleanupResult("all", Diff(before, after));
}
private async Task<CleanupStats> SnapshotAsync(CancellationToken ct) => new(
await _db.Counterparties.CountAsync(ct),
await _db.Products.CountAsync(ct),
await _db.ProductGroups.CountAsync(ct),
await _db.ProductBarcodes.CountAsync(ct),
await _db.ProductPrices.CountAsync(ct),
await _db.Supplies.CountAsync(ct),
await _db.RetailSales.CountAsync(ct),
await _db.Stocks.CountAsync(ct),
await _db.StockMovements.CountAsync(ct));
private static CleanupStats Diff(CleanupStats a, CleanupStats b) => new(
a.Counterparties - b.Counterparties,
a.Products - b.Products,
a.ProductGroups - b.ProductGroups,
a.ProductBarcodes - b.ProductBarcodes,
a.ProductPrices - b.ProductPrices,
a.Supplies - b.Supplies,
a.RetailSales - b.RetailSales,
a.Stocks - b.Stocks,
a.StockMovements - b.StockMovements);
}