UI перестал отправлять токен в теле /test (он теперь из настроек), а TestRequest был с non-null string — ASP.NET model validation отдавал 400 'One or more validation errors occurred'. Сделал nullable.
168 lines
7.1 KiB
C#
168 lines
7.1 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);
|
||
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…";
|
||
_ = RunInBackgroundAsync(job, async (svc, progress, ctInner) =>
|
||
{
|
||
progress.Stage = "Импорт товаров…";
|
||
var result = await svc.ImportProductsAsync(token, req.OverwriteExisting, ctInner, progress);
|
||
progress.Message = $"Готово: {result.Created} записей (создано/обновлено), {result.Skipped} пропущено, {result.GroupsCreated} групп.";
|
||
}, 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 });
|
||
}
|
||
|
||
private Task RunInBackgroundAsync(
|
||
ImportJobProgress job,
|
||
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,
|
||
Guid orgId)
|
||
{
|
||
return Task.Run(async () =>
|
||
{
|
||
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;
|
||
}
|
||
});
|
||
}
|
||
|
||
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] + "…");
|
||
}
|