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>
202 lines
8.6 KiB
C#
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);
|
|
}
|