Commit Graph

110 Commits

Author SHA1 Message Date
Дмитрий 280cfcd6cf feat(routes): register POST /api/webhook/supplier/{secret}
Spec §5.1 supplier-webhook endpoint. SupplierWebhookController tests
переходят с 405 на 8/8 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:39:43 +03:00
Дмитрий e41c8f5aef feat(http): SupplierWebhookController — platform-wide /api/webhook/supplier/{secret}
Defense-in-depth: secret (≥32 chars system_setting) + IP allowlist (CIDR).
Несовпадение → 404. UNIQUE vid → 200 OK на дубль (idempotency).

Тесты пока FAIL (route регистрируется в Task 7 — пишем "красные" тесты заранее
для TDD-цикла).

Spec §5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:38:18 +03:00
Дмитрий 605c457c49 fix(jobs): RouteSupplierLeadJob — per-Project failure isolation + 2 tests
Code-review Important: один сбой Project не должен абортить routing для
остальных tenant'ов (sharing-model). + try/catch + Log::warning +
RuntimeException только если ВСЕ projects упали.

+ 2 новых теста: mixed routing (1 dup из 3 + 2 clean) и partial failure
(soft-delete tenant в середине loop'а).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:30:06 +03:00
Дмитрий c519044319 feat(jobs): RouteSupplierLeadJob — sharing-model deal-copies + charge per tenant
Распределяет supplier_lead по eligible Liderra-проектам через LeadRouter.
Для каждого: транзакция с SET LOCAL app.current_tenant_id, lockForUpdate,
DuplicateDetector check, balance_leads--, delivered_today/month++,
BalanceTransaction, ActivityLog, NotificationService::notifyNewLead.

Spec §5-§6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:05:21 +03:00
Дмитрий 9540090bba fix(services): LeadRouter — Europe/Moscow timezone for workday-mask check
Code-review Important: Carbon::now() resolved against process TZ (UTC quirk
из memory). Reset cron в 00:00 МСК — mismatch вызвал бы off-by-one на
рубеже полуночи. Тест синхронизирован (now('Europe/Moscow')) — иначе
mismatch test/service near midnight. + комментарий про unreachable default
match arm (защищён на DB-уровне через chk_supplier_projects_platform).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:56:15 +03:00
Дмитрий 31929c9dd2 feat(services): LeadRouter — eligible Liderra projects matcher
Plan 2/5 Task 4 — sharing-model routing (spec §6): для входящего лида
возвращает Collection<Project> учитывая platform FK + active + workdays +
region (PhonePrefixService::phoneMatchesRegions) + delivered_today <
COALESCE(effective_daily_limit_today, daily_limit_target) +
tenant.balance_leads > 0. Сортировка created_at ASC, id ASC (детерминированно).

Параллельно расширил Project model fillable/casts на delivered_today
(колонка добавлена в schema v8.18 Plan 2 Task 1, но Project::class не
обновлён — без этого тесты Mass-Assignment'а ломались).

Покрытие: 9 it-blocks (sharing across tenants, paused, workdays, daily quota,
fallback to daily_limit_target, region filter, balance_leads zero, FK routing
по platform, deterministic sort). DatabaseTransactions context + set_config
(session-scoped) для очистки app.current_tenant_id — sharing-flow работает
поверх N tenant'ов, RLS bypass через postgres BYPASSRLS на dev.

PHPStan: 0 errors. Pint: clean. Pest: 9/9 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:50:28 +03:00
Дмитрий 5f08459615 feat(services): PhonePrefixService — phone → federal district bit (MVP)
Маппинг мобильных + 30+ ABC-кодов городов на 8 ФО RF (synchronized
с projects.region_mask битами). Мобильные → all-districts (255).
Полный Минсвязи справочник отложен на Plan 5+.

Spec: §6 step 2 routing geo-filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:36:43 +03:00
Дмитрий aa37f4cbed feat(models): SupplierLead model + factory (raw-payload incoming webhooks)
SaaS-level модель для supplier_leads (Plan 2/5 Task 2).
belongsTo(SupplierProject) + array cast на raw_payload + datetime *_at.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:24:45 +03:00
Дмитрий f5c7c29301 test(schema): add UNIQUE-vid behavioral test + intent-clear placeholder assert
- Add it-block для UNIQUE INDEX idx_supplier_leads_vid_unique:
  две INSERT с одинаковым vid → вторая бросает QueryException
  (прямой behavioral тест webhook-идемпотентности).
- Replace tautological strlen($secret->value) >= 16 на toBe('__SET_ON_DEPLOY__')
  — было проверкой литерала, который мы сами и записали; теперь intent-clear
  assertion того, что seed кладёт placeholder. Реальная strength-валидация
  secret'а — дело deploy-time validator'а, вне scope Plan 2.
- Add uses(DatabaseTransactions::class) — приводит файл к проектному
  pattern (см. WebhookReceiveTest, TenantModelsTest, SetTenantContextTest).
  Без него новый INSERT с vid=999000111 коллидил бы при re-run, т.к.
  Pest.php применяет RefreshDatabase глобально не делает (закомментирован).

Code-review fixes for Plan 2 Task 1.
2026-05-10 18:16:19 +03:00
Дмитрий fb55bfdd1f feat(db): supplier_leads + projects.delivered_today + 2 system_settings (v8.18)
Plan 2/5 Task 1 — слой данных для supplier-webhook flow.

- supplier_leads (SaaS-level, без RLS) — raw payload incoming webhook'ов
- projects.delivered_today — дневной счётчик для проверки daily quota
- system_settings: supplier_webhook_secret + supplier_ip_allowlist

Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5-§6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:07:31 +03:00
Дмитрий 001d7819bf fix(supplier): close code-review BLOCKER+WARN — FK + CHECK + resolver guard
Code-review subagent (CV.12 в Plan 1) нашёл 1 BLOCKER + 2 actionable WARNINGs:

1. **BLOCKER** — projects.supplier_b{1,2,3}_project_id были голыми BIGINT без
   REFERENCES, вопреки явному комментарию «FK добавятся в Task 2». Task 2
   создал supplier_projects, но FK на projects не вернул. Можно было записать
   произвольный BIGINT в эти колонки.
   Fix: ALTER TABLE projects ADD CONSTRAINT … FOREIGN KEY … ON DELETE SET NULL
   для всех трёх + 3 partial index (WHERE NOT NULL) для FK lookup.

2. **WARNING** (Project-level B1+SMS guard) — CHECK существовал только на
   supplier_projects; Project::create(['signal_type'=>'sms','supplier_b1_project_id'=>…])
   проходил вопреки spec §2.2 «B1 не поддерживает СМС».
   Fix: ADD CONSTRAINT chk_projects_b1_not_for_sms
   CHECK (signal_type <> 'sms' OR supplier_b1_project_id IS NULL).

3. **WARNING** (resolver collision) — SupplierProjectResolver::resolveOrStub
   firstOrCreate на (platform, unique_key) без signal_type → при коллизии
   unique_key возвращал чужую запись с другим signal_type без ошибки.
   Fix: после firstOrCreate проверяется match signal_type, иначе DomainException.
   +1 тест на collision.

Schema bumped v8.16 → v8.17. Метрики: 60 таблиц / 111 индексов (+3) / 39 RLS.
Pest: 500/498 passed (+1 collision test). Larastan 0 errors. Pint clean.

Spec: §2.1, §2.2
Plan: Task 2 (закрытие code-review CV.12)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:32:02 +03:00
Дмитрий 44a1b21421 feat(rules): add Signal validators (Domain, Phone, SmsSender) with comprehensive datasets
- app/Rules/SignalIdentifier/DomainIdentifier.php — regex
  ^[a-z0-9-]+(\.[a-z0-9-]+)+$ (нижний регистр, без протокола/пути).
- app/Rules/SignalIdentifier/PhoneIdentifier.php — regex ^7\d{10}$
  (11 цифр, начинается с 7).
- app/Rules/SignalIdentifier/SmsSenderRule.php — 1-30 символов
  [A-Za-z0-9_-]; отвергает 11-значные номера (поставщик блокирует
  физический телефон в роли отправителя — alert "Важно!" в форме
  создания SMS-проекта).
- tests/Feature/Rules/SignalValidatorsTest.php — 24 теста с datasets:
  • Domain: 4 valid + 6 invalid (case, no-TLD, spaces, protocol, path, double-dot)
  • Phone: 3 valid + 6 invalid (8-prefix, length, plus, spaces, letters)
  • SmsSender: alpha+numeric short, 11-digit blocked, length>30 blocked,
    empty (с required), special chars blocked

Quirk: Laravel skips non-implicit rules для пустых строк. Тест empty
использует связку 'required' + правило (как в реальном FormRequest).

Pest: 499 / 497 passed / 2 skipped (473 + 24 новых = 497).
Larastan: 0 errors. Pint passed.

Spec: §3.1
Plan: Task 12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:05:19 +03:00
Дмитрий 59c57f9ec0 feat(services): add SupplierProjectResolver (resolveOrStub with B1+SMS guard)
- app/Services/SupplierProjects/SupplierProjectResolver.php — резолвер
  по ключу (platform, signal_type, unique_key). Возвращает existing supplier_project
  или создаёт pending stub (физическая sync произойдёт в SyncSupplierProjectsJob, Plan 3).
- Защита 1: InvalidArgumentException на платформу не из {B1,B2,B3}.
- Защита 2: InvalidArgumentException на signal_type не из {site,call,sms}.
- Защита 3: DomainException на B1+SMS combo (chk_supplier_projects_b1_not_for_sms).
- tests/Feature/Services/SupplierProjectResolverTest.php — 6 тестов:
  resolve existing, create stub, idempotency (no duplicates), B1+SMS guard,
  invalid platform, invalid signal_type.

Pest: 475 / 473 passed / 2 skipped (467 + 6 новых = 473).
Larastan: 0 errors. Pint passed.

Spec: §2.2, §4.1
Plan: Task 11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:02:25 +03:00
Дмитрий 99afcbc25c feat(models): extend Project with signal_type, sms_senders, supplier_b1/b2/b3 relations + scopes
- app/Models/Project.php — добавлены fillable+casts для supplier integration:
  signal_type, signal_identifier, sms_senders (jsonb array), sms_keyword,
  delivered_in_month, supplier_b{1,2,3}_project_id.
  + supplierB1/B2/B3() BelongsTo relations на SupplierProject (sharing-model).
  + scopeActiveOnDay($iso) — bitmask проверка по delivery_days_mask
    (bit 0 = Mon, bit 6 = Sun; ISO=1 → 1<<0 = 1; ISO=7 → 1<<6 = 64).
  + scopeForSignal($type, $identifier) — фильтр по сигналу (для роутинга в Plan 2).
- database/factories/ProjectFactory.php — defaults null/0 для новых полей
  (CHECK constraints не нарушаются: signal_type IS NULL → остальные опциональны).
  + state-методы asSiteSignal($domain), asCallSignal($phone), asSmsSignal($senders, $keyword).
- tests/Feature/Models/ProjectExtensionsTest.php — 6 тестов: signal_type fillable,
  sms_senders array cast + sms_keyword, SMS без keyword, supplierB1/B2/B3 relations,
  scopeActiveOnDay (bitmask Mon/Sat), scopeForSignal (3 сигнала + edge-case).

Pest: 469 / 467 passed / 2 skipped (461 + 6 новых = 467, с retry на transient
PG connection issues — на параллельных тестах с testing_rls_user GRANT тяжёл).
Larastan: 0 errors. Pint passed.

Spec: §2.1
Plan: Task 10

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:59:53 +03:00
Дмитрий 084a952bfa feat(models): add SupplierSyncLog model + factory (audit trail для AJAX-sync)
- app/Models/SupplierSyncLog.php — fillable + casts (jsonb arrays + datetime)
  + supplierProject() BelongsTo relation (nullable, ON DELETE SET NULL —
    лог переживает удаление supplier-проекта для audit-trail).
  $timestamps = false (только created_at, без updated_at — append-only)
- database/factories/SupplierSyncLogFactory.php — реалистичные действия из enum
- tests/Feature/Models/SupplierSyncLogTest.php — 4 теста: factory,
  supplier_project relation, jsonb array casts, nullable FK lifecycle

Pest: 463 / 461 passed / 2 skipped (457 + 4 новых = 461).
Larastan: 0 errors. Pint passed.

Spec: §4.3
Plan: Task 9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:53:55 +03:00
Дмитрий 31f813315d feat(models): add LeadCharge ledger model + factory + relations to Tenant/Deal
- app/Models/LeadCharge.php — fillable + casts (datetime + integer)
  + tenant() BelongsTo relation
  + deal() BelongsTo relation (по deal_id, без deal_received_at — composite PK
    на партиционированной обеспечивается БД-уровнем через FK)
  + accessor priceRubles (kopecks → float)
- database/factories/LeadChargeFactory.php — НЕ создаёт реальный Deal автоматически
  (composite FK requires (deal_id, deal_received_at) пары); тесты с FK-целостностью
  явно создают Deal::factory() и передают пару в state()
- tests/Feature/Models/LeadChargeTest.php — 4 теста: factory, tenant relation,
  deal relation, priceRubles accessor. testing_rls_user setup в beforeEach
  для проверки RLS context из не-superuser контекста.

Quirk: SET LOCAL app.current_tenant_id НЕ принимает параметрическое связывание PG —
используем string interpolation с {$tenant->id} как в RlsSmokeTest pattern.

ide-helper:models -W -M -N синхронизировал docblocks (WebhookDedupKey).

Pest: 459 / 457 passed / 2 skipped (453 + 4 новых = 457).
Larastan: 0 errors. Pint passed.

Spec: §7.4
Plan: Task 8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:52:10 +03:00
Дмитрий 71e38ee0a9 feat(models): add PricingTier model with kopecks→rubles accessor + current() snapshot
- app/Models/PricingTier.php — fillable + casts (date, boolean, integer)
  + accessor priceRubles (kopecks → float rubles)
  + scopeActive (is_active=true AND effective_from <= today)
  + static current() — keyed by tier_no Collection<int, PricingTier>
- database/factories/PricingTierFactory.php — реалистичные ступени (300/700/1000/.../null)
- tests/Feature/Models/PricingTierTest.php — 4 теста: factory, accessor,
  scopeActive, current() snapshot всех 7 ступеней

ide-helper:models -W -M -N перегенерил docblocks (WebhookDedupKey synced
после schema v8.16).

Pest: 455 / 453 passed / 2 skipped (449 + 4 новых = 453).
Larastan: 0 errors. Pint auto-fix.

Spec: §7.2
Plan: Task 7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:48:00 +03:00
Дмитрий 62aa55f033 feat(models): add SupplierProject Eloquent model + factory + tests
- app/Models/SupplierProject.php — fillable + casts (jsonb arrays + datetime)
  + scopes: active() (inactive_since IS NULL), staleSince(N days),
  forSignal(signal_type, unique_key)
- database/factories/SupplierProjectFactory.php — корректно учитывает
  chk_supplier_projects_b1_not_for_sms (B1 не порождает SMS-проекты)
- tests/Feature/Models/SupplierProjectTest.php — 6 тестов: factory,
  array casts (workdays + regions), scopeActive, scopeStaleSince,
  scopeForSignal (3 платформы на один домен — UNIQUE (platform,unique_key))

ide-helper:models -W -M -N перегенерил docblocks для 4 существующих моделей
(SaasAdminAuditLog, SystemSetting, UserRecoveryCode, ImpersonationToken) —
синхронизировал @property после schema v8.16.

Pest: 451 / 449 passed / 2 skipped (было 443+6 новых от Task 6 = 449).
Larastan: 0 errors. Pint: passed.

Spec: §2.2
Plan: Task 6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:59:39 +03:00
Дмитрий 757f963929 fix(db): consolidate Plan 1 Tasks 1-5 into schema.sql (project convention)
Project convention использует schema.sql как single source of truth + один
load_initial_schema migration вместо incremental migrations. Мои 5 incremental
миграций конфликтовали с migrate:fresh: load_initial_schema применял
обновлённый schema.sql v8.16, а потом 000001-000005 пытались добавить уже
существующие колонки/таблицы (`signal_type already exists`).

Изменения:

- Удалены 5 incremental миграций 2026_05_10_00000{1..5}_*.
  Все DDL уже в schema.sql (v8.11 → v8.16, 4 commits 2ebe000/9b99d81/
  b08e1ed/7f694f7/9cf380f).
- В schema.sql lead_charges FK на partitioned deals(id, received_at)
  вынесен в самый конец файла (после section 5 с deals), DEFERRABLE
  INITIALLY DEFERRED. Иначе DB::unprepared() выдаёт "deals не существует"
  на load.
- Тесты в tests/Feature/Integration/ остаются — они проверяют
  структурные свойства (column existence, constraint name, RLS via
  pg_class) через information_schema, не зависят от того как именно
  schema создалась.

Verification:
- migrate:fresh OK на обеих БД (liderra + liderra_testing)
- Pest: 445 tests / 443 passed / 2 skipped / 0 failed
  (было 421 baseline + 24 новых для Tasks 1-5 = 443; +2 skipped browser tests)
- Larastan: 0 errors
- Pint: passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:50:39 +03:00
Дмитрий 9cf380f170 feat(db): create supplier_sync_log audit table (SaaS-level, append-only)
Append-only journal AJAX-синхронизаций с поставщиком crm.bp-gr.ru.
Используется для retry, отладки rt-project-* и алертов менеджеру.

- 9 columns: id, supplier_project_id (nullable FK SET NULL),
  action, request_payload (jsonb), response_body (jsonb),
  http_status, error_message, duration_ms, created_at
- 1 CHECK chk_supplier_sync_log_action
  (create/update/delete/disable/session_refresh)
- 3 индекса: supplier_project_id, action, created_at
- REVOKE ALL FROM crm_app_user (DO $$ conditional)
- No RLS (SaaS-level)

Spec: §4.3
Plan: Task 5
Test: 4/4 passed (table, action enum, FK, no RLS).

Schema v8.15 → v8.16. Метрики: 60 таблиц (+1) / 108 индексов (+3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:42:46 +03:00
Дмитрий 7f694f78e0 feat(db): create lead_charges ledger (tenant-scoped RLS, FK to partitioned deals)
Append-only ledger списаний за каждый доставленный лид. Tenant-scoped
с RLS tenant_isolation (ENABLE + FORCE + USING/WITH CHECK).

- 8 columns: id, tenant_id, deal_id, deal_received_at, tier_no,
  price_per_lead_kopecks, charged_at, created_at
- Composite FK lead_charges_deals_fk(deal_id, deal_received_at) →
  deals(id, received_at) DEFERRABLE INITIALLY DEFERRED
  (deals партиционирована — DEFERRABLE для атомарного deal+charge)
- 2 индекса: (tenant_id, charged_at), (deal_id, deal_received_at)
- RLS на (tenant_id = current_setting('app.current_tenant_id')::bigint)
- GRANT SELECT, INSERT для crm_app_user (без UPDATE/DELETE — append-only)

Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §7.4
Plan: docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md Task 4

Test: 3/3 passed (table exists, composite FK to deals, RLS enforces
tenant isolation via testing_rls_user role).

Schema v8.14 → v8.15. Метрики: 59 таблиц (+1) / 105 индексов (+2) /
39 RLS (+1) / функции/триггеры без изменений.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:38:17 +03:00
Дмитрий b08e1edb33 feat(db): create pricing_tiers table (7-step volume billing, kopecks integer) 2026-05-10 12:54:02 +03:00
Дмитрий 9b99d81deb feat(db): create supplier_projects table (SaaS-level aggregate, B1/B2/B3 platforms)
Plan 1/5 Task 2 — SaaS-level агрегатная сущность для проектов у поставщиков.
Несколько Лидерра-tenant'ов могут шарить один supplier_project (sharing-model
spec §2.3): для site/call — по domain/phone; для sms — по (sender, keyword)
на B2 или (sender) на B3. RLS НЕ применяется (таблица не tenant-scoped),
defense-in-depth через REVOKE ALL FROM crm_app_user.

Колонки: platform, signal_type, unique_key (TEXT), supplier_external_id,
current_limit, current_workdays/regions (jsonb), sync_status (pending/ok/failed),
last_synced_at, inactive_since (TTL 180 дней), timestamps.

CHECK constraints (chk_supplier_projects_*):
- platform IN (B1, B2, B3)
- signal_type IN (site, call, sms)
- sync_status IN (pending, ok, failed)
- NOT (platform=B1 AND signal_type=sms) — B1 не поддерживает СМС

Indexes: UNIQUE(platform, unique_key); btree на sync_status, inactive_since.

Тесты: 6/6 (table+columns / unique / platform CHECK / sync_status CHECK / no RLS / no privileges).
Schema: v8.12 → v8.13. Метрики: 56→57 таблиц / 98→101 индексов; RLS/функции/триггеры без изменений.
2026-05-10 12:47:25 +03:00
Дмитрий 2ebe000271 feat(db): extend projects for supplier integration (signal_type, identifier, sms_senders, sms_keyword, delivered_in_month, b1/b2/b3 FK placeholders)
Plan 1/5 Task 1. Adds 8 columns + 3 CHECK constraints + 1 composite index
to projects table for supplier integration foundation. Schema bumped
v8.11 to v8.12. Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.1.

7/7 Pest tests pass; rollback verified.
2026-05-10 12:40:02 +03:00
Дмитрий b912724cf7 chore(frontend): Sprint 4 Phase C — bundle analyzer + dead-code cleanup (audit O-refactor-06)
- rollup-plugin-visualizer + script `npm run build:analyze` (env BUILD_ANALYZE=1)
  Output: storage/bundle-analyze.html (gzip + brotli sizes), gitignored.
- cross-env установлен для Windows-совместимости env-переменной build:analyze.
- knip + knip.config.ts (entry app.ts + router/index.ts; ignore *.story.vue + tests/).
  ВАЖНО: knip падает с oxc-parser ArrayBuffer fail на этой машине
  (Windows quirk feedback memory) — конфиг сохранён для будущих запусков на
  Linux/macOS CI. Dead-code search выполнен вручную через grep по composables/.
- Удалены 4 unused exports + 4 private helpers, инициируемых только ими:
  * mockReports.ts: MOCK_JOBS, QuotaInfo (interface), MOCK_QUOTA
  * reportsMapper.ts: reportTypes()
  * mockTenantDetail.ts: expandTenantDetail() + 4 SAMPLE_* consts
    (SAMPLE_USERS/SAMPLE_PROJECTS/SAMPLE_BALANCE_HISTORY/SAMPLE_ACTIVITY)
  * useCsvDownload.ts: csvEscape() — снят `export` (используется внутри файла)

ESLint + vue-tsc + Vitest 416/416 + build — зелёные.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:59:58 +03:00
Дмитрий 79ff60ffd9 refactor(frontend): Sprint 4 Phase B/3 — split 2 utility views (audit O-refactor-04 хвост)
ErrorView 320→178 (+ ErrorBrand 54 + ErrorIllustration 31 + ErrorActions 55 + ErrorMeta 102).
DashboardView 302→84 (+ DashboardPageHead 65 + DashboardKpiRow 97 + DashboardBalance 124).

State (config, errorCode в ErrorView; range/kpis/balance в DashboardView) остаётся
в parent ради единого route.meta-driven flow + future API-fetch'а (Phase B/1 паттерн).
DashboardPageHead использует Vue 3.5 defineModel<T>() для двусторонней привязки range.
Sub-components читают только props — без Pinia stores (mock-data flow).

Все sub-components <250 строк (acceptance threshold). Shell line counts: 178/84.

ЗАМЕЧАНИЕ по acceptance «0 components >300»: НЕ закрыто полностью. 2 файла остались
выше порога — DealsView 560 + DealDetailDrawer 386. Зафиксировано в Sprint 3 Phase C
commit 6c2f0ce: bulk-action функции (applyBulkStatus/applyBulkDelete/applyBulkExport/
undoBulkDelete/applyBulkRestoreFromTrash) и comment/reminders fetch экспонируются
через defineExpose в Vitest-тестах напрямую — дальнейшая декомпозиция требует
изменения тест-контракта (отдельным flow, не вошло в Sprint 4 Phase B).

Phase B/3 закрывает 8/12 audit-кандидатов O-refactor-04: 3 в Sprint 3 Phase C
(Top-3) + 5 в Sprint 4 Phase B/1+B/2 (admin/layout/billing/security/reminders) +
2 в B/3 (errors/dashboard). Оставшиеся: 2 крупных deals-view (defineExpose-blocked)
+ ImpersonationDialog уже <300 органически.

Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 989 ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:53:09 +03:00
Дмитрий 849bc73290 refactor(frontend): Sprint 4 Phase B/2 — split 3 user views (audit O-refactor-04 хвост)
BillingView 416→114 (+ BalanceCard 155 + TransactionsTable 113 + InvoicesTable 90
+ billingFormatters 51 composable: formatPlain/formatCost/statusChipColor/
statusLabel/formatLabel/formatIcon/txAmountClass).
SecurityTab 354→39 (+ ChangePasswordCard 17 + TwoFactorCard 218 + RecoveryCodesCard 104
+ SessionsTable 66; auth-store читается напрямую в каждом sub-component).
RemindersView 345→183 (+ RemindersFilters 51 + RemindersList 173;
ReminderDialog уже отдельный с прошлой фазы — служит как ReminderForm).

State (`activeTab`, `editingReminder`, `deletingReminderId` в RemindersView)
остаётся в parent ради единого reload-flow + confirm-dialog'ов. Auth-store
читается напрямую в TwoFactorCard/RecoveryCodesCard через useAuthStore() —
без prop-drilling. Reminders-store читается напрямую в RemindersFilters/
RemindersList.

Все sub-components <250 строк (acceptance threshold). 3 view-shells: 114/39/183.

Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 968 ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:46:14 +03:00
Дмитрий 30ef61dff8 refactor(frontend): Sprint 4 Phase B/1 — split 3 admin/layout views (audit O-refactor-04 хвост)
3 view'а с >300 строк разделены на shell + sub-components:

AdminTenantsView 377→155 (+ TenantsStatsHeader 82 / TenantsFilters 93 / TenantsTable 116).
AdminTenantDetailView 436→109 (+ TenantDetailHeader 158 / TenantDetailTabs 176
+ adminTenantDetailFormatters 43 composable).
AppLayout 466→78 (+ AppSidebar 155 / AppTopbar 269; R0.6 hard-стоп снят
явным запросом заказчика 10.05.2026).

State (filterStatuses, tenantsState, activeTab, tenant, drawerOpen) остаётся
в parent view'ах ради `defineExpose`-контракта Vitest тестов. Sub-components
читают Pinia stores напрямую (auth + notifications + reminders) — без
prop-drilling.

AppTopbar 269 строк <300 — acceptance threshold выдержан (можно дальше split на
NotificationsDropdown + UserMenu в отдельном flow, не критично).

Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 1.17 сек.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:38:08 +03:00
Дмитрий f77c91d5fa feat(backend): Sprint 4 Phase A — keyset pagination в DealController::index (audit O-perf-04)
Опциональный query-параметр `cursor` (base64-encoded JSON {r:received_at, i:id}).

При cursor — keyset через PG row constructor `(received_at, id) < (?, ?)`
с использованием существующего индекса (received_at DESC, id DESC).
O(1) на любой глубине, без COUNT(*) (total не возвращается в keyset-режиме).

Без cursor — backward-compat OFFSET-путь: total + offset для существующего
frontend. Оба режима возвращают next_cursor (NULL = последняя страница).

Trick "+1 fetch" — узнаём про следующую страницу одним SELECT'ом без COUNT.

3 новых Pest-теста: keyset-навигация через cursor, 422 на невалидный cursor,
next_cursor flow. Pest 421/421 (419 + 2 skipped browser, +3 от 418 baseline).

phpstan-baseline.neon регенерирован: +2 occurrences pattern
`received_at?->toIso8601String()` (cursor build) + 7 occurrences тестовых
helper-properties Pest TestCall — все известные ignored patterns, не реальные ошибки.

Frontend integration в useDealsList/DealsView — отдельным шагом
(не блокирует backend deploy, OFFSET путь жив).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:29:54 +03:00
Дмитрий 6c2f0ce682 refactor(frontend): Sprint 3 Phase C — Top-3 Vue components split (audit O-refactor-04)
Sprint 3 Phase C. Закрытие audit O-refactor-04 (частичное — Top-3 из 12):

- DealsView.vue: 852 → 560 строк. Выделены: DealsFilters (123), DealsBulkBar (150),
  DealsTable (165). CSV-utilities (csvEscape/triggerCsvDownload/triggerBlobDownload/
  buildCsvString) вынесены в composables/useCsvDownload.ts.
- ReportsView.vue: 592 → 261 строк. Выделены: ReportRequestForm (тип отчёта,
  даты, фильтры, формат, submit/reset), ReportJobsList (список заданий со
  статусами и actions retry/cancel/delete).
- DealDetailDrawer.vue: 580 → 386 строк. Выделены: DealDetailHero (header +
  phone-link + status-chip), DealDetailTimeline (activity log с MOCK_EVENTS).
  Comment- и Reminders-секции оставлены inline — связаны с API и defineExpose.

DealsView и DealDetailDrawer остались выше 350-целевого уровня: bulk-action
функции (applyBulkStatus/applyBulkDelete/applyBulkExport/undoBulkDelete/
applyBulkRestoreFromTrash) и comment/reminders fetch — экспонируются через
defineExpose в Vitest-тестах напрямую, дальнейшая декомпозиция требует
изменения тест-контракта (отдельным flow).

Layout-структуры (AppLayout 466, AuthLayout, AppShell) НЕ ТРОНУТЫ — R0.6 hard-стоп.
Остальные 9 components >300 строк (AdminTenantDetailView, BillingView,
AdminTenantsView, SecurityTab, RemindersView, ErrorView, DashboardView,
ImpersonationDialog, далее) — вне scope Sprint 3, отдельным flow по запросу.

vue-tsc: 0 errors. ESLint: 0. Vitest: 416/416 PASS. Build: success (1.15s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:28:25 +03:00
Дмитрий cd13e6c8bb refactor(backend): Sprint 3 Phase B — AuthController split (3 classes by analogy)
Sprint 3 Phase B. Закрытие audit O-refactor-02:

- O-refactor-02: AuthController (595 строк) → split на 3 класса (по аналогии с
  Sprint 3 Phase A DealController split):
  * AuthController (343) — core auth: login/register/logout/me +
    updateNotificationPreferences + helpers (loginThrottleKey, isIpLockedOut,
    maybeNotifySuspiciousLogin, logAuthEvent, lockoutResponse, userResource)
  * TwoFactorController (217, новый) — 2FA verify + recovery codes use
    (login-flow продолжение). Setup/enable/disable остались в
    TwoFactorSetupController (с Sprint 1).
  * PasswordResetController (146, новый) — forgot/reset password

API endpoints без изменений (только routing — controller@method обновлён в web.php):
- POST /api/auth/2fa/verify         → TwoFactorController@verifyTwoFactor
- POST /api/auth/2fa/recovery-use   → TwoFactorController@useRecoveryCode
- POST /api/auth/forgot             → PasswordResetController@forgotPassword
- POST /api/auth/reset-password     → PasswordResetController@resetPassword

Helpers lockoutResponse и userResource дублируются между классами (1:1) — по
принципу Phase A: «копируй методы 1:1, не оптимизировать на ходу». Будущая
итерация может вынести в trait/base controller, если появится 4-й класс.

Pest: 416/416 PASS + 2 skipped.
Larastan: 0 errors.
Pint: passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:14:33 +03:00
Дмитрий 86dc930915 refactor(backend): Sprint 3 Phase A — DealController split + N+1 fix + export streaming
Sprint 3 Phase A. Закрытие audit O-refactor-01 + O-perf-01 + O-perf-05.

O-refactor-01: DealController (802 строки) → split на 3 контроллера
по ответственности:
  * DealController (466 строк) — single-resource CRUD (index/show/store/update).
  * DealBulkActionController (264 строки, новый) — bulk transition/destroy/restore.
  * DealExportController (120 строк, новый) — export() с streaming.
API endpoints без изменений; в routes/web.php обновлены только controller@method.

O-perf-01: N+1 в bulk-actions устранён в новом DealBulkActionController.
  * transition: SELECT (id, status) → filter NO-OP → bulk-UPDATE
    whereIn(changed)->update(status) + ActivityLog::insert(массив).
    100 сделок: ~200 SQL → 2 SQL (после SELECT 1 UPDATE + 1 INSERT).
  * destroy/restore: SELECT id'шников живых/удалённых → bulk-UPDATE
    deleted_at + ActivityLog::insert. Аналогично 2 SQL вместо N.
  Defense-in-depth where(tenant_id) сохранён — DealTransitionTest
  «не апдейтит чужие сделки» проходит.

O-perf-05: export() переписан на OpenSpout streaming (composer require
openspout/openspout ^5.3). PhpSpreadsheet строил весь .xlsx в памяти
(10K сделок ≈ 100+ MB RAM). Теперь:
  * Writer::openToFile('php://output') + Row::fromValues + chunkById(500).
  * StreamedResponse → пик памяти O(1) от размера экспорта.
  * CSV: OpenSpout Options(FIELD_DELIMITER=';', SHOULD_ADD_BOM=true) —
    Excel-friendly RU-локаль сохранена.
  * XLSX: getCurrentSheet()->setName('Сделки'), header через
    Row::fromValuesWithStyle с (new Style)->withFontBold(true).

DealCreateTest.php (4 теста про export): getContent() → streamedContent()
для StreamedResponse + getCell()->getFormattedValue() для inline-string
ячеек, которые OpenSpout пишет как RichText (PhpSpreadsheet writer писал
как plain shared-string). Логика тестов и assertion'ы не меняются.

Verification:
  Pest: 416/416 passed (+ 2 skipped), 1388 assertions, 47.5s.
  Larastan: 0 errors above baseline.
  Pint: passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:08:20 +03:00
Дмитрий 2d7d7d1188 feat(frontend): Sprint 2 Phase B — Vue 3.5 defineModel + Vuetify 3.12 typed slots + lazy-imports + ESLint check
Sprint 2 Phase B (modernization). Закрытие audit O-stack-04/05/07 + O-perf-06:
- O-stack-04: Vue 3.5 defineModel() в 3 диалогах (NewDealDialog,
  ImpersonationDialog, ReminderDialog) — boilerplate −5 строк/файл.
  + useTemplateRef() в TwoFactorView (input v-for refs).
- O-stack-05: Vuetify 3.12 типизированные слоты VDataTable
  (DealsView + AdminTenantsView) — inline-аннотации `{ item }: { item: T }`
  на 6+7 scoped-slot bindings; vue-tsc проверяет доступ к полям статически.
- O-stack-07: ESLint flat-config verified — header-comment добавлен
  в eslint.config.js. Legacy .eslintrc.json не используется.
- O-perf-06: defineAsyncComponent() для тяжёлых диалогов в 3 местах:
  DealsView (DealDetailDrawer + NewDealDialog), DealDetailDrawer
  (ReminderDialog), RemindersView (ReminderDialog). KanbanView оставлен
  sync — async-загрузка приводила к EnvironmentTeardownError в jsdom
  (KanbanView.spec.ts), see in-file comment. Сборка показывает chunk'и
  ImpersonationDialog (7.61 kB), DealDetailDrawer (11.12 kB), NewDealDialog
  и ReminderDialog как отдельные lazy-bundles.

vue-tsc: 0 errors. ESLint: 0. Vitest: 416/416 PASS. Build: success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:36:02 +03:00
Дмитрий 20d4682e21 feat(backend): Sprint 2 Phase A — Pest 4 browser scaffold + mutation + lazy-loading + Larastan cache
Sprint 2 Phase A (modernization). Закрытие audit O-stack-01/02/03 + O-perf-07:

- O-stack-01: Pest 4 browser-tests scaffold. testsuite Browser в phpunit.xml,
  Pest.php extend(...)->in('Browser'), tests/Browser/SmokeTest.php
  (login-flow + deal-create-flow). На Windows native PHP плагин
  pest-plugin-browser требует ext-sockets (отсутствует в стандартной
  сборке) и при автозагрузке вызывает socket_create_listen() ДО старта
  тестов, что ломает весь Pest. Поэтому плагин НЕ в require-dev,
  тесты помечены ->skip(...) с инструкцией активации на Linux CI.
  Скелет тестов в комментариях — на CI достаточно
  composer require pestphp/pest-plugin-browser --dev + npx playwright
  install + раскомментировать тела.
- O-stack-02: infection/infection ^0.32.7 в require-dev + app/infection.json
  (minMsi=50, source: Http/Models/Services). composer mutation script.
  Запуск отдельной задачей в CI (медленный). allow-plugins для
  infection/extension-installer.
- O-stack-03: Laravel 13 string-based lazy-loading в routes/web.php —
  убран блок use App\Http\Controllers\Api\* (16 импортов), все
  ссылки заменены на строки 'App\Http\...\X@method'. Контроллеры
  не загружаются автозагрузчиком при boot route'ов; класс резолвится
  только при матче маршрута. Использован строковый синтаксис, а не
  FQN-class — Pint default preset (fully_qualified_strict_types fixer)
  сворачивает FQN-class обратно в use, на строки не действует.
- O-perf-07: Larastan result cache через tmpDir: .phpstan-cache в
  phpstan.neon (cache-dir не дублируется флагом — phpstan не принимает
  оба источника). lefthook job 6 (larastan) использует этот же
  tmpDir автоматически. Ускорение инкрементальных pre-commit прогонов:
  на правке одного файла — переанализ только зависящих, не всего
  графа классов. .phpstan-cache в .gitignore.

Pest: 416/416 PASS + 2 skipped (browser smoke pending Linux CI).
Larastan: 0 errors above baseline.
Infection: vendor/bin/infection --version → 0.32.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:18:38 +03:00
Дмитрий cc6e1cba72 fix(configs): Windows-fix format:sql:check + lychee/pa11y/composer/ESLint hygiene + npm-outdated CI (audit P0-03 + 5 P1/O)
Закрытие аудита 2026-05-09 (b6ae8dd):
- P0-03: format:sql:check заменён /tmp/ на db/.schema-formatted.tmp.sql (Windows-совместимо).
  + .gitignore: добавлен db/.schema-formatted.tmp.sql.
  + дополнительно: web/**/*.html убран из npm run links — статические концепты
    web/v8/*.html используют root-relative ссылки на будущие маршруты Vue, lychee
    не резолвит их без --root-dir; они уже в exclude_path. Lychee с 20 → 1 error
    (оставшийся 1 — pre-existing битая ссылка в docs/superpowers/specs/, вне scope).
- P1-02: .lychee.toml exclude root-relative для web/v8/*.html — добавлен regex
  паттерн для будущих маршрутов (login/register/legal/dashboard/deals/admin/...).
- P1-12: pa11y.config.json пути обновлены на liderra_v8_handoff/concepts/v8_*.html
  (login/dashboard/deals).
- P1-07: composer audit-offline скрипт (composer audit --locked) для офлайн-режима.
- O-refactor-05: ESLint no-restricted-imports запрещает явный import из 'vuetify/components'.
- O-stack-08: .github/workflows/dependency-check.yml — еженедельный (Mon 09:00 UTC) npm outdated с авто-issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:31:17 +03:00
Дмитрий 56195c8a59 fix(backend): tenant middleware на auth-routes + HasPasswordRules trait + test password (audit P0-01 + O-refactor-03 + P2-01)
Закрытие аудита 2026-05-09 (b6ae8dd):
- P0-01: применён 'tenant' middleware (alias уже в bootstrap/app.php:17) к 3 auth:sanctum-группам:
  /api/notifications, /api/reminders, /api/reports/jobs (web.php:44/52/63).
  /api/deals и /api/admin/* остаются без auth (P1-10/Б-1) — в реестр Спринта 1 Phase F.
- O-refactor-03: HasPasswordRules trait извлекает rules + messages, подключён в Login/Register.
- P2-01: bcrypt('test') → bcrypt('test1234') в AdminIncidentsIndexTest (≥8 chars).
- bonus-fix: SetTenantContext::resolveTenantId — property_exists() заменён на isset() для
  Eloquent magic-attributes (auth-путь резолюции tenant_id никогда не работал из-за этого
  бага; тесты-смоки middleware покрывали только X-Tenant-Id header / subdomain). Без фикса
  P0-01 ломает 58 тестов в /api/notifications + /api/reminders + /api/reports/jobs.

Pest: 416/416 PASS.
Larastan: 0 errors.
Pint: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:22:30 +03:00
Дмитрий cab1f87efd phase2(admin-tenant-detail-frontend): replace mock на real API в AdminTenantDetailView
- api/admin.ts +getAdminTenantDetail(subdomain) + 5 типов (ApiTenantUser/Project/
  BalanceTx/ActivityEvent/Metrics + AdminTenantDetailResponse).
- composables/adminTenantDetailMapper.ts: mapAdminTenantDetail (API → mockTenantDetail
  format). code=subdomain, deriveStatus (trial/overdue/suspended), deriveTariff
  (Trial fallback), users (fullName из first+last||email, role='manager' хардкод —
  schema users role нет, расширим в Post-MVP), projects (slug=tag), balanceHistory
  (id префикс TX-, type-mapping для chargeback_*/trial_bonus/historical_import →
  ближайший UI-эквивалент), activity (actor=actor_email||system, summary из
  context.from→to), activitySinceText (relative time из last_activity_at).
- AdminTenantDetailView.vue: replace mock-lookup на async loadTenant + 3 ветки
  template (loading / notFound / fetchError) + watch(code) для реактивной
  навигации. inn/contact_phone/legal_address скрываются через v-if (нет в schema).
- AdminTenantDetailView.spec.ts переписан с MOCK на vi.mock('api/admin'):
  13 тестов (вызов API с subdomain / organization_name+tariff / 4 KPI / KPI Лиды
  todayActual/desired / Финансы tab / Пользователи tab / Проекты tab / Активность
  tab с actor+summary / Войти как клиент / suspended disabled / 404 fallback /
  500 fetch-error / overdue Просрочка / trial без оплаты).
- adminTenantDetailMapper.spec.ts +20 тестов: code/name/inn-empty/balanceRub
  parse/mrrRub trial-null/status (4 ветки)/tariff (deriveTariff+fallback)/today
  Actual+Desired/users (fullName / fallback)/projects/balanceHistory (TX- prefix +
  chargeback type mapping)/activity (actor+summary)/metrics (4 поля)/activitySince.
- Vitest +23 (всего 416/416, +23 от 393).

Этап B эпика AdminTenantDetailView (frontend) ЗАКРЫТ. Эпик закрыт целиком (2 этапа).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:37:45 +03:00
Дмитрий f9d8926945 phase2(admin-tenant-detail-backend): GET /api/admin/tenants/{subdomain} с 4 секциями
- AdminTenantsController +show($subdomain): возвращает tenant base + users +
  projects + balance_history + activity + computed metrics (leads_today/week/
  month, avg_lead_cost_rub, runway_days). Lookup по subdomain (естественный
  URL slug) + whereNull deleted_at. Без auth-middleware (saas-admin SSO ⏸ Б-1).
- 4 private fetch'ера + computeMetrics:
  - fetchUsers: ORDER last_active_at DESC, LIMIT 50, поля email/first/last/
    is_active/totp_enabled/last_active_at/last_login_at.
  - fetchProjects: LEFT JOIN sub-queries для suppliers_count + leads_today
    (deals в текущем дне). Поля name/tag/is_active/daily_limit_target.
  - fetchBalanceHistory: ORDER created_at DESC, LIMIT 30. Поля type/amount_rub/
    amount_leads/balance_rub_after/description/created_at.
  - fetchActivity: LEFT JOIN users (actor_email), LIMIT 20. context json_decode.
  - computeMetrics: один SELECT с FILTER для leads counts; AVG cost_rub за
    30 дней; runway_days = balance / (month_spend / 30).
- routes/web.php: GET /api/admin/tenants/{subdomain} where [a-z0-9_-]+.
- Pest +13 в AdminTenantShowTest.php (всего 416/416, +13 от 403, 1388 assertions):
  404 unknown / 404 soft-deleted / базовые поля / 4 секции + metrics keys в response /
  users изоляция / projects suppliers_count + leads_today / balance_history ORDER+LIMIT 30 /
  balance_history изоляция / activity actor_email LEFT JOIN (user + system events) /
  metrics leads_today/week/month / metrics runway_days computed / tariff_name+mrr_rub /
  mrr_rub null для trial.
- phpstan-baseline регенерирован.

Этап A эпика AdminTenantDetailView (backend) закрыт. Этап B: frontend
integration + Vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:32:24 +03:00
Дмитрий e0ffe7e686 phase2(reports-stage4): frontend integration ReportsView (replace mock)
- api/reports.ts: типизированные axios-helpers (listReportJobs/createReportJob/
  retryReportJob/cancelReportJob/deleteReportJob) + ApiReportJob/Status/Format/
  Counts/Quota interfaces. ensureCsrfCookie на mutating-вызовах.
- composables/reportsMapper.ts: mapApiReportJob (API → UI mock format с конверсией
  pending→queued / processing→running). title строится на frontend'е (тип + period
  с RU-месяцами «апр 2026» или диапазон «мар 2026 — апр 2026»). sizeText форматирует
  bytes (B/KB/MB). timeText зависит от status (в очереди / в работе · Nс / N мин назад).
  uiTypeToApi (deals → deals_export и т.д.).
- ReportsView.vue полностью переписан под API:
  - onMounted → loadJobs (replace MOCK_JOBS на data из listReportJobs).
  - usePolling 30 сек (фоновый авто-refresh).
  - Submit → createReportJob → reload + success-alert + error-alert (validation+
    общие ошибки извлекаются через extractValidationErrors/extractErrorMessage).
  - canSubmit computed: disable если квота заполнена (active >= max).
  - Reset-btn возвращает форму к defaults.
  - Reload-btn (manual fast-path).
  - Retry/Cancel/Download/Delete-кнопки → соответствующие API-вызовы;
    Delete через v-dialog persistent confirm.
  - fetch-error-alert на listReportJobs reject.
  - Empty-state «Нет отчётов» когда jobs.length=0.
  - canRetry проверяет retry_count<3 (max attempts CTO-6).
- Vitest +24 (всего 393/393, +24 от 369):
  - reportsMapper.spec.ts +14: status mapping (pending/processing/done/failed) /
    title (один месяц / диапазон) / format / sizeText (B/KB/MB/null) / attempt /
    error / timeText (pending / processing / done «10 мин назад» / «только что») /
    uiTypeToApi 4 slug'а / progress=50 для running.
  - ReportsView.spec.ts переписан с MOCK_JOBS на vi.mock('api/reports') +12:
    mount + listReportJobs called on mount / 4 type cards / default Сделки active /
    4 формата / quota-banner из API / empty-state / done с Готов+Скачать /
    failed с Ошибка+Повторить / failed retry_count=3 НЕ показывает Повторить /
    pending с Отменить / Submit вызывает createReportJob+reload / Submit error →
    submit-error-alert / Submit-btn disabled при квоте 3/3 / Reset / Reload-btn /
    fetch-error-alert / Retry-btn / Cancel-btn / Delete confirm-dialog +
    deleteReportJob.

Этап 4/4 эпика Reports backend ЗАКРЫТ. Эпик закрыт целиком.

Backend: 1 type (deals_export) × 4 формата (CSV/XLSX/JSON/PDF-stub).
Этап 2b (3 оставшихся типа: managers_summary/sources_summary/billing_summary)
— расширение через добавление 3 новых Provider-классов без изменений в архитектуре,
вынесено в Post-MVP backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:49:55 +03:00
Дмитрий 9765ed760d phase2(reports-stage3): retry/cancel/destroy + reports:cleanup-expired cron
- ReportJobController +3 endpoints под auth:sanctum:
  - POST /api/reports/jobs/{id}/retry — CTO-6: только owner+failed, max 3 попытки
    (parameters.retry_count), окно 7 дней с created_at, квота CTO-7 учитывается;
    создаёт НОВЫЙ ReportJob (parameters.retry_of=original.id) + dispatch.
  - POST /api/reports/jobs/{id}/cancel — только owner+pending; status=failed +
    error_message=«Отменено пользователем.» + finished_at=NOW.
  - DELETE /api/reports/jobs/{id} — только owner+terminal (done|failed); удаляет
    файл из disk('local') + row.
- toResource +3 поля: is_expired (expires_at < NOW), retry_count, retry_max=3.
- App\Console\Commands\ReportsCleanupExpired (cron `reports:cleanup-expired`):
  где status='done' AND expires_at < NOW AND file_path IS NOT NULL → delete file
  + UPDATE file_path=NULL. CTO-10: status='done' СОХРАНЯЕТСЯ. failed-jobs
  игнорируются. --dry-run + --limit=1000. Запуск ежесуточно через Task Scheduler.
- routes/web.php: новые 3 routes под существующим prefix /api/reports/jobs.
- Pest +21 в ReportLifecycleTest.php (всего 403/403, +21 от 382, 1343 assertions):
  retry 8 (404 unknown/foreign / 403 не владелец / 422 не failed / success+new+
  retry_count=1+retry_of / 422 max retries / 422 окно 7 дней / 422 квота 3) +
  cancel 4 (404 / 422 не pending / success / 403 не владелец) + destroy 5
  (404 / 422 pending / 403 не владелец / success+file / success+file_path=NULL)
  + index +1 (is_expired/retry_count/retry_max в response) + cron 3 (удаление
  expired+CTO-10 status сохраняется / --dry-run / failed игнорируются).
- phpstan-baseline регенерирован.

Этап 3/4 эпика Reports backend (закрыт). Этап 4: frontend integration —
заменить mock в ReportsView на реальный API + UI кнопки retry/cancel/delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:44:09 +03:00
Дмитрий 1a6a74c1a0 phase2(reports-stage2): provider+formatter архитектура + XLSX/JSON/PDF-stub
- Реструктура Services/Reports: вместо `Generator` per (type×format) комбинации
  (16 классов) разделено на 4 Providers + 4 Formatters (8 классов).
- App\Services\Reports\Providers\ReportDataProvider interface + DealsExportProvider
  (вынесен из старого DealsExportCsvGenerator; возвращает headers + rows).
- App\Services\Reports\Formatters\ReportFormatter interface + 4 реализации:
  - CsvFormatter — Excel-friendly (BOM + ; + \r\n + escape).
  - XlsxFormatter — PhpSpreadsheet 5.x (A1-нотация + bold headers + auto-size cols).
  - JsonFormatter — pretty + UNESCAPED_UNICODE (кириллица в исходном виде).
  - PdfStubFormatter — Post-MVP, throw RuntimeException.
- ReportGeneratorRegistry перепаспортирован: provider(type) + formatter(format).
- GenerateReportJob: вызывает provider->headers/rows + formatter->format вместо
  старого generator->generate.
- Удалено: DealsExportCsvGenerator, ReportGenerator interface, GenerationResult DTO.
- Pest +3 (всего 382/382, +3 от 379, 1297 assertions): xlsx → done с XLSX-magic-bytes
  PK\x03\x04; json → done + decoded ['rows', 'headers']; pdf → failed «Post-MVP»;
  managers_summary (не реализован) → failed.
- phpstan-baseline регенерирован.

Этап 2/4 эпика Reports backend (закрыт). Этап 2b: 3 оставшихся типа провайдеров
(managers_summary / sources_summary / billing_summary) — каждый × 4 формата без
изменений в архитектуре. Этап 3: retry/cancel/delete + retention cron.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:39:32 +03:00
Дмитрий 19f319cd5d phase2(reports-stage1): ReportJob model + GenerateReportJob + API + deals_export csv
- ReportJob Eloquent (schema §13.5 report_jobs): status pending/processing/done/failed,
  parameters JSONB (format/date_from/date_to/project_id?/manager_id?), constants
  TYPES + FORMATS, helpers isActive/isDone/isFailed.
- ReportJobFactory + states processing/done/failed.
- App\Services\Reports\* пакет: ReportGenerator interface, GenerationResult DTO,
  ReportGeneratorRegistry с резолвом по (type,format), DealsExportCsvGenerator
  (Excel-friendly CSV: BOM, ; separator, \r\n, escape; deals JOIN projects/users/
  supplier_lead_costs за date_from..date_to, soft-deleted скрыты).
- App\Jobs\GenerateReportJob: tries=1 (auto-retry отключён, retry через UI кнопку
  CTO-6); меняет status pending → processing → done|failed, заполняет file_path/
  file_size/generation_seconds/finished_at/expires_at (=NOW+30д).
- App\Http\Controllers\Api\ReportJobController под auth:sanctum:
  - GET /api/reports/jobs?status=&limit=&offset= → jobs+total+counts+quota
  - GET /api/reports/jobs/{id}
  - POST /api/reports/jobs (квота CTO-7: max 3 active per tenant → 422)
  - dispatch GenerateReportJob (sync на dev → файл готов сразу).
- Storage local-disk на dev (storage/app/reports/{tenant_id}/{job_id}.csv);
  на prod заменим на s3 (Yandex Object Storage) отдельным коммитом.
- Pest +20 в tests/Feature/Reports/ReportJobControllerTest.php (всего 379/379,
  1280 assertions): 401 без auth / GET пустой+only-own+ORDER+filter+counts+limit/
  show success+404 own/foreign / store 422 (без полей/неизвестный type/date_to<from)/
  dispatch / sync queue → done с file (BOM проверен) / unsupported format → failed/
  квота 3 → 422 на 4-м / квота не считает done+failed / квота per-tenant.
- phpstan-baseline регенерирован (+1 ignored для Factory typing).

Этап 1/4 эпика Reports backend (закрыт). Этап 2: 4 типа × 4 формата.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:34:03 +03:00
Дмитрий 4c33323f0e phase2(notifications-stage6): low_balance + zero_balance + topup + invoice email/inapp
P0 этап 6 — 4 оставшихся email-события. Авто-план P0 (6 этапов) закрыт
полностью: все 8 schema-default событий имеют рабочую интеграцию
(new_lead/reminder/low_balance/zero_balance/topup_success/invoice_paid +
заглушки для new_device_login/marketing).

Backend:
- 4 новых Mailable: LowBalanceNotification (threshold), ZeroBalanceNotification,
  TopupSuccessNotification (amountRub, amountLeads?), InvoicePaidNotification
  (amountRub, invoiceNumber?, tariffName?).
- 4 blade-шаблона в emails/ (Forest-палитра, таблицы balance/amount/invoice).
- NotificationService +4 методов: notifyLowBalance / notifyZeroBalance /
  notifyTopupSuccess / notifyInvoicePaid. Все шлют email + inapp по prefs.

Интеграция в ProcessWebhookJob:
- chargeNewLead после lead_charge: notifyLowBalance при пересечении порога
  сверху-вниз (balance_after <= threshold AND (balance_after+1) > threshold).
  Иначе спам при каждом lead_charge при balance < threshold.
- logRejection(zero_balance): notifyZeroBalance ТОЛЬКО если в последний час
  не было другого RejectedDealsLog с тем же reason (anti-spam 1 email/час).
  Защита от self-just-inserted через id!= (timestamp-сравнение ненадёжно
  из-за PG microsecond precision).
- topup_success / invoice_paid — service-методы готовы, integration после
  появления endpoints для пополнения (ЮKassa-webhook) и оплаты тарифа.
- lowBalanceThreshold() читает system_settings.low_balance_threshold_leads
  (default 10, schema seed).

Pest +12 в BalanceNotificationsTest (359/359 за 41.37 сек, 1233 assertions):
- low_balance: пересечение порога / уже < threshold / > threshold /
  prefs.email=false (только inapp).
- zero_balance: первое отклонение / 2-е в час не дублирует / >1ч снова шлёт.
- topup_success / invoice_paid: notify создаёт email+inapp / prefs=email:false.
- balance events изолированы между tenants.

NewLeadNotificationTest: «balance=0 не шлёт» обновлён —
Mail::assertNotSent(NewLeadNotification) вместо Mail::assertNothingSent
(ZeroBalanceNotification теперь шлётся при balance=0 — новое поведение).

PHPStan baseline регенерирован.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:51:32 +03:00
Дмитрий dc1457a008 phase2(reminders-frontend): RemindersView + DealDetailDrawer + nav-badge
P0 этап 5 — frontend для reminders (после backend-этапа 4).
Пользователь может создавать/просматривать/завершать/удалять напоминания
из UI с inline-create в DealDetailDrawer.

Frontend:
- api/reminders.ts: типизированные helpers для 5 endpoints + ensureCsrfCookie
  для mutating. Types ReminderFilter/ApiReminder/ReminderCounts.
- stores/reminders.ts: Pinia с items/counts/currentFilter +
  load/refreshCounts/create/update/complete/remove. Optimistic для
  complete/remove с revert на reject.
- components/reminders/ReminderDialog.vue: dual-mode (create/edit) modal
  с native datetime-local input. Props dealId?/reminder? (edit),
  ISO-конверсия при submit.
- views/RemindersView.vue: page-head с stats (active/overdue) + reload-btn;
  4 tabs (today/upcoming/overdue/completed) с counts на бейджах
  (overdue=error color); v-list с complete-btn / dropdown
  Изменить/Удалить с confirm-dialog; empty-state.
- router: /reminders маршрут (lazy).
- AppLayout: nav-badge «Напоминания» биндит count из store
  (replace static «12»); скрыт при count=0; polling 60 сек для
  refreshCounts.
- DealDetailDrawer: секция «Напоминания» (только при tenantId+deal):
  inline + create-btn / список / complete / встроенный ReminderDialog.

Vitest +20 (369/369 за 21.20 сек):
- reminders-store 11: initial / load+reject / refreshCounts /
  create+reject / complete optimistic+revert / remove+reject / reset.
- RemindersView 7: mount / 4 tabs / counts / empty-state /
  список / reload-btn / filter=today default.
- AppLayout +2: бейдж скрыт при count=0 / показывает count при >0.

Pest 347/347 (без изменений — backend нетронут).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:41:41 +03:00
Дмитрий 39b6127bce phase2(reminders-backend): CRUD + cron-диспетчер + email/inapp (P0 этап 4)
Закрыт пункт «Reminders ⏸ no-view» из AppLayout nav-tree. Schema-таблица
reminders уже была в v8.10 §17.5 — теперь работает целиком backend-side.

Backend:
- App\Models\Reminder — Eloquent с casts/relations + isCompleted/isOverdue.
- ReminderFactory с states overdue/completed/sent.
- App\Http\Controllers\Api\ReminderController под auth:sanctum:
  GET ?filter=&deal_id=&limit= (active/today/upcoming/overdue/completed,
    окно ±1 день, counts для UI badges);
  POST {deal_id, text?, remind_at, assignee_id?} (FK guard на assignee);
  PATCH {id} (при смене remind_at сбрасывает is_sent+sent_at для retrigger);
  POST {id}/complete (idempotent);
  DELETE {id}.
  RLS-обёртка + defense-in-depth where('tenant_id').
- App\Mail\ReminderDueNotification + emails/reminder.blade.php (Forest,
  TZ из recipient.timezone).
- NotificationService::notifyReminder(Reminder) — recipient = assignee_id
  ?? created_by (если active+!deleted). Каналы email+inapp по prefs.
  payload {reminder_id, deal_id} для UI deep-link.
- App\Console\Commands\RemindersDispatchDue — cron reminders:dispatch-due
  {--dry-run} {--limit=500}. По одному reminder в DB::transaction (SET
  LOCAL app.current_tenant_id нельзя переключать). После notifyReminder
  ставит is_sent=true даже если recipient deactivated (защита от retry-spam).

Pest +32 (347/347 за 41.21 сек, 1203 assertions):
- ReminderControllerTest 21: 401 / RLS / 5 filter'ов / counts / deal_id /
  store + FK guard / update text+remind_at сбрасывает is_sent / complete
  idempotent / delete + 404 чужой.
- RemindersDispatchDueTest 11: due → email+inapp / future skip / completed
  skip / уже sent / assignee вместо created_by / deactivated user (is_sent
  всё равно) / только inapp при email=false / --dry-run / --limit / RLS.

PHPStan baseline регенерирован. IDE-helper для всех моделей.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:30:38 +03:00
Дмитрий f55b91cfa4 phase2(notifications-stage3): NotificationsTab schema-aligned + prefs API
Закрывает архитектурное расхождение v1.28 — Tab сохранял prefs только
локально без API. Backend events не совпадали с handoff'ом.

Backend:
- PATCH /api/auth/me/notification-preferences под auth:sanctum.
- Replace-семантика: незадекларированные events/channels отбрасываются.
- userResource расширен: notification_preferences + sound_enabled.
- UserFactory с schema-default JSON (Eloquent не перечитывает после INSERT,
  DB-DEFAULT JSONB виден как null без явного override).
- Pest +10: 401 / replace / неизвестные events/channels отбрасываются /
  422 без prefs / sound_enabled опционален / bool-cast 1/'1' / replace-
  семантика (отсутствующие events исчезают).

Frontend:
- api/auth.ts: типы NotificationChannel/EventKey/Preferences +
  updateNotificationPreferences helper. AuthUser получил optional поля.
- NotificationsTab.vue переписан под schema:
  8 событий (new_lead/reminder/low_balance/zero_balance/topup_success/
  invoice_paid/new_device_login/marketing) × 3 канала (inapp/push/email,
  НЕ sms). Sync-init prefs (без onMounted — иначе v-if блокирует рендер
  и тесты mount-then-find падают). dirty через computed-сравнение с
  originalPrefs snapshot. save async + success/error alerts.
- SettingsView.spec.ts: legacy event-имена → schema-aligned.
- Vitest +10: 8 schema events / 3 channels (НЕ sms) / legacy отсутствуют /
  читает prefs из user / save calls API + alerts / Отменить возвращает.

cspell-words: +prefs.
PHPStan baseline регенерирован.

Pest 315/315 (+10) за 36.73 сек, 1130 assertions.
Vitest 349/349 (+10) за 20.42 сек.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:41:35 +03:00
Дмитрий 508de4eaf3 phase2(notifications-stage2b): API + Pinia + bell в AppLayout (P0 этап 2b)
Закрывает этап 2 P0 целиком (UI bell с unread badge + polling).

Backend:
- App\Http\Controllers\Api\InAppNotificationController под auth:sanctum:
  GET /api/notifications?unread_only=&limit= (1..100 default 50);
  PATCH /api/notifications/{id}/read (idempotent);
  POST /api/notifications/mark-all-read (bulk + count);
  DELETE /api/notifications/{id}.
- Route::middleware('auth:sanctum')->prefix('/api/notifications') в web.php.
- DB::transaction + SET LOCAL app.current_tenant_id для RLS.
- Защита от кражи чужого id через where('user_id', $auth->id).
- Pest +14 (305/305 за 34.71 сек, 1099 assertions).

Frontend:
- api/notifications.ts — типизированные axios-helpers + ensureCsrfCookie.
- stores/notifications.ts — Pinia: items/unreadCount/total/loading +
  optimistic markRead/markAllRead/remove с revert на reject.
- AppLayout: bell-icon → v-menu offset=8 location=bottom-end:
  pip badge показывает unreadDisplay (1..99 / 99+ / hidden);
  v-list последних 10 из sortedItems с event-icon + formatRelative;
  Mark-all-read btn только при unreadCount > 0;
  click на item → markRead + router.push('/deals') если deal_id.
- usePolling(loadNotifications, {intervalMs: 30_000}) с Page Visibility.
- loadNotifications no-op без auth.user.
- Vitest +18 (339/339 за 20.03 сек): store 12 + AppLayout +6
  (bell-btn / pip скрыт при 0 / pip count / 99+ / listNotifications
  на mount с user / no-op без user).

PHPStan baseline регенерирован (50 Pest false-positives подавлены).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:27:57 +03:00
Дмитрий 7f5ff874a8 phase2(notifications-stage2a): in_app_notifications + notifyInApp (schema v8.10)
P0 этап 2a — backend-фундамент bell-icon канала. UI bell + API endpoints
— этап 2b отдельным коммитом.

Schema v8.9 → v8.10:
- Новая таблица in_app_notifications (после reminders в schema):
  id/tenant_id/user_id/event/title/body/deal_id/payload/read_at/created_at.
- 2 индекса: unread (user_id, created_at DESC) WHERE read_at IS NULL
  + recent (user_id, created_at DESC).
- RLS tenant_isolation.
- Метрики: 55→56 таблиц, 93→95 индексов, 36→37 RLS.
- CHANGELOG_schema.md +§T.

Backend:
- App\Models\InAppNotification — Eloquent с UPDATED_AT=null.
- NotificationService::notifyInApp — INSERT через DB::transaction + SET
  LOCAL app.current_tenant_id для RLS. Throwable + Log::warning.
- notifyNewLead шлёт два канала параллельно: email + inapp.

Pest +11 (291/291 за 32.94 сек, 1060 assertions):
- inapp=true/false; schema-default (inapp=true в схеме);
- 2 user'а получают / inactive не получает / RLS изоляция;
- дубль Биз-19 / повторный vid / оба канала / payload deal_id;
- notifyInApp напрямую с reminder.

PHPStan baseline регенерирован. IDE-helper для InAppNotification.
cspell-words: +inapp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:17:39 +03:00
Дмитрий a4601fe84b phase2(notifications-stage1): NotificationService + new_lead email (P0 этап 1)
Старт closing «Notification delivery» из карты P0. Этап 1/6 плана:
NotificationService + Mailable + интеграция в ProcessWebhookJob::chargeNewLead.

- App\Services\NotificationService — диспетчер 8 событий × 3 каналов
  (inapp/push/email) согласно schema.sql:699 users.notification_preferences.
  Этап 1 реализует только email-канал для new_lead.
- App\Mail\NewLeadNotification + emails/new_lead.blade.php — HTML-письмо
  в Forest-палитре с таблицей phone/contact_name/received_at/deal_id.
- ProcessWebhookJob::chargeNewLead — после ActivityLog вызывает
  notifyNewLead. Throwable от Mail::send проглатывается + Log::warning
  (отказ канала не должен валить транзакцию).
- Pest 11/11 в tests/Feature/Notifications/NewLeadNotificationTest.php:
  email=true получает / email=false не получает / schema-default не шлёт /
  inactive не получает / soft-deleted не получает / другой тенант не
  получает / Биз-19 дубль не дублирует / повторный vid не дублирует /
  balance=0 не шлёт / subject содержит project_name.
- IDE-helper регенерирован (4 модели получили @mixin docblocks).
- PHPStan baseline регенерирован (138 ignore.unmatched схлопнулись).

Pest 280/280 за 31.27 сек (+11 от 269, 1029 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:03:43 +03:00
Дмитрий 830a652588 phase2(trash-bin): GET /api/deals?only_deleted + «Корзина» в DealsView
Расширяет stages 5/6 (soft-delete + 8-сек undo) до постоянного доступа
к удалённым сделкам через отдельный view-mode.

Backend (DealController::index):
- Новый query-param only_deleted=true.
- withTrashed() + whereNotNull('deleted_at') — обход global scope
  SoftDeletes + явный фильтр для NO-OP idempotency.
- Все остальные фильтры применимы и в trash-mode.

Pest +3 (DealIndexTest):
- only_deleted=true → только soft-deleted (alive скрыты).
- Без only_deleted → soft-deleted скрыты (default behavior).
- RLS+app-фильтр изолирует чужие удалённые.

Frontend:
- ListDealsParams.onlyDeleted?: boolean + axios mapping.
- DealsView: trashMode ref + toggleTrashMode (clear selected + reload) +
  applyBulkRestoreFromTrash (optimistic remove + bulkRestoreDeals + toast).
- UI changes в trash-mode:
  - Заголовок «Сделки» → «Корзина».
  - Toggle-btn 'mdi-arrow-left К сделкам' (warning-flat) вместо
    'mdi-trash-can-outline Корзина' (outlined).
  - Скрыты Экспорт + Новая сделка.
  - Скрыт chiprow filter-bar.
  - Info-alert «Корзина: показаны удалённые сделки».
  - Bulk-bar: только Восстановить (mdi-restore success-tonal) + clear;
    status/export/delete скрыты.

Vitest +2 (DealsListIntegration):
- toggleTrashMode → trashMode=true + listDeals с onlyDeleted=true.
- applyBulkRestoreFromTrash → bulkRestoreDeals + remove from state +
  toast «Восстановлено 2».

PHPStan baseline регенерирован.

Регресс:
- Lint+type-check+format passed.
- Vitest 321/321 за 19.60 сек (+2 от 319).
- Vite build 1.04 сек.
- Pint + PHPStan passed.
- Pest 269/269 за 29.12 сек (+3 от 266, 1009 assertions).

Реестр v1.72→v1.73 / CLAUDE.md v1.63→v1.64.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:27:11 +03:00