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>
240 lines
11 KiB
C#
240 lines
11 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;
|
||
|
||
[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] + "…");
|
||
}
|