food-market/src/food-market.api/Controllers/Admin/MoySkladImportController.cs
nns 2ed9f04cb2 feat(migration): документный мигратор МойСклад (приёмки) + externalId на документах
Part 2 (документы) — проверенный шаблон на приёмках:
- Phase41: MoySkladExternalId у counterparties/stores/units/retail_points/employees и
  документов (supplies/enters/losses/inventories/supplier_returns/retail_sales/
  cash_shifts/money_transactions) + индексы. Свойства на доменных сущностях.
- MoySkladClient.StreamDocumentsAsync: документы в хронологии (order=moment,asc), expand
  позиций/связей (limit=100), обработка 429/сбоев.
- MoySkladDocumentImportService.ImportSuppliesAsync: идемпотентно по msId, резюмируемо
  (skip перенесённых), moment МСК→UTC, суммы из тиынов, поставщик по externalId контрагента,
  товары по externalId, проведение → движения остатков (StockService). Журнал ошибок.
- Импорт контрагентов теперь пишет MoySkladExternalId (нужно для ссылок в документах).
- Эндпоинт POST /api/admin/moysklad/import-supplies (фон, job). Остальные типы — по шаблону.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:53:52 +05:00

240 lines
11 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 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;
[ApiController]
[Authorize(Policy = "AdminAccess")]
[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(
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 = null);
public record ImportRequest(
string? Token = null,
bool OverwriteExisting = false,
bool SkipArchived = true,
bool MergeByBarcode = true);
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)
{
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(token, ct);
if (!result.Success)
{
var msg = result.StatusCode switch
{
401 or 403 => "Токен недействителен или не имеет доступа к API.",
503 or 502 => "МойСклад временно недоступен. Повтори через минуту.",
_ => $"МойСклад вернул {result.StatusCode}: {Truncate(result.Error, 400)}",
};
return StatusCode(result.StatusCode ?? 502, new { error = msg });
}
return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn });
}
// Async launch — возвращает jobId, реальная работа в фоне. Клиент полит /jobs/{id}.
[HttpPost("import-products")]
public async Task<ActionResult<object>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
{
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 job = _jobs.Create("products");
job.Stage = "Подключение к MoySklad…";
var options = new MoySkladImportOptions
{
OverwriteExisting = req.OverwriteExisting,
SkipArchived = req.SkipArchived,
MergeByBarcode = req.MergeByBarcode,
};
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
{
progress.Stage = "Импорт товаров…";
var result = await svc.ImportProductsAsync(token, options, ctInner, progress);
// Sprint 32: расширенный summary — отдельно merge/skip-archived/skip-dupe.
progress.Message =
$"Готово: создано {result.Created - result.MergedByBarcode}," +
$" merge-barcode {result.MergedByBarcode}," +
$" пропущено {result.Skipped}," +
$" архивных пропущено {result.SkippedArchivedInSource}," +
$" дубль-штрихкод в MS {result.SkippedDuplicateBarcodeInSource}," +
$" групп создано {result.GroupsCreated}," +
$" групп обновлено {result.GroupsUpdated}.";
}, orgId);
return Ok(new { jobId = job.Id });
}
[HttpPost("import-counterparties")]
public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRequest req, CancellationToken ct)
{
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 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 });
}
[HttpPost("import-supplies")]
public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest req, CancellationToken ct)
{
var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token;
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Токен не настроен." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var job = _jobs.Create("supplies");
job.Stage = "Перенос приёмок (с историей)…";
_ = Task.Run(async () =>
{
using var flush = new Timer(_ => { try { using var s = HttpContextTenantContext.UseOverride(orgId); _ = _jobs.SaveAsync(job); } catch { } },
null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3));
try
{
using var tenantScope = HttpContextTenantContext.UseOverride(orgId);
using var scope = _scopes.CreateScope();
var docs = scope.ServiceProvider.GetRequiredService<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
var pr = new foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService.DocImportProgress();
await docs.ImportSuppliesAsync(token, 0, pr, CancellationToken.None);
job.Total = pr.Seen; job.Created = pr.Created; job.Skipped = pr.Skipped;
job.Message = $"Приёмки: перенесено {pr.Created}, пропущено {pr.Skipped}, ошибок {pr.Errors}. " +
(pr.ErrorSamples.Count > 0 ? "Примеры: " + string.Join(" | ", pr.ErrorSamples.Take(5)) : "");
foreach (var e in pr.ErrorSamples) job.Errors.Add(e);
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; try { using var s2 = HttpContextTenantContext.UseOverride(orgId); await _jobs.SaveAsync(job); } catch { } }
});
return Ok(new { jobId = job.Id });
}
private Task RunInBackgroundAsync(
ImportJobProgress job,
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,
Guid orgId)
{
return Task.Run(async () =>
{
// Периодический flush snapshot'а в БД каждые 2 секунды — чтобы UI
// видел actual прогресс (Stage/Created/Total), а не Create-time
// запись до самого финиша. AsyncLocal-override активен в callback'е,
// потому что Timer вызывает наш delegate в этом же ExecutionContext'е.
using var flushTimer = new Timer(_ =>
{
try
{
using var s = HttpContextTenantContext.UseOverride(orgId);
_ = _jobs.SaveAsync(job);
}
catch { /* swallow — следующий тик попробует снова */ }
}, null, dueTime: TimeSpan.FromSeconds(2), period: TimeSpan.FromSeconds(2));
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;
// Финальный flush — обязательный, иначе после рестарта job
// останется со статусом Running навсегда.
try
{
using var tenantScope2 = HttpContextTenantContext.UseOverride(orgId);
await _jobs.SaveAsync(job);
}
catch { /* registry сам логирует */ }
}
});
}
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] + "…");
}