feat(super-admin): настраиваемый retention period для архивных орг
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 48s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 47s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s

Раньше «удалить орг навсегда» было захардкожено на 30 дней архива.
Теперь — глобальная системная настройка SuperAdmin'а.

Domain/DB:
- SystemSettings : Entity (single-row table system_settings).
  Поле ArchiveRetentionDays (int, default 30). Структура расширяется
  именованными полями по мере необходимости — без key-value generic'а.
- Migration Phase4e_SystemSettings создаёт таблицу с default 30.
- DevDataSeeder: при первом старте создаёт single-row дефолт.

API:
- GET /api/super-admin/settings — текущие настройки.
- PUT /api/super-admin/settings — обновить с валидацией [0..3650].
  Audit-log запись ActionType=EditSystemSettings с before/after.
- SuperAdminOrganizationsController.Delete: хардкод 30 заменён
  чтением SystemSettings.ArchiveRetentionDays. При retention=0 —
  удаление доступно сразу после архивации.

UI:
- /super-admin/settings — страница «Системные настройки».
  Select из 6 опций (0/1/3/7/14/30), warning-баннер при выборе
  «Немедленно». Кнопка «Сохранить» disabled пока нет изменений.
- В SuperAdminLayout убрана пометка «скоро» с пункта «Системные
  настройки» — раздел активен.
- SuperAdminOrganizationsPage: кнопка «Удалить навсегда» теперь
  читает retentionDays из API; tooltip показывает оставшиеся дни
  «Доступно через X дн. (retention N)»; при retention=0 — всегда
  active для архивных орг.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 17:59:24 +05:00
parent 4152eb1291
commit 3e25498c3b
12 changed files with 2293 additions and 10 deletions

View file

@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace foodmarket.Api.Controllers.SuperAdmin;
@ -47,6 +48,49 @@ public async Task<ActionResult<DashboardStats>> Dashboard(CancellationToken ct)
RegistrationsLast30Days: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.CreatedAt >= monthAgo, ct));
}
public record SystemSettingsDto(int ArchiveRetentionDays);
public record SystemSettingsInput(int ArchiveRetentionDays);
[HttpGet("settings")]
public async Task<ActionResult<SystemSettingsDto>> GetSettings(CancellationToken ct)
{
var s = await _db.SystemSettings.FirstOrDefaultAsync(ct);
return new SystemSettingsDto(s?.ArchiveRetentionDays ?? 30);
}
[HttpPut("settings")]
public async Task<ActionResult<SystemSettingsDto>> UpdateSettings([FromBody] SystemSettingsInput input, CancellationToken ct)
{
if (input.ArchiveRetentionDays < 0 || input.ArchiveRetentionDays > 3650)
return BadRequest(new { error = "ArchiveRetentionDays должен быть в диапазоне 03650." });
var s = await _db.SystemSettings.FirstOrDefaultAsync(ct);
var prev = s?.ArchiveRetentionDays;
if (s is null)
{
s = new SystemSettings { ArchiveRetentionDays = input.ArchiveRetentionDays };
_db.SystemSettings.Add(s);
}
else
{
s.ArchiveRetentionDays = input.ArchiveRetentionDays;
}
await _db.SaveChangesAsync(ct);
// Audit-log смены настройки
var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
Guid.TryParse(userIdRaw, out var uid);
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
{
SuperAdminUserId = uid,
ActionType = "EditSystemSettings",
Description = $"ArchiveRetentionDays: {prev?.ToString() ?? "(default 30)"} → {input.ArchiveRetentionDays}",
ChangesJson = $"{{\"archiveRetentionDays\":{{\"from\":{prev?.ToString() ?? "30"},\"to\":{input.ArchiveRetentionDays}}}}}",
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
});
await _db.SaveChangesAsync(ct);
return new SystemSettingsDto(s.ArchiveRetentionDays);
}
public record AuditRow(
Guid Id, DateTime CreatedAt, Guid SuperAdminUserId,
string ActionType, Guid? OrganizationId, string? OrganizationName,

View file

@ -189,8 +189,11 @@ public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, C
{
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 (!o.IsArchived || o.ArchivedAt is null)
return Conflict(new { error = "Удалить можно только архивированную организацию." });
var retentionDays = await _db.SystemSettings.Select(s => (int?)s.ArchiveRetentionDays).FirstOrDefaultAsync(ct) ?? 30;
if (o.ArchivedAt > DateTime.UtcNow.AddDays(-retentionDays))
return Conflict(new { error = $"Доступно через {retentionDays} дней архива." });
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." });
await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
$"{{\"name\":\"{o.Name}\"}}", ct);

View file

@ -93,6 +93,15 @@ public async Task StartAsync(CancellationToken ct)
}
await SeedAdminEmployeeAsync(db, demoOrg.Id, admin?.Id, ct);
// Глобальные SystemSettings — single-row. Сидируем дефолт 30 дней
// retention если ещё нет записи.
var anySettings = await db.SystemSettings.AnyAsync(ct);
if (!anySettings)
{
db.SystemSettings.Add(new SystemSettings { ArchiveRetentionDays = 30 });
await db.SaveChangesAsync(ct);
}
}
/// <summary>Привязывает существующего admin@food-market.local к

View file

@ -0,0 +1,15 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Глобальные настройки платформы (single-row table). Управляются
/// SuperAdmin'ом в /super-admin/settings. Не tenant-scoped — действует на всю
/// систему. Расширяется добавлением новых типизированных полей по мере
/// необходимости (KISS — без key-value generic'а).</summary>
public class SystemSettings : Entity
{
/// <summary>Сколько дней должен пройти после архивации, прежде чем
/// SuperAdmin может удалить организацию навсегда. 0 = удалять сразу
/// после архивации (для dev/staging). По умолчанию 30 (prod-safe).</summary>
public int ArchiveRetentionDays { get; set; } = 30;
}

View file

@ -50,6 +50,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
protected override void OnModelCreating(ModelBuilder builder)
{
@ -80,6 +81,11 @@ protected override void OnModelCreating(ModelBuilder builder)
b.HasIndex(o => o.IsArchived);
});
builder.Entity<SystemSettings>(b =>
{
b.ToTable("system_settings");
});
builder.Entity<SuperAdminAuditLog>(b =>
{
b.ToTable("super_admin_audit_log");

View file

@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Глобальные настройки платформы (single-row): ArchiveRetentionDays
/// (по умолчанию 30) — сколько дней архивная орга должна пробыть в архиве
/// прежде чем её можно удалить навсегда. Юзер настраивает через UI
/// /super-admin/settings; раньше был хардкод 30 в SuperAdminOrganizationsController.</summary>
public partial class Phase4e_SystemSettings : Migration
{
protected override void Up(MigrationBuilder b)
{
b.CreateTable(
name: "system_settings",
schema: "public",
columns: table => new
{
Id = table.Column<System.Guid>(type: "uuid", nullable: false),
ArchiveRetentionDays = table.Column<int>(type: "integer", nullable: false, defaultValue: 30),
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table => table.PrimaryKey("PK_system_settings", x => x.Id));
}
protected override void Down(MigrationBuilder b)
{
b.DropTable(name: "system_settings", schema: "public");
}
}
}

View file

@ -1900,6 +1900,16 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Lines");
});
modelBuilder.Entity("foodmarket.Domain.Organizations.SystemSettings", b =>
{
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");
b.Property<int>("ArchiveRetentionDays").HasColumnType("integer");
b.Property<DateTime>("CreatedAt").HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt").HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("system_settings", "public");
});
modelBuilder.Entity("foodmarket.Domain.Organizations.SuperAdminAuditLog", b =>
{
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");

View file

@ -8,6 +8,7 @@ import { SuperAdminOrganizationsPage } from '@/pages/SuperAdminOrganizationsPage
import { SuperAdminOrgCreatePage } from '@/pages/SuperAdminOrgCreatePage'
import { SuperAdminAuditLogPage } from '@/pages/SuperAdminAuditLogPage'
import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage'
import { SuperAdminSettingsPage } from '@/pages/SuperAdminSettingsPage'
import { CountriesPage } from '@/pages/CountriesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage'
@ -60,6 +61,7 @@ export default function App() {
<Route path="countries" element={<CountriesPage />} />
<Route path="groups" element={<ProductGroupsPage />} />
<Route path="units" element={<UnitsOfMeasurePage />} />
<Route path="settings" element={<SuperAdminSettingsPage />} />
</Route>
{/* Tenant-роуты обычный AppLayout, но с TenantRouteGuard:

View file

@ -32,7 +32,7 @@ const NAV: NavSection[] = [
{ group: 'Тех. обслуживание', items: [
{ to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true },
{ to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true },
{ to: '/super-admin/settings', icon: Settings, label: 'Системные настройки', soon: true },
{ to: '/super-admin/settings', icon: Settings, label: 'Системные настройки' },
]},
]

View file

@ -1,7 +1,8 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Plus, Archive, RotateCcw, Trash2, LogIn } from 'lucide-react'
import { setOrgOverride } from '@/lib/api'
import { api, setOrgOverride } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
@ -10,7 +11,6 @@ import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput } from '@/components/Field'
import { useCatalogList } from '@/lib/useCatalog'
import { api } from '@/lib/api'
const URL = '/api/super-admin/organizations'
@ -45,9 +45,20 @@ export function SuperAdminOrganizationsPage() {
list.refetch()
}
const isOver30Days = (iso: string | null) => {
// Период до возможности удалить навсегда — читаем из системных настроек.
const settings = useQuery({
queryKey: ['/api/super-admin/settings'],
queryFn: async () => (await api.get<{ archiveRetentionDays: number }>('/api/super-admin/settings')).data,
})
const retentionDays = settings.data?.archiveRetentionDays ?? 30
const canDelete = (iso: string | null) => {
if (!iso) return false
return new Date(iso).getTime() < Date.now() - 30 * 24 * 60 * 60 * 1000
if (retentionDays === 0) return true
return new Date(iso).getTime() < Date.now() - retentionDays * 24 * 60 * 60 * 1000
}
const daysSinceArchive = (iso: string | null) => {
if (!iso) return 0
return Math.floor((Date.now() - new Date(iso).getTime()) / (24 * 60 * 60 * 1000))
}
return (
@ -116,9 +127,11 @@ export function SuperAdminOrganizationsPage() {
className="p-1.5 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded">
<RotateCcw className="w-4 h-4" />
</button>
<button title={isOver30Days(r.archivedAt) ? 'Удалить навсегда' : 'Удаление доступно через 30 дней архива'}
disabled={!isOver30Days(r.archivedAt)}
onClick={() => isOver30Days(r.archivedAt) && (setDeleteOf(r), setConfirmName(''))}
<button title={canDelete(r.archivedAt)
? 'Удалить навсегда'
: `Доступно через ${Math.max(0, retentionDays - daysSinceArchive(r.archivedAt))} дн. (retention ${retentionDays})`}
disabled={!canDelete(r.archivedAt)}
onClick={() => canDelete(r.archivedAt) && (setDeleteOf(r), setConfirmName(''))}
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-30 disabled:cursor-not-allowed">
<Trash2 className="w-4 h-4" />
</button>

View file

@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Save, AlertTriangle, CheckCircle2 } from 'lucide-react'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
import { Field, Select } from '@/components/Field'
import { Button } from '@/components/Button'
interface SystemSettingsDto { archiveRetentionDays: number }
const RETENTION_OPTIONS = [
{ value: 0, label: 'Немедленно (0 дней)' },
{ value: 1, label: '1 день' },
{ value: 3, label: '3 дня' },
{ value: 7, label: '7 дней' },
{ value: 14, label: '14 дней' },
{ value: 30, label: '30 дней (рекомендуется)' },
]
export function SuperAdminSettingsPage() {
const qc = useQueryClient()
const { data } = useQuery({
queryKey: ['/api/super-admin/settings'],
queryFn: async () => (await api.get<SystemSettingsDto>('/api/super-admin/settings')).data,
})
const [retention, setRetention] = useState<number | null>(null)
const [savedHint, setSavedHint] = useState(false)
useEffect(() => { if (data && retention === null) setRetention(data.archiveRetentionDays) }, [data, retention])
const save = useMutation({
mutationFn: async () => {
if (retention === null) return
await api.put<SystemSettingsDto>('/api/super-admin/settings', { archiveRetentionDays: retention })
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['/api/super-admin/settings'] })
setSavedHint(true)
setTimeout(() => setSavedHint(false), 2500)
},
})
const dirty = retention !== null && data && retention !== data.archiveRetentionDays
const isImmediate = retention === 0
return (
<div className="h-full overflow-auto">
<div className="max-w-2xl mx-auto p-4 sm:p-6 space-y-5">
<PageHeader
title="Системные настройки"
description="Глобальные параметры платформы. Применяются ко всем организациям."
/>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold">Архивирование организаций</h3>
<Field label="Период до полного удаления из архива">
<Select value={String(retention ?? 30)} onChange={(e) => setRetention(Number(e.target.value))}>
{RETENTION_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
</Field>
<p className="text-xs text-slate-500">
Через указанное время архивная организация может быть удалена навсегда.
При значении «Немедленно» кнопка удаления доступна сразу после архивации.
Рекомендуется 730 дней в продакшене.
</p>
{isImmediate && (
<div className="flex items-start gap-2 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 px-3 py-2">
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-200">
Удалённые организации нельзя восстановить. Это поведение подходит для разработки/тестирования,
в продакшене не рекомендуется.
</p>
</div>
)}
<div className="flex items-center gap-3 pt-2">
<Button onClick={() => save.mutate()} disabled={!dirty || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
{savedHint && (
<span className="text-sm text-emerald-600 inline-flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" /> Сохранено
</span>
)}
</div>
</section>
</div>
</div>
)
}