diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs
new file mode 100644
index 0000000..cda385c
--- /dev/null
+++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminController.cs
@@ -0,0 +1,81 @@
+using foodmarket.Application.Common;
+using foodmarket.Domain.Organizations;
+using foodmarket.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace foodmarket.Api.Controllers.SuperAdmin;
+
+/// SuperAdmin: setup-status, dashboard статистика, audit log.
+[ApiController]
+[Authorize(Roles = "SuperAdmin")]
+[Route("api/super-admin")]
+public class SuperAdminController : ControllerBase
+{
+ private readonly AppDbContext _db;
+
+ public SuperAdminController(AppDbContext db) => _db = db;
+
+ public record SetupStatusDto(bool NeedsSetup, int OrgCount);
+
+ [HttpGet("setup-status")]
+ public async Task> GetSetupStatus(CancellationToken ct)
+ {
+ var count = await _db.Organizations.IgnoreQueryFilters().CountAsync(ct);
+ return new SetupStatusDto(NeedsSetup: count == 0, OrgCount: count);
+ }
+
+ public record DashboardStats(
+ int TotalOrgs, int ActiveOrgs, int ArchivedOrgs,
+ int TotalUsers, int ActiveUsers,
+ int TotalProducts, int TotalSuppliesThisMonth);
+
+ [HttpGet("dashboard")]
+ public async Task> Dashboard(CancellationToken ct)
+ {
+ var monthAgo = DateTime.UtcNow.AddDays(-30);
+ return new DashboardStats(
+ TotalOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(ct),
+ ActiveOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => !o.IsArchived, ct),
+ ArchivedOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.IsArchived, ct),
+ TotalUsers: await _db.Users.CountAsync(ct),
+ ActiveUsers: await _db.Users.CountAsync(u => u.IsActive, ct),
+ TotalProducts: await _db.Products.IgnoreQueryFilters().CountAsync(ct),
+ TotalSuppliesThisMonth: await _db.Supplies.IgnoreQueryFilters().CountAsync(s => s.Date >= monthAgo, ct));
+ }
+
+ public record AuditRow(
+ Guid Id, DateTime CreatedAt, Guid SuperAdminUserId,
+ string ActionType, Guid? OrganizationId, string? OrganizationName,
+ string? EntityType, Guid? EntityId, string? Description, string? Reason, string IpAddress);
+
+ [HttpGet("audit-log")]
+ public async Task>> AuditLog(
+ [FromQuery] PagedRequest req,
+ [FromQuery] Guid? organizationId,
+ [FromQuery] string? actionType,
+ [FromQuery] DateTime? from,
+ [FromQuery] DateTime? to,
+ CancellationToken ct)
+ {
+ var q = _db.SuperAdminAuditLogs.AsNoTracking().AsQueryable();
+ if (organizationId is not null) q = q.Where(x => x.OrganizationId == organizationId);
+ if (!string.IsNullOrWhiteSpace(actionType)) q = q.Where(x => x.ActionType == actionType);
+ if (from is not null) q = q.Where(x => x.CreatedAt >= from);
+ if (to is not null) q = q.Where(x => x.CreatedAt <= to);
+ var total = await q.CountAsync(ct);
+ var orgNames = await _db.Organizations.IgnoreQueryFilters()
+ .ToDictionaryAsync(o => o.Id, o => o.Name, ct);
+ var items = await q
+ .OrderByDescending(x => x.CreatedAt)
+ .Skip(req.Skip).Take(req.Take)
+ .ToListAsync(ct);
+ var rows = items.Select(x => new AuditRow(
+ x.Id, x.CreatedAt, x.SuperAdminUserId,
+ x.ActionType, x.OrganizationId,
+ x.OrganizationId is not null && orgNames.TryGetValue(x.OrganizationId.Value, out var n) ? n : null,
+ x.EntityType, x.EntityId, x.Description, x.Reason, x.IpAddress)).ToList();
+ return new PagedResult { Items = rows, Total = total, Page = req.Page, PageSize = req.Take };
+ }
+}
diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs
new file mode 100644
index 0000000..72d776d
--- /dev/null
+++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs
@@ -0,0 +1,282 @@
+using foodmarket.Application.Common;
+using foodmarket.Domain.Catalog;
+using foodmarket.Domain.Organizations;
+using foodmarket.Infrastructure.Identity;
+using foodmarket.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using System.Security.Claims;
+
+namespace foodmarket.Api.Controllers.SuperAdmin;
+
+/// SuperAdmin console: управление организациями. Все запросы
+/// IgnoreQueryFilters() — обходим tenant-фильтр, видим всё. Все мутации
+/// логируются в super_admin_audit_log.
+[ApiController]
+[Authorize(Roles = "SuperAdmin")]
+[Route("api/super-admin/organizations")]
+public class SuperAdminOrganizationsController : ControllerBase
+{
+ private readonly AppDbContext _db;
+ private readonly UserManager _userMgr;
+
+ public SuperAdminOrganizationsController(AppDbContext db, UserManager userMgr)
+ {
+ _db = db; _userMgr = userMgr;
+ }
+
+ public record OrgRow(
+ Guid Id, string Name, string CountryCode,
+ bool IsActive, bool IsArchived, DateTime? ArchivedAt,
+ DateTime CreatedAt, int EmployeeCount, int ProductCount,
+ DateTime? LastLoginAt);
+
+ public record OrgDetail(
+ Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
+ Guid? DefaultCurrencyId, string? DefaultCurrencyCode,
+ bool IsActive, bool IsArchived, DateTime? ArchivedAt,
+ Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
+ DateTime CreatedAt, DateTime? UpdatedAt,
+ int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth);
+
+ public record OrgInput(
+ string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
+ Guid? DefaultCurrencyId, Guid? AccountOwnerUserId);
+
+ public record CreateOrgRequest(OrgInput Org, string AdminLastName, string AdminFirstName,
+ string AdminEmail, string? AdminPosition);
+
+ public record CreateOrgResult(OrgDetail Organization, string AdminEmail, string AdminTempPassword);
+
+ public record ArchiveRequest(string ConfirmationName);
+ public record DeleteRequest(string ConfirmationName);
+ public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
+
+ [HttpGet]
+ public async Task>> List(
+ [FromQuery] PagedRequest req,
+ [FromQuery] bool? archived,
+ CancellationToken ct)
+ {
+ var q = _db.Organizations.IgnoreQueryFilters().AsNoTracking().AsQueryable();
+ if (archived is not null) q = q.Where(o => o.IsArchived == archived);
+ if (!string.IsNullOrWhiteSpace(req.Search))
+ {
+ var s = req.Search.Trim().ToLower();
+ q = q.Where(o => o.Name.ToLower().Contains(s) || (o.Bin != null && o.Bin.Contains(s)));
+ }
+ var total = await q.CountAsync(ct);
+ var items = await q
+ .OrderBy(o => o.IsArchived).ThenBy(o => o.Name)
+ .Skip(req.Skip).Take(req.Take)
+ .Select(o => new OrgRow(
+ o.Id, o.Name, o.CountryCode,
+ o.IsActive, o.IsArchived, o.ArchivedAt, o.CreatedAt,
+ _db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id),
+ _db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
+ _db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
+ .Max(u => (DateTime?)u.LastLoginAt)))
+ .ToListAsync(ct);
+ return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
+ }
+
+ [HttpGet("{id:guid}")]
+ public async Task> Get(Guid id, CancellationToken ct)
+ {
+ var dto = await ProjectAsync(id, ct);
+ return dto is null ? NotFound() : Ok(dto);
+ }
+
+ [HttpPost]
+ public async Task> Create([FromBody] CreateOrgRequest input, CancellationToken ct)
+ {
+ var org = new Organization
+ {
+ Name = input.Org.Name, CountryCode = input.Org.CountryCode,
+ Bin = input.Org.Bin, Address = input.Org.Address,
+ Phone = input.Org.Phone, Email = input.Org.Email,
+ DefaultCurrencyId = input.Org.DefaultCurrencyId,
+ };
+ _db.Organizations.Add(org);
+ // Системные референсы (Stores, Cashiers, Roles) подсеет DevDataSeeder при
+ // следующем рестарте; для немедленной готовности создаём минимум руками.
+ var mainStore = new Store
+ {
+ OrganizationId = org.Id, Name = "Основной склад", Code = "MAIN", IsMain = true,
+ };
+ _db.Stores.Add(mainStore);
+
+ var adminRole = new EmployeeRole
+ {
+ OrganizationId = org.Id,
+ Name = "Администратор", Description = "Полный доступ ко всем разделам организации",
+ IsSystem = true, SortOrder = 0, Permissions = RolePermissions.All(),
+ };
+ _db.EmployeeRoles.Add(adminRole);
+
+ // AppUser админа
+ var existing = await _userMgr.FindByEmailAsync(input.AdminEmail);
+ if (existing is not null)
+ return BadRequest(new { error = $"Пользователь {input.AdminEmail} уже существует." });
+ var tempPwd = GenerateTempPassword();
+ var user = new User
+ {
+ UserName = input.AdminEmail, Email = input.AdminEmail, EmailConfirmed = true,
+ FullName = $"{input.AdminLastName} {input.AdminFirstName}".Trim(),
+ OrganizationId = org.Id, IsActive = true,
+ };
+ var ur = await _userMgr.CreateAsync(user, tempPwd);
+ if (!ur.Succeeded) return BadRequest(new { error = string.Join("; ", ur.Errors.Select(e => e.Description)) });
+ await _userMgr.AddToRoleAsync(user, "Admin");
+ org.AccountOwnerUserId = user.Id;
+
+ _db.Employees.Add(new Employee
+ {
+ OrganizationId = org.Id, UserId = user.Id,
+ LastName = input.AdminLastName, FirstName = input.AdminFirstName,
+ Position = input.AdminPosition ?? "Директор",
+ Email = input.AdminEmail, Role = adminRole, IsActive = true,
+ });
+
+ await _db.SaveChangesAsync(ct);
+ await LogAsync("CreateOrg", org.Id, $"Создана организация «{org.Name}»", null, $"{{\"adminEmail\":\"{input.AdminEmail}\"}}", ct);
+
+ var detail = await ProjectAsync(org.Id, ct);
+ return new CreateOrgResult(detail!, input.AdminEmail, tempPwd);
+ }
+
+ [HttpPut("{id:guid}")]
+ public async Task Update(Guid id, [FromBody] OrgInput input, CancellationToken ct)
+ {
+ var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
+ if (o is null) return NotFound();
+ var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
+ o.Name = input.Name; o.CountryCode = input.CountryCode;
+ o.Bin = input.Bin; o.Address = input.Address;
+ o.Phone = input.Phone; o.Email = input.Email;
+ o.DefaultCurrencyId = input.DefaultCurrencyId;
+ await _db.SaveChangesAsync(ct);
+ var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
+ await LogAsync("EditOrg", o.Id, $"Изменены данные «{o.Name}»", null,
+ $"{{\"before\":{before},\"after\":{after}}}", ct);
+ return NoContent();
+ }
+
+ [HttpPost("{id:guid}/archive")]
+ public async Task Archive(Guid id, [FromBody] ArchiveRequest req, CancellationToken ct)
+ {
+ var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
+ if (o is null) return NotFound();
+ if (o.IsArchived) return Conflict(new { error = "Уже в архиве." });
+ if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно для подтверждения." });
+ o.IsArchived = true; o.ArchivedAt = DateTime.UtcNow;
+ await _db.SaveChangesAsync(ct);
+ await LogAsync("ArchiveOrg", o.Id, $"Архивирована «{o.Name}»", null, "{}", ct);
+ return NoContent();
+ }
+
+ [HttpPost("{id:guid}/restore")]
+ public async Task Restore(Guid id, CancellationToken ct)
+ {
+ var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
+ if (o is null) return NotFound();
+ if (!o.IsArchived) return Conflict(new { error = "Не в архиве." });
+ o.IsArchived = false; o.ArchivedAt = null;
+ await _db.SaveChangesAsync(ct);
+ await LogAsync("RestoreOrg", o.Id, $"Восстановлена из архива «{o.Name}»", null, "{}", ct);
+ return NoContent();
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id, [FromBody] DeleteRequest req, CancellationToken ct)
+ {
+ var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
+ if (o is null) return NotFound();
+ if (!o.IsArchived || o.ArchivedAt is null || o.ArchivedAt > DateTime.UtcNow.AddDays(-30))
+ return Conflict(new { error = "Удалить можно только организации в архиве >30 дней." });
+ if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." });
+ await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
+ $"{{\"name\":\"{o.Name}\"}}", ct);
+ // Cascade delete domain entities is up to FK config; здесь просто Remove,
+ // EF выкинет ошибку если есть restrict-связи — оператор увидит и решит.
+ _db.Organizations.Remove(o);
+ await _db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpPost("{id:guid}/change-owner")]
+ public async Task ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct)
+ {
+ var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
+ if (o is null) return NotFound();
+ if (string.IsNullOrWhiteSpace(req.Reason)) return BadRequest(new { error = "Reason required." });
+ var user = await _userMgr.FindByIdAsync(req.NewOwnerUserId.ToString());
+ if (user is null || user.OrganizationId != o.Id)
+ return BadRequest(new { error = "Пользователь не найден или не принадлежит этой организации." });
+ var prev = o.AccountOwnerUserId;
+ o.AccountOwnerUserId = req.NewOwnerUserId;
+ await _db.SaveChangesAsync(ct);
+ await LogAsync("ChangeOwner", o.Id, $"Сменён владелец «{o.Name}»", req.Reason,
+ $"{{\"from\":\"{prev}\",\"to\":\"{req.NewOwnerUserId}\"}}", ct);
+ return NoContent();
+ }
+
+ private async Task ProjectAsync(Guid id, CancellationToken ct)
+ {
+ var o = await _db.Organizations.IgnoreQueryFilters().AsNoTracking()
+ .Include(x => x.DefaultCurrency)
+ .FirstOrDefaultAsync(x => x.Id == id, ct);
+ if (o is null) return null;
+ var emp = await _db.Employees.IgnoreQueryFilters().CountAsync(e => e.OrganizationId == id, ct);
+ var prod = await _db.Products.IgnoreQueryFilters().CountAsync(p => p.OrganizationId == id, ct);
+ var cp = await _db.Counterparties.IgnoreQueryFilters().CountAsync(c => c.OrganizationId == id, ct);
+ var monthAgo = DateTime.UtcNow.AddDays(-30);
+ var supplies = await _db.Supplies.IgnoreQueryFilters()
+ .CountAsync(s => s.OrganizationId == id && s.Date >= monthAgo, ct);
+ User? owner = null;
+ if (o.AccountOwnerUserId is not null)
+ owner = await _userMgr.FindByIdAsync(o.AccountOwnerUserId.ToString()!);
+ return new OrgDetail(
+ o.Id, o.Name, o.CountryCode, o.Bin, o.Address, o.Phone, o.Email,
+ o.DefaultCurrencyId, o.DefaultCurrency?.Code,
+ o.IsActive, o.IsArchived, o.ArchivedAt,
+ o.AccountOwnerUserId, owner?.FullName, owner?.Email,
+ o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies);
+ }
+
+ private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)
+ {
+ var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
+ Guid.TryParse(userIdRaw, out var uid);
+ _db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
+ {
+ SuperAdminUserId = uid,
+ ActionType = actionType, OrganizationId = orgId,
+ Description = description, Reason = reason,
+ ChangesJson = changesJson,
+ IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
+ });
+ await _db.SaveChangesAsync(ct);
+ }
+
+ private static string GenerateTempPassword()
+ {
+ const string lower = "abcdefghkmnpqrstuvwxyz";
+ const string upper = "ABCDEFGHKMNPQRSTUVWXYZ";
+ const string digits = "23456789";
+ const string special = "!@#$%&*";
+ var rnd = new Random();
+ var chars = new List
+ {
+ upper[rnd.Next(upper.Length)],
+ lower[rnd.Next(lower.Length)],
+ digits[rnd.Next(digits.Length)],
+ special[rnd.Next(special.Length)],
+ };
+ var pool = lower + upper + digits;
+ for (var i = 0; i < 8; i++) chars.Add(pool[rnd.Next(pool.Length)]);
+ return new string(chars.OrderBy(_ => rnd.Next()).ToArray());
+ }
+}