Sprint 15 финальный — реальные axe + coverage + pg_restore numbers.
Ключевые цифры:
- axe-core: critical=0 on 10 страниц stage'а; serious 12→9
после фиксов (sidebar contrast + 8 icon-only back-arrow aria-labels).
- Unit coverage: Application 56%→83%, Domain 11%→79%, combined
60%→80%. Тестов 68→147 (+79).
- Backup recovery drill: RTO ~25 секунд end-to-end
(pg_dump 2s + pg_restore 4s + dotnet startup 19s).
Что сделано:
1. @axe-core/playwright + stage-ui-15 (10 страниц) + stage-ui-16
(SR smoke на login: getByLabel, role=alert, aria-describedby,
keyboard nav).
2. useFocusTrap hook (WCAG 2.4.3 + 2.1.2): return-focus, mount-focus,
Tab cycle. Подключён к Modal + ConfirmDialog с opt-in
defaultFocus='cancel'|'confirm'. ConfirmDialog по дефолту фокусит
Cancel для destructive actions (safer чем Enter→Delete).
3. A11y фиксы:
• text-slate-400→text-slate-500 в sidebar (contrast 2.63→4.61).
• 8 страниц edit с back-arrow Link — aria-label + aria-hidden
на иконке + текст-slate-500 цвет.
• Modal close button — то же.
• LoginPage — aria-invalid/aria-describedby/role=alert на
ошибках валидации.
• Field component — role="alert" на error span (announce'ит SR).
4. 8 файлов unit-тестов: PhoneNormalization, PagedRequest,
RequiredGuid, RolePermissions (Domain), DomainPocoSmoke,
DomainFullPropertyTouch, CatalogDtosSmoke, StockServiceProperty
(4 seeds × 4 size + batch + 2-product isolation).
5. Backup-drill: pg_dump со stage'а → fresh postgres:16-alpine →
pg_restore → dotnet run против восстановленной БД → /health/ready
Healthy. Команды и timing в RUNBOOK.md.
6. Docs review:
• MULTI-TENANCY чеклист «добавить tenant-сущность» расширен с 6
до 19 шагов (Domain → EF Config → Migration с Xmin →
RolePermissions → Validation → Controller + RequiresPermission →
Audit + SensitiveOpsAudit → property tests).
• ARCHITECTURE.md — Sprint 13-15 changes таблица.
• DEVELOPER-GUIDE.md — «что добавилось после первого guide'а» +
a11y pitfalls в «что НЕ делать».
Stage smoke ✓. Это финальный автономно-безопасный спринт. Дальше
нужен вход от user'а (ОФД keys, MoySklad tokens, Windows для POS,
прод-деплой план, kz-перевод, реальный SMTP).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
481 lines
23 KiB
Markdown
481 lines
23 KiB
Markdown
# Developer guide — food-market
|
||
|
||
Как поднять проект, что куда добавлять, какие паттерны соблюдать.
|
||
Предполагается, что вы прочитали [ARCHITECTURE.md](ARCHITECTURE.md) и
|
||
понимаете слои.
|
||
|
||
## Локальный setup
|
||
|
||
### Что нужно
|
||
|
||
- **.NET 8 SDK 8.0.4xx** (см. `global.json`, `rollForward: latestFeature`
|
||
— годится любой 8.0.4xx).
|
||
- **Node 20+** и **pnpm 9+** (для web).
|
||
- **PostgreSQL 14+** — на macOS обычно brew `postgresql@14`.
|
||
БД: `food_market`, owner `nns`, пароль пустой.
|
||
- **Docker** + **Docker Compose** — только для integration-тестов
|
||
(Testcontainers) и stage-деплоя.
|
||
|
||
### Поднять с нуля
|
||
|
||
```bash
|
||
git clone http://127.0.0.1:3000/nns/food-market.git
|
||
cd food-market
|
||
|
||
# 1) БД (если ещё нет)
|
||
createdb -O nns food_market # пользователь nns должен существовать
|
||
|
||
# 2) Backend
|
||
ASPNETCORE_ENVIRONMENT=Development \
|
||
dotnet run --project src/food-market.api
|
||
# первый запуск: применит миграции, посеит справочники, создаст
|
||
# SuperAdmin admin@food-market.local / Admin12345!.
|
||
# API на http://localhost:5081, Swagger на /swagger.
|
||
|
||
# 3) Web (в другом терминале)
|
||
cd src/food-market.web
|
||
pnpm install
|
||
pnpm dev
|
||
# http://localhost:5173
|
||
|
||
# 4) Smoke
|
||
curl http://localhost:5081/health
|
||
# и зайти в браузере, залогиниться admin@food-market.local
|
||
```
|
||
|
||
### Получить токен из CLI
|
||
|
||
```bash
|
||
TOKEN=$(curl -sX POST http://localhost:5081/connect/token \
|
||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||
-d 'grant_type=password&username=admin@food-market.local&password=Admin12345!&client_id=food-market-web&scope=openid profile email roles api offline_access' \
|
||
| jq -r .access_token)
|
||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:5081/api/me | jq
|
||
```
|
||
|
||
## Запуск тестов
|
||
|
||
```bash
|
||
# Unit-тесты (быстрые, ~7-10с)
|
||
dotnet test tests/food-market.UnitTests/
|
||
|
||
# Integration (тянут Postgres-контейнер, ~30-60с на холодную)
|
||
dotnet test tests/food-market.IntegrationTests/
|
||
|
||
# Фильтр по имени класса/метода
|
||
dotnet test tests/... --filter "FullyQualifiedName~Fiscal"
|
||
|
||
# Web — type-check + production build
|
||
cd src/food-market.web && pnpm exec tsc --noEmit && pnpm build
|
||
|
||
# E2E (Playwright против stage)
|
||
cd tests/e2e && pnpm install
|
||
pnpm playwright test stage-smoke.spec.ts
|
||
```
|
||
|
||
### Гочи integration-тестов
|
||
|
||
- **Testcontainers Ryuk** выключен (env `TESTCONTAINERS_RYUK_DISABLED=true`).
|
||
Причина: контейнер reaper тянет образ с Docker Hub, на dev-vm сеть
|
||
туда нестабильна.
|
||
- **Rate-limiter** выключен через `RateLimiting__Enabled=false`. Он
|
||
читает конфиг ЭАГЕРНО при регистрации сервисов — поэтому только через
|
||
переменную окружения (см. memory `test_suites_setup`).
|
||
- **Hangfire-сервер** выключен (`Hangfire__Enabled=false`) — иначе
|
||
создаёт схему и держит коннект в одноразовом контейнере.
|
||
- Один `ApiFactory` на всю xUnit-сессию через `[Collection(ApiCollection.Name)]`.
|
||
Делать второй `WebApplicationFactory<Program>` параллельно нельзя —
|
||
`HostFactoryResolver` сломается.
|
||
|
||
## Конвенции репо
|
||
|
||
- C# 12, `Nullable` enabled, `ImplicitUsings` enabled.
|
||
- Названия неймспейсов — `foodmarket.Domain`, `foodmarket.Application`,
|
||
`foodmarket.Infrastructure`, `foodmarket.Api`. Папки в `src/` —
|
||
`food-market.api`, `food-market.application`, … (с дефисом). Это
|
||
расхождение исторически — менять не нужно.
|
||
- Названия таблиц в БД — snake_case (явный `b.ToTable("retail_sales")`),
|
||
столбцы — PascalCase из C# (EF default), индексы по
|
||
`IX_<table>_<cols>` (EF default).
|
||
- Комментарии в коде — рассказывающие *почему*, не *что*. Если из имени
|
||
переменной/метода не понятно — переименуй; если из логики не понятно,
|
||
*почему* — комментируй.
|
||
- XML-doc на public API в Application/Infrastructure обязателен (даёт
|
||
IntelliSense для другой стороны и появляется в Swagger).
|
||
- Локализация UI — `src/lib/i18n.ts` (русский по умолчанию, есть
|
||
заготовка под KZ — нужен переводчик).
|
||
|
||
## Паттерны: добавить controller с permission
|
||
|
||
Пример: `POST /api/loyalty/programs` (создание программы лояльности),
|
||
доступно только Admin'у орги или SuperAdmin'у в edit-mode.
|
||
|
||
```csharp
|
||
// food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs
|
||
[ApiController]
|
||
[Authorize]
|
||
[Route("api/loyalty/programs")]
|
||
public class LoyaltyProgramsController : ControllerBase
|
||
{
|
||
private readonly AppDbContext _db;
|
||
private readonly ITenantContext _tenant;
|
||
private readonly ILogger<LoyaltyProgramsController> _log;
|
||
|
||
public LoyaltyProgramsController(
|
||
AppDbContext db, ITenantContext tenant, ILogger<LoyaltyProgramsController> log)
|
||
{
|
||
_db = db; _tenant = tenant; _log = log;
|
||
}
|
||
|
||
public record ProgramInput(
|
||
[Required] string Name,
|
||
[Range(1, 4)] int Type,
|
||
[Range(0, 1000)] decimal Rate,
|
||
bool IsActive);
|
||
|
||
[HttpPost, RequiresPermission("LoyaltyEdit")]
|
||
public async Task<ActionResult<Guid>> Create(
|
||
[FromBody] ProgramInput input, CancellationToken ct)
|
||
{
|
||
var p = new LoyaltyProgram
|
||
{
|
||
Name = input.Name.Trim(),
|
||
Type = (LoyaltyProgramType)input.Type,
|
||
Rate = input.Rate,
|
||
IsActive = input.IsActive,
|
||
// OrganizationId stamping применит в SaveChanges
|
||
};
|
||
_db.LoyaltyPrograms.Add(p);
|
||
await _db.SaveChangesAsync(ct);
|
||
_log.LogInformation(
|
||
"Loyalty program created: {ProgramId} {Name} org={OrgId}",
|
||
p.Id, p.Name, _tenant.OrganizationId);
|
||
return Ok(p.Id);
|
||
}
|
||
}
|
||
```
|
||
|
||
Что произошло:
|
||
|
||
- `[Authorize]` — JWT-токен обязателен (валидируется OpenIddict).
|
||
- `[Route("api/...")]` — обычный REST-маршрут. `api/` префикс
|
||
обязателен для всех контроллеров (web-фронт ходит через `/api/*`,
|
||
nginx это знает).
|
||
- `[RequiresPermission("LoyaltyEdit")]` — резолвится в policy `perm:LoyaltyEdit`,
|
||
handler проверяет `RolePermissions.LoyaltyEdit` булеву у роли
|
||
текущего юзера. Добавь поле в `RolePermissions.cs` если ещё нет,
|
||
миграция-`AddColumn` для `bool LoyaltyEdit NOT NULL DEFAULT false`
|
||
+ апдейт admin-роли в сидере.
|
||
- `ProgramInput` — record с DataAnnotations. Для сложной валидации —
|
||
отдельный FluentValidation `AbstractValidator<ProgramInput>` в
|
||
`food-market.api/Infrastructure/Validation/Validators.cs` (см.
|
||
паттерны там).
|
||
- `_db.LoyaltyPrograms.Add(p)` без явного `OrganizationId` —
|
||
`StampTenant` в `SaveChangesAsync` подставит.
|
||
- Логирование структурированное: `{ProgramId}`, `{Name}`, `{OrgId}` —
|
||
Serilog кладёт их как property'и (search'абельны в Loki/ES, не строкой).
|
||
|
||
### Если нужен Admin-only (грубее)
|
||
|
||
```csharp
|
||
[HttpPut, Authorize(Roles = "Admin")]
|
||
```
|
||
|
||
это эквивалентно «или Identity-role Admin, или SuperAdmin в режиме
|
||
override (через `SuperAdminOverrideClaimsTransformer`)». Подходит для
|
||
редких операций; для регулярных используй `RequiresPermission`.
|
||
|
||
## Паттерны: добавить сущность с RowVersion и tenant
|
||
|
||
Допустим, нужна новая сущность `PromoCode`.
|
||
|
||
### 1. Domain
|
||
|
||
```csharp
|
||
// food-market.domain/Sales/PromoCode.cs
|
||
public class PromoCode : TenantEntity, IVersionedEntity
|
||
{
|
||
public uint Xmin { get; set; }
|
||
|
||
public string Code { get; set; } = "";
|
||
public decimal Discount { get; set; }
|
||
public DateTime? ExpiresAt { get; set; }
|
||
public bool IsActive { get; set; } = true;
|
||
}
|
||
```
|
||
|
||
`TenantEntity` даёт `Id`, `CreatedAt`, `UpdatedAt`, `OrganizationId`.
|
||
`IVersionedEntity` + `Xmin` — оптимистичная блокировка через PG xmin.
|
||
|
||
### 2. EF Configuration
|
||
|
||
```csharp
|
||
// food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs
|
||
b.Entity<PromoCode>(e =>
|
||
{
|
||
e.ToTable("promo_codes");
|
||
e.UseXminAsConcurrencyToken();
|
||
e.Ignore(x => x.Xmin);
|
||
e.Property(x => x.Code).HasMaxLength(40).IsRequired();
|
||
e.Property(x => x.Discount).HasPrecision(18, 4);
|
||
e.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique(); // ← OrganizationId первым!
|
||
e.HasIndex(x => new { x.OrganizationId, x.IsActive });
|
||
});
|
||
```
|
||
|
||
**Важно**: индексы — с `OrganizationId` первым полем. Все запросы пройдут
|
||
через query filter и будут фильтроваться по этому полю; без правильного
|
||
индекса PG будет full-scan тенант-таблицы.
|
||
|
||
### 3. DbSet
|
||
|
||
```csharp
|
||
// food-market.infrastructure/Persistence/AppDbContext.cs
|
||
public DbSet<PromoCode> PromoCodes => Set<PromoCode>();
|
||
```
|
||
|
||
### 4. Миграция руками
|
||
|
||
```csharp
|
||
// food-market.infrastructure/Persistence/Migrations/20260608100000_PromoCodes.cs
|
||
[DbContext(typeof(AppDbContext))]
|
||
[Migration("20260608100000_PromoCodes")]
|
||
public partial class PromoCodes : Migration
|
||
{
|
||
protected override void Up(MigrationBuilder b)
|
||
{
|
||
b.CreateTable(
|
||
name: "promo_codes",
|
||
schema: "public",
|
||
columns: t => new
|
||
{
|
||
Id = t.Column<Guid>(type: "uuid", nullable: false),
|
||
OrganizationId = t.Column<Guid>(type: "uuid", nullable: false),
|
||
Code = t.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||
Discount = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||
ExpiresAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||
IsActive = t.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||
CreatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||
UpdatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||
},
|
||
constraints: t => t.PrimaryKey("PK_promo_codes", x => x.Id));
|
||
b.CreateIndex(
|
||
name: "IX_promo_codes_OrganizationId_Code",
|
||
schema: "public", table: "promo_codes",
|
||
columns: new[] { "OrganizationId", "Code" }, unique: true);
|
||
b.CreateIndex(
|
||
name: "IX_promo_codes_OrganizationId_IsActive",
|
||
schema: "public", table: "promo_codes",
|
||
columns: new[] { "OrganizationId", "IsActive" });
|
||
}
|
||
protected override void Down(MigrationBuilder b)
|
||
=> b.DropTable("promo_codes", "public");
|
||
}
|
||
```
|
||
|
||
**Обязательно**: `[DbContext]` + `[Migration("...")]` атрибуты — без них
|
||
`db.Database.Migrate()` миграцию не подхватит (memory:
|
||
`feedback_ef_migrations`).
|
||
|
||
### 5. Тест на изоляцию
|
||
|
||
Добавить case в `TenantIsolationTests`: org A создаёт PromoCode, org B
|
||
делает GET, видит пустой список.
|
||
|
||
## Валидация
|
||
|
||
### Простые правила — DataAnnotations
|
||
|
||
```csharp
|
||
public record ProductInput(
|
||
[Required, MaxLength(200)] string Name,
|
||
[Range(0, 1e10)] decimal Price);
|
||
```
|
||
|
||
### Сложные — FluentValidation
|
||
|
||
В `food-market.api/Infrastructure/Validation/Validators.cs`:
|
||
|
||
```csharp
|
||
public sealed class ProductInputValidator : AbstractValidator<ProductInput>
|
||
{
|
||
public ProductInputValidator()
|
||
{
|
||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
|
||
|
||
// Кросс-полевые правила, async, реализующие бизнес-инвариант
|
||
RuleFor(x => x.Lines).NotEmpty().WithMessage("Хотя бы одна позиция.");
|
||
RuleForEach(x => x.Lines).ChildRules(line =>
|
||
{
|
||
line.RuleFor(l => l.Quantity).GreaterThan(0);
|
||
line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0);
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
Валидаторы регистрируются автоматически через
|
||
`AddValidatorsFromAssemblyContaining<Program>()`. `ValidationFilter`
|
||
(глобальный action-filter в Program.cs) запускает их на каждом
|
||
action и возвращает 400 ProblemDetails (RFC 7807).
|
||
|
||
### Бизнес-валидация (требует БД)
|
||
|
||
Если правило требует справиться с БД (например, «склад существует и
|
||
не архивирован»), вынесите в первый шаг action-метода:
|
||
|
||
```csharp
|
||
[HttpPost]
|
||
public async Task<ActionResult> Create(ProductInput input, CancellationToken ct)
|
||
{
|
||
var groupOk = await _db.ProductGroups.AnyAsync(g => g.Id == input.ProductGroupId, ct);
|
||
if (!groupOk)
|
||
return BadRequest(new { error = "Группа не найдена.", field = "productGroupId" });
|
||
// ...
|
||
}
|
||
```
|
||
|
||
Формат ошибки — `{ error: "...", field?: "..." }`. Фронт показывает
|
||
`error` тостом, `field` подсвечивает в форме.
|
||
|
||
## Логирование
|
||
|
||
Используем Serilog со структурированными полями. `LogEnrichmentMiddleware`
|
||
уже добавляет `CorrelationId/OrgId/UserId` в каждую запись.
|
||
|
||
### Правила
|
||
|
||
- **Не строкой**: `_log.LogInformation("Created product " + id)` — нет.
|
||
`_log.LogInformation("Product created: {ProductId} name={Name}", id, name)` — да.
|
||
- **Уровень**:
|
||
- `Trace/Debug` — только для отладки конкретного бага.
|
||
- `Information` — успешные mutate-операции, важные events
|
||
(post/unpost документа, регистрация чека в ОФД).
|
||
- `Warning` — что-то пошло не как ожидалось, но обработали
|
||
(best-effort fail, retry-able ошибка).
|
||
- `Error` — обработать не удалось, нужен внимательный человек.
|
||
- `Critical` — приложение в плохом состоянии, может перестать работать.
|
||
- **Не логировать** PII в открытом виде (пароли, токены, email — email
|
||
можно, но не светить лишний раз).
|
||
- **Exception как первый аргумент**: `_log.LogError(ex, "...", ...)`,
|
||
не `_log.LogError("... " + ex.Message)` — теряется stack trace.
|
||
|
||
### Пример из RetailSalesController
|
||
|
||
```csharp
|
||
_log.LogInformation(
|
||
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
|
||
sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total);
|
||
|
||
// ...
|
||
|
||
try
|
||
{
|
||
await _notify.PublishAsync(...);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Notification — best-effort: не должна валить транзакцию (она уже закоммичена)
|
||
_log.LogWarning(ex, "SignalR notify failed for sale {SaleId}", sale.Id);
|
||
}
|
||
```
|
||
|
||
## SignalR realtime
|
||
|
||
Если нужно отправить уведомление на фронт (инвалидация query'я,
|
||
показ тоста):
|
||
|
||
```csharp
|
||
// в Program.cs INotificationsPublisher уже зарегистрирован
|
||
public class MyController : ControllerBase
|
||
{
|
||
private readonly INotificationsPublisher _notify;
|
||
|
||
[HttpPost("...")]
|
||
public async Task<IActionResult> Action(...)
|
||
{
|
||
// ... business logic ...
|
||
await _notify.PublishAsync(
|
||
organizationId,
|
||
NotificationEvents.SalePosted, // строковая константа
|
||
new SalePostedPayload(...)); // record DTO
|
||
return NoContent();
|
||
}
|
||
}
|
||
```
|
||
|
||
На фронте — `useNotifications()` хук подписан на хаб и инвалидирует
|
||
relevant query'и. Новые event'ы добавлять в `NotificationEvents`,
|
||
payload — в соседнем record'е.
|
||
|
||
## Что НЕ делать
|
||
|
||
- НЕ инжектить `IServiceProvider` чтобы доставать сервисы лениво —
|
||
объяви явные зависимости в конструкторе. Исключение: фабрики (Fiscal,
|
||
Email) которые открывают scope для свежего DbContext'а.
|
||
- НЕ писать raw SQL (`SqlQueryRaw`/`ExecuteSqlRaw`) без явного
|
||
`WHERE OrganizationId = @org` — query-filter не применится.
|
||
- НЕ менять снапшот в `Persistence/Migrations/AppDbContextModelSnapshot.cs`
|
||
руками для добавления новых полей — он используется только инструментом
|
||
`dotnet ef migrations add`, который мы не запускаем. Trying to add
|
||
partial state ломает только инструмент, ничего не дав. Если хочется —
|
||
обновляй целиком, синхронно с моделью; иначе оставь как есть.
|
||
- НЕ добавлять платные компоненты: Kendo, DevExpress, Syncfusion
|
||
commercial, Telerik (CLAUDE.md).
|
||
- НЕ менять `global.json` без согласования (CLAUDE.md).
|
||
- НЕ создавать миграции через `dotnet ef migrations add` — пиши руками
|
||
(memory: `feedback_ef_migrations`).
|
||
- НЕ делать `git push --force` на main (Forgejo — primary).
|
||
|
||
## Что добавилось после первого релиза этого guide'а
|
||
|
||
| Sprint | Чем пользоваться |
|
||
|---|---|
|
||
| 13 | `SensitiveOpsAudit` (`food-market.api/Infrastructure/Audit/`) — централизованный логгер sensitive-операций. Вместо ручного `OrgAuditLogs.Add` — `_audit.LogAsync(action, entityType, entityId, payload)`. |
|
||
| 13 | `[RequiresPermission("X")]` уже было; добавился `MeSessionsController.RevokeAll` — пример работы с `IOpenIddictAuthorizationManager`. |
|
||
| 13 | Все ответы автоматически получают security-заголовки через `SecurityHeadersMiddleware`. Если новый endpoint требует ослабленную CSP (например, embeds другой домен) — добавь его path в `ShouldSkip` middleware'a. |
|
||
| 14 | Композитный индекс `(OrganizationId, …)` на новых таблицах — must. Для отчётных запросов с фильтром по статусу — добавляй partial index `WHERE Status = X` с `INCLUDE` (covering). |
|
||
| 14 | `ImageVariantService` — при upload картинок автоматически генерирует thumb/medium WebP. Через frontend `<ProductImage src={url} size="thumb" />` — `<picture>` + srcset. |
|
||
| 14 | Hangfire-jobs: `JobTimingFilter` глобально логирует длительность; >30с — Warning. Для своих jobs ничего настраивать не надо. |
|
||
| 15 | `useFocusTrap` (`src/lib/useFocusTrap.ts`) — focus trap + return-focus для модалок. Уже подключён в `Modal` и `ConfirmDialog`. Для своего модала: `const ref = useFocusTrap<HTMLDivElement>(open)`. |
|
||
| 15 | a11y: каждая icon-only `<button>`/`<a>` нуждается в `aria-label="..."` и `aria-hidden="true"` на иконке внутри. Поля формы с ошибкой — `aria-invalid={true}` + `aria-describedby="err-id"` + `<span id="err-id" role="alert">...</span>`. Цвет текста для маленького font'а — `text-slate-500` минимум (4.61 contrast), не `text-slate-400` (2.63, fails WCAG AA). |
|
||
| 15 | Unit-coverage цель — 70% по строкам в Application+Domain. Добавляешь новую POCO → один touch-test в `DomainPocoSmokeTests`/`DomainFullPropertyTouchTests`. Property-based тест на бизнес-инвариант — `StockServicePropertyTests`-pattern (рандомные seed'ы, проверка инварианта). |
|
||
|
||
## Что НЕ делать
|
||
|
||
- НЕ инжектить `IServiceProvider` чтобы доставать сервисы лениво —
|
||
объяви явные зависимости в конструкторе. Исключение: фабрики (Fiscal,
|
||
Email) которые открывают scope для свежего DbContext'а.
|
||
- НЕ писать raw SQL (`SqlQueryRaw`/`ExecuteSqlRaw`) без явного
|
||
`WHERE OrganizationId = @org` — query-filter не применится.
|
||
- НЕ менять снапшот в `Persistence/Migrations/AppDbContextModelSnapshot.cs`
|
||
руками для добавления новых полей — он используется только инструментом
|
||
`dotnet ef migrations add`, который мы не запускаем. Trying to add
|
||
partial state ломает только инструмент, ничего не дав. Если хочется —
|
||
обновляй целиком, синхронно с моделью; иначе оставь как есть.
|
||
- НЕ добавлять платные компоненты: Kendo, DevExpress, Syncfusion
|
||
commercial, Telerik (CLAUDE.md).
|
||
- НЕ менять `global.json` без согласования (CLAUDE.md).
|
||
- НЕ создавать миграции через `dotnet ef migrations add` — пиши руками
|
||
(memory: `feedback_ef_migrations`).
|
||
- НЕ делать `git push --force` на main (Forgejo — primary).
|
||
- НЕ использовать `text-slate-400` для маленьких подписей на белом
|
||
фоне — fails WCAG AA color contrast (Sprint 15). Минимум `text-slate-500`.
|
||
- НЕ делать icon-only `<button>`/`<a>` без `aria-label` — Screen readers
|
||
пропустят его (Sprint 15 axe-core finding).
|
||
|
||
## Полезные ссылки
|
||
|
||
- [ARCHITECTURE.md](ARCHITECTURE.md) — слои и модули.
|
||
- [MULTI-TENANCY.md](MULTI-TENANCY.md) — query-filter, override-режим,
|
||
расширенный чеклист «как добавить tenant-сущность».
|
||
- [RUNBOOK.md](RUNBOOK.md) — операционные процедуры, recovery drill
|
||
(RTO ~25с подтверждённый).
|
||
- [openapi.md](openapi.md) — генерация TS-клиента из Swagger.
|
||
- [observability.md](observability.md) — Serilog + Prometheus + Grafana
|
||
dashboard JSON (Sprint 13).
|
||
- [ofd-integration.md](ofd-integration.md) — ОФД-провайдеры (Sprint 11).
|
||
- [performance-baseline.md](performance-baseline.md) — k6-замеры (Sprint 12, 14).
|
||
- [secrets.md](secrets.md) — где живут секреты.
|