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> GetSettings(CancellationToken ct) { var token = await ReadTokenFromOrgAsync(ct); return new SettingsDto(!string.IsNullOrEmpty(token), Mask(token)); } [HttpPut("settings")] public async Task> 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 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> 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> 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 work, Guid orgId) { return Task.Run(async () => { try { using var tenantScope = HttpContextTenantContext.UseOverride(orgId); using var scope = _scopes.CreateScope(); var svc = scope.ServiceProvider.GetRequiredService(); 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 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] + "…"); }