food-market/src/food-market.api/Controllers/Admin/MoySkladImportController.cs
nurdotnet a3b3caa2d3 fix(other-system/test): сделать Token опциональным
UI перестал отправлять токен в теле /test (он теперь из настроек),
а TestRequest был с non-null string — ASP.NET model validation отдавал
400 'One or more validation errors occurred'. Сделал nullable.
2026-04-24 00:25:24 +05:00

168 lines
7.1 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);
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] + "…");
}